linksee-memory 0.17.0 → 0.17.1
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/README.md +958 -1081
- package/dist/bin/guard-hook.js +19 -4
- package/dist/bin/setup.js +191 -76
- package/dist/bin/share-draft.js +28 -28
- package/dist/bin/sync-session.js +10 -1
- package/dist/db/migrate.d.ts +30 -0
- package/dist/db/migrate.js +178 -37
- package/dist/lib/consolidate.d.ts +9 -0
- package/dist/lib/consolidate.js +14 -0
- package/dist/lib/drift-anchors.js +3 -3
- package/dist/lib/drift-detection.js +20 -20
- package/dist/lib/drift-view.js +16 -16
- package/dist/lib/guard-wiring.d.ts +47 -1
- package/dist/lib/guard-wiring.js +114 -11
- package/dist/lib/hook-runtime.d.ts +41 -0
- package/dist/lib/hook-runtime.js +197 -0
- package/dist/lib/map-import.js +17 -17
- package/dist/lib/native-hint.d.ts +5 -0
- package/dist/lib/native-hint.js +42 -0
- package/dist/lib/settings-file.d.ts +23 -0
- package/dist/lib/settings-file.js +137 -0
- package/dist/mcp/prompts.js +59 -59
- package/dist/mcp/resources.js +7 -7
- package/dist/mcp/sampling.js +20 -20
- package/dist/mcp/server.js +180 -160
- package/dist/skill/SKILL.md +6 -14
- package/package.json +107 -107
package/dist/db/migrate.js
CHANGED
|
@@ -1,19 +1,91 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Decide, WITHOUT a writable connection, whether this file may be opened for writing at all.
|
|
16
|
+
* (0.17.1, Codex re-review of #15.) A writable connection that is the last one to close
|
|
17
|
+
* checkpoints a leftover WAL into memory.db and deletes it, and rolls back a hot journal — both
|
|
18
|
+
* are writes to a file we are about to refuse. So the schema is read through a read-only
|
|
19
|
+
* connection first (SQLITE_OPEN_READONLY: no checkpoint, no rollback, memory.db and -wal untouched).
|
|
20
|
+
* • schema 1–5 → LegacySchemaError
|
|
21
|
+
* • a hot rollback journal → HotJournalError (it cannot be read without rolling back)
|
|
22
|
+
* • not a DB → return; the normal open's corruption path moves it aside
|
|
23
|
+
* • native addon failed → rethrown as is
|
|
24
|
+
* • anything else (busy, locked, unreadable) → PrecheckError: could not check, so no writing
|
|
25
|
+
* When the file is refused, the -wal/-shm that the read-only open itself created are removed
|
|
26
|
+
* again, so the folder is left as it was.
|
|
27
|
+
*/
|
|
28
|
+
function precheckBeforeWrite(path) {
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return; // new DB: nothing to protect, create it
|
|
31
|
+
const had = { wal: existsSync(path + '-wal'), shm: existsSync(path + '-shm') };
|
|
32
|
+
// Remove only what the read-only open itself added: a -wal that was absent and is still empty,
|
|
33
|
+
// and a -shm that was absent (e.g. a leftover -wal with no -shm — the reader builds one).
|
|
34
|
+
// Known limit (0.17.2): if another process opens the file in the same instant, the -shm deleted
|
|
35
|
+
// here may already be in use by it. The tests check a single process only, not concurrency.
|
|
36
|
+
const tidy = () => {
|
|
37
|
+
try {
|
|
38
|
+
if (!had.wal && existsSync(path + '-wal') && statSync(path + '-wal').size === 0)
|
|
39
|
+
unlinkSync(path + '-wal');
|
|
40
|
+
if (!had.shm && existsSync(path + '-shm'))
|
|
41
|
+
unlinkSync(path + '-shm');
|
|
42
|
+
}
|
|
43
|
+
catch { /* leave them; memory.db itself is untouched either way */ }
|
|
44
|
+
};
|
|
45
|
+
let v = 0;
|
|
46
|
+
try {
|
|
47
|
+
const ro = new Database(path, { readonly: true, fileMustExist: true });
|
|
48
|
+
try {
|
|
49
|
+
v = readSchemaVersion(ro);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
ro.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
const code = e?.code ?? '';
|
|
57
|
+
const msg = e?.message ?? String(e);
|
|
58
|
+
// The file vanished between the check and the open: a new DB, as above.
|
|
59
|
+
if (!existsSync(path) && (code === 'SQLITE_CANTOPEN' || code === 'ENOENT' || /unable to open|does not exist/i.test(msg)))
|
|
60
|
+
return;
|
|
61
|
+
// The native addon itself failed: let it surface as such (the hooks turn it into one line).
|
|
62
|
+
if (isNativeLoadError(e))
|
|
63
|
+
throw e;
|
|
64
|
+
// Not a database at all: the normal open's corruption path moves it aside (nothing lost).
|
|
65
|
+
if (code === 'SQLITE_NOTADB' || code === 'SQLITE_CORRUPT' || /not a database|malformed|disk image/i.test(msg))
|
|
66
|
+
return;
|
|
67
|
+
tidy();
|
|
68
|
+
if (code === 'SQLITE_READONLY_ROLLBACK')
|
|
69
|
+
throw new HotJournalError(path);
|
|
70
|
+
// Anything else (SQLITE_BUSY, locked, permission, I/O): we could not check it, so we do not
|
|
71
|
+
// write to it either — fail closed (0.17.1, Codex third review of #15).
|
|
72
|
+
throw new PrecheckError(path, code || msg.split('\n')[0]);
|
|
73
|
+
}
|
|
74
|
+
if (v > 0 && v < MIN_SUPPORTED_SCHEMA) {
|
|
75
|
+
tidy();
|
|
76
|
+
throw new LegacySchemaError(v, path);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
13
79
|
function openAt(path) {
|
|
80
|
+
precheckBeforeWrite(path);
|
|
14
81
|
const db = new Database(path);
|
|
15
82
|
try {
|
|
16
|
-
|
|
83
|
+
// Second line (the read-only precheck is the real gate): refuse before the journal-mode pragma.
|
|
84
|
+
// This is also the first real read of the file — it throws if the file isn't a DB.
|
|
85
|
+
const v = readSchemaVersion(db);
|
|
86
|
+
if (v > 0 && v < MIN_SUPPORTED_SCHEMA)
|
|
87
|
+
throw new LegacySchemaError(v, path);
|
|
88
|
+
db.pragma('journal_mode = WAL');
|
|
17
89
|
db.pragma('foreign_keys = ON');
|
|
18
90
|
return db;
|
|
19
91
|
}
|
|
@@ -54,27 +126,91 @@ export function openDb() {
|
|
|
54
126
|
throw err; // not a corruption we recognize — surface it
|
|
55
127
|
}
|
|
56
128
|
}
|
|
129
|
+
/** Oldest schema this version migrates in place. Older files are refused before any write. */
|
|
130
|
+
export const MIN_SUPPORTED_SCHEMA = 6;
|
|
131
|
+
/** Base class: openDb() refused this file without writing to it. The message says what to do. */
|
|
132
|
+
export class DbRefusedError extends Error {
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Thrown for a schema older than MIN_SUPPORTED_SCHEMA (a DB last opened by a 2026-05-or-earlier
|
|
136
|
+
* version). Raised by openDb() from a read-only connection, before any writable connection exists,
|
|
137
|
+
* and again by runMigrations() as a second line.
|
|
138
|
+
*
|
|
139
|
+
* No old version is recommended as a stepping stone: 0.8.0–0.17.0 stop on `no such table:
|
|
140
|
+
* drift_anchors` after partly altering the file, and 0.7.2 has no subcommands — `npx
|
|
141
|
+
* linksee-memory@0.7.2 <anything>` starts its MCP server, whose start-up consolidate can delete
|
|
142
|
+
* original rows (Codex review of PR #15). A migration command for these files is 0.17.2 work.
|
|
143
|
+
*/
|
|
144
|
+
export class LegacySchemaError extends DbRefusedError {
|
|
145
|
+
schemaVersion;
|
|
146
|
+
constructor(schemaVersion, dbPath) {
|
|
147
|
+
super(`[linksee-memory] ${dbPath} is at schema ${schemaVersion}; this version migrates schema ${MIN_SUPPORTED_SCHEMA} and newer only. ` +
|
|
148
|
+
`Stopped before writing anything to the file.
|
|
149
|
+
\n` +
|
|
150
|
+
` 1. Close every Claude Code / Cursor / other client using linksee-memory.
|
|
151
|
+
\n` +
|
|
152
|
+
` 2. Back up the folder that holds memory.db (copy memory.db and any memory.db-wal / -shm / -journal) and keep the copy.
|
|
153
|
+
\n` +
|
|
154
|
+
` 3. Do not open this file with another linksee-memory version in the meantime — older versions can delete or half-migrate rows.
|
|
155
|
+
\n` +
|
|
156
|
+
`A migration command for this schema is planned for 0.17.2. To start fresh instead, point LINKSEE_MEMORY_DIR at an empty folder.`);
|
|
157
|
+
this.name = 'LegacySchemaError';
|
|
158
|
+
this.schemaVersion = schemaVersion;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** The read-only check could not run (busy, locked, unreadable): refused rather than guessed. */
|
|
162
|
+
export class PrecheckError extends DbRefusedError {
|
|
163
|
+
reason;
|
|
164
|
+
constructor(dbPath, reason) {
|
|
165
|
+
super(`[linksee-memory] could not check ${dbPath} before opening it for writing (${reason}). ` +
|
|
166
|
+
`Stopped before writing anything. Another client may have it open or locked: close other ` +
|
|
167
|
+
`Claude Code / Cursor / linksee-memory processes and try again.`);
|
|
168
|
+
this.name = 'PrecheckError';
|
|
169
|
+
this.reason = reason;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** A rollback journal left by an interrupted write: it cannot be read without being rolled back. */
|
|
173
|
+
export class HotJournalError extends DbRefusedError {
|
|
174
|
+
constructor(dbPath) {
|
|
175
|
+
super(`[linksee-memory] ${dbPath} has an unfinished write from an interrupted process (memory.db-journal), ` +
|
|
176
|
+
`so it cannot be checked without changing it. Stopped before writing anything.
|
|
177
|
+
\n` +
|
|
178
|
+
` Back up the folder that holds memory.db (all memory.db* files) before anything else opens it. ` +
|
|
179
|
+
`A recovery command is planned for 0.17.2.`);
|
|
180
|
+
this.name = 'HotJournalError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Read meta.schema_version without writing (0 when the table or row is absent). */
|
|
184
|
+
export function readSchemaVersion(db) {
|
|
185
|
+
const metaTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
|
|
186
|
+
if (!metaTable?.name)
|
|
187
|
+
return 0;
|
|
188
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
189
|
+
return row ? Number(row.value) || 0 : 0;
|
|
190
|
+
}
|
|
57
191
|
export function runMigrations(db) {
|
|
58
192
|
const __filename = fileURLToPath(import.meta.url);
|
|
59
193
|
const schemaPath = join(dirname(__filename), 'schema.sql');
|
|
60
194
|
const sql = readFileSync(schemaPath, 'utf8');
|
|
61
195
|
// Safely read current schema version. On a fresh DB the meta table doesn't
|
|
62
196
|
// exist yet, which is fine — treat that as "version 0, full schema apply".
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
197
|
+
const currentVersion = readSchemaVersion(db);
|
|
198
|
+
// 0.17.1 (L-1-106 / audit F3): refuse schema 1–5 BEFORE any write. Their path through here
|
|
199
|
+
// (v5 → v6 generated columns, then the v5 entity merge running after the version has already
|
|
200
|
+
// been bumped to 16, with no transaction around it) can leave a half-migrated file. A safe,
|
|
201
|
+
// tested migration is 0.17.2 work. openDb() already refused these before any write; this is
|
|
202
|
+
// the second line for callers that hand in their own connection.
|
|
203
|
+
if (currentVersion > 0 && currentVersion < MIN_SUPPORTED_SCHEMA) {
|
|
204
|
+
throw new LegacySchemaError(currentVersion, getDbPath());
|
|
69
205
|
}
|
|
70
206
|
// v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
|
|
71
207
|
// Only runs when upgrading an existing DB from schema v1-3.
|
|
72
208
|
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;
|
|
209
|
+
db.exec(`
|
|
210
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
211
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
212
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
213
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
78
214
|
`);
|
|
79
215
|
}
|
|
80
216
|
// v4 → v5: add normalized_name column BEFORE schema.sql runs,
|
|
@@ -118,9 +254,9 @@ export function runMigrations(db) {
|
|
|
118
254
|
db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
|
|
119
255
|
}
|
|
120
256
|
// 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
|
|
257
|
+
db.exec(`
|
|
258
|
+
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
259
|
+
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
124
260
|
`);
|
|
125
261
|
}
|
|
126
262
|
// v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
|
|
@@ -128,7 +264,12 @@ export function runMigrations(db) {
|
|
|
128
264
|
// detector/dashboard (which read the original columns + status active/retired) are unaffected.
|
|
129
265
|
// `lifecycle` carries the rich state; `status` stays the coarse scan-gate. New tables
|
|
130
266
|
// (reality_events, memory_write_candidates) are created by db.exec(sql) below (CREATE IF NOT EXISTS).
|
|
131
|
-
|
|
267
|
+
// 0.17.1 (L-1-106 / audit F3): drift_anchors only exists from v8 on. On an older DB the table is
|
|
268
|
+
// missing, PRAGMA table_info returns nothing, and every ALTER below threw "no such table" — so
|
|
269
|
+
// any pre-0.12 DB could not start. If the table is absent, skip: db.exec(sql) below creates it
|
|
270
|
+
// with all of these columns.
|
|
271
|
+
const hasDriftAnchors = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='drift_anchors'").get();
|
|
272
|
+
if (currentVersion > 0 && currentVersion < 9 && hasDriftAnchors) {
|
|
132
273
|
const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
|
|
133
274
|
const addCol = (name, ddl) => {
|
|
134
275
|
if (!have.has(name))
|
|
@@ -221,12 +362,12 @@ function migrateV5EntityNormalization(db) {
|
|
|
221
362
|
}
|
|
222
363
|
})();
|
|
223
364
|
// 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
|
|
365
|
+
const dupes = db.prepare(`
|
|
366
|
+
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
367
|
+
FROM entities
|
|
368
|
+
WHERE normalized_name IS NOT NULL
|
|
369
|
+
GROUP BY kind, normalized_name
|
|
370
|
+
HAVING COUNT(*) > 1
|
|
230
371
|
`).all();
|
|
231
372
|
if (dupes.length > 0) {
|
|
232
373
|
console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
|
|
@@ -247,12 +388,12 @@ function mergeEntityCluster(db, ids) {
|
|
|
247
388
|
if (ids.length < 2)
|
|
248
389
|
return;
|
|
249
390
|
// 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
|
|
391
|
+
const rows = db.prepare(`
|
|
392
|
+
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
393
|
+
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
394
|
+
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
395
|
+
GROUP BY e.id
|
|
396
|
+
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
256
397
|
`).all(...ids);
|
|
257
398
|
const keep = rows[0];
|
|
258
399
|
const mergeIds = rows.slice(1).map(r => r.id);
|
|
@@ -273,16 +414,16 @@ function mergeEntityCluster(db, ids) {
|
|
|
273
414
|
db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
274
415
|
// Reassign edges (both directions)
|
|
275
416
|
// 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
|
-
)
|
|
417
|
+
db.prepare(`
|
|
418
|
+
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
419
|
+
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
420
|
+
)
|
|
280
421
|
`).run(mid, keep.id);
|
|
281
422
|
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
|
-
)
|
|
423
|
+
db.prepare(`
|
|
424
|
+
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
425
|
+
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
426
|
+
)
|
|
286
427
|
`).run(mid, keep.id);
|
|
287
428
|
db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
|
|
288
429
|
// 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;
|
package/dist/lib/consolidate.js
CHANGED
|
@@ -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) {
|
package/dist/lib/drift-view.js
CHANGED
|
@@ -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
|
};
|