hippo-memory 1.58.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.
package/README.md CHANGED
@@ -535,6 +535,18 @@ removes pinned memories, raw receipts (Slack, GitHub, vault imports) or the memo
535
535
  Claude Code compaction saved either way, and duplicate removal and junk cleanup still
536
536
  delete other memories. `hippo forget` still deletes a compaction memory.
537
537
 
538
+ **Clean up project names left by older versions.** Older versions tagged memories saved in
539
+ a git worktree with the worktree's folder name, and older sleep saved merged memories as
540
+ user-global, so every project could see them. Upgrading stops new damage; these commands
541
+ repair old rows. Each is a dry run until you add `--apply`. With `--apply`, it backs up the
542
+ database to `.hippo/backups/` first and logs every id it touched in the audit log:
543
+
544
+ ```bash
545
+ hippo projects --global # names, counts, live worktrees of this repo
546
+ hippo projects merge hippo-wt-fix hippo --global # fold an old worktree name into its repo
547
+ hippo projects repair --global # re-tag user-global merges by their parents
548
+ ```
549
+
538
550
  **See what memory costs in tokens.** Every block of memory text hippo hands an agent (the
539
551
  per-prompt hook, the block `hippo compact-resume` restores after compaction, `hippo context`,
540
552
  `hippo recall`, the MCP tools, the HTTP API) is recorded in a token ledger: counts, surface
@@ -1126,7 +1138,7 @@ Mark it, and it drops out of the top results. `hippo outcome --bad` weakens the
1126
1138
 
1127
1139
  ### Where does hippo keep my data?
1128
1140
 
1129
- On your machine, in SQLite: `.hippo/hippo.db` in each project, plus a global store in `~/.hippo/` for lessons shared across projects, with markdown mirrors you can read and commit. Recall makes no network call by default. Text goes to an outside provider only through features that use one: an API embedder, the Jev or LLM reranker, `hippo refine`, and the fact extraction `hippo sleep` runs through Anthropic's API whenever `ANTHROPIC_API_KEY` is set in its environment. To turn that last one off, set `{"extraction":{"enabled":false}}` in `.hippo/config.json`.
1141
+ On your machine, in SQLite: `.hippo/hippo.db` in each project, plus a global store in `~/.hippo/` for lessons shared across projects, with markdown mirrors you can read and commit. Recall makes no network call by default. Text goes to an outside provider only through features that use one: an API embedder, the Jev, CLEF or LLM reranker, `hippo refine`, and the fact extraction `hippo sleep` runs through Anthropic's API whenever `ANTHROPIC_API_KEY` is set in its environment. To turn that last one off, set `{"extraction":{"enabled":false}}` in `.hippo/config.json`.
1130
1142
 
1131
1143
  ### What does hippo cost?
1132
1144
 
@@ -34,7 +34,7 @@ export interface ContainerOutcome {
34
34
  }
35
35
  /** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
36
36
  export declare function syncContainer(s: StoreSession, work: ContainerWork): ContainerOutcome;
37
- export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover';
37
+ export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover' | 'project-merge';
38
38
  export type SetAsideResult = {
39
39
  readonly kind: 'untagged';
40
40
  readonly entry: MemoryEntry;
@@ -1,6 +1,7 @@
1
1
  // Rows the old Claude import wrote (`claude-memory:<file>`), taken over by the notes they came from (plan design 10).
2
2
  import path from 'node:path';
3
3
  import { duplicateKey } from '../same-text.js';
4
+ import { maskEmails } from '../secret-detect.js';
4
5
  import { selectLiveEntriesBySourcePrefix } from '../store.js';
5
6
  import { matchLegacy } from './plan.js';
6
7
  import { MIN_ITEM_CHARS, storedText } from './source.js';
@@ -25,7 +26,9 @@ export function legacyWork(db, tenantId, listings) {
25
26
  refs.push({ dir: container.path, key: item.key });
26
27
  }
27
28
  }
28
- const match = matchLegacy(rows.map((r) => ({ id: r.id, file: r.source.slice(LEGACY_SOURCE_PREFIX.length), textKey: duplicateKey(r.content) })), targets);
29
+ const match = matchLegacy(
30
+ // The old import kept emails in clear, and targets are masked; without the mask those rows never matched and imported twice.
31
+ rows.map((r) => ({ id: r.id, file: r.source.slice(LEGACY_SOURCE_PREFIX.length), textKey: duplicateKey(maskEmails(r.content)) })), targets);
29
32
  const byId = new Map(rows.map((r) => [r.id, r]));
30
33
  const add = (pick, ref, id) => {
31
34
  const { dir, key } = refs[Number(ref)];
package/dist/audit.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare function auditMemory(entry: MemoryEntry, backsObject?: boolean):
18
18
  /** `backing`: ids of memories that back an object (store.memoriesBackingObjects). */
19
19
  export declare function auditMemories(entries: MemoryEntry[], backing: ReadonlySet<string>): AuditResult;
20
20
  export declare function isContentWorthStoring(content: string): boolean;
21
- export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject", "agent_memory_restore", "agent_memory_set_aside"];
21
+ export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject", "agent_memory_restore", "agent_memory_set_aside", "project_merge", "project_repair"];
22
22
  export type AuditOp = (typeof AUDIT_OPS)[number];
23
23
  export interface AppendAuditOpts {
24
24
  tenantId: string;
package/dist/audit.js CHANGED
@@ -235,6 +235,8 @@ export const AUDIT_OPS = [
235
235
  'quarantine_reject', // emitted by api.quarantineReject
236
236
  'agent_memory_restore', // emitted by the agent memory sync when a deleted note comes back
237
237
  'agent_memory_set_aside', // emitted by the agent memory sync when a note is deleted or refused
238
+ 'project_merge', // emitted by `hippo projects merge --apply` with every id it touched
239
+ 'project_repair', // emitted by `hippo projects repair --apply` with every id it touched
238
240
  ];
239
241
  function isBigIntValue(value) {
240
242
  return typeof value === 'bigint';
@@ -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
@@ -28,6 +28,8 @@ 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;
@@ -84,7 +86,7 @@ export function emitCliAudit(hippoRoot, op, targetId, metadata) {
84
86
  }
85
87
  export function requireInit(hippoRoot) {
86
88
  if (!isInitialized(hippoRoot)) {
87
- 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.`);
88
90
  process.exit(1);
89
91
  }
90
92
  }
@@ -149,7 +151,7 @@ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
149
151
  const failure = client.classifyTransportFailure(err);
150
152
  if (failure === 'never-sent') {
151
153
  failIfServerRequired('the server pidfile was stale (connection refused)');
152
- console.error('hippo: stale server pidfile detected, falling back to direct mode');
154
+ log.warn('stale server pidfile detected, falling back to direct mode');
153
155
  // Clear the pidfile only if it still names the dead server we just
154
156
  // probed — a newer server may have rewritten it (removePidfileIfOwned).
155
157
  removePidfileIfOwned(hippoRoot, { pid: info.pid, startedAt: info.started_at });
@@ -159,7 +161,7 @@ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
159
161
  // Every caller of this helper is a non-idempotent write, so replaying on
160
162
  // the direct path would store a row the server may already have committed.
161
163
  // Leave the pidfile alone: the next command's connect-phase failure heals it.
162
- console.error(`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.`);
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.`);
163
165
  process.exit(1);
164
166
  }
165
167
  throw err;
@@ -216,7 +218,7 @@ export function printAgentImport(report, indent = ' ') {
216
218
  if (line !== null)
217
219
  console.log(`${indent}${line}`);
218
220
  for (const warning of report.warnings)
219
- console.error(`hippo: agent memories: ${warning}`);
221
+ printError(`hippo: agent memories: ${warning}`);
220
222
  }
221
223
  /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
222
224
  export function hippoBlock(text) {
@@ -301,6 +303,7 @@ export function setupDailySchedule(globalRoot) {
301
303
  console.log(` Scheduled machine-level daily runner (6:15am) via crontab`);
302
304
  }
303
305
  catch {
306
+ // No crontab or no permission: print the line for the user to add by hand.
304
307
  const cronLine = `15 6 * * * ${cmd}`;
305
308
  console.log(` To schedule the machine-level daily runner, add to crontab (crontab -e):`);
306
309
  console.log(` ${cronLine}`);
@@ -310,7 +313,7 @@ export function setupDailySchedule(globalRoot) {
310
313
  export function parseAsOfFlag(flags) {
311
314
  const asOf = typeof flags['as-of'] === 'string' ? flags['as-of'] : undefined;
312
315
  if (asOf !== undefined && Number.isNaN(new Date(asOf).getTime())) {
313
- 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).`);
314
317
  process.exit(1);
315
318
  }
316
319
  return asOf;
@@ -342,6 +345,7 @@ export function collectHandoffEvidence(cwd, testStatus) {
342
345
  }).trim() || null;
343
346
  }
344
347
  catch {
348
+ // No git, not a repo, or timed out: evidence is optional, so the field stays null.
345
349
  gitRef = null;
346
350
  }
347
351
  let dirtyTree = null;
@@ -352,6 +356,7 @@ export function collectHandoffEvidence(cwd, testStatus) {
352
356
  dirtyTree = status.trim().length > 0;
353
357
  }
354
358
  catch {
359
+ // Same as gitRef: unknown tree state is reported as null, never as an error.
355
360
  dirtyTree = null;
356
361
  }
357
362
  return { gitRef, dirtyTree, testStatus };
@@ -408,7 +413,7 @@ export function cardStringFlag(flags, key) {
408
413
  if (v === undefined)
409
414
  return undefined;
410
415
  if (v === true || v === false || Array.isArray(v)) {
411
- console.error(`--${key} requires a value`);
416
+ printError(`--${key} requires a value`);
412
417
  process.exit(1);
413
418
  }
414
419
  return v.trim();
@@ -477,6 +482,7 @@ export function withLedgerDb(hippoRoot, fn) {
477
482
  root = getGlobalRoot();
478
483
  }
479
484
  catch {
485
+ // An unreadable store root means no ledger write; the ledger must never break context or recall.
480
486
  return undefined;
481
487
  }
482
488
  if (root === null)
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>