linksee-memory 0.17.0 → 0.17.2

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.
@@ -1,19 +1,142 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { readFileSync } from 'node:fs';
3
- import { mkdirSync, renameSync, existsSync } from 'node:fs';
3
+ import { mkdirSync, renameSync, existsSync, statSync, unlinkSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { normalizeEntityName } from '../lib/normalize.js';
8
+ import { isNativeLoadError } from '../lib/native-hint.js';
8
9
  const DEFAULT_DB_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
9
10
  const DB_PATH = join(DEFAULT_DB_DIR, 'memory.db');
10
11
  export function getDbPath() {
11
12
  return DB_PATH;
12
13
  }
14
+ /** This package's version (dist/db/migrate.js → ../../package.json), for exact-version guidance. */
15
+ export function packageVersion() {
16
+ try {
17
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
18
+ return String(JSON.parse(readFileSync(pkg, 'utf8')).version || '');
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ }
24
+ /**
25
+ * The command that migrates / recovers this file. Pinned to this exact version: an unpinned
26
+ * `npx linksee-memory` could resolve to a cached older version, and versions before 0.17.2 have
27
+ * no `migrate` subcommand — they would start the MCP server instead (L-1-130).
28
+ */
29
+ export function migrateCommand() {
30
+ const v = packageVersion();
31
+ // Never fall back to an unpinned command (Codex review of #19): if this version cannot be read,
32
+ // name the first version that has `migrate`, as a fixed string.
33
+ return /^\d+\.\d+\.\d+/.test(v) ? `npx -y linksee-memory@${v} migrate` : `npx -y linksee-memory@${MIGRATE_SINCE} migrate`;
34
+ }
35
+ /** First version with the `migrate` subcommand. */
36
+ export const MIGRATE_SINCE = '0.17.2';
37
+ /**
38
+ * Read the schema through a read-only connection (no checkpoint, no rollback). Returns what it
39
+ * found plus `tidy`, which removes the -wal/-shm that the read-only open itself added.
40
+ * Throws the native load error as is, and PrecheckError when the file could not be checked at all.
41
+ */
42
+ export function probeReadonly(path) {
43
+ if (!existsSync(path))
44
+ return { probe: { kind: 'missing' }, tidy: () => { } };
45
+ const had = { wal: existsSync(path + '-wal'), shm: existsSync(path + '-shm') };
46
+ const walStat = had.wal ? statSync(path + '-wal') : null;
47
+ // Remove only what the read-only open itself added: a -wal that was absent and is still empty,
48
+ // and a -shm that was absent (e.g. a leftover -wal with no -shm — the reader builds one).
49
+ // 0.17.2 (Codex fifth review of #15, P2): another process may open the file in the same instant
50
+ // and start using the -shm. Two guards: (1) if the -wal changed while we looked (size or mtime),
51
+ // someone else is active → leave both; (2) the -shm is renamed aside first — on Windows a file
52
+ // another process has open cannot be renamed, so an in-use -shm stays. On POSIX an open -shm can
53
+ // still be renamed, so a writer that starts in the exact window remains a known limit.
54
+ const tidy = () => {
55
+ try {
56
+ if (had.wal) {
57
+ const now = existsSync(path + '-wal') ? statSync(path + '-wal') : null;
58
+ if (!now || !walStat || now.size !== walStat.size || now.mtimeMs !== walStat.mtimeMs)
59
+ return;
60
+ }
61
+ else if (existsSync(path + '-wal')) {
62
+ if (statSync(path + '-wal').size !== 0)
63
+ return; // someone wrote to it: not ours
64
+ unlinkSync(path + '-wal');
65
+ }
66
+ if (!had.shm && existsSync(path + '-shm')) {
67
+ const aside = `${path}-shm.tidy-${process.pid}`;
68
+ renameSync(path + '-shm', aside);
69
+ unlinkSync(aside);
70
+ }
71
+ }
72
+ catch { /* leave them; memory.db itself is untouched either way */ }
73
+ };
74
+ let v = 0;
75
+ try {
76
+ const ro = new Database(path, { readonly: true, fileMustExist: true });
77
+ try {
78
+ v = readSchemaVersion(ro);
79
+ }
80
+ finally {
81
+ ro.close();
82
+ }
83
+ }
84
+ catch (e) {
85
+ const code = e?.code ?? '';
86
+ const msg = e?.message ?? String(e);
87
+ // The file vanished between the check and the open: a new DB, as above.
88
+ if (!existsSync(path) && (code === 'SQLITE_CANTOPEN' || code === 'ENOENT' || /unable to open|does not exist/i.test(msg))) {
89
+ return { probe: { kind: 'missing' }, tidy: () => { } };
90
+ }
91
+ // The native addon itself failed: let it surface as such (the hooks turn it into one line).
92
+ if (isNativeLoadError(e))
93
+ throw e;
94
+ // Not a database at all.
95
+ if (code === 'SQLITE_NOTADB' || code === 'SQLITE_CORRUPT' || /not a database|malformed|disk image/i.test(msg)) {
96
+ return { probe: { kind: 'notadb' }, tidy };
97
+ }
98
+ tidy();
99
+ if (code === 'SQLITE_READONLY_ROLLBACK')
100
+ return { probe: { kind: 'hot' }, tidy: () => { } };
101
+ // Anything else (SQLITE_BUSY, locked, permission, I/O): we could not check it, so we do not
102
+ // write to it either — fail closed (0.17.1, Codex third review of #15).
103
+ throw new PrecheckError(path, code || msg.split('\n')[0]);
104
+ }
105
+ return { probe: { kind: 'version', version: v }, tidy };
106
+ }
107
+ /**
108
+ * Decide, WITHOUT a writable connection, whether this file may be opened for writing at all.
109
+ * (0.17.1, Codex re-review of #15.) A writable connection that is the last one to close
110
+ * checkpoints a leftover WAL into memory.db and deletes it, and rolls back a hot journal — both
111
+ * are writes to a file we are about to refuse. So the schema is read through a read-only
112
+ * connection first (SQLITE_OPEN_READONLY: no checkpoint, no rollback, memory.db and -wal untouched).
113
+ * • schema 1–5 → LegacySchemaError
114
+ * • a hot rollback journal → HotJournalError (it cannot be read without rolling back)
115
+ * • not a DB → return; the normal open's corruption path moves it aside
116
+ * • native addon failed → rethrown as is
117
+ * • anything else (busy, locked, unreadable) → PrecheckError: could not check, so no writing
118
+ * When the file is refused, the -wal/-shm that the read-only open itself created are removed
119
+ * again, so the folder is left as it was.
120
+ */
121
+ function precheckBeforeWrite(path) {
122
+ const { probe, tidy } = probeReadonly(path);
123
+ if (probe.kind === 'hot')
124
+ throw new HotJournalError(path);
125
+ if (probe.kind === 'version' && probe.version > 0 && probe.version < MIN_SUPPORTED_SCHEMA) {
126
+ tidy();
127
+ throw new LegacySchemaError(probe.version, path);
128
+ }
129
+ }
13
130
  function openAt(path) {
131
+ precheckBeforeWrite(path);
14
132
  const db = new Database(path);
15
133
  try {
16
- db.pragma('journal_mode = WAL'); // first real read of the file header — throws if it isn't a DB
134
+ // Second line (the read-only precheck is the real gate): refuse before the journal-mode pragma.
135
+ // This is also the first real read of the file — it throws if the file isn't a DB.
136
+ const v = readSchemaVersion(db);
137
+ if (v > 0 && v < MIN_SUPPORTED_SCHEMA)
138
+ throw new LegacySchemaError(v, path);
139
+ db.pragma('journal_mode = WAL');
17
140
  db.pragma('foreign_keys = ON');
18
141
  return db;
19
142
  }
@@ -54,32 +177,102 @@ export function openDb() {
54
177
  throw err; // not a corruption we recognize — surface it
55
178
  }
56
179
  }
57
- export function runMigrations(db) {
180
+ /** Oldest schema this version migrates in place. Older files are refused before any write. */
181
+ export const MIN_SUPPORTED_SCHEMA = 6;
182
+ /** Base class: openDb() refused this file without writing to it. The message says what to do. */
183
+ export class DbRefusedError extends Error {
184
+ }
185
+ /**
186
+ * Thrown for a schema older than MIN_SUPPORTED_SCHEMA (a DB last opened by a 2026-05-or-earlier
187
+ * version). Raised by openDb() from a read-only connection, before any writable connection exists,
188
+ * and again by runMigrations() as a second line.
189
+ *
190
+ * No old version is recommended as a stepping stone: 0.8.0–0.17.0 stop on `no such table:
191
+ * drift_anchors` after partly altering the file, and 0.7.2 has no subcommands — `npx
192
+ * linksee-memory@0.7.2 <anything>` starts its MCP server, whose start-up consolidate can delete
193
+ * original rows (Codex review of PR #15). 0.17.2: `linksee-memory migrate` (src/bin/migrate-legacy.ts).
194
+ */
195
+ export class LegacySchemaError extends DbRefusedError {
196
+ schemaVersion;
197
+ constructor(schemaVersion, dbPath) {
198
+ super(`[linksee-memory] ${dbPath} is at schema ${schemaVersion}; this version migrates schema ${MIN_SUPPORTED_SCHEMA} and newer only. ` +
199
+ `Stopped before writing anything to the file.
200
+ \n` +
201
+ ` 1. Close every Claude Code / Cursor / other client using linksee-memory.
202
+ \n` +
203
+ ` 2. Back up the folder that holds memory.db (copy memory.db and any memory.db-wal / -shm / -journal) and keep the copy.
204
+ \n` +
205
+ ` 3. Do not open this file with another linksee-memory version in the meantime — older versions can delete or half-migrate rows.
206
+ \n` +
207
+ ` 4. Migrate it (it backs the file up first; --dry only reports): ${migrateCommand()} --dry, then the same without --dry.
208
+ \n` +
209
+ `To start fresh instead, point LINKSEE_MEMORY_DIR at an empty folder.`);
210
+ this.name = 'LegacySchemaError';
211
+ this.schemaVersion = schemaVersion;
212
+ }
213
+ }
214
+ /** The read-only check could not run (busy, locked, unreadable): refused rather than guessed. */
215
+ export class PrecheckError extends DbRefusedError {
216
+ reason;
217
+ constructor(dbPath, reason) {
218
+ super(`[linksee-memory] could not check ${dbPath} before opening it for writing (${reason}). ` +
219
+ `Stopped before writing anything. Another client may have it open or locked: close other ` +
220
+ `Claude Code / Cursor / linksee-memory processes and try again.`);
221
+ this.name = 'PrecheckError';
222
+ this.reason = reason;
223
+ }
224
+ }
225
+ /** A rollback journal left by an interrupted write: it cannot be read without being rolled back. */
226
+ export class HotJournalError extends DbRefusedError {
227
+ constructor(dbPath) {
228
+ super(`[linksee-memory] ${dbPath} has an unfinished write from an interrupted process (memory.db-journal), ` +
229
+ `so it cannot be checked without changing it. Stopped before writing anything.
230
+ \n` +
231
+ ` Back up the folder that holds memory.db (all memory.db* files) before anything else opens it, ` +
232
+ `then run: ${migrateCommand()} (it copies the files first, then lets SQLite roll the unfinished write back).`);
233
+ this.name = 'HotJournalError';
234
+ }
235
+ }
236
+ /** Read meta.schema_version without writing (0 when the table or row is absent). */
237
+ export function readSchemaVersion(db) {
238
+ const metaTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
239
+ if (!metaTable?.name)
240
+ return 0;
241
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
242
+ return row ? Number(row.value) || 0 : 0;
243
+ }
244
+ export function runMigrations(db, opts = {}) {
245
+ const step = (name) => opts.onStep?.(name, db);
58
246
  const __filename = fileURLToPath(import.meta.url);
59
247
  const schemaPath = join(dirname(__filename), 'schema.sql');
60
248
  const sql = readFileSync(schemaPath, 'utf8');
61
249
  // Safely read current schema version. On a fresh DB the meta table doesn't
62
250
  // exist yet, which is fine — treat that as "version 0, full schema apply".
63
- let currentVersion = 0;
64
- const metaTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
65
- if (metaTable?.name) {
66
- const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
67
- if (versionRow)
68
- currentVersion = Number(versionRow.value) || 0;
251
+ const currentVersion = readSchemaVersion(db);
252
+ // 0.17.1 (L-1-106 / audit F3): refuse schema 1–5 BEFORE any write. Their path through here
253
+ // (v5 → v6 generated columns, then the v5 entity merge running after the version has already
254
+ // been bumped to 16, with no transaction around it) can leave a half-migrated file. A safe,
255
+ // tested path is the `migrate` command (0.17.2): it backs up, sets allowLegacy and runs all of
256
+ // this inside one transaction. openDb() already refused these before any write; this is the
257
+ // second line for callers that hand in their own connection.
258
+ if (currentVersion > 0 && currentVersion < MIN_SUPPORTED_SCHEMA && !opts.allowLegacy) {
259
+ throw new LegacySchemaError(currentVersion, getDbPath());
69
260
  }
70
261
  // v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
71
262
  // Only runs when upgrading an existing DB from schema v1-3.
72
263
  if (currentVersion > 0 && currentVersion < 4) {
73
- db.exec(`
74
- DROP TRIGGER IF EXISTS trg_memories_fts_ai;
75
- DROP TRIGGER IF EXISTS trg_memories_fts_ad;
76
- DROP TRIGGER IF EXISTS trg_memories_fts_au;
77
- DROP TABLE IF EXISTS memories_fts;
264
+ step('v3-v4');
265
+ db.exec(`
266
+ DROP TRIGGER IF EXISTS trg_memories_fts_ai;
267
+ DROP TRIGGER IF EXISTS trg_memories_fts_ad;
268
+ DROP TRIGGER IF EXISTS trg_memories_fts_au;
269
+ DROP TABLE IF EXISTS memories_fts;
78
270
  `);
79
271
  }
80
272
  // v4 → v5: add normalized_name column BEFORE schema.sql runs,
81
273
  // so the CREATE INDEX IF NOT EXISTS on (kind, normalized_name) succeeds.
82
274
  if (currentVersion > 0 && currentVersion < 5) {
275
+ step('v4-v5');
83
276
  const cols = db.prepare("PRAGMA table_info(entities)").all();
84
277
  if (!cols.some(c => c.name === 'normalized_name')) {
85
278
  db.exec('ALTER TABLE entities ADD COLUMN normalized_name TEXT');
@@ -93,6 +286,7 @@ export function runMigrations(db) {
93
286
  // NOTE: VIRTUAL generated columns are hidden from PRAGMA table_info.
94
287
  // Use PRAGMA table_xinfo (hidden=2 = VIRTUAL generated column) to detect them.
95
288
  if (currentVersion > 0 && currentVersion < 6) {
289
+ step('v5-v6');
96
290
  const xcols = db.prepare("PRAGMA table_xinfo(memories)").all();
97
291
  const hasAltitude = xcols.some(c => c.name === 'altitude' && c.hidden === 2);
98
292
  if (hasAltitude) {
@@ -113,14 +307,15 @@ export function runMigrations(db) {
113
307
  // thread_id groups related memories (session-level or decision chains).
114
308
  // memory_edges creates directed relationships between individual memories.
115
309
  if (currentVersion > 0 && currentVersion < 7) {
310
+ step('v6-v7');
116
311
  const cols = db.prepare("PRAGMA table_info(memories)").all();
117
312
  if (!cols.some(c => c.name === 'thread_id')) {
118
313
  db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
119
314
  }
120
315
  // Backfill thread_id from content JSON session_id for existing memories
121
- db.exec(`
122
- UPDATE memories SET thread_id = json_extract(content, '$.session_id')
123
- WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
316
+ db.exec(`
317
+ UPDATE memories SET thread_id = json_extract(content, '$.session_id')
318
+ WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
124
319
  `);
125
320
  }
126
321
  // v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
@@ -128,7 +323,13 @@ export function runMigrations(db) {
128
323
  // detector/dashboard (which read the original columns + status active/retired) are unaffected.
129
324
  // `lifecycle` carries the rich state; `status` stays the coarse scan-gate. New tables
130
325
  // (reality_events, memory_write_candidates) are created by db.exec(sql) below (CREATE IF NOT EXISTS).
131
- if (currentVersion > 0 && currentVersion < 9) {
326
+ // 0.17.1 (L-1-106 / audit F3): drift_anchors only exists from v8 on. On an older DB the table is
327
+ // missing, PRAGMA table_info returns nothing, and every ALTER below threw "no such table" — so
328
+ // any pre-0.12 DB could not start. If the table is absent, skip: db.exec(sql) below creates it
329
+ // with all of these columns.
330
+ const hasDriftAnchors = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='drift_anchors'").get();
331
+ if (currentVersion > 0 && currentVersion < 9 && hasDriftAnchors) {
332
+ step('v8-v9');
132
333
  const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
133
334
  const addCol = (name, ddl) => {
134
335
  if (!have.has(name))
@@ -151,6 +352,7 @@ export function runMigrations(db) {
151
352
  // without them). Only ALTER if map_nodes already exists; a v10→v12 jump creates
152
353
  // it fresh (with the columns) via db.exec(sql) below.
153
354
  if (currentVersion > 0 && currentVersion < 12) {
355
+ step('v11-v12');
154
356
  const hasMapNodes = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_nodes'").get();
155
357
  if (hasMapNodes) {
156
358
  const have = new Set(db.prepare('PRAGMA table_info(map_nodes)').all().map((c) => c.name));
@@ -164,6 +366,7 @@ export function runMigrations(db) {
164
366
  }
165
367
  // v12 → v13: edge strength + accounted-for expiry (anti-noise / anti-graveyard).
166
368
  if (currentVersion > 0 && currentVersion < 13) {
369
+ step('v12-v13');
167
370
  const has = (table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
168
371
  if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_edges'").get()) {
169
372
  if (!has('map_edges', 'strength'))
@@ -181,8 +384,10 @@ export function runMigrations(db) {
181
384
  // node or a `readme→docs-site` edge. SQLite can't alter a PK/UNIQUE, so drop + recreate;
182
385
  // safe because importMap rebuilds both from map.yaml on every run.
183
386
  if (currentVersion > 0 && currentVersion < 14) {
387
+ step('v13-v14');
184
388
  db.exec('DROP TABLE IF EXISTS map_nodes; DROP TABLE IF EXISTS map_edges;');
185
389
  }
390
+ step('schema.sql');
186
391
  db.exec(sql);
187
392
  if (currentVersion > 0 && currentVersion < 4) {
188
393
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
@@ -204,13 +409,14 @@ export function runMigrations(db) {
204
409
  // v4 → v5: entity name normalization for dedup prevention.
205
410
  // Adds normalized_name column + backfills from existing entity names.
206
411
  if (currentVersion > 0 && currentVersion < 5) {
207
- migrateV5EntityNormalization(db);
412
+ step('v5-backfill');
413
+ migrateV5EntityNormalization(db, opts.mergeDuplicateEntities !== false);
208
414
  }
209
415
  }
210
416
  /**
211
417
  * v5 migration: add normalized_name column and backfill + merge duplicates.
212
418
  */
213
- function migrateV5EntityNormalization(db) {
419
+ function migrateV5EntityNormalization(db, merge) {
214
420
  // Column + index already added before db.exec(sql) above.
215
421
  // Now backfill normalized_name for all entities.
216
422
  const entities = db.prepare('SELECT id, name FROM entities').all();
@@ -221,13 +427,17 @@ function migrateV5EntityNormalization(db) {
221
427
  }
222
428
  })();
223
429
  // 4. Auto-merge duplicate entities (same kind + normalized_name)
224
- const dupes = db.prepare(`
225
- SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
226
- FROM entities
227
- WHERE normalized_name IS NOT NULL
228
- GROUP BY kind, normalized_name
229
- HAVING COUNT(*) > 1
430
+ const dupes = db.prepare(`
431
+ SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
432
+ FROM entities
433
+ WHERE normalized_name IS NOT NULL
434
+ GROUP BY kind, normalized_name
435
+ HAVING COUNT(*) > 1
230
436
  `).all();
437
+ if (dupes.length > 0 && !merge) {
438
+ console.log(`[linksee-memory] v5 migration: ${dupes.length} duplicate entity clusters left as they are (not merged)`);
439
+ return;
440
+ }
231
441
  if (dupes.length > 0) {
232
442
  console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
233
443
  db.transaction(() => {
@@ -247,12 +457,12 @@ function mergeEntityCluster(db, ids) {
247
457
  if (ids.length < 2)
248
458
  return;
249
459
  // Score each entity: prefer most memories, then has canonical_key, then lowest id
250
- const rows = db.prepare(`
251
- SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
252
- FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
253
- WHERE e.id IN (${ids.map(() => '?').join(',')})
254
- GROUP BY e.id
255
- ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
460
+ const rows = db.prepare(`
461
+ SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
462
+ FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
463
+ WHERE e.id IN (${ids.map(() => '?').join(',')})
464
+ GROUP BY e.id
465
+ ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
256
466
  `).all(...ids);
257
467
  const keep = rows[0];
258
468
  const mergeIds = rows.slice(1).map(r => r.id);
@@ -273,16 +483,16 @@ function mergeEntityCluster(db, ids) {
273
483
  db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
274
484
  // Reassign edges (both directions)
275
485
  // Handle UNIQUE constraint: delete duplicates first
276
- db.prepare(`
277
- DELETE FROM edges WHERE from_id = ? AND EXISTS (
278
- SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
279
- )
486
+ db.prepare(`
487
+ DELETE FROM edges WHERE from_id = ? AND EXISTS (
488
+ SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
489
+ )
280
490
  `).run(mid, keep.id);
281
491
  db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
282
- db.prepare(`
283
- DELETE FROM edges WHERE to_id = ? AND EXISTS (
284
- SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
285
- )
492
+ db.prepare(`
493
+ DELETE FROM edges WHERE to_id = ? AND EXISTS (
494
+ SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
495
+ )
286
496
  `).run(mid, keep.id);
287
497
  db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
288
498
  // Reassign consolidations
@@ -13,3 +13,12 @@ export declare function consolidate(db: Database.Database, opts?: {
13
13
  scope?: 'all' | 'session';
14
14
  min_age_days?: number;
15
15
  }): ConsolidateResult;
16
+ /**
17
+ * Should the MCP server run consolidate on its own at start-up? (0.17.1, L-1-106 / audit F1)
18
+ *
19
+ * No, unless the user asked: consolidate deletes the original rows (keeping a 3 × 200-char
20
+ * summary) and their edges, so it must not run without an explicit opt-in.
21
+ * Opt in with LINKSEE_AUTO_CONSOLIDATE=1|true|on|weekly, or `--auto-consolidate` on the server
22
+ * command line. The weekly gate inside the server still applies once enabled.
23
+ */
24
+ export declare function autoConsolidateEnabled(env: Record<string, string | undefined>, argv: readonly string[]): boolean;
@@ -186,4 +186,18 @@ export function consolidate(db, opts = {}) {
186
186
  }
187
187
  return result;
188
188
  }
189
+ /**
190
+ * Should the MCP server run consolidate on its own at start-up? (0.17.1, L-1-106 / audit F1)
191
+ *
192
+ * No, unless the user asked: consolidate deletes the original rows (keeping a 3 × 200-char
193
+ * summary) and their edges, so it must not run without an explicit opt-in.
194
+ * Opt in with LINKSEE_AUTO_CONSOLIDATE=1|true|on|weekly, or `--auto-consolidate` on the server
195
+ * command line. The weekly gate inside the server still applies once enabled.
196
+ */
197
+ export function autoConsolidateEnabled(env, argv) {
198
+ if (argv.includes('--auto-consolidate'))
199
+ return true;
200
+ const v = (env.LINKSEE_AUTO_CONSOLIDATE ?? '').trim().toLowerCase();
201
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes' || v === 'weekly';
202
+ }
189
203
  //# sourceMappingURL=consolidate.js.map
@@ -74,9 +74,9 @@ export function declareAnchor(db, input) {
74
74
  throw new Error(`${input.kind} anchors need at least one violation_signal term (the forbidden act / rejected alternative) — otherwise the detector can never deduce a contradiction`);
75
75
  }
76
76
  const info = db
77
- .prepare(`INSERT INTO drift_anchors
78
- (kind, statement, rationale, affects, detect_terms, violation_signal, tier, source, source_memory_id)
79
- VALUES
77
+ .prepare(`INSERT INTO drift_anchors
78
+ (kind, statement, rationale, affects, detect_terms, violation_signal, tier, source, source_memory_id)
79
+ VALUES
80
80
  (@kind, @statement, @rationale, @affects, @detect_terms, @violation_signal, @tier, @source, @source_memory_id)`)
81
81
  .run({
82
82
  kind: input.kind,
@@ -110,7 +110,7 @@ export function detectDrift(db, opts = {}) {
110
110
  samples: [],
111
111
  };
112
112
  const anchors = db
113
- .prepare(`SELECT id, kind, statement, affects, detect_terms, violation_signal, tier, created_at
113
+ .prepare(`SELECT id, kind, statement, affects, detect_terms, violation_signal, tier, created_at
114
114
  FROM drift_anchors WHERE status = 'active'`)
115
115
  .all();
116
116
  res.anchorsScanned = anchors.length;
@@ -120,8 +120,8 @@ export function detectDrift(db, opts = {}) {
120
120
  // the "why this edit" text, not just path + snippet. (session_file_edits.memory_id →
121
121
  // memories.id; memories_fts.rowid = memories.id.)
122
122
  const edits = db
123
- .prepare(`SELECT e.id, e.file_path, e.context_snippet, e.memory_id, m.content AS memory_content, e.occurred_at
124
- FROM session_file_edits e
123
+ .prepare(`SELECT e.id, e.file_path, e.context_snippet, e.memory_id, m.content AS memory_content, e.occurred_at
124
+ FROM session_file_edits e
125
125
  LEFT JOIN memories m ON m.id = e.memory_id`)
126
126
  .all();
127
127
  res.editsScanned = edits.length;
@@ -137,19 +137,19 @@ export function detectDrift(db, opts = {}) {
137
137
  // Write paths prepared only when persisting — keeps dryRun safe on a readonly connection.
138
138
  const upsertContradicts = opts.dryRun
139
139
  ? null
140
- : db.prepare(`
141
- INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
142
- VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
143
- ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
144
- confidence = excluded.confidence,
145
- evidence = excluded.evidence
146
- WHERE drift_edges.status = 'open'
140
+ : db.prepare(`
141
+ INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
142
+ VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
143
+ ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
144
+ confidence = excluded.confidence,
145
+ evidence = excluded.evidence
146
+ WHERE drift_edges.status = 'open'
147
147
  `);
148
148
  const insertAbsent = opts.dryRun
149
149
  ? null
150
- : db.prepare(`
151
- INSERT OR IGNORE INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
152
- VALUES (@anchor_id, NULL, 'absent', @confidence, @evidence, 'open')
150
+ : db.prepare(`
151
+ INSERT OR IGNORE INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
152
+ VALUES (@anchor_id, NULL, 'absent', @confidence, @evidence, 'open')
153
153
  `);
154
154
  const apply = () => {
155
155
  for (const a of anchors) {
@@ -351,13 +351,13 @@ export function detectFileViolations(db, opts = {}) {
351
351
  .all();
352
352
  const upsert = opts.dryRun
353
353
  ? null
354
- : db.prepare(`
355
- INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
356
- VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
357
- ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
358
- confidence = excluded.confidence,
359
- evidence = excluded.evidence
360
- WHERE drift_edges.status = 'open'
354
+ : db.prepare(`
355
+ INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
356
+ VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
357
+ ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
358
+ confidence = excluded.confidence,
359
+ evidence = excluded.evidence
360
+ WHERE drift_edges.status = 'open'
361
361
  `);
362
362
  const apply = () => {
363
363
  for (const a of anchors) {
@@ -27,14 +27,14 @@ function parseEvidence(s) {
27
27
  export function getDriftHeadline(db, opts = {}) {
28
28
  const limit = opts.limit ?? 3;
29
29
  const rows = db
30
- .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
31
- e.file_path, e.context_snippet, e.occurred_at,
32
- d.confidence, d.evidence
33
- FROM drift_edges d
34
- JOIN drift_anchors a ON a.id = d.anchor_id
35
- JOIN session_file_edits e ON e.id = d.edit_id
36
- WHERE d.verdict = 'contradicts' AND d.status = 'open' AND a.status = 'active'
37
- ORDER BY d.confidence DESC, (a.tier = 'human') DESC, e.occurred_at DESC
30
+ .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
31
+ e.file_path, e.context_snippet, e.occurred_at,
32
+ d.confidence, d.evidence
33
+ FROM drift_edges d
34
+ JOIN drift_anchors a ON a.id = d.anchor_id
35
+ JOIN session_file_edits e ON e.id = d.edit_id
36
+ WHERE d.verdict = 'contradicts' AND d.status = 'open' AND a.status = 'active'
37
+ ORDER BY d.confidence DESC, (a.tier = 'human') DESC, e.occurred_at DESC
38
38
  LIMIT ?`)
39
39
  .all(limit);
40
40
  return rows.map((r) => {
@@ -61,12 +61,12 @@ export function getDriftHeadline(db, opts = {}) {
61
61
  export function getDriftAbsences(db, opts = {}) {
62
62
  const limit = opts.limit ?? 10;
63
63
  const rows = db
64
- .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
65
- d.confidence, d.evidence, d.detected_at
66
- FROM drift_edges d
67
- JOIN drift_anchors a ON a.id = d.anchor_id
68
- WHERE d.verdict = 'absent' AND d.status = 'open' AND a.status = 'active'
69
- ORDER BY a.created_at ASC
64
+ .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
65
+ d.confidence, d.evidence, d.detected_at
66
+ FROM drift_edges d
67
+ JOIN drift_anchors a ON a.id = d.anchor_id
68
+ WHERE d.verdict = 'absent' AND d.status = 'open' AND a.status = 'active'
69
+ ORDER BY a.created_at ASC
70
70
  LIMIT ?`)
71
71
  .all(limit);
72
72
  return rows.map((r) => {
@@ -85,8 +85,8 @@ export function getDriftAbsences(db, opts = {}) {
85
85
  }
86
86
  function countOpen(db, verdict) {
87
87
  return db
88
- .prepare(`SELECT COUNT(*) AS n
89
- FROM drift_edges d JOIN drift_anchors a ON a.id = d.anchor_id
88
+ .prepare(`SELECT COUNT(*) AS n
89
+ FROM drift_edges d JOIN drift_anchors a ON a.id = d.anchor_id
90
90
  WHERE d.verdict = ? AND d.status = 'open' AND a.status = 'active'`)
91
91
  .get(verdict).n;
92
92
  }
@@ -16,7 +16,53 @@ export declare const GUARD_EVENTS: readonly ["SessionStart", "PreToolUse"];
16
16
  export type GuardEvent = (typeof GUARD_EVENTS)[number];
17
17
  /** The bin every wiring form resolves to; used as the idempotency key. */
18
18
  export declare const GUARD_BIN = "linksee-memory-guard";
19
+ /** The npx form setup wrote before 0.17.1. Still the fallback when no stable install exists. */
19
20
  export declare const GUARD_COMMAND = "npx -y linksee-memory guard";
21
+ export declare const SYNC_COMMAND = "npx -y linksee-memory sync";
22
+ export type HookKind = 'guard' | 'sync';
23
+ export type HookCommands = Record<HookKind, string>;
24
+ export declare const NPX_HOOK_COMMANDS: HookCommands;
25
+ /**
26
+ * Marker setup appends to every direct-node hook it writes. It is an ordinary trailing argument
27
+ * (the hook scripts ignore argv), so it survives cmd.exe and sh alike — a `#` comment would not
28
+ * (cmd.exe has no inline comments). Only a command carrying it is treated as setup-owned.
29
+ */
30
+ export declare const MANAGED_MARKER = "--linksee-managed";
31
+ /**
32
+ * Direct `node` form (0.17.1, L-1-106 / audit F11): `npx -y` costs 6–9 s per call on a warm
33
+ * cache (npm start-up + a registry check), against an 8 s PreToolUse timeout — so the guard
34
+ * could time out and silently not fire. `"<node>" "<root>/dist/bin/<bin>.js" --linksee-managed`
35
+ * starts in well under a second.
36
+ *
37
+ * Shells: Claude Code runs a hook command through cmd.exe on Windows and through sh on POSIX
38
+ * (PowerShell is not used for hooks — assumed here; under PowerShell this form would need `&`).
39
+ * Double quotes protect spaces in both. They do NOT neutralise `$` or backticks (sh) or `%`
40
+ * (cmd.exe), and a path containing `"` cannot be quoted at all — see `shellSafePath`; setup falls
41
+ * back to the npx form for such paths instead of writing a command that might expand.
42
+ */
43
+ export declare function nodeHookCommands(nodePath: string, packageRoot: string): HookCommands;
44
+ /** Can this path go inside double quotes in both cmd.exe and sh without being expanded? */
45
+ export declare function shellSafePath(p: string): boolean;
46
+ /**
47
+ * Is this command one setup wrote — (a) the exact 0.17.0-and-earlier npx string, or (b) the
48
+ * 0.17.1+ direct form carrying MANAGED_MARKER? Anything else (wrappers, unquoted paths, extra
49
+ * flags, someone else's script) is not ours to change.
50
+ */
51
+ export declare function setupWrittenKind(command: string | undefined): HookKind | null;
52
+ /**
53
+ * Point the setup-written hooks of the given kinds at `commands`. Exec-form hooks (`args`), other
54
+ * tools' hooks and hand-edited commands are never touched. Only `command` changes — matcher,
55
+ * timeout and any other field stay. Mutates `settings`; returns the replaced commands.
56
+ */
57
+ export declare function rewireLegacyHooks(settings: ClaudeSettings, commands: HookCommands, kinds?: readonly HookKind[]): {
58
+ replaced: string[];
59
+ };
60
+ /**
61
+ * Hooks that look like ours (they run the guard or the sync script) but were not written by setup —
62
+ * hand-wired paths, wrappers, exec form. setup leaves them alone and lists them so the user knows.
63
+ */
64
+ export declare function unmanagedLinkseeHooks(settings: ClaudeSettings): string[];
65
+ export declare function guardHooks(command?: string): Record<GuardEvent, HookEntry>;
20
66
  export declare const GUARD_HOOKS: Record<GuardEvent, HookEntry>;
21
67
  /**
22
68
  * Is one of OUR hooks of `kind` already wired for this event?
@@ -48,7 +94,7 @@ export declare function guardFullyWired(settings: ClaudeSettings): boolean;
48
94
  * Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
49
95
  * events that were actually added (empty when it was already wired).
50
96
  */
51
- export declare function wireGuard(settings: ClaudeSettings): {
97
+ export declare function wireGuard(settings: ClaudeSettings, command?: string): {
52
98
  settings: ClaudeSettings;
53
99
  added: GuardEvent[];
54
100
  };