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.
- package/LICENSE +21 -21
- package/README.md +839 -782
- package/dist/bin/guard-hook.d.ts +2 -0
- package/dist/bin/guard-hook.js +96 -0
- package/dist/bin/import-sessions.js +44 -11
- package/dist/bin/install-skill.js +14 -14
- package/dist/bin/setup.js +107 -18
- package/dist/bin/stats.js +20 -20
- package/dist/db/schema.sql +25 -2
- package/dist/lib/consolidate.js +19 -19
- package/dist/lib/edge-detection.js +8 -8
- 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/momentum.js +7 -7
- package/dist/lib/session-extractor.d.ts +1 -0
- package/dist/lib/session-extractor.js +62 -11
- package/dist/lib/truth-engine.js +11 -11
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +410 -5
- package/dist/skill/SKILL.md +734 -631
- package/package.json +7 -4
|
@@ -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(
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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,
|
package/dist/db/schema.sql
CHANGED
|
@@ -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', '
|
|
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 = '
|
|
371
|
+
UPDATE meta SET value = '10' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9');
|
package/dist/lib/consolidate.js
CHANGED
|
@@ -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()) {
|