@caupulican/pi-adaptative 0.81.2 → 0.81.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/core/agent-session.d.ts +1 -23
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +117 -131
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/background-lane-controller.d.ts +2 -0
  7. package/dist/core/background-lane-controller.d.ts.map +1 -1
  8. package/dist/core/background-lane-controller.js +3 -0
  9. package/dist/core/background-lane-controller.js.map +1 -1
  10. package/dist/core/billing-failover-controller.d.ts +31 -0
  11. package/dist/core/billing-failover-controller.d.ts.map +1 -0
  12. package/dist/core/billing-failover-controller.js +72 -0
  13. package/dist/core/billing-failover-controller.js.map +1 -0
  14. package/dist/core/billing-failover.d.ts +25 -0
  15. package/dist/core/billing-failover.d.ts.map +1 -0
  16. package/dist/core/billing-failover.js +21 -0
  17. package/dist/core/billing-failover.js.map +1 -0
  18. package/dist/core/context-pipeline.d.ts +1 -0
  19. package/dist/core/context-pipeline.d.ts.map +1 -1
  20. package/dist/core/context-pipeline.js +51 -14
  21. package/dist/core/context-pipeline.js.map +1 -1
  22. package/dist/core/model-router/config-diagnostics.d.ts +1 -1
  23. package/dist/core/model-router/config-diagnostics.d.ts.map +1 -1
  24. package/dist/core/model-router/config-diagnostics.js +29 -1
  25. package/dist/core/model-router/config-diagnostics.js.map +1 -1
  26. package/dist/core/model-router/fitness-gate.d.ts +23 -0
  27. package/dist/core/model-router/fitness-gate.d.ts.map +1 -0
  28. package/dist/core/model-router/fitness-gate.js +33 -0
  29. package/dist/core/model-router/fitness-gate.js.map +1 -0
  30. package/dist/core/model-router/status.d.ts +25 -1
  31. package/dist/core/model-router/status.d.ts.map +1 -1
  32. package/dist/core/model-router/status.js +27 -4
  33. package/dist/core/model-router/status.js.map +1 -1
  34. package/dist/core/model-router-controller.d.ts +10 -1
  35. package/dist/core/model-router-controller.d.ts.map +1 -1
  36. package/dist/core/model-router-controller.js +108 -27
  37. package/dist/core/model-router-controller.js.map +1 -1
  38. package/dist/core/runtime-builder.d.ts +16 -0
  39. package/dist/core/runtime-builder.d.ts.map +1 -1
  40. package/dist/core/runtime-builder.js +88 -1
  41. package/dist/core/runtime-builder.js.map +1 -1
  42. package/dist/core/scout-controller.d.ts +44 -0
  43. package/dist/core/scout-controller.d.ts.map +1 -0
  44. package/dist/core/scout-controller.js +165 -0
  45. package/dist/core/scout-controller.js.map +1 -0
  46. package/dist/core/settings-manager.d.ts +16 -0
  47. package/dist/core/settings-manager.d.ts.map +1 -1
  48. package/dist/core/settings-manager.js +35 -3
  49. package/dist/core/settings-manager.js.map +1 -1
  50. package/dist/core/tools/context-scout.d.ts +19 -0
  51. package/dist/core/tools/context-scout.d.ts.map +1 -0
  52. package/dist/core/tools/context-scout.js +47 -0
  53. package/dist/core/tools/context-scout.js.map +1 -0
  54. package/dist/core/tools/index.d.ts +1 -0
  55. package/dist/core/tools/index.d.ts.map +1 -1
  56. package/dist/core/tools/index.js +1 -0
  57. package/dist/core/tools/index.js.map +1 -1
  58. package/dist/main.d.ts.map +1 -1
  59. package/dist/main.js +1 -1
  60. package/dist/main.js.map +1 -1
  61. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  62. package/dist/modes/interactive/components/settings-selector.js +77 -7
  63. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  64. package/dist/modes/interactive/local-model-commands.d.ts +5 -2
  65. package/dist/modes/interactive/local-model-commands.d.ts.map +1 -1
  66. package/dist/modes/interactive/local-model-commands.js +115 -1
  67. package/dist/modes/interactive/local-model-commands.js.map +1 -1
  68. package/docs/index.md +1 -0
  69. package/docs/providers.md +2 -0
  70. package/docs/scout.md +71 -0
  71. package/docs/settings.md +14 -3
  72. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  73. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  74. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  75. package/examples/extensions/sandbox/package-lock.json +2 -2
  76. package/examples/extensions/sandbox/package.json +1 -1
  77. package/examples/extensions/with-deps/package-lock.json +2 -2
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/examples/sdk/12-full-control.ts +1 -1
  80. package/npm-shrinkwrap.json +12 -12
  81. package/package.json +4 -4
@@ -0,0 +1,72 @@
1
+ import { classifyFailure } from "@caupulican/pi-agent-core";
2
+ import { decideBillingFailover } from "./billing-failover.js";
3
+ const DEFAULT_MODEL_PER_PROVIDER = {
4
+ "openai-codex": "gpt-5.5",
5
+ };
6
+ export class ExhaustedProviderRegistry {
7
+ exhausted = new Map();
8
+ markExhausted(ref, until) {
9
+ this.exhausted.set(ref, until);
10
+ }
11
+ isExhausted(ref) {
12
+ const until = this.exhausted.get(ref);
13
+ if (until === undefined)
14
+ return this.exhausted.has(ref);
15
+ if (Date.now() < until)
16
+ return true;
17
+ this.exhausted.delete(ref);
18
+ return false;
19
+ }
20
+ snapshot() {
21
+ return [...this.exhausted.keys()].filter((ref) => this.isExhausted(ref));
22
+ }
23
+ }
24
+ export class BillingFailoverController {
25
+ deps;
26
+ lastNotice;
27
+ constructor(deps) {
28
+ this.deps = deps;
29
+ }
30
+ isExhausted(ref) {
31
+ return this.deps.exhausted.isExhausted(ref);
32
+ }
33
+ snapshotExhausted() {
34
+ return this.deps.exhausted.snapshot();
35
+ }
36
+ getLastNotice() {
37
+ return this.lastNotice;
38
+ }
39
+ getStatus() {
40
+ return { exhausted: this.snapshotExhausted(), lastNotice: this.lastNotice };
41
+ }
42
+ async handleAssistantError(message) {
43
+ if (message.stopReason !== "error")
44
+ return false;
45
+ const classified = classifyFailure({ message: message.errorMessage ?? "", provider: message.provider });
46
+ if (classified.reason !== "billing_or_quota")
47
+ return false;
48
+ const failedModel = this.deps.modelRegistry.find(message.provider, message.model) ?? this.deps.agent.state.model;
49
+ const failedRef = `${failedModel.provider}/${failedModel.id}`;
50
+ this.deps.exhausted.markExhausted(failedRef, expiryFromRetryAfter(classified.retryAfterMs));
51
+ const defaultModelId = DEFAULT_MODEL_PER_PROVIDER[failedModel.provider];
52
+ const hop = defaultModelId ? this.deps.modelRegistry.find(failedModel.provider, defaultModelId) : undefined;
53
+ const action = decideBillingFailover({
54
+ failedModel: { provider: failedModel.provider, id: failedModel.id },
55
+ billingClass: this.deps.modelRegistry.isUsingOAuth(failedModel) ? "subscription" : "metered",
56
+ providerDefaultModelId: defaultModelId,
57
+ hopResolvesWithAuth: Boolean(hop && this.deps.modelRegistry.hasConfiguredAuth(hop)),
58
+ hopExhausted: hop ? this.deps.exhausted.isExhausted(`${hop.provider}/${hop.id}`) : false,
59
+ subscriptionHop: this.deps.subscriptionHop,
60
+ });
61
+ if (action.action === "failover") {
62
+ this.deps.agent.state.model = hop;
63
+ }
64
+ this.lastNotice = action.notice;
65
+ this.deps.emit({ type: "warning", message: action.notice });
66
+ return true;
67
+ }
68
+ }
69
+ function expiryFromRetryAfter(retryAfterMs) {
70
+ return retryAfterMs === undefined ? undefined : Date.now() + retryAfterMs;
71
+ }
72
+ //# sourceMappingURL=billing-failover-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-failover-controller.js","sourceRoot":"","sources":["../../src/core/billing-failover-controller.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Agent } from \"@caupulican/pi-agent-core\";\nimport { classifyFailure } from \"@caupulican/pi-agent-core\";\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport { decideBillingFailover } from \"./billing-failover.ts\";\nimport type { ModelRegistry } from \"./model-registry.ts\";\nimport type { ModelRouterFailoverStatus } from \"./model-router/status.ts\";\n\nconst DEFAULT_MODEL_PER_PROVIDER: Record<string, string> = {\n\t\"openai-codex\": \"gpt-5.5\",\n};\n\nexport class ExhaustedProviderRegistry {\n\tprivate readonly exhausted = new Map<string, number | undefined>();\n\n\tmarkExhausted(ref: string, until?: number): void {\n\t\tthis.exhausted.set(ref, until);\n\t}\n\n\tisExhausted(ref: string): boolean {\n\t\tconst until = this.exhausted.get(ref);\n\t\tif (until === undefined) return this.exhausted.has(ref);\n\t\tif (Date.now() < until) return true;\n\t\tthis.exhausted.delete(ref);\n\t\treturn false;\n\t}\n\n\tsnapshot(): string[] {\n\t\treturn [...this.exhausted.keys()].filter((ref) => this.isExhausted(ref));\n\t}\n}\n\nexport interface BillingFailoverControllerDeps {\n\tagent: Agent;\n\tmodelRegistry: ModelRegistry;\n\temit(event: { type: \"warning\"; message: string }): void;\n\texhausted: ExhaustedProviderRegistry;\n\tsubscriptionHop?: boolean;\n}\n\nexport class BillingFailoverController {\n\tprivate readonly deps: BillingFailoverControllerDeps;\n\tprivate lastNotice: string | undefined;\n\n\tconstructor(deps: BillingFailoverControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\tisExhausted(ref: string): boolean {\n\t\treturn this.deps.exhausted.isExhausted(ref);\n\t}\n\n\tsnapshotExhausted(): string[] {\n\t\treturn this.deps.exhausted.snapshot();\n\t}\n\n\tgetLastNotice(): string | undefined {\n\t\treturn this.lastNotice;\n\t}\n\n\tgetStatus(): ModelRouterFailoverStatus {\n\t\treturn { exhausted: this.snapshotExhausted(), lastNotice: this.lastNotice };\n\t}\n\n\tasync handleAssistantError(message: AssistantMessage): Promise<boolean> {\n\t\tif (message.stopReason !== \"error\") return false;\n\t\tconst classified = classifyFailure({ message: message.errorMessage ?? \"\", provider: message.provider });\n\t\tif (classified.reason !== \"billing_or_quota\") return false;\n\t\tconst failedModel = this.deps.modelRegistry.find(message.provider, message.model) ?? this.deps.agent.state.model;\n\t\tconst failedRef = `${failedModel.provider}/${failedModel.id}`;\n\t\tthis.deps.exhausted.markExhausted(failedRef, expiryFromRetryAfter(classified.retryAfterMs));\n\n\t\tconst defaultModelId = DEFAULT_MODEL_PER_PROVIDER[failedModel.provider];\n\t\tconst hop = defaultModelId ? this.deps.modelRegistry.find(failedModel.provider, defaultModelId) : undefined;\n\t\tconst action = decideBillingFailover({\n\t\t\tfailedModel: { provider: failedModel.provider, id: failedModel.id },\n\t\t\tbillingClass: this.deps.modelRegistry.isUsingOAuth(failedModel) ? \"subscription\" : \"metered\",\n\t\t\tproviderDefaultModelId: defaultModelId,\n\t\t\thopResolvesWithAuth: Boolean(hop && this.deps.modelRegistry.hasConfiguredAuth(hop)),\n\t\t\thopExhausted: hop ? this.deps.exhausted.isExhausted(`${hop.provider}/${hop.id}`) : false,\n\t\t\tsubscriptionHop: this.deps.subscriptionHop,\n\t\t});\n\t\tif (action.action === \"failover\") {\n\t\t\tthis.deps.agent.state.model = hop as Model<Api>;\n\t\t}\n\t\tthis.lastNotice = action.notice;\n\t\tthis.deps.emit({ type: \"warning\", message: action.notice });\n\t\treturn true;\n\t}\n}\n\nfunction expiryFromRetryAfter(retryAfterMs: number | undefined): number | undefined {\n\treturn retryAfterMs === undefined ? undefined : Date.now() + retryAfterMs;\n}\n"]}
@@ -0,0 +1,25 @@
1
+ export type BillingClass = "subscription" | "metered";
2
+ export type BillingFailoverAction = {
3
+ action: "failover";
4
+ to: {
5
+ provider: string;
6
+ modelId: string;
7
+ };
8
+ notice: string;
9
+ } | {
10
+ action: "halt_ask";
11
+ notice: string;
12
+ };
13
+ export interface BillingFailoverInput {
14
+ failedModel: {
15
+ provider: string;
16
+ id: string;
17
+ };
18
+ billingClass: BillingClass;
19
+ providerDefaultModelId: string | undefined;
20
+ hopResolvesWithAuth: boolean;
21
+ hopExhausted: boolean;
22
+ subscriptionHop?: boolean;
23
+ }
24
+ export declare function decideBillingFailover(input: BillingFailoverInput): BillingFailoverAction;
25
+ //# sourceMappingURL=billing-failover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-failover.d.ts","sourceRoot":"","sources":["../../src/core/billing-failover.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,cAAc,GAAG,SAAS,CAAC;AAEtD,MAAM,MAAM,qBAAqB,GAC9B;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjF;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1C,MAAM,WAAW,oBAAoB;IACpC,WAAW,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9C,YAAY,EAAE,YAAY,CAAC;IAC3B,sBAAsB,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,mBAAmB,EAAE,OAAO,CAAC;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AAMD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,oBAAoB,GAAG,qBAAqB,CAkBxF","sourcesContent":["export type BillingClass = \"subscription\" | \"metered\";\n\nexport type BillingFailoverAction =\n\t| { action: \"failover\"; to: { provider: string; modelId: string }; notice: string }\n\t| { action: \"halt_ask\"; notice: string };\n\nexport interface BillingFailoverInput {\n\tfailedModel: { provider: string; id: string };\n\tbillingClass: BillingClass;\n\tproviderDefaultModelId: string | undefined;\n\thopResolvesWithAuth: boolean;\n\thopExhausted: boolean;\n\tsubscriptionHop?: boolean;\n}\n\nfunction haltNotice(provider: string, modelId: string): string {\n\treturn `Provider quota/limit reached for ${provider}/${modelId}: switch models (/model), wait for the limit window, or re-send to retry`;\n}\n\nexport function decideBillingFailover(input: BillingFailoverInput): BillingFailoverAction {\n\tconst { failedModel, providerDefaultModelId } = input;\n\tconst subscriptionHop = input.subscriptionHop ?? true;\n\tif (\n\t\tinput.billingClass === \"subscription\" &&\n\t\tsubscriptionHop &&\n\t\tproviderDefaultModelId &&\n\t\tproviderDefaultModelId !== failedModel.id &&\n\t\tinput.hopResolvesWithAuth &&\n\t\t!input.hopExhausted\n\t) {\n\t\treturn {\n\t\t\taction: \"failover\",\n\t\t\tto: { provider: failedModel.provider, modelId: providerDefaultModelId },\n\t\t\tnotice: `${failedModel.id} quota reached — switched to ${failedModel.provider}/${providerDefaultModelId}`,\n\t\t};\n\t}\n\treturn { action: \"halt_ask\", notice: haltNotice(failedModel.provider, failedModel.id) };\n}\n"]}
@@ -0,0 +1,21 @@
1
+ function haltNotice(provider, modelId) {
2
+ return `Provider quota/limit reached for ${provider}/${modelId}: switch models (/model), wait for the limit window, or re-send to retry`;
3
+ }
4
+ export function decideBillingFailover(input) {
5
+ const { failedModel, providerDefaultModelId } = input;
6
+ const subscriptionHop = input.subscriptionHop ?? true;
7
+ if (input.billingClass === "subscription" &&
8
+ subscriptionHop &&
9
+ providerDefaultModelId &&
10
+ providerDefaultModelId !== failedModel.id &&
11
+ input.hopResolvesWithAuth &&
12
+ !input.hopExhausted) {
13
+ return {
14
+ action: "failover",
15
+ to: { provider: failedModel.provider, modelId: providerDefaultModelId },
16
+ notice: `${failedModel.id} quota reached — switched to ${failedModel.provider}/${providerDefaultModelId}`,
17
+ };
18
+ }
19
+ return { action: "halt_ask", notice: haltNotice(failedModel.provider, failedModel.id) };
20
+ }
21
+ //# sourceMappingURL=billing-failover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-failover.js","sourceRoot":"","sources":["../../src/core/billing-failover.ts"],"names":[],"mappings":"AAeA,SAAS,UAAU,CAAC,QAAgB,EAAE,OAAe,EAAU;IAC9D,OAAO,oCAAoC,QAAQ,IAAI,OAAO,0EAA0E,CAAC;AAAA,CACzI;AAED,MAAM,UAAU,qBAAqB,CAAC,KAA2B,EAAyB;IACzF,MAAM,EAAE,WAAW,EAAE,sBAAsB,EAAE,GAAG,KAAK,CAAC;IACtD,MAAM,eAAe,GAAG,KAAK,CAAC,eAAe,IAAI,IAAI,CAAC;IACtD,IACC,KAAK,CAAC,YAAY,KAAK,cAAc;QACrC,eAAe;QACf,sBAAsB;QACtB,sBAAsB,KAAK,WAAW,CAAC,EAAE;QACzC,KAAK,CAAC,mBAAmB;QACzB,CAAC,KAAK,CAAC,YAAY,EAClB,CAAC;QACF,OAAO;YACN,MAAM,EAAE,UAAU;YAClB,EAAE,EAAE,EAAE,QAAQ,EAAE,WAAW,CAAC,QAAQ,EAAE,OAAO,EAAE,sBAAsB,EAAE;YACvE,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,kCAAgC,WAAW,CAAC,QAAQ,IAAI,sBAAsB,EAAE;SACzG,CAAC;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,CAAC,WAAW,CAAC,QAAQ,EAAE,WAAW,CAAC,EAAE,CAAC,EAAE,CAAC;AAAA,CACxF","sourcesContent":["export type BillingClass = \"subscription\" | \"metered\";\n\nexport type BillingFailoverAction =\n\t| { action: \"failover\"; to: { provider: string; modelId: string }; notice: string }\n\t| { action: \"halt_ask\"; notice: string };\n\nexport interface BillingFailoverInput {\n\tfailedModel: { provider: string; id: string };\n\tbillingClass: BillingClass;\n\tproviderDefaultModelId: string | undefined;\n\thopResolvesWithAuth: boolean;\n\thopExhausted: boolean;\n\tsubscriptionHop?: boolean;\n}\n\nfunction haltNotice(provider: string, modelId: string): string {\n\treturn `Provider quota/limit reached for ${provider}/${modelId}: switch models (/model), wait for the limit window, or re-send to retry`;\n}\n\nexport function decideBillingFailover(input: BillingFailoverInput): BillingFailoverAction {\n\tconst { failedModel, providerDefaultModelId } = input;\n\tconst subscriptionHop = input.subscriptionHop ?? true;\n\tif (\n\t\tinput.billingClass === \"subscription\" &&\n\t\tsubscriptionHop &&\n\t\tproviderDefaultModelId &&\n\t\tproviderDefaultModelId !== failedModel.id &&\n\t\tinput.hopResolvesWithAuth &&\n\t\t!input.hopExhausted\n\t) {\n\t\treturn {\n\t\t\taction: \"failover\",\n\t\t\tto: { provider: failedModel.provider, modelId: providerDefaultModelId },\n\t\t\tnotice: `${failedModel.id} quota reached — switched to ${failedModel.provider}/${providerDefaultModelId}`,\n\t\t};\n\t}\n\treturn { action: \"halt_ask\", notice: haltNotice(failedModel.provider, failedModel.id) };\n}\n"]}
@@ -77,6 +77,7 @@ export declare class ContextPipeline {
77
77
  private _latestPromptPolicyReport;
78
78
  private _latestPromptPolicyGcCorrelation;
79
79
  private _latestPromptEnforcementReport;
80
+ private readonly _tokenBudget;
80
81
  private readonly deps;
81
82
  constructor(deps: ContextPipelineDeps);
82
83
  private _contextGcStorageDir;
@@ -1 +1 @@
1
- {"version":3,"file":"context-pipeline.d.ts","sourceRoot":"","sources":["../../src/core/context-pipeline.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Context pipeline: the session's per-turn context-shaping subsystem — the observe-only context\n * audit, the shadow prompt-policy plan and its correlation with legacy context-gc, the enforcement\n * pilot, the relevance-curation queue + the fitness-gated curation model resolver / brain-curation\n * drain + compaction pre-digest, the legacy context-gc pass and its packed-artifact reference\n * release, the tool-output artifact store, and the current-context token estimate.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the latest\n * audit/policy/correlation/enforcement/gc reports, the {@link BrainCurator} sidecar and its last\n * skip reasons, and the lazily-built tool-artifact store. Everything else it needs — the turn\n * index, the session/settings managers, the model registry, agent/workspace dirs, the active tool\n * names, the disposed flag, the isolated-completion primitive, spawned-usage accounting, and the\n * live {@link MemoryManager} — is reached through narrow deps accessors rather than the whole\n * AgentSession.\n *\n * Context-transform boundary (deliberate): the per-turn stages ({@link estimateCurrentContextTokens},\n * {@link runContextAudit}, {@link runPromptPolicyPlanning}, {@link applyContextGc},\n * {@link correlatePromptPolicyWithContextGc}, {@link runPromptEnforcement},\n * {@link enqueueRelevanceCuration}, {@link maybeDrainBrainCuration}) are invoked from the session's\n * context transform as one-line delegations, so the transform stays the single owner of the pass\n * ordering. This controller reaches {@link MemoryController} functionality only through\n * {@link ContextPipelineDeps.getMemoryManager} (never imports it), and MemoryController never imports\n * the pipeline — keeping the transform the one place the two subsystems meet.\n */\n\nimport { join } from \"node:path\";\nimport type { AgentMessage } from \"@caupulican/pi-agent-core\";\nimport { estimateContextTokens, getLatestCompactionEntry, type SessionManager } from \"@caupulican/pi-agent-core/node\";\nimport type { Api, AssistantMessage, Model, Usage } from \"@caupulican/pi-ai\";\nimport type { IsolatedCompletionOptions, IsolatedCompletionResult } from \"./agent-session.ts\";\nimport { BrainCurator, type CurationTelemetrySnapshot, preDigestConversationText } from \"./context/brain-curator.ts\";\nimport { type ArtifactStore, createFileArtifactStore } from \"./context/context-artifacts.ts\";\nimport { type ContextAuditReport, runContextAudit } from \"./context/context-audit.ts\";\nimport { enforcePromptPolicy, type PromptEnforcementReport } from \"./context/context-prompt-enforcement.ts\";\nimport {\n\tcorrelateWithContextGc,\n\ttype PromptPolicyGcCorrelationReport,\n\ttype PromptPolicyShadowReport,\n\tplanPromptPolicy,\n} from \"./context/context-prompt-policy.ts\";\nimport { applyContextGc, type ContextGcReport } from \"./context-gc.ts\";\nimport type { MemoryManager } from \"./memory/memory-manager.ts\";\nimport type { ModelRegistry } from \"./model-registry.ts\";\nimport { resolveCliModel } from \"./model-resolver.ts\";\nimport { FitnessStore } from \"./models/fitness-store.ts\";\nimport type { SettingsManager } from \"./settings-manager.ts\";\n\n/** Latest user prompt text in the provider-visible array (curation goal line; bounded by caller). */\nexport function latestUserPromptText(messages: AgentMessage[]): string {\n\tfor (let index = messages.length - 1; index >= 0; index--) {\n\t\tconst message = messages[index];\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tif (typeof message.content === \"string\") return message.content;\n\t\tconst text = message.content\n\t\t\t.filter((part): part is { type: \"text\"; text: string } => (part as { type?: string }).type === \"text\")\n\t\t\t.map((part) => part.text)\n\t\t\t.join(\"\\n\");\n\t\tif (text.length > 0) return text;\n\t}\n\treturn \"\";\n}\n\n/** Read a packed grep/find tool result's `details.artifactId`, if present, without `any`. */\nfunction extractArtifactId(message: AgentMessage | undefined): string | undefined {\n\tif (!message || message.role !== \"toolResult\") return undefined;\n\tconst details = (message as { details?: unknown }).details;\n\tif (typeof details !== \"object\" || details === null) return undefined;\n\tconst artifactId = (details as { artifactId?: unknown }).artifactId;\n\treturn typeof artifactId === \"string\" ? artifactId : undefined;\n}\n\nexport interface ContextPipelineDeps {\n\t/** Current turn index, stamped into audit/policy/enforcement reports. */\n\tgetTurnIndex(): number;\n\t/** Session log: audit lookup, gc/artifact storage dirs, curation entries, token-estimate compaction anchor. */\n\tgetSessionManager(): SessionManager;\n\t/** Context-gc / prompt-enforcement / curation settings (all opt-in gates). */\n\tgetSettingsManager(): SettingsManager;\n\t/** Resolves a configured curation model pattern against configured auth. */\n\tgetModelRegistry(): ModelRegistry;\n\t/** Root dir the host-keyed {@link FitnessStore} and per-session gc/artifact storage live under. */\n\tgetAgentDir(): string;\n\t/** Workspace root, passed to the context-gc pass. */\n\tgetCwd(): string;\n\t/** Currently-active tool names — enforcement checks whether artifact_retrieve is a live affordance. */\n\tgetActiveToolNames(): string[];\n\t/** A disposed session must never persist a curation/pre-digest entry. */\n\tisDisposed(): boolean;\n\t/** The live memory manager — the active providers' page markers feed the semantic-gc scan. */\n\tgetMemoryManager(): MemoryManager;\n\t/** Roll a curation drain's spawned usage into session accounting (idempotent per reportId). */\n\taddSpawnedUsage(\n\t\tusage: Usage,\n\t\topts?: { label?: string; sourceSessionId?: string; reportId?: string },\n\t): string | undefined;\n\t/** One-shot LLM call fully isolated from the main session — the curation/pre-digest execution primitive. */\n\trunIsolatedCompletion(opts: IsolatedCompletionOptions): Promise<IsolatedCompletionResult>;\n}\n\nexport class ContextPipeline {\n\tprivate _latestContextGcReport: ContextGcReport | undefined = undefined;\n\t/** Brain-curation sidecar (design: brain-context-curation-design.md). Inert unless the\n\t * contextPolicy.curation setting is enabled AND the model passes the digest fitness gate. */\n\tprivate readonly _brainCurator = new BrainCurator();\n\tprivate _lastCurationSkipReason: string | undefined = undefined;\n\tprivate _lastPreDigestSkipReason: string | undefined = undefined;\n\tprivate _toolArtifactStore: ArtifactStore | undefined = undefined;\n\tprivate _latestContextAuditReport: ContextAuditReport | undefined = undefined;\n\tprivate _latestPromptPolicyReport: PromptPolicyShadowReport | undefined = undefined;\n\tprivate _latestPromptPolicyGcCorrelation: PromptPolicyGcCorrelationReport | undefined = undefined;\n\tprivate _latestPromptEnforcementReport: PromptEnforcementReport | undefined = undefined;\n\n\tprivate readonly deps: ContextPipelineDeps;\n\n\tconstructor(deps: ContextPipelineDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\tprivate _contextGcStorageDir(): string {\n\t\treturn join(this.deps.getAgentDir(), \"context-gc\", this.deps.getSessionManager().getSessionId());\n\t}\n\n\tprivate _toolArtifactsDir(): string {\n\t\treturn join(this.deps.getAgentDir(), \"context-artifacts\", this.deps.getSessionManager().getSessionId());\n\t}\n\n\t/**\n\t * Session-scoped, filesystem-backed artifact store for first-capture-then-bound tool\n\t * output (grep/find only, for now -- see tool-output-artifacts.md). Lazily created and\n\t * cached so every tool construction in this session shares one store instance.\n\t *\n\t * `packToolOutput()` registers a reference (the packing tool call's id) at pack time\n\t * and fails closed, so packed artifacts are never prematurely collected.\n\t * `_releaseGcPackedArtifactReferences()` (called from `applyContextGc()`) releases\n\t * that reference once context-gc packs the result out of live context, and\n\t * opportunistically reclaims now-unreferenced artifacts via `cleanup()`.\n\t * Remaining carry-forward gap: cleanup() now also runs at dispose(), but only reclaims\n\t * already-released (zero-reference) artifacts. A session that ends before context-gc\n\t * ever evicts a result never releases that reference, so its artifact stays on disk by\n\t * design (resolvable on resume). Reclaiming those requires an explicit cross-session\n\t * expiry/liveness policy, not just a sweep.\n\t */\n\tgetToolArtifactStore(): ArtifactStore {\n\t\tthis._toolArtifactStore ??= createFileArtifactStore({ baseDir: this._toolArtifactsDir() });\n\t\treturn this._toolArtifactStore;\n\t}\n\n\t/**\n\t * Best-effort final sweep of any already-released (zero-reference) tool-output artifact at\n\t * session dispose. Reads the field (not the getter) so a session that never packed anything\n\t * doesn't force-create a store/dir just to sweep it.\n\t */\n\tcleanupToolArtifactStoreOnDispose(): void {\n\t\tthis._toolArtifactStore?.cleanup();\n\t}\n\n\t/**\n\t * One pass over the current branch, mapping each toolResult's toolCallId to its\n\t * persisted session-entry id. Rebuilt every audit pass (O(branch) per turn), so this is\n\t * O(n^2) over a long session. Fine at current scale; after the artifact-read fix this is\n\t * the next per-turn audit cost to optimize if it ever matters (e.g. cache/incrementally\n\t * update instead of a full rebuild).\n\t */\n\tprivate _buildSessionEntryIdLookup(): (toolCallId: string) => string | undefined {\n\t\tconst map = new Map<string, string>();\n\t\tfor (const entry of this.deps.getSessionManager().getBranch()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"toolResult\") {\n\t\t\t\tmap.set(entry.message.toolCallId, entry.id);\n\t\t\t}\n\t\t}\n\t\treturn (toolCallId: string) => map.get(toolCallId);\n\t}\n\n\t/**\n\t * Phase 1 observe-only audit pass (see context/context-audit.ts): converts live\n\t * toolResult messages into ContextItems and runs the existing retention/hard-constraint\n\t * evaluators over them, storing the latest deterministic report for tests/debugging.\n\t * Read-only with respect to messages, the transcript, and artifact references -- uses\n\t * `_toolArtifactStore` (the field), not `getToolArtifactStore()` (the getter), so a\n\t * session that never packed anything doesn't force-create a store/dir just to audit.\n\t * Never throws into a live turn: any failure degrades to an empty report.\n\t */\n\trunContextAudit(messages: AgentMessage[]): ContextAuditReport {\n\t\ttry {\n\t\t\tconst report = runContextAudit(messages, {\n\t\t\t\tturnIndex: this.deps.getTurnIndex(),\n\t\t\t\tartifactStore: this._toolArtifactStore,\n\t\t\t\tsessionEntryIdForToolCallId: this._buildSessionEntryIdLookup(),\n\t\t\t});\n\t\t\tthis._latestContextAuditReport = report;\n\t\t\treturn report;\n\t\t} catch {\n\t\t\tconst report: ContextAuditReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestContextAuditReport = report;\n\t\t\treturn report;\n\t\t}\n\t}\n\n\t/**\n\t * Read-only inspection of the context audit. With `messages`, recomputes fresh against\n\t * the given array (still no mutation of messages/transcript/artifact refs); without,\n\t * returns the last report computed during a real transform pass.\n\t */\n\tgetContextAuditReport(messages?: AgentMessage[]): ContextAuditReport {\n\t\tif (messages) return this.runContextAudit(messages);\n\t\treturn this._latestContextAuditReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\t/**\n\t * Observe-first shadow/planning pass (see context/context-prompt-policy.ts): re-shapes\n\t * the audit report into a per-item policy plan whose `appliedAction` is always\n\t * \"keep_raw\" -- this never enforces anything, it only records what the policy engine\n\t * would say. Never throws into a live turn: any failure degrades to an empty report.\n\t */\n\trunPromptPolicyPlanning(auditReport: ContextAuditReport): PromptPolicyShadowReport {\n\t\ttry {\n\t\t\tconst report = planPromptPolicy(auditReport);\n\t\t\tthis._latestPromptPolicyReport = report;\n\t\t\treturn report;\n\t\t} catch {\n\t\t\tconst report: PromptPolicyShadowReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestPromptPolicyReport = report;\n\t\t\treturn report;\n\t\t}\n\t}\n\n\t/**\n\t * Read-only inspection of the shadow policy plan. With `messages`, recomputes fresh\n\t * (audit + plan) against the given array; without, returns the last plan computed\n\t * during a real transform pass. Never mutates messages/transcript/artifact refs.\n\t */\n\tgetPromptPolicyReport(messages?: AgentMessage[]): PromptPolicyShadowReport {\n\t\tif (messages) return this.runPromptPolicyPlanning(this.runContextAudit(messages));\n\t\treturn this._latestPromptPolicyReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\t/**\n\t * Report-only correlation between the shadow plan just computed this turn and what the\n\t * legacy context-gc pass actually packed. Runs after `applyContextGc()` has already\n\t * produced its report; never influences context-gc itself. Never throws into a live\n\t * turn: any failure degrades to an empty correlation.\n\t */\n\tcorrelatePromptPolicyWithContextGc(gcReport: ContextGcReport): void {\n\t\tconst shadowReport = this._latestPromptPolicyReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\ttry {\n\t\t\tthis._latestPromptPolicyGcCorrelation = correlateWithContextGc(shadowReport, gcReport);\n\t\t} catch {\n\t\t\tthis._latestPromptPolicyGcCorrelation = { turnIndex: this.deps.getTurnIndex(), entries: [] };\n\t\t}\n\t}\n\n\t/** Read-only inspection of the latest shadow-plan/legacy-gc correlation, for tests/debugging. */\n\tgetPromptPolicyGcCorrelation(): PromptPolicyGcCorrelationReport {\n\t\treturn this._latestPromptPolicyGcCorrelation ?? { turnIndex: this.deps.getTurnIndex(), entries: [] };\n\t}\n\n\t/**\n\t * First enforcement pilot (see context/context-prompt-enforcement.ts): opt-in,\n\t * default-disabled stub-in-place of stale artifact-backed tool_output results in the\n\t * provider-visible message array only. Runs on `messages` AFTER context-gc has already\n\t * produced its own result, so legacy context-gc's own packing/reporting is completely\n\t * unaffected by this pass -- it only ever acts on messages gc left untouched this turn.\n\t * Never throws into a live turn: any failure degrades to returning `messages` unchanged.\n\t */\n\trunPromptEnforcement(\n\t\tmessages: AgentMessage[],\n\t\tshadowReport: PromptPolicyShadowReport,\n\t): { messages: AgentMessage[]; report: PromptEnforcementReport } {\n\t\ttry {\n\t\t\tconst persistedSettings = this.deps.getSettingsManager().getContextPromptEnforcementSettings();\n\t\t\tconst curationEnabled = this.deps.getSettingsManager().getContextCurationSettings().enabled;\n\t\t\tconst settings = {\n\t\t\t\t...persistedSettings,\n\t\t\t\t// Runtime fact, never assumed: artifact_retrieve is a companion affordance\n\t\t\t\t// (auto-activated alongside grep/find), not a default/global tool, so active\n\t\t\t\t// tools can differ turn to turn -- see context-prompt-enforcement.ts's doc\n\t\t\t\t// comment on why this is checked separately from hasAvailableRetrievalPath.\n\t\t\t\tretrievalToolAvailable: this.deps.getActiveToolNames().includes(\"artifact_retrieve\"),\n\t\t\t\tbrainRelevance: curationEnabled ? (itemId: string) => this._brainCurator.getRelevance(itemId) : undefined,\n\t\t\t};\n\t\t\tconst result = enforcePromptPolicy(messages, shadowReport, settings);\n\t\t\tthis._latestPromptEnforcementReport = result.report;\n\t\t\treturn result;\n\t\t} catch {\n\t\t\tconst report: PromptEnforcementReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestPromptEnforcementReport = report;\n\t\t\treturn { messages, report };\n\t\t}\n\t}\n\n\t/**\n\t * Enqueue relevance-scoring jobs for stale, artifact-backed tool outputs the enforcement\n\t * pilot could act on. Pure queueing — the verdicts only ever take effect through the\n\t * asymmetric advisory lever inside enforcePromptPolicy. Never throws into a turn.\n\t */\n\tenqueueRelevanceCuration(messages: AgentMessage[], shadowReport: PromptPolicyShadowReport): void {\n\t\ttry {\n\t\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tif (!settings.enabled) return;\n\t\t\tconst goal = latestUserPromptText(messages).slice(0, 400);\n\t\t\tfor (const item of shadowReport.items) {\n\t\t\t\tif (!item.hasAvailableRetrievalPath) continue;\n\t\t\t\tconst message = messages[item.messageIndex];\n\t\t\t\tif (!message || message.role !== \"toolResult\" || message.toolCallId !== item.toolCallId) continue;\n\t\t\t\tif (message.isError) continue;\n\t\t\t\tconst details = message.details as\n\t\t\t\t\t| { contextGc?: { packed?: unknown }; promptPolicy?: { enforced?: unknown } }\n\t\t\t\t\t| undefined;\n\t\t\t\tif (details?.contextGc?.packed === true || details?.promptPolicy?.enforced === true) continue;\n\t\t\t\tconst text = message.content\n\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\")\n\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t.join(\"\\n\");\n\t\t\t\tif (text.length === 0) continue;\n\t\t\t\tthis._brainCurator.enqueue({ kind: \"relevance\", key: item.itemId, content: text.slice(0, 4000), goal });\n\t\t\t}\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/**\n\t * Drain gate: settings on, model configured+authed, and the model has PASSED the digest\n\t * fitness probe on THIS host (design: unfit or unprobed models are refused with a visible\n\t * reason, never silently degraded). Fire-and-forget; never throws into a turn.\n\t */\n\t/**\n\t * Resolve the curation model IFF every gate passes: setting enabled, model configured,\n\t * resolvable+authed, and digest-fitness-proven on THIS host (canonical \"provider/id\" ref —\n\t * runModelFitness stores reports under it, while settings.model may be a bare id or pattern).\n\t * Sets _lastCurationSkipReason on refusal; never throws.\n\t */\n\tresolveCurationModelIfFit(): Model<Api> | undefined {\n\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\tif (!settings.enabled) {\n\t\t\t// Never surface a stale refusal reason for a feature the user has since disabled.\n\t\t\tthis._lastCurationSkipReason = undefined;\n\t\t\treturn undefined;\n\t\t}\n\t\tif (!settings.model) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_unset\";\n\t\t\treturn undefined;\n\t\t}\n\t\tconst resolved = resolveCliModel({ cliModel: settings.model, modelRegistry: this.deps.getModelRegistry() });\n\t\tif (!resolved.model || !this.deps.getModelRegistry().hasConfiguredAuth(resolved.model)) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_unresolved\";\n\t\t\treturn undefined;\n\t\t}\n\t\tconst canonicalRef = `${resolved.model.provider}/${resolved.model.id}`;\n\t\tconst fitness = FitnessStore.forAgentDir(this.deps.getAgentDir())\n\t\t\t.getForHost()\n\t\t\t.find((entry) => entry.model === canonicalRef);\n\t\tconst digestScore = fitness?.report.digest;\n\t\tif (!digestScore) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_unprobed\";\n\t\t\treturn undefined;\n\t\t}\n\t\tif (digestScore.succeeded < Math.ceil(digestScore.total * (2 / 3))) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_digest_unfit\";\n\t\t\treturn undefined;\n\t\t}\n\t\tthis._lastCurationSkipReason = undefined;\n\t\treturn resolved.model;\n\t}\n\n\tmaybeDrainBrainCuration(): void {\n\t\ttry {\n\t\t\tif (!this._brainCurator.hasWork() || this._brainCurator.isDraining) return;\n\t\t\tconst model = this.resolveCurationModelIfFit();\n\t\t\tif (!model) return;\n\t\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tvoid this._drainBrainCuration(model, settings.maxJobsPerTurn);\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/**\n\t * Compaction pre-digest gate (design surface 3): everything the drain gate requires PLUS a\n\t * RUNTIME reliability proof — the curator must have run >=5 jobs on this session with a parse\n\t * failure rate <=5% before it is trusted to pre-digest compaction input. Returns undefined\n\t * (verbatim compaction, byte-for-byte today's behavior) whenever any gate refuses.\n\t */\n\tbuildCompactionPreDigest(): ((text: string, signal?: AbortSignal) => Promise<string>) | undefined {\n\t\ttry {\n\t\t\tconst model = this.resolveCurationModelIfFit();\n\t\t\tif (!model) return undefined;\n\t\t\tconst telemetry = this._brainCurator.telemetry();\n\t\t\tif (telemetry.jobsRun < 5 || telemetry.parseFailures / telemetry.jobsRun > 0.05) {\n\t\t\t\tthis._lastPreDigestSkipReason = \"curation_predigest_reliability_unproven\";\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\tthis._lastPreDigestSkipReason = undefined;\n\t\t\treturn async (text, signal) => {\n\t\t\t\tconst result = await preDigestConversationText({\n\t\t\t\t\ttext,\n\t\t\t\t\tsignal,\n\t\t\t\t\tcomplete: async ({ systemPrompt, userPrompt, signal: chunkSignal }) => {\n\t\t\t\t\t\tconst completion = await this.deps.runIsolatedCompletion({\n\t\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\t\tmessages: [{ role: \"user\", content: [{ type: \"text\", text: userPrompt }], timestamp: Date.now() }],\n\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\t\t\t\tmaxTokens: 512,\n\t\t\t\t\t\t\tsignal: chunkSignal,\n\t\t\t\t\t\t\tcacheRetention: \"short\",\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\ttext: completion.text,\n\t\t\t\t\t\t\tcostUsd: completion.usage.cost.total,\n\t\t\t\t\t\t\tstopReason: String(completion.stopReason),\n\t\t\t\t\t\t};\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\tif (!this.deps.isDisposed() && result.totalChunks > 0) {\n\t\t\t\t\tthis.deps.getSessionManager().appendCustomEntry(\"brain-curation-predigest\", {\n\t\t\t\t\t\tversion: 1,\n\t\t\t\t\t\ttotalChunks: result.totalChunks,\n\t\t\t\t\t\tdigested: result.digested,\n\t\t\t\t\t\tfailed: result.failed,\n\t\t\t\t\t\tcharsBefore: text.length,\n\t\t\t\t\t\tcharsAfter: result.text.length,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn result.text;\n\t\t\t};\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async _drainBrainCuration(model: Model<Api>, maxJobs: number): Promise<void> {\n\t\ttry {\n\t\t\t// ACCUMULATE across all drained jobs (the drain runs the completer once PER job) —\n\t\t\t// keeping only the last job's usage would under-report every multi-job drain.\n\t\t\tlet spentUsage: AssistantMessage[\"usage\"] | undefined;\n\t\t\tconst results = await this._brainCurator.drain({\n\t\t\t\tmaxJobs,\n\t\t\t\tcomplete: async ({ systemPrompt, userPrompt, signal }) => {\n\t\t\t\t\tconst completion = await this.deps.runIsolatedCompletion({\n\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\tmessages: [{ role: \"user\", content: [{ type: \"text\", text: userPrompt }], timestamp: Date.now() }],\n\t\t\t\t\t\tmodel,\n\t\t\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\t\t\tmaxTokens: 256,\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t// Both curation system prompts are static — the provider can cache the prefix.\n\t\t\t\t\t\tcacheRetention: \"short\",\n\t\t\t\t\t});\n\t\t\t\t\tconst usage = completion.usage;\n\t\t\t\t\tif (!spentUsage) {\n\t\t\t\t\t\tspentUsage = structuredClone(usage);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tspentUsage.input += usage.input;\n\t\t\t\t\t\tspentUsage.output += usage.output;\n\t\t\t\t\t\tspentUsage.cacheRead += usage.cacheRead;\n\t\t\t\t\t\tspentUsage.cacheWrite += usage.cacheWrite;\n\t\t\t\t\t\tspentUsage.totalTokens += usage.totalTokens;\n\t\t\t\t\t\tspentUsage.cost.input += usage.cost.input;\n\t\t\t\t\t\tspentUsage.cost.output += usage.cost.output;\n\t\t\t\t\t\tspentUsage.cost.cacheRead += usage.cost.cacheRead;\n\t\t\t\t\t\tspentUsage.cost.cacheWrite += usage.cost.cacheWrite;\n\t\t\t\t\t\tspentUsage.cost.total += usage.cost.total;\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\ttext: completion.text,\n\t\t\t\t\t\tcostUsd: completion.usage.cost.total,\n\t\t\t\t\t\tstopReason: String(completion.stopReason),\n\t\t\t\t\t};\n\t\t\t\t},\n\t\t\t});\n\t\t\t// Honest accounting even for free local models: token visibility is the contract.\n\t\t\tif (spentUsage && (spentUsage.cost.total > 0 || spentUsage.totalTokens > 0)) {\n\t\t\t\tthis.deps.addSpawnedUsage(spentUsage, { label: \"context-curator\" });\n\t\t\t}\n\t\t\tif (this.deps.isDisposed() || results.length === 0) return;\n\t\t\tthis.deps.getSessionManager().appendCustomEntry(\"brain-curation\", {\n\t\t\t\tversion: 1,\n\t\t\t\tresults: results.map((result) => ({\n\t\t\t\t\tkey: result.key,\n\t\t\t\t\tkind: result.kind,\n\t\t\t\t\tok: result.ok,\n\t\t\t\t\tms: result.ms,\n\t\t\t\t\t...(result.digest !== undefined ? { digest: result.digest } : {}),\n\t\t\t\t\t...(result.relevant !== undefined ? { relevant: result.relevant, confidence: result.confidence } : {}),\n\t\t\t\t})),\n\t\t\t\ttelemetry: this._brainCurator.telemetry(),\n\t\t\t});\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/** Curation status for diagnostics/dashboard: settings, live telemetry, last refusal reason. */\n\tgetContextCurationStatus(): {\n\t\tenabled: boolean;\n\t\tmodel?: string;\n\t\ttelemetry: CurationTelemetrySnapshot;\n\t\tlastSkipReason?: string;\n\t\tlastPreDigestSkipReason?: string;\n\t} {\n\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\treturn {\n\t\t\tenabled: settings.enabled,\n\t\t\tmodel: settings.model,\n\t\t\ttelemetry: this._brainCurator.telemetry(),\n\t\t\tlastSkipReason: this._lastCurationSkipReason,\n\t\t\tlastPreDigestSkipReason: this._lastPreDigestSkipReason,\n\t\t};\n\t}\n\n\t/** Read-only inspection of the latest prompt-enforcement report, for tests/debugging. */\n\tgetPromptEnforcementReport(): PromptEnforcementReport {\n\t\treturn this._latestPromptEnforcementReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\tapplyContextGc(\n\t\tmessages: AgentMessage[],\n\t\twritePayloads: boolean,\n\t): { messages: AgentMessage[]; report: ContextGcReport } {\n\t\ttry {\n\t\t\tconst settings = this.deps.getSettingsManager().getContextGcSettings();\n\t\t\t// Merge the ACTIVE memory providers' own page markers (e.g. transcript-recall's\n\t\t\t// \"<memory_context\") into the semantic-memory marker list. The settings default is\n\t\t\t// provider-agnostic and non-empty, so without this merge the recall pages the bundled\n\t\t\t// default provider actually emits are never recognized as semantic-memory pages and\n\t\t\t// accumulate raw for the life of the session — the exact growth Bug #7 GC exists to stop.\n\t\t\tconst providerMarkers = this.deps.getMemoryManager().getContextMarkers();\n\t\t\tconst curationSettings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tconst result = applyContextGc(messages, {\n\t\t\t\t...settings,\n\t\t\t\tsemanticMemory: {\n\t\t\t\t\t...settings.semanticMemory,\n\t\t\t\t\tmarkers: [...new Set([...settings.semanticMemory.markers, ...providerMarkers])],\n\t\t\t\t},\n\t\t\t\tcwd: this.deps.getCwd(),\n\t\t\t\tstorageDir: this._contextGcStorageDir(),\n\t\t\t\twritePayloads,\n\t\t\t\tcuration: curationSettings.enabled\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tresolveDigest: (digestKey) => {\n\t\t\t\t\t\t\t\tconst digest = this._brainCurator.getDigest(digestKey);\n\t\t\t\t\t\t\t\t// Count serves on the REAL per-turn pass only, never the report path.\n\t\t\t\t\t\t\t\tif (digest !== undefined && writePayloads) this._brainCurator.noteDigestServed();\n\t\t\t\t\t\t\t\treturn digest;\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t// Only the real per-turn pass enqueues work; the read-only report path\n\t\t\t\t\t\t\t// (writePayloads=false) stays side-effect free.\n\t\t\t\t\t\t\tonPacked: writePayloads\n\t\t\t\t\t\t\t\t? (record, originalText) => {\n\t\t\t\t\t\t\t\t\t\tthis._brainCurator.enqueue({\n\t\t\t\t\t\t\t\t\t\t\tkind: \"stub_digest\",\n\t\t\t\t\t\t\t\t\t\t\tkey: record.key ?? record.toolCallId,\n\t\t\t\t\t\t\t\t\t\t\tcontent: originalText,\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t}\n\t\t\t\t\t: undefined,\n\t\t\t});\n\t\t\tthis._latestContextGcReport = result.report;\n\t\t\t// Only release/reclaim on the real per-turn pass (writePayloads=true), never on\n\t\t\t// the read-only status-report path (getContextGcReport with writePayloads=false),\n\t\t\t// so merely inspecting the report can't have side effects.\n\t\t\tif (writePayloads && result.report.packedCount > 0) {\n\t\t\t\tthis._releaseGcPackedArtifactReferences(messages, result.report);\n\t\t\t}\n\t\t\treturn result;\n\t\t} catch {\n\t\t\tconst report: ContextGcReport = {\n\t\t\t\tenabled: false,\n\t\t\t\tpackedCount: 0,\n\t\t\t\toriginalTokens: 0,\n\t\t\t\tpackedTokens: 0,\n\t\t\t\tsavedTokens: 0,\n\t\t\t\trecords: [],\n\t\t\t};\n\t\t\tthis._latestContextGcReport = report;\n\t\t\treturn { messages, report };\n\t\t}\n\t}\n\n\t/**\n\t * Reference-release + cleanup lifecycle: once context-gc has packed a grep/find tool\n\t * result out of the live prompt (the message is no longer current/active working\n\t * context -- see contracts-and-retention.md's \"ephemeral\"/\"expired\" retention\n\t * classes), release the pack-time reference `packToolOutput()` registered for it, and\n\t * opportunistically reclaim now-unreferenced artifacts. This is the other half of the\n\t * D2b-1 gate: artifacts were being registered but never released, so they accumulated\n\t * for the life of the session.\n\t *\n\t * `record.toolCallId` (from context-gc's packed record) is exactly the holder id\n\t * `packToolOutput()` used when it called `addReference()` -- both trace back to the\n\t * same tool call's id -- so no separate bookkeeping is needed to find it.\n\t */\n\tprivate _releaseGcPackedArtifactReferences(messages: AgentMessage[], report: ContextGcReport): void {\n\t\tconst store = this._toolArtifactStore;\n\t\tif (!store) return; // no store was ever constructed, so nothing could have been packed to one\n\n\t\tlet releasedAny = false;\n\t\tfor (const record of report.records) {\n\t\t\tif (record.toolName !== \"grep\" && record.toolName !== \"find\") continue;\n\t\t\tconst artifactId = extractArtifactId(messages[record.messageIndex]);\n\t\t\tif (!artifactId) continue;\n\t\t\tif (store.removeReference(artifactId, record.toolCallId)) releasedAny = true;\n\t\t}\n\t\t// Cleanup only runs immediately after a release actually happened in this pass, so\n\t\t// a long session doesn't re-scan the artifact directory on every turn once nothing\n\t\t// new became eligible for release.\n\t\tif (releasedAny) store.cleanup();\n\t}\n\n\tgetContextGcReport(messages?: AgentMessage[]): ContextGcReport {\n\t\tif (messages) return this.applyContextGc(messages, false).report;\n\t\treturn (\n\t\t\tthis._latestContextGcReport ?? {\n\t\t\t\tenabled: this.deps.getSettingsManager().getContextGcSettings().enabled,\n\t\t\t\tpackedCount: 0,\n\t\t\t\toriginalTokens: 0,\n\t\t\t\tpackedTokens: 0,\n\t\t\t\tsavedTokens: 0,\n\t\t\t\trecords: [],\n\t\t\t}\n\t\t);\n\t}\n\n\testimateCurrentContextTokens(messages: AgentMessage[]): number {\n\t\tconst estimate = estimateContextTokens(messages);\n\t\tconst compactionEntry = getLatestCompactionEntry(this.deps.getSessionManager().getBranch());\n\t\tif (estimate.lastUsageIndex === null || !compactionEntry) {\n\t\t\treturn estimate.tokens;\n\t\t}\n\t\tconst usageMessage = messages[estimate.lastUsageIndex];\n\t\tif (usageMessage?.role !== \"assistant\") {\n\t\t\treturn estimate.tokens;\n\t\t}\n\t\tconst usageTimestamp = (usageMessage as AssistantMessage).timestamp;\n\t\tconst compactionTimestamp = new Date(compactionEntry.timestamp).getTime();\n\t\tif (usageTimestamp <= compactionTimestamp) {\n\t\t\treturn estimate.trailingTokens;\n\t\t}\n\t\treturn estimate.tokens;\n\t}\n}\n"]}
1
+ {"version":3,"file":"context-pipeline.d.ts","sourceRoot":"","sources":["../../src/core/context-pipeline.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Context pipeline: the session's per-turn context-shaping subsystem — the observe-only context\n * audit, the shadow prompt-policy plan and its correlation with legacy context-gc, the enforcement\n * pilot, the relevance-curation queue + the fitness-gated curation model resolver / brain-curation\n * drain + compaction pre-digest, the legacy context-gc pass and its packed-artifact reference\n * release, the tool-output artifact store, and the current-context token estimate.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the latest\n * audit/policy/correlation/enforcement/gc reports, the {@link BrainCurator} sidecar and its last\n * skip reasons, and the lazily-built tool-artifact store. Everything else it needs — the turn\n * index, the session/settings managers, the model registry, agent/workspace dirs, the active tool\n * names, the disposed flag, the isolated-completion primitive, spawned-usage accounting, and the\n * live {@link MemoryManager} — is reached through narrow deps accessors rather than the whole\n * AgentSession.\n *\n * Context-transform boundary (deliberate): the per-turn stages ({@link estimateCurrentContextTokens},\n * {@link runContextAudit}, {@link runPromptPolicyPlanning}, {@link applyContextGc},\n * {@link correlatePromptPolicyWithContextGc}, {@link runPromptEnforcement},\n * {@link enqueueRelevanceCuration}, {@link maybeDrainBrainCuration}) are invoked from the session's\n * context transform as one-line delegations, so the transform stays the single owner of the pass\n * ordering. This controller reaches {@link MemoryController} functionality only through\n * {@link ContextPipelineDeps.getMemoryManager} (never imports it), and MemoryController never imports\n * the pipeline — keeping the transform the one place the two subsystems meet.\n */\n\nimport { join } from \"node:path\";\nimport type { AgentMessage } from \"@caupulican/pi-agent-core\";\nimport {\n\testimateContextTokens,\n\tgetLatestCompactionEntry,\n\ttype SessionManager,\n\tTokenBudget,\n} from \"@caupulican/pi-agent-core/node\";\nimport type { Api, AssistantMessage, Model, Usage } from \"@caupulican/pi-ai\";\nimport type { IsolatedCompletionOptions, IsolatedCompletionResult } from \"./agent-session.ts\";\nimport { BrainCurator, type CurationTelemetrySnapshot, preDigestConversationText } from \"./context/brain-curator.ts\";\nimport { type ArtifactStore, createFileArtifactStore } from \"./context/context-artifacts.ts\";\nimport { type ContextAuditReport, runContextAudit } from \"./context/context-audit.ts\";\nimport { enforcePromptPolicy, type PromptEnforcementReport } from \"./context/context-prompt-enforcement.ts\";\nimport {\n\tcorrelateWithContextGc,\n\ttype PromptPolicyGcCorrelationReport,\n\ttype PromptPolicyShadowReport,\n\tplanPromptPolicy,\n} from \"./context/context-prompt-policy.ts\";\nimport { applyContextGc, type ContextGcReport } from \"./context-gc.ts\";\nimport type { MemoryManager } from \"./memory/memory-manager.ts\";\nimport type { ModelRegistry } from \"./model-registry.ts\";\nimport { resolveCliModel } from \"./model-resolver.ts\";\nimport { evaluateSurfaceFitness } from \"./model-router/fitness-gate.ts\";\nimport { FitnessStore } from \"./models/fitness-store.ts\";\nimport type { SettingsManager } from \"./settings-manager.ts\";\n\n/** Latest user prompt text in the provider-visible array (curation goal line; bounded by caller). */\nexport function latestUserPromptText(messages: AgentMessage[]): string {\n\tfor (let index = messages.length - 1; index >= 0; index--) {\n\t\tconst message = messages[index];\n\t\tif (!message || message.role !== \"user\") continue;\n\t\tif (typeof message.content === \"string\") return message.content;\n\t\tconst text = message.content\n\t\t\t.filter((part): part is { type: \"text\"; text: string } => (part as { type?: string }).type === \"text\")\n\t\t\t.map((part) => part.text)\n\t\t\t.join(\"\\n\");\n\t\tif (text.length > 0) return text;\n\t}\n\treturn \"\";\n}\n\n/** Read a packed grep/find tool result's `details.artifactId`, if present, without `any`. */\nfunction extractArtifactId(message: AgentMessage | undefined): string | undefined {\n\tif (!message || message.role !== \"toolResult\") return undefined;\n\tconst details = (message as { details?: unknown }).details;\n\tif (typeof details !== \"object\" || details === null) return undefined;\n\tconst artifactId = (details as { artifactId?: unknown }).artifactId;\n\treturn typeof artifactId === \"string\" ? artifactId : undefined;\n}\n\nexport interface ContextPipelineDeps {\n\t/** Current turn index, stamped into audit/policy/enforcement reports. */\n\tgetTurnIndex(): number;\n\t/** Session log: audit lookup, gc/artifact storage dirs, curation entries, token-estimate compaction anchor. */\n\tgetSessionManager(): SessionManager;\n\t/** Context-gc / prompt-enforcement / curation settings (all opt-in gates). */\n\tgetSettingsManager(): SettingsManager;\n\t/** Resolves a configured curation model pattern against configured auth. */\n\tgetModelRegistry(): ModelRegistry;\n\t/** Root dir the host-keyed {@link FitnessStore} and per-session gc/artifact storage live under. */\n\tgetAgentDir(): string;\n\t/** Workspace root, passed to the context-gc pass. */\n\tgetCwd(): string;\n\t/** Currently-active tool names — enforcement checks whether artifact_retrieve is a live affordance. */\n\tgetActiveToolNames(): string[];\n\t/** A disposed session must never persist a curation/pre-digest entry. */\n\tisDisposed(): boolean;\n\t/** The live memory manager — the active providers' page markers feed the semantic-gc scan. */\n\tgetMemoryManager(): MemoryManager;\n\t/** Roll a curation drain's spawned usage into session accounting (idempotent per reportId). */\n\taddSpawnedUsage(\n\t\tusage: Usage,\n\t\topts?: { label?: string; sourceSessionId?: string; reportId?: string },\n\t): string | undefined;\n\t/** One-shot LLM call fully isolated from the main session — the curation/pre-digest execution primitive. */\n\trunIsolatedCompletion(opts: IsolatedCompletionOptions): Promise<IsolatedCompletionResult>;\n}\n\nexport class ContextPipeline {\n\tprivate _latestContextGcReport: ContextGcReport | undefined = undefined;\n\t/** Brain-curation sidecar (design: brain-context-curation-design.md). Inert unless the\n\t * contextPolicy.curation setting is enabled AND the model passes the digest fitness gate. */\n\tprivate readonly _brainCurator = new BrainCurator();\n\tprivate _lastCurationSkipReason: string | undefined = undefined;\n\tprivate _lastPreDigestSkipReason: string | undefined = undefined;\n\tprivate _toolArtifactStore: ArtifactStore | undefined = undefined;\n\tprivate _latestContextAuditReport: ContextAuditReport | undefined = undefined;\n\tprivate _latestPromptPolicyReport: PromptPolicyShadowReport | undefined = undefined;\n\tprivate _latestPromptPolicyGcCorrelation: PromptPolicyGcCorrelationReport | undefined = undefined;\n\tprivate _latestPromptEnforcementReport: PromptEnforcementReport | undefined = undefined;\n\tprivate readonly _tokenBudget = new TokenBudget();\n\n\tprivate readonly deps: ContextPipelineDeps;\n\n\tconstructor(deps: ContextPipelineDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\tprivate _contextGcStorageDir(): string {\n\t\treturn join(this.deps.getAgentDir(), \"context-gc\", this.deps.getSessionManager().getSessionId());\n\t}\n\n\tprivate _toolArtifactsDir(): string {\n\t\treturn join(this.deps.getAgentDir(), \"context-artifacts\", this.deps.getSessionManager().getSessionId());\n\t}\n\n\t/**\n\t * Session-scoped, filesystem-backed artifact store for first-capture-then-bound tool\n\t * output (grep/find only, for now -- see tool-output-artifacts.md). Lazily created and\n\t * cached so every tool construction in this session shares one store instance.\n\t *\n\t * `packToolOutput()` registers a reference (the packing tool call's id) at pack time\n\t * and fails closed, so packed artifacts are never prematurely collected.\n\t * `_releaseGcPackedArtifactReferences()` (called from `applyContextGc()`) releases\n\t * that reference once context-gc packs the result out of live context, and\n\t * opportunistically reclaims now-unreferenced artifacts via `cleanup()`.\n\t * Remaining carry-forward gap: cleanup() now also runs at dispose(), but only reclaims\n\t * already-released (zero-reference) artifacts. A session that ends before context-gc\n\t * ever evicts a result never releases that reference, so its artifact stays on disk by\n\t * design (resolvable on resume). Reclaiming those requires an explicit cross-session\n\t * expiry/liveness policy, not just a sweep.\n\t */\n\tgetToolArtifactStore(): ArtifactStore {\n\t\tthis._toolArtifactStore ??= createFileArtifactStore({ baseDir: this._toolArtifactsDir() });\n\t\treturn this._toolArtifactStore;\n\t}\n\n\t/**\n\t * Best-effort final sweep of any already-released (zero-reference) tool-output artifact at\n\t * session dispose. Reads the field (not the getter) so a session that never packed anything\n\t * doesn't force-create a store/dir just to sweep it.\n\t */\n\tcleanupToolArtifactStoreOnDispose(): void {\n\t\tthis._toolArtifactStore?.cleanup();\n\t}\n\n\t/**\n\t * One pass over the current branch, mapping each toolResult's toolCallId to its\n\t * persisted session-entry id. Rebuilt every audit pass (O(branch) per turn), so this is\n\t * O(n^2) over a long session. Fine at current scale; after the artifact-read fix this is\n\t * the next per-turn audit cost to optimize if it ever matters (e.g. cache/incrementally\n\t * update instead of a full rebuild).\n\t */\n\tprivate _buildSessionEntryIdLookup(): (toolCallId: string) => string | undefined {\n\t\tconst map = new Map<string, string>();\n\t\tfor (const entry of this.deps.getSessionManager().getBranch()) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"toolResult\") {\n\t\t\t\tmap.set(entry.message.toolCallId, entry.id);\n\t\t\t}\n\t\t}\n\t\treturn (toolCallId: string) => map.get(toolCallId);\n\t}\n\n\t/**\n\t * Phase 1 observe-only audit pass (see context/context-audit.ts): converts live\n\t * toolResult messages into ContextItems and runs the existing retention/hard-constraint\n\t * evaluators over them, storing the latest deterministic report for tests/debugging.\n\t * Read-only with respect to messages, the transcript, and artifact references -- uses\n\t * `_toolArtifactStore` (the field), not `getToolArtifactStore()` (the getter), so a\n\t * session that never packed anything doesn't force-create a store/dir just to audit.\n\t * Never throws into a live turn: any failure degrades to an empty report.\n\t */\n\trunContextAudit(messages: AgentMessage[]): ContextAuditReport {\n\t\ttry {\n\t\t\tconst report = runContextAudit(messages, {\n\t\t\t\tturnIndex: this.deps.getTurnIndex(),\n\t\t\t\tartifactStore: this._toolArtifactStore,\n\t\t\t\tsessionEntryIdForToolCallId: this._buildSessionEntryIdLookup(),\n\t\t\t});\n\t\t\tthis._latestContextAuditReport = report;\n\t\t\treturn report;\n\t\t} catch {\n\t\t\tconst report: ContextAuditReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestContextAuditReport = report;\n\t\t\treturn report;\n\t\t}\n\t}\n\n\t/**\n\t * Read-only inspection of the context audit. With `messages`, recomputes fresh against\n\t * the given array (still no mutation of messages/transcript/artifact refs); without,\n\t * returns the last report computed during a real transform pass.\n\t */\n\tgetContextAuditReport(messages?: AgentMessage[]): ContextAuditReport {\n\t\tif (messages) return this.runContextAudit(messages);\n\t\treturn this._latestContextAuditReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\t/**\n\t * Observe-first shadow/planning pass (see context/context-prompt-policy.ts): re-shapes\n\t * the audit report into a per-item policy plan whose `appliedAction` is always\n\t * \"keep_raw\" -- this never enforces anything, it only records what the policy engine\n\t * would say. Never throws into a live turn: any failure degrades to an empty report.\n\t */\n\trunPromptPolicyPlanning(auditReport: ContextAuditReport): PromptPolicyShadowReport {\n\t\ttry {\n\t\t\tconst report = planPromptPolicy(auditReport);\n\t\t\tthis._latestPromptPolicyReport = report;\n\t\t\treturn report;\n\t\t} catch {\n\t\t\tconst report: PromptPolicyShadowReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestPromptPolicyReport = report;\n\t\t\treturn report;\n\t\t}\n\t}\n\n\t/**\n\t * Read-only inspection of the shadow policy plan. With `messages`, recomputes fresh\n\t * (audit + plan) against the given array; without, returns the last plan computed\n\t * during a real transform pass. Never mutates messages/transcript/artifact refs.\n\t */\n\tgetPromptPolicyReport(messages?: AgentMessage[]): PromptPolicyShadowReport {\n\t\tif (messages) return this.runPromptPolicyPlanning(this.runContextAudit(messages));\n\t\treturn this._latestPromptPolicyReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\t/**\n\t * Report-only correlation between the shadow plan just computed this turn and what the\n\t * legacy context-gc pass actually packed. Runs after `applyContextGc()` has already\n\t * produced its report; never influences context-gc itself. Never throws into a live\n\t * turn: any failure degrades to an empty correlation.\n\t */\n\tcorrelatePromptPolicyWithContextGc(gcReport: ContextGcReport): void {\n\t\tconst shadowReport = this._latestPromptPolicyReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\ttry {\n\t\t\tthis._latestPromptPolicyGcCorrelation = correlateWithContextGc(shadowReport, gcReport);\n\t\t} catch {\n\t\t\tthis._latestPromptPolicyGcCorrelation = { turnIndex: this.deps.getTurnIndex(), entries: [] };\n\t\t}\n\t}\n\n\t/** Read-only inspection of the latest shadow-plan/legacy-gc correlation, for tests/debugging. */\n\tgetPromptPolicyGcCorrelation(): PromptPolicyGcCorrelationReport {\n\t\treturn this._latestPromptPolicyGcCorrelation ?? { turnIndex: this.deps.getTurnIndex(), entries: [] };\n\t}\n\n\t/**\n\t * First enforcement pilot (see context/context-prompt-enforcement.ts): opt-in,\n\t * default-disabled stub-in-place of stale artifact-backed tool_output results in the\n\t * provider-visible message array only. Runs on `messages` AFTER context-gc has already\n\t * produced its own result, so legacy context-gc's own packing/reporting is completely\n\t * unaffected by this pass -- it only ever acts on messages gc left untouched this turn.\n\t * Never throws into a live turn: any failure degrades to returning `messages` unchanged.\n\t */\n\trunPromptEnforcement(\n\t\tmessages: AgentMessage[],\n\t\tshadowReport: PromptPolicyShadowReport,\n\t): { messages: AgentMessage[]; report: PromptEnforcementReport } {\n\t\ttry {\n\t\t\tconst persistedSettings = this.deps.getSettingsManager().getContextPromptEnforcementSettings();\n\t\t\tconst curationEnabled = this.deps.getSettingsManager().getContextCurationSettings().enabled;\n\t\t\tconst settings = {\n\t\t\t\t...persistedSettings,\n\t\t\t\t// Runtime fact, never assumed: artifact_retrieve is a companion affordance\n\t\t\t\t// (auto-activated alongside grep/find), not a default/global tool, so active\n\t\t\t\t// tools can differ turn to turn -- see context-prompt-enforcement.ts's doc\n\t\t\t\t// comment on why this is checked separately from hasAvailableRetrievalPath.\n\t\t\t\tretrievalToolAvailable: this.deps.getActiveToolNames().includes(\"artifact_retrieve\"),\n\t\t\t\tbrainRelevance: curationEnabled ? (itemId: string) => this._brainCurator.getRelevance(itemId) : undefined,\n\t\t\t};\n\t\t\tconst result = enforcePromptPolicy(messages, shadowReport, settings);\n\t\t\tthis._latestPromptEnforcementReport = result.report;\n\t\t\treturn result;\n\t\t} catch {\n\t\t\tconst report: PromptEnforcementReport = { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t\t\tthis._latestPromptEnforcementReport = report;\n\t\t\treturn { messages, report };\n\t\t}\n\t}\n\n\t/**\n\t * Enqueue relevance-scoring jobs for stale, artifact-backed tool outputs the enforcement\n\t * pilot could act on. Pure queueing — the verdicts only ever take effect through the\n\t * asymmetric advisory lever inside enforcePromptPolicy. Never throws into a turn.\n\t */\n\tenqueueRelevanceCuration(messages: AgentMessage[], shadowReport: PromptPolicyShadowReport): void {\n\t\ttry {\n\t\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tif (!settings.enabled) return;\n\t\t\tconst goal = latestUserPromptText(messages).slice(0, 400);\n\t\t\tfor (const item of shadowReport.items) {\n\t\t\t\tif (!item.hasAvailableRetrievalPath) continue;\n\t\t\t\tconst message = messages[item.messageIndex];\n\t\t\t\tif (!message || message.role !== \"toolResult\" || message.toolCallId !== item.toolCallId) continue;\n\t\t\t\tif (message.isError) continue;\n\t\t\t\tconst details = message.details as\n\t\t\t\t\t| { contextGc?: { packed?: unknown }; promptPolicy?: { enforced?: unknown } }\n\t\t\t\t\t| undefined;\n\t\t\t\tif (details?.contextGc?.packed === true || details?.promptPolicy?.enforced === true) continue;\n\t\t\t\tconst text = message.content\n\t\t\t\t\t.filter((part): part is { type: \"text\"; text: string } => part.type === \"text\")\n\t\t\t\t\t.map((part) => part.text)\n\t\t\t\t\t.join(\"\\n\");\n\t\t\t\tif (text.length === 0) continue;\n\t\t\t\tthis._brainCurator.enqueue({ kind: \"relevance\", key: item.itemId, content: text.slice(0, 4000), goal });\n\t\t\t}\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/**\n\t * Drain gate: settings on, model configured+authed, and the model has PASSED the digest\n\t * fitness probe on THIS host (design: unfit or unprobed models are refused with a visible\n\t * reason, never silently degraded). Fire-and-forget; never throws into a turn.\n\t */\n\t/**\n\t * Resolve the curation model IFF every gate passes: setting enabled, model configured,\n\t * resolvable+authed, and digest-fitness-proven on THIS host (canonical \"provider/id\" ref —\n\t * runModelFitness stores reports under it, while settings.model may be a bare id or pattern).\n\t * Sets _lastCurationSkipReason on refusal; never throws.\n\t */\n\tresolveCurationModelIfFit(): Model<Api> | undefined {\n\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\tif (!settings.enabled) {\n\t\t\t// Never surface a stale refusal reason for a feature the user has since disabled.\n\t\t\tthis._lastCurationSkipReason = undefined;\n\t\t\treturn undefined;\n\t\t}\n\t\tif (!settings.model) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_unset\";\n\t\t\treturn undefined;\n\t\t}\n\t\tconst resolved = resolveCliModel({ cliModel: settings.model, modelRegistry: this.deps.getModelRegistry() });\n\t\tif (!resolved.model || !this.deps.getModelRegistry().hasConfiguredAuth(resolved.model)) {\n\t\t\tthis._lastCurationSkipReason = \"curation_model_unresolved\";\n\t\t\treturn undefined;\n\t\t}\n\t\tconst canonicalRef = `${resolved.model.provider}/${resolved.model.id}`;\n\t\tconst fitness = FitnessStore.forAgentDir(this.deps.getAgentDir())\n\t\t\t.getForHost()\n\t\t\t.find((entry) => entry.model === canonicalRef);\n\t\tconst verdict = evaluateSurfaceFitness(\"curation\", fitness?.report);\n\t\tif (!verdict.fit) {\n\t\t\tthis._lastCurationSkipReason =\n\t\t\t\tverdict.reason === \"unprobed\" ? \"curation_model_unprobed\" : \"curation_model_digest_unfit\";\n\t\t\treturn undefined;\n\t\t}\n\t\tthis._lastCurationSkipReason = undefined;\n\t\treturn resolved.model;\n\t}\n\n\tmaybeDrainBrainCuration(): void {\n\t\ttry {\n\t\t\tif (!this._brainCurator.hasWork() || this._brainCurator.isDraining) return;\n\t\t\tconst model = this.resolveCurationModelIfFit();\n\t\t\tif (!model) return;\n\t\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tvoid this._drainBrainCuration(model, settings.maxJobsPerTurn);\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/**\n\t * Compaction pre-digest gate (design surface 3): everything the drain gate requires PLUS a\n\t * RUNTIME reliability proof — the curator must have run >=5 jobs on this session with a parse\n\t * failure rate <=5% before it is trusted to pre-digest compaction input. Returns undefined\n\t * (verbatim compaction, byte-for-byte today's behavior) whenever any gate refuses.\n\t */\n\tbuildCompactionPreDigest(): ((text: string, signal?: AbortSignal) => Promise<string>) | undefined {\n\t\ttry {\n\t\t\tconst model = this.resolveCurationModelIfFit();\n\t\t\tif (!model) return undefined;\n\t\t\tconst telemetry = this._brainCurator.telemetry();\n\t\t\tif (telemetry.jobsRun < 5 || telemetry.parseFailures / telemetry.jobsRun > 0.05) {\n\t\t\t\tthis._lastPreDigestSkipReason = \"curation_predigest_reliability_unproven\";\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\tthis._lastPreDigestSkipReason = undefined;\n\t\t\treturn async (text, signal) => {\n\t\t\t\tconst result = await preDigestConversationText({\n\t\t\t\t\ttext,\n\t\t\t\t\tsignal,\n\t\t\t\t\tcomplete: async ({ systemPrompt, userPrompt, signal: chunkSignal }) => {\n\t\t\t\t\t\tconst completion = await this.deps.runIsolatedCompletion({\n\t\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\t\tmessages: [{ role: \"user\", content: [{ type: \"text\", text: userPrompt }], timestamp: Date.now() }],\n\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\t\t\t\tmaxTokens: 512,\n\t\t\t\t\t\t\tsignal: chunkSignal,\n\t\t\t\t\t\t\tcacheRetention: \"short\",\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\ttext: completion.text,\n\t\t\t\t\t\t\tcostUsd: completion.usage.cost.total,\n\t\t\t\t\t\t\tstopReason: String(completion.stopReason),\n\t\t\t\t\t\t};\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\tif (!this.deps.isDisposed() && result.totalChunks > 0) {\n\t\t\t\t\tthis.deps.getSessionManager().appendCustomEntry(\"brain-curation-predigest\", {\n\t\t\t\t\t\tversion: 1,\n\t\t\t\t\t\ttotalChunks: result.totalChunks,\n\t\t\t\t\t\tdigested: result.digested,\n\t\t\t\t\t\tfailed: result.failed,\n\t\t\t\t\t\tcharsBefore: text.length,\n\t\t\t\t\t\tcharsAfter: result.text.length,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn result.text;\n\t\t\t};\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tprivate async _drainBrainCuration(model: Model<Api>, maxJobs: number): Promise<void> {\n\t\ttry {\n\t\t\t// ACCUMULATE across all drained jobs (the drain runs the completer once PER job) —\n\t\t\t// keeping only the last job's usage would under-report every multi-job drain.\n\t\t\tlet spentUsage: AssistantMessage[\"usage\"] | undefined;\n\t\t\tconst results = await this._brainCurator.drain({\n\t\t\t\tmaxJobs,\n\t\t\t\tcomplete: async ({ systemPrompt, userPrompt, signal }) => {\n\t\t\t\t\tconst completion = await this.deps.runIsolatedCompletion({\n\t\t\t\t\t\tsystemPrompt,\n\t\t\t\t\t\tmessages: [{ role: \"user\", content: [{ type: \"text\", text: userPrompt }], timestamp: Date.now() }],\n\t\t\t\t\t\tmodel,\n\t\t\t\t\t\tthinkingLevel: \"off\",\n\t\t\t\t\t\tmaxTokens: 256,\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t// Both curation system prompts are static — the provider can cache the prefix.\n\t\t\t\t\t\tcacheRetention: \"short\",\n\t\t\t\t\t});\n\t\t\t\t\tconst usage = completion.usage;\n\t\t\t\t\tif (!spentUsage) {\n\t\t\t\t\t\tspentUsage = structuredClone(usage);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tspentUsage.input += usage.input;\n\t\t\t\t\t\tspentUsage.output += usage.output;\n\t\t\t\t\t\tspentUsage.cacheRead += usage.cacheRead;\n\t\t\t\t\t\tspentUsage.cacheWrite += usage.cacheWrite;\n\t\t\t\t\t\tspentUsage.totalTokens += usage.totalTokens;\n\t\t\t\t\t\tspentUsage.cost.input += usage.cost.input;\n\t\t\t\t\t\tspentUsage.cost.output += usage.cost.output;\n\t\t\t\t\t\tspentUsage.cost.cacheRead += usage.cost.cacheRead;\n\t\t\t\t\t\tspentUsage.cost.cacheWrite += usage.cost.cacheWrite;\n\t\t\t\t\t\tspentUsage.cost.total += usage.cost.total;\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\ttext: completion.text,\n\t\t\t\t\t\tcostUsd: completion.usage.cost.total,\n\t\t\t\t\t\tstopReason: String(completion.stopReason),\n\t\t\t\t\t};\n\t\t\t\t},\n\t\t\t});\n\t\t\t// Honest accounting even for free local models: token visibility is the contract.\n\t\t\tif (spentUsage && (spentUsage.cost.total > 0 || spentUsage.totalTokens > 0)) {\n\t\t\t\tthis.deps.addSpawnedUsage(spentUsage, { label: \"context-curator\" });\n\t\t\t}\n\t\t\tif (this.deps.isDisposed() || results.length === 0) return;\n\t\t\tthis.deps.getSessionManager().appendCustomEntry(\"brain-curation\", {\n\t\t\t\tversion: 1,\n\t\t\t\tresults: results.map((result) => ({\n\t\t\t\t\tkey: result.key,\n\t\t\t\t\tkind: result.kind,\n\t\t\t\t\tok: result.ok,\n\t\t\t\t\tms: result.ms,\n\t\t\t\t\t...(result.digest !== undefined ? { digest: result.digest } : {}),\n\t\t\t\t\t...(result.relevant !== undefined ? { relevant: result.relevant, confidence: result.confidence } : {}),\n\t\t\t\t})),\n\t\t\t\ttelemetry: this._brainCurator.telemetry(),\n\t\t\t});\n\t\t} catch {\n\t\t\t// curation is a sidecar; it must never disrupt a turn\n\t\t}\n\t}\n\n\t/** Curation status for diagnostics/dashboard: settings, live telemetry, last refusal reason. */\n\tgetContextCurationStatus(): {\n\t\tenabled: boolean;\n\t\tmodel?: string;\n\t\ttelemetry: CurationTelemetrySnapshot;\n\t\tlastSkipReason?: string;\n\t\tlastPreDigestSkipReason?: string;\n\t} {\n\t\tconst settings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\treturn {\n\t\t\tenabled: settings.enabled,\n\t\t\tmodel: settings.model,\n\t\t\ttelemetry: this._brainCurator.telemetry(),\n\t\t\tlastSkipReason: this._lastCurationSkipReason,\n\t\t\tlastPreDigestSkipReason: this._lastPreDigestSkipReason,\n\t\t};\n\t}\n\n\t/** Read-only inspection of the latest prompt-enforcement report, for tests/debugging. */\n\tgetPromptEnforcementReport(): PromptEnforcementReport {\n\t\treturn this._latestPromptEnforcementReport ?? { turnIndex: this.deps.getTurnIndex(), items: [] };\n\t}\n\n\tapplyContextGc(\n\t\tmessages: AgentMessage[],\n\t\twritePayloads: boolean,\n\t): { messages: AgentMessage[]; report: ContextGcReport } {\n\t\ttry {\n\t\t\tconst settings = this.deps.getSettingsManager().getContextGcSettings();\n\t\t\t// Merge the ACTIVE memory providers' own page markers (e.g. transcript-recall's\n\t\t\t// \"<memory_context\") into the semantic-memory marker list. The settings default is\n\t\t\t// provider-agnostic and non-empty, so without this merge the recall pages the bundled\n\t\t\t// default provider actually emits are never recognized as semantic-memory pages and\n\t\t\t// accumulate raw for the life of the session — the exact growth Bug #7 GC exists to stop.\n\t\t\tconst providerMarkers = this.deps.getMemoryManager().getContextMarkers();\n\t\t\tconst curationSettings = this.deps.getSettingsManager().getContextCurationSettings();\n\t\t\tconst result = applyContextGc(messages, {\n\t\t\t\t...settings,\n\t\t\t\tsemanticMemory: {\n\t\t\t\t\t...settings.semanticMemory,\n\t\t\t\t\tmarkers: [...new Set([...settings.semanticMemory.markers, ...providerMarkers])],\n\t\t\t\t},\n\t\t\t\tcwd: this.deps.getCwd(),\n\t\t\t\tstorageDir: this._contextGcStorageDir(),\n\t\t\t\twritePayloads,\n\t\t\t\tcuration: curationSettings.enabled\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tresolveDigest: (digestKey) => {\n\t\t\t\t\t\t\t\tconst digest = this._brainCurator.getDigest(digestKey);\n\t\t\t\t\t\t\t\t// Count serves on the REAL per-turn pass only, never the report path.\n\t\t\t\t\t\t\t\tif (digest !== undefined && writePayloads) this._brainCurator.noteDigestServed();\n\t\t\t\t\t\t\t\treturn digest;\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t// Only the real per-turn pass enqueues work; the read-only report path\n\t\t\t\t\t\t\t// (writePayloads=false) stays side-effect free.\n\t\t\t\t\t\t\tonPacked: writePayloads\n\t\t\t\t\t\t\t\t? (record, originalText) => {\n\t\t\t\t\t\t\t\t\t\tthis._brainCurator.enqueue({\n\t\t\t\t\t\t\t\t\t\t\tkind: \"stub_digest\",\n\t\t\t\t\t\t\t\t\t\t\tkey: record.key ?? record.toolCallId,\n\t\t\t\t\t\t\t\t\t\t\tcontent: originalText,\n\t\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t: undefined,\n\t\t\t\t\t\t}\n\t\t\t\t\t: undefined,\n\t\t\t});\n\t\t\tthis._latestContextGcReport = result.report;\n\t\t\t// Only release/reclaim on the real per-turn pass (writePayloads=true), never on\n\t\t\t// the read-only status-report path (getContextGcReport with writePayloads=false),\n\t\t\t// so merely inspecting the report can't have side effects.\n\t\t\tif (writePayloads && result.report.packedCount > 0) {\n\t\t\t\tthis._releaseGcPackedArtifactReferences(messages, result.report);\n\t\t\t}\n\t\t\treturn result;\n\t\t} catch {\n\t\t\tconst report: ContextGcReport = {\n\t\t\t\tenabled: false,\n\t\t\t\tpackedCount: 0,\n\t\t\t\toriginalTokens: 0,\n\t\t\t\tpackedTokens: 0,\n\t\t\t\tsavedTokens: 0,\n\t\t\t\trecords: [],\n\t\t\t};\n\t\t\tthis._latestContextGcReport = report;\n\t\t\treturn { messages, report };\n\t\t}\n\t}\n\n\t/**\n\t * Reference-release + cleanup lifecycle: once context-gc has packed a grep/find tool\n\t * result out of the live prompt (the message is no longer current/active working\n\t * context -- see contracts-and-retention.md's \"ephemeral\"/\"expired\" retention\n\t * classes), release the pack-time reference `packToolOutput()` registered for it, and\n\t * opportunistically reclaim now-unreferenced artifacts. This is the other half of the\n\t * D2b-1 gate: artifacts were being registered but never released, so they accumulated\n\t * for the life of the session.\n\t *\n\t * `record.toolCallId` (from context-gc's packed record) is exactly the holder id\n\t * `packToolOutput()` used when it called `addReference()` -- both trace back to the\n\t * same tool call's id -- so no separate bookkeeping is needed to find it.\n\t */\n\tprivate _releaseGcPackedArtifactReferences(messages: AgentMessage[], report: ContextGcReport): void {\n\t\tconst store = this._toolArtifactStore;\n\t\tif (!store) return; // no store was ever constructed, so nothing could have been packed to one\n\n\t\tlet releasedAny = false;\n\t\tfor (const record of report.records) {\n\t\t\tif (record.toolName !== \"grep\" && record.toolName !== \"find\") continue;\n\t\t\tconst artifactId = extractArtifactId(messages[record.messageIndex]);\n\t\t\tif (!artifactId) continue;\n\t\t\tif (store.removeReference(artifactId, record.toolCallId)) releasedAny = true;\n\t\t}\n\t\t// Cleanup only runs immediately after a release actually happened in this pass, so\n\t\t// a long session doesn't re-scan the artifact directory on every turn once nothing\n\t\t// new became eligible for release.\n\t\tif (releasedAny) store.cleanup();\n\t}\n\n\tgetContextGcReport(messages?: AgentMessage[]): ContextGcReport {\n\t\tif (messages) return this.applyContextGc(messages, false).report;\n\t\treturn (\n\t\t\tthis._latestContextGcReport ?? {\n\t\t\t\tenabled: this.deps.getSettingsManager().getContextGcSettings().enabled,\n\t\t\t\tpackedCount: 0,\n\t\t\t\toriginalTokens: 0,\n\t\t\t\tpackedTokens: 0,\n\t\t\t\tsavedTokens: 0,\n\t\t\t\trecords: [],\n\t\t\t}\n\t\t);\n\t}\n\n\testimateCurrentContextTokens(messages: AgentMessage[]): number {\n\t\tconst estimate = estimateContextTokens(messages);\n\t\tif (estimate.lastUsageIndex === null) {\n\t\t\treturn this._tokenBudget.estimateDelta(estimateConversationChars(messages));\n\t\t}\n\n\t\tconst usageMessage = messages[estimate.lastUsageIndex];\n\t\tconst compactionEntry = getLatestCompactionEntry(this.deps.getSessionManager().getBranch());\n\t\tif (usageMessage?.role !== \"assistant\" || !compactionEntry) {\n\t\t\treturn estimate.tokens;\n\t\t}\n\t\tconst usageTimestamp = (usageMessage as AssistantMessage).timestamp;\n\t\tconst compactionTimestamp = new Date(compactionEntry.timestamp).getTime();\n\t\tif (usageTimestamp <= compactionTimestamp) {\n\t\t\treturn this._tokenBudget.estimateDelta(estimateConversationChars(messages));\n\t\t}\n\n\t\tconst coveredChars = estimateConversationChars(messages, 0, estimate.lastUsageIndex + 1);\n\t\tconst deltaChars = estimateConversationChars(messages, estimate.lastUsageIndex + 1, messages.length);\n\t\tthis._tokenBudget.anchor(estimate.usageTokens, coveredChars);\n\t\treturn this._tokenBudget.current(deltaChars, estimate.tokens);\n\t}\n}\n\n/**\n * Estimate covered characters from the provider-visible context messages.\n */\nfunction estimateConversationChars(messages: AgentMessage[], start = 0, end = messages.length): number {\n\tlet total = 0;\n\tfor (let index = Math.max(0, start); index < Math.min(messages.length, end); index++) {\n\t\tconst message = messages[index];\n\t\tif (!message) continue;\n\t\tconst rawContent = (message as { content?: unknown }).content;\n\t\tif (typeof rawContent === \"string\") {\n\t\t\ttotal += rawContent.length;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!Array.isArray(rawContent)) {\n\t\t\tcontinue;\n\t\t}\n\t\tfor (const block of rawContent) {\n\t\t\tif (typeof block === \"string\") {\n\t\t\t\ttotal += block.length;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!block || typeof block !== \"object\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (typeof (block as { text?: unknown }).text === \"string\") {\n\t\t\t\ttotal += (block as { text?: string }).text?.length ?? 0;\n\t\t\t}\n\t\t\tif (typeof (block as { thinking?: unknown }).thinking === \"string\") {\n\t\t\t\ttotal += (block as { thinking?: string }).thinking?.length ?? 0;\n\t\t\t}\n\t\t}\n\t}\n\treturn total;\n}\n"]}
@@ -23,7 +23,7 @@
23
23
  * the pipeline — keeping the transform the one place the two subsystems meet.
24
24
  */
25
25
  import { join } from "node:path";
26
- import { estimateContextTokens, getLatestCompactionEntry } from "@caupulican/pi-agent-core/node";
26
+ import { estimateContextTokens, getLatestCompactionEntry, TokenBudget, } from "@caupulican/pi-agent-core/node";
27
27
  import { BrainCurator, preDigestConversationText } from "./context/brain-curator.js";
28
28
  import { createFileArtifactStore } from "./context/context-artifacts.js";
29
29
  import { runContextAudit } from "./context/context-audit.js";
@@ -31,6 +31,7 @@ import { enforcePromptPolicy } from "./context/context-prompt-enforcement.js";
31
31
  import { correlateWithContextGc, planPromptPolicy, } from "./context/context-prompt-policy.js";
32
32
  import { applyContextGc } from "./context-gc.js";
33
33
  import { resolveCliModel } from "./model-resolver.js";
34
+ import { evaluateSurfaceFitness } from "./model-router/fitness-gate.js";
34
35
  import { FitnessStore } from "./models/fitness-store.js";
35
36
  /** Latest user prompt text in the provider-visible array (curation goal line; bounded by caller). */
36
37
  export function latestUserPromptText(messages) {
@@ -71,6 +72,7 @@ export class ContextPipeline {
71
72
  _latestPromptPolicyReport = undefined;
72
73
  _latestPromptPolicyGcCorrelation = undefined;
73
74
  _latestPromptEnforcementReport = undefined;
75
+ _tokenBudget = new TokenBudget();
74
76
  deps;
75
77
  constructor(deps) {
76
78
  this.deps = deps;
@@ -304,13 +306,10 @@ export class ContextPipeline {
304
306
  const fitness = FitnessStore.forAgentDir(this.deps.getAgentDir())
305
307
  .getForHost()
306
308
  .find((entry) => entry.model === canonicalRef);
307
- const digestScore = fitness?.report.digest;
308
- if (!digestScore) {
309
- this._lastCurationSkipReason = "curation_model_unprobed";
310
- return undefined;
311
- }
312
- if (digestScore.succeeded < Math.ceil(digestScore.total * (2 / 3))) {
313
- this._lastCurationSkipReason = "curation_model_digest_unfit";
309
+ const verdict = evaluateSurfaceFitness("curation", fitness?.report);
310
+ if (!verdict.fit) {
311
+ this._lastCurationSkipReason =
312
+ verdict.reason === "unprobed" ? "curation_model_unprobed" : "curation_model_digest_unfit";
314
313
  return undefined;
315
314
  }
316
315
  this._lastCurationSkipReason = undefined;
@@ -575,20 +574,58 @@ export class ContextPipeline {
575
574
  }
576
575
  estimateCurrentContextTokens(messages) {
577
576
  const estimate = estimateContextTokens(messages);
578
- const compactionEntry = getLatestCompactionEntry(this.deps.getSessionManager().getBranch());
579
- if (estimate.lastUsageIndex === null || !compactionEntry) {
580
- return estimate.tokens;
577
+ if (estimate.lastUsageIndex === null) {
578
+ return this._tokenBudget.estimateDelta(estimateConversationChars(messages));
581
579
  }
582
580
  const usageMessage = messages[estimate.lastUsageIndex];
583
- if (usageMessage?.role !== "assistant") {
581
+ const compactionEntry = getLatestCompactionEntry(this.deps.getSessionManager().getBranch());
582
+ if (usageMessage?.role !== "assistant" || !compactionEntry) {
584
583
  return estimate.tokens;
585
584
  }
586
585
  const usageTimestamp = usageMessage.timestamp;
587
586
  const compactionTimestamp = new Date(compactionEntry.timestamp).getTime();
588
587
  if (usageTimestamp <= compactionTimestamp) {
589
- return estimate.trailingTokens;
588
+ return this._tokenBudget.estimateDelta(estimateConversationChars(messages));
589
+ }
590
+ const coveredChars = estimateConversationChars(messages, 0, estimate.lastUsageIndex + 1);
591
+ const deltaChars = estimateConversationChars(messages, estimate.lastUsageIndex + 1, messages.length);
592
+ this._tokenBudget.anchor(estimate.usageTokens, coveredChars);
593
+ return this._tokenBudget.current(deltaChars, estimate.tokens);
594
+ }
595
+ }
596
+ /**
597
+ * Estimate covered characters from the provider-visible context messages.
598
+ */
599
+ function estimateConversationChars(messages, start = 0, end = messages.length) {
600
+ let total = 0;
601
+ for (let index = Math.max(0, start); index < Math.min(messages.length, end); index++) {
602
+ const message = messages[index];
603
+ if (!message)
604
+ continue;
605
+ const rawContent = message.content;
606
+ if (typeof rawContent === "string") {
607
+ total += rawContent.length;
608
+ continue;
609
+ }
610
+ if (!Array.isArray(rawContent)) {
611
+ continue;
612
+ }
613
+ for (const block of rawContent) {
614
+ if (typeof block === "string") {
615
+ total += block.length;
616
+ continue;
617
+ }
618
+ if (!block || typeof block !== "object") {
619
+ continue;
620
+ }
621
+ if (typeof block.text === "string") {
622
+ total += block.text?.length ?? 0;
623
+ }
624
+ if (typeof block.thinking === "string") {
625
+ total += block.thinking?.length ?? 0;
626
+ }
590
627
  }
591
- return estimate.tokens;
592
628
  }
629
+ return total;
593
630
  }
594
631
  //# sourceMappingURL=context-pipeline.js.map