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
@@ -12,6 +12,7 @@ import { deriveOriginProject, isGlobalStoreRoot } from './project-identity.js';
12
12
  import { maskEmails, redactSecretsStrict } from './secret-detect.js';
13
13
  import { strengthenRetrievedOn, updateStats, writeEntryMirrors } from './store.js';
14
14
  import { resolveTenantId } from './tenant.js';
15
+ import { log as logger } from './log.js';
15
16
  /** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
16
17
  export const COMPACTION_DB_WAIT_MS = 2000;
17
18
  /** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
@@ -262,7 +263,7 @@ export function parsePostCompactPayload(stdinText) {
262
263
  raw = JSON.parse((stdinText ?? '').trim());
263
264
  }
264
265
  catch {
265
- return null;
266
+ return null; // non-JSON stdin is not a PostCompact payload; the caller skips it
266
267
  }
267
268
  if (!isObjectLike(raw) || !('session_id' in raw) || !isStringValue(raw.session_id) || raw.session_id === '')
268
269
  return null;
@@ -309,7 +310,7 @@ export function saveCompaction(hippoRoot, payload, log) {
309
310
  if (isSqliteBusy(err))
310
311
  throw err;
311
312
  log(`record step failed: ${errorMessage(err)}`);
312
- console.error(`hippo post-compact: record step failed: ${errorMessage(err)}`);
313
+ logger.error(`post-compact: record step failed: ${errorMessage(err)}`);
313
314
  }
314
315
  try {
315
316
  result.written = saveItems(db, hippoRoot, {
@@ -337,12 +338,12 @@ export function saveCompaction(hippoRoot, payload, log) {
337
338
  }
338
339
  catch (spoolErr) {
339
340
  log(`spool failed: ${errorMessage(spoolErr)}`);
340
- console.error(`hippo post-compact: spool failed: ${errorMessage(spoolErr)}`);
341
+ logger.error(`post-compact: spool failed: ${errorMessage(spoolErr)}`);
341
342
  }
342
343
  }
343
344
  else {
344
345
  log(`items step failed: ${errorMessage(err)}`);
345
- console.error(`hippo post-compact: items step failed: ${errorMessage(err)}`);
346
+ logger.error(`post-compact: items step failed: ${errorMessage(err)}`);
346
347
  }
347
348
  }
348
349
  finally {
@@ -380,7 +381,7 @@ function transcriptSummary(transcriptPath, afterMs, beforeMs) {
380
381
  lines.push(JSON.parse(raw));
381
382
  }
382
383
  catch {
383
- continue;
384
+ continue; // the tail can start mid-line; a torn line holds no summary
384
385
  }
385
386
  }
386
387
  const stamp = (line) => (isStringValue(line.timestamp) ? Date.parse(line.timestamp) : Number.NaN);
@@ -409,7 +410,7 @@ function readSpooled(file, fallbackTenantId) {
409
410
  raw = JSON.parse(fs.readFileSync(file, 'utf8'));
410
411
  }
411
412
  catch {
412
- return null;
413
+ return null; // a half-written or vanished spool file is skipped; the caller treats null as unreadable
413
414
  }
414
415
  if (!isObjectLike(raw) || !('sessionId' in raw) || !isStringValue(raw.sessionId) || !('summary' in raw) || !isStringValue(raw.summary))
415
416
  return null;
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import { DEFAULT_PHYSICS_CONFIG, mergePhysicsConfig } from './physics-config.js';
7
7
  import { DEFAULT_HALF_LIFE_DAYS } from './memory.js';
8
+ import { log } from './log.js';
8
9
  const DEFAULT_CONFIG = {
9
10
  defaultHalfLifeDays: DEFAULT_HALF_LIFE_DAYS,
10
11
  defaultBudget: 4000,
@@ -114,7 +115,7 @@ function deliveryLedgerEnabled(value) {
114
115
  return enabled;
115
116
  if (isObject && enabled === undefined)
116
117
  return false;
117
- console.error(`Warning: config.json's "deliveryLedger" must be an object like {"enabled": true} ` +
118
+ log.warn(`config.json's "deliveryLedger" must be an object like {"enabled": true} ` +
118
119
  `(got ${JSON.stringify(value)}) - using false.`);
119
120
  return false;
120
121
  }
@@ -127,7 +128,7 @@ function pilotHoldoutRate(value) {
127
128
  return 0;
128
129
  if (Number.isInteger(rate) && rate >= 0 && rate <= 10000)
129
130
  return rate;
130
- console.error(`Warning: config.json's "pilot" must be an object like {"holdoutRateBp": 2000}, an integer from 0 to 10000 ` +
131
+ log.warn(`config.json's "pilot" must be an object like {"holdoutRateBp": 2000}, an integer from 0 to 10000 ` +
131
132
  `(got ${JSON.stringify(value)}) - using 0 (pilot off).`);
132
133
  return 0;
133
134
  }
@@ -136,7 +137,7 @@ function agentMemoryTools(value) {
136
137
  return null;
137
138
  if (Array.isArray(value) && value.every((t) => String(t) === t))
138
139
  return value;
139
- console.error(`Warning: config.json's "agentMemories.tools" must be a list of tool ids like ["claude-code", "codex"] ` +
140
+ log.warn(`config.json's "agentMemories.tools" must be a list of tool ids like ["claude-code", "codex"] ` +
140
141
  `(got ${JSON.stringify(value)}) - importing none.`);
141
142
  return [];
142
143
  }
@@ -158,7 +159,7 @@ export function loadConfig(hippoRoot) {
158
159
  const memoryValueRaw = raw.memoryValue;
159
160
  const validMemoryValueConfig = memoryValueRaw === undefined || isMemoryValueConfig(memoryValueRaw);
160
161
  if (!validMemoryValueConfig) {
161
- console.error(`Warning: config.json's "memoryValue" must be an object like {"enabled": true} ` +
162
+ log.warn(`config.json's "memoryValue" must be an object like {"enabled": true} ` +
162
163
  `(got ${JSON.stringify(memoryValueRaw)}) - using defaults.`);
163
164
  }
164
165
  const memoryValueOverride = memoryValueRaw !== undefined && isMemoryValueConfig(memoryValueRaw) ? memoryValueRaw : {};
@@ -167,20 +168,20 @@ export function loadConfig(hippoRoot) {
167
168
  // warns and falls back to the default, which keeps faded memories.
168
169
  const dormantRaw = raw.dormant;
169
170
  if (dormantRaw !== undefined && !isDormantConfig(dormantRaw)) {
170
- console.error(`Warning: config.json's "dormant" must be an object like {"enabled": false} ` +
171
+ log.warn(`config.json's "dormant" must be an object like {"enabled": false} ` +
171
172
  `(got ${JSON.stringify(dormantRaw)}) - using the default (faded memories kept dormant).`);
172
173
  }
173
174
  const dormantOverride = dormantRaw !== undefined && isDormantConfig(dormantRaw) ? dormantRaw : {};
174
175
  // Only a real boolean counts: {"enabled": "false"} is a truthy string.
175
176
  let dormantEnabled = dormantOverride.enabled ?? DEFAULT_CONFIG.dormant.enabled;
176
177
  if (dormantEnabled !== true && dormantEnabled !== false) {
177
- console.error(`Warning: config.json's "dormant.enabled" must be true or false ` +
178
+ log.warn(`config.json's "dormant.enabled" must be true or false ` +
178
179
  `(got ${JSON.stringify(dormantEnabled)}) - using the default (faded memories kept dormant).`);
179
180
  dormantEnabled = DEFAULT_CONFIG.dormant.enabled;
180
181
  }
181
182
  let dormantRetentionDays = dormantOverride.retentionDays ?? DEFAULT_CONFIG.dormant.retentionDays;
182
183
  if (!Number.isFinite(dormantRetentionDays) || dormantRetentionDays < 0) {
183
- console.error(`Warning: config.json's "dormant.retentionDays" must be a number of days, 0 or more ` +
184
+ log.warn(`config.json's "dormant.retentionDays" must be a number of days, 0 or more ` +
184
185
  `(got ${JSON.stringify(dormantRetentionDays)}) - using ${DEFAULT_CONFIG.dormant.retentionDays}.`);
185
186
  dormantRetentionDays = DEFAULT_CONFIG.dormant.retentionDays;
186
187
  }
@@ -188,21 +189,21 @@ export function loadConfig(hippoRoot) {
188
189
  const churnStalenessRaw = raw.churnStaleness;
189
190
  const validChurnStalenessConfig = churnStalenessRaw === undefined || isChurnStalenessConfig(churnStalenessRaw);
190
191
  if (!validChurnStalenessConfig) {
191
- console.error(`Warning: config.json's "churnStaleness" must be an object like {"enabled": true} ` +
192
+ log.warn(`config.json's "churnStaleness" must be an object like {"enabled": true} ` +
192
193
  `(got ${JSON.stringify(churnStalenessRaw)}) - using defaults.`);
193
194
  }
194
195
  let churnStalenessEnabled = churnStalenessRaw !== undefined && isChurnStalenessConfig(churnStalenessRaw)
195
196
  ? churnStalenessRaw.enabled
196
197
  : DEFAULT_CONFIG.churnStaleness.enabled;
197
198
  if (churnStalenessEnabled !== true && churnStalenessEnabled !== false) {
198
- console.error(`Warning: config.json's "churnStaleness.enabled" must be true or false ` +
199
+ log.warn(`config.json's "churnStaleness.enabled" must be true or false ` +
199
200
  `(got ${JSON.stringify(churnStalenessEnabled)}) - using false.`);
200
201
  churnStalenessEnabled = false;
201
202
  }
202
203
  // Every writer starts a memory on this, and a zero or negative half-life scores zero strength, so sleep would retire it.
203
204
  let defaultHalfLifeDays = raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays;
204
205
  if (!Number.isFinite(defaultHalfLifeDays) || defaultHalfLifeDays <= 0) {
205
- console.error(`Warning: config.json's "defaultHalfLifeDays" must be a number of days above 0 ` +
206
+ log.warn(`config.json's "defaultHalfLifeDays" must be a number of days above 0 ` +
206
207
  `(got ${JSON.stringify(defaultHalfLifeDays)}) - using ${DEFAULT_CONFIG.defaultHalfLifeDays}.`);
207
208
  defaultHalfLifeDays = DEFAULT_CONFIG.defaultHalfLifeDays;
208
209
  }
@@ -247,7 +248,7 @@ export function loadConfig(hippoRoot) {
247
248
  }
248
249
  catch (err) {
249
250
  if (fs.existsSync(configPath)) {
250
- console.error(`Warning: failed to parse ${configPath}: ${err instanceof Error ? err.message : err}`);
251
+ log.warn(`failed to parse ${configPath}: ${err instanceof Error ? err.message : err}`);
251
252
  }
252
253
  return { ...DEFAULT_CONFIG };
253
254
  }
@@ -52,6 +52,7 @@ function parsedToIngestEvent(parsed, eventName) {
52
52
  }
53
53
  return null;
54
54
  }
55
+ // Console lines below are the `hippo github` command's printed result and usage text, so they stay off the logger.
55
56
  export function printGithubBackfillUsage() {
56
57
  console.log('hippo github backfill --repo <owner/name> [--since ISO] [--max <N>]');
57
58
  console.log(' --repo GitHub repository in owner/name format (required, e.g. acme/widgets)');
@@ -31,6 +31,7 @@ import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migr
31
31
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
32
32
  import { isQuarantineScope } from './quarantine.js';
33
33
  import { NO_MERGE_TAGS } from './shared.js';
34
+ import { log } from './log.js';
34
35
  const DECAY_THRESHOLD = 0.05;
35
36
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
36
37
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -434,7 +435,7 @@ export async function consolidate(hippoRoot, options = {}) {
434
435
  result.details.push(` 🧬 promoted ${result.promotedTraces} trace${result.promotedTraces === 1 ? '' : 's'} from completed session${result.promotedTraces === 1 ? '' : 's'}`);
435
436
  }
436
437
  if (tracesSkippedRejected > 0) {
437
- console.error(`consolidate: skipped ${tracesSkippedRejected} auto-promoted trace(s) whose content matches a rejected value`);
438
+ log.warn(`consolidate: skipped ${tracesSkippedRejected} auto-promoted trace(s) whose content matches a rejected value`);
438
439
  }
439
440
  }
440
441
  // -------------------------------------------------------------------------
@@ -490,7 +491,7 @@ export async function consolidate(hippoRoot, options = {}) {
490
491
  return;
491
492
  llmErrorsSeen.add(line);
492
493
  result.details.push(line);
493
- console.error(`consolidate ${phase}: ${msg}`);
494
+ log.warn(`consolidate ${phase}: ${msg}`);
494
495
  };
495
496
  const llmOpts = { apiKey, model: config.extraction.model, fetcher: options.fetcher };
496
497
  if (apiKey && extractionCandidates.length > 0 && !dryRun) {
@@ -689,7 +690,7 @@ export async function consolidate(hippoRoot, options = {}) {
689
690
  // before this fix — byte-identical behavior there.
690
691
  const mergeCandidatesByTenant = new Map();
691
692
  for (const entry of mergeCandidates) {
692
- const key = derivationPartitionKey(entry.tenantId, entry.scope);
693
+ const key = derivationPartitionKey(entry.tenantId, entry.scope, entry.origin_project);
693
694
  const bucket = mergeCandidatesByTenant.get(key);
694
695
  if (bucket)
695
696
  bucket.push(entry);
@@ -708,6 +709,7 @@ export async function consolidate(hippoRoot, options = {}) {
708
709
  for (const [, tenantCandidates] of mergeCandidatesByTenant) {
709
710
  const mergeTenant = tenantCandidates[0].tenantId;
710
711
  const mergeScope = derivationScope(tenantCandidates[0].scope);
712
+ const mergeOrigin = tenantCandidates[0].origin_project;
711
713
  const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
712
714
  for (let i = 0; i < tenantCandidates.length; i++) {
713
715
  if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
@@ -752,6 +754,7 @@ export async function consolidate(hippoRoot, options = {}) {
752
754
  scope: mergeScope,
753
755
  baseHalfLifeDays: config.defaultHalfLifeDays,
754
756
  }),
757
+ origin_project: mergeOrigin,
755
758
  parents: cluster.map((e) => e.id),
756
759
  };
757
760
  }
@@ -829,7 +832,7 @@ export async function consolidate(hippoRoot, options = {}) {
829
832
  closeHippoDb(consolidateDbHandle);
830
833
  }
831
834
  if (mergesSkippedRejected > 0) {
832
- console.error(`consolidate: skipped ${mergesSkippedRejected} merge(s) whose content matches a rejected value`);
835
+ log.warn(`consolidate: skipped ${mergesSkippedRejected} merge(s) whose content matches a rejected value`);
833
836
  }
834
837
  result.removedIds = pendingDeletes;
835
838
  // One transaction; the snapshot keeps what the DAG passes and other writers changed while sleep ran.
@@ -994,6 +997,8 @@ rescuedIds = new Set()) {
994
997
  continue;
995
998
  if (survivors[i].superseded_by || survivors[j].superseded_by)
996
999
  continue;
1000
+ if (!recalledTogether(survivors[i], survivors[j]))
1001
+ continue;
997
1002
  if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
998
1003
  continue;
999
1004
  const reasonAndScore = describeConflict(profiles[i], profiles[j]);
@@ -1009,6 +1014,13 @@ rescuedIds = new Set()) {
1009
1014
  }
1010
1015
  return detected;
1011
1016
  }
1017
+ /** One project's ambient context can show both: same tenant, and the same project or a user-global row beside a project's. */
1018
+ function recalledTogether(a, b) {
1019
+ if (a.tenantId !== b.tenantId)
1020
+ return false;
1021
+ const [x, y] = [a.origin_project ?? null, b.origin_project ?? null];
1022
+ return x === y || (x === '' && y !== null) || (y === '' && x !== null);
1023
+ }
1012
1024
  function conflictProfile(text) {
1013
1025
  const opening = openingWindow(text);
1014
1026
  // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
package/dist/dag.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createMemory, Layer } from './memory.js';
2
2
  import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
3
3
  import { RejectedValueError } from './rejection.js';
4
- import { redactSecrets } from './secret-detect.js';
4
+ import { redactSecretsStrict } from './secret-detect.js';
5
5
  import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
6
6
  import { derivationScope, derivationPartitionKey } from './recall-scope.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { neverAutoShareTags } from './shared.js';
9
+ import { log } from './log.js';
9
10
  export function clusterFacts(facts) {
10
11
  if (facts.length === 0)
11
12
  return [];
@@ -47,9 +48,9 @@ Write a single concise paragraph (2-4 sentences) that captures all the key infor
47
48
  export async function generateDagSummary(label, factContents, opts) {
48
49
  const model = opts.model ?? 'claude-sonnet-4-6';
49
50
  const fetchFn = opts.fetcher ?? fetch;
50
- const factsBlock = factContents.map((f, i) => `${i + 1}. ${redactSecrets(f)}`).join('\n');
51
+ const factsBlock = factContents.map((f, i) => `${i + 1}. ${redactSecretsStrict(f)}`).join('\n');
51
52
  const prompt = DAG_SUMMARY_PROMPT
52
- .replace('{label}', redactSecrets(label))
53
+ .replace('{label}', redactSecretsStrict(label))
53
54
  .replace('{facts}', factsBlock);
54
55
  let res;
55
56
  try {
@@ -98,7 +99,7 @@ export async function buildDag(hippoRoot, facts, opts) {
98
99
  // behavior there.
99
100
  const unparentedByTenant = new Map();
100
101
  for (const fact of unparented) {
101
- const key = derivationPartitionKey(fact.tenantId, fact.scope);
102
+ const key = derivationPartitionKey(fact.tenantId, fact.scope, fact.origin_project);
102
103
  const bucket = unparentedByTenant.get(key);
103
104
  if (bucket)
104
105
  bucket.push(fact);
@@ -129,6 +130,7 @@ export async function buildDag(hippoRoot, facts, opts) {
129
130
  scope: factScope,
130
131
  baseHalfLifeDays,
131
132
  });
133
+ summaryEntry.origin_project = tenantFacts[0].origin_project;
132
134
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
133
135
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
134
136
  // can reason about scope without walking the children.
@@ -156,7 +158,7 @@ export async function buildDag(hippoRoot, facts, opts) {
156
158
  catch (err) {
157
159
  if (err instanceof RejectedValueError) {
158
160
  result.rejected++;
159
- console.error(`[buildDag] cluster "${cluster.label}" skipped: summary matches a rejected value`);
161
+ log.warn(`buildDag: cluster "${cluster.label}" skipped: summary matches a rejected value`);
160
162
  continue;
161
163
  }
162
164
  throw err;
@@ -273,8 +275,7 @@ export async function rebuildDirtySummaries(hippoRoot, opts) {
273
275
  // (audit() wraps its own writes try/catch per store.ts:2566, so a
274
276
  // throw here is exotic: SQLite I/O error, prepare failure, etc).
275
277
  result.failed++;
276
- // eslint-disable-next-line no-console
277
- console.error(`[rebuildDirtySummaries] summary ${summary.id} (tenant ${summary.tenantId}) failed: ${err instanceof Error ? err.message : String(err)}`);
278
+ log.error(`rebuildDirtySummaries: summary ${summary.id} (tenant ${summary.tenantId}) failed: ${err instanceof Error ? err.message : String(err)}`);
278
279
  }
279
280
  }
280
281
  return result;
@@ -310,7 +311,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
310
311
  const byTenant = new Map();
311
312
  for (const l2 of unparented) {
312
313
  const tid = l2.tenantId ?? 'default';
313
- const key = derivationPartitionKey(tid, l2.scope);
314
+ const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
314
315
  const list = byTenant.get(key) ?? [];
315
316
  list.push(l2);
316
317
  byTenant.set(key, list);
@@ -338,6 +339,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
338
339
  scope,
339
340
  baseHalfLifeDays,
340
341
  });
342
+ profileEntry.origin_project = tenantL2s[0].origin_project;
341
343
  profileEntry.descendant_count = cluster.members.length;
342
344
  profileEntry.earliest_at = memberCreatedAts[0];
343
345
  profileEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
@@ -351,7 +353,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
351
353
  catch (err) {
352
354
  if (err instanceof RejectedValueError) {
353
355
  result.rejected++;
354
- console.error(`[buildEntityProfiles] cluster "${cluster.label}" skipped: profile matches a rejected value`);
356
+ log.warn(`buildEntityProfiles: cluster "${cluster.label}" skipped: profile matches a rejected value`);
355
357
  continue;
356
358
  }
357
359
  throw err;
package/dist/dashboard.js CHANGED
@@ -18,6 +18,7 @@ import { loadEmbeddingIndex } from './embeddings.js';
18
18
  import { resolveTenantId } from './tenant.js';
19
19
  import { loadCardDetail } from './card-detail.js';
20
20
  import { isCrossSite, LOOPBACK_HOST_HEADER } from './server.js';
21
+ import { log } from './log.js';
21
22
  function buildDashboardData(hippoRoot) {
22
23
  // A5: scope dashboard to active tenant. Without this, the UI surfaces every
23
24
  // tenant's memories on a multi-tenant deployment.
@@ -152,7 +153,7 @@ function serveStaticFile(res, filePath) {
152
153
  return true;
153
154
  }
154
155
  catch {
155
- return false;
156
+ return false; // an unreadable asset falls through to the next route, which answers 404 or the fallback page
156
157
  }
157
158
  }
158
159
  export function serveDashboard(hippoRoot, port = 3333) {
@@ -249,7 +250,7 @@ export function serveDashboard(hippoRoot, port = 3333) {
249
250
  handleRequest(req, res);
250
251
  }
251
252
  catch (err) {
252
- console.error('Dashboard request failed:', err);
253
+ log.error(`dashboard request failed: ${err instanceof Error ? err.message : String(err)}`);
253
254
  if (res.headersSent)
254
255
  res.end();
255
256
  else
@@ -257,6 +258,7 @@ export function serveDashboard(hippoRoot, port = 3333) {
257
258
  }
258
259
  });
259
260
  server.listen(port, '127.0.0.1', () => {
261
+ // The banner is the `hippo dashboard` command's printed result, so it stays on stdout.
260
262
  console.log(`Hippo Dashboard running at http://localhost:${port}`);
261
263
  if (hasDistUi)
262
264
  console.log(`Serving React UI from ${distUiDir}`);
package/dist/db.js CHANGED
@@ -12,7 +12,7 @@ const require = createRequire(import.meta.url);
12
12
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
13
13
  // types for it here, so this require + cast is the module's documented boundary.
14
14
  const { DatabaseSync } = require('node:sqlite');
15
- const CURRENT_SCHEMA_VERSION = 50;
15
+ const CURRENT_SCHEMA_VERSION = 51;
16
16
  const MIGRATIONS = [
17
17
  {
18
18
  version: 1,
@@ -2568,6 +2568,19 @@ const MIGRATIONS = [
2568
2568
  `);
2569
2569
  },
2570
2570
  },
2571
+ {
2572
+ version: 51,
2573
+ up: (db) => {
2574
+ // Both indexes serve loadAmbientCandidates (src/store.ts); their WHERE text must match its SQL for the planner to pick them.
2575
+ // The drift index stays empty on a healthy store, so the drift probe is one index seek instead of a tenant scan. Additive only.
2576
+ db.exec(`
2577
+ CREATE INDEX IF NOT EXISTS idx_memories_pinned
2578
+ ON memories(tenant_id, created, id) WHERE pinned = 1;
2579
+ CREATE INDEX IF NOT EXISTS idx_memories_created_drift
2580
+ ON memories(tenant_id) WHERE superseded_by IS NULL AND (length(created) <> 24 OR created NOT LIKE '%Z');
2581
+ `);
2582
+ },
2583
+ },
2571
2584
  ];
2572
2585
  function tableHasColumn(db, tableName, columnName) {
2573
2586
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2700,7 +2713,7 @@ function openOwnHippoDb(hippoRoot, opts) {
2700
2713
  cleanupArchivedMirrors(hippoRoot, db);
2701
2714
  }
2702
2715
  catch (cleanupErr) {
2703
- console.error('openHippoDb: cleanupArchivedMirrors failed (non-fatal):', cleanupErr);
2716
+ log.error(`openHippoDb: cleanupArchivedMirrors failed (non-fatal): ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
2704
2717
  }
2705
2718
  return db;
2706
2719
  }
@@ -2964,7 +2977,7 @@ function ensureOptionalFts(db) {
2964
2977
  available = true;
2965
2978
  }
2966
2979
  catch (err) {
2967
- console.error(`hippo: full-text index unavailable (${err instanceof Error ? err.message : String(err)}); search falls back to slower LIKE matching`);
2980
+ log.warn(`full-text index unavailable (${err instanceof Error ? err.message : String(err)}); search falls back to slower LIKE matching`);
2968
2981
  }
2969
2982
  // Read-first: only write when the flag actually changed, so a healthy
2970
2983
  // store's open doesn't take a write lock for a same-value upsert.
package/dist/dedupe.js CHANGED
@@ -78,7 +78,7 @@ export function deduplicateStore(hippoRoot, options = {}) {
78
78
  const entriesByTenant = new Map();
79
79
  for (const entry of entries) {
80
80
  // EI2: also split by restricted scope, else a private copy can delete the readable one.
81
- const key = derivationPartitionKey(entry.tenantId, entry.scope);
81
+ const key = derivationPartitionKey(entry.tenantId, entry.scope, entry.origin_project);
82
82
  const bucket = entriesByTenant.get(key);
83
83
  if (bucket)
84
84
  bucket.push(entry);
@@ -205,6 +205,7 @@ export function createDeliveryRecorder(init) {
205
205
  return;
206
206
  flushed = true;
207
207
  if (broken !== null) {
208
+ // Same pinned `[hippo] delivery ledger` hook stderr line as recall-trace.ts's write failure.
208
209
  console.error(`[hippo] delivery ledger skipped: recorder failed: ${broken}`);
209
210
  return;
210
211
  }
package/dist/doctor.js CHANGED
@@ -15,6 +15,8 @@ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVe
15
15
  import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
16
16
  import { isEmbeddingAvailable } from './embeddings.js';
17
17
  import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
18
+ import { planUserGlobalRepair } from './project-merge.js';
19
+ import { resolveTenantId } from './tenant.js';
18
20
  /** Minimum Node.js version hippo supports (package.json engines). */
19
21
  export const MIN_NODE = '22.16.0';
20
22
  function versionAtLeast(actual, min) {
@@ -34,6 +36,7 @@ function readJson(file) {
34
36
  return JSON.parse(fs.readFileSync(file, 'utf8'));
35
37
  }
36
38
  catch {
39
+ // A missing or corrupt file is the finding doctor reports, so null is the answer.
37
40
  return null;
38
41
  }
39
42
  }
@@ -128,6 +131,25 @@ function compactionsCheck(db, now) {
128
131
  : { id: 'compactions', status: 'warn', detail: `cannot read the compaction records: ${message}` };
129
132
  }
130
133
  }
134
+ /** Merged rows older sleep saved as user-global, counted by the repair's own dry run so a truly user-global merge never warns. */
135
+ function projectsCheck(globalRoot) {
136
+ let db = null;
137
+ try {
138
+ db = openHippoDbReadOnly(globalRoot);
139
+ const r = planUserGlobalRepair(db, resolveTenantId({}));
140
+ const n = r.toProject.length + r.setAside.length;
141
+ return n === 0
142
+ ? { id: 'projects', status: 'pass', detail: 'no merged memories in the global store are tagged user-global by mistake' }
143
+ : { id: 'projects', status: 'warn', detail: `${n} merged memories in the global store are tagged user-global, so every project's context can show them`, fix: 'hippo projects repair --global (dry run; add --apply to write)' };
144
+ }
145
+ catch (err) {
146
+ return { id: 'projects', status: 'info', detail: `project tags not checked (${err instanceof Error ? err.message : String(err)})` };
147
+ }
148
+ finally {
149
+ if (db !== null)
150
+ closeHippoDb(db);
151
+ }
152
+ }
131
153
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
132
154
  export function runDoctor(opts) {
133
155
  const cwd = opts.cwd ?? process.cwd();
@@ -213,6 +235,8 @@ export function runDoctor(opts) {
213
235
  if (holdoutRateBp > 0) {
214
236
  checks.push({ id: 'pilot', status: 'info', detail: `pilot holdout on: about ${holdoutRateBp / 100}% of sessions get no memories pushed by hippo (pilot.holdoutRateBp=${holdoutRateBp})` });
215
237
  }
238
+ if (hasGlobal)
239
+ checks.push(projectsCheck(globalRoot));
216
240
  const claudeDir = path.join(home, '.claude');
217
241
  if (fs.existsSync(claudeDir)) {
218
242
  const settings = readJson(path.join(claudeDir, 'settings.json'));
package/dist/dormant.d.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import type { DatabaseSyncLike } from './db.js';
21
21
  import type { MemoryEntry } from './memory.js';
22
- /** Why a memory went dormant: sleep's decay pass, or an imported agent memory whose note was deleted. */
23
- export type DormantReason = 'decay' | 'source-deleted';
22
+ /** Why a memory went dormant: sleep's decay pass, an imported agent memory whose note was deleted, or `hippo projects repair` splitting a two-project merge. */
23
+ export type DormantReason = 'decay' | 'source-deleted' | 'project-repair';
24
24
  /** One memory that sleep is moving out of active memory into the dormant store. */
25
25
  export interface DormantMove {
26
26
  /** The memory as it stood when it faded; restored verbatim apart from its recall clock. */
package/dist/dormant.js CHANGED
@@ -36,6 +36,7 @@ function parseSnapshot(row) {
36
36
  return entry;
37
37
  }
38
38
  catch {
39
+ // A row that will not parse is out of shape, so it is not restorable.
39
40
  return null;
40
41
  }
41
42
  }
@@ -32,7 +32,7 @@
32
32
  */
33
33
  import { getEmbedding, isEmbeddingAvailable, resolveEmbeddingModel, DEFAULT_EMBEDDING_MODEL, } from './embeddings.js';
34
34
  import { loadConfig } from './config.js';
35
- import { redactSecrets } from './secret-detect.js';
35
+ import { redactSecretsStrict } from './secret-detect.js';
36
36
  import { fetchWithRetry } from './http-retry.js';
37
37
  export const API_PROVIDER_KINDS = ['openai', 'voyage', 'cohere'];
38
38
  function isApiProviderKind(x) {
@@ -191,7 +191,7 @@ class ApiEmbeddingProvider {
191
191
  'content-type': 'application/json',
192
192
  authorization: `Bearer ${key}`,
193
193
  },
194
- body: JSON.stringify(spec.buildBody(this.model, chunk.map(redactSecrets), role)),
194
+ body: JSON.stringify(spec.buildBody(this.model, chunk.map(redactSecretsStrict), role)),
195
195
  }, { timeoutMs: REQUEST_TIMEOUT_MS });
196
196
  }
197
197
  catch (err) {
@@ -237,6 +237,7 @@ function readEmbeddingsConfig(hippoRoot) {
237
237
  return loadConfig(hippoRoot).embeddings;
238
238
  }
239
239
  catch {
240
+ // An unreadable config falls back to provider defaults; loadConfig warns on a bad parse.
240
241
  return {};
241
242
  }
242
243
  }
@@ -147,7 +147,7 @@ function resolveTransformersPackage() {
147
147
  return '@xenova/transformers';
148
148
  }
149
149
  catch {
150
- return null;
150
+ return null; // neither optional package is installed; callers fall back to no local embeddings
151
151
  }
152
152
  }
153
153
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -177,7 +177,8 @@ async function loadPipeline(model) {
177
177
  }
178
178
  pipelineFn = mod.pipeline ?? mod.default?.pipeline;
179
179
  }
180
- catch {
180
+ catch (err) {
181
+ log.debug(`transformers import failed (${pkg}): ${err instanceof Error ? err.message : String(err)}`);
181
182
  return null;
182
183
  }
183
184
  if (!pipelineFn)
@@ -193,7 +194,8 @@ async function loadPipeline(model) {
193
194
  _pipelineInstances.set(model, instance);
194
195
  return instance;
195
196
  }
196
- catch {
197
+ catch (err) {
198
+ log.debug(`embedding pipeline load failed (${model}): ${err instanceof Error ? err.message : String(err)}`);
197
199
  return null;
198
200
  }
199
201
  finally {
@@ -228,7 +230,8 @@ function loadStoredEmbeddingModel(hippoRoot) {
228
230
  closeHippoDb(db);
229
231
  }
230
232
  }
231
- catch {
233
+ catch (err) {
234
+ log.debug(`stored embedding model unreadable: ${err instanceof Error ? err.message : String(err)}`);
232
235
  return null;
233
236
  }
234
237
  }
@@ -363,7 +366,7 @@ function parseEmbeddingIndex(raw) {
363
366
  return parsed instanceof Object && !Array.isArray(parsed) ? parsed : null;
364
367
  }
365
368
  catch {
366
- return null;
369
+ return null; // the caller quarantines the corrupt file and logs it
367
370
  }
368
371
  }
369
372
  /** Move a corrupt index aside and flag a full rebuild, so no later save can write over the only copy of those bytes. */
@@ -523,7 +526,7 @@ function warnEmbedFailureOnce(source, rawMessage) {
523
526
  _embedFailureWarned = true;
524
527
  // Strict scrub: this line can land in a hook log file, and an API may echo the key back in its error body.
525
528
  const message = redactSecretsStrict(rawMessage).replace(/\s+/g, ' ').replace(/\.+$/, '');
526
- console.error(`hippo: embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
529
+ log.warn(`embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
527
530
  }
528
531
  /**
529
532
  * Embed a single memory entry and cache the result in the embedding index.
@@ -591,7 +594,7 @@ export async function embedMemory(hippoRoot, entry, model) {
591
594
  warnEmbedFailureOnce(provider.kind, err instanceof Error ? err.message : String(err));
592
595
  }
593
596
  }).catch((err) => {
594
- console.error(`hippo: skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)}); run 'hippo embed' to backfill`);
597
+ log.warn(`skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)}); run 'hippo embed' to backfill`);
595
598
  });
596
599
  }
597
600
  /**