linksee-memory 0.8.0 → 0.11.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
@@ -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
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};