@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
package/src/export.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// memport export — read a memory store into the neutral memport format (#2).
|
|
2
|
+
//
|
|
3
|
+
// A thin, adapter-based pipeline on top of the #1 schema:
|
|
4
|
+
// 1. select an export adapter (default `folder`) from the registry,
|
|
5
|
+
// 2. call its `read` to get raw record objects from the store,
|
|
6
|
+
// 3. run them through `validateMemorySet`,
|
|
7
|
+
// 4. return the array on success, or throw an ExportError on failure.
|
|
8
|
+
//
|
|
9
|
+
// The output is exactly a memory set — a bare JS array `validate`/`import`
|
|
10
|
+
// understand — so `exportMemory` composes directly with import (#3) and
|
|
11
|
+
// conformance (#4). Export does I/O, so unlike the validators it can throw:
|
|
12
|
+
// failures surface as an ExportError carrying the structured #1 errors.
|
|
13
|
+
|
|
14
|
+
import { validateMemorySet } from './validate.js';
|
|
15
|
+
import { getAdapter } from './adapters/index.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error thrown by the export pipeline. `.code` names the failure so the CLI can
|
|
19
|
+
* map it to an exit code; `.errors` carries the #1 structured errors on an
|
|
20
|
+
* invalid set, and `.file` names the offending file for I/O failures.
|
|
21
|
+
*/
|
|
22
|
+
export class ExportError extends Error {
|
|
23
|
+
constructor(code, message, { errors, file } = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'ExportError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (errors !== undefined) this.errors = errors;
|
|
28
|
+
if (file !== undefined) this.file = file;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a memory store into a validated memory set.
|
|
34
|
+
* @param {string} source - a directory or `.json` file (folder adapter), etc.
|
|
35
|
+
* @param {{ adapter?: string }} [opts]
|
|
36
|
+
* @returns {Array<object>} the memory set (passes validateMemorySet)
|
|
37
|
+
* @throws {ExportError} on unknown adapter, I/O failure, or an invalid set
|
|
38
|
+
*/
|
|
39
|
+
export function exportMemory(source, opts = {}) {
|
|
40
|
+
const adapter = getAdapter(opts.adapter ?? 'folder');
|
|
41
|
+
const { records } = adapter.read(source, opts);
|
|
42
|
+
|
|
43
|
+
const { valid, errors } = validateMemorySet(records);
|
|
44
|
+
if (!valid) {
|
|
45
|
+
throw new ExportError('invalid_set', 'export produced an invalid memory set', {
|
|
46
|
+
errors,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return records;
|
|
51
|
+
}
|
package/src/import.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// memport import — write a memport set into a target store (#3).
|
|
2
|
+
//
|
|
3
|
+
// The mirror of export (#2): where export reads a store *out* into the neutral
|
|
4
|
+
// format and validates its *output*, import takes a neutral set and writes it
|
|
5
|
+
// *into* a target, validating its *input* first. Same adapter seam, opposite
|
|
6
|
+
// direction:
|
|
7
|
+
// 1. validate the incoming set with `validateMemorySet` — reject garbage
|
|
8
|
+
// before touching the target (symmetric to export's output check),
|
|
9
|
+
// 2. select an import adapter (default `folder`) from the registry,
|
|
10
|
+
// 3. `map` the set to what the target can hold, splitting off an
|
|
11
|
+
// `unsupported` list of `{ id, fields }` (empty for the full-fidelity
|
|
12
|
+
// folder target — but the reporting path is real, because loudly naming
|
|
13
|
+
// what a target cannot represent is the whole point of import),
|
|
14
|
+
// 4. read the target's existing records and apply the merge `mode`
|
|
15
|
+
// (`merge` | `replace` | `fail`) with an adapter-agnostic helper,
|
|
16
|
+
// 5. write the final list back via the adapter,
|
|
17
|
+
// 6. return `{ written, skipped, unsupported }`.
|
|
18
|
+
|
|
19
|
+
import { validateMemorySet } from './validate.js';
|
|
20
|
+
import { getImportAdapter } from './adapters/index.js';
|
|
21
|
+
|
|
22
|
+
// The merge modes. `merge` (default) is additive/non-destructive; `replace`
|
|
23
|
+
// discards existing target records; `fail` refuses a non-empty target.
|
|
24
|
+
export const IMPORT_MODES = ['merge', 'replace', 'fail'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Error thrown by the import pipeline. Mirrors `ExportError`'s shape so the CLI
|
|
28
|
+
* renders both uniformly: `.code` names the failure for exit-code mapping,
|
|
29
|
+
* `.errors` carries the #1 structured errors on an invalid input set, and
|
|
30
|
+
* `.file` names the offending file for I/O failures.
|
|
31
|
+
*/
|
|
32
|
+
export class ImportError extends Error {
|
|
33
|
+
constructor(code, message, { errors, file } = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'ImportError';
|
|
36
|
+
this.code = code;
|
|
37
|
+
if (errors !== undefined) this.errors = errors;
|
|
38
|
+
if (file !== undefined) this.file = file;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Adapter-agnostic merge, keyed by `id`. Returns the final record list plus the
|
|
43
|
+
// written/skipped counts. Order is fixed for determinism.
|
|
44
|
+
// - replace → the incoming set wins outright.
|
|
45
|
+
// - merge → existing kept as-is; incoming records with a *new* id appended
|
|
46
|
+
// (written), incoming records whose id already exists dropped
|
|
47
|
+
// (skipped, existing wins — non-destructive). Existing order
|
|
48
|
+
// first, then new records in incoming order.
|
|
49
|
+
// - fail → the caller rejects a non-empty target before calling; on an
|
|
50
|
+
// empty target this behaves like replace.
|
|
51
|
+
function applyMode(existing, incoming, mode) {
|
|
52
|
+
if (mode === 'replace' || mode === 'fail') {
|
|
53
|
+
return { final: incoming, written: incoming.length, skipped: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// mode === 'merge'
|
|
57
|
+
const existingIds = new Set(existing.map((r) => r.id));
|
|
58
|
+
const newOnes = [];
|
|
59
|
+
let skipped = 0;
|
|
60
|
+
for (const record of incoming) {
|
|
61
|
+
if (existingIds.has(record.id)) {
|
|
62
|
+
skipped++;
|
|
63
|
+
} else {
|
|
64
|
+
newOnes.push(record);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
final: existing.concat(newOnes),
|
|
69
|
+
written: newOnes.length,
|
|
70
|
+
skipped,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write a memport set into a target store.
|
|
76
|
+
* @param {Array<object>} set - a memport set (e.g. the output of `exportMemory`)
|
|
77
|
+
* @param {string} target - the target store (a `.json` / `.memport` file for folder)
|
|
78
|
+
* @param {{ adapter?: string, mode?: string }} [opts]
|
|
79
|
+
* @returns {{ written: number, skipped: number, unsupported: Array<{id: string, fields: string[]}> }}
|
|
80
|
+
* @throws {ImportError} on an invalid set, unknown adapter/mode, a blocked
|
|
81
|
+
* `fail` target, or an I/O failure — always *before* any partial write.
|
|
82
|
+
*/
|
|
83
|
+
export function importMemory(set, target, opts = {}) {
|
|
84
|
+
const { valid, errors } = validateMemorySet(set);
|
|
85
|
+
if (!valid) {
|
|
86
|
+
throw new ImportError('invalid_set', 'import received an invalid memory set', {
|
|
87
|
+
errors,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const adapter = getImportAdapter(opts.adapter ?? 'folder');
|
|
92
|
+
|
|
93
|
+
const mode = opts.mode ?? 'merge';
|
|
94
|
+
if (!IMPORT_MODES.includes(mode)) {
|
|
95
|
+
throw new ImportError('unknown_mode', `unknown mode: ${mode} (known: ${IMPORT_MODES.join(', ')})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Map the set to what the target can hold, splitting off anything the target
|
|
99
|
+
// cannot represent (empty for the full-fidelity folder target).
|
|
100
|
+
const { supported, unsupported } = adapter.map(set, opts);
|
|
101
|
+
|
|
102
|
+
// Read existing target records (empty for a not-yet-existing target — writing
|
|
103
|
+
// to a fresh file is the common case, not an error).
|
|
104
|
+
const existing = adapter.readExisting(target, opts);
|
|
105
|
+
|
|
106
|
+
if (mode === 'fail' && existing.length > 0) {
|
|
107
|
+
throw new ImportError(
|
|
108
|
+
'target_not_empty',
|
|
109
|
+
`target already contains ${existing.length} record${existing.length === 1 ? '' : 's'}; refusing to import with --mode fail`,
|
|
110
|
+
{ file: target },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { final, written, skipped } = applyMode(existing, supported, mode);
|
|
115
|
+
|
|
116
|
+
adapter.write(target, final, opts);
|
|
117
|
+
|
|
118
|
+
return { written, skipped, unsupported };
|
|
119
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public surface for @avee1234/memport.
|
|
2
|
+
export { validateMemoryRecord, validateMemorySet, KINDS } from './validate.js';
|
|
3
|
+
export { exportMemory, ExportError } from './export.js';
|
|
4
|
+
export { importMemory, ImportError, IMPORT_MODES } from './import.js';
|
|
5
|
+
export { scoreConformance, ConformanceError, CONFORMANCE_IDENTITIES } from './conformance.js';
|
|
6
|
+
export { EXPORT_ADAPTERS, IMPORT_ADAPTERS } from './adapters/index.js';
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Portable memory-record schema + validators (v0.1).
|
|
2
|
+
//
|
|
3
|
+
// Hand-written, zero-dependency. Both validators are pure and never throw:
|
|
4
|
+
// bad input is reported as a structured error, not an exception, so adapters
|
|
5
|
+
// can collect and report all problems instead of catching.
|
|
6
|
+
//
|
|
7
|
+
// Error shape:
|
|
8
|
+
// record-level: { code, path, message }
|
|
9
|
+
// set-level: { index, code, path, message } // adds the record's index
|
|
10
|
+
// Return value is always { valid, errors } with valid === (errors.length === 0).
|
|
11
|
+
|
|
12
|
+
export const KINDS = ['fact', 'preference', 'event', 'entity'];
|
|
13
|
+
|
|
14
|
+
// --- helper predicates -------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function isPlainObject(v) {
|
|
17
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(v) {
|
|
21
|
+
return typeof v === 'string' && v.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Date.parse is lenient (some engines accept "yesterday" or partial forms), so
|
|
25
|
+
// we pair it with a shape guard requiring an ISO-8601-ish string: a date, an
|
|
26
|
+
// optional time, and an optional Z or ±HH:MM offset.
|
|
27
|
+
const ISO_8601 =
|
|
28
|
+
/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
29
|
+
|
|
30
|
+
function isIsoTimestamp(v) {
|
|
31
|
+
return typeof v === 'string' && ISO_8601.test(v) && !Number.isNaN(Date.parse(v));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isStringArray(v) {
|
|
35
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isNumberArray(v) {
|
|
39
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'number' && !Number.isNaN(x));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- record validator --------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a single memory record.
|
|
46
|
+
* @param {unknown} obj
|
|
47
|
+
* @returns {{ valid: boolean, errors: Array<{code: string, path: string, message: string}> }}
|
|
48
|
+
*/
|
|
49
|
+
export function validateMemoryRecord(obj) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const err = (code, path, message) => errors.push({ code, path, message });
|
|
52
|
+
|
|
53
|
+
if (!isPlainObject(obj)) {
|
|
54
|
+
err('not_an_object', '', 'record must be a plain JSON object');
|
|
55
|
+
return { valid: false, errors };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Required: id, content, kind, created.
|
|
59
|
+
if (!('id' in obj)) {
|
|
60
|
+
err('missing_field', 'id', 'id is required');
|
|
61
|
+
} else if (!isNonEmptyString(obj.id)) {
|
|
62
|
+
err('empty_string', 'id', 'id must be a non-empty string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!('content' in obj)) {
|
|
66
|
+
err('missing_field', 'content', 'content is required');
|
|
67
|
+
} else if (!isNonEmptyString(obj.content)) {
|
|
68
|
+
err('empty_string', 'content', 'content must be a non-empty string');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!('kind' in obj)) {
|
|
72
|
+
err('missing_field', 'kind', 'kind is required');
|
|
73
|
+
} else if (!KINDS.includes(obj.kind)) {
|
|
74
|
+
err('invalid_enum', 'kind', `kind must be one of ${KINDS.join(' | ')}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!('created' in obj)) {
|
|
78
|
+
err('missing_field', 'created', 'created is required');
|
|
79
|
+
} else if (!isIsoTimestamp(obj.created)) {
|
|
80
|
+
err('invalid_iso8601', 'created', 'created must be an ISO 8601 timestamp');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Optional fields — only checked when present.
|
|
84
|
+
if ('salience' in obj) {
|
|
85
|
+
if (typeof obj.salience !== 'number' || Number.isNaN(obj.salience)) {
|
|
86
|
+
err('wrong_type', 'salience', 'salience must be a number');
|
|
87
|
+
} else if (obj.salience < 0 || obj.salience > 1) {
|
|
88
|
+
err('out_of_range', 'salience', 'salience must be in [0, 1]');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ('source' in obj && !isNonEmptyString(obj.source)) {
|
|
93
|
+
err('empty_string', 'source', 'source must be a non-empty string');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if ('links' in obj && !isStringArray(obj.links)) {
|
|
97
|
+
err('wrong_type', 'links', 'links must be an array of strings');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ('embedding' in obj && !isNumberArray(obj.embedding)) {
|
|
101
|
+
err('wrong_type', 'embedding', 'embedding must be an array of numbers');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Unknown/extra fields are allowed and preserved — not an error.
|
|
105
|
+
|
|
106
|
+
return { valid: errors.length === 0, errors };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- set validator -----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate a memory set (array of records). Runs the record validator per
|
|
113
|
+
* element (prefixing each error with its index), then two cross-record checks:
|
|
114
|
+
* unique ids and referential integrity of links.
|
|
115
|
+
* @param {unknown} arr
|
|
116
|
+
* @returns {{ valid: boolean, errors: Array<{index?: number, code: string, path: string, message: string}> }}
|
|
117
|
+
*/
|
|
118
|
+
export function validateMemorySet(arr) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
|
|
121
|
+
if (!Array.isArray(arr)) {
|
|
122
|
+
errors.push({
|
|
123
|
+
code: 'not_an_array',
|
|
124
|
+
path: '',
|
|
125
|
+
message: 'memory set must be a JSON array of records',
|
|
126
|
+
});
|
|
127
|
+
return { valid: false, errors };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Per-record validation, tagged with the record's index.
|
|
131
|
+
arr.forEach((record, index) => {
|
|
132
|
+
const { errors: recErrors } = validateMemoryRecord(record);
|
|
133
|
+
for (const e of recErrors) errors.push({ index, ...e });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Collect the set's ids for the cross-record checks.
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const ids = new Set();
|
|
139
|
+
arr.forEach((record, index) => {
|
|
140
|
+
if (!isPlainObject(record) || !isNonEmptyString(record.id)) return;
|
|
141
|
+
if (seen.has(record.id)) {
|
|
142
|
+
errors.push({
|
|
143
|
+
index,
|
|
144
|
+
code: 'duplicate_id',
|
|
145
|
+
path: 'id',
|
|
146
|
+
message: `duplicate id: ${record.id}`,
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
seen.add(record.id);
|
|
150
|
+
}
|
|
151
|
+
ids.add(record.id);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Dangling links — a link that points to an id absent from the set.
|
|
155
|
+
arr.forEach((record, index) => {
|
|
156
|
+
if (!isPlainObject(record) || !isStringArray(record.links)) return;
|
|
157
|
+
record.links.forEach((linkId) => {
|
|
158
|
+
if (!ids.has(linkId)) {
|
|
159
|
+
errors.push({
|
|
160
|
+
index,
|
|
161
|
+
code: 'dangling_link',
|
|
162
|
+
path: 'links',
|
|
163
|
+
message: `link points to unknown id: ${linkId}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return { valid: errors.length === 0, errors };
|
|
170
|
+
}
|