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,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
|
+
};
|