@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.
- package/README.md +3 -3
- package/omp-extension.ts +1200 -136
- 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
|
|
5
|
-
* from settled turns, and registers
|
|
6
|
-
*
|
|
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
|
|
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
|
|
30
|
+
* Two capture paths:
|
|
26
31
|
*
|
|
27
|
-
* Hook (
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
*
|
|
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 {
|
|
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: "
|
|
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
|
|
61
|
-
type: "
|
|
86
|
+
export interface SessionStopEvent {
|
|
87
|
+
type: "session_stop";
|
|
62
88
|
messages: unknown[];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
// Before
|
|
141
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
1056
|
+
// Structural capture: awaited final-settle hook
|
|
192
1057
|
// -----------------------------------------------------------------------
|
|
193
|
-
api.on("
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
|
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 (
|
|
225
|
-
api.logger.warn(`[engram] failed to invoke engram CLI: ${
|
|
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
|
|
1135
|
+
// Status tool: report loaded pack, mode, and pending capture state
|
|
231
1136
|
// -----------------------------------------------------------------------
|
|
232
1137
|
api.registerTool({
|
|
233
1138
|
name: "engram_status",
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1157
|
+
// Explicit capture: engram_capture_preview
|
|
245
1158
|
// -----------------------------------------------------------------------
|
|
246
1159
|
api.registerTool({
|
|
247
|
-
name: "
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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: ["
|
|
1174
|
+
required: ["change_set"],
|
|
1175
|
+
additionalProperties: false,
|
|
267
1176
|
},
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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",
|
|
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:
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
|
386
|
-
narrative
|
|
1448
|
+
turnIndex: latestUserMessage,
|
|
1449
|
+
timestamp,
|
|
1450
|
+
narrative,
|
|
387
1451
|
toolCalls,
|
|
388
1452
|
};
|
|
389
1453
|
}
|