hippo-memory 1.50.0 → 1.52.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/README.md +2 -2
- package/dist/api.js +10 -3
- package/dist/capture.d.ts +2 -7
- package/dist/capture.js +23 -130
- package/dist/churn-git.d.ts +22 -0
- package/dist/churn-git.js +102 -0
- package/dist/cli.js +79 -5
- package/dist/config.d.ts +5 -0
- package/dist/config.js +24 -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/search.d.ts +4 -0
- package/dist/search.js +17 -6
- 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/README.md
CHANGED
|
@@ -724,9 +724,9 @@ For Claude Code, it also adds:
|
|
|
724
724
|
- a `SessionEnd` hook so `hippo sleep` runs automatically when the session exits
|
|
725
725
|
- a `SessionStart` hook that prints the previous session's consolidation output
|
|
726
726
|
- a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus the last 5 writes, so fresh same-session lessons appear on the next prompt before you pin them. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. `{"pinnedInject":{"skipUnchanged":false}}` sends it every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
|
|
727
|
-
- a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It saves a working-state snapshot (task/summary/next step)
|
|
727
|
+
- a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it; the `SessionEnd` hook still owns extracting durable memories.
|
|
728
728
|
- a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot plus the recent session trail back into context right after compaction.
|
|
729
|
-
- a `PostCompact` hook that runs `hippo post-compact`, which tells you what was saved ("Hippo saved your task snapshot
|
|
729
|
+
- a `PostCompact` hook that runs `hippo post-compact`, which tells you what was saved ("Hippo saved your task snapshot before compacting."). It prints nothing when nothing was saved.
|
|
730
730
|
- a `PostToolUseFailure` hook that runs `hippo capture-error`, which stores a failed tool call as an error memory. It skips interrupts, declined permissions and searches that found nothing, and stores a repeated failure once. It also logs every failure, stored or not, for `hippo failures`: the session, the tool and hashes of the error, never its text. A hash is not anonymous, since anyone who guesses an error's text can check it against the hash. The log keeps 90 days.
|
|
731
731
|
|
|
732
732
|
To remove: `hippo hook uninstall claude-code`
|
package/dist/api.js
CHANGED
|
@@ -17,7 +17,7 @@ import { detectInstruction } from './instruction-detect.js';
|
|
|
17
17
|
import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
|
|
18
18
|
import { summarizeFailures } from './failure-log.js';
|
|
19
19
|
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
20
|
-
import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
|
|
20
|
+
import { createMemory, applyOutcome, calculateStrength, Layer, CHURN_STALE_TAG, } from './memory.js';
|
|
21
21
|
import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
22
22
|
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
23
23
|
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
@@ -25,7 +25,7 @@ import { evalNow } from './ablation.js';
|
|
|
25
25
|
import { archiveRawMemory } from './raw-archive.js';
|
|
26
26
|
import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
|
|
27
27
|
import { applyGoalStackBoost } from './goals.js';
|
|
28
|
-
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
|
|
28
|
+
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
|
|
29
29
|
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
30
30
|
import { scopeMatch } from './scope.js';
|
|
31
31
|
import { consolidate } from './consolidate.js';
|
|
@@ -313,6 +313,10 @@ function recallFrom(ctx, opts, windowSize, all) {
|
|
|
313
313
|
// for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
|
|
314
314
|
// into the same field per the plan's Task 3 mapping table).
|
|
315
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);
|
|
316
320
|
// BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
|
|
317
321
|
// Score is a placeholder — the physics/hybrid scorers in src/search.ts
|
|
318
322
|
// produce richer breakdowns and will replace this when wired up.
|
|
@@ -992,7 +996,10 @@ export function outcome(ctx, ids, good, opts) {
|
|
|
992
996
|
const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
993
997
|
if (!entry)
|
|
994
998
|
continue;
|
|
995
|
-
|
|
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
|
+
}
|
|
996
1003
|
writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
|
|
997
1004
|
appendAuditEvent(db, {
|
|
998
1005
|
tenantId: ctx.tenantId,
|
package/dist/capture.d.ts
CHANGED
|
@@ -106,16 +106,11 @@ export declare function sanitizeLogMessage(message: string): string;
|
|
|
106
106
|
/** What one pre-compact run saved, reported to the user after compaction. */
|
|
107
107
|
export interface PreCompactReport {
|
|
108
108
|
snapshotSaved: boolean;
|
|
109
|
-
captured: number;
|
|
110
109
|
/** Claude Code session the run belonged to, when the payload named one. */
|
|
111
110
|
sessionId: string | null;
|
|
112
111
|
}
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
* saved. Without it the only sign was Claude Code's generic
|
|
116
|
-
* "PreCompact [...] completed successfully".
|
|
117
|
-
*/
|
|
118
|
-
export declare function preCompactMessage(report: Pick<PreCompactReport, 'snapshotSaved' | 'captured'>): string | null;
|
|
112
|
+
/** Line shown after compaction, or null when nothing was saved (Claude Code's own generic message is the fallback). */
|
|
113
|
+
export declare function preCompactMessage(report: Pick<PreCompactReport, 'snapshotSaved'>): string | null;
|
|
119
114
|
/**
|
|
120
115
|
* Where pre-compact leaves its report for `hippo post-compact`: next to the
|
|
121
116
|
* pre-compact log, so both hooks find it from the same `--log-file`.
|
package/dist/capture.js
CHANGED
|
@@ -21,7 +21,6 @@ import { defaultPreCompactLogPath } from './hooks.js';
|
|
|
21
21
|
import { redactSecrets } from './secret-detect.js';
|
|
22
22
|
import { RejectedValueError, checkRejectionGuard } from './rejection.js';
|
|
23
23
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
24
|
-
import { loadConfig } from './config.js';
|
|
25
24
|
// Sentence-level patterns
|
|
26
25
|
//
|
|
27
26
|
// T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
|
|
@@ -469,62 +468,6 @@ function isDuplicate(content, existing) {
|
|
|
469
468
|
}
|
|
470
469
|
return false;
|
|
471
470
|
}
|
|
472
|
-
/**
|
|
473
|
-
* Write already-extracted items to the store, deduped against existing
|
|
474
|
-
* tenant-scoped entries. Shared write path for `cmdCapture` (extracted from
|
|
475
|
-
* raw text inline) and `cmdPreCompact` (extracted from a pre-computed tail
|
|
476
|
-
* summary, no raw-text re-extraction). Mirrors the non-dry-run write loop in
|
|
477
|
-
* `cmdCaptureCore`: same layer/source/confidence, same embed-if-configured,
|
|
478
|
-
* fire-and-forget behaviour.
|
|
479
|
-
*
|
|
480
|
-
* Returns the fire-and-forget `embedMemory` promises alongside the counts
|
|
481
|
-
* (review round X6) so a caller that must not exit before embeddings settle
|
|
482
|
-
* — `cmdPreCompact`, which runs process.exit(0) right after — can await them
|
|
483
|
-
* with a bounded timeout instead of racing a detached write.
|
|
484
|
-
*/
|
|
485
|
-
function writeExtractedItems(hippoRoot, tenantId, extracted) {
|
|
486
|
-
if (extracted.length === 0)
|
|
487
|
-
return { captured: 0, skipped: 0, rejected: 0, embeds: [] };
|
|
488
|
-
const existing = loadAllEntries(hippoRoot, tenantId);
|
|
489
|
-
const embeds = [];
|
|
490
|
-
let captured = 0;
|
|
491
|
-
let skipped = 0;
|
|
492
|
-
let rejected = 0;
|
|
493
|
-
const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
|
|
494
|
-
for (const item of extracted) {
|
|
495
|
-
if (isDuplicate(item.content, existing)) {
|
|
496
|
-
skipped++;
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
const entry = createMemory(item.content, {
|
|
500
|
-
layer: Layer.Episodic,
|
|
501
|
-
tags: item.tags,
|
|
502
|
-
source: 'capture',
|
|
503
|
-
confidence: 'observed',
|
|
504
|
-
tenantId,
|
|
505
|
-
baseHalfLifeDays,
|
|
506
|
-
});
|
|
507
|
-
// AT1 (plan §3 containment): a refusal is per-VALUE — one rejected
|
|
508
|
-
// extraction must not abort the rest of this transcript's captures.
|
|
509
|
-
try {
|
|
510
|
-
writeEntry(hippoRoot, entry);
|
|
511
|
-
}
|
|
512
|
-
catch (err) {
|
|
513
|
-
if (err instanceof RejectedValueError) {
|
|
514
|
-
rejected++;
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
throw err;
|
|
518
|
-
}
|
|
519
|
-
updateStats(hippoRoot, { remembered: 1 });
|
|
520
|
-
existing.push(entry); // within-batch dedup
|
|
521
|
-
if (isEmbeddingConfigured(hippoRoot)) {
|
|
522
|
-
embeds.push(embedMemory(hippoRoot, entry).catch(() => { }));
|
|
523
|
-
}
|
|
524
|
-
captured++;
|
|
525
|
-
}
|
|
526
|
-
return { captured, skipped, rejected, embeds };
|
|
527
|
-
}
|
|
528
471
|
/**
|
|
529
472
|
* Runtime shape guards used throughout this file wherever a value arrives
|
|
530
473
|
* unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
|
|
@@ -557,7 +500,7 @@ function errorMessage(cause) {
|
|
|
557
500
|
/** Leading markers of the command lines Claude Code writes with type 'user'. */
|
|
558
501
|
const CLAUDE_CODE_COMMAND_PREFIXES = ['<local-command-', '<command-name>', '<command-message>', '<command-args>'];
|
|
559
502
|
function isNonHumanUserLine(entry, content) {
|
|
560
|
-
if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true)
|
|
503
|
+
if (entry.isMeta === true || entry.isSidechain === true || entry.isCompactSummary === true || entry.promptSource === 'system')
|
|
561
504
|
return true;
|
|
562
505
|
const head = content.trimStart();
|
|
563
506
|
return CLAUDE_CODE_COMMAND_PREFIXES.some((p) => head.startsWith(p));
|
|
@@ -1121,20 +1064,11 @@ function isReadableFile(filePath) {
|
|
|
1121
1064
|
return false;
|
|
1122
1065
|
}
|
|
1123
1066
|
}
|
|
1124
|
-
/**
|
|
1125
|
-
* The line shown to the user after compaction, or null when nothing was
|
|
1126
|
-
* saved. Without it the only sign was Claude Code's generic
|
|
1127
|
-
* "PreCompact [...] completed successfully".
|
|
1128
|
-
*/
|
|
1067
|
+
/** Line shown after compaction, or null when nothing was saved (Claude Code's own generic message is the fallback). */
|
|
1129
1068
|
export function preCompactMessage(report) {
|
|
1130
|
-
if (!report.snapshotSaved
|
|
1069
|
+
if (!report.snapshotSaved)
|
|
1131
1070
|
return null;
|
|
1132
|
-
|
|
1133
|
-
if (report.snapshotSaved)
|
|
1134
|
-
parts.push('your task snapshot');
|
|
1135
|
-
if (report.captured > 0)
|
|
1136
|
-
parts.push(`${report.captured} new memor${report.captured === 1 ? 'y' : 'ies'}`);
|
|
1137
|
-
return `Hippo saved ${parts.join(' and ')} before compacting.${report.snapshotSaved ? ' The snapshot is restored into the new context.' : ''}`;
|
|
1071
|
+
return 'Hippo saved your task snapshot before compacting. The snapshot is restored into the new context.';
|
|
1138
1072
|
}
|
|
1139
1073
|
/**
|
|
1140
1074
|
* Where pre-compact leaves its report for `hippo post-compact`: next to the
|
|
@@ -1143,24 +1077,20 @@ export function preCompactMessage(report) {
|
|
|
1143
1077
|
export function preCompactReportPath(logFile) {
|
|
1144
1078
|
return path.join(path.dirname(logFile), 'pre-compact-last.json');
|
|
1145
1079
|
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Runs the PreCompact producer. Returns any `embedMemory` promises kicked
|
|
1148
|
-
* off along the way (empty on every skip path) so `cmdPreCompact` can await
|
|
1149
|
-
* them, bounded, before it exits (X6).
|
|
1150
|
-
*/
|
|
1080
|
+
/** Runs the PreCompact producer: saves a working-state snapshot into `report`. Never extracts memories; SessionEnd capture owns that. */
|
|
1151
1081
|
function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
1152
1082
|
// X3: the PreCompact hook fires in every Claude Code project, including
|
|
1153
1083
|
// ones that never ran `hippo init`, so gate before any store-opening call
|
|
1154
1084
|
// (saveActiveTaskSnapshot etc. call initStore, which would create one).
|
|
1155
1085
|
if (!isInitialized(hippoRoot)) {
|
|
1156
1086
|
appendPreCompactLog(logFile, 'skip: store not initialized');
|
|
1157
|
-
return
|
|
1087
|
+
return;
|
|
1158
1088
|
}
|
|
1159
1089
|
// Same hazard X4 guards below, different trigger: a read that timed out
|
|
1160
1090
|
// must not reach auto-discovery either, or it snapshots another session.
|
|
1161
1091
|
if (stdinTimedOut && (!stdinText || stdinText.trim() === '')) {
|
|
1162
1092
|
appendPreCompactLog(logFile, 'skip: no PreCompact payload arrived before the stdin wait window closed');
|
|
1163
|
-
return
|
|
1093
|
+
return;
|
|
1164
1094
|
}
|
|
1165
1095
|
// A true manual invocation has no stdin at all (TTY, or a non-TTY pipe
|
|
1166
1096
|
// that yielded an empty read) — that's the ONLY case newest-transcript
|
|
@@ -1186,7 +1116,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1186
1116
|
// `"transcript_path": null` (or the key missing entirely) — all fail
|
|
1187
1117
|
// the string check. Log and skip; never fall through to auto-discovery.
|
|
1188
1118
|
appendPreCompactLog(logFile, 'skip: malformed or incomplete PreCompact payload (missing string transcript_path)');
|
|
1189
|
-
return
|
|
1119
|
+
return;
|
|
1190
1120
|
}
|
|
1191
1121
|
if ('session_id' in payload && isStringValue(payload.session_id))
|
|
1192
1122
|
sessionId = payload.session_id;
|
|
@@ -1202,7 +1132,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1202
1132
|
// this check could gate on, so containment buys no real isolation.
|
|
1203
1133
|
if (payloadTranscriptPath !== null && !/\.jsonl$/i.test(payloadTranscriptPath)) {
|
|
1204
1134
|
appendPreCompactLog(logFile, `skip: payload transcript_path is not a .jsonl file: ${payloadTranscriptPath}`);
|
|
1205
|
-
return
|
|
1135
|
+
return;
|
|
1206
1136
|
}
|
|
1207
1137
|
// A payload transcript_path is EXCLUSIVE: never fall back to
|
|
1208
1138
|
// newest-transcript auto-discovery when it's missing/unreadable. That
|
|
@@ -1217,7 +1147,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1217
1147
|
}
|
|
1218
1148
|
else {
|
|
1219
1149
|
appendPreCompactLog(logFile, `skip: payload transcript_path unreadable: ${payloadTranscriptPath}`);
|
|
1220
|
-
return
|
|
1150
|
+
return;
|
|
1221
1151
|
}
|
|
1222
1152
|
}
|
|
1223
1153
|
else {
|
|
@@ -1225,7 +1155,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1225
1155
|
}
|
|
1226
1156
|
if (!transcriptPath) {
|
|
1227
1157
|
appendPreCompactLog(logFile, 'skip: no transcript resolved');
|
|
1228
|
-
return
|
|
1158
|
+
return;
|
|
1229
1159
|
}
|
|
1230
1160
|
let tail;
|
|
1231
1161
|
try {
|
|
@@ -1248,22 +1178,18 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1248
1178
|
}
|
|
1249
1179
|
catch (err) {
|
|
1250
1180
|
appendPreCompactLog(logFile, `skip: could not read transcript tail: ${errorMessage(err)}`);
|
|
1251
|
-
return
|
|
1181
|
+
return;
|
|
1252
1182
|
}
|
|
1253
1183
|
const summaryFull = summariseTranscript(tail);
|
|
1254
|
-
//
|
|
1255
|
-
// items become durable memories and must never carry raw secrets any more
|
|
1256
|
-
// than the snapshot fields may. (The pre-existing SessionEnd capture path
|
|
1257
|
-
// is deliberately unchanged.)
|
|
1184
|
+
// Snapshot fields must never carry raw secrets, same rule X9 applies below to task/next_step.
|
|
1258
1185
|
const scrubbedSummary = redactSecrets(summaryFull);
|
|
1259
|
-
const extracted = extractFromText(scrubbedSummary);
|
|
1260
1186
|
const rawTask = lastPlainUserMessage(tail);
|
|
1261
1187
|
const rawNextStep = lastAssistantTextBlock(tail);
|
|
1262
|
-
// Full skip only when
|
|
1263
|
-
//
|
|
1264
|
-
if (!rawTask.trim() && !summaryFull.trim() && !rawNextStep.trim()
|
|
1265
|
-
appendPreCompactLog(logFile, 'skip: empty summary
|
|
1266
|
-
return
|
|
1188
|
+
// Full skip only when every derived field is empty: never clobber a
|
|
1189
|
+
// user-authored active snapshot with junk.
|
|
1190
|
+
if (!rawTask.trim() && !summaryFull.trim() && !rawNextStep.trim()) {
|
|
1191
|
+
appendPreCompactLog(logFile, 'skip: empty summary');
|
|
1192
|
+
return;
|
|
1267
1193
|
}
|
|
1268
1194
|
const tenantId = resolveTenantId({});
|
|
1269
1195
|
// Per-field merge (X1): a tool-heavy tail whose only user turns are
|
|
@@ -1309,10 +1235,8 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1309
1235
|
const nextStep = scrubbedNextStep.trim()
|
|
1310
1236
|
? truncateCodePointSafe(scrubbedNextStep, PRE_COMPACT_NEXT_STEP_CAP)
|
|
1311
1237
|
: (fallback?.next_step ?? '');
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
// fields (cross-session tail with nothing derivable) skip the write so a
|
|
1315
|
-
// foreign session's junk never displaces the owning session's snapshot.
|
|
1238
|
+
// All-empty fields (cross-session tail with nothing derivable) skip the
|
|
1239
|
+
// write so a foreign session's junk never displaces the owning snapshot.
|
|
1316
1240
|
if (!task && !summary && !nextStep) {
|
|
1317
1241
|
appendPreCompactLog(logFile, 'skip: no snapshot content for this session (nothing derivable; fallback blocked or empty)');
|
|
1318
1242
|
}
|
|
@@ -1332,25 +1256,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
|
|
|
1332
1256
|
appendPreCompactLog(logFile, `snapshot save failed: ${errorMessage(err)}`);
|
|
1333
1257
|
}
|
|
1334
1258
|
}
|
|
1335
|
-
// Capture extraction SECOND, own try/catch: a failure here self-heals at
|
|
1336
|
-
// the next SessionEnd capture (existing dedup absorbs the overlap).
|
|
1337
|
-
try {
|
|
1338
|
-
const { captured, skipped, rejected, embeds } = writeExtractedItems(hippoRoot, tenantId, extracted);
|
|
1339
|
-
report.captured = captured;
|
|
1340
|
-
appendPreCompactLog(logFile, `capture: ${captured} items captured, ${skipped} skipped` +
|
|
1341
|
-
(rejected > 0 ? `, ${rejected} rejected` : ''));
|
|
1342
|
-
return embeds;
|
|
1343
|
-
}
|
|
1344
|
-
catch (err) {
|
|
1345
|
-
appendPreCompactLog(logFile, `capture failed: ${errorMessage(err)}`);
|
|
1346
|
-
return [];
|
|
1347
|
-
}
|
|
1348
1259
|
}
|
|
1349
|
-
// X6: bound how long cmdPreCompact will wait for fire-and-forget embeddings
|
|
1350
|
-
// to settle before it exits. PreCompact runs under a hook timeout (30s in
|
|
1351
|
-
// the installer) — 3s leaves ample headroom while still giving embeddings a
|
|
1352
|
-
// real chance to finish instead of racing process.exit(0) unconditionally.
|
|
1353
|
-
const EMBED_SETTLE_TIMEOUT_MS = 3000;
|
|
1354
1260
|
/**
|
|
1355
1261
|
* PreCompact hook entry point. Exit code 2 on PreCompact BLOCKS compaction,
|
|
1356
1262
|
* so this verb must exit 0 on every path — malformed stdin, missing
|
|
@@ -1360,29 +1266,17 @@ const EMBED_SETTLE_TIMEOUT_MS = 3000;
|
|
|
1360
1266
|
*/
|
|
1361
1267
|
export async function cmdPreCompact(hippoRoot, options) {
|
|
1362
1268
|
const logFile = options.logFile ?? defaultPreCompactLogPath();
|
|
1363
|
-
|
|
1364
|
-
const report = { snapshotSaved: false, captured: 0, sessionId: null };
|
|
1269
|
+
const report = { snapshotSaved: false, sessionId: null };
|
|
1365
1270
|
try {
|
|
1366
|
-
|
|
1271
|
+
runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile, report);
|
|
1367
1272
|
}
|
|
1368
1273
|
catch (err) {
|
|
1369
1274
|
appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
|
|
1370
1275
|
}
|
|
1371
|
-
if (embeds.length > 0) {
|
|
1372
|
-
let timer;
|
|
1373
|
-
const timeout = new Promise((resolve) => {
|
|
1374
|
-
timer = setTimeout(() => resolve('timeout'), EMBED_SETTLE_TIMEOUT_MS);
|
|
1375
|
-
timer.unref?.();
|
|
1376
|
-
});
|
|
1377
|
-
const settled = Promise.allSettled(embeds).then(() => 'settled');
|
|
1378
|
-
const outcome = await Promise.race([settled, timeout]);
|
|
1379
|
-
clearTimeout(timer);
|
|
1380
|
-
appendPreCompactLog(logFile, outcome === 'settled' ? 'embeddings settled' : 'embeddings timeout');
|
|
1381
|
-
}
|
|
1382
1276
|
// Nothing goes to stdout: Claude Code passes PreCompact stdout to the
|
|
1383
1277
|
// summarising model as extra instructions. The PostCompact hook
|
|
1384
1278
|
// (`hippo post-compact`) tells the user instead, from this report.
|
|
1385
|
-
if (report.snapshotSaved
|
|
1279
|
+
if (report.snapshotSaved) {
|
|
1386
1280
|
try {
|
|
1387
1281
|
fs.writeFileSync(preCompactReportPath(logFile), JSON.stringify({ ...report, at: new Date().toISOString() }));
|
|
1388
1282
|
}
|
|
@@ -1431,7 +1325,6 @@ export function postCompactMessage(stdinText, logFile = defaultPreCompactLogPath
|
|
|
1431
1325
|
return null;
|
|
1432
1326
|
return preCompactMessage({
|
|
1433
1327
|
snapshotSaved: 'snapshotSaved' in report && report.snapshotSaved === true,
|
|
1434
|
-
captured: 'captured' in report && Number.isInteger(report.captured) ? Number(report.captured) : 0,
|
|
1435
1328
|
});
|
|
1436
1329
|
}
|
|
1437
1330
|
catch {
|
|
@@ -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
|
@@ -41,7 +41,7 @@ import { execFileSync, execSync, spawn } from 'child_process';
|
|
|
41
41
|
import { installJsonHooks, uninstallJsonHooks, resolveJsonHookPaths, detectInstalledTools, defaultSleepLogPath, ensureCodexWrapperInstalled, installCodexWrapper, isCodexWrapperInstalled, repairCodexWrapperIfInstalled, uninstallCodexWrapper, resolveCodexSessionTranscript, resolveCodexWrapperPaths, installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath, } from './hooks.js';
|
|
42
42
|
import { createMemory, calculateStrength, calculateRewardFactor, deriveHalfLife, resolveConfidence, confidenceFacets, confidenceLabel, computeSchemaFit, Layer, } from './memory.js';
|
|
43
43
|
import { detectSecret } from './secret-detect.js';
|
|
44
|
-
import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, createCard, loadCard, listCards, loadCardRuns, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, } from './store.js';
|
|
44
|
+
import { getHippoRoot, isInitialized, initStore, writeEntry, strengthenRetrieved, readEntry, deleteEntry, loadAllEntries, loadSearchEntries, loadRecallSearchEntries, loadIndex, saveIndex, loadStats, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, clearActiveTaskSnapshot, appendSessionEvent, listSessionEvents, listMemoryConflicts, resolveConflict, saveSessionHandoff, loadLatestHandoff, loadHandoffById, stampHandoffOutcome, writeSessionEndHandoff, createCard, loadCard, listCards, loadCardRuns, claimCard, heartbeatCard, blockCard, reviewCard, completeCard, reclaimExpiredCards, addCardComment, } from './store.js';
|
|
45
45
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
46
46
|
import { RejectedValueError } from './rejection.js';
|
|
47
47
|
import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
|
|
@@ -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)
|
|
@@ -2905,6 +2935,8 @@ function cmdLastSleep(flags) {
|
|
|
2905
2935
|
// X8: session-event content is capped at print time only — the shared
|
|
2906
2936
|
// printSessionEvents stays untouched for every other caller.
|
|
2907
2937
|
const COMPACT_RESUME_EVENT_CONTENT_CAP = 400;
|
|
2938
|
+
// A snapshot older than this was not written for this compaction (pre-compact skipped), so restoring it is stale, not a resume.
|
|
2939
|
+
const COMPACT_RESUME_MAX_AGE_MS = 15 * 60_000;
|
|
2908
2940
|
function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
|
|
2909
2941
|
try {
|
|
2910
2942
|
// X3: gate on the non-exiting isInitialized check before any
|
|
@@ -2955,7 +2987,7 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
|
|
|
2955
2987
|
}
|
|
2956
2988
|
if (!suppressOutput) {
|
|
2957
2989
|
const tenantId = resolveTenantId({});
|
|
2958
|
-
const snapshot =
|
|
2990
|
+
const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
|
|
2959
2991
|
// X5: concurrent sessions must not cross-restore. Only suppress when
|
|
2960
2992
|
// BOTH ids are present and differ — either side missing, or a manual
|
|
2961
2993
|
// invocation with no payload session_id, still prints.
|
|
@@ -8923,7 +8955,7 @@ Commands:
|
|
|
8923
8955
|
last-sleep Print the last 'hippo sleep --log-file' output and clear it
|
|
8924
8956
|
--path <p> Log path (default: ~/.hippo/logs/last-sleep.log)
|
|
8925
8957
|
--keep Print without clearing
|
|
8926
|
-
pre-compact PreCompact hook:
|
|
8958
|
+
pre-compact PreCompact hook: save a working-state snapshot before compaction
|
|
8927
8959
|
--log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
|
|
8928
8960
|
compact-resume SessionStart(compact) hook: re-print the snapshot + session trail
|
|
8929
8961
|
post-compact PostCompact hook: tell the user what pre-compact saved
|
|
@@ -9022,6 +9054,11 @@ Commands:
|
|
|
9022
9054
|
'invalidated' re-weakens previously invalidated
|
|
9023
9055
|
memories - preview with --dry-run first
|
|
9024
9056
|
--reason "<why>" Optional: what replaced it
|
|
9057
|
+
invalidate --churn FE2: tag memories 'churn-stale' whose named file
|
|
9058
|
+
changed or was deleted, or whose named symbol or
|
|
9059
|
+
npm script was removed, in this repo's git history
|
|
9060
|
+
since the memory was stored or confirmed
|
|
9061
|
+
--dry-run Preview what would be tagged; writes nothing
|
|
9025
9062
|
wm <sub> Working memory — bounded buffer for current state
|
|
9026
9063
|
wm push Push a working memory entry
|
|
9027
9064
|
--scope <scope> Scope name (default: default)
|
|
@@ -9134,6 +9171,7 @@ Examples:
|
|
|
9134
9171
|
hippo invalidate "REST API" --dry-run
|
|
9135
9172
|
hippo invalidate "REST API" --reason "migrated to GraphQL"
|
|
9136
9173
|
hippo invalidate --id mem_a1b2c3d4e5f6 --reason "superseded by new policy"
|
|
9174
|
+
hippo invalidate --churn --dry-run
|
|
9137
9175
|
hippo export memories.json
|
|
9138
9176
|
hippo export --format markdown memories.md
|
|
9139
9177
|
hippo sleep --dry-run
|
|
@@ -9898,6 +9936,42 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9898
9936
|
}
|
|
9899
9937
|
case 'invalidate': {
|
|
9900
9938
|
requireInit(hippoRoot);
|
|
9939
|
+
if (flags['churn'] === true) {
|
|
9940
|
+
if (args[0] || flags['id'] !== undefined) {
|
|
9941
|
+
console.error('Usage: hippo invalidate --churn [--dry-run]');
|
|
9942
|
+
console.error('--churn takes no pattern or --id.');
|
|
9943
|
+
process.exit(1);
|
|
9944
|
+
}
|
|
9945
|
+
if (!isGitRepo(process.cwd())) {
|
|
9946
|
+
console.error('hippo invalidate --churn must run inside a git repository.');
|
|
9947
|
+
process.exit(1);
|
|
9948
|
+
}
|
|
9949
|
+
const churnDryRun = flags['dry-run'] === true;
|
|
9950
|
+
let churnFailed = false;
|
|
9951
|
+
for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
|
|
9952
|
+
if (result.error) {
|
|
9953
|
+
console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
|
|
9954
|
+
churnFailed = true;
|
|
9955
|
+
continue;
|
|
9956
|
+
}
|
|
9957
|
+
if (result.preview.length === 0) {
|
|
9958
|
+
console.log(`No churn-stale candidates in ${root}.`);
|
|
9959
|
+
}
|
|
9960
|
+
else if (churnDryRun) {
|
|
9961
|
+
console.log(`DRY RUN - ${result.marked} memories in ${root} WOULD be tagged churn-stale (${result.alreadyMarked} already tagged):`);
|
|
9962
|
+
}
|
|
9963
|
+
else {
|
|
9964
|
+
console.log(`Tagged ${result.marked} memories churn-stale in ${root} (${result.alreadyMarked} already tagged):`);
|
|
9965
|
+
}
|
|
9966
|
+
result.preview.forEach(p => console.log(` ${p.id} ${p.evidence} ${p.already ? '(already) ' : ''}${p.headline}`));
|
|
9967
|
+
if (result.skippedPinned.length > 0) {
|
|
9968
|
+
console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
|
|
9969
|
+
}
|
|
9970
|
+
}
|
|
9971
|
+
if (churnFailed)
|
|
9972
|
+
process.exit(1);
|
|
9973
|
+
break;
|
|
9974
|
+
}
|
|
9901
9975
|
const target = args[0];
|
|
9902
9976
|
if (flags['id'] === true) {
|
|
9903
9977
|
// 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;
|
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) {
|
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:
|
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. */
|
package/dist/search.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Zero external dependencies when embeddings are not available.
|
|
4
4
|
*/
|
|
5
5
|
import { estimateTokens } from './token-ledger.js';
|
|
6
|
-
import { calculateStrength, netWrong } from './memory.js';
|
|
6
|
+
import { calculateStrength, netWrong, CHURN_STALE_TAG } from './memory.js';
|
|
7
7
|
import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
|
|
8
8
|
import { extractPathTags, pathBoostMultiplier } from './path-context.js';
|
|
9
9
|
import { detectScope, scopeMatch } from './scope.js';
|
|
@@ -16,6 +16,10 @@ import { openHippoDb, closeHippoDb } from './db.js';
|
|
|
16
16
|
import { rrfFuse } from './rrf.js';
|
|
17
17
|
import { graphRankStream, selectGraphSeeds, DEFAULT_GRAPH_SEED_COUNT } from './graph-stream.js';
|
|
18
18
|
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
19
|
+
export const CHURN_STALE_RANK_MULTIPLIER = 0.5; // SHORTCUT: untuned; FE3 measures before any default.
|
|
20
|
+
export function churnStaleFactor(entry) {
|
|
21
|
+
return entry.tags.includes(CHURN_STALE_TAG) ? CHURN_STALE_RANK_MULTIPLIER : 1.0;
|
|
22
|
+
}
|
|
19
23
|
// ---------------------------------------------------------------------------
|
|
20
24
|
// Tokenizer
|
|
21
25
|
// ---------------------------------------------------------------------------
|
|
@@ -368,6 +372,8 @@ export async function hybridSearch(query, entries, options = {}) {
|
|
|
368
372
|
// Decision-tagged memories get a 1.2x recall boost
|
|
369
373
|
const decisionBoost = entries[i].tags.includes('decision') ? 1.2 : 1.0;
|
|
370
374
|
compositeScore *= decisionBoost;
|
|
375
|
+
const churnStaleMultiplier = churnStaleFactor(entries[i]);
|
|
376
|
+
compositeScore *= churnStaleMultiplier;
|
|
371
377
|
// Path-based boost: memories tagged with matching path segments get up to 1.3x
|
|
372
378
|
const pathBoost = pathBoostMultiplier(entries[i].tags, currentPathTags);
|
|
373
379
|
compositeScore *= pathBoost;
|
|
@@ -424,6 +430,7 @@ export async function hybridSearch(query, entries, options = {}) {
|
|
|
424
430
|
scopeBoost,
|
|
425
431
|
sourceBump: 1,
|
|
426
432
|
outcomeBoost,
|
|
433
|
+
churnStaleMultiplier,
|
|
427
434
|
matchedTerms,
|
|
428
435
|
final: compositeScore,
|
|
429
436
|
ageDays,
|
|
@@ -476,7 +483,7 @@ export async function hybridSearch(query, entries, options = {}) {
|
|
|
476
483
|
for (const child of childEntriesAsync) {
|
|
477
484
|
if (!scoredDeduped.some((r) => r.entry.id === child.id)) {
|
|
478
485
|
const parentResult = scoredDeduped.find((r) => r.entry.id === child.dag_parent_id);
|
|
479
|
-
const childScore = parentResult ? parentResult.score * 0.9 : 0;
|
|
486
|
+
const childScore = parentResult ? parentResult.score * 0.9 * churnStaleFactor(child) : 0;
|
|
480
487
|
scoredDeduped.push({
|
|
481
488
|
entry: child,
|
|
482
489
|
score: childScore,
|
|
@@ -700,7 +707,8 @@ export async function physicsSearch(query, entries, options = {}) {
|
|
|
700
707
|
freshnessMultiplier = summaryFreshnessMultiplier(entry, now);
|
|
701
708
|
}
|
|
702
709
|
}
|
|
703
|
-
const
|
|
710
|
+
const churnStaleMultiplier = churnStaleFactor(entry);
|
|
711
|
+
const finalScore = s.finalScore * summaryDeboostMultiplier * freshnessMultiplier * churnStaleMultiplier;
|
|
704
712
|
if (finalScore <= 0)
|
|
705
713
|
continue;
|
|
706
714
|
const result = {
|
|
@@ -726,6 +734,7 @@ export async function physicsSearch(query, entries, options = {}) {
|
|
|
726
734
|
scopeBoost: 1,
|
|
727
735
|
sourceBump: 1,
|
|
728
736
|
outcomeBoost: 1,
|
|
737
|
+
churnStaleMultiplier,
|
|
729
738
|
matchedTerms: [],
|
|
730
739
|
final: finalScore,
|
|
731
740
|
ageDays,
|
|
@@ -768,8 +777,9 @@ export async function physicsSearch(query, entries, options = {}) {
|
|
|
768
777
|
}
|
|
769
778
|
/** Normalize two score pools to [0,1] and combine. */
|
|
770
779
|
function mergeScorePools(poolA, poolB) {
|
|
771
|
-
const
|
|
772
|
-
const
|
|
780
|
+
const unpenalised = (r) => r.score / churnStaleFactor(r.entry);
|
|
781
|
+
const maxA = poolA.reduce((m, r) => Math.max(m, unpenalised(r)), 1e-9);
|
|
782
|
+
const maxB = poolB.reduce((m, r) => Math.max(m, unpenalised(r)), 1e-9);
|
|
773
783
|
const merged = [];
|
|
774
784
|
for (const r of poolA) {
|
|
775
785
|
merged.push({ ...r, score: r.score / maxA });
|
|
@@ -842,6 +852,7 @@ export function search(query, entries, options = {}) {
|
|
|
842
852
|
// Decision-tagged memories get a 1.2x recall boost
|
|
843
853
|
const decisionBoost = entries[i].tags.includes('decision') ? 1.2 : 1.0;
|
|
844
854
|
composite *= decisionBoost;
|
|
855
|
+
composite *= churnStaleFactor(entries[i]);
|
|
845
856
|
// Path-based boost: memories tagged with matching path segments get up to 1.3x
|
|
846
857
|
composite *= pathBoostMultiplier(entries[i].tags, currentPathTagsSync);
|
|
847
858
|
// Scope boost (sync path)
|
|
@@ -883,7 +894,7 @@ export function search(query, entries, options = {}) {
|
|
|
883
894
|
for (const child of childEntries) {
|
|
884
895
|
if (!dedupedSync.some((r) => r.entry.id === child.id)) {
|
|
885
896
|
const parentResult = dedupedSync.find((r) => r.entry.id === child.dag_parent_id);
|
|
886
|
-
const childScore = parentResult ? parentResult.score * 0.9 : 0;
|
|
897
|
+
const childScore = parentResult ? parentResult.score * 0.9 * churnStaleFactor(child) : 0;
|
|
887
898
|
dedupedSync.push({
|
|
888
899
|
entry: child,
|
|
889
900
|
score: childScore,
|
package/dist/version.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* an ESM `import` can resolve cleanly, and a hardcoded constant survives
|
|
17
17
|
* any packager that drops .json files.
|
|
18
18
|
*/
|
|
19
|
-
export declare const PACKAGE_VERSION = "1.
|
|
19
|
+
export declare const PACKAGE_VERSION = "1.52.0";
|
|
20
20
|
/** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
|
|
21
21
|
export declare function compareSemver(a: string, b: string): number;
|
|
22
22
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* an ESM `import` can resolve cleanly, and a hardcoded constant survives
|
|
17
17
|
* any packager that drops .json files.
|
|
18
18
|
*/
|
|
19
|
-
export const PACKAGE_VERSION = '1.
|
|
19
|
+
export const PACKAGE_VERSION = '1.52.0';
|
|
20
20
|
/** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
|
|
21
21
|
export function compareSemver(a, b) {
|
|
22
22
|
const parse = (v) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hippo-memory",
|
|
3
3
|
"name": "Hippo Memory",
|
|
4
4
|
"description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.52.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hippo-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.52.0",
|
|
4
4
|
"description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
|
|
5
5
|
"mcpName": "io.github.kitfunso/hippo-memory",
|
|
6
6
|
"type": "module",
|