open-memex 0.3.0-alpha → 0.3.0-alpha.3

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,132 @@
1
+ import { createRequire } from "node:module";
2
+ import { paths } from "../paths.js";
3
+ // Runtime-agnostic SQLite:
4
+ // - Inside opencode (Bun runtime): bun:sqlite (built-in, no native module load)
5
+ // - Under Node CLI: better-sqlite3
6
+ // Both expose the same shape: new Database(path), .exec(sql), .prepare(sql).{run,all,get}, .close()
7
+ const require = createRequire(import.meta.url);
8
+ const isBun = typeof globalThis.Bun !== "undefined";
9
+ function loadDatabase() {
10
+ if (isBun) {
11
+ // bun:sqlite is a built-in module; only resolvable under the Bun runtime.
12
+ return require("bun:sqlite").Database;
13
+ }
14
+ return require("better-sqlite3");
15
+ }
16
+ let _db = null;
17
+ const TABLE_SCHEMA = `
18
+ CREATE TABLE IF NOT EXISTS memories (
19
+ id TEXT PRIMARY KEY,
20
+ scope_key TEXT NOT NULL,
21
+ scope TEXT NOT NULL,
22
+ visibility TEXT NOT NULL DEFAULT 'private',
23
+ project_name TEXT NOT NULL DEFAULT '',
24
+ type TEXT NOT NULL DEFAULT 'fact',
25
+ role TEXT NOT NULL DEFAULT 'knowledge',
26
+ importance TEXT NOT NULL DEFAULT 'normal',
27
+ status TEXT NOT NULL DEFAULT 'active',
28
+ tags TEXT NOT NULL DEFAULT '',
29
+ content TEXT NOT NULL,
30
+ cjk TEXT NOT NULL DEFAULT '',
31
+ content_hash TEXT NOT NULL DEFAULT '',
32
+ superseded_by TEXT,
33
+ source TEXT NOT NULL DEFAULT '',
34
+ file_path TEXT NOT NULL,
35
+ mtime_ms REAL NOT NULL,
36
+ created_at INTEGER NOT NULL,
37
+ updated_at INTEGER NOT NULL
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_memories_scope_updated
41
+ ON memories(scope_key, updated_at DESC);
42
+ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
43
+ CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
44
+ `;
45
+ // The `cjk` column holds pre-tokenized CJK unigrams+bigrams (see
46
+ // src/retrieve/cjk.ts). FTS5's unicode61 treats a CJK run as one token, so
47
+ // without this column CJK substring search cannot work. Kept as a separate
48
+ // column (rather than a custom tokenizer) so both runtimes stay on stock
49
+ // SQLite with zero native dependencies.
50
+ const FTS_SCHEMA = `
51
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
52
+ content,
53
+ tags,
54
+ type,
55
+ cjk,
56
+ scope_key UNINDEXED,
57
+ content='memories',
58
+ content_rowid='rowid',
59
+ tokenize='porter unicode61'
60
+ );
61
+
62
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
63
+ INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
64
+ VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
65
+ END;
66
+
67
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
68
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
69
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
70
+ END;
71
+
72
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
73
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags, type, cjk, scope_key)
74
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type, old.cjk, old.scope_key);
75
+ INSERT INTO memories_fts(rowid, content, tags, type, cjk, scope_key)
76
+ VALUES (new.rowid, new.content, new.tags, new.type, new.cjk, new.scope_key);
77
+ END;
78
+ `;
79
+ /** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
80
+ const SCHEMA_VERSION = 5;
81
+ function userVersion(d) {
82
+ const row = d.prepare("PRAGMA user_version").get();
83
+ return row.user_version;
84
+ }
85
+ /**
86
+ * Any schema change: the query layer is fully derived from markdown (D1),
87
+ * so wipe it and let the next syncScope() repopulate. The markdown files
88
+ * themselves are untouched — `migrate --to-v2` handles the file format.
89
+ * NOTE ordering matters: triggers are dropped BEFORE the wipe, and the FTS
90
+ * table is rebuilt after. FTS5's 'delete' command corrupts
91
+ * (SQLITE_CORRUPT_VTAB) when it targets a rowid that was never indexed, so
92
+ * the wipe must not fire any FTS trigger while the index is out of sync
93
+ * with the table (found 2026-09-26).
94
+ */
95
+ function rebuildIndexSchema(d) {
96
+ d.exec(`DROP TRIGGER IF EXISTS memories_ai;
97
+ DROP TRIGGER IF EXISTS memories_ad;
98
+ DROP TRIGGER IF EXISTS memories_au;`);
99
+ d.exec("DELETE FROM memories");
100
+ d.exec(`DROP TABLE IF EXISTS memories_fts;`);
101
+ d.exec(`DROP TABLE IF EXISTS memories;`);
102
+ d.exec(TABLE_SCHEMA);
103
+ d.exec(FTS_SCHEMA);
104
+ d.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
105
+ }
106
+ export function db() {
107
+ if (_db)
108
+ return _db;
109
+ const { indexDb } = paths();
110
+ const Database = loadDatabase();
111
+ const d = new Database(indexDb);
112
+ d.exec("PRAGMA journal_mode = WAL;");
113
+ d.exec("PRAGMA synchronous = NORMAL;");
114
+ d.exec("PRAGMA foreign_keys = ON;");
115
+ d.exec(TABLE_SCHEMA);
116
+ d.exec(FTS_SCHEMA);
117
+ if (userVersion(d) < SCHEMA_VERSION)
118
+ rebuildIndexSchema(d);
119
+ _db = d;
120
+ return d;
121
+ }
122
+ /** Close the DB. Only used by tests / CLI teardown. */
123
+ export function closeDb() {
124
+ if (_db) {
125
+ _db.close();
126
+ _db = null;
127
+ }
128
+ }
129
+ /** Which backend this process is using. Useful for diagnostics. */
130
+ export function backendName() {
131
+ return isBun ? "bun:sqlite" : "better-sqlite3";
132
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { db } from "./db.js";
5
+ import { paths, memoriesDirPath } from "../paths.js";
6
+ import { cjkIndexText } from "../retrieve/cjk.js";
7
+ import { parse, serialize, ulid, msToRfc3339, } from "./markdown.js";
8
+ /**
9
+ * Dedup + lifecycle (V2-DESIGN §3.3, §3.4).
10
+ *
11
+ * - Dedup on write: exact content hash → idempotent add; fuzzy token
12
+ * overlap (Jaccard ≥ 0.8, CJK-bigram aware) → near-duplicate notice.
13
+ * - Lifecycle: active → superseded | deprecated | retracted | archived.
14
+ * Superseding never overwrites: the old memory keeps its history with a
15
+ * bidirectional supersedes/superseded_by chain. Retrieval (§3.3) returns
16
+ * only the newest of a chain and excludes retracted/archived.
17
+ */
18
+ /** sha256 of whitespace-normalized body. Stored in the index (derived). */
19
+ export function contentHash(body) {
20
+ const norm = body.replace(/\s+/g, " ").trim();
21
+ return createHash("sha256").update(norm, "utf8").digest("hex");
22
+ }
23
+ function tokenSet(text) {
24
+ const latin = text.toLowerCase().match(/[a-z0-9_\-]+/g) ?? [];
25
+ const cjk = cjkIndexText(text).split(" ").filter(Boolean);
26
+ return new Set([...latin, ...cjk]);
27
+ }
28
+ /** Jaccard similarity over latin tokens + CJK bigrams. 1 = identical. */
29
+ export function similarity(a, b) {
30
+ const sa = tokenSet(a);
31
+ const sb = tokenSet(b);
32
+ if (sa.size === 0 || sb.size === 0)
33
+ return 0;
34
+ let inter = 0;
35
+ for (const t of sa)
36
+ if (sb.has(t))
37
+ inter++;
38
+ const union = sa.size + sb.size - inter;
39
+ return union === 0 ? 0 : inter / union;
40
+ }
41
+ export const NEAR_DUP_THRESHOLD = 0.8;
42
+ /** Find exact + near duplicates of `body` among ACTIVE memories in scope. */
43
+ export function findDuplicates(scopeKey, body, excludeId) {
44
+ const hash = contentHash(body);
45
+ const rows = db()
46
+ .prepare(`SELECT id, content_hash, content FROM memories
47
+ WHERE scope_key = ? AND status = 'active'`)
48
+ .all(scopeKey);
49
+ let exact;
50
+ const near = [];
51
+ for (const r of rows) {
52
+ if (r.id === excludeId)
53
+ continue;
54
+ const snippet = r.content.replace(/\s+/g, " ").trim().slice(0, 120);
55
+ if (r.content_hash && r.content_hash === hash) {
56
+ exact = { id: r.id, score: 1, snippet };
57
+ continue;
58
+ }
59
+ const score = similarity(body, r.content);
60
+ if (score >= NEAR_DUP_THRESHOLD)
61
+ near.push({ id: r.id, score, snippet });
62
+ }
63
+ near.sort((x, y) => y.score - x.score);
64
+ return { exact, near: near.slice(0, 3) };
65
+ }
66
+ /** Locate a memory file by id: index first, then a full dir scan fallback. */
67
+ export function findMemoryFile(id) {
68
+ const { memories } = paths();
69
+ let filePath = null;
70
+ try {
71
+ const row = db()
72
+ .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
73
+ .get(id);
74
+ if (row) {
75
+ const p = path.join(memoriesDirPath(row.scope_key), `${id}.md`);
76
+ if (fs.existsSync(p))
77
+ filePath = p;
78
+ }
79
+ }
80
+ catch {
81
+ // index unavailable — fall through to scan
82
+ }
83
+ if (!filePath && fs.existsSync(memories)) {
84
+ for (const entry of fs.readdirSync(memories, { withFileTypes: true })) {
85
+ if (!entry.isDirectory())
86
+ continue;
87
+ const p = path.join(memories, entry.name, `${id}.md`);
88
+ if (fs.existsSync(p)) {
89
+ filePath = p;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ if (!filePath)
95
+ return null;
96
+ const raw = fs.readFileSync(filePath, "utf8");
97
+ const parsed = parse(raw);
98
+ if (!parsed)
99
+ return null;
100
+ const st = fs.statSync(filePath);
101
+ return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs };
102
+ }
103
+ export function rewriteMemoryFile(mf) {
104
+ fs.writeFileSync(mf.filePath, serialize(mf.fm, mf.body), "utf8");
105
+ }
106
+ /**
107
+ * Chain integrity (§3.3): supersedes/superseded_by must be pairwise
108
+ * consistent. A missing side is auto-completed and reported — a broken chain
109
+ * must never silently degrade retrieval. Dangling pointers (target file
110
+ * gone) can only be reported.
111
+ */
112
+ export function repairChain(mf) {
113
+ const warnings = [];
114
+ const repaired = [];
115
+ const fm = mf.fm;
116
+ if (fm.supersedes) {
117
+ const prev = findMemoryFile(fm.supersedes);
118
+ if (!prev) {
119
+ warnings.push(`${fm.id}: supersedes target ${fm.supersedes} not found (dangling)`);
120
+ }
121
+ else if (prev.fm.superseded_by !== fm.id) {
122
+ prev.fm.superseded_by = fm.id;
123
+ if (prev.fm.status === "active")
124
+ prev.fm.status = "superseded";
125
+ prev.fm.updated_at = msToRfc3339(Date.now());
126
+ rewriteMemoryFile(prev);
127
+ repaired.push(prev);
128
+ warnings.push(`${fm.id}: auto-completed ${prev.fm.id}.superseded_by → ${fm.id}`);
129
+ }
130
+ }
131
+ if (fm.superseded_by) {
132
+ const next = findMemoryFile(fm.superseded_by);
133
+ if (!next) {
134
+ warnings.push(`${fm.id}: superseded_by target ${fm.superseded_by} not found (dangling)`);
135
+ }
136
+ else if (next.fm.supersedes !== fm.id) {
137
+ next.fm.supersedes = fm.id;
138
+ next.fm.updated_at = msToRfc3339(Date.now());
139
+ rewriteMemoryFile(next);
140
+ repaired.push(next);
141
+ warnings.push(`${fm.id}: auto-completed ${next.fm.id}.supersedes → ${fm.id}`);
142
+ }
143
+ }
144
+ return { warnings, repaired };
145
+ }
146
+ /**
147
+ * Replace an active memory with a new one. The old memory is NOT overwritten:
148
+ * it becomes `status: superseded` with a forward pointer; the new memory
149
+ * points back. History preserved; retrieval returns the newest (§3.4).
150
+ */
151
+ export function supersede(oldId, input) {
152
+ const oldMf = findMemoryFile(oldId);
153
+ if (!oldMf)
154
+ throw new Error(`no memory with id ${oldId}`);
155
+ if (oldMf.fm.status !== "active") {
156
+ throw new Error(`cannot supersede memory with status '${oldMf.fm.status}' (id ${oldId}); only active memories can be superseded`);
157
+ }
158
+ const now = msToRfc3339(Date.now());
159
+ const newFm = {
160
+ ...oldMf.fm,
161
+ id: ulid(),
162
+ type: input.type ?? oldMf.fm.type,
163
+ tags: input.tags ?? oldMf.fm.tags,
164
+ source: input.source ?? oldMf.fm.source,
165
+ status: "active",
166
+ created_at: now,
167
+ updated_at: now,
168
+ supersedes: oldId,
169
+ superseded_by: null,
170
+ };
171
+ const dir = path.dirname(oldMf.filePath);
172
+ const newPath = path.join(dir, `${newFm.id}.md`);
173
+ fs.writeFileSync(newPath, serialize(newFm, input.body), "utf8");
174
+ const st = fs.statSync(newPath);
175
+ const newMf = {
176
+ fm: newFm,
177
+ body: input.body,
178
+ filePath: newPath,
179
+ mtimeMs: st.mtimeMs,
180
+ };
181
+ oldMf.fm.status = "superseded";
182
+ oldMf.fm.superseded_by = newFm.id;
183
+ oldMf.fm.updated_at = now;
184
+ rewriteMemoryFile(oldMf);
185
+ return { oldMf, newMf };
186
+ }
187
+ const SETTABLE_STATUSES = [
188
+ "active",
189
+ "deprecated",
190
+ "retracted",
191
+ "archived",
192
+ ];
193
+ export function isSettableStatus(s) {
194
+ return SETTABLE_STATUSES.includes(s);
195
+ }
196
+ /**
197
+ * Direct lifecycle transition. `superseded` is NOT settable here — it is
198
+ * managed exclusively by `supersede()` so the chain stays consistent.
199
+ */
200
+ export function setStatus(id, status) {
201
+ if (!isSettableStatus(status)) {
202
+ throw new Error(`invalid status '${status}'; use one of: ${SETTABLE_STATUSES.join(", ")}`);
203
+ }
204
+ const mf = findMemoryFile(id);
205
+ if (!mf)
206
+ throw new Error(`no memory with id ${id}`);
207
+ if (mf.fm.status === "superseded") {
208
+ throw new Error(`memory ${id} is superseded (chain-managed); supersede it again instead of changing status directly`);
209
+ }
210
+ mf.fm.status = status;
211
+ mf.fm.updated_at = msToRfc3339(Date.now());
212
+ rewriteMemoryFile(mf);
213
+ return mf;
214
+ }
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import yaml from "js-yaml";
5
+ import { memoriesDirFor, memoriesDirPath } from "../paths.js";
6
+ /** v2 content-kind taxonomy (V2-DESIGN §3.1). `type` = what the memory IS. */
7
+ export const MEMORY_TYPE_TAXONOMY = [
8
+ "preference",
9
+ "fact",
10
+ "decision",
11
+ "lesson",
12
+ "warning",
13
+ "workflow",
14
+ "architecture",
15
+ "constraint",
16
+ "todo",
17
+ "knowledge",
18
+ "observation",
19
+ ];
20
+ const TAXONOMY = new Set(MEMORY_TYPE_TAXONOMY);
21
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
22
+ /** ULID-ish: 10 chars of base32 timestamp + 16 chars of base32 randomness. */
23
+ export function ulid() {
24
+ let ts = "";
25
+ let n = Date.now();
26
+ for (let i = 0; i < 10; i++) {
27
+ ts = CROCKFORD[n % 32] + ts;
28
+ n = Math.floor(n / 32);
29
+ }
30
+ const bytes = randomBytes(16);
31
+ let rand = "";
32
+ for (const b of bytes)
33
+ rand += CROCKFORD[b % 32];
34
+ return ts + rand;
35
+ }
36
+ /** epoch ms → RFC 3339 (v2 times). */
37
+ export function msToRfc3339(ms) {
38
+ return new Date(ms).toISOString().replace(/\.000Z$/, "Z");
39
+ }
40
+ /** RFC 3339 (or epoch ms) → epoch ms. Falls back to now on garbage. */
41
+ export function timeToMs(v) {
42
+ if (typeof v === "number" && Number.isFinite(v))
43
+ return Math.round(v);
44
+ if (typeof v === "string") {
45
+ const t = Date.parse(v);
46
+ if (Number.isFinite(t))
47
+ return t;
48
+ }
49
+ return Date.now();
50
+ }
51
+ function asScopeKind(v, scopeKind) {
52
+ if (v === "personal" || v === "project" || v === "org")
53
+ return v;
54
+ if (scopeKind === "user")
55
+ return "personal"; // v1 → v2 (§19)
56
+ if (scopeKind === "project")
57
+ return "project";
58
+ return "personal";
59
+ }
60
+ function asVisibility(v, scope) {
61
+ if (v === "private" || v === "internal" || v === "shared")
62
+ return v;
63
+ // v2 defaults (§4): personal stays local, project is internal.
64
+ return scope === "personal" ? "private" : "internal";
65
+ }
66
+ function asRole(v) {
67
+ if (v === "knowledge" || v === "instruction")
68
+ return v;
69
+ return "knowledge";
70
+ }
71
+ function asImportance(v, priority) {
72
+ if (v === "low" || v === "normal" || v === "high")
73
+ return v;
74
+ // v1 `priority: N` → importance (§19): 1–3 low, 8–10 high, else normal.
75
+ if (typeof priority === "number" && Number.isFinite(priority)) {
76
+ if (priority <= 3)
77
+ return "low";
78
+ if (priority >= 8)
79
+ return "high";
80
+ }
81
+ return "normal";
82
+ }
83
+ function asStatus(v) {
84
+ if (v === "active" ||
85
+ v === "superseded" ||
86
+ v === "deprecated" ||
87
+ v === "retracted" ||
88
+ v === "archived")
89
+ return v;
90
+ return "active";
91
+ }
92
+ /**
93
+ * Normalize raw (possibly v1) frontmatter into v2 shape. Lenient on read:
94
+ * v1 files (epoch times, scope_kind, priority, type: instruction) are mapped
95
+ * per §19 so old files keep working even before `migrate --to-v2` rewrites
96
+ * them. Use `planConversion` in v2migrate.ts for the explicit, reporting
97
+ * migration path.
98
+ */
99
+ export function normalizeFrontmatter(raw) {
100
+ const scope = asScopeKind(raw.scope, raw.scope_kind);
101
+ let scopeKey = typeof raw.scope_key === "string" && raw.scope_key ? raw.scope_key : scope;
102
+ if (scopeKey === "user")
103
+ scopeKey = "personal"; // v1 storage dir → v2
104
+ let type = typeof raw.type === "string" && raw.type ? raw.type : "fact";
105
+ let role = asRole(raw.role);
106
+ if (type === "instruction") {
107
+ // §19: v1 `type: instruction` → content-kind + role split (D11).
108
+ type = "knowledge";
109
+ role = "instruction";
110
+ }
111
+ return {
112
+ id: String(raw.id),
113
+ schema_version: 2,
114
+ scope_key: scopeKey,
115
+ scope,
116
+ visibility: asVisibility(raw.visibility, scope),
117
+ project_name: typeof raw.project_name === "string" ? raw.project_name : scopeKey,
118
+ type,
119
+ role,
120
+ importance: asImportance(raw.importance, raw.priority),
121
+ status: asStatus(raw.status),
122
+ tags: Array.isArray(raw.tags)
123
+ ? raw.tags.filter((t) => typeof t === "string")
124
+ : [],
125
+ source: typeof raw.source === "string" ? raw.source : "",
126
+ created_at: msToRfc3339(timeToMs(raw.created_at)),
127
+ updated_at: msToRfc3339(timeToMs(raw.updated_at)),
128
+ supersedes: typeof raw.supersedes === "string" && raw.supersedes ? raw.supersedes : null,
129
+ superseded_by: typeof raw.superseded_by === "string" && raw.superseded_by
130
+ ? raw.superseded_by
131
+ : null,
132
+ };
133
+ }
134
+ export function isTaxonomyType(t) {
135
+ return TAXONOMY.has(t);
136
+ }
137
+ export function serialize(fm, body) {
138
+ const yml = yaml.dump(fm, { lineWidth: -1, quotingType: '"' });
139
+ return `---\n${yml}---\n\n${body.trimEnd()}\n`;
140
+ }
141
+ /** Raw frontmatter parse (no normalization) — for migration tooling. */
142
+ export function parseRawFrontmatter(raw) {
143
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
144
+ if (!m)
145
+ return null;
146
+ try {
147
+ const rawFm = yaml.load(m[1]);
148
+ if (!rawFm || typeof rawFm !== "object" || !rawFm.id)
149
+ return null;
150
+ const body = (m[2] ?? "").replace(/^\n+/, "");
151
+ return { rawFm, body };
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ export function parse(raw) {
158
+ const parsed = parseRawFrontmatter(raw);
159
+ if (!parsed)
160
+ return null;
161
+ return { fm: normalizeFrontmatter(parsed.rawFm), body: parsed.body };
162
+ }
163
+ export function writeMemoryFile(fm, body) {
164
+ const dir = memoriesDirFor(fm.scope_key);
165
+ const filePath = path.join(dir, `${fm.id}.md`);
166
+ fs.writeFileSync(filePath, serialize(fm, body), "utf8");
167
+ const st = fs.statSync(filePath);
168
+ return { filePath, mtimeMs: st.mtimeMs };
169
+ }
170
+ export function deleteMemoryFile(scopeKey, id) {
171
+ const dir = memoriesDirPath(scopeKey);
172
+ const filePath = path.join(dir, `${id}.md`);
173
+ if (fs.existsSync(filePath)) {
174
+ fs.unlinkSync(filePath);
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ export function readMemoryFile(filePath) {
180
+ if (!fs.existsSync(filePath))
181
+ return null;
182
+ const raw = fs.readFileSync(filePath, "utf8");
183
+ const parsed = parse(raw);
184
+ if (!parsed)
185
+ return null;
186
+ const st = fs.statSync(filePath);
187
+ return { fm: parsed.fm, body: parsed.body, filePath, mtimeMs: st.mtimeMs };
188
+ }
189
+ export function* iterMemoryFiles(scopeKey) {
190
+ const dir = memoriesDirPath(scopeKey);
191
+ if (!fs.existsSync(dir))
192
+ return;
193
+ for (const name of fs.readdirSync(dir)) {
194
+ if (name.endsWith(".md"))
195
+ yield path.join(dir, name);
196
+ }
197
+ }
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { memoriesDirFor, memoriesDirPath } from "../paths.js";
4
+ import { db } from "./db.js";
5
+ import { iterMemoryFiles, readMemoryFile, serialize, } from "./markdown.js";
6
+ import { syncScope } from "./sync.js";
7
+ /** True if this scope has at least one `.md` file on disk. Does not create
8
+ * the directory if it doesn't already exist. */
9
+ export function scopeHasFiles(scopeKey) {
10
+ const dir = memoriesDirPath(scopeKey);
11
+ if (!fs.existsSync(dir))
12
+ return false;
13
+ return fs.readdirSync(dir).some((f) => f.endsWith(".md"));
14
+ }
15
+ /**
16
+ * Move all memories from `fromKey` to `toKey`. Rewrites each file's
17
+ * `scope_key` (and optionally `project_name`) frontmatter. On id collision
18
+ * in the destination, `onConflict` decides:
19
+ * - "newer" (default): keep the file with the greater `updated_at`
20
+ * - "overwrite": always take the source
21
+ * - "skip": always keep the destination
22
+ *
23
+ * After moving files, clears any lingering FTS rows for `fromKey` and
24
+ * re-runs `syncScope(toKey)` so the index reflects reality. Removes the
25
+ * empty source directory when possible.
26
+ */
27
+ export function migrateScope(fromKey, toKey, opts = {}) {
28
+ const log = opts.logger ?? (() => { });
29
+ const strategy = opts.onConflict ?? "newer";
30
+ const dryRun = !!opts.dryRun;
31
+ const stats = { moved: 0, skipped: 0, conflicts: 0, dryRun };
32
+ if (fromKey === toKey) {
33
+ throw new Error("migrate: --from and --to are identical");
34
+ }
35
+ const fromDir = memoriesDirPath(fromKey);
36
+ const toDir = memoriesDirFor(toKey);
37
+ if (!fs.existsSync(fromDir)) {
38
+ log(`no source directory: ${fromDir}`);
39
+ return stats;
40
+ }
41
+ for (const srcPath of iterMemoryFiles(fromKey)) {
42
+ const mf = readMemoryFile(srcPath);
43
+ if (!mf) {
44
+ log(`skip unparseable: ${srcPath}`);
45
+ stats.skipped++;
46
+ continue;
47
+ }
48
+ const dstPath = path.join(toDir, path.basename(srcPath));
49
+ let action = "write";
50
+ if (fs.existsSync(dstPath)) {
51
+ stats.conflicts++;
52
+ const dst = readMemoryFile(dstPath);
53
+ if (!dst) {
54
+ log(`conflict but destination unreadable, overwriting: ${dstPath}`);
55
+ }
56
+ else if (strategy === "skip") {
57
+ action = "skip";
58
+ log(`conflict: kept destination (--on-conflict=skip): ${dstPath}`);
59
+ }
60
+ else if (strategy === "overwrite") {
61
+ log(`conflict: overwriting destination (--on-conflict=overwrite): ${dstPath}`);
62
+ }
63
+ else if (dst.fm.updated_at >= mf.fm.updated_at) {
64
+ action = "skip";
65
+ log(`conflict: kept destination (newer updated_at): ${dstPath}`);
66
+ }
67
+ else {
68
+ log(`conflict: replaced destination (source newer): ${dstPath}`);
69
+ }
70
+ }
71
+ if (action === "skip") {
72
+ stats.skipped++;
73
+ continue;
74
+ }
75
+ const newFm = {
76
+ ...mf.fm,
77
+ scope_key: toKey,
78
+ project_name: opts.toProjectName ?? mf.fm.project_name,
79
+ };
80
+ const raw = serialize(newFm, mf.body);
81
+ if (!dryRun) {
82
+ fs.writeFileSync(dstPath, raw, "utf8");
83
+ // Only unlink source if it's a different path (paths differ because
84
+ // scope dir differs; belt-and-suspenders check).
85
+ if (path.resolve(srcPath) !== path.resolve(dstPath)) {
86
+ fs.unlinkSync(srcPath);
87
+ }
88
+ }
89
+ log(`${dryRun ? "would move" : "moved"}: ${srcPath} -> ${dstPath}`);
90
+ stats.moved++;
91
+ }
92
+ if (!dryRun) {
93
+ // Best-effort: remove now-empty source dir.
94
+ try {
95
+ if (fs.existsSync(fromDir) && fs.readdirSync(fromDir).length === 0) {
96
+ fs.rmdirSync(fromDir);
97
+ }
98
+ }
99
+ catch {
100
+ /* ignore */
101
+ }
102
+ // Drop any remaining FTS/memories rows for the old scope, then rebuild
103
+ // the destination index from disk. `syncScope` also handles removals for
104
+ // the destination scope if a conflict-skip left stale rows behind.
105
+ db().prepare(`DELETE FROM memories WHERE scope_key = ?`).run(fromKey);
106
+ syncScope(toKey);
107
+ }
108
+ return stats;
109
+ }