linksee-memory 0.7.2 → 0.10.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/LICENSE +21 -21
- package/README.md +839 -678
- 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/bin/guard-hook.d.ts +2 -0
- package/dist/bin/guard-hook.js +96 -0
- package/dist/bin/import-sessions.js +44 -11
- package/dist/bin/install-skill.js +14 -14
- package/dist/bin/setup.js +107 -18
- package/dist/bin/stats.js +20 -20
- package/dist/db/migrate.js +52 -28
- package/dist/db/schema.sql +371 -232
- package/dist/lib/consolidate.js +19 -19
- 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/edge-detection.js +8 -8
- package/dist/lib/guard.d.ts +81 -0
- package/dist/lib/guard.js +321 -0
- package/dist/lib/lexical-match.d.ts +6 -0
- package/dist/lib/lexical-match.js +87 -0
- package/dist/lib/momentum.js +7 -7
- package/dist/lib/session-extractor.d.ts +1 -0
- package/dist/lib/session-extractor.js +62 -11
- package/dist/lib/truth-engine.d.ts +84 -0
- package/dist/lib/truth-engine.js +417 -0
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +573 -3
- package/dist/skill/SKILL.md +734 -631
- package/package.json +10 -5
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type GateMode = 'off' | 'soft' | 'hard';
|
|
3
|
+
export type GateLevel = 'allow' | 'inform' | 'warn' | 'block';
|
|
4
|
+
export interface AcceptedAnchor {
|
|
5
|
+
id: number;
|
|
6
|
+
kind: string;
|
|
7
|
+
statement: string;
|
|
8
|
+
rationale: string | null;
|
|
9
|
+
affects: string;
|
|
10
|
+
detect_terms: string;
|
|
11
|
+
violation_signal: string;
|
|
12
|
+
card_policy: string;
|
|
13
|
+
}
|
|
14
|
+
export interface GateMatch {
|
|
15
|
+
anchor_id: number;
|
|
16
|
+
statement: string;
|
|
17
|
+
rationale: string | null;
|
|
18
|
+
verdict: 'contradicts' | 'in_scope';
|
|
19
|
+
why: string;
|
|
20
|
+
gate_mode: GateMode;
|
|
21
|
+
}
|
|
22
|
+
export interface GateResult {
|
|
23
|
+
gate: GateLevel;
|
|
24
|
+
matched: GateMatch[];
|
|
25
|
+
reinject: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ActionInput {
|
|
28
|
+
tool?: string;
|
|
29
|
+
file_path?: string;
|
|
30
|
+
files?: string[];
|
|
31
|
+
command?: string;
|
|
32
|
+
content?: string;
|
|
33
|
+
diff?: string;
|
|
34
|
+
action?: string;
|
|
35
|
+
}
|
|
36
|
+
interface ActionCtx {
|
|
37
|
+
tool: string;
|
|
38
|
+
files: string[];
|
|
39
|
+
lines: string[];
|
|
40
|
+
haystack: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function acceptedAnchors(db: Database.Database): AcceptedAnchor[];
|
|
43
|
+
export declare function matchAction(db: Database.Database, act: ActionCtx): GateMatch[];
|
|
44
|
+
export declare function gateAction(db: Database.Database, input: ActionInput, opts?: {
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
}): GateResult;
|
|
47
|
+
export declare function formatReinject(matches: GateMatch[], gate: GateLevel): string;
|
|
48
|
+
export declare function buildBootDigest(db: Database.Database, opts?: {
|
|
49
|
+
maxAnchors?: number;
|
|
50
|
+
maxForks?: number;
|
|
51
|
+
}): {
|
|
52
|
+
text: string;
|
|
53
|
+
anchors: number;
|
|
54
|
+
forks: number;
|
|
55
|
+
distill: number;
|
|
56
|
+
};
|
|
57
|
+
export interface FrictionItem {
|
|
58
|
+
anchor_id: number;
|
|
59
|
+
statement: string;
|
|
60
|
+
gate_mode: GateMode;
|
|
61
|
+
lifecycle: string;
|
|
62
|
+
gate_contradicts: number;
|
|
63
|
+
gate_blocks: number;
|
|
64
|
+
ignored: number;
|
|
65
|
+
reality_contradicts: number;
|
|
66
|
+
last_at: string | null;
|
|
67
|
+
signal: 'violated_for_real' | 'gate_holding';
|
|
68
|
+
suggested_action: 'escalate_to_hard' | 'review_or_supersede' | 'none';
|
|
69
|
+
recommendation: string;
|
|
70
|
+
}
|
|
71
|
+
export declare function backfillHeeded(db: Database.Database): void;
|
|
72
|
+
export declare function getReinjectionFriction(db: Database.Database, opts?: {
|
|
73
|
+
minContradicts?: number;
|
|
74
|
+
}): FrictionItem[];
|
|
75
|
+
export declare function setGateMode(db: Database.Database, anchorId: number, mode: GateMode): {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
anchor_id: number;
|
|
78
|
+
gate_mode: GateMode;
|
|
79
|
+
card_policy: Record<string, unknown>;
|
|
80
|
+
};
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Re-injection guard (the "6th pillar" — active observability). The post-hoc detector
|
|
2
|
+
// (drift-detection.ts) answers "did reality drift from a declared anchor?" AFTER the fact, into
|
|
3
|
+
// drift_edges. This module answers "is the action ABOUT to be taken in scope of / contradicting an
|
|
4
|
+
// accepted anchor?" BEFORE the fact, and re-surfaces that anchor into the agent's context. Pre vs
|
|
5
|
+
// post: the gate writes ONLY injection_log, never drift_edges — the two streams stay separate and
|
|
6
|
+
// rejoin in `dream`.
|
|
7
|
+
//
|
|
8
|
+
// Enforcement lives OUTSIDE the agent's volition (a Claude Code PreToolUse hook calls this via the
|
|
9
|
+
// guard-hook bin), because the load-bearing pain — "Claude read the rule, understood it, still used
|
|
10
|
+
// cp" (anthropics/claude-code#15443) — is precisely the case where the agent will NOT self-check.
|
|
11
|
+
//
|
|
12
|
+
// FAIL-OPEN by construction: every DB op is best-effort; only an explicit 'hard' contradiction yields
|
|
13
|
+
// a block. Lexical only (no embeddings): reuses lexical-match.ts (the same logic as the detector).
|
|
14
|
+
import { normPath, compileGlob, parseArray, matchViolation, SCRAPE_ANCHOR } from './lexical-match.js';
|
|
15
|
+
// "accepted = the only thing the gate compares against": declared (status active), still live
|
|
16
|
+
// (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
|
|
17
|
+
// at_risk(stale) anchors deliberately DON'T gate — we don't enforce a rule we're no longer sure of.
|
|
18
|
+
const ACCEPTED_SQL = `status = 'active'
|
|
19
|
+
AND lifecycle IN ('active', 'experiment')
|
|
20
|
+
AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
|
|
21
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
22
|
+
function jsonGet(json, key, def) {
|
|
23
|
+
try {
|
|
24
|
+
const o = JSON.parse(json || '{}');
|
|
25
|
+
return o && o[key] !== undefined && o[key] !== null ? o[key] : def;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return def;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function bestEffort(fn) {
|
|
32
|
+
try {
|
|
33
|
+
fn();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* logging/telemetry must NEVER block the gate */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function acceptedAnchors(db) {
|
|
40
|
+
return db
|
|
41
|
+
.prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
|
|
42
|
+
FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
|
|
43
|
+
.all();
|
|
44
|
+
}
|
|
45
|
+
function buildActionCtx(input) {
|
|
46
|
+
const files = [];
|
|
47
|
+
if (input.file_path)
|
|
48
|
+
files.push(input.file_path);
|
|
49
|
+
if (Array.isArray(input.files))
|
|
50
|
+
files.push(...input.files);
|
|
51
|
+
const rawParts = [input.command, input.diff, input.content, input.action].filter((x) => typeof x === 'string' && x.length > 0);
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const part of rawParts) {
|
|
54
|
+
for (const ln of part.split(/\r?\n/)) {
|
|
55
|
+
const t = ln.trim();
|
|
56
|
+
if (t)
|
|
57
|
+
lines.push(t);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const haystack = [...files.map(normPath), ...rawParts].join('\n').toLowerCase();
|
|
61
|
+
return { tool: input.tool ?? 'unknown', files, lines, haystack };
|
|
62
|
+
}
|
|
63
|
+
export function matchAction(db, act) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const a of acceptedAnchors(db)) {
|
|
66
|
+
const gate_mode = jsonGet(a.card_policy, 'gate_mode', 'soft');
|
|
67
|
+
if (gate_mode === 'off')
|
|
68
|
+
continue;
|
|
69
|
+
const globs = parseArray(a.affects).map(compileGlob);
|
|
70
|
+
const terms = parseArray(a.detect_terms)
|
|
71
|
+
.map((t) => t.trim().toLowerCase())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
const signals = parseArray(a.violation_signal)
|
|
74
|
+
.map((s) => s.trim().toLowerCase())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
const hasScope = globs.length > 0;
|
|
77
|
+
const pathHit = hasScope && act.files.some((f) => {
|
|
78
|
+
const p = normPath(f);
|
|
79
|
+
return globs.some((g) => g(p));
|
|
80
|
+
});
|
|
81
|
+
const termHit = terms.length > 0 && terms.some((t) => act.haystack.includes(t));
|
|
82
|
+
const isScrape = SCRAPE_ANCHOR.test(a.statement);
|
|
83
|
+
let sigHit = null;
|
|
84
|
+
if (signals.length > 0) {
|
|
85
|
+
for (const line of act.lines) {
|
|
86
|
+
const hit = matchViolation(line, line.toLowerCase(), signals, isScrape);
|
|
87
|
+
if (hit) {
|
|
88
|
+
sigHit = hit;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Scope (mirrors the detector): a path-scoped anchor requires the action to touch an in-scope
|
|
94
|
+
// file; a global anchor (no affects) fires on topical-term OR forbidden-signal relevance.
|
|
95
|
+
const inScope = hasScope ? pathHit : termHit || sigHit != null;
|
|
96
|
+
if (!inScope)
|
|
97
|
+
continue;
|
|
98
|
+
out.push({
|
|
99
|
+
anchor_id: a.id,
|
|
100
|
+
statement: a.statement,
|
|
101
|
+
rationale: a.rationale,
|
|
102
|
+
verdict: sigHit ? 'contradicts' : 'in_scope',
|
|
103
|
+
why: sigHit
|
|
104
|
+
? `your action contains \`${sigHit}\``
|
|
105
|
+
: pathHit
|
|
106
|
+
? `touches a file under this decision's scope`
|
|
107
|
+
: `matches this decision's topic`,
|
|
108
|
+
gate_mode,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// contradicts first (the headline), then in_scope.
|
|
112
|
+
return out.sort((x, y) => (y.verdict === 'contradicts' ? 1 : 0) - (x.verdict === 'contradicts' ? 1 : 0));
|
|
113
|
+
}
|
|
114
|
+
function withinCooldown(db, anchorId, sessionId) {
|
|
115
|
+
if (!sessionId)
|
|
116
|
+
return false;
|
|
117
|
+
const minutes = jsonGet(db.prepare(`SELECT card_policy FROM drift_anchors WHERE id = ?`).get(anchorId)
|
|
118
|
+
?.card_policy, 'reinject_cooldown_min', 30);
|
|
119
|
+
const cutoff = nowSec() - minutes * 60;
|
|
120
|
+
const row = db
|
|
121
|
+
.prepare(`SELECT 1 FROM injection_log WHERE anchor_id = ? AND session_id = ? AND occurred_at >= ? LIMIT 1`)
|
|
122
|
+
.get(anchorId, sessionId, cutoff);
|
|
123
|
+
return !!row;
|
|
124
|
+
}
|
|
125
|
+
function logInjection(db, matches, act, surface, sessionId) {
|
|
126
|
+
const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
|
|
127
|
+
VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
|
|
128
|
+
const snip = (act.lines[0] ?? '').slice(0, 120);
|
|
129
|
+
const tx = db.transaction(() => {
|
|
130
|
+
for (const m of matches)
|
|
131
|
+
ins.run(m.anchor_id, sessionId ?? null, surface, act.tool, snip, m.verdict);
|
|
132
|
+
});
|
|
133
|
+
tx();
|
|
134
|
+
}
|
|
135
|
+
export function gateAction(db, input, opts = {}) {
|
|
136
|
+
const act = buildActionCtx(input);
|
|
137
|
+
let matches = matchAction(db, act);
|
|
138
|
+
if (matches.length === 0)
|
|
139
|
+
return { gate: 'allow', matched: [], reinject: '' };
|
|
140
|
+
const contradictions = matches.filter((m) => m.verdict === 'contradicts');
|
|
141
|
+
let gate = contradictions.length > 0 ? 'warn' : 'inform';
|
|
142
|
+
if (contradictions.some((m) => m.gate_mode === 'hard'))
|
|
143
|
+
gate = 'block';
|
|
144
|
+
// Cooldown applies ONLY to pure-informational re-injection (no contradiction). A real contradiction
|
|
145
|
+
// is surfaced every single time — an ignored rule must not be silenced by a timer.
|
|
146
|
+
if (gate === 'inform') {
|
|
147
|
+
matches = matches.filter((m) => !withinCooldown(db, m.anchor_id, opts.sessionId));
|
|
148
|
+
if (matches.length === 0)
|
|
149
|
+
return { gate: 'allow', matched: [], reinject: '' };
|
|
150
|
+
}
|
|
151
|
+
const shown = matches.slice(0, 4);
|
|
152
|
+
const reinject = formatReinject(shown, gate);
|
|
153
|
+
bestEffort(() => logInjection(db, shown, act, gate, opts.sessionId));
|
|
154
|
+
return { gate, matched: shown, reinject };
|
|
155
|
+
}
|
|
156
|
+
export function formatReinject(matches, gate) {
|
|
157
|
+
const head = gate === 'block'
|
|
158
|
+
? '⛔ Blocked — this action breaks a decision you locked earlier.'
|
|
159
|
+
: gate === 'warn'
|
|
160
|
+
? '⚠ Heads up — this action contradicts a decision you locked earlier.'
|
|
161
|
+
: 'ℹ Reminder — you have an active decision covering what you are about to touch.';
|
|
162
|
+
const body = matches.map((m) => {
|
|
163
|
+
const rationale = m.rationale ? ` — ${m.rationale}` : '';
|
|
164
|
+
const tail = m.verdict === 'contradicts' ? `${m.why} → contradicts it.` : `${m.why}.`;
|
|
165
|
+
return `• [#${m.anchor_id}] "${m.statement}"${rationale}\n ↳ ${tail}`;
|
|
166
|
+
});
|
|
167
|
+
const firstContra = matches.find((m) => m.verdict === 'contradicts');
|
|
168
|
+
const foot = firstContra
|
|
169
|
+
? `\nIf you are intentionally changing this decision, supersede it on the record:\n resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'supersede', superseded_by: <new anchor>).`
|
|
170
|
+
: '';
|
|
171
|
+
return [head, ...body, foot].filter(Boolean).join('\n');
|
|
172
|
+
}
|
|
173
|
+
// SessionStart boot digest — re-load the accepted anchors + open forks into a fresh session, killing
|
|
174
|
+
// cross-session amnesia ("groundhog day"). Kept small (top-N) and budget-bounded by caller.
|
|
175
|
+
export function buildBootDigest(db, opts = {}) {
|
|
176
|
+
const maxAnchors = opts.maxAnchors ?? 8;
|
|
177
|
+
const maxForks = opts.maxForks ?? 5;
|
|
178
|
+
const anchors = db
|
|
179
|
+
.prepare(`SELECT id, statement, rationale FROM drift_anchors
|
|
180
|
+
WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
|
|
181
|
+
ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
|
|
182
|
+
.all(maxAnchors);
|
|
183
|
+
const forks = db
|
|
184
|
+
.prepare(`SELECT c.id, c.rationale, a.statement
|
|
185
|
+
FROM memory_write_candidates c
|
|
186
|
+
LEFT JOIN drift_anchors a ON a.id = c.target_node_id
|
|
187
|
+
WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
|
|
188
|
+
ORDER BY c.created_at DESC LIMIT ?`)
|
|
189
|
+
.all(maxForks);
|
|
190
|
+
// Distillation pressure — the routine's structural trigger. The drain must not depend on
|
|
191
|
+
// the agent remembering to dream (#15443 lesson): while raw auto-captured memories exist,
|
|
192
|
+
// EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
|
|
193
|
+
let distill = 0;
|
|
194
|
+
try {
|
|
195
|
+
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
196
|
+
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
197
|
+
AND (json_extract(content, '$.needs_distill') = 1
|
|
198
|
+
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
199
|
+
OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* digest must never fail on the nudge */
|
|
203
|
+
}
|
|
204
|
+
if (anchors.length === 0 && forks.length === 0 && distill === 0)
|
|
205
|
+
return { text: '', anchors: 0, forks: 0, distill: 0 };
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (anchors.length > 0) {
|
|
208
|
+
parts.push('📌 Linksee — decisions you locked (still in force):');
|
|
209
|
+
for (const a of anchors)
|
|
210
|
+
parts.push(`• [#${a.id}] "${a.statement}"${a.rationale ? ` — ${a.rationale}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
if (forks.length > 0) {
|
|
213
|
+
parts.push('', '🔀 Open forks still awaiting your call:');
|
|
214
|
+
for (const f of forks)
|
|
215
|
+
parts.push(`• ${f.statement ?? f.rationale}`);
|
|
216
|
+
}
|
|
217
|
+
if (distill > 0) {
|
|
218
|
+
parts.push('', `🧪 ${distill} auto-captured memories are still raw utterances — call dream() and rewrite the distill_queue via remember(memory_id, content) with "distilled": true.`);
|
|
219
|
+
}
|
|
220
|
+
if (anchors.length > 0)
|
|
221
|
+
parts.push('', 'Honor these unless you explicitly supersede them (resolve_drift action=supersede).');
|
|
222
|
+
return { text: parts.join('\n'), anchors: anchors.length, forks: forks.length, distill };
|
|
223
|
+
}
|
|
224
|
+
// Coarse, best-effort heeded backfill (idempotent — only touches heeded IS NULL). heeded=0 when a soft
|
|
225
|
+
// re-injection failed to prevent the violation (the anchor still has an OPEN reality contradiction);
|
|
226
|
+
// heeded=1 for blocks (the tool was denied) and for soft injections whose anchor's reality is clean.
|
|
227
|
+
// Intentionally NOT timestamp-correlated — it powers the "ignored" tally only, never a gating decision.
|
|
228
|
+
export function backfillHeeded(db) {
|
|
229
|
+
bestEffort(() => {
|
|
230
|
+
const live = `SELECT anchor_id FROM drift_edges WHERE verdict = 'contradicts' AND status = 'open'`;
|
|
231
|
+
db.prepare(`UPDATE injection_log SET heeded = 0
|
|
232
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
|
|
233
|
+
AND anchor_id IN (${live})`).run();
|
|
234
|
+
db.prepare(`UPDATE injection_log SET heeded = 1
|
|
235
|
+
WHERE heeded IS NULL AND surface IN ('warn', 'inform')
|
|
236
|
+
AND anchor_id NOT IN (${live})`).run();
|
|
237
|
+
db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Surface anchors the gate keeps re-injecting. PRIMARY signal = two directly-queryable counts (no fragile
|
|
241
|
+
// inference): gate_contradicts (pre-action) × reality_contradicts (post-action). Both > 0 ⇒ re-injection
|
|
242
|
+
// isn't holding ⇒ escalate (soft→hard) or review/supersede (if reality has outrun the rule).
|
|
243
|
+
export function getReinjectionFriction(db, opts = {}) {
|
|
244
|
+
const minC = opts.minContradicts ?? 3;
|
|
245
|
+
backfillHeeded(db);
|
|
246
|
+
const rows = db
|
|
247
|
+
.prepare(`SELECT i.anchor_id,
|
|
248
|
+
SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
|
|
249
|
+
SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
|
|
250
|
+
SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
|
|
251
|
+
datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
|
|
252
|
+
a.statement, a.lifecycle, a.card_policy
|
|
253
|
+
FROM injection_log i
|
|
254
|
+
JOIN drift_anchors a ON a.id = i.anchor_id
|
|
255
|
+
WHERE a.status = 'active'
|
|
256
|
+
GROUP BY i.anchor_id
|
|
257
|
+
HAVING gate_contradicts >= ?`)
|
|
258
|
+
.all(minC);
|
|
259
|
+
const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const r of rows) {
|
|
262
|
+
const gate_mode = jsonGet(r.card_policy, 'gate_mode', 'soft');
|
|
263
|
+
const reality_contradicts = realityStmt.get(r.anchor_id).n;
|
|
264
|
+
let signal;
|
|
265
|
+
let suggested_action;
|
|
266
|
+
let recommendation;
|
|
267
|
+
if (reality_contradicts > 0) {
|
|
268
|
+
signal = 'violated_for_real';
|
|
269
|
+
if (gate_mode !== 'hard') {
|
|
270
|
+
suggested_action = 'escalate_to_hard';
|
|
271
|
+
recommendation = `Re-surfaced ${r.gate_contradicts}× yet ${reality_contradicts} contradiction(s) are still live in the code — soft warnings aren't holding. Escalate to gate_mode:'hard', or supersede the anchor if reality has outrun the rule.`;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
suggested_action = 'review_or_supersede';
|
|
275
|
+
recommendation = `Hard-gated yet ${reality_contradicts} contradiction(s) persist (pre-existing or overridden) — the rule is fighting reality. Review whether it is still correct; supersede if not.`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
signal = 'gate_holding';
|
|
280
|
+
const heavy = gate_mode !== 'hard' && r.gate_contradicts >= minC * 2;
|
|
281
|
+
suggested_action = heavy ? 'escalate_to_hard' : 'none';
|
|
282
|
+
recommendation = `The gate caught ${r.gate_contradicts} attempt(s) and reality stayed clean — working as intended.${heavy ? " It keeps firing — consider gate_mode:'hard' to stop the attempts at the source." : ''}`;
|
|
283
|
+
}
|
|
284
|
+
out.push({
|
|
285
|
+
anchor_id: r.anchor_id,
|
|
286
|
+
statement: r.statement,
|
|
287
|
+
gate_mode,
|
|
288
|
+
lifecycle: r.lifecycle,
|
|
289
|
+
gate_contradicts: r.gate_contradicts,
|
|
290
|
+
gate_blocks: r.gate_blocks,
|
|
291
|
+
ignored: r.ignored,
|
|
292
|
+
reality_contradicts,
|
|
293
|
+
last_at: r.last_at,
|
|
294
|
+
signal,
|
|
295
|
+
suggested_action,
|
|
296
|
+
recommendation,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return out.sort((x, y) => y.reality_contradicts - x.reality_contradicts || y.gate_contradicts - x.gate_contradicts);
|
|
300
|
+
}
|
|
301
|
+
// One-call enforcement change — applies the friction "escalate_to_hard" recommendation. Merges gate_mode
|
|
302
|
+
// into the anchor's card_policy (preserving other keys). Exposed via resolve_drift(action:'harden'|'soften'),
|
|
303
|
+
// NOT a new tool — honoring anchor #1 ("public tools = 3, don't add a 4th"; the surface already drifted to 10).
|
|
304
|
+
export function setGateMode(db, anchorId, mode) {
|
|
305
|
+
const row = db.prepare(`SELECT card_policy FROM drift_anchors WHERE id = ?`).get(anchorId);
|
|
306
|
+
if (!row)
|
|
307
|
+
throw new Error(`anchor #${anchorId} not found`);
|
|
308
|
+
let policy = {};
|
|
309
|
+
try {
|
|
310
|
+
policy = JSON.parse(row.card_policy || '{}') || {};
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
policy = {};
|
|
314
|
+
}
|
|
315
|
+
policy.gate_mode = mode;
|
|
316
|
+
if (policy.enabled === undefined)
|
|
317
|
+
policy.enabled = true;
|
|
318
|
+
db.prepare(`UPDATE drift_anchors SET card_policy = ?, updated_at = unixepoch() WHERE id = ?`).run(JSON.stringify(policy), anchorId);
|
|
319
|
+
return { ok: true, anchor_id: anchorId, gate_mode: mode, card_policy: policy };
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function parseArray(s: string | null | undefined): string[];
|
|
2
|
+
export declare function normPath(p: string): string;
|
|
3
|
+
export declare function compileGlob(glob: string): (path: string) => boolean;
|
|
4
|
+
export declare const SCRAPE_ANCHOR: RegExp;
|
|
5
|
+
export declare function signalIndex(lowerLine: string, signal: string): number;
|
|
6
|
+
export declare function matchViolation(rawLine: string, lowerLine: string, signals: string[], isScrapeAnchor: boolean): string | null;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Lexical matching primitives — path-glob + violation-signal detection with precision guards.
|
|
2
|
+
//
|
|
3
|
+
// DUPLICATED VERBATIM from drift-detection.ts (parseArray / normPath / compileGlob / signalIndex /
|
|
4
|
+
// matchViolation / SCRAPE_ANCHOR). Kept standalone so the re-injection guard (guard.ts) reuses the
|
|
5
|
+
// EXACT same lexical logic as the post-hoc drift detector WITHOUT importing the detector (which carries
|
|
6
|
+
// unrelated state, and is mid-WIP). FOLLOW-UP: when drift-detection.ts's in-flight changes land, unify
|
|
7
|
+
// by having it import from here and deleting its private copies. No embedding layer in this stack:
|
|
8
|
+
// matching is substring + path-glob + word-boundary by construction.
|
|
9
|
+
export function parseArray(s) {
|
|
10
|
+
if (!s)
|
|
11
|
+
return [];
|
|
12
|
+
try {
|
|
13
|
+
const a = JSON.parse(s);
|
|
14
|
+
return Array.isArray(a) ? a.map((x) => String(x)) : [];
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Normalize a path/glob to canonical form (forward slashes, lowercase) so Windows reality
|
|
21
|
+
// (mixed `C:\...` and `C:/...`, mixed case) matches lowercase forward-slash anchor fragments.
|
|
22
|
+
export function normPath(p) {
|
|
23
|
+
return p.replace(/\\/g, '/').toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
// Compile an affects glob into a matcher over a normalized path. A glob with no wildcard is a plain
|
|
26
|
+
// substring; `*` matches within a path segment, `**` crosses segments; tested unanchored so a fragment
|
|
27
|
+
// matches anywhere in the absolute path.
|
|
28
|
+
export function compileGlob(glob) {
|
|
29
|
+
const g = normPath(glob.trim());
|
|
30
|
+
if (!g)
|
|
31
|
+
return () => false;
|
|
32
|
+
if (!/[*?]/.test(g)) {
|
|
33
|
+
return (path) => path.includes(g);
|
|
34
|
+
}
|
|
35
|
+
let re = '';
|
|
36
|
+
for (let i = 0; i < g.length; i++) {
|
|
37
|
+
const c = g[i];
|
|
38
|
+
if (c === '*') {
|
|
39
|
+
if (g[i + 1] === '*') {
|
|
40
|
+
re += '.*';
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
re += '[^/]*';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (c === '?') {
|
|
48
|
+
re += '[^/]';
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const rx = new RegExp(re);
|
|
55
|
+
return (path) => rx.test(path);
|
|
56
|
+
}
|
|
57
|
+
// ── precision guards (mirror drift-detection.ts §"precision guards") ───────────
|
|
58
|
+
// A raw substring of a forbidden term is too weak a notion of "violation"; reject three FP classes
|
|
59
|
+
// deductively: 1. sub-token ("cp" in "scp") → word-boundary; 2. negated ("do NOT cp") → negation
|
|
60
|
+
// window before; 3. citation (a scrape-host appears only as a stored value) → require a real net call.
|
|
61
|
+
const NEGATION_NEAR = /\b(?:not|never|no\s+longer|don'?t|do\s+not|avoid|without)\b|禁止|しない|させない|不可|避け|してはいけない|ではなく/i;
|
|
62
|
+
export const SCRAPE_ANCHOR = /crawl|scrap|robots|クロール|スクレイピング|スクレイプ|自動収集|自動巡回/i;
|
|
63
|
+
const NET_CALL = /\b(?:fetch|axios|requests?|urllib|httpx|curl|wget|got|puppeteer|playwright|cheerio|beautifulsoup|selenium|crawl|scrape|scraping)\b|クロール|スクレイピング/i;
|
|
64
|
+
// Locate a signal: ASCII signals must hit on a word boundary; CJK (no boundaries) stays substring.
|
|
65
|
+
export function signalIndex(lowerLine, signal) {
|
|
66
|
+
if (/^[\x20-\x7e]+$/.test(signal)) {
|
|
67
|
+
const esc = signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
68
|
+
const m = new RegExp(`(?<![a-z0-9_])${esc}(?![a-z0-9_])`).exec(lowerLine);
|
|
69
|
+
return m ? m.index : -1;
|
|
70
|
+
}
|
|
71
|
+
return lowerLine.indexOf(signal);
|
|
72
|
+
}
|
|
73
|
+
// Deductive violation match with the three precision guards applied (returns the hit term or null).
|
|
74
|
+
export function matchViolation(rawLine, lowerLine, signals, isScrapeAnchor) {
|
|
75
|
+
for (const s of signals) {
|
|
76
|
+
const idx = signalIndex(lowerLine, s);
|
|
77
|
+
if (idx < 0)
|
|
78
|
+
continue; // 1. word-boundary
|
|
79
|
+
if (NEGATION_NEAR.test(rawLine.slice(Math.max(0, idx - 24), idx)))
|
|
80
|
+
continue; // 2. negated context
|
|
81
|
+
if (isScrapeAnchor && !NET_CALL.test(rawLine))
|
|
82
|
+
continue; // 3. citation, no net call
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=lexical-match.js.map
|
package/dist/lib/momentum.js
CHANGED
|
@@ -52,13 +52,13 @@ export function refreshMomentumForEntity(db, entityId) {
|
|
|
52
52
|
const dayAgo = now - 86400;
|
|
53
53
|
const weekAgo = now - 7 * 86400;
|
|
54
54
|
const stats = db
|
|
55
|
-
.prepare(`
|
|
56
|
-
SELECT
|
|
57
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e24,
|
|
58
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e7d,
|
|
59
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ?) as eAll,
|
|
60
|
-
(SELECT created_at FROM entities WHERE id = ?) as createdAt,
|
|
61
|
-
(SELECT COALESCE(AVG(importance), 0.5) FROM memories WHERE entity_id = ? AND created_at >= ?) as avgImpRecent
|
|
55
|
+
.prepare(`
|
|
56
|
+
SELECT
|
|
57
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e24,
|
|
58
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e7d,
|
|
59
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ?) as eAll,
|
|
60
|
+
(SELECT created_at FROM entities WHERE id = ?) as createdAt,
|
|
61
|
+
(SELECT COALESCE(AVG(importance), 0.5) FROM memories WHERE entity_id = ? AND created_at >= ?) as avgImpRecent
|
|
62
62
|
`)
|
|
63
63
|
.get(entityId, dayAgo, entityId, weekAgo, entityId, entityId, entityId, weekAgo);
|
|
64
64
|
const daysObserved = stats.createdAt ? Math.max(1, (now - stats.createdAt) / 86400) : 1;
|