linksee-memory 0.12.0 → 0.13.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 +1014 -1004
- package/dist/bin/setup.js +60 -47
- package/dist/db/migrate.js +28 -28
- package/dist/db/schema.sql +461 -461
- package/dist/lib/drift-anchors.js +3 -3
- package/dist/lib/drift-detection.js +20 -20
- package/dist/lib/drift-view.js +16 -16
- package/dist/lib/guard-wiring.d.ts +54 -0
- package/dist/lib/guard-wiring.js +85 -0
- package/dist/lib/guard.js +38 -31
- package/dist/lib/map-import.js +17 -17
- package/dist/lib/truth-engine.js +13 -13
- package/dist/mcp/prompts.js +58 -58
- package/dist/mcp/resources.js +7 -7
- package/dist/mcp/sampling.js +20 -20
- package/dist/mcp/server.js +130 -130
- package/package.json +96 -95
|
@@ -74,9 +74,9 @@ export function declareAnchor(db, input) {
|
|
|
74
74
|
throw new Error(`${input.kind} anchors need at least one violation_signal term (the forbidden act / rejected alternative) — otherwise the detector can never deduce a contradiction`);
|
|
75
75
|
}
|
|
76
76
|
const info = db
|
|
77
|
-
.prepare(`INSERT INTO drift_anchors
|
|
78
|
-
(kind, statement, rationale, affects, detect_terms, violation_signal, tier, source, source_memory_id)
|
|
79
|
-
VALUES
|
|
77
|
+
.prepare(`INSERT INTO drift_anchors
|
|
78
|
+
(kind, statement, rationale, affects, detect_terms, violation_signal, tier, source, source_memory_id)
|
|
79
|
+
VALUES
|
|
80
80
|
(@kind, @statement, @rationale, @affects, @detect_terms, @violation_signal, @tier, @source, @source_memory_id)`)
|
|
81
81
|
.run({
|
|
82
82
|
kind: input.kind,
|
|
@@ -110,7 +110,7 @@ export function detectDrift(db, opts = {}) {
|
|
|
110
110
|
samples: [],
|
|
111
111
|
};
|
|
112
112
|
const anchors = db
|
|
113
|
-
.prepare(`SELECT id, kind, statement, affects, detect_terms, violation_signal, tier, created_at
|
|
113
|
+
.prepare(`SELECT id, kind, statement, affects, detect_terms, violation_signal, tier, created_at
|
|
114
114
|
FROM drift_anchors WHERE status = 'active'`)
|
|
115
115
|
.all();
|
|
116
116
|
res.anchorsScanned = anchors.length;
|
|
@@ -120,8 +120,8 @@ export function detectDrift(db, opts = {}) {
|
|
|
120
120
|
// the "why this edit" text, not just path + snippet. (session_file_edits.memory_id →
|
|
121
121
|
// memories.id; memories_fts.rowid = memories.id.)
|
|
122
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
|
|
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
125
|
LEFT JOIN memories m ON m.id = e.memory_id`)
|
|
126
126
|
.all();
|
|
127
127
|
res.editsScanned = edits.length;
|
|
@@ -137,19 +137,19 @@ export function detectDrift(db, opts = {}) {
|
|
|
137
137
|
// Write paths prepared only when persisting — keeps dryRun safe on a readonly connection.
|
|
138
138
|
const upsertContradicts = opts.dryRun
|
|
139
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'
|
|
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
147
|
`);
|
|
148
148
|
const insertAbsent = opts.dryRun
|
|
149
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')
|
|
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
153
|
`);
|
|
154
154
|
const apply = () => {
|
|
155
155
|
for (const a of anchors) {
|
|
@@ -351,13 +351,13 @@ export function detectFileViolations(db, opts = {}) {
|
|
|
351
351
|
.all();
|
|
352
352
|
const upsert = opts.dryRun
|
|
353
353
|
? null
|
|
354
|
-
: db.prepare(`
|
|
355
|
-
INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
|
|
356
|
-
VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
|
|
357
|
-
ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
|
|
358
|
-
confidence = excluded.confidence,
|
|
359
|
-
evidence = excluded.evidence
|
|
360
|
-
WHERE drift_edges.status = 'open'
|
|
354
|
+
: db.prepare(`
|
|
355
|
+
INSERT INTO drift_edges (anchor_id, edit_id, verdict, confidence, evidence, status)
|
|
356
|
+
VALUES (@anchor_id, @edit_id, 'contradicts', @confidence, @evidence, 'open')
|
|
357
|
+
ON CONFLICT(anchor_id, edit_id, verdict) DO UPDATE SET
|
|
358
|
+
confidence = excluded.confidence,
|
|
359
|
+
evidence = excluded.evidence
|
|
360
|
+
WHERE drift_edges.status = 'open'
|
|
361
361
|
`);
|
|
362
362
|
const apply = () => {
|
|
363
363
|
for (const a of anchors) {
|
package/dist/lib/drift-view.js
CHANGED
|
@@ -27,14 +27,14 @@ function parseEvidence(s) {
|
|
|
27
27
|
export function getDriftHeadline(db, opts = {}) {
|
|
28
28
|
const limit = opts.limit ?? 3;
|
|
29
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
|
|
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
38
|
LIMIT ?`)
|
|
39
39
|
.all(limit);
|
|
40
40
|
return rows.map((r) => {
|
|
@@ -61,12 +61,12 @@ export function getDriftHeadline(db, opts = {}) {
|
|
|
61
61
|
export function getDriftAbsences(db, opts = {}) {
|
|
62
62
|
const limit = opts.limit ?? 10;
|
|
63
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
|
|
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
70
|
LIMIT ?`)
|
|
71
71
|
.all(limit);
|
|
72
72
|
return rows.map((r) => {
|
|
@@ -85,8 +85,8 @@ export function getDriftAbsences(db, opts = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
function countOpen(db, verdict) {
|
|
87
87
|
return db
|
|
88
|
-
.prepare(`SELECT COUNT(*) AS n
|
|
89
|
-
FROM drift_edges d JOIN drift_anchors a ON a.id = d.anchor_id
|
|
88
|
+
.prepare(`SELECT COUNT(*) AS n
|
|
89
|
+
FROM drift_edges d JOIN drift_anchors a ON a.id = d.anchor_id
|
|
90
90
|
WHERE d.verdict = ? AND d.status = 'open' AND a.status = 'active'`)
|
|
91
91
|
.get(verdict).n;
|
|
92
92
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type HookCommand = {
|
|
2
|
+
type: string;
|
|
3
|
+
command?: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
args?: string[];
|
|
6
|
+
};
|
|
7
|
+
export type HookEntry = {
|
|
8
|
+
matcher?: string;
|
|
9
|
+
hooks?: HookCommand[];
|
|
10
|
+
};
|
|
11
|
+
export type ClaudeSettings = {
|
|
12
|
+
hooks?: Record<string, HookEntry[]>;
|
|
13
|
+
[k: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
export declare const GUARD_EVENTS: readonly ["SessionStart", "PreToolUse"];
|
|
16
|
+
export type GuardEvent = (typeof GUARD_EVENTS)[number];
|
|
17
|
+
/** The bin every wiring form resolves to; used as the idempotency key. */
|
|
18
|
+
export declare const GUARD_BIN = "linksee-memory-guard";
|
|
19
|
+
export declare const GUARD_COMMAND = "npx -y linksee-memory guard";
|
|
20
|
+
export declare const GUARD_HOOKS: Record<GuardEvent, HookEntry>;
|
|
21
|
+
/**
|
|
22
|
+
* Is one of OUR hooks of `kind` already wired for this event?
|
|
23
|
+
*
|
|
24
|
+
* Shared by the guard and the session-sync hook because they hit the same trap: each has been
|
|
25
|
+
* wired as an npx subcommand, as a global bin, as a dist path, and in exec form with the path
|
|
26
|
+
* in `args`. A probe that knows only one shape appends a duplicate — which is exactly what
|
|
27
|
+
* happened to the Stop hook on 2026-09-07 (`sync-session.js` did not match `linksee-memory-sync`,
|
|
28
|
+
* so setup added a second one and sessions were captured twice).
|
|
29
|
+
*/
|
|
30
|
+
export declare function linkseeHookWired(settings: ClaudeSettings, event: string, kind: 'guard' | 'sync'): boolean;
|
|
31
|
+
/** Is the session-sync (Stop) hook already wired? */
|
|
32
|
+
export declare function syncWiredFor(settings: ClaudeSettings, event?: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Is OUR guard already wired for this event?
|
|
35
|
+
*
|
|
36
|
+
* Has to recognise every shape the guard has ever been wired in, or setup duplicates it:
|
|
37
|
+
* npx -y linksee-memory guard (what setup writes)
|
|
38
|
+
* linksee-memory-guard (the global bin)
|
|
39
|
+
* node /path/to/linksee-memory/dist/bin/guard-hook.js (the old README block)
|
|
40
|
+
* { command: 'node', args: ['.../dist/bin/guard-hook.js'] } (exec form — the path is in args)
|
|
41
|
+
*
|
|
42
|
+
* The last two put the identifying part in different places, so match against command and args
|
|
43
|
+
* joined together. `guard-hook` alone is accepted because the exec form carries no package name.
|
|
44
|
+
*/
|
|
45
|
+
export declare function guardWiredFor(settings: ClaudeSettings, event: string): boolean;
|
|
46
|
+
export declare function guardFullyWired(settings: ClaudeSettings): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
|
|
49
|
+
* events that were actually added (empty when it was already wired).
|
|
50
|
+
*/
|
|
51
|
+
export declare function wireGuard(settings: ClaudeSettings): {
|
|
52
|
+
settings: ClaudeSettings;
|
|
53
|
+
added: GuardEvent[];
|
|
54
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// guard-wiring — merge the re-injection guard's hooks into a Claude Code settings object.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from bin/setup.ts so the merge is testable without running the installer (which
|
|
4
|
+
// also registers an MCP server and copies a skill). The rules that matter here:
|
|
5
|
+
//
|
|
6
|
+
// • merge, never replace — other people's hooks in the same event must survive
|
|
7
|
+
// • idempotent — running setup twice must not produce two guard entries
|
|
8
|
+
// • recognise a hand-pasted guard from the README as already-wired (match on the bin name,
|
|
9
|
+
// not on the exact command string, which differs between `npx` and a dist path)
|
|
10
|
+
export const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
|
|
11
|
+
/** The bin every wiring form resolves to; used as the idempotency key. */
|
|
12
|
+
export const GUARD_BIN = 'linksee-memory-guard';
|
|
13
|
+
export const GUARD_COMMAND = 'npx -y linksee-memory guard';
|
|
14
|
+
export const GUARD_HOOKS = {
|
|
15
|
+
SessionStart: {
|
|
16
|
+
matcher: 'startup|resume|compact',
|
|
17
|
+
hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 15 }],
|
|
18
|
+
},
|
|
19
|
+
PreToolUse: {
|
|
20
|
+
matcher: 'Edit|Write|Bash',
|
|
21
|
+
hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 8 }],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
/** Everything a hook entry could carry an identifier in: `command`, or `args` for the exec form. */
|
|
25
|
+
function hookHaystack(h) {
|
|
26
|
+
return [h?.command, ...(h?.args ?? [])].filter((x) => typeof x === 'string').join(' ');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Is one of OUR hooks of `kind` already wired for this event?
|
|
30
|
+
*
|
|
31
|
+
* Shared by the guard and the session-sync hook because they hit the same trap: each has been
|
|
32
|
+
* wired as an npx subcommand, as a global bin, as a dist path, and in exec form with the path
|
|
33
|
+
* in `args`. A probe that knows only one shape appends a duplicate — which is exactly what
|
|
34
|
+
* happened to the Stop hook on 2026-09-07 (`sync-session.js` did not match `linksee-memory-sync`,
|
|
35
|
+
* so setup added a second one and sessions were captured twice).
|
|
36
|
+
*/
|
|
37
|
+
export function linkseeHookWired(settings, event, kind) {
|
|
38
|
+
const bare = kind === 'guard' ? 'guard-hook' : 'sync-session';
|
|
39
|
+
return (settings.hooks?.[event] ?? []).some((entry) => entry?.hooks?.some((h) => {
|
|
40
|
+
const hay = hookHaystack(h);
|
|
41
|
+
if (!hay)
|
|
42
|
+
return false;
|
|
43
|
+
return hay.includes(bare) || (hay.includes('linksee-memory') && hay.includes(kind));
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
/** Is the session-sync (Stop) hook already wired? */
|
|
47
|
+
export function syncWiredFor(settings, event = 'Stop') {
|
|
48
|
+
return linkseeHookWired(settings, event, 'sync');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Is OUR guard already wired for this event?
|
|
52
|
+
*
|
|
53
|
+
* Has to recognise every shape the guard has ever been wired in, or setup duplicates it:
|
|
54
|
+
* npx -y linksee-memory guard (what setup writes)
|
|
55
|
+
* linksee-memory-guard (the global bin)
|
|
56
|
+
* node /path/to/linksee-memory/dist/bin/guard-hook.js (the old README block)
|
|
57
|
+
* { command: 'node', args: ['.../dist/bin/guard-hook.js'] } (exec form — the path is in args)
|
|
58
|
+
*
|
|
59
|
+
* The last two put the identifying part in different places, so match against command and args
|
|
60
|
+
* joined together. `guard-hook` alone is accepted because the exec form carries no package name.
|
|
61
|
+
*/
|
|
62
|
+
export function guardWiredFor(settings, event) {
|
|
63
|
+
return linkseeHookWired(settings, event, 'guard');
|
|
64
|
+
}
|
|
65
|
+
export function guardFullyWired(settings) {
|
|
66
|
+
return GUARD_EVENTS.every((ev) => guardWiredFor(settings, ev));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
|
|
70
|
+
* events that were actually added (empty when it was already wired).
|
|
71
|
+
*/
|
|
72
|
+
export function wireGuard(settings) {
|
|
73
|
+
const hooks = (settings.hooks ??= {});
|
|
74
|
+
const added = [];
|
|
75
|
+
for (const ev of GUARD_EVENTS) {
|
|
76
|
+
if (!Array.isArray(hooks[ev]))
|
|
77
|
+
hooks[ev] = [];
|
|
78
|
+
if (!guardWiredFor(settings, ev)) {
|
|
79
|
+
hooks[ev].push(GUARD_HOOKS[ev]);
|
|
80
|
+
added.push(ev);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { settings, added };
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=guard-wiring.js.map
|
package/dist/lib/guard.js
CHANGED
|
@@ -16,8 +16,8 @@ import { supersededAnchorIds } from './truth-engine.js';
|
|
|
16
16
|
// "accepted = the only thing the gate compares against": declared (status active), still live
|
|
17
17
|
// (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
|
|
18
18
|
// at_risk(stale) anchors deliberately DON'T gate — we don't enforce a rule we're no longer sure of.
|
|
19
|
-
const ACCEPTED_SQL = `status = 'active'
|
|
20
|
-
AND lifecycle IN ('active', 'experiment')
|
|
19
|
+
const ACCEPTED_SQL = `status = 'active'
|
|
20
|
+
AND lifecycle IN ('active', 'experiment')
|
|
21
21
|
AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
|
|
22
22
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
23
23
|
function jsonGet(json, key, def) {
|
|
@@ -39,7 +39,7 @@ function bestEffort(fn) {
|
|
|
39
39
|
}
|
|
40
40
|
export function acceptedAnchors(db) {
|
|
41
41
|
const rows = db
|
|
42
|
-
.prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
|
|
42
|
+
.prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
|
|
43
43
|
FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
|
|
44
44
|
.all();
|
|
45
45
|
// A superseded anchor must stop gating. The block/warn text tells the user to run
|
|
@@ -97,9 +97,16 @@ export function matchAction(db, act) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
// Scope
|
|
101
|
-
//
|
|
102
|
-
|
|
100
|
+
// Scope. `affects` says WHERE a decision applies; `violation_signal` says WHAT is forbidden.
|
|
101
|
+
// An explicit signal hit is the stronger evidence, so it brings the anchor into scope on its
|
|
102
|
+
// own — otherwise a path-scoped anchor is blind to `Bash`, which carries no file path at all.
|
|
103
|
+
// That blindness was measured on a real machine: 21 of 42 active anchors declared forbidden
|
|
104
|
+
// strings and could never fire on a Bash command — including "ALTER TABLE memories DROP" on
|
|
105
|
+
// the anchor that exists to prevent exactly that. Bash is where the destructive things run.
|
|
106
|
+
//
|
|
107
|
+
// (matchViolation already guards the obvious false positives: word boundaries, a negation
|
|
108
|
+
// window, and citation-without-call — a naive substring test produced ~90% noise.)
|
|
109
|
+
const inScope = sigHit != null || (hasScope ? pathHit : termHit);
|
|
103
110
|
if (!inScope)
|
|
104
111
|
continue;
|
|
105
112
|
out.push({
|
|
@@ -130,7 +137,7 @@ function withinCooldown(db, anchorId, sessionId) {
|
|
|
130
137
|
return !!row;
|
|
131
138
|
}
|
|
132
139
|
function logInjection(db, matches, act, surface, sessionId) {
|
|
133
|
-
const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
|
|
140
|
+
const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
|
|
134
141
|
VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
|
|
135
142
|
const snip = (act.lines[0] ?? '').slice(0, 120);
|
|
136
143
|
const tx = db.transaction(() => {
|
|
@@ -185,16 +192,16 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
185
192
|
// Same rule as the gate: never re-surface a decision the user has already superseded.
|
|
186
193
|
const retiredIds = supersededAnchorIds(db);
|
|
187
194
|
const anchorRows = db
|
|
188
|
-
.prepare(`SELECT id, statement, rationale FROM drift_anchors
|
|
189
|
-
WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
|
|
195
|
+
.prepare(`SELECT id, statement, rationale FROM drift_anchors
|
|
196
|
+
WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
|
|
190
197
|
ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
|
|
191
198
|
.all(maxAnchors + retiredIds.size);
|
|
192
199
|
const anchors = anchorRows.filter((a) => !retiredIds.has(a.id)).slice(0, maxAnchors);
|
|
193
200
|
const forks = db
|
|
194
|
-
.prepare(`SELECT c.id, c.rationale, a.statement
|
|
195
|
-
FROM memory_write_candidates c
|
|
196
|
-
LEFT JOIN drift_anchors a ON a.id = c.target_node_id
|
|
197
|
-
WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
|
|
201
|
+
.prepare(`SELECT c.id, c.rationale, a.statement
|
|
202
|
+
FROM memory_write_candidates c
|
|
203
|
+
LEFT JOIN drift_anchors a ON a.id = c.target_node_id
|
|
204
|
+
WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
|
|
198
205
|
ORDER BY c.created_at DESC LIMIT ?`)
|
|
199
206
|
.all(maxForks);
|
|
200
207
|
// Distillation pressure — the routine's structural trigger. The drain must not depend on
|
|
@@ -202,10 +209,10 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
202
209
|
// EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
|
|
203
210
|
let distill = 0;
|
|
204
211
|
try {
|
|
205
|
-
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
206
|
-
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
207
|
-
AND (json_extract(content, '$.needs_distill') = 1
|
|
208
|
-
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
212
|
+
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
213
|
+
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
214
|
+
AND (json_extract(content, '$.needs_distill') = 1
|
|
215
|
+
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
209
216
|
OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
|
|
210
217
|
}
|
|
211
218
|
catch {
|
|
@@ -238,11 +245,11 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
238
245
|
export function backfillHeeded(db) {
|
|
239
246
|
bestEffort(() => {
|
|
240
247
|
const live = `SELECT anchor_id FROM drift_edges WHERE verdict = 'contradicts' AND status = 'open'`;
|
|
241
|
-
db.prepare(`UPDATE injection_log SET heeded = 0
|
|
242
|
-
WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
|
|
248
|
+
db.prepare(`UPDATE injection_log SET heeded = 0
|
|
249
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
|
|
243
250
|
AND anchor_id IN (${live})`).run();
|
|
244
|
-
db.prepare(`UPDATE injection_log SET heeded = 1
|
|
245
|
-
WHERE heeded IS NULL AND surface IN ('warn', 'inform')
|
|
251
|
+
db.prepare(`UPDATE injection_log SET heeded = 1
|
|
252
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform')
|
|
246
253
|
AND anchor_id NOT IN (${live})`).run();
|
|
247
254
|
db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
|
|
248
255
|
});
|
|
@@ -254,16 +261,16 @@ export function getReinjectionFriction(db, opts = {}) {
|
|
|
254
261
|
const minC = opts.minContradicts ?? 3;
|
|
255
262
|
backfillHeeded(db);
|
|
256
263
|
const rows = db
|
|
257
|
-
.prepare(`SELECT i.anchor_id,
|
|
258
|
-
SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
|
|
259
|
-
SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
|
|
260
|
-
SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
|
|
261
|
-
datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
|
|
262
|
-
a.statement, a.lifecycle, a.card_policy
|
|
263
|
-
FROM injection_log i
|
|
264
|
-
JOIN drift_anchors a ON a.id = i.anchor_id
|
|
265
|
-
WHERE a.status = 'active'
|
|
266
|
-
GROUP BY i.anchor_id
|
|
264
|
+
.prepare(`SELECT i.anchor_id,
|
|
265
|
+
SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
|
|
266
|
+
SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
|
|
267
|
+
SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
|
|
268
|
+
datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
|
|
269
|
+
a.statement, a.lifecycle, a.card_policy
|
|
270
|
+
FROM injection_log i
|
|
271
|
+
JOIN drift_anchors a ON a.id = i.anchor_id
|
|
272
|
+
WHERE a.status = 'active'
|
|
273
|
+
GROUP BY i.anchor_id
|
|
267
274
|
HAVING gate_contradicts >= ?`)
|
|
268
275
|
.all(minC);
|
|
269
276
|
const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
|
package/dist/lib/map-import.js
CHANGED
|
@@ -71,13 +71,13 @@ export function importMap(db, map) {
|
|
|
71
71
|
const anchorExists = db.prepare('SELECT 1 FROM drift_anchors WHERE id = ?');
|
|
72
72
|
let linked = 0;
|
|
73
73
|
const tx = db.transaction(() => {
|
|
74
|
-
db.prepare(`
|
|
75
|
-
INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
|
|
76
|
-
VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
|
|
77
|
-
ON CONFLICT(project) DO UPDATE SET
|
|
78
|
-
job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
|
|
79
|
-
template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
|
|
80
|
-
updated_at=unixepoch()
|
|
74
|
+
db.prepare(`
|
|
75
|
+
INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
|
|
76
|
+
VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
|
|
77
|
+
ON CONFLICT(project) DO UPDATE SET
|
|
78
|
+
job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
|
|
79
|
+
template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
|
|
80
|
+
updated_at=unixepoch()
|
|
81
81
|
`).run({
|
|
82
82
|
project: map.project,
|
|
83
83
|
job: map.job ?? null,
|
|
@@ -89,13 +89,13 @@ export function importMap(db, map) {
|
|
|
89
89
|
});
|
|
90
90
|
db.prepare('DELETE FROM map_edges WHERE project = ?').run(map.project);
|
|
91
91
|
db.prepare('DELETE FROM map_nodes WHERE project = ?').run(map.project);
|
|
92
|
-
const insNode = db.prepare(`
|
|
93
|
-
INSERT INTO map_nodes
|
|
94
|
-
(id, project, layer, stage, statement, status, facets, role, note, due,
|
|
95
|
-
paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
|
|
96
|
-
VALUES
|
|
97
|
-
(@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
|
|
98
|
-
@paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
|
|
92
|
+
const insNode = db.prepare(`
|
|
93
|
+
INSERT INTO map_nodes
|
|
94
|
+
(id, project, layer, stage, statement, status, facets, role, note, due,
|
|
95
|
+
paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
|
|
96
|
+
VALUES
|
|
97
|
+
(@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
|
|
98
|
+
@paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
|
|
99
99
|
`);
|
|
100
100
|
for (const n of map.nodes) {
|
|
101
101
|
if (!n.id)
|
|
@@ -134,9 +134,9 @@ export function importMap(db, map) {
|
|
|
134
134
|
extra: JSON.stringify(extra),
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
|
-
const insEdge = db.prepare(`
|
|
138
|
-
INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
|
|
139
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
137
|
+
const insEdge = db.prepare(`
|
|
138
|
+
INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
|
|
139
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
140
140
|
`);
|
|
141
141
|
for (const e of map.edges)
|
|
142
142
|
insEdge.run(map.project, e.from, e.to, e.type, e.strength ?? null, e.note ?? null);
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -144,7 +144,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
144
144
|
const resolutionFor = buildResolutionLookup(db);
|
|
145
145
|
// ── Candidates (indexed by target node) ──
|
|
146
146
|
const candRows = db
|
|
147
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
147
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
148
148
|
FROM memory_write_candidates ORDER BY id DESC`)
|
|
149
149
|
.all();
|
|
150
150
|
const pendingByNode = new Map();
|
|
@@ -165,8 +165,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
165
165
|
// anchors it has hard evidence against. (2026-09-05: 11 open `contradicts` edges across 4
|
|
166
166
|
// anchors — one of them the PII constraint — all rendered "reality matches intent".)
|
|
167
167
|
const edgeRows = db
|
|
168
|
-
.prepare(`SELECT anchor_id, verdict, confidence, evidence, detected_at
|
|
169
|
-
FROM drift_edges WHERE status = 'open'
|
|
168
|
+
.prepare(`SELECT anchor_id, verdict, confidence, evidence, detected_at
|
|
169
|
+
FROM drift_edges WHERE status = 'open'
|
|
170
170
|
ORDER BY detected_at DESC`)
|
|
171
171
|
.all();
|
|
172
172
|
const openEdges = new Map();
|
|
@@ -176,8 +176,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
176
176
|
openEdges.get(e.anchor_id).push(e);
|
|
177
177
|
}
|
|
178
178
|
// ── Active nodes + state derivation ──
|
|
179
|
-
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
180
|
-
card_policy, review_after
|
|
179
|
+
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
180
|
+
card_policy, review_after
|
|
181
181
|
FROM drift_anchors WHERE status = 'active'`;
|
|
182
182
|
const params = [];
|
|
183
183
|
if (opts.domain) {
|
|
@@ -350,8 +350,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
350
350
|
// ── check_decision: single-node deep view ────────────────────────────────────
|
|
351
351
|
export function getDecisionDetail(db, anchorId) {
|
|
352
352
|
const row = db
|
|
353
|
-
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
354
|
-
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
353
|
+
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
354
|
+
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
355
355
|
FROM drift_anchors WHERE id = ? AND status = 'active'`)
|
|
356
356
|
.get(anchorId);
|
|
357
357
|
if (!row)
|
|
@@ -369,14 +369,14 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
369
369
|
// State derivation (same logic)
|
|
370
370
|
let state, accounted, accountedBy;
|
|
371
371
|
const pendingCand = db
|
|
372
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
373
|
-
FROM memory_write_candidates
|
|
374
|
-
WHERE target_node_id = ? AND status = 'pending_review'
|
|
372
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
373
|
+
FROM memory_write_candidates
|
|
374
|
+
WHERE target_node_id = ? AND status = 'pending_review'
|
|
375
375
|
ORDER BY id DESC`)
|
|
376
376
|
.all(anchorId);
|
|
377
377
|
const cardCand = db
|
|
378
|
-
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
379
|
-
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
378
|
+
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
379
|
+
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
380
380
|
ORDER BY id DESC LIMIT 1`)
|
|
381
381
|
.get(anchorId);
|
|
382
382
|
const hasPending = pendingCand.length > 0;
|
|
@@ -414,7 +414,7 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
414
414
|
?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
|
|
415
415
|
// Drift edges for this anchor
|
|
416
416
|
const edges = db
|
|
417
|
-
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
417
|
+
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
418
418
|
FROM drift_edges WHERE anchor_id = ? ORDER BY detected_at DESC`)
|
|
419
419
|
.all(anchorId);
|
|
420
420
|
return {
|