{"version":3,"file":"summarizer.mjs","names":[],"sources":["../../../../src/batteries/context/compact/summarizer.ts"],"sourcesContent":["/**\n * The Compact summarizer and history assembler — the whole Compact thesis, in code.\n *\n * @module @nhtio/adk/batteries/context/compact/summarizer\n *\n * @remarks\n * This is a direct extraction of the flagship reference agent's `#summariseTurns` /\n * `#assembleCompactedTurns` head-to-head baseline (the \"compact\" arm evaluated against Token Thrift —\n * see the battery barrel's TSDoc for the honest, both-ways evaluation results), retargeted at the\n * local structural contracts in {@link @nhtio/adk/batteries/context/compact/contracts} and the shared\n * {@link @nhtio/adk/batteries/context/thrift/relevance!HistoryTurn} shape so it couples to nothing in\n * `@nhtio/adk` core — the one true dependency this battery cannot avoid (an actual model call to\n * summarize) is an injected {@link SummarizeFn}, never a bundled transport.\n */\n\nimport { E_CONTEXT_RESOLVER_MISSING } from '../exceptions'\nimport type { EstimateTokensFn, SummarizeFn, OnCostFn } from './contracts'\nimport type { HistoryTurn, RelevanceMessage, RelevanceToolCall } from '../thrift/relevance'\n\nexport type { HistoryTurn, RelevanceMessage, RelevanceToolCall } from '../thrift/relevance'\nexport type { EstimateTokensFn, SummarizeFn, CompactionCostEvent, OnCostFn } from './contracts'\n\n/**\n * How many of the MOST RECENT turns stay verbatim, never folded into the running summary —\n * coreference (\"it\", \"that file\") needs the immediately preceding turns present no matter what.\n *\n * @remarks\n * The flagship reference agent's own calibrated value (`COMPACT_KEEP_VERBATIM`), lifted unchanged.\n */\nexport const DEFAULT_KEEP_VERBATIM = 2\n\n/**\n * The token threshold, measured over the OLDER (non-verbatim) region's combined text, past which a\n * (re)summarization call fires.\n *\n * @remarks\n * The flagship reference agent's own calibrated value (`COMPACT_SUMMARISE_AT_TOKENS`), lifted\n * unchanged — modeled on Claude Code auto-compacting as it approaches the context limit (there, a\n * fraction of an 8k window).\n */\nexport const DEFAULT_SUMMARISE_AT_TOKENS = 2500\n\n/**\n * The default id assigned to the synthetic summary message {@link assembleCompactedTurns} emits.\n *\n * @remarks\n * This exact string is the cross-battery contract with Token Thrift: `thrift`'s\n * {@link @nhtio/adk/batteries/context/thrift/contracts!IsSummaryMessageFn} default predicate is\n * `id === '__compact-summary'` — a caller running `compact` upstream of `thrift` in the same pipeline\n * gets, for free, thrift's protection of this message from being shed like an ordinary old turn. If a\n * caller overrides `summaryMessageId` here, they must also override thrift's `isSummaryMessage` option\n * to match, or thrift will treat the running summary as just another shed-able message.\n */\nexport const DEFAULT_SUMMARY_MESSAGE_ID = '__compact-summary'\n\n/**\n * The FAITHFUL Claude Code compaction schema — the 9 sections extracted VERBATIM from the flagship\n * reference agent's own real auto-compactions (~4700-token structured summaries observed across 28\n * compactions in that agent's development session). Using the real prompt (not an invented one) is\n * what makes this battery's evaluation honest: the compact strategy loses exactly the detail Claude\n * Code's own compaction loses, no more and no less.\n *\n * @remarks\n * The 9 sections: (1) Primary Request and Intent, (2) Key Technical Concepts, (3) Files and Code\n * Sections, (4) Errors and Fixes, (5) Problem Solving, (6) All User Messages, (7) Pending Tasks,\n * (8) Current Work, (9) Next Step. Callers may supply their own prompt via the `systemPrompt` option\n * on {@link assembleCompactedTurns} (and {@link summariseTurns} directly) — this default is a\n * calibrated starting point, not a hard requirement.\n */\nexport const COMPACTION_SYSTEM_PROMPT =\n  'Your task is to create a detailed summary of the conversation so far, paying close attention to the ' +\n  \"user's explicit requests and your previous actions. This summary will REPLACE the older conversation \" +\n  'history, so it must capture every fact a later turn might need. Structure it under these sections:\\n' +\n  '1. Primary Request and Intent — what the user is trying to accomplish, verbatim where possible.\\n' +\n  '2. Key Technical Concepts — technologies, APIs, and terms discussed.\\n' +\n  '3. Files and Code Sections — specific files/functions/values examined or referenced.\\n' +\n  '4. Errors and Fixes — problems hit and how they were resolved.\\n' +\n  '5. Problem Solving — decisions made and why.\\n' +\n  '6. All User Messages — a list of every non-tool user message, to preserve intent.\\n' +\n  '7. Pending Tasks — what remains to do.\\n' +\n  '8. Current Work — what was happening most recently.\\n' +\n  '9. Next Step — the immediate next action.\\n' +\n  'Be precise and factual. Preserve exact names, paths, and values. Do not summarise away specifics.'\n\n/** The token encoding this battery measures cost under, by default (matches Token Thrift's own\n *  default — see {@link @nhtio/adk/batteries/context/thrift/subtractive_pass!DEFAULT_ENCODING}). */\nconst DEFAULT_ENCODING = 'cl100k_base'\n\n/** The character-length cap the flagship reference agent applied to the summarizer's input text\n *  (prior summary + older-turn text combined), to keep the summarizer dispatch itself bounded. Lifted\n *  unchanged from `#summariseTurns`'s `.slice(0, 12000)`. */\nconst HISTORY_TEXT_CHAR_CAP = 12_000\n\n/**\n * Options for {@link summariseTurns}.\n */\nexport interface SummariseTurnsOptions {\n  /** REQUIRED. The model-call seam — see {@link SummarizeFn}. There is no default; this battery\n   *  ships with no bundled transport. */\n  summarize: SummarizeFn\n  /** REQUIRED. Measures the exact request/response text's token cost for {@link OnCostFn} reporting.\n   *  There is no default; this battery ships with no bundled tokenizer. */\n  estimateTokens: EstimateTokensFn\n  /** The encoding to measure cost under. Default: `'cl100k_base'`. */\n  encoding?: string\n  /** The compaction instructions sent as `system` to {@link SummarizeFn}. Default:\n   *  {@link COMPACTION_SYSTEM_PROMPT}. */\n  systemPrompt?: string\n  /** Fired once, after a successful summarization call, with the estimated request/response token\n   *  cost. See {@link OnCostFn}. */\n  onCost?: OnCostFn\n}\n\n/**\n * Run ONE summarization call: fold `priorSummary` (if any) and `historyText` into the request text\n * exactly as the flagship reference agent's `#summariseTurns` did, dispatch it through the injected\n * {@link SummarizeFn}, and report the estimated token cost via `options.onCost`.\n *\n * @remarks\n * **Faithfulness to the original**: this is a line-for-line port of `#summariseTurns`'s request\n * assembly and prompt shape, with exactly one deliberate behavioral change: the original SWALLOWED a\n * failed summarizer call and degraded to `priorSummary ?? ''` (never letting a summarizer error\n * propagate, since it ran inline in a chat turn that had to keep going); this battery instead lets a\n * rejected {@link SummarizeFn} promise propagate UNCAUGHT (see {@link SummarizeFn}'s own TSDoc) — a\n * battery has no chat-turn context to know whether \"degrade silently\" is the right failure mode for\n * a given caller, so it surfaces the error and lets the caller decide (their own `SummarizeFn` can\n * implement the original's degrade-on-failure behavior internally if desired, by catching there\n * instead). The original's GPU-OOM special-case rethrow is likewise the caller's concern now — it\n * lived inside the original's own adapter-specific `SummarizeFn` equivalent, not in the algorithm.\n *\n * @param historyText - The older-turn text to summarize (already assembled by the caller, e.g. from\n *   {@link assembleCompactedTurns}'s older-turns join).\n * @param priorSummary - The prior rolling summary, if one exists, folded into the request text ahead\n *   of `historyText` under a `PREVIOUS SUMMARY (extend/merge, do not lose facts)` header — `null` on\n *   the first summarization of a run.\n * @param options - See {@link SummariseTurnsOptions}.\n * @returns The new summary text, trimmed. Falls back to `priorSummary ?? ''` only when\n *   {@link SummarizeFn} resolves to an empty/whitespace-only string (never on rejection — see above).\n * @throws {@link @nhtio/adk/batteries/context/exceptions!E_CONTEXT_RESOLVER_MISSING} When\n *   `options.summarize` or `options.estimateTokens` is not a function.\n */\nexport async function summariseTurns(\n  historyText: string,\n  priorSummary: string | null,\n  options: SummariseTurnsOptions\n): Promise<string> {\n  if (typeof options?.summarize !== 'function') {\n    throw new E_CONTEXT_RESOLVER_MISSING(['summariseTurns', 'summarize'])\n  }\n  if (typeof options?.estimateTokens !== 'function') {\n    throw new E_CONTEXT_RESOLVER_MISSING(['summariseTurns', 'estimateTokens'])\n  }\n  const encoding = options.encoding ?? DEFAULT_ENCODING\n  const system = options.systemPrompt ?? COMPACTION_SYSTEM_PROMPT\n  const text = (\n    (priorSummary\n      ? `PREVIOUS SUMMARY (extend/merge, do not lose facts):\\n${priorSummary}\\n\\n---\\n\\n`\n      : '') + `CONVERSATION TO SUMMARISE:\\n${historyText}`\n  ).slice(0, HISTORY_TEXT_CHAR_CAP)\n\n  const rawSummary = await options.summarize({ system, text })\n  const summary = rawSummary.trim()\n\n  options.onCost?.({\n    calls: 1,\n    inTok: options.estimateTokens(`${system}\\n\\n${text}`, encoding),\n    outTok: options.estimateTokens(summary, encoding),\n  })\n\n  return summary || (priorSummary ?? '')\n}\n\n/**\n * Options for {@link assembleCompactedTurns}.\n */\nexport interface AssembleCompactedTurnsOptions {\n  /** REQUIRED. The model-call seam — see {@link SummarizeFn}. */\n  summarize: SummarizeFn\n  /** REQUIRED. Measures token costs (the older-region threshold check, and {@link OnCostFn}\n   *  reporting). */\n  estimateTokens: EstimateTokensFn\n  /** The encoding to measure under. Default: `'cl100k_base'`. */\n  encoding?: string\n  /** How many of the most recent turns stay verbatim. Default: {@link DEFAULT_KEEP_VERBATIM}. */\n  keepVerbatim?: number\n  /** The older-region token threshold past which a (re)summarization fires. Default:\n   *  {@link DEFAULT_SUMMARISE_AT_TOKENS}. */\n  summariseAtTokens?: number\n  /** The compaction instructions passed through to {@link summariseTurns}. Default:\n   *  {@link COMPACTION_SYSTEM_PROMPT}. */\n  systemPrompt?: string\n  /** The id assigned to the synthetic summary message/turn. Default:\n   *  {@link DEFAULT_SUMMARY_MESSAGE_ID} — see that constant's TSDoc for the cross-battery contract\n   *  with Token Thrift's `isSummaryMessage` predicate before changing this. */\n  summaryMessageId?: string\n  /** Fired once per summarization call that actually runs (never on the below-threshold pass-through\n   *  path). See {@link OnCostFn}. */\n  onCost?: OnCostFn\n  /**\n   * The caller's own bookkeeping of prior state, threaded through explicitly (replacing the original\n   * `#compactSummary`/`#compactCoveredOlder` private fields on the flagship agent's class instance):\n   * the rolling summary text produced by a previous call, and how many older-region turns that summary\n   * already covers. Omit on the FIRST call for a fresh run (equivalent to the originals' `null`/`0`\n   * initial values). This battery holds no state of its own between calls — see\n   * {@link AssembleCompactedTurnsResult} for what to persist and pass back in on the next call.\n   */\n  priorState?: { summary: string | null; coveredOlder: number }\n}\n\n/**\n * {@link assembleCompactedTurns}'s return value: the compacted turns PLUS the rolling state a caller\n * must persist and pass back in as `options.priorState` on the next call (this battery is stateless\n * between calls by design — no private fields, no `globalThis`).\n */\nexport interface AssembleCompactedTurnsResult<\n  M extends RelevanceMessage = RelevanceMessage,\n  TC extends RelevanceToolCall = RelevanceToolCall,\n> {\n  /** `[syntheticSummaryTurn, ...verbatimRecentTurns]` when older turns exist and have been summarized\n   *  at least once; otherwise just the verbatim turns unchanged (nothing to compact yet). */\n  turns: Array<HistoryTurn<M, TC>>\n  /** The rolling summary text after this call (unchanged from `priorState.summary` when the threshold\n   *  didn't fire this call) — persist and pass back in as `priorState.summary` next call. */\n  summary: string | null\n  /** How many older-region turns `summary` covers — persist and pass back in as\n   *  `priorState.coveredOlder` next call. */\n  coveredOlder: number\n}\n\n/**\n * The Compact assembly step: keep the newest `keepVerbatim` turns in full; fold everything OLDER into\n * a running structured summary, (re)generated via {@link summariseTurns} when the older region grows\n * past `summariseAtTokens` AND new turns have aged into it since the last summarization. Returns\n * `[syntheticSummaryTurn, ...recentVerbatimTurns]`.\n *\n * @remarks\n * **Faithfulness to the original** (`#assembleCompactedTurns`): the threshold/keep-verbatim/rolling\n * logic is a line-for-line port — same re-summarize condition (`older.length > coveredOlder &&\n * (summary === null || olderTokens > summariseAtTokens)`), same synthetic turn shape (`qa` = the\n * summary text, `createdAt` = the epoch-zero sentinel that sorts before every real turn, one message\n * with `id: summaryMessageId`, content prefixed `[Earlier conversation, compacted summary]`, no tool\n * calls), same \"nothing to compact yet\" early return when there's no older region at all. The ONE\n * deviation: state that lived on private class fields (`#compactSummary`, `#compactCoveredOlder`) in\n * the original is now explicit input/output (`options.priorState` in, `{ summary, coveredOlder }` out)\n * — a pure battery function cannot own mutable instance state, so the caller threads it through. This\n * is a mechanical decoupling change, not a behavioral one: a caller who persists and re-supplies\n * `priorState` exactly as returned reproduces the original's stateful behavior turn-for-turn.\n *\n * Unlike Token Thrift, this function CANNOT resurface the exact older detail a later turn needs — it\n * only has the summary's blurred prose. See the module barrel for the honest, both-ways evaluation of\n * that fidelity trade-off against thrift's subtractive approach.\n *\n * @param turns - The full grouped history, chronological (e.g. from\n *   {@link @nhtio/adk/batteries/context/thrift/relevance!groupHistoryIntoTurns}).\n * @param options - See {@link AssembleCompactedTurnsOptions}.\n * @returns See {@link AssembleCompactedTurnsResult}.\n * @throws {@link @nhtio/adk/batteries/context/exceptions!E_CONTEXT_RESOLVER_MISSING} When\n *   `options.summarize` or `options.estimateTokens` is not a function.\n */\nexport async function assembleCompactedTurns<\n  M extends RelevanceMessage = RelevanceMessage,\n  TC extends RelevanceToolCall = RelevanceToolCall,\n>(\n  turns: ReadonlyArray<HistoryTurn<M, TC>>,\n  options: AssembleCompactedTurnsOptions\n): Promise<AssembleCompactedTurnsResult<M, TC>> {\n  if (typeof options?.summarize !== 'function') {\n    throw new E_CONTEXT_RESOLVER_MISSING(['assembleCompactedTurns', 'summarize'])\n  }\n  if (typeof options?.estimateTokens !== 'function') {\n    throw new E_CONTEXT_RESOLVER_MISSING(['assembleCompactedTurns', 'estimateTokens'])\n  }\n  const encoding = options.encoding ?? DEFAULT_ENCODING\n  const keepVerbatim = options.keepVerbatim ?? DEFAULT_KEEP_VERBATIM\n  const summariseAtTokens = options.summariseAtTokens ?? DEFAULT_SUMMARISE_AT_TOKENS\n  const summaryMessageId = options.summaryMessageId ?? DEFAULT_SUMMARY_MESSAGE_ID\n  let summary = options.priorState?.summary ?? null\n  let coveredOlder = options.priorState?.coveredOlder ?? 0\n\n  const recentStart = Math.max(0, turns.length - keepVerbatim)\n  const older = turns.slice(0, recentStart)\n  const recent = turns.slice(recentStart)\n\n  if (older.length === 0) {\n    return { turns: [...recent], summary, coveredOlder }\n  }\n\n  const olderText = older.map((t) => t.qa).join('\\n\\n')\n  const olderTokens = options.estimateTokens(olderText, encoding)\n\n  if (older.length > coveredOlder && (summary === null || olderTokens > summariseAtTokens)) {\n    summary = await summariseTurns(olderText, summary, {\n      summarize: options.summarize,\n      estimateTokens: options.estimateTokens,\n      encoding,\n      systemPrompt: options.systemPrompt,\n      onCost: options.onCost,\n    })\n    coveredOlder = older.length\n  }\n\n  const summaryText = summary ?? olderText\n  const sentinelCreatedAt = new Date(0).toISOString() // stable, sorts before real turns\n  const summaryTurn = {\n    qa: summaryText,\n    createdAt: sentinelCreatedAt,\n    messages: [\n      {\n        id: summaryMessageId,\n        role: 'user',\n        content: `[Earlier conversation, compacted summary]\\n${summaryText}`,\n        createdAt: sentinelCreatedAt,\n      } as unknown as M,\n    ],\n    toolCalls: [] as TC[],\n  } as HistoryTurn<M, TC>\n\n  return { turns: [summaryTurn, ...recent], summary, coveredOlder }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA6BA,IAAa,wBAAwB;;;;;;;;;;AAWrC,IAAa,8BAA8B;;;;;;;;;;;;AAa3C,IAAa,6BAA6B;;;;;;;;;;;;;;;AAgB1C,IAAa,2BACX;;;AAgBF,IAAM,mBAAmB;;;;AAKzB,IAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkD9B,eAAsB,eACpB,aACA,cACA,SACiB;CACjB,IAAI,OAAO,SAAS,cAAc,YAChC,MAAM,IAAI,2BAA2B,CAAC,kBAAkB,WAAW,CAAC;CAEtE,IAAI,OAAO,SAAS,mBAAmB,YACrC,MAAM,IAAI,2BAA2B,CAAC,kBAAkB,gBAAgB,CAAC;CAE3E,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,SAAS,QAAQ,gBAAA;CACvB,MAAM,SACH,eACG,wDAAwD,aAAa,eACrE,MAAM,+BAA+B,eACzC,MAAM,GAAG,qBAAqB;CAGhC,MAAM,WAAU,MADS,QAAQ,UAAU;EAAE;EAAQ;CAAK,CAAC,GAChC,KAAK;CAEhC,QAAQ,SAAS;EACf,OAAO;EACP,OAAO,QAAQ,eAAe,GAAG,OAAO,MAAM,QAAQ,QAAQ;EAC9D,QAAQ,QAAQ,eAAe,SAAS,QAAQ;CAClD,CAAC;CAED,OAAO,YAAY,gBAAgB;AACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyFA,eAAsB,uBAIpB,OACA,SAC8C;CAC9C,IAAI,OAAO,SAAS,cAAc,YAChC,MAAM,IAAI,2BAA2B,CAAC,0BAA0B,WAAW,CAAC;CAE9E,IAAI,OAAO,SAAS,mBAAmB,YACrC,MAAM,IAAI,2BAA2B,CAAC,0BAA0B,gBAAgB,CAAC;CAEnF,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,eAAe,QAAQ,gBAAA;CAC7B,MAAM,oBAAoB,QAAQ,qBAAA;CAClC,MAAM,mBAAmB,QAAQ,oBAAA;CACjC,IAAI,UAAU,QAAQ,YAAY,WAAW;CAC7C,IAAI,eAAe,QAAQ,YAAY,gBAAgB;CAEvD,MAAM,cAAc,KAAK,IAAI,GAAG,MAAM,SAAS,YAAY;CAC3D,MAAM,QAAQ,MAAM,MAAM,GAAG,WAAW;CACxC,MAAM,SAAS,MAAM,MAAM,WAAW;CAEtC,IAAI,MAAM,WAAW,GACnB,OAAO;EAAE,OAAO,CAAC,GAAG,MAAM;EAAG;EAAS;CAAa;CAGrD,MAAM,YAAY,MAAM,KAAK,MAAM,EAAE,EAAE,EAAE,KAAK,MAAM;CACpD,MAAM,cAAc,QAAQ,eAAe,WAAW,QAAQ;CAE9D,IAAI,MAAM,SAAS,iBAAiB,YAAY,QAAQ,cAAc,oBAAoB;EACxF,UAAU,MAAM,eAAe,WAAW,SAAS;GACjD,WAAW,QAAQ;GACnB,gBAAgB,QAAQ;GACxB;GACA,cAAc,QAAQ;GACtB,QAAQ,QAAQ;EAClB,CAAC;EACD,eAAe,MAAM;CACvB;CAEA,MAAM,cAAc,WAAW;CAC/B,MAAM,qCAAoB,IAAI,KAAK,CAAC,GAAE,YAAY;CAelD,OAAO;EAAE,OAAO,CAAC;GAbf,IAAI;GACJ,WAAW;GACX,UAAU,CACR;IACE,IAAI;IACJ,MAAM;IACN,SAAS,8CAA8C;IACvD,WAAW;GACb,CACF;GACA,WAAW,CAAC;EAGG,GAAa,GAAG,MAAM;EAAG;EAAS;CAAa;AAClE"}