hippo-memory 1.58.0 → 1.60.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 (78) hide show
  1. package/README.md +13 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/legacy.js +4 -1
  4. package/dist/api.d.ts +2 -0
  5. package/dist/api.js +25 -9
  6. package/dist/audit.d.ts +1 -1
  7. package/dist/audit.js +3 -0
  8. package/dist/autolearn.js +5 -2
  9. package/dist/capture.js +8 -6
  10. package/dist/cli/output.d.ts +3 -0
  11. package/dist/cli/output.js +7 -0
  12. package/dist/cli/projects.d.ts +4 -0
  13. package/dist/cli/projects.js +90 -0
  14. package/dist/cli/shared.js +14 -8
  15. package/dist/cli/sleep.js +5 -3
  16. package/dist/cli.d.ts +356 -0
  17. package/dist/cli.js +1587 -1404
  18. package/dist/compaction-record.js +7 -6
  19. package/dist/config.js +12 -11
  20. package/dist/connectors/github/cli-impl.js +1 -0
  21. package/dist/consolidate.js +16 -4
  22. package/dist/dag.js +11 -9
  23. package/dist/dashboard.js +4 -2
  24. package/dist/db.js +16 -3
  25. package/dist/dedupe.js +1 -1
  26. package/dist/delivery-recorder.js +1 -0
  27. package/dist/doctor.js +24 -0
  28. package/dist/dormant.d.ts +2 -2
  29. package/dist/dormant.js +1 -0
  30. package/dist/embedding-provider.js +3 -2
  31. package/dist/embeddings.js +10 -7
  32. package/dist/extract.js +20 -19
  33. package/dist/graph.js +3 -8
  34. package/dist/handoff.js +3 -0
  35. package/dist/hooks.js +3 -0
  36. package/dist/importers.js +6 -3
  37. package/dist/incidents.js +1 -0
  38. package/dist/judgment.js +5 -2
  39. package/dist/mcp/server.d.ts +5 -0
  40. package/dist/mcp/server.js +32 -13
  41. package/dist/memory.d.ts +5 -3
  42. package/dist/processes.js +1 -0
  43. package/dist/project-identity.d.ts +1 -1
  44. package/dist/project-identity.js +9 -4
  45. package/dist/project-merge.d.ts +52 -0
  46. package/dist/project-merge.js +168 -0
  47. package/dist/raw-archive-mirror-cleanup.js +2 -1
  48. package/dist/recall-scope.d.ts +3 -3
  49. package/dist/recall-scope.js +5 -4
  50. package/dist/recall-trace.d.ts +2 -2
  51. package/dist/recall-trace.js +10 -14
  52. package/dist/refine-llm.js +18 -10
  53. package/dist/rerankers/clef.d.ts +29 -0
  54. package/dist/rerankers/clef.js +223 -0
  55. package/dist/rerankers/cross-encoder.js +6 -4
  56. package/dist/rerankers/index.js +3 -0
  57. package/dist/rerankers/jev.d.ts +14 -1
  58. package/dist/rerankers/jev.js +30 -20
  59. package/dist/rerankers/llm.js +3 -3
  60. package/dist/rerankers/types.d.ts +16 -0
  61. package/dist/same-text.d.ts +2 -0
  62. package/dist/same-text.js +4 -0
  63. package/dist/scheduler.js +1 -0
  64. package/dist/search.js +4 -2
  65. package/dist/secret-detect.js +1 -0
  66. package/dist/server.d.ts +6 -1
  67. package/dist/server.js +38 -15
  68. package/dist/shared.js +21 -19
  69. package/dist/stdin.js +1 -0
  70. package/dist/store.d.ts +33 -1
  71. package/dist/store.js +123 -22
  72. package/dist/token-ledger.js +1 -0
  73. package/dist/version.d.ts +1 -1
  74. package/dist/version.js +1 -1
  75. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  76. package/extensions/openclaw-plugin/package.json +1 -1
  77. package/openclaw.plugin.json +1 -1
  78. package/package.json +6 -2
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/api.d.ts CHANGED
@@ -95,6 +95,8 @@ export { classifyOriginProject } from './project-identity.js';
95
95
  * flagged rows with no project origin never ambient-inject.
96
96
  */
97
97
  export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean;
98
+ /** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
99
+ export declare const CONTEXT_CANDIDATE_CAP = 2000;
98
100
  export declare function oneCopyPerMemory(local: readonly MemoryEntry[], global: readonly MemoryEntry[], now: Date): [MemoryEntry[], MemoryEntry[]];
99
101
  export interface RememberOpts {
100
102
  content: string;
package/dist/api.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { openHippoDb, closeHippoDb } from './db.js';
10
10
  import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
11
11
  export { ApiError, BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
12
- import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
12
+ import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, loadContextCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
13
13
  import { RejectedValueError } from './rejection.js';
14
14
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
15
15
  import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
@@ -17,6 +17,7 @@ import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
17
17
  import { detectInstruction } from './instruction-detect.js';
18
18
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
19
19
  import { summarizeFailures } from './failure-log.js';
20
+ import { log } from './log.js';
20
21
  import { formatHandoffEvidenceLine } from './handoff.js';
21
22
  import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
22
23
  import { appendAuditEvent, reportAuditWriteFailure, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
@@ -128,10 +129,15 @@ export function ambientSecretAdmit(e, currentProjectName) {
128
129
  return false;
129
130
  return origin === currentProjectName;
130
131
  }
132
+ /** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
133
+ export const CONTEXT_CANDIDATE_CAP = 2000;
131
134
  // The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
132
- function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop) {
133
- if (!pinnedOnly)
134
- return { entries: loadAllEntries(hippoRoot, tenantId).filter(admit) };
135
+ // Without `window` the whole store loads: a local-only query searches every local row.
136
+ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop, window) {
137
+ if (!pinnedOnly) {
138
+ const rows = window ? loadContextCandidates(hippoRoot, tenantId, window) : loadAllEntries(hippoRoot, tenantId);
139
+ return { entries: rows.filter(admit) };
140
+ }
135
141
  // DF3's quality floor runs on the recent-N slice AFTER this load, so the load
136
142
  // counts by it too, or it stops short of a store whose newest rows are junk.
137
143
  const admitAmbient = (e) => {
@@ -1314,7 +1320,7 @@ export function supersede(ctx, oldId, newContent) {
1314
1320
  writeEntryMirrors(ctx.hippoRoot, newEntry);
1315
1321
  }
1316
1322
  catch (mirrorErr) {
1317
- console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
1323
+ log.error(`supersede: mirror write failed (non-fatal, will self-heal): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
1318
1324
  }
1319
1325
  }
1320
1326
  finally {
@@ -1359,7 +1365,7 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
1359
1365
  mirrorOk = true;
1360
1366
  }
1361
1367
  catch (mirrorErr) {
1362
- console.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb):`, mirrorErr);
1368
+ log.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
1363
1369
  }
1364
1370
  if (mirrorOk) {
1365
1371
  // Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
@@ -1694,12 +1700,22 @@ export async function getContext(ctx, opts = {}) {
1694
1700
  const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
1695
1701
  const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
1696
1702
  const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
1703
+ // The window's predicates are ones admit applies anyway, so below the cap the admitted rows are unchanged.
1704
+ const searchesLocalRows = query !== '*' && !(hasGlobal && !primaryIsGlobal);
1705
+ const window = pinnedOnly || searchesLocalRows
1706
+ ? undefined
1707
+ : {
1708
+ exactScope,
1709
+ project: includeCrossProject || currentProjectName === '' ? undefined : currentProjectName,
1710
+ cap: CONTEXT_CANDIDATE_CAP,
1711
+ now: evalNow(),
1712
+ };
1697
1713
  // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1698
1714
  const localLoad = hasLocal
1699
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal))
1715
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal), window)
1700
1716
  : { entries: [] };
1701
1717
  const globalLoad = hasGlobal && !primaryIsGlobal
1702
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true))
1718
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true), window)
1703
1719
  : { entries: [] };
1704
1720
  let localEntries = localLoad.entries;
1705
1721
  let globalEntries = globalLoad.entries;
@@ -2402,7 +2418,7 @@ export function quarantineApprove(ctx, id) {
2402
2418
  writeEntryMirrors(ctx.hippoRoot, restored);
2403
2419
  }
2404
2420
  catch (err) {
2405
- console.error(`quarantine: mirror rewrite failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
2421
+ log.error(`quarantine: mirror rewrite failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
2406
2422
  }
2407
2423
  }
2408
2424
  /** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
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';
@@ -328,6 +330,7 @@ function safeJsonParse(raw) {
328
330
  return v instanceof Object ? v : {};
329
331
  }
330
332
  catch {
333
+ // Malformed metadata reads as empty so the audit row itself stays listable.
331
334
  return {};
332
335
  }
333
336
  }
package/dist/autolearn.js CHANGED
@@ -8,6 +8,7 @@ import { loadAllEntries } from './store.js';
8
8
  import { textOverlap } from './search.js';
9
9
  import { isContentWorthStoring } from './audit.js';
10
10
  import { redactSecretsStrict } from './secret-detect.js';
11
+ import { log } from './log.js';
11
12
  /** A memory of a failed command, "Command '<cmd>' failed: <truncated stderr>"; no store is in reach, so `hippo watch` re-derives its half-life from the store's config. */
12
13
  export function captureError(exitCode, stderr, command, tenantId) {
13
14
  // Truncate to first 500 chars to avoid storing megabytes of build logs
@@ -154,7 +155,8 @@ export function isGitRepo(cwd) {
154
155
  });
155
156
  return raw.trim() === 'true';
156
157
  }
157
- catch {
158
+ catch (err) {
159
+ log.debug(`autolearn: not a git repo: ${err instanceof Error ? err.message : String(err)}`);
158
160
  return false;
159
161
  }
160
162
  }
@@ -169,7 +171,8 @@ export function fetchGitLog(cwd, days) {
169
171
  ], { encoding: 'utf8', cwd, timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
170
172
  return raw;
171
173
  }
172
- catch {
174
+ catch (err) {
175
+ log.debug(`autolearn: git log unavailable: ${err instanceof Error ? err.message : String(err)}`);
173
176
  return '';
174
177
  }
175
178
  }
package/dist/capture.js CHANGED
@@ -26,6 +26,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
26
26
  import { loadConfig } from './config.js';
27
27
  import { classifyOriginProject } from './project-identity.js';
28
28
  import { isObjectLike, isStringValue, readClaudeCodePreCompact } from './capture-contract.js';
29
+ import { log } from './log.js';
29
30
  // Sentence-level patterns
30
31
  //
31
32
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -521,7 +522,7 @@ export function collectSessionTurns(jsonl, visit) {
521
522
  entry = JSON.parse(line);
522
523
  }
523
524
  catch {
524
- continue;
525
+ continue; // a torn or partial transcript line carries no turn
525
526
  }
526
527
  if (!isObjectLike(entry) || !('type' in entry))
527
528
  continue;
@@ -660,7 +661,7 @@ export function resolveLastSessionTranscript(explicit, stdinText, opts) {
660
661
  }
661
662
  }
662
663
  catch {
663
- return null;
664
+ return null; // an unlistable projects dir means no transcript to find; the caller prints that
664
665
  }
665
666
  return newest?.path ?? null;
666
667
  }
@@ -698,7 +699,7 @@ function beginLogTee(logFile) {
698
699
  fs.appendFileSync(logFile, `[hippo] ${new Date().toISOString()} capturing session...\n`, 'utf8');
699
700
  }
700
701
  catch (err) {
701
- console.error(`[hippo] warning: could not open log file ${logFile}: ${errorMessage(err)}`);
702
+ log.warn(`could not open log file ${logFile}: ${errorMessage(err)}`);
702
703
  return () => { };
703
704
  }
704
705
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
@@ -734,6 +735,7 @@ function beginLogTee(logFile) {
734
735
  process.stderr.write = origStderrWrite;
735
736
  };
736
737
  }
738
+ // Console lines here are the `hippo capture` command's printed result, so they stay off the logger.
737
739
  function cmdCaptureCore(hippoRoot, options) {
738
740
  const useGlobal = options.global;
739
741
  const targetRoot = useGlobal ? getGlobalRoot() : hippoRoot;
@@ -993,7 +995,7 @@ function lastPlainUserMessage(jsonl) {
993
995
  entry = JSON.parse(lines[i]);
994
996
  }
995
997
  catch {
996
- continue;
998
+ continue; // the tail read can start mid-line; skip the torn fragment
997
999
  }
998
1000
  if (!isObjectLike(entry))
999
1001
  continue;
@@ -1023,7 +1025,7 @@ function lastAssistantTextBlock(jsonl) {
1023
1025
  entry = JSON.parse(lines[i]);
1024
1026
  }
1025
1027
  catch {
1026
- continue;
1028
+ continue; // the tail read can start mid-line; skip the torn fragment
1027
1029
  }
1028
1030
  if (!isObjectLike(entry))
1029
1031
  continue;
@@ -1085,7 +1087,7 @@ function isReadableFile(filePath) {
1085
1087
  return true;
1086
1088
  }
1087
1089
  catch {
1088
- return false;
1090
+ return false; // missing and unreadable both mean "no file" to the caller
1089
1091
  }
1090
1092
  }
1091
1093
  /** PreCompact stdout is the summariser's instructions; sent before the snapshot work because a locked store can run the hook past its 30 s limit, and via writeSync because process.exit drops buffered pipe output. */
@@ -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
  }