hippo-memory 1.56.0 → 1.58.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 +11 -0
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -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 +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/db.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createPhysicsTable } from './physics-state.js';
|
|
|
6
6
|
import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
|
|
7
7
|
import { PACKAGE_VERSION, compareSemver } from './version.js';
|
|
8
8
|
import { deriveOriginProject, originFromSource, isGlobalStoreRoot } from './project-identity.js';
|
|
9
|
+
import { log } from './log.js';
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
// SAFETY: node:sqlite's DatabaseSync constructor genuinely has this shape at
|
|
11
12
|
// runtime (Node's built-in synchronous SQLite module); there are no bundled
|
|
@@ -2607,7 +2608,7 @@ export function isSqliteBusy(error) {
|
|
|
2607
2608
|
// busy_timeout covers neither of this file's two contended statements: SQLite
|
|
2608
2609
|
// skips the busy handler for `PRAGMA journal_mode` and for a write that upgrades
|
|
2609
2610
|
// a deferred read snapshot. Both need an explicit wait instead.
|
|
2610
|
-
function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
2611
|
+
export function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
2611
2612
|
const deadline = Date.now() + timeoutMs;
|
|
2612
2613
|
const idle = new Int32Array(new SharedArrayBuffer(4));
|
|
2613
2614
|
for (;;) {
|
|
@@ -2622,8 +2623,66 @@ function execWithBusyRetry(db, sql, timeoutMs = 30000) {
|
|
|
2622
2623
|
}
|
|
2623
2624
|
}
|
|
2624
2625
|
}
|
|
2626
|
+
// Hook commands run on every prompt, so inside withSharedStoreHandles each store pays its pragmas, migration check and mirror cleanup once.
|
|
2627
|
+
const sharedHandles = new Map();
|
|
2628
|
+
const sharedSet = new WeakSet();
|
|
2629
|
+
let shareDepth = 0;
|
|
2630
|
+
let shareBusyWaitMs;
|
|
2631
|
+
/** Lock wait for hook commands: under the 5 s prompt-hook budget even after a few skipped writes, and far above a normal write's hold. */
|
|
2632
|
+
export const HOOK_DB_WAIT_MS = 1000;
|
|
2633
|
+
/** A busy store made a command skip work: warn once per process (the holder is usually `hippo sleep`). */
|
|
2634
|
+
export function noteStoreBusy(skipped) {
|
|
2635
|
+
log.once('store-busy', 'warn', `store busy (another hippo process holds the write lock); ${skipped}`);
|
|
2636
|
+
// A lock held past one full wait belongs to a long transaction, so the hook's later writes skip at once.
|
|
2637
|
+
for (const db of sharedHandles.values()) {
|
|
2638
|
+
if (db.isOpen !== false)
|
|
2639
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
function closeSharedStoreHandles() {
|
|
2643
|
+
for (const db of sharedHandles.values()) {
|
|
2644
|
+
sharedSet.delete(db);
|
|
2645
|
+
if (db.isOpen !== false)
|
|
2646
|
+
db.close();
|
|
2647
|
+
}
|
|
2648
|
+
sharedHandles.clear();
|
|
2649
|
+
}
|
|
2650
|
+
/** Runs `fn` with one handle per store: openHippoDb reuses it and closeHippoDb leaves it open until `fn` settles or the process exits.
|
|
2651
|
+
* `busyWaitMs` is the lock wait of every open inside `fn` that does not pass its own. */
|
|
2652
|
+
export async function withSharedStoreHandles(fn, opts) {
|
|
2653
|
+
if (shareDepth++ === 0) {
|
|
2654
|
+
process.once('exit', closeSharedStoreHandles);
|
|
2655
|
+
shareBusyWaitMs = opts?.busyWaitMs;
|
|
2656
|
+
}
|
|
2657
|
+
try {
|
|
2658
|
+
return await fn();
|
|
2659
|
+
}
|
|
2660
|
+
finally {
|
|
2661
|
+
if (--shareDepth === 0) {
|
|
2662
|
+
process.off('exit', closeSharedStoreHandles);
|
|
2663
|
+
closeSharedStoreHandles();
|
|
2664
|
+
shareBusyWaitMs = undefined;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2625
2668
|
/** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
|
|
2626
2669
|
export function openHippoDb(hippoRoot, opts) {
|
|
2670
|
+
if (shareDepth === 0)
|
|
2671
|
+
return openOwnHippoDb(hippoRoot, opts);
|
|
2672
|
+
const busyWaitMs = opts?.busyWaitMs ?? shareBusyWaitMs;
|
|
2673
|
+
const key = `${path.resolve(getHippoDbPath(hippoRoot))}\0${busyWaitMs ?? ''}`;
|
|
2674
|
+
const shared = sharedHandles.get(key);
|
|
2675
|
+
if (shared?.isOpen && !shared.isTransaction)
|
|
2676
|
+
return shared;
|
|
2677
|
+
const db = openOwnHippoDb(hippoRoot, { busyWaitMs });
|
|
2678
|
+
// An open nested inside a transaction gets its own connection, as it did before sharing.
|
|
2679
|
+
if (!shared?.isOpen) {
|
|
2680
|
+
sharedHandles.set(key, db);
|
|
2681
|
+
sharedSet.add(db);
|
|
2682
|
+
}
|
|
2683
|
+
return db;
|
|
2684
|
+
}
|
|
2685
|
+
function openOwnHippoDb(hippoRoot, opts) {
|
|
2627
2686
|
fs.mkdirSync(hippoRoot, { recursive: true });
|
|
2628
2687
|
const db = new DatabaseSync(getHippoDbPath(hippoRoot));
|
|
2629
2688
|
const busyWaitMs = opts?.busyWaitMs;
|
|
@@ -2937,6 +2996,8 @@ function backfillFtsIndex(db) {
|
|
|
2937
2996
|
`);
|
|
2938
2997
|
}
|
|
2939
2998
|
export function closeHippoDb(db) {
|
|
2999
|
+
if (sharedSet.has(db))
|
|
3000
|
+
return;
|
|
2940
3001
|
db.close();
|
|
2941
3002
|
}
|
|
2942
3003
|
export function getMeta(db, key, fallback = '') {
|
package/dist/decisions.js
CHANGED
|
@@ -23,8 +23,10 @@
|
|
|
23
23
|
* 'write_entry' (store.ts:1196) via the afterWrite hook, so a failure in any
|
|
24
24
|
* step rolls all of them back. Pattern matches savePrediction (predictions.ts).
|
|
25
25
|
*/
|
|
26
|
+
import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
|
|
26
27
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
27
|
-
import { writeEntry
|
|
28
|
+
import { writeEntry } from './store.js';
|
|
29
|
+
import { assertTenantId } from './tenant.js';
|
|
28
30
|
import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
|
|
29
31
|
import { createMemory, Layer } from './memory.js';
|
|
30
32
|
import { appendAuditEvent } from './audit.js';
|
|
@@ -72,7 +74,7 @@ const DECISION_COLS = `
|
|
|
72
74
|
export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
73
75
|
assertTenantId('saveDecision', tenantId);
|
|
74
76
|
if (!opts.decisionText)
|
|
75
|
-
throw new
|
|
77
|
+
throw new BadRequestError('saveDecision: decisionText is required');
|
|
76
78
|
const now = new Date().toISOString();
|
|
77
79
|
const content = opts.context
|
|
78
80
|
? `${opts.decisionText}\n\nContext: ${opts.context}`
|
|
@@ -102,10 +104,10 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
102
104
|
// SAFETY: row shape matches the single `status` column named in the SELECT below.
|
|
103
105
|
const pred = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(opts.supersedesDecisionId, tenantId);
|
|
104
106
|
if (!pred) {
|
|
105
|
-
throw new
|
|
107
|
+
throw new NotFoundError(`saveDecision: decision ${opts.supersedesDecisionId} to supersede not found for tenant ${tenantId}`);
|
|
106
108
|
}
|
|
107
109
|
if (pred.status !== 'active') {
|
|
108
|
-
throw new
|
|
110
|
+
throw new ConflictError(`saveDecision: decision ${opts.supersedesDecisionId} is not active (status='${pred.status}'); only active decisions can be superseded.`);
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
const result = db.prepare(`
|
|
@@ -126,7 +128,7 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
126
128
|
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
127
129
|
`).run(decisionId, now, opts.supersedesDecisionId, tenantId, decisionId);
|
|
128
130
|
if (sup.changes === 0) {
|
|
129
|
-
throw new
|
|
131
|
+
throw new BadRequestError(`saveDecision: decision ${opts.supersedesDecisionId} could not be superseded (no longer active or self-reference).`);
|
|
130
132
|
}
|
|
131
133
|
appendAuditEvent(db, {
|
|
132
134
|
tenantId,
|
|
@@ -190,15 +192,15 @@ export function closeDecision(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
190
192
|
// SAFETY: row shape matches the single `status` column named in the SELECT above.
|
|
191
193
|
const existing = db.prepare(`SELECT status FROM decisions WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
192
194
|
if (!existing) {
|
|
193
|
-
throw new
|
|
195
|
+
throw new NotFoundError(`closeDecision: decision ${id} not found for tenant ${tenantId}`);
|
|
194
196
|
}
|
|
195
|
-
throw new
|
|
197
|
+
throw new ConflictError(`closeDecision: decision ${id} is not active (status='${existing.status}'); only active decisions can be closed.`);
|
|
196
198
|
}
|
|
197
199
|
// SAFETY: row's shape matches the columns named in DECISION_COLS above.
|
|
198
200
|
const row = db.prepare(`SELECT ${DECISION_COLS} FROM decisions WHERE id = ? AND tenant_id = ?`)
|
|
199
201
|
.get(id, tenantId);
|
|
200
202
|
if (!row)
|
|
201
|
-
throw new
|
|
203
|
+
throw new NotFoundError(`closeDecision: decision ${id} not found after UPDATE`);
|
|
202
204
|
appendAuditEvent(db, {
|
|
203
205
|
tenantId,
|
|
204
206
|
actor,
|
|
@@ -255,7 +257,7 @@ export function loadDecisions(hippoRoot, tenantId, opts = {}) {
|
|
|
255
257
|
let rows;
|
|
256
258
|
if (opts.status) {
|
|
257
259
|
if (!VALID_DECISION_STATES.has(opts.status)) {
|
|
258
|
-
throw new
|
|
260
|
+
throw new BadRequestError(`loadDecisions: status must be one of ${Array.from(VALID_DECISION_STATES).join('|')}; got ${opts.status}`);
|
|
259
261
|
}
|
|
260
262
|
// SAFETY: rows' shape matches the columns named in DECISION_COLS above.
|
|
261
263
|
rows = db.prepare(`
|
package/dist/doctor.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
|
|
10
10
|
import { findHippoStoreDir } from './project-identity.js';
|
|
11
11
|
import { getGlobalRoot } from './shared.js';
|
|
12
12
|
import { isInitialized } from './store.js';
|
|
13
|
+
import { loadConfig } from './config.js';
|
|
13
14
|
import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
|
|
14
15
|
import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
|
|
15
16
|
import { isEmbeddingAvailable } from './embeddings.js';
|
|
@@ -208,6 +209,10 @@ export function runDoctor(opts) {
|
|
|
208
209
|
closeHippoDb(db);
|
|
209
210
|
}
|
|
210
211
|
}
|
|
212
|
+
const holdoutRateBp = store === null ? 0 : loadConfig(store).pilot.holdoutRateBp;
|
|
213
|
+
if (holdoutRateBp > 0) {
|
|
214
|
+
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
|
+
}
|
|
211
216
|
const claudeDir = path.join(home, '.claude');
|
|
212
217
|
if (fs.existsSync(claudeDir)) {
|
|
213
218
|
const settings = readJson(path.join(claudeDir, 'settings.json'));
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { getEmbedding, isEmbeddingAvailable, resolveEmbeddingModel, DEFAULT_EMBEDDING_MODEL, } from './embeddings.js';
|
|
34
34
|
import { loadConfig } from './config.js';
|
|
35
35
|
import { redactSecrets } from './secret-detect.js';
|
|
36
|
+
import { fetchWithRetry } from './http-retry.js';
|
|
36
37
|
export const API_PROVIDER_KINDS = ['openai', 'voyage', 'cohere'];
|
|
37
38
|
function isApiProviderKind(x) {
|
|
38
39
|
return x === 'openai' || x === 'voyage' || x === 'cohere';
|
|
@@ -184,15 +185,14 @@ class ApiEmbeddingProvider {
|
|
|
184
185
|
const url = `${this.baseUrl.replace(/\/$/, '')}/${spec.path}`;
|
|
185
186
|
let resp;
|
|
186
187
|
try {
|
|
187
|
-
resp = await
|
|
188
|
+
resp = await fetchWithRetry(url, {
|
|
188
189
|
method: 'POST',
|
|
189
190
|
headers: {
|
|
190
191
|
'content-type': 'application/json',
|
|
191
192
|
authorization: `Bearer ${key}`,
|
|
192
193
|
},
|
|
193
194
|
body: JSON.stringify(spec.buildBody(this.model, chunk.map(redactSecrets), role)),
|
|
194
|
-
|
|
195
|
-
});
|
|
195
|
+
}, { timeoutMs: REQUEST_TIMEOUT_MS });
|
|
196
196
|
}
|
|
197
197
|
catch (err) {
|
|
198
198
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/embeddings.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Falls back silently if the library is not installed.
|
|
5
5
|
*/
|
|
6
6
|
import { MemoryEntry } from './memory.js';
|
|
7
|
+
import { type EmbeddingProvider } from './embedding-provider.js';
|
|
7
8
|
export declare const DEFAULT_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
8
9
|
export declare const EMBEDDING_MODEL_META_KEY = "embedding_model";
|
|
9
10
|
/**
|
|
@@ -114,8 +115,7 @@ export declare function getEmbedding(text: string, model?: string, role?: Embedd
|
|
|
114
115
|
*/
|
|
115
116
|
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
116
117
|
/**
|
|
117
|
-
* Load the cached embedding index
|
|
118
|
-
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
118
|
+
* Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
|
|
119
119
|
*/
|
|
120
120
|
export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, number[]>;
|
|
121
121
|
/**
|
|
@@ -129,7 +129,7 @@ export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model
|
|
|
129
129
|
/**
|
|
130
130
|
* Embed all entries in hippoRoot that don't already have cached vectors.
|
|
131
131
|
* Prunes orphaned embeddings for memories that no longer exist.
|
|
132
|
-
* Returns the count of newly embedded entries.
|
|
132
|
+
* Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
|
|
133
133
|
*/
|
|
134
|
-
export declare function embedAll(hippoRoot: string, model?: string): Promise<number>;
|
|
134
|
+
export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider): Promise<number>;
|
|
135
135
|
//# sourceMappingURL=embeddings.d.ts.map
|
package/dist/embeddings.js
CHANGED
|
@@ -13,6 +13,7 @@ import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysics
|
|
|
13
13
|
import { loadConfig } from './config.js';
|
|
14
14
|
import { resolveEmbeddingProvider } from './embedding-provider.js';
|
|
15
15
|
import { redactSecretsStrict } from './secret-detect.js';
|
|
16
|
+
import { log } from './log.js';
|
|
16
17
|
// Use createRequire for synchronous module resolution check in ESM
|
|
17
18
|
const _require = createRequire(import.meta.url);
|
|
18
19
|
// Cached availability check
|
|
@@ -270,6 +271,9 @@ async function rebuildEmbeddingIndex(entries, provider) {
|
|
|
270
271
|
if (vec && vec.length > 0) {
|
|
271
272
|
rebuilt[entries[i].id] = vec;
|
|
272
273
|
}
|
|
274
|
+
else {
|
|
275
|
+
noteSkippedEmbedding(entries[i].id);
|
|
276
|
+
}
|
|
273
277
|
}
|
|
274
278
|
return rebuilt;
|
|
275
279
|
}
|
|
@@ -283,10 +287,15 @@ function resetPhysicsFromIndex(hippoRoot, entries, index) {
|
|
|
283
287
|
closeHippoDb(db);
|
|
284
288
|
}
|
|
285
289
|
}
|
|
286
|
-
catch {
|
|
287
|
-
//
|
|
290
|
+
catch (err) {
|
|
291
|
+
// Best effort: retrieval still falls back without physics state.
|
|
292
|
+
log.warn(`physics reset after reindex failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
288
293
|
}
|
|
289
294
|
}
|
|
295
|
+
/** A provider's `[]` row is a swallowed per-item failure; name the memory so the gap can be traced. */
|
|
296
|
+
function noteSkippedEmbedding(id) {
|
|
297
|
+
log.warn('memory not embedded; the next embed run retries it', { id });
|
|
298
|
+
}
|
|
290
299
|
/**
|
|
291
300
|
* Get an embedding vector for a piece of text.
|
|
292
301
|
* Returns an empty array if transformers is not available or fails.
|
|
@@ -314,7 +323,9 @@ export async function getEmbedding(text, model = DEFAULT_EMBEDDING_MODEL, role)
|
|
|
314
323
|
// pipeline's documented tensor output shape.
|
|
315
324
|
return Array.from(output.data);
|
|
316
325
|
}
|
|
317
|
-
catch {
|
|
326
|
+
catch (err) {
|
|
327
|
+
// The caller sees `[]` and names the memory; the reason only shows at debug.
|
|
328
|
+
log.debug(`local embedding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
329
|
return [];
|
|
319
330
|
}
|
|
320
331
|
}
|
|
@@ -340,23 +351,66 @@ export function cosineSimilarity(a, b) {
|
|
|
340
351
|
return Math.min(1, Math.max(-1, dot / denom));
|
|
341
352
|
}
|
|
342
353
|
const EMBEDDINGS_FILE = 'embeddings.json';
|
|
354
|
+
// Never equal to a real index identity, so the next embed run treats it as a model change and rebuilds every vector.
|
|
355
|
+
const QUARANTINED_INDEX_IDENTITY = 'quarantined-corrupt-index';
|
|
356
|
+
function isErrnoCode(err, code) {
|
|
357
|
+
return err instanceof Error && 'code' in err && err.code === code;
|
|
358
|
+
}
|
|
359
|
+
function parseEmbeddingIndex(raw) {
|
|
360
|
+
try {
|
|
361
|
+
const parsed = JSON.parse(raw);
|
|
362
|
+
// SAFETY: saveEmbeddingIndex is the only writer and always writes this shape; anything that is not an object is corrupt.
|
|
363
|
+
return parsed instanceof Object && !Array.isArray(parsed) ? parsed : null;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Move a corrupt index aside and flag a full rebuild, so no later save can write over the only copy of those bytes. */
|
|
370
|
+
function quarantineCorruptIndex(hippoRoot, fp) {
|
|
371
|
+
const aside = `${fp}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
|
|
372
|
+
try {
|
|
373
|
+
fs.renameSync(fp, aside);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (isErrnoCode(err, 'ENOENT'))
|
|
377
|
+
return;
|
|
378
|
+
// A rename blocked by an open handle (Windows) still gets a copy kept; if the copy fails too, the throw stops the save.
|
|
379
|
+
fs.copyFileSync(fp, aside, fs.constants.COPYFILE_EXCL);
|
|
380
|
+
}
|
|
381
|
+
log.error(`${EMBEDDINGS_FILE} could not be parsed; kept it as ${path.basename(aside)} and the next embed rebuilds the index`, { hippoRoot });
|
|
382
|
+
try {
|
|
383
|
+
const db = openHippoDb(hippoRoot);
|
|
384
|
+
try {
|
|
385
|
+
setMeta(db, EMBEDDING_MODEL_META_KEY, QUARANTINED_INDEX_IDENTITY);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
closeHippoDb(db);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
log.warn(`could not flag the embedding index for rebuild; run 'hippo embed' to restore vectors (${err instanceof Error ? err.message : String(err)})`, { hippoRoot });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
343
395
|
/**
|
|
344
|
-
* Load the cached embedding index
|
|
345
|
-
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
396
|
+
* Load the cached embedding index; `{}` when the file is missing. A corrupt file is moved aside and rebuilt on the next embed; any other read error throws, so nothing saves over an index it could not read.
|
|
346
397
|
*/
|
|
347
398
|
export function loadEmbeddingIndex(hippoRoot) {
|
|
348
399
|
const fp = path.join(hippoRoot, EMBEDDINGS_FILE);
|
|
349
|
-
|
|
350
|
-
return {};
|
|
400
|
+
let raw;
|
|
351
401
|
try {
|
|
352
|
-
|
|
353
|
-
// this exact shape; a corrupt or foreign file is caught by the try/catch
|
|
354
|
-
// below and treated as an empty index.
|
|
355
|
-
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
402
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
356
403
|
}
|
|
357
|
-
catch {
|
|
358
|
-
|
|
404
|
+
catch (err) {
|
|
405
|
+
if (isErrnoCode(err, 'ENOENT'))
|
|
406
|
+
return {};
|
|
407
|
+
throw err;
|
|
359
408
|
}
|
|
409
|
+
const index = parseEmbeddingIndex(raw);
|
|
410
|
+
if (index)
|
|
411
|
+
return index;
|
|
412
|
+
quarantineCorruptIndex(hippoRoot, fp);
|
|
413
|
+
return {};
|
|
360
414
|
}
|
|
361
415
|
/**
|
|
362
416
|
* Save the embedding index to disk.
|
|
@@ -543,10 +597,9 @@ export async function embedMemory(hippoRoot, entry, model) {
|
|
|
543
597
|
/**
|
|
544
598
|
* Embed all entries in hippoRoot that don't already have cached vectors.
|
|
545
599
|
* Prunes orphaned embeddings for memories that no longer exist.
|
|
546
|
-
* Returns the count of newly embedded entries.
|
|
600
|
+
* Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
|
|
547
601
|
*/
|
|
548
|
-
export async function embedAll(hippoRoot, model) {
|
|
549
|
-
const provider = resolveEmbeddingProvider(hippoRoot, { model });
|
|
602
|
+
export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model })) {
|
|
550
603
|
if (!provider.isAvailable()) {
|
|
551
604
|
// A configured (non-disabled) API provider with a missing key is a
|
|
552
605
|
// misconfiguration, not a no-op: surface it so programmatic callers of the
|
|
@@ -618,6 +671,9 @@ export async function embedAll(hippoRoot, model) {
|
|
|
618
671
|
dirty = true;
|
|
619
672
|
chunkDirty = true;
|
|
620
673
|
}
|
|
674
|
+
else {
|
|
675
|
+
noteSkippedEmbedding(chunk[j].id);
|
|
676
|
+
}
|
|
621
677
|
}
|
|
622
678
|
if (chunkDirty)
|
|
623
679
|
saveEmbeddingIndex(hippoRoot, index);
|
package/dist/eval-stats.d.ts
CHANGED
|
@@ -120,4 +120,62 @@ export declare function passAtK(runsByTask: boolean[][], k: number): number;
|
|
|
120
120
|
* NaN when no task has k runs.
|
|
121
121
|
*/
|
|
122
122
|
export declare function passHatK(runsByTask: boolean[][], k: number): number;
|
|
123
|
+
/** All units of one family (tasks by seeds), resampled as one block so seeds stay together. */
|
|
124
|
+
export type Family<T> = readonly T[];
|
|
125
|
+
/** One repository's families; a task with no lesson is a family of one. */
|
|
126
|
+
export type Repository<T> = readonly Family<T>[];
|
|
127
|
+
/** An {@link Estimate} with a two-sided p-value from the same resamples. */
|
|
128
|
+
export interface TestedEstimate extends Estimate {
|
|
129
|
+
/** 2 x min(share <= null, share >= null), capped at 1; NaN with fewer than two repositories. */
|
|
130
|
+
readonly p: number;
|
|
131
|
+
/** Non-finite resamples, dropped; `iterations + dropped` is the number requested. */
|
|
132
|
+
readonly dropped: number;
|
|
133
|
+
/** The null the p-value tested against; {@link verdict} reads its sides from here. */
|
|
134
|
+
readonly nullValue: number;
|
|
135
|
+
}
|
|
136
|
+
/** Options for {@link twoLevelBootstrap}. */
|
|
137
|
+
export interface TwoLevelOpts extends BootstrapOpts {
|
|
138
|
+
/** Resamples. Default 10,000. */
|
|
139
|
+
readonly iterations?: number;
|
|
140
|
+
/** Value the p-value tests against: 0 for differences, 1 for ratios. Default 0. */
|
|
141
|
+
readonly nullValue?: number;
|
|
142
|
+
}
|
|
143
|
+
/** Resamples repositories, then families inside each; only the repository draw carries a shared-store fault.
|
|
144
|
+
* The statistic never uses the PRNG, so a second call on one seed draws the same resamples. */
|
|
145
|
+
export declare function twoLevelBootstrap<T>(repos: readonly Repository<T>[], statistic: (units: readonly T[]) => number, opts?: TwoLevelOpts): TestedEstimate;
|
|
146
|
+
/** Holm step-down in input order; the family size is `ps.length`, as preregistered.
|
|
147
|
+
* A NaN stays NaN but ranks as 1, so it never loosens the others; a finite p outside [0, 1] throws. */
|
|
148
|
+
export declare function holmAdjust(ps: readonly number[]): number[];
|
|
149
|
+
/** One of the four mutually exclusive outcomes the preregistration allows per hypothesis. */
|
|
150
|
+
export type Verdict = 'loss' | 'win' | 'tie' | 'inconclusive';
|
|
151
|
+
/** What a hypothesis needs to be read; see {@link verdict}. */
|
|
152
|
+
export interface VerdictSpec {
|
|
153
|
+
/** Direction that favours the treatment arm. */
|
|
154
|
+
readonly helpful: 'lower' | 'higher';
|
|
155
|
+
/** Inclusive band the interval must sit inside for a tie. */
|
|
156
|
+
readonly tieBand: readonly [number, number];
|
|
157
|
+
/** An estimate on this value or beyond it, on the helpful side, reaches the minimum effect. */
|
|
158
|
+
readonly minimumEffectAt: number;
|
|
159
|
+
/** Default 0.05. */
|
|
160
|
+
readonly alpha?: number;
|
|
161
|
+
}
|
|
162
|
+
/** A verdict, plus whether a win reaches the minimum effect (a win below it is a small win). */
|
|
163
|
+
export interface VerdictResult {
|
|
164
|
+
readonly verdict: Verdict;
|
|
165
|
+
readonly reachesMinimum: boolean;
|
|
166
|
+
}
|
|
167
|
+
/** Checks in the preregistered order; the null comes from `e.nullValue`, so p and sides cannot disagree.
|
|
168
|
+
* A NaN estimate, p or bound is inconclusive, since a win or loss cannot be ruled out. */
|
|
169
|
+
export declare function verdict(e: TestedEstimate, adjustedP: number, spec: VerdictSpec): VerdictResult;
|
|
170
|
+
/** A win must hold under both codings of not-applicable, while a loss under either is reported. */
|
|
171
|
+
export declare function combineCodings(a: VerdictResult, b: VerdictResult): VerdictResult;
|
|
172
|
+
/** Outcome of the harm gate; see {@link harmGate}. */
|
|
173
|
+
export interface HarmGate {
|
|
174
|
+
readonly pass: boolean;
|
|
175
|
+
readonly costOk: boolean;
|
|
176
|
+
readonly resolveOk: boolean;
|
|
177
|
+
}
|
|
178
|
+
/** Cost ratio's upper bound below 1.10, resolve difference's lower bound (a fraction) above -0.05.
|
|
179
|
+
* Both estimates must use alpha 0.05, the conservative reading of "upper 95% bound"; a NaN bound fails. */
|
|
180
|
+
export declare function harmGate(costRatio: Estimate, resolveDiff: Estimate): HarmGate;
|
|
123
181
|
//# sourceMappingURL=eval-stats.d.ts.map
|
package/dist/eval-stats.js
CHANGED
|
@@ -184,4 +184,115 @@ export function passHatK(runsByTask, k) {
|
|
|
184
184
|
return Number.NaN;
|
|
185
185
|
return eligible.filter((r) => r.slice(0, k).every(Boolean)).length / eligible.length;
|
|
186
186
|
}
|
|
187
|
+
function notANumber(nullValue, dropped) {
|
|
188
|
+
const nan = Number.NaN;
|
|
189
|
+
return { estimate: nan, low: nan, high: nan, p: nan, iterations: 0, dropped, nullValue };
|
|
190
|
+
}
|
|
191
|
+
function resampleUnits(repos, rand) {
|
|
192
|
+
const units = [];
|
|
193
|
+
for (let i = 0; i < repos.length; i++) {
|
|
194
|
+
const repo = repos[Math.floor(rand() * repos.length)];
|
|
195
|
+
for (let j = 0; j < repo.length; j++) {
|
|
196
|
+
for (const unit of repo[Math.floor(rand() * repo.length)])
|
|
197
|
+
units.push(unit);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return units;
|
|
201
|
+
}
|
|
202
|
+
// Inclusive on both sides, as the p-value is worded; float noise around the null breaks a tie.
|
|
203
|
+
function twoSidedP(samples, nullValue) {
|
|
204
|
+
let below = 0;
|
|
205
|
+
let above = 0;
|
|
206
|
+
for (const s of samples) {
|
|
207
|
+
if (s <= nullValue)
|
|
208
|
+
below++;
|
|
209
|
+
if (s >= nullValue)
|
|
210
|
+
above++;
|
|
211
|
+
}
|
|
212
|
+
return Math.min(1, (2 * Math.min(below, above)) / samples.length);
|
|
213
|
+
}
|
|
214
|
+
/** Resamples repositories, then families inside each; only the repository draw carries a shared-store fault.
|
|
215
|
+
* The statistic never uses the PRNG, so a second call on one seed draws the same resamples. */
|
|
216
|
+
export function twoLevelBootstrap(repos, statistic, opts = {}) {
|
|
217
|
+
const requested = opts.iterations ?? 10_000;
|
|
218
|
+
if (!Number.isInteger(requested) || requested <= 0) {
|
|
219
|
+
throw new RangeError(`iterations must be a positive integer, got ${requested}`);
|
|
220
|
+
}
|
|
221
|
+
const nullValue = opts.nullValue ?? 0;
|
|
222
|
+
const kept = repos.map((r) => r.filter((f) => f.length > 0)).filter((r) => r.length > 0);
|
|
223
|
+
if (kept.length === 0)
|
|
224
|
+
return notANumber(nullValue, 0);
|
|
225
|
+
const rand = seededRandom(opts.seed ?? 1);
|
|
226
|
+
const samples = [];
|
|
227
|
+
for (let b = 0; b < requested; b++) {
|
|
228
|
+
const value = statistic(resampleUnits(kept, rand));
|
|
229
|
+
if (Number.isFinite(value))
|
|
230
|
+
samples.push(value);
|
|
231
|
+
}
|
|
232
|
+
const dropped = requested - samples.length;
|
|
233
|
+
if (samples.length === 0)
|
|
234
|
+
return notANumber(nullValue, dropped);
|
|
235
|
+
const estimate = statistic(kept.flatMap((r) => r.flat()));
|
|
236
|
+
const p = kept.length < 2 ? Number.NaN : twoSidedP(samples, nullValue);
|
|
237
|
+
const interval = percentileInterval(samples, opts.alpha ?? 0.05);
|
|
238
|
+
return { estimate, ...interval, p, iterations: samples.length, dropped, nullValue };
|
|
239
|
+
}
|
|
240
|
+
/** Holm step-down in input order; the family size is `ps.length`, as preregistered.
|
|
241
|
+
* A NaN stays NaN but ranks as 1, so it never loosens the others; a finite p outside [0, 1] throws. */
|
|
242
|
+
export function holmAdjust(ps) {
|
|
243
|
+
for (const p of ps) {
|
|
244
|
+
if (!Number.isNaN(p) && !(p >= 0 && p <= 1))
|
|
245
|
+
throw new RangeError(`p-value out of range: ${p}`);
|
|
246
|
+
}
|
|
247
|
+
const ranked = ps
|
|
248
|
+
.map((p, index) => ({ index, key: Number.isNaN(p) ? 1 : p }))
|
|
249
|
+
.sort((a, b) => a.key - b.key || a.index - b.index);
|
|
250
|
+
const adjusted = Array.from({ length: ps.length }, () => Number.NaN);
|
|
251
|
+
let running = 0;
|
|
252
|
+
ranked.forEach(({ index, key }, rank) => {
|
|
253
|
+
running = Math.max(running, (ps.length - rank) * key);
|
|
254
|
+
if (!Number.isNaN(ps[index]))
|
|
255
|
+
adjusted[index] = Math.min(1, running);
|
|
256
|
+
});
|
|
257
|
+
return adjusted;
|
|
258
|
+
}
|
|
259
|
+
/** Checks in the preregistered order; the null comes from `e.nullValue`, so p and sides cannot disagree.
|
|
260
|
+
* A NaN estimate, p or bound is inconclusive, since a win or loss cannot be ruled out. */
|
|
261
|
+
export function verdict(e, adjustedP, spec) {
|
|
262
|
+
const inconclusive = { verdict: 'inconclusive', reachesMinimum: false };
|
|
263
|
+
if ([adjustedP, e.estimate, e.low, e.high].some(Number.isNaN))
|
|
264
|
+
return inconclusive;
|
|
265
|
+
const nullValue = e.nullValue;
|
|
266
|
+
const lowerIsHelpful = spec.helpful === 'lower';
|
|
267
|
+
if (adjustedP < (spec.alpha ?? 0.05)) {
|
|
268
|
+
const helpfulSide = lowerIsHelpful ? e.estimate < nullValue : e.estimate > nullValue;
|
|
269
|
+
const harmfulSide = lowerIsHelpful ? e.estimate > nullValue : e.estimate < nullValue;
|
|
270
|
+
if (harmfulSide)
|
|
271
|
+
return { verdict: 'loss', reachesMinimum: false };
|
|
272
|
+
if (helpfulSide) {
|
|
273
|
+
const reaches = lowerIsHelpful ? e.estimate <= spec.minimumEffectAt : e.estimate >= spec.minimumEffectAt;
|
|
274
|
+
return { verdict: 'win', reachesMinimum: reaches };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const insideBand = e.low >= spec.tieBand[0] && e.high <= spec.tieBand[1];
|
|
278
|
+
return insideBand ? { verdict: 'tie', reachesMinimum: false } : inconclusive;
|
|
279
|
+
}
|
|
280
|
+
/** A win must hold under both codings of not-applicable, while a loss under either is reported. */
|
|
281
|
+
export function combineCodings(a, b) {
|
|
282
|
+
if (a.verdict === 'loss' || b.verdict === 'loss')
|
|
283
|
+
return { verdict: 'loss', reachesMinimum: false };
|
|
284
|
+
if (a.verdict === 'win' && b.verdict === 'win') {
|
|
285
|
+
return { verdict: 'win', reachesMinimum: a.reachesMinimum && b.reachesMinimum };
|
|
286
|
+
}
|
|
287
|
+
if (a.verdict === 'tie' && b.verdict === 'tie')
|
|
288
|
+
return { verdict: 'tie', reachesMinimum: false };
|
|
289
|
+
return { verdict: 'inconclusive', reachesMinimum: false };
|
|
290
|
+
}
|
|
291
|
+
/** Cost ratio's upper bound below 1.10, resolve difference's lower bound (a fraction) above -0.05.
|
|
292
|
+
* Both estimates must use alpha 0.05, the conservative reading of "upper 95% bound"; a NaN bound fails. */
|
|
293
|
+
export function harmGate(costRatio, resolveDiff) {
|
|
294
|
+
const costOk = costRatio.high < 1.1;
|
|
295
|
+
const resolveOk = resolveDiff.low > -0.05;
|
|
296
|
+
return { pass: costOk && resolveOk, costOk, resolveOk };
|
|
297
|
+
}
|
|
187
298
|
//# sourceMappingURL=eval-stats.js.map
|
package/dist/extract.js
CHANGED
|
@@ -3,6 +3,7 @@ import { writeEntry } from './store.js';
|
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
4
|
import { RejectedValueError } from './rejection.js';
|
|
5
5
|
import { redactSecrets } from './secret-detect.js';
|
|
6
|
+
import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
|
|
6
7
|
import { neverAutoShareTags } from './shared.js';
|
|
7
8
|
function isJsonString(value) {
|
|
8
9
|
return typeof value === 'string';
|
|
@@ -24,7 +25,7 @@ export async function extractFacts(text, opts) {
|
|
|
24
25
|
const fetchFn = opts.fetcher ?? fetch;
|
|
25
26
|
let res;
|
|
26
27
|
try {
|
|
27
|
-
res = await
|
|
28
|
+
res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
|
|
28
29
|
method: 'POST',
|
|
29
30
|
headers: {
|
|
30
31
|
'content-type': 'application/json',
|
|
@@ -36,7 +37,7 @@ export async function extractFacts(text, opts) {
|
|
|
36
37
|
max_tokens: 1200,
|
|
37
38
|
messages: [{ role: 'user', content: EXTRACTION_PROMPT + redactSecrets(text) }],
|
|
38
39
|
}),
|
|
39
|
-
});
|
|
40
|
+
}, { timeoutMs: llmTimeoutMs(), fetchFn });
|
|
40
41
|
}
|
|
41
42
|
catch (err) {
|
|
42
43
|
opts.onError?.(`request failed: ${err instanceof Error ? err.message : String(err)}`);
|