@isparling/engram-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +187 -0
- package/README.md +41 -0
- package/bin/engram +14 -0
- package/package.json +32 -0
- package/release/engram-release.ts +1493 -0
- package/src/atomicWrite.ts +80 -0
- package/src/candidate.ts +229 -0
- package/src/classify.ts +275 -0
- package/src/cli.ts +709 -0
- package/src/contentHash.ts +54 -0
- package/src/deepFreeze.ts +13 -0
- package/src/diff.ts +81 -0
- package/src/guardedRetrieval.ts +128 -0
- package/src/guardedRetrievalInternal.ts +321 -0
- package/src/knowledgeRecord.ts +256 -0
- package/src/knowledgeRetrieval.ts +564 -0
- package/src/knowledgeRollup.ts +479 -0
- package/src/knowledgeTransaction.ts +683 -0
- package/src/knowledgeTypes.ts +249 -0
- package/src/knowledgeValidation.ts +269 -0
- package/src/markdownRecord.ts +265 -0
- package/src/packLoader.ts +188 -0
- package/src/packTypes.ts +12 -0
- package/src/presentation.ts +673 -0
- package/src/qmdConfigGuard.ts +245 -0
- package/src/qmdRunner.ts +392 -0
- package/src/realPath.ts +47 -0
- package/src/spaceBinding.ts +37 -0
- package/src/spaceRegistry.ts +1139 -0
- package/src/submit.ts +228 -0
- package/src/symlinkGuard.ts +71 -0
- package/src/transactionLock.ts +188 -0
- package/src/types.ts +38 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI entry — schema_version 0 remains intentionally unstable.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// engram submit --candidate <path-to-candidate.json>
|
|
6
|
+
// engram submit --candidate <path-to-candidate.json> --approve --expect <hash>
|
|
7
|
+
// engram knowledge submit|reconcile --candidate <path-to-candidate.json>
|
|
8
|
+
// engram knowledge approve|reject --candidate <path-to-candidate.json> --expect <plan_hash>
|
|
9
|
+
// engram rollup preview --bullets <path-to-batch.json>
|
|
10
|
+
// engram rollup approve --bullets <path-to-batch.json> --expect <rollup-hash>
|
|
11
|
+
// engram space register --binding <path-to-local-binding.json>
|
|
12
|
+
// engram space select <space-id>
|
|
13
|
+
// engram space status
|
|
14
|
+
// engram recall --query <text> --audience <id>
|
|
15
|
+
// engram version
|
|
16
|
+
//
|
|
17
|
+
// Knowledge operations resolve the selected space through ENGRAM_BINDING_REGISTRY
|
|
18
|
+
// and ENGRAM_HOST_SESSION_ID. There is deliberately no submit-time --space,
|
|
19
|
+
// --root, or --collection flag: a candidate cannot redirect its operation.
|
|
20
|
+
//
|
|
21
|
+
// --expect <hash> is mandatory alongside --approve: a non-additive
|
|
22
|
+
// candidate's approval_required result carries a `plan_hash` covering both
|
|
23
|
+
// the record as read and the bytes that would replace it, and re-approving
|
|
24
|
+
// must name that exact hash or the commit is refused as `stale_approval`
|
|
25
|
+
// (see submit.ts). This binds approval to the mutation the caller actually
|
|
26
|
+
// saw — not merely to the record it started from, which would let a
|
|
27
|
+
// different candidate be approved under an unchanged record's hash.
|
|
28
|
+
//
|
|
29
|
+
// Every result is printed to stdout as JSON carrying schema_version: 0.
|
|
30
|
+
// Exit codes: 0 = committed, 2 = approval_required, 3 = stale_approval,
|
|
31
|
+
// 1 = anything else (invalid candidate, invalid space binding, usage error).
|
|
32
|
+
|
|
33
|
+
import { readFile } from "node:fs/promises";
|
|
34
|
+
import { isAbsolute } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import {
|
|
37
|
+
inspectSpaceRegistry,
|
|
38
|
+
recordQmdFreshness,
|
|
39
|
+
registerSpace,
|
|
40
|
+
resolveActiveSpace,
|
|
41
|
+
selectSpace,
|
|
42
|
+
type ActiveSpace,
|
|
43
|
+
} from "./spaceRegistry.ts";
|
|
44
|
+
import { submitCandidate, type SubmitOutcome } from "./submit.ts";
|
|
45
|
+
import { REFRESH_NOT_ATTEMPTED } from "./qmdRunner.ts";
|
|
46
|
+
import { guardedRetrieve } from "./guardedRetrieval.ts";
|
|
47
|
+
import { renderPresentation } from "./presentation.ts";
|
|
48
|
+
import type { KnowledgePack, KnowledgeExtractor, PresentationPack, TurnContext, TurnToolCall, PackHelpers } from "./knowledgeTypes.ts";
|
|
49
|
+
import { loadExtractionPack, resolveKnowledgePack } from "./packLoader.ts";
|
|
50
|
+
import { requireDefined } from "./types.ts";
|
|
51
|
+
import {
|
|
52
|
+
applyKnowledgeProposal,
|
|
53
|
+
reconcileKnowledgeTransaction,
|
|
54
|
+
submitKnowledgeCandidate,
|
|
55
|
+
type ApplyKnowledgeOutcome,
|
|
56
|
+
} from "./knowledgeTransaction.ts";
|
|
57
|
+
import { approveKnowledgeRollup, previewKnowledgeRollup, type KnowledgeRollupApplyOutcome } from "./knowledgeRollup.ts";
|
|
58
|
+
import { readReleaseManifest } from "../release/engram-release.ts";
|
|
59
|
+
|
|
60
|
+
function printJson(value: unknown): void {
|
|
61
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const USAGE = [
|
|
65
|
+
"usage: engram submit --candidate <path> [--approve --expect <hash>]",
|
|
66
|
+
" engram knowledge submit --candidate <path>",
|
|
67
|
+
" engram knowledge reconcile --candidate <path>",
|
|
68
|
+
" engram knowledge approve|reject --candidate <path> --expect <plan_hash>",
|
|
69
|
+
" engram rollup preview --bullets <path>",
|
|
70
|
+
" engram rollup approve --bullets <path> --expect <rollup-hash>",
|
|
71
|
+
" engram space register --binding <path>",
|
|
72
|
+
" engram space select <space-id>",
|
|
73
|
+
" engram space status",
|
|
74
|
+
" engram recall --query <text> --audience <id> [--source-class <class>]",
|
|
75
|
+
" engram render --view <id> --audience <id> --delivery <id> --model <provider/model> [--query <text>]",
|
|
76
|
+
].join("\n");
|
|
77
|
+
|
|
78
|
+
function usageError(message: string): never {
|
|
79
|
+
process.stderr.write(`${message}\n`);
|
|
80
|
+
process.stderr.write(`${USAGE}\n`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function todayIsoDate(): string {
|
|
85
|
+
return new Date().toISOString().slice(0, 10);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function printInvalid(errors: string[]): never {
|
|
89
|
+
printJson({ schema_version: 0, status: "invalid", errors });
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Reads all of stdin as a string. */
|
|
94
|
+
async function readStdin(): Promise<string> {
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
97
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the one pack a space's manifest declares (`required_packs`,
|
|
102
|
+
* surfaced as `ActiveSpace.packs`) into its implementation. Resolution is
|
|
103
|
+
* explicit and external-only: the declared id/version are resolved through the
|
|
104
|
+
* binding's `from` module specifier, and any resolution failure — a missing
|
|
105
|
+
* `from`, an unloadable module, an invalid export, or an identity mismatch —
|
|
106
|
+
* is reported to the caller and never substituted by another source. A space
|
|
107
|
+
* declares exactly one pack; zero or several are refused here rather than
|
|
108
|
+
* guessed at.
|
|
109
|
+
*/
|
|
110
|
+
async function resolveCliPack(active: ActiveSpace): Promise<KnowledgePack & PresentationPack> {
|
|
111
|
+
if (active.packs.length !== 1) {
|
|
112
|
+
printInvalid([
|
|
113
|
+
active.packs.length === 0
|
|
114
|
+
? "active space declares no required packs; the CLI requires exactly one to resolve knowledge operations against"
|
|
115
|
+
: `active space declares ${active.packs.length} required packs (${active.packs.map((pack) => pack.id).join(", ")}); the CLI will not guess which one to use`,
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
const declared = requireDefined(active.packs[0], "active space packs[0] must exist once packs.length === 1");
|
|
119
|
+
const resolved = await resolveKnowledgePack(declared.id, declared.version, declared.from, active.bindingPath);
|
|
120
|
+
if (!resolved.ok) printInvalid(resolved.errors.map((error) => `${error.code}: ${error.message}`));
|
|
121
|
+
return resolved.value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function registryPath(): string {
|
|
125
|
+
const value = process.env.ENGRAM_BINDING_REGISTRY;
|
|
126
|
+
if (value === undefined || value.length === 0) printInvalid(["missing ENGRAM_BINDING_REGISTRY"]);
|
|
127
|
+
if (!isAbsolute(value)) printInvalid(["ENGRAM_BINDING_REGISTRY must be an absolute path"]);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hostSessionId(): string {
|
|
132
|
+
const value = process.env.ENGRAM_HOST_SESSION_ID;
|
|
133
|
+
if (value === undefined || value.length === 0) printInvalid(["missing ENGRAM_HOST_SESSION_ID"]);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runSpaceCommand(args: string[]): Promise<void> {
|
|
138
|
+
const [subcommand, ...rest] = args;
|
|
139
|
+
if (subcommand === "register") {
|
|
140
|
+
if (rest.length !== 2 || rest[0] !== "--binding") {
|
|
141
|
+
usageError("space register requires exactly --binding <path>");
|
|
142
|
+
}
|
|
143
|
+
const bindingPath = rest[1];
|
|
144
|
+
if (bindingPath === undefined) usageError("--binding requires a path");
|
|
145
|
+
const result = await registerSpace(registryPath(), bindingPath);
|
|
146
|
+
if (!result.ok) printInvalid(result.errors);
|
|
147
|
+
printJson({ schema_version: 0, status: "registered", space: result.value });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (subcommand === "select") {
|
|
151
|
+
if (rest.length !== 1) usageError("space select requires exactly one space id");
|
|
152
|
+
const spaceId = rest[0];
|
|
153
|
+
if (spaceId === undefined) usageError("space select requires a space id");
|
|
154
|
+
const result = await selectSpace(registryPath(), spaceId, hostSessionId());
|
|
155
|
+
if (!result.ok) printInvalid(result.errors);
|
|
156
|
+
printJson({ schema_version: 0, status: "selected", space: result.value });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (subcommand === "status") {
|
|
160
|
+
if (rest.length !== 0) usageError("space status accepts no arguments");
|
|
161
|
+
const result = await inspectSpaceRegistry(registryPath());
|
|
162
|
+
if (!result.ok) printInvalid(result.errors);
|
|
163
|
+
printJson(result.value);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
usageError(`unknown space command: ${subcommand ?? "(none)"}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runSubmitCommand(rest: string[]): Promise<void> {
|
|
170
|
+
let candidatePath: string | undefined;
|
|
171
|
+
let approve = false;
|
|
172
|
+
let expectHash: string | undefined;
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < rest.length; i++) {
|
|
175
|
+
const arg = rest[i];
|
|
176
|
+
if (arg === "--approve") {
|
|
177
|
+
approve = true;
|
|
178
|
+
} else if (arg === "--candidate") {
|
|
179
|
+
i++;
|
|
180
|
+
candidatePath = rest[i];
|
|
181
|
+
if (candidatePath === undefined) {
|
|
182
|
+
usageError("--candidate requires a path argument");
|
|
183
|
+
}
|
|
184
|
+
} else if (arg === "--expect") {
|
|
185
|
+
i++;
|
|
186
|
+
expectHash = rest[i];
|
|
187
|
+
if (expectHash === undefined) {
|
|
188
|
+
usageError("--expect requires a hash argument");
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!candidatePath) {
|
|
196
|
+
usageError("missing required --candidate <path>");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (approve && expectHash === undefined) {
|
|
200
|
+
usageError("--approve requires --expect <hash> (the plan_hash from a prior approval_required result)");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
204
|
+
if (!bindingResult.ok) {
|
|
205
|
+
printJson({
|
|
206
|
+
schema_version: 0,
|
|
207
|
+
status: "invalid",
|
|
208
|
+
errors: bindingResult.errors,
|
|
209
|
+
refresh: REFRESH_NOT_ATTEMPTED,
|
|
210
|
+
});
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let candidateInput: unknown;
|
|
215
|
+
try {
|
|
216
|
+
const raw = await readFile(candidatePath, "utf8");
|
|
217
|
+
candidateInput = JSON.parse(raw);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
printJson({
|
|
220
|
+
schema_version: 0,
|
|
221
|
+
status: "invalid",
|
|
222
|
+
errors: [`failed to read/parse candidate file: ${error instanceof Error ? error.message : String(error)}`],
|
|
223
|
+
refresh: REFRESH_NOT_ATTEMPTED,
|
|
224
|
+
});
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const result: SubmitOutcome = await submitCandidate({
|
|
229
|
+
binding: bindingResult.value,
|
|
230
|
+
candidateInput,
|
|
231
|
+
approve,
|
|
232
|
+
...(expectHash === undefined ? {} : { expectHash }),
|
|
233
|
+
submittedAt: todayIsoDate(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
let output: unknown = result;
|
|
237
|
+
if (result.status === "committed") {
|
|
238
|
+
const registry = process.env.ENGRAM_BINDING_REGISTRY;
|
|
239
|
+
if (registry !== undefined) {
|
|
240
|
+
const recorded = await recordQmdFreshness(registry, bindingResult.value.spaceId, result.refresh.state);
|
|
241
|
+
if (!recorded.ok) output = { ...result, status_warnings: recorded.errors };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
printJson(output);
|
|
246
|
+
|
|
247
|
+
if (result.status === "committed") process.exit(0);
|
|
248
|
+
if (result.status === "approval_required") process.exit(2);
|
|
249
|
+
if (result.status === "stale_approval") process.exit(3);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readCandidateFile(candidatePath: string): Promise<unknown> {
|
|
254
|
+
try {
|
|
255
|
+
const parsed: unknown = JSON.parse(await readFile(candidatePath, "utf8"));
|
|
256
|
+
return parsed;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
printInvalid([`failed to read/parse candidate file: ${error instanceof Error ? error.message : String(error)}`]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function knowledgeArgs(rest: string[]): { candidatePath: string; expectHash?: string } {
|
|
263
|
+
let candidatePath: string | undefined;
|
|
264
|
+
let expectHash: string | undefined;
|
|
265
|
+
for (let index = 0; index < rest.length; index++) {
|
|
266
|
+
const arg = rest[index];
|
|
267
|
+
if (arg === "--candidate") {
|
|
268
|
+
index++;
|
|
269
|
+
candidatePath = rest[index];
|
|
270
|
+
if (candidatePath === undefined) usageError("--candidate requires a path argument");
|
|
271
|
+
} else if (arg === "--expect") {
|
|
272
|
+
index++;
|
|
273
|
+
expectHash = rest[index];
|
|
274
|
+
if (expectHash === undefined) usageError("--expect requires a plan hash argument");
|
|
275
|
+
} else {
|
|
276
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (candidatePath === undefined) usageError("missing required --candidate <path>");
|
|
280
|
+
return { candidatePath, ...(expectHash === undefined ? {} : { expectHash }) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function knowledgeExit(outcome: ApplyKnowledgeOutcome): never {
|
|
284
|
+
if (outcome.status === "committed" || outcome.status === "rejected" || outcome.status === "no_change") process.exit(0);
|
|
285
|
+
if (outcome.status === "stale_approval") process.exit(3);
|
|
286
|
+
if (outcome.status === "approval_required") process.exit(2);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function runKnowledgeCommand(args: string[]): Promise<void> {
|
|
291
|
+
const [subcommand, ...rest] = args;
|
|
292
|
+
if (subcommand !== "submit" && subcommand !== "reconcile" && subcommand !== "approve" && subcommand !== "reject") {
|
|
293
|
+
usageError(`unknown knowledge command: ${subcommand ?? "(none)"}`);
|
|
294
|
+
}
|
|
295
|
+
const parsedArgs = knowledgeArgs(rest);
|
|
296
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
297
|
+
if (!bindingResult.ok) {
|
|
298
|
+
printJson({ schema_version: 0, status: "invalid", errors: bindingResult.errors, refresh: REFRESH_NOT_ATTEMPTED });
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
const pack = await resolveCliPack(bindingResult.value);
|
|
302
|
+
const candidateInput = await readCandidateFile(parsedArgs.candidatePath);
|
|
303
|
+
|
|
304
|
+
if (subcommand === "submit") {
|
|
305
|
+
const result = submitKnowledgeCandidate({ binding: bindingResult.value, candidateInput, pack });
|
|
306
|
+
printJson(result);
|
|
307
|
+
if (result.status === "submitted") process.exit(0);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const proposalResult = await reconcileKnowledgeTransaction({
|
|
312
|
+
binding: bindingResult.value,
|
|
313
|
+
candidateInput,
|
|
314
|
+
pack,
|
|
315
|
+
});
|
|
316
|
+
if (subcommand === "reconcile") {
|
|
317
|
+
printJson(proposalResult);
|
|
318
|
+
if (proposalResult.status === "proposal") process.exit(0);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
if (proposalResult.status !== "proposal") {
|
|
322
|
+
printJson(proposalResult);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
if (parsedArgs.expectHash === undefined) {
|
|
326
|
+
printJson({ schema_version: 0, status: "invalid", errors: ["knowledge approval requires --expect <plan_hash> from a prior reconcile"] });
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
const applied = await applyKnowledgeProposal({
|
|
330
|
+
binding: bindingResult.value,
|
|
331
|
+
proposal: proposalResult.proposal,
|
|
332
|
+
decision: subcommand === "approve" ? "approve" : "reject",
|
|
333
|
+
expectedPlanHash: parsedArgs.expectHash,
|
|
334
|
+
pack,
|
|
335
|
+
});
|
|
336
|
+
let output: unknown = applied;
|
|
337
|
+
if (applied.status === "committed") {
|
|
338
|
+
const registry = process.env.ENGRAM_BINDING_REGISTRY;
|
|
339
|
+
if (registry !== undefined) {
|
|
340
|
+
const recorded = await recordQmdFreshness(registry, bindingResult.value.spaceId, applied.refresh.state);
|
|
341
|
+
if (!recorded.ok) output = { ...applied, status_warnings: recorded.errors };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
printJson(output);
|
|
345
|
+
knowledgeExit(applied);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rollupArgs(rest: string[]): { bulletsPath: string; expectHash?: string } {
|
|
349
|
+
let bulletsPath: string | undefined;
|
|
350
|
+
let expectHash: string | undefined;
|
|
351
|
+
for (let index = 0; index < rest.length; index++) {
|
|
352
|
+
const arg = rest[index];
|
|
353
|
+
if (arg === "--bullets") {
|
|
354
|
+
index++;
|
|
355
|
+
bulletsPath = rest[index];
|
|
356
|
+
if (bulletsPath === undefined) printInvalid(["--bullets requires a path argument"]);
|
|
357
|
+
} else if (arg === "--expect") {
|
|
358
|
+
index++;
|
|
359
|
+
expectHash = rest[index];
|
|
360
|
+
if (expectHash === undefined) printInvalid(["--expect requires a hash argument"]);
|
|
361
|
+
} else {
|
|
362
|
+
printInvalid([`unrecognized argument: ${arg}`]);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (bulletsPath === undefined) printInvalid(["missing required --bullets <path>"]);
|
|
366
|
+
return { bulletsPath, ...(expectHash === undefined ? {} : { expectHash }) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function readBulletsFile(bulletsPath: string): Promise<unknown> {
|
|
370
|
+
let content: string;
|
|
371
|
+
try {
|
|
372
|
+
content = await readFile(bulletsPath, "utf8");
|
|
373
|
+
} catch {
|
|
374
|
+
printInvalid(["bullets file could not be read"]);
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const parsed: unknown = JSON.parse(content);
|
|
378
|
+
return parsed;
|
|
379
|
+
} catch {
|
|
380
|
+
printInvalid(["bullets file content is not valid JSON"]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function rollupExit(outcome: KnowledgeRollupApplyOutcome): never {
|
|
385
|
+
if (outcome.status === "committed" || outcome.status === "no_change") process.exit(0);
|
|
386
|
+
if (outcome.status === "stale_approval") process.exit(3);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function runRollupCommand(args: string[]): Promise<void> {
|
|
391
|
+
const [subcommand, ...rest] = args;
|
|
392
|
+
if (subcommand !== "preview" && subcommand !== "approve") {
|
|
393
|
+
printInvalid([`unknown rollup command: ${subcommand ?? "(none)"}`]);
|
|
394
|
+
}
|
|
395
|
+
const parsedArgs = rollupArgs(rest);
|
|
396
|
+
if (subcommand === "preview" && parsedArgs.expectHash !== undefined) {
|
|
397
|
+
printInvalid(["rollup preview does not accept --expect (only rollup approve does)"]);
|
|
398
|
+
}
|
|
399
|
+
if (subcommand === "approve" && parsedArgs.expectHash === undefined) {
|
|
400
|
+
printInvalid(["rollup approve requires --expect <rollup-hash> (the rollup_hash from a prior rollup preview)"]);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
404
|
+
if (!bindingResult.ok) printInvalid(bindingResult.errors);
|
|
405
|
+
const pack = await resolveCliPack(bindingResult.value);
|
|
406
|
+
const batchInput = await readBulletsFile(parsedArgs.bulletsPath);
|
|
407
|
+
|
|
408
|
+
if (subcommand === "preview") {
|
|
409
|
+
const result = await previewKnowledgeRollup({ binding: bindingResult.value, batchInput, pack });
|
|
410
|
+
printJson(result);
|
|
411
|
+
process.exit(result.status === "preview" ? 0 : 1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const expectedRollupHash = requireDefined(parsedArgs.expectHash, "rollup approve expectHash validated above");
|
|
415
|
+
const applied = await approveKnowledgeRollup({
|
|
416
|
+
binding: bindingResult.value,
|
|
417
|
+
batchInput,
|
|
418
|
+
expectedRollupHash,
|
|
419
|
+
pack,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
let output: unknown = applied;
|
|
423
|
+
if (applied.status === "committed" || applied.status === "no_change" || applied.status === "stopped") {
|
|
424
|
+
const registry = process.env.ENGRAM_BINDING_REGISTRY;
|
|
425
|
+
if (registry !== undefined) {
|
|
426
|
+
const warnings: string[] = [];
|
|
427
|
+
const committedItems = applied.status === "stopped" ? applied.committed_items : applied.items;
|
|
428
|
+
for (const item of committedItems) {
|
|
429
|
+
if (item.refresh.state === "not-attempted") continue;
|
|
430
|
+
const recorded = await recordQmdFreshness(registry, bindingResult.value.spaceId, item.refresh.state);
|
|
431
|
+
if (!recorded.ok) warnings.push(...recorded.errors);
|
|
432
|
+
}
|
|
433
|
+
if (warnings.length > 0) output = { ...applied, status_warnings: warnings };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
printJson(output);
|
|
437
|
+
rollupExit(applied);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function parseRecallArgs(args: string[]): { query: string; audienceId: string; requestedSourceClasses: string[] } {
|
|
441
|
+
let query: string | undefined;
|
|
442
|
+
let audienceId: string | undefined;
|
|
443
|
+
const requestedSourceClasses: string[] = [];
|
|
444
|
+
for (let index = 0; index < args.length; index++) {
|
|
445
|
+
const arg = args[index];
|
|
446
|
+
if (arg === "--query") {
|
|
447
|
+
index++;
|
|
448
|
+
query = args[index];
|
|
449
|
+
if (query === undefined) usageError("--query requires a value");
|
|
450
|
+
} else if (arg === "--audience") {
|
|
451
|
+
index++;
|
|
452
|
+
audienceId = args[index];
|
|
453
|
+
if (audienceId === undefined) usageError("--audience requires a value");
|
|
454
|
+
} else if (arg === "--source-class") {
|
|
455
|
+
index++;
|
|
456
|
+
const sourceClass = args[index];
|
|
457
|
+
if (sourceClass === undefined) usageError("--source-class requires a value");
|
|
458
|
+
requestedSourceClasses.push(sourceClass);
|
|
459
|
+
} else {
|
|
460
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (query === undefined) usageError("recall requires --query <text>");
|
|
464
|
+
if (audienceId === undefined) usageError("recall requires --audience <id>");
|
|
465
|
+
return { query, audienceId, requestedSourceClasses };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function runRecallCommand(args: string[]): Promise<void> {
|
|
469
|
+
const parsed = parseRecallArgs(args);
|
|
470
|
+
// No pack fallback. If the space cannot be resolved there is no declared
|
|
471
|
+
// pack, and substituting one would run a query under rules the space never
|
|
472
|
+
// asked for. guardedRetrieve would fail on the same unresolved space anyway,
|
|
473
|
+
// so failing here costs nothing and removes a silent substitution.
|
|
474
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
475
|
+
if (!bindingResult.ok) printInvalid(bindingResult.errors);
|
|
476
|
+
const pack = await resolveCliPack(bindingResult.value);
|
|
477
|
+
const result = await guardedRetrieve({
|
|
478
|
+
query: parsed.query,
|
|
479
|
+
audienceId: parsed.audienceId,
|
|
480
|
+
...(parsed.requestedSourceClasses.length === 0 ? {} : { requestedSourceClasses: parsed.requestedSourceClasses }),
|
|
481
|
+
pack,
|
|
482
|
+
});
|
|
483
|
+
printJson(result);
|
|
484
|
+
if (result.status === "failed") process.exit(1);
|
|
485
|
+
process.exit(0);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function parseRenderArgs(args: string[]): {
|
|
489
|
+
viewId: string;
|
|
490
|
+
audienceId: string;
|
|
491
|
+
deliveryId: string;
|
|
492
|
+
model: string;
|
|
493
|
+
query?: string;
|
|
494
|
+
generatedAt?: string;
|
|
495
|
+
} {
|
|
496
|
+
let viewId: string | undefined;
|
|
497
|
+
let audienceId: string | undefined;
|
|
498
|
+
let deliveryId: string | undefined;
|
|
499
|
+
let model: string | undefined;
|
|
500
|
+
let query: string | undefined;
|
|
501
|
+
let generatedAt: string | undefined;
|
|
502
|
+
for (let index = 0; index < args.length; index++) {
|
|
503
|
+
const arg = args[index];
|
|
504
|
+
if (arg === "--view") {
|
|
505
|
+
index++;
|
|
506
|
+
viewId = args[index];
|
|
507
|
+
if (viewId === undefined) usageError("--view requires a value");
|
|
508
|
+
} else if (arg === "--audience") {
|
|
509
|
+
index++;
|
|
510
|
+
audienceId = args[index];
|
|
511
|
+
if (audienceId === undefined) usageError("--audience requires a value");
|
|
512
|
+
} else if (arg === "--delivery") {
|
|
513
|
+
index++;
|
|
514
|
+
deliveryId = args[index];
|
|
515
|
+
if (deliveryId === undefined) usageError("--delivery requires a value");
|
|
516
|
+
} else if (arg === "--model") {
|
|
517
|
+
index++;
|
|
518
|
+
model = args[index];
|
|
519
|
+
if (model === undefined) usageError("--model requires a value");
|
|
520
|
+
} else if (arg === "--query") {
|
|
521
|
+
index++;
|
|
522
|
+
query = args[index];
|
|
523
|
+
if (query === undefined) usageError("--query requires a value");
|
|
524
|
+
} else if (arg === "--generated-at") {
|
|
525
|
+
index++;
|
|
526
|
+
generatedAt = args[index];
|
|
527
|
+
if (generatedAt === undefined) usageError("--generated-at requires a value");
|
|
528
|
+
} else {
|
|
529
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (viewId === undefined) usageError("render requires --view <id>");
|
|
533
|
+
if (audienceId === undefined) usageError("render requires --audience <id>");
|
|
534
|
+
if (deliveryId === undefined) usageError("render requires --delivery <id>");
|
|
535
|
+
if (model === undefined) usageError("render requires --model <provider/model>");
|
|
536
|
+
return {
|
|
537
|
+
viewId,
|
|
538
|
+
audienceId,
|
|
539
|
+
deliveryId,
|
|
540
|
+
model,
|
|
541
|
+
...(query === undefined ? {} : { query }),
|
|
542
|
+
...(generatedAt === undefined ? {} : { generatedAt }),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function runRenderCommand(args: string[]): Promise<void> {
|
|
547
|
+
const parsed = parseRenderArgs(args);
|
|
548
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
549
|
+
if (!bindingResult.ok) printInvalid(bindingResult.errors);
|
|
550
|
+
const pack = await resolveCliPack(bindingResult.value);
|
|
551
|
+
const result = await renderPresentation({ ...parsed, pack });
|
|
552
|
+
printJson(result);
|
|
553
|
+
if (result.status === "failed") process.exit(1);
|
|
554
|
+
process.exit(0);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function runCaptureFromTurnCommand(args: string[]): Promise<void> {
|
|
558
|
+
// Read TurnContext from stdin
|
|
559
|
+
const stdin = await readStdin();
|
|
560
|
+
if (stdin.length === 0) {
|
|
561
|
+
printJson({ schema_version: 0, status: "invalid", errors: ["expected TurnContext JSON on stdin"] });
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let turnInput: unknown;
|
|
566
|
+
try {
|
|
567
|
+
turnInput = JSON.parse(stdin);
|
|
568
|
+
} catch {
|
|
569
|
+
printJson({ schema_version: 0, status: "invalid", errors: ["stdin must be valid JSON"] });
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
574
|
+
if (!bindingResult.ok) {
|
|
575
|
+
printJson({ schema_version: 0, status: "invalid", errors: bindingResult.errors, refresh: REFRESH_NOT_ATTEMPTED });
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
const binding = bindingResult.value;
|
|
579
|
+
|
|
580
|
+
// Find the designated extraction pack from the active space
|
|
581
|
+
const extractionPack = binding.packs.find((p) => p.extract === true);
|
|
582
|
+
if (extractionPack === undefined) {
|
|
583
|
+
printJson({ schema_version: 0, status: "invalid", errors: ["no extraction pack configured (no pack with extract: true)"] });
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Load the KnowledgeExtractor via the pack loader
|
|
588
|
+
const extractor = await loadExtractionPack(extractionPack.id, extractionPack.version, extractionPack.from, binding.bindingPath);
|
|
589
|
+
if (!extractor.ok) {
|
|
590
|
+
printJson({
|
|
591
|
+
schema_version: 0,
|
|
592
|
+
status: "invalid",
|
|
593
|
+
errors: extractor.errors.map((error) => `${error.code}: ${error.message}`),
|
|
594
|
+
});
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Build TurnContext from stdin
|
|
599
|
+
const raw = turnInput as Record<string, unknown>;
|
|
600
|
+
const turn: TurnContext = {
|
|
601
|
+
session: { id: String((raw.session as Record<string, unknown>)?.id ?? "unknown"), host: "engram-cli" },
|
|
602
|
+
turnIndex: typeof raw.turnIndex === "number" ? raw.turnIndex : 0,
|
|
603
|
+
timestamp: String(raw.timestamp ?? new Date().toISOString()),
|
|
604
|
+
narrative: String(raw.narrative ?? ""),
|
|
605
|
+
toolCalls: Array.isArray(raw.toolCalls) ? raw.toolCalls as TurnToolCall[] : [],
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const helpers: PackHelpers = {};
|
|
609
|
+
const candidates = await extractor.value.extractCandidates(turn, helpers);
|
|
610
|
+
|
|
611
|
+
if (candidates.length === 0) {
|
|
612
|
+
printJson({ schema_version: 0, status: "no_candidates" });
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Submit each candidate through the knowledge transaction pipeline
|
|
617
|
+
const results: Record<string, unknown>[] = [];
|
|
618
|
+
for (const candidate of candidates) {
|
|
619
|
+
const packRef = (candidate as Record<string, unknown>).pack as { id: string; version: string } | undefined;
|
|
620
|
+
if (packRef === undefined) {
|
|
621
|
+
results.push({ id: "(unknown)", status: "invalid", errors: ["candidate missing pack reference"] });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Resolve the KnowledgePack for validation through the binding's `from`
|
|
626
|
+
// module specifier alone. The external interface is documented in
|
|
627
|
+
// harness/docs/pack-interface.md. There is no bundled fallback.
|
|
628
|
+
const fromPack = binding.packs.find((p) => p.id === packRef.id);
|
|
629
|
+
const packResult = await resolveKnowledgePack(packRef.id, packRef.version, fromPack?.from, binding.bindingPath);
|
|
630
|
+
if (!packResult.ok) {
|
|
631
|
+
results.push({ id: packRef.id, status: "invalid", errors: packResult.errors.map((error) => `${error.code}: ${error.message}`) });
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const outcome = submitKnowledgeCandidate({ binding, candidateInput: candidate, pack: packResult.value });
|
|
636
|
+
results.push({ id: packRef.id, ...outcome });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
printJson({ schema_version: 0, status: "complete", results });
|
|
640
|
+
const hasErrors = results.some((r) => r.status === "invalid");
|
|
641
|
+
process.exit(hasErrors ? 1 : 0);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function runVersionCommand(): Promise<void> {
|
|
645
|
+
const packageManifestPath = fileURLToPath(new URL("../release-manifest.json", import.meta.url));
|
|
646
|
+
const releaseManifestPath = fileURLToPath(new URL("../../release-manifest.json", import.meta.url));
|
|
647
|
+
let manifest = await readReleaseManifest(packageManifestPath);
|
|
648
|
+
if (!manifest.ok) manifest = await readReleaseManifest(releaseManifestPath);
|
|
649
|
+
if (!manifest.ok) {
|
|
650
|
+
printJson({
|
|
651
|
+
schema_version: 0,
|
|
652
|
+
status: "invalid",
|
|
653
|
+
errors: [{ code: "release_manifest_invalid", message: "installed release manifest is unavailable or invalid" }],
|
|
654
|
+
});
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
printJson({
|
|
658
|
+
schema_version: 0,
|
|
659
|
+
status: "version",
|
|
660
|
+
release_id: manifest.value.version,
|
|
661
|
+
source_revision: manifest.value.source_revision,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function main(): Promise<void> {
|
|
666
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
667
|
+
if (command === "space") {
|
|
668
|
+
await runSpaceCommand(rest);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (command === "submit") {
|
|
672
|
+
await runSubmitCommand(rest);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (command === "knowledge") {
|
|
676
|
+
await runKnowledgeCommand(rest);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (command === "rollup") {
|
|
680
|
+
await runRollupCommand(rest);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (command === "recall") {
|
|
684
|
+
await runRecallCommand(rest);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (command === "render") {
|
|
688
|
+
await runRenderCommand(rest);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (command === "capture-from-turn") {
|
|
692
|
+
await runCaptureFromTurnCommand(rest);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (command === "version") {
|
|
696
|
+
await runVersionCommand();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
700
|
+
process.stdout.write(`${USAGE}\n`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
usageError(`unknown command: ${command ?? "(none)"}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
main().catch((error: unknown) => {
|
|
707
|
+
process.stderr.write(`unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
708
|
+
process.exit(1);
|
|
709
|
+
});
|