@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,319 @@
1
+ /**
2
+ * Priority-ordered token-budget allocator. The allocator runs over the six
3
+ * layers of the {@link ContextEngine}'s system prompt and trims the
4
+ * lowest-priority layers first when the combined cost exceeds the configured
5
+ * budget.
6
+ *
7
+ * This **truncation priority** is deliberately separate from the **emission
8
+ * order** (CE-9): the engine emits the surviving layers in a stable order
9
+ * (identity → rules → blocks → skills → [KV-cache breakpoint] → metadata →
10
+ * auto-recall) regardless of which were trimmed, so the cacheable prefix stays
11
+ * byte-stable across turns. This ladder governs only *what gets dropped first*.
12
+ *
13
+ * Priority (configurable), highest preserved → first truncated:
14
+ *
15
+ * 1. **identity** - Layer 1 (`graphorin_memory_base` + agent
16
+ * instructions). Always preserved; truncated last.
17
+ * 2. **memoryMetadata** - Layer 5. Small (~100-300 tokens).
18
+ * 3. **activeRules** - Layer 4 procedural rules.
19
+ * 4. **workingBlocks** - Layer 3.
20
+ * 5. **activeSkills** - Layer 4 skills metadata cards.
21
+ * 6. **autoRecall** - Layer 6. Lowest priority; truncated first.
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+
26
+ import type { ContextTokenCounter } from './token-counter.js';
27
+
28
+ /**
29
+ * Layer-id discriminator. Mirrors the documented Layer 1-6
30
+ * structure of the layered system prompt.
31
+ *
32
+ * @stable
33
+ */
34
+ export type LayerId =
35
+ | 'identity'
36
+ | 'memoryMetadata'
37
+ | 'activeRules'
38
+ | 'workingBlocks'
39
+ | 'activeSkills'
40
+ | 'autoRecall';
41
+
42
+ /**
43
+ * Default priority ladder. Higher priority means the layer is
44
+ * preserved longer when the budget is tight.
45
+ *
46
+ * @stable
47
+ */
48
+ export const DEFAULT_LAYER_PRIORITY: ReadonlyArray<LayerId> = Object.freeze([
49
+ 'identity',
50
+ 'memoryMetadata',
51
+ 'activeRules',
52
+ 'workingBlocks',
53
+ 'activeSkills',
54
+ 'autoRecall',
55
+ ]);
56
+
57
+ /**
58
+ * Single layer candidate fed to {@link allocate}. The `text` field
59
+ * carries the rendered fragment; `cap` is the optional per-layer
60
+ * upper bound (in tokens); `priority` overrides the default ladder
61
+ * for advanced use cases.
62
+ *
63
+ * @stable
64
+ */
65
+ export interface LayerCandidate {
66
+ readonly id: LayerId;
67
+ readonly text: string;
68
+ readonly cap?: number;
69
+ readonly priority?: number;
70
+ }
71
+
72
+ /**
73
+ * Per-layer truncation mode used when the layer overflows its cap
74
+ * or the global budget. `truncate` is the default; `drop` removes
75
+ * the layer entirely when it would otherwise overflow.
76
+ *
77
+ * @stable
78
+ */
79
+ export type OverflowMode = 'truncate' | 'drop';
80
+
81
+ /**
82
+ * Output of {@link allocate} - one entry per surviving layer in
83
+ * priority order, plus a `truncated` flag for observability.
84
+ *
85
+ * @stable
86
+ */
87
+ export interface LayerAllocation {
88
+ readonly id: LayerId;
89
+ readonly text: string;
90
+ readonly tokens: number;
91
+ readonly truncated: boolean;
92
+ readonly droppedTokens: number;
93
+ }
94
+
95
+ /**
96
+ * Result of an allocator run.
97
+ *
98
+ * @stable
99
+ */
100
+ export interface AllocationResult {
101
+ readonly layers: ReadonlyArray<LayerAllocation>;
102
+ readonly totalTokens: number;
103
+ readonly budgetTokens: number;
104
+ readonly overflowDropped: ReadonlyArray<LayerId>;
105
+ }
106
+
107
+ /**
108
+ * Tags opened-but-not-closed in `text`, innermost-first. A heuristic
109
+ * scanner sized for the engine's own XML-ish layer markup
110
+ * (`<memory_blocks>`, `<memory_rules>`, ...) - self-closing tags are
111
+ * skipped and an unmatched closer is ignored.
112
+ */
113
+ function unclosedTags(text: string): string[] {
114
+ const stack: string[] = [];
115
+ const re = /<(\/?)([A-Za-z_][\w.-]*)(?:\s[^<>]*)?(\/?)>/g;
116
+ let match = re.exec(text);
117
+ while (match !== null) {
118
+ const [, closing, name, selfClosing] = match;
119
+ if (selfClosing !== '/' && name !== undefined) {
120
+ if (closing === '/') {
121
+ const idx = stack.lastIndexOf(name);
122
+ if (idx !== -1) stack.splice(idx, 1);
123
+ } else {
124
+ stack.push(name);
125
+ }
126
+ }
127
+ match = re.exec(text);
128
+ }
129
+ return stack.reverse();
130
+ }
131
+
132
+ /**
133
+ * Snap a prefix cut to a structure-safe point (CE-16e): never end
134
+ * inside a partially-emitted tag (`...<memory_blo`).
135
+ */
136
+ function snapCut(kept: string): string {
137
+ const lastOpen = kept.lastIndexOf('<');
138
+ return lastOpen > kept.lastIndexOf('>') ? kept.slice(0, lastOpen) : kept;
139
+ }
140
+
141
+ /**
142
+ * Truncate `text` to fit `maxTokens`, preserving the leading
143
+ * portion and replacing the trailing portion with the literal
144
+ * `[...truncated]` marker. The token estimate is computed via the
145
+ * supplied `counter`; truncation falls back to character-based
146
+ * trimming when the estimate is non-monotonic.
147
+ *
148
+ * Structure-aware (CE-16e): the cut never splits a tag, and block
149
+ * tags the cut leaves open are re-closed after the marker, so a
150
+ * capped layer of XML-ish markup (e.g. `<memory_blocks>`) stays
151
+ * well-formed in the assembled prompt. Plain strings are unaffected.
152
+ *
153
+ * @stable
154
+ */
155
+ export async function truncateToTokens(
156
+ text: string,
157
+ maxTokens: number,
158
+ counter: ContextTokenCounter,
159
+ ): Promise<{ readonly text: string; readonly tokens: number; readonly truncated: boolean }> {
160
+ if (maxTokens <= 0) return { text: '', tokens: 0, truncated: text.length > 0 };
161
+ const initial = await counter.countText(text);
162
+ if (initial <= maxTokens) return { text, tokens: initial, truncated: false };
163
+ const marker = '\n[...truncated]';
164
+ const markerTokens = await counter.countText(marker);
165
+ const targetTokens = Math.max(0, maxTokens - markerTokens);
166
+ if (targetTokens <= 0) return { text: marker, tokens: markerTokens, truncated: true };
167
+ // Approximate the cut point via the inverse of the heuristic
168
+ // chars-per-token ratio. Falls back to repeated halving when the
169
+ // ratio is non-monotonic for the supplied counter.
170
+ let lo = 0;
171
+ let hi = text.length;
172
+ let bestCut = 0;
173
+ while (lo <= hi) {
174
+ const mid = (lo + hi) >>> 1;
175
+ const slice = text.slice(0, mid);
176
+ const tokens = await counter.countText(slice);
177
+ if (tokens <= targetTokens) {
178
+ bestCut = mid;
179
+ lo = mid + 1;
180
+ } else {
181
+ hi = mid - 1;
182
+ }
183
+ }
184
+ let kept = snapCut(text.slice(0, bestCut));
185
+ let closers = unclosedTags(kept)
186
+ .map((t) => `</${t}>`)
187
+ .join('');
188
+ let truncated = `${kept}${marker}${closers.length > 0 ? `\n${closers}` : ''}`;
189
+ let tokens = await counter.countText(truncated);
190
+ // Re-closing may push past the cap - shave the kept body until the
191
+ // whole thing (body + marker + closers) fits. Terminates: `kept`
192
+ // shrinks by ≥1 char per pass, and the empty body fits by the
193
+ // `targetTokens > 0` guard above.
194
+ while (tokens > maxTokens && kept.length > 0) {
195
+ kept = snapCut(kept.slice(0, kept.length - Math.ceil(kept.length * 0.1)));
196
+ closers = unclosedTags(kept)
197
+ .map((t) => `</${t}>`)
198
+ .join('');
199
+ truncated = `${kept}${marker}${closers.length > 0 ? `\n${closers}` : ''}`;
200
+ tokens = await counter.countText(truncated);
201
+ }
202
+ return { text: truncated, tokens, truncated: true };
203
+ }
204
+
205
+ /**
206
+ * Run the allocator. Layers are sorted by priority ascending (the
207
+ * first layer is the highest priority); when the running total
208
+ * exceeds the budget, lower-priority layers are first capped at
209
+ * their per-layer `cap` (when set) and finally truncated to
210
+ * whatever fits.
211
+ *
212
+ * @stable
213
+ */
214
+ export async function allocate(
215
+ layers: ReadonlyArray<LayerCandidate>,
216
+ budgetTokens: number,
217
+ counter: ContextTokenCounter,
218
+ options: {
219
+ readonly priority?: ReadonlyArray<LayerId>;
220
+ readonly overflowMode?: OverflowMode;
221
+ } = {},
222
+ ): Promise<AllocationResult> {
223
+ const priorityOrder = options.priority ?? DEFAULT_LAYER_PRIORITY;
224
+ const overflowMode = options.overflowMode ?? 'truncate';
225
+ const orderIndex = new Map<LayerId, number>();
226
+ priorityOrder.forEach((id, idx) => {
227
+ orderIndex.set(id, idx);
228
+ });
229
+ const sorted = [...layers].sort((a, b) => {
230
+ const aPriority = a.priority ?? orderIndex.get(a.id) ?? 999;
231
+ const bPriority = b.priority ?? orderIndex.get(b.id) ?? 999;
232
+ return aPriority - bPriority;
233
+ });
234
+
235
+ // First pass: enforce per-layer caps (regardless of total budget).
236
+ const capped: Array<{
237
+ readonly candidate: LayerCandidate;
238
+ text: string;
239
+ tokens: number;
240
+ truncated: boolean;
241
+ }> = [];
242
+ for (const candidate of sorted) {
243
+ if (candidate.text.length === 0) {
244
+ capped.push({ candidate, text: '', tokens: 0, truncated: false });
245
+ continue;
246
+ }
247
+ if (candidate.cap !== undefined && candidate.cap >= 0) {
248
+ const trimmed = await truncateToTokens(candidate.text, candidate.cap, counter);
249
+ capped.push({
250
+ candidate,
251
+ text: trimmed.text,
252
+ tokens: trimmed.tokens,
253
+ truncated: trimmed.truncated,
254
+ });
255
+ } else {
256
+ const tokens = await counter.countText(candidate.text);
257
+ capped.push({ candidate, text: candidate.text, tokens, truncated: false });
258
+ }
259
+ }
260
+
261
+ // Second pass: enforce the global budget. Walk the layers in
262
+ // priority order, accumulating tokens; when the running total
263
+ // exceeds the budget, the rest are dropped or truncated to fit.
264
+ const budget = Math.max(0, budgetTokens);
265
+ const layersOut: LayerAllocation[] = [];
266
+ const overflowDropped: LayerId[] = [];
267
+ let runningTotal = 0;
268
+ for (const entry of capped) {
269
+ if (entry.text.length === 0) {
270
+ layersOut.push({
271
+ id: entry.candidate.id,
272
+ text: '',
273
+ tokens: 0,
274
+ truncated: false,
275
+ droppedTokens: 0,
276
+ });
277
+ continue;
278
+ }
279
+ if (runningTotal + entry.tokens <= budget) {
280
+ runningTotal += entry.tokens;
281
+ layersOut.push({
282
+ id: entry.candidate.id,
283
+ text: entry.text,
284
+ tokens: entry.tokens,
285
+ truncated: entry.truncated,
286
+ droppedTokens: 0,
287
+ });
288
+ continue;
289
+ }
290
+ const remaining = Math.max(0, budget - runningTotal);
291
+ if (overflowMode === 'drop' || remaining === 0) {
292
+ overflowDropped.push(entry.candidate.id);
293
+ layersOut.push({
294
+ id: entry.candidate.id,
295
+ text: '',
296
+ tokens: 0,
297
+ truncated: false,
298
+ droppedTokens: entry.tokens,
299
+ });
300
+ continue;
301
+ }
302
+ const trimmed = await truncateToTokens(entry.text, remaining, counter);
303
+ runningTotal += trimmed.tokens;
304
+ layersOut.push({
305
+ id: entry.candidate.id,
306
+ text: trimmed.text,
307
+ tokens: trimmed.tokens,
308
+ truncated: true,
309
+ droppedTokens: entry.tokens - trimmed.tokens,
310
+ });
311
+ }
312
+
313
+ return Object.freeze({
314
+ layers: Object.freeze(layersOut),
315
+ totalTokens: runningTotal,
316
+ budgetTokens: budget,
317
+ overflowDropped: Object.freeze(overflowDropped),
318
+ });
319
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Token-counting helpers used by the {@link ContextEngine}. The
3
+ * production path accepts a {@link TokenCounter} from
4
+ * `@graphorin/core` (the default `JsTiktokenCounter` lives in
5
+ * `@graphorin/provider`); the fallback path is a deterministic
6
+ * heuristic that keeps unit tests dependency-free.
7
+ *
8
+ * The fallback counts ~1 token per 4 characters - close enough for
9
+ * budgeting decisions, intentionally less precise than a real BPE
10
+ * tokenizer. Production deployments wire a real
11
+ * {@link TokenCounter} through `createMemory({ contextEngine: {
12
+ * tokenCounter: ... } })`.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+
17
+ import type { Message, TokenCounter } from '@graphorin/core';
18
+
19
+ /**
20
+ * Pluggable text-token counter used inside the ContextEngine. The
21
+ * surface is narrower than {@link TokenCounter} because the engine
22
+ * never assembles a message list during the budget-allocation
23
+ * phase - it operates on rendered text fragments.
24
+ *
25
+ * @stable
26
+ */
27
+ export interface ContextTokenCounter {
28
+ readonly id: string;
29
+ countText(text: string): Promise<number>;
30
+ }
31
+
32
+ /**
33
+ * Script-aware heuristic (CE-13). The flat chars/4 rule undercounts
34
+ * non-Latin scripts ~4x - real tokenizers emit roughly one token per
35
+ * CJK character (and per kana / hangul syllable), so a 200k-window
36
+ * threshold expressed in "heuristic tokens" let CJK conversations blow
37
+ * past the provider limit before compaction ever fired. Characters
38
+ * outside the Latin-ish range count at one token each; Latin text keeps
39
+ * the classic chars/4. Still a heuristic - wire a real `TokenCounter`
40
+ * (e.g. the provider package's JsTiktokenCounter) for production
41
+ * accuracy.
42
+ */
43
+ const DENSE_SCRIPT_RE =
44
+ // CJK unified + extensions, kana, hangul, CJK punctuation, full-width forms.
45
+ /[ᄀ-ᇿ⺀-〿぀-ヿ㄰-㆏ㇰ-䶿一-鿿ꥠ-꥿가-퟿豈-﫿＀-￯]/g;
46
+
47
+ /**
48
+ * Built-in heuristic counter - chars/4 for Latin-ish text plus one
49
+ * token per dense-script (CJK/kana/hangul) character (CE-13). Stable
50
+ * default when the operator does not pass a real {@link TokenCounter}.
51
+ *
52
+ * @stable
53
+ */
54
+ export const HEURISTIC_TOKEN_COUNTER: ContextTokenCounter = Object.freeze({
55
+ id: 'graphorin/heuristic@chars-per-4+dense-script',
56
+ async countText(text: string): Promise<number> {
57
+ if (typeof text !== 'string' || text.length === 0) return 0;
58
+ const dense = text.match(DENSE_SCRIPT_RE)?.length ?? 0;
59
+ const latin = text.length - dense;
60
+ return Math.ceil(latin / 4) + dense;
61
+ },
62
+ });
63
+
64
+ /**
65
+ * Wrap a real {@link TokenCounter} into the narrower
66
+ * {@link ContextTokenCounter} surface - PRESERVING the native
67
+ * message-level `count(messages)` (context-engine-03). The adapter
68
+ * used to keep only `countText`, which forced
69
+ * {@link countMessageTokens} onto the per-message render path for
70
+ * every real counter; combined with `renderMessageText` ignoring
71
+ * tool calls, tool-call arguments contributed zero to every trigger /
72
+ * before / after count while the provider billed for them.
73
+ *
74
+ * @stable
75
+ */
76
+ export function adaptTokenCounter(
77
+ counter: TokenCounter,
78
+ ): ContextTokenCounter & Pick<TokenCounter, 'count'> {
79
+ return Object.freeze({
80
+ id: counter.id,
81
+ count: (messages: ReadonlyArray<Message>): Promise<number> => counter.count(messages),
82
+ async countText(text: string): Promise<number> {
83
+ if (typeof text !== 'string' || text.length === 0) return 0;
84
+ return counter.countText(text);
85
+ },
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Count tokens across a message list using a {@link TokenCounter}
91
+ * (or the heuristic fallback). Used by the trigger-evaluation hot
92
+ * path of the auto-compaction subsystem (RB-46) at the top of every
93
+ * step.
94
+ *
95
+ * @stable
96
+ */
97
+ export async function countMessageTokens(
98
+ messages: ReadonlyArray<Message>,
99
+ counter: TokenCounter | ContextTokenCounter,
100
+ ): Promise<number> {
101
+ if ('count' in counter && typeof (counter as TokenCounter).count === 'function') {
102
+ return (counter as TokenCounter).count(messages);
103
+ }
104
+ let total = 0;
105
+ for (const message of messages) {
106
+ total += await counter.countText(renderMessageText(message));
107
+ }
108
+ return total;
109
+ }
110
+
111
+ /**
112
+ * Render a `Message` into a single textual approximation suitable
113
+ * for token counting. Multimodal parts other than `'text'` /
114
+ * `'reasoning'` contribute a constant approximation so the counter
115
+ * does not silently under-count. Assistant tool calls render their
116
+ * name + serialized args (context-engine-03) - file writes and
117
+ * `code_execute` scripts are frequently the dominant tokens of an
118
+ * agentic step, and the provider serializes + counts them, so the
119
+ * engine's arithmetic must too (mirrors the provider-side
120
+ * `serialiseMessageForCount`).
121
+ *
122
+ * @stable
123
+ */
124
+ export function renderMessageText(message: Message): string {
125
+ if (message.role === 'system') return message.content;
126
+ const content = message.content;
127
+ let out = '';
128
+ if (typeof content === 'string') {
129
+ out = content;
130
+ } else {
131
+ for (const part of content) {
132
+ if (part.type === 'text' || part.type === 'reasoning') {
133
+ out += `${part.text}\n`;
134
+ } else {
135
+ // Approximate non-text parts (image / audio / file) as a small
136
+ // fixed cost so the counter does not silently under-count.
137
+ out += '[non-text-part]\n';
138
+ }
139
+ }
140
+ }
141
+ if (message.role === 'assistant' && message.toolCalls !== undefined) {
142
+ for (const call of message.toolCalls) {
143
+ const args = typeof call.args === 'string' ? call.args : safeStringifyArgs(call.args);
144
+ out += `\n[tool-call:${call.toolName}] ${args}`;
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** JSON-stringify tool args without ever throwing (circular → constant). */
151
+ function safeStringifyArgs(args: unknown): string {
152
+ try {
153
+ return JSON.stringify(args) ?? '';
154
+ } catch {
155
+ return '[unserializable-args]';
156
+ }
157
+ }