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/LICENSE +21 -21
- package/README.md +839 -678
- package/dist/bin/declare-anchor.d.ts +2 -0
- package/dist/bin/declare-anchor.js +146 -0
- package/dist/bin/detect-drift.d.ts +2 -0
- package/dist/bin/detect-drift.js +91 -0
- 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/migrate.js +52 -28
- package/dist/db/schema.sql +371 -232
- package/dist/lib/consolidate.js +19 -19
- package/dist/lib/drift-anchors.d.ts +78 -0
- package/dist/lib/drift-anchors.js +224 -0
- package/dist/lib/drift-detection.d.ts +62 -0
- package/dist/lib/drift-detection.js +416 -0
- package/dist/lib/drift-view.d.ts +62 -0
- package/dist/lib/drift-view.js +120 -0
- 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.d.ts +84 -0
- package/dist/lib/truth-engine.js +417 -0
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +573 -3
- package/dist/skill/SKILL.md +734 -631
- package/package.json +10 -5
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-declare — declare / list / retire drift anchors (v8).
|
|
3
|
+
//
|
|
4
|
+
// The explicit write path for drift observability. Anchors declared here are clean
|
|
5
|
+
// by construction (declare-don't-mine): a human typed them. The bulk seeding script
|
|
6
|
+
// (curate the existing candidate pool) reuses curateAnchorFromMemory() from the lib.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// linksee-memory-declare list [--status active|retired] [--kind prohibition|decision|constraint]
|
|
10
|
+
// linksee-memory-declare add --kind <k> --statement "<text>" [--rationale "<text>"]
|
|
11
|
+
// [--affects "glob1,glob2"] [--terms "t1,t2"] [--violation "v1,v2"] [--tier human|explicit]
|
|
12
|
+
// linksee-memory-declare retire --id <n>
|
|
13
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
14
|
+
import { declareAnchor, listAnchors, retireAnchor, setNodeFields, getCurrentTruth, getAlertPolicy, setAlertPolicy, } from '../lib/drift-anchors.js';
|
|
15
|
+
function parseFlags(argv) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a.startsWith('--')) {
|
|
20
|
+
const key = a.slice(2);
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (next === undefined || next.startsWith('--')) {
|
|
23
|
+
out[key] = 'true';
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
out[key] = next;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function splitList(v) {
|
|
34
|
+
if (!v)
|
|
35
|
+
return [];
|
|
36
|
+
return v
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
function print(obj) {
|
|
42
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
function main() {
|
|
45
|
+
const [, , sub, ...rest] = process.argv;
|
|
46
|
+
const flags = parseFlags(rest);
|
|
47
|
+
const db = openDb();
|
|
48
|
+
runMigrations(db); // ensure drift tables exist when run standalone
|
|
49
|
+
try {
|
|
50
|
+
switch (sub) {
|
|
51
|
+
case 'add': {
|
|
52
|
+
const anchor = declareAnchor(db, {
|
|
53
|
+
kind: flags.kind,
|
|
54
|
+
statement: flags.statement ?? '',
|
|
55
|
+
rationale: flags.rationale,
|
|
56
|
+
affects: splitList(flags.affects),
|
|
57
|
+
detect_terms: splitList(flags.terms),
|
|
58
|
+
violation_signal: splitList(flags.violation),
|
|
59
|
+
tier: flags.tier || undefined,
|
|
60
|
+
});
|
|
61
|
+
// v9 ProjectCoreNode fields (minimal input — only what's given):
|
|
62
|
+
// --node-type --domain --mode --confidence --cadence --stale-days --applies --not-applies
|
|
63
|
+
const nf = {};
|
|
64
|
+
if (flags['node-type'])
|
|
65
|
+
nf.node_type = flags['node-type'];
|
|
66
|
+
if (flags.domain)
|
|
67
|
+
nf.domain = flags.domain;
|
|
68
|
+
if (flags.mode)
|
|
69
|
+
nf.decision_mode = flags.mode;
|
|
70
|
+
if (flags.confidence)
|
|
71
|
+
nf.confidence = Number(flags.confidence);
|
|
72
|
+
if (flags.cadence || flags['stale-days']) {
|
|
73
|
+
const cp = { enabled: true };
|
|
74
|
+
if (flags.cadence)
|
|
75
|
+
cp.cadence_days = Number(flags.cadence);
|
|
76
|
+
if (flags['stale-days'])
|
|
77
|
+
cp.stale_threshold_days = Number(flags['stale-days']);
|
|
78
|
+
nf.card_policy = cp;
|
|
79
|
+
}
|
|
80
|
+
if (flags.applies || flags['not-applies']) {
|
|
81
|
+
const vs = {};
|
|
82
|
+
if (flags.applies)
|
|
83
|
+
vs.applies_to = splitList(flags.applies);
|
|
84
|
+
if (flags['not-applies'])
|
|
85
|
+
vs.does_not_apply_to = splitList(flags['not-applies']);
|
|
86
|
+
nf.validity_scope = vs;
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(nf).length)
|
|
89
|
+
setNodeFields(db, anchor.id, nf);
|
|
90
|
+
print({ ok: true, declared: anchor, node_fields: Object.keys(nf).length ? nf : null });
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'retire': {
|
|
94
|
+
const id = Number(flags.id);
|
|
95
|
+
if (!Number.isFinite(id))
|
|
96
|
+
throw new Error('--id <n> required');
|
|
97
|
+
const ok = retireAnchor(db, id);
|
|
98
|
+
print({ ok, retired: ok ? id : null });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'list':
|
|
102
|
+
case undefined: {
|
|
103
|
+
const anchors = listAnchors(db, {
|
|
104
|
+
status: flags.status,
|
|
105
|
+
kind: flags.kind,
|
|
106
|
+
});
|
|
107
|
+
print({ ok: true, count: anchors.length, anchors });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'truth': {
|
|
111
|
+
// ⑧ read_smart-style scoped read: active Current-Truth slice only.
|
|
112
|
+
const nodes = getCurrentTruth(db, { domain: flags.domain, decision_mode: flags.mode });
|
|
113
|
+
print({ ok: true, scope: { domain: flags.domain ?? 'all', decision_mode: flags.mode ?? 'all' }, count: nodes.length, current_truth: nodes });
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'policy': {
|
|
117
|
+
const p = {};
|
|
118
|
+
if (flags['max-cards-per-day'])
|
|
119
|
+
p.max_cards_per_day = Number(flags['max-cards-per-day']);
|
|
120
|
+
if (flags['max-soft-per-week'])
|
|
121
|
+
p.max_soft_cards_per_week = Number(flags['max-soft-per-week']);
|
|
122
|
+
if (flags['min-soft-confidence'])
|
|
123
|
+
p.min_confidence_for_soft_card = Number(flags['min-soft-confidence']);
|
|
124
|
+
if (flags['two-sided'])
|
|
125
|
+
p.require_two_sided_evidence = flags['two-sided'] !== 'false';
|
|
126
|
+
const policy = Object.keys(p).length ? setAlertPolicy(db, p) : getAlertPolicy(db);
|
|
127
|
+
print({ ok: true, alert_policy: policy });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
throw new Error(`unknown subcommand "${sub}" — use: add | list | retire | truth | policy`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
print({ ok: false, error: err?.message ?? String(err) });
|
|
136
|
+
db.close();
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
db.close();
|
|
140
|
+
}
|
|
141
|
+
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
142
|
+
process.argv[1]?.endsWith('declare-anchor.ts') ||
|
|
143
|
+
process.argv[1]?.endsWith('declare-anchor.js')) {
|
|
144
|
+
main();
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=declare-anchor.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-detect — run the drift detector (照合: declared intent vs. actual reality).
|
|
3
|
+
//
|
|
4
|
+
// The manual-trigger entry point for drift detection (the cadence question). Mirrors
|
|
5
|
+
// declare-anchor.ts; purely additive — touches only drift_edges (the feature's own table) and
|
|
6
|
+
// ONLY when --persist is passed. SAFE BY DEFAULT: a bare run is a dry-run (reads only, no
|
|
7
|
+
// writes), so you can preview drift before committing it to the view. Pass --persist to write
|
|
8
|
+
// the edges that /drift renders. No embedding layer: matching is lexical/glob/trigram-FTS only.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// linksee-memory-detect # dry-run — preview drift, writes NOTHING (default)
|
|
12
|
+
// linksee-memory-detect --persist # write contradicts/absent edges into drift_edges
|
|
13
|
+
// linksee-memory-detect --stale-days 30 # override absence staleness gate (default 14)
|
|
14
|
+
// linksee-memory-detect --threshold 0.5 # override emit threshold (default 0.5)
|
|
15
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
16
|
+
import { detectDrift, detectFileViolations } from '../lib/drift-detection.js';
|
|
17
|
+
function parseFlags(argv) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a.startsWith('--')) {
|
|
22
|
+
const key = a.slice(2);
|
|
23
|
+
const next = argv[i + 1];
|
|
24
|
+
if (next === undefined || next.startsWith('--')) {
|
|
25
|
+
out[key] = 'true';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
out[key] = next;
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function print(obj) {
|
|
36
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
function main() {
|
|
39
|
+
const [, , ...rest] = process.argv;
|
|
40
|
+
const flags = parseFlags(rest);
|
|
41
|
+
const persist = flags.persist === 'true';
|
|
42
|
+
const staleDays = flags['stale-days'] !== undefined ? Number(flags['stale-days']) : undefined;
|
|
43
|
+
const emitThreshold = flags.threshold !== undefined ? Number(flags.threshold) : undefined;
|
|
44
|
+
if (staleDays !== undefined && !Number.isFinite(staleDays))
|
|
45
|
+
throw new Error('--stale-days <n> must be a number');
|
|
46
|
+
if (emitThreshold !== undefined && !Number.isFinite(emitThreshold))
|
|
47
|
+
throw new Error('--threshold <n> must be a number');
|
|
48
|
+
const db = openDb();
|
|
49
|
+
runMigrations(db); // ensure drift tables exist when run standalone
|
|
50
|
+
try {
|
|
51
|
+
const res = detectDrift(db, { dryRun: !persist, staleDays, emitThreshold });
|
|
52
|
+
const fres = detectFileViolations(db, { dryRun: !persist, emitThreshold });
|
|
53
|
+
print({
|
|
54
|
+
ok: true,
|
|
55
|
+
mode: persist ? 'PERSISTED' : 'DRY RUN (no writes — pass --persist to write)',
|
|
56
|
+
persisted: res.persisted,
|
|
57
|
+
anchorsScanned: res.anchorsScanned,
|
|
58
|
+
editsScanned: res.editsScanned,
|
|
59
|
+
// v1 — edit-snippet scan (what a captured edit's snippet contained)
|
|
60
|
+
editSnippetScan: {
|
|
61
|
+
contradicts: res.contradicts,
|
|
62
|
+
absent: res.absent,
|
|
63
|
+
edgesEmitted: res.edgesEmitted,
|
|
64
|
+
byAnchorHits: res.byAnchor.filter((b) => b.contradicts > 0 || b.absent > 0),
|
|
65
|
+
samples: res.samples.slice(0, 6),
|
|
66
|
+
},
|
|
67
|
+
// v2 — current-file scan (live file:line against violation_signal)
|
|
68
|
+
currentFileScan: {
|
|
69
|
+
filesScanned: fres.filesScanned,
|
|
70
|
+
anchorsCapped: fres.anchorsCapped,
|
|
71
|
+
contradicts: fres.contradicts,
|
|
72
|
+
edgesEmitted: fres.edgesEmitted,
|
|
73
|
+
byAnchorHits: fres.byAnchor,
|
|
74
|
+
samples: fres.samples.slice(0, 14),
|
|
75
|
+
},
|
|
76
|
+
totalEdgesEmitted: res.edgesEmitted + fres.edgesEmitted,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
print({ ok: false, error: err?.message ?? String(err) });
|
|
81
|
+
db.close();
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
87
|
+
process.argv[1]?.endsWith('detect-drift.ts') ||
|
|
88
|
+
process.argv[1]?.endsWith('detect-drift.js')) {
|
|
89
|
+
main();
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=detect-drift.js.map
|
|
@@ -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
|
}
|