@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.
@@ -0,0 +1,256 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ validateKnowledgeEnvelope,
5
+ validationError,
6
+ } from "./knowledgeValidation.ts";
7
+ import type {
8
+ KnowledgeError,
9
+ KnowledgeHistoryEntry,
10
+ KnowledgeRecord,
11
+ KnowledgeRelationships,
12
+ KnowledgeResult,
13
+ RelationshipKind,
14
+ } from "./knowledgeTypes.ts";
15
+
16
+ const FRONTMATTER_FIELDS = [
17
+ "schema_version",
18
+ "id",
19
+ "kind",
20
+ "status",
21
+ "statement",
22
+ "details",
23
+ "scope",
24
+ "pack",
25
+ "sources",
26
+ "session",
27
+ "submitted_at",
28
+ "disposition",
29
+ "relationships",
30
+ "history",
31
+ ] as const;
32
+
33
+ export function canonicalJson(value: unknown): string {
34
+ if (value === null) return "null";
35
+ if (typeof value === "string") return JSON.stringify(value);
36
+ if (typeof value === "boolean" || typeof value === "number") {
37
+ if (typeof value === "number" && !Number.isFinite(value)) throw new Error("cannot canonicalize a non-finite number");
38
+ return JSON.stringify(value);
39
+ }
40
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
41
+ if (typeof value === "object") {
42
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
43
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
44
+ }
45
+ throw new Error("cannot canonicalize an unsupported value");
46
+ }
47
+
48
+ export function hashKnowledgeText(text: string): string {
49
+ return createHash("sha256").update(text, "utf8").digest("hex");
50
+ }
51
+
52
+ function isObject(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+
56
+ function parseJson(raw: string, field: string, errors: KnowledgeError[]): unknown {
57
+ try {
58
+ const parsed: unknown = JSON.parse(raw);
59
+ return parsed;
60
+ } catch (error) {
61
+ errors.push(validationError("record_invalid", `${field} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, field));
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ function parseFrontmatter(text: string): KnowledgeResult<{ values: Record<string, unknown>; body: string[] }> {
67
+ const errors: KnowledgeError[] = [];
68
+ const lines = text.split("\n").map((line) => line.replace(/\r$/, ""));
69
+ if (lines[0] !== "---") {
70
+ return { ok: false, errors: [validationError("record_invalid", "record must begin with a frontmatter delimiter")] };
71
+ }
72
+ const closingIndex = lines.indexOf("---", 1);
73
+ if (closingIndex < 0) {
74
+ return { ok: false, errors: [validationError("record_invalid", "record frontmatter is missing its closing delimiter")] };
75
+ }
76
+ const values: Record<string, unknown> = {};
77
+ for (let index = 1; index < closingIndex; index++) {
78
+ const line = lines[index];
79
+ if (line === undefined || line.trim() === "") continue;
80
+ const match = /^([a-z][a-z0-9_]*):\s*(.*)$/.exec(line);
81
+ if (match === null) {
82
+ errors.push(validationError("record_invalid", `unrecognized frontmatter line: ${JSON.stringify(line)}`));
83
+ continue;
84
+ }
85
+ const key = match[1];
86
+ const rawValue = match[2];
87
+ if (key === undefined || rawValue === undefined) {
88
+ errors.push(validationError("record_invalid", `frontmatter line is incomplete: ${JSON.stringify(line)}`));
89
+ continue;
90
+ }
91
+ if (!FRONTMATTER_FIELDS.some((field) => field === key)) {
92
+ errors.push(validationError("unknown_field", `record has unknown frontmatter field ${key}`, key));
93
+ continue;
94
+ }
95
+ if (Object.prototype.hasOwnProperty.call(values, key)) {
96
+ errors.push(validationError("record_invalid", `record repeats frontmatter field ${key}`, key));
97
+ continue;
98
+ }
99
+ values[key] = parseJson(rawValue, key, errors);
100
+ }
101
+ const body = lines.slice(closingIndex + 1);
102
+ if (body.length > 0 && body[body.length - 1] === "") body.pop();
103
+ if (errors.length > 0) return { ok: false, errors };
104
+ return { ok: true, value: { values, body } };
105
+ }
106
+
107
+ function relationshipValues(value: unknown, errors: KnowledgeError[]): KnowledgeRelationships | undefined {
108
+ if (!isObject(value)) {
109
+ errors.push(validationError("record_invalid", "relationships must be an object", "relationships"));
110
+ return undefined;
111
+ }
112
+ const keys = ["supports", "contradicts", "refines", "supersedes"] as const;
113
+ for (const key of Object.keys(value)) {
114
+ if (!keys.some((allowed) => allowed === key)) errors.push(validationError("unknown_field", `relationships contains unknown field ${key}`, `relationships.${key}`));
115
+ }
116
+ let supports: string[] | undefined;
117
+ let contradicts: string[] | undefined;
118
+ let refines: string[] | undefined;
119
+ let supersedes: string[] | undefined;
120
+ for (const key of keys) {
121
+ const raw = value[key];
122
+ if (!Array.isArray(raw)) {
123
+ errors.push(validationError("record_invalid", `relationships.${key} must be an array of record ids`, `relationships.${key}`));
124
+ continue;
125
+ }
126
+ const parsed: string[] = [];
127
+ let valid = true;
128
+ for (const item of raw) {
129
+ if (typeof item !== "string" || !/^[a-z][a-z0-9-]*$/.test(item)) valid = false;
130
+ else parsed.push(item);
131
+ }
132
+ if (!valid) {
133
+ errors.push(validationError("record_invalid", `relationships.${key} must be an array of record ids`, `relationships.${key}`));
134
+ continue;
135
+ }
136
+ if (key === "supports") supports = parsed;
137
+ else if (key === "contradicts") contradicts = parsed;
138
+ else if (key === "refines") refines = parsed;
139
+ else supersedes = parsed;
140
+ }
141
+ if (supports === undefined || contradicts === undefined || refines === undefined || supersedes === undefined) return undefined;
142
+ return { supports, contradicts, refines, supersedes };
143
+ }
144
+
145
+ function historyValues(value: unknown, errors: KnowledgeError[]): KnowledgeHistoryEntry[] | undefined {
146
+ if (!Array.isArray(value)) {
147
+ errors.push(validationError("record_invalid", "history must be an array", "history"));
148
+ return undefined;
149
+ }
150
+ const result: KnowledgeHistoryEntry[] = [];
151
+ for (let index = 0; index < value.length; index++) {
152
+ const item = value[index];
153
+ if (!isObject(item)) {
154
+ errors.push(validationError("record_invalid", `history[${index}] must be an object`, `history[${index}]`));
155
+ continue;
156
+ }
157
+ const keys = Object.keys(item);
158
+ for (const key of keys) {
159
+ if (key !== "event" && key !== "related_id" && key !== "submitted_at") errors.push(validationError("unknown_field", `history[${index}] contains unknown field ${key}`, `history[${index}].${key}`));
160
+ }
161
+ if (typeof item.event !== "string" || item.event.length === 0 || /[\r\n]/.test(item.event)) errors.push(validationError("record_invalid", `history[${index}].event is invalid`, `history[${index}].event`));
162
+ if (typeof item.related_id !== "string" || !/^[a-z][a-z0-9-]*$/.test(item.related_id)) errors.push(validationError("record_invalid", `history[${index}].related_id is invalid`, `history[${index}].related_id`));
163
+ if (typeof item.submitted_at !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(item.submitted_at)) errors.push(validationError("record_invalid", `history[${index}].submitted_at is invalid`, `history[${index}].submitted_at`));
164
+ if (typeof item.event === "string" && typeof item.related_id === "string" && typeof item.submitted_at === "string") {
165
+ result.push({ event: item.event, relatedId: item.related_id, submittedAt: item.submitted_at });
166
+ }
167
+ }
168
+ return result.length === value.length ? result : undefined;
169
+ }
170
+
171
+ export function parseKnowledgeRecord(text: string): KnowledgeResult<KnowledgeRecord> {
172
+ const frontmatter = parseFrontmatter(text);
173
+ if (!frontmatter.ok) return frontmatter;
174
+ const errors: KnowledgeError[] = [];
175
+ const values = frontmatter.value.values;
176
+ for (const field of FRONTMATTER_FIELDS) {
177
+ if (!Object.prototype.hasOwnProperty.call(values, field)) errors.push(validationError("record_invalid", `record is missing frontmatter field ${field}`, field));
178
+ }
179
+ if (values.schema_version !== 0) errors.push(validationError("record_invalid", "record schema_version must be 0", "schema_version"));
180
+
181
+ const envelopeInput = {
182
+ id: values.id,
183
+ kind: values.kind,
184
+ status: values.status,
185
+ statement: values.statement,
186
+ details: values.details,
187
+ scope: values.scope,
188
+ pack: values.pack,
189
+ sources: values.sources,
190
+ session: values.session,
191
+ submitted_at: values.submitted_at,
192
+ disposition: values.disposition,
193
+ };
194
+ const envelope = validateKnowledgeEnvelope(envelopeInput);
195
+ if (!envelope.ok) errors.push(...envelope.errors);
196
+ const relationships = relationshipValues(values.relationships, errors);
197
+ const history = historyValues(values.history, errors);
198
+ const body = frontmatter.value.body;
199
+ if (body.length !== 3 || body[0] !== "## Statement" || body[1] !== "" || body[2] !== values.statement) {
200
+ errors.push(validationError("record_invalid", "record body must contain the authoritative statement under ## Statement", "body"));
201
+ }
202
+ if (errors.length > 0 || !envelope.ok || relationships === undefined || history === undefined) return { ok: false, errors };
203
+ return {
204
+ ok: true,
205
+ value: {
206
+ schemaVersion: 0,
207
+ ...envelope.value,
208
+ relationships,
209
+ history,
210
+ },
211
+ };
212
+ }
213
+
214
+ export function serializeKnowledgeRecord(record: KnowledgeRecord): string {
215
+ const lines = [
216
+ "---",
217
+ "schema_version: 0",
218
+ `id: ${canonicalJson(record.id)}`,
219
+ `kind: ${canonicalJson(record.kind)}`,
220
+ `status: ${canonicalJson(record.status)}`,
221
+ `statement: ${canonicalJson(record.statement)}`,
222
+ `details: ${canonicalJson(record.details)}`,
223
+ `scope: ${canonicalJson(record.scope)}`,
224
+ `pack: ${canonicalJson(record.pack)}`,
225
+ `sources: ${canonicalJson(record.sources)}`,
226
+ `session: ${canonicalJson(record.session)}`,
227
+ `submitted_at: ${canonicalJson(record.submittedAt)}`,
228
+ `disposition: ${canonicalJson(record.disposition)}`,
229
+ `relationships: ${canonicalJson(record.relationships)}`,
230
+ `history: ${canonicalJson(record.history.map((entry) => ({ event: entry.event, related_id: entry.relatedId, submitted_at: entry.submittedAt })))}`,
231
+ "---",
232
+ "## Statement",
233
+ "",
234
+ record.statement,
235
+ "",
236
+ ];
237
+ return lines.join("\n");
238
+ }
239
+
240
+ export async function readKnowledgeRecord(path: string): Promise<KnowledgeResult<{ text: string; record: KnowledgeRecord }>> {
241
+ try {
242
+ const text = await readFile(path, "utf8");
243
+ const parsed = parseKnowledgeRecord(text);
244
+ if (!parsed.ok) return parsed;
245
+ return { ok: true, value: { text, record: parsed.value } };
246
+ } catch (error) {
247
+ return {
248
+ ok: false,
249
+ errors: [validationError("record_read_failed", `failed to read knowledge record: ${error instanceof Error ? error.message : String(error)}`, path)],
250
+ };
251
+ }
252
+ }
253
+
254
+ export function relationshipArray(record: KnowledgeRecord, kind: RelationshipKind): string[] {
255
+ return record.relationships[kind];
256
+ }