@graphorin/memory 0.6.1 → 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 +71 -0
  2. package/README.md +1 -1
  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 +16 -13
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -5
  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 +1 -1
  89. package/dist/package.js.map +1 -1
  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,403 @@
1
+ /**
2
+ * Auto-compaction subsystem core (RB-46 / suggested DEC-162 /
3
+ * ADR-050). Trims the in-flight ContextEngine message buffer when
4
+ * the assembled token count crosses a per-provider threshold; the
5
+ * trim is summarized via the configured section template; the
6
+ * trim is followed by synchronous-await firing of registered
7
+ * post-compaction hooks (lifecycle owned by Phase 12; this module
8
+ * exposes the trim primitive).
9
+ *
10
+ * Disjoint from the Phase 10c consolidator: never writes to
11
+ * `semantic_facts` / `episodic_episodes` / `procedural_rules`;
12
+ * reads only `memory.metadata(...)` / `memory.semantic.search(...)`
13
+ * / `memory.procedural.activate(...)` / `memory.working.read(...)`
14
+ * (non-mutating).
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import type { Message, ModelSpec, SystemMessage } from '@graphorin/core';
20
+ import {
21
+ type ImperativeScanResult as ScanResult,
22
+ scanImperativePatterns,
23
+ } from '@graphorin/observability/redaction';
24
+ import { UNTRUSTED_CONTENT_OPEN_PREFIX, wrapUntrusted } from '../../internal/envelope.js';
25
+ import type { ContextLocalePack } from '../locale-packs/index.js';
26
+ import {
27
+ type ContextTokenCounter,
28
+ countMessageTokens,
29
+ HEURISTIC_TOKEN_COUNTER,
30
+ renderMessageText,
31
+ } from '../token-counter.js';
32
+ import { clearOldToolResults } from './clear-tool-results.js';
33
+ import {
34
+ buildSummarizerPrompt,
35
+ type CompactionMetadataPayload,
36
+ renderFinalSummary,
37
+ SUMMARY_TEMPLATE_NAME,
38
+ SUMMARY_TEMPLATE_VERSION,
39
+ } from './templates/summary-9-section.js';
40
+ import type {
41
+ CompactionContext,
42
+ CompactionResult,
43
+ CompactionSource,
44
+ CompactionStrategy,
45
+ CompactionSummarizer,
46
+ } from './types.js';
47
+
48
+ /**
49
+ * Default count of recent turns preserved verbatim by the
50
+ * `summarize-old-preserve-recent` strategy.
51
+ *
52
+ * @stable
53
+ */
54
+ export const DEFAULT_PRESERVE_RECENT_TURNS = 6;
55
+
56
+ /** Opening marker of the inbound-untrusted envelope (tools sanitize layer). */
57
+ const UNTRUSTED_MARKER = UNTRUSTED_CONTENT_OPEN_PREFIX;
58
+
59
+ /**
60
+ * Wall-clock budget for the CE-15 injection scan of the summarizer
61
+ * output. The scanner's 5ms default exists for the per-tool-result
62
+ * hot path; a compaction already paid for an LLM call, the summary
63
+ * body is a single bounded text, and the pattern catalogue is fixed,
64
+ * so the scan here runs UNBUDGETED - a wall-clock budget only turned
65
+ * scheduler noise into a `null` verdict (a 50ms budget was observed
66
+ * expiring on a degraded CI runner, silently failing the check open).
67
+ */
68
+ const COMPACTION_SCAN_BUDGET_MS = Number.POSITIVE_INFINITY;
69
+
70
+ /**
71
+ * CE-15 trust decision for a freshly produced summary. Exported for
72
+ * unit tests because the `scan === null` branch (scanner budget
73
+ * exceeded) MUST fail closed: the scanner's contract is "null = the
74
+ * caller applies its own best-effort degradation", and treating null
75
+ * as "no hits" would commit a poisoned summary as trusted exactly on
76
+ * the contended hosts where the budget expires.
77
+ *
78
+ * @internal
79
+ */
80
+ export function resolveSummaryTrust(
81
+ windowUntrusted: boolean,
82
+ scan: ScanResult | null,
83
+ ): 'trusted' | 'untrusted-derived' {
84
+ if (windowUntrusted) return 'untrusted-derived';
85
+ if (scan === null) return 'untrusted-derived';
86
+ return scan.hits.length > 0 ? 'untrusted-derived' : 'trusted';
87
+ }
88
+
89
+ /** CE-15: does the compacted window carry inbound-untrusted envelopes? */
90
+ function windowContainsUntrusted(messages: ReadonlyArray<Message>): boolean {
91
+ return messages.some((message) => renderMessageText(message).includes(UNTRUSTED_MARKER));
92
+ }
93
+
94
+ /**
95
+ * CE-15: wrap the LLM-authored summary body in a derived-trust
96
+ * envelope. Envelope marker sequences inside the body are neutralized
97
+ * first so summarizer output influenced by injected text cannot break
98
+ * out of the envelope and masquerade as authoritative system text.
99
+ * Delegates to the shared `internal/envelope.ts` helper (W-083) so the
100
+ * whole memory package neutralizes markers with one scheme; the
101
+ * output is byte-identical to the historical inline implementation on
102
+ * literal-marker inputs.
103
+ */
104
+ function wrapSummaryAsDerived(body: string): string {
105
+ return wrapUntrusted(body, { trust: 'derived', tool: 'compaction-summarizer' });
106
+ }
107
+
108
+ /**
109
+ * Trim the in-flight buffer using the
110
+ * `summarize-old-preserve-recent` strategy. Returns the trimmed
111
+ * messages + summary metadata; the caller is responsible for
112
+ * appending the summary to the message buffer (Phase 12 owns the
113
+ * lifecycle; this module exposes the primitive).
114
+ *
115
+ * @stable
116
+ */
117
+ export interface ExecuteCompactionInput {
118
+ readonly messages: ReadonlyArray<Message>;
119
+ readonly source: CompactionSource;
120
+ readonly strategy: CompactionStrategy;
121
+ readonly localePack: ContextLocalePack;
122
+ readonly summarizer: CompactionSummarizer;
123
+ readonly tokenCounter?: ContextTokenCounter;
124
+ readonly thresholdTokens: number;
125
+ readonly runId: string;
126
+ readonly sessionId: string;
127
+ readonly agentId: string;
128
+ readonly scope: import('@graphorin/core').SessionScope;
129
+ readonly providerTrust?: import('@graphorin/core').LocalProviderTrust;
130
+ readonly now?: () => number;
131
+ readonly signal?: AbortSignal;
132
+ }
133
+
134
+ /**
135
+ * Perform a compaction call. Returns the result envelope containing
136
+ * the produced summary, the dropped/preserved message slices, and
137
+ * the per-event metadata. Phase 12 / `agent.compact()` is the
138
+ * lifecycle owner; this function is the trim primitive.
139
+ *
140
+ * @stable
141
+ */
142
+ export async function executeCompaction(input: ExecuteCompactionInput): Promise<CompactionResult> {
143
+ const counter = input.tokenCounter ?? HEURISTIC_TOKEN_COUNTER;
144
+ const now = input.now ?? Date.now;
145
+ const startTs = now();
146
+ const beforeTokens = await countMessageTokens(input.messages, counter);
147
+
148
+ if (input.strategy.kind === 'custom') {
149
+ const ctx: CompactionContext = {
150
+ runId: input.runId,
151
+ sessionId: input.sessionId,
152
+ agentId: input.agentId,
153
+ scope: input.scope,
154
+ source: input.source,
155
+ messages: input.messages,
156
+ beforeTokens,
157
+ thresholdTokens: input.thresholdTokens,
158
+ preserveRecentTurns: DEFAULT_PRESERVE_RECENT_TURNS,
159
+ providerTrust: input.providerTrust ?? 'public-tls',
160
+ };
161
+ const result = await input.strategy.compact(ctx);
162
+ return result;
163
+ }
164
+
165
+ if (input.strategy.kind === 'clear-old-tool-results') {
166
+ return executeClearStrategy(input, input.strategy, counter, now, startTs, beforeTokens);
167
+ }
168
+
169
+ const preserveRecentTurns = input.strategy.preserveRecentTurns ?? DEFAULT_PRESERVE_RECENT_TURNS;
170
+ const summarizerModelInput = input.strategy.summarizerModel;
171
+ const summarizerTimeoutMs = input.strategy.summarizerTimeoutMs;
172
+
173
+ // Slice the older portion + preserve the recent N turns. We treat
174
+ // a "turn" as a single message for the v0.1 surface; the spec
175
+ // notes more nuanced segmentation is post-MVP per Q-121.
176
+ let olderCount = Math.max(0, input.messages.length - preserveRecentTurns);
177
+ // context-engine-01: never split an assistant/tool pair. A purely
178
+ // positional slice can strand a `role:'tool'` message at the head of
179
+ // the preserved window while its `tool_calls` assistant partner is
180
+ // summarized away - OpenAI-compatible servers 400 on the orphan and
181
+ // `invalid-request` is fallback-ineligible, so the run would die right
182
+ // after an otherwise successful compaction. Snap the boundary backward
183
+ // until the preserved slice starts on a non-`tool` message: preserving
184
+ // more than requested is always transcript-safe. (The
185
+ // clear-tool-results strategy is immune by construction - it replaces
186
+ // content in place and never removes messages.)
187
+ while (olderCount > 0 && input.messages[olderCount]?.role === 'tool') {
188
+ olderCount -= 1;
189
+ }
190
+ const olderRaw = input.messages.slice(0, olderCount);
191
+ const preservedTail = input.messages.slice(olderCount);
192
+
193
+ // C4: keep the most recent USER messages verbatim across compaction -
194
+ // only assistant/tool content gets summarized away. User words are the
195
+ // task statement; a paraphrase in the summary routinely drops the
196
+ // constraint that mattered. The carried messages are removed from the
197
+ // summarizer's input (no double-counting) and re-inserted between the
198
+ // summary and the preserved tail, keeping their relative order.
199
+ const preserveUserMessages = Math.max(0, input.strategy.preserveUserMessages ?? 2);
200
+ const carriedUserIndices = new Set<number>();
201
+ if (preserveUserMessages > 0) {
202
+ for (let i = olderRaw.length - 1; i >= 0 && carriedUserIndices.size < preserveUserMessages; ) {
203
+ if (olderRaw[i]?.role === 'user') carriedUserIndices.add(i);
204
+ i -= 1;
205
+ }
206
+ }
207
+ // Degenerate guard: on a tiny (user-dominated) window, carrying users
208
+ // could empty the summarizable slice and turn the whole compaction into
209
+ // a no-op - there, positional summarization wins.
210
+ if (carriedUserIndices.size > 0 && carriedUserIndices.size === olderRaw.length) {
211
+ carriedUserIndices.clear();
212
+ }
213
+ const olderMessages = olderRaw.filter((_, idx) => !carriedUserIndices.has(idx));
214
+ const carriedUserMessages = olderRaw.filter((_, idx) => carriedUserIndices.has(idx));
215
+ const preservedMessages = [...carriedUserMessages, ...preservedTail];
216
+
217
+ if (olderMessages.length === 0) {
218
+ return Object.freeze({
219
+ summary: '',
220
+ summaryTokens: 0,
221
+ beforeTokens,
222
+ afterTokens: beforeTokens,
223
+ droppedMessageIds: Object.freeze([]),
224
+ droppedMessageIndices: Object.freeze([]),
225
+ preservedMessages: Object.freeze([...preservedMessages]),
226
+ trimmedMessages: Object.freeze([...preservedMessages]),
227
+ source: input.source,
228
+ durationMs: now() - startTs,
229
+ hooksFiredCount: 0,
230
+ summaryTrust: 'trusted',
231
+ });
232
+ }
233
+
234
+ const template = {
235
+ preamble: input.localePack.compactionSummaryTemplate.preamble,
236
+ sections: input.localePack.compactionSummaryTemplate.sections,
237
+ };
238
+ const prompt = buildSummarizerPrompt({
239
+ template,
240
+ olderMessages,
241
+ ...(input.strategy.summarizerInputCharBudget !== undefined
242
+ ? { maxDumpChars: input.strategy.summarizerInputCharBudget }
243
+ : {}),
244
+ });
245
+
246
+ const summarizerInput: {
247
+ prompt: string;
248
+ model?: ModelSpec | string;
249
+ signal?: AbortSignal;
250
+ timeoutMs?: number;
251
+ } = { prompt };
252
+ if (summarizerModelInput !== undefined) summarizerInput.model = summarizerModelInput;
253
+ if (input.signal !== undefined) summarizerInput.signal = input.signal;
254
+ if (summarizerTimeoutMs !== undefined) summarizerInput.timeoutMs = summarizerTimeoutMs;
255
+ const summarized = await input.summarizer.summarize(summarizerInput);
256
+
257
+ // Section 9 metadata payload - stable shape so consumers can
258
+ // deserialize and reason about a compaction event.
259
+ const metadata: CompactionMetadataPayload = {
260
+ compactedAtIso: new Date(startTs).toISOString(),
261
+ // CE-14: positional labels, prefixed with the compaction instant so
262
+ // they no longer collide across compaction events (core Message has
263
+ // no id - these are indices into THIS compaction's dropped slice).
264
+ compactedFromMessageIds: Object.freeze(olderMessages.map((_, idx) => `c${startTs}_msg_${idx}`)),
265
+ compactedFromMessageIndices: Object.freeze(olderMessages.map((_, idx) => idx)),
266
+ compactedFromTokens: await countMessageTokens(olderMessages, counter),
267
+ summaryTokens: summarized.usageTokens ?? (await counter.countText(summarized.text)),
268
+ summarizerModel: resolveSummarizerModelLabel(summarizerModelInput, input.summarizer.id),
269
+ templateName: SUMMARY_TEMPLATE_NAME,
270
+ templateVersion: SUMMARY_TEMPLATE_VERSION,
271
+ preserveRecentTurns,
272
+ };
273
+
274
+ // CE-15: the summary must not launder untrusted content into a
275
+ // trusted system message. When the compacted window carried
276
+ // untrusted envelopes - or the injection heuristics flag the
277
+ // summarizer output itself - the LLM-authored body is committed
278
+ // inside a derived-trust envelope (sticky across re-compactions:
279
+ // the envelope re-triggers this detection next time around).
280
+ const summaryScan = scanImperativePatterns(summarized.text, undefined, COMPACTION_SCAN_BUDGET_MS);
281
+ const summaryTrust = resolveSummaryTrust(windowContainsUntrusted(olderMessages), summaryScan);
282
+ const finalSummary = renderFinalSummary({
283
+ template,
284
+ summaryFromLlm:
285
+ summaryTrust === 'untrusted-derived'
286
+ ? wrapSummaryAsDerived(summarized.text)
287
+ : summarized.text,
288
+ preservedMessages,
289
+ metadata,
290
+ });
291
+ const summaryTokens = await counter.countText(finalSummary);
292
+
293
+ const summaryMessage: SystemMessage = {
294
+ role: 'system',
295
+ content: finalSummary,
296
+ };
297
+ const trimmedMessages: ReadonlyArray<Message> = Object.freeze([
298
+ summaryMessage,
299
+ ...preservedMessages,
300
+ ]);
301
+ const afterTokens = await countMessageTokens(trimmedMessages, counter);
302
+ const durationMs = now() - startTs;
303
+
304
+ return Object.freeze({
305
+ summary: finalSummary,
306
+ summaryTokens,
307
+ beforeTokens,
308
+ afterTokens,
309
+ droppedMessageIds: metadata.compactedFromMessageIds,
310
+ droppedMessageIndices: metadata.compactedFromMessageIndices,
311
+ preservedMessages: Object.freeze([...preservedMessages]),
312
+ trimmedMessages,
313
+ source: input.source,
314
+ durationMs,
315
+ hooksFiredCount: 0,
316
+ summaryTrust,
317
+ });
318
+ }
319
+
320
+ /**
321
+ * SOTA-1: clear the oldest tool results (zero-LLM), then summarize only if the
322
+ * cleared buffer is still over the threshold. The summarizer runs on the
323
+ * already-reduced window, so a few-tool-result buffer compacts for free.
324
+ */
325
+ async function executeClearStrategy(
326
+ input: ExecuteCompactionInput,
327
+ strategy: Extract<CompactionStrategy, { kind: 'clear-old-tool-results' }>,
328
+ counter: ContextTokenCounter,
329
+ now: () => number,
330
+ startTs: number,
331
+ beforeTokens: number,
332
+ ): Promise<CompactionResult> {
333
+ const outcome = await clearOldToolResults(
334
+ input.messages,
335
+ {
336
+ ...(strategy.keepToolUses !== undefined ? { keepToolUses: strategy.keepToolUses } : {}),
337
+ ...(strategy.clearAtLeast !== undefined ? { clearAtLeast: strategy.clearAtLeast } : {}),
338
+ ...(strategy.excludeTools !== undefined ? { excludeTools: strategy.excludeTools } : {}),
339
+ ...(strategy.externalize !== undefined ? { externalize: strategy.externalize } : {}),
340
+ ...(strategy.clearToolInputs !== undefined
341
+ ? { clearToolInputs: strategy.clearToolInputs }
342
+ : {}),
343
+ ...(strategy.readResultToolName !== undefined
344
+ ? { readResultToolName: strategy.readResultToolName }
345
+ : {}),
346
+ },
347
+ counter,
348
+ );
349
+ const afterClearTokens = await countMessageTokens(outcome.messages, counter);
350
+
351
+ const fallback = strategy.summarizeFallback;
352
+ if (fallback !== false && afterClearTokens > input.thresholdTokens) {
353
+ // Clearing did not reclaim enough - summarize the ALREADY-cleared buffer.
354
+ const summarized = await executeCompaction({
355
+ ...input,
356
+ messages: outcome.messages,
357
+ strategy: { kind: 'summarize-old-preserve-recent', ...(fallback ?? {}) },
358
+ });
359
+ return Object.freeze({
360
+ ...summarized,
361
+ // Report against the original buffer + carry the cleared indices.
362
+ beforeTokens,
363
+ droppedMessageIndices: Object.freeze([
364
+ ...outcome.clearedIndices,
365
+ ...summarized.droppedMessageIndices.filter((i) => !outcome.clearedIndices.includes(i)),
366
+ ]),
367
+ durationMs: now() - startTs,
368
+ });
369
+ }
370
+
371
+ // Zero-LLM clear-only outcome: content replaced in place, nothing summarized.
372
+ const clearedSet = new Set(outcome.clearedIndices);
373
+ const preservedMessages = outcome.messages.filter((_, i) => !clearedSet.has(i));
374
+ return Object.freeze({
375
+ summary: '',
376
+ summaryTokens: 0,
377
+ beforeTokens,
378
+ afterTokens: afterClearTokens,
379
+ droppedMessageIds: Object.freeze(outcome.clearedIndices.map((i) => `c${startTs}_cleared_${i}`)),
380
+ droppedMessageIndices: Object.freeze([...outcome.clearedIndices]),
381
+ preservedMessages: Object.freeze(preservedMessages),
382
+ trimmedMessages: Object.freeze([...outcome.messages]),
383
+ source: input.source,
384
+ durationMs: now() - startTs,
385
+ hooksFiredCount: 0,
386
+ summaryTrust: 'trusted',
387
+ });
388
+ }
389
+
390
+ function resolveSummarizerModelLabel(
391
+ modelInput: ModelSpec | string | undefined,
392
+ summarizerId: string | undefined,
393
+ ): string | null {
394
+ if (typeof modelInput === 'string') return modelInput;
395
+ if (modelInput !== undefined) {
396
+ const provider =
397
+ 'provider' in modelInput
398
+ ? `${modelInput.provider.name}:${modelInput.model}`
399
+ : `${modelInput.name}:${modelInput.modelId}`;
400
+ return provider;
401
+ }
402
+ return summarizerId ?? null;
403
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `reanchorPersonaBlock({ blockLabel = 'persona' })` - built-in
3
+ * post-compaction hook. Queries `memory.working.read(...)` for the
4
+ * persona block and returns it as a system-content fragment for
5
+ * re-injection.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { MessageContent } from '@graphorin/core';
11
+ import type { HookDeps, NamedPostCompactionHook } from './types.js';
12
+
13
+ /**
14
+ * Build a `reanchorPersonaBlock` hook.
15
+ *
16
+ * @stable
17
+ */
18
+ export function reanchorPersonaBlock(
19
+ options: { readonly blockLabel?: string } = {},
20
+ ): NamedPostCompactionHook {
21
+ const blockLabel = options.blockLabel ?? 'persona';
22
+ return {
23
+ id: 'reanchorPersonaBlock',
24
+ async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
25
+ const value = await deps.memory.working.read(deps.scope, blockLabel);
26
+ if (value === null || value.length === 0) return [];
27
+ // context-engine-02: the D2 privacy filter gates re-anchored content
28
+ // exactly as it gates the assembled prompt - a secret-tier persona
29
+ // block the assembly withheld must not ship to the provider via the
30
+ // post-compaction splice. `read()` returns only the value, so the
31
+ // sensitivity comes from the block listing; a block absent from the
32
+ // listing (unwritten, default-value fallback) is evaluated at the
33
+ // engine's default sensitivity.
34
+ if (deps.allowSensitivity !== undefined) {
35
+ const blocks = await deps.memory.working.list(deps.scope);
36
+ const block = blocks.find((b) => b.label === blockLabel);
37
+ if (!deps.allowSensitivity(block?.sensitivity)) return [];
38
+ }
39
+ return [
40
+ {
41
+ type: 'text',
42
+ text: `<memory_blocks anchor="post-compaction">\n <block label="${escapeXml(blockLabel)}">\n ${escapeXml(value)}\n </block>\n</memory_blocks>`,
43
+ },
44
+ ];
45
+ },
46
+ };
47
+ }
48
+
49
+ function escapeXml(value: string): string {
50
+ return value
51
+ .replace(/&/g, '&amp;')
52
+ .replace(/</g, '&lt;')
53
+ .replace(/>/g, '&gt;')
54
+ .replace(/"/g, '&quot;');
55
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `reanchorPinnedFacts({ pinnedFactIds, maxTokens })` - built-in
3
+ * post-compaction hook. Looks up the explicitly-pinned fact IDs
4
+ * via `memory.semantic.get(...)` and returns them as a
5
+ * system-content fragment with their tags.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { MessageContent } from '@graphorin/core';
11
+ import { type ContextTokenCounter, HEURISTIC_TOKEN_COUNTER } from '../../token-counter.js';
12
+ import type { HookDeps, NamedPostCompactionHook } from './types.js';
13
+
14
+ /**
15
+ * Build a `reanchorPinnedFacts` hook.
16
+ *
17
+ * @stable
18
+ */
19
+ export function reanchorPinnedFacts(options: {
20
+ readonly pinnedFactIds: ReadonlyArray<string>;
21
+ readonly maxTokens?: number;
22
+ readonly tokenCounter?: ContextTokenCounter;
23
+ }): NamedPostCompactionHook {
24
+ const ids = options.pinnedFactIds;
25
+ const maxTokens = Math.max(0, options.maxTokens ?? 2000);
26
+ const counter = options.tokenCounter ?? HEURISTIC_TOKEN_COUNTER;
27
+ return {
28
+ id: 'reanchorPinnedFacts',
29
+ async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
30
+ if (ids.length === 0) return [];
31
+ const lines = ['<memory_pinned_facts anchor="post-compaction">'];
32
+ let runningTokens = 0;
33
+ for (const id of ids) {
34
+ const fact = await deps.memory.semantic.get(deps.scope, id).catch(() => null);
35
+ if (fact === null || fact.deletedAt !== undefined) continue;
36
+ // context-engine-02: pinning is not a privacy override - a
37
+ // secret-tier fact the D2 filter withholds from the assembled
38
+ // prompt must not re-enter via the post-compaction splice.
39
+ if (deps.allowSensitivity !== undefined && !deps.allowSensitivity(fact.sensitivity)) {
40
+ continue;
41
+ }
42
+ const tagBlob =
43
+ fact.tags !== undefined ? ` tags="${escapeXml((fact.tags ?? []).join(','))}"` : '';
44
+ const fragment = ` <fact id="${escapeXml(fact.id)}"${tagBlob}>${escapeXml(fact.text)}</fact>`;
45
+ const fragmentTokens = await counter.countText(fragment);
46
+ if (maxTokens > 0 && runningTokens + fragmentTokens > maxTokens) {
47
+ lines.push(' <!-- additional pinned facts truncated to fit budget -->');
48
+ break;
49
+ }
50
+ runningTokens += fragmentTokens;
51
+ lines.push(fragment);
52
+ }
53
+ if (lines.length === 1) return [];
54
+ lines.push('</memory_pinned_facts>');
55
+ return [{ type: 'text', text: lines.join('\n') }];
56
+ },
57
+ };
58
+ }
59
+
60
+ function escapeXml(value: string): string {
61
+ return value
62
+ .replace(/&/g, '&amp;')
63
+ .replace(/</g, '&lt;')
64
+ .replace(/>/g, '&gt;')
65
+ .replace(/"/g, '&quot;');
66
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `reanchorProjectRules({ ruleTagsAllowlist? })` - built-in
3
+ * post-compaction hook. Queries `memory.procedural.activate(...)`
4
+ * and returns the active rule set as a system-content fragment for
5
+ * re-injection into the trimmed buffer.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { MessageContent } from '@graphorin/core';
11
+ import type { HookDeps, NamedPostCompactionHook } from './types.js';
12
+
13
+ /**
14
+ * Build a `reanchorProjectRules` hook.
15
+ *
16
+ * @stable
17
+ */
18
+ export function reanchorProjectRules(
19
+ options: { readonly ruleTagsAllowlist?: ReadonlyArray<string> } = {},
20
+ ): NamedPostCompactionHook {
21
+ const allowlist =
22
+ options.ruleTagsAllowlist !== undefined ? new Set(options.ruleTagsAllowlist) : null;
23
+ return {
24
+ id: 'reanchorProjectRules',
25
+ async resolveContent(deps: HookDeps): Promise<ReadonlyArray<MessageContent>> {
26
+ const procedural = deps.procedural ?? {};
27
+ const rules = await deps.memory.procedural.activate(deps.scope, procedural);
28
+ // context-engine-02: apply the same D2 privacy decision `assemble()`
29
+ // applies to active rules - the hook's output ships to the provider.
30
+ const visible =
31
+ deps.allowSensitivity === undefined
32
+ ? rules
33
+ : rules.filter((rule) => deps.allowSensitivity?.(rule.sensitivity) === true);
34
+ const filtered =
35
+ allowlist === null
36
+ ? visible
37
+ : visible.filter((rule) => (rule.tags ?? []).some((tag) => allowlist.has(tag)));
38
+ if (filtered.length === 0) return [];
39
+ const lines = ['<memory_rules anchor="post-compaction">'];
40
+ for (const rule of filtered) {
41
+ lines.push(
42
+ ` <rule priority="${rule.priority}" tags="${escapeXml((rule.tags ?? []).join(','))}">${escapeXml(rule.text)}</rule>`,
43
+ );
44
+ }
45
+ lines.push('</memory_rules>');
46
+ return [{ type: 'text', text: lines.join('\n') }];
47
+ },
48
+ };
49
+ }
50
+
51
+ function escapeXml(value: string): string {
52
+ return value
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;');
57
+ }