@graphorin/memory 0.6.0 → 0.7.0

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 (227) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +24 -7
  3. package/dist/conflict/index.js +0 -1
  4. package/dist/conflict/pipeline.js +0 -1
  5. package/dist/conflict/pipeline.js.map +1 -1
  6. package/dist/conflict/stages/stage3-heuristic-regex.js +0 -1
  7. package/dist/conflict/stages/stage3-heuristic-regex.js.map +1 -1
  8. package/dist/conflict/types.d.ts +1 -1
  9. package/dist/conflict/types.js.map +1 -1
  10. package/dist/consolidator/index.js +2 -0
  11. package/dist/consolidator/index.js.map +1 -1
  12. package/dist/consolidator/lock.js +7 -1
  13. package/dist/consolidator/lock.js.map +1 -1
  14. package/dist/consolidator/phases/deep.js +19 -12
  15. package/dist/consolidator/phases/deep.js.map +1 -1
  16. package/dist/consolidator/phases/induce.d.ts.map +1 -1
  17. package/dist/consolidator/phases/induce.js +2 -3
  18. package/dist/consolidator/phases/induce.js.map +1 -1
  19. package/dist/consolidator/phases/reflect.js +35 -8
  20. package/dist/consolidator/phases/reflect.js.map +1 -1
  21. package/dist/consolidator/phases/standard.js +199 -28
  22. package/dist/consolidator/phases/standard.js.map +1 -1
  23. package/dist/consolidator/reconcile.js +16 -15
  24. package/dist/consolidator/reconcile.js.map +1 -1
  25. package/dist/consolidator/runtime.d.ts.map +1 -1
  26. package/dist/consolidator/runtime.js +129 -47
  27. package/dist/consolidator/runtime.js.map +1 -1
  28. package/dist/consolidator/scheduler.d.ts +17 -3
  29. package/dist/consolidator/scheduler.d.ts.map +1 -1
  30. package/dist/consolidator/scheduler.js.map +1 -1
  31. package/dist/consolidator/types.d.ts +30 -1
  32. package/dist/consolidator/types.d.ts.map +1 -1
  33. package/dist/consolidator/types.js +10 -0
  34. package/dist/consolidator/types.js.map +1 -1
  35. package/dist/context-engine/compaction/clear-tool-results.d.ts +1 -1
  36. package/dist/context-engine/compaction/clear-tool-results.js +1 -1
  37. package/dist/context-engine/compaction/clear-tool-results.js.map +1 -1
  38. package/dist/context-engine/compaction/compactor.d.ts +1 -0
  39. package/dist/context-engine/compaction/compactor.d.ts.map +1 -1
  40. package/dist/context-engine/compaction/compactor.js +32 -8
  41. package/dist/context-engine/compaction/compactor.js.map +1 -1
  42. package/dist/context-engine/compaction/hooks/types.d.ts +1 -1
  43. package/dist/context-engine/compaction/index.d.ts +1 -0
  44. package/dist/context-engine/compaction/templates/marker.d.ts +33 -0
  45. package/dist/context-engine/compaction/templates/marker.d.ts.map +1 -0
  46. package/dist/context-engine/compaction/templates/marker.js +34 -0
  47. package/dist/context-engine/compaction/templates/marker.js.map +1 -0
  48. package/dist/context-engine/compaction/templates/summary-9-section.d.ts +1 -1
  49. package/dist/context-engine/compaction/templates/summary-9-section.d.ts.map +1 -1
  50. package/dist/context-engine/compaction/templates/summary-9-section.js +4 -3
  51. package/dist/context-engine/compaction/templates/summary-9-section.js.map +1 -1
  52. package/dist/context-engine/engine.d.ts +7 -176
  53. package/dist/context-engine/engine.d.ts.map +1 -1
  54. package/dist/context-engine/engine.js +0 -2
  55. package/dist/context-engine/engine.js.map +1 -1
  56. package/dist/context-engine/index.d.ts +8 -49
  57. package/dist/context-engine/io-types.d.ts +144 -0
  58. package/dist/context-engine/io-types.d.ts.map +1 -0
  59. package/dist/context-engine/locale-packs/types.d.ts +1 -1
  60. package/dist/context-engine/locale-packs/types.js.map +1 -1
  61. package/dist/context-engine/privacy-filter.d.ts +1 -2
  62. package/dist/context-engine/privacy-filter.d.ts.map +1 -1
  63. package/dist/context-engine/privacy-filter.js +1 -2
  64. package/dist/context-engine/privacy-filter.js.map +1 -1
  65. package/dist/errors/index.d.ts +2 -2
  66. package/dist/errors/index.js +2 -2
  67. package/dist/errors/index.js.map +1 -1
  68. package/dist/facade.d.ts +43 -52
  69. package/dist/facade.d.ts.map +1 -1
  70. package/dist/facade.js +5 -3
  71. package/dist/facade.js.map +1 -1
  72. package/dist/graph/entity-resolver.js +1 -1
  73. package/dist/graph/entity-resolver.js.map +1 -1
  74. package/dist/index.d.ts +17 -15
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -7
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal/envelope.js +57 -0
  79. package/dist/internal/envelope.js.map +1 -0
  80. package/dist/internal/injection-heuristics.js +0 -0
  81. package/dist/internal/injection-heuristics.js.map +1 -1
  82. package/dist/internal/llm-json.js +40 -0
  83. package/dist/internal/llm-json.js.map +1 -0
  84. package/dist/internal/storage-adapter.d.ts +23 -10
  85. package/dist/internal/storage-adapter.d.ts.map +1 -1
  86. package/dist/memory-interface.d.ts +131 -0
  87. package/dist/memory-interface.d.ts.map +1 -0
  88. package/dist/package.js +6 -0
  89. package/dist/package.js.map +1 -0
  90. package/dist/search/iterative.d.ts.map +1 -1
  91. package/dist/search/iterative.js +2 -3
  92. package/dist/search/iterative.js.map +1 -1
  93. package/dist/search/query-transform.d.ts.map +1 -1
  94. package/dist/search/query-transform.js +2 -3
  95. package/dist/search/query-transform.js.map +1 -1
  96. package/dist/tiers/episodic-memory.js +3 -3
  97. package/dist/tiers/episodic-memory.js.map +1 -1
  98. package/dist/tiers/index.d.ts +2 -2
  99. package/dist/tiers/insight-memory.js +1 -1
  100. package/dist/tiers/insight-memory.js.map +1 -1
  101. package/dist/tiers/procedural-memory.d.ts +1 -1
  102. package/dist/tiers/procedural-memory.js +2 -2
  103. package/dist/tiers/procedural-memory.js.map +1 -1
  104. package/dist/tiers/semantic-memory.d.ts +58 -4
  105. package/dist/tiers/semantic-memory.d.ts.map +1 -1
  106. package/dist/tiers/semantic-memory.js +103 -20
  107. package/dist/tiers/semantic-memory.js.map +1 -1
  108. package/dist/tools/block-tools.d.ts +19 -57
  109. package/dist/tools/block-tools.d.ts.map +1 -1
  110. package/dist/tools/block-tools.js.map +1 -1
  111. package/dist/tools/fact-tools.d.ts +53 -214
  112. package/dist/tools/fact-tools.d.ts.map +1 -1
  113. package/dist/tools/fact-tools.js +3 -2
  114. package/dist/tools/fact-tools.js.map +1 -1
  115. package/dist/tools/recall-tools.d.ts +34 -175
  116. package/dist/tools/recall-tools.d.ts.map +1 -1
  117. package/dist/tools/recall-tools.js +5 -4
  118. package/dist/tools/recall-tools.js.map +1 -1
  119. package/dist/tools/runbook-tools.d.ts +15 -58
  120. package/dist/tools/runbook-tools.d.ts.map +1 -1
  121. package/dist/tools/runbook-tools.js.map +1 -1
  122. package/dist/tools/types.d.ts +1 -1
  123. package/package.json +18 -17
  124. package/src/conflict/index.ts +29 -0
  125. package/src/conflict/locale-packs/en.ts +142 -0
  126. package/src/conflict/locale-packs/index.ts +17 -0
  127. package/src/conflict/locale-packs/types.ts +138 -0
  128. package/src/conflict/pipeline.ts +371 -0
  129. package/src/conflict/stages/helpers.ts +65 -0
  130. package/src/conflict/stages/stage1-exact-dedup.ts +40 -0
  131. package/src/conflict/stages/stage2-embedding-three-zone.ts +54 -0
  132. package/src/conflict/stages/stage3-heuristic-regex.ts +51 -0
  133. package/src/conflict/stages/stage4-subject-predicate.ts +46 -0
  134. package/src/conflict/stages/stage5-defer-to-deep.ts +34 -0
  135. package/src/conflict/types.ts +263 -0
  136. package/src/consolidator/budget.ts +312 -0
  137. package/src/consolidator/decay.ts +246 -0
  138. package/src/consolidator/dlq.ts +61 -0
  139. package/src/consolidator/errors.ts +87 -0
  140. package/src/consolidator/idempotency.ts +27 -0
  141. package/src/consolidator/index.ts +269 -0
  142. package/src/consolidator/lock.ts +141 -0
  143. package/src/consolidator/noise-filter.ts +235 -0
  144. package/src/consolidator/phases/deep.ts +308 -0
  145. package/src/consolidator/phases/induce.ts +423 -0
  146. package/src/consolidator/phases/learned-context.ts +233 -0
  147. package/src/consolidator/phases/light.ts +166 -0
  148. package/src/consolidator/phases/reflect.ts +504 -0
  149. package/src/consolidator/phases/standard.ts +1002 -0
  150. package/src/consolidator/reconcile.ts +273 -0
  151. package/src/consolidator/runtime.ts +1049 -0
  152. package/src/consolidator/scheduler.ts +182 -0
  153. package/src/consolidator/triggers.ts +134 -0
  154. package/src/consolidator/types.ts +622 -0
  155. package/src/context-engine/annotations.ts +161 -0
  156. package/src/context-engine/auto-recall.ts +92 -0
  157. package/src/context-engine/compaction/clear-tool-results.ts +232 -0
  158. package/src/context-engine/compaction/compactor.ts +403 -0
  159. package/src/context-engine/compaction/hooks/reanchor-persona-block.ts +55 -0
  160. package/src/context-engine/compaction/hooks/reanchor-pinned-facts.ts +66 -0
  161. package/src/context-engine/compaction/hooks/reanchor-project-rules.ts +57 -0
  162. package/src/context-engine/compaction/hooks/reanchor-recent-results.ts +116 -0
  163. package/src/context-engine/compaction/hooks/types.ts +68 -0
  164. package/src/context-engine/compaction/index.ts +60 -0
  165. package/src/context-engine/compaction/templates/marker.ts +33 -0
  166. package/src/context-engine/compaction/templates/summary-9-section.ts +223 -0
  167. package/src/context-engine/compaction/thresholds.ts +107 -0
  168. package/src/context-engine/compaction/types.ts +255 -0
  169. package/src/context-engine/engine.ts +970 -0
  170. package/src/context-engine/index.ts +118 -0
  171. package/src/context-engine/io-types.ts +162 -0
  172. package/src/context-engine/locale-packs/en.ts +107 -0
  173. package/src/context-engine/locale-packs/index.ts +19 -0
  174. package/src/context-engine/locale-packs/resolver.ts +197 -0
  175. package/src/context-engine/locale-packs/types.ts +177 -0
  176. package/src/context-engine/metadata.ts +114 -0
  177. package/src/context-engine/preambles/inbound-en.ts +19 -0
  178. package/src/context-engine/privacy-filter.ts +190 -0
  179. package/src/context-engine/templates/base-en.ts +19 -0
  180. package/src/context-engine/templates/composer.ts +91 -0
  181. package/src/context-engine/token-budget.ts +319 -0
  182. package/src/context-engine/token-counter.ts +157 -0
  183. package/src/errors/index.ts +273 -0
  184. package/src/facade.ts +750 -0
  185. package/src/graph/entity-resolver.ts +364 -0
  186. package/src/graph/index.ts +24 -0
  187. package/src/index.ts +259 -0
  188. package/src/internal/contextualize.ts +173 -0
  189. package/src/internal/embedder-binding.ts +53 -0
  190. package/src/internal/envelope.ts +70 -0
  191. package/src/internal/id.ts +23 -0
  192. package/src/internal/injection-heuristics.ts +0 -0
  193. package/src/internal/llm-json.ts +38 -0
  194. package/src/internal/spans.ts +35 -0
  195. package/src/internal/storage-adapter.ts +963 -0
  196. package/src/memory-interface.ts +158 -0
  197. package/src/migration/embedder-migration.ts +237 -0
  198. package/src/migration/index.ts +13 -0
  199. package/src/search/explain.ts +123 -0
  200. package/src/search/fit-weights.ts +109 -0
  201. package/src/search/graph-ppr.ts +38 -0
  202. package/src/search/index.ts +55 -0
  203. package/src/search/iterative.ts +658 -0
  204. package/src/search/query-transform.ts +285 -0
  205. package/src/search/rrf.ts +225 -0
  206. package/src/search/trust.ts +55 -0
  207. package/src/search/types.ts +50 -0
  208. package/src/tiers/episodic-memory.ts +463 -0
  209. package/src/tiers/index.ts +50 -0
  210. package/src/tiers/insight-memory.ts +208 -0
  211. package/src/tiers/procedural-memory.ts +482 -0
  212. package/src/tiers/semantic-memory.ts +1729 -0
  213. package/src/tiers/session-memory.ts +271 -0
  214. package/src/tiers/shared-memory.ts +62 -0
  215. package/src/tiers/working-memory.ts +368 -0
  216. package/src/tools/block-tools.ts +172 -0
  217. package/src/tools/fact-tools.ts +473 -0
  218. package/src/tools/index.ts +109 -0
  219. package/src/tools/recall-tools.ts +303 -0
  220. package/src/tools/runbook-tools.ts +107 -0
  221. package/src/tools/taint.ts +39 -0
  222. package/src/tools/types.ts +37 -0
  223. package/dist/conflict/locale-packs/index.js +0 -4
  224. package/dist/context-engine/compaction/index.js +0 -10
  225. package/dist/context-engine/index.d.ts.map +0 -1
  226. package/dist/context-engine/index.js +0 -25
  227. package/dist/context-engine/locale-packs/index.js +0 -4
@@ -0,0 +1,970 @@
1
+ /**
2
+ * `ContextEngine` - the layered six-layer system prompt assembler.
3
+ *
4
+ * Combines:
5
+ *
6
+ * - Layer 1 (`graphorin_memory_base`) educational template
7
+ * - Layer 2 user-defined `agent_instructions`
8
+ * - Layer 3 working-memory blocks (filtered through D2 privacy)
9
+ * - Layer 4 procedural rules + skill metadata cards
10
+ * - Layer 5 memory-metadata counters
11
+ * - Layer 6 opt-in auto-recall
12
+ *
13
+ * Cross-cuts:
14
+ *
15
+ * - D2 privacy filter (sensitivity-tier drop)
16
+ * - D3 cooperation contract (`ContentOrigin` annotation)
17
+ * - D4 inbound preamble injection (post cache-breakpoint)
18
+ * - RB-44 per-step tool-catalogue cardinality allocator (separate axis)
19
+ * - RB-46 in-flight session message-history compaction (separate axis)
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import type {
25
+ Fact,
26
+ LocalProviderTrust,
27
+ Message,
28
+ MessageContent,
29
+ Sensitivity,
30
+ SessionScope,
31
+ TokenCounter,
32
+ } from '@graphorin/core';
33
+ import type { ContextEngine, Memory } from '../memory-interface.js';
34
+ import { annotate, type ContentAnnotation, shouldFireInboundPreamble } from './annotations.js';
35
+ import {
36
+ type AutoRecallStrategy,
37
+ type AutoRecallTriggerResult,
38
+ defaultLocaleHeuristicStrategy,
39
+ } from './auto-recall.js';
40
+ import {
41
+ DEFAULT_PRESERVE_RECENT_TURNS,
42
+ type ExecuteCompactionInput,
43
+ executeCompaction,
44
+ } from './compaction/compactor.js';
45
+ import type { NamedPostCompactionHook } from './compaction/hooks/types.js';
46
+ import {
47
+ reanchorPersonaBlock,
48
+ reanchorPinnedFacts,
49
+ reanchorProjectRules,
50
+ } from './compaction/index.js';
51
+ import { resolveAutoCompactionDefault, resolveTriggerThreshold } from './compaction/thresholds.js';
52
+ import type {
53
+ CompactionConfig,
54
+ CompactionResult,
55
+ CompactionSource,
56
+ CompactionStrategy,
57
+ CompactionSummarizer,
58
+ PostCompactionHook,
59
+ } from './compaction/types.js';
60
+ import type {
61
+ AnnotatedPart,
62
+ AssembledPrompt,
63
+ AssembleInput,
64
+ ResolvedContextEngineConfig,
65
+ } from './io-types.js';
66
+ import {
67
+ type ContextLocalePack,
68
+ enLocalePack,
69
+ type PartialContextLocalePack,
70
+ } from './locale-packs/index.js';
71
+ import { resolveLocalePack } from './locale-packs/resolver.js';
72
+ import { renderMetadataBlock } from './metadata.js';
73
+ import { type PrivacyDecisionReason, decide as privacyDecide } from './privacy-filter.js';
74
+ import {
75
+ composeInboundPreamble,
76
+ composeLayer1,
77
+ composeLayer2,
78
+ composeLayer4Skills,
79
+ type MemoryBaseMode,
80
+ } from './templates/composer.js';
81
+ import {
82
+ allocate as allocateLayers,
83
+ type LayerAllocation,
84
+ type LayerCandidate,
85
+ } from './token-budget.js';
86
+ import {
87
+ adaptTokenCounter,
88
+ type ContextTokenCounter,
89
+ countMessageTokens,
90
+ HEURISTIC_TOKEN_COUNTER,
91
+ } from './token-counter.js';
92
+
93
+ /**
94
+ * Per-layer cap configuration. Mirrors the documented
95
+ * `ContextEngineConfig.layers.*` fields.
96
+ *
97
+ * @stable
98
+ */
99
+ export interface LayerConfig {
100
+ readonly enabled?: boolean;
101
+ readonly cap?: number;
102
+ }
103
+
104
+ /**
105
+ * Auto-recall config knob. `false` disables; `{ topK }` enables
106
+ * the heuristic with a bounded top-K.
107
+ *
108
+ * @stable
109
+ */
110
+ export type AutoRecallConfig =
111
+ | false
112
+ | {
113
+ readonly topK?: number;
114
+ /**
115
+ * Minimum fused score a hit must reach to be injected. **Default `0`**
116
+ * (CE-4) - `topK` already bounds the volume. The scale is
117
+ * reranker-dependent: the default RRF reranker fuses the FTS + vector
118
+ * candidate lists as `1/(60 + rank)` per list, so scores top out near
119
+ * `2/(60 + 1) ≈ 0.033` - any positive default would silently drop every
120
+ * hit. Set this only when calibrating against a known reranker's scale.
121
+ */
122
+ readonly threshold?: number;
123
+ readonly strategy?: AutoRecallStrategy;
124
+ };
125
+
126
+ /**
127
+ * Privacy block of the engine config.
128
+ *
129
+ * @stable
130
+ */
131
+ export interface PrivacyConfig {
132
+ readonly cloudUploadConsent?: boolean;
133
+ readonly defaultSensitivity?: Sensitivity;
134
+ readonly providerAcceptsSensitivity?: ReadonlyArray<Sensitivity>;
135
+ readonly providerTrust?: LocalProviderTrust;
136
+ }
137
+
138
+ /**
139
+ * Configuration accepted by {@link createContextEngine}.
140
+ *
141
+ * @stable
142
+ */
143
+ export interface ContextEngineConfig {
144
+ /**
145
+ * Layer 1 base-template mode. `'full'` (default) ships the
146
+ * verbose ~250-350 token narrative aimed at general LLMs;
147
+ * `'minimal'` opts top-tier models into the ~80-120 token
148
+ * compact form.
149
+ */
150
+ readonly memoryBaseMode?: MemoryBaseMode;
151
+ /** Default `'en'`. Pluggable via `defineContextLocalePack`. */
152
+ readonly locale?: string | ContextLocalePack | PartialContextLocalePack;
153
+ /** Per-layer enable / cap overrides. */
154
+ readonly layers?: {
155
+ readonly identity?: LayerConfig;
156
+ readonly memoryMetadata?: LayerConfig;
157
+ readonly activeRules?: LayerConfig;
158
+ readonly activeSkills?: LayerConfig;
159
+ readonly workingBlocks?: LayerConfig;
160
+ readonly autoRecall?: LayerConfig & { readonly topK?: number; readonly threshold?: number };
161
+ };
162
+ /** Auto-recall trigger configuration. Default `false`. */
163
+ readonly factsAutoRecall?: AutoRecallConfig;
164
+ /** Privacy-filter configuration. */
165
+ readonly privacy?: PrivacyConfig;
166
+ /** Hard token budget. Default `Number.POSITIVE_INFINITY` (no global cap). */
167
+ readonly maxContextTokens?: number;
168
+ /** Tokens reserved for the model's response. Default `4096`. */
169
+ readonly reservedForResponse?: number;
170
+ /** Tokens reserved for the compaction summarizer call. Default `8192`. */
171
+ readonly reservedForCompaction?: number;
172
+ /** Pluggable token counter. Default heuristic (chars/4). */
173
+ readonly tokenCounter?: TokenCounter | ContextTokenCounter;
174
+ /** Auto-compaction configuration (RB-46). */
175
+ readonly compaction?: false | CompactionConfig;
176
+ /** Active provider's context window; required when compaction is enabled. */
177
+ readonly providerContextWindow?: number;
178
+ /** Default summarizer adapter the auto-trigger uses. */
179
+ readonly summarizer?: CompactionSummarizer;
180
+ /** Wall clock for tests + deterministic compaction. */
181
+ readonly now?: () => number;
182
+ }
183
+
184
+ // The IO shapes (AssembleInput / AnnotatedPart / AssembledPrompt /
185
+ // ResolvedContextEngineConfig) live in the io-types leaf and the
186
+ // ContextEngine interface itself is co-located with `Memory` in
187
+ // memory-interface.ts (issue #22 - the pair is mutually recursive).
188
+ // Both are re-exported here so every existing import path keeps
189
+ // working unchanged.
190
+ export type { ContextEngine } from '../memory-interface.js';
191
+ export type {
192
+ AnnotatedPart,
193
+ AssembledPrompt,
194
+ AssembleInput,
195
+ ResolvedContextEngineConfig,
196
+ } from './io-types.js';
197
+
198
+ const DEFAULT_LAYER_CAPS: Readonly<
199
+ Record<keyof Required<NonNullable<ContextEngineConfig['layers']>>, number | undefined>
200
+ > = {
201
+ identity: undefined,
202
+ memoryMetadata: undefined,
203
+ activeRules: undefined,
204
+ activeSkills: undefined,
205
+ workingBlocks: undefined,
206
+ autoRecall: undefined,
207
+ };
208
+
209
+ let compactionIneffectiveWarned = false;
210
+ let heuristicCounterWarned = false;
211
+
212
+ /** Emit the "compaction enabled but ineffective" warning at most once (CE-12). */
213
+ function warnCompactionIneffective(message: string): void {
214
+ if (compactionIneffectiveWarned) return;
215
+ compactionIneffectiveWarned = true;
216
+ process.stderr.write(`[graphorin/memory] ${message}\n`);
217
+ }
218
+
219
+ /**
220
+ * Test-only - reset the one-time CE-12 compaction warning so a test can assert
221
+ * it fires.
222
+ *
223
+ * @internal
224
+ */
225
+ export function _resetCompactionWarningForTesting(): void {
226
+ compactionIneffectiveWarned = false;
227
+ }
228
+
229
+ /** @internal - test seam for the one-time heuristic-counter warning (CE-13). */
230
+ export function _resetHeuristicCounterWarningForTesting(): void {
231
+ heuristicCounterWarned = false;
232
+ }
233
+
234
+ /**
235
+ * Emission order for the assembled layers - deliberately **distinct from the
236
+ * truncation priority ladder** (CE-9). Allocation still trims by
237
+ * `DEFAULT_LAYER_PRIORITY` (lowest priority first), but layers are *emitted* in
238
+ * this order so the volatile blocks that change every turn (`memoryMetadata`'s
239
+ * counts, `autoRecall`'s injected facts) sit **after** the stable Layer 1-4
240
+ * prefix (identity / rules / blocks / skills). That keeps the provider's - and a
241
+ * local llama.cpp / vLLM server's - KV-cache breakpoint real: the prefix stays
242
+ * byte-identical across turns even as the message count and recalled facts move.
243
+ */
244
+ const LAYER_EMIT_ORDER: Readonly<Record<LayerAllocation['id'], number>> = Object.freeze({
245
+ identity: 0,
246
+ activeRules: 1,
247
+ workingBlocks: 2,
248
+ activeSkills: 3,
249
+ // --- KV-cache breakpoint: everything below changes per turn ---
250
+ memoryMetadata: 4,
251
+ autoRecall: 5,
252
+ });
253
+
254
+ /**
255
+ * Build a ContextEngine instance from the supplied configuration.
256
+ *
257
+ * @stable
258
+ */
259
+ export function createContextEngine(config: ContextEngineConfig = {}): ContextEngine {
260
+ const memoryBaseMode = config.memoryBaseMode ?? 'full';
261
+ const localeInput = config.locale;
262
+ const localePack: ContextLocalePack = resolvePackInput(localeInput);
263
+ const localeId = localePack.id;
264
+ const layerCfg = config.layers ?? {};
265
+ const layersEnabled: Record<string, boolean> = {
266
+ identity: layerCfg.identity?.enabled ?? true,
267
+ memoryMetadata: layerCfg.memoryMetadata?.enabled ?? true,
268
+ activeRules: layerCfg.activeRules?.enabled ?? true,
269
+ activeSkills: layerCfg.activeSkills?.enabled ?? true,
270
+ workingBlocks: layerCfg.workingBlocks?.enabled ?? true,
271
+ autoRecall: layerCfg.autoRecall?.enabled ?? true,
272
+ };
273
+ const layerCaps: Record<string, number | undefined> = {
274
+ identity: layerCfg.identity?.cap ?? DEFAULT_LAYER_CAPS.identity,
275
+ memoryMetadata: layerCfg.memoryMetadata?.cap ?? DEFAULT_LAYER_CAPS.memoryMetadata,
276
+ activeRules: layerCfg.activeRules?.cap ?? DEFAULT_LAYER_CAPS.activeRules,
277
+ activeSkills: layerCfg.activeSkills?.cap ?? DEFAULT_LAYER_CAPS.activeSkills,
278
+ workingBlocks: layerCfg.workingBlocks?.cap ?? DEFAULT_LAYER_CAPS.workingBlocks,
279
+ autoRecall: layerCfg.autoRecall?.cap ?? DEFAULT_LAYER_CAPS.autoRecall,
280
+ };
281
+ const factsAutoRecall = config.factsAutoRecall ?? false;
282
+ const reservedForResponse = config.reservedForResponse ?? 4096;
283
+ const reservedForCompaction = config.reservedForCompaction ?? 8192;
284
+ const maxContextTokens = config.maxContextTokens ?? Number.POSITIVE_INFINITY;
285
+
286
+ const tokenCounter: ContextTokenCounter = config.tokenCounter
287
+ ? 'count' in config.tokenCounter
288
+ ? adaptTokenCounter(config.tokenCounter)
289
+ : (config.tokenCounter as ContextTokenCounter)
290
+ : HEURISTIC_TOKEN_COUNTER;
291
+ // CE-13: budgeting against a real provider window with the heuristic
292
+ // counter is approximate - say so once instead of failing silently
293
+ // late (the heuristic's failure mode is a provider context-length
294
+ // error before compaction fires).
295
+ if (
296
+ config.tokenCounter === undefined &&
297
+ typeof config.providerContextWindow === 'number' &&
298
+ !heuristicCounterWarned
299
+ ) {
300
+ heuristicCounterWarned = true;
301
+ process.stderr.write(
302
+ '[graphorin/memory] context-engine token counts use the built-in heuristic (chars/4 + dense-script). For production budgeting against a real provider window, pass `contextEngine.tokenCounter` (e.g. the provider package JsTiktokenCounter).\n',
303
+ );
304
+ }
305
+
306
+ const privacy = config.privacy ?? {};
307
+ const defaultSensitivity: Sensitivity = privacy.defaultSensitivity ?? 'internal';
308
+ const providerTrust: LocalProviderTrust = privacy.providerTrust ?? 'public-tls';
309
+ const cloudUploadConsent = privacy.cloudUploadConsent ?? false;
310
+ const heuristicStrategy = defaultLocaleHeuristicStrategy(localePack);
311
+
312
+ // Compaction config resolution.
313
+ const compactionInput = config.compaction;
314
+ const compactionAutoDefault = resolveAutoCompactionDefault(providerTrust);
315
+ const compactionEnabled =
316
+ compactionInput === false
317
+ ? false
318
+ : compactionInput === undefined
319
+ ? compactionAutoDefault === 'enabled'
320
+ : compactionInput.trigger !== 'never';
321
+ const providerContextWindow = config.providerContextWindow ?? null;
322
+ const triggerSpec =
323
+ compactionInput === false || compactionInput === undefined
324
+ ? undefined
325
+ : compactionInput.trigger;
326
+ const compactionThresholdTokens =
327
+ compactionEnabled && providerContextWindow !== null
328
+ ? resolveTriggerThreshold({
329
+ contextWindow: providerContextWindow,
330
+ ...(triggerSpec !== undefined ? { trigger: triggerSpec } : {}),
331
+ reservedForResponse,
332
+ reservedForCompaction,
333
+ })
334
+ : Number.POSITIVE_INFINITY;
335
+ // CE-12: compaction enabled without a `providerContextWindow` leaves the
336
+ // threshold at Infinity, so `shouldCompact` returns false forever - a
337
+ // silently-dead default-on protection. Surface it: throw if the operator
338
+ // explicitly configured compaction, warn (once) if it is on by the default
339
+ // trust policy. Auto-detecting the window from the provider is not
340
+ // implemented.
341
+ const compactionEffective = compactionEnabled && providerContextWindow !== null;
342
+ if (compactionEnabled && providerContextWindow === null) {
343
+ const message =
344
+ 'context-engine compaction is enabled but `providerContextWindow` is not set, so the ' +
345
+ 'trigger threshold is Infinity and compaction will never fire. Pass `providerContextWindow` ' +
346
+ "(your model's context window, in tokens) - auto-detection from the provider is not implemented.";
347
+ if (compactionInput !== undefined && compactionInput !== false) {
348
+ throw new Error(`[graphorin/memory] ${message}`);
349
+ }
350
+ warnCompactionIneffective(message);
351
+ }
352
+ const compactionStrategy: CompactionStrategy =
353
+ compactionInput === false ||
354
+ compactionInput === undefined ||
355
+ compactionInput.strategy === undefined
356
+ ? {
357
+ kind: 'summarize-old-preserve-recent',
358
+ preserveRecentTurns: 6,
359
+ templateName: 'summary-sections',
360
+ }
361
+ : compactionInput.strategy;
362
+ // SOTA-4 reclaim-floor: defer a compaction whose older (compactable) portion
363
+ // is below this floor (read from the trigger config; 0 = off, byte-identical).
364
+ const compactionMinReclaimTokens =
365
+ triggerSpec !== undefined && triggerSpec !== 'never'
366
+ ? Math.max(0, triggerSpec.minReclaimTokens ?? 0)
367
+ : 0;
368
+ // How many recent turns the active strategy preserves (the reclaimable portion
369
+ // is everything before them).
370
+ const reclaimPreserveTurns =
371
+ compactionStrategy.kind === 'summarize-old-preserve-recent'
372
+ ? (compactionStrategy.preserveRecentTurns ?? DEFAULT_PRESERVE_RECENT_TURNS)
373
+ : compactionStrategy.kind === 'clear-old-tool-results' &&
374
+ typeof compactionStrategy.summarizeFallback === 'object'
375
+ ? (compactionStrategy.summarizeFallback.preserveRecentTurns ??
376
+ DEFAULT_PRESERVE_RECENT_TURNS)
377
+ : DEFAULT_PRESERVE_RECENT_TURNS;
378
+ const compactionHooks: ReadonlyArray<NamedPostCompactionHook> = resolveDefaultHooks(
379
+ compactionInput === false || compactionInput === undefined
380
+ ? undefined
381
+ : compactionInput.postCompactionHooks,
382
+ );
383
+ const summarizer = config.summarizer;
384
+ const now = config.now ?? Date.now;
385
+
386
+ const resolvedConfig: ResolvedContextEngineConfig = Object.freeze({
387
+ memoryBaseMode,
388
+ localeId,
389
+ maxContextTokens,
390
+ reservedForResponse,
391
+ reservedForCompaction,
392
+ compactionEnabled,
393
+ compactionEffective,
394
+ compactionThresholdTokens,
395
+ providerContextWindow,
396
+ providerTrust,
397
+ cloudUploadConsent,
398
+ defaultSensitivity,
399
+ });
400
+
401
+ async function assemble(memory: Memory, input: AssembleInput): Promise<AssembledPrompt> {
402
+ // Layer 1 - base template.
403
+ const layer1Text = layersEnabled.identity ? composeLayer1(localePack, memoryBaseMode) : '';
404
+ // Layer 2 - agent instructions.
405
+ const layer2Text = composeLayer2(input.agentInstructions);
406
+
407
+ // Layer 3 - working blocks (filter D2).
408
+ const blocks = await memory.working.list(input.scope);
409
+ const blocksKept: Array<(typeof blocks)[number]> = [];
410
+ const privacyCounters: Record<PrivacyDecisionReason, number> = {
411
+ allowed: 0,
412
+ 'no-cloud-upload-consent': 0,
413
+ 'provider-rejects-internal': 0,
414
+ 'provider-rejects-secret': 0,
415
+ };
416
+ for (const block of blocks) {
417
+ const decision = privacyDecide(block.sensitivity, {
418
+ ...(privacy.providerAcceptsSensitivity !== undefined
419
+ ? { providerAcceptsSensitivity: privacy.providerAcceptsSensitivity }
420
+ : {}),
421
+ providerTrust,
422
+ cloudUploadConsent,
423
+ defaultSensitivity,
424
+ });
425
+ privacyCounters[decision.reason] += 1;
426
+ if (decision.decision === 'pass') blocksKept.push(block);
427
+ }
428
+ const layer3Text = layersEnabled.workingBlocks ? renderWorkingBlocks(blocksKept) : '';
429
+
430
+ // Layer 4 - procedural rules + skills (filter D2 on rules).
431
+ const rules = await memory.procedural.activate(input.scope, input.proceduralActivation ?? {});
432
+ const rulesKept: Array<(typeof rules)[number]> = [];
433
+ for (const rule of rules) {
434
+ const decision = privacyDecide(rule.sensitivity, {
435
+ ...(privacy.providerAcceptsSensitivity !== undefined
436
+ ? { providerAcceptsSensitivity: privacy.providerAcceptsSensitivity }
437
+ : {}),
438
+ providerTrust,
439
+ cloudUploadConsent,
440
+ defaultSensitivity,
441
+ });
442
+ privacyCounters[decision.reason] += 1;
443
+ if (decision.decision === 'pass') rulesKept.push(rule);
444
+ }
445
+ const layer4RulesText = layersEnabled.activeRules ? renderProceduralRules(rulesKept) : '';
446
+ const layer4SkillsText = layersEnabled.activeSkills
447
+ ? composeLayer4Skills(input.skills ?? [])
448
+ : '';
449
+
450
+ // Layer 5 - memory metadata.
451
+ const meta = await memory.metadata(input.scope);
452
+ const layer5Text = layersEnabled.memoryMetadata
453
+ ? renderMetadataBlock(enrichMetadataTags(meta, localeId, memory.embedderId()))
454
+ : '';
455
+
456
+ // Layer 6 - auto-recall.
457
+ let autoRecall: AutoRecallTriggerResult = { factsTriggered: false };
458
+ let layer6Text = '';
459
+ if (factsAutoRecall !== false && layersEnabled.autoRecall) {
460
+ const strategy =
461
+ input.autoRecallStrategyOverride ?? factsAutoRecall.strategy ?? heuristicStrategy;
462
+ autoRecall = strategy({
463
+ locale: localeId,
464
+ lastUserMessage: input.lastUserMessage ?? '',
465
+ });
466
+ if (autoRecall.factsTriggered) {
467
+ const topK = factsAutoRecall.topK ?? layerCfg.autoRecall?.topK ?? 5;
468
+ // CE-4: default 0 - rank-based `topK` already bounds the injected
469
+ // volume, and the threshold's scale is reranker-dependent (the default
470
+ // RRF reranker fuses 2 candidate lists, so scores top out near
471
+ // `2/(60+1) ≈ 0.033` and any positive default would silently drop
472
+ // everything). Operators tuning against a known reranker scale can pass
473
+ // an explicit `threshold`.
474
+ const threshold = factsAutoRecall.threshold ?? layerCfg.autoRecall?.threshold ?? 0;
475
+ const hits = await memory.semantic
476
+ .search(input.scope, input.lastUserMessage ?? '', { topK })
477
+ .catch(() => []);
478
+ const facts: Fact[] = [];
479
+ for (const hit of hits) {
480
+ if (hit.score < threshold) continue;
481
+ const decision = privacyDecide(hit.record.sensitivity, {
482
+ ...(privacy.providerAcceptsSensitivity !== undefined
483
+ ? { providerAcceptsSensitivity: privacy.providerAcceptsSensitivity }
484
+ : {}),
485
+ providerTrust,
486
+ cloudUploadConsent,
487
+ defaultSensitivity,
488
+ });
489
+ privacyCounters[decision.reason] += 1;
490
+ if (decision.decision === 'pass') facts.push(hit.record);
491
+ }
492
+ layer6Text = renderAutoRecalledFacts(facts);
493
+ }
494
+ }
495
+
496
+ // Token-budget allocation. Layer 1 (`graphorin_memory_base`) and
497
+ // Layer 2 (`agent_instructions`) are concatenated into the
498
+ // single `identity` candidate so they share a priority slot;
499
+ // they are joined with a blank line so the assembled prompt is
500
+ // readable.
501
+ const identityText =
502
+ layer1Text.length > 0 && layer2Text.length > 0
503
+ ? `${layer1Text}\n\n${layer2Text}`
504
+ : layer1Text + layer2Text;
505
+ const candidates: LayerCandidate[] = [
506
+ buildCandidate('identity', identityText, layerCaps.identity),
507
+ buildCandidate('memoryMetadata', layer5Text, layerCaps.memoryMetadata),
508
+ buildCandidate('activeRules', layer4RulesText, layerCaps.activeRules),
509
+ buildCandidate('workingBlocks', layer3Text, layerCaps.workingBlocks),
510
+ buildCandidate('activeSkills', layer4SkillsText, layerCaps.activeSkills),
511
+ buildCandidate('autoRecall', layer6Text, layerCaps.autoRecall),
512
+ ].filter((c) => c.text.length > 0);
513
+
514
+ const allocation = await allocateLayers(candidates, maxContextTokens, tokenCounter);
515
+
516
+ // D4 inbound preamble - fires on steps containing untrusted upstream parts.
517
+ const preambleFired = shouldFireInboundPreamble(input.upstreamAnnotations ?? []);
518
+ const preambleText = preambleFired ? composeInboundPreamble(localePack) : '';
519
+
520
+ // CE-9: emit in stability order (Layer 1-4 prefix, then the cache
521
+ // breakpoint, then the volatile metadata / auto-recall), NOT in the
522
+ // truncation-priority order `allocation.layers` arrives in.
523
+ const assembledLayers = allocation.layers
524
+ .filter((l) => l.text.length > 0)
525
+ .sort((a, b) => LAYER_EMIT_ORDER[a.id] - LAYER_EMIT_ORDER[b.id]);
526
+ const finalParts: string[] = assembledLayers.map((l) => l.text);
527
+ if (preambleText.length > 0) finalParts.push(preambleText);
528
+ const systemContent = finalParts.join('\n\n');
529
+
530
+ // Annotations - one per assembled fragment, in the same order
531
+ // as `finalParts` so observers can correlate.
532
+ const annotations: AnnotatedPart[] = assembledLayers.map((layer) => ({
533
+ content: { type: 'text', text: layer.text } as MessageContent,
534
+ annotation: annotationForLayer(layer),
535
+ }));
536
+ if (preambleText.length > 0) {
537
+ annotations.push({
538
+ content: { type: 'text', text: preambleText } as MessageContent,
539
+ annotation: annotate('system:framework', 'n/a'),
540
+ });
541
+ }
542
+
543
+ return Object.freeze({
544
+ systemMessage: Object.freeze({ role: 'system' as const, content: systemContent }),
545
+ annotations: Object.freeze(annotations),
546
+ layerAllocation: allocation,
547
+ inboundPreambleFired: preambleFired,
548
+ privacyCounters: Object.freeze(privacyCounters),
549
+ localeId,
550
+ memoryBaseMode,
551
+ autoRecall,
552
+ });
553
+ }
554
+
555
+ // CE-7 anti-thrash: after a compaction whose afterTokens still sits at
556
+ // or above the trigger threshold (e.g. huge tool results among the
557
+ // preserved turns), an immediate re-trigger would summarize the
558
+ // previous summary - spend with no reclaim. Track the last outcome and
559
+ // require the buffer to have GROWN past it before firing again.
560
+ let lastCompactionAfterTokens: number | null = null;
561
+ const REARM_GROWTH_TOKENS = 256;
562
+
563
+ // C4 (context-engine-06/07 hardening): a persistently-failing summarizer
564
+ // previously re-fired an LLM call at the top of EVERY step (the
565
+ // anti-thrash guard arms only on success). Count consecutive failures -
566
+ // including "successful" passes that did not actually shrink the buffer
567
+ // - retry each pass once with a short backoff, and after
568
+ // MAX_CONSECUTIVE_COMPACTION_FAILURES disable the AUTO trigger until a
569
+ // later compactNow (e.g. a manual compact with a fixed summarizer)
570
+ // succeeds and re-arms it.
571
+ let consecutiveCompactionFailures = 0;
572
+ let autoCompactionDisabledByFailures = false;
573
+ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3;
574
+ const COMPACTION_RETRY_BACKOFF_MS = 250;
575
+
576
+ function recordCompactionFailure(reason: string): void {
577
+ consecutiveCompactionFailures += 1;
578
+ if (
579
+ consecutiveCompactionFailures >= MAX_CONSECUTIVE_COMPACTION_FAILURES &&
580
+ !autoCompactionDisabledByFailures
581
+ ) {
582
+ autoCompactionDisabledByFailures = true;
583
+ process.stderr.write(
584
+ `[graphorin/memory] auto-compaction disabled after ${consecutiveCompactionFailures} consecutive failed passes (last: ${reason}). Manual compactNow() still runs and re-enables the trigger on success.\n`,
585
+ );
586
+ }
587
+ }
588
+
589
+ async function shouldCompact(
590
+ messages: ReadonlyArray<Message>,
591
+ options: {
592
+ readonly precomputedTokens?: number;
593
+ readonly compactableFromIndex?: number;
594
+ } = {},
595
+ ): Promise<boolean> {
596
+ if (!compactionEnabled) return false;
597
+ if (autoCompactionDisabledByFailures) return false;
598
+ if (compactionThresholdTokens === Number.POSITIVE_INFINITY) return false;
599
+ const total = options.precomputedTokens ?? (await countMessageTokens(messages, tokenCounter));
600
+ if (total < compactionThresholdTokens) return false;
601
+ if (
602
+ lastCompactionAfterTokens !== null &&
603
+ total <= lastCompactionAfterTokens + REARM_GROWTH_TOKENS
604
+ ) {
605
+ return false;
606
+ }
607
+ // SOTA-4 reclaim-floor: skip when the older (compactable) portion is too
608
+ // small to be worth a summarizer call - avoids compact-thrash at the
609
+ // threshold. Counts only the older slice; cheap relative to the avoided
610
+ // call. context-engine-04: the caller's pinned system prefix
611
+ // (`compactableFromIndex`) is NOT reclaimable and must not be counted -
612
+ // a 10k-token prefix would otherwise satisfy any floor while the body
613
+ // holds near-zero reclaimable tokens.
614
+ if (compactionMinReclaimTokens > 0) {
615
+ const from = Math.max(0, Math.min(options.compactableFromIndex ?? 0, messages.length));
616
+ const olderCount = Math.max(from, messages.length - reclaimPreserveTurns);
617
+ if (olderCount <= from) return false;
618
+ const olderTokens = await countMessageTokens(messages.slice(from, olderCount), tokenCounter);
619
+ if (olderTokens < compactionMinReclaimTokens) return false;
620
+ }
621
+ return true;
622
+ }
623
+
624
+ async function compactNow(callInput: {
625
+ readonly scope: SessionScope;
626
+ readonly runId: string;
627
+ readonly sessionId: string;
628
+ readonly agentId: string;
629
+ readonly source: CompactionSource;
630
+ readonly messages: ReadonlyArray<Message>;
631
+ readonly memory: Memory;
632
+ readonly summarizer?: CompactionSummarizer;
633
+ /** Per-call override of the strategy's preserve-recent count (CE-3). */
634
+ readonly preserveRecentTurns?: number;
635
+ /** Topic/tags narrowing for the procedural-rules re-anchor hook (CE-6). */
636
+ readonly procedural?: { readonly topic?: string; readonly tags?: ReadonlyArray<string> };
637
+ /** The caller's pinned system prefix - accounting only (context-engine-04). */
638
+ readonly prefixMessages?: ReadonlyArray<Message>;
639
+ readonly signal?: AbortSignal;
640
+ }): Promise<{
641
+ readonly result: CompactionResult;
642
+ readonly extraContent: ReadonlyArray<MessageContent>;
643
+ readonly hookFailures: ReadonlyArray<{ readonly hookName: string; readonly reason: string }>;
644
+ }> {
645
+ const activeSummarizer = callInput.summarizer ?? summarizer;
646
+ if (activeSummarizer === undefined) {
647
+ throw new TypeError(
648
+ '[graphorin/memory] ContextEngine.compactNow: no summarizer configured. ' +
649
+ 'Pass `summarizer` to createContextEngine({...}) or to compactNow({...}).',
650
+ );
651
+ }
652
+ const compactionInputCall: ExecuteCompactionInput = {
653
+ messages: callInput.messages,
654
+ source: callInput.source,
655
+ strategy:
656
+ callInput.preserveRecentTurns !== undefined &&
657
+ compactionStrategy.kind === 'summarize-old-preserve-recent'
658
+ ? { ...compactionStrategy, preserveRecentTurns: callInput.preserveRecentTurns }
659
+ : compactionStrategy,
660
+ localePack,
661
+ summarizer: activeSummarizer,
662
+ tokenCounter,
663
+ thresholdTokens: compactionThresholdTokens,
664
+ runId: callInput.runId,
665
+ sessionId: callInput.sessionId,
666
+ agentId: callInput.agentId,
667
+ scope: callInput.scope,
668
+ providerTrust,
669
+ now,
670
+ ...(callInput.signal !== undefined ? { signal: callInput.signal } : {}),
671
+ };
672
+ // C4: one retry with a short backoff on summarizer failure, then a
673
+ // strictly-smaller-than-input assert - a pass that dropped messages
674
+ // yet did not shrink the buffer is a failure (a looping/echoing
675
+ // summarizer), not a success to arm the anti-thrash guard with.
676
+ let result: CompactionResult;
677
+ try {
678
+ result = await executeCompaction(compactionInputCall);
679
+ } catch (firstError) {
680
+ if (callInput.signal?.aborted === true) throw firstError;
681
+ await new Promise<void>((resolve) => setTimeout(resolve, COMPACTION_RETRY_BACKOFF_MS));
682
+ try {
683
+ result = await executeCompaction(compactionInputCall);
684
+ } catch (secondError) {
685
+ recordCompactionFailure(
686
+ secondError instanceof Error ? secondError.message : String(secondError),
687
+ );
688
+ throw secondError;
689
+ }
690
+ }
691
+ if (result.droppedMessageIndices.length > 0 && result.afterTokens >= result.beforeTokens) {
692
+ const reason = `compaction did not shrink the buffer (${result.beforeTokens} -> ${result.afterTokens} tokens)`;
693
+ if (callInput.source === 'auto-trigger') {
694
+ // The auto loop must never keep paying for passes that grow the
695
+ // buffer (the Gemini-CLI compression-loop class) - fail the pass.
696
+ recordCompactionFailure(reason);
697
+ throw new Error(`[graphorin/memory] ${reason}`);
698
+ }
699
+ // An operator-requested compaction of a small window can
700
+ // legitimately cost more than it reclaims (the summary skeleton
701
+ // has a floor) - warn, hand back the result, count nothing.
702
+ process.stderr.write(`[graphorin/memory] ${reason} (source: ${callInput.source}).\n`);
703
+ } else if (result.droppedMessageIndices.length > 0) {
704
+ // A pass that actually reclaimed space re-arms the auto trigger.
705
+ consecutiveCompactionFailures = 0;
706
+ if (autoCompactionDisabledByFailures) {
707
+ autoCompactionDisabledByFailures = false;
708
+ process.stderr.write(
709
+ '[graphorin/memory] auto-compaction re-enabled after a successful pass.\n',
710
+ );
711
+ }
712
+ }
713
+ // C4: hand hooks the DROPPED message content (not just indices) so
714
+ // re-anchoring hooks like reanchorRecentResults can recover the
715
+ // result handles that just left the window. Reference-diff against
716
+ // trimmedMessages (both strategies carry surviving originals by
717
+ // reference; the per-strategy index spaces differ, so indices are
718
+ // not usable here).
719
+ const survivors = new Set(result.trimmedMessages);
720
+ const droppedMessages = callInput.messages.filter((m) => !survivors.has(m));
721
+ const ctx = {
722
+ result,
723
+ scope: callInput.scope,
724
+ runId: callInput.runId,
725
+ sessionId: callInput.sessionId,
726
+ agentId: callInput.agentId,
727
+ source: callInput.source,
728
+ droppedMessages,
729
+ };
730
+ const hookFailures: Array<{ readonly hookName: string; readonly reason: string }> = [];
731
+ const extraContent: MessageContent[] = [];
732
+ let hooksFired = 0;
733
+ // context-engine-02: the same D2 privacy decision `assemble()` applies
734
+ // to blocks / rules / facts, handed to the hooks - their output is
735
+ // spliced into the buffer and shipped to the provider, so it must obey
736
+ // the same filter or secret-tier content leaks on the first compaction.
737
+ const allowSensitivity = (sensitivity: Sensitivity | undefined): boolean =>
738
+ privacyDecide(sensitivity, {
739
+ ...(privacy.providerAcceptsSensitivity !== undefined
740
+ ? { providerAcceptsSensitivity: privacy.providerAcceptsSensitivity }
741
+ : {}),
742
+ providerTrust,
743
+ cloudUploadConsent,
744
+ defaultSensitivity,
745
+ }).decision === 'pass';
746
+ for (const hook of compactionHooks) {
747
+ try {
748
+ // CE-6: hooks receive the REAL compaction context - the old code
749
+ // built `ctx` and then `void ctx;`-discarded it while the
750
+ // function-form wrapper fabricated a zeroed result.
751
+ const parts = await hook.resolveContent(
752
+ {
753
+ memory: callInput.memory,
754
+ scope: callInput.scope,
755
+ ...(callInput.procedural !== undefined ? { procedural: callInput.procedural } : {}),
756
+ allowSensitivity,
757
+ },
758
+ ctx,
759
+ );
760
+ extraContent.push(...parts);
761
+ hooksFired += 1;
762
+ } catch (err) {
763
+ hookFailures.push({
764
+ hookName: hook.id,
765
+ reason: err instanceof Error ? err.name : 'UnknownError',
766
+ });
767
+ }
768
+ }
769
+ const enrichedResult: CompactionResult = Object.freeze({
770
+ ...result,
771
+ hooksFiredCount: hooksFired,
772
+ });
773
+ // CE-7: arm the anti-thrash guard and surface the failure mode where
774
+ // compaction could not get under the trigger threshold.
775
+ //
776
+ // context-engine-04: `shouldCompact` compares the FULL buffer total
777
+ // (pinned prefix included) against this baseline, so the baseline must
778
+ // be full-basis too: body afterTokens + the caller's pinned prefix +
779
+ // the Context-Essentials message the caller splices in. Arming with
780
+ // the body-only number let any real system prompt (> 256 tokens)
781
+ // defeat the guard, re-firing a summarizer LLM call at the top of
782
+ // EVERY step whenever compaction could not get under the threshold.
783
+ const prefixTokens =
784
+ callInput.prefixMessages !== undefined && callInput.prefixMessages.length > 0
785
+ ? await countMessageTokens(callInput.prefixMessages, tokenCounter)
786
+ : 0;
787
+ let essentialsTokens = 0;
788
+ for (const part of extraContent) {
789
+ if (part.type === 'text') essentialsTokens += await tokenCounter.countText(part.text);
790
+ }
791
+ const fullAfterTokens = enrichedResult.afterTokens + prefixTokens + essentialsTokens;
792
+ lastCompactionAfterTokens = fullAfterTokens;
793
+ if (
794
+ compactionThresholdTokens !== Number.POSITIVE_INFINITY &&
795
+ fullAfterTokens >= compactionThresholdTokens
796
+ ) {
797
+ process.stderr.write(
798
+ `[graphorin/memory] compaction finished at ${fullAfterTokens} tokens (body ${enrichedResult.afterTokens} + pinned prefix ${prefixTokens} + essentials ${essentialsTokens}) - still at/above the ${compactionThresholdTokens}-token trigger (oversized preserved turns?). The immediate re-trigger is suppressed until the buffer grows.\n`,
799
+ );
800
+ }
801
+ return Object.freeze({
802
+ result: enrichedResult,
803
+ extraContent: Object.freeze(extraContent),
804
+ hookFailures: Object.freeze(hookFailures),
805
+ });
806
+ }
807
+
808
+ return Object.freeze({
809
+ assemble,
810
+ shouldCompact,
811
+ compactNow,
812
+ config(): ResolvedContextEngineConfig {
813
+ return resolvedConfig;
814
+ },
815
+ });
816
+ }
817
+
818
+ function buildCandidate(
819
+ id: LayerCandidate['id'],
820
+ text: string,
821
+ cap: number | undefined,
822
+ ): LayerCandidate {
823
+ if (cap !== undefined) return { id, text, cap };
824
+ return { id, text };
825
+ }
826
+
827
+ function annotationForLayer(layer: LayerAllocation): ContentAnnotation {
828
+ switch (layer.id) {
829
+ case 'identity':
830
+ // Identity merges the framework base (Layer 1) + the user-defined
831
+ // agent_instructions (Layer 2) into one trusted fragment, tagged
832
+ // `system:framework` so D3 skips re-scanning it under
833
+ // `scanScope: 'untrusted'`. (Both halves are first-party prompt text, so
834
+ // the merged layer carries a single framework trust tag - `LayerAllocation`
835
+ // does not surface which halves were present.)
836
+ return annotate('system:framework', 'n/a');
837
+ case 'memoryMetadata':
838
+ case 'activeRules':
839
+ case 'workingBlocks':
840
+ case 'activeSkills':
841
+ case 'autoRecall':
842
+ return annotate('memory:tier-filtered', 'n/a');
843
+ default:
844
+ return annotate('system:framework', 'n/a');
845
+ }
846
+ }
847
+
848
+ function renderWorkingBlocks(
849
+ blocks: ReadonlyArray<{
850
+ readonly label: string;
851
+ readonly value: string;
852
+ readonly description?: string;
853
+ }>,
854
+ ): string {
855
+ if (blocks.length === 0) return '';
856
+ const lines = ['<memory_blocks>'];
857
+ for (const block of blocks) {
858
+ const description =
859
+ block.description !== undefined ? ` description="${escapeXmlAttr(block.description)}"` : '';
860
+ lines.push(` <block label="${escapeXmlAttr(block.label)}"${description}>`);
861
+ lines.push(` ${escapeXmlText(block.value)}`);
862
+ lines.push(' </block>');
863
+ }
864
+ lines.push('</memory_blocks>');
865
+ return lines.join('\n');
866
+ }
867
+
868
+ function renderProceduralRules(
869
+ rules: ReadonlyArray<{ readonly text: string; readonly priority: number }>,
870
+ ): string {
871
+ if (rules.length === 0) return '';
872
+ const lines = ['<memory_rules>'];
873
+ for (const rule of rules) {
874
+ lines.push(` <rule priority="${rule.priority}">${escapeXmlText(rule.text)}</rule>`);
875
+ }
876
+ lines.push('</memory_rules>');
877
+ return lines.join('\n');
878
+ }
879
+
880
+ function renderAutoRecalledFacts(facts: ReadonlyArray<Fact>): string {
881
+ if (facts.length === 0) return '';
882
+ const lines = ['<auto_recalled_facts>'];
883
+ for (const fact of facts) {
884
+ const tagsAttr =
885
+ fact.tags !== undefined ? ` tags="${escapeXmlAttr((fact.tags ?? []).join(','))}"` : '';
886
+ lines.push(
887
+ ` <fact id="${escapeXmlAttr(fact.id)}"${tagsAttr}>${escapeXmlText(fact.text)}</fact>`,
888
+ );
889
+ }
890
+ lines.push('</auto_recalled_facts>');
891
+ return lines.join('\n');
892
+ }
893
+
894
+ function escapeXmlAttr(value: string): string {
895
+ return value
896
+ .replace(/&/g, '&amp;')
897
+ .replace(/</g, '&lt;')
898
+ .replace(/>/g, '&gt;')
899
+ .replace(/"/g, '&quot;');
900
+ }
901
+
902
+ function escapeXmlText(value: string): string {
903
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
904
+ }
905
+
906
+ function resolvePackInput(input: ContextEngineConfig['locale']): ContextLocalePack {
907
+ if (input === undefined) return enLocalePack;
908
+ if (typeof input === 'string') {
909
+ if (input === enLocalePack.id) return enLocalePack;
910
+ return resolveLocalePack({ id: input });
911
+ }
912
+ return resolveLocalePack(input);
913
+ }
914
+
915
+ function resolveDefaultHooks(
916
+ input: ReadonlyArray<PostCompactionHook> | ReadonlyArray<NamedPostCompactionHook> | undefined,
917
+ ): ReadonlyArray<NamedPostCompactionHook> {
918
+ if (input === undefined) {
919
+ return Object.freeze([
920
+ reanchorProjectRules(),
921
+ reanchorPersonaBlock(),
922
+ reanchorPinnedFacts({ pinnedFactIds: [] }),
923
+ ]);
924
+ }
925
+ return Object.freeze(
926
+ input.map((hook, idx): NamedPostCompactionHook => {
927
+ if (typeof hook === 'function') {
928
+ const id = `customHook_${idx}`;
929
+ return {
930
+ id,
931
+ async resolveContent(_deps, ctx) {
932
+ // CE-6: the operator's hook sees the genuine compaction
933
+ // context. `ctx` is always supplied by compactNow; the
934
+ // throw guards a direct caller that forgot it rather than
935
+ // silently fabricating zeros (the old behaviour).
936
+ if (ctx === undefined) {
937
+ throw new TypeError(
938
+ '[graphorin/memory] post-compaction hooks require the compaction context - call through ContextEngine.compactNow(...).',
939
+ );
940
+ }
941
+ const result = await hook(ctx);
942
+ return result;
943
+ },
944
+ };
945
+ }
946
+ return hook;
947
+ }),
948
+ );
949
+ }
950
+
951
+ /**
952
+ * Augment a {@link MemoryMetadata} snapshot with the active locale +
953
+ * embedder tags so the rendered metadata block carries the
954
+ * additional context the model uses for reasoning. Pure: the
955
+ * original `meta` reference is not mutated.
956
+ */
957
+ function enrichMetadataTags(
958
+ meta: import('@graphorin/core').MemoryMetadata,
959
+ localeId: string,
960
+ embedderId: string | null,
961
+ ): import('@graphorin/core').MemoryMetadata {
962
+ const baseTags = meta.tags !== undefined ? [...meta.tags] : [];
963
+ if (!baseTags.some((tag) => tag.startsWith('locale:'))) {
964
+ baseTags.push(`locale:${localeId}`);
965
+ }
966
+ if (embedderId !== null && !baseTags.some((tag) => tag.startsWith('embedder:'))) {
967
+ baseTags.push(`embedder:${embedderId}`);
968
+ }
969
+ return { ...meta, tags: Object.freeze(baseTags) };
970
+ }