@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,1049 @@
1
+ /**
2
+ * Consolidator runtime - orchestrates the three phases, the lock,
3
+ * the budget, the cursor, and the DLQ. The factory is the public
4
+ * surface; the class lives below it as a private implementation
5
+ * detail.
6
+ *
7
+ * The runtime is **library-mode** by default - it does not start a
8
+ * background scheduler. Cron / idle triggers are realised by
9
+ * registering them with a `@graphorin/triggers` scheduler via
10
+ * `registerWithScheduler(...)` (the standalone server calls this in
11
+ * `beforeStart` whenever a consolidator + a triggers scheduler are both
12
+ * supplied). Turn / event triggers are consumer-emitted - the scheduler
13
+ * cannot fire them on its own, so the consumer must call
14
+ * `trigger({ kind: 'turn' | 'event' }, ...)` from its own loop. Manual
15
+ * `trigger(...)` / `fireNow(...)` calls work in either mode.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import type { Provider, SessionScope, Tracer } from '@graphorin/core';
21
+ import { NOOP_TRACER } from '@graphorin/core';
22
+ import type {
23
+ ConsolidatorMemoryStoreExt,
24
+ DlqBatchRow,
25
+ MemoryStoreAdapter,
26
+ } from '../internal/storage-adapter.js';
27
+ import type { EpisodicMemory } from '../tiers/episodic-memory.js';
28
+ import type { SemanticMemory } from '../tiers/semantic-memory.js';
29
+ import type { WorkingMemory } from '../tiers/working-memory.js';
30
+ import { BudgetTracker } from './budget.js';
31
+ import { DEFAULT_SALIENCE_WEIGHTS } from './decay.js';
32
+ import { classifyError, describeError, nextBackoffMs } from './dlq.js';
33
+ import { CustomTierMisconfiguredError, ProviderNotConfiguredError } from './errors.js';
34
+ import { tipMessageId } from './idempotency.js';
35
+ import { LockManager, scopeKey } from './lock.js';
36
+ import type { NoiseFilterPreset } from './noise-filter.js';
37
+ import { runDeepPhase } from './phases/deep.js';
38
+ import { runLearnedContextPass } from './phases/learned-context.js';
39
+ import { runLightPhase } from './phases/light.js';
40
+ import { runReflectionPass } from './phases/reflect.js';
41
+ import { runStandardPhase } from './phases/standard.js';
42
+ import {
43
+ type RegisterTriggersResult,
44
+ registerConsolidatorTriggers,
45
+ type SchedulerLike,
46
+ } from './scheduler.js';
47
+ import {
48
+ CONSOLIDATOR_TIER_DEFAULTS,
49
+ type ConsolidatorConfig,
50
+ type ConsolidatorLastRuns,
51
+ type ConsolidatorPhase,
52
+ type ConsolidatorStatus,
53
+ type ConsolidatorTier,
54
+ type ConsolidatorTriggerReason,
55
+ type ConsolidatorTriggerSpec,
56
+ type CreateConsolidatorOptions,
57
+ type PhaseListener,
58
+ type PhaseOutcome,
59
+ } from './types.js';
60
+
61
+ /**
62
+ * Age after which an unresolved CONFLICT-CHECK row is expired as
63
+ * `'admit'` at tiers with no deep phase (memory-consolidation-04) -
64
+ * the judge that would resolve it never runs there.
65
+ */
66
+ const PENDING_CONFLICT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
67
+
68
+ /**
69
+ * Consolidator runtime surface returned by {@link createConsolidator}.
70
+ * Compatible with the placeholder shape so the facade can swap the
71
+ * implementation without breaking consumers.
72
+ *
73
+ * @stable
74
+ */
75
+ export interface Consolidator {
76
+ start(): Promise<void>;
77
+ stop(): Promise<void>;
78
+ trigger(reason: ConsolidatorTriggerReason, scope: SessionScope): Promise<PhaseOutcome | null>;
79
+ status(): Promise<ConsolidatorStatus>;
80
+ /**
81
+ * Manual trigger for the requested phase. Skips phase gating + the
82
+ * idle/cron scheduler so admins can flush the queue on demand.
83
+ */
84
+ fireNow(phase: ConsolidatorPhase, scope?: SessionScope): Promise<PhaseOutcome | null>;
85
+ /** Replace the active tier - recomputes ceilings + phase set. */
86
+ setTier(tier: ConsolidatorTier): Promise<void>;
87
+ /** Pause the consolidator until the next budget reset. */
88
+ pause(): Promise<void>;
89
+ /** Resume after `pause()`. */
90
+ resume(): Promise<void>;
91
+ /** Subscribe to phase-finished notifications. Returns an unsubscribe. */
92
+ onPhaseFinished(listener: PhaseListener): () => void;
93
+ /**
94
+ * Record memory-pipeline LLM spend that happened OUTSIDE a phase run
95
+ * (MCON-15 - e.g. workflow induction) so the daily ceilings cover it.
96
+ * Counted under the deep-phase bucket.
97
+ */
98
+ recordExternalSpend(tokens: number, costUsd?: number): void;
99
+ /** Active config - frozen snapshot. */
100
+ config(): ConsolidatorConfig;
101
+ /**
102
+ * Register this consolidator's cron / idle triggers with a
103
+ * `@graphorin/triggers` scheduler so they fire `trigger(...)`
104
+ * automatically (the daemon ↔ triggers bridge - MCON-4). Uses the
105
+ * configured `defaultScope`; throws if none was set. Turn / event
106
+ * triggers are skipped (consumer-emitted). The standalone server calls
107
+ * this in `beforeStart`.
108
+ */
109
+ registerWithScheduler(scheduler: SchedulerLike): Promise<RegisterTriggersResult>;
110
+ /** True when `tier === 'free'`. */
111
+ isFree(): boolean;
112
+ /** Drain DLQ rows whose `nextRetryAt` <= now. */
113
+ drainDlq(scope: SessionScope): Promise<number>;
114
+ }
115
+
116
+ /**
117
+ * Build the runtime consolidator.
118
+ *
119
+ * @stable
120
+ */
121
+ export function createConsolidator(opts: CreateConsolidatorOptions): Consolidator {
122
+ return new ConsolidatorImpl(opts);
123
+ }
124
+
125
+ class ConsolidatorImpl implements Consolidator {
126
+ readonly #semantic: SemanticMemory;
127
+ readonly #episodic: EpisodicMemory | null;
128
+ readonly #working: WorkingMemory | null;
129
+ readonly #store: MemoryStoreAdapter;
130
+ readonly #consolidatorStore: ConsolidatorMemoryStoreExt | null;
131
+ readonly #tracer: Tracer;
132
+ readonly #now: () => number;
133
+ readonly #randomId: () => string;
134
+ readonly #provider: Provider | null;
135
+ /** Per-phase provider overrides (MCON-7); fall back to `#provider`. */
136
+ readonly #cheapProvider: Provider | null;
137
+ readonly #deepProvider: Provider | null;
138
+ /**
139
+ * USD pricer for phase LLM usage (memory-consolidation-02). Without
140
+ * it every phase's `priceUsage?.(...) ?? 0` evaluates to zero and the
141
+ * `maxCostPerDay` ceiling can never trip.
142
+ */
143
+ readonly #priceUsage:
144
+ | ((usage: { promptTokens: number; completionTokens: number }) => number)
145
+ | null;
146
+ readonly #defaultScope: SessionScope | null;
147
+ readonly #listeners = new Set<PhaseListener>();
148
+ readonly #lockManager: LockManager;
149
+ readonly #budget: BudgetTracker;
150
+ #config: ConsolidatorConfig;
151
+ #running = false;
152
+ #manuallyPaused = false;
153
+ #deferredRuns = 0;
154
+ /**
155
+ * Message ids of the batch the in-flight `#dispatch` operates on,
156
+ * keyed by {@link scopeKey} (MCON-10, W-142). Captured so a thrown
157
+ * phase can enqueue its DLQ row with the REAL failed slice instead of
158
+ * `[]` - replays must target the window that failed, not whatever the
159
+ * cursor points at later. Keyed per scope because the lock is
160
+ * per-scope: two scopes may run standard phases concurrently in one
161
+ * process, and a single instance field would let scope B overwrite
162
+ * the slice scope A is about to fail on. The per-scope lock bounds
163
+ * this map to one live entry per scope; entries are removed in the
164
+ * dispatch `finally`.
165
+ */
166
+ readonly #dispatchMessageIdsByScope = new Map<string, ReadonlyArray<string>>();
167
+ /**
168
+ * Bumped when the runtime cannot persist a deferred run to the
169
+ * audit log (e.g., adapter omits the consolidator surface). The
170
+ * `status()` reader merges this with the persisted count so
171
+ * library-mode callers still observe the deferral.
172
+ */
173
+ #deferredRunsAdjustment = 0;
174
+ #emptyExtractions = 0;
175
+
176
+ constructor(opts: CreateConsolidatorOptions) {
177
+ this.#semantic = opts.semantic;
178
+ this.#episodic = opts.episodic ?? null;
179
+ this.#working = opts.working ?? null;
180
+ this.#store = opts.store;
181
+ this.#consolidatorStore = this.#store.consolidator ?? null;
182
+ this.#tracer = opts.tracer ?? NOOP_TRACER;
183
+ this.#now = opts.now ?? Date.now;
184
+ this.#randomId =
185
+ opts.randomId ??
186
+ ((): string => {
187
+ const a = Math.floor(Math.random() * 1e9).toString(36);
188
+ const b = Math.floor(Math.random() * 1e9).toString(36);
189
+ return `cr_${a}${b}`;
190
+ });
191
+ this.#provider = opts.provider ?? null;
192
+ this.#cheapProvider = opts.cheapProvider ?? null;
193
+ this.#deepProvider = opts.deepProvider ?? null;
194
+ this.#priceUsage = opts.priceUsage ?? null;
195
+ this.#defaultScope = opts.defaultScope ?? null;
196
+
197
+ this.#config = resolveConfig(opts);
198
+ this.#lockManager = new LockManager({
199
+ store: this.#consolidatorStore,
200
+ waitMs: this.#config.lockWaitMs,
201
+ maxRunDurationMs: this.#config.ceilings.maxRunDurationMs,
202
+ now: this.#now,
203
+ randomId: this.#randomId,
204
+ });
205
+ this.#budget = new BudgetTracker({
206
+ maxTokensPerDay: this.#config.ceilings.maxTokensPerDay,
207
+ maxCostPerDay: this.#config.ceilings.maxCostPerDay,
208
+ onExceed: this.#config.onExceed,
209
+ resetSemantics: this.#config.budgetResetSemantics,
210
+ now: this.#now,
211
+ });
212
+ }
213
+
214
+ async start(): Promise<void> {
215
+ this.#running = true;
216
+ }
217
+
218
+ async stop(): Promise<void> {
219
+ this.#running = false;
220
+ }
221
+
222
+ async pause(): Promise<void> {
223
+ this.#manuallyPaused = true;
224
+ }
225
+
226
+ async resume(): Promise<void> {
227
+ this.#manuallyPaused = false;
228
+ }
229
+
230
+ config(): ConsolidatorConfig {
231
+ return this.#config;
232
+ }
233
+
234
+ async registerWithScheduler(scheduler: SchedulerLike): Promise<RegisterTriggersResult> {
235
+ if (this.#defaultScope === null) {
236
+ throw new Error(
237
+ '[graphorin/memory] Consolidator.registerWithScheduler requires a defaultScope. ' +
238
+ 'Pass `defaultScope` to createConsolidator / createMemory, or call ' +
239
+ 'registerConsolidatorTriggers(consolidator, scheduler, { scope }) directly.',
240
+ );
241
+ }
242
+ return registerConsolidatorTriggers(this, scheduler, { scope: this.#defaultScope });
243
+ }
244
+
245
+ isFree(): boolean {
246
+ return this.#config.tier === 'free';
247
+ }
248
+
249
+ onPhaseFinished(listener: PhaseListener): () => void {
250
+ this.#listeners.add(listener);
251
+ return () => {
252
+ this.#listeners.delete(listener);
253
+ };
254
+ }
255
+
256
+ recordExternalSpend(tokens: number, costUsd?: number): void {
257
+ if (!Number.isFinite(tokens) || tokens <= 0) return;
258
+ this.#budget.record({ phase: 'deep', tokens, costUsd: costUsd ?? 0 });
259
+ }
260
+
261
+ async setTier(tier: ConsolidatorTier): Promise<void> {
262
+ const preset = CONSOLIDATOR_TIER_DEFAULTS[tier];
263
+ if (preset === undefined) {
264
+ throw new Error(`[graphorin/memory] unknown consolidator tier '${tier}'`);
265
+ }
266
+ if (tier === 'custom') {
267
+ throw new Error(
268
+ '[graphorin/memory] consolidator.setTier("custom") requires explicit ceilings - re-create the consolidator with `tier: "custom"` + `ceilings`.',
269
+ );
270
+ }
271
+ this.#config = Object.freeze({
272
+ ...this.#config,
273
+ tier,
274
+ phases: preset.phases,
275
+ ceilings: preset.ceilings,
276
+ onExceed: preset.onExceed,
277
+ formEpisodes: preset.formEpisodes,
278
+ importanceScoring: preset.importanceScoring,
279
+ reflection: preset.reflection,
280
+ importanceThreshold: preset.importanceThreshold,
281
+ reflectionMaxQuestions: preset.reflectionMaxQuestions,
282
+ reflectionMaxQuarantinedInsights: preset.reflectionMaxQuarantinedInsights,
283
+ contextualRetrieval: preset.contextualRetrieval,
284
+ });
285
+ this.#budget.reconfigure({
286
+ maxTokensPerDay: preset.ceilings.maxTokensPerDay,
287
+ maxCostPerDay: preset.ceilings.maxCostPerDay,
288
+ });
289
+ }
290
+
291
+ async status(): Promise<ConsolidatorStatus> {
292
+ let lastRunAt: string | undefined;
293
+ let lastPhase: ConsolidatorPhase | undefined;
294
+ const lastRuns: { -readonly [K in keyof ConsolidatorLastRuns]: ConsolidatorLastRuns[K] } = {};
295
+ let pendingConflicts = 0;
296
+ let dlqSize = 0;
297
+ let deferredFromStore = 0;
298
+ if (this.#consolidatorStore !== null && this.#defaultScope !== null) {
299
+ const state = await this.#consolidatorStore.getState(this.#defaultScope);
300
+ if (state !== null) {
301
+ if (state.lastCompletedAt !== null) {
302
+ lastRunAt = new Date(state.lastCompletedAt).toISOString();
303
+ }
304
+ if (state.lastPhase !== null) {
305
+ lastPhase = state.lastPhase;
306
+ }
307
+ }
308
+ const failed = await this.#consolidatorStore.listFailedBatches(this.#defaultScope, 1000);
309
+ dlqSize = failed.length;
310
+ // Hydrate per-phase `lastRuns` and the persisted deferred_runs
311
+ // counter from the run audit log so process restart preserves
312
+ // both surfaces.
313
+ const recent = await this.#consolidatorStore.listRecentRuns(this.#defaultScope, 500);
314
+ for (const run of recent) {
315
+ if (run.status === 'deferred') {
316
+ deferredFromStore += 1;
317
+ continue;
318
+ }
319
+ if (run.status !== 'completed' && run.status !== 'partial') continue;
320
+ const stamp = new Date(run.finishedAt ?? run.startedAt).toISOString();
321
+ if (run.phase === 'light' && lastRuns.light === undefined) lastRuns.light = stamp;
322
+ else if (run.phase === 'standard' && lastRuns.standard === undefined)
323
+ lastRuns.standard = stamp;
324
+ else if (run.phase === 'deep' && lastRuns.deep === undefined) lastRuns.deep = stamp;
325
+ }
326
+ }
327
+ if (this.#store.conflicts !== undefined && this.#defaultScope !== null) {
328
+ const list = await this.#store.conflicts.listPending(this.#defaultScope, 1000);
329
+ pendingConflicts = list.length;
330
+ }
331
+ const snapshot = this.#budget.snapshot();
332
+ const persistedDeferred = deferredFromStore + this.#deferredRunsAdjustment;
333
+ const totalDeferred = Math.max(persistedDeferred, this.#deferredRuns);
334
+ const out: ConsolidatorStatus = {
335
+ tier: this.#config.tier,
336
+ triggers: this.#config.triggers,
337
+ phases: this.#config.phases,
338
+ running: this.#running,
339
+ paused: this.#manuallyPaused || snapshot.paused,
340
+ pendingConflicts,
341
+ queueDepth: pendingConflicts,
342
+ dlqSize,
343
+ deferredRuns: totalDeferred,
344
+ emptyExtractions: this.#emptyExtractions,
345
+ lastRuns: Object.freeze(lastRuns),
346
+ budget: {
347
+ tokensUsedToday: snapshot.tokensUsedToday,
348
+ costUsedToday: snapshot.costUsedToday,
349
+ tokensRemaining: snapshot.tokensRemaining,
350
+ costRemaining: snapshot.costRemaining,
351
+ resetAt: snapshot.resetAt,
352
+ },
353
+ budgetRemaining: {
354
+ tokens: snapshot.tokensRemaining,
355
+ costUsd: snapshot.costRemaining,
356
+ },
357
+ ...(lastRunAt !== undefined ? { lastRunAt } : {}),
358
+ ...(lastPhase !== undefined ? { lastPhase } : {}),
359
+ };
360
+ return out;
361
+ }
362
+
363
+ async trigger(
364
+ reason: ConsolidatorTriggerReason,
365
+ scope: SessionScope,
366
+ ): Promise<PhaseOutcome | null> {
367
+ if (!this.#running) return null;
368
+ if (this.#manuallyPaused) return null;
369
+ // memory-consolidation-03: `drainDlq` finally has a production
370
+ // caller - failed batches whose backoff has elapsed replay here
371
+ // instead of accumulating forever. Cheap when the queue is empty
372
+ // (one SELECT); replays are backoff-gated by the store.
373
+ await this.drainDlq(scope).catch(() => 0);
374
+ // memory-consolidation-04: tiers without a deep phase never drain
375
+ // the pending CONFLICT-CHECK queue - expire stale rows as 'admit'
376
+ // so it cannot grow monotonically at the free/cheap defaults.
377
+ await this.#expireStalePendingConflicts(scope).catch(() => {});
378
+ const phases = this.#planPhases(reason);
379
+ if (phases.length === 0) return null;
380
+ // MCON-8: enforce the cooldown the runtime always PERSISTED but never
381
+ // read - back-to-back triggers used to fire with zero quiet period.
382
+ // Checked ONCE per trigger dispatch (phases within one dispatch chain
383
+ // freely); manual fireNow(...) / DLQ replays bypass it.
384
+ if (reason.kind !== 'manual' && this.#config.ceilings.cooldownMs > 0) {
385
+ const state =
386
+ this.#consolidatorStore !== null ? await this.#consolidatorStore.getState(scope) : null;
387
+ const eligibleAt = state?.nextEligibleAt ?? null;
388
+ const firstPhase = phases[0] ?? 'light';
389
+ if (eligibleAt !== null && this.#now() < eligibleAt) {
390
+ return this.#emit(
391
+ { ...skipOutcome(firstPhase, 'cooldown'), phase: firstPhase },
392
+ scope,
393
+ reason,
394
+ );
395
+ }
396
+ }
397
+ let last: PhaseOutcome | null = null;
398
+ for (const phase of phases) {
399
+ last = await this.#runPhase(phase, scope, reason);
400
+ if (last?.status === 'failed' || last?.status === 'deferred') break;
401
+ }
402
+ return last;
403
+ }
404
+
405
+ async fireNow(phase: ConsolidatorPhase, scope?: SessionScope): Promise<PhaseOutcome | null> {
406
+ const target = scope ?? this.#defaultScope;
407
+ if (target === null) {
408
+ throw new Error(
409
+ '[graphorin/memory] consolidator.fireNow requires a scope (default scope was not configured).',
410
+ );
411
+ }
412
+ if (!this.#config.phases.includes(phase) && phase !== 'light') {
413
+ // MCON-17: manual flushes bypass phase gating ON PURPOSE - but the
414
+ // operator should know they are running a phase the tier disabled
415
+ // (the old empty branch promised a warn and emitted nothing).
416
+ process.stderr.write(
417
+ `[graphorin/memory] consolidator.fireNow('${phase}') runs a phase not enabled for tier '${this.#config.tier}' - proceeding (manual flushes bypass phase gating).\n`,
418
+ );
419
+ }
420
+ return this.#runPhase(phase, target, { kind: 'manual', value: phase });
421
+ }
422
+
423
+ /**
424
+ * memory-consolidation-04: at tiers with no deep phase the pending
425
+ * CONFLICT-CHECK queue has no drain. Resolve rows older than 7 days
426
+ * as `'admit'` (the safe direction - the candidate fact stays live;
427
+ * only the never-coming judge call is skipped), bounded per sweep.
428
+ */
429
+ async #expireStalePendingConflicts(scope: SessionScope): Promise<void> {
430
+ if (this.#config.phases.includes('deep')) return;
431
+ const conflicts = this.#store.conflicts;
432
+ if (conflicts === undefined) return;
433
+ const cutoff = this.#now() - PENDING_CONFLICT_TTL_MS;
434
+ const pending = await conflicts.listPending(scope, 200);
435
+ for (const row of pending) {
436
+ if (row.enqueuedAt <= cutoff) {
437
+ await conflicts.markResolved(row.id, 'admit');
438
+ }
439
+ }
440
+ }
441
+
442
+ async drainDlq(scope: SessionScope): Promise<number> {
443
+ const store = this.#consolidatorStore;
444
+ if (store === null) return 0;
445
+ const ready = await store.claimReadyBatches(scope, this.#now(), 50);
446
+ let drained = 0;
447
+ for (const row of ready) {
448
+ // MCON-10: replay the phase that actually failed (persisted at
449
+ // enqueue); legacy rows without one fall back to 'standard',
450
+ // matching the old behaviour.
451
+ const replayPhase: ConsolidatorPhase = row.phase ?? 'standard';
452
+ let succeeded = false;
453
+ let deferred = false;
454
+ let lastError: unknown = null;
455
+ try {
456
+ // MCON-10: replays run through the FULL phase envelope - budget
457
+ // precheck, scope lock, and recordRunStart/Finish - so they are
458
+ // visible in `consolidator_runs` and cannot race a live run or
459
+ // spend past a pause.
460
+ const outcome = await this.#runPhase(replayPhase, scope, {
461
+ kind: 'manual',
462
+ value: `dlq-replay:${replayPhase}`,
463
+ });
464
+ if (outcome?.status === 'completed' || outcome?.status === 'partial') {
465
+ succeeded = true;
466
+ } else if (outcome?.status === 'deferred') {
467
+ deferred = true;
468
+ lastError = new Error(outcome.errorMessage ?? 'replay deferred');
469
+ } else if (outcome?.errorMessage != null) {
470
+ lastError = new Error(outcome.errorMessage);
471
+ }
472
+ } catch (err) {
473
+ lastError = err;
474
+ }
475
+ if (succeeded) {
476
+ await store.markBatchSucceeded(row.id);
477
+ drained += 1;
478
+ continue;
479
+ }
480
+ if (deferred) {
481
+ // Lock contention / budget pause is transient - reschedule at the
482
+ // SAME retry count so contention cannot exhaust the row.
483
+ const delayMs = nextBackoffMs({
484
+ retryCount: row.retryCount + 1,
485
+ baseMs: this.#config.dlqBaseBackoffMs,
486
+ maxMs: this.#config.dlqMaxBackoffMs,
487
+ });
488
+ await store.rescheduleBatch(row.id, row.retryCount, this.#now() + delayMs);
489
+ continue;
490
+ }
491
+ const nextRetryCount = row.retryCount + 1;
492
+ if (nextRetryCount >= this.#config.dlqMaxRetries) {
493
+ await store.markBatchExhausted(row.id, describeError(lastError), nextRetryCount);
494
+ await this.#skipPoisonSlice(scope, row, replayPhase);
495
+ } else {
496
+ const delayMs = nextBackoffMs({
497
+ retryCount: nextRetryCount,
498
+ baseMs: this.#config.dlqBaseBackoffMs,
499
+ maxMs: this.#config.dlqMaxBackoffMs,
500
+ });
501
+ await store.rescheduleBatch(row.id, nextRetryCount, this.#now() + delayMs);
502
+ }
503
+ }
504
+ return drained;
505
+ }
506
+
507
+ /**
508
+ * W-081: poison-slice skip. When a standard-phase batch exhausts its
509
+ * DLQ retries while the cursor still points before / inside the failed
510
+ * window, every future trigger re-reads the SAME slice, fails the same
511
+ * way and consolidation for the scope stalls forever (each drain also
512
+ * used to mint a fresh DLQ row for the identical window). Force-advance
513
+ * the cursor past the window instead: losing the poison slice's facts
514
+ * is deliberate and bounded - its messageIds stay on the exhausted row
515
+ * for manual replay - whereas the alternative is losing every
516
+ * subsequent slice too. The gate is membership-based: the cursor is
517
+ * "not past the window" exactly when the next unprocessed message falls
518
+ * inside the exhausted window, so the advance can never move the cursor
519
+ * backwards over a window that was already passed.
520
+ */
521
+ async #skipPoisonSlice(
522
+ scope: SessionScope,
523
+ row: DlqBatchRow,
524
+ replayPhase: ConsolidatorPhase,
525
+ ): Promise<void> {
526
+ if (replayPhase !== 'standard' || row.messageIds.length === 0) return;
527
+ const store = this.#consolidatorStore;
528
+ if (store === null) return;
529
+ const session = this.#store.session;
530
+ if (typeof session.listMessagesSince !== 'function') return;
531
+ const tip = row.messageIds[row.messageIds.length - 1];
532
+ if (tip === undefined) return;
533
+ const state = await store.getState(scope);
534
+ const cursor = state?.lastProcessedMessageId ?? null;
535
+ if (cursor === tip) return;
536
+ const next = (await session.listMessagesSince(scope, cursor, 1))[0];
537
+ if (next === undefined || !row.messageIds.includes(next.id)) return;
538
+ await store.upsertState(scope, { lastProcessedMessageId: tip });
539
+ process.stderr.write(
540
+ `[graphorin/memory] poison slice skipped: standard-phase DLQ batch '${row.id}' exhausted its retries with the cursor still inside the failed window - advancing lastProcessedMessageId past ${row.messageIds.length} message(s); the ids remain on the exhausted row for manual replay.\n`,
541
+ );
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+
546
+ #planPhases(reason: ConsolidatorTriggerReason): ConsolidatorPhase[] {
547
+ const enabled = new Set(this.#config.phases);
548
+ const planned: ConsolidatorPhase[] = [];
549
+ if (reason.kind === 'turn' || reason.kind === 'idle' || reason.kind === 'event') {
550
+ if (enabled.has('light')) planned.push('light');
551
+ if (enabled.has('standard')) planned.push('standard');
552
+ } else if (reason.kind === 'cron' || reason.kind === 'manual' || reason.kind === 'budget') {
553
+ if (enabled.has('light')) planned.push('light');
554
+ if (enabled.has('standard')) planned.push('standard');
555
+ if (enabled.has('deep')) planned.push('deep');
556
+ }
557
+ return planned;
558
+ }
559
+
560
+ async #runPhase(
561
+ phase: ConsolidatorPhase,
562
+ scope: SessionScope,
563
+ reason: ConsolidatorTriggerReason,
564
+ ): Promise<PhaseOutcome | null> {
565
+ const precheck = this.#budget.precheck(phase);
566
+ if (!precheck.allowed && phase !== 'light') {
567
+ return this.#emit(
568
+ { ...skipOutcome(phase, precheck.reason ?? 'paused'), phase },
569
+ scope,
570
+ reason,
571
+ );
572
+ }
573
+ const acquisition = await this.#lockManager.acquire(scope);
574
+ if (acquisition.kind === 'deferred') {
575
+ this.#deferredRuns += 1;
576
+ const deferredRunId = this.#randomId();
577
+ const deferredAt = this.#now();
578
+ // Persist the deferral as a `consolidator_runs` row so the
579
+ // counter survives process restart (DEC-150 - same code path
580
+ // lib + server). Adapters that do not expose the consolidator
581
+ // surface fall through to the in-memory adjustment counter.
582
+ if (this.#consolidatorStore !== null) {
583
+ try {
584
+ await this.#consolidatorStore.recordRunStart({
585
+ id: deferredRunId,
586
+ scope,
587
+ triggerKind: reason.kind,
588
+ phase,
589
+ startedAt: deferredAt,
590
+ });
591
+ await this.#consolidatorStore.recordRunFinish({
592
+ id: deferredRunId,
593
+ finishedAt: deferredAt,
594
+ status: 'deferred',
595
+ errorMessage: `lock held by ${acquisition.heldBy ?? 'unknown'}`,
596
+ });
597
+ } catch {
598
+ this.#deferredRunsAdjustment += 1;
599
+ }
600
+ } else {
601
+ this.#deferredRunsAdjustment += 1;
602
+ }
603
+ return this.#emit(
604
+ {
605
+ phase,
606
+ status: 'deferred',
607
+ factsCreated: 0,
608
+ factsUpdated: 0,
609
+ conflictsResolved: 0,
610
+ episodesFormed: 0,
611
+ insightsCreated: 0,
612
+ noiseFilteredCount: 0,
613
+ emptyExtractions: 0,
614
+ llmTokensUsed: 0,
615
+ llmCostUsd: null,
616
+ errorMessage: `lock held by ${acquisition.heldBy ?? 'unknown'}`,
617
+ },
618
+ scope,
619
+ reason,
620
+ );
621
+ }
622
+ const runId = acquisition.runId;
623
+ const startedAt = this.#now();
624
+ if (this.#consolidatorStore !== null) {
625
+ await this.#consolidatorStore.recordRunStart({
626
+ id: runId,
627
+ scope,
628
+ triggerKind: reason.kind,
629
+ phase,
630
+ startedAt,
631
+ });
632
+ }
633
+ let outcome: PhaseOutcome;
634
+ try {
635
+ outcome = await this.#dispatch(phase, scope);
636
+ } catch (err) {
637
+ outcome = {
638
+ phase,
639
+ status: 'failed',
640
+ factsCreated: 0,
641
+ factsUpdated: 0,
642
+ conflictsResolved: 0,
643
+ episodesFormed: 0,
644
+ insightsCreated: 0,
645
+ noiseFilteredCount: 0,
646
+ emptyExtractions: 0,
647
+ llmTokensUsed: 0,
648
+ llmCostUsd: null,
649
+ errorMessage: describeError(err),
650
+ };
651
+ // MCON-10: a failed DLQ *replay* must not enqueue a fresh DLQ row -
652
+ // the original row already tracks the failure (drainDlq reschedules
653
+ // or exhausts it); double-tracking multiplies rows per drain.
654
+ const isDlqReplay =
655
+ reason.kind === 'manual' &&
656
+ typeof reason.value === 'string' &&
657
+ reason.value.startsWith('dlq-replay:');
658
+ if (this.#consolidatorStore !== null && !isDlqReplay) {
659
+ // W-143: the phase's work is already done (and its failure
660
+ // captured in `outcome`); a transient storage error while
661
+ // ENQUEUEING the DLQ row must not skip run accounting or hold
662
+ // the scope lock until staleness takeover.
663
+ try {
664
+ await this.#consolidatorStore.enqueueFailedBatch({
665
+ id: this.#randomId(),
666
+ consolidatorRunId: runId,
667
+ scope,
668
+ // MCON-10: capture the slice that actually failed so replays
669
+ // can be audited against it; the cursor may move past this
670
+ // window before the replay fires. W-142: read per scope - a
671
+ // concurrent dispatch on ANOTHER scope must not have replaced
672
+ // this scope's slice.
673
+ messageIds: this.#dispatchMessageIdsByScope.get(scopeKey(scope)) ?? [],
674
+ errorKind: classifyError(err),
675
+ errorMessage: describeError(err),
676
+ failedAt: this.#now(),
677
+ nextRetryAt:
678
+ this.#now() +
679
+ nextBackoffMs({
680
+ retryCount: 0,
681
+ baseMs: this.#config.dlqBaseBackoffMs,
682
+ maxMs: this.#config.dlqMaxBackoffMs,
683
+ }),
684
+ retryCount: 0,
685
+ // MCON-10: persist the failed phase - replays must re-run the
686
+ // SAME phase, not an inferred 'standard'.
687
+ phase,
688
+ });
689
+ } catch (accountingErr) {
690
+ this.#recordAccountingFailure('enqueue-failed-batch', scope, phase, accountingErr);
691
+ }
692
+ }
693
+ } finally {
694
+ // W-142: the entry must not outlive its dispatch (the catch above
695
+ // has already consumed it by the time finally runs); without this
696
+ // the map would leak one entry per scope forever.
697
+ this.#dispatchMessageIdsByScope.delete(scopeKey(scope));
698
+ }
699
+ if (outcome.emptyExtractions > 0) {
700
+ this.#emptyExtractions += outcome.emptyExtractions;
701
+ }
702
+ // W-143: completion accounting is best-effort, the lock release is
703
+ // not. Pre-fix, a transient storage error (SQLITE_BUSY) in
704
+ // recordRunFinish/upsertState flew out of #runPhase, leaving the
705
+ // scope lock held until staleness takeover (5-15 minutes by tier),
706
+ // the consolidator_runs row forever unfinished, and every trigger
707
+ // of the window deferred - although the phase's work was already
708
+ // committed. Each accounting step now records its failure and
709
+ // continues; the release itself is guarded so a release error
710
+ // cannot mask the accounting one.
711
+ try {
712
+ if (this.#consolidatorStore !== null) {
713
+ try {
714
+ await this.#consolidatorStore.recordRunFinish({
715
+ id: runId,
716
+ finishedAt: this.#now(),
717
+ status: outcome.status,
718
+ llmTokensUsed: outcome.llmTokensUsed,
719
+ llmCostUsd: outcome.llmCostUsd,
720
+ factsCreated: outcome.factsCreated,
721
+ factsUpdated: outcome.factsUpdated,
722
+ conflictsResolved: outcome.conflictsResolved,
723
+ noiseFilteredCount: outcome.noiseFilteredCount,
724
+ emptyExtractions: outcome.emptyExtractions,
725
+ // MCON-17: the P1-2 / P1-1 counters were computed on the outcome
726
+ // and then dropped - the run audit lost them.
727
+ episodesFormed: outcome.episodesFormed,
728
+ insightsCreated: outcome.insightsCreated,
729
+ errorMessage: outcome.errorMessage,
730
+ });
731
+ } catch (accountingErr) {
732
+ this.#recordAccountingFailure('record-run-finish', scope, phase, accountingErr);
733
+ }
734
+ try {
735
+ await this.#consolidatorStore.upsertState(scope, {
736
+ lastPhase: phase,
737
+ lastCompletedAt: this.#now(),
738
+ nextEligibleAt: this.#now() + this.#config.ceilings.cooldownMs,
739
+ });
740
+ } catch (accountingErr) {
741
+ this.#recordAccountingFailure('upsert-state', scope, phase, accountingErr);
742
+ }
743
+ }
744
+ } finally {
745
+ try {
746
+ await this.#lockManager.release(scope, runId);
747
+ } catch (releaseErr) {
748
+ this.#recordAccountingFailure('lock-release', scope, phase, releaseErr);
749
+ }
750
+ }
751
+ return this.#emit(outcome, scope, reason);
752
+ }
753
+
754
+ /**
755
+ * W-143: surface a swallowed completion-accounting error as a
756
+ * dedicated error span. Swallowing is deliberate (the phase's work is
757
+ * committed; the lock must be released), but a systematic store
758
+ * problem must stay visible to operators.
759
+ */
760
+ #recordAccountingFailure(
761
+ step: string,
762
+ scope: SessionScope,
763
+ phase: ConsolidatorPhase,
764
+ err: unknown,
765
+ ): void {
766
+ const span = this.#tracer.startSpan({
767
+ type: 'x.memory.consolidator.accounting',
768
+ attrs: { step, phase, userId: scope.userId },
769
+ });
770
+ span.recordException(err);
771
+ span.setStatus('error');
772
+ span.end();
773
+ }
774
+
775
+ async #dispatch(phase: ConsolidatorPhase, scope: SessionScope): Promise<PhaseOutcome> {
776
+ // MCON-10: reset the failed-slice capture per dispatch; the standard
777
+ // branch refills it from the batch it actually processes. W-142:
778
+ // scoped to this dispatch's scope only.
779
+ this.#dispatchMessageIdsByScope.delete(scopeKey(scope));
780
+ const state =
781
+ this.#consolidatorStore !== null ? await this.#consolidatorStore.getState(scope) : null;
782
+ const lastProcessedMessageId = state?.lastProcessedMessageId ?? null;
783
+ if (phase === 'light') {
784
+ const out = await runLightPhase({
785
+ store: this.#store,
786
+ consolidatorStore: this.#consolidatorStore,
787
+ tracer: this.#tracer,
788
+ scope,
789
+ now: this.#now,
790
+ decayTauDays: this.#config.decayTauDays,
791
+ decayArchiveThreshold: this.#config.decayArchiveThreshold,
792
+ decayCapacity: this.#config.decayCapacity,
793
+ salienceWeights: this.#config.salienceWeights,
794
+ noiseFilters: this.#config.noiseFilters as ReadonlyArray<NoiseFilterPreset>,
795
+ maxBatchSize: this.#config.maxStandardBatchSize,
796
+ lastProcessedMessageId,
797
+ tier: this.#config.tier,
798
+ });
799
+ return out;
800
+ }
801
+ if (phase === 'standard') {
802
+ const standardProvider = this.#cheapProvider ?? this.#provider;
803
+ if (standardProvider === null) {
804
+ throw new ProviderNotConfiguredError('standard');
805
+ }
806
+ const session = this.#store.session;
807
+ const rawBatch =
808
+ typeof session.listMessagesSince === 'function'
809
+ ? await session.listMessagesSince(
810
+ scope,
811
+ lastProcessedMessageId,
812
+ this.#config.maxStandardBatchSize,
813
+ )
814
+ : [];
815
+ this.#dispatchMessageIdsByScope.set(
816
+ scopeKey(scope),
817
+ rawBatch.map((r) => r.id),
818
+ );
819
+ const out = await runStandardPhase({
820
+ semantic: this.#semantic,
821
+ episodic: this.#episodic,
822
+ formEpisodes: this.#config.formEpisodes,
823
+ importanceScoring: this.#config.importanceScoring,
824
+ autoPromoteExtraction: this.#config.autoPromoteExtraction,
825
+ contextualRetrieval: this.#config.contextualRetrieval,
826
+ store: this.#store,
827
+ consolidatorStore: this.#consolidatorStore,
828
+ // MCON-7: the standard phase routes to the cheap-tier provider
829
+ // when one is configured.
830
+ provider: standardProvider,
831
+ tracer: this.#tracer,
832
+ scope,
833
+ cheapModel: this.#config.cheapModel,
834
+ noiseFilters: this.#config.noiseFilters as ReadonlyArray<NoiseFilterPreset>,
835
+ maxBatchSize: this.#config.maxStandardBatchSize,
836
+ maxTranscriptChars: this.#config.maxTranscriptChars,
837
+ lastProcessedMessageId,
838
+ budget: this.#budget,
839
+ tier: this.#config.tier === 'free' ? 'cheap' : this.#config.tier,
840
+ now: this.#now,
841
+ batch: rawBatch,
842
+ ...(this.#priceUsage !== null ? { priceUsage: this.#priceUsage } : {}),
843
+ });
844
+ const cursor = tipMessageId(rawBatch);
845
+ if (cursor !== null && this.#consolidatorStore !== null) {
846
+ await this.#consolidatorStore.upsertState(scope, {
847
+ lastProcessedMessageId: cursor,
848
+ });
849
+ }
850
+ return out;
851
+ }
852
+ const deepProvider = this.#deepProvider ?? this.#provider;
853
+ if (deepProvider === null) {
854
+ throw new ProviderNotConfiguredError('deep');
855
+ }
856
+ const deepOut = await runDeepPhase({
857
+ store: this.#store,
858
+ consolidatorStore: this.#consolidatorStore,
859
+ provider: deepProvider,
860
+ tracer: this.#tracer,
861
+ scope,
862
+ deepModel: this.#config.deepModel,
863
+ maxConflictsPerRun: this.#config.maxDeepConflictsPerRun,
864
+ budget: this.#budget,
865
+ tier:
866
+ this.#config.tier === 'free' || this.#config.tier === 'cheap'
867
+ ? 'standard'
868
+ : this.#config.tier,
869
+ now: this.#now,
870
+ ...(this.#priceUsage !== null ? { priceUsage: this.#priceUsage } : {}),
871
+ });
872
+ // Reflection pass (P1-1) runs after the conflict drain, reusing the
873
+ // deep run's lock + budget + audit window. Triple-gated: enabled by
874
+ // config, an episodic tier present (importance source), and an
875
+ // insight-capable storage adapter. The accumulated-importance
876
+ // threshold is enforced inside the pass.
877
+ let out: PhaseOutcome = deepOut;
878
+ const insightStore = this.#store.insights;
879
+ if (this.#config.reflection && this.#episodic !== null && insightStore !== undefined) {
880
+ // MCON-13: read the persisted reflection watermark so the gate only
881
+ // accumulates importance from episodes newer than the last pass, and
882
+ // persist the advanced value afterwards (a no-op when unchanged).
883
+ const priorWatermark =
884
+ this.#consolidatorStore !== null
885
+ ? ((await this.#consolidatorStore.getState(scope))?.reflectionWatermark ?? null)
886
+ : null;
887
+ const reflection = await runReflectionPass({
888
+ // MCON-7: reflection rides the deep-tier provider.
889
+ provider: deepProvider,
890
+ tracer: this.#tracer,
891
+ scope,
892
+ semantic: this.#semantic,
893
+ episodic: this.#episodic,
894
+ insights: insightStore,
895
+ budget: this.#budget,
896
+ importanceThreshold: this.#config.importanceThreshold,
897
+ reflectionWatermark: priorWatermark,
898
+ maxQuestions: this.#config.reflectionMaxQuestions,
899
+ maxQuarantinedInsights: this.#config.reflectionMaxQuarantinedInsights,
900
+ now: this.#now,
901
+ ...(this.#priceUsage !== null ? { priceUsage: this.#priceUsage } : {}),
902
+ });
903
+ if (this.#consolidatorStore !== null && reflection.nextWatermark !== priorWatermark) {
904
+ await this.#consolidatorStore.upsertState(scope, {
905
+ reflectionWatermark: reflection.nextWatermark,
906
+ });
907
+ }
908
+ out = {
909
+ ...out,
910
+ insightsCreated: reflection.insightsCreated,
911
+ llmTokensUsed: out.llmTokensUsed + reflection.tokens,
912
+ llmCostUsd:
913
+ out.llmCostUsd === null && reflection.costUsd === 0
914
+ ? null
915
+ : (out.llmCostUsd ?? 0) + reflection.costUsd,
916
+ };
917
+ }
918
+ // Learned-context pass (D3) runs last, folding the run's fresh
919
+ // synthesis into the standing digest block. Double-gated: enabled by
920
+ // config and a working-tier handle wired. Rides the deep provider +
921
+ // budget like reflection.
922
+ if (this.#config.learnedContext && this.#working !== null) {
923
+ const learned = await runLearnedContextPass({
924
+ provider: deepProvider,
925
+ tracer: this.#tracer,
926
+ scope,
927
+ working: this.#working,
928
+ episodic: this.#episodic,
929
+ store: this.#store,
930
+ budget: this.#budget,
931
+ maxChars: this.#config.learnedContextMaxChars,
932
+ now: this.#now,
933
+ ...(this.#priceUsage !== null ? { priceUsage: this.#priceUsage } : {}),
934
+ });
935
+ out = {
936
+ ...out,
937
+ learnedContextUpdated: learned.updated,
938
+ llmTokensUsed: out.llmTokensUsed + learned.tokens,
939
+ llmCostUsd:
940
+ out.llmCostUsd === null && learned.costUsd === 0
941
+ ? null
942
+ : (out.llmCostUsd ?? 0) + learned.costUsd,
943
+ };
944
+ }
945
+ return out;
946
+ }
947
+
948
+ #emit(
949
+ outcome: PhaseOutcome,
950
+ scope: SessionScope,
951
+ reason: ConsolidatorTriggerReason,
952
+ ): PhaseOutcome {
953
+ for (const listener of this.#listeners) {
954
+ try {
955
+ listener({ ...outcome, scope, trigger: reason });
956
+ } catch {
957
+ // Listeners must never break the runtime.
958
+ }
959
+ }
960
+ return outcome;
961
+ }
962
+ }
963
+
964
+ function skipOutcome(
965
+ phase: ConsolidatorPhase,
966
+ reason: 'tokens-exceeded' | 'cost-exceeded' | 'paused' | 'cooldown',
967
+ ): PhaseOutcome {
968
+ return {
969
+ phase,
970
+ status: 'deferred',
971
+ factsCreated: 0,
972
+ factsUpdated: 0,
973
+ conflictsResolved: 0,
974
+ episodesFormed: 0,
975
+ insightsCreated: 0,
976
+ noiseFilteredCount: 0,
977
+ emptyExtractions: 0,
978
+ llmTokensUsed: 0,
979
+ llmCostUsd: null,
980
+ errorMessage: `phase skipped - ${reason}`,
981
+ };
982
+ }
983
+
984
+ /** Resolve operator-supplied options into a fully-defaulted config. */
985
+ function resolveConfig(opts: CreateConsolidatorOptions): ConsolidatorConfig {
986
+ const tier: ConsolidatorTier = opts.tier ?? 'free';
987
+ const preset = CONSOLIDATOR_TIER_DEFAULTS[tier];
988
+ if (preset === undefined) {
989
+ throw new Error(`[graphorin/memory] unknown consolidator tier '${tier}'`);
990
+ }
991
+ const ceilings = {
992
+ ...preset.ceilings,
993
+ ...(opts.ceilings ?? {}),
994
+ };
995
+ const phases = opts.phases ?? preset.phases;
996
+ if (tier === 'custom') {
997
+ const missing: string[] = [];
998
+ if (ceilings.maxTokensPerDay <= 0) missing.push('ceilings.maxTokensPerDay');
999
+ if (ceilings.maxCostPerDay <= 0) missing.push('ceilings.maxCostPerDay');
1000
+ if (phases.length === 0) missing.push('phases');
1001
+ if (missing.length > 0) {
1002
+ throw new CustomTierMisconfiguredError(missing);
1003
+ }
1004
+ }
1005
+ return Object.freeze({
1006
+ triggers: Object.freeze([...(opts.triggers ?? defaultTriggers())] as ConsolidatorTriggerSpec[]),
1007
+ tier,
1008
+ phases: Object.freeze([...phases]) as ReadonlyArray<ConsolidatorPhase>,
1009
+ ceilings,
1010
+ onExceed: opts.onExceed ?? preset.onExceed,
1011
+ cheapModel: opts.cheapModel ?? preset.cheapModel,
1012
+ deepModel: opts.deepModel ?? preset.deepModel,
1013
+ budgetResetSemantics: opts.budgetResetSemantics ?? 'utc',
1014
+ noiseFilters: Object.freeze([...(opts.noiseFilters ?? ['default'])]),
1015
+ lockWaitMs: opts.lockWaitMs ?? 30_000,
1016
+ decayTauDays: opts.decayTauDays ?? 7,
1017
+ decayArchiveThreshold: opts.decayArchiveThreshold ?? 0.05,
1018
+ decayCapacity: opts.decayCapacity ?? null,
1019
+ salienceWeights: opts.salienceWeights ?? DEFAULT_SALIENCE_WEIGHTS,
1020
+ maxStandardBatchSize: opts.maxStandardBatchSize ?? 50,
1021
+ maxTranscriptChars: opts.maxTranscriptChars ?? preset.maxTranscriptChars,
1022
+ maxDeepConflictsPerRun: opts.maxDeepConflictsPerRun ?? 20,
1023
+ dlqMaxRetries: opts.dlqMaxRetries ?? 5,
1024
+ dlqBaseBackoffMs: opts.dlqBaseBackoffMs ?? 60_000,
1025
+ dlqMaxBackoffMs: opts.dlqMaxBackoffMs ?? 60 * 60 * 1000,
1026
+ formEpisodes: opts.formEpisodes ?? preset.formEpisodes,
1027
+ importanceScoring: opts.importanceScoring ?? preset.importanceScoring,
1028
+ // MCON-2: opt-in only - fail-safe (quarantine) stays the default at every tier.
1029
+ autoPromoteExtraction: opts.autoPromoteExtraction ?? false,
1030
+ reflection: opts.reflection ?? preset.reflection,
1031
+ importanceThreshold: opts.importanceThreshold ?? preset.importanceThreshold,
1032
+ reflectionMaxQuestions: opts.reflectionMaxQuestions ?? preset.reflectionMaxQuestions,
1033
+ reflectionMaxQuarantinedInsights:
1034
+ opts.reflectionMaxQuarantinedInsights ?? preset.reflectionMaxQuarantinedInsights,
1035
+ contextualRetrieval: opts.contextualRetrieval ?? preset.contextualRetrieval,
1036
+ learnedContext: opts.learnedContext ?? preset.learnedContext,
1037
+ learnedContextMaxChars: opts.learnedContextMaxChars ?? preset.learnedContextMaxChars,
1038
+ });
1039
+ }
1040
+
1041
+ function defaultTriggers(): ConsolidatorTriggerSpec[] {
1042
+ // `idle:5m` drives the light + standard phases between sessions; the daily
1043
+ // cron is what makes the **deep** phase reachable (it drains the deferred
1044
+ // conflict-check queue + runs reflection - `#planPhases` only schedules deep
1045
+ // for cron / manual / budget reasons). A `turn:N` default was dropped: the
1046
+ // scheduler cannot count user turns, so it was inert unless a consumer
1047
+ // emitted `trigger({ kind: 'turn' })` itself (MCON-4).
1048
+ return ['idle:5m', 'cron:0 4 * * *'];
1049
+ }