linksee-memory 0.13.0 → 0.14.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/dist/db/schema.sql +21 -2
- package/dist/lib/guard.d.ts +2 -0
- package/dist/lib/guard.js +60 -7
- package/dist/lib/truth-engine.d.ts +8 -1
- package/dist/lib/truth-engine.js +69 -13
- package/dist/mcp/roots.js +6 -2
- package/dist/mcp/server.js +166 -15
- package/package.json +1 -1
package/dist/db/schema.sql
CHANGED
|
@@ -377,6 +377,25 @@ CREATE TABLE IF NOT EXISTS anchor_touch_log (
|
|
|
377
377
|
CREATE INDEX IF NOT EXISTS idx_anchor_touch_time ON anchor_touch_log(occurred_at);
|
|
378
378
|
CREATE INDEX IF NOT EXISTS idx_anchor_touch_anchor ON anchor_touch_log(anchor_id, occurred_at);
|
|
379
379
|
|
|
380
|
+
-- ============================================================
|
|
381
|
+
-- v16: gate_dismissals — the human's "that was a false positive", made durable.
|
|
382
|
+
-- resolve_drift(action:'dismiss') closed drift_edges but the GATE never read the verdict, so
|
|
383
|
+
-- the same wrong match fired again on the next command. Observed: anchor #13 ("don't favour
|
|
384
|
+
-- our own products in rankings", signals = the bare product names) fired 6× in two minutes
|
|
385
|
+
-- because a temp file path contained "Sake-Navi". A verdict that does not change the next
|
|
386
|
+
-- detection is not a feedback loop.
|
|
387
|
+
-- hit_term NULL = silence this anchor at the gate entirely; otherwise only that match term.
|
|
388
|
+
-- ============================================================
|
|
389
|
+
CREATE TABLE IF NOT EXISTS gate_dismissals (
|
|
390
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
391
|
+
anchor_id INTEGER NOT NULL REFERENCES drift_anchors(id) ON DELETE CASCADE,
|
|
392
|
+
hit_term TEXT, -- lowercased match term; NULL = whole anchor
|
|
393
|
+
rationale TEXT,
|
|
394
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
395
|
+
);
|
|
396
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_gate_dismissal_uniq
|
|
397
|
+
ON gate_dismissals(anchor_id, COALESCE(hit_term, ''));
|
|
398
|
+
|
|
380
399
|
-- ============================================================
|
|
381
400
|
-- v11: Current Truth Map — journey-spine topology (Product Drift OS spec v3).
|
|
382
401
|
-- map.yaml (git) is the desired-state SOURCE OF TRUTH (anchor #58); these tables
|
|
@@ -456,6 +475,6 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
456
475
|
value TEXT NOT NULL
|
|
457
476
|
);
|
|
458
477
|
|
|
459
|
-
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '
|
|
478
|
+
INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '16');
|
|
460
479
|
INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
|
|
461
|
-
UPDATE meta SET value = '
|
|
480
|
+
UPDATE meta SET value = '16' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15');
|
package/dist/lib/guard.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface GateMatch {
|
|
|
18
18
|
verdict: 'contradicts' | 'in_scope';
|
|
19
19
|
why: string;
|
|
20
20
|
gate_mode: GateMode;
|
|
21
|
+
/** The exact term that matched, so the reader can dismiss precisely this match. */
|
|
22
|
+
hit_term?: string | null;
|
|
21
23
|
}
|
|
22
24
|
export interface GateResult {
|
|
23
25
|
gate: GateLevel;
|
package/dist/lib/guard.js
CHANGED
|
@@ -67,9 +67,35 @@ function buildActionCtx(input) {
|
|
|
67
67
|
const haystack = [...files.map(normPath), ...rawParts].join('\n').toLowerCase();
|
|
68
68
|
return { tool: input.tool ?? 'unknown', files, lines, haystack };
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* What the human has already called a false positive.
|
|
72
|
+
*
|
|
73
|
+
* Keyed by anchor and (optionally) the exact term that matched, so dismissing "this anchor
|
|
74
|
+
* matched my file path" does not throw away the anchor's real detections.
|
|
75
|
+
*/
|
|
76
|
+
function dismissedFor(db) {
|
|
77
|
+
const out = new Map();
|
|
78
|
+
let rows = [];
|
|
79
|
+
try {
|
|
80
|
+
rows = db.prepare('SELECT anchor_id, hit_term FROM gate_dismissals').all();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return out; // table not migrated yet → nothing dismissed
|
|
84
|
+
}
|
|
85
|
+
for (const r of rows) {
|
|
86
|
+
if (!out.has(r.anchor_id))
|
|
87
|
+
out.set(r.anchor_id, new Set());
|
|
88
|
+
out.get(r.anchor_id).add((r.hit_term ?? '*').toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
70
92
|
export function matchAction(db, act) {
|
|
71
93
|
const out = [];
|
|
94
|
+
const dismissed = dismissedFor(db);
|
|
72
95
|
for (const a of acceptedAnchors(db)) {
|
|
96
|
+
const dis = dismissed.get(a.id);
|
|
97
|
+
if (dis?.has('*'))
|
|
98
|
+
continue; // whole anchor silenced at the gate
|
|
73
99
|
const gate_mode = jsonGet(a.card_policy, 'gate_mode', 'soft');
|
|
74
100
|
if (gate_mode === 'off')
|
|
75
101
|
continue;
|
|
@@ -98,17 +124,31 @@ export function matchAction(db, act) {
|
|
|
98
124
|
}
|
|
99
125
|
}
|
|
100
126
|
// Scope. `affects` says WHERE a decision applies; `violation_signal` says WHAT is forbidden.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
127
|
+
//
|
|
128
|
+
// A path-scoped anchor is blind to `Bash`, which carries no file path — measured on a real
|
|
129
|
+
// machine, 21 of 42 active anchors could never fire on a Bash command, including "ALTER
|
|
130
|
+
// TABLE memories DROP" on the anchor that exists to prevent exactly that. So a signal hit
|
|
131
|
+
// counts on its own WHEN THERE IS NO PATH TO CHECK.
|
|
132
|
+
//
|
|
133
|
+
// But only then. Making signal hits scope-free outright (0.13.0) traded one failure for
|
|
134
|
+
// another: an anchor scoped to one repo started firing in every repo that happened to
|
|
135
|
+
// contain its term — "insert or ignore" is ordinary SQLite everywhere, and a temp file path
|
|
136
|
+
// containing "Sake-Navi" is not favouritism in a ranking. When the action names files, the
|
|
137
|
+
// anchor's own scope is the better evidence and it decides.
|
|
106
138
|
//
|
|
107
139
|
// (matchViolation already guards the obvious false positives: word boundaries, a negation
|
|
108
140
|
// window, and citation-without-call — a naive substring test produced ~90% noise.)
|
|
109
|
-
const
|
|
141
|
+
const actionNamesFiles = act.files.length > 0;
|
|
142
|
+
const inScope = hasScope
|
|
143
|
+
? pathHit || (!actionNamesFiles && sigHit != null)
|
|
144
|
+
: termHit || sigHit != null;
|
|
110
145
|
if (!inScope)
|
|
111
146
|
continue;
|
|
147
|
+
// A dismissed term stops firing; the anchor's other terms keep working.
|
|
148
|
+
if (sigHit && dis?.has(sigHit.toLowerCase()))
|
|
149
|
+
continue;
|
|
150
|
+
if (!sigHit && dis?.has('*'))
|
|
151
|
+
continue;
|
|
112
152
|
out.push({
|
|
113
153
|
anchor_id: a.id,
|
|
114
154
|
statement: a.statement,
|
|
@@ -120,6 +160,7 @@ export function matchAction(db, act) {
|
|
|
120
160
|
? `touches a file under this decision's scope`
|
|
121
161
|
: `matches this decision's topic`,
|
|
122
162
|
gate_mode,
|
|
163
|
+
hit_term: sigHit ?? null,
|
|
123
164
|
});
|
|
124
165
|
}
|
|
125
166
|
// contradicts first (the headline), then in_scope.
|
|
@@ -178,9 +219,21 @@ export function formatReinject(matches, gate) {
|
|
|
178
219
|
const tail = m.verdict === 'contradicts' ? `${m.why} → contradicts it.` : `${m.why}.`;
|
|
179
220
|
return `• [#${m.anchor_id}] "${m.statement}"${rationale}\n ↳ ${tail}`;
|
|
180
221
|
});
|
|
222
|
+
// Offer both exits, because the reader is in one of two situations and only they know which:
|
|
223
|
+
// the decision changed (supersede), or the match was wrong (dismiss). Naming the exact term
|
|
224
|
+
// matters — dismissing a whole anchor to escape one bad match throws away its real work.
|
|
181
225
|
const firstContra = matches.find((m) => m.verdict === 'contradicts');
|
|
182
226
|
const foot = firstContra
|
|
183
|
-
?
|
|
227
|
+
? `
|
|
228
|
+
If you are intentionally changing this decision, supersede it on the record:
|
|
229
|
+
` +
|
|
230
|
+
` resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'supersede', superseded_by: <new anchor>).
|
|
231
|
+
` +
|
|
232
|
+
`If this match is simply wrong, say so and it stops firing:
|
|
233
|
+
` +
|
|
234
|
+
` resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'dismiss'` +
|
|
235
|
+
(firstContra.hit_term ? `, hit_term: '${firstContra.hit_term}'` : '') +
|
|
236
|
+
`, rationale: '<why>').`
|
|
184
237
|
: '';
|
|
185
238
|
return [head, ...body, foot].filter(Boolean).join('\n');
|
|
186
239
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type Database from 'better-sqlite3';
|
|
2
|
-
export type DriftState = 'drift' | 'review' | 'held' | 'aligned';
|
|
2
|
+
export type DriftState = 'drift' | 'review' | 'held' | 'aligned' | 'unverified';
|
|
3
3
|
export type Species = 'hypothesis' | 'constraint' | 'commitment' | 'source_of_truth';
|
|
4
4
|
export interface TruthNode {
|
|
5
5
|
id: number;
|
|
@@ -39,6 +39,11 @@ export interface TruthCounts {
|
|
|
39
39
|
}
|
|
40
40
|
export interface TruthView {
|
|
41
41
|
attention: TruthNode[];
|
|
42
|
+
/** Active anchors the detector has no evidence about — neither aligned nor drifting. */
|
|
43
|
+
unverifiedByDomain: Array<{
|
|
44
|
+
domain: string;
|
|
45
|
+
nodes: TruthNode[];
|
|
46
|
+
}>;
|
|
42
47
|
alignedByDomain: Array<{
|
|
43
48
|
domain: string;
|
|
44
49
|
nodes: TruthNode[];
|
|
@@ -88,6 +93,8 @@ export declare function getTruthView(db: Database.Database, opts?: {
|
|
|
88
93
|
export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
|
|
89
94
|
export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
|
|
90
95
|
export interface ResolveInput {
|
|
96
|
+
/** For dismiss: silence only this match term. Omitted → silence the anchor at the gate. */
|
|
97
|
+
hit_term?: string;
|
|
91
98
|
anchor_id: number;
|
|
92
99
|
action: ResolutionAction;
|
|
93
100
|
rationale?: string;
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -18,7 +18,7 @@ const DOMAIN_ORDER = [
|
|
|
18
18
|
'growth', 'operations', 'security', 'roadmap', 'memory', 'other',
|
|
19
19
|
];
|
|
20
20
|
const STATE_RANK = {
|
|
21
|
-
drift: 0, review: 1, held: 2, aligned: 3,
|
|
21
|
+
drift: 0, review: 1, held: 2, aligned: 3, unverified: 4,
|
|
22
22
|
};
|
|
23
23
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
24
|
function safeJsonParse(s, fallback) {
|
|
@@ -70,6 +70,9 @@ function summarizeEdges(verdict, edges) {
|
|
|
70
70
|
const file = ev.file_path ? String(ev.file_path).replace(/\\/g, '/').split('/').slice(-2).join('/') : null;
|
|
71
71
|
const hit = ev.hit_term ? ` hit "${ev.hit_term}"` : '';
|
|
72
72
|
const when = new Date(latest.detected_at * 1000).toISOString().slice(0, 10);
|
|
73
|
+
if (verdict === 'implements') {
|
|
74
|
+
return `Observed in reality${file ? ` — ${file}` : ''} (${when}).`;
|
|
75
|
+
}
|
|
73
76
|
const head = verdict === 'contradicts'
|
|
74
77
|
? `${edges.length} open contradiction${edges.length > 1 ? 's' : ''}`
|
|
75
78
|
: `declared but not found in reality (${edges.length} absent signal${edges.length > 1 ? 's' : ''})`;
|
|
@@ -203,6 +206,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
203
206
|
const edges = openEdges.get(r.id) ?? [];
|
|
204
207
|
const contradicts = edges.filter((e) => e.verdict === 'contradicts');
|
|
205
208
|
const absent = edges.filter((e) => e.verdict === 'absent');
|
|
209
|
+
const implemented = edges.filter((e) => e.verdict === 'implements');
|
|
206
210
|
// ── State derivation (the make-or-break logic) ──
|
|
207
211
|
let state;
|
|
208
212
|
let accounted;
|
|
@@ -251,10 +255,16 @@ export function getTruthView(db, opts = {}) {
|
|
|
251
255
|
accounted = false;
|
|
252
256
|
accountedBy = null;
|
|
253
257
|
}
|
|
254
|
-
else {
|
|
255
|
-
// 🔵
|
|
258
|
+
else if (implemented.length > 0) {
|
|
259
|
+
// 🔵 the detector saw reality match — this is the only way to be aligned without a verdict
|
|
256
260
|
state = 'aligned';
|
|
257
261
|
accounted = true;
|
|
262
|
+
accountedBy = 'observed (implements)';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// ⚫ nothing checked, nothing decided. Not a problem — but not "fine" either.
|
|
266
|
+
state = 'unverified';
|
|
267
|
+
accounted = false;
|
|
258
268
|
accountedBy = null;
|
|
259
269
|
}
|
|
260
270
|
// Say what was observed. Never claim convergence when nothing was checked — an agent
|
|
@@ -263,9 +273,9 @@ export function getTruthView(db, opts = {}) {
|
|
|
263
273
|
?? pending?.rationale
|
|
264
274
|
?? (contradicts.length > 0 ? summarizeEdges('contradicts', contradicts) : null)
|
|
265
275
|
?? (absent.length > 0 ? summarizeEdges('absent', absent) : null)
|
|
266
|
-
?? (state === 'aligned'
|
|
267
|
-
|
|
268
|
-
|
|
276
|
+
?? (state === 'aligned' && implemented.length > 0 ? summarizeEdges('implements', implemented) : null)
|
|
277
|
+
?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
|
|
278
|
+
?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
|
|
269
279
|
return {
|
|
270
280
|
id: r.id,
|
|
271
281
|
node_type: r.node_type,
|
|
@@ -288,8 +298,18 @@ export function getTruthView(db, opts = {}) {
|
|
|
288
298
|
});
|
|
289
299
|
// ── Partition: attention (loud) vs aligned (quiet) ──
|
|
290
300
|
const attention = nodes
|
|
291
|
-
.filter((n) => n.state !== 'aligned')
|
|
301
|
+
.filter((n) => n.state !== 'aligned' && n.state !== 'unverified')
|
|
292
302
|
.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || b.confidence - a.confidence);
|
|
303
|
+
const unverifiedGroups = new Map();
|
|
304
|
+
for (const n of nodes.filter((n) => n.state === 'unverified')) {
|
|
305
|
+
const d = n.domain ?? 'other';
|
|
306
|
+
if (!unverifiedGroups.has(d))
|
|
307
|
+
unverifiedGroups.set(d, []);
|
|
308
|
+
unverifiedGroups.get(d).push(n);
|
|
309
|
+
}
|
|
310
|
+
const unverifiedByDomain = [...unverifiedGroups.entries()]
|
|
311
|
+
.sort((a, b) => DOMAIN_ORDER.indexOf(a[0]) - DOMAIN_ORDER.indexOf(b[0]))
|
|
312
|
+
.map(([domain, ns]) => ({ domain, nodes: ns }));
|
|
293
313
|
const alignedGroups = new Map();
|
|
294
314
|
for (const n of nodes.filter((n) => n.state === 'aligned')) {
|
|
295
315
|
const d = n.domain ?? 'other';
|
|
@@ -311,7 +331,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
311
331
|
};
|
|
312
332
|
for (const n of nodes)
|
|
313
333
|
by_species[n.species]++;
|
|
314
|
-
const by_state = { drift: 0, review: 0, held: 0, aligned: 0 };
|
|
334
|
+
const by_state = { drift: 0, review: 0, held: 0, aligned: 0, unverified: 0 };
|
|
315
335
|
for (const n of nodes)
|
|
316
336
|
by_state[n.state]++;
|
|
317
337
|
const reopenDates = nodes
|
|
@@ -335,6 +355,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
335
355
|
return {
|
|
336
356
|
attention,
|
|
337
357
|
alignedByDomain,
|
|
358
|
+
unverifiedByDomain,
|
|
338
359
|
candidates: { auto, suppressed },
|
|
339
360
|
counts: {
|
|
340
361
|
nodes: nodes.length,
|
|
@@ -366,7 +387,14 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
366
387
|
catch { /* */ }
|
|
367
388
|
const res = resolutionFor(row.id);
|
|
368
389
|
const overdue = row.review_after != null && row.review_after * 1000 < now;
|
|
369
|
-
// State derivation
|
|
390
|
+
// State derivation — MUST mirror getTruthView. It had drifted: this branch never looked at
|
|
391
|
+
// drift_edges, so check_decision could say "aligned" on the anchor drift_status flagged 🔴.
|
|
392
|
+
const openEdgesHere = db
|
|
393
|
+
.prepare(`SELECT verdict, confidence, evidence, detected_at FROM drift_edges WHERE anchor_id = ? AND status = 'open' ORDER BY detected_at DESC`)
|
|
394
|
+
.all(anchorId);
|
|
395
|
+
const contradictsHere = openEdgesHere.filter((e) => e.verdict === 'contradicts');
|
|
396
|
+
const absentHere = openEdgesHere.filter((e) => e.verdict === 'absent');
|
|
397
|
+
const implementedHere = openEdgesHere.filter((e) => e.verdict === 'implements');
|
|
370
398
|
let state, accounted, accountedBy;
|
|
371
399
|
const pendingCand = db
|
|
372
400
|
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
@@ -395,7 +423,17 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
395
423
|
accounted = true;
|
|
396
424
|
accountedBy = 'supersede (intentional evolution)';
|
|
397
425
|
}
|
|
398
|
-
else if (
|
|
426
|
+
else if (res?.action === 'dismiss') {
|
|
427
|
+
state = 'aligned';
|
|
428
|
+
accounted = true;
|
|
429
|
+
accountedBy = 'dismiss (false positive)';
|
|
430
|
+
}
|
|
431
|
+
else if (contradictsHere.length > 0) {
|
|
432
|
+
state = 'drift';
|
|
433
|
+
accounted = false;
|
|
434
|
+
accountedBy = null;
|
|
435
|
+
}
|
|
436
|
+
else if (hasPending || absentHere.length > 0) {
|
|
399
437
|
state = 'review';
|
|
400
438
|
accounted = false;
|
|
401
439
|
accountedBy = null;
|
|
@@ -405,13 +443,22 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
405
443
|
accounted = false;
|
|
406
444
|
accountedBy = null;
|
|
407
445
|
}
|
|
408
|
-
else {
|
|
446
|
+
else if (implementedHere.length > 0) {
|
|
409
447
|
state = 'aligned';
|
|
410
448
|
accounted = true;
|
|
449
|
+
accountedBy = 'observed (implements)';
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
state = 'unverified';
|
|
453
|
+
accounted = false;
|
|
411
454
|
accountedBy = null;
|
|
412
455
|
}
|
|
413
456
|
const reality = cardCand?.rationale ?? (hasPending ? pendingCand[0].rationale : null)
|
|
414
|
-
?? (
|
|
457
|
+
?? (contradictsHere.length > 0 ? summarizeEdges('contradicts', contradictsHere) : null)
|
|
458
|
+
?? (absentHere.length > 0 ? summarizeEdges('absent', absentHere) : null)
|
|
459
|
+
?? (state === 'aligned' && implementedHere.length > 0 ? summarizeEdges('implements', implementedHere) : null)
|
|
460
|
+
?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
|
|
461
|
+
?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
|
|
415
462
|
// Drift edges for this anchor
|
|
416
463
|
const edges = db
|
|
417
464
|
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
@@ -490,9 +537,18 @@ export function resolveDrift(db, input) {
|
|
|
490
537
|
existing[`A${input.anchor_id}`] = resolution;
|
|
491
538
|
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?")
|
|
492
539
|
.run(key, JSON.stringify(existing), JSON.stringify(existing));
|
|
493
|
-
//
|
|
540
|
+
// 'dismiss' must change what happens NEXT time, not just tidy the current edges — otherwise
|
|
541
|
+
// the same wrong match fires on the next command and the verdict was theatre. Record it where
|
|
542
|
+
// the gate reads (gate_dismissals), keyed to the exact term when one was given so the anchor's
|
|
543
|
+
// real detections survive.
|
|
494
544
|
if (input.action === 'dismiss') {
|
|
495
545
|
db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
|
|
546
|
+
try {
|
|
547
|
+
db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
|
|
548
|
+
ON CONFLICT(anchor_id, COALESCE(hit_term, '')) DO UPDATE SET rationale = excluded.rationale`).run(input.anchor_id, input.hit_term ? input.hit_term.toLowerCase() : null, input.rationale ?? null);
|
|
549
|
+
resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
|
|
550
|
+
}
|
|
551
|
+
catch { /* pre-v16 DB → edges-only dismiss, as before */ }
|
|
496
552
|
}
|
|
497
553
|
// If action is 'fix', mark open edges as resolved
|
|
498
554
|
if (input.action === 'fix') {
|
package/dist/mcp/roots.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// MCP semantics: client owns the root list, server is informed. We refresh on demand
|
|
7
7
|
// (lazily on first use) and on roots/list_changed notification.
|
|
8
|
-
import {
|
|
8
|
+
import { ListRootsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
let cachedRoots = null;
|
|
10
10
|
let lastFetched = 0;
|
|
11
11
|
const STALE_MS = 60_000; // re-fetch at most once a minute
|
|
@@ -14,7 +14,11 @@ export async function fetchRoots(server) {
|
|
|
14
14
|
if (cachedRoots && now - lastFetched < STALE_MS)
|
|
15
15
|
return cachedRoots;
|
|
16
16
|
try {
|
|
17
|
-
|
|
17
|
+
// Protocol.request(request, RESULT schema). This passed the REQUEST schema, so every client's
|
|
18
|
+
// reply — `{ roots: [...] }` — failed validation against a shape expecting `{ method: 'roots/list' }`,
|
|
19
|
+
// was swallowed by the catch below, and cached as "no roots" for a minute. Roots had been empty
|
|
20
|
+
// for every client since this was written; where_am_i's root inference never once fired.
|
|
21
|
+
const res = await server.request({ method: 'roots/list', params: {} }, ListRootsResultSchema);
|
|
18
22
|
cachedRoots = Array.isArray(res?.roots) ? res.roots : [];
|
|
19
23
|
lastFetched = now;
|
|
20
24
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -136,15 +136,32 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
|
|
|
136
136
|
const TOOLS = [
|
|
137
137
|
{
|
|
138
138
|
name: 'remember',
|
|
139
|
-
description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved →
|
|
139
|
+
description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved → content + anchor: {} (remembered AND enforced in one call)\n• When a goal is set or updated → layer: "goal"\n• When something new is learned → layer: "learning"\n• When the user says "remember this" / "覚えておいて"\n• After completing a task or receiving user approval\n\nMODES:\n• Create (default): content is the only required field. entity defaults to the project you are in; layer defaults to "context" (or "learning" when anchor is set).\n• Create + enforce: add anchor: {} — the memory also becomes a decision the guard re-injects before Edit/Write/Bash and on session start. Give anchor.violation_signal (forbidden strings) to make contradictions detectable; anchor.affects (path globs) to scope it.\n• Update: memory_id (+ optional content, layer, importance)\n• Delete: memory_id + forget: true\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
|
|
140
140
|
inputSchema: {
|
|
141
141
|
type: 'object',
|
|
142
142
|
properties: {
|
|
143
|
-
entity_name: { type: 'string', description: '
|
|
144
|
-
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: '
|
|
143
|
+
entity_name: { type: 'string', description: 'What this memory is about. Optional — defaults to the project you are working in (from workspace roots, else the files edited recently).' },
|
|
144
|
+
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Optional — defaults to "project".' },
|
|
145
|
+
anchor: {
|
|
146
|
+
description: 'Also declare this as an enforceable decision. Pass {} for defaults, or an object: { kind?: "decision"|"prohibition"|"constraint", violation_signal?: string[] (forbidden strings — needed for the gate to detect a contradiction), affects?: string[] (path globs that scope it), detect_terms?: string[], domain?: string, rationale?: string }. Without violation_signal the anchor is a constraint: re-injected on session start and when its scope is touched, but no contradiction can be detected.',
|
|
147
|
+
anyOf: [
|
|
148
|
+
{ type: 'boolean' },
|
|
149
|
+
{
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
kind: { type: 'string', enum: ['decision', 'prohibition', 'constraint'] },
|
|
153
|
+
violation_signal: { type: 'array', items: { type: 'string' } },
|
|
154
|
+
affects: { type: 'array', items: { type: 'string' } },
|
|
155
|
+
detect_terms: { type: 'array', items: { type: 'string' } },
|
|
156
|
+
domain: { type: 'string' },
|
|
157
|
+
rationale: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
145
162
|
entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
|
|
146
|
-
layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation).' },
|
|
147
|
-
content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why)' },
|
|
163
|
+
layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation). Optional — defaults to "context", or "learning" when anchor is set.' },
|
|
164
|
+
content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why). The only required field for create.' },
|
|
148
165
|
importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Set ≥0.9 to pin (protects from forgetting).' },
|
|
149
166
|
thread_id: { type: 'string', description: 'Optional thread ID to group related memories (decision chains, session groups).' },
|
|
150
167
|
force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
|
|
@@ -255,7 +272,7 @@ const TOOLS = [
|
|
|
255
272
|
},
|
|
256
273
|
{
|
|
257
274
|
name: 'resolve_drift',
|
|
258
|
-
description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
|
|
275
|
+
description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed AND the gate stops firing on it (pass hit_term to silence just that word)\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
|
|
259
276
|
inputSchema: {
|
|
260
277
|
type: 'object',
|
|
261
278
|
properties: {
|
|
@@ -264,6 +281,7 @@ const TOOLS = [
|
|
|
264
281
|
rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
|
|
265
282
|
review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
|
|
266
283
|
superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
|
|
284
|
+
hit_term: { type: 'string', description: "For dismiss: silence only this match term (the word the gate quoted back at you). Omit to silence the whole anchor at the gate — prefer the term, so the anchor's real detections keep working." },
|
|
267
285
|
},
|
|
268
286
|
required: ['anchor_id', 'action'],
|
|
269
287
|
},
|
|
@@ -435,14 +453,74 @@ function handleRemember(args) {
|
|
|
435
453
|
.run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
|
|
436
454
|
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
|
|
437
455
|
const mom = refreshMomentumForEntity(db, entityId);
|
|
438
|
-
|
|
456
|
+
const memoryId = Number(result.lastInsertRowid);
|
|
457
|
+
const out = {
|
|
439
458
|
ok: true,
|
|
440
|
-
memory_id:
|
|
459
|
+
memory_id: memoryId,
|
|
441
460
|
entity_id: entityId,
|
|
442
461
|
layer,
|
|
443
462
|
pinned: importance >= 0.9,
|
|
444
463
|
momentum: { score: mom.score, band: mom.band },
|
|
445
|
-
}
|
|
464
|
+
};
|
|
465
|
+
// "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
|
|
466
|
+
// Picking remember meant the decision was stored but never re-injected — the exact failure
|
|
467
|
+
// the product exists to prevent. One call now does both when the caller asks for it.
|
|
468
|
+
if (args.anchor) {
|
|
469
|
+
const spec = typeof args.anchor === 'object' && args.anchor !== null ? args.anchor : {};
|
|
470
|
+
let what = rawContent, why = spec.rationale;
|
|
471
|
+
try {
|
|
472
|
+
const c = JSON.parse(rawContent);
|
|
473
|
+
if (c && typeof c === 'object') {
|
|
474
|
+
what = String(c.what ?? c.title ?? rawContent);
|
|
475
|
+
why = why ?? (c.why ? String(c.why) : undefined);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch { /* plain text */ }
|
|
479
|
+
const signals = Array.isArray(spec.violation_signal) ? spec.violation_signal : [];
|
|
480
|
+
// A decision/prohibition needs forbidden strings for a contradiction to be detectable; without
|
|
481
|
+
// them the honest shape is a constraint (still re-injected on boot and when its scope is touched).
|
|
482
|
+
const kind = spec.kind ?? (signals.length > 0 ? 'decision' : 'constraint');
|
|
483
|
+
try {
|
|
484
|
+
const anchor = declareAnchor(db, {
|
|
485
|
+
kind,
|
|
486
|
+
statement: what.length >= 8 ? what : `${what} (decision)`,
|
|
487
|
+
rationale: why,
|
|
488
|
+
affects: Array.isArray(spec.affects) ? spec.affects : undefined,
|
|
489
|
+
detect_terms: Array.isArray(spec.detect_terms) ? spec.detect_terms : undefined,
|
|
490
|
+
violation_signal: signals.length > 0 ? signals : undefined,
|
|
491
|
+
tier: 'human',
|
|
492
|
+
source_memory_id: memoryId,
|
|
493
|
+
});
|
|
494
|
+
const nodeFields = {};
|
|
495
|
+
if (spec.domain)
|
|
496
|
+
nodeFields.domain = spec.domain;
|
|
497
|
+
if (spec.confidence !== undefined)
|
|
498
|
+
nodeFields.confidence = spec.confidence;
|
|
499
|
+
if (Object.keys(nodeFields).length > 0)
|
|
500
|
+
setNodeFields(db, anchor.id, nodeFields);
|
|
501
|
+
// Link both ways so recall can show "this memory is enforced as #N" and the anchor can
|
|
502
|
+
// point back at the moment it was decided.
|
|
503
|
+
try {
|
|
504
|
+
const c = JSON.parse(rawContent);
|
|
505
|
+
if (c && typeof c === 'object') {
|
|
506
|
+
c.anchor_id = anchor.id;
|
|
507
|
+
db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(JSON.stringify(c), memoryId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch { /* leave content as-is */ }
|
|
511
|
+
logAnchorTouch(db, { anchorId: anchor.id, tool: 'remember', interaction: 'create' });
|
|
512
|
+
out.anchor_id = anchor.id;
|
|
513
|
+
out.anchor_kind = kind;
|
|
514
|
+
out.enforced = signals.length > 0
|
|
515
|
+
? 'contradictions are detected at the gate; re-injected on boot and in scope'
|
|
516
|
+
: 'no violation_signal given — re-injected on boot and when its scope is touched, but contradictions cannot be detected';
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
out.anchor_error = String(e?.message ?? e);
|
|
520
|
+
out.hint = 'The memory was saved. Fix the anchor spec and call declare_anchor, or remember again with a corrected anchor.';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return JSON.stringify(out);
|
|
446
524
|
}
|
|
447
525
|
// Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
|
|
448
526
|
// Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
|
|
@@ -1233,14 +1311,77 @@ async function handleRememberUnified(args) {
|
|
|
1233
1311
|
if (args.memory_id) {
|
|
1234
1312
|
return handleUpdateMemory(args);
|
|
1235
1313
|
}
|
|
1236
|
-
// Create mode (default)
|
|
1237
|
-
|
|
1314
|
+
// Create mode (default). Only content is required; everything an agent used to have to
|
|
1315
|
+
// invent is defaulted. Inventing an entity name and picking a layer were the two places a
|
|
1316
|
+
// "remember this" call stalled — the taxonomy is for the dashboard, not for the agent.
|
|
1317
|
+
if (!args.content || !String(args.content).trim()) {
|
|
1238
1318
|
return JSON.stringify({
|
|
1239
1319
|
ok: false,
|
|
1240
|
-
error: '
|
|
1320
|
+
error: 'content is required. (entity/layer are optional now.) To update, provide memory_id. To delete, set forget: true + memory_id.',
|
|
1241
1321
|
});
|
|
1242
1322
|
}
|
|
1243
|
-
|
|
1323
|
+
const a = { ...args };
|
|
1324
|
+
let entityInferred;
|
|
1325
|
+
if (!a.entity_name) {
|
|
1326
|
+
const inferred = await inferDefaultEntity();
|
|
1327
|
+
a.entity_name = inferred.name;
|
|
1328
|
+
a.entity_kind = a.entity_kind ?? inferred.kind;
|
|
1329
|
+
entityInferred = inferred.from;
|
|
1330
|
+
}
|
|
1331
|
+
if (!a.entity_kind)
|
|
1332
|
+
a.entity_kind = 'project';
|
|
1333
|
+
if (!a.layer)
|
|
1334
|
+
a.layer = a.anchor ? 'learning' : 'context';
|
|
1335
|
+
const out = handleRemember(a);
|
|
1336
|
+
if (!entityInferred)
|
|
1337
|
+
return out;
|
|
1338
|
+
try {
|
|
1339
|
+
const parsed = JSON.parse(out);
|
|
1340
|
+
if (parsed.ok)
|
|
1341
|
+
parsed.entity_inferred_from = entityInferred;
|
|
1342
|
+
return JSON.stringify(parsed);
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
return out;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The entity a memory is about when the caller did not say: the project they are in.
|
|
1350
|
+
* Same evidence chain as where_am_i — workspace roots first, then the files edited recently.
|
|
1351
|
+
*/
|
|
1352
|
+
async function inferDefaultEntity() {
|
|
1353
|
+
try {
|
|
1354
|
+
const roots = await fetchRoots(server);
|
|
1355
|
+
if (roots.length === 1) {
|
|
1356
|
+
const base = rootPathFromUri(roots[0].uri).replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop();
|
|
1357
|
+
if (base)
|
|
1358
|
+
return { name: base, kind: 'project', from: 'workspace_root' };
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
catch { /* roots unsupported by this host */ }
|
|
1362
|
+
try {
|
|
1363
|
+
const rows = db
|
|
1364
|
+
.prepare(`SELECT file_path FROM session_file_edits WHERE occurred_at > unixepoch() - 86400 ORDER BY occurred_at DESC LIMIT 40`)
|
|
1365
|
+
.all();
|
|
1366
|
+
const paths = rows.map((r) => r.file_path.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
1367
|
+
if (paths.length > 0) {
|
|
1368
|
+
// longest common directory prefix of what was touched → its basename is the project
|
|
1369
|
+
let prefix = paths[0].slice(0, -1);
|
|
1370
|
+
for (const p of paths.slice(1)) {
|
|
1371
|
+
let i = 0;
|
|
1372
|
+
while (i < prefix.length && i < p.length - 1 && prefix[i].toLowerCase() === p[i].toLowerCase())
|
|
1373
|
+
i++;
|
|
1374
|
+
prefix = prefix.slice(0, i);
|
|
1375
|
+
if (prefix.length === 0)
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
const base = prefix[prefix.length - 1];
|
|
1379
|
+
if (base && !/^(c:|users|home|[a-z])$/i.test(base))
|
|
1380
|
+
return { name: base, kind: 'project', from: 'recent_edits' };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
catch { /* fall through */ }
|
|
1384
|
+
return { name: 'workspace', kind: 'project', from: 'fallback' };
|
|
1244
1385
|
}
|
|
1245
1386
|
async function handleRecallUnified(args) {
|
|
1246
1387
|
// File history mode (path takes priority; if query also provided, include it as context)
|
|
@@ -1293,7 +1434,8 @@ function handleDriftStatus(args) {
|
|
|
1293
1434
|
by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
|
|
1294
1435
|
by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
|
|
1295
1436
|
by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
|
|
1296
|
-
`🔵 ${by_state.aligned}
|
|
1437
|
+
`🔵 ${by_state.aligned} verified`,
|
|
1438
|
+
by_state.unverified > 0 ? `⚫ ${by_state.unverified} unverified` : null,
|
|
1297
1439
|
].filter(Boolean).join(' · ');
|
|
1298
1440
|
if (args?.verbose) {
|
|
1299
1441
|
return JSON.stringify({
|
|
@@ -1314,6 +1456,13 @@ function handleDriftStatus(args) {
|
|
|
1314
1456
|
count: g.nodes.length,
|
|
1315
1457
|
nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
|
|
1316
1458
|
}));
|
|
1459
|
+
// Unverified is the quiet majority on most machines. One line each, grouped by domain —
|
|
1460
|
+
// enough to see where the detector has no eyes, not enough to bury the six that matter.
|
|
1461
|
+
const unverified = view.unverifiedByDomain.map((g) => ({
|
|
1462
|
+
domain: g.domain,
|
|
1463
|
+
count: g.nodes.length,
|
|
1464
|
+
nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement })),
|
|
1465
|
+
}));
|
|
1317
1466
|
const cand = view.candidates;
|
|
1318
1467
|
return JSON.stringify({
|
|
1319
1468
|
ok: true,
|
|
@@ -1321,9 +1470,10 @@ function handleDriftStatus(args) {
|
|
|
1321
1470
|
nextReopen: view.nextReopen,
|
|
1322
1471
|
attention: view.attention,
|
|
1323
1472
|
aligned,
|
|
1473
|
+
unverified,
|
|
1324
1474
|
candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
|
|
1325
1475
|
counts: view.counts,
|
|
1326
|
-
hint: 'verbose:true for full
|
|
1476
|
+
hint: 'verbose:true for full nodes and candidate details; check_decision(anchor_id) for one node. ⚫ unverified = declared, never checked against reality: give it affects/violation_signal so the detector can see it, or leave it as a note.',
|
|
1327
1477
|
});
|
|
1328
1478
|
}
|
|
1329
1479
|
// Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
|
|
@@ -1485,6 +1635,7 @@ function handleResolveDrift(args) {
|
|
|
1485
1635
|
rationale: args.rationale,
|
|
1486
1636
|
review_after: args.review_after,
|
|
1487
1637
|
superseded_by: args.superseded_by,
|
|
1638
|
+
hit_term: args.hit_term,
|
|
1488
1639
|
});
|
|
1489
1640
|
return JSON.stringify(result);
|
|
1490
1641
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|