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/api.d.ts +26 -0
- package/dist/api.js +144 -6
- package/dist/audit.d.ts +1 -1
- package/dist/churn-git.d.ts +22 -0
- package/dist/churn-git.js +102 -0
- package/dist/cli.js +136 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +24 -0
- package/dist/connectors/github/ingest.js +1 -0
- package/dist/connectors/slack/ingest.js +1 -0
- package/dist/consolidate.js +3 -0
- package/dist/db.js +26 -1
- package/dist/instruction-detect.d.ts +7 -0
- package/dist/instruction-detect.js +27 -0
- package/dist/invalidation.d.ts +30 -0
- package/dist/invalidation.js +266 -1
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +2 -0
- package/dist/quarantine.d.ts +31 -0
- package/dist/quarantine.js +63 -0
- package/dist/search.d.ts +4 -0
- package/dist/search.js +17 -6
- package/dist/server.js +59 -1
- package/dist/shared.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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 (?, ?, ?)`)
|
package/dist/consolidate.js
CHANGED
|
@@ -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 =
|
|
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
|
package/dist/invalidation.d.ts
CHANGED
|
@@ -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
|
package/dist/invalidation.js
CHANGED
|
@@ -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. */
|