hippo-memory 1.46.0 → 1.48.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 +4 -2
- package/bin/hippo.js +0 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +20 -7
- package/dist/capture-error.d.ts +18 -12
- package/dist/capture-error.js +70 -42
- package/dist/cli.js +91 -2
- package/dist/db.d.ts +7 -0
- package/dist/db.js +62 -2
- package/dist/doctor.js +52 -32
- package/dist/failure-log.d.ts +49 -0
- package/dist/failure-log.js +58 -0
- package/dist/reject-flow.d.ts +2 -2
- package/dist/reject-flow.js +4 -7
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +18 -6
- package/dist/store.d.ts +9 -26
- package/dist/store.js +18 -36
- package/dist/support-bundle.d.ts +12 -0
- package/dist/support-bundle.js +292 -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/README.md
CHANGED
|
@@ -256,7 +256,7 @@ hippo recall "data pipeline" --why --limit 5
|
|
|
256
256
|
|
|
257
257
|
Input enters the buffer. Important things get encoded into episodic memory. During "sleep," repeated episodes compress into semantic patterns. Weak memories decay and disappear.
|
|
258
258
|
|
|
259
|
-
The store is SQLite (`.hippo/hippo.db`). The markdown files
|
|
259
|
+
The store is SQLite (`.hippo/hippo.db`). The markdown files are mirrors written after each change. `index.json` is no longer refreshed by writes, deletes or recalls: it is written only when you call `rebuildIndex()` from the package, so a copy an older version left on disk goes stale. Read the store through the CLI, the MCP server or the HTTP API.
|
|
260
260
|
|
|
261
261
|
```mermaid
|
|
262
262
|
flowchart TD
|
|
@@ -629,7 +629,9 @@ hippo watch "npm run build"
|
|
|
629
629
|
| `hippo dormant restore <id>` | Bring a dormant memory back to active memory |
|
|
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
|
+
| `hippo support-bundle [--out <file>] [--include-logs]` | Write a redacted JSON file for a support ticket: versions, doctor checks, config, store counts and log names, never memory text; `--include-logs` adds each log's last 200 lines, which can quote it |
|
|
632
633
|
| `hippo tokens [--days n]` | Estimated tokens of memory text handed to agents, per surface, and what skipping unchanged hook blocks saved |
|
|
634
|
+
| `hippo failures [--days n]` | Failed tool calls the capture-error hook saw, by outcome, and how many errors first happened in another session |
|
|
633
635
|
| `hippo embed` | Embed all memories for semantic search |
|
|
634
636
|
| `hippo embed --status` | Show embedding coverage |
|
|
635
637
|
| `hippo watch "<command>"` | Run command, auto-learn from failures |
|
|
@@ -725,7 +727,7 @@ For Claude Code, it also adds:
|
|
|
725
727
|
- 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
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.
|
|
727
729
|
- 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.
|
|
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.
|
|
729
731
|
|
|
730
732
|
To remove: `hippo hook uninstall claude-code`
|
|
731
733
|
|
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';
|
|
@@ -1215,11 +1216,11 @@ export function supersede(ctx, oldId, newContent) {
|
|
|
1215
1216
|
}
|
|
1216
1217
|
// Mirrors after COMMIT, while the db handle is still open. Same
|
|
1217
1218
|
// invariant as the original writeEntry: a mirror failure leaves disk
|
|
1218
|
-
// MISSING the markdown for the new memory (
|
|
1219
|
-
//
|
|
1219
|
+
// MISSING the markdown for the new memory (rebuildIndex rewrites every
|
|
1220
|
+
// markdown mirror from the DB) but DOES NOT desync the DB or
|
|
1220
1221
|
// roll back the supersede. Logged + swallowed, non-fatal.
|
|
1221
1222
|
try {
|
|
1222
|
-
writeEntryMirrors(ctx.hippoRoot,
|
|
1223
|
+
writeEntryMirrors(ctx.hippoRoot, newEntry);
|
|
1223
1224
|
}
|
|
1224
1225
|
catch (mirrorErr) {
|
|
1225
1226
|
console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
|
|
@@ -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
|
|
@@ -1985,7 +1998,7 @@ export function restoreDormant(ctx, id) {
|
|
|
1985
1998
|
}
|
|
1986
1999
|
throw err;
|
|
1987
2000
|
}
|
|
1988
|
-
writeEntryMirrors(ctx.hippoRoot,
|
|
2001
|
+
writeEntryMirrors(ctx.hippoRoot, restored);
|
|
1989
2002
|
return restored;
|
|
1990
2003
|
}
|
|
1991
2004
|
finally {
|
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
|
@@ -60,8 +60,11 @@ import { computeSystemEnergy, vecNorm } from './physics.js';
|
|
|
60
60
|
import { loadConfig } from './config.js';
|
|
61
61
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
62
62
|
import { runDoctor, formatDoctor } from './doctor.js';
|
|
63
|
+
import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
|
|
64
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
63
65
|
import { captureToolFailure } from './capture-error.js';
|
|
64
66
|
import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
|
|
67
|
+
import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
|
|
65
68
|
import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
|
|
66
69
|
import { rowToGoal } from './goals.js';
|
|
67
70
|
import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
|
|
@@ -263,7 +266,7 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
263
266
|
'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
|
|
264
267
|
'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
|
|
265
268
|
'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
|
|
266
|
-
'help', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
|
|
269
|
+
'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
|
|
267
270
|
'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
|
|
268
271
|
'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
|
|
269
272
|
'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
|
|
@@ -487,7 +490,7 @@ function cmdInit(hippoRoot, flags) {
|
|
|
487
490
|
initStore(hippoRoot);
|
|
488
491
|
console.log('Initialized Hippo at', hippoRoot);
|
|
489
492
|
console.log(' Directories: buffer/ episodic/ semantic/ conflicts/');
|
|
490
|
-
console.log(' Files: hippo.db
|
|
493
|
+
console.log(' Files: hippo.db stats.json');
|
|
491
494
|
}
|
|
492
495
|
const globalRoot = getGlobalRoot();
|
|
493
496
|
registerWorkspace(globalRoot, path.dirname(hippoRoot));
|
|
@@ -3929,6 +3932,48 @@ function cmdTokens(hippoRoot, flags) {
|
|
|
3929
3932
|
console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
|
|
3930
3933
|
}
|
|
3931
3934
|
}
|
|
3935
|
+
/** `hippo failures [--days <n>] [--json] [--global]`: failed tool calls by outcome, and repeats across sessions (CD13). */
|
|
3936
|
+
function cmdFailures(hippoRoot, flags) {
|
|
3937
|
+
// The store the capture-error hook writes to; a report never creates one.
|
|
3938
|
+
const root = flags['global'] ? getGlobalRoot() : hookStoreRoot(hippoRoot);
|
|
3939
|
+
requireInit(root);
|
|
3940
|
+
const ctx = {
|
|
3941
|
+
hippoRoot: root,
|
|
3942
|
+
tenantId: resolveTenantId({}),
|
|
3943
|
+
actor: api.adminActor('cli'),
|
|
3944
|
+
};
|
|
3945
|
+
const days = parseCountFlag(flags['days']);
|
|
3946
|
+
const summary = api.failureSummary(ctx, { days: days > 0 ? days : undefined });
|
|
3947
|
+
if (flags['json']) {
|
|
3948
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
const windowDays = days > 0 ? days : 30;
|
|
3952
|
+
const kept = windowDays > FAILURE_LOG_RETENTION_DAYS ? ` (rows are kept ${FAILURE_LOG_RETENTION_DAYS} days)` : '';
|
|
3953
|
+
if (summary.total === 0) {
|
|
3954
|
+
console.log(`No failed tool calls recorded in the last ${windowDays} days${kept}.`);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
const o = summary.outcomes;
|
|
3958
|
+
const errors = o.stored + o.duplicate + o['store-failed'];
|
|
3959
|
+
const unsaved = o['store-failed'] > 0 ? `, ${o['store-failed']} could not be saved` : '';
|
|
3960
|
+
const rows = [
|
|
3961
|
+
['errors', errors, `(${o.stored} new, ${o.duplicate} already in memory${unsaved})`],
|
|
3962
|
+
['routine', o['skipped-routine'], ''],
|
|
3963
|
+
['interrupted', o['skipped-interrupt'], ''],
|
|
3964
|
+
['unreadable', o['skipped-invalid'], ''],
|
|
3965
|
+
];
|
|
3966
|
+
console.log(`Failed tool calls seen by the capture-error hook, last ${windowDays} days${kept}\n`);
|
|
3967
|
+
for (const [label, count, note] of rows) {
|
|
3968
|
+
console.log(` ${label.padEnd(13)}${String(count).padStart(6)} ${note}`.trimEnd());
|
|
3969
|
+
}
|
|
3970
|
+
// Counts, not a rate: a share means little without a holdout arm to compare against (CD11).
|
|
3971
|
+
if (summary.rated > 0) {
|
|
3972
|
+
const noSession = errors - summary.rated;
|
|
3973
|
+
const unrated = noSession > 0 ? ` ${noSession} more had no session id.` : '';
|
|
3974
|
+
console.log(`\n Repeats: ${summary.repeats} of ${summary.rated} errors first happened in another session.${unrated}`);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3932
3977
|
function cmdSnapshot(hippoRoot, args, flags) {
|
|
3933
3978
|
requireInit(hippoRoot);
|
|
3934
3979
|
const subcommand = args[0] ?? 'show';
|
|
@@ -8632,12 +8677,21 @@ Commands:
|
|
|
8632
8677
|
PostToolUseFailure hook payload on stdin; skips routine failures)
|
|
8633
8678
|
doctor Check the install: Node, store, schema, sleep, agent hooks
|
|
8634
8679
|
--json Machine-readable report (exit code 1 on any failure)
|
|
8680
|
+
support-bundle Write a redacted JSON file for a support ticket: versions, doctor,
|
|
8681
|
+
config without secrets, store counts, log names; never memory text
|
|
8682
|
+
--out <file> Where to write it (default: hippo-support-<time>.json here)
|
|
8683
|
+
--include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed
|
|
8635
8684
|
tokens Tokens of memory text hippo handed agents, per surface
|
|
8636
8685
|
(hook, context, recall, MCP, HTTP), and what skipping
|
|
8637
8686
|
unchanged hook blocks saved
|
|
8638
8687
|
--days <n> Window in days (default: 30)
|
|
8639
8688
|
--json Output as JSON
|
|
8640
8689
|
--global Operate on the global store
|
|
8690
|
+
failures Failed tool calls capture-error saw, by outcome, and how
|
|
8691
|
+
many errors first happened in another session
|
|
8692
|
+
--days <n> Window in days (default: 30)
|
|
8693
|
+
--json Output as JSON
|
|
8694
|
+
--global Operate on the global store
|
|
8641
8695
|
snapshot <sub> Persist or inspect the current active task
|
|
8642
8696
|
snapshot save Save active task state
|
|
8643
8697
|
--task <task>
|
|
@@ -9373,6 +9427,9 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9373
9427
|
case 'tokens':
|
|
9374
9428
|
cmdTokens(hippoRoot, flags);
|
|
9375
9429
|
break;
|
|
9430
|
+
case 'failures':
|
|
9431
|
+
cmdFailures(hippoRoot, flags);
|
|
9432
|
+
break;
|
|
9376
9433
|
case 'doctor': {
|
|
9377
9434
|
// SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
|
|
9378
9435
|
const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
@@ -9382,6 +9439,38 @@ async function main(command, args, flags, hippoRoot) {
|
|
|
9382
9439
|
process.exit(1);
|
|
9383
9440
|
break;
|
|
9384
9441
|
}
|
|
9442
|
+
case 'support-bundle': {
|
|
9443
|
+
const outFlag = cardStringFlag(flags, 'out');
|
|
9444
|
+
if (outFlag === '') {
|
|
9445
|
+
console.error('--out requires a file path.');
|
|
9446
|
+
process.exit(1);
|
|
9447
|
+
}
|
|
9448
|
+
const includeLogs = flags['include-logs'] === true;
|
|
9449
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
9450
|
+
const now = new Date();
|
|
9451
|
+
const bundle = buildSupportBundle({ cwd: process.cwd(), home, version: PACKAGE_VERSION, includeLogs, now });
|
|
9452
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
9453
|
+
const file = outFlag ?? path.join(process.cwd(), `hippo-support-${stamp}.json`);
|
|
9454
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
9455
|
+
try {
|
|
9456
|
+
fs.writeFileSync(file, `${json}\n`, { flag: 'wx', mode: 0o600 });
|
|
9457
|
+
}
|
|
9458
|
+
catch (err) {
|
|
9459
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
9460
|
+
console.error(`${file} already exists; pass --out to choose another file. Nothing was written.`);
|
|
9461
|
+
}
|
|
9462
|
+
else {
|
|
9463
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
9464
|
+
}
|
|
9465
|
+
process.exit(1);
|
|
9466
|
+
}
|
|
9467
|
+
const kb = Math.round(Buffer.byteLength(json) / 1024);
|
|
9468
|
+
console.log(`Wrote ${file} (${kb} KB).`);
|
|
9469
|
+
console.log(includeLogs
|
|
9470
|
+
? `It holds versions, doctor checks, config with secrets removed, store counts, and the last ${TAIL_MAX_LINES} lines of each hippo log with known secret shapes removed. Those log lines can quote memory text. Read it before you attach it to a ticket.`
|
|
9471
|
+
: 'It holds versions, doctor checks, config with secrets removed, store counts and log file names. It never holds memory text. Read it before you attach it to a ticket.');
|
|
9472
|
+
break;
|
|
9473
|
+
}
|
|
9385
9474
|
case 'snapshot':
|
|
9386
9475
|
cmdSnapshot(hippoRoot, args, flags);
|
|
9387
9476
|
break;
|
package/dist/db.d.ts
CHANGED
|
@@ -13,11 +13,18 @@ export interface DatabaseSyncLike {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function getHippoDbPath(hippoRoot: string): string;
|
|
15
15
|
export declare function getCurrentSchemaVersion(): number;
|
|
16
|
+
/** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
|
|
17
|
+
export declare class IncompatibleBinaryError extends Error {
|
|
18
|
+
}
|
|
16
19
|
export declare function openHippoDb(hippoRoot: string): DatabaseSyncLike;
|
|
20
|
+
/** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
|
|
21
|
+
export declare function openHippoDbReadOnly(hippoRoot: string): DatabaseSyncLike;
|
|
17
22
|
export declare function getSchemaVersion(db: DatabaseSyncLike): number;
|
|
18
23
|
export declare function closeHippoDb(db: DatabaseSyncLike): void;
|
|
19
24
|
export declare function getMeta(db: DatabaseSyncLike, key: string, fallback?: string): string;
|
|
20
25
|
export declare function setMeta(db: DatabaseSyncLike, key: string, value: string): void;
|
|
26
|
+
/** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
|
|
27
|
+
export declare function countTableRows(db: DatabaseSyncLike, table: string): number | null;
|
|
21
28
|
export declare function isFtsAvailable(db: DatabaseSyncLike): boolean;
|
|
22
29
|
export declare function pruneConsolidationRuns(db: DatabaseSyncLike, keep?: number): void;
|
|
23
30
|
export {};
|
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))
|
|
@@ -2445,11 +2469,14 @@ export function getHippoDbPath(hippoRoot) {
|
|
|
2445
2469
|
export function getCurrentSchemaVersion() {
|
|
2446
2470
|
return CURRENT_SCHEMA_VERSION;
|
|
2447
2471
|
}
|
|
2472
|
+
/** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
|
|
2473
|
+
export class IncompatibleBinaryError extends Error {
|
|
2474
|
+
}
|
|
2448
2475
|
/** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
|
|
2449
2476
|
function assertBinaryCompatible(db) {
|
|
2450
2477
|
const minRequired = getMeta(db, 'min_compatible_binary');
|
|
2451
2478
|
if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
|
|
2452
|
-
throw new
|
|
2479
|
+
throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
|
|
2453
2480
|
`Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
|
|
2454
2481
|
}
|
|
2455
2482
|
}
|
|
@@ -2506,6 +2533,25 @@ export function openHippoDb(hippoRoot) {
|
|
|
2506
2533
|
throw error;
|
|
2507
2534
|
}
|
|
2508
2535
|
}
|
|
2536
|
+
/** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
|
|
2537
|
+
export function openHippoDbReadOnly(hippoRoot) {
|
|
2538
|
+
const db = new DatabaseSync(getHippoDbPath(hippoRoot), { readOnly: true });
|
|
2539
|
+
try {
|
|
2540
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
2541
|
+
if (tableExists(db, 'meta'))
|
|
2542
|
+
assertBinaryCompatible(db);
|
|
2543
|
+
return db;
|
|
2544
|
+
}
|
|
2545
|
+
catch (error) {
|
|
2546
|
+
try {
|
|
2547
|
+
db.close();
|
|
2548
|
+
}
|
|
2549
|
+
catch {
|
|
2550
|
+
// Best effort only.
|
|
2551
|
+
}
|
|
2552
|
+
throw error;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2509
2555
|
function runMigrations(db, hippoRoot) {
|
|
2510
2556
|
ensureMetaTable(db);
|
|
2511
2557
|
// Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
|
|
@@ -2554,6 +2600,8 @@ function ensureMetaTable(db) {
|
|
|
2554
2600
|
`);
|
|
2555
2601
|
}
|
|
2556
2602
|
export function getSchemaVersion(db) {
|
|
2603
|
+
if (!tableExists(db, 'meta'))
|
|
2604
|
+
return 0;
|
|
2557
2605
|
// SAFETY: row's shape matches the single `value` column named in the
|
|
2558
2606
|
// SELECT above.
|
|
2559
2607
|
const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
|
|
@@ -2760,6 +2808,18 @@ export function getMeta(db, key, fallback = '') {
|
|
|
2760
2808
|
export function setMeta(db, key, value) {
|
|
2761
2809
|
db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
|
|
2762
2810
|
}
|
|
2811
|
+
/** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
|
|
2812
|
+
export function countTableRows(db, table) {
|
|
2813
|
+
try {
|
|
2814
|
+
// SAFETY: COUNT(*) returns one row with one numeric column.
|
|
2815
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM "${table.replace(/"/g, '""')}"`).get();
|
|
2816
|
+
return Number(row?.n ?? 0);
|
|
2817
|
+
}
|
|
2818
|
+
catch {
|
|
2819
|
+
// Callers treat an uncountable table as unknown; the bundle and doctor still finish.
|
|
2820
|
+
return null;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2763
2823
|
export function isFtsAvailable(db) {
|
|
2764
2824
|
return getMeta(db, 'fts5_available', '0') === '1';
|
|
2765
2825
|
}
|