@isparling/engram-omp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/omp-extension.ts +1200 -136
  3. package/package.json +3 -2
package/omp-extension.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Oh My Pi extension for engram knowledge capture.
3
3
  *
4
- * Hooks into the agent lifecycle to extract structured knowledge candidates
5
- * from settled turns, and registers a voluntary `engram_capture` tool for
6
- * mid-turn capture.
4
+ * Hooks into the agent lifecycle to extract ambient knowledge candidates
5
+ * from settled turns, and registers two typed, hash-bound capture tools:
6
+ *
7
+ * engram_capture_preview({ change_set })
8
+ * engram_capture_apply({ plan_hash })
7
9
  *
8
10
  * ## Installation
9
11
  *
@@ -18,33 +20,57 @@
18
20
  * Environment variables read at session start:
19
21
  *
20
22
  * ENGRAM_BINDING_REGISTRY (required) path to the engram binding registry
21
- * ENGRAM_CLI (optional) path to engram CLI binary (default: "engram")
23
+ * ENGRAM_CLI (optional) path to engram CLI binary (default: "engram")
24
+ * ENGRAM_SPACE_ID (optional) override nearest engram.space.json
25
+ * ENGRAM_PROJECT_ROOT (optional) artifact root handed to pack
26
+ * materializers (default: process cwd)
22
27
  *
23
28
  * ## Design
24
29
  *
25
- * Two capture paths, both converging on the engram transaction pipeline:
30
+ * Two capture paths:
26
31
  *
27
- * Hook (agent_end): receives raw turn transcript builds TurnContext
28
- * pipes to `engram capture-from-turn` pack's
29
- * extractCandidates() LLM extraction → submit
30
- * Tool (engram_capture): agent provides structured kind/statement/topics
31
- * builds KnowledgeEnvelopeInput writes to temp
32
- * file calls `engram knowledge submit` → direct
33
- * submission, no extraction
32
+ * Hook (session_stop): receives the settled transcript's latest user turn
33
+ * builds TurnContext invokes the binding-selected
34
+ * pack's optional captureFromTurn(turn, tools). The
35
+ * pack owns draft policy; the extension confines writes
36
+ * to recordsRoot and refreshes scoped qmd. Packs without
37
+ * the handler fall back to `engram capture-from-turn`.
38
+ * Tools (explicit capture): the agent supplies a structured change set →
39
+ * the pack's previewStructuredCapture builds the
40
+ * candidate and calls back into the host's
41
+ * `engram knowledge reconcile`; the extension stores
42
+ * the resulting plan hash plus the candidate privately.
43
+ * Approval runs `engram knowledge approve --expect
44
+ * <plan-hash>` against that exact candidate, then hands
45
+ * the applied mutation view to the pack's materialize.
46
+ * The candidate envelope never appears in any tool
47
+ * result; only the mutation summary does.
34
48
  *
35
- * The extension is deliberately thin: it normalizes Oh My Pi events and
36
- * shells out to the engram CLI for all pack operations. This avoids
37
- * import-resolution issues between Oh My Pi's extension runtime and
38
- * engram's separate module layout.
49
+ * The extension owns only OMP lifecycle and host mechanics. Capture policy
50
+ * remains external-pack code; core transaction behavior stays unchanged.
39
51
  *
40
52
  * @module
41
53
  */
42
54
 
43
- import { chmod, mkdtemp, open, rm } from "node:fs/promises";
55
+ import { chmod, mkdtemp, open, readFile, realpath, rm } from "node:fs/promises";
44
56
  import { tmpdir } from "node:os";
45
- import { join } from "node:path";
46
- import { fileURLToPath } from "node:url";
47
- import type { HostSessionProvenance, TurnContext, TurnToolCall } from "@isparling/engram-harness/knowledge-types";
57
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
58
+ import { fileURLToPath, pathToFileURL } from "node:url";
59
+ import type {
60
+ ArtifactReplacementResult,
61
+ CaptureMutationView,
62
+ CompletionRequest,
63
+ HostCapturePreview,
64
+ } from "@isparling/engram-harness/capture-types";
65
+ import type {
66
+ HostSessionProvenance,
67
+ JsonValue,
68
+ KnowledgeEnvelope,
69
+ KnowledgeError,
70
+ KnowledgeRecord,
71
+ TurnContext,
72
+ TurnToolCall,
73
+ } from "@isparling/engram-harness/knowledge-types";
48
74
 
49
75
  // ---------------------------------------------------------------------------
50
76
  // ExtensionAPI types — mirrors the real omp type from
@@ -52,30 +78,466 @@ import type { HostSessionProvenance, TurnContext, TurnToolCall } from "@isparlin
52
78
  // ---------------------------------------------------------------------------
53
79
 
54
80
  export interface ExtensionAPI {
55
- on(event: "agent_end", handler: (event: AgentEndEvent, ctx: ExtensionContext) => void | Promise<void>): void;
81
+ on(event: "session_stop", handler: (event: SessionStopEvent, ctx: ExtensionContext) => void | Promise<void>): void;
56
82
  registerTool(tool: ToolDefinition): void;
57
83
  logger: { info: (message: string) => void; warn: (message: string) => void };
58
84
  }
59
85
 
60
- export interface AgentEndEvent {
61
- type: "agent_end";
86
+ export interface SessionStopEvent {
87
+ type: "session_stop";
62
88
  messages: unknown[];
63
- willContinue?: boolean;
64
- sessionId?: string;
65
- turnIndex?: number;
66
- timestamp?: string;
89
+ session_id: string;
90
+ session_file: string;
91
+ turn_id: number;
92
+ last_assistant_message?: unknown;
93
+ stop_hook_active: boolean;
94
+ signal: AbortSignal;
67
95
  }
68
96
 
69
97
  export interface ExtensionContext {
70
- sessionId: string;
71
98
  cwd: string;
72
99
  }
73
100
 
101
+ export type ToolResult = {
102
+ content: Array<{ type: "text"; text: string }>;
103
+ };
104
+
74
105
  export interface ToolDefinition {
75
106
  name: string;
107
+ label: string;
76
108
  description: string;
77
109
  parameters: unknown;
78
- handler: (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
110
+ execute: (
111
+ toolCallId: string,
112
+ params: Record<string, unknown>,
113
+ signal: AbortSignal | undefined,
114
+ onUpdate: unknown,
115
+ ctx: ExtensionContext,
116
+ ) => Promise<ToolResult>;
117
+ }
118
+
119
+ /**
120
+ * A spawned headless completion process. Mirrors the subset of Bun's
121
+ * Subprocess the extension consumes, so tests can inject a seam without
122
+ * launching a real child OMP.
123
+ */
124
+ export type OmpCompletionProcess = {
125
+ exited: Promise<number>;
126
+ stdout: ReadableStream<Uint8Array>;
127
+ stderr: ReadableStream<Uint8Array>;
128
+ };
129
+
130
+ /** Injectable child-OMP spawn seam. Production uses `Bun.spawn`. */
131
+ export type OmpSpawn = (
132
+ argv: string[],
133
+ options: { signal: AbortSignal },
134
+ ) => OmpCompletionProcess;
135
+
136
+ /** Test-only construction seam for the extension factory. */
137
+ export type ExtensionOptions = {
138
+ spawnOmp?: OmpSpawn;
139
+ };
140
+
141
+ export type CaptureTools = {
142
+ recordsRoot: string;
143
+ spaceId: string;
144
+ projectRoot: string;
145
+ writeFile(path: string, content: string): Promise<void>;
146
+ refreshIndex(): Promise<void>;
147
+ complete(request: CompletionRequest): Promise<string>;
148
+ };
149
+
150
+ export type CaptureSummary = {
151
+ created: string[];
152
+ existing: string[];
153
+ invalid: Array<{ id: string; errors: string[] }>;
154
+ warnings: string[];
155
+ };
156
+
157
+ export type CaptureHandler = (
158
+ turn: TurnContext,
159
+ tools: CaptureTools,
160
+ ) => Promise<CaptureSummary>;
161
+
162
+ /** Host mechanics handed to a pack's previewStructuredCapture. */
163
+ export type StructuredPreviewTools = {
164
+ spaceId: string;
165
+ previewCandidate(candidate: KnowledgeEnvelope): Promise<HostCapturePreview>;
166
+ };
167
+
168
+ /** Host mechanics handed to a pack's materialize. */
169
+ export type StructuredMaterializeTools = {
170
+ listRecords(): Promise<KnowledgeRecord[]>;
171
+ replaceArtifact(request: {
172
+ root: string;
173
+ relativePath: string;
174
+ content: string;
175
+ }): Promise<ArtifactReplacementResult>;
176
+ projectRoot: string;
177
+ appliedAt: string;
178
+ };
179
+
180
+ export type PackStructuredPreview = (
181
+ changeSet: { [key: string]: JsonValue },
182
+ tools: StructuredPreviewTools,
183
+ ) => Promise<unknown>;
184
+
185
+ export type PackMaterialize = (
186
+ appliedPlan: { planHash: string; mutations: CaptureMutationView[] },
187
+ tools: StructuredMaterializeTools,
188
+ ) => Promise<unknown>;
189
+
190
+ /**
191
+ * Resolution of the binding-selected pack module. Pack identity is validated
192
+ * once; each capture export is recorded independently and stays optional.
193
+ * A pack selected with `extract: true` is valid when it exports
194
+ * `captureFromTurn` — its root pack object need not implement
195
+ * `KnowledgeExtractor.extractCandidates`.
196
+ */
197
+ export type CaptureResolution =
198
+ | {
199
+ kind: "available";
200
+ captureFromTurn: CaptureHandler;
201
+ previewStructuredCapture?: PackStructuredPreview;
202
+ materialize?: PackMaterialize;
203
+ }
204
+ | { kind: "absent" }
205
+ | { kind: "failed"; message: string };
206
+
207
+ /**
208
+ * A pending explicit-capture plan keyed by its immutable plan hash.
209
+ *
210
+ * State machine:
211
+ * previewed → apply committed/no-change → records-committed
212
+ * previewed → apply stale → (entry deleted; fresh
213
+ * preview required)
214
+ * records-committed → apply (same hash) reruns ONLY materialize → deleted
215
+ */
216
+ type PendingCapture = {
217
+ sessionId: string;
218
+ candidate: KnowledgeEnvelope;
219
+ preview: Extract<HostCapturePreview, { status: "ready" }>;
220
+ state: "previewed" | "records-committed";
221
+ appliedPlan?: { planHash: string; mutations: CaptureMutationView[] };
222
+ /** One captured apply timestamp, reused verbatim across materialize retries. */
223
+ appliedAt?: string;
224
+ appliedStatus?: "committed" | "no-change";
225
+ indexState: "fresh" | "stale" | "not-attempted";
226
+ };
227
+
228
+ function stopTurnKey(event: SessionStopEvent): string {
229
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
230
+ const message = event.messages[index];
231
+ if (
232
+ typeof message === "object" &&
233
+ message !== null &&
234
+ !Array.isArray(message) &&
235
+ (message as Record<string, unknown>).role === "user"
236
+ ) {
237
+ const record = message as Record<string, unknown>;
238
+ const content = JSON.stringify(record.content);
239
+ const identity = typeof record.id === "string"
240
+ ? record.id
241
+ : typeof record.timestamp === "string" || typeof record.timestamp === "number"
242
+ ? `${record.timestamp}:${content}`
243
+ : content;
244
+ return `${event.session_id}:${event.turn_id}:${identity}`;
245
+ }
246
+ }
247
+ return `${event.session_id}:${event.turn_id}:no-user-message`;
248
+ }
249
+
250
+
251
+ function packExport(module: Record<string, unknown>, id: string): unknown {
252
+ const camelId = id.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase());
253
+ const direct = module[id] ?? module[camelId] ?? module.default;
254
+ if (direct !== undefined) return direct;
255
+ const registry = module.packs ?? module.packRegistry;
256
+ return typeof registry === "object" && registry !== null && !Array.isArray(registry)
257
+ ? (registry as Record<string, unknown>)[id]
258
+ : undefined;
259
+ }
260
+ function toolText(value: unknown): ToolResult {
261
+ return {
262
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
263
+ };
264
+ }
265
+ async function nativePackageSpecifier(specifier: string): Promise<string> {
266
+ const parentUrl = pathToFileURL(fileURLToPath(import.meta.url)).href;
267
+ const proc = Bun.spawn([
268
+ "node",
269
+ "--experimental-import-meta-resolve",
270
+ "--input-type=module",
271
+ "-e",
272
+ "console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
273
+ specifier,
274
+ parentUrl,
275
+ ], {
276
+ stdout: "pipe",
277
+ stderr: "pipe",
278
+ });
279
+ const exitCode = await proc.exited;
280
+ const stdout = (await new Response(proc.stdout).text()).trim();
281
+ if (exitCode !== 0 || stdout === "") {
282
+ const stderr = await new Response(proc.stderr).text();
283
+ throw new Error(`native ESM resolution failed: ${stderr.slice(0, 500)}`);
284
+ }
285
+ return stdout;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // JSON narrowing helpers
290
+ // ---------------------------------------------------------------------------
291
+
292
+ function isJsonObject(value: unknown): value is { [key: string]: unknown } {
293
+ return typeof value === "object" && value !== null && !Array.isArray(value);
294
+ }
295
+
296
+ function isJsonValue(value: unknown): value is JsonValue {
297
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
298
+ if (typeof value === "number") return Number.isFinite(value);
299
+ if (Array.isArray(value)) return value.every(isJsonValue);
300
+ return isJsonRecord(value);
301
+ }
302
+
303
+ function isJsonRecord(value: unknown): value is { [key: string]: JsonValue } {
304
+ return isJsonObject(value) && Object.values(value).every(isJsonValue);
305
+ }
306
+
307
+ function parseKnowledgeError(value: unknown): KnowledgeError | undefined {
308
+ if (!isJsonObject(value)) return undefined;
309
+ if (typeof value.code !== "string" || typeof value.message !== "string") return undefined;
310
+ return {
311
+ kind: typeof value.kind === "string" ? value.kind as KnowledgeError["kind"] : "validation",
312
+ code: value.code,
313
+ ...(typeof value.field === "string" ? { field: value.field } : {}),
314
+ message: value.message,
315
+ };
316
+ }
317
+
318
+ function parseKnowledgeErrors(value: unknown): KnowledgeError[] | undefined {
319
+ if (!Array.isArray(value)) return undefined;
320
+ const errors: KnowledgeError[] = [];
321
+ for (const item of value) {
322
+ const parsed = parseKnowledgeError(item);
323
+ if (parsed === undefined) return undefined;
324
+ errors.push(parsed);
325
+ }
326
+ return errors;
327
+ }
328
+
329
+ /** The public shape a pack's previewStructuredCapture must return when ready. */
330
+ type PackPreviewReady = {
331
+ schemaVersion: 0;
332
+ status: "ready";
333
+ planHash: string;
334
+ candidate: KnowledgeEnvelope;
335
+ changes: Array<{ [key: string]: JsonValue }>;
336
+ artifacts: string[];
337
+ };
338
+
339
+ function parsePackPreview(
340
+ value: unknown,
341
+ ): PackPreviewReady | { status: "blocked"; errors: KnowledgeError[] } | undefined {
342
+ if (!isJsonObject(value) || value.schemaVersion !== 0) return undefined;
343
+ if (value.status === "blocked") {
344
+ const errors = parseKnowledgeErrors(value.errors);
345
+ return errors === undefined ? undefined : { status: "blocked", errors };
346
+ }
347
+ if (value.status !== "ready") return undefined;
348
+ if (typeof value.planHash !== "string" || value.planHash.length === 0) return undefined;
349
+ if (!isJsonObject(value.candidate) || typeof value.candidate.id !== "string") return undefined;
350
+ if (!Array.isArray(value.changes) || !Array.isArray(value.artifacts)) return undefined;
351
+ const changes: PackPreviewReady["changes"] = [];
352
+ for (const change of value.changes) {
353
+ if (!isJsonRecord(change)) return undefined;
354
+ changes.push(change);
355
+ }
356
+ const artifacts: string[] = [];
357
+ for (const artifact of value.artifacts) {
358
+ if (typeof artifact !== "string" || artifact.length === 0) return undefined;
359
+ artifacts.push(artifact);
360
+ }
361
+ return {
362
+ schemaVersion: 0,
363
+ status: "ready",
364
+ planHash: value.planHash,
365
+ candidate: value.candidate as KnowledgeEnvelope,
366
+ changes,
367
+ artifacts,
368
+ };
369
+ }
370
+
371
+ /** Serialized planned-mutation shape printed by the knowledge CLI. */
372
+ type CliPlannedMutation = {
373
+ recordId: unknown;
374
+ action: unknown;
375
+ beforeHash: unknown;
376
+ after: unknown;
377
+ };
378
+
379
+ function parseCliMutations(value: unknown): CaptureMutationView[] | undefined {
380
+ if (!Array.isArray(value)) return undefined;
381
+ const mutations: CaptureMutationView[] = [];
382
+ for (const raw of value) {
383
+ if (!isJsonObject(raw)) return undefined;
384
+ const source = raw as unknown as CliPlannedMutation;
385
+ if (typeof source.recordId !== "string") return undefined;
386
+ if (source.action !== "create" && source.action !== "update") return undefined;
387
+ if (source.beforeHash !== null && typeof source.beforeHash !== "string") return undefined;
388
+ if (!isJsonObject(source.after)) return undefined;
389
+ const after = parseKnowledgeRecordShape(source.after);
390
+ if (after === undefined) return undefined;
391
+ mutations.push({
392
+ recordId: source.recordId,
393
+ action: source.action,
394
+ beforeHash: source.beforeHash,
395
+ after,
396
+ });
397
+ }
398
+ return mutations;
399
+ }
400
+
401
+ /** Structural validation of a CLI-reported knowledge record. */
402
+ function parseKnowledgeRecordShape(value: { [key: string]: unknown }): KnowledgeRecord | undefined {
403
+ if (
404
+ typeof value.id !== "string" ||
405
+ typeof value.kind !== "string" ||
406
+ typeof value.status !== "string" ||
407
+ typeof value.statement !== "string" ||
408
+ !isJsonObject(value.details) ||
409
+ !isJsonObject(value.scope) ||
410
+ !isJsonObject(value.pack) ||
411
+ !Array.isArray(value.sources) ||
412
+ !isJsonObject(value.session) ||
413
+ typeof value.submittedAt !== "string" ||
414
+ typeof value.disposition !== "string" ||
415
+ value.schemaVersion !== 0 ||
416
+ !isJsonObject(value.relationships) ||
417
+ !Array.isArray(value.history)
418
+ ) {
419
+ return undefined;
420
+ }
421
+ const scope = value.scope as unknown as KnowledgeRecord["scope"];
422
+ if (!Array.isArray(scope.subjects) || !Array.isArray(scope.topics)) return undefined;
423
+ const relationships = value.relationships as unknown as KnowledgeRecord["relationships"];
424
+ for (const key of ["supports", "contradicts", "refines", "supersedes"] as const) {
425
+ if (!Array.isArray(relationships[key])) return undefined;
426
+ }
427
+ return value as unknown as KnowledgeRecord;
428
+ }
429
+
430
+ type CliApplyCommitted = {
431
+ status: "committed" | "no_change";
432
+ mutations: CaptureMutationView[];
433
+ index: "fresh" | "stale" | "not-attempted";
434
+ };
435
+
436
+ function mapRefreshIndex(refresh: unknown): "fresh" | "stale" | "not-attempted" {
437
+ if (!isJsonObject(refresh) || refresh.attempted !== true) return "not-attempted";
438
+ return refresh.state === "fresh" ? "fresh" : "stale";
439
+ }
440
+
441
+ function parseCliApplyOutcome(stdout: string): CliApplyCommitted | { status: "stale_approval" } | undefined {
442
+ let parsed: unknown;
443
+ try {
444
+ parsed = JSON.parse(stdout);
445
+ } catch {
446
+ return undefined;
447
+ }
448
+ if (!isJsonObject(parsed)) return undefined;
449
+ if (parsed.status === "stale_approval") return { status: "stale_approval" };
450
+ if (parsed.status !== "committed" && parsed.status !== "no_change") return undefined;
451
+ const mutations = parseCliMutations(parsed.mutations);
452
+ if (mutations === undefined) return undefined;
453
+ return { status: parsed.status, mutations, index: mapRefreshIndex(parsed.refresh) };
454
+ }
455
+
456
+ function parseArtifactReplacement(stdout: string): ArtifactReplacementResult | undefined {
457
+ let parsed: unknown;
458
+ try {
459
+ parsed = JSON.parse(stdout);
460
+ } catch {
461
+ return undefined;
462
+ }
463
+ if (!isJsonObject(parsed)) return undefined;
464
+ if ((parsed.status !== "replaced" && parsed.status !== "unchanged") || typeof parsed.path !== "string") {
465
+ return undefined;
466
+ }
467
+ return { status: parsed.status, path: parsed.path };
468
+ }
469
+
470
+ function parseListedRecords(stdout: string): KnowledgeRecord[] | undefined {
471
+ let parsed: unknown;
472
+ try {
473
+ parsed = JSON.parse(stdout);
474
+ } catch {
475
+ return undefined;
476
+ }
477
+ if (!isJsonObject(parsed) || parsed.status !== "ok" || !Array.isArray(parsed.records)) return undefined;
478
+ const records: KnowledgeRecord[] = [];
479
+ for (const raw of parsed.records) {
480
+ if (!isJsonObject(raw)) return undefined;
481
+ const record = parseKnowledgeRecordShape(raw);
482
+ if (record === undefined) return undefined;
483
+ records.push(record);
484
+ }
485
+ return records;
486
+ }
487
+
488
+ /** Mechanical mutation summary: no domain interpretation beyond field reads. */
489
+ function summarizeMutations(mutations: CaptureMutationView[]): {
490
+ created: string[];
491
+ retired: string[];
492
+ entityKeys: string[];
493
+ } {
494
+ const created: string[] = [];
495
+ const retired = new Set<string>();
496
+ const entityKeys = new Set<string>();
497
+ for (const mutation of mutations) {
498
+ if (mutation.action === "create") created.push(mutation.recordId);
499
+ const supersedes = mutation.after.relationships.supersedes;
500
+ for (const id of Array.isArray(supersedes) ? supersedes : []) {
501
+ if (typeof id === "string" && !created.includes(id)) retired.add(id);
502
+ }
503
+ const entityKey = mutation.after.details.entityKey;
504
+ if (typeof entityKey === "string") entityKeys.add(entityKey);
505
+ }
506
+ return {
507
+ created,
508
+ retired: [...retired].sort(),
509
+ entityKeys: [...entityKeys].sort(),
510
+ };
511
+ }
512
+
513
+ type MaterializationOutcome = {
514
+ written: ArtifactReplacementResult[];
515
+ unchanged: ArtifactReplacementResult[];
516
+ stale: Array<{ path: string; reason: string }>;
517
+ };
518
+
519
+ function normalizeMaterialization(value: unknown): MaterializationOutcome {
520
+ const empty: MaterializationOutcome = { written: [], unchanged: [], stale: [] };
521
+ if (!isJsonObject(value)) return empty;
522
+ const results = (raw: unknown): ArtifactReplacementResult[] => {
523
+ if (!Array.isArray(raw)) return [];
524
+ const out: ArtifactReplacementResult[] = [];
525
+ for (const item of raw) {
526
+ if (!isJsonObject(item)) continue;
527
+ if ((item.status !== "replaced" && item.status !== "unchanged") || typeof item.path !== "string") continue;
528
+ out.push({ status: item.status, path: item.path });
529
+ }
530
+ return out;
531
+ };
532
+ const stale: Array<{ path: string; reason: string }> = [];
533
+ if (Array.isArray(value.stale)) {
534
+ for (const item of value.stale) {
535
+ if (!isJsonObject(item)) continue;
536
+ if (typeof item.path !== "string" || typeof item.reason !== "string") continue;
537
+ stale.push({ path: item.path, reason: item.reason });
538
+ }
539
+ }
540
+ return { written: results(value.written), unchanged: results(value.unchanged), stale };
79
541
  }
80
542
 
81
543
  // ---------------------------------------------------------------------------
@@ -103,6 +565,46 @@ function toCliCandidate(input: object): Record<string, unknown> {
103
565
  return out;
104
566
  }
105
567
 
568
+ /**
569
+ * Write one candidate envelope to a fresh mode-0600 file inside a mode-0700
570
+ * temporary directory, hand the file path to the caller, and always remove
571
+ * the directory afterwards.
572
+ */
573
+ async function withTempCandidate<T>(candidate: object, fn: (file: string) => Promise<T>): Promise<T> {
574
+ const dir = await mkdtemp(join(tmpdir(), "engram-candidate-"));
575
+ try {
576
+ await chmod(dir, 0o700);
577
+ const file = join(dir, "candidate.json");
578
+ const handle = await open(file, "wx", 0o600);
579
+ try {
580
+ await handle.writeFile(JSON.stringify(toCliCandidate(candidate)), "utf8");
581
+ } finally {
582
+ await handle.close();
583
+ }
584
+ return await fn(file);
585
+ } finally {
586
+ await rm(dir, { recursive: true, force: true });
587
+ }
588
+ }
589
+
590
+ /** Same confinement for arbitrary generated-artifact content. */
591
+ async function withTempContent<T>(content: string, fn: (file: string) => Promise<T>): Promise<T> {
592
+ const dir = await mkdtemp(join(tmpdir(), "engram-artifact-"));
593
+ try {
594
+ await chmod(dir, 0o700);
595
+ const file = join(dir, "artifact.content");
596
+ const handle = await open(file, "wx", 0o600);
597
+ try {
598
+ await handle.writeFile(content, "utf8");
599
+ } finally {
600
+ await handle.close();
601
+ }
602
+ return await fn(file);
603
+ } finally {
604
+ await rm(dir, { recursive: true, force: true });
605
+ }
606
+ }
607
+
106
608
  // ---------------------------------------------------------------------------
107
609
  // Extension factory
108
610
  // ---------------------------------------------------------------------------
@@ -124,7 +626,39 @@ function resolveCliPath(): string {
124
626
  }
125
627
  }
126
628
 
127
- export default async function engramExtension(api: ExtensionAPI): Promise<void> {
629
+ /**
630
+ * Attribute a headless-completion failure to the precise cause the pack
631
+ * must distinguish. Cancellation wins over the deadline: an aborted stop
632
+ * hook means the whole turn is going away, not that the model was slow.
633
+ */
634
+ function completionFailure(
635
+ signal: AbortSignal,
636
+ deadline: AbortSignal,
637
+ detail: string,
638
+ ): Error {
639
+ if (signal.aborted) return new Error(`capture_cancelled: ${detail}`);
640
+ if (deadline.aborted) return new Error(`capture_timeout: ${detail}`);
641
+ return new Error(`capture_model_failed: ${detail}`);
642
+ }
643
+
644
+ /** Artifact root handed to packs; defaults to the process working directory. */
645
+ function artifactProjectRoot(): string {
646
+ const configured = process.env.ENGRAM_PROJECT_ROOT;
647
+ return configured !== undefined && configured.length > 0 ? configured : process.cwd();
648
+ }
649
+
650
+ export default async function engramExtension(
651
+ api: ExtensionAPI,
652
+ options: ExtensionOptions = {},
653
+ ): Promise<void> {
654
+ // Production spawns a real child OMP; tests inject a seam so the exact
655
+ // isolation argv can be asserted without launching a model.
656
+ const spawnOmp: OmpSpawn = options.spawnOmp ?? ((argv, spawnOptions) =>
657
+ Bun.spawn(argv, {
658
+ stdout: "pipe",
659
+ stderr: "pipe",
660
+ signal: spawnOptions.signal,
661
+ }));
128
662
  const cliPath = resolveCliPath();
129
663
  const registryPath = process.env.ENGRAM_BINDING_REGISTRY;
130
664
 
@@ -136,9 +670,9 @@ export default async function engramExtension(api: ExtensionAPI): Promise<void>
136
670
  const bindingRegistryPath = registryPath;
137
671
 
138
672
 
139
- // Session identifier — captured from the first agent_end event's ctx.
140
- // Before that, CLI calls will use "pending" which won't resolve a space;
141
- // the first agent_end event resolves it.
673
+ // Session identifier — captured from OMP's awaited session_stop payload.
674
+ // Before the first final settle, CLI tools use "pending" and status reports
675
+ // no resolved pack.
142
676
  let hostSessionId: string | undefined;
143
677
 
144
678
  /** Build env for CLI calls, including the session id resolveActiveSpace requires. */
@@ -149,14 +683,116 @@ export default async function engramExtension(api: ExtensionAPI): Promise<void>
149
683
  ENGRAM_HOST_SESSION_ID: hostSessionId ?? "pending",
150
684
  };
151
685
  }
686
+ async function configuredSpaceId(cwd: string): Promise<string | undefined> {
687
+ const override = process.env.ENGRAM_SPACE_ID?.trim();
688
+ if (override !== undefined && override !== "") return override;
152
689
 
153
- // Cached extraction pack id — resolved lazily once a real session id is known.
690
+ let directory = resolve(cwd);
691
+ while (true) {
692
+ const manifestPath = join(directory, "engram.space.json");
693
+ try {
694
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
695
+ schema_version?: unknown;
696
+ space_id?: unknown;
697
+ };
698
+ if (manifest.schema_version !== 0 || typeof manifest.space_id !== "string" || manifest.space_id === "") {
699
+ throw new Error(`invalid Engram space manifest: ${manifestPath}`);
700
+ }
701
+ return manifest.space_id;
702
+ } catch (error) {
703
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
704
+ }
705
+ const parent = dirname(directory);
706
+ if (parent === directory) return undefined;
707
+ directory = parent;
708
+ }
709
+ }
710
+
711
+ async function ensureSessionSelection(cwd: string, signal: AbortSignal): Promise<void> {
712
+ const statusProc = Bun.spawn([cliPath, "space", "status"], {
713
+ stdout: "pipe",
714
+ stderr: "pipe",
715
+ env: cliEnv(),
716
+ signal,
717
+ });
718
+ const statusExit = await statusProc.exited;
719
+ if (statusExit === 0) {
720
+ const status = JSON.parse(await new Response(statusProc.stdout).text()) as {
721
+ active_spaces?: Record<string, unknown>;
722
+ };
723
+ if (hostSessionId !== undefined && status.active_spaces?.[hostSessionId] !== undefined) return;
724
+ }
725
+
726
+ const spaceId = await configuredSpaceId(cwd);
727
+ if (spaceId === undefined) return;
728
+ signal.throwIfAborted();
729
+ const selectProc = Bun.spawn([cliPath, "space", "select", spaceId], {
730
+ stdout: "pipe",
731
+ stderr: "pipe",
732
+ env: cliEnv(),
733
+ signal,
734
+ });
735
+ const selectExit = await selectProc.exited;
736
+ if (selectExit !== 0) {
737
+ const stdout = await new Response(selectProc.stdout).text();
738
+ const stderr = await new Response(selectProc.stderr).text();
739
+ throw new Error(`automatic space selection failed (exit ${selectExit}): ${(stdout || stderr).slice(0, 500)}`);
740
+ }
741
+ }
742
+
743
+ // Active-space state is session-bound. OMP keeps extension instances alive
744
+ // across session switches, so every new session must resolve independently.
154
745
  let extractionPackId = "work-pack";
155
746
  let extractionPackVersion = "0.1.0";
747
+ let extractionPackFrom: string | undefined;
156
748
  let extractionSpaceId = "current";
749
+ let extractionRecordsRoot = "";
750
+ let captureResolution: CaptureResolution | undefined;
157
751
  let resolvedPackId = false;
752
+ let lastCapturedTurnKey: string | undefined;
753
+
754
+ // Explicit-capture session state.
755
+ const pendingPlans = new Map<string, PendingCapture>();
756
+ let lastIndexState: "fresh" | "stale" | "not-attempted" = "not-attempted";
757
+ const sessionStaleArtifacts: string[] = [];
758
+
759
+ function resetSessionResolution(): void {
760
+ extractionPackId = "work-pack";
761
+ extractionPackVersion = "0.1.0";
762
+ extractionPackFrom = undefined;
763
+ extractionSpaceId = "current";
764
+ extractionRecordsRoot = "";
765
+ captureResolution = undefined;
766
+ resolvedPackId = false;
767
+ lastCapturedTurnKey = undefined;
768
+ pendingPlans.clear();
769
+ lastIndexState = "not-attempted";
770
+ sessionStaleArtifacts.length = 0;
771
+ }
772
+
773
+ async function bindingPathForActiveSpace(): Promise<string> {
774
+ const registry = JSON.parse(await readFile(bindingRegistryPath, "utf8")) as {
775
+ spaces?: Array<{ space_id?: unknown; binding_path?: unknown }>;
776
+ };
777
+ const entry = registry.spaces?.find((space) => space.space_id === extractionSpaceId);
778
+ if (typeof entry?.binding_path !== "string") {
779
+ throw new Error(`registry omitted binding path for active space ${extractionSpaceId}`);
780
+ }
781
+ return entry.binding_path;
782
+ }
158
783
 
159
- /** Resolve the extraction pack from the active space. Called once on first agent_end. */
784
+ async function packModuleSpecifier(): Promise<string> {
785
+ if (extractionPackFrom === undefined) throw new Error("active extraction pack omitted from");
786
+ if (extractionPackFrom.startsWith("./") || extractionPackFrom.startsWith("../")) {
787
+ const bindingPath = await bindingPathForActiveSpace();
788
+ return pathToFileURL(resolve(dirname(bindingPath), extractionPackFrom)).href;
789
+ }
790
+ if (isAbsolute(extractionPackFrom)) return pathToFileURL(extractionPackFrom).href;
791
+ if (extractionPackFrom.startsWith("file:")) return extractionPackFrom;
792
+ return nativePackageSpecifier(extractionPackFrom);
793
+ }
794
+
795
+ /** Resolve the extraction pack from the active space after final settlement. */
160
796
  async function resolveExtractionPack(): Promise<void> {
161
797
  if (hostSessionId === undefined || resolvedPackId) return;
162
798
  try {
@@ -170,150 +806,563 @@ export default async function engramExtension(api: ExtensionAPI): Promise<void>
170
806
  const stdout = await new Response(proc.stdout).text();
171
807
  const status = JSON.parse(stdout) as Record<string, unknown>;
172
808
  const activeSpaces = status.active_spaces as Record<string, Record<string, unknown>> | undefined;
173
- if (activeSpaces === undefined) return;
174
- const space = activeSpaces[hostSessionId];
809
+ const space = activeSpaces?.[hostSessionId];
175
810
  if (space === undefined) return;
176
811
  extractionSpaceId = String(space.space_id ?? "current");
812
+ extractionRecordsRoot = String(space.records_root ?? "");
177
813
  const packs = space.packs as Array<Record<string, unknown>> | undefined;
178
- if (packs === undefined) return;
179
- const extractPack = packs.find((p) => p.extract === true);
814
+ const extractPack = packs?.find((pack) => pack.extract === true);
180
815
  if (extractPack === undefined) return;
181
816
  extractionPackId = String(extractPack.id);
182
817
  extractionPackVersion = String(extractPack.version);
818
+ extractionPackFrom = typeof extractPack.from === "string" ? extractPack.from : undefined;
183
819
  resolvedPackId = true;
184
820
  } catch {
185
- // Keep defaults
821
+ // Resolution failure remains unresolved and capture stops at the caller.
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Resolve the complete capture module surface once per session: pack
827
+ * identity is validated exactly once, and the optional captureFromTurn /
828
+ * previewStructuredCapture / materialize exports are recorded independently.
829
+ * A pack selected with `extract: true` is valid when it exports
830
+ * captureFromTurn; its root pack object need not implement extractCandidates.
831
+ */
832
+ async function resolveCaptureModule(): Promise<CaptureResolution> {
833
+ if (captureResolution !== undefined) return captureResolution;
834
+ try {
835
+ const specifier = await packModuleSpecifier();
836
+ // Runtime-selected by the active binding; a static import cannot model
837
+ // an external pack declaration.
838
+ const module = await import(specifier) as Record<string, unknown>;
839
+ const selected = packExport(module, extractionPackId);
840
+ const identityMatches = typeof selected === "object" &&
841
+ selected !== null &&
842
+ !Array.isArray(selected) &&
843
+ (selected as Record<string, unknown>).id === extractionPackId &&
844
+ (selected as Record<string, unknown>).version === extractionPackVersion;
845
+ if (!identityMatches) {
846
+ captureResolution = { kind: "failed", message: "pack export identity does not match the active binding" };
847
+ } else if (typeof module.captureFromTurn !== "function") {
848
+ captureResolution = { kind: "absent" };
849
+ } else {
850
+ captureResolution = {
851
+ kind: "available",
852
+ captureFromTurn: module.captureFromTurn as CaptureHandler,
853
+ ...(typeof module.previewStructuredCapture === "function"
854
+ ? { previewStructuredCapture: module.previewStructuredCapture as PackStructuredPreview }
855
+ : {}),
856
+ ...(typeof module.materialize === "function"
857
+ ? { materialize: module.materialize as PackMaterialize }
858
+ : {}),
859
+ };
860
+ }
861
+ } catch (error) {
862
+ captureResolution = { kind: "failed", message: String(error) };
186
863
  }
864
+ return captureResolution;
865
+ }
866
+
867
+ async function captureTools(signal: AbortSignal): Promise<CaptureTools> {
868
+ const recordsRoot = await realpath(extractionRecordsRoot);
869
+ return {
870
+ recordsRoot,
871
+ spaceId: extractionSpaceId,
872
+ projectRoot: artifactProjectRoot(),
873
+ writeFile: async (path, content) => {
874
+ signal.throwIfAborted();
875
+ const target = resolve(path);
876
+ if (target !== recordsRoot && !target.startsWith(recordsRoot + sep)) {
877
+ throw new Error(`capture handler write escaped records root: ${path}`);
878
+ }
879
+ const parent = await realpath(dirname(target));
880
+ if (parent !== recordsRoot && !parent.startsWith(recordsRoot + sep)) {
881
+ throw new Error(`capture handler write escaped records root through a symlink: ${path}`);
882
+ }
883
+ signal.throwIfAborted();
884
+ const handle = await open(join(parent, basename(target)), "wx");
885
+ try {
886
+ signal.throwIfAborted();
887
+ await handle.writeFile(content, "utf8");
888
+ } finally {
889
+ await handle.close();
890
+ }
891
+ },
892
+ refreshIndex: async () => {
893
+ signal.throwIfAborted();
894
+ const proc = Bun.spawn([cliPath, "space", "refresh"], {
895
+ stdout: "pipe",
896
+ stderr: "pipe",
897
+ env: cliEnv(),
898
+ signal,
899
+ });
900
+ const exitCode = await proc.exited;
901
+ if (exitCode !== 0) {
902
+ const stdout = await new Response(proc.stdout).text();
903
+ const stderr = await new Response(proc.stderr).text();
904
+ throw new Error(`guarded space refresh failed (exit ${exitCode}): ${(stdout || stderr).slice(0, 500)}`);
905
+ }
906
+ },
907
+ complete: async (request) => {
908
+ signal.throwIfAborted();
909
+ // The stop hook and the request's own deadline both terminate the
910
+ // child. Keeping the two signals separate lets the failure be
911
+ // attributed precisely instead of collapsing into one error.
912
+ const deadline = AbortSignal.timeout(request.timeoutSeconds * 1000);
913
+ const combined = AbortSignal.any([signal, deadline]);
914
+ // The system prompt rides in the prompt body: the isolation argv is
915
+ // fixed, and dropping `system` would silently lose pack instructions.
916
+ const prompt = request.system.length > 0
917
+ ? `${request.system}\n\n${request.prompt}`
918
+ : request.prompt;
919
+ const argv = [
920
+ "omp",
921
+ "--no-session",
922
+ "--no-extensions",
923
+ "--no-skills",
924
+ "--no-prompt-templates",
925
+ "--mode",
926
+ "text",
927
+ "--model",
928
+ request.model,
929
+ "-p",
930
+ prompt,
931
+ ];
932
+ const proc = spawnOmp(argv, { signal: combined });
933
+ let exitCode: number;
934
+ try {
935
+ exitCode = await proc.exited;
936
+ } catch (error) {
937
+ throw completionFailure(signal, deadline, `headless completion failed: ${String(error)}`);
938
+ }
939
+ if (signal.aborted || deadline.aborted) {
940
+ throw completionFailure(signal, deadline, "headless completion did not finish");
941
+ }
942
+ if (exitCode !== 0) {
943
+ const stderr = await new Response(proc.stderr).text();
944
+ throw new Error(
945
+ `capture_model_failed: headless completion exited ${exitCode}: ${stderr.slice(0, 500)}`,
946
+ );
947
+ }
948
+ return await new Response(proc.stdout).text();
949
+ },
950
+ };
951
+ }
952
+
953
+ async function runCli(
954
+ args: string[],
955
+ signal?: AbortSignal,
956
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
957
+ const proc = Bun.spawn([cliPath, ...args], {
958
+ stdout: "pipe",
959
+ stderr: "pipe",
960
+ env: cliEnv(),
961
+ ...(signal === undefined ? {} : { signal }),
962
+ });
963
+ const exitCode = await proc.exited;
964
+ return {
965
+ exitCode,
966
+ stdout: await new Response(proc.stdout).text(),
967
+ stderr: await new Response(proc.stderr).text(),
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Host preview mechanics: write the candidate to a protected temporary
973
+ * file, run `engram knowledge reconcile --candidate <file>`, and map the
974
+ * proposal into a HostCapturePreview. Every invalid/retrieval failure maps
975
+ * to status "blocked". The temporary directory is removed in finally.
976
+ */
977
+ async function previewCandidate(candidate: KnowledgeEnvelope): Promise<HostCapturePreview> {
978
+ return withTempCandidate(candidate, async (file) => {
979
+ const outcome = await runCli(["knowledge", "reconcile", "--candidate", file]);
980
+ let parsed: unknown;
981
+ try {
982
+ parsed = JSON.parse(outcome.stdout);
983
+ } catch {
984
+ parsed = undefined;
985
+ }
986
+ if (outcome.exitCode === 0 && isJsonObject(parsed) && parsed.status === "proposal" && isJsonObject(parsed.proposal)) {
987
+ const proposal = parsed.proposal as { [key: string]: unknown };
988
+ const plan = isJsonObject(proposal.plan) ? proposal.plan as { [key: string]: unknown } : undefined;
989
+ const mutations = plan === undefined ? undefined : parseCliMutations(plan.mutations);
990
+ if (typeof proposal.plan_hash === "string" && mutations !== undefined) {
991
+ return {
992
+ schemaVersion: 0,
993
+ status: "ready",
994
+ planHash: proposal.plan_hash,
995
+ mutations,
996
+ };
997
+ }
998
+ }
999
+ const errors = isJsonObject(parsed) ? parseKnowledgeErrors(parsed.errors) : undefined;
1000
+ return {
1001
+ schemaVersion: 0,
1002
+ status: "blocked",
1003
+ errors: errors ?? [{
1004
+ kind: "transaction",
1005
+ code: "reconcile_failed",
1006
+ message: `engram knowledge reconcile failed (exit ${outcome.exitCode}): ${(outcome.stdout || outcome.stderr).slice(0, 500)}`,
1007
+ }],
1008
+ };
1009
+ });
1010
+ }
1011
+
1012
+ /**
1013
+ * Materialization host mechanics. The adapter never interprets record
1014
+ * roles, entity keys, artifact kinds, or output shape: it validates CLI
1015
+ * responses structurally and forwards them verbatim.
1016
+ */
1017
+ function materializeTools(entry: PendingCapture): StructuredMaterializeTools {
1018
+ return {
1019
+ projectRoot: artifactProjectRoot(),
1020
+ appliedAt: (entry.appliedAt ??= new Date().toISOString()),
1021
+ listRecords: async () => {
1022
+ const outcome = await runCli(["knowledge", "list", "--pack", extractionPackId, "--status", "active"]);
1023
+ const records = parseListedRecords(outcome.stdout);
1024
+ if (outcome.exitCode !== 0 || records === undefined) {
1025
+ throw new Error(
1026
+ `engram knowledge list failed (exit ${outcome.exitCode}): ${(outcome.stdout || outcome.stderr).slice(0, 500)}`,
1027
+ );
1028
+ }
1029
+ return records;
1030
+ },
1031
+ replaceArtifact: async (request) =>
1032
+ withTempContent(request.content, async (file) => {
1033
+ const outcome = await runCli([
1034
+ "artifact",
1035
+ "replace",
1036
+ "--root",
1037
+ request.root,
1038
+ "--relative",
1039
+ request.relativePath,
1040
+ "--input",
1041
+ file,
1042
+ ]);
1043
+ const mapped = parseArtifactReplacement(outcome.stdout);
1044
+ if (outcome.exitCode !== 0 || mapped === undefined) {
1045
+ throw new Error(
1046
+ `engram artifact replace failed (exit ${outcome.exitCode}): ${(outcome.stdout || outcome.stderr).slice(0, 500)}`,
1047
+ );
1048
+ }
1049
+ return mapped;
1050
+ }),
1051
+ };
187
1052
  }
188
1053
 
189
1054
 
190
1055
  // -----------------------------------------------------------------------
191
- // Structural capture: agent_end hook
1056
+ // Structural capture: awaited final-settle hook
192
1057
  // -----------------------------------------------------------------------
193
- api.on("agent_end", async (event: AgentEndEvent, ctx: ExtensionContext) => {
194
- // Capture the real session id from the first event; resolves the
195
- // extraction pack lazily now that we can query the right space.
196
- hostSessionId = ctx.sessionId;
1058
+ api.on("session_stop", async (event: SessionStopEvent, ctx: ExtensionContext) => {
1059
+ if (event.signal.aborted) return;
1060
+ if (event.session_id === "") {
1061
+ api.logger.warn("[engram] session_stop omitted a host session id");
1062
+ return;
1063
+ }
1064
+ if (hostSessionId !== event.session_id) resetSessionResolution();
1065
+ hostSessionId = event.session_id;
1066
+ try {
1067
+ await ensureSessionSelection(ctx.cwd, event.signal);
1068
+ } catch (error) {
1069
+ if (!event.signal.aborted) api.logger.warn(`[engram] ${String(error)}`);
1070
+ return;
1071
+ }
197
1072
  await resolveExtractionPack();
198
-
199
- // Guard: skip auto-retry/continuation settles
200
- if (event.willContinue === true) return;
1073
+ if (!resolvedPackId) {
1074
+ api.logger.warn("[engram] session_stop could not resolve an active extraction pack");
1075
+ return;
1076
+ }
201
1077
  if (!Array.isArray(event.messages) || event.messages.length === 0) return;
1078
+ const turnKey = stopTurnKey(event);
1079
+ if (lastCapturedTurnKey === turnKey) return;
202
1080
 
203
1081
  const turn = buildTurnContext(event);
204
1082
  if (turn === undefined) return;
1083
+ const resolution = await resolveCaptureModule();
1084
+ if (resolution.kind === "failed") {
1085
+ api.logger.warn(`[engram] failed to load pack capture handler: ${resolution.message}`);
1086
+ return;
1087
+ }
1088
+ if (resolution.kind === "available") {
1089
+ try {
1090
+ const summary = await resolution.captureFromTurn(turn, await captureTools(event.signal));
1091
+ if (event.signal.aborted) return;
1092
+ // Ambient capture never blocks the turn, so a silent warning would be
1093
+ // invisible: every pack-reported failure surfaces here.
1094
+ for (const warning of summary.warnings) {
1095
+ api.logger.warn(`[engram] capture warning: ${warning}`);
1096
+ }
1097
+ api.logger.info(
1098
+ `[engram] capture: ${summary.created.length} draft(s), ` +
1099
+ `${summary.existing.length} existing, ${summary.invalid.length} invalid, ` +
1100
+ `${summary.warnings.length} warning(s)`,
1101
+ );
1102
+ lastCapturedTurnKey = turnKey;
1103
+ } catch (error) {
1104
+ if (!event.signal.aborted) api.logger.warn(`[engram] pack capture handler failed: ${String(error)}`);
1105
+ }
1106
+ return;
1107
+ }
205
1108
 
206
-
207
- // CLI fallback path
1109
+ // A successfully loaded pack without a captureFromTurn handler retains
1110
+ // the generic CLI path.
208
1111
  try {
209
1112
  const proc = Bun.spawn([cliPath, "capture-from-turn"], {
210
1113
  stdin: "pipe",
211
1114
  stdout: "pipe",
212
1115
  stderr: "pipe",
213
1116
  env: cliEnv(),
1117
+ signal: event.signal,
214
1118
  });
215
1119
  await proc.stdin.write(JSON.stringify(turn) + "\n");
216
1120
  await proc.stdin.end();
217
1121
 
218
1122
  const exitCode = await proc.exited;
219
- if (exitCode !== 0) {
1123
+ if (exitCode === 0) lastCapturedTurnKey = turnKey;
1124
+ if (exitCode !== 0 && !event.signal.aborted) {
220
1125
  const stdout = await new Response(proc.stdout).text();
221
1126
  const stderr = await new Response(proc.stderr).text();
222
1127
  api.logger.warn(`[engram] capture-from-turn failed (exit ${exitCode}): ${(stdout || stderr).slice(0, 500)}`);
223
1128
  }
224
- } catch (err) {
225
- api.logger.warn(`[engram] failed to invoke engram CLI: ${err}`);
1129
+ } catch (error) {
1130
+ if (!event.signal.aborted) api.logger.warn(`[engram] failed to invoke engram CLI: ${String(error)}`);
226
1131
  }
227
1132
  });
228
1133
 
229
1134
  // -----------------------------------------------------------------------
230
- // Status tool: report loaded pack and mode
1135
+ // Status tool: report loaded pack, mode, and pending capture state
231
1136
  // -----------------------------------------------------------------------
232
1137
  api.registerTool({
233
1138
  name: "engram_status",
234
- description: "Report the binding-selected pack identity and CLI mode.",
1139
+ label: "Engram status",
1140
+ description: "Report the binding-selected pack identity, CLI mode, and pending capture state.",
235
1141
  parameters: { type: "object", properties: {} },
236
- handler: async () => ({
1142
+ execute: async () => toolText({
237
1143
  pack_id: resolvedPackId ? extractionPackId : null,
238
1144
  pack_version: resolvedPackId ? extractionPackVersion : null,
239
1145
  mode: "cli",
1146
+ pending_plan_hashes: hostSessionId === undefined
1147
+ ? []
1148
+ : [...pendingPlans.entries()]
1149
+ .filter(([, entry]) => entry.sessionId === hostSessionId)
1150
+ .map(([hash]) => hash),
1151
+ index_state: lastIndexState,
1152
+ stale_artifacts: [...sessionStaleArtifacts],
240
1153
  }),
241
1154
  });
242
1155
 
243
1156
  // -----------------------------------------------------------------------
244
- // Voluntary capture: engram_capture tool
1157
+ // Explicit capture: engram_capture_preview
245
1158
  // -----------------------------------------------------------------------
246
1159
  api.registerTool({
247
- name: "engram_capture",
248
- description: `Submit a structured knowledge observation from the current session.
249
- Use this to record decisions, outcomes, risks, or notable events mid-turn
250
- rather than waiting for end-of-turn extraction. Bypasses the pack's
251
- extraction pipeline the agent provides the classification directly.
1160
+ name: "engram_capture_preview",
1161
+ label: "Preview Engram capture",
1162
+ description: `Preview a structured knowledge capture against the active engram space.
1163
+ The binding-selected pack turns your change set into candidate records, the
1164
+ host reconciles them authoritatively, and you receive the exact mutation plan
1165
+ bound to an immutable plan hash. Apply that hash with engram_capture_apply.
252
1166
 
253
1167
  Parameters:
254
- - kind: one of "evidence", "claim", "interpretation", "decision", "recommendation"
255
- - statement: free-form description of the observation
256
- - scope_topics: array of topic tags (e.g. ["work:decision", "work:architecture"])
257
- - subjects: array of subject identifiers (optional)`,
1168
+ - change_set: pack-defined JSON object describing what to capture.`,
258
1169
  parameters: {
259
1170
  type: "object",
260
1171
  properties: {
261
- kind: { type: "string", enum: ["evidence", "claim", "interpretation", "decision", "recommendation"] },
262
- statement: { type: "string", minLength: 1 },
263
- scope_topics: { type: "array", items: { type: "string" }, default: [] },
264
- subjects: { type: "array", items: { type: "string" }, default: [] },
1172
+ change_set: { type: "object" },
265
1173
  },
266
- required: ["kind", "statement"],
1174
+ required: ["change_set"],
1175
+ additionalProperties: false,
267
1176
  },
268
- handler: async (params: Record<string, unknown>) => {
269
- const id = `capture-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
270
- const envelope = {
271
- id,
272
- kind: String(params.kind ?? "claim"),
273
- status: "candidate" as const,
274
- disposition: "new" as const,
275
- scope: {
276
- space: extractionSpaceId,
277
- subjects: Array.isArray(params.subjects) ? params.subjects.map(String) : [],
278
- topics: Array.isArray(params.scope_topics) ? params.scope_topics.map(String) : [],
279
- contexts: [] as string[],
280
- dimensions: {} as Record<string, string[]>,
281
- },
282
- pack: { id: extractionPackId, version: extractionPackVersion },
283
- sources: [{ type: "engram-capture-tool" as const, ref: `session:${registryPath}` }],
284
- session: { id: hostSessionId ?? "pending", host: "omp" as const },
285
- submittedAt: new Date().toISOString(),
286
- details: {} as Record<string, unknown>,
287
- statement: String(params.statement ?? ""),
288
- };
1177
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
1178
+ try {
1179
+ if (hostSessionId === undefined) {
1180
+ return toolText({ status: "error", errors: ["no active engram session; settle a turn first"] });
1181
+ }
1182
+ const changeSet = params.change_set;
1183
+ if (changeSet === undefined || changeSet === null || typeof changeSet !== "object" || Array.isArray(changeSet)) {
1184
+ return toolText({ status: "error", errors: ["change_set must be a JSON object"] });
1185
+ }
1186
+ const resolution = await resolveCaptureModule();
1187
+ if (resolution.kind === "failed") {
1188
+ return toolText({ status: "error", errors: [`pack load failed: ${resolution.message}`] });
1189
+ }
1190
+ if (resolution.kind === "absent" || resolution.previewStructuredCapture === undefined) {
1191
+ return toolText({ status: "error", errors: ["the binding-selected pack does not expose previewStructuredCapture"] });
1192
+ }
289
1193
 
1194
+ // The host preview happens inside the pack callback; capture it so the
1195
+ // pack-declared hash can be verified against the authoritative one.
1196
+ let hostPreview: HostCapturePreview | undefined;
1197
+ const packResult = await resolution.previewStructuredCapture(
1198
+ changeSet as { [key: string]: JsonValue },
1199
+ {
1200
+ spaceId: extractionSpaceId,
1201
+ previewCandidate: async (candidate) => {
1202
+ hostPreview = await previewCandidate(candidate);
1203
+ return hostPreview;
1204
+ },
1205
+ },
1206
+ );
1207
+ const parsed = parsePackPreview(packResult);
1208
+ if (parsed === undefined) {
1209
+ return toolText({ status: "error", errors: ["pack returned a malformed structured capture preview"] });
1210
+ }
1211
+ if (parsed.status === "blocked") {
1212
+ return toolText({ status: "blocked", errors: parsed.errors });
1213
+ }
1214
+ if (hostPreview === undefined || hostPreview.status !== "ready") {
1215
+ return toolText({ status: "error", errors: ["pack declared a ready preview without a successful host reconcile"] });
1216
+ }
1217
+ if (parsed.planHash !== hostPreview.planHash) {
1218
+ return toolText({
1219
+ status: "error",
1220
+ errors: [
1221
+ `plan hash mismatch: pack declared ${parsed.planHash} but the host reconciled ${hostPreview.planHash}`,
1222
+ ],
1223
+ });
1224
+ }
1225
+ pendingPlans.set(parsed.planHash, {
1226
+ sessionId: hostSessionId,
1227
+ candidate: parsed.candidate,
1228
+ preview: hostPreview,
1229
+ state: "previewed",
1230
+ indexState: "not-attempted",
1231
+ });
1232
+ return toolText({
1233
+ plan_hash: parsed.planHash,
1234
+ changes: parsed.changes,
1235
+ artifacts: [...parsed.artifacts].sort(),
1236
+ });
1237
+ } catch (error) {
1238
+ return toolText({ status: "error", errors: [String(error instanceof Error ? error.message : error)] });
1239
+ }
1240
+ },
1241
+ });
290
1242
 
291
- const cliCandidate = toCliCandidate(envelope);
292
- let candidateDir: string | undefined;
1243
+ // -----------------------------------------------------------------------
1244
+ // Explicit capture: engram_capture_apply
1245
+ // -----------------------------------------------------------------------
1246
+ api.registerTool({
1247
+ name: "engram_capture_apply",
1248
+ label: "Apply Engram capture",
1249
+ description: `Commit a previously previewed engram capture plan by its exact plan hash.
1250
+ If the underlying records changed since the preview, the apply is refused as
1251
+ stale and a fresh preview/approval round is required. After records commit,
1252
+ the pack regenerates compatibility views; if that fails the commit stands and
1253
+ a second apply with the same hash retries only view regeneration.`,
1254
+ parameters: {
1255
+ type: "object",
1256
+ properties: {
1257
+ plan_hash: { type: "string", minLength: 1 },
1258
+ },
1259
+ required: ["plan_hash"],
1260
+ additionalProperties: false,
1261
+ },
1262
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
293
1263
  try {
294
- candidateDir = await mkdtemp(join(tmpdir(), "engram-candidate-"));
295
- await chmod(candidateDir, 0o700);
296
- const tmpFile = join(candidateDir, "candidate.json");
297
- const file = await open(tmpFile, "wx", 0o600);
298
- try {
299
- await file.writeFile(JSON.stringify(cliCandidate), "utf8");
300
- } finally {
301
- await file.close();
1264
+ if (hostSessionId === undefined) {
1265
+ return toolText({ status: "error", errors: ["no active engram session; settle a turn first"] });
302
1266
  }
303
- const proc = Bun.spawn([cliPath, "knowledge", "submit", "--candidate", tmpFile], {
304
- stdout: "pipe",
305
- stderr: "pipe",
306
- env: cliEnv(),
1267
+ const planHash = params.plan_hash;
1268
+ if (typeof planHash !== "string" || planHash.length === 0) {
1269
+ return toolText({ status: "error", errors: ["plan_hash must be a non-empty string"] });
1270
+ }
1271
+ // Unknown or session-mismatched hashes are rejected without touching the CLI.
1272
+ const entry = pendingPlans.get(planHash);
1273
+ if (entry === undefined || entry.sessionId !== hostSessionId) {
1274
+ return toolText({
1275
+ plan_hash: planHash,
1276
+ status: "error",
1277
+ errors: ["unknown or session-mismatched plan_hash; run engram_capture_preview first"],
1278
+ });
1279
+ }
1280
+ const resolution = await resolveCaptureModule();
1281
+ if (resolution.kind !== "available") {
1282
+ return toolText({ plan_hash: planHash, status: "error", errors: ["binding-selected pack is unavailable"] });
1283
+ }
1284
+
1285
+ if (entry.state === "previewed") {
1286
+ const outcome = await withTempCandidate(entry.candidate, (file) =>
1287
+ runCli(["knowledge", "approve", "--candidate", file, "--expect", planHash]));
1288
+ const parsed = parseCliApplyOutcome(outcome.stdout);
1289
+ if (parsed?.status === "stale_approval") {
1290
+ pendingPlans.delete(planHash);
1291
+ return toolText({ plan_hash: planHash, status: "stale", errors: [] });
1292
+ }
1293
+ if (parsed === undefined) {
1294
+ return toolText({
1295
+ plan_hash: planHash,
1296
+ status: "error",
1297
+ errors: [
1298
+ `engram knowledge approve failed (exit ${outcome.exitCode}): ${(outcome.stdout || outcome.stderr).slice(0, 500)}`,
1299
+ ],
1300
+ });
1301
+ }
1302
+ entry.appliedPlan = { planHash, mutations: parsed.mutations };
1303
+ entry.appliedAt ??= new Date().toISOString();
1304
+ entry.appliedStatus = parsed.status === "committed" ? "committed" : "no-change";
1305
+ entry.indexState = parsed.index;
1306
+ entry.state = "records-committed";
1307
+ }
1308
+
1309
+ const appliedPlan = entry.appliedPlan;
1310
+ if (appliedPlan === undefined) {
1311
+ return toolText({ plan_hash: planHash, status: "error", errors: ["pending entry lost its applied plan"] });
1312
+ }
1313
+ const summary = summarizeMutations(appliedPlan.mutations);
1314
+ let materialization: MaterializationOutcome = { written: [], unchanged: [], stale: [] };
1315
+ let materializationFailed = false;
1316
+ if (resolution.materialize !== undefined) {
1317
+ try {
1318
+ materialization = normalizeMaterialization(
1319
+ await resolution.materialize(appliedPlan, materializeTools(entry)),
1320
+ );
1321
+ } catch (error) {
1322
+ materializationFailed = true;
1323
+ api.logger.warn(`[engram] materialization failed for plan ${planHash}: ${String(error)}`);
1324
+ materialization.stale.push({
1325
+ path: "(materialization)",
1326
+ reason: String(error instanceof Error ? error.message : error),
1327
+ });
1328
+ }
1329
+ }
1330
+ if (materializationFailed) {
1331
+ // Records stay committed; the entry is retained so a second apply
1332
+ // with the SAME hash reruns ONLY materialize, never knowledge approve.
1333
+ return toolText({
1334
+ plan_hash: planHash,
1335
+ status: "records-committed",
1336
+ index: entry.indexState,
1337
+ created: summary.created,
1338
+ retired: summary.retired,
1339
+ entity_keys: summary.entityKeys,
1340
+ artifacts: {
1341
+ generated: materialization.written.map((item) => item.path),
1342
+ unchanged: materialization.unchanged.map((item) => item.path),
1343
+ stale: materialization.stale,
1344
+ },
1345
+ retry: "apply the same plan_hash to retry materialization only",
1346
+ });
1347
+ }
1348
+ pendingPlans.delete(planHash);
1349
+ lastIndexState = entry.indexState;
1350
+ for (const staleItem of materialization.stale) sessionStaleArtifacts.push(staleItem.path);
1351
+ return toolText({
1352
+ plan_hash: planHash,
1353
+ status: entry.appliedStatus ?? "committed",
1354
+ index: entry.indexState,
1355
+ created: summary.created,
1356
+ retired: summary.retired,
1357
+ entity_keys: summary.entityKeys,
1358
+ artifacts: {
1359
+ generated: materialization.written.map((item) => item.path),
1360
+ unchanged: materialization.unchanged.map((item) => item.path),
1361
+ stale: materialization.stale,
1362
+ },
307
1363
  });
308
- const exitCode = await proc.exited;
309
- const stdout = await new Response(proc.stdout).text();
310
- const stderr = await new Response(proc.stderr).text();
311
- if (exitCode === 0) return { status: "submitted", detail: stdout, id };
312
- return { status: "error", detail: (stdout || stderr).slice(0, 1000), id };
313
1364
  } catch (error) {
314
- return { status: "error", detail: String(error).slice(0, 1000), id };
315
- } finally {
316
- if (candidateDir !== undefined) await rm(candidateDir, { recursive: true, force: true });
1365
+ return toolText({ status: "error", errors: [String(error instanceof Error ? error.message : error)] });
317
1366
  }
318
1367
  },
319
1368
  });
@@ -323,29 +1372,45 @@ Parameters:
323
1372
  // TurnContext builder
324
1373
  // ---------------------------------------------------------------------------
325
1374
 
326
- function buildTurnContext(event: AgentEndEvent): TurnContext | undefined {
327
- const messages = event.messages;
328
- if (!Array.isArray(messages) || messages.length === 0) return undefined;
329
-
330
- const session: HostSessionProvenance = {
331
- id: String(event.sessionId ?? "unknown"),
332
- host: "omp",
333
- };
1375
+ function buildTurnContext(event: SessionStopEvent): TurnContext | undefined {
1376
+ const records = event.messages.filter(
1377
+ (message): message is Record<string, unknown> =>
1378
+ typeof message === "object" && message !== null && !Array.isArray(message),
1379
+ );
1380
+ if (records.length === 0) return undefined;
334
1381
 
335
- const turnIndex = typeof event.turnIndex === "number" ? event.turnIndex : 0;
336
- const timestamp = event.timestamp ?? new Date().toISOString();
1382
+ // Ambient capture reads only what the user just said. Anchoring on the LAST
1383
+ // user message keeps `turnIndex` stable across repeat settlement of the same
1384
+ // turn, which is what makes ambient record IDs deterministic — the total
1385
+ // message count is not stable for that purpose.
1386
+ let latestUserMessage = -1;
1387
+ for (let index = 0; index < records.length; index += 1) {
1388
+ if (records[index]?.role === "user") latestUserMessage = index;
1389
+ }
1390
+ if (latestUserMessage === -1) return undefined;
1391
+ const messages = records.slice(latestUserMessage);
1392
+ const narrative = extractTextContent(records[latestUserMessage] ?? {});
1393
+ const session: HostSessionProvenance = { id: event.session_id, host: "omp" };
1394
+ const lastAssistant = typeof event.last_assistant_message === "object" &&
1395
+ event.last_assistant_message !== null &&
1396
+ !Array.isArray(event.last_assistant_message)
1397
+ ? event.last_assistant_message as Record<string, unknown>
1398
+ : undefined;
1399
+ const rawTimestamp = lastAssistant?.timestamp;
1400
+ const timestamp = typeof rawTimestamp === "number"
1401
+ ? new Date(rawTimestamp).toISOString()
1402
+ : typeof rawTimestamp === "string"
1403
+ ? rawTimestamp
1404
+ : new Date().toISOString();
337
1405
 
338
- const narrativeParts: string[] = [];
1406
+ // Tool provenance from the latest user message onward is retained so the
1407
+ // pack can suppress ambient duplicates of an explicit capture applied in
1408
+ // this same turn.
339
1409
  const toolCalls: TurnToolCall[] = [];
340
-
341
- for (const raw of messages as Array<Record<string, unknown>>) {
1410
+ for (const raw of messages) {
342
1411
  const role = String(raw.role ?? "");
343
1412
  const content = extractTextContent(raw);
344
-
345
- if (role === "user") {
346
- narrativeParts.push(`User: ${content}`);
347
- } else if (role === "assistant") {
348
- narrativeParts.push(`Assistant: ${content}`);
1413
+ if (role === "assistant") {
349
1414
  const toolCallsData = raw.tool_calls ?? raw.toolCalls;
350
1415
  if (Array.isArray(toolCallsData)) {
351
1416
  for (const tc of toolCallsData) {
@@ -365,7 +1430,6 @@ function buildTurnContext(event: AgentEndEvent): TurnContext | undefined {
365
1430
  } else if (role === "tool" || role === "tool_result") {
366
1431
  const toolName = String(raw.name ?? raw.tool_name ?? "tool");
367
1432
  const result = raw.content ?? raw.result;
368
- narrativeParts.push(`Tool ${toolName}: returned`);
369
1433
  const pending = [...toolCalls].reverse().find((tc) => tc.tool === toolName && tc.result === undefined);
370
1434
  if (pending) {
371
1435
  pending.result = typeof result === "string" ? result.slice(0, 500) : result;
@@ -381,9 +1445,9 @@ function buildTurnContext(event: AgentEndEvent): TurnContext | undefined {
381
1445
 
382
1446
  return {
383
1447
  session,
384
- turnIndex,
385
- timestamp: String(timestamp ?? new Date().toISOString()),
386
- narrative: narrativeParts.join("\n"),
1448
+ turnIndex: latestUserMessage,
1449
+ timestamp,
1450
+ narrative,
387
1451
  toolCalls,
388
1452
  };
389
1453
  }