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.
@@ -0,0 +1,416 @@
1
+ // Drift detection (v8) — the "照合" engine: declared intent (drift_anchors) vs. actual
2
+ // reality (session_file_edits). Sibling to edge-detection.ts; must NOT touch it.
3
+ //
4
+ // Deductive, NOT inferential. A 'contradicts' edge is emitted ONLY when a declared
5
+ // violation_signal literally appears inside a file edit that falls within the anchor's
6
+ // declared path scope — a citation-backed match, never a gap-guess. Lexical/glob/FTS only:
7
+ // there is no embedding layer in this stack (retrieval = trigram FTS5 + BM25), so all
8
+ // matching here is substring + path-glob + trigram-FTS by construction.
9
+ //
10
+ // Verdict vocabulary (Software Reflexion Models, Murphy-Notkin-Sullivan 1995):
11
+ // contradicts = divergence | absent = absence | implements = convergence.
12
+ //
13
+ // v1 emits contradicts + absent only. 'implements' (low-priority convergence) is part of
14
+ // the design but deliberately NOT emitted here: at ~6k edits it would flood the view, and
15
+ // its match_strength is underspecified without a violation-signal to ground it. The FTS
16
+ // topical scope is still computed — it correctly suppresses false 'absent' for an anchor
17
+ // whose reality is reachable only by topic, not path.
18
+ //
19
+ // Idempotent: contradicts upserts, refreshing confidence/evidence for OPEN rows only, so a
20
+ // user's dismissal is never resurrected; absent is INSERT OR IGNORE under the partial unique
21
+ // index idx_drift_absent (one open absence per anchor). Run inside the consolidation sweep
22
+ // or on a manual trigger.
23
+ import { readFileSync, existsSync } from 'node:fs';
24
+ import { extname } from 'node:path';
25
+ // ── tuning knobs (design §5) ────────────────────────────────────────────────
26
+ const TIER_WEIGHT = { human: 1.0, explicit: 0.8 };
27
+ const REALITY_TIER_WEIGHT = 0.8; // a real session_file_edit demonstrably happened
28
+ const SCOPE_WEIGHT_GLOB = 1.0; // path-glob hit — structural scope, trusted
29
+ const SCOPE_WEIGHT_FTS = 0.6; // topical (detect_terms via FTS) scope only
30
+ const EMIT_THRESHOLD_DEFAULT = 0.5;
31
+ const STALE_DAYS_DEFAULT = 14; // "not done yet ≠ drift" — suppress young anchors' absence
32
+ const SECONDS_PER_DAY = 86_400;
33
+ const MAX_SAMPLES = 25;
34
+ const SAMPLE_BUFFER = 500; // cap collection before the final sort+slice (bounds memory)
35
+ function parseArray(s) {
36
+ if (!s)
37
+ return [];
38
+ try {
39
+ const a = JSON.parse(s);
40
+ return Array.isArray(a) ? a.map((x) => String(x)) : [];
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ // Normalize a path or glob to the detector's canonical form so Windows reality
47
+ // (mixed `C:\Users\...` and `C:/Users/...`, mixed case) matches the lowercase forward-slash
48
+ // anchor fragments. SQLite GLOB is case-sensitive Unix-glob (no `**`, `*` crosses slashes)
49
+ // and unreliable on these paths, so ALL path matching happens here in JS instead.
50
+ function normPath(p) {
51
+ return p.replace(/\\/g, '/').toLowerCase();
52
+ }
53
+ // Compile an affects glob into a matcher over a normalized path. A glob with no wildcard is
54
+ // a plain substring (e.g. "sake_navi", "linksee-memory/src/mcp/server.ts"); `*` matches
55
+ // within a path segment, `**` crosses segments — tested unanchored so a fragment matches
56
+ // anywhere in the absolute path.
57
+ function compileGlob(glob) {
58
+ const g = normPath(glob.trim());
59
+ if (!g)
60
+ return () => false;
61
+ if (!/[*?]/.test(g)) {
62
+ return (path) => path.includes(g);
63
+ }
64
+ let re = '';
65
+ for (let i = 0; i < g.length; i++) {
66
+ const c = g[i];
67
+ if (c === '*') {
68
+ if (g[i + 1] === '*') {
69
+ re += '.*';
70
+ i++;
71
+ }
72
+ else {
73
+ re += '[^/]*';
74
+ }
75
+ }
76
+ else if (c === '?') {
77
+ re += '[^/]';
78
+ }
79
+ else {
80
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
81
+ }
82
+ }
83
+ const rx = new RegExp(re);
84
+ return (path) => rx.test(path);
85
+ }
86
+ // Build an FTS5 MATCH query from detect_terms: trigram needs >= 3 chars, each term quoted
87
+ // (handles spaces / punctuation / CJK), OR-joined. Returns '' when no term qualifies.
88
+ function ftsQuery(terms) {
89
+ return terms
90
+ .map((t) => t.trim())
91
+ .filter((t) => t.length >= 3)
92
+ .map((t) => '"' + t.replace(/"/g, '""') + '"')
93
+ .join(' OR ');
94
+ }
95
+ function tierWeight(tier) {
96
+ return TIER_WEIGHT[tier] ?? REALITY_TIER_WEIGHT;
97
+ }
98
+ export function detectDrift(db, opts = {}) {
99
+ const staleDays = opts.staleDays ?? STALE_DAYS_DEFAULT;
100
+ const emitThreshold = opts.emitThreshold ?? EMIT_THRESHOLD_DEFAULT;
101
+ const now = Math.floor(Date.now() / 1000);
102
+ const res = {
103
+ anchorsScanned: 0,
104
+ editsScanned: 0,
105
+ contradicts: 0,
106
+ absent: 0,
107
+ edgesEmitted: 0,
108
+ persisted: !opts.dryRun,
109
+ byAnchor: [],
110
+ samples: [],
111
+ };
112
+ const anchors = db
113
+ .prepare(`SELECT id, kind, statement, affects, detect_terms, violation_signal, tier, created_at
114
+ FROM drift_anchors WHERE status = 'active'`)
115
+ .all();
116
+ res.anchorsScanned = anchors.length;
117
+ if (anchors.length === 0)
118
+ return res;
119
+ // Reality, loaded once. LEFT JOIN the linked memory so violation_signal matching can see
120
+ // the "why this edit" text, not just path + snippet. (session_file_edits.memory_id →
121
+ // memories.id; memories_fts.rowid = memories.id.)
122
+ const edits = db
123
+ .prepare(`SELECT e.id, e.file_path, e.context_snippet, e.memory_id, m.content AS memory_content, e.occurred_at
124
+ FROM session_file_edits e
125
+ LEFT JOIN memories m ON m.id = e.memory_id`)
126
+ .all();
127
+ res.editsScanned = edits.length;
128
+ const editsNorm = edits.map((e) => {
129
+ const pathNorm = normPath(e.file_path);
130
+ return {
131
+ row: e,
132
+ pathNorm,
133
+ haystack: `${pathNorm}\n${e.context_snippet ?? ''}\n${e.memory_content ?? ''}`.toLowerCase(),
134
+ };
135
+ });
136
+ const ftsStmt = db.prepare(`SELECT rowid FROM memories_fts WHERE memories_fts MATCH ?`);
137
+ // Write paths prepared only when persisting — keeps dryRun safe on a readonly connection.
138
+ const upsertContradicts = opts.dryRun
139
+ ? null
140
+ : db.prepare(`
141
+ INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
142
+ VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
143
+ ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
144
+ confidence = excluded.confidence,
145
+ evidence = excluded.evidence
146
+ WHERE drift_edges.status = 'open'
147
+ `);
148
+ const insertAbsent = opts.dryRun
149
+ ? null
150
+ : db.prepare(`
151
+ INSERT OR IGNORE INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
152
+ VALUES (@anchor_id, NULL, 'absent', @confidence, @evidence, 'open')
153
+ `);
154
+ const apply = () => {
155
+ for (const a of anchors) {
156
+ const globs = parseArray(a.affects).map(compileGlob);
157
+ const signals = parseArray(a.violation_signal)
158
+ .map((s) => s.trim().toLowerCase())
159
+ .filter(Boolean);
160
+ const terms = parseArray(a.detect_terms);
161
+ const aW = tierWeight(a.tier);
162
+ const tally = {
163
+ anchor_id: a.id,
164
+ kind: a.kind,
165
+ statement: a.statement.slice(0, 80),
166
+ contradicts: 0,
167
+ absent: 0,
168
+ };
169
+ // Topical scope: memory rowids whose content matches the anchor's detect_terms.
170
+ let ftsRowids = null;
171
+ const q = ftsQuery(terms);
172
+ if (q) {
173
+ try {
174
+ ftsRowids = new Set(ftsStmt.all(q).map((r) => r.rowid));
175
+ }
176
+ catch {
177
+ ftsRowids = null; // malformed query → fall back to glob-only scope
178
+ }
179
+ }
180
+ let scopedCount = 0;
181
+ for (const e of editsNorm) {
182
+ const globHit = globs.length > 0 && globs.some((m) => m(e.pathNorm));
183
+ const ftsHit = !globHit && ftsRowids != null && e.row.memory_id != null && ftsRowids.has(e.row.memory_id);
184
+ if (!globHit && !ftsHit)
185
+ continue;
186
+ scopedCount++;
187
+ // Deduce a contradiction: a declared forbidden term literally present in scope text.
188
+ if (signals.length === 0)
189
+ continue; // e.g. a constraint with no signal — can't deduce contradicts
190
+ const hit = signals.find((s) => e.haystack.includes(s));
191
+ if (!hit)
192
+ continue;
193
+ const scopeWeight = globHit ? SCOPE_WEIGHT_GLOB : SCOPE_WEIGHT_FTS;
194
+ // confidence = min(tierWeight_anchor, tierWeight_reality) × scopeWeight × signalWeight.
195
+ // signalWeight = 1.0 (the forbidden term is literally present — binary presence).
196
+ const confidence = Math.min(aW, REALITY_TIER_WEIGHT) * scopeWeight;
197
+ if (confidence < emitThreshold)
198
+ continue; // FTS-only scope (0.48) never clears 0.5 — path scope required
199
+ const evidence = JSON.stringify({
200
+ file_path: e.row.file_path,
201
+ context_snippet: (e.row.context_snippet ?? '').slice(0, 280),
202
+ occurred_at: e.row.occurred_at,
203
+ hit_term: hit,
204
+ scope: globHit ? 'glob' : 'fts',
205
+ matched_terms: terms.slice(0, 8),
206
+ memory_id: e.row.memory_id,
207
+ });
208
+ if (!opts.dryRun) {
209
+ upsertContradicts.run({ anchor_id: a.id, edit_id: e.row.id, confidence, evidence });
210
+ }
211
+ res.contradicts++;
212
+ tally.contradicts++;
213
+ res.edgesEmitted++;
214
+ if (res.samples.length < SAMPLE_BUFFER) {
215
+ res.samples.push({
216
+ anchor_id: a.id,
217
+ kind: a.kind,
218
+ statement: tally.statement,
219
+ verdict: 'contradicts',
220
+ confidence,
221
+ file_path: e.row.file_path,
222
+ hit_term: hit,
223
+ scope: globHit ? 'glob' : 'fts',
224
+ occurred_at: e.row.occurred_at,
225
+ });
226
+ }
227
+ }
228
+ // Absence: a declared anchor with zero reality in scope, but only once it is old enough
229
+ // that "not built yet" can be ruled out (staleness gate). Confidence is for sort/display
230
+ // only — absence is not confidence-gated (see design §6/§7).
231
+ if (scopedCount === 0) {
232
+ const ageDays = (now - a.created_at) / SECONDS_PER_DAY;
233
+ if (ageDays >= staleDays) {
234
+ const ageFactor = Math.min(1, ageDays / (2 * staleDays));
235
+ const confidence = aW * ageFactor;
236
+ const evidence = JSON.stringify({
237
+ reason: 'no reality in declared scope',
238
+ age_days: Math.round(ageDays),
239
+ stale_days: staleDays,
240
+ });
241
+ if (!opts.dryRun) {
242
+ insertAbsent.run({ anchor_id: a.id, confidence, evidence });
243
+ }
244
+ res.absent++;
245
+ tally.absent++;
246
+ res.edgesEmitted++;
247
+ if (res.samples.length < SAMPLE_BUFFER) {
248
+ res.samples.push({
249
+ anchor_id: a.id,
250
+ kind: a.kind,
251
+ statement: tally.statement,
252
+ verdict: 'absent',
253
+ confidence,
254
+ file_path: null,
255
+ hit_term: null,
256
+ scope: null,
257
+ occurred_at: null,
258
+ });
259
+ }
260
+ }
261
+ }
262
+ res.byAnchor.push(tally);
263
+ }
264
+ };
265
+ if (opts.dryRun)
266
+ apply();
267
+ else
268
+ db.transaction(apply)();
269
+ // contradicts first (the headline verdict), then by descending confidence.
270
+ res.samples.sort((x, y) => {
271
+ if (x.verdict !== y.verdict)
272
+ return x.verdict === 'contradicts' ? -1 : 1;
273
+ return y.confidence - x.confidence;
274
+ });
275
+ res.samples = res.samples.slice(0, MAX_SAMPLES);
276
+ return res;
277
+ }
278
+ // ── v2: current-file scan ─────────────────────────────────────────────────────
279
+ // detectDrift (above) sees only what a captured edit's SNIPPET window contained — a
280
+ // violation whose line was never in a captured snippet is invisible to it (proven on the
281
+ // real corpus: `INSERT OR IGNORE INTO services` sat in current files yet returned 0). This
282
+ // pass closes the gap: for each anchor it reads the CURRENT contents of the files under its
283
+ // affects scope and flags any (non-comment) line containing a violation_signal — the
284
+ // strongest citation possible: the live file:line. Candidate files are grounded in
285
+ // session_file_edits (so we know where the affects files actually live on disk); code
286
+ // extensions only; most-recent first; capped per anchor. Purely additive — detectDrift is
287
+ // untouched. Comment lines are skipped so a doc-mention of a forbidden term isn't a hit.
288
+ const CODE_EXT = new Set([
289
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.sql', '.py', '.go', '.rs',
290
+ '.java', '.rb', '.php', '.sh', '.yml', '.yaml', '.toml', '.env', '.md', '.html',
291
+ ]);
292
+ const MAX_FILES_PER_ANCHOR = 400;
293
+ const COMMENT_PREFIXES = ['//', '*', '/*', '#', '<!--', '--'];
294
+ export function detectFileViolations(db, opts = {}) {
295
+ const emitThreshold = opts.emitThreshold ?? EMIT_THRESHOLD_DEFAULT;
296
+ const res = {
297
+ persisted: !opts.dryRun,
298
+ anchorsScanned: 0,
299
+ filesScanned: 0,
300
+ anchorsCapped: 0,
301
+ contradicts: 0,
302
+ edgesEmitted: 0,
303
+ byAnchor: [],
304
+ samples: [],
305
+ };
306
+ const anchors = db
307
+ .prepare(`SELECT id, kind, statement, affects, violation_signal, detect_terms, tier FROM drift_anchors WHERE status = 'active'`)
308
+ .all();
309
+ res.anchorsScanned = anchors.length;
310
+ if (anchors.length === 0)
311
+ return res;
312
+ // Distinct edited file paths + latest edit id per path. edit_id grounds the edge in a real
313
+ // row (FK + occurred_at); the CURRENT violating line lives in the evidence (current_file).
314
+ const pathRows = db
315
+ .prepare(`SELECT file_path, MAX(id) AS latest_edit_id FROM session_file_edits GROUP BY file_path`)
316
+ .all();
317
+ const upsert = opts.dryRun
318
+ ? null
319
+ : db.prepare(`
320
+ INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
321
+ VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
322
+ ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
323
+ confidence = excluded.confidence,
324
+ evidence = excluded.evidence
325
+ WHERE drift_edges.status = 'open'
326
+ `);
327
+ const apply = () => {
328
+ for (const a of anchors) {
329
+ const globs = parseArray(a.affects).map(compileGlob);
330
+ if (globs.length === 0)
331
+ continue;
332
+ const signals = parseArray(a.violation_signal).map((s) => s.trim().toLowerCase()).filter(Boolean);
333
+ if (signals.length === 0)
334
+ continue; // no signal → can't deduce a contradiction
335
+ const terms = parseArray(a.detect_terms);
336
+ const aW = tierWeight(a.tier);
337
+ const confidence = Math.min(aW, REALITY_TIER_WEIGHT) * SCOPE_WEIGHT_GLOB;
338
+ if (confidence < emitThreshold)
339
+ continue;
340
+ let candidates = pathRows.filter((r) => {
341
+ const p = normPath(r.file_path);
342
+ return CODE_EXT.has(extname(p)) && globs.some((g) => g(p));
343
+ });
344
+ candidates.sort((x, y) => y.latest_edit_id - x.latest_edit_id);
345
+ if (candidates.length > MAX_FILES_PER_ANCHOR) {
346
+ res.anchorsCapped++;
347
+ candidates = candidates.slice(0, MAX_FILES_PER_ANCHOR);
348
+ }
349
+ const tally = { anchor_id: a.id, statement: a.statement.slice(0, 80), contradicts: 0 };
350
+ for (const c of candidates) {
351
+ if (!existsSync(c.file_path))
352
+ continue; // deleted since the edit
353
+ let content;
354
+ try {
355
+ content = readFileSync(c.file_path, 'utf8');
356
+ }
357
+ catch {
358
+ continue;
359
+ }
360
+ res.filesScanned++;
361
+ const lines = content.split(/\r?\n/);
362
+ let found = null;
363
+ for (let i = 0; i < lines.length; i++) {
364
+ const trimmed = lines[i].trim();
365
+ if (!trimmed)
366
+ continue;
367
+ if (COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
368
+ continue; // skip comments
369
+ const ll = trimmed.toLowerCase();
370
+ const hit = signals.find((s) => ll.includes(s));
371
+ if (hit) {
372
+ found = { line_no: i + 1, line_text: trimmed.slice(0, 280), hit };
373
+ break; // one edge per (anchor, file): first real hit
374
+ }
375
+ }
376
+ if (!found)
377
+ continue;
378
+ const evidence = JSON.stringify({
379
+ file_path: c.file_path,
380
+ line_no: found.line_no,
381
+ context_snippet: found.line_text,
382
+ current_file: true,
383
+ hit_term: found.hit,
384
+ scope: 'file',
385
+ matched_terms: terms.slice(0, 8),
386
+ });
387
+ if (!opts.dryRun) {
388
+ upsert.run({ anchor_id: a.id, edit_id: c.latest_edit_id, confidence, evidence });
389
+ }
390
+ res.contradicts++;
391
+ tally.contradicts++;
392
+ res.edgesEmitted++;
393
+ if (res.samples.length < 60) {
394
+ res.samples.push({
395
+ anchor_id: a.id,
396
+ kind: a.kind,
397
+ statement: tally.statement,
398
+ file_path: c.file_path,
399
+ line_no: found.line_no,
400
+ line_text: found.line_text,
401
+ hit_term: found.hit,
402
+ confidence,
403
+ });
404
+ }
405
+ }
406
+ if (tally.contradicts > 0)
407
+ res.byAnchor.push(tally);
408
+ }
409
+ };
410
+ if (opts.dryRun)
411
+ apply();
412
+ else
413
+ db.transaction(apply)();
414
+ return res;
415
+ }
416
+ //# sourceMappingURL=drift-detection.js.map
@@ -0,0 +1,62 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { AnchorKind } from './drift-anchors.js';
3
+ export type DriftEdgeStatus = 'open' | 'ack' | 'dismissed' | 'resolved';
4
+ export interface DriftHeadlineCard {
5
+ edge_id: number;
6
+ anchor_id: number;
7
+ kind: AnchorKind;
8
+ statement: string;
9
+ rationale: string | null;
10
+ file_path: string;
11
+ context_snippet: string | null;
12
+ occurred_at: number;
13
+ confidence: number;
14
+ hit_term: string | null;
15
+ evidence: Record<string, unknown>;
16
+ }
17
+ export interface DriftAbsenceCard {
18
+ edge_id: number;
19
+ anchor_id: number;
20
+ kind: AnchorKind;
21
+ statement: string;
22
+ rationale: string | null;
23
+ confidence: number;
24
+ age_days: number | null;
25
+ detected_at: number;
26
+ }
27
+ export interface DriftViewCounts {
28
+ contradicts_open: number;
29
+ absent_open: number;
30
+ dismissed: number;
31
+ }
32
+ export interface DriftView {
33
+ headline: DriftHeadlineCard[];
34
+ absences: DriftAbsenceCard[];
35
+ counts: DriftViewCounts;
36
+ }
37
+ /**
38
+ * §7① Headline: divergence (decided-but-violated). Confidence, then entrenchment
39
+ * (human > explicit), then recency. Only OPEN edges on ACTIVE anchors — a dismissed card
40
+ * stays gone, a retired anchor stops surfacing.
41
+ */
42
+ export declare function getDriftHeadline(db: Database.Database, opts?: {
43
+ limit?: number;
44
+ }): DriftHeadlineCard[];
45
+ /**
46
+ * §7② Secondary: absence (decided-but-no-reality), staleness-gated at write time.
47
+ * Oldest unfulfilled decision first.
48
+ */
49
+ export declare function getDriftAbsences(db: Database.Database, opts?: {
50
+ limit?: number;
51
+ }): DriftAbsenceCard[];
52
+ /** The whole Drift View in one call — headline + absences + the badge counts step 5 needs. */
53
+ export declare function getDriftView(db: Database.Database, opts?: {
54
+ headlineLimit?: number;
55
+ absenceLimit?: number;
56
+ }): DriftView;
57
+ /**
58
+ * The feedback action behind a Drift View card. `dismissed` is the precision signal — a user
59
+ * marking a false positive teaches us to tune. (The detector's contradicts upsert never
60
+ * resurrects a non-open row, so a dismissal sticks across re-runs.) Returns true if it changed.
61
+ */
62
+ export declare function setDriftEdgeStatus(db: Database.Database, edgeId: number, status: DriftEdgeStatus): boolean;
@@ -0,0 +1,120 @@
1
+ // Drift view (v8) — the READ side of drift observability (build order step 4).
2
+ //
3
+ // Pairs with drift-detection.ts (which WRITES drift_edges). This module turns those edges
4
+ // into Drift View cards and provides the human feedback action. It is the §7 verdict layer,
5
+ // consumed by step 5 (the top-3 contradiction cards in the dashboard) and by any CLI/MCP
6
+ // surface later. Read-only except setDriftEdgeStatus (the dismiss/ack loop).
7
+ //
8
+ // A card = [anchor statement + WHY] / [reality file_path:snippet + occurred_at] /
9
+ // [verdict + confidence] / dismiss → status='dismissed' (the precision feedback loop).
10
+ const EDGE_STATUSES = new Set(['open', 'ack', 'dismissed', 'resolved']);
11
+ function parseEvidence(s) {
12
+ if (!s)
13
+ return {};
14
+ try {
15
+ const o = JSON.parse(s);
16
+ return o && typeof o === 'object' ? o : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ /**
23
+ * §7① Headline: divergence (decided-but-violated). Confidence, then entrenchment
24
+ * (human > explicit), then recency. Only OPEN edges on ACTIVE anchors — a dismissed card
25
+ * stays gone, a retired anchor stops surfacing.
26
+ */
27
+ export function getDriftHeadline(db, opts = {}) {
28
+ const limit = opts.limit ?? 3;
29
+ const rows = db
30
+ .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
31
+ e.file_path, e.context_snippet, e.occurred_at,
32
+ d.confidence, d.evidence
33
+ FROM drift_edges d
34
+ JOIN drift_anchors a ON a.id = d.anchor_id
35
+ JOIN session_file_edits e ON e.id = d.edit_id
36
+ WHERE d.verdict = 'contradicts' AND d.status = 'open' AND a.status = 'active'
37
+ ORDER BY d.confidence DESC, (a.tier = 'human') DESC, e.occurred_at DESC
38
+ LIMIT ?`)
39
+ .all(limit);
40
+ return rows.map((r) => {
41
+ const evidence = parseEvidence(r.evidence);
42
+ return {
43
+ edge_id: r.edge_id,
44
+ anchor_id: r.anchor_id,
45
+ kind: r.kind,
46
+ statement: r.statement,
47
+ rationale: r.rationale ?? null,
48
+ file_path: r.file_path,
49
+ context_snippet: r.context_snippet ?? null,
50
+ occurred_at: r.occurred_at,
51
+ confidence: r.confidence,
52
+ hit_term: typeof evidence.hit_term === 'string' ? evidence.hit_term : null,
53
+ evidence,
54
+ };
55
+ });
56
+ }
57
+ /**
58
+ * §7② Secondary: absence (decided-but-no-reality), staleness-gated at write time.
59
+ * Oldest unfulfilled decision first.
60
+ */
61
+ export function getDriftAbsences(db, opts = {}) {
62
+ const limit = opts.limit ?? 10;
63
+ const rows = db
64
+ .prepare(`SELECT d.id AS edge_id, d.anchor_id, a.kind, a.statement, a.rationale,
65
+ d.confidence, d.evidence, d.detected_at
66
+ FROM drift_edges d
67
+ JOIN drift_anchors a ON a.id = d.anchor_id
68
+ WHERE d.verdict = 'absent' AND d.status = 'open' AND a.status = 'active'
69
+ ORDER BY a.created_at ASC
70
+ LIMIT ?`)
71
+ .all(limit);
72
+ return rows.map((r) => {
73
+ const evidence = parseEvidence(r.evidence);
74
+ return {
75
+ edge_id: r.edge_id,
76
+ anchor_id: r.anchor_id,
77
+ kind: r.kind,
78
+ statement: r.statement,
79
+ rationale: r.rationale ?? null,
80
+ confidence: r.confidence,
81
+ age_days: typeof evidence.age_days === 'number' ? evidence.age_days : null,
82
+ detected_at: r.detected_at,
83
+ };
84
+ });
85
+ }
86
+ function countOpen(db, verdict) {
87
+ return db
88
+ .prepare(`SELECT COUNT(*) AS n
89
+ FROM drift_edges d JOIN drift_anchors a ON a.id = d.anchor_id
90
+ WHERE d.verdict = ? AND d.status = 'open' AND a.status = 'active'`)
91
+ .get(verdict).n;
92
+ }
93
+ /** The whole Drift View in one call — headline + absences + the badge counts step 5 needs. */
94
+ export function getDriftView(db, opts = {}) {
95
+ const dismissed = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE status = 'dismissed'`).get().n;
96
+ return {
97
+ headline: getDriftHeadline(db, { limit: opts.headlineLimit }),
98
+ absences: getDriftAbsences(db, { limit: opts.absenceLimit }),
99
+ counts: {
100
+ contradicts_open: countOpen(db, 'contradicts'),
101
+ absent_open: countOpen(db, 'absent'),
102
+ dismissed,
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * The feedback action behind a Drift View card. `dismissed` is the precision signal — a user
108
+ * marking a false positive teaches us to tune. (The detector's contradicts upsert never
109
+ * resurrects a non-open row, so a dismissal sticks across re-runs.) Returns true if it changed.
110
+ */
111
+ export function setDriftEdgeStatus(db, edgeId, status) {
112
+ if (!EDGE_STATUSES.has(status)) {
113
+ throw new Error(`invalid status "${status}" — one of: open, ack, dismissed, resolved`);
114
+ }
115
+ const r = db
116
+ .prepare(`UPDATE drift_edges SET status = ? WHERE id = ? AND status <> ?`)
117
+ .run(status, edgeId, status);
118
+ return r.changes > 0;
119
+ }
120
+ //# sourceMappingURL=drift-view.js.map
@@ -0,0 +1,84 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type DriftState = 'drift' | 'review' | 'held' | 'aligned';
3
+ export type Species = 'hypothesis' | 'constraint' | 'commitment' | 'source_of_truth';
4
+ export interface TruthNode {
5
+ id: number;
6
+ node_type: string | null;
7
+ domain: string | null;
8
+ decision_mode: string | null;
9
+ species: Species;
10
+ statement: string;
11
+ rationale: string | null;
12
+ confidence: number;
13
+ lifecycle: string;
14
+ cadence_days: number | null;
15
+ review_after: number | null;
16
+ state: DriftState;
17
+ accounted: boolean;
18
+ accountedBy: string | null;
19
+ reality: string | null;
20
+ reviewDate: string | null;
21
+ overdue: boolean;
22
+ }
23
+ export interface TruthCandidate {
24
+ id: number;
25
+ candidate_type: string;
26
+ target_node_id: number | null;
27
+ target_statement: string | null;
28
+ rationale: string;
29
+ confidence: number;
30
+ status: string;
31
+ }
32
+ export interface TruthCounts {
33
+ nodes: number;
34
+ by_mode: Record<string, number>;
35
+ by_species: Record<Species, number>;
36
+ by_state: Record<DriftState, number>;
37
+ auto: number;
38
+ suppressed: number;
39
+ }
40
+ export interface TruthView {
41
+ attention: TruthNode[];
42
+ alignedByDomain: Array<{
43
+ domain: string;
44
+ nodes: TruthNode[];
45
+ }>;
46
+ candidates: {
47
+ auto: TruthCandidate[];
48
+ suppressed: TruthCandidate[];
49
+ };
50
+ counts: TruthCounts;
51
+ nextReopen: string | null;
52
+ }
53
+ export interface DecisionDetail extends TruthNode {
54
+ kind: string | null;
55
+ affects: string[];
56
+ detect_terms: string[];
57
+ violation_signal: string[];
58
+ tier: string;
59
+ pendingCandidates: TruthCandidate[];
60
+ driftEdges: Array<{
61
+ edge_id: number;
62
+ verdict: string;
63
+ confidence: number;
64
+ status: string;
65
+ detected_at: number;
66
+ }>;
67
+ }
68
+ export declare function getTruthView(db: Database.Database, opts?: {
69
+ domain?: string;
70
+ decision_mode?: string;
71
+ }): TruthView;
72
+ export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
73
+ export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
74
+ export interface ResolveInput {
75
+ anchor_id: number;
76
+ action: ResolutionAction;
77
+ rationale?: string;
78
+ review_after?: string;
79
+ superseded_by?: number;
80
+ }
81
+ export declare function resolveDrift(db: Database.Database, input: ResolveInput): {
82
+ ok: boolean;
83
+ resolution: any;
84
+ };