@vib795/agent-memory 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
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { relative, sep } from 'node:path';
3
+ import { paths } from './config.js';
4
+ import { ensureStore, listNotes, contentHash, nowIso, touchNoteAccessed } from './store.js';
5
+
6
+ /**
7
+ * The index is a cache, never a source of truth.
8
+ *
9
+ * Every row here is derived from a markdown note. Deleting index.db and running
10
+ * `agent-memory index` must reproduce identical query output, which is what makes
11
+ * it safe to throw the database away when anything looks wrong.
12
+ */
13
+
14
+ // 2: nodes_fts gained the porter stemmer. Bumping rebuilds the index on next open,
15
+ // which costs nothing to get wrong because it is derived entirely from the markdown.
16
+ const SCHEMA_VERSION = 2;
17
+
18
+ const DDL = `
19
+ CREATE TABLE IF NOT EXISTS nodes (
20
+ id TEXT PRIMARY KEY,
21
+ type TEXT NOT NULL CHECK (type IN ('system','decision','convention','constraint')),
22
+ title TEXT NOT NULL,
23
+ scope TEXT NOT NULL CHECK (scope IN ('repo','global')),
24
+ confidence TEXT NOT NULL CHECK (confidence IN ('observed','inferred')),
25
+ source TEXT,
26
+ body TEXT NOT NULL,
27
+ path TEXT NOT NULL,
28
+ content_hash TEXT NOT NULL,
29
+ captured_sha TEXT,
30
+ archived INTEGER NOT NULL DEFAULT 0,
31
+ created TEXT NOT NULL,
32
+ updated TEXT NOT NULL,
33
+ accessed TEXT
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS node_repos (
37
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
38
+ repo TEXT NOT NULL,
39
+ PRIMARY KEY (node_id, repo)
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS edges (
43
+ src TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
44
+ dst TEXT NOT NULL,
45
+ rel TEXT NOT NULL CHECK (rel IN ('depends-on','applies-to','supersedes','contradicts','evidence-for')),
46
+ PRIMARY KEY (src, dst, rel)
47
+ );
48
+
49
+ CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
50
+ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type) WHERE archived = 0;
51
+ CREATE INDEX IF NOT EXISTS idx_node_repos_repo ON node_repos(repo);
52
+
53
+ -- The porter stemmer is what lets a question find a note. Without it "secrets"
54
+ -- misses "secret" and "written" misses "write", which is precisely the mismatch
55
+ -- natural questions produce, and asking questions is the entire use case.
56
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts
57
+ USING fts5(title, body, content='nodes', content_rowid='rowid',
58
+ tokenize='porter unicode61');
59
+ `;
60
+
61
+ const DROP = `
62
+ DROP TABLE IF EXISTS nodes_fts;
63
+ DROP TABLE IF EXISTS edges;
64
+ DROP TABLE IF EXISTS node_repos;
65
+ DROP TABLE IF EXISTS nodes;
66
+ `;
67
+
68
+ /** Flipped off once we learn this build of node:sqlite lacks FTS5. */
69
+ let ftsAvailable = true;
70
+
71
+ /**
72
+ * Open the index, creating or rebuilding it as needed.
73
+ *
74
+ * A schema version mismatch rebuilds from notes/ rather than migrating. Migrating
75
+ * a cache is work with no payoff when the source of truth is sitting right there.
76
+ */
77
+ export function openDb({ reindexOnCreate = true } = {}) {
78
+ ensureStore();
79
+ const db = new DatabaseSync(paths.db);
80
+ // busy_timeout must come first. Every pragma below can contend with another VS
81
+ // Code window, and until the timeout is set they fail instantly on SQLITE_BUSY
82
+ // rather than waiting. Two windows open at once is the normal working mode here.
83
+ db.exec('PRAGMA busy_timeout = 5000');
84
+ try {
85
+ // WAL lets a reader in one window proceed while another window writes. It is a
86
+ // persistent property of the file, so losing this race is harmless: whichever
87
+ // process won already set it, and this connection inherits the result.
88
+ db.exec('PRAGMA journal_mode = WAL');
89
+ } catch {
90
+ /* already WAL, or another connection is mid-switch */
91
+ }
92
+ db.exec('PRAGMA foreign_keys = ON');
93
+
94
+ const version = db.prepare('PRAGMA user_version').get()?.user_version ?? 0;
95
+ if (version !== SCHEMA_VERSION) {
96
+ db.exec(DROP);
97
+ createSchema(db);
98
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
99
+ if (reindexOnCreate) reindex(db);
100
+ }
101
+ return db;
102
+ }
103
+
104
+ function createSchema(db) {
105
+ try {
106
+ db.exec(DDL);
107
+ } catch (err) {
108
+ // A build without FTS5 still gets a working graph; only search degrades.
109
+ if (!/fts5/i.test(String(err.message))) throw err;
110
+ ftsAvailable = false;
111
+ db.exec(DDL.slice(0, DDL.indexOf('CREATE VIRTUAL TABLE')));
112
+ }
113
+ }
114
+
115
+ /** Store paths relative and slash-separated so the index is not machine-shaped. */
116
+ function relPath(abs) {
117
+ return relative(paths.root, abs).split(sep).join('/');
118
+ }
119
+
120
+ /**
121
+ * Rebuild every row from notes/. Idempotent by construction: it truncates first.
122
+ *
123
+ * Notes are sorted by id before insert so two rebuilds of the same store produce
124
+ * the same rowids, which is what keeps `search` result order stable.
125
+ */
126
+ export function reindex(db) {
127
+ const all = listNotes();
128
+ const malformed = all.filter((n) => n.__error).map((n) => ({ path: n.path, error: n.__error }));
129
+
130
+ const good = all
131
+ .filter((n) => !n.__error && typeof n.id === 'string' && n.id)
132
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.path < b.path ? -1 : 1));
133
+
134
+ const seen = new Map();
135
+ const duplicates = [];
136
+ const rows = [];
137
+ for (const n of good) {
138
+ if (seen.has(n.id)) {
139
+ // Two files claiming one id. Keep the first by sort order and say so, rather
140
+ // than letting INSERT OR REPLACE quietly pick a winner.
141
+ duplicates.push({ id: n.id, kept: relPath(seen.get(n.id).path), dropped: relPath(n.path) });
142
+ continue;
143
+ }
144
+ seen.set(n.id, n);
145
+ rows.push(n);
146
+ }
147
+
148
+ db.exec('BEGIN IMMEDIATE');
149
+ try {
150
+ db.exec('DELETE FROM nodes');
151
+ db.exec('DELETE FROM node_repos');
152
+ db.exec('DELETE FROM edges');
153
+
154
+ const insNode = db.prepare(`
155
+ INSERT INTO nodes (id, type, title, scope, confidence, source, body, path,
156
+ content_hash, captured_sha, archived, created, updated, accessed)
157
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
158
+ `);
159
+ const insRepo = db.prepare('INSERT OR IGNORE INTO node_repos (node_id, repo) VALUES (?, ?)');
160
+ const insEdge = db.prepare('INSERT OR IGNORE INTO edges (src, dst, rel) VALUES (?, ?, ?)');
161
+
162
+ for (const n of rows) {
163
+ const ts = n.created || nowIso();
164
+ insNode.run(
165
+ n.id,
166
+ n.type,
167
+ n.title ?? '',
168
+ n.scope || (n.repos?.length ? 'repo' : 'global'),
169
+ n.confidence || 'observed',
170
+ n.source ?? null,
171
+ n.body ?? '',
172
+ relPath(n.path),
173
+ contentHash(n),
174
+ n.captured_sha ?? null,
175
+ n.archived ? 1 : 0,
176
+ ts,
177
+ n.updated || ts,
178
+ n.accessed ?? null,
179
+ );
180
+ for (const repo of n.repos || []) insRepo.run(n.id, repo);
181
+ for (const e of n.edges || []) insEdge.run(n.id, e.dst, e.rel);
182
+ }
183
+
184
+ if (ftsAvailable) {
185
+ try {
186
+ db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')");
187
+ } catch {
188
+ ftsAvailable = false;
189
+ }
190
+ }
191
+ db.exec('COMMIT');
192
+ } catch (err) {
193
+ db.exec('ROLLBACK');
194
+ throw err;
195
+ }
196
+
197
+ return { indexed: rows.length, malformed, duplicates };
198
+ }
199
+
200
+ export function hasFts() {
201
+ return ftsAvailable;
202
+ }
203
+
204
+ export function nodeCount(db, { includeArchived = false } = {}) {
205
+ const sql = includeArchived
206
+ ? 'SELECT COUNT(*) AS c FROM nodes'
207
+ : 'SELECT COUNT(*) AS c FROM nodes WHERE archived = 0';
208
+ return db.prepare(sql).get().c;
209
+ }
210
+
211
+ /** Attach repos, outbound edges and inbound edges to a bare node row. */
212
+ export function hydrate(db, row) {
213
+ if (!row) return null;
214
+ row.repos = db
215
+ .prepare('SELECT repo FROM node_repos WHERE node_id = ? ORDER BY repo')
216
+ .all(row.id)
217
+ .map((r) => r.repo);
218
+ row.edges = db.prepare('SELECT rel, dst FROM edges WHERE src = ? ORDER BY rel, dst').all(row.id);
219
+ row.inbound = db.prepare('SELECT rel, src FROM edges WHERE dst = ? ORDER BY rel, src').all(row.id);
220
+ return row;
221
+ }
222
+
223
+ export function getNodeRow(db, id, { includeArchived = true } = {}) {
224
+ const row = db
225
+ .prepare('SELECT * FROM nodes WHERE id = ? AND (? = 1 OR archived = 0)')
226
+ .get(id, includeArchived ? 1 : 0);
227
+ return hydrate(db, row);
228
+ }
229
+
230
+ /**
231
+ * Full-text search, with a LIKE fallback.
232
+ *
233
+ * The fallback covers two real cases: a Node build without FTS5, and a query whose
234
+ * punctuation is ordinary English but invalid FTS5 syntax. Returning worse results
235
+ * beats returning a syntax error to someone who just asked a question.
236
+ */
237
+ export function searchNodes(db, terms, { limit = 10, includeArchived = false } = {}) {
238
+ const q = String(terms ?? '').trim();
239
+ if (!q) return [];
240
+ const arch = includeArchived ? 1 : 0;
241
+
242
+ if (ftsAvailable) {
243
+ const stmt = db.prepare(`
244
+ SELECT n.*, bm25(nodes_fts) AS rank
245
+ FROM nodes_fts
246
+ JOIN nodes n ON n.rowid = nodes_fts.rowid
247
+ WHERE nodes_fts MATCH ?
248
+ AND (? = 1 OR n.archived = 0)
249
+ ORDER BY rank, n.id
250
+ LIMIT ?
251
+ `);
252
+ try {
253
+ // Every term first, which is precise when the caller knows the vocabulary.
254
+ // Then any term, because people ask questions rather than name keywords, and
255
+ // "why did we avoid a native build step" shares only three words with the note
256
+ // that answers it. Requiring all of them means a question never matches.
257
+ // bm25 does the discriminating: rare words outrank "why" and "we" on their own.
258
+ for (const mode of ['all', 'any']) {
259
+ const expr = ftsQuery(q, mode);
260
+ if (!expr) break;
261
+ const rows = stmt.all(expr, arch, limit);
262
+ if (rows.length) return rows.map((r) => hydrate(db, r));
263
+ }
264
+ return [];
265
+ } catch {
266
+ // Fall through to LIKE rather than surfacing an FTS5 error to the caller.
267
+ }
268
+ }
269
+
270
+ const like = `%${q.replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
271
+ return db
272
+ .prepare(`
273
+ SELECT * FROM nodes
274
+ WHERE (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')
275
+ AND (? = 1 OR archived = 0)
276
+ ORDER BY id
277
+ LIMIT ?
278
+ `)
279
+ .all(like, like, arch, limit)
280
+ .map((r) => hydrate(db, r));
281
+ }
282
+
283
+ /**
284
+ * Quote each term so user punctuation cannot be read as an FTS5 operator.
285
+ *
286
+ * 'all' joins with a space, which FTS5 reads as AND. 'any' joins with OR.
287
+ * Returns null when there is nothing to search for, so the caller can stop.
288
+ */
289
+ function ftsQuery(q, mode = 'all') {
290
+ const terms = q.match(/[\p{L}\p{N}_]+/gu) || [];
291
+ if (!terms.length) return null;
292
+ const quoted = terms.map((t) => `"${t}"`);
293
+ return mode === 'any' ? quoted.join(' OR ') : quoted.join(' ');
294
+ }
295
+
296
+ /**
297
+ * Record that these nodes were read.
298
+ *
299
+ * Written through to the markdown as well, because decay reads `accessed` and the
300
+ * index can be deleted at any time. Skipped when the stored date is already today,
301
+ * so a read-heavy session does not rewrite the same file over and over.
302
+ */
303
+ export function markAccessed(db, ids) {
304
+ const ts = nowIso();
305
+ const today = ts.slice(0, 10);
306
+ const touched = [];
307
+ const upd = db.prepare('UPDATE nodes SET accessed = ? WHERE id = ?');
308
+ const sel = db.prepare('SELECT type, accessed, archived FROM nodes WHERE id = ?');
309
+ for (const id of ids) {
310
+ const row = sel.get(id);
311
+ if (!row) continue;
312
+ if (typeof row.accessed === 'string' && row.accessed.slice(0, 10) === today) continue;
313
+ upd.run(ts, id);
314
+ if (touchNoteAccessed(row.type, id, ts, !!row.archived)) touched.push(id);
315
+ }
316
+ return touched;
317
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * VS Code prompt files, generated from the skills.
3
+ *
4
+ * Copilot chat reads `<user data>/prompts/<name>.prompt.md` and exposes each one as
5
+ * `/<name>`. That is a different file format from an agent skill, but it must not
6
+ * become a second copy of the instructions: two hand-maintained copies of the same
7
+ * procedure drift, and the one nobody is looking at is the one that goes stale.
8
+ *
9
+ * So the prompt file is derived from SKILL.md at install time. The skill stays the
10
+ * single source of truth, and `compact` regenerates the description in both.
11
+ */
12
+
13
+ export const GENERATED_MARKER =
14
+ '<!-- generated by agent-memory from SKILL.md; edits here are overwritten -->';
15
+
16
+ /** Split a leading `---` frontmatter block from the body. */
17
+ function splitFrontmatter(text) {
18
+ const src = String(text).replace(/^/, '').replace(/\r\n/g, '\n');
19
+ if (!src.startsWith('---')) return { head: '', body: src.trim() };
20
+ const end = src.indexOf('\n---', 3);
21
+ if (end === -1) return { head: '', body: src.trim() };
22
+ return {
23
+ head: src.slice(3, end),
24
+ body: src.slice(end + 4).replace(/^\n+/, '').trimEnd(),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Read one scalar out of a frontmatter block.
30
+ * Handles the quoted form, since a regenerated description contains colons.
31
+ */
32
+ function field(head, key) {
33
+ const m = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(head);
34
+ if (!m) return null;
35
+ const raw = m[1].trim();
36
+ if (raw.startsWith('"')) {
37
+ try {
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return raw.slice(1, -1);
41
+ }
42
+ }
43
+ return raw;
44
+ }
45
+
46
+ /**
47
+ * Convert a SKILL.md into a VS Code prompt file.
48
+ *
49
+ * `mode: agent` because every one of these skills runs terminal commands; in ask
50
+ * mode they would render as text the model cannot act on.
51
+ */
52
+ export function toPromptFile(skillMarkdown, { name } = {}) {
53
+ const { head, body } = splitFrontmatter(skillMarkdown);
54
+ const description = field(head, 'description') || `agent-memory: ${name}`;
55
+
56
+ return [
57
+ '---',
58
+ 'mode: agent',
59
+ `description: ${JSON.stringify(description)}`,
60
+ '---',
61
+ '',
62
+ GENERATED_MARKER,
63
+ '',
64
+ body,
65
+ '',
66
+ ].join('\n');
67
+ }
68
+
69
+ /**
70
+ * Whether a file on disk was written by us.
71
+ *
72
+ * Uninstall removes prompt files by this marker rather than by filename. Somebody
73
+ * may have written their own `recall.prompt.md`, and deleting it because the name
74
+ * matched would be destroying work we were never asked to manage.
75
+ */
76
+ export function isGenerated(content) {
77
+ return String(content).includes(GENERATED_MARKER);
78
+ }
package/src/redact.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Fail-closed redaction.
3
+ *
4
+ * The store is plaintext on a corporate desktop and its contents are generated by
5
+ * a model from conversations that may contain secrets. Nothing reaches disk
6
+ * unscanned. A scan that throws aborts the write rather than letting unscanned
7
+ * bytes through, which is what "fail-closed" means here.
8
+ */
9
+
10
+ const PATTERNS = [
11
+ // Provider-specific credentials first; these are unambiguous.
12
+ { kind: 'aws-access-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
13
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
14
+ { kind: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
15
+ { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
16
+ { kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
17
+ { kind: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
18
+ {
19
+ kind: 'private-key',
20
+ re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
21
+ },
22
+ { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
23
+ { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/gi },
24
+ // Connection strings carry the credential inline, so the whole URI goes.
25
+ { kind: 'connection-string', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@[^\s/]+/gi },
26
+ // Assignment-shaped secrets. Requires quotes or a long unquoted run so that
27
+ // ordinary prose like "the password rotation policy" survives untouched.
28
+ {
29
+ kind: 'assigned-secret',
30
+ re: /\b(?:api[_-]?key|secret|password|passwd|pwd|token|client[_-]?secret|access[_-]?key)\b\s*[:=]\s*(?:"[^"\n]{6,}"|'[^'\n]{6,}'|[^\s"'`,;)]{12,})/gi,
31
+ },
32
+ {
33
+ kind: 'session-cookie',
34
+ re: /\b(?:session|sid|auth|jsessionid|phpsessid)[_-]?(?:id|token)?\s*=\s*[A-Za-z0-9._-]{16,}/gi,
35
+ },
36
+ {
37
+ kind: 'private-ip',
38
+ re: /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b/g,
39
+ },
40
+ { kind: 'internal-host', re: /\b[a-z0-9][a-z0-9-]*\.(?:internal|corp|local|intranet|lan)\b/gi },
41
+ ];
42
+
43
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
44
+
45
+ /**
46
+ * Scan text and return the redacted form plus the kinds that fired.
47
+ *
48
+ * @param {string} text
49
+ * @param {{ selfEmail?: string }} [opts] selfEmail is the local git identity, the
50
+ * one address left intact because it is already public in every commit.
51
+ * @returns {{ text: string, findings: Array<{kind: string, count: number}> }}
52
+ */
53
+ export function redact(text, opts = {}) {
54
+ if (typeof text !== 'string') {
55
+ throw new TypeError('redact() requires a string; refusing to write unscanned content');
56
+ }
57
+ const counts = new Map();
58
+ let out = text;
59
+
60
+ for (const { kind, re } of PATTERNS) {
61
+ out = out.replace(re, () => {
62
+ counts.set(kind, (counts.get(kind) || 0) + 1);
63
+ return `<redacted:${kind}>`;
64
+ });
65
+ }
66
+
67
+ const self = (opts.selfEmail || '').trim().toLowerCase();
68
+ out = out.replace(EMAIL_RE, (m) => {
69
+ if (self && m.toLowerCase() === self) return m;
70
+ counts.set('email', (counts.get('email') || 0) + 1);
71
+ return '<redacted:email>';
72
+ });
73
+
74
+ return {
75
+ text: out,
76
+ findings: [...counts.entries()].map(([kind, count]) => ({ kind, count })),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Redact every string field of a node.
82
+ * Returns the cleaned node and the union of findings across all fields.
83
+ */
84
+ export function redactNode(node, opts = {}) {
85
+ const findings = new Map();
86
+ const clean = { ...node };
87
+ for (const field of ['title', 'body']) {
88
+ if (typeof clean[field] !== 'string') continue;
89
+ const r = redact(clean[field], opts);
90
+ clean[field] = r.text;
91
+ for (const f of r.findings) findings.set(f.kind, (findings.get(f.kind) || 0) + f.count);
92
+ }
93
+ return {
94
+ node: clean,
95
+ findings: [...findings.entries()].map(([kind, count]) => ({ kind, count })),
96
+ };
97
+ }
package/src/schema.js ADDED
@@ -0,0 +1,98 @@
1
+ import { NOTE_TYPES } from './config.js';
2
+
3
+ export const EDGE_RELS = ['depends-on', 'applies-to', 'supersedes', 'contradicts', 'evidence-for'];
4
+ export const SCOPES = ['repo', 'global'];
5
+ export const CONFIDENCE = ['observed', 'inferred'];
6
+
7
+ const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
9
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
10
+
11
+ export class ValidationError extends Error {
12
+ constructor(errors) {
13
+ super(`invalid node: ${errors.join('; ')}`);
14
+ this.name = 'ValidationError';
15
+ this.errors = errors;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Validate and normalize a node.
21
+ *
22
+ * Reports every problem rather than the first, so a model correcting its own
23
+ * output fixes everything in one round trip instead of discovering faults one at
24
+ * a time. That matters here: each retry costs a premium request.
25
+ */
26
+ export function validateNode(input) {
27
+ const e = [];
28
+ const n = { ...input };
29
+
30
+ if (typeof n.id !== 'string' || !ID_RE.test(n.id)) {
31
+ e.push(`id must be kebab-case (got ${JSON.stringify(n.id)})`);
32
+ }
33
+ if (!NOTE_TYPES.includes(n.type)) {
34
+ e.push(`type must be one of ${NOTE_TYPES.join('|')} (got ${JSON.stringify(n.type)})`);
35
+ }
36
+ if (typeof n.title !== 'string' || !n.title.trim()) e.push('title is required');
37
+ if (typeof n.body !== 'string' || !n.body.trim()) e.push('body is required');
38
+
39
+ n.repos = Array.isArray(n.repos) ? n.repos.filter((r) => typeof r === 'string' && r.trim()) : [];
40
+ // An empty repo list means the note is not tied to any one project.
41
+ n.scope = n.scope || (n.repos.length ? 'repo' : 'global');
42
+ if (!SCOPES.includes(n.scope)) e.push(`scope must be one of ${SCOPES.join('|')}`);
43
+ if (n.scope === 'repo' && n.repos.length === 0) {
44
+ e.push('scope "repo" requires at least one entry in repos');
45
+ }
46
+
47
+ n.confidence = n.confidence || 'observed';
48
+ if (!CONFIDENCE.includes(n.confidence)) {
49
+ e.push(`confidence must be one of ${CONFIDENCE.join('|')}`);
50
+ }
51
+
52
+ n.source = typeof n.source === 'string' && n.source.trim() ? n.source.trim() : 'manual';
53
+
54
+ n.supersedes =
55
+ typeof n.supersedes === 'string' && n.supersedes.trim() ? n.supersedes.trim() : null;
56
+ if (n.supersedes && !ID_RE.test(n.supersedes)) e.push('supersedes must be a kebab-case id');
57
+ if (n.supersedes && n.supersedes === n.id) e.push('a node cannot supersede itself');
58
+
59
+ n.captured_sha =
60
+ typeof n.captured_sha === 'string' && SHA_RE.test(n.captured_sha)
61
+ ? n.captured_sha.toLowerCase()
62
+ : null;
63
+
64
+ const edges = [];
65
+ const seen = new Set();
66
+ for (const raw of Array.isArray(n.edges) ? n.edges : []) {
67
+ if (!raw || typeof raw !== 'object') {
68
+ e.push('each edge must be an object with rel and dst');
69
+ continue;
70
+ }
71
+ const { rel, dst } = raw;
72
+ if (!EDGE_RELS.includes(rel)) {
73
+ e.push(`edge rel must be one of ${EDGE_RELS.join('|')} (got ${JSON.stringify(rel)})`);
74
+ continue;
75
+ }
76
+ if (typeof dst !== 'string' || !ID_RE.test(dst)) {
77
+ e.push(`edge dst must be a kebab-case id (got ${JSON.stringify(dst)})`);
78
+ continue;
79
+ }
80
+ // A self-loop is meaningless for every relation, contradicts included.
81
+ if (dst === n.id) {
82
+ e.push(`edge ${rel} points at its own node`);
83
+ continue;
84
+ }
85
+ const key = `${rel} ${dst}`;
86
+ if (seen.has(key)) continue;
87
+ seen.add(key);
88
+ edges.push({ rel, dst });
89
+ }
90
+ n.edges = edges;
91
+
92
+ for (const f of ['created', 'updated', 'accessed']) {
93
+ if (n[f] != null && !ISO_RE.test(n[f])) e.push(`${f} must be ISO 8601 UTC`);
94
+ }
95
+
96
+ if (e.length) throw new ValidationError(e);
97
+ return n;
98
+ }