@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Content hashing used to detect record changes across preview, approval, and
|
|
2
|
+
// commit.
|
|
3
|
+
//
|
|
4
|
+
// Binds an `approval_required` result to the exact MUTATION the caller saw,
|
|
5
|
+
// not merely to the record it started from.
|
|
6
|
+
//
|
|
7
|
+
// Hashing the record alone is insufficient and was a real defect: it detects
|
|
8
|
+
// that the record changed under the caller, but it does not detect that the
|
|
9
|
+
// caller approved a DIFFERENT mutation. With a record-only hash, previewing
|
|
10
|
+
// candidate A against an unchanged record and then approving candidate B
|
|
11
|
+
// with A's hash commits B — a diff the caller never saw. The submission date
|
|
12
|
+
// has the same effect, since it changes the plan without touching the record.
|
|
13
|
+
//
|
|
14
|
+
// `hashPlan` therefore covers both endpoints of the diff: the exact bytes
|
|
15
|
+
// read and the exact bytes that would be written.
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
export function hashRecordText(text: string): string {
|
|
20
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hashes the complete mutation: where it lands, the record as read, and the
|
|
25
|
+
* text that would replace it. Any change to any of the three — a different
|
|
26
|
+
* destination, a modified record, a different candidate, or a submission date
|
|
27
|
+
* that alters the result — produces a different hash, so an approval cannot
|
|
28
|
+
* carry across to another mutation.
|
|
29
|
+
*
|
|
30
|
+
* The destination is included because before/after text alone is not a unique
|
|
31
|
+
* mutation: two spaces holding byte-identical records make the same textual
|
|
32
|
+
* transition, so a hash previewed in one space would otherwise approve the
|
|
33
|
+
* commit in another after a binding change.
|
|
34
|
+
*
|
|
35
|
+
* The record is hashed as raw bytes rather than decoded text. Decoding first
|
|
36
|
+
* maps every invalid UTF-8 sequence to U+FFFD, so distinct byte-level edits
|
|
37
|
+
* would collide and an intervening change could commit instead of being
|
|
38
|
+
* refused as stale.
|
|
39
|
+
*
|
|
40
|
+
* Components are length-prefixed so no triple can collide with a different
|
|
41
|
+
* triple through concatenation.
|
|
42
|
+
*/
|
|
43
|
+
export function hashPlan(recordPath: string, recordBytes: Buffer, afterText: string): string {
|
|
44
|
+
const after = Buffer.from(afterText, "utf8");
|
|
45
|
+
const destination = Buffer.from(recordPath, "utf8");
|
|
46
|
+
return createHash("sha256")
|
|
47
|
+
.update(`${destination.length}\n`)
|
|
48
|
+
.update(destination)
|
|
49
|
+
.update(`\n${recordBytes.length}\n`)
|
|
50
|
+
.update(recordBytes)
|
|
51
|
+
.update(`\n${after.length}\n`)
|
|
52
|
+
.update(after)
|
|
53
|
+
.digest("hex");
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Shared deep-freeze. Two call sites (knowledgeRetrieval.ts and
|
|
2
|
+
// presentation.ts) used to carry verbatim copies of this without a seen-set,
|
|
3
|
+
// so a self-referential object graph would recurse forever. Not reachable
|
|
4
|
+
// from parseKnowledgeRecord output today, but worth one implementation with
|
|
5
|
+
// cycle protection before anything else starts feeding it.
|
|
6
|
+
export function deepFreeze<T>(value: T, seen: WeakSet<object> = new WeakSet()): T {
|
|
7
|
+
if (typeof value !== "object" || value === null) return value;
|
|
8
|
+
if (seen.has(value)) return value;
|
|
9
|
+
seen.add(value);
|
|
10
|
+
if (!Object.isFrozen(value)) Object.freeze(value);
|
|
11
|
+
for (const child of Object.values(value)) deepFreeze(child, seen);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Structural diff helpers for comparing knowledge records before they are
|
|
2
|
+
// written.
|
|
3
|
+
//
|
|
4
|
+
// Small hand-written line-based diff (classic LCS dynamic program). Record
|
|
5
|
+
// files are tiny (tens of lines), so the O(n*m) table is not a concern.
|
|
6
|
+
// Renders the complete before/after text as a unified-style diff — this is
|
|
7
|
+
// the "render the complete diff" step, used both to gate approval and to
|
|
8
|
+
// show the reviewer exactly what a write will do.
|
|
9
|
+
|
|
10
|
+
import { requireDefined } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export type DiffOp = { type: "equal" | "add" | "remove"; line: string };
|
|
13
|
+
|
|
14
|
+
/** Reads the LCS table at [i][j]. Every call site keeps i, j within
|
|
15
|
+
* [0, n] / [0, m] by construction, so an out-of-range read here would
|
|
16
|
+
* indicate a bug in the loop bounds, not a legitimate "no value" case —
|
|
17
|
+
* hence the thrown default instead of a silent `?? 0`. */
|
|
18
|
+
function lcsAt(table: number[][], i: number, j: number): number {
|
|
19
|
+
const row = requireDefined(table[i], `lcs table row ${i} out of range`);
|
|
20
|
+
return requireDefined(row[j], `lcs table cell [${i}][${j}] out of range`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lineAt(lines: string[], i: number): string {
|
|
24
|
+
return requireDefined(lines[i], `line index ${i} out of range`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function diffLines(before: string[], after: string[]): DiffOp[] {
|
|
28
|
+
const n = before.length;
|
|
29
|
+
const m = after.length;
|
|
30
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
31
|
+
|
|
32
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
33
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
34
|
+
const row = requireDefined(lcs[i], `lcs table row ${i} out of range`);
|
|
35
|
+
if (before[i] === after[j]) {
|
|
36
|
+
row[j] = lcsAt(lcs, i + 1, j + 1) + 1;
|
|
37
|
+
} else {
|
|
38
|
+
row[j] = Math.max(lcsAt(lcs, i + 1, j), lcsAt(lcs, i, j + 1));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ops: DiffOp[] = [];
|
|
44
|
+
let i = 0;
|
|
45
|
+
let j = 0;
|
|
46
|
+
while (i < n && j < m) {
|
|
47
|
+
if (before[i] === after[j]) {
|
|
48
|
+
ops.push({ type: "equal", line: lineAt(before, i) });
|
|
49
|
+
i++;
|
|
50
|
+
j++;
|
|
51
|
+
} else if (lcsAt(lcs, i + 1, j) >= lcsAt(lcs, i, j + 1)) {
|
|
52
|
+
ops.push({ type: "remove", line: lineAt(before, i) });
|
|
53
|
+
i++;
|
|
54
|
+
} else {
|
|
55
|
+
ops.push({ type: "add", line: lineAt(after, j) });
|
|
56
|
+
j++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
while (i < n) {
|
|
60
|
+
ops.push({ type: "remove", line: lineAt(before, i) });
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
while (j < m) {
|
|
64
|
+
ops.push({ type: "add", line: lineAt(after, j) });
|
|
65
|
+
j++;
|
|
66
|
+
}
|
|
67
|
+
return ops;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderUnifiedDiff(recordId: string, beforeText: string, afterText: string): string {
|
|
71
|
+
const before = beforeText.split("\n");
|
|
72
|
+
const after = afterText.split("\n");
|
|
73
|
+
const ops = diffLines(before, after);
|
|
74
|
+
|
|
75
|
+
const lines: string[] = [`--- ${recordId} (before)`, `+++ ${recordId} (after)`];
|
|
76
|
+
for (const op of ops) {
|
|
77
|
+
const prefix = op.type === "equal" ? " " : op.type === "add" ? "+" : "-";
|
|
78
|
+
lines.push(`${prefix}${op.line}`);
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { guardedRetrieveInActiveSpace, receiptFor } from "./guardedRetrievalInternal.ts";
|
|
2
|
+
import type { RetrievalReceipt } from "./knowledgeRetrieval.ts";
|
|
3
|
+
import type { SpawnFn } from "./qmdRunner.ts";
|
|
4
|
+
import { resolveActiveSpace, type ActiveSpace } from "./spaceRegistry.ts";
|
|
5
|
+
import type { EnvLike, Result } from "./types.ts";
|
|
6
|
+
import type {
|
|
7
|
+
KnowledgeError,
|
|
8
|
+
KnowledgeRecord,
|
|
9
|
+
PresentationPack,
|
|
10
|
+
} from "./knowledgeTypes.ts";
|
|
11
|
+
|
|
12
|
+
export type GuardedRetrievalRequest = {
|
|
13
|
+
query: string;
|
|
14
|
+
audienceId: string;
|
|
15
|
+
pack: PresentationPack;
|
|
16
|
+
viewId?: string;
|
|
17
|
+
requestedSourceClasses?: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A space-scoped view's retrieval: enumerates every eligible record under
|
|
21
|
+
// the active space's records root instead of running a ranked qmd search.
|
|
22
|
+
// No `query` field — an enumerating view accepts no caller-supplied query
|
|
23
|
+
// at all (see presentation.ts's `query_not_scoped` refusal).
|
|
24
|
+
export type GuardedEnumerationRequest = {
|
|
25
|
+
audienceId: string;
|
|
26
|
+
pack: PresentationPack;
|
|
27
|
+
viewId?: string;
|
|
28
|
+
requestedSourceClasses?: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type GuardedRetrievalRecord = {
|
|
32
|
+
record: KnowledgeRecord;
|
|
33
|
+
relativePath: string;
|
|
34
|
+
sourceUri: string;
|
|
35
|
+
sourceClasses: string[];
|
|
36
|
+
score?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type GuardedRetrievalHit = {
|
|
40
|
+
schema_version: 0;
|
|
41
|
+
status: "hit";
|
|
42
|
+
records: GuardedRetrievalRecord[];
|
|
43
|
+
receipt: RetrievalReceipt;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type GuardedRetrievalMiss = {
|
|
47
|
+
schema_version: 0;
|
|
48
|
+
status: "miss";
|
|
49
|
+
records: [];
|
|
50
|
+
receipt: RetrievalReceipt;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type GuardedRetrievalFailure = {
|
|
54
|
+
schema_version: 0;
|
|
55
|
+
status: "failed";
|
|
56
|
+
errors: KnowledgeError[];
|
|
57
|
+
receipt: RetrievalReceipt;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type GuardedRetrievalOutcome = GuardedRetrievalHit | GuardedRetrievalMiss | GuardedRetrievalFailure;
|
|
61
|
+
|
|
62
|
+
export type GuardedRetrievalOptions = {
|
|
63
|
+
env?: EnvLike;
|
|
64
|
+
spawnFn?: SpawnFn;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A receipt for the "no active space" failure: guardedRetrieve cannot name
|
|
68
|
+
// a space it never resolved, so this is a distinct type rather than a
|
|
69
|
+
// fabricated ActiveSpace fed through the ordinary receipt shape.
|
|
70
|
+
export type UnresolvedRetrievalReceipt = Omit<RetrievalReceipt, "activeSpace"> & { activeSpace: null };
|
|
71
|
+
|
|
72
|
+
export type GuardedRetrievalUnresolvedFailure = {
|
|
73
|
+
schema_version: 0;
|
|
74
|
+
status: "failed";
|
|
75
|
+
errors: KnowledgeError[];
|
|
76
|
+
receipt: UnresolvedRetrievalReceipt;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mirrors the defensiveness already applied to `request.query` below: this
|
|
84
|
+
// runs before any pack validation, so `request.pack` cannot be trusted to
|
|
85
|
+
// match its declared type at runtime.
|
|
86
|
+
function allowedSourceClassesOf(pack: unknown): readonly string[] {
|
|
87
|
+
if (!isObject(pack)) return [];
|
|
88
|
+
const policy = pack.retrievalPolicy;
|
|
89
|
+
if (!isObject(policy)) return [];
|
|
90
|
+
const allowed = policy.allowedSourceClasses;
|
|
91
|
+
if (!Array.isArray(allowed)) return [];
|
|
92
|
+
return allowed.filter((entry): entry is string => typeof entry === "string");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function guardedRetrieve(
|
|
96
|
+
request: GuardedRetrievalRequest,
|
|
97
|
+
options: GuardedRetrievalOptions = {},
|
|
98
|
+
): Promise<GuardedRetrievalOutcome | GuardedRetrievalUnresolvedFailure> {
|
|
99
|
+
const env = options.env ?? process.env;
|
|
100
|
+
const activeResult: Result<ActiveSpace> = await resolveActiveSpace(env);
|
|
101
|
+
if (!activeResult.ok) {
|
|
102
|
+
return {
|
|
103
|
+
schema_version: 0,
|
|
104
|
+
status: "failed",
|
|
105
|
+
errors: activeResult.errors.map((message) => ({ kind: "retrieval", code: "active_space_unresolved", message })),
|
|
106
|
+
receipt: receiptFor(
|
|
107
|
+
null,
|
|
108
|
+
typeof request.query === "string" ? request.query : null,
|
|
109
|
+
"search",
|
|
110
|
+
request.requestedSourceClasses === undefined ? [] : request.requestedSourceClasses,
|
|
111
|
+
allowedSourceClassesOf(request.pack),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return guardedRetrieveInActiveSpace(activeResult.value, request, options);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isGuardedRetrievalHit(result: GuardedRetrievalOutcome): result is GuardedRetrievalHit {
|
|
119
|
+
return result.status === "hit";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isGuardedRetrievalMiss(result: GuardedRetrievalOutcome): result is GuardedRetrievalMiss {
|
|
123
|
+
return result.status === "miss";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isGuardedRetrievalFailure(result: GuardedRetrievalOutcome): result is GuardedRetrievalFailure {
|
|
127
|
+
return result.status === "failed";
|
|
128
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
retrieveEnumeratedRecords,
|
|
3
|
+
retrieveGuardedRecords,
|
|
4
|
+
type GuardedRetrievalFilter,
|
|
5
|
+
type RetrievalOutcome,
|
|
6
|
+
type RetrievalReceipt,
|
|
7
|
+
} from "./knowledgeRetrieval.ts";
|
|
8
|
+
import type { ActiveSpace } from "./spaceRegistry.ts";
|
|
9
|
+
import type {
|
|
10
|
+
GuardedEnumerationRequest,
|
|
11
|
+
GuardedRetrievalFailure,
|
|
12
|
+
GuardedRetrievalOptions,
|
|
13
|
+
GuardedRetrievalOutcome,
|
|
14
|
+
GuardedRetrievalRecord,
|
|
15
|
+
GuardedRetrievalRequest,
|
|
16
|
+
UnresolvedRetrievalReceipt,
|
|
17
|
+
} from "./guardedRetrieval.ts";
|
|
18
|
+
import type { KnowledgeError, PresentationPack } from "./knowledgeTypes.ts";
|
|
19
|
+
|
|
20
|
+
function retrievalError(code: string, message: string, field?: string): KnowledgeError {
|
|
21
|
+
return field === undefined
|
|
22
|
+
? { kind: "retrieval", code, message }
|
|
23
|
+
: { kind: "retrieval", code, field, message };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function receiptFor(
|
|
27
|
+
active: ActiveSpace,
|
|
28
|
+
query: string | null,
|
|
29
|
+
scope: "search" | "space",
|
|
30
|
+
requestedSourceClasses: readonly string[],
|
|
31
|
+
allowedSourceClasses: readonly string[],
|
|
32
|
+
): RetrievalReceipt;
|
|
33
|
+
export function receiptFor(
|
|
34
|
+
active: null,
|
|
35
|
+
query: string | null,
|
|
36
|
+
scope: "search" | "space",
|
|
37
|
+
requestedSourceClasses: readonly string[],
|
|
38
|
+
allowedSourceClasses: readonly string[],
|
|
39
|
+
): UnresolvedRetrievalReceipt;
|
|
40
|
+
export function receiptFor(
|
|
41
|
+
active: ActiveSpace | null,
|
|
42
|
+
query: string | null,
|
|
43
|
+
scope: "search" | "space",
|
|
44
|
+
requestedSourceClasses: readonly string[],
|
|
45
|
+
allowedSourceClasses: readonly string[],
|
|
46
|
+
): RetrievalReceipt | UnresolvedRetrievalReceipt {
|
|
47
|
+
return {
|
|
48
|
+
schemaVersion: 0,
|
|
49
|
+
scope,
|
|
50
|
+
query,
|
|
51
|
+
activeSpace: active === null ? null : active.spaceId,
|
|
52
|
+
collection: active === null ? "" : active.qmdCollectionName,
|
|
53
|
+
requestedSourceClasses: [...requestedSourceClasses],
|
|
54
|
+
allowedSourceClasses: [...allowedSourceClasses],
|
|
55
|
+
kind: "miss",
|
|
56
|
+
locatorUris: [],
|
|
57
|
+
recordIds: [],
|
|
58
|
+
exposedResults: [],
|
|
59
|
+
// No filtering — let alone ranking — has run yet at any of this
|
|
60
|
+
// function's call sites (pack validation, installation, query shape,
|
|
61
|
+
// audience lookup, source-class authorization, or query-strategy
|
|
62
|
+
// failures, plus the "no active space" case handled by guardedRetrieve
|
|
63
|
+
// before any of the above even runs), so there is no threshold to report
|
|
64
|
+
// and no withholding to disclose.
|
|
65
|
+
relevanceThreshold: null,
|
|
66
|
+
withheld: { audienceId: null, count: 0 },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function failure(
|
|
71
|
+
active: ActiveSpace,
|
|
72
|
+
query: string | null,
|
|
73
|
+
scope: "search" | "space",
|
|
74
|
+
requestedSourceClasses: readonly string[],
|
|
75
|
+
allowedSourceClasses: readonly string[],
|
|
76
|
+
errors: KnowledgeError[],
|
|
77
|
+
): GuardedRetrievalFailure {
|
|
78
|
+
return {
|
|
79
|
+
schema_version: 0,
|
|
80
|
+
status: "failed",
|
|
81
|
+
errors,
|
|
82
|
+
receipt: receiptFor(active, query, scope, requestedSourceClasses, allowedSourceClasses),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function activePackInstalled(active: ActiveSpace, pack: PresentationPack): boolean {
|
|
87
|
+
return active.packs.some((installed) => installed.id === pack.id && installed.version === pack.version);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueStrings(values: readonly string[]): string[] {
|
|
91
|
+
return [...new Set(values)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
99
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? [...value] : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function functionValue(value: unknown): Function | undefined {
|
|
103
|
+
return typeof value === "function" ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type PolicySnapshot = {
|
|
107
|
+
allowedSourceClasses: string[];
|
|
108
|
+
queryStrategy: PresentationPack["retrievalPolicy"]["queryStrategy"];
|
|
109
|
+
classifySource: PresentationPack["retrievalPolicy"]["classifySource"];
|
|
110
|
+
relevanceThreshold: number | null;
|
|
111
|
+
isEligible: PresentationPack["retrievalPolicy"]["isEligible"];
|
|
112
|
+
includePresentations: unknown;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function snapshotPolicy(pack: unknown): { ok: true; value: PolicySnapshot } | { ok: false; allowedSourceClasses: string[]; errors: KnowledgeError[] } {
|
|
116
|
+
if (!isObject(pack) || !isObject(pack.retrievalPolicy)) {
|
|
117
|
+
return { ok: false, allowedSourceClasses: [], errors: [retrievalError("policy_shape_invalid", "retrieval policy must be an object", "pack")] };
|
|
118
|
+
}
|
|
119
|
+
const policy = pack.retrievalPolicy;
|
|
120
|
+
const allowedSourceClasses = stringArray(policy.allowedSourceClasses);
|
|
121
|
+
if (allowedSourceClasses === undefined) {
|
|
122
|
+
return { ok: false, allowedSourceClasses: [], errors: [retrievalError("policy_shape_invalid", "retrieval policy allowedSourceClasses must be an array of strings", "pack")] };
|
|
123
|
+
}
|
|
124
|
+
const queryStrategy = functionValue(policy.queryStrategy);
|
|
125
|
+
const classifySource = functionValue(policy.classifySource);
|
|
126
|
+
const isEligible = functionValue(policy.isEligible);
|
|
127
|
+
if (queryStrategy === undefined || classifySource === undefined || isEligible === undefined) {
|
|
128
|
+
return { ok: false, allowedSourceClasses, errors: [retrievalError("policy_shape_invalid", "retrieval policy callbacks must be functions", "pack")] };
|
|
129
|
+
}
|
|
130
|
+
if (policy.includePresentations !== false) {
|
|
131
|
+
return { ok: true, value: {
|
|
132
|
+
allowedSourceClasses,
|
|
133
|
+
queryStrategy: (input) => Reflect.apply(queryStrategy, undefined, [input]),
|
|
134
|
+
classifySource: (source) => Reflect.apply(classifySource, undefined, [source]),
|
|
135
|
+
relevanceThreshold: typeof policy.relevanceThreshold === "number" || policy.relevanceThreshold === null ? policy.relevanceThreshold : Number.NaN,
|
|
136
|
+
isEligible: (record) => Reflect.apply(isEligible, undefined, [record]),
|
|
137
|
+
includePresentations: policy.includePresentations,
|
|
138
|
+
} };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, value: {
|
|
141
|
+
allowedSourceClasses,
|
|
142
|
+
queryStrategy: (input) => Reflect.apply(queryStrategy, undefined, [input]),
|
|
143
|
+
classifySource: (source) => Reflect.apply(classifySource, undefined, [source]),
|
|
144
|
+
relevanceThreshold: typeof policy.relevanceThreshold === "number" || policy.relevanceThreshold === null ? policy.relevanceThreshold : Number.NaN,
|
|
145
|
+
isEligible: (record) => Reflect.apply(isEligible, undefined, [record]),
|
|
146
|
+
includePresentations: false,
|
|
147
|
+
} };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validatePolicy(policy: PolicySnapshot): KnowledgeError[] {
|
|
151
|
+
const errors: KnowledgeError[] = [];
|
|
152
|
+
if (policy.includePresentations !== false) {
|
|
153
|
+
errors.push(retrievalError("policy_presentations_included", "retrieval policy must exclude presentation artifacts"));
|
|
154
|
+
}
|
|
155
|
+
if (policy.allowedSourceClasses.length === 0) {
|
|
156
|
+
errors.push(retrievalError("policy_source_classes_empty", "retrieval policy must declare at least one allowed source class"));
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
policy.relevanceThreshold !== null &&
|
|
160
|
+
(!Number.isFinite(policy.relevanceThreshold) || policy.relevanceThreshold < 0)
|
|
161
|
+
) {
|
|
162
|
+
errors.push(retrievalError("policy_relevance_threshold_invalid", "retrieval policy relevance threshold must be null or a non-negative finite number"));
|
|
163
|
+
}
|
|
164
|
+
return errors;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// "search" runs the pack's declared query strategy over a ranked qmd search;
|
|
168
|
+
// "space" enumerates every Markdown record under the active space's records
|
|
169
|
+
// root and runs no qmd process. Everything in `performRetrieval` below this
|
|
170
|
+
// point — policy validation, pack installation, audience lookup, source-class
|
|
171
|
+
// authorization — applies identically to both; only query construction and
|
|
172
|
+
// the final retrieval call differ, so neither mode can silently skip a guard
|
|
173
|
+
// the other one runs.
|
|
174
|
+
type RetrievalMode =
|
|
175
|
+
| { kind: "search"; query: string }
|
|
176
|
+
| { kind: "space" };
|
|
177
|
+
|
|
178
|
+
async function performRetrieval(
|
|
179
|
+
active: ActiveSpace,
|
|
180
|
+
mode: RetrievalMode,
|
|
181
|
+
request: GuardedEnumerationRequest,
|
|
182
|
+
options: GuardedRetrievalOptions,
|
|
183
|
+
): Promise<GuardedRetrievalOutcome> {
|
|
184
|
+
const policySnapshot = snapshotPolicy(request.pack);
|
|
185
|
+
const baseQuery: string | null = mode.kind === "search" ? mode.query : null;
|
|
186
|
+
const scope = mode.kind;
|
|
187
|
+
const initialAllowedSourceClasses = policySnapshot.ok ? policySnapshot.value.allowedSourceClasses : policySnapshot.allowedSourceClasses;
|
|
188
|
+
const requestedSourceClasses = request.requestedSourceClasses === undefined
|
|
189
|
+
? [...initialAllowedSourceClasses]
|
|
190
|
+
: uniqueStrings(request.requestedSourceClasses);
|
|
191
|
+
if (!policySnapshot.ok) {
|
|
192
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policySnapshot.allowedSourceClasses, policySnapshot.errors);
|
|
193
|
+
}
|
|
194
|
+
const policy = policySnapshot.value;
|
|
195
|
+
|
|
196
|
+
const packErrors = validatePolicy(policy);
|
|
197
|
+
if (packErrors.length > 0) {
|
|
198
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, packErrors);
|
|
199
|
+
}
|
|
200
|
+
if (!activePackInstalled(active, request.pack)) {
|
|
201
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
202
|
+
"pack_not_installed",
|
|
203
|
+
`presentation pack ${request.pack.id}@${request.pack.version} is not installed in the active space`,
|
|
204
|
+
"pack",
|
|
205
|
+
)]);
|
|
206
|
+
}
|
|
207
|
+
if (mode.kind === "search" && (typeof mode.query !== "string" || mode.query.trim().length === 0 || mode.query.includes("\u0000"))) {
|
|
208
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
209
|
+
"query_invalid",
|
|
210
|
+
"retrieval query must be a non-empty string without NUL bytes",
|
|
211
|
+
"query",
|
|
212
|
+
)]);
|
|
213
|
+
}
|
|
214
|
+
const audience = request.pack.audiences.find((candidate) => candidate.id === request.audienceId);
|
|
215
|
+
if (audience === undefined) {
|
|
216
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
217
|
+
"audience_unknown",
|
|
218
|
+
`audience ${request.audienceId} is not configured by the presentation pack`,
|
|
219
|
+
"audience",
|
|
220
|
+
)]);
|
|
221
|
+
}
|
|
222
|
+
const audienceSnapshot = Object.freeze({ id: audience.id, authorize: audience.authorize });
|
|
223
|
+
const disallowedRequestedClass = requestedSourceClasses.find(
|
|
224
|
+
(sourceClass) => !policy.allowedSourceClasses.includes(sourceClass),
|
|
225
|
+
);
|
|
226
|
+
if (disallowedRequestedClass !== undefined) {
|
|
227
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
228
|
+
"source_class_not_allowed",
|
|
229
|
+
`requested source class ${disallowedRequestedClass} is not allowed by the retrieval policy`,
|
|
230
|
+
"requestedSourceClasses",
|
|
231
|
+
)]);
|
|
232
|
+
}
|
|
233
|
+
if (requestedSourceClasses.length === 0) {
|
|
234
|
+
return {
|
|
235
|
+
schema_version: 0,
|
|
236
|
+
status: "miss",
|
|
237
|
+
records: [],
|
|
238
|
+
receipt: receiptFor(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const filter: GuardedRetrievalFilter = {
|
|
243
|
+
audienceId: audienceSnapshot.id,
|
|
244
|
+
requestedSourceClasses,
|
|
245
|
+
allowedSourceClasses: policy.allowedSourceClasses,
|
|
246
|
+
includePresentations: false,
|
|
247
|
+
relevanceThreshold: policy.relevanceThreshold,
|
|
248
|
+
classifySource: policy.classifySource,
|
|
249
|
+
isEligible: policy.isEligible,
|
|
250
|
+
authorize: audienceSnapshot.authorize,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
let retrieval: RetrievalOutcome;
|
|
254
|
+
if (mode.kind === "search") {
|
|
255
|
+
let query: string;
|
|
256
|
+
try {
|
|
257
|
+
query = policy.queryStrategy({
|
|
258
|
+
query: mode.query,
|
|
259
|
+
...(request.viewId === undefined ? {} : { viewId: request.viewId }),
|
|
260
|
+
requestedSourceClasses,
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
264
|
+
"query_strategy_failed",
|
|
265
|
+
`retrieval query strategy failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
266
|
+
)]);
|
|
267
|
+
}
|
|
268
|
+
if (typeof query !== "string" || query.trim().length === 0 || query.includes("\u0000")) {
|
|
269
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
270
|
+
"query_strategy_invalid",
|
|
271
|
+
"retrieval query strategy returned an empty or unsafe query",
|
|
272
|
+
)]);
|
|
273
|
+
}
|
|
274
|
+
retrieval = await retrieveGuardedRecords(active, query, filter, options.spawnFn);
|
|
275
|
+
} else {
|
|
276
|
+
retrieval = await retrieveEnumeratedRecords(active, filter);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (retrieval.kind === "failure") return { schema_version: 0, status: "failed", errors: retrieval.errors, receipt: retrieval.receipt };
|
|
280
|
+
if (retrieval.kind === "miss") return { schema_version: 0, status: "miss", records: [], receipt: retrieval.receipt };
|
|
281
|
+
|
|
282
|
+
const records: GuardedRetrievalRecord[] = [];
|
|
283
|
+
for (const related of retrieval.records) {
|
|
284
|
+
const exposed = retrieval.receipt.exposedResults.find((candidate) => candidate.recordId === related.record.id);
|
|
285
|
+
if (exposed === undefined) {
|
|
286
|
+
return failure(active, baseQuery, scope, requestedSourceClasses, policy.allowedSourceClasses, [retrievalError(
|
|
287
|
+
"receipt_incomplete",
|
|
288
|
+
"retrieval receipt did not contain a reference for every exposed record",
|
|
289
|
+
)]);
|
|
290
|
+
}
|
|
291
|
+
records.push({
|
|
292
|
+
record: related.record,
|
|
293
|
+
relativePath: related.relativePath,
|
|
294
|
+
sourceUri: related.sourceUri,
|
|
295
|
+
sourceClasses: [...exposed.sourceClasses],
|
|
296
|
+
...(exposed.score === undefined ? {} : { score: exposed.score }),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return { schema_version: 0, status: "hit", records, receipt: retrieval.receipt };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function guardedRetrieveInActiveSpace(
|
|
303
|
+
active: ActiveSpace,
|
|
304
|
+
request: GuardedRetrievalRequest,
|
|
305
|
+
options: GuardedRetrievalOptions = {},
|
|
306
|
+
): Promise<GuardedRetrievalOutcome> {
|
|
307
|
+
return performRetrieval(active, { kind: "search", query: request.query }, request, options);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The space-scoped counterpart used only by `renderPresentation` when a
|
|
311
|
+
// view's scope is "space": no query is ever constructed or validated, and
|
|
312
|
+
// the final retrieval call enumerates the records root instead of running a
|
|
313
|
+
// qmd search. Every other guard above is shared, unchanged, with
|
|
314
|
+
// `guardedRetrieveInActiveSpace`.
|
|
315
|
+
export async function guardedEnumerateInActiveSpace(
|
|
316
|
+
active: ActiveSpace,
|
|
317
|
+
request: GuardedEnumerationRequest,
|
|
318
|
+
options: GuardedRetrievalOptions = {},
|
|
319
|
+
): Promise<GuardedRetrievalOutcome> {
|
|
320
|
+
return performRetrieval(active, { kind: "space" }, request, options);
|
|
321
|
+
}
|