hippo-memory 1.46.0 → 1.47.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 -1
- package/bin/hippo.js +0 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +16 -3
- package/dist/capture-error.d.ts +18 -12
- package/dist/capture-error.js +70 -42
- package/dist/cli.js +51 -0
- package/dist/db.js +25 -1
- package/dist/doctor.js +9 -0
- package/dist/failure-log.d.ts +49 -0
- package/dist/failure-log.js +58 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -630,6 +630,7 @@ hippo watch "npm run build"
|
|
|
630
630
|
| `hippo dormant forget <id>` | Delete a dormant memory permanently |
|
|
631
631
|
| `hippo doctor [--json]` | Check the install: Node, store, schema, sleep, agent hooks; each problem names its fix |
|
|
632
632
|
| `hippo tokens [--days n]` | Estimated tokens of memory text handed to agents, per surface, and what skipping unchanged hook blocks saved |
|
|
633
|
+
| `hippo failures [--days n]` | Failed tool calls the capture-error hook saw, by outcome, and how many errors first happened in another session |
|
|
633
634
|
| `hippo embed` | Embed all memories for semantic search |
|
|
634
635
|
| `hippo embed --status` | Show embedding coverage |
|
|
635
636
|
| `hippo watch "<command>"` | Run command, auto-learn from failures |
|
|
@@ -725,7 +726,7 @@ For Claude Code, it also adds:
|
|
|
725
726
|
- a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It saves a working-state snapshot (task/summary/next step) and extracts durable memories from the tail, so mid-session compaction can't drop them.
|
|
726
727
|
- 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.
|
|
727
728
|
- a `PostCompact` hook that runs `hippo post-compact`, which tells you what was saved ("Hippo saved your task snapshot and 2 new memories before compacting"). It prints nothing when nothing was saved.
|
|
728
|
-
- 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.
|
|
729
|
+
- 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.
|
|
729
730
|
|
|
730
731
|
To remove: `hippo hook uninstall claude-code`
|
|
731
732
|
|
package/bin/hippo.js
CHANGED
|
File without changes
|
package/dist/api.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } fro
|
|
|
11
11
|
import { type RejectedValueRow } from './rejection.js';
|
|
12
12
|
import { type DormantMemory, type ListDormantOpts } from './dormant.js';
|
|
13
13
|
import { type TokenSummary, type TokenSurface } from './token-ledger.js';
|
|
14
|
+
import { type FailureSummary } from './failure-log.js';
|
|
14
15
|
import { type SessionHandoff } from './handoff.js';
|
|
15
16
|
import { type MemoryKind, type MemoryEntry } from './memory.js';
|
|
16
17
|
import { auditMemories, type AuditEvent, type AuditOp } from './audit.js';
|
|
@@ -82,6 +83,7 @@ import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
|
|
|
82
83
|
export { isPrivateScope, passesScopeFilterForRecall };
|
|
83
84
|
export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
|
|
84
85
|
export type { TokenSummary, TokenSurface, TokenSurfaceSummary } from './token-ledger.js';
|
|
86
|
+
export type { FailureSummary } from './failure-log.js';
|
|
85
87
|
export { classifyOriginProject } from './project-identity.js';
|
|
86
88
|
/**
|
|
87
89
|
* v39 S4: the secret half of the ambient policy on its own, for surfaces
|
|
@@ -1019,6 +1021,10 @@ export declare function recordTokens(ctx: Context, surface: TokenSurface, use: {
|
|
|
1019
1021
|
export declare function tokenSummary(ctx: Context, opts?: {
|
|
1020
1022
|
days?: number;
|
|
1021
1023
|
}): TokenSummary;
|
|
1024
|
+
/** Failed tool calls by outcome, and repeats across sessions, over the last `days` days (default 30); ROADMAP CD13. */
|
|
1025
|
+
export declare function failureSummary(ctx: Context, opts?: {
|
|
1026
|
+
days?: number;
|
|
1027
|
+
}): FailureSummary;
|
|
1022
1028
|
/**
|
|
1023
1029
|
* A tenant's dormant memories (src/dormant.ts): what sleep moved out of
|
|
1024
1030
|
* active memory instead of deleting, when `dormant.enabled` is on. Newest
|
package/dist/api.js
CHANGED
|
@@ -13,6 +13,7 @@ import { RejectedValueError } from './rejection.js';
|
|
|
13
13
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
14
14
|
import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
|
|
15
15
|
import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
|
|
16
|
+
import { summarizeFailures } from './failure-log.js';
|
|
16
17
|
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
17
18
|
import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
|
|
18
19
|
import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
@@ -1896,16 +1897,28 @@ export function recordTokens(ctx, surface, use) {
|
|
|
1896
1897
|
* saved, and mean tokens per session.
|
|
1897
1898
|
*/
|
|
1898
1899
|
export function tokenSummary(ctx, opts = {}) {
|
|
1899
|
-
const days = opts.days !== undefined && Number.isFinite(opts.days) && opts.days > 0 ? opts.days : 30;
|
|
1900
|
-
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
1901
1900
|
const db = openHippoDb(ctx.hippoRoot);
|
|
1902
1901
|
try {
|
|
1903
|
-
return summarizeTokenUse(db, ctx.tenantId,
|
|
1902
|
+
return summarizeTokenUse(db, ctx.tenantId, reportWindowStart(opts.days));
|
|
1904
1903
|
}
|
|
1905
1904
|
finally {
|
|
1906
1905
|
closeHippoDb(db);
|
|
1907
1906
|
}
|
|
1908
1907
|
}
|
|
1908
|
+
/** Failed tool calls by outcome, and repeats across sessions, over the last `days` days (default 30); ROADMAP CD13. */
|
|
1909
|
+
export function failureSummary(ctx, opts = {}) {
|
|
1910
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
1911
|
+
try {
|
|
1912
|
+
return summarizeFailures(db, ctx.tenantId, reportWindowStart(opts.days));
|
|
1913
|
+
}
|
|
1914
|
+
finally {
|
|
1915
|
+
closeHippoDb(db);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
function reportWindowStart(days) {
|
|
1919
|
+
const span = days !== undefined && Number.isFinite(days) && days > 0 ? days : 30;
|
|
1920
|
+
return new Date(Date.now() - span * 86_400_000).toISOString();
|
|
1921
|
+
}
|
|
1909
1922
|
/**
|
|
1910
1923
|
* A tenant's dormant memories (src/dormant.ts): what sleep moved out of
|
|
1911
1924
|
* active memory instead of deleting, when `dormant.enabled` is on. Newest
|
package/dist/capture-error.d.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import type { JsonValue } from './working-memory.js';
|
|
2
2
|
/** Why a failure was not stored, or `stored`. */
|
|
3
3
|
export type CaptureErrorOutcome = 'stored' | 'duplicate' | 'skipped-interrupt' | 'skipped-routine' | 'skipped-invalid';
|
|
4
|
-
/**
|
|
5
|
-
export
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
/** Which routine check skipped a failure; the log keeps it so declines can be told apart from empty searches. */
|
|
5
|
+
export type RoutineRule = 'declined' | 'os-permission' | 'no-match' | 'search-tool' | 'quiet-exit';
|
|
6
|
+
/** What {@link lessonFromFailure} read from a payload; `detail` is the finer failure-log key (untruncated, command head). */
|
|
7
|
+
export type FailureReading = {
|
|
8
|
+
text: string;
|
|
9
|
+
detail: string;
|
|
10
|
+
} | {
|
|
11
|
+
skip: 'skipped-routine';
|
|
12
|
+
rule: RoutineRule;
|
|
11
13
|
text: string;
|
|
14
|
+
detail: string;
|
|
12
15
|
} | {
|
|
13
|
-
skip:
|
|
16
|
+
skip: 'skipped-interrupt' | 'skipped-invalid';
|
|
17
|
+
text: null;
|
|
18
|
+
detail: null;
|
|
14
19
|
};
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
/** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
|
|
21
|
+
export declare function failureSignature(text: string): string;
|
|
22
|
+
/** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. Pure. */
|
|
23
|
+
export declare function lessonFromFailure(payload: JsonValue): FailureReading;
|
|
24
|
+
/** Store a failure as an error memory unless it is routine or a repeat, and log it either way, even when storing throws. */
|
|
19
25
|
export declare function captureToolFailure(hippoRoot: string, tenantId: string, payload: JsonValue): CaptureErrorOutcome;
|
|
20
26
|
//# sourceMappingURL=capture-error.d.ts.map
|
package/dist/capture-error.js
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* claude-code` and the plugin), which writes the failure as JSON on stdin.
|
|
5
|
-
*
|
|
6
|
-
* Most failed tool calls are not lessons. An interrupt, a permission the
|
|
7
|
-
* user declined, a search that found nothing or a `grep` that exits 1 is
|
|
8
|
-
* routine, and error memories decay slower than other memories, so storing
|
|
9
|
-
* them would crowd out real lessons. Those are dropped here, repeats of the
|
|
10
|
-
* same failure are stored once, and what is stored is marked `observed`
|
|
11
|
-
* (auto-captured, not verified): outcome feedback, not capture, is what
|
|
12
|
-
* should strengthen it.
|
|
13
|
-
*/
|
|
1
|
+
// `hippo capture-error`, run by the Claude Code PostToolUseFailure hook: routine failures and repeats are not
|
|
2
|
+
// stored, because error memories decay slowly and would crowd out real lessons; what is stored stays `observed`
|
|
3
|
+
// until outcome feedback confirms it. Every failure, stored or not, goes to the failure log (ROADMAP CD13).
|
|
14
4
|
import { createMemory } from './memory.js';
|
|
15
5
|
import { writeEntry, loadAllEntries } from './store.js';
|
|
16
6
|
import { loadConfig } from './config.js';
|
|
7
|
+
import { closeHippoDb, openHippoDb } from './db.js';
|
|
8
|
+
import { recordFailure } from './failure-log.js';
|
|
9
|
+
import { blockHash } from './token-ledger.js';
|
|
17
10
|
const MAX_LEN = 200;
|
|
18
|
-
/**
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
/** The user, a permission prompt or a hook said no: routine, not a lesson. */
|
|
12
|
+
const DECLINED = /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission to use|was blocked by (?:a )?hook/i;
|
|
13
|
+
/** The OS or a remote host refused access (EACCES, SSH publickey): routine too, but nobody declined anything. */
|
|
14
|
+
const OS_PERMISSION = /permission denied/i;
|
|
15
|
+
/** Leading `cd` or `pushd` steps say where a command ran, not what it ran. */
|
|
16
|
+
const LEADING_CD = /^\s*(?:(?:cd|pushd)\b[^;&|]*(?:&&|\|\||;)\s*)+/;
|
|
17
|
+
const NO_MATCH = /\bno (?:matches|files|results) found\b/i;
|
|
23
18
|
/** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
|
|
24
19
|
const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
|
|
25
20
|
function isString(v) {
|
|
@@ -28,48 +23,81 @@ function isString(v) {
|
|
|
28
23
|
function isObject(v) {
|
|
29
24
|
return v !== undefined && v !== null && !Array.isArray(v) && v.constructor === Object;
|
|
30
25
|
}
|
|
31
|
-
|
|
26
|
+
function payloadString(payload, key) {
|
|
27
|
+
if (!isObject(payload))
|
|
28
|
+
return null;
|
|
29
|
+
const value = payload[key];
|
|
30
|
+
return isString(value) && value.trim() !== '' ? value : null;
|
|
31
|
+
}
|
|
32
|
+
/** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
|
|
32
33
|
export function failureSignature(text) {
|
|
33
34
|
return text.toLowerCase().replace(/[0-9a-f]{7,}/g, '#').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
|
|
34
35
|
}
|
|
35
|
-
/**
|
|
36
|
-
* The memory text for a failure payload, or the reason it is not stored.
|
|
37
|
-
* Pure: no store access.
|
|
38
|
-
*/
|
|
36
|
+
/** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. Pure. */
|
|
39
37
|
export function lessonFromFailure(payload) {
|
|
40
38
|
if (!isObject(payload))
|
|
41
|
-
return { skip: 'skipped-invalid' };
|
|
39
|
+
return { skip: 'skipped-invalid', text: null, detail: null };
|
|
42
40
|
// SAFETY: isObject narrowed payload to a plain JSON object; the fields read are all optional.
|
|
43
41
|
const p = payload;
|
|
44
42
|
if (p.is_interrupt === true)
|
|
45
|
-
return { skip: 'skipped-interrupt' };
|
|
43
|
+
return { skip: 'skipped-interrupt', text: null, detail: null };
|
|
46
44
|
if (!isString(p.error) || p.error.trim().length < 12)
|
|
47
|
-
return { skip: 'skipped-invalid' };
|
|
45
|
+
return { skip: 'skipped-invalid', text: null, detail: null };
|
|
48
46
|
const tool = isString(p.tool_name) ? p.tool_name : 'tool';
|
|
49
47
|
const error = p.error.replace(/\s+/g, ' ').trim();
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const text = `${tool}: ${error}`.slice(0, MAX_LEN);
|
|
49
|
+
const command = isObject(p.tool_input) && isString(p.tool_input['command']) ? p.tool_input['command'].replace(LEADING_CD, '') : '';
|
|
50
|
+
const head = command.trim().split(/\s+/).slice(0, 2).join(' ');
|
|
51
|
+
const detail = `${tool}${head ? ` ${head}` : ''}: ${error}`;
|
|
52
|
+
const routine = (rule) => ({ skip: 'skipped-routine', rule, text, detail });
|
|
53
|
+
if (DECLINED.test(error))
|
|
54
|
+
return routine('declined');
|
|
55
|
+
if (OS_PERMISSION.test(error))
|
|
56
|
+
return routine('os-permission');
|
|
57
|
+
if (NO_MATCH.test(error))
|
|
58
|
+
return routine('no-match');
|
|
52
59
|
if (tool === 'Grep' || tool === 'Glob')
|
|
53
|
-
return
|
|
54
|
-
if (tool === 'Bash' &&
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
return { text: `${tool}: ${error}`.slice(0, MAX_LEN) };
|
|
60
|
+
return routine('search-tool');
|
|
61
|
+
if (tool === 'Bash' && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
|
|
62
|
+
return routine('quiet-exit');
|
|
63
|
+
return { text, detail };
|
|
59
64
|
}
|
|
60
|
-
/**
|
|
61
|
-
* Store a failure payload as an error memory unless it is routine or a
|
|
62
|
-
* repeat of an auto-captured error already in the store.
|
|
63
|
-
*/
|
|
65
|
+
/** Store a failure as an error memory unless it is routine or a repeat, and log it either way, even when storing throws. */
|
|
64
66
|
export function captureToolFailure(hippoRoot, tenantId, payload) {
|
|
65
67
|
const lesson = lessonFromFailure(payload);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
let outcome = 'store-failed';
|
|
69
|
+
try {
|
|
70
|
+
outcome = 'skip' in lesson ? lesson.skip : storeLesson(hippoRoot, tenantId, lesson.text);
|
|
71
|
+
return outcome;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
logFailure(hippoRoot, tenantId, payload, lesson, outcome);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function logFailure(hippoRoot, tenantId, payload, lesson, outcome) {
|
|
78
|
+
const hash = (s) => (s === null ? null : blockHash(failureSignature(s)));
|
|
79
|
+
const db = openHippoDb(hippoRoot);
|
|
80
|
+
try {
|
|
81
|
+
recordFailure(db, {
|
|
82
|
+
tenantId,
|
|
83
|
+
sessionId: payloadString(payload, 'session_id'),
|
|
84
|
+
tool: payloadString(payload, 'tool_name'),
|
|
85
|
+
outcome,
|
|
86
|
+
rule: 'rule' in lesson ? lesson.rule : null,
|
|
87
|
+
sigHash: hash(lesson.text),
|
|
88
|
+
detailHash: hash(lesson.detail),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
closeHippoDb(db);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function storeLesson(hippoRoot, tenantId, text) {
|
|
96
|
+
const sig = failureSignature(text);
|
|
69
97
|
const repeat = loadAllEntries(hippoRoot, tenantId).some((e) => e.tags.includes('auto-captured') && failureSignature(e.content) === sig);
|
|
70
98
|
if (repeat)
|
|
71
99
|
return 'duplicate';
|
|
72
|
-
const entry = createMemory(
|
|
100
|
+
const entry = createMemory(text, {
|
|
73
101
|
tags: ['error', 'auto-captured'],
|
|
74
102
|
source: 'tool-failure',
|
|
75
103
|
confidence: 'observed',
|
package/dist/cli.js
CHANGED
|
@@ -62,6 +62,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
|
|
|
62
62
|
import { runDoctor, formatDoctor } from './doctor.js';
|
|
63
63
|
import { captureToolFailure } from './capture-error.js';
|
|
64
64
|
import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
|
|
65
|
+
import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
|
|
65
66
|
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
|
|
66
67
|
import { rowToGoal } from './goals.js';
|
|
67
68
|
import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
|
|
@@ -3929,6 +3930,48 @@ function cmdTokens(hippoRoot, flags) {
|
|
|
3929
3930
|
console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
|
|
3930
3931
|
}
|
|
3931
3932
|
}
|
|
3933
|
+
/** `hippo failures [--days <n>] [--json] [--global]`: failed tool calls by outcome, and repeats across sessions (CD13). */
|
|
3934
|
+
function cmdFailures(hippoRoot, flags) {
|
|
3935
|
+
// The store the capture-error hook writes to; a report never creates one.
|
|
3936
|
+
const root = flags['global'] ? getGlobalRoot() : hookStoreRoot(hippoRoot);
|
|
3937
|
+
requireInit(root);
|
|
3938
|
+
const ctx = {
|
|
3939
|
+
hippoRoot: root,
|
|
3940
|
+
tenantId: resolveTenantId({}),
|
|
3941
|
+
actor: api.adminActor('cli'),
|
|
3942
|
+
};
|
|
3943
|
+
const days = parseCountFlag(flags['days']);
|
|
3944
|
+
const summary = api.failureSummary(ctx, { days: days > 0 ? days : undefined });
|
|
3945
|
+
if (flags['json']) {
|
|
3946
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
const windowDays = days > 0 ? days : 30;
|
|
3950
|
+
const kept = windowDays > FAILURE_LOG_RETENTION_DAYS ? ` (rows are kept ${FAILURE_LOG_RETENTION_DAYS} days)` : '';
|
|
3951
|
+
if (summary.total === 0) {
|
|
3952
|
+
console.log(`No failed tool calls recorded in the last ${windowDays} days${kept}.`);
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
const o = summary.outcomes;
|
|
3956
|
+
const errors = o.stored + o.duplicate + o['store-failed'];
|
|
3957
|
+
const unsaved = o['store-failed'] > 0 ? `, ${o['store-failed']} could not be saved` : '';
|
|
3958
|
+
const rows = [
|
|
3959
|
+
['errors', errors, `(${o.stored} new, ${o.duplicate} already in memory${unsaved})`],
|
|
3960
|
+
['routine', o['skipped-routine'], ''],
|
|
3961
|
+
['interrupted', o['skipped-interrupt'], ''],
|
|
3962
|
+
['unreadable', o['skipped-invalid'], ''],
|
|
3963
|
+
];
|
|
3964
|
+
console.log(`Failed tool calls seen by the capture-error hook, last ${windowDays} days${kept}\n`);
|
|
3965
|
+
for (const [label, count, note] of rows) {
|
|
3966
|
+
console.log(` ${label.padEnd(13)}${String(count).padStart(6)} ${note}`.trimEnd());
|
|
3967
|
+
}
|
|
3968
|
+
// Counts, not a rate: a share means little without a holdout arm to compare against (CD11).
|
|
3969
|
+
if (summary.rated > 0) {
|
|
3970
|
+
const noSession = errors - summary.rated;
|
|
3971
|
+
const unrated = noSession > 0 ? ` ${noSession} more had no session id.` : '';
|
|
3972
|
+
console.log(`\n Repeats: ${summary.repeats} of ${summary.rated} errors first happened in another session.${unrated}`);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3932
3975
|
function cmdSnapshot(hippoRoot, args, flags) {
|
|
3933
3976
|
requireInit(hippoRoot);
|
|
3934
3977
|
const subcommand = args[0] ?? 'show';
|
|
@@ -8638,6 +8681,11 @@ Commands:
|
|
|
8638
8681
|
--days <n> Window in days (default: 30)
|
|
8639
8682
|
--json Output as JSON
|
|
8640
8683
|
--global Operate on the global store
|
|
8684
|
+
failures Failed tool calls capture-error saw, by outcome, and how
|
|
8685
|
+
many errors first happened in another session
|
|
8686
|
+
--days <n> Window in days (default: 30)
|
|
8687
|
+
--json Output as JSON
|
|
8688
|
+
--global Operate on the global store
|
|
8641
8689
|
snapshot <sub> Persist or inspect the current active task
|
|
8642
8690
|
snapshot save Save active task state
|
|
8643
8691
|
--task <task>
|
|
@@ -9373,6 +9421,9 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9373
9421
|
case 'tokens':
|
|
9374
9422
|
cmdTokens(hippoRoot, flags);
|
|
9375
9423
|
break;
|
|
9424
|
+
case 'failures':
|
|
9425
|
+
cmdFailures(hippoRoot, flags);
|
|
9426
|
+
break;
|
|
9376
9427
|
case 'doctor': {
|
|
9377
9428
|
// SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
|
|
9378
9429
|
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
package/dist/db.js
CHANGED
|
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
// runtime (Node's built-in synchronous SQLite module); there are no bundled
|
|
12
12
|
// types for it here, so this require + cast is the module's documented boundary.
|
|
13
13
|
const { DatabaseSync } = require('node:sqlite');
|
|
14
|
-
const CURRENT_SCHEMA_VERSION =
|
|
14
|
+
const CURRENT_SCHEMA_VERSION = 46;
|
|
15
15
|
const MIGRATIONS = [
|
|
16
16
|
{
|
|
17
17
|
version: 1,
|
|
@@ -2423,6 +2423,30 @@ const MIGRATIONS = [
|
|
|
2423
2423
|
`);
|
|
2424
2424
|
},
|
|
2425
2425
|
},
|
|
2426
|
+
{
|
|
2427
|
+
version: 46,
|
|
2428
|
+
up: (db) => {
|
|
2429
|
+
// CD13 failure log (src/failure-log.ts): hashes only, since failure text can carry paths and secrets.
|
|
2430
|
+
// Additive only: no min_compatible_binary bump.
|
|
2431
|
+
db.exec(`
|
|
2432
|
+
CREATE TABLE IF NOT EXISTS failure_log (
|
|
2433
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2434
|
+
ts TEXT NOT NULL,
|
|
2435
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
2436
|
+
session_id TEXT,
|
|
2437
|
+
tool TEXT,
|
|
2438
|
+
outcome TEXT NOT NULL,
|
|
2439
|
+
skip_rule TEXT,
|
|
2440
|
+
sig_hash TEXT,
|
|
2441
|
+
detail_hash TEXT
|
|
2442
|
+
);
|
|
2443
|
+
CREATE INDEX IF NOT EXISTS idx_failure_log_tenant
|
|
2444
|
+
ON failure_log(tenant_id, id);
|
|
2445
|
+
CREATE INDEX IF NOT EXISTS idx_failure_log_ts
|
|
2446
|
+
ON failure_log(ts);
|
|
2447
|
+
`);
|
|
2448
|
+
},
|
|
2449
|
+
},
|
|
2426
2450
|
];
|
|
2427
2451
|
function tableHasColumn(db, tableName, columnName) {
|
|
2428
2452
|
if (!/^[a-z_]+$/i.test(tableName))
|
package/dist/doctor.js
CHANGED
|
@@ -97,6 +97,15 @@ export function runDoctor(opts) {
|
|
|
97
97
|
catch {
|
|
98
98
|
checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
|
|
99
99
|
}
|
|
100
|
+
try {
|
|
101
|
+
// SAFETY: COUNT aggregate row.
|
|
102
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
|
|
103
|
+
checks.push({ id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Opening the store migrates it, so a missing table was dropped: the hook is logging nothing.
|
|
107
|
+
checks.push({ id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' });
|
|
108
|
+
}
|
|
100
109
|
}
|
|
101
110
|
catch (err) {
|
|
102
111
|
checks.push({ id: 'schema', status: 'fail', detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`, fix: 'check file permissions on the .hippo folder' });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Failure log (ROADMAP CD13): every failed tool call the capture-error hook sees, stored or not. */
|
|
2
|
+
import type { CaptureErrorOutcome, RoutineRule } from './capture-error.js';
|
|
3
|
+
import type { DatabaseSyncLike } from './db.js';
|
|
4
|
+
/** Rows older than this are pruned on write, which also bounds how far back a repeat can be found. */
|
|
5
|
+
export declare const FAILURE_LOG_RETENTION_DAYS = 90;
|
|
6
|
+
/** A capture-error outcome, or `store-failed` when storing the lesson threw. */
|
|
7
|
+
export type FailureOutcome = CaptureErrorOutcome | 'store-failed';
|
|
8
|
+
/** One failed tool call, for {@link recordFailure}. Never the failure text: it can carry paths and secrets. */
|
|
9
|
+
export interface FailureEvent {
|
|
10
|
+
tenantId: string;
|
|
11
|
+
/** Host session id from the hook payload; null when it had none. */
|
|
12
|
+
sessionId?: string | null;
|
|
13
|
+
tool?: string | null;
|
|
14
|
+
outcome: FailureOutcome;
|
|
15
|
+
/** The routine check that skipped it, for `skipped-routine`. */
|
|
16
|
+
rule?: RoutineRule | null;
|
|
17
|
+
/** Hash of the lesson text's signature, the key dedupe uses; null when the payload had no readable error. */
|
|
18
|
+
sigHash?: string | null;
|
|
19
|
+
/** Hash of the untruncated error plus the command's first two words, finer than `sigHash`. */
|
|
20
|
+
detailHash?: string | null;
|
|
21
|
+
/** Override the timestamp (tests). ISO string. */
|
|
22
|
+
now?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Append one failure row and prune rows past {@link FAILURE_LOG_RETENTION_DAYS}. */
|
|
25
|
+
export declare function recordFailure(db: DatabaseSyncLike, event: FailureEvent): void;
|
|
26
|
+
/** Rated failures and repeats in one session, for {@link failuresBySession}. */
|
|
27
|
+
export interface SessionFailures {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
/** Failures hippo treats as lessons (stored, duplicate or store-failed) in the window. */
|
|
30
|
+
failures: number;
|
|
31
|
+
/** Of those, failures whose signature another session hit first. */
|
|
32
|
+
repeats: number;
|
|
33
|
+
}
|
|
34
|
+
/** Rated failures per session since `sinceIso`, the input for repeat-error rate per arm (CD11, CD12). */
|
|
35
|
+
export declare function failuresBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionFailures[];
|
|
36
|
+
/** Failure log totals over a window, for {@link summarizeFailures}. Counts only: a rate needs a holdout arm (CD11). */
|
|
37
|
+
export interface FailureSummary {
|
|
38
|
+
/** ISO start of the window (inclusive). */
|
|
39
|
+
since: string;
|
|
40
|
+
outcomes: Record<FailureOutcome, number>;
|
|
41
|
+
total: number;
|
|
42
|
+
/** Rated failures from sessions with an id: the failures a repeat is counted among. */
|
|
43
|
+
rated: number;
|
|
44
|
+
repeats: number;
|
|
45
|
+
sessions: number;
|
|
46
|
+
}
|
|
47
|
+
/** Sum the failure log for one tenant since `sinceIso`. */
|
|
48
|
+
export declare function summarizeFailures(db: DatabaseSyncLike, tenantId: string, sinceIso: string): FailureSummary;
|
|
49
|
+
//# sourceMappingURL=failure-log.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Rows older than this are pruned on write, which also bounds how far back a repeat can be found. */
|
|
2
|
+
export const FAILURE_LOG_RETENTION_DAYS = 90;
|
|
3
|
+
/** Longest session id or tool name kept; the hook payload is not trusted to be short. */
|
|
4
|
+
const MAX_FIELD = 128;
|
|
5
|
+
/** Append one failure row and prune rows past {@link FAILURE_LOG_RETENTION_DAYS}. */
|
|
6
|
+
export function recordFailure(db, event) {
|
|
7
|
+
// Normalised, because the window and prune compare timestamps as strings.
|
|
8
|
+
const now = new Date(event.now ?? Date.now()).toISOString();
|
|
9
|
+
db.prepare(`INSERT INTO failure_log (ts, tenant_id, session_id, tool, outcome, skip_rule, sig_hash, detail_hash)
|
|
10
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(now, event.tenantId, event.sessionId?.slice(0, MAX_FIELD) ?? null, event.tool?.slice(0, MAX_FIELD) ?? null, event.outcome, event.rule ?? null, event.sigHash ?? null, event.detailHash ?? null);
|
|
11
|
+
const cutoff = new Date(Date.parse(now) - FAILURE_LOG_RETENTION_DAYS * 86_400_000).toISOString();
|
|
12
|
+
db.prepare(`DELETE FROM failure_log WHERE ts < ?`).run(cutoff);
|
|
13
|
+
}
|
|
14
|
+
/** Rated failures per session since `sinceIso`, the input for repeat-error rate per arm (CD11, CD12). */
|
|
15
|
+
export function failuresBySession(db, tenantId, sinceIso) {
|
|
16
|
+
// SAFETY: the SELECT names exactly these three TEXT columns.
|
|
17
|
+
const rows = db.prepare(`SELECT ts, session_id, sig_hash FROM failure_log
|
|
18
|
+
WHERE tenant_id = ? AND session_id IS NOT NULL AND sig_hash IS NOT NULL
|
|
19
|
+
AND outcome IN ('stored', 'duplicate', 'store-failed')
|
|
20
|
+
ORDER BY id`).all(tenantId);
|
|
21
|
+
const firstSession = new Map();
|
|
22
|
+
const bySession = new Map();
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (!firstSession.has(row.sig_hash))
|
|
25
|
+
firstSession.set(row.sig_hash, row.session_id);
|
|
26
|
+
// Rows before the window are not counted but still decide which session hit a signature first.
|
|
27
|
+
if (row.ts < sinceIso)
|
|
28
|
+
continue;
|
|
29
|
+
const repeat = firstSession.get(row.sig_hash) !== row.session_id;
|
|
30
|
+
const s = bySession.get(row.session_id) ?? { sessionId: row.session_id, failures: 0, repeats: 0 };
|
|
31
|
+
bySession.set(row.session_id, { ...s, failures: s.failures + 1, repeats: s.repeats + (repeat ? 1 : 0) });
|
|
32
|
+
}
|
|
33
|
+
return [...bySession.values()];
|
|
34
|
+
}
|
|
35
|
+
/** Sum the failure log for one tenant since `sinceIso`. */
|
|
36
|
+
export function summarizeFailures(db, tenantId, sinceIso) {
|
|
37
|
+
// SAFETY: the SELECT names exactly these two columns, TEXT and an aggregate.
|
|
38
|
+
const rows = db.prepare(`SELECT outcome, COUNT(*) AS n FROM failure_log WHERE tenant_id = ? AND ts >= ? GROUP BY outcome`).all(tenantId, sinceIso);
|
|
39
|
+
const counts = new Map(rows.map((r) => [r.outcome, Number(r.n)]));
|
|
40
|
+
const outcomes = {
|
|
41
|
+
stored: counts.get('stored') ?? 0,
|
|
42
|
+
duplicate: counts.get('duplicate') ?? 0,
|
|
43
|
+
'store-failed': counts.get('store-failed') ?? 0,
|
|
44
|
+
'skipped-interrupt': counts.get('skipped-interrupt') ?? 0,
|
|
45
|
+
'skipped-routine': counts.get('skipped-routine') ?? 0,
|
|
46
|
+
'skipped-invalid': counts.get('skipped-invalid') ?? 0,
|
|
47
|
+
};
|
|
48
|
+
const sessions = failuresBySession(db, tenantId, sinceIso);
|
|
49
|
+
return {
|
|
50
|
+
since: sinceIso,
|
|
51
|
+
outcomes,
|
|
52
|
+
total: Object.values(outcomes).reduce((sum, n) => sum + n, 0),
|
|
53
|
+
rated: sessions.reduce((sum, s) => sum + s.failures, 0),
|
|
54
|
+
repeats: sessions.reduce((sum, s) => sum + s.repeats, 0),
|
|
55
|
+
sessions: sessions.length,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=failure-log.js.map
|
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.47.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.47.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.47.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.47.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",
|