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.
@@ -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
@@ -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;
@@ -4,6 +4,7 @@ export interface ExtractedMemory {
4
4
  content: string;
5
5
  importance: number;
6
6
  thread_id: string;
7
+ occurred_at?: number;
7
8
  source: {
8
9
  session_id: string;
9
10
  turn_uuid?: string;