linksee-memory 0.11.4 → 0.12.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 +3 -1
- package/dist/bin/setup.js +54 -4
- package/dist/lib/guard.js +41 -31
- package/dist/lib/telemetry.js +13 -0
- package/dist/lib/truth-engine.d.ts +16 -0
- package/dist/lib/truth-engine.js +100 -14
- package/dist/mcp/server.js +72 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -618,7 +618,7 @@ Use both.
|
|
|
618
618
|
linksee-memory runs locally and is built to read — and send — as little as possible.
|
|
619
619
|
|
|
620
620
|
- **Local-first.** Memory is one SQLite file at `~/.linksee-memory/memory.db`. No account, no cloud, no API key.
|
|
621
|
-
- **Telemetry is opt-in and OFF by default.**
|
|
621
|
+
- **Telemetry is opt-in and OFF by default.** `setup` asks once; nothing is sent unless you agree there (or set `LINKSEE_TELEMETRY=basic`). Even then it never sends your source code, file contents, prompts, conversation, entity/project names, or the memory DB — only anonymous counters ([details](#telemetry-opt-in-off-by-default)).
|
|
622
622
|
- **No automatic repo crawling.** linksee reads: memory you explicitly save, your `map.yaml`, the specific files a map reality-check points at, the local SQLite DB, and — when the Stop hook fires — your Claude Code session transcript (locally, to capture what happened). It does **not** crawl your repo, read `.env`/secrets/`node_modules`, or touch your home directory on its own.
|
|
623
623
|
- **Clean MCP transport.** The server writes only JSON-RPC to stdout; all logs go to stderr.
|
|
624
624
|
- **Hooks are documented and removable.** `setup` adds a Stop hook (session capture) and an optional guard hook. They make no network calls by default, are time-bounded, fail-open (a hook error never breaks your session), and are listed under [Uninstall](#uninstall).
|
|
@@ -636,6 +636,8 @@ linksee-memory ships with **opt-in** anonymous telemetry that helps us understan
|
|
|
636
636
|
```bash
|
|
637
637
|
export LINKSEE_TELEMETRY=basic # opt in
|
|
638
638
|
export LINKSEE_TELEMETRY=off # opt out (or just unset the variable)
|
|
639
|
+
# `linksee-memory setup` also asks once and records your choice in
|
|
640
|
+
# ~/.linksee-memory/telemetry-consent (delete that file to be asked again).
|
|
639
641
|
```
|
|
640
642
|
|
|
641
643
|
### Exactly what gets sent (Level 1 contract)
|
package/dist/bin/setup.js
CHANGED
|
@@ -70,6 +70,9 @@ const GUARD_COMMAND = 'npx -y linksee-memory guard';
|
|
|
70
70
|
const PROJECT_DIR = process.cwd();
|
|
71
71
|
const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
|
|
72
72
|
const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
|
|
73
|
+
// Opt-in telemetry consent recorded at setup time (read by telemetry.ts getTelemetryMode).
|
|
74
|
+
const TELEMETRY_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(HOME, '.linksee-memory');
|
|
75
|
+
const TELEMETRY_CONSENT_FILE = join(TELEMETRY_DIR, 'telemetry-consent');
|
|
73
76
|
const CHECK = '\x1b[32m✓\x1b[0m';
|
|
74
77
|
const SKIP = '\x1b[33m○\x1b[0m';
|
|
75
78
|
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
@@ -225,13 +228,15 @@ const GUARD_HOOKS = {
|
|
|
225
228
|
function guardWiredFor(s, ev) {
|
|
226
229
|
return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
|
|
227
230
|
}
|
|
228
|
-
function askYesNo(question) {
|
|
231
|
+
function askYesNo(question, defaultYes = true) {
|
|
229
232
|
return new Promise((resolve) => {
|
|
230
233
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
231
|
-
rl.question(`${question} [Y/n] `, (ans) => {
|
|
234
|
+
rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, (ans) => {
|
|
232
235
|
rl.close();
|
|
233
236
|
const a = ans.trim().toLowerCase();
|
|
234
|
-
|
|
237
|
+
if (a === '')
|
|
238
|
+
return resolve(defaultYes);
|
|
239
|
+
resolve(a === 'y' || a === 'yes');
|
|
235
240
|
});
|
|
236
241
|
});
|
|
237
242
|
}
|
|
@@ -288,13 +293,58 @@ async function configureGuard() {
|
|
|
288
293
|
}
|
|
289
294
|
const guardConfigured = await configureGuard();
|
|
290
295
|
console.log('');
|
|
296
|
+
// ── Telemetry consent (opt-in, anonymous, off unless you agree) ──────────
|
|
297
|
+
async function configureTelemetryConsent() {
|
|
298
|
+
console.log(`${BOLD}Anonymous usage stats${RESET} ${DIM}(optional)${RESET}`);
|
|
299
|
+
const env = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
|
|
300
|
+
if (env) {
|
|
301
|
+
console.log(` ${SKIP} Controlled by LINKSEE_TELEMETRY=${env} ${DIM}(env overrides)${RESET}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (existsSync(TELEMETRY_CONSENT_FILE)) {
|
|
305
|
+
let cur = 'off';
|
|
306
|
+
try {
|
|
307
|
+
cur = (readFileSync(TELEMETRY_CONSENT_FILE, 'utf8').trim().toLowerCase()) || 'off';
|
|
308
|
+
}
|
|
309
|
+
catch { /* ignore */ }
|
|
310
|
+
console.log(` ${SKIP} Already chosen: ${cur === 'basic' ? 'on' : 'off'} ${DIM}(edit ${TELEMETRY_CONSENT_FILE} to change)${RESET}`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (dryRun) {
|
|
314
|
+
console.log(` ${DIM}[dry-run] Would ask whether to share anonymous usage stats${RESET}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const dnt = process.env.DO_NOT_TRACK === '1' || process.env.DO_NOT_TRACK === 'true';
|
|
318
|
+
if (dnt || autoYes || !process.stdin.isTTY) {
|
|
319
|
+
try {
|
|
320
|
+
mkdirSync(TELEMETRY_DIR, { recursive: true });
|
|
321
|
+
writeFileSync(TELEMETRY_CONSENT_FILE, 'off');
|
|
322
|
+
}
|
|
323
|
+
catch { /* best-effort */ }
|
|
324
|
+
const why = dnt ? 'DO_NOT_TRACK' : autoYes ? '--yes → off' : 'non-interactive';
|
|
325
|
+
console.log(` ${SKIP} Left off ${DIM}(${why})${RESET}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
console.log(` ${DIM}Helps us see which workflows actually work. Anonymous counts only —`);
|
|
329
|
+
console.log(` never your memory, code, prompts, file contents, entity names, or paths.`);
|
|
330
|
+
console.log(` Off unless you say yes; change anytime with LINKSEE_TELEMETRY=off.${RESET}`);
|
|
331
|
+
const ok = await askYesNo(' Share anonymous usage stats?', false);
|
|
332
|
+
try {
|
|
333
|
+
mkdirSync(TELEMETRY_DIR, { recursive: true });
|
|
334
|
+
writeFileSync(TELEMETRY_CONSENT_FILE, ok ? 'basic' : 'off');
|
|
335
|
+
}
|
|
336
|
+
catch { /* best-effort */ }
|
|
337
|
+
console.log(` ${ok ? CHECK : SKIP} Telemetry ${ok ? 'enabled — thank you!' : 'left off'}`);
|
|
338
|
+
}
|
|
339
|
+
await configureTelemetryConsent();
|
|
340
|
+
console.log('');
|
|
291
341
|
// ── Summary ──────────────────────────────────────────────
|
|
292
342
|
console.log(`${BOLD}Setup complete!${RESET}`);
|
|
293
343
|
console.log('');
|
|
294
344
|
console.log('How it works:');
|
|
295
345
|
console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, learnings)${RESET}`);
|
|
296
346
|
console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
|
|
297
|
-
console.log(` ${DIM}• Memory is local-first (
|
|
347
|
+
console.log(` ${DIM}• Memory is local-first (your memory never leaves your machine)${RESET}`);
|
|
298
348
|
console.log(` ${DIM}• Works across Claude Code, Cursor, Windsurf, Codex, Gemini (cross-agent)${RESET}`);
|
|
299
349
|
if (guardConfigured) {
|
|
300
350
|
console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
|
package/dist/lib/guard.js
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// FAIL-OPEN by construction: every DB op is best-effort; only an explicit 'hard' contradiction yields
|
|
13
13
|
// a block. Lexical only (no embeddings): reuses lexical-match.ts (the same logic as the detector).
|
|
14
14
|
import { normPath, compileGlob, parseArray, matchViolation, SCRAPE_ANCHOR } from './lexical-match.js';
|
|
15
|
+
import { supersededAnchorIds } from './truth-engine.js';
|
|
15
16
|
// "accepted = the only thing the gate compares against": declared (status active), still live
|
|
16
17
|
// (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
|
|
17
18
|
// at_risk(stale) anchors deliberately DON'T gate — we don't enforce a rule we're no longer sure of.
|
|
18
|
-
const ACCEPTED_SQL = `status = 'active'
|
|
19
|
-
AND lifecycle IN ('active', 'experiment')
|
|
19
|
+
const ACCEPTED_SQL = `status = 'active'
|
|
20
|
+
AND lifecycle IN ('active', 'experiment')
|
|
20
21
|
AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
|
|
21
22
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
22
23
|
function jsonGet(json, key, def) {
|
|
@@ -37,10 +38,16 @@ function bestEffort(fn) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
export function acceptedAnchors(db) {
|
|
40
|
-
|
|
41
|
-
.prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
|
|
42
43
|
FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
|
|
43
44
|
.all();
|
|
45
|
+
// A superseded anchor must stop gating. The block/warn text tells the user to run
|
|
46
|
+
// resolve_drift(action:'supersede') — if the gate ignored that resolution, following the
|
|
47
|
+
// instruction would not lift the block (dead end). status/lifecycle are not touched by
|
|
48
|
+
// supersede, so the resolution record is the only place that knows.
|
|
49
|
+
const retired = supersededAnchorIds(db);
|
|
50
|
+
return retired.size === 0 ? rows : rows.filter((a) => !retired.has(a.id));
|
|
44
51
|
}
|
|
45
52
|
function buildActionCtx(input) {
|
|
46
53
|
const files = [];
|
|
@@ -123,7 +130,7 @@ function withinCooldown(db, anchorId, sessionId) {
|
|
|
123
130
|
return !!row;
|
|
124
131
|
}
|
|
125
132
|
function logInjection(db, matches, act, surface, sessionId) {
|
|
126
|
-
const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
|
|
133
|
+
const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
|
|
127
134
|
VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
|
|
128
135
|
const snip = (act.lines[0] ?? '').slice(0, 120);
|
|
129
136
|
const tx = db.transaction(() => {
|
|
@@ -175,16 +182,19 @@ export function formatReinject(matches, gate) {
|
|
|
175
182
|
export function buildBootDigest(db, opts = {}) {
|
|
176
183
|
const maxAnchors = opts.maxAnchors ?? 8;
|
|
177
184
|
const maxForks = opts.maxForks ?? 5;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
// Same rule as the gate: never re-surface a decision the user has already superseded.
|
|
186
|
+
const retiredIds = supersededAnchorIds(db);
|
|
187
|
+
const anchorRows = db
|
|
188
|
+
.prepare(`SELECT id, statement, rationale FROM drift_anchors
|
|
189
|
+
WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
|
|
181
190
|
ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
|
|
182
|
-
.all(maxAnchors);
|
|
191
|
+
.all(maxAnchors + retiredIds.size);
|
|
192
|
+
const anchors = anchorRows.filter((a) => !retiredIds.has(a.id)).slice(0, maxAnchors);
|
|
183
193
|
const forks = db
|
|
184
|
-
.prepare(`SELECT c.id, c.rationale, a.statement
|
|
185
|
-
FROM memory_write_candidates c
|
|
186
|
-
LEFT JOIN drift_anchors a ON a.id = c.target_node_id
|
|
187
|
-
WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
|
|
194
|
+
.prepare(`SELECT c.id, c.rationale, a.statement
|
|
195
|
+
FROM memory_write_candidates c
|
|
196
|
+
LEFT JOIN drift_anchors a ON a.id = c.target_node_id
|
|
197
|
+
WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
|
|
188
198
|
ORDER BY c.created_at DESC LIMIT ?`)
|
|
189
199
|
.all(maxForks);
|
|
190
200
|
// Distillation pressure — the routine's structural trigger. The drain must not depend on
|
|
@@ -192,10 +202,10 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
192
202
|
// EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
|
|
193
203
|
let distill = 0;
|
|
194
204
|
try {
|
|
195
|
-
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
196
|
-
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
197
|
-
AND (json_extract(content, '$.needs_distill') = 1
|
|
198
|
-
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
205
|
+
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
206
|
+
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
207
|
+
AND (json_extract(content, '$.needs_distill') = 1
|
|
208
|
+
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
199
209
|
OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
|
|
200
210
|
}
|
|
201
211
|
catch {
|
|
@@ -228,11 +238,11 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
228
238
|
export function backfillHeeded(db) {
|
|
229
239
|
bestEffort(() => {
|
|
230
240
|
const live = `SELECT anchor_id FROM drift_edges WHERE verdict = 'contradicts' AND status = 'open'`;
|
|
231
|
-
db.prepare(`UPDATE injection_log SET heeded = 0
|
|
232
|
-
WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
|
|
241
|
+
db.prepare(`UPDATE injection_log SET heeded = 0
|
|
242
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
|
|
233
243
|
AND anchor_id IN (${live})`).run();
|
|
234
|
-
db.prepare(`UPDATE injection_log SET heeded = 1
|
|
235
|
-
WHERE heeded IS NULL AND surface IN ('warn', 'inform')
|
|
244
|
+
db.prepare(`UPDATE injection_log SET heeded = 1
|
|
245
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform')
|
|
236
246
|
AND anchor_id NOT IN (${live})`).run();
|
|
237
247
|
db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
|
|
238
248
|
});
|
|
@@ -244,16 +254,16 @@ export function getReinjectionFriction(db, opts = {}) {
|
|
|
244
254
|
const minC = opts.minContradicts ?? 3;
|
|
245
255
|
backfillHeeded(db);
|
|
246
256
|
const rows = db
|
|
247
|
-
.prepare(`SELECT i.anchor_id,
|
|
248
|
-
SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
|
|
249
|
-
SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
|
|
250
|
-
SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
|
|
251
|
-
datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
|
|
252
|
-
a.statement, a.lifecycle, a.card_policy
|
|
253
|
-
FROM injection_log i
|
|
254
|
-
JOIN drift_anchors a ON a.id = i.anchor_id
|
|
255
|
-
WHERE a.status = 'active'
|
|
256
|
-
GROUP BY i.anchor_id
|
|
257
|
+
.prepare(`SELECT i.anchor_id,
|
|
258
|
+
SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
|
|
259
|
+
SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
|
|
260
|
+
SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
|
|
261
|
+
datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
|
|
262
|
+
a.statement, a.lifecycle, a.card_policy
|
|
263
|
+
FROM injection_log i
|
|
264
|
+
JOIN drift_anchors a ON a.id = i.anchor_id
|
|
265
|
+
WHERE a.status = 'active'
|
|
266
|
+
GROUP BY i.anchor_id
|
|
257
267
|
HAVING gate_contradicts >= ?`)
|
|
258
268
|
.all(minC);
|
|
259
269
|
const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
|
package/dist/lib/telemetry.js
CHANGED
|
@@ -29,6 +29,19 @@ export function getTelemetryMode() {
|
|
|
29
29
|
const v = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
|
|
30
30
|
if (v === 'basic' || v === 'on' || v === '1' || v === 'true')
|
|
31
31
|
return 'basic';
|
|
32
|
+
if (v)
|
|
33
|
+
return 'off'; // any explicit env value (off/0/false/no/…) always wins
|
|
34
|
+
// No env override → use the consent recorded at setup time. Stays off if absent,
|
|
35
|
+
// so "off by default" holds: nothing is sent unless the user agreed at setup.
|
|
36
|
+
try {
|
|
37
|
+
const consentFile = join(TELEMETRY_DIR, 'telemetry-consent');
|
|
38
|
+
if (existsSync(consentFile)) {
|
|
39
|
+
const c = readFileSync(consentFile, 'utf8').trim().toLowerCase();
|
|
40
|
+
if (c === 'basic' || c === 'on')
|
|
41
|
+
return 'basic';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* ignore */ }
|
|
32
45
|
return 'off';
|
|
33
46
|
}
|
|
34
47
|
export function getOrCreateAnonId() {
|
|
@@ -65,6 +65,22 @@ export interface DecisionDetail extends TruthNode {
|
|
|
65
65
|
detected_at: number;
|
|
66
66
|
}>;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Anchors that have been explicitly superseded ("intent evolved, this is the new direction").
|
|
70
|
+
*
|
|
71
|
+
* Resolutions live in meta.t3_resolutions keyed `A<anchor_id>`, one record per anchor
|
|
72
|
+
* (a later resolve_drift overwrites the earlier one), so the current resolution for
|
|
73
|
+
* anchor N is simply t3_resolutions["A"+N].
|
|
74
|
+
*
|
|
75
|
+
* NOTE the asymmetry: a supersede record names BOTH sides (superseded_node = the old
|
|
76
|
+
* anchor, superseded_by = the new one). Only the OLD one is retired — the new anchor must
|
|
77
|
+
* keep enforcing, so we match on `superseded_node` and never on `superseded_by`.
|
|
78
|
+
*
|
|
79
|
+
* Used by the gate (guard.ts) so that the enforcement layer honours the same
|
|
80
|
+
* make-or-break rule as the reporting layer: a divergence accounted for by a recorded
|
|
81
|
+
* resolution is NOT drift.
|
|
82
|
+
*/
|
|
83
|
+
export declare function supersededAnchorIds(db: Database.Database): Set<number>;
|
|
68
84
|
export declare function getTruthView(db: Database.Database, opts?: {
|
|
69
85
|
domain?: string;
|
|
70
86
|
decision_mode?: string;
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -59,6 +59,22 @@ function classifySpecies(decision_mode) {
|
|
|
59
59
|
default: return 'hypothesis';
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
// ── Edge summary (one line the agent can act on) ─────────────────────────────
|
|
63
|
+
function summarizeEdges(verdict, edges) {
|
|
64
|
+
const latest = edges[0];
|
|
65
|
+
let ev = {};
|
|
66
|
+
try {
|
|
67
|
+
ev = JSON.parse(latest.evidence || '{}');
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
const file = ev.file_path ? String(ev.file_path).replace(/\\/g, '/').split('/').slice(-2).join('/') : null;
|
|
71
|
+
const hit = ev.hit_term ? ` hit "${ev.hit_term}"` : '';
|
|
72
|
+
const when = new Date(latest.detected_at * 1000).toISOString().slice(0, 10);
|
|
73
|
+
const head = verdict === 'contradicts'
|
|
74
|
+
? `${edges.length} open contradiction${edges.length > 1 ? 's' : ''}`
|
|
75
|
+
: `declared but not found in reality (${edges.length} absent signal${edges.length > 1 ? 's' : ''})`;
|
|
76
|
+
return `${head}${file ? ` — ${file}` : ''}${hit} (${when}). resolve_drift: fix if reality is wrong, dismiss if false positive.`;
|
|
77
|
+
}
|
|
62
78
|
function buildResolutionLookup(db) {
|
|
63
79
|
const t3res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t3_resolutions'").get()?.value, {});
|
|
64
80
|
const t2res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t2_resolutions'").get()?.value, {});
|
|
@@ -67,6 +83,11 @@ function buildResolutionLookup(db) {
|
|
|
67
83
|
return function resolutionFor(id) {
|
|
68
84
|
const matches = [];
|
|
69
85
|
for (const r of Object.values(t3res)) {
|
|
86
|
+
// A supersede record names both sides. Only the OLD anchor is accounted for by it;
|
|
87
|
+
// the replacement must stay checkable (otherwise a new North Star could never drift,
|
|
88
|
+
// because the supersede branch wins over the contradicts branch).
|
|
89
|
+
if (r && r.action === 'supersede' && r.superseded_by === id && r.superseded_node !== id)
|
|
90
|
+
continue;
|
|
70
91
|
if (r && (r.superseded_node === id || r.superseded_by === id ||
|
|
71
92
|
r.node === id || r.direction_node === id || r.constraint_node === id)) {
|
|
72
93
|
matches.push(r);
|
|
@@ -91,12 +112,39 @@ function buildResolutionLookup(db) {
|
|
|
91
112
|
};
|
|
92
113
|
}
|
|
93
114
|
// ── Core: getTruthView ───────────────────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Anchors that have been explicitly superseded ("intent evolved, this is the new direction").
|
|
117
|
+
*
|
|
118
|
+
* Resolutions live in meta.t3_resolutions keyed `A<anchor_id>`, one record per anchor
|
|
119
|
+
* (a later resolve_drift overwrites the earlier one), so the current resolution for
|
|
120
|
+
* anchor N is simply t3_resolutions["A"+N].
|
|
121
|
+
*
|
|
122
|
+
* NOTE the asymmetry: a supersede record names BOTH sides (superseded_node = the old
|
|
123
|
+
* anchor, superseded_by = the new one). Only the OLD one is retired — the new anchor must
|
|
124
|
+
* keep enforcing, so we match on `superseded_node` and never on `superseded_by`.
|
|
125
|
+
*
|
|
126
|
+
* Used by the gate (guard.ts) so that the enforcement layer honours the same
|
|
127
|
+
* make-or-break rule as the reporting layer: a divergence accounted for by a recorded
|
|
128
|
+
* resolution is NOT drift.
|
|
129
|
+
*/
|
|
130
|
+
export function supersededAnchorIds(db) {
|
|
131
|
+
const out = new Set();
|
|
132
|
+
for (const key of ['t3_resolutions', 't2_resolutions']) {
|
|
133
|
+
const res = safeJsonParse(db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value, {});
|
|
134
|
+
for (const r of Object.values(res)) {
|
|
135
|
+
if (r && r.action === 'supersede' && typeof r.superseded_node === 'number') {
|
|
136
|
+
out.add(r.superseded_node);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
94
142
|
export function getTruthView(db, opts = {}) {
|
|
95
143
|
const now = Date.now();
|
|
96
144
|
const resolutionFor = buildResolutionLookup(db);
|
|
97
145
|
// ── Candidates (indexed by target node) ──
|
|
98
146
|
const candRows = db
|
|
99
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
147
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
100
148
|
FROM memory_write_candidates ORDER BY id DESC`)
|
|
101
149
|
.all();
|
|
102
150
|
const pendingByNode = new Map();
|
|
@@ -112,9 +160,24 @@ export function getTruthView(db, opts = {}) {
|
|
|
112
160
|
cardByNode.set(c.target_node_id, c);
|
|
113
161
|
}
|
|
114
162
|
}
|
|
163
|
+
// ── Open drift edges (what the detector actually observed) ──
|
|
164
|
+
// The detector writes drift_edges; a truth view that never reads them will report 🔵 for
|
|
165
|
+
// anchors it has hard evidence against. (2026-09-05: 11 open `contradicts` edges across 4
|
|
166
|
+
// anchors — one of them the PII constraint — all rendered "reality matches intent".)
|
|
167
|
+
const edgeRows = db
|
|
168
|
+
.prepare(`SELECT anchor_id, verdict, confidence, evidence, detected_at
|
|
169
|
+
FROM drift_edges WHERE status = 'open'
|
|
170
|
+
ORDER BY detected_at DESC`)
|
|
171
|
+
.all();
|
|
172
|
+
const openEdges = new Map();
|
|
173
|
+
for (const e of edgeRows) {
|
|
174
|
+
if (!openEdges.has(e.anchor_id))
|
|
175
|
+
openEdges.set(e.anchor_id, []);
|
|
176
|
+
openEdges.get(e.anchor_id).push(e);
|
|
177
|
+
}
|
|
115
178
|
// ── Active nodes + state derivation ──
|
|
116
|
-
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
117
|
-
card_policy, review_after
|
|
179
|
+
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
180
|
+
card_policy, review_after
|
|
118
181
|
FROM drift_anchors WHERE status = 'active'`;
|
|
119
182
|
const params = [];
|
|
120
183
|
if (opts.domain) {
|
|
@@ -137,6 +200,9 @@ export function getTruthView(db, opts = {}) {
|
|
|
137
200
|
const pending = pendingByNode.get(r.id);
|
|
138
201
|
const card = cardByNode.get(r.id);
|
|
139
202
|
const overdue = r.review_after != null && r.review_after * 1000 < now;
|
|
203
|
+
const edges = openEdges.get(r.id) ?? [];
|
|
204
|
+
const contradicts = edges.filter((e) => e.verdict === 'contradicts');
|
|
205
|
+
const absent = edges.filter((e) => e.verdict === 'absent');
|
|
140
206
|
// ── State derivation (the make-or-break logic) ──
|
|
141
207
|
let state;
|
|
142
208
|
let accounted;
|
|
@@ -159,8 +225,22 @@ export function getTruthView(db, opts = {}) {
|
|
|
159
225
|
accounted = true;
|
|
160
226
|
accountedBy = 'supersede (intentional evolution)';
|
|
161
227
|
}
|
|
162
|
-
else if (
|
|
163
|
-
//
|
|
228
|
+
else if (res?.action === 'dismiss') {
|
|
229
|
+
// 🔵 a human looked at the signal and called it a false positive — that IS an answer.
|
|
230
|
+
state = 'aligned';
|
|
231
|
+
accounted = true;
|
|
232
|
+
accountedBy = 'dismiss (false positive)';
|
|
233
|
+
}
|
|
234
|
+
else if (contradicts.length > 0) {
|
|
235
|
+
// 🔴 the detector has hard evidence against this anchor and nobody has answered it.
|
|
236
|
+
// resolve_drift(fix|dismiss) closes the edges; supersede/acknowledge are handled above.
|
|
237
|
+
state = 'drift';
|
|
238
|
+
accounted = false;
|
|
239
|
+
accountedBy = null;
|
|
240
|
+
}
|
|
241
|
+
else if (pending || absent.length > 0) {
|
|
242
|
+
// 🟡 Soft signal awaiting human decision (a pending candidate, or "declared but not
|
|
243
|
+
// found in reality" — absent is weaker than contradicts, so it asks rather than alarms)
|
|
164
244
|
state = 'review';
|
|
165
245
|
accounted = false;
|
|
166
246
|
accountedBy = null;
|
|
@@ -177,9 +257,15 @@ export function getTruthView(db, opts = {}) {
|
|
|
177
257
|
accounted = true;
|
|
178
258
|
accountedBy = null;
|
|
179
259
|
}
|
|
260
|
+
// Say what was observed. Never claim convergence when nothing was checked — an agent
|
|
261
|
+
// reading "reality matches intent" will trust it; "no signal observed" it will verify.
|
|
180
262
|
const reality = card?.rationale
|
|
181
263
|
?? pending?.rationale
|
|
182
|
-
?? (
|
|
264
|
+
?? (contradicts.length > 0 ? summarizeEdges('contradicts', contradicts) : null)
|
|
265
|
+
?? (absent.length > 0 ? summarizeEdges('absent', absent) : null)
|
|
266
|
+
?? (state === 'aligned'
|
|
267
|
+
? (accountedBy ? 'Accounted for by recorded resolution' : 'No signal observed (not verified against reality)')
|
|
268
|
+
: null);
|
|
183
269
|
return {
|
|
184
270
|
id: r.id,
|
|
185
271
|
node_type: r.node_type,
|
|
@@ -264,8 +350,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
264
350
|
// ── check_decision: single-node deep view ────────────────────────────────────
|
|
265
351
|
export function getDecisionDetail(db, anchorId) {
|
|
266
352
|
const row = db
|
|
267
|
-
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
268
|
-
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
353
|
+
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
354
|
+
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
269
355
|
FROM drift_anchors WHERE id = ? AND status = 'active'`)
|
|
270
356
|
.get(anchorId);
|
|
271
357
|
if (!row)
|
|
@@ -283,14 +369,14 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
283
369
|
// State derivation (same logic)
|
|
284
370
|
let state, accounted, accountedBy;
|
|
285
371
|
const pendingCand = db
|
|
286
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
287
|
-
FROM memory_write_candidates
|
|
288
|
-
WHERE target_node_id = ? AND status = 'pending_review'
|
|
372
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
373
|
+
FROM memory_write_candidates
|
|
374
|
+
WHERE target_node_id = ? AND status = 'pending_review'
|
|
289
375
|
ORDER BY id DESC`)
|
|
290
376
|
.all(anchorId);
|
|
291
377
|
const cardCand = db
|
|
292
|
-
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
293
|
-
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
378
|
+
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
379
|
+
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
294
380
|
ORDER BY id DESC LIMIT 1`)
|
|
295
381
|
.get(anchorId);
|
|
296
382
|
const hasPending = pendingCand.length > 0;
|
|
@@ -328,7 +414,7 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
328
414
|
?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
|
|
329
415
|
// Drift edges for this anchor
|
|
330
416
|
const edges = db
|
|
331
|
-
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
417
|
+
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
332
418
|
FROM drift_edges WHERE anchor_id = ? ORDER BY detected_at DESC`)
|
|
333
419
|
.all(anchorId);
|
|
334
420
|
return {
|
package/dist/mcp/server.js
CHANGED
|
@@ -174,6 +174,7 @@ const TOOLS = [
|
|
|
174
174
|
path: { type: 'string', description: 'File path or substring. When set, returns file edit history with per-edit user-intent context instead of memory search.' },
|
|
175
175
|
max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
|
|
176
176
|
scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
|
|
177
|
+
explain: { type: 'boolean', default: false, description: 'Include ranking internals (composite, heat, band, momentum, match_reasons, score_breakdown). Off by default so more memories fit the token budget.' },
|
|
177
178
|
kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
|
|
178
179
|
min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
|
|
179
180
|
},
|
|
@@ -200,6 +201,7 @@ const TOOLS = [
|
|
|
200
201
|
properties: {
|
|
201
202
|
domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
|
|
202
203
|
decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
|
|
204
|
+
verbose: { type: 'boolean', default: false, description: 'Return full aligned nodes and all candidates. Default is compact: attention items in full, aligned as id+statement per domain, candidates as counts.' },
|
|
203
205
|
},
|
|
204
206
|
},
|
|
205
207
|
},
|
|
@@ -767,12 +769,18 @@ function handleRecall(args) {
|
|
|
767
769
|
content: parsedContent,
|
|
768
770
|
importance: r.importance,
|
|
769
771
|
pinned: r.importance >= 0.9,
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
772
|
+
// Ranking internals only on request — they roughly double the per-memory cost and an
|
|
773
|
+
// agent acting on the memory needs what/why/state, not the scorer's arithmetic.
|
|
774
|
+
...(args?.explain ? {
|
|
775
|
+
heat: Number(r.heat_score.toFixed(1)),
|
|
776
|
+
band: r.heat_band,
|
|
777
|
+
composite: Number(r.composite_score.toFixed(3)),
|
|
778
|
+
match_reasons: r._reasons,
|
|
779
|
+
score_breakdown: r._breakdown,
|
|
780
|
+
} : {}),
|
|
775
781
|
};
|
|
782
|
+
if (!args?.explain)
|
|
783
|
+
delete memObj.entity.momentum;
|
|
776
784
|
const memTokens = estimateTokens(JSON.stringify(memObj));
|
|
777
785
|
if (memoriesOut.length > 0 && accTokens + memTokens > maxTokens) {
|
|
778
786
|
stoppedBy = 'tokens';
|
|
@@ -1287,14 +1295,35 @@ function handleDriftStatus(args) {
|
|
|
1287
1295
|
by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
|
|
1288
1296
|
`🔵 ${by_state.aligned} aligned`,
|
|
1289
1297
|
].filter(Boolean).join(' · ');
|
|
1298
|
+
if (args?.verbose) {
|
|
1299
|
+
return JSON.stringify({
|
|
1300
|
+
ok: true,
|
|
1301
|
+
triage: `${nodes} anchors: ${triage}`,
|
|
1302
|
+
nextReopen: view.nextReopen,
|
|
1303
|
+
attention: view.attention,
|
|
1304
|
+
alignedByDomain: view.alignedByDomain,
|
|
1305
|
+
candidates: view.candidates,
|
|
1306
|
+
counts: view.counts,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
// Compact by default. The agent asked "what needs attention" — answer that in full and keep
|
|
1310
|
+
// the rest to one line per node. (Full form was ~15k tokens for 43 anchors; the 2 drifting
|
|
1311
|
+
// items were buried under 41 aligned ones with rationale.)
|
|
1312
|
+
const aligned = view.alignedByDomain.map((g) => ({
|
|
1313
|
+
domain: g.domain,
|
|
1314
|
+
count: g.nodes.length,
|
|
1315
|
+
nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
|
|
1316
|
+
}));
|
|
1317
|
+
const cand = view.candidates;
|
|
1290
1318
|
return JSON.stringify({
|
|
1291
1319
|
ok: true,
|
|
1292
1320
|
triage: `${nodes} anchors: ${triage}`,
|
|
1293
1321
|
nextReopen: view.nextReopen,
|
|
1294
1322
|
attention: view.attention,
|
|
1295
|
-
|
|
1296
|
-
candidates:
|
|
1323
|
+
aligned,
|
|
1324
|
+
candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
|
|
1297
1325
|
counts: view.counts,
|
|
1326
|
+
hint: 'verbose:true for full aligned nodes and candidate details; check_decision(anchor_id) for one node.',
|
|
1298
1327
|
});
|
|
1299
1328
|
}
|
|
1300
1329
|
// Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
|
|
@@ -1315,9 +1344,39 @@ async function resolveProjectFromRoots() {
|
|
|
1315
1344
|
});
|
|
1316
1345
|
return matches.length === 1 ? matches[0] : undefined; // unique repo match only; else ② handles it
|
|
1317
1346
|
}
|
|
1347
|
+
// ①b (2026-09-05): when the client sends no roots (common — not every MCP host implements
|
|
1348
|
+
// roots), infer the map from the files edited most recently. The DB already knows what you
|
|
1349
|
+
// touched; the map slug is a path segment of those files.
|
|
1350
|
+
function resolveProjectFromRecentEdits() {
|
|
1351
|
+
const projects = listMapProjects(db);
|
|
1352
|
+
if (projects.length <= 1)
|
|
1353
|
+
return { project: projects[0] };
|
|
1354
|
+
const rows = db
|
|
1355
|
+
.prepare(`SELECT file_path FROM session_file_edits
|
|
1356
|
+
WHERE occurred_at > unixepoch() - 86400
|
|
1357
|
+
ORDER BY occurred_at DESC LIMIT 50`)
|
|
1358
|
+
.all();
|
|
1359
|
+
if (rows.length === 0)
|
|
1360
|
+
return {};
|
|
1361
|
+
const hits = new Map();
|
|
1362
|
+
for (const r of rows) {
|
|
1363
|
+
const segs = r.file_path.toLowerCase().replace(/\\/g, '/').split('/');
|
|
1364
|
+
for (const p of projects)
|
|
1365
|
+
if (segs.includes(p.toLowerCase()))
|
|
1366
|
+
hits.set(p, (hits.get(p) ?? 0) + 1);
|
|
1367
|
+
}
|
|
1368
|
+
if (hits.size === 0)
|
|
1369
|
+
return {};
|
|
1370
|
+
const [best, second] = [...hits.entries()].sort((a, b) => b[1] - a[1]);
|
|
1371
|
+
if (second && second[1] === best[1])
|
|
1372
|
+
return {}; // tie → still ambiguous
|
|
1373
|
+
return { project: best[0], from: 'recent_edits' };
|
|
1374
|
+
}
|
|
1318
1375
|
async function handleWhereAmI(args) {
|
|
1319
|
-
// ①
|
|
1320
|
-
|
|
1376
|
+
// ① roots; ①b recent edits; ② (inside whereAmI) is the last fallback.
|
|
1377
|
+
let project = args?.project ?? (await resolveProjectFromRoots());
|
|
1378
|
+
if (!project)
|
|
1379
|
+
project = resolveProjectFromRecentEdits().project;
|
|
1321
1380
|
const res = whereAmI(db, {
|
|
1322
1381
|
query: args?.query, node_id: args?.node_id, project, limit: args?.limit,
|
|
1323
1382
|
});
|
|
@@ -1325,7 +1384,7 @@ async function handleWhereAmI(args) {
|
|
|
1325
1384
|
return JSON.stringify({
|
|
1326
1385
|
ok: true, located: false, reason: 'ambiguous_project',
|
|
1327
1386
|
available_projects: res.ambiguous.available,
|
|
1328
|
-
hint: `Multiple maps imported — pass project: one of [${res.ambiguous.available.join(', ')}]
|
|
1387
|
+
hint: `Multiple maps imported and none matched your workspace roots or the files you edited in the last 24h — pass project: one of [${res.ambiguous.available.join(', ')}].`,
|
|
1329
1388
|
});
|
|
1330
1389
|
}
|
|
1331
1390
|
if (res.matched.length === 0) {
|
|
@@ -1535,6 +1594,9 @@ function handleDream(args) {
|
|
|
1535
1594
|
FROM memories m JOIN entities e ON e.id = m.entity_id
|
|
1536
1595
|
WHERE m.layer IN ('learning', 'caveat')
|
|
1537
1596
|
AND json_valid(m.content)
|
|
1597
|
+
-- settle window: the Stop hook extracts every turn, so the newest rows belong to a
|
|
1598
|
+
-- session still in motion. Don't ask the agent to distill the conversation it is in.
|
|
1599
|
+
AND m.created_at < unixepoch() - 1800
|
|
1538
1600
|
AND (json_extract(m.content, '$.needs_distill') = 1
|
|
1539
1601
|
OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
1540
1602
|
OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
5
|
"description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dev": "tsx src/mcp/server.ts",
|
|
33
33
|
"migrate": "node dist/db/migrate.js",
|
|
34
34
|
"migrate:dev": "tsx src/db/migrate.ts",
|
|
35
|
-
"prepublishOnly": "npm run build"
|
|
35
|
+
"prepublishOnly": "node scripts/release-gate.mjs && npm run build"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"mcp",
|