@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 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.1.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 { KnowledgePack, KnowledgeExtractor, PresentationPack, TurnContext, TurnToolCall, PackHelpers } from "./knowledgeTypes.ts";
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 query = input.pack.relatedQuery(candidateResult.value);
470
- if (typeof query !== "string" || query.trim().length === 0 || /[\r\n]/.test(query)) {
471
- return invalidOutcome([validationError("query_invalid", "pack retrieval query must be a non-empty single-line string")]);
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();
@@ -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
- relatedQuery: (envelope: KnowledgeEnvelope) => string;
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 (!validDetails) errors.push(error("details_invalid", "details must be a JSON object", "details"));
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.relatedQuery === "function" &&
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) &&