{"version":3,"file":"compaction.mjs","names":[],"sources":["../../../../../../../ai/src/orchestrator/compaction.ts"],"sourcesContent":["import type { Message } from \"../contracts/conversation-message.type\";\nimport type {\n  SummarizeCallback,\n  SummarizeConfig,\n} from \"../contracts/orchestrator/orchestrator-config.type\";\nimport type { CompactionResult } from \"../contracts/result/orchestrator-result.type\";\nimport type { OrchestratorEngineContext } from \"./engine-context.type\";\nimport { DEFAULT_LOCK_MAX_WAIT } from \"./lock\";\n\n/**\n * Most-recent messages kept verbatim when `summarize.keep` is omitted.\n * Defaulting to 0 would compact the entire history into one memo (losing\n * the live tail every turn), so the object form keeps a small recent\n * window by default — matching the canonical doc examples (`keep: 8`).\n */\nexport const DEFAULT_COMPACTION_KEEP = 8;\n\n/** Whether the configured `summarize` is the fully-pluggable callback form. */\nfunction isCallbackForm(\n  summarize: SummarizeConfig | SummarizeCallback | undefined,\n): summarize is SummarizeCallback {\n  return typeof summarize === \"function\";\n}\n\n/**\n * Decide whether Phase 7 compaction should fire this turn (orchestrator\n * .md §4 Phase 7 / §12.1). v1 trigger is count-based:\n * `summarize.afterTurns` fires once `turnIndex >= afterTurns`. The\n * callback form has no threshold and never auto-fires — it is driven\n * exclusively by `command(\"compact\")` (the manual path). Returns false\n * when `summarize` is unset.\n */\nexport function shouldCompact(\n  ctx: OrchestratorEngineContext,\n  turnIndex: number,\n): boolean {\n  const summarize = ctx.config.summarize;\n\n  if (summarize === undefined || isCallbackForm(summarize)) {\n    return false;\n  }\n\n  const afterTurns = summarize.afterTurns;\n\n  return afterTurns !== undefined && turnIndex >= afterTurns;\n}\n\n/** Result of a Phase-7 run — what the engine folds into the turn. */\nexport type CompactionOutcome = {\n  /** The produced compaction, surfaced on `result.compaction`. */\n  compaction: CompactionResult;\n  /**\n   * Whether `summarize.onCompact` ran AND succeeded — when true the\n   * engine advances `summarized_through` to `replacesToIndex` (§12.2\n   * step 4); when false it leaves it unchanged (§12.2 step 5).\n   */\n  applied: boolean;\n};\n\n/**\n * Run the configured summarizer against the session history and build\n * a {@link CompactionResult} (§12.2 step 2–3). Three resolution paths:\n *\n * - callback form — `summarize(history)` returns the result directly.\n * - config + `summarizer` model — summarize the slice (history minus\n *   the most-recent `keep`) into one synthetic memo turn.\n * - config without a `summarizer` — produce a trivial degenerate memo\n *   (no model available); the dev is expected to supply a summarizer\n *   for real compaction. The range still reflects the kept tail.\n */\nasync function produceCompaction(\n  summarize: SummarizeConfig | SummarizeCallback,\n  history: Message[],\n): Promise<CompactionResult> {\n  if (isCallbackForm(summarize)) {\n    return summarize(history);\n  }\n\n  const keep = summarize.keep ?? DEFAULT_COMPACTION_KEEP;\n  const replacesFromIndex = 0;\n  const replacesToIndex = Math.max(-1, history.length - keep - 1);\n  const slice = history.slice(0, replacesToIndex + 1);\n\n  const summaryText = await summarizeSlice(summarize, slice);\n\n  return {\n    summary: { role: \"system\", content: summaryText },\n    replacesFromIndex,\n    replacesToIndex,\n  };\n}\n\n/**\n * Summarize a slice of history into text. Uses the configured\n * `summarizer` model when present (a single non-streaming completion);\n * otherwise returns a placeholder memo. The summarizer is intentionally\n * the cheap model — never the specialists (§12.4).\n */\nasync function summarizeSlice(\n  summarize: SummarizeConfig,\n  slice: Message[],\n): Promise<string> {\n  if (slice.length === 0) {\n    return \"\";\n  }\n\n  if (!summarize.summarizer) {\n    return `Summary of ${slice.length} prior message(s).`;\n  }\n\n  const transcript = slice\n    .map((message) => `${message.role}: ${stringifyContent(message.content)}`)\n    .join(\"\\n\");\n\n  const response = await summarize.summarizer.complete([\n    {\n      role: \"system\",\n      content:\n        \"Summarize the following conversation slice into a concise memo \" +\n        \"that preserves the facts, decisions, and open threads a later \" +\n        \"turn would need. Reply with the memo only.\",\n    },\n    { role: \"user\", content: transcript },\n  ]);\n\n  return response.content;\n}\n\n/** Coerce a message's content into a flat string for the summarizer prompt. */\nfunction stringifyContent(content: Message[\"content\"]): string {\n  if (typeof content === \"string\") {\n    return content;\n  }\n\n  return JSON.stringify(content);\n}\n\n/**\n * Phase 7 — post-turn compaction (orchestrator.md §3 / §4 Phase 7 /\n * §12.2). Runs AFTER the turn settles; never blocks the caller's\n * resolution path beyond this phase.\n *\n * Acquires the cooperative session lock (writes `lock_acquired_at` /\n * `lock_expires_at` onto a fresh row), runs the summarizer, builds the\n * compaction, then either invokes `summarize.onCompact` (framework-\n * driven apply) or surfaces the compaction on the result for the dev to\n * apply. Releases the lock on settle. Emits\n * `orchestrator.compaction.suggested` always,\n * `orchestrator.compaction.applied` only when `onCompact` succeeded, and\n * `orchestrator.compaction.failed` (carrying the thrown `error` and the\n * `phase` that failed) when the summarizer or `onCompact` throws.\n *\n * Retry policy is skip-and-log (§4 Phase 7): a summarizer failure\n * leaves the session running with unchanged history — the engine\n * treats a thrown summarizer / `onCompact` as \"no compaction this\n * turn\", emits `orchestrator.compaction.failed`, and returns `undefined`\n * (summarizer) or surfaces the unapplied compaction (`onCompact`).\n */\nexport async function runCompaction<TState>(\n  ctx: OrchestratorEngineContext<unknown, TState>,\n  sessionId: string,\n  history: Message[],\n): Promise<CompactionOutcome | undefined> {\n  const summarize = ctx.config.summarize;\n\n  if (summarize === undefined) {\n    return undefined;\n  }\n\n  await acquireCompactionLock(ctx, sessionId);\n\n  try {\n    const compaction = await produceCompaction(summarize, history);\n\n    ctx.emitter.emit(\"orchestrator.compaction.suggested\", {\n      sessionId,\n      compaction,\n    });\n\n    const onCompact = isCallbackForm(summarize) ? undefined : summarize.onCompact;\n\n    if (!onCompact) {\n      return { compaction, applied: false };\n    }\n\n    try {\n      await onCompact(compaction, { sessionId });\n\n      ctx.emitter.emit(\"orchestrator.compaction.applied\", {\n        sessionId,\n        compaction,\n      });\n\n      return { compaction, applied: true };\n    } catch (error) {\n      // onCompact threw — surface the compaction for the dev to apply,\n      // leave summarized_through unchanged (§12.2 step 4 failure path).\n      ctx.emitter.emit(\"orchestrator.compaction.failed\", {\n        sessionId,\n        phase: \"onCompact\",\n        error,\n      });\n\n      return { compaction, applied: false };\n    }\n  } catch (error) {\n    // Summarizer failed — skip-and-log; session keeps running unchanged.\n    ctx.emitter.emit(\"orchestrator.compaction.failed\", {\n      sessionId,\n      phase: \"summarize\",\n      error,\n    });\n\n    return undefined;\n  } finally {\n    await releaseCompactionLock(ctx, sessionId);\n  }\n}\n\n/**\n * Run a manual compaction for `command(\"compact\", ...)` (§11 / §12.1).\n * Same code path as the post-turn trigger but driven on demand against\n * the supplied history, returning the raw {@link CompactionResult}. The\n * callback form is honored; the config form without a `summarizer`\n * produces the degenerate memo. Does not apply `onCompact` — the\n * command surface returns the compaction for the caller to handle.\n */\nexport async function runManualCompaction<TState>(\n  ctx: OrchestratorEngineContext<unknown, TState>,\n  history: Message[],\n): Promise<CompactionResult> {\n  const summarize = ctx.config.summarize;\n\n  if (summarize === undefined) {\n    // No summarize policy configured — produce a degenerate memo over\n    // the full supplied history so the command always resolves.\n    return {\n      summary: { role: \"system\", content: `Summary of ${history.length} message(s).` },\n      replacesFromIndex: 0,\n      replacesToIndex: Math.max(-1, history.length - 1),\n    };\n  }\n\n  return produceCompaction(summarize, history);\n}\n\n/** Resolve the lock TTL from the summarize config (config form only). */\nfunction resolveLockMaxWait(ctx: OrchestratorEngineContext): number {\n  const summarize = ctx.config.summarize;\n\n  if (summarize === undefined || isCallbackForm(summarize)) {\n    return DEFAULT_LOCK_MAX_WAIT;\n  }\n\n  return summarize.lock?.maxWait ?? DEFAULT_LOCK_MAX_WAIT;\n}\n\n/**\n * Write the cooperative compaction lock onto a fresh checkpoint row\n * (§12.2 step 1). The lock lives on the latest persisted row; we load\n * it, stamp the lock columns, and re-save (append-only) so the next\n * turn's Phase 3 observes it.\n */\nasync function acquireCompactionLock<TState>(\n  ctx: OrchestratorEngineContext<unknown, TState>,\n  sessionId: string,\n): Promise<void> {\n  const latest = await ctx.checkpointStore.load(ctx.config.name, sessionId);\n\n  if (!latest) {\n    return;\n  }\n\n  const now = Date.now();\n  const maxWait = resolveLockMaxWait(ctx as OrchestratorEngineContext);\n\n  await ctx.checkpointStore.save({\n    ...latest,\n    lock_acquired_at: new Date(now).toISOString(),\n    lock_expires_at: new Date(now + maxWait).toISOString(),\n    saved_at: new Date(now).toISOString(),\n  });\n}\n\n/**\n * Clear the compaction lock columns (§12.2 step 6) by re-saving the\n * latest row with the lock fields nulled.\n */\nasync function releaseCompactionLock<TState>(\n  ctx: OrchestratorEngineContext<unknown, TState>,\n  sessionId: string,\n): Promise<void> {\n  const latest = await ctx.checkpointStore.load(ctx.config.name, sessionId);\n\n  if (!latest) {\n    return;\n  }\n\n  await ctx.checkpointStore.save({\n    ...latest,\n    lock_acquired_at: null,\n    lock_expires_at: null,\n    saved_at: new Date().toISOString(),\n  });\n}\n"],"mappings":"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"}