linksee-memory 0.7.2 → 0.8.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 +111 -7
- 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/db/migrate.js +52 -28
- package/dist/db/schema.sql +348 -232
- 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/truth-engine.d.ts +84 -0
- package/dist/lib/truth-engine.js +417 -0
- package/dist/mcp/server.js +168 -3
- package/package.json +5 -3
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Truth Engine — drift state derivation (engine-side, P0).
|
|
2
|
+
//
|
|
3
|
+
// Migrated from linksee-dashboard/lib/truth.ts into the MCP engine so that
|
|
4
|
+
// agents can query drift status via MCP tools. The dashboard can later import
|
|
5
|
+
// from here instead of duplicating the logic.
|
|
6
|
+
//
|
|
7
|
+
// Make-or-break rule: a divergence accounted for by a recorded resolution
|
|
8
|
+
// (supersede/fix/acknowledge) is NOT drift. Only unaccounted gaps are flagged.
|
|
9
|
+
//
|
|
10
|
+
// 4-species taxonomy (display-layer classification):
|
|
11
|
+
// hypothesis → Decision Cards (decision journal format)
|
|
12
|
+
// constraint → Rules (compact pass/fail checklist)
|
|
13
|
+
// commitment → Heartbeats (cadence monitoring, alive/dead)
|
|
14
|
+
// source_of_truth → Reference (quiet, rarely changes)
|
|
15
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
16
|
+
const DOMAIN_ORDER = [
|
|
17
|
+
'strategy', 'monetization', 'product', 'engineering',
|
|
18
|
+
'growth', 'operations', 'security', 'roadmap', 'memory', 'other',
|
|
19
|
+
];
|
|
20
|
+
const STATE_RANK = {
|
|
21
|
+
drift: 0, review: 1, held: 2, aligned: 3,
|
|
22
|
+
};
|
|
23
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
|
+
function safeJsonParse(s, fallback) {
|
|
25
|
+
if (s == null)
|
|
26
|
+
return fallback;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(String(s));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function formatDate(ts) {
|
|
35
|
+
if (ts == null)
|
|
36
|
+
return null;
|
|
37
|
+
return new Date(ts * 1000).toISOString().slice(0, 10);
|
|
38
|
+
}
|
|
39
|
+
function parseJsonArray(s) {
|
|
40
|
+
if (!s)
|
|
41
|
+
return [];
|
|
42
|
+
try {
|
|
43
|
+
const a = JSON.parse(s);
|
|
44
|
+
return Array.isArray(a) ? a.map(String) : [];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Classify a node into one of 4 species based on decision_mode */
|
|
51
|
+
function classifySpecies(decision_mode) {
|
|
52
|
+
switch (decision_mode) {
|
|
53
|
+
case 'hypothesis': return 'hypothesis';
|
|
54
|
+
case 'constraint': return 'constraint';
|
|
55
|
+
case 'commitment': return 'commitment';
|
|
56
|
+
case 'source_of_truth': return 'source_of_truth';
|
|
57
|
+
// Fallback: constraints and prohibitions map to 'constraint',
|
|
58
|
+
// metrics to 'hypothesis', anything else to 'hypothesis' (safest default)
|
|
59
|
+
default: return 'hypothesis';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function buildResolutionLookup(db) {
|
|
63
|
+
const t3res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t3_resolutions'").get()?.value, {});
|
|
64
|
+
const t2res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t2_resolutions'").get()?.value, {});
|
|
65
|
+
// Collect ALL matching resolutions, then pick the most recent one.
|
|
66
|
+
// Without this, an older acknowledge can shadow a newer fix.
|
|
67
|
+
return function resolutionFor(id) {
|
|
68
|
+
const matches = [];
|
|
69
|
+
for (const r of Object.values(t3res)) {
|
|
70
|
+
if (r && (r.superseded_node === id || r.superseded_by === id ||
|
|
71
|
+
r.node === id || r.direction_node === id || r.constraint_node === id)) {
|
|
72
|
+
matches.push(r);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const r of Object.values(t2res)) {
|
|
76
|
+
if (r && (r.node === id || r.constraint_node === id)) {
|
|
77
|
+
matches.push(r);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (matches.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
if (matches.length === 1)
|
|
83
|
+
return { action: matches[0].action };
|
|
84
|
+
// Multiple matches → prefer most recent (by resolved_at, falling back to last found)
|
|
85
|
+
matches.sort((a, b) => {
|
|
86
|
+
const ta = a.resolved_at ? new Date(a.resolved_at).getTime() : 0;
|
|
87
|
+
const tb = b.resolved_at ? new Date(b.resolved_at).getTime() : 0;
|
|
88
|
+
return tb - ta; // newest first
|
|
89
|
+
});
|
|
90
|
+
return { action: matches[0].action };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ── Core: getTruthView ───────────────────────────────────────────────────────
|
|
94
|
+
export function getTruthView(db, opts = {}) {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const resolutionFor = buildResolutionLookup(db);
|
|
97
|
+
// ── Candidates (indexed by target node) ──
|
|
98
|
+
const candRows = db
|
|
99
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
100
|
+
FROM memory_write_candidates ORDER BY id DESC`)
|
|
101
|
+
.all();
|
|
102
|
+
const pendingByNode = new Map();
|
|
103
|
+
const cardByNode = new Map();
|
|
104
|
+
for (const c of candRows) {
|
|
105
|
+
if (c.target_node_id == null)
|
|
106
|
+
continue;
|
|
107
|
+
if (c.status === 'pending_review' && !pendingByNode.has(c.target_node_id)) {
|
|
108
|
+
pendingByNode.set(c.target_node_id, c);
|
|
109
|
+
}
|
|
110
|
+
const pn = String(c.proposed_node || '');
|
|
111
|
+
if ((pn.includes('"src":"t3"') || pn.includes('"src":"t2"')) && !cardByNode.has(c.target_node_id)) {
|
|
112
|
+
cardByNode.set(c.target_node_id, c);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── Active nodes + state derivation ──
|
|
116
|
+
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
117
|
+
card_policy, review_after
|
|
118
|
+
FROM drift_anchors WHERE status = 'active'`;
|
|
119
|
+
const params = [];
|
|
120
|
+
if (opts.domain) {
|
|
121
|
+
sql += ' AND domain = ?';
|
|
122
|
+
params.push(opts.domain);
|
|
123
|
+
}
|
|
124
|
+
if (opts.decision_mode) {
|
|
125
|
+
sql += ' AND decision_mode = ?';
|
|
126
|
+
params.push(opts.decision_mode);
|
|
127
|
+
}
|
|
128
|
+
sql += ' ORDER BY domain, id';
|
|
129
|
+
const rows = db.prepare(sql).all(...params);
|
|
130
|
+
const nodes = rows.map((r) => {
|
|
131
|
+
let cadence = null;
|
|
132
|
+
try {
|
|
133
|
+
cadence = JSON.parse(r.card_policy || '{}').cadence_days ?? null;
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
const res = resolutionFor(r.id);
|
|
137
|
+
const pending = pendingByNode.get(r.id);
|
|
138
|
+
const card = cardByNode.get(r.id);
|
|
139
|
+
const overdue = r.review_after != null && r.review_after * 1000 < now;
|
|
140
|
+
// ── State derivation (the make-or-break logic) ──
|
|
141
|
+
let state;
|
|
142
|
+
let accounted;
|
|
143
|
+
let accountedBy;
|
|
144
|
+
if (res?.action === 'acknowledge_validate' || res?.action === 'acknowledge') {
|
|
145
|
+
// ⚪ held — but if time-box expired, escalate to 🔴
|
|
146
|
+
state = overdue ? 'drift' : 'held';
|
|
147
|
+
accounted = !overdue;
|
|
148
|
+
accountedBy = overdue
|
|
149
|
+
? 'acknowledge (expired → reopened)'
|
|
150
|
+
: 'acknowledge (held, time-boxed)';
|
|
151
|
+
}
|
|
152
|
+
else if (res?.action === 'fix' || res?.action === 'fix_implemented') {
|
|
153
|
+
state = 'aligned';
|
|
154
|
+
accounted = true;
|
|
155
|
+
accountedBy = 'fix (resolved)';
|
|
156
|
+
}
|
|
157
|
+
else if (res?.action === 'supersede') {
|
|
158
|
+
state = 'aligned';
|
|
159
|
+
accounted = true;
|
|
160
|
+
accountedBy = 'supersede (intentional evolution)';
|
|
161
|
+
}
|
|
162
|
+
else if (pending) {
|
|
163
|
+
// 🟡 Soft signal awaiting human decision
|
|
164
|
+
state = 'review';
|
|
165
|
+
accounted = false;
|
|
166
|
+
accountedBy = null;
|
|
167
|
+
}
|
|
168
|
+
else if (r.lifecycle === 'at_risk') {
|
|
169
|
+
// 🔴 declared-core but unproven / unaccounted
|
|
170
|
+
state = 'drift';
|
|
171
|
+
accounted = false;
|
|
172
|
+
accountedBy = null;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
// 🔵 convergent — reality matches intent (or no signal)
|
|
176
|
+
state = 'aligned';
|
|
177
|
+
accounted = true;
|
|
178
|
+
accountedBy = null;
|
|
179
|
+
}
|
|
180
|
+
const reality = card?.rationale
|
|
181
|
+
?? pending?.rationale
|
|
182
|
+
?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
|
|
183
|
+
return {
|
|
184
|
+
id: r.id,
|
|
185
|
+
node_type: r.node_type,
|
|
186
|
+
domain: r.domain,
|
|
187
|
+
decision_mode: r.decision_mode,
|
|
188
|
+
species: classifySpecies(r.decision_mode),
|
|
189
|
+
statement: r.statement,
|
|
190
|
+
rationale: r.rationale ?? null,
|
|
191
|
+
confidence: r.confidence,
|
|
192
|
+
lifecycle: r.lifecycle ?? 'active',
|
|
193
|
+
cadence_days: cadence,
|
|
194
|
+
review_after: r.review_after ?? null,
|
|
195
|
+
state,
|
|
196
|
+
accounted,
|
|
197
|
+
accountedBy,
|
|
198
|
+
reality,
|
|
199
|
+
reviewDate: formatDate(r.review_after),
|
|
200
|
+
overdue,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
// ── Partition: attention (loud) vs aligned (quiet) ──
|
|
204
|
+
const attention = nodes
|
|
205
|
+
.filter((n) => n.state !== 'aligned')
|
|
206
|
+
.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || b.confidence - a.confidence);
|
|
207
|
+
const alignedGroups = new Map();
|
|
208
|
+
for (const n of nodes.filter((n) => n.state === 'aligned')) {
|
|
209
|
+
const d = n.domain ?? 'other';
|
|
210
|
+
if (!alignedGroups.has(d))
|
|
211
|
+
alignedGroups.set(d, []);
|
|
212
|
+
alignedGroups.get(d).push(n);
|
|
213
|
+
}
|
|
214
|
+
const alignedByDomain = [...alignedGroups.entries()]
|
|
215
|
+
.sort((a, b) => DOMAIN_ORDER.indexOf(a[0]) - DOMAIN_ORDER.indexOf(b[0]))
|
|
216
|
+
.map(([domain, ns]) => ({ domain, nodes: ns }));
|
|
217
|
+
// ── Counts + next reopen ──
|
|
218
|
+
const by_mode = {};
|
|
219
|
+
for (const n of nodes) {
|
|
220
|
+
const m = n.decision_mode ?? 'unset';
|
|
221
|
+
by_mode[m] = (by_mode[m] || 0) + 1;
|
|
222
|
+
}
|
|
223
|
+
const by_species = {
|
|
224
|
+
hypothesis: 0, constraint: 0, commitment: 0, source_of_truth: 0,
|
|
225
|
+
};
|
|
226
|
+
for (const n of nodes)
|
|
227
|
+
by_species[n.species]++;
|
|
228
|
+
const by_state = { drift: 0, review: 0, held: 0, aligned: 0 };
|
|
229
|
+
for (const n of nodes)
|
|
230
|
+
by_state[n.state]++;
|
|
231
|
+
const reopenDates = nodes
|
|
232
|
+
.filter((n) => n.state === 'held' && n.reviewDate)
|
|
233
|
+
.map((n) => n.reviewDate)
|
|
234
|
+
.sort();
|
|
235
|
+
const nextReopen = reopenDates[0] ?? null;
|
|
236
|
+
// ── Candidate mapping ──
|
|
237
|
+
const stmtById = new Map(nodes.map((n) => [n.id, n.statement]));
|
|
238
|
+
const mapCand = (c) => ({
|
|
239
|
+
id: c.id,
|
|
240
|
+
candidate_type: c.candidate_type,
|
|
241
|
+
target_node_id: c.target_node_id,
|
|
242
|
+
target_statement: c.target_node_id != null ? stmtById.get(c.target_node_id) ?? null : null,
|
|
243
|
+
rationale: c.rationale,
|
|
244
|
+
confidence: c.confidence,
|
|
245
|
+
status: c.status,
|
|
246
|
+
});
|
|
247
|
+
const auto = candRows.filter((c) => c.status === 'auto_accepted').map(mapCand);
|
|
248
|
+
const suppressed = candRows.filter((c) => c.status === 'rejected').map(mapCand);
|
|
249
|
+
return {
|
|
250
|
+
attention,
|
|
251
|
+
alignedByDomain,
|
|
252
|
+
candidates: { auto, suppressed },
|
|
253
|
+
counts: {
|
|
254
|
+
nodes: nodes.length,
|
|
255
|
+
by_mode,
|
|
256
|
+
by_species,
|
|
257
|
+
by_state,
|
|
258
|
+
auto: auto.length,
|
|
259
|
+
suppressed: suppressed.length,
|
|
260
|
+
},
|
|
261
|
+
nextReopen,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// ── check_decision: single-node deep view ────────────────────────────────────
|
|
265
|
+
export function getDecisionDetail(db, anchorId) {
|
|
266
|
+
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
|
|
269
|
+
FROM drift_anchors WHERE id = ? AND status = 'active'`)
|
|
270
|
+
.get(anchorId);
|
|
271
|
+
if (!row)
|
|
272
|
+
return null;
|
|
273
|
+
// Get full truth view for this single node (reuse state derivation)
|
|
274
|
+
const resolutionFor = buildResolutionLookup(db);
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
let cadence = null;
|
|
277
|
+
try {
|
|
278
|
+
cadence = JSON.parse(row.card_policy || '{}').cadence_days ?? null;
|
|
279
|
+
}
|
|
280
|
+
catch { /* */ }
|
|
281
|
+
const res = resolutionFor(row.id);
|
|
282
|
+
const overdue = row.review_after != null && row.review_after * 1000 < now;
|
|
283
|
+
// State derivation (same logic)
|
|
284
|
+
let state, accounted, accountedBy;
|
|
285
|
+
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'
|
|
289
|
+
ORDER BY id DESC`)
|
|
290
|
+
.all(anchorId);
|
|
291
|
+
const cardCand = db
|
|
292
|
+
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
293
|
+
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
294
|
+
ORDER BY id DESC LIMIT 1`)
|
|
295
|
+
.get(anchorId);
|
|
296
|
+
const hasPending = pendingCand.length > 0;
|
|
297
|
+
if (res?.action === 'acknowledge_validate' || res?.action === 'acknowledge') {
|
|
298
|
+
state = overdue ? 'drift' : 'held';
|
|
299
|
+
accounted = !overdue;
|
|
300
|
+
accountedBy = overdue ? 'acknowledge (expired → reopened)' : 'acknowledge (held, time-boxed)';
|
|
301
|
+
}
|
|
302
|
+
else if (res?.action === 'fix' || res?.action === 'fix_implemented') {
|
|
303
|
+
state = 'aligned';
|
|
304
|
+
accounted = true;
|
|
305
|
+
accountedBy = 'fix (resolved)';
|
|
306
|
+
}
|
|
307
|
+
else if (res?.action === 'supersede') {
|
|
308
|
+
state = 'aligned';
|
|
309
|
+
accounted = true;
|
|
310
|
+
accountedBy = 'supersede (intentional evolution)';
|
|
311
|
+
}
|
|
312
|
+
else if (hasPending) {
|
|
313
|
+
state = 'review';
|
|
314
|
+
accounted = false;
|
|
315
|
+
accountedBy = null;
|
|
316
|
+
}
|
|
317
|
+
else if (row.lifecycle === 'at_risk') {
|
|
318
|
+
state = 'drift';
|
|
319
|
+
accounted = false;
|
|
320
|
+
accountedBy = null;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
state = 'aligned';
|
|
324
|
+
accounted = true;
|
|
325
|
+
accountedBy = null;
|
|
326
|
+
}
|
|
327
|
+
const reality = cardCand?.rationale ?? (hasPending ? pendingCand[0].rationale : null)
|
|
328
|
+
?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
|
|
329
|
+
// Drift edges for this anchor
|
|
330
|
+
const edges = db
|
|
331
|
+
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
332
|
+
FROM drift_edges WHERE anchor_id = ? ORDER BY detected_at DESC`)
|
|
333
|
+
.all(anchorId);
|
|
334
|
+
return {
|
|
335
|
+
id: row.id,
|
|
336
|
+
kind: row.kind,
|
|
337
|
+
node_type: row.node_type,
|
|
338
|
+
domain: row.domain,
|
|
339
|
+
decision_mode: row.decision_mode,
|
|
340
|
+
species: classifySpecies(row.decision_mode),
|
|
341
|
+
statement: row.statement,
|
|
342
|
+
rationale: row.rationale ?? null,
|
|
343
|
+
confidence: row.confidence,
|
|
344
|
+
lifecycle: row.lifecycle ?? 'active',
|
|
345
|
+
cadence_days: cadence,
|
|
346
|
+
review_after: row.review_after ?? null,
|
|
347
|
+
state,
|
|
348
|
+
accounted,
|
|
349
|
+
accountedBy,
|
|
350
|
+
reality,
|
|
351
|
+
reviewDate: formatDate(row.review_after),
|
|
352
|
+
overdue,
|
|
353
|
+
// Deep fields
|
|
354
|
+
affects: parseJsonArray(row.affects),
|
|
355
|
+
detect_terms: parseJsonArray(row.detect_terms),
|
|
356
|
+
violation_signal: parseJsonArray(row.violation_signal),
|
|
357
|
+
tier: row.tier,
|
|
358
|
+
pendingCandidates: pendingCand.map((c) => ({
|
|
359
|
+
id: c.id,
|
|
360
|
+
candidate_type: c.candidate_type,
|
|
361
|
+
target_node_id: c.target_node_id,
|
|
362
|
+
target_statement: row.statement,
|
|
363
|
+
rationale: c.rationale,
|
|
364
|
+
confidence: c.confidence,
|
|
365
|
+
status: c.status,
|
|
366
|
+
})),
|
|
367
|
+
driftEdges: edges,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export function resolveDrift(db, input) {
|
|
371
|
+
const ACTIONS = new Set(['fix', 'supersede', 'acknowledge', 'dismiss']);
|
|
372
|
+
if (!ACTIONS.has(input.action)) {
|
|
373
|
+
throw new Error(`invalid action "${input.action}" — one of: fix, supersede, acknowledge, dismiss`);
|
|
374
|
+
}
|
|
375
|
+
// Verify anchor exists and is active
|
|
376
|
+
const anchor = db
|
|
377
|
+
.prepare("SELECT id, statement FROM drift_anchors WHERE id = ? AND status = 'active'")
|
|
378
|
+
.get(input.anchor_id);
|
|
379
|
+
if (!anchor) {
|
|
380
|
+
throw new Error(`anchor ${input.anchor_id} not found or not active`);
|
|
381
|
+
}
|
|
382
|
+
// Build resolution record
|
|
383
|
+
const resolution = {
|
|
384
|
+
action: input.action,
|
|
385
|
+
node: input.anchor_id,
|
|
386
|
+
rationale: input.rationale ?? null,
|
|
387
|
+
resolved_at: new Date().toISOString(),
|
|
388
|
+
};
|
|
389
|
+
if (input.action === 'acknowledge' && input.review_after) {
|
|
390
|
+
resolution.action = 'acknowledge_validate';
|
|
391
|
+
const ts = Math.floor(new Date(input.review_after).getTime() / 1000);
|
|
392
|
+
resolution.review_after = ts;
|
|
393
|
+
// Also set review_after on the anchor itself
|
|
394
|
+
db.prepare('UPDATE drift_anchors SET review_after = ?, updated_at = unixepoch() WHERE id = ?')
|
|
395
|
+
.run(ts, input.anchor_id);
|
|
396
|
+
}
|
|
397
|
+
if (input.action === 'supersede' && input.superseded_by != null) {
|
|
398
|
+
resolution.superseded_node = input.anchor_id;
|
|
399
|
+
resolution.superseded_by = input.superseded_by;
|
|
400
|
+
}
|
|
401
|
+
// Write to meta (t3_resolutions) — additive, keyed by anchor id
|
|
402
|
+
const key = 't3_resolutions';
|
|
403
|
+
const existing = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value, {});
|
|
404
|
+
existing[`A${input.anchor_id}`] = resolution;
|
|
405
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?")
|
|
406
|
+
.run(key, JSON.stringify(existing), JSON.stringify(existing));
|
|
407
|
+
// If action is 'dismiss', also mark all open drift_edges for this anchor as dismissed
|
|
408
|
+
if (input.action === 'dismiss') {
|
|
409
|
+
db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
|
|
410
|
+
}
|
|
411
|
+
// If action is 'fix', mark open edges as resolved
|
|
412
|
+
if (input.action === 'fix') {
|
|
413
|
+
db.prepare("UPDATE drift_edges SET status = 'resolved' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
|
|
414
|
+
}
|
|
415
|
+
return { ok: true, resolution };
|
|
416
|
+
}
|
|
417
|
+
//# sourceMappingURL=truth-engine.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// linksee-memory MCP server (stdio transport).
|
|
3
|
-
// Tools: remember / recall / read_smart
|
|
4
|
-
// v0.
|
|
3
|
+
// Tools: remember / recall / read_smart / drift_status / check_decision / declare_anchor / resolve_drift
|
|
4
|
+
// v0.8.0 — drift MCP tools (P0: agents can now query & act on drift state)
|
|
5
5
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -19,7 +19,9 @@ import { PROMPTS, getPrompt } from './prompts.js';
|
|
|
19
19
|
import { fetchRoots, isInsideRoots } from './roots.js';
|
|
20
20
|
import { sampleConsolidation } from './sampling.js';
|
|
21
21
|
import { confirmForget } from './elicitation.js';
|
|
22
|
-
|
|
22
|
+
import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engine.js';
|
|
23
|
+
import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
|
|
24
|
+
const SERVER_VERSION = '0.8.0';
|
|
23
25
|
const db = openDb();
|
|
24
26
|
runMigrations(db);
|
|
25
27
|
// Auto-maintenance: consolidate stale memories on startup (non-blocking)
|
|
@@ -142,6 +144,68 @@ const TOOLS = [
|
|
|
142
144
|
required: ['path'],
|
|
143
145
|
},
|
|
144
146
|
},
|
|
147
|
+
// ── Drift tools (v0.8.0) ─────────────────────────────────────────────────
|
|
148
|
+
{
|
|
149
|
+
name: 'drift_status',
|
|
150
|
+
description: 'Check what\'s drifting right now — the "Intent Datadog" for your product decisions.\n\nReturns a structured truth map showing which decisions/constraints/hypotheses are:\n🔴 drift (unaccounted divergence from intent)\n🟡 review (soft signal, awaiting human decision)\n⚪ held (acknowledged, time-boxed, not forgotten)\n🔵 aligned (reality matches intent)\n\nNodes are classified into 4 species:\n• hypothesis → Decision Cards (decision journal format)\n• constraint → Rules (pass/fail checklist)\n• commitment → Heartbeats (cadence monitoring)\n• source_of_truth → Reference (stable anchors)\n\nWHEN TO CALL:\n• At session start — "what needs my attention?"\n• Before making a decision — check for existing anchors on the topic\n• After completing work — verify drift state changed\n• When the user asks about product health / what\'s broken / what\'s stale',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
|
|
155
|
+
decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'check_decision',
|
|
161
|
+
description: 'Deep-dive into a specific decision/anchor — its state, premises, drift edges, and pending candidates.\n\nReturns the full context for one truth-map node: what was decided, why, what reality says, whether it\'s drifting, and what actions are pending.\n\nWHEN TO CALL:\n• When the user asks about a specific decision ("what happened with X?")\n• Before resolving a drift signal — understand the full picture first\n• When reviewing premises of a decision ("is this still true?")',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
anchor_id: { type: 'number', description: 'The drift_anchor ID to inspect' },
|
|
166
|
+
},
|
|
167
|
+
required: ['anchor_id'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'declare_anchor',
|
|
172
|
+
description: 'Declare a new decision, constraint, or prohibition as a truth-map anchor.\n\nAnchors are NORMATIVE claims: "we decided X", "Y is forbidden", "Z must always hold."\nThe drift detector later checks these against committed reality.\n\ndeclare-don\'t-mine: anchors come ONLY from explicit human declaration, never from pattern extraction.\n\nWHEN TO CALL:\n• When the user makes a product decision ("let\'s go with approach A")\n• When a constraint is established ("never do X")\n• When a commitment is made ("we ship weekly")\n• When the user says "anchor this" / "record this decision"',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
kind: { type: 'string', enum: ['prohibition', 'decision', 'constraint'], description: 'Anchor type' },
|
|
177
|
+
statement: { type: 'string', description: 'The normative claim (>= 8 chars)' },
|
|
178
|
+
rationale: { type: 'string', description: 'Why this was decided' },
|
|
179
|
+
affects: { type: 'array', items: { type: 'string' }, description: 'Path globs that scope this anchor' },
|
|
180
|
+
detect_terms: { type: 'array', items: { type: 'string' }, description: 'Keywords for scoping' },
|
|
181
|
+
violation_signal: { type: 'array', items: { type: 'string' }, description: 'Terms whose presence = violation (required for prohibition/decision)' },
|
|
182
|
+
tier: { type: 'string', enum: ['human', 'explicit'], description: 'Declaration tier (default: human)' },
|
|
183
|
+
// v9 ProjectCoreNode fields
|
|
184
|
+
node_type: { type: 'string', description: 'Node type label' },
|
|
185
|
+
domain: { type: 'string', description: 'Domain (strategy, product, engineering, etc.)' },
|
|
186
|
+
decision_mode: { type: 'string', enum: ['hypothesis', 'constraint', 'commitment', 'source_of_truth'], description: 'Classification for 4-species display' },
|
|
187
|
+
confidence: { type: 'number', minimum: 0, maximum: 1, description: 'Confidence level (0.0-1.0)' },
|
|
188
|
+
lifecycle: { type: 'string', description: 'Lifecycle state (active, at_risk, retired)' },
|
|
189
|
+
review_after: { type: 'string', description: 'ISO date for next review (e.g. "2026-07-04")' },
|
|
190
|
+
},
|
|
191
|
+
required: ['kind', 'statement'],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: 'resolve_drift',
|
|
196
|
+
description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n4 resolution 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\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',
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
anchor_id: { type: 'number', description: 'The drift_anchor ID to resolve' },
|
|
201
|
+
action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss'], description: 'Resolution action' },
|
|
202
|
+
rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
|
|
203
|
+
review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
|
|
204
|
+
superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
|
|
205
|
+
},
|
|
206
|
+
required: ['anchor_id', 'action'],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
145
209
|
];
|
|
146
210
|
// ============================================================
|
|
147
211
|
// Handlers
|
|
@@ -1067,6 +1131,94 @@ async function handleRecallUnified(args) {
|
|
|
1067
1131
|
return handleRecall(args);
|
|
1068
1132
|
}
|
|
1069
1133
|
// ============================================================
|
|
1134
|
+
// Drift tool handlers (v0.8.0)
|
|
1135
|
+
// ============================================================
|
|
1136
|
+
function handleDriftStatus(args) {
|
|
1137
|
+
const view = getTruthView(db, {
|
|
1138
|
+
domain: args?.domain,
|
|
1139
|
+
decision_mode: args?.decision_mode,
|
|
1140
|
+
});
|
|
1141
|
+
// Build a concise triage line
|
|
1142
|
+
const { by_state, nodes } = view.counts;
|
|
1143
|
+
const triage = [
|
|
1144
|
+
by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
|
|
1145
|
+
by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
|
|
1146
|
+
by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
|
|
1147
|
+
`🔵 ${by_state.aligned} aligned`,
|
|
1148
|
+
].filter(Boolean).join(' · ');
|
|
1149
|
+
return JSON.stringify({
|
|
1150
|
+
ok: true,
|
|
1151
|
+
triage: `${nodes} anchors: ${triage}`,
|
|
1152
|
+
nextReopen: view.nextReopen,
|
|
1153
|
+
attention: view.attention,
|
|
1154
|
+
alignedByDomain: view.alignedByDomain,
|
|
1155
|
+
candidates: view.candidates,
|
|
1156
|
+
counts: view.counts,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
function handleCheckDecision(args) {
|
|
1160
|
+
if (!args?.anchor_id)
|
|
1161
|
+
throw new Error('anchor_id is required');
|
|
1162
|
+
const detail = getDecisionDetail(db, args.anchor_id);
|
|
1163
|
+
if (!detail) {
|
|
1164
|
+
return JSON.stringify({ ok: false, error: `Anchor ${args.anchor_id} not found or not active` });
|
|
1165
|
+
}
|
|
1166
|
+
return JSON.stringify({ ok: true, decision: detail });
|
|
1167
|
+
}
|
|
1168
|
+
function handleDeclareAnchor(args) {
|
|
1169
|
+
if (!args?.kind || !args?.statement) {
|
|
1170
|
+
throw new Error('kind and statement are required');
|
|
1171
|
+
}
|
|
1172
|
+
// Create the base anchor
|
|
1173
|
+
const anchor = declareAnchor(db, {
|
|
1174
|
+
kind: args.kind,
|
|
1175
|
+
statement: args.statement,
|
|
1176
|
+
rationale: args.rationale,
|
|
1177
|
+
affects: args.affects,
|
|
1178
|
+
detect_terms: args.detect_terms,
|
|
1179
|
+
violation_signal: args.violation_signal,
|
|
1180
|
+
tier: args.tier ?? 'human',
|
|
1181
|
+
});
|
|
1182
|
+
// Apply v9 ProjectCoreNode fields if provided
|
|
1183
|
+
const nodeFields = {};
|
|
1184
|
+
if (args.node_type !== undefined)
|
|
1185
|
+
nodeFields.node_type = args.node_type;
|
|
1186
|
+
if (args.domain !== undefined)
|
|
1187
|
+
nodeFields.domain = args.domain;
|
|
1188
|
+
if (args.decision_mode !== undefined)
|
|
1189
|
+
nodeFields.decision_mode = args.decision_mode;
|
|
1190
|
+
if (args.confidence !== undefined)
|
|
1191
|
+
nodeFields.confidence = args.confidence;
|
|
1192
|
+
if (args.lifecycle !== undefined)
|
|
1193
|
+
nodeFields.lifecycle = args.lifecycle;
|
|
1194
|
+
if (args.review_after !== undefined) {
|
|
1195
|
+
nodeFields.review_after = Math.floor(new Date(args.review_after).getTime() / 1000);
|
|
1196
|
+
}
|
|
1197
|
+
if (Object.keys(nodeFields).length > 0) {
|
|
1198
|
+
setNodeFields(db, anchor.id, nodeFields);
|
|
1199
|
+
}
|
|
1200
|
+
return JSON.stringify({
|
|
1201
|
+
ok: true,
|
|
1202
|
+
anchor_id: anchor.id,
|
|
1203
|
+
statement: anchor.statement,
|
|
1204
|
+
kind: anchor.kind,
|
|
1205
|
+
message: `Anchor #${anchor.id} declared: "${anchor.statement.substring(0, 80)}"`,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function handleResolveDrift(args) {
|
|
1209
|
+
if (!args?.anchor_id || !args?.action) {
|
|
1210
|
+
throw new Error('anchor_id and action are required');
|
|
1211
|
+
}
|
|
1212
|
+
const result = resolveDrift(db, {
|
|
1213
|
+
anchor_id: args.anchor_id,
|
|
1214
|
+
action: args.action,
|
|
1215
|
+
rationale: args.rationale,
|
|
1216
|
+
review_after: args.review_after,
|
|
1217
|
+
superseded_by: args.superseded_by,
|
|
1218
|
+
});
|
|
1219
|
+
return JSON.stringify(result);
|
|
1220
|
+
}
|
|
1221
|
+
// ============================================================
|
|
1070
1222
|
// MCP wiring
|
|
1071
1223
|
// ============================================================
|
|
1072
1224
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
@@ -1084,6 +1236,19 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1084
1236
|
case 'read_smart':
|
|
1085
1237
|
text = handleReadSmart(args);
|
|
1086
1238
|
break;
|
|
1239
|
+
// Drift tools (v0.8.0)
|
|
1240
|
+
case 'drift_status':
|
|
1241
|
+
text = handleDriftStatus(args);
|
|
1242
|
+
break;
|
|
1243
|
+
case 'check_decision':
|
|
1244
|
+
text = handleCheckDecision(args);
|
|
1245
|
+
break;
|
|
1246
|
+
case 'declare_anchor':
|
|
1247
|
+
text = handleDeclareAnchor(args);
|
|
1248
|
+
break;
|
|
1249
|
+
case 'resolve_drift':
|
|
1250
|
+
text = handleResolveDrift(args);
|
|
1251
|
+
break;
|
|
1087
1252
|
default: {
|
|
1088
1253
|
const migrations = {
|
|
1089
1254
|
update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
|
-
"description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"linksee-memory": "dist/mcp/server.js",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"linksee-memory-sync": "dist/bin/sync-session.js",
|
|
11
11
|
"linksee-memory-install-skill": "dist/bin/install-skill.js",
|
|
12
12
|
"linksee-memory-stats": "dist/bin/stats.js",
|
|
13
|
-
"linksee-memory-setup": "dist/bin/setup.js"
|
|
13
|
+
"linksee-memory-setup": "dist/bin/setup.js",
|
|
14
|
+
"linksee-memory-declare": "dist/bin/declare-anchor.js",
|
|
15
|
+
"linksee-memory-detect": "dist/bin/detect-drift.js"
|
|
14
16
|
},
|
|
15
17
|
"main": "./dist/mcp/server.js",
|
|
16
18
|
"files": [
|