linksee-memory 0.7.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/setup.js CHANGED
@@ -6,14 +6,16 @@
6
6
  // npx linksee-memory-setup --yes (accept all defaults, no prompts)
7
7
  // npx linksee-memory-setup --dry-run
8
8
  //
9
- // Does three things:
9
+ // Does four things:
10
10
  // 1. Registers the MCP server with Claude Code
11
11
  // 2. Installs the SKILL.md (agent trigger phrases)
12
- // 3. Configures the Stop hook (auto-capture sessions)
12
+ // 3. Configures the Stop hook (auto-capture sessions) — user-global
13
+ // 4. Offers to wire the re-injection guard into THIS project's .claude/settings.json
13
14
  //
14
15
  // After setup, every Claude Code session:
15
16
  // - Auto-captures decisions, learnings, caveats to local memory
16
17
  // - Agent auto-recalls past context at task start (via SKILL.md triggers)
18
+ // - (if guard enabled) accepted decisions are re-injected before edits + on session start
17
19
  // - "Use Linksee" in any prompt forces a recall
18
20
  //
19
21
  // Why: Competing memory tools (claude-mem, etc.) are one-install-and-done.
@@ -24,24 +26,26 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from
24
26
  import { join, dirname } from 'node:path';
25
27
  import { homedir } from 'node:os';
26
28
  import { fileURLToPath } from 'node:url';
29
+ import { createInterface } from 'node:readline';
27
30
  const args = process.argv.slice(2);
28
31
  const dryRun = args.includes('--dry-run');
29
32
  const autoYes = args.includes('--yes') || args.includes('-y');
30
33
  const showHelp = args.includes('--help') || args.includes('-h');
31
34
  if (showHelp) {
32
- console.log(`linksee-memory-setup — One-command setup for Linksee Memory
33
-
34
- Usage:
35
- npx linksee-memory-setup Interactive setup
36
- npx linksee-memory-setup --yes Accept all defaults, no prompts
37
- npx linksee-memory-setup --dry-run Show what would happen
38
-
39
- What it does:
40
- 1. Registers linksee-memory MCP server with Claude Code
41
- 2. Installs SKILL.md (teaches the agent when to recall/remember)
42
- 3. Configures Stop hook (auto-captures every session)
43
-
44
- After setup, just chat with Claude Code normally.
35
+ console.log(`linksee-memory-setup — One-command setup for Linksee Memory
36
+
37
+ Usage:
38
+ npx linksee-memory-setup Interactive setup
39
+ npx linksee-memory-setup --yes Accept all defaults, no prompts
40
+ npx linksee-memory-setup --dry-run Show what would happen
41
+
42
+ What it does:
43
+ 1. Registers linksee-memory MCP server with Claude Code
44
+ 2. Installs SKILL.md (teaches the agent when to recall/remember)
45
+ 3. Configures Stop hook (auto-captures every session)
46
+ 4. Offers to wire the re-injection guard into THIS project's .claude/settings.json
47
+
48
+ After setup, just chat with Claude Code normally.
45
49
  Add "Use Linksee" to any prompt to trigger memory recall.`);
46
50
  process.exit(0);
47
51
  }
@@ -56,6 +60,14 @@ const SKILL_SRC = join(dirname(__filename), '..', 'skill', 'SKILL.md');
56
60
  const SERVER_NAME = 'linksee';
57
61
  const MCP_COMMAND = `claude mcp add -s user ${SERVER_NAME} -- npx -y linksee-memory`;
58
62
  const HOOK_COMMAND = 'npx -y linksee-memory-sync';
63
+ // Re-injection guard — wired into the PROJECT (not user-global) settings, because it enforces THIS
64
+ // project's accepted decisions. Mirrors the dogfood wiring's ${CLAUDE_PROJECT_DIR}/dist/bin path, but
65
+ // points at the globally-installed `linksee-memory-guard` bin so it ships without a build step. Shell
66
+ // form (resolved at run time) survives npx-cache eviction; a baked dist path would not.
67
+ const GUARD_COMMAND = 'npx -y linksee-memory-guard';
68
+ const PROJECT_DIR = process.cwd();
69
+ const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
70
+ const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
59
71
  const CHECK = '\x1b[32m✓\x1b[0m';
60
72
  const SKIP = '\x1b[33m○\x1b[0m';
61
73
  const FAIL = '\x1b[31m✗\x1b[0m';
@@ -67,7 +79,7 @@ console.log(`${BOLD}Linksee Memory Setup${RESET}`);
67
79
  console.log(`${DIM}Local-first cross-LLM memory · precision recall${RESET}`);
68
80
  console.log('');
69
81
  // ── Step 1: Register MCP server ──────────────────────────
70
- console.log(`${BOLD}[1/3]${RESET} Registering MCP server...`);
82
+ console.log(`${BOLD}[1/4]${RESET} Registering MCP server...`);
71
83
  let mcpAlreadyRegistered = false;
72
84
  try {
73
85
  // Check if already registered by looking at settings.json or .claude.json
@@ -132,7 +144,7 @@ else {
132
144
  }
133
145
  console.log('');
134
146
  // ── Step 2: Install SKILL.md ─────────────────────────────
135
- console.log(`${BOLD}[2/3]${RESET} Installing agent skill...`);
147
+ console.log(`${BOLD}[2/4]${RESET} Installing agent skill...`);
136
148
  if (!existsSync(SKILL_SRC)) {
137
149
  console.log(` ${FAIL} Bundled SKILL.md not found (packaging bug)`);
138
150
  console.log(` ${DIM}Expected at: ${SKILL_SRC}${RESET}`);
@@ -165,7 +177,7 @@ else {
165
177
  }
166
178
  console.log('');
167
179
  // ── Step 3: Configure Stop hook ──────────────────────────
168
- console.log(`${BOLD}[3/3]${RESET} Configuring auto-capture hook...`);
180
+ console.log(`${BOLD}[3/4]${RESET} Configuring auto-capture hook...`);
169
181
  let settings = {};
170
182
  if (existsSync(SETTINGS_PATH)) {
171
183
  try {
@@ -200,6 +212,80 @@ else {
200
212
  console.log(` ${CHECK} Stop hook added → ${SETTINGS_PATH}`);
201
213
  }
202
214
  console.log('');
215
+ const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
216
+ const GUARD_HOOKS = {
217
+ // matchers + timeouts mirror the dogfood .claude/settings.json
218
+ SessionStart: { matcher: 'startup|resume|compact', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 15 }] },
219
+ PreToolUse: { matcher: 'Edit|Write|Bash', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 8 }] },
220
+ };
221
+ // Idempotency probe: is OUR guard already wired for this event? (Match by bin name so a manually-added
222
+ // or previously-installed entry isn't duplicated, and other people's hooks are never touched.)
223
+ function guardWiredFor(s, ev) {
224
+ return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
225
+ }
226
+ function askYesNo(question) {
227
+ return new Promise((resolve) => {
228
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
229
+ rl.question(`${question} [Y/n] `, (ans) => {
230
+ rl.close();
231
+ const a = ans.trim().toLowerCase();
232
+ resolve(a === '' || a === 'y' || a === 'yes'); // default = yes
233
+ });
234
+ });
235
+ }
236
+ async function configureGuard() {
237
+ console.log(`${BOLD}[4/4]${RESET} Configuring re-injection guard (this project)...`);
238
+ let project = {};
239
+ if (existsSync(PROJECT_SETTINGS_PATH)) {
240
+ try {
241
+ // Strip a leading BOM (U+FEFF) — Windows editors (Notepad) emit UTF-8+BOM, which JSON.parse rejects.
242
+ const raw = readFileSync(PROJECT_SETTINGS_PATH, 'utf8');
243
+ project = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
244
+ }
245
+ catch {
246
+ // Never clobber a file we can't parse — the user may have hand-authored it.
247
+ console.log(` ${FAIL} Could not parse ${PROJECT_SETTINGS_PATH} — left untouched`);
248
+ console.log(` ${DIM}Add the guard block by hand (see README → Re-injection Guard).${RESET}`);
249
+ return false;
250
+ }
251
+ }
252
+ if (GUARD_EVENTS.every((ev) => guardWiredFor(project, ev))) {
253
+ console.log(` ${SKIP} Guard already wired in ${PROJECT_SETTINGS_PATH}`);
254
+ return true;
255
+ }
256
+ if (dryRun) {
257
+ console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${PROJECT_SETTINGS_PATH}${RESET}`);
258
+ return false;
259
+ }
260
+ // "Offer" — opt-in, because the guard can deny tool calls on a 'hard' contradiction.
261
+ if (!autoYes) {
262
+ if (!process.stdin.isTTY) {
263
+ console.log(` ${SKIP} Skipped (non-interactive shell). Re-run with --yes, or paste the README block.`);
264
+ return false;
265
+ }
266
+ console.log(` ${DIM}Re-injects your accepted decisions before Edit/Write/Bash and on session start.`);
267
+ console.log(` Fail-open — only an action that contradicts a 'hard' anchor is ever blocked.${RESET}`);
268
+ const ok = await askYesNo(` Wire it into ${PROJECT_SETTINGS_PATH}?`);
269
+ if (!ok) {
270
+ console.log(` ${SKIP} Skipped. Enable later via the README → Re-injection Guard.`);
271
+ return false;
272
+ }
273
+ }
274
+ // Merge, don't replace: append only the events we don't already own; leave foreign hooks intact.
275
+ const hooks = project.hooks ?? (project.hooks = {});
276
+ for (const ev of GUARD_EVENTS) {
277
+ if (!Array.isArray(hooks[ev]))
278
+ hooks[ev] = [];
279
+ if (!guardWiredFor(project, ev))
280
+ hooks[ev].push(GUARD_HOOKS[ev]);
281
+ }
282
+ mkdirSync(PROJECT_CLAUDE_DIR, { recursive: true });
283
+ writeFileSync(PROJECT_SETTINGS_PATH, JSON.stringify(project, null, 2), 'utf8');
284
+ console.log(` ${CHECK} Guard wired → ${PROJECT_SETTINGS_PATH}`);
285
+ return true;
286
+ }
287
+ const guardConfigured = await configureGuard();
288
+ console.log('');
203
289
  // ── Summary ──────────────────────────────────────────────
204
290
  console.log(`${BOLD}Setup complete!${RESET}`);
205
291
  console.log('');
@@ -208,6 +294,9 @@ console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, lea
208
294
  console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
209
295
  console.log(` ${DIM}• Memory is local-first (nothing leaves your machine)${RESET}`);
210
296
  console.log(` ${DIM}• Works across Claude Code, Cursor, ChatGPT (cross-LLM)${RESET}`);
297
+ if (guardConfigured) {
298
+ console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
299
+ }
211
300
  console.log('');
212
301
  console.log('Test by asking:');
213
302
  console.log(` ${BOLD}"How did we solve this before?"${RESET}`);
package/dist/bin/stats.js CHANGED
@@ -53,11 +53,11 @@ function humanAge(unix) {
53
53
  function main() {
54
54
  const args = parseArgs();
55
55
  if (args.help) {
56
- console.log(`linksee-memory-stats — summary of the local memory DB
57
-
58
- --json Output machine-readable JSON
59
- --per-entity N Show top N entities (default 5, 0 to skip)
60
- -h, --help This message
56
+ console.log(`linksee-memory-stats — summary of the local memory DB
57
+
58
+ --json Output machine-readable JSON
59
+ --per-entity N Show top N entities (default 5, 0 to skip)
60
+ -h, --help This message
61
61
  `);
62
62
  return;
63
63
  }
@@ -88,23 +88,23 @@ function main() {
88
88
  const oldest = db.prepare('SELECT MIN(created_at) as t FROM memories').get().t;
89
89
  const newest = db.prepare('SELECT MAX(created_at) as t FROM memories').get().t;
90
90
  const topEntities = args.perEntity > 0
91
- ? db.prepare(`
92
- SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
93
- MAX(m.last_accessed_at) as last_access
94
- FROM entities e
95
- LEFT JOIN memories m ON m.entity_id = e.id
96
- GROUP BY e.id
97
- ORDER BY memory_count DESC, e.momentum_score DESC
98
- LIMIT ?
91
+ ? db.prepare(`
92
+ SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
93
+ MAX(m.last_accessed_at) as last_access
94
+ FROM entities e
95
+ LEFT JOIN memories m ON m.entity_id = e.id
96
+ GROUP BY e.id
97
+ ORDER BY memory_count DESC, e.momentum_score DESC
98
+ LIMIT ?
99
99
  `).all(args.perEntity)
100
100
  : [];
101
- const topFiles = db.prepare(`
102
- SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
103
- FROM session_file_edits
104
- WHERE operation IN ('edit', 'write')
105
- GROUP BY file_path
106
- ORDER BY edits DESC
107
- LIMIT 5
101
+ const topFiles = db.prepare(`
102
+ SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
103
+ FROM session_file_edits
104
+ WHERE operation IN ('edit', 'write')
105
+ GROUP BY file_path
106
+ ORDER BY edits DESC
107
+ LIMIT 5
108
108
  `).all();
109
109
  const result = {
110
110
  db_path: dbPath,
@@ -33,11 +33,11 @@ export function runMigrations(db) {
33
33
  // v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
34
34
  // Only runs when upgrading an existing DB from schema v1-3.
35
35
  if (currentVersion > 0 && currentVersion < 4) {
36
- db.exec(`
37
- DROP TRIGGER IF EXISTS trg_memories_fts_ai;
38
- DROP TRIGGER IF EXISTS trg_memories_fts_ad;
39
- DROP TRIGGER IF EXISTS trg_memories_fts_au;
40
- DROP TABLE IF EXISTS memories_fts;
36
+ db.exec(`
37
+ DROP TRIGGER IF EXISTS trg_memories_fts_ai;
38
+ DROP TRIGGER IF EXISTS trg_memories_fts_ad;
39
+ DROP TRIGGER IF EXISTS trg_memories_fts_au;
40
+ DROP TABLE IF EXISTS memories_fts;
41
41
  `);
42
42
  }
43
43
  // v4 → v5: add normalized_name column BEFORE schema.sql runs,
@@ -81,11 +81,35 @@ export function runMigrations(db) {
81
81
  db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
82
82
  }
83
83
  // Backfill thread_id from content JSON session_id for existing memories
84
- db.exec(`
85
- UPDATE memories SET thread_id = json_extract(content, '$.session_id')
86
- WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
84
+ db.exec(`
85
+ UPDATE memories SET thread_id = json_extract(content, '$.session_id')
86
+ WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
87
87
  `);
88
88
  }
89
+ // v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
90
+ // ADDITIVE columns only (ADD COLUMN with safe defaults) — NO CHECK rebuild, so the existing
91
+ // detector/dashboard (which read the original columns + status active/retired) are unaffected.
92
+ // `lifecycle` carries the rich state; `status` stays the coarse scan-gate. New tables
93
+ // (reality_events, memory_write_candidates) are created by db.exec(sql) below (CREATE IF NOT EXISTS).
94
+ if (currentVersion > 0 && currentVersion < 9) {
95
+ const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
96
+ const addCol = (name, ddl) => {
97
+ if (!have.has(name))
98
+ db.exec(`ALTER TABLE drift_anchors ADD COLUMN ${ddl}`);
99
+ };
100
+ addCol('node_type', 'node_type TEXT');
101
+ addCol('domain', 'domain TEXT');
102
+ addCol('decision_mode', 'decision_mode TEXT');
103
+ addCol('confidence', 'confidence REAL NOT NULL DEFAULT 0.8');
104
+ addCol('lifecycle', "lifecycle TEXT NOT NULL DEFAULT 'active'");
105
+ addCol('validity_scope', "validity_scope TEXT NOT NULL DEFAULT '{}'");
106
+ addCol('card_policy', "card_policy TEXT NOT NULL DEFAULT '{}'");
107
+ addCol('reality_manifestations', "reality_manifestations TEXT NOT NULL DEFAULT '[]'");
108
+ addCol('evidence_refs', "evidence_refs TEXT NOT NULL DEFAULT '[]'");
109
+ addCol('review_after', 'review_after INTEGER');
110
+ addCol('last_confirmed_at', 'last_confirmed_at INTEGER');
111
+ addCol('owner', 'owner TEXT');
112
+ }
89
113
  db.exec(sql);
90
114
  if (currentVersion > 0 && currentVersion < 4) {
91
115
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
@@ -124,12 +148,12 @@ function migrateV5EntityNormalization(db) {
124
148
  }
125
149
  })();
126
150
  // 4. Auto-merge duplicate entities (same kind + normalized_name)
127
- const dupes = db.prepare(`
128
- SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
129
- FROM entities
130
- WHERE normalized_name IS NOT NULL
131
- GROUP BY kind, normalized_name
132
- HAVING COUNT(*) > 1
151
+ const dupes = db.prepare(`
152
+ SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
153
+ FROM entities
154
+ WHERE normalized_name IS NOT NULL
155
+ GROUP BY kind, normalized_name
156
+ HAVING COUNT(*) > 1
133
157
  `).all();
134
158
  if (dupes.length > 0) {
135
159
  console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
@@ -150,12 +174,12 @@ function mergeEntityCluster(db, ids) {
150
174
  if (ids.length < 2)
151
175
  return;
152
176
  // Score each entity: prefer most memories, then has canonical_key, then lowest id
153
- const rows = db.prepare(`
154
- SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
155
- FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
156
- WHERE e.id IN (${ids.map(() => '?').join(',')})
157
- GROUP BY e.id
158
- ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
177
+ const rows = db.prepare(`
178
+ SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
179
+ FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
180
+ WHERE e.id IN (${ids.map(() => '?').join(',')})
181
+ GROUP BY e.id
182
+ ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
159
183
  `).all(...ids);
160
184
  const keep = rows[0];
161
185
  const mergeIds = rows.slice(1).map(r => r.id);
@@ -176,16 +200,16 @@ function mergeEntityCluster(db, ids) {
176
200
  db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
177
201
  // Reassign edges (both directions)
178
202
  // Handle UNIQUE constraint: delete duplicates first
179
- db.prepare(`
180
- DELETE FROM edges WHERE from_id = ? AND EXISTS (
181
- SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
182
- )
203
+ db.prepare(`
204
+ DELETE FROM edges WHERE from_id = ? AND EXISTS (
205
+ SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
206
+ )
183
207
  `).run(mid, keep.id);
184
208
  db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
185
- db.prepare(`
186
- DELETE FROM edges WHERE to_id = ? AND EXISTS (
187
- SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
188
- )
209
+ db.prepare(`
210
+ DELETE FROM edges WHERE to_id = ? AND EXISTS (
211
+ SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
212
+ )
189
213
  `).run(mid, keep.id);
190
214
  db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
191
215
  // Reassign consolidations