@sovorn/pi-session-memory 0.1.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.
@@ -0,0 +1,700 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import type { Readable, Writable } from "node:stream";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createInterface, type Interface as ReadlineInterface } from "node:readline";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { ExtensionAPI, ExtensionContext, SessionEntry, SessionStartEvent, TurnEndEvent } from "@earendil-works/pi-coding-agent";
8
+ import { loadMemoryConfig, resolvePython, type LoadedMemoryConfig, type MemorySettings } from "./config.ts";
9
+ import { registerMemoryCommand } from "./memory-command.ts";
10
+ import { CANDIDATES, PROJECTION_CHARS, currentCandidates, registerProjection, type ProjectionGateRequest, type ProjectionLimits, type ProjectionSelectionDecision } from "./projection.ts";
11
+ import { classifySupersessionDecision, supersessionStatuses, type SupersessionData } from "./supersession.ts";
12
+
13
+ const OBSERVATION_TYPE = "pi-session-memory.observation";
14
+ const REFLECTION_TYPE = "pi-session-memory.reflection";
15
+ const SUPERSESSION_TYPE = "pi-session-memory.supersession";
16
+ const PROTOCOL_VERSION = 1;
17
+ const MAX_GATE_STATE_BYTES = 8_000;
18
+ const MAX_MEMORY_TEXT_CHARS = 1_000;
19
+ const MAX_RAW_SOURCES = 12;
20
+ const MAX_REFLECTION_SOURCES = 6;
21
+ const MAX_SUPERSESSION_CANDIDATES = 8;
22
+ // Covers the measured ~1.3s MPS load plus inference; this internal fail-native guard is not a latency SLA.
23
+ const WORKER_REQUEST_TIMEOUT_MS = 30_000;
24
+ export const CONFIRM_TITLE = "Allow session-memory generation?";
25
+ export const DISCLOSURE =
26
+ "Session-derived text and its source entry IDs will be sent to the currently configured Pi model/provider only after a local Laya gate accepts. Laya runs locally. The Pi session remains canonical; generated memories are appended as non-context session entries. Do you allow this for the current session?";
27
+ const WORKER_FAILURE_NOTICE = "Session memory worker unavailable; continuing with native Pi context.";
28
+ type LayaWorkerProcess = ChildProcessByStdio<Writable, Readable, null>;
29
+
30
+ export type FormationGate = "observation" | "reflection" | "resident" | "projection" | "supersession";
31
+ export interface GateRequest {
32
+ gate: FormationGate;
33
+ state: string;
34
+ }
35
+ export interface GateDecision {
36
+ accepted: boolean;
37
+ p_true: number;
38
+ confidence: number;
39
+ }
40
+ type GateResult = GateDecision | ProjectionSelectionDecision;
41
+ type GateEvaluator = (request: GateRequest) => Promise<GateResult>;
42
+ interface WorkerTimeoutScheduler {
43
+ setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
44
+ clearTimeout(timer: ReturnType<typeof setTimeout>): void;
45
+ }
46
+ const workerTimeoutScheduler: WorkerTimeoutScheduler = {
47
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
48
+ clearTimeout: (timer) => clearTimeout(timer),
49
+ };
50
+ export interface FormationOptions {
51
+ evaluateGate?: GateEvaluator;
52
+ timeoutScheduler?: WorkerTimeoutScheduler;
53
+ agentDir?: () => string;
54
+ env?: NodeJS.ProcessEnv;
55
+ projectionLimits?: ProjectionLimits;
56
+ }
57
+
58
+ interface ObservationData {
59
+ schemaVersion: 1;
60
+ text: string;
61
+ sourceEntryIds: string[];
62
+ }
63
+ interface ReflectionData {
64
+ schemaVersion: 1;
65
+ text: string;
66
+ supportingObservationIds: string[];
67
+ }
68
+ interface FormationCadence {
69
+ lastUsageTokens: number;
70
+ observationTokens: number;
71
+ reflectionTokens: number;
72
+ }
73
+ interface RawSource {
74
+ entryId: string;
75
+ role: string;
76
+ text: string;
77
+ }
78
+ interface LinkedObservation {
79
+ entryId: string;
80
+ data: ObservationData;
81
+ }
82
+ type MemoryCandidate = ReturnType<typeof currentCandidates>[number];
83
+
84
+ class LayaWorker {
85
+ private child: LayaWorkerProcess | undefined;
86
+ private lines: ReadlineInterface | undefined;
87
+ private pending: { requestId: string; gate: FormationGate; resolve: (decision: GateResult) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> } | undefined;
88
+ private queue: Promise<void> = Promise.resolve();
89
+ private unhealthy = false;
90
+ private readonly timeoutScheduler: WorkerTimeoutScheduler;
91
+ private readonly reportFailure: () => void;
92
+
93
+ private readonly pythonCommand: () => string;
94
+
95
+ constructor(timeoutScheduler: WorkerTimeoutScheduler, reportFailure: () => void, pythonCommand: () => string) {
96
+ this.timeoutScheduler = timeoutScheduler;
97
+ this.reportFailure = reportFailure;
98
+ this.pythonCommand = pythonCommand;
99
+ }
100
+
101
+ request(request: GateRequest): Promise<GateResult> {
102
+ const queued = this.queue.then(() => {
103
+ if (this.unhealthy) return Promise.reject(new Error("Laya worker is unhealthy for this session"));
104
+ return new Promise<GateResult>((resolveResult, rejectResult) => {
105
+ let child: LayaWorkerProcess;
106
+ try {
107
+ child = this.start();
108
+ } catch (error) {
109
+ const failure = error instanceof Error ? error : new Error("Could not start Laya worker");
110
+ this.fail(failure);
111
+ rejectResult(failure);
112
+ return;
113
+ }
114
+ const requestId = randomUUID();
115
+ const timer = this.timeoutScheduler.setTimeout(() => this.fail(new Error("Laya worker request timed out"), child), WORKER_REQUEST_TIMEOUT_MS);
116
+ this.pending = { requestId, gate: request.gate, resolve: resolveResult, reject: rejectResult, timer };
117
+ child.stdin.write(`${JSON.stringify({ protocol_version: PROTOCOL_VERSION, request_id: requestId, gate: request.gate, state: request.state })}\n`, (error) => {
118
+ if (error) this.fail(error, child);
119
+ });
120
+ });
121
+ });
122
+ this.queue = queued.then(() => undefined, () => undefined);
123
+ return queued;
124
+ }
125
+
126
+ stop(): void {
127
+ this.fail(new Error("Laya worker stopped"), undefined, false);
128
+ }
129
+
130
+ private start(): LayaWorkerProcess {
131
+ if (this.child) return this.child;
132
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
133
+ const python = this.pythonCommand();
134
+ const child = spawn(python, ["-m", "worker"], { cwd: projectRoot, stdio: ["pipe", "pipe", "ignore"] });
135
+ this.child = child;
136
+ const lines = createInterface({ input: child.stdout });
137
+ this.lines = lines;
138
+ lines.on("line", (line) => this.receive(line, child));
139
+ child.on("error", (error) => this.fail(error, child));
140
+ child.on("exit", (code, signal) => {
141
+ if (this.child !== child) return;
142
+ this.fail(new Error(`Laya worker exited (${code ?? signal ?? "unknown"})`), child);
143
+ });
144
+ return child;
145
+ }
146
+
147
+ private receive(line: string, child: LayaWorkerProcess): void {
148
+ if (this.child !== child) return;
149
+ const pending = this.pending;
150
+ if (!pending) return this.fail(new Error("Unexpected Laya worker response"), child);
151
+ let payload: unknown;
152
+ try {
153
+ payload = JSON.parse(line);
154
+ } catch {
155
+ return this.fail(new Error("Invalid Laya worker response"), child);
156
+ }
157
+ if (!isRecord(payload) || payload.protocol_version !== PROTOCOL_VERSION || payload.request_id !== pending.requestId || payload.gate !== pending.gate || payload.status !== "ok" || !isRecord(payload.decision)) {
158
+ return this.fail(new Error("Untrusted Laya worker response"), child);
159
+ }
160
+ if (pending.gate === "projection") {
161
+ const selected_entry_id = payload.decision.selected_entry_id;
162
+ if (selected_entry_id === null) {
163
+ this.timeoutScheduler.clearTimeout(pending.timer);
164
+ this.pending = undefined;
165
+ pending.resolve({ selected_entry_id: null });
166
+ return;
167
+ }
168
+ if (typeof selected_entry_id !== "string") return this.fail(new Error("Invalid Laya worker selection"), child);
169
+ this.timeoutScheduler.clearTimeout(pending.timer);
170
+ this.pending = undefined;
171
+ pending.resolve({ selected_entry_id });
172
+ return;
173
+ }
174
+ const { accepted, p_true, confidence } = payload.decision;
175
+ if (typeof accepted !== "boolean" || !isProbability(p_true) || !isProbability(confidence)) {
176
+ return this.fail(new Error("Invalid Laya worker decision"), child);
177
+ }
178
+ this.timeoutScheduler.clearTimeout(pending.timer);
179
+ this.pending = undefined;
180
+ pending.resolve({ accepted, p_true, confidence });
181
+ }
182
+
183
+ private fail(error: Error, sourceChild?: LayaWorkerProcess, report = true): void {
184
+ if (sourceChild && this.child !== sourceChild) return;
185
+ const shouldReport = report && !this.unhealthy;
186
+ this.unhealthy = true;
187
+ const pending = this.pending;
188
+ this.pending = undefined;
189
+ if (pending) this.timeoutScheduler.clearTimeout(pending.timer);
190
+ pending?.reject(error);
191
+ this.child?.kill();
192
+ this.child = undefined;
193
+ this.lines?.close();
194
+ this.lines = undefined;
195
+ if (shouldReport) this.reportFailure();
196
+ }
197
+ }
198
+
199
+ export function registerFormation(pi: ExtensionAPI, options: FormationOptions = {}): void {
200
+ const {
201
+ evaluateGate,
202
+ timeoutScheduler = workerTimeoutScheduler,
203
+ agentDir = () => "",
204
+ env = process.env,
205
+ projectionLimits = { candidates: CANDIDATES, chars: PROJECTION_CHARS },
206
+ } = options;
207
+
208
+ const cadenceBySession = new Map<string, FormationCadence>();
209
+ const consentedSessions = new Set<string>();
210
+ const generationOverrides = new Map<string, boolean>();
211
+ const pendingTurns = new Map<string, Promise<void>>();
212
+ let worker: LayaWorker | undefined;
213
+ let workerSessionId: string | undefined;
214
+ let loaded: LoadedMemoryConfig | undefined;
215
+ let noticeSent = false;
216
+
217
+ const reload = (): LoadedMemoryConfig => {
218
+ loaded = loadMemoryConfig(agentDir());
219
+ return loaded;
220
+ };
221
+ const ensure = (): LoadedMemoryConfig => loaded ?? reload();
222
+ const warn = (ctx: ExtensionContext): void => {
223
+ const config = ensure();
224
+ if (noticeSent || config.notice === null || !ctx.hasUI) return;
225
+ noticeSent = true;
226
+ try {
227
+ ctx.ui.notify(`${config.path}: ${config.notice}`, "warning");
228
+ } catch {
229
+ // A missing UI notification must not break the session.
230
+ }
231
+ };
232
+
233
+ const gate = (sessionId: string, request: GateRequest, ctx: ExtensionContext): Promise<GateResult> => {
234
+ if (evaluateGate) return evaluateGate(request);
235
+ if (workerSessionId !== sessionId) {
236
+ worker?.stop();
237
+ worker = undefined;
238
+ workerSessionId = sessionId;
239
+ }
240
+ worker ??= new LayaWorker(
241
+ timeoutScheduler,
242
+ () => reportWorkerFailure(ctx),
243
+ () => resolvePython(env, ensure().settings).command,
244
+ );
245
+ return worker.request(request);
246
+ };
247
+
248
+ const clearProjection = registerProjection(
249
+ pi,
250
+ (sessionId, request: ProjectionGateRequest, ctx) => gate(sessionId, request, ctx),
251
+ projectionLimits,
252
+ );
253
+ const generationEnabled = (sessionId: string): boolean => {
254
+ const override = generationOverrides.get(sessionId);
255
+ if (override !== undefined) return override;
256
+ return ensure().settings.generation === true;
257
+ };
258
+ registerMemoryCommand(pi, {
259
+ ensure,
260
+ env,
261
+ sessionId: (ctx) => ctx.sessionManager.getSessionId(),
262
+ overrides: generationOverrides,
263
+ consented: consentedSessions,
264
+ });
265
+
266
+ pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => {
267
+ reload();
268
+ warn(ctx);
269
+ const sessionId = ctx.sessionManager.getSessionId();
270
+ if (workerSessionId !== sessionId) {
271
+ worker?.stop();
272
+ worker = undefined;
273
+ workerSessionId = sessionId;
274
+ }
275
+ cadenceBySession.set(sessionId, {
276
+ lastUsageTokens: latestAssistantUsageTokens(ctx.sessionManager.getBranch()),
277
+ observationTokens: 0,
278
+ reflectionTokens: 0,
279
+ });
280
+ switch (event.reason) {
281
+ case "startup":
282
+ case "reload":
283
+ case "new":
284
+ case "resume":
285
+ case "fork":
286
+ consentedSessions.delete(sessionId);
287
+ generationOverrides.delete(sessionId);
288
+ break;
289
+ default: {
290
+ const unexpected: never = event.reason;
291
+ throw new Error(`unexpected session start ${String(unexpected)}`);
292
+ }
293
+ }
294
+ });
295
+
296
+ pi.on("turn_end", (event: TurnEndEvent, ctx: ExtensionContext) => {
297
+ const sessionId = ctx.sessionManager.getSessionId();
298
+ const prior = pendingTurns.get(sessionId) ?? Promise.resolve();
299
+ const work = prior.then(() => formForTurn(
300
+ pi,
301
+ event,
302
+ ctx,
303
+ sessionId,
304
+ cadenceBySession,
305
+ consentedSessions,
306
+ gate,
307
+ () => ensure().settings,
308
+ () => generationEnabled(sessionId),
309
+ () => {
310
+ reload();
311
+ return generationEnabled(sessionId);
312
+ },
313
+ ));
314
+ const pending = work.catch(() => undefined);
315
+ pendingTurns.set(sessionId, pending);
316
+ return pending;
317
+ });
318
+
319
+ pi.on("session_shutdown", () => {
320
+ worker?.stop();
321
+ worker = undefined;
322
+ workerSessionId = undefined;
323
+ cadenceBySession.clear();
324
+ consentedSessions.clear();
325
+ generationOverrides.clear();
326
+ pendingTurns.clear();
327
+ clearProjection();
328
+ });
329
+ }
330
+
331
+ async function formForTurn(
332
+ pi: ExtensionAPI,
333
+ event: TurnEndEvent,
334
+ ctx: ExtensionContext,
335
+ sessionId: string,
336
+ cadenceBySession: Map<string, FormationCadence>,
337
+ consentedSessions: Set<string>,
338
+ gate: (sessionId: string, request: GateRequest, ctx: ExtensionContext) => Promise<GateResult>,
339
+ settings: () => MemorySettings,
340
+ generationOn: () => boolean,
341
+ rereadGeneration: () => boolean,
342
+ ): Promise<void> {
343
+ const branch = ctx.sessionManager.getBranch();
344
+ const cadence = cadenceBySession.get(sessionId) ?? {
345
+ lastUsageTokens: latestAssistantUsageTokens(branch, event.messageEntryId),
346
+ observationTokens: 0,
347
+ reflectionTokens: 0,
348
+ };
349
+ cadenceBySession.set(sessionId, cadence);
350
+ if (event.message.role !== "assistant") return;
351
+ const usageTokens = assistantUsageTokens(event.message);
352
+ if (usageTokens === undefined) return;
353
+ const newTokens = Math.max(0, usageTokens - cadence.lastUsageTokens);
354
+ cadence.lastUsageTokens = usageTokens;
355
+ cadence.observationTokens += newTokens;
356
+ cadence.reflectionTokens += newTokens;
357
+
358
+ const cadenceSettings = settings();
359
+ const observeAfter = cadenceSettings.observeAfterTokens;
360
+ const reflectAfter = cadenceSettings.reflectAfterTokens;
361
+ const observationDue = cadence.observationTokens >= observeAfter;
362
+ const reflectionDue = cadence.reflectionTokens >= reflectAfter;
363
+ if (observationDue) cadence.observationTokens = 0;
364
+ if (reflectionDue) cadence.reflectionTokens = 0;
365
+
366
+ if (observationDue) {
367
+ const sources = rawSourcesForTurn(event, branch);
368
+ if (sources.length > 0) {
369
+ const state = encodeGateState({ sourceEntryIds: sources.map(({ entryId }) => entryId), sources }, "observation");
370
+ if (state) {
371
+ const decision = await gate(sessionId, { gate: "observation", state }, ctx);
372
+ if (isGateDecision(decision) && decision.accepted) {
373
+ const observationId = await generateObservation(pi, ctx, sessionId, sources, consentedSessions, generationOn, rereadGeneration);
374
+ if (observationId) await supersedeMemories(pi, ctx, sessionId, observationId, sources, gate);
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ if (reflectionDue) {
381
+ const branch = ctx.sessionManager.getBranch();
382
+ const statuses = supersessionStatuses(branch);
383
+ const observations = linkedObservations(branch)
384
+ .filter(({ entryId }) => statuses.get(entryId)?.status === "current")
385
+ .slice(-MAX_REFLECTION_SOURCES);
386
+ if (observations.length >= 2) {
387
+ const state = encodeGateState({ observations: observations.map(({ entryId, data }) => ({ entryId, text: data.text, sourceEntryIds: data.sourceEntryIds })) }, "reflection");
388
+ if (state) {
389
+ const decision = await gate(sessionId, { gate: "reflection", state }, ctx);
390
+ if (isGateDecision(decision) && decision.accepted) await generateReflection(pi, ctx, sessionId, observations, consentedSessions, generationOn, rereadGeneration);
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ async function generateObservation(
397
+ pi: ExtensionAPI,
398
+ ctx: ExtensionContext,
399
+ sessionId: string,
400
+ sources: RawSource[],
401
+ consentedSessions: Set<string>,
402
+ generationOn: () => boolean,
403
+ rereadGeneration: () => boolean,
404
+ ): Promise<string | undefined> {
405
+ const model = await enabledModel(ctx, sessionId, consentedSessions, generationOn, rereadGeneration);
406
+ if (!model) return undefined;
407
+ const sourceEntryIds = sources.map(({ entryId }) => entryId);
408
+ const prompt = `Write one concise durable observation, at most two sentences. Return only the observation text; do not invent or emit source IDs.\n\nSupporting session evidence:\n${JSON.stringify(sources)}`;
409
+ const text = await generateText(ctx, model, prompt);
410
+ if (!text || ctx.sessionManager.getSessionId() !== sessionId) return undefined;
411
+ const branch = ctx.sessionManager.getBranch();
412
+ if (!activeRawSources(branch, sourceEntryIds)) return undefined;
413
+ const existingIds = new Set(branch.map(({ id }) => id));
414
+ const data: ObservationData = { schemaVersion: 1, text, sourceEntryIds };
415
+ pi.appendEntry(OBSERVATION_TYPE, data);
416
+ if (ctx.sessionManager.getSessionId() !== sessionId) return undefined;
417
+ const appended = ctx.sessionManager.getBranch().filter((entry) =>
418
+ !existingIds.has(entry.id) && isCustomEntry(entry, OBSERVATION_TYPE) && isObservationData(entry.data) &&
419
+ entry.data.text === data.text && sameStrings(entry.data.sourceEntryIds, data.sourceEntryIds),
420
+ );
421
+ return appended.length === 1 ? appended[0].id : undefined;
422
+ }
423
+
424
+ async function supersedeMemories(
425
+ pi: ExtensionAPI,
426
+ ctx: ExtensionContext,
427
+ sessionId: string,
428
+ replacementEntryId: string,
429
+ sources: RawSource[],
430
+ gate: (sessionId: string, request: GateRequest, ctx: ExtensionContext) => Promise<GateResult>,
431
+ ): Promise<void> {
432
+ const branch = ctx.sessionManager.getBranch();
433
+ if (ctx.sessionManager.getSessionId() !== sessionId || !rawSourcesStillActive(branch, sources)) return;
434
+ const candidates = currentCandidates(branch);
435
+ const replacement = candidates.find(({ entryId, kind }) => entryId === replacementEntryId && kind === "observation");
436
+ if (!replacement || !sameStrings(replacement.sourceEntryIds, sources.map(({ entryId }) => entryId))) return;
437
+ const oldCandidates = candidates
438
+ .filter(({ entryId, branchIndex }) => entryId !== replacementEntryId && branchIndex < replacement.branchIndex)
439
+ .slice(-MAX_SUPERSESSION_CANDIDATES)
440
+ .reverse();
441
+
442
+ for (const oldMemory of oldCandidates) {
443
+ const state = encodeGateState({
444
+ oldMemory: { entryId: oldMemory.entryId, kind: oldMemory.kind, text: oldMemory.text },
445
+ newEvidence: {
446
+ entryId: replacement.entryId,
447
+ text: replacement.text,
448
+ sourceEntryIds: replacement.sourceEntryIds,
449
+ sources,
450
+ },
451
+ }, "supersession");
452
+ let outcome: ReturnType<typeof classifySupersessionDecision>;
453
+ if (!state) {
454
+ outcome = { status: "unresolved" };
455
+ } else {
456
+ try {
457
+ outcome = classifySupersessionDecision(await gate(sessionId, { gate: "supersession", state }, ctx));
458
+ } catch {
459
+ outcome = { status: "unresolved" };
460
+ }
461
+ }
462
+ if (outcome.status === "rejected") continue;
463
+
464
+ const currentBranch = ctx.sessionManager.getBranch();
465
+ if (ctx.sessionManager.getSessionId() !== sessionId ||
466
+ !supersessionLinksStillActive(currentBranch, oldMemory, replacement, sources)) continue;
467
+ const data: SupersessionData = outcome.status === "superseded"
468
+ ? {
469
+ schemaVersion: 1,
470
+ status: "superseded",
471
+ supersededEntryId: oldMemory.entryId,
472
+ replacementEntryId: replacement.entryId,
473
+ decision: outcome.decision,
474
+ }
475
+ : {
476
+ schemaVersion: 1,
477
+ status: "unresolved",
478
+ supersededEntryId: oldMemory.entryId,
479
+ replacementEntryId: replacement.entryId,
480
+ };
481
+ pi.appendEntry(SUPERSESSION_TYPE, data);
482
+ }
483
+ }
484
+
485
+ async function generateReflection(
486
+ pi: ExtensionAPI,
487
+ ctx: ExtensionContext,
488
+ sessionId: string,
489
+ observations: LinkedObservation[],
490
+ consentedSessions: Set<string>,
491
+ generationOn: () => boolean,
492
+ rereadGeneration: () => boolean,
493
+ ): Promise<void> {
494
+ const model = await enabledModel(ctx, sessionId, consentedSessions, generationOn, rereadGeneration);
495
+ if (!model) return;
496
+ const supportingObservationIds = observations.map(({ entryId }) => entryId);
497
+ const prompt = `Synthesize only a stable insight supported by these linked observations. Write at most two sentences and return only the reflection text; do not emit IDs.\n\nSupporting observations:\n${JSON.stringify(observations.map(({ entryId, data }) => ({ entryId, text: data.text, sourceEntryIds: data.sourceEntryIds })))}`;
498
+ const text = await generateText(ctx, model, prompt);
499
+ if (!text || ctx.sessionManager.getSessionId() !== sessionId) return;
500
+ const branch = ctx.sessionManager.getBranch();
501
+ const statuses = supersessionStatuses(branch);
502
+ const current = linkedObservations(branch).filter(({ entryId }) => statuses.get(entryId)?.status === "current");
503
+ const available = new Set(current.map(({ entryId }) => entryId));
504
+ if (!supportingObservationIds.every((id) => available.has(id))) return;
505
+ const data: ReflectionData = { schemaVersion: 1, text, supportingObservationIds };
506
+ pi.appendEntry(REFLECTION_TYPE, data);
507
+ }
508
+
509
+ async function enabledModel(
510
+ ctx: ExtensionContext,
511
+ sessionId: string,
512
+ consentedSessions: Set<string>,
513
+ generationOn: () => boolean,
514
+ rereadGeneration: () => boolean,
515
+ ): Promise<NonNullable<ExtensionContext["model"]> | undefined> {
516
+ if (!generationOn() || !ctx.model || !ctx.hasUI) return undefined;
517
+ if (!consentedSessions.has(sessionId)) {
518
+ const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, DISCLOSURE);
519
+ if (!confirmed || !rereadGeneration()) return undefined;
520
+ consentedSessions.add(sessionId);
521
+ }
522
+ return rereadGeneration() ? ctx.model : undefined;
523
+ }
524
+
525
+ async function generateText(ctx: ExtensionContext, model: NonNullable<ExtensionContext["model"]>, prompt: string): Promise<string | undefined> {
526
+ try {
527
+ const response = await ctx.modelRegistry.complete(model, { messages: [{ role: "user", content: prompt, timestamp: Date.now() }] });
528
+ const text = response.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n").trim();
529
+ return text.length > 0 && text.length <= MAX_MEMORY_TEXT_CHARS ? text : undefined;
530
+ } catch {
531
+ return undefined;
532
+ }
533
+ }
534
+
535
+ function reportWorkerFailure(ctx: ExtensionContext): void {
536
+ if (ctx.hasUI) {
537
+ try {
538
+ ctx.ui.notify(WORKER_FAILURE_NOTICE, "warning");
539
+ return;
540
+ } catch {}
541
+ }
542
+ process.stderr.write(`${WORKER_FAILURE_NOTICE}\n`);
543
+ }
544
+
545
+ function rawSourcesForTurn(event: TurnEndEvent, branch: SessionEntry[]): RawSource[] {
546
+ const currentIndex = branch.findIndex(({ id }) => id === event.messageEntryId);
547
+ if (currentIndex < 0 || !isMessageEntry(branch[currentIndex]) || branch[currentIndex].message.role !== "assistant") return [];
548
+ const byId = new Map<string, SessionEntry>();
549
+ for (const entry of branch) byId.set(entry.id, entry);
550
+ if (!event.toolResultEntryIds.every((id) => {
551
+ const entry = byId.get(id);
552
+ return entry !== undefined && isMessageEntry(entry) && entry.message.role === "toolResult";
553
+ })) return [];
554
+
555
+ let userIndex = -1;
556
+ for (let index = currentIndex; index >= 0; index -= 1) {
557
+ const entry = branch[index];
558
+ if (isMessageEntry(entry) && entry.message.role === "user") {
559
+ userIndex = index;
560
+ break;
561
+ }
562
+ }
563
+ if (userIndex < 0) return [];
564
+ const entries = branch.slice(userIndex, currentIndex + 1).filter((entry): entry is Extract<SessionEntry, { type: "message" }> =>
565
+ isMessageEntry(entry) && ["user", "assistant", "toolResult"].includes(entry.message.role),
566
+ ).slice(-MAX_RAW_SOURCES);
567
+ const perSourceBytes = Math.floor(5_000 / Math.max(entries.length, 1));
568
+ return entries.map((entry) => ({
569
+ entryId: entry.id,
570
+ role: entry.message.role,
571
+ text: truncateUtf8(messageText(entry.message), perSourceBytes),
572
+ }));
573
+ }
574
+
575
+ function linkedObservations(branch: SessionEntry[]): LinkedObservation[] {
576
+ const rawIds = new Set(branch.filter(isMessageEntry).map(({ id }) => id));
577
+ return branch.flatMap((entry): LinkedObservation[] => {
578
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== OBSERVATION_TYPE || !isObservationData(entry.data)) return [];
579
+ if (!entry.data.sourceEntryIds.every((id) => rawIds.has(id))) return [];
580
+ return [{ entryId: entry.id, data: entry.data }];
581
+ });
582
+ }
583
+
584
+ function activeRawSources(branch: SessionEntry[], sourceEntryIds: string[]): boolean {
585
+ if (sourceEntryIds.length === 0 || new Set(sourceEntryIds).size !== sourceEntryIds.length) return false;
586
+ const rawIds = new Set(branch.filter(isMessageEntry).map(({ id }) => id));
587
+ return sourceEntryIds.every((id) => rawIds.has(id));
588
+ }
589
+
590
+ function rawSourcesStillActive(branch: SessionEntry[], sources: RawSource[]): boolean {
591
+ if (sources.length === 0 || new Set(sources.map(({ entryId }) => entryId)).size !== sources.length) return false;
592
+ const byId = new Map(branch.filter(isMessageEntry).map((entry) => [entry.id, entry]));
593
+ const perSourceBytes = Math.floor(5_000 / sources.length);
594
+ return sources.every((source) => {
595
+ const entry = byId.get(source.entryId);
596
+ return entry !== undefined && entry.message.role === source.role &&
597
+ truncateUtf8(messageText(entry.message), perSourceBytes) === source.text;
598
+ });
599
+ }
600
+
601
+ function supersessionLinksStillActive(
602
+ branch: SessionEntry[],
603
+ oldMemory: MemoryCandidate,
604
+ replacement: MemoryCandidate,
605
+ sources: RawSource[],
606
+ ): boolean {
607
+ const current = currentCandidates(branch);
608
+ const oldNow = current.find(({ entryId }) => entryId === oldMemory.entryId);
609
+ const replacementNow = current.find(({ entryId }) => entryId === replacement.entryId);
610
+ return oldNow !== undefined && replacementNow !== undefined &&
611
+ sameCandidate(oldNow, oldMemory) && sameCandidate(replacementNow, replacement) &&
612
+ oldNow.branchIndex < replacementNow.branchIndex &&
613
+ sameStrings(replacementNow.sourceEntryIds, sources.map(({ entryId }) => entryId)) &&
614
+ rawSourcesStillActive(branch, sources);
615
+ }
616
+
617
+ function sameCandidate(left: MemoryCandidate, right: MemoryCandidate): boolean {
618
+ return left.entryId === right.entryId && left.kind === right.kind && left.text === right.text &&
619
+ left.sourceText === right.sourceText && sameStrings(left.observationIds, right.observationIds) &&
620
+ sameStrings(left.sourceEntryIds, right.sourceEntryIds);
621
+ }
622
+
623
+ function sameStrings(left: string[], right: string[]): boolean {
624
+ return left.length === right.length && left.every((value, index) => value === right[index]);
625
+ }
626
+
627
+ function latestAssistantUsageTokens(branch: SessionEntry[], beforeId?: string): number {
628
+ const stopAt = beforeId === undefined ? branch.length : branch.findIndex(({ id }) => id === beforeId);
629
+ for (let index = (stopAt < 0 ? branch.length : stopAt) - 1; index >= 0; index -= 1) {
630
+ const entry = branch[index];
631
+ if (isMessageEntry(entry) && entry.message.role === "assistant") {
632
+ return assistantUsageTokens(entry.message) ?? 0;
633
+ }
634
+ }
635
+ return 0;
636
+ }
637
+
638
+ function assistantUsageTokens(message: Extract<SessionEntry, { type: "message" }>['message']): number | undefined {
639
+ if (message.role !== "assistant") return undefined;
640
+ const usage = message.usage;
641
+ if (typeof usage.totalTokens === "number" && Number.isFinite(usage.totalTokens) && usage.totalTokens > 0) return usage.totalTokens;
642
+ const total = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
643
+ return Number.isFinite(total) && total > 0 ? total : undefined;
644
+ }
645
+
646
+ function encodeGateState(value: unknown, gate: FormationGate): string | undefined {
647
+ const encoded = JSON.stringify(value);
648
+ if (encoded === undefined) return undefined;
649
+ const request = JSON.stringify({ protocol_version: PROTOCOL_VERSION, request_id: "00000000-0000-0000-0000-000000000000", gate, state: encoded });
650
+ return Buffer.byteLength(encoded, "utf8") <= MAX_GATE_STATE_BYTES && Buffer.byteLength(request, "utf8") < 16_384 ? encoded : undefined;
651
+ }
652
+
653
+ function messageText(message: unknown): string {
654
+ const content = isRecord(message) && "content" in message ? message.content : message;
655
+ if (typeof content === "string") return content;
656
+ if (!Array.isArray(content)) return "";
657
+ return content.flatMap((part): string[] => {
658
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string") return [part.text];
659
+ return [];
660
+ }).join("\n");
661
+ }
662
+
663
+ function truncateUtf8(text: string, maxBytes: number): string {
664
+ let bytes = 0;
665
+ let result = "";
666
+ for (const character of text) {
667
+ const size = Buffer.byteLength(character, "utf8");
668
+ if (bytes + size > maxBytes) break;
669
+ bytes += size;
670
+ result += character;
671
+ }
672
+ return result;
673
+ }
674
+
675
+ function isGateDecision(value: GateResult): value is GateDecision {
676
+ return "accepted" in value && typeof value.accepted === "boolean" && isProbability(value.p_true) && isProbability(value.confidence);
677
+ }
678
+
679
+ function isProbability(value: unknown): value is number {
680
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
681
+ }
682
+
683
+ function isRecord(value: unknown): value is Record<string, unknown> {
684
+ return typeof value === "object" && value !== null && !Array.isArray(value);
685
+ }
686
+
687
+ function isCustomEntry(entry: unknown, customType: string): entry is Extract<SessionEntry, { type: "custom" }> {
688
+ return isRecord(entry) && entry.type === "custom" && entry.customType === customType && typeof entry.id === "string";
689
+ }
690
+
691
+ function isMessageEntry(entry: unknown): entry is Extract<SessionEntry, { type: "message" }> {
692
+ return isRecord(entry) && entry.type === "message" && isRecord(entry.message) && typeof entry.message.role === "string" &&
693
+ ["user", "assistant", "toolResult"].includes(entry.message.role);
694
+ }
695
+
696
+ function isObservationData(value: unknown): value is ObservationData {
697
+ return isRecord(value) && value.schemaVersion === 1 && typeof value.text === "string" && value.text.length > 0 &&
698
+ value.text.length <= MAX_MEMORY_TEXT_CHARS && Array.isArray(value.sourceEntryIds) && value.sourceEntryIds.length > 0 &&
699
+ value.sourceEntryIds.every((id) => typeof id === "string" && id.length > 0);
700
+ }