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/api.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } fro
|
|
|
11
11
|
import { type RejectedValueRow } from './rejection.js';
|
|
12
12
|
import { type DormantMemory, type ListDormantOpts } from './dormant.js';
|
|
13
13
|
import { type TokenSummary, type TokenSurface } from './token-ledger.js';
|
|
14
|
+
import { type QuarantineStatus } from './quarantine.js';
|
|
14
15
|
import { type FailureSummary } from './failure-log.js';
|
|
15
16
|
import { type SessionHandoff } from './handoff.js';
|
|
16
17
|
import { type MemoryKind, type MemoryEntry } from './memory.js';
|
|
@@ -110,11 +111,17 @@ export interface RememberOpts {
|
|
|
110
111
|
* back and the error is rethrown.
|
|
111
112
|
*/
|
|
112
113
|
afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void;
|
|
114
|
+
/** CD5: connector-ingested content an agent doesn't control; gates detectInstruction. CLI/HTTP/MCP never set this. */
|
|
115
|
+
untrusted?: boolean;
|
|
113
116
|
}
|
|
114
117
|
export interface RememberResult {
|
|
115
118
|
id: string;
|
|
116
119
|
kind: MemoryKind;
|
|
117
120
|
tenantId: string;
|
|
121
|
+
/** Set only when untrusted content was flagged and quarantined instead of stored under its requested scope. */
|
|
122
|
+
quarantined?: {
|
|
123
|
+
reason: string;
|
|
124
|
+
};
|
|
118
125
|
}
|
|
119
126
|
export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
|
|
120
127
|
export interface RecallOpts {
|
|
@@ -1061,6 +1068,25 @@ export declare function restoreDormant(ctx: Context, id: string): MemoryEntry;
|
|
|
1061
1068
|
export declare function forgetDormant(ctx: Context, id: string): void;
|
|
1062
1069
|
/** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
|
|
1063
1070
|
export declare function isDormant(ctx: Context, id: string): boolean;
|
|
1071
|
+
export interface QuarantineListItem {
|
|
1072
|
+
id: string;
|
|
1073
|
+
originalScope: string | null;
|
|
1074
|
+
reason: string;
|
|
1075
|
+
status: QuarantineStatus;
|
|
1076
|
+
quarantinedAt: string;
|
|
1077
|
+
decidedAt: string | null;
|
|
1078
|
+
decidedBy: string | null;
|
|
1079
|
+
contentPreview: string;
|
|
1080
|
+
}
|
|
1081
|
+
/** A tenant's quarantined memories, newest first. Default `status` is 'pending' (the review queue). */
|
|
1082
|
+
export declare function quarantineList(ctx: Context, opts?: {
|
|
1083
|
+
status?: QuarantineStatus | 'all';
|
|
1084
|
+
limit?: number;
|
|
1085
|
+
}): QuarantineListItem[];
|
|
1086
|
+
/** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
|
|
1087
|
+
export declare function quarantineApprove(ctx: Context, id: string): void;
|
|
1088
|
+
/** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
|
|
1089
|
+
export declare function quarantineReject(ctx: Context, id: string): void;
|
|
1064
1090
|
export interface SleepResult {
|
|
1065
1091
|
active: number;
|
|
1066
1092
|
removed: number;
|
package/dist/api.js
CHANGED
|
@@ -13,9 +13,11 @@ import { RejectedValueError } from './rejection.js';
|
|
|
13
13
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
14
14
|
import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
|
|
15
15
|
import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
|
|
16
|
+
import { detectInstruction } from './instruction-detect.js';
|
|
17
|
+
import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
|
|
16
18
|
import { summarizeFailures } from './failure-log.js';
|
|
17
19
|
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
18
|
-
import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
|
|
20
|
+
import { createMemory, applyOutcome, calculateStrength, Layer, CHURN_STALE_TAG, } from './memory.js';
|
|
19
21
|
import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
20
22
|
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
21
23
|
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
@@ -23,7 +25,7 @@ import { evalNow } from './ablation.js';
|
|
|
23
25
|
import { archiveRawMemory } from './raw-archive.js';
|
|
24
26
|
import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
|
|
25
27
|
import { applyGoalStackBoost } from './goals.js';
|
|
26
|
-
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
|
|
28
|
+
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
|
|
27
29
|
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
28
30
|
import { scopeMatch } from './scope.js';
|
|
29
31
|
import { consolidate } from './consolidate.js';
|
|
@@ -140,9 +142,11 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
|
|
|
140
142
|
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient);
|
|
141
143
|
}
|
|
142
144
|
export function remember(ctx, opts) {
|
|
145
|
+
const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
|
|
146
|
+
const requestedScope = opts.scope ?? null;
|
|
143
147
|
const entry = createMemory(opts.content, {
|
|
144
148
|
kind: opts.kind ?? 'distilled',
|
|
145
|
-
scope:
|
|
149
|
+
scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
|
|
146
150
|
owner: opts.owner ?? null,
|
|
147
151
|
artifact_ref: opts.artifactRef ?? null,
|
|
148
152
|
tags: opts.tags,
|
|
@@ -151,8 +155,23 @@ export function remember(ctx, opts) {
|
|
|
151
155
|
});
|
|
152
156
|
// writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
|
|
153
157
|
// one 'remember' event lands in the log with the supplied actor.
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
const afterWrite = detection.flagged
|
|
159
|
+
? (db, memoryId) => {
|
|
160
|
+
recordQuarantine(db, {
|
|
161
|
+
tenantId: ctx.tenantId,
|
|
162
|
+
memoryId,
|
|
163
|
+
originalScope: requestedScope,
|
|
164
|
+
reason: detection.reason ?? 'unknown',
|
|
165
|
+
actor: ctx.actor.subject,
|
|
166
|
+
});
|
|
167
|
+
opts.afterWrite?.(db, memoryId);
|
|
168
|
+
}
|
|
169
|
+
: opts.afterWrite;
|
|
170
|
+
writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite });
|
|
171
|
+
const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
172
|
+
if (detection.flagged)
|
|
173
|
+
result.quarantined = { reason: detection.reason ?? 'unknown' };
|
|
174
|
+
return result;
|
|
156
175
|
}
|
|
157
176
|
/**
|
|
158
177
|
* Shared construction helper for `RecallSuppressionSummary`. Used by
|
|
@@ -294,6 +313,10 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
294
313
|
// for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
|
|
295
314
|
// into the same field per the plan's Task 3 mapping table).
|
|
296
315
|
droppedPreRankCount = all.length - entries.length;
|
|
316
|
+
entries = entries
|
|
317
|
+
.map((e, i) => ({ e, s: (1 - i / entries.length) * churnStaleFactor(e) }))
|
|
318
|
+
.sort((a, b) => b.s - a.s)
|
|
319
|
+
.map((r) => r.e);
|
|
297
320
|
// BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
|
|
298
321
|
// Score is a placeholder — the physics/hybrid scorers in src/search.ts
|
|
299
322
|
// produce richer breakdowns and will replace this when wired up.
|
|
@@ -973,7 +996,10 @@ export function outcome(ctx, ids, good, opts) {
|
|
|
973
996
|
const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
974
997
|
if (!entry)
|
|
975
998
|
continue;
|
|
976
|
-
|
|
999
|
+
let updated = applyOutcome(entry, good);
|
|
1000
|
+
if (good && updated.tags.includes(CHURN_STALE_TAG)) { // FE2: a good outcome reconfirms the entry
|
|
1001
|
+
updated = { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
|
|
1002
|
+
}
|
|
977
1003
|
writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
|
|
978
1004
|
appendAuditEvent(db, {
|
|
979
1005
|
tenantId: ctx.tenantId,
|
|
@@ -2090,6 +2116,118 @@ export function isDormant(ctx, id) {
|
|
|
2090
2116
|
closeHippoDb(db);
|
|
2091
2117
|
}
|
|
2092
2118
|
}
|
|
2119
|
+
const QUARANTINE_PREVIEW_CHARS = 200;
|
|
2120
|
+
/** A tenant's quarantined memories, newest first. Default `status` is 'pending' (the review queue). */
|
|
2121
|
+
export function quarantineList(ctx, opts = {}) {
|
|
2122
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
2123
|
+
try {
|
|
2124
|
+
const rows = listQuarantineRows(db, ctx.tenantId, opts.status ?? 'pending', opts.limit);
|
|
2125
|
+
return rows.map((row) => {
|
|
2126
|
+
const entry = readEntry(ctx.hippoRoot, row.memoryId, ctx.tenantId);
|
|
2127
|
+
return {
|
|
2128
|
+
id: row.memoryId,
|
|
2129
|
+
originalScope: row.originalScope,
|
|
2130
|
+
reason: row.reason,
|
|
2131
|
+
status: row.status,
|
|
2132
|
+
quarantinedAt: row.quarantinedAt,
|
|
2133
|
+
decidedAt: row.decidedAt,
|
|
2134
|
+
decidedBy: row.decidedBy,
|
|
2135
|
+
contentPreview: entry ? entry.content.slice(0, QUARANTINE_PREVIEW_CHARS) : '',
|
|
2136
|
+
};
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
finally {
|
|
2140
|
+
closeHippoDb(db);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function loadPendingQuarantineRow(db, tenantId, id) {
|
|
2144
|
+
const row = getQuarantineRow(db, tenantId, id);
|
|
2145
|
+
if (!row)
|
|
2146
|
+
throw new Error(`not quarantined: ${id}`);
|
|
2147
|
+
if (row.status !== 'pending')
|
|
2148
|
+
throw new Error(`${id} is already ${row.status}`);
|
|
2149
|
+
return row;
|
|
2150
|
+
}
|
|
2151
|
+
/** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
|
|
2152
|
+
export function quarantineApprove(ctx, id) {
|
|
2153
|
+
if (ctx.actor.role !== 'admin') {
|
|
2154
|
+
throw new ForbiddenError('Only an admin key can approve a quarantined memory');
|
|
2155
|
+
}
|
|
2156
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
2157
|
+
try {
|
|
2158
|
+
db.exec('BEGIN IMMEDIATE');
|
|
2159
|
+
try {
|
|
2160
|
+
const row = loadPendingQuarantineRow(db, ctx.tenantId, id);
|
|
2161
|
+
const quarantineScope = quarantineScopeFor(row.originalScope);
|
|
2162
|
+
const updated = db
|
|
2163
|
+
.prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
|
|
2164
|
+
.run(row.originalScope, id, ctx.tenantId, quarantineScope);
|
|
2165
|
+
if (Number(updated.changes ?? 0) !== 1) {
|
|
2166
|
+
throw new Error(`memory ${id} scope changed since quarantine; refusing to approve`);
|
|
2167
|
+
}
|
|
2168
|
+
approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
|
|
2169
|
+
appendAuditEvent(db, {
|
|
2170
|
+
tenantId: ctx.tenantId,
|
|
2171
|
+
actor: ctx.actor.subject,
|
|
2172
|
+
op: 'quarantine_approve',
|
|
2173
|
+
targetId: id,
|
|
2174
|
+
metadata: { originalScope: row.originalScope },
|
|
2175
|
+
});
|
|
2176
|
+
db.exec('COMMIT');
|
|
2177
|
+
}
|
|
2178
|
+
catch (err) {
|
|
2179
|
+
try {
|
|
2180
|
+
db.exec('ROLLBACK');
|
|
2181
|
+
}
|
|
2182
|
+
catch { /* already rolled back */ }
|
|
2183
|
+
throw err;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
finally {
|
|
2187
|
+
closeHippoDb(db);
|
|
2188
|
+
}
|
|
2189
|
+
// Post-commit, best-effort: a failed rewrite leaves the mirror showing the quarantine scope (fail-closed).
|
|
2190
|
+
try {
|
|
2191
|
+
const restored = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
2192
|
+
if (restored)
|
|
2193
|
+
writeEntryMirrors(ctx.hippoRoot, restored);
|
|
2194
|
+
}
|
|
2195
|
+
catch (err) {
|
|
2196
|
+
console.error(`quarantine: mirror rewrite failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
|
|
2200
|
+
export function quarantineReject(ctx, id) {
|
|
2201
|
+
if (ctx.actor.role !== 'admin') {
|
|
2202
|
+
throw new ForbiddenError('Only an admin key can reject a quarantined memory');
|
|
2203
|
+
}
|
|
2204
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
2205
|
+
try {
|
|
2206
|
+
db.exec('BEGIN IMMEDIATE');
|
|
2207
|
+
try {
|
|
2208
|
+
loadPendingQuarantineRow(db, ctx.tenantId, id);
|
|
2209
|
+
rejectQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
|
|
2210
|
+
appendAuditEvent(db, {
|
|
2211
|
+
tenantId: ctx.tenantId,
|
|
2212
|
+
actor: ctx.actor.subject,
|
|
2213
|
+
op: 'quarantine_reject',
|
|
2214
|
+
targetId: id,
|
|
2215
|
+
metadata: {},
|
|
2216
|
+
});
|
|
2217
|
+
db.exec('COMMIT');
|
|
2218
|
+
}
|
|
2219
|
+
catch (err) {
|
|
2220
|
+
try {
|
|
2221
|
+
db.exec('ROLLBACK');
|
|
2222
|
+
}
|
|
2223
|
+
catch { /* already rolled back */ }
|
|
2224
|
+
throw err;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
finally {
|
|
2228
|
+
closeHippoDb(db);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2093
2231
|
const DEFAULT_SLEEP_PHASES = {
|
|
2094
2232
|
consolidate,
|
|
2095
2233
|
deduplicateStore,
|
package/dist/audit.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface AuditResult {
|
|
|
16
16
|
export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
|
|
17
17
|
export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
|
|
18
18
|
export declare function isContentWorthStoring(content: string): boolean;
|
|
19
|
-
export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve' | 'auth_grant' | 'auth_ungrant';
|
|
19
|
+
export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve' | 'auth_grant' | 'auth_ungrant' | 'quarantine' | 'quarantine_approve' | 'quarantine_reject';
|
|
20
20
|
export interface AppendAuditOpts {
|
|
21
21
|
tenantId: string;
|
|
22
22
|
actor: string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Git subprocess helpers for FE2 churn-staleness (src/invalidation.ts). */
|
|
2
|
+
/** A real git failure (not git grep's expected exit-1-no-match). Callers abort the whole run on this. */
|
|
3
|
+
export declare class GitReadError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare function gitLsFilesAtHead(repoRoot: string): Set<string>;
|
|
6
|
+
export interface ChurnCommitFile {
|
|
7
|
+
status: string;
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ChurnCommit {
|
|
11
|
+
hash: string;
|
|
12
|
+
date: string;
|
|
13
|
+
files: ChurnCommitFile[];
|
|
14
|
+
}
|
|
15
|
+
export declare function fetchChurnWindowLog(repoRoot: string, sinceIso: string): ChurnCommit[];
|
|
16
|
+
/** `git grep -o -h -w -F -f <patternFile> <rev>`; returns the subset of `patterns` found. */
|
|
17
|
+
export declare function gitGrepPresence(repoRoot: string, patterns: string[], rev: string): Set<string>;
|
|
18
|
+
/** Last commit at or before `beforeIso`, or null when the repo has none. */
|
|
19
|
+
export declare function resolveCommitBefore(repoRoot: string, beforeIso: string): string | null;
|
|
20
|
+
/** package.json `scripts` at a revision; {} when the file is absent, null when unparsable. Git failures throw GitReadError. */
|
|
21
|
+
export declare function packageScriptsAt(repoRoot: string, rev: string): Record<string, string> | null;
|
|
22
|
+
//# sourceMappingURL=churn-git.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Git subprocess helpers for FE2 churn-staleness (src/invalidation.ts). */
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
// A `git log --name-status` dump can run to tens of MB; default 1MB pipe would truncate it.
|
|
7
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
8
|
+
/** A real git failure (not git grep's expected exit-1-no-match). Callers abort the whole run on this. */
|
|
9
|
+
export class GitReadError extends Error {
|
|
10
|
+
}
|
|
11
|
+
function runGit(args, repoRoot) {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
throw new GitReadError(`git ${args.join(' ')} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// git grep exits 1 for "no match" -- a normal empty result, not a failure.
|
|
20
|
+
function runGitGrepOrEmpty(args, repoRoot) {
|
|
21
|
+
try {
|
|
22
|
+
return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
// SAFETY: execFileSync attaches `status` to the thrown Error on a non-zero child exit.
|
|
26
|
+
const status = err.status;
|
|
27
|
+
if (status === 1)
|
|
28
|
+
return '';
|
|
29
|
+
throw new GitReadError(`git ${args.join(' ')} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ls-tree, not ls-files: the index can hold staged adds/deletes that HEAD does not.
|
|
33
|
+
export function gitLsFilesAtHead(repoRoot) {
|
|
34
|
+
const raw = runGit(['ls-tree', '-r', '--name-only', 'HEAD'], repoRoot);
|
|
35
|
+
return new Set(raw.split('\n').map((l) => l.trim()).filter(Boolean));
|
|
36
|
+
}
|
|
37
|
+
// 1-day buffer guards against git's --since boundary excluding a commit dated exactly at the anchor.
|
|
38
|
+
export function fetchChurnWindowLog(repoRoot, sinceIso) {
|
|
39
|
+
const buffered = new Date(new Date(sinceIso).getTime() - 24 * 60 * 60 * 1000).toISOString();
|
|
40
|
+
const raw = runGit(
|
|
41
|
+
// --first-parent -m: a merge's changes land as one diff against its mainline parent.
|
|
42
|
+
['log', '--no-renames', '--first-parent', '-m', `--since=${buffered}`, '--pretty=format:%x01%H%x02%cI', '--name-status'], repoRoot);
|
|
43
|
+
const commits = [];
|
|
44
|
+
let current = null;
|
|
45
|
+
for (const line of raw.split('\n')) {
|
|
46
|
+
if (line.startsWith('\x01')) {
|
|
47
|
+
const [hash, date] = line.slice(1).split('\x02');
|
|
48
|
+
current = { hash, date, files: [] };
|
|
49
|
+
commits.push(current);
|
|
50
|
+
}
|
|
51
|
+
else if (current && line.trim()) {
|
|
52
|
+
const m = line.match(/^([AMD])\s+(.+)$/);
|
|
53
|
+
if (m)
|
|
54
|
+
current.files.push({ status: m[1], path: m[2] });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return commits;
|
|
58
|
+
}
|
|
59
|
+
/** `git grep -o -h -w -F -f <patternFile> <rev>`; returns the subset of `patterns` found. */
|
|
60
|
+
export function gitGrepPresence(repoRoot, patterns, rev) {
|
|
61
|
+
if (patterns.length === 0)
|
|
62
|
+
return new Set();
|
|
63
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hippo-churn-grep-'));
|
|
64
|
+
try {
|
|
65
|
+
const patternFile = path.join(tmpDir, 'patterns.txt');
|
|
66
|
+
fs.writeFileSync(patternFile, `${patterns.join('\n')}\n`, 'utf8');
|
|
67
|
+
const raw = runGitGrepOrEmpty(['grep', '-o', '-h', '-w', '-F', '-f', patternFile, rev], repoRoot);
|
|
68
|
+
const patternSet = new Set(patterns);
|
|
69
|
+
const found = new Set();
|
|
70
|
+
for (const line of raw.split('\n')) {
|
|
71
|
+
const t = line.trim();
|
|
72
|
+
if (patternSet.has(t))
|
|
73
|
+
found.add(t);
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Last commit at or before `beforeIso`, or null when the repo has none. */
|
|
82
|
+
export function resolveCommitBefore(repoRoot, beforeIso) {
|
|
83
|
+
const raw = runGit(['rev-list', '-1', '--first-parent', `--before=${beforeIso}`, 'HEAD'], repoRoot).trim();
|
|
84
|
+
return raw === '' ? null : raw;
|
|
85
|
+
}
|
|
86
|
+
/** package.json `scripts` at a revision; {} when the file is absent, null when unparsable. Git failures throw GitReadError. */
|
|
87
|
+
export function packageScriptsAt(repoRoot, rev) {
|
|
88
|
+
if (runGit(['ls-tree', '--name-only', rev, '--', 'package.json'], repoRoot).trim() === '')
|
|
89
|
+
return {};
|
|
90
|
+
const raw = runGit(['show', `${rev}:package.json`], repoRoot);
|
|
91
|
+
try {
|
|
92
|
+
// SAFETY: optional chaining makes a `null` or scalar package.json read as "no scripts".
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return parsed?.scripts ?? {};
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof SyntaxError)
|
|
98
|
+
return null;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=churn-git.js.map
|
package/dist/cli.js
CHANGED
|
@@ -68,7 +68,8 @@ import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
|
|
|
68
68
|
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
|
|
69
69
|
import { rowToGoal } from './goals.js';
|
|
70
70
|
import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
|
|
71
|
-
import { extractInvalidationTarget, invalidateMatching } from './invalidation.js';
|
|
71
|
+
import { extractInvalidationTarget, invalidateMatching, detectChurnStale } from './invalidation.js';
|
|
72
|
+
import { resolveProjectIdentity } from './project-identity.js';
|
|
72
73
|
import { extractPathTags } from './path-context.js';
|
|
73
74
|
import { detectScope } from './scope.js';
|
|
74
75
|
import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
|
|
@@ -200,6 +201,24 @@ function requireInit(hippoRoot) {
|
|
|
200
201
|
process.exit(1);
|
|
201
202
|
}
|
|
202
203
|
}
|
|
204
|
+
/** FE2: run detectChurnStale against every store this repo's memories can live in. */
|
|
205
|
+
function runChurnStaleForRepo(hippoRoot, dryRun) {
|
|
206
|
+
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim();
|
|
207
|
+
const projectName = resolveProjectIdentity(process.cwd()).name;
|
|
208
|
+
const globalRoot = getGlobalRoot();
|
|
209
|
+
const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
|
|
210
|
+
const tenantId = resolveTenantId({});
|
|
211
|
+
return roots.map((root) => {
|
|
212
|
+
// One store failing must not abort sleep's later phases or skip the other store.
|
|
213
|
+
try {
|
|
214
|
+
return { root, result: detectChurnStale(root, repoRoot, { tenantId, projectName, dryRun }) };
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
218
|
+
return { root, result: { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned: [], dryRun, preview: [], error: message } };
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
203
222
|
/**
|
|
204
223
|
* H2: when HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
|
|
205
224
|
* direct DB mode — a missing server then masks a real misconfiguration (the
|
|
@@ -263,7 +282,7 @@ async function runViaServerIfAvailable(hippoRoot, httpFn) {
|
|
|
263
282
|
// and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
|
|
264
283
|
// tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
|
|
265
284
|
export const BOOLEAN_FLAGS = new Set([
|
|
266
|
-
'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
|
|
285
|
+
'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'churn', 'continuity',
|
|
267
286
|
'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
|
|
268
287
|
'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
|
|
269
288
|
'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
|
|
@@ -2197,6 +2216,8 @@ async function cmdExplain(hippoRoot, query, flags) {
|
|
|
2197
2216
|
console.log(` source: x${fmt(b.sourceBump, 2)} (local priority bump over global)`);
|
|
2198
2217
|
if (b.outcomeBoost !== 1)
|
|
2199
2218
|
console.log(` outcome: x${fmt(b.outcomeBoost, 3)} (user feedback: pos-neg = ${(r.entry.outcome_positive ?? 0) - (r.entry.outcome_negative ?? 0)})`);
|
|
2219
|
+
if (b.churnStaleMultiplier !== 1)
|
|
2220
|
+
console.log(` churn: x${fmt(b.churnStaleMultiplier, 2)} (tagged 'churn-stale')`);
|
|
2200
2221
|
if (b.preMmrRank !== undefined && b.postMmrRank !== undefined && b.preMmrRank !== b.postMmrRank) {
|
|
2201
2222
|
const arrow = b.postMmrRank < b.preMmrRank ? 'up' : 'down';
|
|
2202
2223
|
console.log(` mmr: rank ${b.preMmrRank} -> ${b.postMmrRank} (diversity ${arrow})`);
|
|
@@ -2840,6 +2861,15 @@ async function cmdSleepCore(hippoRoot, flags) {
|
|
|
2840
2861
|
if (added > 0)
|
|
2841
2862
|
console.log(`Auto-learned ${added} lessons from today's git commits.`);
|
|
2842
2863
|
}
|
|
2864
|
+
// FE2: opt-in code-churn staleness, off by default (config.churnStaleness.enabled).
|
|
2865
|
+
if (config.churnStaleness.enabled && isGitRepo(process.cwd())) {
|
|
2866
|
+
for (const { root, result } of runChurnStaleForRepo(hippoRoot, false)) {
|
|
2867
|
+
if (result.marked > 0)
|
|
2868
|
+
console.log(`Tagged ${result.marked} memories churn-stale in ${root}.`);
|
|
2869
|
+
if (result.error)
|
|
2870
|
+
console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2843
2873
|
// Also learn from Claude Code MEMORY.md files
|
|
2844
2874
|
const memImported = learnFromMemoryMd(hippoRoot);
|
|
2845
2875
|
if (memImported > 0)
|
|
@@ -3897,6 +3927,56 @@ function cmdDormant(hippoRoot, args, flags) {
|
|
|
3897
3927
|
}
|
|
3898
3928
|
console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
|
|
3899
3929
|
}
|
|
3930
|
+
/** `hippo quarantine [list] [--all] [--json] [--global]`, `quarantine approve <id>`, `quarantine reject <id>` (CD5 poisoning defence). */
|
|
3931
|
+
function cmdQuarantine(hippoRoot, args, flags) {
|
|
3932
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3933
|
+
const ctx = {
|
|
3934
|
+
hippoRoot: root,
|
|
3935
|
+
tenantId: resolveTenantId({}),
|
|
3936
|
+
actor: api.adminActor('cli'),
|
|
3937
|
+
};
|
|
3938
|
+
const sub = args[0];
|
|
3939
|
+
if (sub === 'approve' || sub === 'reject') {
|
|
3940
|
+
const id = (args[1] ?? '').trim();
|
|
3941
|
+
if (!id) {
|
|
3942
|
+
console.error(`Usage: hippo quarantine ${sub} <id>`);
|
|
3943
|
+
process.exit(1);
|
|
3944
|
+
}
|
|
3945
|
+
try {
|
|
3946
|
+
if (sub === 'approve') {
|
|
3947
|
+
api.quarantineApprove(ctx, id);
|
|
3948
|
+
console.log(`Approved ${id}: restored to its original scope.`);
|
|
3949
|
+
}
|
|
3950
|
+
else {
|
|
3951
|
+
api.quarantineReject(ctx, id);
|
|
3952
|
+
console.log(`Rejected ${id}: stays quarantined.`);
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
catch (err) {
|
|
3956
|
+
console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3957
|
+
process.exit(1);
|
|
3958
|
+
}
|
|
3959
|
+
return;
|
|
3960
|
+
}
|
|
3961
|
+
const status = flags['all'] ? 'all' : 'pending';
|
|
3962
|
+
const rows = api.quarantineList(ctx, { status });
|
|
3963
|
+
if (flags['json']) {
|
|
3964
|
+
console.log(JSON.stringify({ quarantine: rows }, null, 2));
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
if (rows.length === 0) {
|
|
3968
|
+
console.log(status === 'all' ? 'No quarantined memories.' : 'No pending quarantined memories.');
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
3971
|
+
console.log(`${rows.length} quarantined memor${rows.length === 1 ? 'y' : 'ies'} (newest first):\n`);
|
|
3972
|
+
for (const row of rows) {
|
|
3973
|
+
console.log(`--- ${row.id} [${row.status}]`);
|
|
3974
|
+
console.log(` ${row.contentPreview}`);
|
|
3975
|
+
console.log(` ${row.reason}, original scope ${row.originalScope ?? '(none)'}, quarantined ${row.quarantinedAt.slice(0, 10)}`);
|
|
3976
|
+
console.log('');
|
|
3977
|
+
}
|
|
3978
|
+
console.log('Approve: hippo quarantine approve <id> Reject: hippo quarantine reject <id>');
|
|
3979
|
+
}
|
|
3900
3980
|
/**
|
|
3901
3981
|
* `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
|
|
3902
3982
|
* (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
|
|
@@ -7979,6 +8059,9 @@ const VALID_AUDIT_OPS = new Set([
|
|
|
7979
8059
|
'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
|
|
7980
8060
|
'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
|
|
7981
8061
|
'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
|
|
8062
|
+
'quarantine', // CD5: emitted by recordQuarantine; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
|
|
8063
|
+
'quarantine_approve', // CD5: emitted by api.quarantineApprove; lockstep
|
|
8064
|
+
'quarantine_reject', // CD5: emitted by api.quarantineReject; lockstep
|
|
7982
8065
|
]);
|
|
7983
8066
|
function formatAuditRow(ev) {
|
|
7984
8067
|
const target = ev.targetId ?? '-';
|
|
@@ -8721,6 +8804,12 @@ Commands:
|
|
|
8721
8804
|
--global Operate on the global store
|
|
8722
8805
|
dormant restore <id> Bring a dormant memory back to active memory
|
|
8723
8806
|
dormant forget <id> Delete a dormant memory permanently
|
|
8807
|
+
quarantine [list] List memories a connector flagged as an instruction attempt, pending review
|
|
8808
|
+
--all Include approved and rejected rows too (default: pending only)
|
|
8809
|
+
--json Output as JSON
|
|
8810
|
+
--global Operate on the global store
|
|
8811
|
+
quarantine approve <id> Restore a quarantined memory to its original scope
|
|
8812
|
+
quarantine reject <id> Keep a quarantined memory hidden for good
|
|
8724
8813
|
capture-error Store a failed tool call as an error memory (reads the Claude Code
|
|
8725
8814
|
PostToolUseFailure hook payload on stdin; skips routine failures)
|
|
8726
8815
|
doctor Check the install: Node, store, schema, sleep, agent hooks
|
|
@@ -8963,6 +9052,11 @@ Commands:
|
|
|
8963
9052
|
'invalidated' re-weakens previously invalidated
|
|
8964
9053
|
memories - preview with --dry-run first
|
|
8965
9054
|
--reason "<why>" Optional: what replaced it
|
|
9055
|
+
invalidate --churn FE2: tag memories 'churn-stale' whose named file
|
|
9056
|
+
changed or was deleted, or whose named symbol or
|
|
9057
|
+
npm script was removed, in this repo's git history
|
|
9058
|
+
since the memory was stored or confirmed
|
|
9059
|
+
--dry-run Preview what would be tagged; writes nothing
|
|
8966
9060
|
wm <sub> Working memory — bounded buffer for current state
|
|
8967
9061
|
wm push Push a working memory entry
|
|
8968
9062
|
--scope <scope> Scope name (default: default)
|
|
@@ -9075,6 +9169,7 @@ Examples:
|
|
|
9075
9169
|
hippo invalidate "REST API" --dry-run
|
|
9076
9170
|
hippo invalidate "REST API" --reason "migrated to GraphQL"
|
|
9077
9171
|
hippo invalidate --id mem_a1b2c3d4e5f6 --reason "superseded by new policy"
|
|
9172
|
+
hippo invalidate --churn --dry-run
|
|
9078
9173
|
hippo export memories.json
|
|
9079
9174
|
hippo export --format markdown memories.md
|
|
9080
9175
|
hippo sleep --dry-run
|
|
@@ -9478,6 +9573,9 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9478
9573
|
case 'dormant':
|
|
9479
9574
|
cmdDormant(hippoRoot, args, flags);
|
|
9480
9575
|
break;
|
|
9576
|
+
case 'quarantine':
|
|
9577
|
+
cmdQuarantine(hippoRoot, args, flags);
|
|
9578
|
+
break;
|
|
9481
9579
|
case 'tokens':
|
|
9482
9580
|
cmdTokens(hippoRoot, flags);
|
|
9483
9581
|
break;
|
|
@@ -9836,6 +9934,42 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9836
9934
|
}
|
|
9837
9935
|
case 'invalidate': {
|
|
9838
9936
|
requireInit(hippoRoot);
|
|
9937
|
+
if (flags['churn'] === true) {
|
|
9938
|
+
if (args[0] || flags['id'] !== undefined) {
|
|
9939
|
+
console.error('Usage: hippo invalidate --churn [--dry-run]');
|
|
9940
|
+
console.error('--churn takes no pattern or --id.');
|
|
9941
|
+
process.exit(1);
|
|
9942
|
+
}
|
|
9943
|
+
if (!isGitRepo(process.cwd())) {
|
|
9944
|
+
console.error('hippo invalidate --churn must run inside a git repository.');
|
|
9945
|
+
process.exit(1);
|
|
9946
|
+
}
|
|
9947
|
+
const churnDryRun = flags['dry-run'] === true;
|
|
9948
|
+
let churnFailed = false;
|
|
9949
|
+
for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
|
|
9950
|
+
if (result.error) {
|
|
9951
|
+
console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
|
|
9952
|
+
churnFailed = true;
|
|
9953
|
+
continue;
|
|
9954
|
+
}
|
|
9955
|
+
if (result.preview.length === 0) {
|
|
9956
|
+
console.log(`No churn-stale candidates in ${root}.`);
|
|
9957
|
+
}
|
|
9958
|
+
else if (churnDryRun) {
|
|
9959
|
+
console.log(`DRY RUN - ${result.marked} memories in ${root} WOULD be tagged churn-stale (${result.alreadyMarked} already tagged):`);
|
|
9960
|
+
}
|
|
9961
|
+
else {
|
|
9962
|
+
console.log(`Tagged ${result.marked} memories churn-stale in ${root} (${result.alreadyMarked} already tagged):`);
|
|
9963
|
+
}
|
|
9964
|
+
result.preview.forEach(p => console.log(` ${p.id} ${p.evidence} ${p.already ? '(already) ' : ''}${p.headline}`));
|
|
9965
|
+
if (result.skippedPinned.length > 0) {
|
|
9966
|
+
console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
|
|
9967
|
+
}
|
|
9968
|
+
}
|
|
9969
|
+
if (churnFailed)
|
|
9970
|
+
process.exit(1);
|
|
9971
|
+
break;
|
|
9972
|
+
}
|
|
9839
9973
|
const target = args[0];
|
|
9840
9974
|
if (flags['id'] === true) {
|
|
9841
9975
|
// Value-less --id must never silently fall through to pattern mode
|
package/dist/config.d.ts
CHANGED
|
@@ -116,6 +116,11 @@ export interface HippoConfig {
|
|
|
116
116
|
* Default 180. 0 keeps dormant memories forever. */
|
|
117
117
|
retentionDays: number;
|
|
118
118
|
};
|
|
119
|
+
/** FE2: tags a memory `churn-stale` when its named file/symbol/script
|
|
120
|
+
* changed since storage. Default OFF - FE3 measures before it flips. */
|
|
121
|
+
churnStaleness: {
|
|
122
|
+
enabled: boolean;
|
|
123
|
+
};
|
|
119
124
|
}
|
|
120
125
|
export declare function loadConfig(hippoRoot: string): HippoConfig;
|
|
121
126
|
export declare function saveConfig(hippoRoot: string, config: HippoConfig): void;
|