@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,265 @@
1
+ // Hand-written parser/serializer for the Markdown record shape:
2
+ //
3
+ // ---
4
+ // id: <id>
5
+ // title: <title>
6
+ // updated: <YYYY-MM-DD>
7
+ // ---
8
+ //
9
+ // ## Active claims
10
+ //
11
+ // - claim text [source: tag]
12
+ //
13
+ // ## Evidence log
14
+ //
15
+ // - YYYY-MM-DD — entry text [source: tag]
16
+ //
17
+ // `## Active claims` and `## Evidence log` are the two sections this
18
+ // module understands and can mutate. Any OTHER `## ...` section in the
19
+ // body is preserved verbatim, in its original position, through parse and
20
+ // serialize — a record is free to carry a `## Notes` section (or anything
21
+ // else) that is never touched. So is any PREAMBLE: free-form content
22
+ // between the frontmatter's closing `---` and the first `##` heading.
23
+ // Losing either on write would be a silent, ungated destructive edit —
24
+ // exactly what the additive/non-additive gate exists to prevent, and
25
+ // exactly what happened here before preambleLines existed: content before
26
+ // the first heading was parsed and then never re-emitted.
27
+
28
+ import { err, ok, requireDefined, type Result } from "./types.ts";
29
+
30
+ export type RecordFrontmatter = {
31
+ id: string;
32
+ title: string;
33
+ updated: string;
34
+ };
35
+
36
+ export type KnownSection =
37
+ | { kind: "active-claims"; bullets: string[] }
38
+ | { kind: "evidence-log"; bullets: string[] };
39
+
40
+ export type OtherSection = { kind: "other"; heading: string; lines: string[] };
41
+
42
+ export type RecordSection = KnownSection | OtherSection;
43
+
44
+ export type ParsedRecord = {
45
+ frontmatter: RecordFrontmatter;
46
+ /** Raw body lines before the first `## ...` heading, preserved verbatim
47
+ * (including the mandatory blank separator line after the frontmatter
48
+ * delimiter, for a record with no real preamble). A record is free to
49
+ * carry introductory prose here; losing it on the first write would be
50
+ * a silent, ungated destructive edit. */
51
+ preambleLines: string[];
52
+ sections: RecordSection[];
53
+ };
54
+
55
+ const ACTIVE_CLAIMS_HEADING = "## Active claims";
56
+ const EVIDENCE_LOG_HEADING = "## Evidence log";
57
+
58
+ export function parseRecord(text: string): Result<ParsedRecord> {
59
+ const errors: string[] = [];
60
+ const lines = text.split("\n").map((line) => line.replace(/\r$/, ""));
61
+
62
+ if (lines[0] !== "---") {
63
+ return err(["record must begin with a '---' frontmatter delimiter"]);
64
+ }
65
+
66
+ const closingIndex = lines.indexOf("---", 1);
67
+ if (closingIndex === -1) {
68
+ return err(["record frontmatter is missing its closing '---' delimiter"]);
69
+ }
70
+
71
+ const frontmatterLines = lines.slice(1, closingIndex);
72
+ const frontmatterFields = new Map<string, string>();
73
+ for (const line of frontmatterLines) {
74
+ if (line.trim() === "") continue;
75
+ const match = /^([a-zA-Z_][a-zA-Z0-9_]*):\s?(.*)$/.exec(line);
76
+ if (!match) {
77
+ errors.push(`unrecognized frontmatter line: ${JSON.stringify(line)}`);
78
+ continue;
79
+ }
80
+ const key = requireDefined(match[1], "frontmatter regex capture group 1");
81
+ const value = requireDefined(match[2], "frontmatter regex capture group 2");
82
+ frontmatterFields.set(key, value.trim());
83
+ }
84
+
85
+ for (const required of ["id", "title", "updated"] as const) {
86
+ if (!frontmatterFields.has(required)) {
87
+ errors.push(`frontmatter is missing required field: ${required}`);
88
+ }
89
+ }
90
+ const knownFrontmatterKeys = new Set(["id", "title", "updated"]);
91
+ for (const key of frontmatterFields.keys()) {
92
+ if (!knownFrontmatterKeys.has(key)) {
93
+ errors.push(`frontmatter has unexpected field: ${key}`);
94
+ }
95
+ }
96
+
97
+ if (errors.length > 0) {
98
+ return err(errors);
99
+ }
100
+
101
+ const frontmatter: RecordFrontmatter = {
102
+ id: requireDefined(frontmatterFields.get("id"), "id present after validation"),
103
+ title: requireDefined(frontmatterFields.get("title"), "title present after validation"),
104
+ updated: requireDefined(frontmatterFields.get("updated"), "updated present after validation"),
105
+ };
106
+
107
+ const bodyLines = lines.slice(closingIndex + 1);
108
+ const firstHeadingIndex = bodyLines.findIndex((line) => line.startsWith("## "));
109
+ const preambleLines = firstHeadingIndex === -1 ? bodyLines.slice() : bodyLines.slice(0, firstHeadingIndex);
110
+ const sectionLines = firstHeadingIndex === -1 ? [] : bodyLines.slice(firstHeadingIndex);
111
+ const rawSections = splitIntoSections(sectionLines);
112
+
113
+ const sections: RecordSection[] = [];
114
+ let activeClaimsIndex = -1;
115
+ let evidenceLogIndex = -1;
116
+
117
+ for (const raw of rawSections) {
118
+ if (raw.heading === ACTIVE_CLAIMS_HEADING) {
119
+ if (activeClaimsIndex !== -1) {
120
+ errors.push(`record has more than one "${ACTIVE_CLAIMS_HEADING}" section`);
121
+ continue;
122
+ }
123
+ const bulletsResult = parseBullets(raw.lines, ACTIVE_CLAIMS_HEADING);
124
+ if (!bulletsResult.ok) {
125
+ errors.push(...bulletsResult.errors);
126
+ continue;
127
+ }
128
+ activeClaimsIndex = sections.length;
129
+ sections.push({ kind: "active-claims", bullets: bulletsResult.value });
130
+ } else if (raw.heading === EVIDENCE_LOG_HEADING) {
131
+ if (evidenceLogIndex !== -1) {
132
+ errors.push(`record has more than one "${EVIDENCE_LOG_HEADING}" section`);
133
+ continue;
134
+ }
135
+ const bulletsResult = parseBullets(raw.lines, EVIDENCE_LOG_HEADING);
136
+ if (!bulletsResult.ok) {
137
+ errors.push(...bulletsResult.errors);
138
+ continue;
139
+ }
140
+ evidenceLogIndex = sections.length;
141
+ sections.push({ kind: "evidence-log", bullets: bulletsResult.value });
142
+ } else {
143
+ sections.push({ kind: "other", heading: raw.heading, lines: raw.lines.slice() });
144
+ }
145
+ }
146
+
147
+ if (activeClaimsIndex === -1) {
148
+ errors.push(`record body is missing the "${ACTIVE_CLAIMS_HEADING}" section`);
149
+ }
150
+ if (evidenceLogIndex === -1) {
151
+ errors.push(`record body is missing the "${EVIDENCE_LOG_HEADING}" section`);
152
+ }
153
+ if (activeClaimsIndex !== -1 && evidenceLogIndex !== -1 && activeClaimsIndex > evidenceLogIndex) {
154
+ errors.push(`"${ACTIVE_CLAIMS_HEADING}" must come before "${EVIDENCE_LOG_HEADING}"`);
155
+ }
156
+
157
+ if (errors.length > 0) {
158
+ return err(errors);
159
+ }
160
+
161
+ return ok({ frontmatter, preambleLines, sections });
162
+ }
163
+
164
+ type RawSection = { heading: string; lines: string[] };
165
+
166
+ function splitIntoSections(bodyLines: string[]): RawSection[] {
167
+ const sections: RawSection[] = [];
168
+ let current: RawSection | null = null;
169
+ for (const line of bodyLines) {
170
+ if (line.startsWith("## ")) {
171
+ current = { heading: line.trimEnd(), lines: [] };
172
+ sections.push(current);
173
+ } else if (current) {
174
+ current.lines.push(line);
175
+ }
176
+ // lines before the first heading (blank separator lines) are ignored
177
+ }
178
+ return sections;
179
+ }
180
+
181
+ function parseBullets(lines: string[], sectionName: string): Result<string[]> {
182
+ const bullets: string[] = [];
183
+ const errors: string[] = [];
184
+ for (const line of lines) {
185
+ if (line.trim() === "") continue;
186
+ const match = /^- (.+)$/.exec(line);
187
+ if (!match) {
188
+ errors.push(`unrecognized line in "${sectionName}": ${JSON.stringify(line)}`);
189
+ continue;
190
+ }
191
+ const bulletText = requireDefined(match[1], "bullet regex capture group 1");
192
+ bullets.push(bulletText.trimEnd());
193
+ }
194
+ if (errors.length > 0) return err(errors);
195
+ return ok(bullets);
196
+ }
197
+
198
+ export function serializeRecord(record: ParsedRecord): string {
199
+ const lines: string[] = [];
200
+ lines.push("---");
201
+ lines.push(`id: ${record.frontmatter.id}`);
202
+ lines.push(`title: ${record.frontmatter.title}`);
203
+ lines.push(`updated: ${record.frontmatter.updated}`);
204
+ lines.push("---");
205
+ lines.push(...record.preambleLines);
206
+
207
+ for (const section of record.sections) {
208
+ if (section.kind === "active-claims") {
209
+ lines.push(ACTIVE_CLAIMS_HEADING);
210
+ lines.push("");
211
+ for (const claim of section.bullets) lines.push(`- ${claim}`);
212
+ lines.push("");
213
+ } else if (section.kind === "evidence-log") {
214
+ lines.push(EVIDENCE_LOG_HEADING);
215
+ lines.push("");
216
+ for (const evidence of section.bullets) lines.push(`- ${evidence}`);
217
+ lines.push("");
218
+ } else {
219
+ lines.push(section.heading);
220
+ lines.push(...section.lines);
221
+ }
222
+ }
223
+
224
+ // Normalize to exactly one trailing newline regardless of how the last
225
+ // section's captured lines ended.
226
+ return `${lines.join("\n").replace(/\n*$/, "")}\n`;
227
+ }
228
+
229
+ function findKnownSection(record: ParsedRecord, kind: "active-claims" | "evidence-log"): KnownSection {
230
+ for (const section of record.sections) {
231
+ if (section.kind === kind) return section;
232
+ }
233
+ // parseRecord guarantees both known sections exist exactly once before
234
+ // ever returning ok(...), so reaching here means a ParsedRecord was
235
+ // constructed some other way. That is a programming error, not a
236
+ // user-facing validation failure, so it throws rather than returning a
237
+ // Result.
238
+ throw new Error(`internal invariant violated: parsed record is missing its "${kind}" section`);
239
+ }
240
+
241
+ export function getActiveClaims(record: ParsedRecord): string[] {
242
+ return findKnownSection(record, "active-claims").bullets;
243
+ }
244
+
245
+ export function getEvidenceLog(record: ParsedRecord): string[] {
246
+ return findKnownSection(record, "evidence-log").bullets;
247
+ }
248
+
249
+ /**
250
+ * Returns a new ParsedRecord with the active-claims and evidence-log
251
+ * sections replaced and frontmatter updated, preserving the preamble and
252
+ * every other section (unknown headings, and their relative position)
253
+ * unchanged — no candidate field can touch either.
254
+ */
255
+ export function withMutatedContent(
256
+ record: ParsedRecord,
257
+ update: { frontmatter: RecordFrontmatter; activeClaims: string[]; evidenceLog: string[] },
258
+ ): ParsedRecord {
259
+ const sections: RecordSection[] = record.sections.map((section) => {
260
+ if (section.kind === "active-claims") return { kind: "active-claims", bullets: update.activeClaims };
261
+ if (section.kind === "evidence-log") return { kind: "evidence-log", bullets: update.evidenceLog };
262
+ return section;
263
+ });
264
+ return { frontmatter: update.frontmatter, preambleLines: record.preambleLines, sections };
265
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Pack loader — resolves a pack's declared `from` module specifier to a
3
+ * KnowledgeExtractor or KnowledgePack implementation. Resolution is explicit
4
+ * and external-only: the loader imports exactly the module the binding named
5
+ * and enforces that the selected export's declared id and version match the
6
+ * binding request. There is no bundled registry to fall back to and no silent
7
+ * substitution.
8
+ *
9
+ * The loader does NOT own pack validation, reconciliation, or the transaction
10
+ * pipeline. It is the one bridge between the binding's declared pack identity
11
+ * and a loadable module.
12
+ *
13
+ * Error contract: every failure is `kind: "validation"` and uses exactly
14
+ * `pack_from_required`, `pack_load_failed`, `pack_export_invalid`, or
15
+ * `pack_identity_mismatch`. Messages describe the category and never print
16
+ * module contents, local paths, or an imported exception stack.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { dirname, resolve } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import type { KnowledgeExtractor, KnowledgePack, KnowledgeResult, PresentationPack } from "./knowledgeTypes.ts";
24
+
25
+ function packError<T>(code: string, message: string): KnowledgeResult<T> {
26
+ return { ok: false, errors: [{ kind: "validation", code, message }] };
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function camelToSnake(name: string): string {
34
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
35
+ }
36
+
37
+ /**
38
+ * Locates a candidate export by the requested pack id. Checks, in order:
39
+ * 1. A named export matching the pack id.
40
+ * 2. A `default` export.
41
+ * 3. An export in a `packs` or `packRegistry` object.
42
+ */
43
+ function findExport(mod: Record<string, unknown>, id: string): unknown {
44
+ const named = mod[id] ?? mod[camelToSnake(id)];
45
+ if (named !== undefined) return named;
46
+
47
+ const defaultExport = mod.default;
48
+ if (defaultExport !== undefined) return defaultExport;
49
+
50
+ const registry = mod.packs ?? mod.packRegistry;
51
+ if (isRecord(registry)) {
52
+ const entry = registry[id];
53
+ if (entry !== undefined) return entry;
54
+ }
55
+
56
+ return undefined;
57
+ }
58
+
59
+ function isSourceClassPolicy(value: unknown): boolean {
60
+ return (
61
+ isRecord(value) &&
62
+ Array.isArray(value.allowedSourceClasses) &&
63
+ value.allowedSourceClasses.every((sourceClass) => typeof sourceClass === "string") &&
64
+ typeof value.queryStrategy === "function" &&
65
+ typeof value.classifySource === "function" &&
66
+ (typeof value.relevanceThreshold === "number" || value.relevanceThreshold === null) &&
67
+ typeof value.isEligible === "function" &&
68
+ value.includePresentations === false
69
+ );
70
+ }
71
+
72
+ function isView(value: unknown): boolean {
73
+ return isRecord(value) && typeof value.id === "string" && typeof value.version === "number" &&
74
+ (value.scope === "search" || value.scope === "space") &&
75
+ typeof value.retrievalQuery === "function" && typeof value.project === "function";
76
+ }
77
+
78
+ function isAudience(value: unknown): boolean {
79
+ return isRecord(value) && typeof value.id === "string" && typeof value.version === "number" &&
80
+ typeof value.authorize === "function" && typeof value.adapt === "function";
81
+ }
82
+
83
+ function isDelivery(value: unknown): boolean {
84
+ return isRecord(value) && typeof value.id === "string" && typeof value.version === "number" &&
85
+ (value.format === "markdown" || value.format === "plain" || value.format === "json") &&
86
+ typeof value.maxWords === "number" && typeof value.retain === "boolean";
87
+ }
88
+
89
+ function isKnowledgePack(value: unknown): value is KnowledgePack & PresentationPack & Record<string, unknown> {
90
+ return (
91
+ isRecord(value) &&
92
+ typeof value.id === "string" &&
93
+ typeof value.version === "string" &&
94
+ typeof value.validateEnvelope === "function" &&
95
+ typeof value.relatedQuery === "function" &&
96
+ typeof value.reconcile === "function" &&
97
+ isSourceClassPolicy(value.retrievalPolicy) &&
98
+ Array.isArray(value.views) && value.views.every(isView) &&
99
+ Array.isArray(value.audiences) && value.audiences.every(isAudience) &&
100
+ Array.isArray(value.deliveries) && value.deliveries.every(isDelivery)
101
+ );
102
+ }
103
+
104
+ function isKnowledgeExtractor(value: unknown): value is KnowledgeExtractor & Record<string, unknown> {
105
+ return (
106
+ isRecord(value) &&
107
+ typeof value.id === "string" &&
108
+ typeof value.version === "string" &&
109
+ typeof value.extractCandidates === "function"
110
+ );
111
+ }
112
+
113
+ function moduleSpecifier(from: string, bindingPath: string | undefined): string {
114
+ if (bindingPath !== undefined && (from.startsWith("./") || from.startsWith("../"))) {
115
+ return pathToFileURL(resolve(dirname(bindingPath), from)).href;
116
+ }
117
+ return from;
118
+ }
119
+
120
+ /**
121
+ * Resolves a pack `id`+`version` to its implementation, importing exactly the
122
+ * binding's `from` module specifier and enforcing that the selected export's
123
+ * declared identity matches. Refuses:
124
+ * - A missing or whitespace-only `from` as `pack_from_required`.
125
+ * - An unloadable module as `pack_load_failed`.
126
+ * - A module whose candidate export is not a complete KnowledgePack and
127
+ * PresentationPack as `pack_export_invalid`.
128
+ * - A valid-shaped export whose id or version differs as
129
+ * `pack_identity_mismatch`.
130
+ */
131
+ export async function resolveKnowledgePack(
132
+ id: string,
133
+ version: string,
134
+ from: string | undefined,
135
+ bindingPath?: string,
136
+ ): Promise<KnowledgeResult<KnowledgePack & PresentationPack>> {
137
+ if (from === undefined || from.trim().length === 0) {
138
+ return packError("pack_from_required", "a from module specifier is required to resolve the external pack");
139
+ }
140
+
141
+ let mod: Record<string, unknown>;
142
+ try {
143
+ mod = await import(moduleSpecifier(from, bindingPath));
144
+ } catch {
145
+ return packError("pack_load_failed", "the external pack module could not be loaded");
146
+ }
147
+
148
+ const candidate = findExport(mod, id);
149
+ if (!isKnowledgePack(candidate)) {
150
+ return packError("pack_export_invalid", "the external pack module does not export a complete KnowledgePack and PresentationPack");
151
+ }
152
+ if (candidate.id !== id || candidate.version !== version) {
153
+ return packError("pack_identity_mismatch", "the external pack module's id or version does not match the declared pack");
154
+ }
155
+ return { ok: true, value: candidate };
156
+ }
157
+
158
+ /**
159
+ * Loads a KnowledgeExtractor for the pack `id`+`version` declared with `from`,
160
+ * with the same from, load, export, and identity checks as
161
+ * `resolveKnowledgePack`.
162
+ */
163
+ export async function loadExtractionPack(
164
+ id: string,
165
+ version: string,
166
+ from: string | undefined,
167
+ bindingPath?: string,
168
+ ): Promise<KnowledgeResult<KnowledgeExtractor>> {
169
+ if (from === undefined || from.trim().length === 0) {
170
+ return packError("pack_from_required", "a from module specifier is required to load the extraction pack");
171
+ }
172
+
173
+ let mod: Record<string, unknown>;
174
+ try {
175
+ mod = await import(moduleSpecifier(from, bindingPath));
176
+ } catch {
177
+ return packError("pack_load_failed", "the extraction pack module could not be loaded");
178
+ }
179
+
180
+ const candidate = findExport(mod, id);
181
+ if (!isKnowledgeExtractor(candidate)) {
182
+ return packError("pack_export_invalid", "the extraction pack module does not export a complete KnowledgeExtractor");
183
+ }
184
+ if (candidate.id !== id || candidate.version !== version) {
185
+ return packError("pack_identity_mismatch", "the extraction pack module's id or version does not match the declared extractor");
186
+ }
187
+ return { ok: true, value: candidate };
188
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Public external-pack interface types.
3
+ *
4
+ * A normal knowledge operation requires the full KnowledgePack and
5
+ * PresentationPack surface. KnowledgeExtractor is separately optional and is
6
+ * selected only by a binding entry with extract: true.
7
+ */
8
+ export type {
9
+ KnowledgeExtractor,
10
+ KnowledgePack,
11
+ PresentationPack,
12
+ } from "./knowledgeTypes.ts";