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,78 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type AnchorKind = 'prohibition' | 'decision' | 'constraint';
|
|
3
|
+
export type AnchorTier = 'human' | 'explicit';
|
|
4
|
+
export type AnchorSource = 'declare' | 'curate' | 'claude_md';
|
|
5
|
+
export type AnchorStatus = 'active' | 'retired';
|
|
6
|
+
export interface DeclareAnchorInput {
|
|
7
|
+
kind: AnchorKind;
|
|
8
|
+
statement: string;
|
|
9
|
+
rationale?: string;
|
|
10
|
+
affects?: string[];
|
|
11
|
+
detect_terms?: string[];
|
|
12
|
+
violation_signal?: string[];
|
|
13
|
+
tier?: AnchorTier;
|
|
14
|
+
source?: AnchorSource;
|
|
15
|
+
source_memory_id?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AnchorRow {
|
|
18
|
+
id: number;
|
|
19
|
+
kind: AnchorKind;
|
|
20
|
+
statement: string;
|
|
21
|
+
rationale: string | null;
|
|
22
|
+
affects: string[];
|
|
23
|
+
detect_terms: string[];
|
|
24
|
+
violation_signal: string[];
|
|
25
|
+
tier: AnchorTier;
|
|
26
|
+
source: AnchorSource;
|
|
27
|
+
source_memory_id: number | null;
|
|
28
|
+
status: AnchorStatus;
|
|
29
|
+
created_at: number;
|
|
30
|
+
updated_at: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Declare a drift anchor from explicit input. Clean by construction — validates the
|
|
34
|
+
* declare-don't-mine tier floor and the "can this anchor ever fire?" structural rule.
|
|
35
|
+
*/
|
|
36
|
+
export declare function declareAnchor(db: Database.Database, input: DeclareAnchorInput): AnchorRow;
|
|
37
|
+
/**
|
|
38
|
+
* Promote an existing memory into an anchor (the seeding/curation path). Pulls a
|
|
39
|
+
* candidate statement/rationale from the memory's structured content, but the curator
|
|
40
|
+
* must still supply the lexical bridge (affects / detect_terms / violation_signal) —
|
|
41
|
+
* that human review is what keeps the anchor pool clean.
|
|
42
|
+
*/
|
|
43
|
+
export declare function curateAnchorFromMemory(db: Database.Database, memoryId: number, overrides: Partial<DeclareAnchorInput> & {
|
|
44
|
+
kind: AnchorKind;
|
|
45
|
+
}): AnchorRow;
|
|
46
|
+
export declare function getAnchor(db: Database.Database, id: number): AnchorRow | null;
|
|
47
|
+
export declare function listAnchors(db: Database.Database, opts?: {
|
|
48
|
+
status?: AnchorStatus;
|
|
49
|
+
kind?: AnchorKind;
|
|
50
|
+
}): AnchorRow[];
|
|
51
|
+
/** Retire an anchor so the detector stops firing it, without losing history (minimal change). */
|
|
52
|
+
export declare function retireAnchor(db: Database.Database, id: number): boolean;
|
|
53
|
+
export interface NodeFields {
|
|
54
|
+
node_type?: string;
|
|
55
|
+
domain?: string;
|
|
56
|
+
decision_mode?: string;
|
|
57
|
+
confidence?: number;
|
|
58
|
+
lifecycle?: string;
|
|
59
|
+
validity_scope?: Record<string, unknown>;
|
|
60
|
+
card_policy?: Record<string, unknown>;
|
|
61
|
+
reality_manifestations?: unknown[];
|
|
62
|
+
review_after?: number;
|
|
63
|
+
last_confirmed_at?: number;
|
|
64
|
+
owner?: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function getCurrentTruth(db: Database.Database, opts?: {
|
|
67
|
+
domain?: string;
|
|
68
|
+
decision_mode?: string;
|
|
69
|
+
}): any[];
|
|
70
|
+
export declare function setNodeFields(db: Database.Database, id: number, f: NodeFields): boolean;
|
|
71
|
+
export interface AlertPolicy {
|
|
72
|
+
max_cards_per_day: number;
|
|
73
|
+
max_soft_cards_per_week: number;
|
|
74
|
+
min_confidence_for_soft_card: number;
|
|
75
|
+
require_two_sided_evidence: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function getAlertPolicy(db: Database.Database): AlertPolicy;
|
|
78
|
+
export declare function setAlertPolicy(db: Database.Database, partial: Partial<AlertPolicy>): AlertPolicy;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Drift anchor write path (v8) — the "intent" side of drift observability.
|
|
2
|
+
//
|
|
3
|
+
// An anchor is a DECLARED normative claim: a prohibition ("never assert safety"),
|
|
4
|
+
// a decision ("we use FTS5, not vector"), or a constraint ("all writes go through
|
|
5
|
+
// remember()"). The detector (drift-detection.ts) later checks these against reality
|
|
6
|
+
// (session_file_edits) and emits drift_edges.
|
|
7
|
+
//
|
|
8
|
+
// declare-don't-mine: anchors are clean BY CONSTRUCTION. They come only from explicit
|
|
9
|
+
// declaration (CLI / curation / CLAUDE.md), never from the session pattern-extractor.
|
|
10
|
+
// The schema's `tier CHECK ('human','explicit')` is the hard boundary; this module adds
|
|
11
|
+
// a friendlier error and the structural validation (a prohibition with no violation
|
|
12
|
+
// signal can never fire, so we reject it at write time rather than store dead weight).
|
|
13
|
+
const KINDS = new Set(['prohibition', 'decision', 'constraint']);
|
|
14
|
+
const TIERS = new Set(['human', 'explicit']);
|
|
15
|
+
const SOURCES = new Set(['declare', 'curate', 'claude_md']);
|
|
16
|
+
function toJsonArray(v) {
|
|
17
|
+
if (v == null)
|
|
18
|
+
return '[]';
|
|
19
|
+
if (!Array.isArray(v))
|
|
20
|
+
throw new Error('expected an array of strings');
|
|
21
|
+
const clean = v.map((x) => String(x).trim()).filter(Boolean);
|
|
22
|
+
return JSON.stringify(clean);
|
|
23
|
+
}
|
|
24
|
+
function parseArray(s) {
|
|
25
|
+
try {
|
|
26
|
+
const a = JSON.parse(s);
|
|
27
|
+
return Array.isArray(a) ? a.map(String) : [];
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function hydrate(r) {
|
|
34
|
+
return {
|
|
35
|
+
id: r.id,
|
|
36
|
+
kind: r.kind,
|
|
37
|
+
statement: r.statement,
|
|
38
|
+
rationale: r.rationale ?? null,
|
|
39
|
+
affects: parseArray(r.affects),
|
|
40
|
+
detect_terms: parseArray(r.detect_terms),
|
|
41
|
+
violation_signal: parseArray(r.violation_signal),
|
|
42
|
+
tier: r.tier,
|
|
43
|
+
source: r.source,
|
|
44
|
+
source_memory_id: r.source_memory_id ?? null,
|
|
45
|
+
status: r.status,
|
|
46
|
+
created_at: r.created_at,
|
|
47
|
+
updated_at: r.updated_at,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Declare a drift anchor from explicit input. Clean by construction — validates the
|
|
52
|
+
* declare-don't-mine tier floor and the "can this anchor ever fire?" structural rule.
|
|
53
|
+
*/
|
|
54
|
+
export function declareAnchor(db, input) {
|
|
55
|
+
if (!KINDS.has(input.kind)) {
|
|
56
|
+
throw new Error(`invalid kind "${input.kind}" — one of: prohibition, decision, constraint`);
|
|
57
|
+
}
|
|
58
|
+
const statement = String(input.statement ?? '').trim();
|
|
59
|
+
if (statement.length < 8) {
|
|
60
|
+
throw new Error('statement is required — a real normative claim (>= 8 chars)');
|
|
61
|
+
}
|
|
62
|
+
const tier = input.tier ?? 'human';
|
|
63
|
+
if (!TIERS.has(tier)) {
|
|
64
|
+
throw new Error(`invalid tier "${tier}" — declare-don't-mine: anchors must be 'human' or 'explicit', never agent/inferred`);
|
|
65
|
+
}
|
|
66
|
+
const source = input.source ?? 'declare';
|
|
67
|
+
if (!SOURCES.has(source)) {
|
|
68
|
+
throw new Error(`invalid source "${source}" — one of: declare, curate, claude_md`);
|
|
69
|
+
}
|
|
70
|
+
const violation = toJsonArray(input.violation_signal);
|
|
71
|
+
// A prohibition/decision is only useful if the detector can deduce a violation from it.
|
|
72
|
+
// Without a violation_signal the anchor is dead weight — reject at write time.
|
|
73
|
+
if ((input.kind === 'prohibition' || input.kind === 'decision') && parseArray(violation).length === 0) {
|
|
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
|
+
}
|
|
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
|
|
80
|
+
(@kind, @statement, @rationale, @affects, @detect_terms, @violation_signal, @tier, @source, @source_memory_id)`)
|
|
81
|
+
.run({
|
|
82
|
+
kind: input.kind,
|
|
83
|
+
statement,
|
|
84
|
+
rationale: input.rationale?.trim() || null,
|
|
85
|
+
affects: toJsonArray(input.affects),
|
|
86
|
+
detect_terms: toJsonArray(input.detect_terms),
|
|
87
|
+
violation_signal: violation,
|
|
88
|
+
tier,
|
|
89
|
+
source,
|
|
90
|
+
source_memory_id: input.source_memory_id ?? null,
|
|
91
|
+
});
|
|
92
|
+
return getAnchor(db, Number(info.lastInsertRowid));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Promote an existing memory into an anchor (the seeding/curation path). Pulls a
|
|
96
|
+
* candidate statement/rationale from the memory's structured content, but the curator
|
|
97
|
+
* must still supply the lexical bridge (affects / detect_terms / violation_signal) —
|
|
98
|
+
* that human review is what keeps the anchor pool clean.
|
|
99
|
+
*/
|
|
100
|
+
export function curateAnchorFromMemory(db, memoryId, overrides) {
|
|
101
|
+
const mem = db.prepare('SELECT id, content FROM memories WHERE id = ?').get(memoryId);
|
|
102
|
+
if (!mem)
|
|
103
|
+
throw new Error(`memory ${memoryId} not found`);
|
|
104
|
+
let statement = overrides.statement;
|
|
105
|
+
let rationale = overrides.rationale;
|
|
106
|
+
if (!statement) {
|
|
107
|
+
try {
|
|
108
|
+
const o = JSON.parse(mem.content);
|
|
109
|
+
statement = o?.rule_or_warning ?? o?.title ?? o?.what ?? o?.decision ?? o?.learned ?? '';
|
|
110
|
+
rationale = rationale ?? o?.why;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
statement = mem.content;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return declareAnchor(db, {
|
|
117
|
+
...overrides,
|
|
118
|
+
statement: String(statement ?? '').trim(),
|
|
119
|
+
rationale,
|
|
120
|
+
tier: overrides.tier ?? 'explicit',
|
|
121
|
+
source: 'curate',
|
|
122
|
+
source_memory_id: memoryId,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function getAnchor(db, id) {
|
|
126
|
+
const r = db.prepare('SELECT * FROM drift_anchors WHERE id = ?').get(id);
|
|
127
|
+
return r ? hydrate(r) : null;
|
|
128
|
+
}
|
|
129
|
+
export function listAnchors(db, opts = {}) {
|
|
130
|
+
let sql = 'SELECT * FROM drift_anchors WHERE 1=1';
|
|
131
|
+
const params = [];
|
|
132
|
+
if (opts.status) {
|
|
133
|
+
sql += ' AND status = ?';
|
|
134
|
+
params.push(opts.status);
|
|
135
|
+
}
|
|
136
|
+
if (opts.kind) {
|
|
137
|
+
sql += ' AND kind = ?';
|
|
138
|
+
params.push(opts.kind);
|
|
139
|
+
}
|
|
140
|
+
// Most entrenched first (human > explicit), then newest.
|
|
141
|
+
sql += " ORDER BY (tier = 'human') DESC, created_at DESC";
|
|
142
|
+
return db.prepare(sql).all(...params).map(hydrate);
|
|
143
|
+
}
|
|
144
|
+
/** Retire an anchor so the detector stops firing it, without losing history (minimal change). */
|
|
145
|
+
export function retireAnchor(db, id) {
|
|
146
|
+
const r = db
|
|
147
|
+
.prepare("UPDATE drift_anchors SET status = 'retired', updated_at = unixepoch() WHERE id = ? AND status = 'active'")
|
|
148
|
+
.run(id);
|
|
149
|
+
return r.changes > 0;
|
|
150
|
+
}
|
|
151
|
+
// ⑧ read side: the active Current-Truth slice for read_smart("current truth for X").
|
|
152
|
+
// Returns ONLY active nodes (the desired-state), token-cheap, optionally scoped by domain/mode.
|
|
153
|
+
export function getCurrentTruth(db, opts = {}) {
|
|
154
|
+
let sql = "SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle, card_policy, review_after FROM drift_anchors WHERE status = 'active'";
|
|
155
|
+
const p = [];
|
|
156
|
+
if (opts.domain) {
|
|
157
|
+
sql += ' AND domain = ?';
|
|
158
|
+
p.push(opts.domain);
|
|
159
|
+
}
|
|
160
|
+
if (opts.decision_mode) {
|
|
161
|
+
sql += ' AND decision_mode = ?';
|
|
162
|
+
p.push(opts.decision_mode);
|
|
163
|
+
}
|
|
164
|
+
sql += " ORDER BY (decision_mode = 'source_of_truth') DESC, domain, id";
|
|
165
|
+
return db.prepare(sql).all(...p);
|
|
166
|
+
}
|
|
167
|
+
export function setNodeFields(db, id, f) {
|
|
168
|
+
const sets = [];
|
|
169
|
+
const params = { id };
|
|
170
|
+
const put = (col, val) => { sets.push(`${col} = @${col}`); params[col] = val; };
|
|
171
|
+
if (f.node_type !== undefined)
|
|
172
|
+
put('node_type', f.node_type);
|
|
173
|
+
if (f.domain !== undefined)
|
|
174
|
+
put('domain', f.domain);
|
|
175
|
+
if (f.decision_mode !== undefined)
|
|
176
|
+
put('decision_mode', f.decision_mode);
|
|
177
|
+
if (f.confidence !== undefined)
|
|
178
|
+
put('confidence', f.confidence);
|
|
179
|
+
if (f.lifecycle !== undefined)
|
|
180
|
+
put('lifecycle', f.lifecycle);
|
|
181
|
+
if (f.validity_scope !== undefined)
|
|
182
|
+
put('validity_scope', JSON.stringify(f.validity_scope));
|
|
183
|
+
if (f.card_policy !== undefined)
|
|
184
|
+
put('card_policy', JSON.stringify(f.card_policy));
|
|
185
|
+
if (f.reality_manifestations !== undefined)
|
|
186
|
+
put('reality_manifestations', JSON.stringify(f.reality_manifestations));
|
|
187
|
+
if (f.review_after !== undefined)
|
|
188
|
+
put('review_after', f.review_after);
|
|
189
|
+
if (f.last_confirmed_at !== undefined)
|
|
190
|
+
put('last_confirmed_at', f.last_confirmed_at);
|
|
191
|
+
if (f.owner !== undefined)
|
|
192
|
+
put('owner', f.owner);
|
|
193
|
+
if (sets.length === 0)
|
|
194
|
+
return false;
|
|
195
|
+
const r = db
|
|
196
|
+
.prepare(`UPDATE drift_anchors SET ${sets.join(', ')}, updated_at = unixepoch() WHERE id = @id`)
|
|
197
|
+
.run(params);
|
|
198
|
+
return r.changes > 0;
|
|
199
|
+
}
|
|
200
|
+
const DEFAULT_ALERT_POLICY = {
|
|
201
|
+
max_cards_per_day: 5,
|
|
202
|
+
max_soft_cards_per_week: 3,
|
|
203
|
+
min_confidence_for_soft_card: 0.55,
|
|
204
|
+
require_two_sided_evidence: true,
|
|
205
|
+
};
|
|
206
|
+
export function getAlertPolicy(db) {
|
|
207
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'alert_policy'").get();
|
|
208
|
+
if (!row)
|
|
209
|
+
return { ...DEFAULT_ALERT_POLICY };
|
|
210
|
+
try {
|
|
211
|
+
return { ...DEFAULT_ALERT_POLICY, ...JSON.parse(row.value) };
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return { ...DEFAULT_ALERT_POLICY };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export function setAlertPolicy(db, partial) {
|
|
218
|
+
const merged = { ...getAlertPolicy(db), ...partial };
|
|
219
|
+
db.prepare("INSERT INTO meta (key, value) VALUES ('alert_policy', @v) ON CONFLICT(key) DO UPDATE SET value = @v").run({
|
|
220
|
+
v: JSON.stringify(merged),
|
|
221
|
+
});
|
|
222
|
+
return merged;
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=drift-anchors.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export interface DriftSample {
|
|
3
|
+
anchor_id: number;
|
|
4
|
+
kind: string;
|
|
5
|
+
statement: string;
|
|
6
|
+
verdict: 'contradicts' | 'absent';
|
|
7
|
+
confidence: number;
|
|
8
|
+
file_path: string | null;
|
|
9
|
+
hit_term: string | null;
|
|
10
|
+
scope: 'glob' | 'fts' | null;
|
|
11
|
+
occurred_at: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface DriftAnchorTally {
|
|
14
|
+
anchor_id: number;
|
|
15
|
+
kind: string;
|
|
16
|
+
statement: string;
|
|
17
|
+
contradicts: number;
|
|
18
|
+
absent: number;
|
|
19
|
+
}
|
|
20
|
+
export interface DriftDetectionResult {
|
|
21
|
+
anchorsScanned: number;
|
|
22
|
+
editsScanned: number;
|
|
23
|
+
contradicts: number;
|
|
24
|
+
absent: number;
|
|
25
|
+
edgesEmitted: number;
|
|
26
|
+
persisted: boolean;
|
|
27
|
+
byAnchor: DriftAnchorTally[];
|
|
28
|
+
samples: DriftSample[];
|
|
29
|
+
}
|
|
30
|
+
export declare function detectDrift(db: Database.Database, opts?: {
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
staleDays?: number;
|
|
33
|
+
emitThreshold?: number;
|
|
34
|
+
}): DriftDetectionResult;
|
|
35
|
+
export interface FileViolationSample {
|
|
36
|
+
anchor_id: number;
|
|
37
|
+
kind: string;
|
|
38
|
+
statement: string;
|
|
39
|
+
file_path: string;
|
|
40
|
+
line_no: number;
|
|
41
|
+
line_text: string;
|
|
42
|
+
hit_term: string;
|
|
43
|
+
confidence: number;
|
|
44
|
+
}
|
|
45
|
+
export interface FileViolationResult {
|
|
46
|
+
persisted: boolean;
|
|
47
|
+
anchorsScanned: number;
|
|
48
|
+
filesScanned: number;
|
|
49
|
+
anchorsCapped: number;
|
|
50
|
+
contradicts: number;
|
|
51
|
+
edgesEmitted: number;
|
|
52
|
+
byAnchor: Array<{
|
|
53
|
+
anchor_id: number;
|
|
54
|
+
statement: string;
|
|
55
|
+
contradicts: number;
|
|
56
|
+
}>;
|
|
57
|
+
samples: FileViolationSample[];
|
|
58
|
+
}
|
|
59
|
+
export declare function detectFileViolations(db: Database.Database, opts?: {
|
|
60
|
+
dryRun?: boolean;
|
|
61
|
+
emitThreshold?: number;
|
|
62
|
+
}): FileViolationResult;
|