@avee1234/memport 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,317 @@
1
+ // Claude Code memory adapter — the first *real* store adapter (#14).
2
+ //
3
+ // A Claude Code memory store is a **directory**, not a single file: one
4
+ // `<slug>.md` per fact, each with YAML-ish frontmatter (`name`, `description`,
5
+ // `metadata.type`) plus a Markdown body, alongside a derived `MEMORY.md` index.
6
+ // Because the store's unit is one file = one fact, the mapping is **one file ↔
7
+ // one record** and `type ↔ kind` is a 4↔4 bijection, so the translation is
8
+ // reversible for every field the CC format can hold.
9
+ //
10
+ // Export reads each memory file into a neutral record (skipping the derived
11
+ // `MEMORY.md`); import writes records back as files and regenerates `MEMORY.md`.
12
+ // Fields the CC format cannot store (`created`, `salience`, `source`,
13
+ // `embedding`) are reported in `map`'s `unsupported` list rather than silently
14
+ // dropped. Frontmatter is read by a small hand-written parser for the known CC
15
+ // subset — not a general YAML engine — so the adapter stays zero-dependency.
16
+
17
+ import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { ExportError } from '../export.js';
20
+ import { ImportError } from '../import.js';
21
+
22
+ // The one CC-storable file (an index, not a memory) — never a record.
23
+ const INDEX_FILE = 'MEMORY.md';
24
+
25
+ // Fixed bijection between Claude Code `metadata.type` and memport `kind`.
26
+ const TYPE_TO_KIND = { user: 'fact', feedback: 'preference', project: 'event', reference: 'entity' };
27
+ const KIND_TO_TYPE = { fact: 'user', preference: 'feedback', event: 'project', entity: 'reference' };
28
+
29
+ // Fields the neutral format carries but the CC file format has no slot for.
30
+ // Reported (never silently dropped) via `map`, in this fixed order.
31
+ const UNSUPPORTED_FIELDS = ['created', 'salience', 'source', 'embedding'];
32
+
33
+ // --- frontmatter (hand-written, zero-dep) ------------------------------------
34
+
35
+ // Parse the leading `---`…`---` block plus the Markdown body. Handles the known
36
+ // CC subset: flat `key: value` lines and the one nested block `metadata:` /
37
+ // ` type: <value>`. Only the *first* `---…---` at the top is consumed, so a
38
+ // `---` horizontal rule inside the body is left as body text.
39
+ function parseFrontmatter(text, file) {
40
+ const lines = text.split('\n');
41
+ if (lines[0].trim() !== '---') {
42
+ throw new ExportError('invalid_memory_file', `missing frontmatter opening --- in ${file}`, { file });
43
+ }
44
+ let close = -1;
45
+ for (let i = 1; i < lines.length; i++) {
46
+ if (lines[i].trim() === '---') {
47
+ close = i;
48
+ break;
49
+ }
50
+ }
51
+ if (close === -1) {
52
+ throw new ExportError('invalid_memory_file', `missing frontmatter closing --- in ${file}`, { file });
53
+ }
54
+
55
+ const fm = {};
56
+ let inMetadata = false;
57
+ for (const line of lines.slice(1, close)) {
58
+ if (line.trim() === '') continue;
59
+ const colon = line.indexOf(':');
60
+ if (colon === -1) continue; // ignore lines outside the known subset
61
+ const key = line.slice(0, colon).trim();
62
+ const value = line.slice(colon + 1).trim();
63
+ const indented = /^\s/.test(line);
64
+ if (indented) {
65
+ if (inMetadata) fm.metadata[key] = value;
66
+ continue;
67
+ }
68
+ if (key === 'metadata') {
69
+ fm.metadata = {};
70
+ inMetadata = true;
71
+ continue;
72
+ }
73
+ inMetadata = false;
74
+ fm[key] = value;
75
+ }
76
+
77
+ const body = lines.slice(close + 1).join('\n').trim();
78
+ return { fm, body };
79
+ }
80
+
81
+ // Parse one memory file into a partial record (links resolved in a second pass).
82
+ function parseMemoryFile(file) {
83
+ const { fm, body } = parseFrontmatter(readFileSync(file, 'utf8'), file);
84
+
85
+ if (!fm.name) {
86
+ throw new ExportError('invalid_memory_file', `missing frontmatter 'name' in ${file}`, { file });
87
+ }
88
+ const type = fm.metadata && fm.metadata.type;
89
+ if (!type) {
90
+ throw new ExportError('invalid_memory_file', `missing frontmatter 'metadata.type' in ${file}`, { file });
91
+ }
92
+ const kind = TYPE_TO_KIND[type];
93
+ if (!kind) {
94
+ throw new ExportError(
95
+ 'invalid_memory_file',
96
+ `unknown metadata.type '${type}' in ${file} (known: ${Object.keys(TYPE_TO_KIND).join(', ')})`,
97
+ { file },
98
+ );
99
+ }
100
+ if (body.length === 0) {
101
+ throw new ExportError('invalid_memory_file', `empty body in ${file}`, { file });
102
+ }
103
+
104
+ const stat = statSync(file);
105
+ const created = new Date(stat.birthtimeMs || stat.mtimeMs).toISOString();
106
+
107
+ const record = { id: fm.name, content: body, kind, created, source: 'claude-code' };
108
+ if (fm.description) record.description = fm.description;
109
+ return record;
110
+ }
111
+
112
+ // The `[[slug]]` wiki-links found in a body, in order of first appearance,
113
+ // deduped.
114
+ function wikiLinks(body) {
115
+ const found = [];
116
+ const seen = new Set();
117
+ const re = /\[\[([^\]]+)\]\]/g;
118
+ let m;
119
+ while ((m = re.exec(body)) !== null) {
120
+ const slug = m[1].trim();
121
+ if (!seen.has(slug)) {
122
+ seen.add(slug);
123
+ found.push(slug);
124
+ }
125
+ }
126
+ return found;
127
+ }
128
+
129
+ // Lift each record's resolved wiki-links into `links[]`: a `[[slug]]` counts
130
+ // only when `slug` names *another* exported record. Unresolved links stay body
131
+ // text, so the exported set never carries a `dangling_link`.
132
+ function resolveLinks(records) {
133
+ const ids = new Set(records.map((r) => r.id));
134
+ for (const r of records) {
135
+ const links = wikiLinks(r.content).filter((slug) => slug !== r.id && ids.has(slug));
136
+ if (links.length > 0) r.links = links;
137
+ }
138
+ return records;
139
+ }
140
+
141
+ // Read every memory file in `dir` into resolved records, name-sorted for
142
+ // determinism. Throws ExportError on a malformed file or a missing directory.
143
+ function readMemoryDir(dir) {
144
+ let stat;
145
+ try {
146
+ stat = statSync(dir);
147
+ } catch {
148
+ throw new ExportError('source_not_found', `source not found: ${dir}`, { file: dir });
149
+ }
150
+ if (!stat.isDirectory()) {
151
+ throw new ExportError(
152
+ 'source_not_a_directory',
153
+ `source is not a directory: ${dir} (a Claude Code memory store is a directory of .md files)`,
154
+ { file: dir },
155
+ );
156
+ }
157
+
158
+ const files = readdirSync(dir)
159
+ .filter((name) => name.endsWith('.md') && name !== INDEX_FILE)
160
+ .sort((a, b) => a.localeCompare(b))
161
+ .map((name) => join(dir, name));
162
+
163
+ return resolveLinks(files.map(parseMemoryFile));
164
+ }
165
+
166
+ // --- serialization (record → CC file) ----------------------------------------
167
+
168
+ // The frontmatter `description` / index hook for a record: its preserved
169
+ // `description` field, else the first non-empty line of `content` truncated.
170
+ function deriveHook(record) {
171
+ if (typeof record.description === 'string' && record.description.trim()) {
172
+ return record.description.trim();
173
+ }
174
+ const firstLine = (record.content || '').split('\n').find((l) => l.trim()) || '';
175
+ const trimmed = firstLine.trim();
176
+ return trimmed.length > 100 ? `${trimmed.slice(0, 100).trimEnd()}…` : trimmed;
177
+ }
178
+
179
+ function escapeRegExp(s) {
180
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
181
+ }
182
+
183
+ // The body to write: `content`, plus a trailing `Related: [[a]] [[b]]` line for
184
+ // any `links[]` id not already present as an inline `[[id]]` (idempotent for
185
+ // CC-origin sets, whose links are already inline).
186
+ function bodyWithLinks(record) {
187
+ const content = record.content;
188
+ const links = Array.isArray(record.links) ? record.links : [];
189
+ const missing = links.filter((id) => !new RegExp(`\\[\\[${escapeRegExp(id)}\\]\\]`).test(content));
190
+ if (missing.length === 0) return content;
191
+ return `${content}\n\nRelated: ${missing.map((id) => `[[${id}]]`).join(' ')}`;
192
+ }
193
+
194
+ // Serialize one record as a `<id>.md` file body (2-space frontmatter, one
195
+ // trailing newline). `created`/`salience`/`source`/`embedding` have no slot.
196
+ function serializeMemoryFile(record) {
197
+ const type = KIND_TO_TYPE[record.kind];
198
+ return (
199
+ `---\n` +
200
+ `name: ${record.id}\n` +
201
+ `description: ${deriveHook(record)}\n` +
202
+ `metadata:\n` +
203
+ ` type: ${type}\n` +
204
+ `---\n\n` +
205
+ `${bodyWithLinks(record)}\n`
206
+ );
207
+ }
208
+
209
+ // The title-cased slug used as an index link label (`user-role` → `User Role`).
210
+ function titleCase(id) {
211
+ return id
212
+ .split('-')
213
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
214
+ .join(' ');
215
+ }
216
+
217
+ // Regenerate `MEMORY.md`: a header plus one `- [Title](id.md) — hook` line per
218
+ // record, in the given record order.
219
+ function renderIndex(records) {
220
+ const lines = ['# Memory index', ''];
221
+ for (const r of records) {
222
+ lines.push(`- [${titleCase(r.id)}](${r.id}.md) — ${deriveHook(r)}`);
223
+ }
224
+ return `${lines.join('\n')}\n`;
225
+ }
226
+
227
+ export const claudeCodeAdapter = {
228
+ name: 'claude-code',
229
+
230
+ /**
231
+ * @param {string} source - a Claude Code memory directory of `<slug>.md` files
232
+ * @returns {{ records: Array<object> }} one record per memory file (MEMORY.md excluded)
233
+ * @throws {ExportError} on a missing/non-directory source or a malformed file
234
+ */
235
+ read(source) {
236
+ return { records: readMemoryDir(source) };
237
+ },
238
+
239
+ // --- import side ----------------------------------------------------------
240
+
241
+ /**
242
+ * Split each record into what a CC file can hold vs. what it cannot. Every
243
+ * record is `supported` (written with its storable fields); `unsupported`
244
+ * names the CC-unstorable fields (`created`/`salience`/`source`/`embedding`)
245
+ * present on each record so import can report, not silently drop, them.
246
+ * @param {Array<object>} set
247
+ * @returns {{ supported: Array<object>, unsupported: Array<{id: string, fields: string[]}> }}
248
+ */
249
+ map(set) {
250
+ const unsupported = [];
251
+ for (const record of set) {
252
+ const fields = UNSUPPORTED_FIELDS.filter((f) => f in record);
253
+ if (fields.length > 0) unsupported.push({ id: record.id, fields });
254
+ }
255
+ return { supported: set, unsupported };
256
+ },
257
+
258
+ /**
259
+ * Read the existing records at an import target directory so `import.js` can
260
+ * merge by id. A missing directory is normal (a fresh write) → `[]`; a
261
+ * malformed existing file surfaces as an ImportError.
262
+ * @param {string} target - a Claude Code memory directory
263
+ * @returns {Array<object>} existing records ([] if the directory is absent)
264
+ */
265
+ readExisting(target) {
266
+ try {
267
+ statSync(target);
268
+ } catch {
269
+ return []; // absent target — a fresh write, not an error
270
+ }
271
+ try {
272
+ return readMemoryDir(target);
273
+ } catch (e) {
274
+ if (e instanceof ExportError) {
275
+ throw new ImportError(e.code, e.message, { file: e.file });
276
+ }
277
+ throw e;
278
+ }
279
+ },
280
+
281
+ /**
282
+ * Write the final record list as `<id>.md` files plus a regenerated
283
+ * `MEMORY.md`. Existing `*.md` files whose id is not in the incoming list are
284
+ * pruned so the directory reflects the set (this happens on `replace`, whose
285
+ * list is the incoming set alone; `merge`'s list includes the existing
286
+ * records, so nothing is pruned).
287
+ * @param {string} target - a Claude Code memory directory
288
+ * @param {Array<object>} records - the final record list from `import.js`
289
+ * @throws {ImportError} if the directory cannot be created or written
290
+ */
291
+ write(target, records) {
292
+ try {
293
+ mkdirSync(target, { recursive: true });
294
+ } catch (e) {
295
+ throw new ImportError('write_failed', `cannot create target directory ${target}: ${e.message}`, {
296
+ file: target,
297
+ });
298
+ }
299
+
300
+ try {
301
+ const keep = new Set(records.map((r) => `${r.id}.md`));
302
+ for (const name of readdirSync(target)) {
303
+ if (name.endsWith('.md') && name !== INDEX_FILE && !keep.has(name)) {
304
+ unlinkSync(join(target, name));
305
+ }
306
+ }
307
+ for (const record of records) {
308
+ writeFileSync(join(target, `${record.id}.md`), serializeMemoryFile(record));
309
+ }
310
+ writeFileSync(join(target, INDEX_FILE), renderIndex(records));
311
+ } catch (e) {
312
+ throw new ImportError('write_failed', `cannot write target ${target}: ${e.message}`, {
313
+ file: target,
314
+ });
315
+ }
316
+ },
317
+ };
@@ -0,0 +1,170 @@
1
+ // Folder adapter — the always-works case for both export (#2) and import (#3).
2
+ //
3
+ // Export side: reads a single `.json` file or every `.json` file in a directory
4
+ // and yields the raw record objects it finds, in a stable order (files
5
+ // name-sorted, record order within a file preserved). It is an *identity-ish*
6
+ // export: the folder is expected to already hold memport-shaped records, so
7
+ // records pass through unchanged — no field fabrication, no stripping. Anything
8
+ // malformed is a loud ExportError; whether the records are actually valid is
9
+ // decided downstream by validateMemorySet in the pipeline.
10
+ //
11
+ // Import side: the folder store is a single bare-array `.memport` file. It is
12
+ // full-fidelity, so `map` is a pure pass-through (`unsupported` always `[]`),
13
+ // `readExisting` returns the current records (or `[]` if the file is absent —
14
+ // writing to a fresh target is normal), and `write` emits the same 2-space,
15
+ // trailing-newline JSON as export, so the round-trip is byte-stable.
16
+
17
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { ExportError } from '../export.js';
20
+ import { ImportError } from '../import.js';
21
+
22
+ // Resolve `source` to the list of `.json` files to read, name-sorted for
23
+ // determinism. A directory contributes its top-level `*.json` entries (no
24
+ // recursion); a single file is used as-is.
25
+ function listFiles(source) {
26
+ let stat;
27
+ try {
28
+ stat = statSync(source);
29
+ } catch {
30
+ throw new ExportError('source_not_found', `source not found: ${source}`, {
31
+ file: source,
32
+ });
33
+ }
34
+
35
+ if (stat.isDirectory()) {
36
+ return readdirSync(source)
37
+ .filter((name) => name.endsWith('.json'))
38
+ .sort((a, b) => a.localeCompare(b))
39
+ .map((name) => join(source, name));
40
+ }
41
+
42
+ return [source];
43
+ }
44
+
45
+ export const folderAdapter = {
46
+ name: 'folder',
47
+ /**
48
+ * @param {string} source - a directory of `.json` files or a single `.json` file
49
+ * @returns {{ records: Array<object> }} raw records in stable order
50
+ */
51
+ read(source) {
52
+ const files = listFiles(source);
53
+ const records = [];
54
+
55
+ for (const file of files) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
59
+ } catch (e) {
60
+ throw new ExportError('invalid_json', `invalid JSON in ${file}: ${e.message}`, {
61
+ file,
62
+ });
63
+ }
64
+
65
+ if (Array.isArray(parsed)) {
66
+ records.push(...parsed);
67
+ } else if (parsed !== null && typeof parsed === 'object') {
68
+ records.push(parsed);
69
+ } else {
70
+ // A JSON scalar (number, string, boolean, null) is neither a record nor
71
+ // a batch of records.
72
+ throw new ExportError(
73
+ 'invalid_json',
74
+ `${file} must contain a record object or an array of records`,
75
+ { file },
76
+ );
77
+ }
78
+ }
79
+
80
+ return { records };
81
+ },
82
+
83
+ // --- import side ----------------------------------------------------------
84
+
85
+ /**
86
+ * Map a set to what the folder target can hold. The folder store is
87
+ * full-fidelity, so this is a pure pass-through: everything is supported,
88
+ * nothing is dropped. Lossy real adapters (#5/#6) populate `unsupported`.
89
+ * @param {Array<object>} set
90
+ * @returns {{ supported: Array<object>, unsupported: Array<{id: string, fields: string[]}> }}
91
+ */
92
+ map(set) {
93
+ return { supported: set, unsupported: [] };
94
+ },
95
+
96
+ /**
97
+ * Read the *existing* records at an import target. Unlike `read` (export's
98
+ * strict source read), a missing target is normal — writing to a fresh file
99
+ * is the common case — so it yields `[]` rather than throwing.
100
+ * @param {string} target - a single `.json` / `.memport` file
101
+ * @returns {Array<object>} existing records ([] if the target does not exist)
102
+ * @throws {ImportError} if the target is a directory or holds non-array JSON
103
+ */
104
+ readExisting(target) {
105
+ let stat;
106
+ try {
107
+ stat = statSync(target);
108
+ } catch {
109
+ return []; // absent target — a fresh write, not an error
110
+ }
111
+
112
+ if (stat.isDirectory()) {
113
+ throw new ImportError(
114
+ 'target_is_directory',
115
+ `target is a directory: ${target} (folder import writes a single file)`,
116
+ { file: target },
117
+ );
118
+ }
119
+
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(readFileSync(target, 'utf8'));
123
+ } catch (e) {
124
+ throw new ImportError('invalid_json', `invalid JSON in target ${target}: ${e.message}`, {
125
+ file: target,
126
+ });
127
+ }
128
+
129
+ if (!Array.isArray(parsed)) {
130
+ throw new ImportError(
131
+ 'invalid_json',
132
+ `target ${target} must contain a memory set (a JSON array of records)`,
133
+ { file: target },
134
+ );
135
+ }
136
+
137
+ return parsed;
138
+ },
139
+
140
+ /**
141
+ * Write the final record list to the target file as a bare JSON array with
142
+ * 2-space indent + a trailing newline — byte-identical to export's output.
143
+ * @param {string} target - a single `.json` / `.memport` file path
144
+ * @param {Array<object>} records
145
+ * @throws {ImportError} if the target is a directory or cannot be written
146
+ */
147
+ write(target, records) {
148
+ let stat;
149
+ try {
150
+ stat = statSync(target);
151
+ } catch {
152
+ stat = undefined;
153
+ }
154
+ if (stat && stat.isDirectory()) {
155
+ throw new ImportError(
156
+ 'target_is_directory',
157
+ `target is a directory: ${target} (folder import writes a single file)`,
158
+ { file: target },
159
+ );
160
+ }
161
+
162
+ try {
163
+ writeFileSync(target, `${JSON.stringify(records, null, 2)}\n`);
164
+ } catch (e) {
165
+ throw new ImportError('write_failed', `cannot write target ${target}: ${e.message}`, {
166
+ file: target,
167
+ });
168
+ }
169
+ },
170
+ };
@@ -0,0 +1,55 @@
1
+ // Export-adapter registry (#2).
2
+ //
3
+ // The seam that lets Mem0 (#5) / Claude Code (#6) slot in by adding one file and
4
+ // one entry here — the pipeline and CLI stay adapter-agnostic. v1 ships only the
5
+ // folder adapter.
6
+
7
+ import { ExportError } from '../export.js';
8
+ import { ImportError } from '../import.js';
9
+ import { folderAdapter } from './folder.js';
10
+ import { claudeCodeAdapter } from './claude-code.js';
11
+
12
+ export const EXPORT_ADAPTERS = {
13
+ folder: folderAdapter,
14
+ 'claude-code': claudeCodeAdapter,
15
+ };
16
+
17
+ // A separate registry for import: export adapters are read-only by contract,
18
+ // while import needs read+write. The same `folderAdapter` object satisfies both
19
+ // (it gained `map`/`readExisting`/`write`), but keeping the registries distinct
20
+ // keeps the read-only vs read-write contracts explicit and lets a future store
21
+ // support one direction only.
22
+ export const IMPORT_ADAPTERS = {
23
+ folder: folderAdapter,
24
+ 'claude-code': claudeCodeAdapter,
25
+ };
26
+
27
+ /**
28
+ * Look up an export adapter by name.
29
+ * @param {string} name
30
+ * @returns {{ name: string, read: Function }}
31
+ * @throws {ExportError} with code `unknown_adapter` if the name is unregistered
32
+ */
33
+ export function getAdapter(name) {
34
+ const adapter = EXPORT_ADAPTERS[name];
35
+ if (!adapter) {
36
+ const known = Object.keys(EXPORT_ADAPTERS).join(', ');
37
+ throw new ExportError('unknown_adapter', `unknown adapter: ${name} (known: ${known})`);
38
+ }
39
+ return adapter;
40
+ }
41
+
42
+ /**
43
+ * Look up an import adapter by name.
44
+ * @param {string} name
45
+ * @returns {{ name: string, map: Function, readExisting: Function, write: Function }}
46
+ * @throws {ImportError} with code `unknown_adapter` if the name is unregistered
47
+ */
48
+ export function getImportAdapter(name) {
49
+ const adapter = IMPORT_ADAPTERS[name];
50
+ if (!adapter) {
51
+ const known = Object.keys(IMPORT_ADAPTERS).join(', ');
52
+ throw new ImportError('unknown_adapter', `unknown adapter: ${name} (known: ${known})`);
53
+ }
54
+ return adapter;
55
+ }
@@ -0,0 +1,140 @@
1
+ // memport conformance — score how much memory survives a round-trip (#4).
2
+ //
3
+ // The headline metric: given two memport sets — the memory *before* a
4
+ // round-trip or migration and the memory *after* — score how much survived and
5
+ // name exactly what was lost or degraded. Pure and read-only: no adapters, no
6
+ // stores, no writes. Inputs are already memport sets (e.g. two `exportMemory`
7
+ // outputs), so the pipeline is just:
8
+ // 1. validate *both* sets with `validateMemorySet` — matching depends on
9
+ // reliable, unique ids, so garbage is rejected before scoring,
10
+ // 2. resolve the identity strategy (`opts.identity`, default `'id'`) and
11
+ // index `after` by id,
12
+ // 3. walk `before` in order: an id absent from `after` is `lost`; an id
13
+ // present is `preserved` (every field identical) or `degraded` (≥1 field
14
+ // dropped/changed/added, itemized),
15
+ // 4. return `{ score, total, preserved, lost, degraded }` where
16
+ // `score = preserved / total` (rounded to 4 decimals; `total === 0 → 1`).
17
+
18
+ import { validateMemorySet } from './validate.js';
19
+
20
+ // The supported identity matchers (the seam). v1 lists only exact-`id` match;
21
+ // content-hash / fuzzy are roadmap follow-ups that slot in here.
22
+ export const CONFORMANCE_IDENTITIES = ['id'];
23
+
24
+ /**
25
+ * Error thrown by the conformance scorer. Mirrors `ExportError` / `ImportError`
26
+ * so the CLI renders all three uniformly: `.code` names the failure for
27
+ * exit-code mapping, `.errors` carries the #1 structured errors on an invalid
28
+ * input, `.side` names which set (`'before'` | `'after'`) was invalid, and
29
+ * `.file` names the offending file (set by the CLI for messaging).
30
+ */
31
+ export class ConformanceError extends Error {
32
+ constructor(code, message, { errors, side, file } = {}) {
33
+ super(message);
34
+ this.name = 'ConformanceError';
35
+ this.code = code;
36
+ if (errors !== undefined) this.errors = errors;
37
+ if (side !== undefined) this.side = side;
38
+ if (file !== undefined) this.file = file;
39
+ }
40
+ }
41
+
42
+ // Small recursive structural equality for JSON values. Records are JSON, so
43
+ // structural equality is well-defined and zero-dep. Primitives compared strict;
44
+ // arrays elementwise by length+index (so `embedding` / `links` order matters);
45
+ // plain objects by same key set + recursive values.
46
+ function deepEqual(x, y) {
47
+ if (x === y) return true;
48
+
49
+ if (Array.isArray(x) || Array.isArray(y)) {
50
+ if (!Array.isArray(x) || !Array.isArray(y)) return false;
51
+ if (x.length !== y.length) return false;
52
+ return x.every((el, i) => deepEqual(el, y[i]));
53
+ }
54
+
55
+ if (typeof x === 'object' && x !== null && typeof y === 'object' && y !== null) {
56
+ const kx = Object.keys(x);
57
+ const ky = Object.keys(y);
58
+ if (kx.length !== ky.length) return false;
59
+ return kx.every((k) => Object.prototype.hasOwnProperty.call(y, k) && deepEqual(x[k], y[k]));
60
+ }
61
+
62
+ return false;
63
+ }
64
+
65
+ // The sorted list of field names that differ between two matched records, over
66
+ // the union of their keys minus `ignore`. A key present on only one side is
67
+ // always a difference (a dropped or added field). Sorted with `localeCompare`
68
+ // for a deterministic order.
69
+ function diffFields(a, b, ignore) {
70
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
71
+ const fields = [];
72
+ for (const k of keys) {
73
+ if (ignore.has(k)) continue;
74
+ if (!deepEqual(a[k], b[k])) fields.push(k);
75
+ }
76
+ return fields.sort((x, y) => x.localeCompare(y));
77
+ }
78
+
79
+ function round4(n) {
80
+ return Math.round(n * 1e4) / 1e4;
81
+ }
82
+
83
+ /**
84
+ * Score how much of `before` survived into `after`.
85
+ * @param {Array<object>} before - the reference set (what we ask to survive)
86
+ * @param {Array<object>} after - the set to check it against
87
+ * @param {{ identity?: string, ignoreFields?: string[] }} [opts]
88
+ * @returns {{ score: number, total: number, preserved: number, lost: Array<{id: string, kind: string, content: string}>, degraded: Array<{id: string, fields: string[]}> }}
89
+ * @throws {ConformanceError} on an invalid input set (`invalid_set`, carrying
90
+ * `.side` + the #1 `.errors`) or an unknown identity (`unknown_identity`).
91
+ */
92
+ export function scoreConformance(before, after, opts = {}) {
93
+ // Validate both sets first — reliable, unique ids are what matching depends
94
+ // on, so comparing garbage is meaningless. Report the first invalid side.
95
+ for (const [side, set] of [['before', before], ['after', after]]) {
96
+ const { valid, errors } = validateMemorySet(set);
97
+ if (!valid) {
98
+ throw new ConformanceError('invalid_set', `${side} is an invalid memory set`, {
99
+ side,
100
+ errors,
101
+ });
102
+ }
103
+ }
104
+
105
+ const identity = opts.identity ?? 'id';
106
+ if (!CONFORMANCE_IDENTITIES.includes(identity)) {
107
+ throw new ConformanceError(
108
+ 'unknown_identity',
109
+ `unknown identity: ${identity} (known: ${CONFORMANCE_IDENTITIES.join(', ')})`,
110
+ );
111
+ }
112
+
113
+ const ignore = new Set(opts.ignoreFields ?? []);
114
+
115
+ // ids are unique post-validation, so no record is silently clobbered.
116
+ const afterById = new Map(after.map((r) => [r.id, r]));
117
+
118
+ const lost = [];
119
+ const degraded = [];
120
+ let preserved = 0;
121
+
122
+ for (const rec of before) {
123
+ const other = afterById.get(rec.id);
124
+ if (other === undefined) {
125
+ lost.push({ id: rec.id, kind: rec.kind, content: rec.content });
126
+ continue;
127
+ }
128
+ const fields = diffFields(rec, other, ignore);
129
+ if (fields.length === 0) {
130
+ preserved++;
131
+ } else {
132
+ degraded.push({ id: rec.id, fields });
133
+ }
134
+ }
135
+
136
+ const total = before.length;
137
+ const score = total === 0 ? 1 : round4(preserved / total);
138
+
139
+ return { score, total, preserved, lost, degraded };
140
+ }