@remnic/core 9.3.746 → 9.3.748

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.
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Turn-ingestion coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 20).
4
+ *
5
+ * Owns the buffer-side entry points that feed the extraction pipeline:
6
+ * - processTurn (per-turn buffering + trigger-mode flush decisions)
7
+ * - observeSessionHeartbeat (heartbeat-driven extraction observer)
8
+ * - queueBufferedExtraction (dedupe-gated extraction queueing)
9
+ * - ingestReplayBatch / ingestBulkImportBatch (batch ingestion)
10
+ * - maybeCapturePassiveCorrections (passive correction capture)
11
+ *
12
+ * Behavior-preserving move from orchestrator.ts. The orchestrator keeps
13
+ * thin delegating methods; every member the moved code consults flows
14
+ * back through TurnIngestionDeps live accessors/arrows so prototype-call
15
+ * tests (Orchestrator.prototype.processTurn.call(fake, …)) and instance
16
+ * stubs keep working (same late-binding rule as seams 18/19).
17
+ */
18
+
19
+ import { createHash, randomBytes } from "node:crypto";
20
+ import { SmartBuffer } from "../buffer.js";
21
+ import type { ImportTurn } from "../bulk-import/types.js";
22
+ import { resolvePipelineProcessingCapabilities, resolveRecallAuxiliaryCapabilities } from "../capabilities.js";
23
+ import type { CorrectionService } from "../correction/correction-service.js";
24
+ import { type PassiveCaptureConfig, capturePassiveCorrections } from "../correction/passive-capture.js";
25
+ import { detectPassiveCorrections } from "../correction/passive-correction-detector.js";
26
+ import { shouldSkipImplicitExtraction } from "../explicit-capture.js";
27
+ import { StorageManager } from "../index.js";
28
+ import { LcmEngine } from "../lcm/index.js";
29
+ import { log } from "../logger.js";
30
+ import { ExtractionQueueCoordinator } from "./extraction-queue-coordinator.js";
31
+ import { ExtractionRunCoordinator, type ExtractionRunResult } from "./extraction-run.js";
32
+ import { stripHandles } from "../recall-handles.js";
33
+ import { type ReplayTurn, normalizeReplaySessionKey } from "../replay/types.js";
34
+ import { SessionObserverState } from "../session-observer-state.js";
35
+ import { CODEX_THREAD_KEY_PREFIX } from "../thread-key.js";
36
+ import { TranscriptManager } from "../transcript.js";
37
+ import type { BufferTurn, PluginConfig } from "../types.js";
38
+ import {
39
+ BulkImportBatchPartialFailureError,
40
+ splitTurnsBySourceValidAt,
41
+ targetSourceValidAtSortMs,
42
+ type BulkImportBatchIngestResult,
43
+ } from "../orchestrator.js";
44
+
45
+ export interface TurnIngestionDeps {
46
+ readonly buffer: SmartBuffer;
47
+ bulkImportWriteNamespace(): string;
48
+ readonly config: PluginConfig;
49
+ readonly extractionQueueCoordinator: ExtractionQueueCoordinator;
50
+ getStorage(namespace?: string): Promise<StorageManager>;
51
+ readonly heartbeatObserverChains: Map<string, Promise<void>>;
52
+ readonly lcmEngine: LcmEngine | null;
53
+ readonly passiveCorrectionDedup: Set<string>;
54
+ passiveCorrectionService(): CorrectionService;
55
+ readonly passiveCorrectionTelemetry: {
56
+ detected: number;
57
+ queued: number;
58
+ autoApplied: number;
59
+ suppressedReasonCounts: Record<string, number>;
60
+ };
61
+ queueBufferedExtraction(
62
+ turnsToExtract: BufferTurn[],
63
+ reason: "trigger_mode" | "heartbeat_observer",
64
+ options?: {
65
+ skipDedupeCheck?: boolean;
66
+ clearBufferAfterExtraction?: boolean;
67
+ skipCharThreshold?: boolean;
68
+ skipUserTurnThreshold?: boolean;
69
+ extractionDeadlineMs?: number;
70
+ failOnExtractionFailure?: boolean;
71
+ onTaskSettled?: (
72
+ error?: unknown,
73
+ result?: ExtractionRunResult,
74
+ ) => void;
75
+ bufferKey?: string;
76
+ abortSignal?: AbortSignal;
77
+ /**
78
+ * Explicit namespace override for the write path (#460). When set,
79
+ * `runExtraction` writes to this namespace instead of deriving one
80
+ * from `defaultNamespaceForPrincipal(resolvePrincipal(sessionKey))`.
81
+ * Used by bulk-import to pin writes to a deterministic namespace
82
+ * regardless of user-configured principal routing rules.
83
+ */
84
+ writeNamespaceOverride?: string;
85
+ /**
86
+ * Pin the provenance principal (#1495 thread 1). Forwarded to
87
+ * `runExtraction` so access `observe` can record provenance under the
88
+ * authenticated principal instead of `resolvePrincipal(sessionKey)`.
89
+ */
90
+ principalOverride?: string;
91
+ },
92
+ ): Promise<void>;
93
+ resolveMemoryIdOrHandle(ref: string, sessionKey?: string): string;
94
+ runExtraction(
95
+ ...args: Parameters<ExtractionRunCoordinator["runExtraction"]>
96
+ ): Promise<ExtractionRunResult>;
97
+ readonly sessionObserver: SessionObserverState;
98
+ shouldQueueExtraction(
99
+ turns: BufferTurn[],
100
+ options?: { commit?: boolean; bufferKey?: string },
101
+ ): boolean;
102
+ readonly transcript: TranscriptManager;
103
+ }
104
+
105
+ export class TurnIngestionCoordinator {
106
+ constructor(
107
+ private readonly deps: TurnIngestionDeps,
108
+ ) {}
109
+
110
+ async processTurn(
111
+ role: "user" | "assistant",
112
+ content: string,
113
+ sessionKey?: string,
114
+ options: {
115
+ bufferKey?: string;
116
+ logicalSessionKey?: string;
117
+ providerThreadId?: string | null;
118
+ turnFingerprint?: string;
119
+ persistProcessedFingerprint?: boolean;
120
+ } = {},
121
+ ): Promise<void> {
122
+ if (role !== "user" && role !== "assistant") {
123
+ log.debug(`processTurn: ignoring unsupported role=${String(role)}`);
124
+ return;
125
+ }
126
+ if (shouldSkipImplicitExtraction(this.deps.config)) {
127
+ log.debug(
128
+ "processTurn: skipping implicit extraction because captureMode=explicit",
129
+ );
130
+ return;
131
+ }
132
+
133
+ const bufferKey =
134
+ typeof options.bufferKey === "string" && options.bufferKey.length > 0
135
+ ? options.bufferKey
136
+ : typeof sessionKey === "string" && sessionKey.length > 0
137
+ ? sessionKey
138
+ : "default";
139
+ const captureTimestamp = new Date().toISOString();
140
+ // Issue #1582 hygiene §2 — strip any echoed `[m:xxxx]` handle before the
141
+ // turn enters the extraction buffer so handles never become memory content
142
+ // or get QMD-indexed (rule 23). Gated on the feature flag: when handles are
143
+ // off none are ever injected, so there is nothing to strip and the buffer
144
+ // stays byte-identical to the pre-#1582 path.
145
+ const bufferedContent = this.deps.config.recallMemoryHandles
146
+ ? stripHandles(content)
147
+ : content;
148
+ const turn: BufferTurn = {
149
+ role,
150
+ content: bufferedContent,
151
+ timestamp: captureTimestamp,
152
+ // #1578: anchor live-capture turns to wall-clock when bi-temporal is on;
153
+ // replay/import turns carry sourceValidAt explicitly (codex P1).
154
+ ...(this.deps.config.temporalBiTemporal
155
+ ? { sourceValidAt: captureTimestamp }
156
+ : {}),
157
+ sessionKey,
158
+ logicalSessionKey: options.logicalSessionKey ?? bufferKey,
159
+ providerThreadId: options.providerThreadId ?? null,
160
+ turnFingerprint: options.turnFingerprint,
161
+ persistProcessedFingerprint: options.persistProcessedFingerprint === true,
162
+ };
163
+
164
+ const outcome =
165
+ typeof this.deps.buffer.addTurnWithOutcome === "function"
166
+ ? await this.deps.buffer.addTurnWithOutcome(bufferKey, turn)
167
+ : { decision: await this.deps.buffer.addTurn(bufferKey, turn) };
168
+
169
+ if (outcome.decision === "keep_buffering") return;
170
+ await this.deps.queueBufferedExtraction(
171
+ outcome.extractionTurns ?? this.deps.buffer.getTurns(bufferKey),
172
+ "trigger_mode",
173
+ { bufferKey },
174
+ );
175
+ }
176
+
177
+ async ingestReplayBatch(
178
+ turns: ReplayTurn[],
179
+ options: {
180
+ deadlineMs?: number;
181
+ archiveLcm?: boolean;
182
+ abortSignal?: AbortSignal;
183
+ /**
184
+ * Pin extraction writes to this namespace instead of deriving one from
185
+ * `defaultNamespaceForPrincipal(resolvePrincipal(sessionKey))` + the
186
+ * coding overlay (#1495). The access `observe` surface resolves a single
187
+ * effective scope plan and passes its `writeNamespace` here so the
188
+ * extracted memories land in the SAME namespace as LCM archival,
189
+ * objective-state snapshots, and project-scoped recall — without relying
190
+ * on re-deriving the namespace from a namespace-prefixed session key.
191
+ * Same hook bulk-import uses (#460).
192
+ */
193
+ writeNamespaceOverride?: string;
194
+ /**
195
+ * Pin the provenance PRINCIPAL instead of deriving it from
196
+ * `resolvePrincipal(turn.sessionKey)` (#1495 thread 1). The access
197
+ * `observe` surface authenticates the caller at the transport layer and
198
+ * passes its resolved principal here so extracted-memory provenance uses
199
+ * the SAME identity the surface authorized — independent of storage
200
+ * routing (`writeNamespaceOverride`) and of whatever `resolvePrincipal`
201
+ * would parse from the raw session key. Mirrors the recall path's
202
+ * `principalOverride` (issue #570 PR 4).
203
+ */
204
+ principalOverride?: string;
205
+ } = {},
206
+ ): Promise<void> {
207
+ if (!Array.isArray(turns) || turns.length === 0) return;
208
+ if (options.abortSignal?.aborted) {
209
+ throw options.abortSignal.reason instanceof Error
210
+ ? options.abortSignal.reason
211
+ : new Error("ingestReplayBatch aborted");
212
+ }
213
+ if (shouldSkipImplicitExtraction(this.deps.config)) {
214
+ log.debug(
215
+ "ingestReplayBatch: skipping implicit extraction because captureMode=explicit",
216
+ );
217
+ return;
218
+ }
219
+
220
+ const bySession = new Map<string, BufferTurn[]>();
221
+ for (const turn of turns) {
222
+ if (turn.role !== "user" && turn.role !== "assistant") continue;
223
+ const key = normalizeReplaySessionKey(turn.sessionKey);
224
+ const list = bySession.get(key) ?? [];
225
+ list.push({
226
+ role: turn.role,
227
+ content: turn.content,
228
+ timestamp: turn.timestamp,
229
+ sourceValidAt: turn.sourceValidAt,
230
+ sessionKey: key,
231
+ parts: turn.parts,
232
+ rawContent: turn.rawContent,
233
+ sourceFormat: turn.sourceFormat,
234
+ });
235
+ bySession.set(key, list);
236
+ }
237
+
238
+ const replaySlices: Array<{
239
+ bufferKey: string;
240
+ order: number;
241
+ targetValidAtMs: number;
242
+ turns: BufferTurn[];
243
+ }> = [];
244
+ for (const [key, sessionTurns] of bySession.entries()) {
245
+ if (sessionTurns.length === 0) continue;
246
+ if (options.abortSignal?.aborted) {
247
+ throw options.abortSignal.reason instanceof Error
248
+ ? options.abortSignal.reason
249
+ : new Error("ingestReplayBatch aborted");
250
+ }
251
+ if (options.archiveLcm !== false && this.deps.lcmEngine?.enabled) {
252
+ await this.deps.lcmEngine.observeMessages(
253
+ key,
254
+ sessionTurns.map((turn) => ({
255
+ role: turn.role,
256
+ content: turn.content,
257
+ parts: turn.parts,
258
+ rawContent: turn.rawContent,
259
+ sourceFormat: turn.sourceFormat,
260
+ })),
261
+ );
262
+ }
263
+ for (const sessionSlice of splitTurnsBySourceValidAt(sessionTurns)) {
264
+ replaySlices.push({
265
+ bufferKey: key,
266
+ order: replaySlices.length,
267
+ targetValidAtMs: targetSourceValidAtSortMs(sessionSlice),
268
+ turns: sessionSlice,
269
+ });
270
+ }
271
+ }
272
+
273
+ const replayTasks = replaySlices
274
+ .sort((a, b) => {
275
+ if (a.targetValidAtMs < b.targetValidAtMs) return -1;
276
+ if (a.targetValidAtMs > b.targetValidAtMs) return 1;
277
+ if (a.order === b.order) return 0;
278
+ return a.order < b.order ? -1 : 1;
279
+ })
280
+ .map(
281
+ ({ bufferKey, turns: sessionSlice }) =>
282
+ new Promise<void>((resolve, reject) => {
283
+ void this.deps.queueBufferedExtraction(sessionSlice, "trigger_mode", {
284
+ skipDedupeCheck: true,
285
+ clearBufferAfterExtraction: false,
286
+ skipCharThreshold: true,
287
+ skipUserTurnThreshold: true,
288
+ bufferKey,
289
+ extractionDeadlineMs: options.deadlineMs,
290
+ abortSignal: options.abortSignal,
291
+ writeNamespaceOverride: options.writeNamespaceOverride,
292
+ principalOverride: options.principalOverride,
293
+ onTaskSettled: (err) => (err ? reject(err) : resolve()),
294
+ }).catch(reject);
295
+ }),
296
+ );
297
+ if (replayTasks.length > 0) {
298
+ const settled = await Promise.allSettled(replayTasks);
299
+ const firstRejected = settled.find(
300
+ (result): result is PromiseRejectedResult =>
301
+ result.status === "rejected",
302
+ );
303
+ if (firstRejected) {
304
+ throw firstRejected.reason;
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Ingest a batch of bulk-import turns (#460). Like ingestReplayBatch, this
311
+ * normalizes user/assistant turns into the extraction buffer and awaits
312
+ * settlement, but it intentionally bypasses the captureMode="explicit"
313
+ * gate because bulk-import is itself an explicit user action — the user
314
+ * ran `bulk-import --source <name> --file ...` and would be surprised to
315
+ * see the command silently no-op when capture is otherwise restricted.
316
+ *
317
+ * Turns with role="other" are skipped (not supported by the extraction
318
+ * pipeline).
319
+ *
320
+ * Two design decisions worth calling out:
321
+ *
322
+ * - **sessionKey is truthy and per-batch-unique.**
323
+ * `ThreadingManager.shouldStartNewThread` only applies the session-key
324
+ * boundary check when `turn.sessionKey` is truthy (threading.ts:82);
325
+ * with an empty string, imported turns could attach to the current
326
+ * live thread or merge across unrelated import batches. A unique
327
+ * `bulk-import:batch:<timestamp>-<rand>` key forces a fresh thread per
328
+ * batch without matching common prefix/map rules in
329
+ * `principalFromSessionKeyRules`. (Catch-all regex rules could still
330
+ * remap the principal, but that only affects metadata provenance —
331
+ * see the next point for why write routing is unaffected.)
332
+ *
333
+ * - **writeNamespaceOverride pins the storage target.**
334
+ * We pass `writeNamespaceOverride: this.deps.bulkImportWriteNamespace()` to
335
+ * `queueBufferedExtraction`, which tells `runExtraction` to skip
336
+ * `defaultNamespaceForPrincipal` and write directly into the
337
+ * orchestrator's declared bulk-import write namespace. This keeps
338
+ * writes deterministic even when namespace policies named `"default"`
339
+ * exist alongside a different `config.defaultNamespace`, and also
340
+ * guards against regex-catch-all principal rules steering bulk-import
341
+ * into an unexpected tenant.
342
+ *
343
+ * Per-invocation namespace routing (letting callers target a namespace
344
+ * other than `bulkImportWriteNamespace()`) is a separate feature tracked
345
+ * as a follow-up — the hook is the `writeNamespaceOverride` option, but
346
+ * the CLI surface does not yet expose a `--namespace` flag.
347
+ */
348
+ async ingestBulkImportBatch(
349
+ turns: ImportTurn[],
350
+ options: {
351
+ deadlineMs?: number;
352
+ failOnExtractionFailure?: boolean;
353
+ includeSourceValidAtContext?: boolean;
354
+ } = {},
355
+ ): Promise<BulkImportBatchIngestResult> {
356
+ if (!Array.isArray(turns) || turns.length === 0) {
357
+ return {
358
+ attemptedTurnCount: 0,
359
+ extractionCount: 0,
360
+ persistedCount: 0,
361
+ durableOutputCount: 0,
362
+ skippedCount: 0,
363
+ failedCount: 0,
364
+ postPersistMetadataFailureCount: 0,
365
+ processedTurnCount: 0,
366
+ };
367
+ }
368
+
369
+ // Per-batch unique sessionKey keeps threading honest without matching
370
+ // typical prefix/map routing rules. Combined with writeNamespaceOverride
371
+ // below, the storage target is independent of principal resolution.
372
+ // Uses crypto.randomBytes (not Math.random) so CodeQL does not flag a
373
+ // security-context insecure-randomness use even though this value never
374
+ // leaves the process; the bytes just need to be collision-resistant
375
+ // across concurrent bulk-import batches.
376
+ const shouldUseStableBatchKey = turns.some(
377
+ (turn) =>
378
+ turn.persistProcessedFingerprint === true ||
379
+ (typeof turn.turnFingerprint === "string" &&
380
+ turn.turnFingerprint.length > 0),
381
+ );
382
+ const stableBatchFingerprint = shouldUseStableBatchKey
383
+ ? createHash("sha256")
384
+ .update(
385
+ turns
386
+ .map((turn) =>
387
+ [
388
+ turn.role,
389
+ typeof turn.turnFingerprint === "string" &&
390
+ turn.turnFingerprint.length > 0
391
+ ? turn.turnFingerprint
392
+ : turn.content.replace(/\s+/g, " ").trim(),
393
+ ].join(":"),
394
+ )
395
+ .join("\n"),
396
+ )
397
+ .digest("hex")
398
+ .slice(0, 32)
399
+ : undefined;
400
+ const sessionKey = stableBatchFingerprint
401
+ ? `bulk-import:batch:${stableBatchFingerprint}`
402
+ : `bulk-import:batch:${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
403
+
404
+ const sessionTurns: BufferTurn[] = [];
405
+ for (const turn of turns) {
406
+ if (turn.role !== "user" && turn.role !== "assistant") continue;
407
+ sessionTurns.push({
408
+ role: turn.role,
409
+ content: turn.content,
410
+ timestamp: turn.timestamp,
411
+ sourceValidAt: turn.timestamp,
412
+ sessionKey,
413
+ parts: turn.parts,
414
+ rawContent: turn.rawContent,
415
+ sourceFormat: turn.sourceFormat,
416
+ importProvenance: turn.importProvenance,
417
+ turnFingerprint: turn.turnFingerprint,
418
+ persistProcessedFingerprint: turn.persistProcessedFingerprint === true,
419
+ });
420
+ }
421
+ if (sessionTurns.length === 0) {
422
+ return {
423
+ attemptedTurnCount: 0,
424
+ extractionCount: 0,
425
+ persistedCount: 0,
426
+ durableOutputCount: 0,
427
+ skippedCount: 0,
428
+ failedCount: 0,
429
+ postPersistMetadataFailureCount: 0,
430
+ processedTurnCount: 0,
431
+ };
432
+ }
433
+
434
+ if (this.deps.lcmEngine?.enabled) {
435
+ await this.deps.lcmEngine.observeMessages(
436
+ sessionKey,
437
+ sessionTurns.map((turn) => ({
438
+ role: turn.role,
439
+ content: turn.content,
440
+ parts: turn.parts,
441
+ rawContent: turn.rawContent,
442
+ sourceFormat: turn.sourceFormat,
443
+ })),
444
+ );
445
+ }
446
+
447
+ const sessionSlices = splitTurnsBySourceValidAt(sessionTurns, {
448
+ includeContext: options.includeSourceValidAtContext !== false,
449
+ });
450
+ const results: ExtractionRunResult[] = [];
451
+ let processedTurnCount = 0;
452
+ let firstRejected: unknown;
453
+ for (const sessionSlice of sessionSlices) {
454
+ try {
455
+ const result = await new Promise<ExtractionRunResult>(
456
+ (resolve, reject) => {
457
+ void this.deps.queueBufferedExtraction(sessionSlice, "trigger_mode", {
458
+ skipDedupeCheck: true,
459
+ clearBufferAfterExtraction: false,
460
+ skipCharThreshold: true,
461
+ skipUserTurnThreshold: true,
462
+ bufferKey: sessionKey,
463
+ extractionDeadlineMs: options.deadlineMs,
464
+ failOnExtractionFailure: options.failOnExtractionFailure === true,
465
+ writeNamespaceOverride: this.deps.bulkImportWriteNamespace(),
466
+ onTaskSettled: (err, result) =>
467
+ err
468
+ ? reject(err)
469
+ : resolve(
470
+ result ?? {
471
+ status: "skipped",
472
+ reason: "missing_extraction_result",
473
+ persistedCount: 0,
474
+ durableOutputCount: 0,
475
+ },
476
+ ),
477
+ }).catch(reject);
478
+ },
479
+ );
480
+ results.push(result);
481
+ processedTurnCount += sessionSlice.filter(
482
+ (turn) => turn.extractionContextOnly !== true,
483
+ ).length;
484
+ } catch (err) {
485
+ firstRejected = err;
486
+ break;
487
+ }
488
+ }
489
+ const rejectedCount = firstRejected ? 1 : 0;
490
+ const ingestResult: BulkImportBatchIngestResult = {
491
+ attemptedTurnCount: sessionTurns.length,
492
+ extractionCount: results.length,
493
+ persistedCount: results.reduce(
494
+ (sum, result) => sum + result.persistedCount,
495
+ 0,
496
+ ),
497
+ durableOutputCount: results.reduce(
498
+ (sum, result) => sum + result.durableOutputCount,
499
+ 0,
500
+ ),
501
+ skippedCount: results.filter((result) => result.status === "skipped").length,
502
+ failedCount: rejectedCount,
503
+ postPersistMetadataFailureCount: results.filter(
504
+ (result) => result.postPersistMetadataFailed === true,
505
+ ).length,
506
+ processedTurnCount:
507
+ rejectedCount === 0 ? sessionTurns.length : processedTurnCount,
508
+ };
509
+ if (firstRejected) {
510
+ if (processedTurnCount > 0) {
511
+ throw new BulkImportBatchPartialFailureError(
512
+ "bulk import failed after partial processing",
513
+ ingestResult,
514
+ firstRejected,
515
+ );
516
+ }
517
+ throw firstRejected;
518
+ }
519
+ return ingestResult;
520
+ }
521
+
522
+ async observeSessionHeartbeat(
523
+ sessionKey: string,
524
+ options: { bufferKey?: string } = {},
525
+ ): Promise<void> {
526
+ if (resolvePipelineProcessingCapabilities(this.deps.config).sessionObserver !== true) return;
527
+ if (!sessionKey || sessionKey.length === 0) return;
528
+
529
+ const bufferKey =
530
+ typeof options.bufferKey === "string" && options.bufferKey.length > 0
531
+ ? options.bufferKey
532
+ : sessionKey;
533
+ const previous =
534
+ this.deps.heartbeatObserverChains.get(sessionKey) ?? Promise.resolve();
535
+ const next = previous
536
+ .catch(() => undefined)
537
+ .then(async () => {
538
+ const turns = this.deps.buffer.getTurns(bufferKey);
539
+ if (turns.length === 0) return;
540
+ const normalizedSessionKey = normalizeReplaySessionKey(sessionKey);
541
+ const allowSharedSessionBuffer = bufferKey.startsWith(
542
+ CODEX_THREAD_KEY_PREFIX,
543
+ );
544
+ if (
545
+ !allowSharedSessionBuffer &&
546
+ turns.some(
547
+ (turn) =>
548
+ turn.sessionKey &&
549
+ normalizeReplaySessionKey(turn.sessionKey) !== normalizedSessionKey,
550
+ )
551
+ ) {
552
+ log.debug(
553
+ `heartbeat observer skipped: mixed-session buffer contents for ${bufferKey}`,
554
+ );
555
+ return;
556
+ }
557
+ if (!this.deps.shouldQueueExtraction(turns, {
558
+ commit: false,
559
+ bufferKey,
560
+ })) {
561
+ log.debug(
562
+ `heartbeat observer skipped: extraction dedupe for ${bufferKey}`,
563
+ );
564
+ return;
565
+ }
566
+ const footprint =
567
+ await this.deps.transcript.estimateSessionFootprint(sessionKey);
568
+ const decision = await this.deps.sessionObserver.observe({
569
+ sessionKey,
570
+ totalBytes: footprint.bytes,
571
+ totalTokens: footprint.tokens,
572
+ });
573
+ if (!decision.triggered) return;
574
+ log.debug(
575
+ `heartbeat observer trigger: session=${sessionKey} deltaBytes=${decision.deltaBytes} deltaTokens=${decision.deltaTokens}`,
576
+ );
577
+ await this.deps.queueBufferedExtraction(turns, "heartbeat_observer", {
578
+ bufferKey,
579
+ });
580
+ });
581
+
582
+ this.deps.heartbeatObserverChains.set(sessionKey, next);
583
+ try {
584
+ await next;
585
+ } finally {
586
+ if (this.deps.heartbeatObserverChains.get(sessionKey) === next) {
587
+ this.deps.heartbeatObserverChains.delete(sessionKey);
588
+ }
589
+ }
590
+ }
591
+
592
+ async queueBufferedExtraction(
593
+ turnsToExtract: BufferTurn[],
594
+ reason: "trigger_mode" | "heartbeat_observer",
595
+ options: {
596
+ skipDedupeCheck?: boolean;
597
+ clearBufferAfterExtraction?: boolean;
598
+ skipCharThreshold?: boolean;
599
+ skipUserTurnThreshold?: boolean;
600
+ extractionDeadlineMs?: number;
601
+ failOnExtractionFailure?: boolean;
602
+ onTaskSettled?: (
603
+ error?: unknown,
604
+ result?: ExtractionRunResult,
605
+ ) => void;
606
+ bufferKey?: string;
607
+ abortSignal?: AbortSignal;
608
+ /**
609
+ * Explicit namespace override for the write path (#460). When set,
610
+ * `runExtraction` writes to this namespace instead of deriving one
611
+ * from `defaultNamespaceForPrincipal(resolvePrincipal(sessionKey))`.
612
+ * Used by bulk-import to pin writes to a deterministic namespace
613
+ * regardless of user-configured principal routing rules.
614
+ */
615
+ writeNamespaceOverride?: string;
616
+ /**
617
+ * Pin the provenance principal (#1495 thread 1). Forwarded to
618
+ * `runExtraction` so access `observe` can record provenance under the
619
+ * authenticated principal instead of `resolvePrincipal(sessionKey)`.
620
+ */
621
+ principalOverride?: string;
622
+ } = {},
623
+ ): Promise<void> {
624
+ const bufferKey = options.bufferKey ?? turnsToExtract[0]?.sessionKey ?? "default";
625
+ if (
626
+ !options.skipDedupeCheck &&
627
+ !this.deps.shouldQueueExtraction(turnsToExtract, { bufferKey })
628
+ ) {
629
+ log.debug(`extraction dedupe skip: preserving buffer (${reason})`);
630
+ options.onTaskSettled?.(undefined, {
631
+ status: "skipped",
632
+ reason: "dedupe",
633
+ persistedCount: 0,
634
+ durableOutputCount: 0,
635
+ });
636
+ return;
637
+ }
638
+
639
+ const extractionDeadlineMs =
640
+ typeof options.extractionDeadlineMs === "number" &&
641
+ Number.isFinite(options.extractionDeadlineMs)
642
+ ? options.extractionDeadlineMs
643
+ : undefined;
644
+ let timeout: ReturnType<typeof setTimeout> | undefined;
645
+ let settled = false;
646
+ const clearQueueWaitTimer = (): void => {
647
+ if (timeout) {
648
+ clearTimeout(timeout);
649
+ timeout = undefined;
650
+ }
651
+ };
652
+ const settleTask = (
653
+ error?: unknown,
654
+ result?: ExtractionRunResult,
655
+ ): boolean => {
656
+ if (settled) return false;
657
+ settled = true;
658
+ clearQueueWaitTimer();
659
+ options.onTaskSettled?.(error, result);
660
+ return true;
661
+ };
662
+
663
+ if (typeof extractionDeadlineMs === "number") {
664
+ const remainingMs = extractionDeadlineMs - Date.now();
665
+ if (remainingMs <= 0) {
666
+ settleTask(new Error("replay extraction deadline exceeded (queue_wait)"));
667
+ return;
668
+ }
669
+ timeout = setTimeout(() => {
670
+ settleTask(new Error("replay extraction deadline exceeded (queue_wait)"));
671
+ }, remainingMs);
672
+ }
673
+
674
+ this.deps.extractionQueueCoordinator.enqueue(async () => {
675
+ if (settled) return;
676
+ if (
677
+ typeof extractionDeadlineMs === "number" &&
678
+ extractionDeadlineMs <= Date.now()
679
+ ) {
680
+ settleTask(new Error("replay extraction deadline exceeded (queue_wait)"));
681
+ return;
682
+ }
683
+ clearQueueWaitTimer();
684
+ try {
685
+ const result = await this.deps.runExtraction(turnsToExtract, {
686
+ clearBufferAfterExtraction:
687
+ options.clearBufferAfterExtraction ?? true,
688
+ skipCharThreshold: options.skipCharThreshold ?? false,
689
+ skipUserTurnThreshold: options.skipUserTurnThreshold ?? false,
690
+ deadlineMs: extractionDeadlineMs,
691
+ bufferKey,
692
+ abortSignal: options.abortSignal,
693
+ failOnExtractionFailure: options.failOnExtractionFailure === true,
694
+ writeNamespaceOverride: options.writeNamespaceOverride,
695
+ principalOverride: options.principalOverride,
696
+ });
697
+ settleTask(undefined, result);
698
+ } catch (err) {
699
+ if (settleTask(err)) {
700
+ throw err;
701
+ }
702
+ }
703
+ });
704
+
705
+ log.debug(`queued extraction from ${reason}`);
706
+ }
707
+
708
+ /**
709
+ * Passive correction capture (issue #1581) — detects corrections expressed
710
+ * passively in conversation turns and routes them to the Correction Contract
711
+ * (#1580). Called from `runExtraction` after persistence completes.
712
+ *
713
+ * Thin wiring: delegates ALL correction logic to the detector + capture
714
+ * modules + the CorrectionService. This method only checks gates, calls the
715
+ * detector, and routes results. Fail-open: capture errors never block the
716
+ * extraction return path.
717
+ */
718
+ async maybeCapturePassiveCorrections(
719
+ turns: readonly BufferTurn[],
720
+ opts: {
721
+ sessionKey: string;
722
+ principal?: string;
723
+ namespace: string;
724
+ bufferKey: string;
725
+ isLiveSession: boolean;
726
+ },
727
+ ): Promise<void> {
728
+ const mode = this.deps.config.correctionCaptureMode;
729
+ if (mode === "off") return;
730
+ if (!resolveRecallAuxiliaryCapabilities(this.deps.config).correction) return;
731
+
732
+ try {
733
+ const corrections = detectPassiveCorrections(
734
+ turns.map((t) => ({ role: t.role, content: t.content })),
735
+ );
736
+ if (corrections.length === 0) return;
737
+
738
+ // Replay/import: force queue-only mode even if config says auto.
739
+ const effectiveMode = opts.isLiveSession ? mode : "queue";
740
+ const captureConfig: PassiveCaptureConfig = {
741
+ mode: effectiveMode,
742
+ confidenceFloor: this.deps.config.correctionCaptureConfidenceFloor,
743
+ autoApplyMaxAffected: this.deps.config.correctionCaptureAutoApplyMaxAffected,
744
+ };
745
+
746
+ const service = this.deps.passiveCorrectionService();
747
+ const result = await capturePassiveCorrections(
748
+ corrections,
749
+ {
750
+ correctionEnabled: resolveRecallAuxiliaryCapabilities(this.deps.config).correction,
751
+ isLiveSession: opts.isLiveSession,
752
+ bufferKey: opts.bufferKey,
753
+ sessionKey: opts.sessionKey,
754
+ principal: opts.principal,
755
+ namespace: opts.namespace,
756
+ },
757
+ captureConfig,
758
+ {
759
+ planCorrection: (req) => service.plan(req),
760
+ applyCorrection: (planId, applyOpts) => service.apply(planId, applyOpts),
761
+ storageDir: async (ns) => (await this.deps.getStorage(ns)).dir,
762
+ // Resolve `[m:xxxx]` handles to concrete memory ids via the single
763
+ // shared helper (#1582). Returns null on miss/ambiguity so the
764
+ // capture loop drops the handle and the planner falls back to text
765
+ // search (review: "memory handles not resolved").
766
+ resolveHandle: (ref, sessionKey) => {
767
+ try {
768
+ return this.deps.resolveMemoryIdOrHandle(ref, sessionKey);
769
+ } catch {
770
+ return null;
771
+ }
772
+ },
773
+ },
774
+ this.deps.passiveCorrectionDedup,
775
+ );
776
+
777
+ // Accumulate telemetry
778
+ this.deps.passiveCorrectionTelemetry.detected += result.telemetry.detected;
779
+ this.deps.passiveCorrectionTelemetry.queued += result.telemetry.queued;
780
+ this.deps.passiveCorrectionTelemetry.autoApplied += result.telemetry.autoApplied;
781
+ for (const [reason, count] of Object.entries(result.telemetry.suppressedReasons)) {
782
+ this.deps.passiveCorrectionTelemetry.suppressedReasonCounts[reason] =
783
+ (this.deps.passiveCorrectionTelemetry.suppressedReasonCounts[reason] ?? 0) + count;
784
+ }
785
+ } catch (err) {
786
+ // Fail-open: passive capture never blocks extraction.
787
+ log.debug(
788
+ `passive-correction: capture failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
789
+ );
790
+ }
791
+ }
792
+ }