@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,673 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, relative, resolve, sep, join } from "node:path";
|
|
4
|
+
import { type GuardedRetrievalOutcome, type GuardedRetrievalRecord } from "./guardedRetrieval.ts";
|
|
5
|
+
import { guardedEnumerateInActiveSpace, guardedRetrieveInActiveSpace } from "./guardedRetrievalInternal.ts";
|
|
6
|
+
import type { SpawnFn } from "./qmdRunner.ts";
|
|
7
|
+
import { resolveActiveSpace, type ActiveSpace } from "./spaceRegistry.ts";
|
|
8
|
+
import type { EnvLike } from "./types.ts";
|
|
9
|
+
import type {
|
|
10
|
+
AudienceDefinition,
|
|
11
|
+
DeliveryDefinition,
|
|
12
|
+
KnowledgeError,
|
|
13
|
+
KnowledgeRecord,
|
|
14
|
+
PresentationDraft,
|
|
15
|
+
PresentationPack,
|
|
16
|
+
SemanticProjection,
|
|
17
|
+
ViewDefinition,
|
|
18
|
+
} from "./knowledgeTypes.ts";
|
|
19
|
+
import type { RetrievalReceipt } from "./knowledgeRetrieval.ts";
|
|
20
|
+
import { canonicalJson } from "./knowledgeRecord.ts";
|
|
21
|
+
import { deepFreeze } from "./deepFreeze.ts";
|
|
22
|
+
|
|
23
|
+
export type { PresentationPack } from "./knowledgeTypes.ts";
|
|
24
|
+
|
|
25
|
+
export type PresentationRequest = {
|
|
26
|
+
viewId: string;
|
|
27
|
+
audienceId: string;
|
|
28
|
+
deliveryId: string;
|
|
29
|
+
model: string;
|
|
30
|
+
pack: PresentationPack;
|
|
31
|
+
query?: string;
|
|
32
|
+
generatedAt?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type PresentationReceipt = {
|
|
36
|
+
schemaVersion: 0;
|
|
37
|
+
presentationId: string;
|
|
38
|
+
activeSpace: string;
|
|
39
|
+
viewId: string;
|
|
40
|
+
viewVersion: number;
|
|
41
|
+
audienceId: string;
|
|
42
|
+
audienceVersion: number;
|
|
43
|
+
deliveryId: string;
|
|
44
|
+
deliveryVersion: number;
|
|
45
|
+
sourceRecordIds: string[];
|
|
46
|
+
sourceReferences: Array<{ recordId: string; sourceUri: string; relativePath: string }>;
|
|
47
|
+
retrievalReceipt: RetrievalReceipt;
|
|
48
|
+
pack: { id: string; version: string };
|
|
49
|
+
model: string;
|
|
50
|
+
recommendationIds: string[];
|
|
51
|
+
generatedAt: string;
|
|
52
|
+
retention?: { artifactReference: string; receiptReference: string };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RetainedPresentation = {
|
|
56
|
+
artifactPath: string;
|
|
57
|
+
receiptPath: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type PresentationSuccess = {
|
|
61
|
+
schema_version: 0;
|
|
62
|
+
status: "presented";
|
|
63
|
+
content: string;
|
|
64
|
+
receipt: PresentationReceipt;
|
|
65
|
+
retained?: RetainedPresentation;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type PresentationFailure = {
|
|
69
|
+
schema_version: 0;
|
|
70
|
+
status: "failed";
|
|
71
|
+
errors: KnowledgeError[];
|
|
72
|
+
retrieval?: RetrievalReceipt;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type PresentationOutcome = PresentationSuccess | PresentationFailure;
|
|
76
|
+
|
|
77
|
+
export type PresentationOptions = {
|
|
78
|
+
env?: EnvLike;
|
|
79
|
+
spawnFn?: SpawnFn;
|
|
80
|
+
writeRetentionFile?: (path: string, content: string) => Promise<void>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function presentationError(code: string, message: string, field?: string, kind: KnowledgeError["kind"] = "presentation"): KnowledgeError {
|
|
84
|
+
return field === undefined
|
|
85
|
+
? { kind, code, message }
|
|
86
|
+
: { kind, code, field, message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function failure(errors: KnowledgeError[], retrieval?: RetrievalReceipt): PresentationFailure {
|
|
90
|
+
return {
|
|
91
|
+
schema_version: 0,
|
|
92
|
+
status: "failed",
|
|
93
|
+
errors,
|
|
94
|
+
...(retrieval === undefined ? {} : { retrieval }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isInstalled(active: ActiveSpace, pack: PresentationPack): boolean {
|
|
99
|
+
return active.packs.some((candidate) => candidate.id === pack.id && candidate.version === pack.version);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findView(pack: PresentationPack, id: string): ViewDefinition | undefined {
|
|
103
|
+
return pack.views.find((candidate) => candidate.id === id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function findAudience(pack: PresentationPack, id: string): AudienceDefinition | undefined {
|
|
107
|
+
return pack.audiences.find((candidate) => candidate.id === id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findDelivery(pack: PresentationPack, id: string): DeliveryDefinition | undefined {
|
|
111
|
+
return pack.deliveries.find((candidate) => candidate.id === id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function textErrors(value: unknown, field: string, invalidCode = "text_type_invalid"): KnowledgeError[] {
|
|
115
|
+
if (typeof value !== "string") return [presentationError(invalidCode, `${field} must be a string`, field)];
|
|
116
|
+
if (value.trim().length === 0) return [presentationError("text_empty", `${field} must not be empty`, field)];
|
|
117
|
+
if (value.includes("\n") || value.includes("\r")) return [presentationError("text_multiline", `${field} must be a single line`, field)];
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function listErrors(values: unknown, field: string, invalidCode = "text_type_invalid"): KnowledgeError[] {
|
|
122
|
+
if (!Array.isArray(values)) return [presentationError(invalidCode, `${field} must be an array of strings`, field)];
|
|
123
|
+
const errors: KnowledgeError[] = [];
|
|
124
|
+
for (const value of values) errors.push(...textErrors(value, field, invalidCode));
|
|
125
|
+
return errors;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateProjection(projection: SemanticProjection, records: readonly GuardedRetrievalRecord[]): KnowledgeError[] {
|
|
129
|
+
const errors: KnowledgeError[] = [
|
|
130
|
+
...textErrors(projection.title, "view.title", "view_projection_invalid"),
|
|
131
|
+
...textErrors(projection.summary, "view.summary", "view_projection_invalid"),
|
|
132
|
+
...listErrors(projection.facts, "view.facts", "view_projection_invalid"),
|
|
133
|
+
...listErrors(projection.requiredFacts, "view.requiredFacts", "view_projection_invalid"),
|
|
134
|
+
...listErrors(projection.uncertainty, "view.uncertainty", "view_projection_invalid"),
|
|
135
|
+
...listErrors(projection.actions, "view.actions", "view_projection_invalid"),
|
|
136
|
+
...listErrors(projection.recommendationIds, "view.recommendationIds", "view_projection_invalid"),
|
|
137
|
+
];
|
|
138
|
+
for (const fact of projection.requiredFacts) {
|
|
139
|
+
if (!projection.facts.includes(fact)) errors.push(presentationError("required_fact_missing", "view required facts must be included in the view facts"));
|
|
140
|
+
}
|
|
141
|
+
errors.push(...validateRecommendationIds(records, projection.recommendationIds));
|
|
142
|
+
return errors;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateDraft(draft: PresentationDraft): KnowledgeError[] {
|
|
146
|
+
return [
|
|
147
|
+
...textErrors(draft.title, "audience.title", "audience_adaptation_invalid"),
|
|
148
|
+
...textErrors(draft.summary, "audience.summary", "audience_adaptation_invalid"),
|
|
149
|
+
...listErrors(draft.facts, "audience.facts", "audience_adaptation_invalid"),
|
|
150
|
+
...listErrors(draft.uncertainty, "audience.uncertainty", "audience_adaptation_invalid"),
|
|
151
|
+
...listErrors(draft.actions, "audience.actions", "audience_adaptation_invalid"),
|
|
152
|
+
...listErrors(draft.recommendationIds, "audience.recommendationIds", "audience_adaptation_invalid"),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameSet(left: readonly string[], right: readonly string[]): boolean {
|
|
157
|
+
if (left.length !== right.length) return false;
|
|
158
|
+
const rightSet = new Set(right);
|
|
159
|
+
return left.every((value) => rightSet.has(value));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function subsetOf(values: readonly string[], allowed: readonly string[]): boolean {
|
|
163
|
+
return values.every((value) => allowed.includes(value));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderContent(draft: PresentationDraft, delivery: DeliveryDefinition): string {
|
|
167
|
+
if (delivery.format === "json") {
|
|
168
|
+
return JSON.stringify({
|
|
169
|
+
title: draft.title,
|
|
170
|
+
summary: draft.summary,
|
|
171
|
+
facts: draft.facts,
|
|
172
|
+
uncertainty: draft.uncertainty,
|
|
173
|
+
actions: draft.actions,
|
|
174
|
+
}, null, 2) + "\n";
|
|
175
|
+
}
|
|
176
|
+
if (delivery.format === "plain") {
|
|
177
|
+
return [
|
|
178
|
+
draft.title,
|
|
179
|
+
draft.summary,
|
|
180
|
+
`Facts: ${draft.facts.join("; ")}`,
|
|
181
|
+
`Uncertainty: ${draft.uncertainty.join("; ")}`,
|
|
182
|
+
`Actions: ${draft.actions.length === 0 ? "None recorded." : draft.actions.join("; ")}`,
|
|
183
|
+
"",
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
return [
|
|
187
|
+
`# ${draft.title}`,
|
|
188
|
+
"",
|
|
189
|
+
draft.summary,
|
|
190
|
+
"",
|
|
191
|
+
"## Facts",
|
|
192
|
+
...draft.facts.map((fact) => `- ${fact}`),
|
|
193
|
+
"",
|
|
194
|
+
"## Uncertainty",
|
|
195
|
+
...draft.uncertainty.map((item) => `- ${item}`),
|
|
196
|
+
"",
|
|
197
|
+
"## Actions",
|
|
198
|
+
...(draft.actions.length === 0 ? ["- None recorded."] : draft.actions.map((action) => `- ${action}`)),
|
|
199
|
+
"",
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function wordCount(content: string): number {
|
|
204
|
+
const trimmed = content.trim();
|
|
205
|
+
return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pathWithin(root: string, candidate: string): boolean {
|
|
209
|
+
const pathFromRoot = relative(root, candidate);
|
|
210
|
+
return pathFromRoot === "" || (pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`) && !pathFromRoot.startsWith(sep));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Pack callbacks are handed configuration and hand back a draft. Both cross the
|
|
214
|
+
// boundary as live object references, so every guard that reads them must read
|
|
215
|
+
// a copy taken before the callback ran, not the object the callback still holds.
|
|
216
|
+
// Freezing alone is not enough: a frozen object with accessor properties still
|
|
217
|
+
// answers differently on each read, and freezing the caller's object does not
|
|
218
|
+
// stop it substituting a different one. These take one value per field, once.
|
|
219
|
+
//
|
|
220
|
+
// Without this, `renderPresentation` evaluated the retention boundary before
|
|
221
|
+
// `adapt` and the word limit after it, so an adapter that flipped `retain` and
|
|
222
|
+
// raised `maxWords` reached retention with the write-boundary check never
|
|
223
|
+
// having run.
|
|
224
|
+
|
|
225
|
+
function snapshotDelivery(delivery: DeliveryDefinition): DeliveryDefinition {
|
|
226
|
+
return Object.freeze({
|
|
227
|
+
id: delivery.id,
|
|
228
|
+
version: delivery.version,
|
|
229
|
+
format: delivery.format,
|
|
230
|
+
maxWords: delivery.maxWords,
|
|
231
|
+
retain: delivery.retain,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function snapshotProjection(projection: SemanticProjection): SemanticProjection {
|
|
236
|
+
return deepFreeze({
|
|
237
|
+
title: projection.title,
|
|
238
|
+
summary: projection.summary,
|
|
239
|
+
facts: [...projection.facts],
|
|
240
|
+
requiredFacts: [...projection.requiredFacts],
|
|
241
|
+
uncertainty: [...projection.uncertainty],
|
|
242
|
+
actions: [...projection.actions],
|
|
243
|
+
recommendationIds: [...projection.recommendationIds],
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function snapshotDraft(draft: PresentationDraft): PresentationDraft {
|
|
248
|
+
return deepFreeze({
|
|
249
|
+
title: draft.title,
|
|
250
|
+
summary: draft.summary,
|
|
251
|
+
facts: [...draft.facts],
|
|
252
|
+
uncertainty: [...draft.uncertainty],
|
|
253
|
+
actions: [...draft.actions],
|
|
254
|
+
recommendationIds: [...draft.recommendationIds],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve `path` through any symlinks on the part of it that exists, keeping
|
|
260
|
+
* the not-yet-created tail. `retain` creates its own root, so a plain realpath
|
|
261
|
+
* fails on first use and a plain `resolve` follows nothing — and a symlink at
|
|
262
|
+
* `<spaceRoot>/.engram-presentations` is exactly how a retained presentation
|
|
263
|
+
* ends up inside the authoritative records root. Every other containment check
|
|
264
|
+
* in this harness resolves; this one must too.
|
|
265
|
+
*/
|
|
266
|
+
async function resolveIntendedPath(path: string): Promise<string> {
|
|
267
|
+
const tail: string[] = [];
|
|
268
|
+
let current = resolve(path);
|
|
269
|
+
for (;;) {
|
|
270
|
+
try {
|
|
271
|
+
const existing = await realpath(current);
|
|
272
|
+
return tail.length === 0 ? existing : join(existing, ...tail);
|
|
273
|
+
} catch {
|
|
274
|
+
const parent = dirname(current);
|
|
275
|
+
if (parent === current) return resolve(path);
|
|
276
|
+
tail.unshift(basename(current));
|
|
277
|
+
current = parent;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function retentionPaths(active: ActiveSpace, presentationId: string): { root: string; directory: string; artifact: string; receipt: string; artifactReference: string; receiptReference: string } {
|
|
283
|
+
const root = resolve(active.spaceRoot, ".engram-presentations");
|
|
284
|
+
const directory = join(root, presentationId);
|
|
285
|
+
return {
|
|
286
|
+
root,
|
|
287
|
+
directory,
|
|
288
|
+
artifact: join(directory, "presentation.md"),
|
|
289
|
+
receipt: join(directory, "receipt.json"),
|
|
290
|
+
artifactReference: `.engram-presentations/${presentationId}/presentation.md`,
|
|
291
|
+
receiptReference: `.engram-presentations/${presentationId}/receipt.json`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type RetentionBoundary =
|
|
296
|
+
| { ok: true; resolvedRoot: string }
|
|
297
|
+
| { ok: false; error: KnowledgeError };
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Decide whether this space may retain a presentation at all, against the
|
|
301
|
+
* paths that will actually be written rather than the paths as written down.
|
|
302
|
+
* Returns the resolved root so `retain` can confirm the directory it creates
|
|
303
|
+
* is still the one that was authorized.
|
|
304
|
+
*/
|
|
305
|
+
async function retentionBoundary(active: ActiveSpace, delivery: DeliveryDefinition): Promise<RetentionBoundary> {
|
|
306
|
+
const declaredRoot = retentionPaths(active, "placeholder").root;
|
|
307
|
+
const resolvedRoot = await resolveIntendedPath(declaredRoot);
|
|
308
|
+
const recordsRoot = await resolveIntendedPath(active.recordsRoot);
|
|
309
|
+
const where = `${declaredRoot} resolves to ${resolvedRoot}`;
|
|
310
|
+
if (pathWithin(recordsRoot, resolvedRoot) || pathWithin(resolvedRoot, recordsRoot)) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: presentationError(
|
|
314
|
+
"artifact_root_not_segregated",
|
|
315
|
+
`presentation retention root must be structurally outside the records root: ${where}`,
|
|
316
|
+
"delivery",
|
|
317
|
+
"artifact",
|
|
318
|
+
),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const writeRoots = await Promise.all(active.writeRoots.map((candidate) => resolveIntendedPath(candidate)));
|
|
322
|
+
if (!writeRoots.some((candidate) => pathWithin(candidate, resolvedRoot))) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: presentationError(
|
|
326
|
+
"artifact_root_not_writable",
|
|
327
|
+
`presentation retention root is not authorized by the active write boundary: ${where}`,
|
|
328
|
+
"delivery",
|
|
329
|
+
"artifact",
|
|
330
|
+
),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return { ok: true, resolvedRoot };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validateGeneratedAt(generatedAt: string): boolean {
|
|
337
|
+
return !Number.isNaN(Date.parse(generatedAt)) && generatedAt.includes("T");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function referencesFor(records: readonly GuardedRetrievalRecord[]): Array<{ recordId: string; sourceUri: string; relativePath: string }> {
|
|
341
|
+
return records
|
|
342
|
+
.map((record) => ({ recordId: record.record.id, sourceUri: record.sourceUri, relativePath: record.relativePath }))
|
|
343
|
+
.sort((left, right) => left.recordId.localeCompare(right.recordId));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function recordsForView(records: readonly GuardedRetrievalRecord[]): readonly KnowledgeRecord[] {
|
|
347
|
+
return deepFreeze(records.map((record) => record.record));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function validateRecommendationIds(
|
|
351
|
+
records: readonly GuardedRetrievalRecord[],
|
|
352
|
+
recommendationIds: readonly string[],
|
|
353
|
+
): KnowledgeError[] {
|
|
354
|
+
const errors: KnowledgeError[] = [];
|
|
355
|
+
for (const id of recommendationIds) {
|
|
356
|
+
const record = records.find((candidate) => candidate.record.id === id)?.record;
|
|
357
|
+
if (record === undefined || record.kind !== "recommendation" || record.status !== "active") {
|
|
358
|
+
errors.push(presentationError("recommendation_invalid", `recommendation ${id} is not an authorized active recommendation record`, "recommendationIds", "authorization"));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return errors;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function authorizedRecommendationStatements(
|
|
365
|
+
records: readonly GuardedRetrievalRecord[],
|
|
366
|
+
recommendationIds: readonly string[],
|
|
367
|
+
): Set<string> {
|
|
368
|
+
const statements = new Set<string>();
|
|
369
|
+
for (const id of recommendationIds) {
|
|
370
|
+
const record = records.find((candidate) => candidate.record.id === id)?.record;
|
|
371
|
+
if (record !== undefined && record.kind === "recommendation" && record.status === "active") {
|
|
372
|
+
statements.add(record.statement);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return statements;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function validateAdaptation(
|
|
379
|
+
projection: SemanticProjection,
|
|
380
|
+
draft: PresentationDraft,
|
|
381
|
+
records: readonly GuardedRetrievalRecord[],
|
|
382
|
+
): KnowledgeError[] {
|
|
383
|
+
const errors = validateDraft(draft);
|
|
384
|
+
if (!subsetOf(draft.facts, projection.facts)) errors.push(presentationError("fact_not_in_view", "audience facts must be selected from the audience-independent view"));
|
|
385
|
+
if (!subsetOf(draft.uncertainty, projection.uncertainty)) errors.push(presentationError("uncertainty_not_in_view", "audience uncertainty must be selected from the audience-independent view"));
|
|
386
|
+
if (!projection.requiredFacts.every((fact) => draft.facts.includes(fact))) errors.push(presentationError("required_fact_hidden", "audience adaptation omitted a required baseline fact"));
|
|
387
|
+
if (!projection.uncertainty.every((item) => draft.uncertainty.includes(item))) errors.push(presentationError("uncertainty_hidden", "audience adaptation omitted explicit uncertainty"));
|
|
388
|
+
|
|
389
|
+
// Every rendered action must be traceable to what authorizes it: either it
|
|
390
|
+
// is one of the view's own actions, or it is exactly the statement of a
|
|
391
|
+
// record the draft cites as an authorized active recommendation. This runs
|
|
392
|
+
// unconditionally, not gated on whether the action set changed — citing
|
|
393
|
+
// the right recommendation id must not excuse inventing different action
|
|
394
|
+
// prose.
|
|
395
|
+
const authorizedStatements = authorizedRecommendationStatements(records, draft.recommendationIds);
|
|
396
|
+
for (const action of draft.actions) {
|
|
397
|
+
if (!projection.actions.includes(action) && !authorizedStatements.has(action)) {
|
|
398
|
+
errors.push(presentationError(
|
|
399
|
+
"action_unauthorized",
|
|
400
|
+
`audience action "${action}" is neither a view action nor the statement of a cited authorized recommendation`,
|
|
401
|
+
"audience.actions",
|
|
402
|
+
));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const actionChanged = !sameSet(projection.actions, draft.actions);
|
|
407
|
+
if (actionChanged && draft.recommendationIds.length === 0) {
|
|
408
|
+
errors.push(presentationError("recommendation_required", "an action-changing adaptation requires a distinct authorized recommendation"));
|
|
409
|
+
}
|
|
410
|
+
if (actionChanged && !draft.recommendationIds.some((id) => !projection.recommendationIds.includes(id))) {
|
|
411
|
+
errors.push(presentationError("recommendation_distinct_required", "an action-changing adaptation must cite an authorized recommendation not used for the baseline action"));
|
|
412
|
+
}
|
|
413
|
+
errors.push(...validateRecommendationIds(records, draft.recommendationIds));
|
|
414
|
+
return errors;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function uniqueStrings(values: readonly string[]): string[] {
|
|
418
|
+
return [...new Set(values)];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// `presentationId` is deterministic given the request and `generatedAt`, so a
|
|
422
|
+
// byte-identical re-render targets the same final directory. `rename` onto a
|
|
423
|
+
// non-empty destination fails with an opaque ENOTEMPTY; detect the collision
|
|
424
|
+
// before doing any pending-directory work and refuse explicitly instead of
|
|
425
|
+
// treating the second render as silently idempotent.
|
|
426
|
+
class PresentationAlreadyRetainedError extends Error {}
|
|
427
|
+
|
|
428
|
+
async function directoryExists(path: string): Promise<boolean> {
|
|
429
|
+
try {
|
|
430
|
+
await stat(path);
|
|
431
|
+
return true;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (error instanceof Error && error.message.includes("ENOENT")) return false;
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function retain(
|
|
439
|
+
active: ActiveSpace,
|
|
440
|
+
authorizedRoot: string,
|
|
441
|
+
content: string,
|
|
442
|
+
receipt: PresentationReceipt,
|
|
443
|
+
writer: (path: string, content: string) => Promise<void>,
|
|
444
|
+
): Promise<RetainedPresentation> {
|
|
445
|
+
const paths = retentionPaths(active, receipt.presentationId);
|
|
446
|
+
await mkdir(paths.root, { recursive: true });
|
|
447
|
+
// The root existed as a resolved, authorized path when the boundary was
|
|
448
|
+
// checked; confirm the directory just created is still that path before
|
|
449
|
+
// anything is written into it.
|
|
450
|
+
const createdRoot = await realpath(paths.root);
|
|
451
|
+
if (createdRoot !== authorizedRoot) {
|
|
452
|
+
throw new Error(`presentation retention root changed after authorization: expected ${authorizedRoot}, found ${createdRoot}`);
|
|
453
|
+
}
|
|
454
|
+
if (await directoryExists(paths.directory)) {
|
|
455
|
+
throw new PresentationAlreadyRetainedError(`presentation ${receipt.presentationId} is already retained at ${paths.directory}`);
|
|
456
|
+
}
|
|
457
|
+
const temporaryDirectory = await mkdtemp(join(paths.root, ".engram-presentation-pending-"));
|
|
458
|
+
try {
|
|
459
|
+
await writer(join(temporaryDirectory, "presentation.md"), content);
|
|
460
|
+
await writer(join(temporaryDirectory, "receipt.json"), JSON.stringify(receipt, null, 2) + "\n");
|
|
461
|
+
await rename(temporaryDirectory, paths.directory);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
return { artifactPath: paths.artifact, receiptPath: paths.receipt };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function presentationIdentity(
|
|
470
|
+
active: ActiveSpace,
|
|
471
|
+
request: { pack: { id: string; version: string }; model: string },
|
|
472
|
+
view: ViewDefinition,
|
|
473
|
+
audience: AudienceDefinition,
|
|
474
|
+
delivery: DeliveryDefinition,
|
|
475
|
+
records: readonly GuardedRetrievalRecord[],
|
|
476
|
+
retrieval: RetrievalReceipt,
|
|
477
|
+
recommendationIds: readonly string[],
|
|
478
|
+
draft: PresentationDraft,
|
|
479
|
+
generatedAt: string,
|
|
480
|
+
): Record<string, unknown> {
|
|
481
|
+
return {
|
|
482
|
+
activeSpace: active.spaceId,
|
|
483
|
+
viewId: view.id,
|
|
484
|
+
viewVersion: view.version,
|
|
485
|
+
audienceId: audience.id,
|
|
486
|
+
audienceVersion: audience.version,
|
|
487
|
+
deliveryId: delivery.id,
|
|
488
|
+
deliveryVersion: delivery.version,
|
|
489
|
+
sourceRecordIds: records.map((record) => record.record.id).sort(),
|
|
490
|
+
sourceReferences: referencesFor(records),
|
|
491
|
+
retrievalReceipt: retrieval,
|
|
492
|
+
pack: { id: request.pack.id, version: request.pack.version },
|
|
493
|
+
model: request.model,
|
|
494
|
+
recommendationIds: [...recommendationIds],
|
|
495
|
+
generatedAt,
|
|
496
|
+
content: renderContent(draft, delivery),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export async function renderPresentation(
|
|
501
|
+
request: PresentationRequest,
|
|
502
|
+
options: PresentationOptions = {},
|
|
503
|
+
): Promise<PresentationOutcome> {
|
|
504
|
+
const env = options.env ?? process.env;
|
|
505
|
+
const activeResult = await resolveActiveSpace(env);
|
|
506
|
+
if (!activeResult.ok) {
|
|
507
|
+
return failure(activeResult.errors.map((message) => presentationError("active_space_unresolved", message, undefined, "authorization")));
|
|
508
|
+
}
|
|
509
|
+
const active = activeResult.value;
|
|
510
|
+
const requestSnapshot = Object.freeze({
|
|
511
|
+
viewId: request.viewId,
|
|
512
|
+
audienceId: request.audienceId,
|
|
513
|
+
deliveryId: request.deliveryId,
|
|
514
|
+
query: request.query,
|
|
515
|
+
model: request.model,
|
|
516
|
+
generatedAt: request.generatedAt,
|
|
517
|
+
pack: Object.freeze({ id: request.pack.id, version: request.pack.version }),
|
|
518
|
+
});
|
|
519
|
+
if (!isInstalled(active, request.pack)) {
|
|
520
|
+
return failure([presentationError(
|
|
521
|
+
"pack_not_installed",
|
|
522
|
+
`presentation pack ${requestSnapshot.pack.id}@${requestSnapshot.pack.version} is not installed in the active space`,
|
|
523
|
+
"pack",
|
|
524
|
+
"authorization",
|
|
525
|
+
)]);
|
|
526
|
+
}
|
|
527
|
+
if (!active.allowedModels.includes(requestSnapshot.model)) {
|
|
528
|
+
return failure([presentationError("model_not_allowed", `model ${requestSnapshot.model} is not allowed by the active space`, "model", "authorization")]);
|
|
529
|
+
}
|
|
530
|
+
const view = findView(request.pack, requestSnapshot.viewId);
|
|
531
|
+
if (view === undefined) return failure([presentationError("view_unknown", `view ${requestSnapshot.viewId} is not configured by the presentation pack`, "viewId")]);
|
|
532
|
+
if (view.scope !== "search" && view.scope !== "space") {
|
|
533
|
+
return failure([presentationError("view_scope_invalid", "view scope must be search or space", "viewId")]);
|
|
534
|
+
}
|
|
535
|
+
const audience = findAudience(request.pack, requestSnapshot.audienceId);
|
|
536
|
+
if (audience === undefined) return failure([presentationError("audience_unknown", `audience ${requestSnapshot.audienceId} is not configured by the presentation pack`, "audienceId", "authorization")]);
|
|
537
|
+
const declaredDelivery = findDelivery(request.pack, requestSnapshot.deliveryId);
|
|
538
|
+
if (declaredDelivery === undefined) return failure([presentationError("delivery_unknown", `delivery ${requestSnapshot.deliveryId} is not configured by the presentation pack`, "deliveryId")]);
|
|
539
|
+
const viewSnapshot = Object.freeze({ id: view.id, version: view.version, scope: view.scope, retrievalQuery: view.retrievalQuery, project: view.project });
|
|
540
|
+
const audienceSnapshot = Object.freeze({ id: audience.id, version: audience.version, authorize: audience.authorize, adapt: audience.adapt });
|
|
541
|
+
// Everything downstream reads this snapshot, never the pack's live object, so
|
|
542
|
+
// a callback that mutates what it was handed cannot move a guard it already
|
|
543
|
+
// passed or one it has not reached yet.
|
|
544
|
+
const delivery = snapshotDelivery(declaredDelivery);
|
|
545
|
+
if (!Number.isSafeInteger(viewSnapshot.version) || viewSnapshot.version < 0 || !Number.isSafeInteger(audienceSnapshot.version) || audienceSnapshot.version < 0 || !Number.isSafeInteger(delivery.version) || delivery.version < 0) {
|
|
546
|
+
return failure([presentationError("definition_version_invalid", "view, audience, and delivery versions must be non-negative integers")]);
|
|
547
|
+
}
|
|
548
|
+
if (!Number.isSafeInteger(delivery.maxWords) || delivery.maxWords <= 0) {
|
|
549
|
+
return failure([presentationError("delivery_constraint_invalid", "delivery maxWords must be a positive integer", "deliveryId")]);
|
|
550
|
+
}
|
|
551
|
+
if (requestSnapshot.generatedAt !== undefined && !validateGeneratedAt(requestSnapshot.generatedAt)) {
|
|
552
|
+
return failure([presentationError("generated_at_invalid", "generatedAt must be an ISO-like timestamp", "generatedAt")]);
|
|
553
|
+
}
|
|
554
|
+
// Resolved once, here, and carried to `retain`. `delivery.retain` is read off
|
|
555
|
+
// the snapshot, so a callback cannot turn retention on after this point.
|
|
556
|
+
let retentionRoot: string | undefined;
|
|
557
|
+
if (delivery.retain) {
|
|
558
|
+
const boundary = await retentionBoundary(active, delivery);
|
|
559
|
+
if (!boundary.ok) return failure([boundary.error]);
|
|
560
|
+
retentionRoot = boundary.resolvedRoot;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let retrieval: GuardedRetrievalOutcome;
|
|
564
|
+
if (viewSnapshot.scope === "space") {
|
|
565
|
+
// Refuse rather than ignore: silently dropping a caller-supplied query
|
|
566
|
+
// on an enumerating view would let the caller believe they had narrowed
|
|
567
|
+
// a result set that was in fact returned whole.
|
|
568
|
+
if (requestSnapshot.query !== undefined) {
|
|
569
|
+
return failure([presentationError("query_not_scoped", "a space-scoped view does not accept a caller-supplied query", "query", "retrieval")]);
|
|
570
|
+
}
|
|
571
|
+
retrieval = await guardedEnumerateInActiveSpace(
|
|
572
|
+
active,
|
|
573
|
+
{ audienceId: audienceSnapshot.id, viewId: viewSnapshot.id, pack: request.pack },
|
|
574
|
+
options,
|
|
575
|
+
);
|
|
576
|
+
} else {
|
|
577
|
+
let retrievalQuery: string;
|
|
578
|
+
try {
|
|
579
|
+
retrievalQuery = viewSnapshot.retrievalQuery(requestSnapshot.query);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
return failure([presentationError("view_query_failed", `view retrieval query failed: ${error instanceof Error ? error.message : String(error)}`)]);
|
|
582
|
+
}
|
|
583
|
+
retrieval = await guardedRetrieveInActiveSpace(
|
|
584
|
+
active,
|
|
585
|
+
{
|
|
586
|
+
query: retrievalQuery,
|
|
587
|
+
audienceId: audienceSnapshot.id,
|
|
588
|
+
viewId: viewSnapshot.id,
|
|
589
|
+
pack: request.pack,
|
|
590
|
+
},
|
|
591
|
+
options,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
if (retrieval.status === "failed") return failure(retrieval.errors, retrieval.receipt);
|
|
595
|
+
if (retrieval.status === "miss") {
|
|
596
|
+
return failure([presentationError("retrieval_miss", "the view has no eligible current records; a miss is not evidence of absence", "query", "retrieval")], retrieval.receipt);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const records = retrieval.records;
|
|
600
|
+
let projection: SemanticProjection;
|
|
601
|
+
try {
|
|
602
|
+
projection = snapshotProjection(viewSnapshot.project(recordsForView(records)));
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return failure([presentationError("view_projection_failed", `view projection failed: ${error instanceof Error ? error.message : String(error)}`)], retrieval.receipt);
|
|
605
|
+
}
|
|
606
|
+
const projectionErrors = validateProjection(projection, records);
|
|
607
|
+
if (projectionErrors.length > 0) return failure(projectionErrors, retrieval.receipt);
|
|
608
|
+
|
|
609
|
+
let draft: PresentationDraft;
|
|
610
|
+
try {
|
|
611
|
+
draft = snapshotDraft(audienceSnapshot.adapt({ projection, delivery, records: recordsForView(records) }));
|
|
612
|
+
} catch (error) {
|
|
613
|
+
return failure([presentationError("audience_adaptation_failed", `audience adaptation failed: ${error instanceof Error ? error.message : String(error)}`)], retrieval.receipt);
|
|
614
|
+
}
|
|
615
|
+
const adaptationErrors = validateAdaptation(projection, draft, records);
|
|
616
|
+
if (adaptationErrors.length > 0) return failure(adaptationErrors, retrieval.receipt);
|
|
617
|
+
|
|
618
|
+
const recommendationIds = uniqueStrings([...projection.recommendationIds, ...draft.recommendationIds]);
|
|
619
|
+
const generatedAt = requestSnapshot.generatedAt ?? new Date().toISOString();
|
|
620
|
+
const content = renderContent(draft, delivery);
|
|
621
|
+
if (wordCount(content) > delivery.maxWords) {
|
|
622
|
+
return failure([presentationError("delivery_limit_exceeded", `rendered presentation exceeds the ${delivery.maxWords}-word delivery limit`, "deliveryId")], retrieval.receipt);
|
|
623
|
+
}
|
|
624
|
+
const identity = presentationIdentity(active, { pack: requestSnapshot.pack, model: requestSnapshot.model }, viewSnapshot, audienceSnapshot, delivery, records, retrieval.receipt, recommendationIds, draft, generatedAt);
|
|
625
|
+
const presentationId = `presentation-${createHash("sha256").update(canonicalJson(identity), "utf8").digest("hex").slice(0, 32)}`;
|
|
626
|
+
const retainedPaths = delivery.retain ? retentionPaths(active, presentationId) : undefined;
|
|
627
|
+
const receipt: PresentationReceipt = {
|
|
628
|
+
schemaVersion: 0,
|
|
629
|
+
presentationId,
|
|
630
|
+
activeSpace: active.spaceId,
|
|
631
|
+
viewId: viewSnapshot.id,
|
|
632
|
+
viewVersion: viewSnapshot.version,
|
|
633
|
+
audienceId: audienceSnapshot.id,
|
|
634
|
+
audienceVersion: audienceSnapshot.version,
|
|
635
|
+
deliveryId: delivery.id,
|
|
636
|
+
deliveryVersion: delivery.version,
|
|
637
|
+
sourceRecordIds: records.map((record) => record.record.id).sort(),
|
|
638
|
+
sourceReferences: referencesFor(records),
|
|
639
|
+
retrievalReceipt: retrieval.receipt,
|
|
640
|
+
pack: { id: requestSnapshot.pack.id, version: requestSnapshot.pack.version },
|
|
641
|
+
model: requestSnapshot.model,
|
|
642
|
+
recommendationIds,
|
|
643
|
+
generatedAt,
|
|
644
|
+
...(retainedPaths === undefined ? {} : {
|
|
645
|
+
retention: {
|
|
646
|
+
artifactReference: retainedPaths.artifactReference,
|
|
647
|
+
receiptReference: retainedPaths.receiptReference,
|
|
648
|
+
},
|
|
649
|
+
}),
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
if (!delivery.retain || retentionRoot === undefined) return { schema_version: 0, status: "presented", content, receipt };
|
|
653
|
+
const writer = options.writeRetentionFile ?? ((path: string, value: string) => writeFile(path, value, "utf8"));
|
|
654
|
+
try {
|
|
655
|
+
const retained = await retain(active, retentionRoot, content, receipt, writer);
|
|
656
|
+
return { schema_version: 0, status: "presented", content, receipt, retained };
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error instanceof PresentationAlreadyRetainedError) {
|
|
659
|
+
return failure([presentationError(
|
|
660
|
+
"presentation_already_retained",
|
|
661
|
+
error.message,
|
|
662
|
+
"deliveryId",
|
|
663
|
+
"artifact",
|
|
664
|
+
)], retrieval.receipt);
|
|
665
|
+
}
|
|
666
|
+
return failure([presentationError(
|
|
667
|
+
"artifact_write_failed",
|
|
668
|
+
`presentation retention failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
669
|
+
"deliveryId",
|
|
670
|
+
"artifact",
|
|
671
|
+
)], retrieval.receipt);
|
|
672
|
+
}
|
|
673
|
+
}
|