@isparling/engram-cli 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 +5 -0
- package/package.json +1 -1
- package/src/artifactReplacement.ts +208 -0
- package/src/captureTypes.ts +31 -0
- package/src/cli.ts +142 -2
- package/src/knowledgeListing.ts +80 -0
- package/src/knowledgeRetrieval.ts +43 -0
- package/src/knowledgeTransaction.ts +14 -5
- package/src/knowledgeTypes.ts +9 -1
- package/src/knowledgeValidation.ts +2 -19
- package/src/packLoader.ts +1 -1
package/README.md
CHANGED
|
@@ -19,12 +19,17 @@ engram rollup approve --bullets <path> --expect <rollup-hash>
|
|
|
19
19
|
engram space register --binding <path>
|
|
20
20
|
engram space select <space-id>
|
|
21
21
|
engram space status
|
|
22
|
+
engram space refresh
|
|
22
23
|
engram recall --query <text> --audience <id> [--source-class <class>]
|
|
23
24
|
engram render --view <id> --audience <id> --delivery <id> --model <provider/model>
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Run `engram --help` for current command usage.
|
|
27
28
|
|
|
29
|
+
`space refresh` runs the active space's qmd update through Engram's guarded
|
|
30
|
+
boundary: scoped config/cache variables, normalized `PWD`, config validation,
|
|
31
|
+
symlink checks, and registry freshness recording.
|
|
32
|
+
|
|
28
33
|
## Requirements
|
|
29
34
|
|
|
30
35
|
Requires [Bun](https://bun.sh) to run. Install:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isparling/engram-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The CLI binary backing the @isparling/engram-harness extension's knowledge submission, reconciliation, recall, and rendering pipeline.",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Artifact-root-confined atomic replacement: the write mechanic pack
|
|
2
|
+
// materializers use to regenerate compatibility views (prescription YAML,
|
|
3
|
+
// consultation logs, ...) from committed records.
|
|
4
|
+
//
|
|
5
|
+
// Authorization layering, in order:
|
|
6
|
+
//
|
|
7
|
+
// 1. The requested root is pack-selected configuration — it may name any
|
|
8
|
+
// directory — but it is only ever a candidate. The binding remains the
|
|
9
|
+
// final authorization boundary: the canonicalized root must sit inside an
|
|
10
|
+
// active `writeRoot` AND inside the space root, or the request is refused.
|
|
11
|
+
// 2. Every existing parent of the target is resolved with realpath and must
|
|
12
|
+
// stay inside the canonicalized root, so a symlinked directory cannot move
|
|
13
|
+
// the write outside even when its destination is itself inside the binding.
|
|
14
|
+
// 3. A symlinked target is refused outright rather than written through.
|
|
15
|
+
// 4. Current bytes are compared first; atomicWriteFile runs only on a real
|
|
16
|
+
// change, so repeated materialization is a byte-identical no-op.
|
|
17
|
+
|
|
18
|
+
import { lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
19
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { atomicWriteFile } from "./atomicWrite.ts";
|
|
21
|
+
import type { ActiveSpace } from "./spaceRegistry.ts";
|
|
22
|
+
import type { ArtifactReplacementResult } from "./captureTypes.ts";
|
|
23
|
+
import type { KnowledgeError, KnowledgeResult } from "./knowledgeTypes.ts";
|
|
24
|
+
|
|
25
|
+
export type ArtifactReplacementRequest = {
|
|
26
|
+
root: string;
|
|
27
|
+
relativePath: string;
|
|
28
|
+
content: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function artifactError(code: string, message: string, field?: string): KnowledgeError {
|
|
32
|
+
return field === undefined
|
|
33
|
+
? { kind: "artifact", code, message }
|
|
34
|
+
: { kind: "artifact", code, field, message };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isMissing(error: unknown): boolean {
|
|
38
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Same containment test spaceRegistry uses for binding validation: relative()
|
|
42
|
+
// based, so a candidate equals-or-under check cannot be fooled by ".." or
|
|
43
|
+
// absolute spellings.
|
|
44
|
+
function containsPath(root: string, candidate: string): boolean {
|
|
45
|
+
const pathFromRoot = relative(root, candidate);
|
|
46
|
+
return pathFromRoot === "" || (pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function replaceArtifact(
|
|
50
|
+
active: ActiveSpace,
|
|
51
|
+
request: ArtifactReplacementRequest,
|
|
52
|
+
): Promise<KnowledgeResult<ArtifactReplacementResult>> {
|
|
53
|
+
if (!isAbsolute(request.root)) {
|
|
54
|
+
return { ok: false, errors: [artifactError("artifact_root_invalid", "root must be an absolute path", "root")] };
|
|
55
|
+
}
|
|
56
|
+
const segments = request.relativePath.split("/");
|
|
57
|
+
if (
|
|
58
|
+
request.relativePath.length === 0 ||
|
|
59
|
+
request.relativePath.includes("\\") ||
|
|
60
|
+
request.relativePath.includes("\u0000") ||
|
|
61
|
+
segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")
|
|
62
|
+
) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
errors: [artifactError(
|
|
66
|
+
"relative_path_invalid",
|
|
67
|
+
"relativePath must be a normalized relative path without .., ., empty, backslash, or NUL segments",
|
|
68
|
+
"relativePath",
|
|
69
|
+
)],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Canonicalize the requested root before any comparison: every containment
|
|
74
|
+
// decision below compares real paths so a symlinked path component cannot
|
|
75
|
+
// make two locations that are the same directory compare unequal — or two
|
|
76
|
+
// different directories compare equal.
|
|
77
|
+
let rootReal: string;
|
|
78
|
+
try {
|
|
79
|
+
rootReal = await realpath(request.root);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
errors: [artifactError("artifact_root_unavailable", `root could not be resolved: ${error instanceof Error ? error.message : String(error)}`, "root")],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!(await stat(rootReal)).isDirectory()) {
|
|
87
|
+
return { ok: false, errors: [artifactError("artifact_root_invalid", "root must name a directory", "root")] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The requested root is configuration; the binding authorizes it.
|
|
91
|
+
if (!active.writeRoots.some((writeRoot) => containsPath(writeRoot, rootReal))) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
errors: [artifactError("root_not_writable", `requested artifact root is not inside an active write root: ${request.root}`, "root")],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!containsPath(active.spaceRoot, rootReal)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
errors: [artifactError("root_outside_space", `requested artifact root is outside the active space root: ${request.root}`, "root")],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve the deepest existing ancestor lexically, then realpath it: if any
|
|
105
|
+
// existing ancestor is a symlink, its resolved location must still be inside
|
|
106
|
+
// the canonicalized root. New (not-yet-existing) trailing components are
|
|
107
|
+
// appended under that resolved location.
|
|
108
|
+
const dirSegments = segments.slice(0, -1);
|
|
109
|
+
const fileName = requireSegment(segments, segments.length - 1);
|
|
110
|
+
let existingCount = 0;
|
|
111
|
+
let ancestor = rootReal;
|
|
112
|
+
while (existingCount < dirSegments.length) {
|
|
113
|
+
const next = join(ancestor, requireSegment(dirSegments, existingCount));
|
|
114
|
+
try {
|
|
115
|
+
await lstat(next);
|
|
116
|
+
} catch {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
ancestor = next;
|
|
120
|
+
existingCount++;
|
|
121
|
+
}
|
|
122
|
+
let ancestorReal: string;
|
|
123
|
+
try {
|
|
124
|
+
ancestorReal = await realpath(ancestor);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
errors: [artifactError("parent_resolution_failed", `parent directory could not be resolved: ${error instanceof Error ? error.message : String(error)}`, "relativePath")],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (!containsPath(rootReal, ancestorReal)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
errors: [artifactError("parent_escape", `a parent directory resolves outside the requested artifact root: ${request.root}/${request.relativePath}`, "relativePath")],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const targetPath = join(ancestorReal, ...dirSegments.slice(existingCount), fileName);
|
|
139
|
+
|
|
140
|
+
// Refuse a symlinked target outright: replacement means "these bytes at this
|
|
141
|
+
// path", never "follow this link and clobber whatever it names".
|
|
142
|
+
try {
|
|
143
|
+
const targetStat = await lstat(targetPath);
|
|
144
|
+
if (targetStat.isSymbolicLink()) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
errors: [artifactError("target_symlink", `target path is a symbolic link and will not be written through: ${request.relativePath}`, "relativePath")],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!isMissing(error)) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
errors: [artifactError("target_stat_failed", `target could not be inspected: ${error instanceof Error ? error.message : String(error)}`, "relativePath")],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let current: string | undefined;
|
|
160
|
+
try {
|
|
161
|
+
current = await readFile(targetPath, "utf8");
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!isMissing(error)) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
errors: [artifactError("current_read_failed", `current artifact bytes could not be read: ${error instanceof Error ? error.message : String(error)}`, "relativePath")],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (current === request.content) {
|
|
171
|
+
return { ok: true, value: { status: "unchanged", path: displayPath(request.root, segments) } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Materialized views live in nested paths that may not exist yet; the
|
|
175
|
+
// confinement checks above already validated every existing ancestor, so
|
|
176
|
+
// creating the remaining directories under the resolved location is safe.
|
|
177
|
+
try {
|
|
178
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
errors: [artifactError("parent_create_failed", `parent directory could not be created: ${error instanceof Error ? error.message : String(error)}`, "relativePath")],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await atomicWriteFile(targetPath, request.content);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
errors: [artifactError("artifact_write_failed", `atomic artifact write failed; previous bytes are untouched: ${error instanceof Error ? error.message : String(error)}`, "relativePath")],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, value: { status: "replaced", path: displayPath(request.root, segments) } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function requireSegment(segments: readonly string[], index: number): string {
|
|
198
|
+
const segment = segments[index];
|
|
199
|
+
if (segment === undefined) throw new Error(`internal invariant violated: segment ${index} must exist within relativePath`);
|
|
200
|
+
return segment;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The reported path mirrors the caller's spelling (requested root + relative
|
|
204
|
+
// path), not the internal resolved location: confinement decisions used real
|
|
205
|
+
// paths, but the caller asked about this path.
|
|
206
|
+
function displayPath(root: string, segments: readonly string[]): string {
|
|
207
|
+
return resolve(root, ...segments);
|
|
208
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Public host-capture DTOs shared between the OMP adapter and external packs.
|
|
2
|
+
// JSON-safe by construction: these shapes cross process boundaries verbatim,
|
|
3
|
+
// so they deliberately carry no functions, classes, or host handles.
|
|
4
|
+
|
|
5
|
+
import type { JsonObject, KnowledgeError, KnowledgeRecord } from "./knowledgeTypes.ts";
|
|
6
|
+
|
|
7
|
+
export type CaptureMutationView = {
|
|
8
|
+
recordId: string;
|
|
9
|
+
action: "create" | "update";
|
|
10
|
+
beforeHash: string | null;
|
|
11
|
+
after: KnowledgeRecord;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type HostCapturePreview =
|
|
15
|
+
| { schemaVersion: 0; status: "ready"; planHash: string; mutations: CaptureMutationView[] }
|
|
16
|
+
| { schemaVersion: 0; status: "blocked"; errors: KnowledgeError[] };
|
|
17
|
+
|
|
18
|
+
export type HostCaptureApply = {
|
|
19
|
+
schemaVersion: 0;
|
|
20
|
+
status: "committed" | "no-change" | "stale" | "failed";
|
|
21
|
+
planHash: string;
|
|
22
|
+
mutations: CaptureMutationView[];
|
|
23
|
+
index: "fresh" | "stale" | "not-attempted";
|
|
24
|
+
errors: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ArtifactReplacementResult = { status: "replaced" | "unchanged"; path: string };
|
|
28
|
+
|
|
29
|
+
export type CompletionRequest = { model: string; prompt: string; system: string; timeoutSeconds: number };
|
|
30
|
+
|
|
31
|
+
export type CaptureChangeSetInput = JsonObject;
|
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// engram space register --binding <path-to-local-binding.json>
|
|
12
12
|
// engram space select <space-id>
|
|
13
13
|
// engram space status
|
|
14
|
+
// engram space refresh
|
|
14
15
|
// engram recall --query <text> --audience <id>
|
|
15
16
|
// engram version
|
|
16
17
|
//
|
|
@@ -42,10 +43,10 @@ import {
|
|
|
42
43
|
type ActiveSpace,
|
|
43
44
|
} from "./spaceRegistry.ts";
|
|
44
45
|
import { submitCandidate, type SubmitOutcome } from "./submit.ts";
|
|
45
|
-
import { REFRESH_NOT_ATTEMPTED } from "./qmdRunner.ts";
|
|
46
|
+
import { refreshQmdCollection, REFRESH_NOT_ATTEMPTED } from "./qmdRunner.ts";
|
|
46
47
|
import { guardedRetrieve } from "./guardedRetrieval.ts";
|
|
47
48
|
import { renderPresentation } from "./presentation.ts";
|
|
48
|
-
import type
|
|
49
|
+
import { KNOWLEDGE_STATUSES, type KnowledgePack, type KnowledgeExtractor, type KnowledgeStatus, type PresentationPack, type TurnContext, type TurnToolCall, type PackHelpers } from "./knowledgeTypes.ts";
|
|
49
50
|
import { loadExtractionPack, resolveKnowledgePack } from "./packLoader.ts";
|
|
50
51
|
import { requireDefined } from "./types.ts";
|
|
51
52
|
import {
|
|
@@ -55,6 +56,8 @@ import {
|
|
|
55
56
|
type ApplyKnowledgeOutcome,
|
|
56
57
|
} from "./knowledgeTransaction.ts";
|
|
57
58
|
import { approveKnowledgeRollup, previewKnowledgeRollup, type KnowledgeRollupApplyOutcome } from "./knowledgeRollup.ts";
|
|
59
|
+
import { listKnowledgeRecords } from "./knowledgeListing.ts";
|
|
60
|
+
import { replaceArtifact } from "./artifactReplacement.ts";
|
|
58
61
|
import { readReleaseManifest } from "../release/engram-release.ts";
|
|
59
62
|
|
|
60
63
|
function printJson(value: unknown): void {
|
|
@@ -66,13 +69,16 @@ const USAGE = [
|
|
|
66
69
|
" engram knowledge submit --candidate <path>",
|
|
67
70
|
" engram knowledge reconcile --candidate <path>",
|
|
68
71
|
" engram knowledge approve|reject --candidate <path> --expect <plan_hash>",
|
|
72
|
+
" engram knowledge list --pack <id> --status <status> [--status <status> ...]",
|
|
69
73
|
" engram rollup preview --bullets <path>",
|
|
70
74
|
" engram rollup approve --bullets <path> --expect <rollup-hash>",
|
|
71
75
|
" engram space register --binding <path>",
|
|
72
76
|
" engram space select <space-id>",
|
|
73
77
|
" engram space status",
|
|
78
|
+
" engram space refresh",
|
|
74
79
|
" engram recall --query <text> --audience <id> [--source-class <class>]",
|
|
75
80
|
" engram render --view <id> --audience <id> --delivery <id> --model <provider/model> [--query <text>]",
|
|
81
|
+
" engram artifact replace --root <absolute-root> --relative <path> --input <file>",
|
|
76
82
|
].join("\n");
|
|
77
83
|
|
|
78
84
|
function usageError(message: string): never {
|
|
@@ -163,6 +169,23 @@ async function runSpaceCommand(args: string[]): Promise<void> {
|
|
|
163
169
|
printJson(result.value);
|
|
164
170
|
return;
|
|
165
171
|
}
|
|
172
|
+
if (subcommand === "refresh") {
|
|
173
|
+
if (rest.length !== 0) usageError("space refresh accepts no arguments");
|
|
174
|
+
const registry = registryPath();
|
|
175
|
+
const active = await resolveActiveSpace(process.env);
|
|
176
|
+
if (!active.ok) printInvalid(active.errors);
|
|
177
|
+
const refresh = await refreshQmdCollection(active.value);
|
|
178
|
+
const recorded = await recordQmdFreshness(registry, active.value.spaceId, refresh.state);
|
|
179
|
+
const output = {
|
|
180
|
+
schema_version: 0,
|
|
181
|
+
status: refresh.state === "fresh" ? "refreshed" : "index-stale",
|
|
182
|
+
refresh,
|
|
183
|
+
...(recorded.ok ? {} : { status_warnings: recorded.errors }),
|
|
184
|
+
};
|
|
185
|
+
printJson(output);
|
|
186
|
+
if (refresh.state !== "fresh") process.exit(1);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
166
189
|
usageError(`unknown space command: ${subcommand ?? "(none)"}`);
|
|
167
190
|
}
|
|
168
191
|
|
|
@@ -280,6 +303,31 @@ function knowledgeArgs(rest: string[]): { candidatePath: string; expectHash?: st
|
|
|
280
303
|
return { candidatePath, ...(expectHash === undefined ? {} : { expectHash }) };
|
|
281
304
|
}
|
|
282
305
|
|
|
306
|
+
function knowledgeListArgs(rest: string[]): { packId: string; statuses: KnowledgeStatus[] } {
|
|
307
|
+
let packId: string | undefined;
|
|
308
|
+
const statuses: KnowledgeStatus[] = [];
|
|
309
|
+
for (let index = 0; index < rest.length; index++) {
|
|
310
|
+
const arg = rest[index];
|
|
311
|
+
if (arg === "--pack") {
|
|
312
|
+
index++;
|
|
313
|
+
packId = rest[index];
|
|
314
|
+
if (packId === undefined) usageError("--pack requires a value");
|
|
315
|
+
} else if (arg === "--status") {
|
|
316
|
+
index++;
|
|
317
|
+
const rawStatus = rest[index];
|
|
318
|
+
if (rawStatus === undefined) usageError("--status requires a value");
|
|
319
|
+
const status = KNOWLEDGE_STATUSES.find((candidate) => candidate === rawStatus);
|
|
320
|
+
if (status === undefined) usageError(`--status must be one of ${KNOWLEDGE_STATUSES.join(", ")}`);
|
|
321
|
+
statuses.push(status);
|
|
322
|
+
} else {
|
|
323
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (packId === undefined) usageError("knowledge list requires --pack <id>");
|
|
327
|
+
if (statuses.length === 0) usageError("knowledge list requires at least one --status <status>");
|
|
328
|
+
return { packId, statuses };
|
|
329
|
+
}
|
|
330
|
+
|
|
283
331
|
function knowledgeExit(outcome: ApplyKnowledgeOutcome): never {
|
|
284
332
|
if (outcome.status === "committed" || outcome.status === "rejected" || outcome.status === "no_change") process.exit(0);
|
|
285
333
|
if (outcome.status === "stale_approval") process.exit(3);
|
|
@@ -287,8 +335,28 @@ function knowledgeExit(outcome: ApplyKnowledgeOutcome): never {
|
|
|
287
335
|
process.exit(1);
|
|
288
336
|
}
|
|
289
337
|
|
|
338
|
+
async function runKnowledgeListCommand(rest: string[]): Promise<void> {
|
|
339
|
+
const parsed = knowledgeListArgs(rest);
|
|
340
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
341
|
+
if (!bindingResult.ok) {
|
|
342
|
+
printJson({ schema_version: 0, status: "invalid", errors: bindingResult.errors });
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
const result = await listKnowledgeRecords(bindingResult.value, parsed);
|
|
346
|
+
if (!result.ok) {
|
|
347
|
+
printJson({ schema_version: 0, status: "invalid", errors: result.errors });
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
printJson({ schema_version: 0, status: "ok", records: result.value });
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
|
|
290
354
|
async function runKnowledgeCommand(args: string[]): Promise<void> {
|
|
291
355
|
const [subcommand, ...rest] = args;
|
|
356
|
+
if (subcommand === "list") {
|
|
357
|
+
await runKnowledgeListCommand(rest);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
292
360
|
if (subcommand !== "submit" && subcommand !== "reconcile" && subcommand !== "approve" && subcommand !== "reject") {
|
|
293
361
|
usageError(`unknown knowledge command: ${subcommand ?? "(none)"}`);
|
|
294
362
|
}
|
|
@@ -662,6 +730,74 @@ async function runVersionCommand(): Promise<void> {
|
|
|
662
730
|
});
|
|
663
731
|
}
|
|
664
732
|
|
|
733
|
+
function artifactReplaceArgs(rest: string[]): { root: string; relativePath: string; inputPath: string } {
|
|
734
|
+
let root: string | undefined;
|
|
735
|
+
let relativePath: string | undefined;
|
|
736
|
+
let inputPath: string | undefined;
|
|
737
|
+
for (let index = 0; index < rest.length; index++) {
|
|
738
|
+
const arg = rest[index];
|
|
739
|
+
if (arg === "--root") {
|
|
740
|
+
index++;
|
|
741
|
+
root = rest[index];
|
|
742
|
+
if (root === undefined) usageError("--root requires a value");
|
|
743
|
+
} else if (arg === "--relative") {
|
|
744
|
+
index++;
|
|
745
|
+
relativePath = rest[index];
|
|
746
|
+
if (relativePath === undefined) usageError("--relative requires a value");
|
|
747
|
+
} else if (arg === "--input") {
|
|
748
|
+
index++;
|
|
749
|
+
inputPath = rest[index];
|
|
750
|
+
if (inputPath === undefined) usageError("--input requires a value");
|
|
751
|
+
} else {
|
|
752
|
+
usageError(`unrecognized argument: ${arg}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (root === undefined) usageError("artifact replace requires --root <absolute-root>");
|
|
756
|
+
if (!isAbsolute(root)) usageError("--root must be an absolute path");
|
|
757
|
+
if (relativePath === undefined) usageError("artifact replace requires --relative <path>");
|
|
758
|
+
if (inputPath === undefined) usageError("artifact replace requires --input <file>");
|
|
759
|
+
return { root, relativePath, inputPath };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function runArtifactReplaceCommand(rest: string[]): Promise<void> {
|
|
763
|
+
const parsed = artifactReplaceArgs(rest);
|
|
764
|
+
const bindingResult = await resolveActiveSpace(process.env);
|
|
765
|
+
if (!bindingResult.ok) {
|
|
766
|
+
printJson({ schema_version: 0, status: "invalid", errors: bindingResult.errors });
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
let content: string;
|
|
770
|
+
try {
|
|
771
|
+
content = await readFile(parsed.inputPath, "utf8");
|
|
772
|
+
} catch (error) {
|
|
773
|
+
printJson({
|
|
774
|
+
schema_version: 0,
|
|
775
|
+
status: "invalid",
|
|
776
|
+
errors: [`failed to read --input file: ${error instanceof Error ? error.message : String(error)}`],
|
|
777
|
+
});
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
const result = await replaceArtifact(bindingResult.value, {
|
|
781
|
+
root: parsed.root,
|
|
782
|
+
relativePath: parsed.relativePath,
|
|
783
|
+
content,
|
|
784
|
+
});
|
|
785
|
+
if (!result.ok) {
|
|
786
|
+
printJson({ schema_version: 0, status: "invalid", errors: result.errors });
|
|
787
|
+
process.exit(1);
|
|
788
|
+
}
|
|
789
|
+
printJson({ schema_version: 0, status: result.value.status, path: result.value.path });
|
|
790
|
+
process.exit(0);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function runArtifactCommand(args: string[]): Promise<void> {
|
|
794
|
+
const [subcommand, ...rest] = args;
|
|
795
|
+
if (subcommand !== "replace") {
|
|
796
|
+
usageError(`unknown artifact command: ${subcommand ?? "(none)"}`);
|
|
797
|
+
}
|
|
798
|
+
await runArtifactReplaceCommand(rest);
|
|
799
|
+
}
|
|
800
|
+
|
|
665
801
|
async function main(): Promise<void> {
|
|
666
802
|
const [command, ...rest] = process.argv.slice(2);
|
|
667
803
|
if (command === "space") {
|
|
@@ -692,6 +828,10 @@ async function main(): Promise<void> {
|
|
|
692
828
|
await runCaptureFromTurnCommand(rest);
|
|
693
829
|
return;
|
|
694
830
|
}
|
|
831
|
+
if (command === "artifact") {
|
|
832
|
+
await runArtifactCommand(rest);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
695
835
|
if (command === "version") {
|
|
696
836
|
await runVersionCommand();
|
|
697
837
|
return;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Guarded record listing: a host mechanic for capture materialization that
|
|
2
|
+
// enumerates the active space's records root and filters exactly, in memory,
|
|
3
|
+
// by pack id and status.
|
|
4
|
+
//
|
|
5
|
+
// Two invariants shape this module:
|
|
6
|
+
//
|
|
7
|
+
// 1. The filesystem guard is shared, not reimplemented. Enumeration goes
|
|
8
|
+
// through retrieveEnumeratedRecords — the same candidate-guard sequence
|
|
9
|
+
// (`safeRelativeMarkdownPath` containment + realpath checks) guarded
|
|
10
|
+
// search uses — so a symlink escaping the records root FAILS the listing
|
|
11
|
+
// with a path_escape error rather than being silently skipped or followed.
|
|
12
|
+
// No qmd process is ever spawned: enumeration reads the records root
|
|
13
|
+
// directly.
|
|
14
|
+
// 2. No caller-supplied filesystem root exists in the interface. The only
|
|
15
|
+
// root involved is the active space's bound recordsRoot; the filter is
|
|
16
|
+
// pack id + statuses, both evaluated exactly against parsed records.
|
|
17
|
+
|
|
18
|
+
import { retrieveEnumeratedRecords, type GuardedRetrievalFilter } from "./knowledgeRetrieval.ts";
|
|
19
|
+
import type { ActiveSpace } from "./spaceRegistry.ts";
|
|
20
|
+
import type { KnowledgeError, KnowledgeRecord, KnowledgeResult } from "./knowledgeTypes.ts";
|
|
21
|
+
|
|
22
|
+
export type KnowledgeListingFilter = {
|
|
23
|
+
packId: string;
|
|
24
|
+
statuses: readonly KnowledgeRecord["status"][];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The listing is a host mechanic, not an audience-scoped view: materialization
|
|
28
|
+
// needs every record of a pack regardless of which audience could see it. The
|
|
29
|
+
// retrieval filter below is therefore a permissive host policy whose only real
|
|
30
|
+
// work is routing every record through the shared containment guards; source
|
|
31
|
+
// classification collapses to one host class so no record is dropped for
|
|
32
|
+
// having a class the pack's presentation policy would withhold.
|
|
33
|
+
const HOST_SOURCE_CLASS = "record";
|
|
34
|
+
|
|
35
|
+
function hostListingRetrievalFilter(): GuardedRetrievalFilter {
|
|
36
|
+
return {
|
|
37
|
+
audienceId: "host-record-listing",
|
|
38
|
+
requestedSourceClasses: [HOST_SOURCE_CLASS],
|
|
39
|
+
allowedSourceClasses: [HOST_SOURCE_CLASS],
|
|
40
|
+
includePresentations: false,
|
|
41
|
+
relevanceThreshold: null,
|
|
42
|
+
classifySource: () => HOST_SOURCE_CLASS,
|
|
43
|
+
isEligible: () => true,
|
|
44
|
+
authorize: () => true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listingError(code: string, message: string, field?: string): KnowledgeError {
|
|
49
|
+
return field === undefined
|
|
50
|
+
? { kind: "validation", code, message }
|
|
51
|
+
: { kind: "validation", code, field, message };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listKnowledgeRecords(
|
|
55
|
+
active: ActiveSpace,
|
|
56
|
+
filter: KnowledgeListingFilter,
|
|
57
|
+
): Promise<KnowledgeResult<KnowledgeRecord[]>> {
|
|
58
|
+
if (typeof filter.packId !== "string" || filter.packId.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
errors: [listingError("listing_filter_invalid", "packId must be a non-empty string", "packId")],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(filter.statuses) || filter.statuses.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
errors: [listingError("listing_filter_invalid", "statuses must be a non-empty array", "statuses")],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const outcome = await retrieveEnumeratedRecords(active, hostListingRetrievalFilter());
|
|
72
|
+
if (outcome.kind === "failure") return { ok: false, errors: outcome.errors };
|
|
73
|
+
|
|
74
|
+
const records = outcome.records
|
|
75
|
+
.map((related) => related.record)
|
|
76
|
+
.filter((record) => record.pack.id === filter.packId && filter.statuses.includes(record.status))
|
|
77
|
+
// Deterministic ordering by record id regardless of directory-read order.
|
|
78
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
79
|
+
return { ok: true, value: records };
|
|
80
|
+
}
|
|
@@ -472,6 +472,49 @@ export async function retrieveEnumeratedRecords(
|
|
|
472
472
|
};
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
// Builds the receipt for an exact related-record enumeration. Enumeration
|
|
476
|
+
// never ranks and runs no query, so `query` stays null and
|
|
477
|
+
// `relevanceThreshold` stays null regardless of any pack-declared search
|
|
478
|
+
// threshold; locators inherit the deterministic filename order
|
|
479
|
+
// `enumerateCandidates` produced, and only the matched record IDs appear.
|
|
480
|
+
function receiptForEnumeratedRecords(
|
|
481
|
+
binding: ActiveSpace,
|
|
482
|
+
matched: readonly RelatedKnowledgeRecord[],
|
|
483
|
+
withheldCount: number,
|
|
484
|
+
): RetrievalReceipt {
|
|
485
|
+
const base = emptyReceipt(binding, null, matched.length === 0 ? "miss" : "hit", "space");
|
|
486
|
+
return withheldReceipt(
|
|
487
|
+
{ ...base, locatorUris: matched.map((item) => item.sourceUri), recordIds: matched.map((item) => item.record.id) },
|
|
488
|
+
withheldCount,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Exact related-record discovery for pack reconciliation: enumerates the
|
|
493
|
+
// active records root directly (zero qmd invocations — see
|
|
494
|
+
// `enumerateCandidates`), passes every candidate through the SAME
|
|
495
|
+
// containment/parser guard sequence a search hit would face
|
|
496
|
+
// (`filterCandidates` with no policy filter and no score threshold, because
|
|
497
|
+
// enumeration never ranks), and only then applies the pack-owned predicate
|
|
498
|
+
// to fully parsed records. A symlink escaping the root fails closed here
|
|
499
|
+
// exactly as it does for search: the predicate can never observe an
|
|
500
|
+
// uncontained or unparseable locator.
|
|
501
|
+
export async function retrieveExactRelatedRecords(
|
|
502
|
+
binding: ActiveSpace,
|
|
503
|
+
matches: (record: KnowledgeRecord) => boolean,
|
|
504
|
+
): Promise<RetrievalOutcome> {
|
|
505
|
+
const enumerated = await enumerateCandidates(binding);
|
|
506
|
+
if (enumerated.kind === "failure") {
|
|
507
|
+
return { kind: "failure", errors: [enumerated.error], receipt: emptyReceipt(binding, null, "miss", "space") };
|
|
508
|
+
}
|
|
509
|
+
const guarded = await filterCandidates(binding, enumerated.candidates, undefined, false);
|
|
510
|
+
if (guarded.kind === "failure") {
|
|
511
|
+
return { kind: "failure", errors: [guarded.error], receipt: emptyReceipt(binding, null, "miss", "space") };
|
|
512
|
+
}
|
|
513
|
+
const matched = guarded.records.filter((item) => matches(item.record));
|
|
514
|
+
const receipt = receiptForEnumeratedRecords(binding, matched, guarded.withheldCount);
|
|
515
|
+
return matched.length === 0 ? { kind: "miss", records: [], receipt } : { kind: "hit", records: matched, receipt };
|
|
516
|
+
}
|
|
517
|
+
|
|
475
518
|
async function retrieveRecords(
|
|
476
519
|
binding: ActiveSpace,
|
|
477
520
|
query: string,
|
|
@@ -4,7 +4,7 @@ import { relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { atomicWriteFile, AtomicWriteDirectorySyncError } from "./atomicWrite.ts";
|
|
5
5
|
import { canonicalJson, hashKnowledgeText, parseKnowledgeRecord, serializeKnowledgeRecord } from "./knowledgeRecord.ts";
|
|
6
6
|
import { validateKnowledgeEnvelope } from "./knowledgeValidation.ts";
|
|
7
|
-
import { retrieveRelatedRecords, type RetrievalReceipt } from "./knowledgeRetrieval.ts";
|
|
7
|
+
import { retrieveExactRelatedRecords, retrieveRelatedRecords, type RetrievalOutcome, type RetrievalReceipt } from "./knowledgeRetrieval.ts";
|
|
8
8
|
import { acquireTransactionLock, transactionLockDirectory, type TransactionLock, type TransactionLockHooks } from "./transactionLock.ts";
|
|
9
9
|
import { REFRESH_NOT_ATTEMPTED, refreshQmdCollection, type AttemptedRefreshReport, type RefreshReport, type SpawnFn } from "./qmdRunner.ts";
|
|
10
10
|
import { resolveRecordPath } from "./spaceBinding.ts";
|
|
@@ -466,11 +466,20 @@ export async function reconcileKnowledgeTransaction(input: {
|
|
|
466
466
|
}): Promise<ReconcileOutcome> {
|
|
467
467
|
const candidateResult = prepareCandidate(input.binding, input.candidateInput, input.pack);
|
|
468
468
|
if (!candidateResult.ok) return invalidOutcome(candidateResult.errors);
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
const selection = input.pack.selectRelatedRecords(candidateResult.value);
|
|
470
|
+
let retrieval: RetrievalOutcome;
|
|
471
|
+
if (selection.mode === "search") {
|
|
472
|
+
const query = selection.query;
|
|
473
|
+
if (typeof query !== "string" || query.trim().length === 0 || /[\r\n]/.test(query)) {
|
|
474
|
+
return invalidOutcome([validationError("query_invalid", "pack retrieval query must be a non-empty single-line string")]);
|
|
475
|
+
}
|
|
476
|
+
retrieval = await retrieveRelatedRecords(input.binding, query, input.spawnFn);
|
|
477
|
+
} else {
|
|
478
|
+
// Exact mode changes related-record discovery only: qmd is never invoked
|
|
479
|
+
// and the authoritative re-read plus beforeHash capture below still apply
|
|
480
|
+
// to every returned record.
|
|
481
|
+
retrieval = await retrieveExactRelatedRecords(input.binding, selection.matches);
|
|
472
482
|
}
|
|
473
|
-
const retrieval = await retrieveRelatedRecords(input.binding, query, input.spawnFn);
|
|
474
483
|
if (retrieval.kind === "failure") return { schema_version: 0, status: "retrieval_failed", errors: retrieval.errors, retrieval: retrieval.receipt };
|
|
475
484
|
const relatedRecords = retrieval.kind === "hit" ? retrieval.records : [];
|
|
476
485
|
if (input.afterRetrieval !== undefined) await input.afterRetrieval();
|
package/src/knowledgeTypes.ts
CHANGED
|
@@ -121,11 +121,19 @@ export type PackReconciliation = {
|
|
|
121
121
|
mutations: PackMutation[];
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
export type RelatedRecordSelection =
|
|
125
|
+
| { mode: "search"; query: string }
|
|
126
|
+
| {
|
|
127
|
+
mode: "exact";
|
|
128
|
+
description: string;
|
|
129
|
+
matches: (record: KnowledgeRecord) => boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
124
132
|
export type KnowledgePack = {
|
|
125
133
|
id: string;
|
|
126
134
|
version: string;
|
|
127
135
|
validateEnvelope: (envelope: KnowledgeEnvelope) => KnowledgeResult<void>;
|
|
128
|
-
|
|
136
|
+
selectRelatedRecords: (envelope: KnowledgeEnvelope) => RelatedRecordSelection;
|
|
129
137
|
reconcile: (input: PackReconcileInput) => KnowledgeResult<PackReconciliation>;
|
|
130
138
|
};
|
|
131
139
|
|
|
@@ -41,20 +41,6 @@ function isJsonValue(value: unknown): value is JsonValue {
|
|
|
41
41
|
return Object.values(value).every((item) => isJsonValue(item));
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function findNewline(value: JsonValue, field: string, errors: KnowledgeError[]): void {
|
|
45
|
-
if (typeof value === "string") {
|
|
46
|
-
if (/[\r\n]/.test(value)) errors.push(error("newline_forbidden", `${field} must not contain newlines`, field));
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (Array.isArray(value)) {
|
|
50
|
-
value.forEach((item, index) => findNewline(item, `${field}[${index}]`, errors));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (value !== null && typeof value === "object") {
|
|
54
|
-
for (const [key, item] of Object.entries(value)) findNewline(item, `${field}.${key}`, errors);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
44
|
function nonEmptySingleLine(value: unknown, field: string, errors: KnowledgeError[], pattern?: RegExp): string | undefined {
|
|
59
45
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
60
46
|
errors.push(error("field_invalid", `${field} must be a non-empty string`, field));
|
|
@@ -212,11 +198,8 @@ export function validateKnowledgeEnvelope(raw: unknown): KnowledgeResult<Knowled
|
|
|
212
198
|
if (!isJsonValue(item)) validDetails = false;
|
|
213
199
|
else parsedDetails[key] = item;
|
|
214
200
|
}
|
|
215
|
-
if (
|
|
216
|
-
else
|
|
217
|
-
details = parsedDetails;
|
|
218
|
-
findNewline(details, "details", errors);
|
|
219
|
-
}
|
|
201
|
+
if (validDetails) details = parsedDetails;
|
|
202
|
+
else errors.push(error("details_invalid", "details must be a JSON object", "details"));
|
|
220
203
|
}
|
|
221
204
|
|
|
222
205
|
const scope = parseScope(raw.scope, errors);
|
package/src/packLoader.ts
CHANGED
|
@@ -92,7 +92,7 @@ function isKnowledgePack(value: unknown): value is KnowledgePack & PresentationP
|
|
|
92
92
|
typeof value.id === "string" &&
|
|
93
93
|
typeof value.version === "string" &&
|
|
94
94
|
typeof value.validateEnvelope === "function" &&
|
|
95
|
-
typeof value.
|
|
95
|
+
typeof value.selectRelatedRecords === "function" &&
|
|
96
96
|
typeof value.reconcile === "function" &&
|
|
97
97
|
isSourceClassPolicy(value.retrievalPolicy) &&
|
|
98
98
|
Array.isArray(value.views) && value.views.every(isView) &&
|