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.
- package/README.md +923 -782
- package/dist/bin/guard-hook.d.ts +2 -0
- package/dist/bin/guard-hook.js +96 -0
- package/dist/bin/import-sessions.js +40 -7
- package/dist/bin/map-import.d.ts +2 -0
- package/dist/bin/map-import.js +493 -0
- package/dist/bin/setup.js +94 -5
- package/dist/db/migrate.js +36 -0
- package/dist/db/schema.sql +96 -2
- package/dist/lib/drift-detection.js +38 -2
- package/dist/lib/guard.d.ts +81 -0
- package/dist/lib/guard.js +321 -0
- package/dist/lib/lexical-match.d.ts +6 -0
- package/dist/lib/lexical-match.js +87 -0
- package/dist/lib/map-import.d.ts +55 -0
- package/dist/lib/map-import.js +150 -0
- package/dist/lib/map-reconcile.d.ts +29 -0
- package/dist/lib/map-reconcile.js +245 -0
- package/dist/lib/map-view.d.ts +103 -0
- package/dist/lib/map-view.js +201 -0
- package/dist/lib/session-extractor.d.ts +1 -0
- package/dist/lib/session-extractor.js +62 -11
- package/dist/mcp/server.js +455 -5
- package/dist/skill/SKILL.md +103 -0
- package/package.json +12 -5
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
}
|