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.
- package/README.md +13 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/legacy.js +4 -1
- package/dist/api.d.ts +2 -0
- package/dist/api.js +25 -9
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +3 -0
- package/dist/autolearn.js +5 -2
- package/dist/capture.js +8 -6
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +14 -8
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +356 -0
- package/dist/cli.js +1587 -1404
- package/dist/compaction-record.js +7 -6
- package/dist/config.js +12 -11
- package/dist/connectors/github/cli-impl.js +1 -0
- package/dist/consolidate.js +16 -4
- package/dist/dag.js +11 -9
- package/dist/dashboard.js +4 -2
- package/dist/db.js +16 -3
- package/dist/dedupe.js +1 -1
- package/dist/delivery-recorder.js +1 -0
- package/dist/doctor.js +24 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/dormant.js +1 -0
- package/dist/embedding-provider.js +3 -2
- package/dist/embeddings.js +10 -7
- package/dist/extract.js +20 -19
- package/dist/graph.js +3 -8
- package/dist/handoff.js +3 -0
- package/dist/hooks.js +3 -0
- package/dist/importers.js +6 -3
- package/dist/incidents.js +1 -0
- package/dist/judgment.js +5 -2
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.js +32 -13
- package/dist/memory.d.ts +5 -3
- package/dist/processes.js +1 -0
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +9 -4
- package/dist/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/raw-archive-mirror-cleanup.js +2 -1
- package/dist/recall-scope.d.ts +3 -3
- package/dist/recall-scope.js +5 -4
- package/dist/recall-trace.d.ts +2 -2
- package/dist/recall-trace.js +10 -14
- package/dist/refine-llm.js +18 -10
- package/dist/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +223 -0
- package/dist/rerankers/cross-encoder.js +6 -4
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +14 -1
- package/dist/rerankers/jev.js +30 -20
- package/dist/rerankers/llm.js +3 -3
- package/dist/rerankers/types.d.ts +16 -0
- package/dist/same-text.d.ts +2 -0
- package/dist/same-text.js +4 -0
- package/dist/scheduler.js +1 -0
- package/dist/search.js +4 -2
- package/dist/secret-detect.js +1 -0
- package/dist/server.d.ts +6 -1
- package/dist/server.js +38 -15
- package/dist/shared.js +21 -19
- package/dist/stdin.js +1 -0
- package/dist/store.d.ts +33 -1
- package/dist/store.js +123 -22
- package/dist/token-ledger.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)');
|
package/dist/consolidate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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}. ${
|
|
51
|
+
const factsBlock = factContents.map((f, i) => `${i + 1}. ${redactSecretsStrict(f)}`).join('\n');
|
|
51
52
|
const prompt = DAG_SUMMARY_PROMPT
|
|
52
|
-
.replace('{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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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 {
|
|
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(
|
|
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
|
}
|
package/dist/embeddings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|