@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.
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +2 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-operations.d.ts +2 -2
- package/dist/{access-service-Cx16nmCv.d.ts → access-service-CH1aqqb1.d.ts} +1 -1
- package/dist/access-service.d.ts +2 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-PJE6D3NH.js → chunk-ZWGXZ7ID.js} +11555 -10846
- package/dist/chunk-ZWGXZ7ID.js.map +1 -0
- package/dist/{cli-D1y3vooB.d.ts → cli-CE5olss2.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-CnCE3Hvq.d.ts → orchestrator-CIOqdNzF.d.ts} +77 -85
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +27 -3
- package/package.json +2 -2
- package/src/orchestration/orchestrator-init.ts +712 -0
- package/src/orchestration/recall-internal.ts +5479 -0
- package/src/orchestration/recall-introspection.ts +709 -0
- package/src/orchestration/recall-search-pipeline.ts +1710 -0
- package/src/orchestration/turn-ingestion.ts +792 -0
- package/src/orchestrator.ts +423 -8077
- package/dist/chunk-PJE6D3NH.js.map +0 -1
|
@@ -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
|
+
}
|