hippo-memory 1.45.0 → 1.46.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 +57 -15
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +60 -1
- package/dist/api.js +189 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +20 -0
- package/dist/capture-error.js +82 -0
- package/dist/capture.d.ts +25 -8
- package/dist/capture.js +100 -5
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +381 -48
- package/dist/config.d.ts +20 -0
- package/dist/config.js +35 -0
- package/dist/consolidate.d.ts +6 -0
- package/dist/consolidate.js +98 -13
- package/dist/db.js +57 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +174 -0
- package/dist/dormant.d.ts +91 -0
- package/dist/dormant.js +121 -0
- package/dist/eval-stats.d.ts +123 -0
- package/dist/eval-stats.js +187 -0
- package/dist/half-life-migration.d.ts +55 -0
- package/dist/half-life-migration.js +111 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +47 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +70 -13
- package/dist/memory.d.ts +16 -2
- package/dist/memory.js +27 -5
- package/dist/physics-config.js +5 -1
- package/dist/recall-scope.d.ts +24 -0
- package/dist/recall-scope.js +41 -0
- package/dist/reject-flow.d.ts +3 -3
- package/dist/reject-flow.js +10 -3
- package/dist/search.d.ts +4 -4
- package/dist/search.js +23 -18
- package/dist/server.js +11 -1
- package/dist/store.d.ts +12 -1
- package/dist/store.js +58 -12
- package/dist/token-ledger.d.ts +119 -0
- package/dist/token-ledger.js +181 -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 +2 -1
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* hippo reject <id>|--value "<text>" --reason "<why>"
|
|
20
20
|
* hippo rejections
|
|
21
21
|
* hippo unreject <digest-prefix>
|
|
22
|
+
* hippo dormant [<query>] [--limit <n>] [--json] | restore <id> | forget <id>
|
|
23
|
+
* hippo tokens [--days <n>] [--json] [--global]
|
|
24
|
+
* hippo doctor [--json]
|
|
22
25
|
* hippo inspect <id>
|
|
23
26
|
* hippo embed [--status]
|
|
24
27
|
* hippo watch "<command>"
|
|
@@ -45,7 +48,7 @@ import { isHandoffOutcome, formatHandoffEvidenceLine } from './handoff.js';
|
|
|
45
48
|
import { isCardStatus } from './card.js';
|
|
46
49
|
import { loadCardDetail } from './card-detail.js';
|
|
47
50
|
import { passesScopeFilterForRecall } from './recall-scope.js';
|
|
48
|
-
import { hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
|
|
51
|
+
import { estimateTokens, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
|
|
49
52
|
import { compareEntryIdentity } from './compare.js';
|
|
50
53
|
import { renderTraceContent, parseSteps } from './trace.js';
|
|
51
54
|
import { writeRecallTraceAtRoot } from './recall-trace.js';
|
|
@@ -56,6 +59,9 @@ import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
|
|
|
56
59
|
import { computeSystemEnergy, vecNorm } from './physics.js';
|
|
57
60
|
import { loadConfig } from './config.js';
|
|
58
61
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
62
|
+
import { runDoctor, formatDoctor } from './doctor.js';
|
|
63
|
+
import { captureToolFailure } from './capture-error.js';
|
|
64
|
+
import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
|
|
59
65
|
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
|
|
60
66
|
import { rowToGoal } from './goals.js';
|
|
61
67
|
import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
|
|
@@ -65,7 +71,7 @@ import { detectScope } from './scope.js';
|
|
|
65
71
|
import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
|
|
66
72
|
import { DAILY_TASK_NAME, buildDailyRunnerCommand, listRegisteredWorkspaces, registerWorkspace, runDailyMaintenance, } from './scheduler.js';
|
|
67
73
|
import { importChatGPT, importClaude, importCursor, importGenericFile, importMarkdown, importVault, } from './importers.js';
|
|
68
|
-
import { cmdCapture, cmdPreCompact, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
|
|
74
|
+
import { cmdCapture, cmdPreCompact, postCompactMessage, resolveLastSessionTranscript, truncateCodePointSafe, sanitizeLogMessage } from './capture.js';
|
|
69
75
|
import { readStdinBounded } from './stdin.js';
|
|
70
76
|
import { auditMemories, appendAuditEvent, } from './audit.js';
|
|
71
77
|
import { listApiKeys, revokeApiKey } from './auth.js';
|
|
@@ -542,17 +548,17 @@ function autoInstallHooks(quiet) {
|
|
|
542
548
|
// Skip if we already installed a hook into this file
|
|
543
549
|
if (installed.has(targetPath))
|
|
544
550
|
continue;
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
+
// An instruction block already present is not rewritten, but the JSON
|
|
552
|
+
// hooks and plugins below still run: they are idempotent, and skipping
|
|
553
|
+
// them meant a store set up by an older hippo never got hooks added in
|
|
554
|
+
// later releases (PreCompact, PostToolUseFailure) on a re-run of init.
|
|
555
|
+
const blockPresent = fs.existsSync(targetPath)
|
|
556
|
+
&& fs.readFileSync(targetPath, 'utf8').includes(HOOK_MARKERS.start);
|
|
551
557
|
// Only patch the agent-instructions file if it already exists.
|
|
552
558
|
// Never create a new CLAUDE.md / AGENTS.md / etc. just because a sibling
|
|
553
559
|
// marker file (.claude/settings.json, .codex, etc.) was detected — that
|
|
554
560
|
// pollutes dirs the user didn't intend to configure.
|
|
555
|
-
if (fs.existsSync(targetPath)) {
|
|
561
|
+
if (!blockPresent && fs.existsSync(targetPath)) {
|
|
556
562
|
const block = `${HOOK_MARKERS.start}\n${hookDef.content}\n${HOOK_MARKERS.end}`;
|
|
557
563
|
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
558
564
|
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
@@ -563,7 +569,7 @@ function autoInstallHooks(quiet) {
|
|
|
563
569
|
// The Codex session-capture wrapper swaps the codex launcher binary, so
|
|
564
570
|
// init never installs it silently — it points at the explicit opt-in
|
|
565
571
|
// command instead (issue #133).
|
|
566
|
-
if (hook === 'codex' && !isCodexWrapperInstalled()) {
|
|
572
|
+
if (hook === 'codex' && !blockPresent && !isCodexWrapperInstalled()) {
|
|
567
573
|
console.log(' Codex detected. To capture Codex sessions: hippo hook install codex');
|
|
568
574
|
}
|
|
569
575
|
// For Claude Code, also install SessionEnd+SessionStart entries in its
|
|
@@ -586,6 +592,12 @@ function autoInstallHooks(quiet) {
|
|
|
586
592
|
if (result.installedCompactResume) {
|
|
587
593
|
console.log(` Auto-installed hippo compact-resume SessionStart(compact) hook in ${hook} settings`);
|
|
588
594
|
}
|
|
595
|
+
if (result.installedPostCompact) {
|
|
596
|
+
console.log(` Auto-installed hippo post-compact PostCompact hook in ${hook} settings`);
|
|
597
|
+
}
|
|
598
|
+
if (result.installedCaptureError) {
|
|
599
|
+
console.log(` Auto-installed hippo capture-error PostToolUseFailure hook in ${hook} settings`);
|
|
600
|
+
}
|
|
589
601
|
if (result.migratedFromStop) {
|
|
590
602
|
console.log(` Migrated legacy Stop hook → SessionEnd (no longer runs every turn)`);
|
|
591
603
|
}
|
|
@@ -1657,7 +1669,7 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1657
1669
|
sessionHandoff =
|
|
1658
1670
|
rawHandoff && passesScopeFilterForRecall(rowScope(rawHandoff), effectiveScope) ? rawHandoff : null;
|
|
1659
1671
|
recentSessionEvents = rawEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), effectiveScope));
|
|
1660
|
-
const tokenize = (s) => s ?
|
|
1672
|
+
const tokenize = (s) => s ? estimateTokens(s) : 0;
|
|
1661
1673
|
continuityTokens =
|
|
1662
1674
|
tokenize(activeSnapshot?.task) +
|
|
1663
1675
|
tokenize(activeSnapshot?.summary) +
|
|
@@ -1675,6 +1687,16 @@ async function cmdRecall(hippoRoot, query, flags) {
|
|
|
1675
1687
|
const hasContinuity = activeSnapshot !== null
|
|
1676
1688
|
|| sessionHandoff !== null
|
|
1677
1689
|
|| recentSessionEvents.length > 0;
|
|
1690
|
+
// TE0 token ledger: the memory text this recall hands back (results plus
|
|
1691
|
+
// any continuity block), recorded once per query.
|
|
1692
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
1693
|
+
tenantId,
|
|
1694
|
+
sessionId: hostSessionId() ?? null,
|
|
1695
|
+
surface: 'recall',
|
|
1696
|
+
event: 'inject',
|
|
1697
|
+
items: results.length,
|
|
1698
|
+
tokens: results.reduce((acc, r) => acc + (r.tokens || 0), 0) + continuityTokens,
|
|
1699
|
+
}));
|
|
1678
1700
|
if (results.length === 0) {
|
|
1679
1701
|
// LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
|
|
1680
1702
|
// trace the zero-result recall too (result_count 0, no result rows) so
|
|
@@ -2748,6 +2770,14 @@ export function renderSleepResult(result) {
|
|
|
2748
2770
|
console.log(`\nResults:`);
|
|
2749
2771
|
console.log(` Active memories: ${result.active}`);
|
|
2750
2772
|
console.log(` Removed (decayed): ${result.removed}`);
|
|
2773
|
+
// Only when dormant.enabled moved something, so every other render stays
|
|
2774
|
+
// byte-identical (tests/cli-context-render-snapshot.test.ts).
|
|
2775
|
+
if (result.dormant !== undefined && result.dormant > 0) {
|
|
2776
|
+
console.log(` Kept dormant: ${result.dormant} (hippo dormant to list)`);
|
|
2777
|
+
}
|
|
2778
|
+
if (result.dormantExpired !== undefined && result.dormantExpired > 0) {
|
|
2779
|
+
console.log(` Expired dormant: ${result.dormantExpired} (past dormant.retentionDays)`);
|
|
2780
|
+
}
|
|
2751
2781
|
console.log(` Merged episodic: ${result.mergedEpisodic}`);
|
|
2752
2782
|
console.log(` New semantic: ${result.newSemantic}`);
|
|
2753
2783
|
if (result.details && result.details.length > 0) {
|
|
@@ -3506,6 +3536,12 @@ function cmdForget(hippoRoot, id, flags) {
|
|
|
3506
3536
|
// memory, not a missing one. Point the user at the archive path.
|
|
3507
3537
|
console.error(rawForgetRefusal(id));
|
|
3508
3538
|
}
|
|
3539
|
+
else if (api.isDormant(ctx, id)) {
|
|
3540
|
+
// Sleep moved it to the dormant store: it is not in active memory, so
|
|
3541
|
+
// point at the command that owns it.
|
|
3542
|
+
console.error(`${id} is dormant, not in active memory. Delete it for good: hippo dormant forget ${id} ` +
|
|
3543
|
+
`(or bring it back: hippo dormant restore ${id})`);
|
|
3544
|
+
}
|
|
3509
3545
|
else {
|
|
3510
3546
|
console.error(`Memory not found: ${id}`);
|
|
3511
3547
|
}
|
|
@@ -3789,6 +3825,110 @@ function cmdUnreject(hippoRoot, args, flags) {
|
|
|
3789
3825
|
}
|
|
3790
3826
|
console.log(`Unrejected [${outcome.digest.slice(0, 12)}...] (was: ${outcome.reason ?? 'none given'})`);
|
|
3791
3827
|
}
|
|
3828
|
+
/**
|
|
3829
|
+
* `hippo dormant [list] [<query>...] [--limit <n>] [--json] [--global]`,
|
|
3830
|
+
* `hippo dormant restore <id>`, `hippo dormant forget <id>`.
|
|
3831
|
+
* Dormant memories are what sleep keeps instead of deleting (on by default;
|
|
3832
|
+
* `"dormant": { "enabled": false }` in .hippo/config.json deletes instead).
|
|
3833
|
+
*/
|
|
3834
|
+
function cmdDormant(hippoRoot, args, flags) {
|
|
3835
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3836
|
+
const ctx = {
|
|
3837
|
+
hippoRoot: root,
|
|
3838
|
+
tenantId: resolveTenantId({}),
|
|
3839
|
+
actor: api.adminActor('cli'),
|
|
3840
|
+
};
|
|
3841
|
+
const sub = args[0];
|
|
3842
|
+
if (sub === 'restore' || sub === 'forget') {
|
|
3843
|
+
const id = (args[1] ?? '').trim();
|
|
3844
|
+
if (!id) {
|
|
3845
|
+
console.error(`Usage: hippo dormant ${sub} <id>`);
|
|
3846
|
+
process.exit(1);
|
|
3847
|
+
}
|
|
3848
|
+
try {
|
|
3849
|
+
if (sub === 'restore') {
|
|
3850
|
+
api.restoreDormant(ctx, id);
|
|
3851
|
+
console.log(`Restored ${id} to active memory.`);
|
|
3852
|
+
}
|
|
3853
|
+
else {
|
|
3854
|
+
api.forgetDormant(ctx, id);
|
|
3855
|
+
console.log(`Forgot dormant memory ${id} permanently.`);
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
catch (err) {
|
|
3859
|
+
if (err instanceof RejectedValueError) {
|
|
3860
|
+
console.error(`Cannot restore ${id}: its value was rejected (${err.reason ?? 'no reason given'}). Run \`hippo unreject\` first to allow it.`);
|
|
3861
|
+
}
|
|
3862
|
+
else {
|
|
3863
|
+
console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3864
|
+
}
|
|
3865
|
+
process.exit(1);
|
|
3866
|
+
}
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
const queryArgs = sub === 'list' ? args.slice(1) : args;
|
|
3870
|
+
const limit = parseCountFlag(flags['limit']);
|
|
3871
|
+
const rows = api.listDormant(ctx, {
|
|
3872
|
+
query: queryArgs.join(' '),
|
|
3873
|
+
limit: limit > 0 ? limit : undefined,
|
|
3874
|
+
});
|
|
3875
|
+
if (flags['json']) {
|
|
3876
|
+
console.log(JSON.stringify({ dormant: rows }, null, 2));
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
if (rows.length === 0) {
|
|
3880
|
+
console.log(queryArgs.length > 0 ? 'No dormant memories match.' : 'No dormant memories.');
|
|
3881
|
+
if (!loadConfig(root).dormant.enabled) {
|
|
3882
|
+
console.log(`Sleep deletes faded memories. To keep them dormant instead, set "dormant": { "enabled": true } in ${path.join(root, 'config.json')}.`);
|
|
3883
|
+
}
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
3886
|
+
console.log(`${rows.length} dormant memor${rows.length === 1 ? 'y' : 'ies'}${queryArgs.length > 0 ? ' matching' : ''} (newest first):\n`);
|
|
3887
|
+
for (const row of rows) {
|
|
3888
|
+
const preview = row.content.length > 100 ? `${row.content.slice(0, 100)}...` : row.content;
|
|
3889
|
+
console.log(`--- ${row.id}`);
|
|
3890
|
+
console.log(` ${preview}`);
|
|
3891
|
+
console.log(` Dormant since ${row.dormantAt.slice(0, 10)} (${row.reason}, strength ${row.strength.toFixed(3)})${row.tags.length > 0 ? ` tags: ${row.tags.join(', ')}` : ''}`);
|
|
3892
|
+
console.log('');
|
|
3893
|
+
}
|
|
3894
|
+
console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
|
|
3895
|
+
}
|
|
3896
|
+
/**
|
|
3897
|
+
* `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
|
|
3898
|
+
* (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
|
|
3899
|
+
* the per-prompt hook skipped as unchanged (TE2) and the tokens that saved.
|
|
3900
|
+
* Counts are estimates (characters / 4), the same estimate every budget uses.
|
|
3901
|
+
*/
|
|
3902
|
+
function cmdTokens(hippoRoot, flags) {
|
|
3903
|
+
const root = resolveAuthRoot(hippoRoot, flags);
|
|
3904
|
+
const ctx = {
|
|
3905
|
+
hippoRoot: root,
|
|
3906
|
+
tenantId: resolveTenantId({}),
|
|
3907
|
+
actor: api.adminActor('cli'),
|
|
3908
|
+
};
|
|
3909
|
+
const days = parseCountFlag(flags['days']);
|
|
3910
|
+
const summary = api.tokenSummary(ctx, { days: days > 0 ? days : undefined });
|
|
3911
|
+
if (flags['json']) {
|
|
3912
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
3913
|
+
return;
|
|
3914
|
+
}
|
|
3915
|
+
const windowDays = days > 0 ? days : 30;
|
|
3916
|
+
if (summary.surfaces.length === 0) {
|
|
3917
|
+
console.log(`No memory text recorded in the last ${windowDays} days.`);
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
console.log(`Memory text handed to agents, last ${windowDays} days (estimated tokens)\n`);
|
|
3921
|
+
console.log(` ${'surface'.padEnd(14)}${'sent'.padStart(8)}${'tokens'.padStart(12)}${'skipped'.padStart(10)}${'saved'.padStart(12)}`);
|
|
3922
|
+
for (const row of summary.surfaces) {
|
|
3923
|
+
console.log(` ${row.surface.padEnd(14)}${String(row.injected).padStart(8)}${String(row.tokens).padStart(12)}`
|
|
3924
|
+
+ `${String(row.skipped).padStart(10)}${String(row.tokensAvoided).padStart(12)}`);
|
|
3925
|
+
}
|
|
3926
|
+
console.log('');
|
|
3927
|
+
console.log(` Total sent: ${summary.totalTokens} tokens. Saved by skipping unchanged blocks: ${summary.totalTokensAvoided}.`);
|
|
3928
|
+
if (summary.meanTokensPerSession > 0) {
|
|
3929
|
+
console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3792
3932
|
function cmdSnapshot(hippoRoot, args, flags) {
|
|
3793
3933
|
requireInit(hippoRoot);
|
|
3794
3934
|
const subcommand = args[0] ?? 'show';
|
|
@@ -6117,22 +6257,24 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6117
6257
|
origin: r.origin ?? null,
|
|
6118
6258
|
category: r.category ?? null,
|
|
6119
6259
|
}));
|
|
6120
|
-
|
|
6260
|
+
const jsonText = JSON.stringify({
|
|
6121
6261
|
query: query || '*',
|
|
6122
6262
|
activeSnapshot: result.activeSnapshot ?? null,
|
|
6123
6263
|
sessionHandoff: result.sessionHandoff ?? null,
|
|
6124
6264
|
recentSessionEvents: result.recentEvents ?? [],
|
|
6125
6265
|
memories: output,
|
|
6126
6266
|
tokens: result.tokens,
|
|
6267
|
+
});
|
|
6268
|
+
console.log(jsonText);
|
|
6269
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6270
|
+
tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
|
|
6271
|
+
event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
|
|
6127
6272
|
}));
|
|
6128
6273
|
}
|
|
6129
6274
|
else if (format === 'additional-context') {
|
|
6130
6275
|
// Claude Code UserPromptSubmit hook JSON shape. Capture print* helpers'
|
|
6131
6276
|
// output into a string buffer and wrap as `additionalContext`.
|
|
6132
|
-
const
|
|
6133
|
-
const realLog = console.log;
|
|
6134
|
-
console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
|
|
6135
|
-
try {
|
|
6277
|
+
const textBlock = captureConsole(() => {
|
|
6136
6278
|
if (result.activeSnapshot)
|
|
6137
6279
|
printActiveTaskSnapshot(result.activeSnapshot);
|
|
6138
6280
|
if (result.sessionHandoff)
|
|
@@ -6141,16 +6283,37 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6141
6283
|
printSessionEvents(result.recentEvents);
|
|
6142
6284
|
}
|
|
6143
6285
|
if (renderItems.length > 0) {
|
|
6144
|
-
|
|
6286
|
+
// TE1: no live strength percentage, so an unchanged set of memories
|
|
6287
|
+
// renders byte-identically turn after turn.
|
|
6288
|
+
printContextMarkdown(renderItems, result.tokens, framing, { showStrength: false });
|
|
6145
6289
|
}
|
|
6146
6290
|
printCrossProjectSection(crossEntries);
|
|
6147
|
-
}
|
|
6148
|
-
finally {
|
|
6149
|
-
console.log = realLog;
|
|
6150
|
-
}
|
|
6151
|
-
const textBlock = lines.join('\n');
|
|
6291
|
+
});
|
|
6152
6292
|
if (!textBlock.trim())
|
|
6153
6293
|
return;
|
|
6294
|
+
const surface = pinnedOnly ? 'hook' : 'context';
|
|
6295
|
+
const hash = blockHash(textBlock);
|
|
6296
|
+
const tokens = estimateTokens(textBlock);
|
|
6297
|
+
// TE2: the per-prompt hook skips a block identical to the one this
|
|
6298
|
+
// session already has, and resends it every refreshTurns skips. Only
|
|
6299
|
+
// with a session id from the hook payload itself: an inherited env id
|
|
6300
|
+
// (a manual run inside an agent's shell) must never suppress output.
|
|
6301
|
+
if (pinnedOnly && payloadSessionId !== undefined) {
|
|
6302
|
+
const injectCfg = loadConfig(hippoRoot).pinnedInject;
|
|
6303
|
+
if (injectCfg.skipUnchanged !== false) {
|
|
6304
|
+
const refreshTurns = Number.isFinite(injectCfg.refreshTurns) && injectCfg.refreshTurns >= 0
|
|
6305
|
+
? injectCfg.refreshTurns
|
|
6306
|
+
: 10;
|
|
6307
|
+
const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
|
|
6308
|
+
if (shouldSkipUnchanged(last ?? null, hash, refreshTurns)) {
|
|
6309
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6310
|
+
tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
|
|
6311
|
+
items: renderItems.length, tokens, hash,
|
|
6312
|
+
}));
|
|
6313
|
+
return;
|
|
6314
|
+
}
|
|
6315
|
+
}
|
|
6316
|
+
}
|
|
6154
6317
|
const payload = {
|
|
6155
6318
|
hookSpecificOutput: {
|
|
6156
6319
|
hookEventName: 'UserPromptSubmit',
|
|
@@ -6158,25 +6321,113 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6158
6321
|
},
|
|
6159
6322
|
};
|
|
6160
6323
|
process.stdout.write(JSON.stringify(payload));
|
|
6324
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6325
|
+
tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
|
|
6326
|
+
items: renderItems.length, tokens, hash,
|
|
6327
|
+
}));
|
|
6161
6328
|
}
|
|
6162
6329
|
else {
|
|
6163
6330
|
// markdown (default)
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6331
|
+
const text = captureConsole(() => {
|
|
6332
|
+
if (result.activeSnapshot) {
|
|
6333
|
+
printActiveTaskSnapshot(result.activeSnapshot);
|
|
6334
|
+
}
|
|
6335
|
+
if (result.sessionHandoff) {
|
|
6336
|
+
printHandoff(result.sessionHandoff);
|
|
6337
|
+
}
|
|
6338
|
+
if (result.recentEvents && result.recentEvents.length > 0) {
|
|
6339
|
+
printSessionEvents(result.recentEvents);
|
|
6340
|
+
}
|
|
6341
|
+
if (renderItems.length > 0) {
|
|
6342
|
+
printContextMarkdown(renderItems, result.tokens, framing);
|
|
6343
|
+
}
|
|
6344
|
+
printCrossProjectSection(crossEntries);
|
|
6345
|
+
if (result.ambientState) {
|
|
6346
|
+
console.log(`\n${renderAmbientSummary(result.ambientState)}`);
|
|
6347
|
+
}
|
|
6348
|
+
});
|
|
6349
|
+
if (text.length > 0)
|
|
6350
|
+
console.log(text);
|
|
6351
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6352
|
+
tenantId: ctx.tenantId, sessionId: currentSessionId, surface: pinnedOnly ? 'hook' : 'context',
|
|
6353
|
+
event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
|
|
6354
|
+
}));
|
|
6355
|
+
}
|
|
6356
|
+
}
|
|
6357
|
+
/**
|
|
6358
|
+
* TE2: compaction drops the pinned blocks the per-prompt hook injected
|
|
6359
|
+
* earlier, so record a `reset` for the payload's session and the next prompt
|
|
6360
|
+
* injects again even if nothing changed. `requiredSource` limits it to hook
|
|
6361
|
+
* payloads with that `source` (SessionStart fires for other reasons too).
|
|
6362
|
+
* Best-effort and silent: a malformed payload records nothing.
|
|
6363
|
+
*/
|
|
6364
|
+
function resetHookInjection(hippoRoot, stdinText, requiredSource) {
|
|
6365
|
+
const sessionId = hookPayloadSessionId(stdinText, requiredSource);
|
|
6366
|
+
if (sessionId === null)
|
|
6367
|
+
return;
|
|
6368
|
+
withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
|
|
6369
|
+
tenantId: resolveTenantId({}), sessionId, surface: 'hook', event: 'reset', items: 0, tokens: 0,
|
|
6370
|
+
}));
|
|
6371
|
+
}
|
|
6372
|
+
/**
|
|
6373
|
+
* Run `fn` with console.log captured; returns the captured lines joined by
|
|
6374
|
+
* newlines (what the same calls would have printed, minus the final newline).
|
|
6375
|
+
*/
|
|
6376
|
+
function captureConsole(fn) {
|
|
6377
|
+
const lines = [];
|
|
6378
|
+
const realLog = console.log;
|
|
6379
|
+
console.log = (...parts) => { lines.push(parts.map(String).join(' ')); };
|
|
6380
|
+
try {
|
|
6381
|
+
fn();
|
|
6382
|
+
}
|
|
6383
|
+
finally {
|
|
6384
|
+
console.log = realLog;
|
|
6385
|
+
}
|
|
6386
|
+
return lines.join('\n');
|
|
6387
|
+
}
|
|
6388
|
+
/**
|
|
6389
|
+
* The store a Claude Code hook writes to: the project store when there is
|
|
6390
|
+
* one, else an existing global store, else the project path (which the hook
|
|
6391
|
+
* then skips, since hooks fire in every directory and must not create one).
|
|
6392
|
+
* Pre-compact and compact-resume must agree, or a snapshot saved to one store
|
|
6393
|
+
* is looked for in the other.
|
|
6394
|
+
*/
|
|
6395
|
+
function hookStoreRoot(hippoRoot) {
|
|
6396
|
+
if (isInitialized(hippoRoot))
|
|
6397
|
+
return hippoRoot;
|
|
6398
|
+
const globalRoot = getGlobalRoot();
|
|
6399
|
+
return isInitialized(globalRoot) ? globalRoot : hippoRoot;
|
|
6400
|
+
}
|
|
6401
|
+
/**
|
|
6402
|
+
* Run `fn` against the token ledger's store: the local store when it is
|
|
6403
|
+
* initialized, else the global one (the per-prompt hook runs in directories
|
|
6404
|
+
* without a local store). Best-effort: returns undefined and never throws,
|
|
6405
|
+
* because a ledger failure must not break context or recall.
|
|
6406
|
+
*/
|
|
6407
|
+
function withLedgerDb(hippoRoot, fn) {
|
|
6408
|
+
let root = null;
|
|
6409
|
+
try {
|
|
6410
|
+
if (isInitialized(hippoRoot))
|
|
6411
|
+
root = hippoRoot;
|
|
6412
|
+
else if (isInitialized(getGlobalRoot()))
|
|
6413
|
+
root = getGlobalRoot();
|
|
6414
|
+
}
|
|
6415
|
+
catch {
|
|
6416
|
+
return undefined;
|
|
6417
|
+
}
|
|
6418
|
+
if (root === null)
|
|
6419
|
+
return undefined;
|
|
6420
|
+
let db;
|
|
6421
|
+
try {
|
|
6422
|
+
db = openHippoDb(root);
|
|
6423
|
+
return fn(db);
|
|
6424
|
+
}
|
|
6425
|
+
catch {
|
|
6426
|
+
return undefined;
|
|
6427
|
+
}
|
|
6428
|
+
finally {
|
|
6429
|
+
if (db)
|
|
6430
|
+
closeHippoDb(db);
|
|
6180
6431
|
}
|
|
6181
6432
|
}
|
|
6182
6433
|
/**
|
|
@@ -6195,13 +6446,15 @@ function printCrossProjectSection(items) {
|
|
|
6195
6446
|
}
|
|
6196
6447
|
}
|
|
6197
6448
|
/** @internal — exported for snapshot tests (tests/cli-context-render-snapshot.test.ts). NOT a stable public API. */
|
|
6198
|
-
export function printContextMarkdown(items, totalTokens, framing = 'observe') {
|
|
6449
|
+
export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
|
|
6199
6450
|
const now = evalNow();
|
|
6451
|
+
const showStrength = opts.showStrength !== false;
|
|
6200
6452
|
console.log(`## Project Memory (${items.length} entries, ${totalTokens} tokens)\n`);
|
|
6201
6453
|
for (const item of items) {
|
|
6202
6454
|
const e = item.entry;
|
|
6203
6455
|
const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
|
|
6204
6456
|
const strengthPct = Math.round(calculateStrength(e) * 100);
|
|
6457
|
+
const strengthStr = showStrength ? ` (${strengthPct}%)` : '';
|
|
6205
6458
|
const globalPrefix = item.isGlobal ? '[global] ' : '';
|
|
6206
6459
|
const effectiveConf = confidenceFacets(e, now).tier;
|
|
6207
6460
|
const label = confidenceLabel(e, now);
|
|
@@ -6211,21 +6464,21 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe') {
|
|
|
6211
6464
|
const dateStr = new Date(e.created).toISOString().slice(0, 10);
|
|
6212
6465
|
if (effectiveConf === 'verified') {
|
|
6213
6466
|
// Verified: no date prefix, just the rule
|
|
6214
|
-
console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}
|
|
6467
|
+
console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
|
|
6215
6468
|
}
|
|
6216
6469
|
else if (effectiveConf === 'stale') {
|
|
6217
|
-
console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}
|
|
6470
|
+
console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
|
|
6218
6471
|
}
|
|
6219
6472
|
else {
|
|
6220
|
-
console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}
|
|
6473
|
+
console.log(`- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
|
|
6221
6474
|
}
|
|
6222
6475
|
}
|
|
6223
6476
|
else if (framing === 'suggest') {
|
|
6224
|
-
console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}
|
|
6477
|
+
console.log(`- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
|
|
6225
6478
|
}
|
|
6226
6479
|
else {
|
|
6227
6480
|
// framing === 'assert': no prefix (bare facts)
|
|
6228
|
-
console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}
|
|
6481
|
+
console.log(`- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`);
|
|
6229
6482
|
}
|
|
6230
6483
|
}
|
|
6231
6484
|
}
|
|
@@ -6369,7 +6622,7 @@ async function cmdWatch(command, hippoRoot) {
|
|
|
6369
6622
|
const existingWatch = loadAllEntries(hippoRoot, entry.tenantId);
|
|
6370
6623
|
const watchFit = computeSchemaFit(entry.content, entry.tags, existingWatch);
|
|
6371
6624
|
entry.schema_fit = watchFit;
|
|
6372
|
-
entry.half_life_days = deriveHalfLife(
|
|
6625
|
+
entry.half_life_days = deriveHalfLife(loadConfig(hippoRoot).defaultHalfLifeDays, entry);
|
|
6373
6626
|
entry.strength = calculateStrength(entry);
|
|
6374
6627
|
// AT1 (plan §3 containment): mechanical content from a failed command — a
|
|
6375
6628
|
// rejection-guard refusal here must not crash the watcher. Skip silently
|
|
@@ -6408,7 +6661,9 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
|
|
|
6408
6661
|
console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
|
|
6409
6662
|
return { added: 0, skipped: 0, lowInfo: 0 };
|
|
6410
6663
|
}
|
|
6411
|
-
|
|
6664
|
+
// Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
|
|
6665
|
+
// equals extractLessons' built-in list) so a custom list applies everywhere.
|
|
6666
|
+
const parsedLessons = extractLessons(gitLog, loadConfig(hippoRoot).gitLearnPatterns);
|
|
6412
6667
|
if (parsedLessons.length === 0) {
|
|
6413
6668
|
console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
|
|
6414
6669
|
return { added: 0, skipped: 0, lowInfo: 0 };
|
|
@@ -6968,6 +7223,12 @@ function cmdHook(args, flags) {
|
|
|
6968
7223
|
if (result.installedCompactResume) {
|
|
6969
7224
|
console.log(`Installed hippo compact-resume SessionStart(compact) hook in ${result.target} settings`);
|
|
6970
7225
|
}
|
|
7226
|
+
if (result.installedPostCompact) {
|
|
7227
|
+
console.log(`Installed hippo post-compact PostCompact hook in ${result.target} settings`);
|
|
7228
|
+
}
|
|
7229
|
+
if (result.installedCaptureError) {
|
|
7230
|
+
console.log(`Installed hippo capture-error PostToolUseFailure hook in ${result.target} settings`);
|
|
7231
|
+
}
|
|
6971
7232
|
if (result.migratedFromStop) {
|
|
6972
7233
|
console.log(`Migrated legacy Stop hook → SessionEnd (was running every turn; now fires once on session exit)`);
|
|
6973
7234
|
}
|
|
@@ -7088,6 +7349,10 @@ function cmdSetup(flags) {
|
|
|
7088
7349
|
bits.push('PreCompact (pre-compact)');
|
|
7089
7350
|
if (result.installedCompactResume)
|
|
7090
7351
|
bits.push('SessionStart(compact) (compact-resume)');
|
|
7352
|
+
if (result.installedPostCompact)
|
|
7353
|
+
bits.push('PostCompact (post-compact)');
|
|
7354
|
+
if (result.installedCaptureError)
|
|
7355
|
+
bits.push('PostToolUseFailure (capture-error)');
|
|
7091
7356
|
if (result.migratedFromStop)
|
|
7092
7357
|
bits.push('migrated legacy Stop');
|
|
7093
7358
|
if (result.migratedSplitSessionEnd)
|
|
@@ -7629,6 +7894,8 @@ const VALID_AUDIT_OPS = new Set([
|
|
|
7629
7894
|
'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
|
|
7630
7895
|
'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
|
|
7631
7896
|
'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
|
|
7897
|
+
'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
|
|
7898
|
+
'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
|
|
7632
7899
|
]);
|
|
7633
7900
|
function formatAuditRow(ev) {
|
|
7634
7901
|
const target = ev.targetId ?? '-';
|
|
@@ -8354,6 +8621,23 @@ Commands:
|
|
|
8354
8621
|
--global Operate on the global store
|
|
8355
8622
|
unreject <digest-prefix> Delete a tombstone (the only escape hatch)
|
|
8356
8623
|
--global Operate on the global store
|
|
8624
|
+
dormant [<query>] List faded memories sleep kept instead of deleting
|
|
8625
|
+
(on by default; "dormant": {"enabled": false} deletes instead)
|
|
8626
|
+
--limit <n> Max rows, newest first (default: 20)
|
|
8627
|
+
--json Output as JSON
|
|
8628
|
+
--global Operate on the global store
|
|
8629
|
+
dormant restore <id> Bring a dormant memory back to active memory
|
|
8630
|
+
dormant forget <id> Delete a dormant memory permanently
|
|
8631
|
+
capture-error Store a failed tool call as an error memory (reads the Claude Code
|
|
8632
|
+
PostToolUseFailure hook payload on stdin; skips routine failures)
|
|
8633
|
+
doctor Check the install: Node, store, schema, sleep, agent hooks
|
|
8634
|
+
--json Machine-readable report (exit code 1 on any failure)
|
|
8635
|
+
tokens Tokens of memory text hippo handed agents, per surface
|
|
8636
|
+
(hook, context, recall, MCP, HTTP), and what skipping
|
|
8637
|
+
unchanged hook blocks saved
|
|
8638
|
+
--days <n> Window in days (default: 30)
|
|
8639
|
+
--json Output as JSON
|
|
8640
|
+
--global Operate on the global store
|
|
8357
8641
|
snapshot <sub> Persist or inspect the current active task
|
|
8358
8642
|
snapshot save Save active task state
|
|
8359
8643
|
--task <task>
|
|
@@ -8481,6 +8765,8 @@ Commands:
|
|
|
8481
8765
|
pre-compact PreCompact hook: snapshot + capture the tail before compaction
|
|
8482
8766
|
--log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
|
|
8483
8767
|
compact-resume SessionStart(compact) hook: re-print the snapshot + session trail
|
|
8768
|
+
post-compact PostCompact hook: tell the user what pre-compact saved
|
|
8769
|
+
--log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)
|
|
8484
8770
|
codex-run [-- ...args] Launch real Codex behind Hippo's session-end wrapper
|
|
8485
8771
|
hook <sub> [target] Manage framework integrations
|
|
8486
8772
|
hook list Show available hooks
|
|
@@ -8645,6 +8931,9 @@ Examples:
|
|
|
8645
8931
|
hippo reject --value "never store my key again" --reason "secret"
|
|
8646
8932
|
hippo rejections
|
|
8647
8933
|
hippo unreject a1b2c3d4e5f6
|
|
8934
|
+
hippo dormant "staging hostname"
|
|
8935
|
+
hippo dormant restore mem_abc123
|
|
8936
|
+
hippo tokens --days 7
|
|
8648
8937
|
hippo session log --id sess_123 --task "Ship feature" --type progress --content "Build is green, next step is docs"
|
|
8649
8938
|
hippo session latest --json
|
|
8650
8939
|
hippo session resume
|
|
@@ -8893,16 +9182,45 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
8893
9182
|
case 'pre-compact': {
|
|
8894
9183
|
// Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
|
|
8895
9184
|
const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
|
|
8896
|
-
|
|
9185
|
+
resetHookInjection(hippoRoot, stdinText, null);
|
|
9186
|
+
await cmdPreCompact(hookStoreRoot(hippoRoot), {
|
|
8897
9187
|
stdinText,
|
|
8898
9188
|
stdinTimedOut,
|
|
8899
9189
|
logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
|
|
8900
9190
|
});
|
|
8901
9191
|
break;
|
|
8902
9192
|
}
|
|
9193
|
+
case 'post-compact': {
|
|
9194
|
+
// PostCompact hook: tells the user what pre-compact saved. Plain text,
|
|
9195
|
+
// because Claude Code shows this hook's stdout as-is. Always exits 0.
|
|
9196
|
+
const { text } = await readStdinBounded();
|
|
9197
|
+
const logFlag = flags['log-file'];
|
|
9198
|
+
const message = postCompactMessage(text, logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag);
|
|
9199
|
+
if (message !== null)
|
|
9200
|
+
console.log(message);
|
|
9201
|
+
break;
|
|
9202
|
+
}
|
|
9203
|
+
case 'capture-error': {
|
|
9204
|
+
// PostToolUseFailure hook: every path exits 0, and nothing is created
|
|
9205
|
+
// when no store exists (the hook fires in every directory).
|
|
9206
|
+
const { text } = await readStdinBounded();
|
|
9207
|
+
try {
|
|
9208
|
+
const root = hookStoreRoot(hippoRoot);
|
|
9209
|
+
const payload = (text ?? '').trim();
|
|
9210
|
+
if (isInitialized(root) && payload) {
|
|
9211
|
+
// SAFETY: JSON.parse returns a JSON value by definition.
|
|
9212
|
+
captureToolFailure(root, resolveTenantId({}), JSON.parse(payload));
|
|
9213
|
+
}
|
|
9214
|
+
}
|
|
9215
|
+
catch {
|
|
9216
|
+
// A malformed payload or store error must never fail the agent's tool call.
|
|
9217
|
+
}
|
|
9218
|
+
break;
|
|
9219
|
+
}
|
|
8903
9220
|
case 'compact-resume': {
|
|
8904
9221
|
const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
|
|
8905
|
-
|
|
9222
|
+
resetHookInjection(hippoRoot, stdinText, 'compact');
|
|
9223
|
+
cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
|
|
8906
9224
|
break;
|
|
8907
9225
|
}
|
|
8908
9226
|
case 'codex-run':
|
|
@@ -9049,6 +9367,21 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9049
9367
|
case 'unreject':
|
|
9050
9368
|
cmdUnreject(hippoRoot, args, flags);
|
|
9051
9369
|
break;
|
|
9370
|
+
case 'dormant':
|
|
9371
|
+
cmdDormant(hippoRoot, args, flags);
|
|
9372
|
+
break;
|
|
9373
|
+
case 'tokens':
|
|
9374
|
+
cmdTokens(hippoRoot, flags);
|
|
9375
|
+
break;
|
|
9376
|
+
case 'doctor': {
|
|
9377
|
+
// SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
|
|
9378
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
9379
|
+
const report = runDoctor({ version: pkg.version });
|
|
9380
|
+
console.log(flags['json'] ? JSON.stringify(report, null, 2) : formatDoctor(report));
|
|
9381
|
+
if (!report.ok)
|
|
9382
|
+
process.exit(1);
|
|
9383
|
+
break;
|
|
9384
|
+
}
|
|
9052
9385
|
case 'snapshot':
|
|
9053
9386
|
cmdSnapshot(hippoRoot, args, flags);
|
|
9054
9387
|
break;
|