hippo-memory 1.57.0 → 1.59.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.
Files changed (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -0,0 +1,49 @@
1
+ // The shared shape every agent adapter turns a host payload into, so capture code is written once for all runtimes.
2
+ // Field meanings, statuses and how to add a runtime: docs/integrations/agent-inventory.md.
3
+ /** A string guard that avoids `typeof`; it matches it for every value `JSON.parse` can produce. */
4
+ export function isStringValue(value) {
5
+ return String(value) === value;
6
+ }
7
+ /** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
8
+ export function isObjectLike(value) {
9
+ return value !== null && value instanceof Object;
10
+ }
11
+ /** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
12
+ export function readClaudeCodePreCompact(stdinText, timedOut) {
13
+ const empty = !stdinText || stdinText.trim() === '';
14
+ if (timedOut && empty) {
15
+ return { status: 'unavailable', reason: 'no PreCompact payload arrived before the stdin wait window closed' };
16
+ }
17
+ const base = { runtime: 'claude-code', event: 'pre-compact' };
18
+ if (empty) {
19
+ return { status: 'received', input: { ...base, manual: true, sessionId: null, cwd: null, transcriptPath: null, trigger: null } };
20
+ }
21
+ let payload;
22
+ try {
23
+ payload = JSON.parse(stdinText.trim());
24
+ }
25
+ catch {
26
+ // Non-JSON stdin leaves payload undefined, which the shape check rejects like an explicit null.
27
+ }
28
+ // A bad payload is skipped, never treated as manual: discovery could pick up another session's transcript.
29
+ if (!isObjectLike(payload) || !('transcript_path' in payload) || !isStringValue(payload.transcript_path)) {
30
+ return { status: 'skipped', reason: 'malformed or incomplete PreCompact payload (missing string transcript_path)' };
31
+ }
32
+ const transcriptPath = payload.transcript_path;
33
+ // Path shape only: CLAUDE_CONFIG_DIR can move the transcript root, and a same-user process can already read every transcript.
34
+ if (!/\.jsonl$/i.test(transcriptPath)) {
35
+ return { status: 'skipped', reason: `payload transcript_path is not a .jsonl file: ${transcriptPath}` };
36
+ }
37
+ return {
38
+ status: 'received',
39
+ input: {
40
+ ...base,
41
+ manual: false,
42
+ sessionId: 'session_id' in payload && isStringValue(payload.session_id) ? payload.session_id : null,
43
+ cwd: 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null,
44
+ transcriptPath,
45
+ trigger: 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null,
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=capture-contract.js.map
@@ -7,6 +7,7 @@ import { loadConfig } from './config.js';
7
7
  import { closeHippoDb, openHippoDb } from './db.js';
8
8
  import { recordFailure } from './failure-log.js';
9
9
  import { blockHash } from './token-ledger.js';
10
+ import { redactSecretsStrict } from './secret-detect.js';
10
11
  const MAX_LEN = 200;
11
12
  /** The user, a permission prompt or a hook said no: routine, not a lesson. */
12
13
  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;
@@ -44,7 +45,7 @@ export function lessonFromFailure(payload) {
44
45
  if (!isString(p.error) || p.error.trim().length < 12)
45
46
  return { skip: 'skipped-invalid', text: null, detail: null };
46
47
  const tool = isString(p.tool_name) ? p.tool_name : 'tool';
47
- const error = p.error.replace(/\s+/g, ' ').trim();
48
+ const error = redactSecretsStrict(p.error.replace(/\s+/g, ' ').trim());
48
49
  const text = `${tool}: ${error}`.slice(0, MAX_LEN);
49
50
  const command = isObject(p.tool_input) && isString(p.tool_input['command']) ? p.tool_input['command'].replace(LEADING_CD, '') : '';
50
51
  const head = command.trim().split(/\s+/).slice(0, 2).join(' ');
package/dist/capture.d.ts CHANGED
@@ -49,19 +49,6 @@ export interface CaptureOptions {
49
49
  tenantId?: string;
50
50
  originProject?: string;
51
51
  }
52
- /**
53
- * Runtime shape guards used throughout this file wherever a value arrives
54
- * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
55
- * over the input so the parameter is never annotated `unknown` directly — TS
56
- * infers it from the call site — while the check itself avoids `typeof` by
57
- * testing identity against the coercion (`String(x) === x`) /
58
- * prototype-chain (`instanceof Object`) instead. Behaviourally equivalent to
59
- * `typeof x === 'string'` / a truthy `typeof x === 'object'` check for
60
- * anything `JSON.parse` can produce (the only divergence is boxed
61
- * primitives, which JSON.parse never yields).
62
- */
63
- export declare function isStringValue<T>(value: T): value is T & string;
64
- export declare function isObjectLike<T>(value: T): value is T & object;
65
52
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
66
53
  export declare function errorMessage(cause: unknown): string;
67
54
  /** Transcript-line flags isNonHumanUserLine reads (any may be absent); `promptSource: 'system'` marks Claude Code's own notices. */
package/dist/capture.js CHANGED
@@ -25,6 +25,7 @@ import { RejectedValueError, checkRejectionGuard } from './rejection.js';
25
25
  import { openHippoDb, closeHippoDb } from './db.js';
26
26
  import { loadConfig } from './config.js';
27
27
  import { classifyOriginProject } from './project-identity.js';
28
+ import { isObjectLike, isStringValue, readClaudeCodePreCompact } from './capture-contract.js';
28
29
  // Sentence-level patterns
29
30
  //
30
31
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -471,23 +472,6 @@ export function extractFromText(text) {
471
472
  }
472
473
  return items;
473
474
  }
474
- /**
475
- * Runtime shape guards used throughout this file wherever a value arrives
476
- * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
477
- * over the input so the parameter is never annotated `unknown` directly — TS
478
- * infers it from the call site — while the check itself avoids `typeof` by
479
- * testing identity against the coercion (`String(x) === x`) /
480
- * prototype-chain (`instanceof Object`) instead. Behaviourally equivalent to
481
- * `typeof x === 'string'` / a truthy `typeof x === 'object'` check for
482
- * anything `JSON.parse` can produce (the only divergence is boxed
483
- * primitives, which JSON.parse never yields).
484
- */
485
- export function isStringValue(value) {
486
- return String(value) === value;
487
- }
488
- export function isObjectLike(value) {
489
- return value !== null && value instanceof Object;
490
- }
491
475
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
492
476
  export function errorMessage(cause) {
493
477
  return cause instanceof Error ? cause.message : String(cause);
@@ -1161,57 +1145,12 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1161
1145
  appendPreCompactLog(logFile, 'skip: store not initialized');
1162
1146
  return;
1163
1147
  }
1164
- // Same hazard X4 guards below, different trigger: a read that timed out
1165
- // must not reach auto-discovery either, or it snapshots another session.
1166
- if (stdinTimedOut && (!stdinText || stdinText.trim() === '')) {
1167
- appendPreCompactLog(logFile, 'skip: no PreCompact payload arrived before the stdin wait window closed');
1168
- return;
1169
- }
1170
- // A true manual invocation has no stdin at all (TTY, or a non-TTY pipe
1171
- // that yielded an empty read) — that's the ONLY case newest-transcript
1172
- // auto-discovery is allowed to run. Any other non-empty stdin must
1173
- // JSON-parse to an object carrying a string transcript_path, or it is
1174
- // treated as malformed input and skipped (X4) rather than silently
1175
- // falling back to discovery, which could snapshot an unrelated session's
1176
- // transcript under this payload's session_id.
1177
- const manualInvocation = !stdinText || stdinText.trim() === '';
1178
- let sessionId = null;
1179
- let payloadTranscriptPath = null;
1180
- let payloadCwd = null;
1181
- let payloadTrigger = null;
1182
- if (!manualInvocation) {
1183
- let payload;
1184
- try {
1185
- payload = JSON.parse(stdinText.trim());
1186
- }
1187
- catch {
1188
- // payload stays undefined; the isObjectLike check below rejects it
1189
- // the same way it would reject an explicit null.
1190
- }
1191
- if (!isObjectLike(payload) || !('transcript_path' in payload) || !isStringValue(payload.transcript_path)) {
1192
- // Covers non-JSON stdin, a JSON value that isn't an object, and
1193
- // `"transcript_path": null` (or the key missing entirely) — all fail
1194
- // the string check. Log and skip; never fall through to auto-discovery.
1195
- appendPreCompactLog(logFile, 'skip: malformed or incomplete PreCompact payload (missing string transcript_path)');
1196
- return;
1197
- }
1198
- if ('session_id' in payload && isStringValue(payload.session_id))
1199
- sessionId = payload.session_id;
1200
- payloadTranscriptPath = payload.transcript_path;
1201
- payloadCwd = 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null;
1202
- payloadTrigger = 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null;
1203
- }
1204
- // X11: payload transcript_path must end .jsonl. No directory-containment
1205
- // check is applied on top of this — CLAUDE_CONFIG_DIR can relocate the
1206
- // transcript root entirely, so a path-prefix allowlist would just reject
1207
- // legitimate relocated installs. The trust boundary here is process
1208
- // identity, not path shape: a local process able to feed this hook
1209
- // arbitrary stdin already runs as the same user who owns every transcript
1210
- // this check could gate on, so containment buys no real isolation.
1211
- if (payloadTranscriptPath !== null && !/\.jsonl$/i.test(payloadTranscriptPath)) {
1212
- appendPreCompactLog(logFile, `skip: payload transcript_path is not a .jsonl file: ${payloadTranscriptPath}`);
1148
+ const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut);
1149
+ if (receipt.status !== 'received') {
1150
+ appendPreCompactLog(logFile, `skip: ${receipt.reason}`);
1213
1151
  return;
1214
1152
  }
1153
+ const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
1215
1154
  // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
1216
1155
  let recordId = null;
1217
1156
  if (sessionId !== null && sessionId !== '') {
@@ -0,0 +1,3 @@
1
+ /** Writes a message the user asked for, or must act on, to stderr. */
2
+ export declare function printError(...args: unknown[]): void;
3
+ //# sourceMappingURL=output.d.ts.map
@@ -0,0 +1,7 @@
1
+ // User-facing CLI messages (usage, not-found, refusals) print at every HIPPO_LOG level; diagnostics go to `log`.
2
+ // Both forward to console.error so the bytes match the old calls and console spies in tests still see them.
3
+ /** Writes a message the user asked for, or must act on, to stderr. */
4
+ export function printError(...args) {
5
+ console.error(...args);
6
+ }
7
+ //# sourceMappingURL=output.js.map
@@ -0,0 +1,4 @@
1
+ type Flags = Record<string, string | boolean | string[]>;
2
+ export declare function cmdProjects(hippoRoot: string, args: string[], flags: Flags): void;
3
+ export {};
4
+ //# sourceMappingURL=projects.d.ts.map
@@ -0,0 +1,90 @@
1
+ // The `hippo projects` verb: list a store's project names, fold an old worktree name into its repo, repair sleep's user-global merges.
2
+ import * as path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import { closeHippoDb, openHippoDb } from '../db.js';
5
+ import { listProjects, mergeProjects, repairUserGlobalMerges } from '../project-merge.js';
6
+ import { resolveTenantId } from '../tenant.js';
7
+ import { resolveAuthRoot } from './shared.js';
8
+ import { printError } from './output.js';
9
+ /** Old per-worktree project names of the repo at cwd, mapped to the repo's main checkout name; empty outside git. */
10
+ function worktreeNames() {
11
+ try {
12
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
13
+ const paths = out.split(/\r?\n/).filter((l) => l.startsWith('worktree ')).map((l) => path.basename(l.slice('worktree '.length)));
14
+ return new Map(paths.slice(1).map((name) => [name, paths[0]]));
15
+ }
16
+ catch {
17
+ // Outside a git checkout, or no git on PATH: the list just has no worktree hints.
18
+ return new Map();
19
+ }
20
+ }
21
+ function label(origin) {
22
+ return origin === null ? '(unknown)' : origin === '' ? '(user-global)' : origin;
23
+ }
24
+ function hint(p, worktrees) {
25
+ const main = p.origin ? worktrees.get(p.origin) : undefined;
26
+ if (main)
27
+ return `\n a worktree of ${main}: hippo projects merge ${p.origin} ${main}`;
28
+ return p.copiesElsewhere > 0 ? `\n ${p.copiesElsewhere} of its imported notes are copies also held under another name` : '';
29
+ }
30
+ function count(n, one, many = `${one}s`) {
31
+ return `${n} ${n === 1 ? one : many}`;
32
+ }
33
+ export function cmdProjects(hippoRoot, args, flags) {
34
+ const root = resolveAuthRoot(hippoRoot, flags);
35
+ const tenantId = resolveTenantId({});
36
+ const apply = flags['apply'] === true;
37
+ const sub = args[0] ?? 'list';
38
+ const db = openHippoDb(root);
39
+ try {
40
+ if (sub === 'list') {
41
+ const projects = listProjects(db, tenantId);
42
+ if (flags['json']) {
43
+ console.log(JSON.stringify({ store: root, projects }, null, 2));
44
+ return;
45
+ }
46
+ const worktrees = worktreeNames();
47
+ console.log(`${count(projects.length, 'project name')} in ${root} (newest write first):\n`);
48
+ for (const p of projects) {
49
+ console.log(`${label(p.origin)} ${count(p.live, 'memory', 'memories')}, ${p.imported} imported from agent notes, newest ${p.newest.slice(0, 10)}${hint(p, worktrees)}`);
50
+ }
51
+ return;
52
+ }
53
+ if (sub === 'merge') {
54
+ const [from, into] = [args[1] ?? '', args[2] ?? ''];
55
+ const r = mergeProjects(db, root, { tenantId, from, into, dryRun: !apply });
56
+ if (flags['json']) {
57
+ console.log(JSON.stringify(r, null, 2));
58
+ return;
59
+ }
60
+ console.log(`${apply ? 'Merged' : 'Dry run: would merge'} ${from} into ${into}:`);
61
+ console.log(` ${count(r.setAside.length, 'imported note copy', 'imported note copies')} set aside (dormant; the next sync under ${into} imports the notes still on disk)`);
62
+ console.log(` ${count(r.restamped.length, 'memory', 'memories')} re-tagged, plus ${r.dormantRestamped.length} dormant and ${count(r.compactions, 'compaction record')}`);
63
+ console.log(apply ? `Backup: ${r.backup}\nEvery id is in the audit log: hippo audit list --op project_merge` : 'Nothing written. Add --apply to run it.');
64
+ return;
65
+ }
66
+ if (sub === 'repair') {
67
+ const r = repairUserGlobalMerges(db, root, { tenantId, dryRun: !apply });
68
+ if (flags['json']) {
69
+ console.log(JSON.stringify(r, null, 2));
70
+ return;
71
+ }
72
+ console.log(`${apply ? 'Repaired' : 'Dry run: would repair'} sleep's user-global merged rows:`);
73
+ console.log(` ${r.toProject.length} re-tagged to their parents' project`);
74
+ console.log(` ${r.setAside.length} set aside (parents in two projects; sleep re-merges them per project)`);
75
+ console.log(` ${r.untraced.length} left as they are (no parent left to show which project; check them with hippo inspect <id>)`);
76
+ console.log(apply ? `Backup: ${r.backup}\nEvery id is in the audit log: hippo audit list --op project_repair` : 'Nothing written. Add --apply to run it.');
77
+ return;
78
+ }
79
+ printError('Usage: hippo projects [list] [--json] | merge <from> <into> [--apply] | repair [--apply] [--global]');
80
+ process.exitCode = 1;
81
+ }
82
+ catch (err) {
83
+ printError(err instanceof Error ? err.message : String(err));
84
+ process.exitCode = 1;
85
+ }
86
+ finally {
87
+ closeHippoDb(db);
88
+ }
89
+ }
90
+ //# sourceMappingURL=projects.js.map
@@ -10,7 +10,7 @@ import { RejectedValueError } from '../rejection.js';
10
10
  import { explainMatch } from '../search.js';
11
11
  import { embedMemory } from '../embeddings.js';
12
12
  import { loadConfig } from '../config.js';
13
- import { openHippoDb, closeHippoDb } from '../db.js';
13
+ import { openHippoDb, closeHippoDb, isSqliteBusy, noteStoreBusy } from '../db.js';
14
14
  import { hookPayloadSessionId, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
15
15
  import { isGitRepo, fetchGitLog, extractLessons, partitionLessons } from '../autolearn.js';
16
16
  import { storedTextKeys, duplicateKey } from '../same-text.js';
@@ -22,12 +22,14 @@ import { extractPathTags } from '../path-context.js';
22
22
  import { getGlobalRoot, initGlobal } from '../shared.js';
23
23
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, buildSchtasksCreateArgs, buildWindowsTaskRun } from '../scheduler.js';
24
24
  import { sanitizeLogMessage } from '../capture.js';
25
- import { appendAuditEvent } from '../audit.js';
25
+ import { appendAuditEvent, reportAuditWriteFailure } from '../audit.js';
26
26
  import { createHash } from 'node:crypto';
27
27
  import * as client from '../client.js';
28
28
  import { detectServer, removePidfileIfOwned } from '../server-detect.js';
29
29
  import { resolveTenantId } from '../tenant.js';
30
30
  import { snapshotText, sessionTrailText, handoffText } from '../context-render.js';
31
+ import { log } from '../log.js';
32
+ import { printError } from './output.js';
31
33
  export function parseLimitFlag(value) {
32
34
  if (!value)
33
35
  return Infinity;
@@ -45,13 +47,13 @@ export function parseBudgetFlag(value, fallback) {
45
47
  return fallback;
46
48
  // A value-less flag and a junk value are different typos; the --hops guard already splits them.
47
49
  if (typeof value !== 'string') {
48
- console.error('--budget requires an integer value (e.g. --budget 1500).');
50
+ printError('--budget requires an integer value (e.g. --budget 1500).');
49
51
  process.exit(1);
50
52
  }
51
53
  // Number(), like the --hops guard: parseInt('12abc') is 12, silently accepting what this message rejects.
52
54
  const parsed = Number(value);
53
55
  if (!Number.isInteger(parsed) || parsed < 0) {
54
- console.error(`Invalid --budget: "${value}". Must be a non-negative integer.`);
56
+ printError(`Invalid --budget: "${value}". Must be a non-negative integer.`);
55
57
  process.exit(1);
56
58
  }
57
59
  return parsed;
@@ -77,13 +79,14 @@ export function emitCliAudit(hippoRoot, op, targetId, metadata) {
77
79
  closeHippoDb(db);
78
80
  }
79
81
  }
80
- catch {
81
- // Audit is best-effort; surface failures only via missing rows.
82
+ catch (error) {
83
+ // Best effort: the command already did its work.
84
+ reportAuditWriteFailure(op, String(error), targetId);
82
85
  }
83
86
  }
84
87
  export function requireInit(hippoRoot) {
85
88
  if (!isInitialized(hippoRoot)) {
86
- console.error(`No hippo store at ${hippoRoot} (searched ${process.cwd()} and its parents up to your home directory). Run \`hippo init\` first.`);
89
+ printError(`No hippo store at ${hippoRoot} (searched ${process.cwd()} and its parents up to your home directory). Run \`hippo init\` first.`);
87
90
  process.exit(1);
88
91
  }
89
92
  }
@@ -148,7 +151,7 @@ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
148
151
  const failure = client.classifyTransportFailure(err);
149
152
  if (failure === 'never-sent') {
150
153
  failIfServerRequired('the server pidfile was stale (connection refused)');
151
- console.error('hippo: stale server pidfile detected, falling back to direct mode');
154
+ log.warn('stale server pidfile detected, falling back to direct mode');
152
155
  // Clear the pidfile only if it still names the dead server we just
153
156
  // probed — a newer server may have rewritten it (removePidfileIfOwned).
154
157
  removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
@@ -158,7 +161,7 @@ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
158
161
  // Every caller of this helper is a non-idempotent write, so replaying on
159
162
  // the direct path would store a row the server may already have committed.
160
163
  // Leave the pidfile alone: the next command's connect-phase failure heals it.
161
- console.error(`hippo: the connection to ${info.url} dropped mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
164
+ printError(`hippo: the connection to ${info.url} dropped or timed out mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
162
165
  process.exit(1);
163
166
  }
164
167
  throw err;
@@ -215,7 +218,7 @@ export function printAgentImport(report, indent = ' ') {
215
218
  if (line !== null)
216
219
  console.log(`${indent}${line}`);
217
220
  for (const warning of report.warnings)
218
- console.error(`hippo: agent memories: ${warning}`);
221
+ printError(`hippo: agent memories: ${warning}`);
219
222
  }
220
223
  /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
221
224
  export function hippoBlock(text) {
@@ -300,6 +303,7 @@ export function setupDailySchedule(globalRoot) {
300
303
  console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
301
304
  }
302
305
  catch {
306
+ // No crontab or no permission: print the line for the user to add by hand.
303
307
  const cronLine = `15 6 * * * ${cmd}`;
304
308
  console.log(` To schedule the machine-level daily runner, add to crontab (crontab -e):`);
305
309
  console.log(` ${cronLine}`);
@@ -309,7 +313,7 @@ export function setupDailySchedule(globalRoot) {
309
313
  export function parseAsOfFlag(flags) {
310
314
  const asOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
311
315
  if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
312
- console.error(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
316
+ printError(`Error: --as-of value "${asOf}" is not a valid ISO date (e.g. 2026-04-22 or 2026-04-22T12:00:00Z).`);
313
317
  process.exit(1);
314
318
  }
315
319
  return asOf;
@@ -341,6 +345,7 @@ export function collectHandoffEvidence(cwd, testStatus) {
341
345
  }).trim() || null;
342
346
  }
343
347
  catch {
348
+ // No git, not a repo, or timed out: evidence is optional, so the field stays null.
344
349
  gitRef = null;
345
350
  }
346
351
  let dirtyTree = null;
@@ -351,6 +356,7 @@ export function collectHandoffEvidence(cwd, testStatus) {
351
356
  dirtyTree = status.trim().length > 0;
352
357
  }
353
358
  catch {
359
+ // Same as gitRef: unknown tree state is reported as null, never as an error.
354
360
  dirtyTree = null;
355
361
  }
356
362
  return { gitRef, dirtyTree, testStatus };
@@ -407,7 +413,7 @@ export function cardStringFlag(flags, key) {
407
413
  if (v === undefined)
408
414
  return undefined;
409
415
  if (v === true || v === false || Array.isArray(v)) {
410
- console.error(`--${key} requires a value`);
416
+ printError(`--${key} requires a value`);
411
417
  process.exit(1);
412
418
  }
413
419
  return v.trim();
@@ -476,6 +482,7 @@ export function withLedgerDb(hippoRoot, fn) {
476
482
  root = getGlobalRoot();
477
483
  }
478
484
  catch {
485
+ // An unreadable store root means no ledger write; the ledger must never break context or recall.
479
486
  return undefined;
480
487
  }
481
488
  if (root === null)
@@ -485,7 +492,10 @@ export function withLedgerDb(hippoRoot, fn) {
485
492
  db = openHippoDb(root);
486
493
  return fn(db);
487
494
  }
488
- catch {
495
+ catch (error) {
496
+ // Best effort, but a busy store is the one failure an operator can act on, so it warns once.
497
+ if (isSqliteBusy(error))
498
+ noteStoreBusy('token ledger row skipped');
489
499
  return undefined;
490
500
  }
491
501
  finally {
package/dist/cli/sleep.js CHANGED
@@ -8,7 +8,9 @@ import { replayCompactionsAt } from '../compaction-record.js';
8
8
  import * as api from '../api.js';
9
9
  import { resolveTenantId } from '../tenant.js';
10
10
  import { renderAmbientSummary } from '../ambient.js';
11
+ import { log } from '../log.js';
11
12
  import { requireInit, learnFromRepo, runChurnStaleForRepo, printAgentImport } from './shared.js';
13
+ import { printError } from './output.js';
12
14
  /** Runs `hippo sleep`; with `--log-file` it also tees its output to that file. */
13
15
  export async function cmdSleep(hippoRoot, flags) {
14
16
  // Tee stdout/stderr to a log file when --log-file is set. The SessionEnd
@@ -45,7 +47,7 @@ export async function cmdSleep(hippoRoot, flags) {
45
47
  };
46
48
  }
47
49
  catch (err) {
48
- console.error(`[hippo] warning: could not open log file ${logFile}: ${err.message}`);
50
+ log.warn(`could not open log file ${logFile}: ${err.message}`);
49
51
  }
50
52
  }
51
53
  try {
@@ -145,14 +147,14 @@ async function cmdSleepCore(hippoRoot, flags) {
145
147
  if (result.marked > 0)
146
148
  console.log(`Tagged ${result.marked} memories churn-stale in ${root}.`);
147
149
  if (result.error)
148
- console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
150
+ printError(`Churn-staleness check failed for ${root}: ${result.error}`);
149
151
  }
150
152
  }
151
153
  printAgentImport(importForStore(hippoRoot, { machine: currentMachine() }), '');
152
154
  }
153
155
  // Finishes compactions a killed or busy post-compact hook left; never throws, and a dry run writes nothing.
154
156
  if (!flags['dry-run']) {
155
- const finished = replayCompactionsAt(hippoRoot, (message) => console.error(`compaction replay: ${message}`));
157
+ const finished = replayCompactionsAt(hippoRoot, (message) => log.warn(`compaction replay: ${message}`));
156
158
  if (finished > 0)
157
159
  console.log(`Finished saving ${finished} compaction${finished === 1 ? '' : 's'} left over from earlier sessions.`);
158
160
  }
package/dist/cli.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * hippo rejections
21
21
  * hippo unreject <digest-prefix>
22
22
  * hippo dormant [<query>] [--limit <n>] [--json] | restore <id> | forget <id>
23
+ * hippo projects [--json] | merge <from> <into> [--apply] | repair [--apply]
23
24
  * hippo tokens [--days <n>] [--json] [--global]
24
25
  * hippo doctor [--json]
25
26
  * hippo inspect <id>