hippo-memory 1.49.0 → 1.51.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/dist/config.js CHANGED
@@ -73,6 +73,9 @@ const DEFAULT_CONFIG = {
73
73
  enabled: true,
74
74
  retentionDays: 180,
75
75
  },
76
+ churnStaleness: {
77
+ enabled: false,
78
+ },
76
79
  };
77
80
  function isMemoryValueConfig(value) {
78
81
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -80,6 +83,9 @@ function isMemoryValueConfig(value) {
80
83
  function isDormantConfig(value) {
81
84
  return typeof value === 'object' && value !== null && !Array.isArray(value);
82
85
  }
86
+ function isChurnStalenessConfig(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
83
89
  export function loadConfig(hippoRoot) {
84
90
  const configPath = path.join(hippoRoot, 'config.json');
85
91
  if (!fs.existsSync(configPath))
@@ -124,6 +130,21 @@ export function loadConfig(hippoRoot) {
124
130
  `(got ${JSON.stringify(dormantRetentionDays)}) - using ${DEFAULT_CONFIG.dormant.retentionDays}.`);
125
131
  dormantRetentionDays = DEFAULT_CONFIG.dormant.retentionDays;
126
132
  }
133
+ // Same "never silently wrong" rule as memoryValue/dormant above.
134
+ const churnStalenessRaw = raw.churnStaleness;
135
+ const validChurnStalenessConfig = churnStalenessRaw === undefined || isChurnStalenessConfig(churnStalenessRaw);
136
+ if (!validChurnStalenessConfig) {
137
+ console.error(`Warning: config.json's "churnStaleness" must be an object like {"enabled": true} ` +
138
+ `(got ${JSON.stringify(churnStalenessRaw)}) - using defaults.`);
139
+ }
140
+ let churnStalenessEnabled = churnStalenessRaw !== undefined && isChurnStalenessConfig(churnStalenessRaw)
141
+ ? churnStalenessRaw.enabled
142
+ : DEFAULT_CONFIG.churnStaleness.enabled;
143
+ if (churnStalenessEnabled !== true && churnStalenessEnabled !== false) {
144
+ console.error(`Warning: config.json's "churnStaleness.enabled" must be true or false ` +
145
+ `(got ${JSON.stringify(churnStalenessEnabled)}) - using false.`);
146
+ churnStalenessEnabled = false;
147
+ }
127
148
  return {
128
149
  defaultHalfLifeDays: raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays,
129
150
  defaultBudget: raw.defaultBudget ?? DEFAULT_CONFIG.defaultBudget,
@@ -155,6 +176,9 @@ export function loadConfig(hippoRoot) {
155
176
  enabled: dormantEnabled,
156
177
  retentionDays: dormantRetentionDays,
157
178
  },
179
+ churnStaleness: {
180
+ enabled: churnStalenessEnabled,
181
+ },
158
182
  };
159
183
  }
160
184
  catch (err) {
@@ -108,6 +108,7 @@ export function ingestEvent(ctx, input) {
108
108
  // v1.12.0: drop the legacy `|| 'connector:github'` fallback (see slack/ingest.ts:73 for rationale).
109
109
  const result = remember(ctx, {
110
110
  ...opts,
111
+ untrusted: true,
111
112
  afterWrite: (innerDb, memoryId) => {
112
113
  if (input.__testInjectBeforeLog) {
113
114
  input.__testInjectBeforeLog(innerDb, idempotencyKey);
@@ -61,6 +61,7 @@ export function ingestMessage(ctx, input) {
61
61
  // ever missing); explicit reliance on the caller is safer.
62
62
  const result = remember(ctx, {
63
63
  ...opts,
64
+ untrusted: true,
64
65
  afterWrite: (innerDb, memoryId) => {
65
66
  const inserted = innerDb
66
67
  .prepare(`INSERT OR IGNORE INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`)
@@ -26,6 +26,7 @@ import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-wei
26
26
  import { appendAuditEvent } from './audit.js';
27
27
  import { migrateDefaultHalfLife } from './half-life-migration.js';
28
28
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
29
+ import { isQuarantineScope } from './quarantine.js';
29
30
  const DECAY_THRESHOLD = 0.05;
30
31
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
31
32
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -945,6 +946,8 @@ function detectConflicts(entries, now, decayOpts = {},
945
946
  // Default empty set: flag-off behavior is unchanged.
946
947
  rescuedIds = new Set()) {
947
948
  const survivors = entries.filter((entry) => entry.layer !== Layer.Semantic
949
+ // CD5: an unreviewed quarantined row must not taint a visible memory as conflicted.
950
+ && !isQuarantineScope(entry.scope ?? null)
948
951
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
949
952
  const detected = [];
950
953
  for (let i = 0; i < survivors.length; i++) {
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 47;
14
+ const CURRENT_SCHEMA_VERSION = 48;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2463,6 +2463,14 @@ const MIGRATIONS = [
2463
2463
  `);
2464
2464
  },
2465
2465
  },
2466
+ {
2467
+ version: 48,
2468
+ up: (db) => {
2469
+ // Quarantine (src/quarantine.ts, CD5): a poisoned or suspect memory sits here pending
2470
+ // admin review instead of being hidden with no record. Additive only: no min_compatible_binary bump.
2471
+ db.exec(MEMORY_QUARANTINE_DDL);
2472
+ },
2473
+ },
2466
2474
  ];
2467
2475
  function tableHasColumn(db, tableName, columnName) {
2468
2476
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2628,6 +2636,22 @@ function setSchemaVersion(db, version) {
2628
2636
  db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
2629
2637
  db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
2630
2638
  }
2639
+ // Shared by migration v48 and the stamped-store self-heal below.
2640
+ const MEMORY_QUARANTINE_DDL = `
2641
+ CREATE TABLE IF NOT EXISTS memory_quarantine (
2642
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2643
+ memory_id TEXT NOT NULL,
2644
+ original_scope TEXT,
2645
+ reason TEXT NOT NULL,
2646
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
2647
+ quarantined_at TEXT NOT NULL,
2648
+ decided_at TEXT,
2649
+ decided_by TEXT,
2650
+ PRIMARY KEY (tenant_id, memory_id)
2651
+ ) WITHOUT ROWID;
2652
+ CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status
2653
+ ON memory_quarantine(tenant_id, status, quarantined_at DESC);
2654
+ `;
2631
2655
  // Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
2632
2656
  // incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
2633
2657
  function ensureContinuityTables(db) {
@@ -2729,6 +2753,7 @@ function ensureContinuityTables(db) {
2729
2753
  tenant_id TEXT NOT NULL DEFAULT 'default'
2730
2754
  )
2731
2755
  `);
2756
+ db.exec(MEMORY_QUARANTINE_DDL);
2732
2757
  }
2733
2758
  // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
2734
2759
  function ensureContinuityIndexes(db) {
@@ -0,0 +1,7 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ export interface InstructionDetection {
3
+ flagged: boolean;
4
+ reason: string | null;
5
+ }
6
+ export declare function detectInstruction(content: string): InstructionDetection;
7
+ //# sourceMappingURL=instruction-detect.d.ts.map
@@ -0,0 +1,27 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ const INSTRUCTION_PATTERNS = [
3
+ { name: 'override-instructions', re: /\b(?:ignore|disregard|forget)\b[^.\n]{0,40}\b(?:previous|prior|above)\b[^.\n]{0,40}\binstructions?\b/i },
4
+ { name: 'role-reassignment', re: /\b(?:you are now|act as|pretend to be)\b[^.\n]{0,40}\b(?:an?\s+)?(?:ai|assistant|agent|system)\b/i },
5
+ { name: 'chat-role-markup', re: /<\|im_start\|>|<\|system\|>|\[INST\]|<\/?system>|###\s*system\s*:/i },
6
+ { name: 'system-prompt-reference', re: /\b(?:(?:override|ignore|bypass|replace|reveal)\b[^.\n]{0,40}\b(?:system prompt|developer message)|(?:system prompt|developer message)\b[^.\n]{0,40}\b(?:override|ignore|bypass|replace|reveal))\b/i },
7
+ // "always"/"never" alone (ordinary PR prose) doesn't flag without an agent-facing target nearby too.
8
+ { name: 'standing-order', re: /\b(?:from now on|whenever you|always|never)\b[^.\n]{0,50}\b(?:you must|(?:the ai|the assistant|the agent|claude|copilot)\s+(?:must|should|will)\b)/i },
9
+ { name: 'concealment', re: /\b(?:do not|never|don't)\b[^.\n]{0,40}\b(?:tell|mention|reveal)\b[^.\n]{0,40}\bthe user\b/i },
10
+ { name: 'remote-script-exec', re: /\b(?:curl|wget)\b[^\n|]{0,80}\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b|\biex\s*\(\s*(?:iwr|invoke-webrequest|new-object\s+net\.webclient)/i },
11
+ { name: 'exfiltration', re: /\b(?:send|post|upload|exfiltrate)\b[^.\n]{0,40}\b(?:secrets?|tokens?|api[- ]?keys?|credentials?|env(?:ironment)? variables?)\b/i },
12
+ { name: 'unicode-tag-chars', re: /[\u{E0000}-\u{E007F}]/u },
13
+ ];
14
+ // A stray zero-width char has legitimate uses (ZWJ); only a run of 3+ adjacent ones reads as smuggling.
15
+ const ZERO_WIDTH_RUN_RE = /[\u200B-\u200F\u2060-\u2064]{3,}/;
16
+ export function detectInstruction(content) {
17
+ for (const { name, re } of INSTRUCTION_PATTERNS) {
18
+ if (re.test(content)) {
19
+ return { flagged: true, reason: `pattern:${name}` };
20
+ }
21
+ }
22
+ if (ZERO_WIDTH_RUN_RE.test(content)) {
23
+ return { flagged: true, reason: 'pattern:zero-width-smuggling' };
24
+ }
25
+ return { flagged: false, reason: null };
26
+ }
27
+ //# sourceMappingURL=instruction-detect.js.map
@@ -38,4 +38,34 @@ export declare function extractInvalidationTarget(message: string): Invalidation
38
38
  * auto-learn-from-git path) inherit this contract.
39
39
  */
40
40
  export declare function invalidateMatching(hippoRoot: string, target: InvalidationTarget, tenantId?: string, options?: InvalidationOptions): InvalidationResult;
41
+ export interface ChurnRefs {
42
+ paths: string[];
43
+ symbols: string[];
44
+ scripts: string[];
45
+ }
46
+ /** Pure syntactic extraction; repo-aware resolution (trackedness, path relativization) happens only in detectChurnStale. */
47
+ export declare function extractChurnRefs(content: string): ChurnRefs;
48
+ export interface DetectChurnStaleOptions {
49
+ tenantId: string;
50
+ projectName: string;
51
+ /** Evaluate matches but write nothing. */
52
+ dryRun?: boolean;
53
+ }
54
+ export interface ChurnStalePreviewRow {
55
+ id: string;
56
+ headline: string;
57
+ evidence: string;
58
+ already: boolean;
59
+ }
60
+ export interface ChurnStaleResult {
61
+ checked: number;
62
+ marked: number;
63
+ alreadyMarked: number;
64
+ skippedPinned: string[];
65
+ dryRun: boolean;
66
+ preview: ChurnStalePreviewRow[];
67
+ error?: string;
68
+ }
69
+ /** Flags memories whose named file/symbol/script changed or disappeared since storage (or last confirmation); only adds/removes CHURN_STALE_TAG, never confidence/half-life/strength. */
70
+ export declare function detectChurnStale(hippoRoot: string, repoRoot: string, opts: DetectChurnStaleOptions): ChurnStaleResult;
41
71
  //# sourceMappingURL=invalidation.d.ts.map
@@ -1,4 +1,7 @@
1
- import { loadAllEntries, writeEntry } from './store.js';
1
+ import { loadAllEntries, readEntry, writeEntry } from './store.js';
2
+ import { openHippoDb, closeHippoDb } from './db.js';
3
+ import { CHURN_STALE_TAG } from './memory.js';
4
+ import { GitReadError, gitLsFilesAtHead, fetchChurnWindowLog, gitGrepPresence, resolveCommitBefore, packageScriptsAt, } from './churn-git.js';
2
5
  /**
3
6
  * Extract what was replaced/removed from a commit message.
4
7
  * Returns null if the commit isn't a breaking/migration change.
@@ -126,4 +129,266 @@ function matchScore(fromTokens, contentTokens) {
126
129
  const matches = fromTokens.filter(t => contentSet.has(t)).length;
127
130
  return matches / fromTokens.length;
128
131
  }
132
+ // FE2: staleness from code churn (opt-in, ROADMAP Part XIII).
133
+ const CHURN_PATH_EXTENSIONS = [
134
+ 'ts', 'tsx', 'js', 'mjs', 'cjs', 'py', 'md', 'json', 'sh', 'ps1',
135
+ 'toml', 'yml', 'yaml', 'sql', 'rs', 'go', 'css', 'html',
136
+ ];
137
+ const CHURN_PATH_RE = new RegExp(
138
+ // The lookahead stops `ts` winning over `tsx` and `js` over `json`.
139
+ `[A-Za-z0-9_./\\\\:-]+\\.(?:${CHURN_PATH_EXTENSIONS.join('|')})(?![A-Za-z0-9_])(?::\\d+(?:-\\d+)?)?`, 'g');
140
+ const CHURN_SYMBOL_RE = /`([A-Za-z_$][A-Za-z0-9_$]*)(?:\(\))?`/g;
141
+ const CHURN_SCRIPT_RE = /npm run(?:-script)?\s+([A-Za-z0-9:_-]+)/g;
142
+ /** Pure syntactic extraction; repo-aware resolution (trackedness, path relativization) happens only in detectChurnStale. */
143
+ export function extractChurnRefs(content) {
144
+ const paths = new Set();
145
+ for (const m of content.matchAll(CHURN_PATH_RE)) {
146
+ let token = m[0].replace(/:\d+(?:-\d+)?$/, '');
147
+ token = token.replace(/\\/g, '/').replace(/^\.\//, '');
148
+ if (token)
149
+ paths.add(token);
150
+ }
151
+ const symbols = new Set();
152
+ for (const m of content.matchAll(CHURN_SYMBOL_RE)) {
153
+ const name = m[1];
154
+ // Plain words never count: require a camelCase/PascalCase transition or an inner underscore.
155
+ if (name.length >= 6 && (/[a-z][A-Z]/.test(name) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(name))) {
156
+ symbols.add(name);
157
+ }
158
+ }
159
+ const scripts = new Set();
160
+ for (const m of content.matchAll(CHURN_SCRIPT_RE))
161
+ scripts.add(m[1]);
162
+ return { paths: [...paths], symbols: [...symbols], scripts: [...scripts] };
163
+ }
164
+ const IS_WIN32 = process.platform === 'win32';
165
+ function buildTrackedIndex(paths) {
166
+ const lower = new Map();
167
+ for (const p of paths)
168
+ lower.set(p.toLowerCase(), p);
169
+ return { exact: paths, lower };
170
+ }
171
+ // Returns git's spelling so later exact matches against the log agree on case-insensitive Windows.
172
+ function trackedSpelling(index, candidate) {
173
+ if (index.exact.has(candidate))
174
+ return candidate;
175
+ return IS_WIN32 ? index.lower.get(candidate.toLowerCase()) ?? null : null;
176
+ }
177
+ function isTrackedPath(index, candidate) {
178
+ return trackedSpelling(index, candidate) !== null;
179
+ }
180
+ // Absolute-path relativization and the <repoName>/ strip both need the repo, so they
181
+ // happen here rather than in the pure extractor.
182
+ function resolveTrackedPath(token, repoRoot, projectName, index) {
183
+ let candidate = token;
184
+ const isAbsUnix = candidate.startsWith('/');
185
+ const isAbsWin = /^[A-Za-z]:\//.test(candidate);
186
+ if (isAbsUnix || isAbsWin) {
187
+ const rootPosix = repoRoot.replace(/\\/g, '/').replace(/\/$/, '');
188
+ const prefix = `${rootPosix}/`;
189
+ const under = IS_WIN32
190
+ ? candidate.toLowerCase().startsWith(prefix.toLowerCase())
191
+ : candidate.startsWith(prefix);
192
+ if (!under)
193
+ return null;
194
+ candidate = candidate.slice(prefix.length);
195
+ }
196
+ const direct = trackedSpelling(index, candidate);
197
+ if (direct)
198
+ return direct;
199
+ const repoPrefix = `${projectName}/`;
200
+ if (candidate.toLowerCase().startsWith(repoPrefix.toLowerCase())) {
201
+ return trackedSpelling(index, candidate.slice(repoPrefix.length));
202
+ }
203
+ return null;
204
+ }
205
+ // Anchor SQL runs on its own handle (closed in finally); queryAuditEvents is NOT
206
+ // reused here because its 10k-row cap would silently miss old confirmations.
207
+ function queryConfirmedAt(hippoRoot, tenantId) {
208
+ const db = openHippoDb(hippoRoot);
209
+ try {
210
+ // SAFETY: the SELECT list above is exactly target_id and ts; no other shape reaches this cast.
211
+ const rows = db.prepare(`SELECT target_id, MAX(ts) AS ts FROM audit_log
212
+ WHERE tenant_id = ? AND op = 'outcome' AND target_id IS NOT NULL
213
+ AND json_extract(metadata_json, '$.good') = 1
214
+ GROUP BY target_id`).all(tenantId);
215
+ return new Map(rows.map((r) => [r.target_id, r.ts]));
216
+ }
217
+ finally {
218
+ closeHippoDb(db);
219
+ }
220
+ }
221
+ function emptyChurnResult(dryRun, skippedPinned = [], error) {
222
+ const result = { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
223
+ if (error !== undefined)
224
+ result.error = error;
225
+ return result;
226
+ }
227
+ /** Flags memories whose named file/symbol/script changed or disappeared since storage (or last confirmation); only adds/removes CHURN_STALE_TAG, never confidence/half-life/strength. */
228
+ export function detectChurnStale(hippoRoot, repoRoot, opts) {
229
+ const dryRun = opts.dryRun === true;
230
+ if (!opts.projectName)
231
+ return emptyChurnResult(dryRun);
232
+ const entries = loadAllEntries(hippoRoot, opts.tenantId);
233
+ const skippedPinned = [];
234
+ const candidates = [];
235
+ for (const entry of entries) {
236
+ if (!entry.origin_project || entry.origin_project !== opts.projectName)
237
+ continue;
238
+ if (entry.superseded_by)
239
+ continue;
240
+ if (entry.kind === 'raw' || entry.kind === 'archived')
241
+ continue;
242
+ if (entry.pinned) {
243
+ skippedPinned.push(entry.id);
244
+ continue;
245
+ }
246
+ // An unparsable created has no anchor to compare commits against.
247
+ if (Number.isNaN(Date.parse(entry.created)))
248
+ continue;
249
+ candidates.push({ entry, refs: extractChurnRefs(entry.content) });
250
+ }
251
+ if (candidates.length === 0)
252
+ return emptyChurnResult(dryRun, skippedPinned);
253
+ const confirmedAt = queryConfirmedAt(hippoRoot, opts.tenantId);
254
+ // Compared as epoch ms, not strings: imported rows may carry offsets or other ISO forms.
255
+ const anchorOf = (entry) => {
256
+ const confirmed = confirmedAt.get(entry.id);
257
+ const confirmedMs = confirmed ? Date.parse(confirmed) : NaN;
258
+ return confirmedMs > Date.parse(entry.created) ? new Date(confirmedMs).toISOString() : new Date(entry.created).toISOString();
259
+ };
260
+ const needsPaths = candidates.some((c) => c.refs.paths.length > 0);
261
+ const needsSymbols = candidates.some((c) => c.refs.symbols.length > 0);
262
+ const needsScripts = candidates.some((c) => c.refs.scripts.length > 0);
263
+ if (!needsPaths && !needsSymbols && !needsScripts) {
264
+ return { checked: candidates.length, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
265
+ }
266
+ const result = { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
267
+ try {
268
+ const headFiles = gitLsFilesAtHead(repoRoot);
269
+ let windowLog = [];
270
+ if (needsPaths) {
271
+ const minAnchor = candidates.reduce((min, c) => {
272
+ const a = anchorOf(c.entry);
273
+ return min === '' || a < min ? a : min;
274
+ }, '');
275
+ windowLog = fetchChurnWindowLog(repoRoot, minAnchor);
276
+ }
277
+ const touchedInWindow = new Set();
278
+ for (const c of windowLog)
279
+ for (const f of c.files)
280
+ touchedInWindow.add(f.path);
281
+ const trackedIndex = buildTrackedIndex(new Set([...headFiles, ...touchedInWindow]));
282
+ const headIndex = buildTrackedIndex(headFiles);
283
+ const presentAtHeadSymbols = needsSymbols
284
+ ? gitGrepPresence(repoRoot, [...new Set(candidates.flatMap((c) => c.refs.symbols))], 'HEAD')
285
+ : new Set();
286
+ const anchorCommitCache = new Map();
287
+ const resolveAnchorCommit = (anchorIso) => {
288
+ if (!anchorCommitCache.has(anchorIso)) {
289
+ anchorCommitCache.set(anchorIso, resolveCommitBefore(repoRoot, anchorIso));
290
+ }
291
+ return anchorCommitCache.get(anchorIso) ?? null;
292
+ };
293
+ const symbolsAtCommit = new Map();
294
+ const symbolsPresentAt = (hash, symbols) => {
295
+ const known = symbolsAtCommit.get(hash) ?? new Set();
296
+ const missing = symbols.filter((s) => !known.has(s));
297
+ if (missing.length > 0) {
298
+ for (const s of gitGrepPresence(repoRoot, missing, hash))
299
+ known.add(s);
300
+ symbolsAtCommit.set(hash, known);
301
+ }
302
+ return known;
303
+ };
304
+ const headScripts = needsScripts ? packageScriptsAt(repoRoot, 'HEAD') : null;
305
+ const scriptsAtCommit = new Map();
306
+ // Collected here, written only after every candidate's evidence is
307
+ // computed: a GitReadError thrown mid-loop must never leave an earlier
308
+ // candidate tagged while a later one aborts the run untagged.
309
+ const toTag = [];
310
+ for (const { entry, refs } of candidates) {
311
+ result.checked++;
312
+ const anchor = anchorOf(entry);
313
+ const anchorTime = new Date(anchor).getTime();
314
+ let evidence = null;
315
+ for (const rawPath of refs.paths) {
316
+ const resolved = resolveTrackedPath(rawPath, repoRoot, opts.projectName, trackedIndex);
317
+ if (!resolved)
318
+ continue;
319
+ if (isTrackedPath(headIndex, resolved)) {
320
+ const changed = windowLog.some((c) => new Date(c.date).getTime() > anchorTime && c.files.some((f) => f.path === resolved));
321
+ if (changed) {
322
+ evidence = `file-changed: ${resolved}`;
323
+ break;
324
+ }
325
+ }
326
+ else {
327
+ // --no-renames means a rename shows as a D + A pair, so this also fires for renames.
328
+ const deleted = windowLog.some((c) => new Date(c.date).getTime() > anchorTime &&
329
+ c.files.some((f) => f.status === 'D' && f.path === resolved));
330
+ if (deleted) {
331
+ evidence = `file-deleted: ${resolved}`;
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ if (!evidence && refs.symbols.length > 0) {
337
+ const absent = refs.symbols.filter((s) => !presentAtHeadSymbols.has(s));
338
+ if (absent.length > 0) {
339
+ const anchorCommit = resolveAnchorCommit(anchor);
340
+ if (anchorCommit) {
341
+ const presentAtAnchor = symbolsPresentAt(anchorCommit, absent);
342
+ const hit = absent.find((s) => presentAtAnchor.has(s));
343
+ if (hit)
344
+ evidence = `symbol-gone: \`${hit}\``;
345
+ }
346
+ }
347
+ }
348
+ if (!evidence && refs.scripts.length > 0 && headScripts !== null) {
349
+ const anchorCommit = resolveAnchorCommit(anchor);
350
+ if (anchorCommit) {
351
+ if (!scriptsAtCommit.has(anchorCommit)) {
352
+ scriptsAtCommit.set(anchorCommit, packageScriptsAt(repoRoot, anchorCommit));
353
+ }
354
+ const anchorScripts = scriptsAtCommit.get(anchorCommit);
355
+ const hit = anchorScripts
356
+ ? refs.scripts.find((s) => anchorScripts[s] !== undefined && headScripts[s] === undefined)
357
+ : undefined;
358
+ if (hit)
359
+ evidence = `script-gone: ${hit}`;
360
+ }
361
+ }
362
+ if (!evidence)
363
+ continue;
364
+ const headline = entry.content.replace(/\s+/g, ' ').slice(0, 60);
365
+ if (entry.tags.includes(CHURN_STALE_TAG)) {
366
+ result.alreadyMarked++;
367
+ result.preview.push({ id: entry.id, headline, evidence, already: true });
368
+ continue;
369
+ }
370
+ result.marked++;
371
+ result.preview.push({ id: entry.id, headline, evidence, already: false });
372
+ toTag.push(entry);
373
+ }
374
+ if (!dryRun && toTag.length > 0) {
375
+ // The git calls above can take seconds; a good outcome landing meanwhile moves the anchor past the evidence.
376
+ const confirmedNow = queryConfirmedAt(hippoRoot, opts.tenantId);
377
+ for (const stale of toTag) {
378
+ if (confirmedNow.get(stale.id) !== confirmedAt.get(stale.id))
379
+ continue;
380
+ const entry = readEntry(hippoRoot, stale.id, opts.tenantId);
381
+ if (!entry || entry.tags.includes(CHURN_STALE_TAG))
382
+ continue;
383
+ writeEntry(hippoRoot, { ...entry, tags: [...entry.tags, CHURN_STALE_TAG] });
384
+ }
385
+ }
386
+ }
387
+ catch (err) {
388
+ if (err instanceof GitReadError)
389
+ return emptyChurnResult(dryRun, skippedPinned, err.message);
390
+ throw err;
391
+ }
392
+ return result;
393
+ }
129
394
  //# sourceMappingURL=invalidation.js.map
package/dist/memory.d.ts CHANGED
@@ -120,6 +120,8 @@ export declare const POLICY_HALF_LIFE_DAYS = 90;
120
120
  export declare const SKILL_HALF_LIFE_DAYS = 90;
121
121
  export declare const PROJECT_BRIEF_HALF_LIFE_DAYS = 90;
122
122
  export declare const CUSTOMER_NOTE_HALF_LIFE_DAYS = 90;
123
+ /** FE2: tag on a memory whose named file/symbol/script changed after it was stored. */
124
+ export declare const CHURN_STALE_TAG = "churn-stale";
123
125
  /**
124
126
  * Test-only helper. Tests that mutate `process.env.HIPPO_LOSS_AVERSION_RATIO`
125
127
  * MUST call this in BOTH `beforeEach` AND `afterEach`:
package/dist/memory.js CHANGED
@@ -18,6 +18,8 @@ export const POLICY_HALF_LIFE_DAYS = 90;
18
18
  export const SKILL_HALF_LIFE_DAYS = 90;
19
19
  export const PROJECT_BRIEF_HALF_LIFE_DAYS = 90;
20
20
  export const CUSTOMER_NOTE_HALF_LIFE_DAYS = 90;
21
+ /** FE2: tag on a memory whose named file/symbol/script changed after it was stored. */
22
+ export const CHURN_STALE_TAG = 'churn-stale';
21
23
  // Emotional multipliers from PLAN.md.
22
24
  //
23
25
  // v1.13.5 / J5 loss-aversion calibration (Lovallo-Kahneman TFAS empirics:
@@ -0,0 +1,31 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import type { DatabaseSyncLike } from './db.js';
3
+ export declare const QUARANTINE_SCOPE_PREFIX = "quarantine:private:";
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export declare function quarantineScopeFor(original: string | null): string;
6
+ export declare function isQuarantineScope(scope: string | null | undefined): boolean;
7
+ export type QuarantineStatus = 'pending' | 'approved' | 'rejected';
8
+ export interface QuarantineRow {
9
+ tenantId: string;
10
+ memoryId: string;
11
+ originalScope: string | null;
12
+ reason: string;
13
+ status: QuarantineStatus;
14
+ quarantinedAt: string;
15
+ decidedAt: string | null;
16
+ decidedBy: string | null;
17
+ }
18
+ export interface RecordQuarantineOpts {
19
+ tenantId: string;
20
+ memoryId: string;
21
+ originalScope: string | null;
22
+ reason: string;
23
+ actor: string;
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export declare function recordQuarantine(db: DatabaseSyncLike, opts: RecordQuarantineOpts): void;
27
+ export declare function getQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string): QuarantineRow | null;
28
+ export declare function listQuarantineRows(db: DatabaseSyncLike, tenantId: string, status?: QuarantineStatus | 'all', limit?: number): QuarantineRow[];
29
+ export declare function approveQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
30
+ export declare function rejectQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
31
+ //# sourceMappingURL=quarantine.d.ts.map
@@ -0,0 +1,63 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import { appendAuditEvent } from './audit.js';
3
+ export const QUARANTINE_SCOPE_PREFIX = 'quarantine:private:';
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export function quarantineScopeFor(original) {
6
+ return `${QUARANTINE_SCOPE_PREFIX}${original ?? 'unscoped'}`;
7
+ }
8
+ export function isQuarantineScope(scope) {
9
+ return scope != null && scope.startsWith(QUARANTINE_SCOPE_PREFIX);
10
+ }
11
+ const SELECT_COLUMNS = 'tenant_id, memory_id, original_scope, reason, status, quarantined_at, decided_at, decided_by';
12
+ function fromDbRow(row) {
13
+ return {
14
+ tenantId: row.tenant_id,
15
+ memoryId: row.memory_id,
16
+ originalScope: row.original_scope,
17
+ reason: row.reason,
18
+ // SAFETY: this module's own INSERT/UPDATE statements are the only writers of status.
19
+ status: row.status,
20
+ quarantinedAt: row.quarantined_at,
21
+ decidedAt: row.decided_at,
22
+ decidedBy: row.decided_by,
23
+ };
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export function recordQuarantine(db, opts) {
27
+ db.prepare(`INSERT INTO memory_quarantine (tenant_id, memory_id, original_scope, reason, status, quarantined_at)
28
+ VALUES (?, ?, ?, ?, 'pending', ?)`).run(opts.tenantId, opts.memoryId, opts.originalScope, opts.reason, new Date().toISOString());
29
+ appendAuditEvent(db, {
30
+ tenantId: opts.tenantId,
31
+ actor: opts.actor,
32
+ op: 'quarantine',
33
+ targetId: opts.memoryId,
34
+ metadata: { reason: opts.reason, originalScope: opts.originalScope },
35
+ });
36
+ }
37
+ export function getQuarantineRow(db, tenantId, memoryId) {
38
+ const row = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND memory_id = ?`)
39
+ .get(tenantId, memoryId);
40
+ return row ? fromDbRow(row) : null;
41
+ }
42
+ export function listQuarantineRows(db, tenantId, status = 'pending', limit = 100) {
43
+ // A pending row whose memory was deleted (e.g. Slack message_deleted) is dead; keep its history, drop it from the queue.
44
+ const live = status === 'pending'
45
+ ? ' AND EXISTS (SELECT 1 FROM memories m WHERE m.id = memory_quarantine.memory_id AND m.tenant_id = memory_quarantine.tenant_id)'
46
+ : '';
47
+ const rows = status === 'all'
48
+ ? db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? ORDER BY quarantined_at DESC LIMIT ?`)
49
+ .all(tenantId, limit)
50
+ : db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND status = ?${live} ORDER BY quarantined_at DESC LIMIT ?`)
51
+ .all(tenantId, status, limit);
52
+ // SAFETY: both branches select SELECT_COLUMNS, matching QuarantineDbRow's field set.
53
+ return rows.map(fromDbRow);
54
+ }
55
+ export function approveQuarantineRow(db, tenantId, memoryId, decidedBy) {
56
+ db.prepare(`UPDATE memory_quarantine SET status = 'approved', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
57
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
58
+ }
59
+ export function rejectQuarantineRow(db, tenantId, memoryId, decidedBy) {
60
+ db.prepare(`UPDATE memory_quarantine SET status = 'rejected', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
61
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
62
+ }
63
+ //# sourceMappingURL=quarantine.js.map
package/dist/search.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import { estimateTokens } from './token-ledger.js';
6
6
  import { MemoryEntry } from './memory.js';
7
7
  import type { PhysicsConfig } from './physics-config.js';
8
+ export declare const CHURN_STALE_RANK_MULTIPLIER = 0.5;
9
+ export declare function churnStaleFactor(entry: MemoryEntry): number;
8
10
  export declare function tokenize(text: string): string[];
9
11
  /**
10
12
  * Tokenized BM25 corpus. Callers can pre-build this with `buildCorpus` once
@@ -119,6 +121,8 @@ export interface ScoreBreakdown {
119
121
  * to [0.85, 1.15]. Immediate nudge from `hippo outcome --good/--bad`.
120
122
  * Separate from the slow strength-via-reward-factor path. */
121
123
  outcomeBoost: number;
124
+ /** FE2: CHURN_STALE_RANK_MULTIPLIER if tagged 'churn-stale', else 1.0. */
125
+ churnStaleMultiplier: number;
122
126
  /** Pre-MMR rank (1-indexed). Only set when MMR re-ranking ran. */
123
127
  preMmrRank?: number;
124
128
  /** Post-MMR rank (1-indexed). Only set when MMR re-ranking ran. */