linksee-memory 0.7.1 → 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.
@@ -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