@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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/bin/memport.js +377 -0
- package/package.json +44 -0
- package/src/adapters/claude-code.js +317 -0
- package/src/adapters/folder.js +170 -0
- package/src/adapters/index.js +55 -0
- package/src/conformance.js +140 -0
- package/src/export.js +51 -0
- package/src/import.js +119 -0
- package/src/index.js +6 -0
- package/src/validate.js +170 -0
|
@@ -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
|
+
}
|