linksee-memory 0.8.0 → 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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // linksee-memory-guard — Claude Code hook adapter for the re-injection layer.
3
+ //
4
+ // Reads a hook event JSON from stdin and emits hook-control JSON to stdout:
5
+ // • PreToolUse (matcher Edit|Write|Bash) → gateAction → block (hard) / soft-inject (warn|inform)
6
+ // • SessionStart (startup|resume|compact) → buildBootDigest → additionalContext
7
+ //
8
+ // FAIL-OPEN by construction: any parse/DB/logic error → NO output, exit 0 → the tool/session proceeds
9
+ // unblocked. The ONLY thing that ever blocks is an explicit `gate_mode:'hard'` contradiction. This is
10
+ // intentional — a guard that breaks the user's workflow on its own bug is a footgun.
11
+ //
12
+ // Wire it (project or ~/.claude/settings.json), exec form so Windows .cmd shims are bypassed:
13
+ // { "hooks": { "PreToolUse": [ { "matcher": "Edit|Write|Bash",
14
+ // "hooks": [ { "type": "command", "command": "node",
15
+ // "args": ["${CLAUDE_PROJECT_DIR}/dist/bin/guard-hook.js"], "timeout": 8 } ] } ] } }
16
+ import { pathToFileURL } from 'node:url';
17
+ import { openDb, runMigrations } from '../db/migrate.js';
18
+ import { gateAction, buildBootDigest } from '../lib/guard.js';
19
+ function readStdin() {
20
+ return new Promise((resolve) => {
21
+ const chunks = [];
22
+ process.stdin.on('data', (c) => chunks.push(c));
23
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
24
+ process.stdin.on('error', () => resolve(''));
25
+ });
26
+ }
27
+ function emit(obj) {
28
+ process.stdout.write(JSON.stringify(obj));
29
+ }
30
+ async function main() {
31
+ let ev;
32
+ try {
33
+ ev = JSON.parse(await readStdin());
34
+ }
35
+ catch {
36
+ process.exit(0); // unparseable stdin → fail-open
37
+ }
38
+ if (!ev || typeof ev !== 'object')
39
+ process.exit(0);
40
+ let db;
41
+ try {
42
+ db = openDb();
43
+ db.pragma('busy_timeout = 2000'); // MCP server may hold a write lock; wait briefly, else fail-open
44
+ runMigrations(db); // ensures injection_log exists even when run standalone
45
+ }
46
+ catch {
47
+ process.exit(0); // can't open DB → never block
48
+ }
49
+ try {
50
+ if (ev.hook_event_name === 'PreToolUse') {
51
+ const ti = (ev.tool_input ?? {});
52
+ const r = gateAction(db, {
53
+ tool: ev.tool_name,
54
+ file_path: ti.file_path,
55
+ command: ti.command,
56
+ content: ti.content,
57
+ diff: ti.new_string ?? ti.diff, // Edit passes new_string; some tools pass diff
58
+ }, { sessionId: ev.session_id });
59
+ if (r.gate === 'block') {
60
+ emit({
61
+ hookSpecificOutput: {
62
+ hookEventName: 'PreToolUse',
63
+ permissionDecision: 'deny',
64
+ permissionDecisionReason: r.reinject,
65
+ },
66
+ });
67
+ }
68
+ else if (r.gate === 'warn' || r.gate === 'inform') {
69
+ emit({
70
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: r.reinject },
71
+ });
72
+ }
73
+ // 'allow' → emit nothing
74
+ }
75
+ else if (ev.hook_event_name === 'SessionStart') {
76
+ const d = buildBootDigest(db);
77
+ if (d.text) {
78
+ emit({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: d.text } });
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ /* fail-open: surface nothing rather than risk blocking on a guard bug */
84
+ }
85
+ try {
86
+ db.close();
87
+ }
88
+ catch {
89
+ /* ignore */
90
+ }
91
+ process.exit(0);
92
+ }
93
+ const invoked = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
94
+ if (invoked)
95
+ main();
96
+ //# sourceMappingURL=guard-hook.js.map
@@ -16,10 +16,10 @@ import { extractSession } from '../lib/session-extractor.js';
16
16
  import { normalizeEntityName } from '../lib/normalize.js';
17
17
  const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
18
18
  function usage() {
19
- console.log(`Usage:
20
- node dist/bin/import-sessions.js [--dry-run] [--all | <projectDir> [<projectDir> ...]]
21
- --all : scan every project under ~/.claude/projects/*
22
- --dry-run : parse + extract but do not write to DB
19
+ console.log(`Usage:
20
+ node dist/bin/import-sessions.js [--dry-run] [--all | <projectDir> [<projectDir> ...]]
21
+ --all : scan every project under ~/.claude/projects/*
22
+ --dry-run : parse + extract but do not write to DB
23
23
  projectDir : absolute path to a project dir (must contain *.jsonl files)`);
24
24
  }
25
25
  function collectJsonlFiles(projectDir) {
@@ -42,13 +42,33 @@ function collectJsonlFiles(projectDir) {
42
42
  }
43
43
  // Idempotent wipe: remove all rows tied to a given session_id BEFORE re-inserting.
44
44
  // Uses LIKE matching on the JSON-encoded source field for memories/events.
45
+ // DISTILLED memories survive the wipe: a session stays ACTIVE while its raw extractions
46
+ // get distilled (dream → remember(memory_id) rewrite), and the next Stop-hook re-import
47
+ // must not destroy that human/agent-curated rewrite and resurrect the raw utterance.
45
48
  function wipeSession(db, sessionId) {
46
49
  const sidNeedle = `%"session_id":"${sessionId}"%`;
47
50
  const editDel = db.prepare('DELETE FROM session_file_edits WHERE session_id = ?').run(sessionId);
48
- const memDel = db.prepare('DELETE FROM memories WHERE source LIKE ?').run(sidNeedle);
51
+ const memDel = db.prepare(`DELETE FROM memories WHERE source LIKE ?
52
+ AND (NOT json_valid(content) OR COALESCE(json_extract(content, '$.distilled'), 0) != 1)`).run(sidNeedle);
49
53
  const evtDel = db.prepare('DELETE FROM events WHERE payload LIKE ?').run(sidNeedle);
50
54
  return { memories: memDel.changes, edits: editDel.changes, events: evtDel.changes };
51
55
  }
56
+ // A re-extracted memory must NOT be re-inserted if its source turn already has a surviving
57
+ // distilled rewrite (matched by turn_uuid — the stable key shared by raw and rewrite).
58
+ function makeDistilledTurnSet(db, sessionId) {
59
+ const rows = db.prepare(`SELECT source FROM memories
60
+ WHERE source LIKE ? AND json_valid(content) AND json_extract(content, '$.distilled') = 1`).all(`%"session_id":"${sessionId}"%`);
61
+ const set = new Set();
62
+ for (const r of rows) {
63
+ try {
64
+ const uuid = JSON.parse(r.source)?.turn_uuid;
65
+ if (uuid)
66
+ set.add(String(uuid));
67
+ }
68
+ catch { /* ignore malformed source */ }
69
+ }
70
+ return set;
71
+ }
52
72
  async function main() {
53
73
  const args = process.argv.slice(2);
54
74
  if (args.includes('-h') || args.includes('--help')) {
@@ -86,7 +106,10 @@ async function main() {
86
106
  if (!db)
87
107
  return;
88
108
  // Idempotent: wipe any prior data for THIS session before re-inserting
109
+ // (distilled rewrites survive — see wipeSession), then skip re-extracting
110
+ // any turn that already has a distilled rewrite.
89
111
  const wiped = wipeSession(db, result.session_id);
112
+ const distilledTurns = makeDistilledTurnSet(db, result.session_id);
90
113
  // Resolve project entity (canonical_key = project:<name>, so same project across
91
114
  // different sessions/cwds collapses to one entity)
92
115
  let projectEntityId;
@@ -102,14 +125,19 @@ async function main() {
102
125
  const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
103
126
  projectEntityId = Number(ins.lastInsertRowid);
104
127
  }
105
- const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id) VALUES (?, ?, ?, ?, ?, ?)');
128
+ // created_at = the source turn's true timestamp. The Stop hook re-imports the growing
129
+ // transcript every turn (wipe+reinsert), and DEFAULT unixepoch() restamped days-old
130
+ // content as "now" — inflating every time-windowed view ("today" showed last week).
131
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
106
132
  const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
107
133
  const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
108
134
  let inserted = { memories: 0, edits: 0 };
109
135
  db.transaction(() => {
110
136
  const memContentToId = new Map();
111
137
  for (const m of result.memories) {
112
- const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source), m.thread_id ?? null);
138
+ if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
139
+ continue; // distilled rewrite wins
140
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source), m.thread_id ?? null, m.occurred_at ?? Math.floor(Date.now() / 1000));
113
141
  memContentToId.set(m.content, Number(res.lastInsertRowid));
114
142
  inserted.memories++;
115
143
  }
@@ -221,17 +249,22 @@ async function main() {
221
249
  const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
222
250
  projectEntityId = Number(ins.lastInsertRowid);
223
251
  }
224
- // Idempotent: wipe any prior data for THIS session before re-inserting (Phase B)
252
+ // Idempotent: wipe any prior data for THIS session before re-inserting (Phase B).
253
+ // Distilled rewrites survive the wipe and suppress re-insertion of their raw turn.
225
254
  wipeSession(db, result.session_id);
226
- // Insert memories + file_edits in a single transaction per session
227
- const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id) VALUES (?, ?, ?, ?, ?, ?)');
255
+ const distilledTurns = makeDistilledTurnSet(db, result.session_id);
256
+ // Insert memories + file_edits in a single transaction per session.
257
+ // created_at = source-turn timestamp (see single-file mode above for why).
258
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
228
259
  const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
229
260
  const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
230
261
  const tx = db.transaction(() => {
231
262
  const memContentToId = new Map();
232
263
  for (const m of result.memories) {
264
+ if (m.source.turn_uuid && distilledTurns.has(m.source.turn_uuid))
265
+ continue; // distilled rewrite wins
233
266
  const srcJson = JSON.stringify(m.source);
234
- const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson, m.thread_id ?? null);
267
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson, m.thread_id ?? null, m.occurred_at ?? Math.floor(Date.now() / 1000));
235
268
  memContentToId.set(m.content, Number(res.lastInsertRowid));
236
269
  agg.memories_inserted++;
237
270
  }
@@ -21,20 +21,20 @@ const force = args.includes('--force') || args.includes('-f');
21
21
  const dryRun = args.includes('--dry-run');
22
22
  const showHelp = args.includes('--help') || args.includes('-h');
23
23
  if (showHelp) {
24
- console.log(`linksee-memory-install-skill
25
-
26
- Install the linksee-memory Claude Code skill into ~/.claude/skills/linksee-memory/.
27
-
28
- Options:
29
- --force, -f Overwrite an existing skill file
30
- --dry-run Show what would happen without writing
31
- --help, -h This message
32
-
33
- After installation, ensure the MCP server is registered in Claude Code:
34
- claude mcp add -s user linksee -- npx -y linksee-memory
35
-
36
- The skill expects tool names of the form mcp__linksee__*. If you register the
37
- server under a different name (e.g. "linksee-memory"), edit the skill file
24
+ console.log(`linksee-memory-install-skill
25
+
26
+ Install the linksee-memory Claude Code skill into ~/.claude/skills/linksee-memory/.
27
+
28
+ Options:
29
+ --force, -f Overwrite an existing skill file
30
+ --dry-run Show what would happen without writing
31
+ --help, -h This message
32
+
33
+ After installation, ensure the MCP server is registered in Claude Code:
34
+ claude mcp add -s user linksee -- npx -y linksee-memory
35
+
36
+ The skill expects tool names of the form mcp__linksee__*. If you register the
37
+ server under a different name (e.g. "linksee-memory"), edit the skill file
38
38
  afterwards.`);
39
39
  process.exit(0);
40
40
  }
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,
@@ -335,6 +335,29 @@ CREATE TABLE IF NOT EXISTS memory_write_candidates (
335
335
  CREATE INDEX IF NOT EXISTS idx_mwc_status ON memory_write_candidates(status);
336
336
  CREATE INDEX IF NOT EXISTS idx_mwc_scope ON memory_write_candidates(scope);
337
337
 
338
+ -- ============================================================
339
+ -- v10: Re-injection log — the ACTIVE-observability stream (pre-action gate hits).
340
+ -- Separate from drift_edges (POST-action reality): the gate (guard.ts, fired by a Claude Code
341
+ -- PreToolUse hook) writes here when an accepted anchor is re-surfaced into context BEFORE an action.
342
+ -- Feeds `dream` — "re-injected N times, still contradicted (heeded=0)" is the machine evidence behind
343
+ -- #15443. trigger: boot=SessionStart · cue=prompt · gate=PreToolUse.
344
+ -- ============================================================
345
+ CREATE TABLE IF NOT EXISTS injection_log (
346
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
347
+ anchor_id INTEGER REFERENCES drift_anchors(id) ON DELETE CASCADE,
348
+ session_id TEXT,
349
+ trigger TEXT NOT NULL CHECK (trigger IN ('boot', 'cue', 'gate')),
350
+ surface TEXT NOT NULL CHECK (surface IN ('inform', 'warn', 'block', 'allow')),
351
+ tool_name TEXT,
352
+ action_snip TEXT, -- first ~120 chars of the attempted action
353
+ verdict TEXT, -- contradicts | in_scope | none
354
+ heeded INTEGER, -- NULL=unknown / 1=followed / 0=ignored (dream backfills)
355
+ occurred_at INTEGER NOT NULL DEFAULT (unixepoch())
356
+ );
357
+
358
+ CREATE INDEX IF NOT EXISTS idx_injlog_anchor ON injection_log(anchor_id, occurred_at);
359
+ CREATE INDEX IF NOT EXISTS idx_injlog_session ON injection_log(session_id, occurred_at);
360
+
338
361
  -- ============================================================
339
362
  -- Meta — schema version tracking
340
363
  -- ============================================================
@@ -343,6 +366,6 @@ CREATE TABLE IF NOT EXISTS meta (
343
366
  value TEXT NOT NULL
344
367
  );
345
368
 
346
- INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '9');
369
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '10');
347
370
  INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
348
- UPDATE meta SET value = '9' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8');
371
+ UPDATE meta SET value = '10' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9');
@@ -25,17 +25,17 @@ export function consolidate(db, opts = {}) {
25
25
  // table is actually populated by the MCP server).
26
26
  const layerPlaceholders = CLUSTER_LAYERS.map(() => '?').join(',');
27
27
  const rows = db
28
- .prepare(`
29
- SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
30
- m.last_accessed_at, m.access_count, m.created_at, m.protected,
31
- m.altitude,
32
- e.name as entity_name
33
- FROM memories m
34
- JOIN entities e ON e.id = m.entity_id
35
- WHERE m.protected = 0
36
- AND m.layer IN (${layerPlaceholders})
37
- AND m.created_at <= ?
38
- ORDER BY m.entity_id, m.layer, m.created_at
28
+ .prepare(`
29
+ SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
30
+ m.last_accessed_at, m.access_count, m.created_at, m.protected,
31
+ m.altitude,
32
+ e.name as entity_name
33
+ FROM memories m
34
+ JOIN entities e ON e.id = m.entity_id
35
+ WHERE m.protected = 0
36
+ AND m.layer IN (${layerPlaceholders})
37
+ AND m.created_at <= ?
38
+ ORDER BY m.entity_id, m.layer, m.created_at
39
39
  `)
40
40
  .all(...CLUSTER_LAYERS, ageCutoff);
41
41
  // Filter to cold-heat memories
@@ -68,10 +68,10 @@ export function consolidate(db, opts = {}) {
68
68
  memoryEdgesCreated: 0,
69
69
  decisionsSuperseded: 0,
70
70
  };
71
- const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
71
+ const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
72
72
  VALUES (?, 'learning', ?, ?, 1, ?)`);
73
73
  const deleteMemory = db.prepare('DELETE FROM memories WHERE id = ?');
74
- const insertAudit = db.prepare(`INSERT INTO consolidations (learning_id, replaced_ids, replaced_count, entity_id, original_layer)
74
+ const insertAudit = db.prepare(`INSERT INTO consolidations (learning_id, replaced_ids, replaced_count, entity_id, original_layer)
75
75
  VALUES (?, ?, ?, ?, ?)`);
76
76
  const insertEvent = db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)');
77
77
  const tx = db.transaction(() => {
@@ -137,12 +137,12 @@ export function consolidate(db, opts = {}) {
137
137
  // Uses json_set() to update the state field in content JSON atomically.
138
138
  const STALLED_THRESHOLD_DAYS = 30;
139
139
  const stalledCutoff = now - STALLED_THRESHOLD_DAYS * 86400;
140
- const stalledResult = db.prepare(`
141
- UPDATE memories SET content = json_set(content, '$.state', 'stalled')
142
- WHERE json_valid(content)
143
- AND json_extract(content, '$.state') = 'in_progress'
144
- AND last_accessed_at < ?
145
- AND protected = 0
140
+ const stalledResult = db.prepare(`
141
+ UPDATE memories SET content = json_set(content, '$.state', 'stalled')
142
+ WHERE json_valid(content)
143
+ AND json_extract(content, '$.state') = 'in_progress'
144
+ AND last_accessed_at < ?
145
+ AND protected = 0
146
146
  `).run(stalledCutoff);
147
147
  result.stalledTransitions = stalledResult.changes;
148
148
  // Memory→memory edge detection: link superseding/contradicting decisions so the
@@ -71,11 +71,11 @@ export function detectMemoryEdges(db, opts = {}) {
71
71
  const res = {
72
72
  decisionsScanned: 0, edgesCreated: 0, supersedes: 0, contradicts: 0, extends: 0, supersededMarked: 0, samples: [],
73
73
  };
74
- const rows = db.prepare(`
75
- SELECT id, entity_id, content, created_at
76
- FROM memories
77
- WHERE mem_type = 'decision' AND json_valid(content)
78
- ORDER BY entity_id ASC, created_at ASC, id ASC
74
+ const rows = db.prepare(`
75
+ SELECT id, entity_id, content, created_at
76
+ FROM memories
77
+ WHERE mem_type = 'decision' AND json_valid(content)
78
+ ORDER BY entity_id ASC, created_at ASC, id ASC
79
79
  `).all();
80
80
  res.decisionsScanned = rows.length;
81
81
  if (rows.length < 2)
@@ -91,9 +91,9 @@ export function detectMemoryEdges(db, opts = {}) {
91
91
  // Prepare write statements only when actually writing — keeps dryRun safe on a
92
92
  // readonly connection (preview / verification path).
93
93
  const insEdge = opts.dryRun ? null : db.prepare(`INSERT OR IGNORE INTO memory_edges (from_memory_id, to_memory_id, relation) VALUES (?, ?, ?)`);
94
- const markSuperseded = opts.dryRun ? null : db.prepare(`
95
- UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
- WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
94
+ const markSuperseded = opts.dryRun ? null : db.prepare(`
95
+ UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
+ WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
97
97
  `);
98
98
  const apply = () => {
99
99
  for (const decisions of byEntity.values()) {