linksee-memory 0.13.0 → 0.13.1
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 +2 -0
- package/dist/lib/truth-engine.js +10 -1
- package/dist/mcp/server.js +3 -1
- 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
|
}
|
|
@@ -88,6 +88,8 @@ export declare function getTruthView(db: Database.Database, opts?: {
|
|
|
88
88
|
export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
|
|
89
89
|
export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
|
|
90
90
|
export interface ResolveInput {
|
|
91
|
+
/** For dismiss: silence only this match term. Omitted → silence the anchor at the gate. */
|
|
92
|
+
hit_term?: string;
|
|
91
93
|
anchor_id: number;
|
|
92
94
|
action: ResolutionAction;
|
|
93
95
|
rationale?: string;
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -490,9 +490,18 @@ export function resolveDrift(db, input) {
|
|
|
490
490
|
existing[`A${input.anchor_id}`] = resolution;
|
|
491
491
|
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?")
|
|
492
492
|
.run(key, JSON.stringify(existing), JSON.stringify(existing));
|
|
493
|
-
//
|
|
493
|
+
// 'dismiss' must change what happens NEXT time, not just tidy the current edges — otherwise
|
|
494
|
+
// the same wrong match fires on the next command and the verdict was theatre. Record it where
|
|
495
|
+
// the gate reads (gate_dismissals), keyed to the exact term when one was given so the anchor's
|
|
496
|
+
// real detections survive.
|
|
494
497
|
if (input.action === 'dismiss') {
|
|
495
498
|
db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
|
|
499
|
+
try {
|
|
500
|
+
db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
|
|
501
|
+
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);
|
|
502
|
+
resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
|
|
503
|
+
}
|
|
504
|
+
catch { /* pre-v16 DB → edges-only dismiss, as before */ }
|
|
496
505
|
}
|
|
497
506
|
// If action is 'fix', mark open edges as resolved
|
|
498
507
|
if (input.action === 'fix') {
|
package/dist/mcp/server.js
CHANGED
|
@@ -255,7 +255,7 @@ const TOOLS = [
|
|
|
255
255
|
},
|
|
256
256
|
{
|
|
257
257
|
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',
|
|
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 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
259
|
inputSchema: {
|
|
260
260
|
type: 'object',
|
|
261
261
|
properties: {
|
|
@@ -264,6 +264,7 @@ const TOOLS = [
|
|
|
264
264
|
rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
|
|
265
265
|
review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
|
|
266
266
|
superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
|
|
267
|
+
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
268
|
},
|
|
268
269
|
required: ['anchor_id', 'action'],
|
|
269
270
|
},
|
|
@@ -1485,6 +1486,7 @@ function handleResolveDrift(args) {
|
|
|
1485
1486
|
rationale: args.rationale,
|
|
1486
1487
|
review_after: args.review_after,
|
|
1487
1488
|
superseded_by: args.superseded_by,
|
|
1489
|
+
hit_term: args.hit_term,
|
|
1488
1490
|
});
|
|
1489
1491
|
return JSON.stringify(result);
|
|
1490
1492
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
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",
|