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
@@ -0,0 +1,168 @@
1
+ // `hippo projects`: list the project names a store holds, fold one into another, and re-tag sleep's old user-global merges.
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { setAsideRow } from './agent-memories/apply.js';
5
+ import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS } from './agent-memories/tools.js';
6
+ import { appendAuditEvent } from './audit.js';
7
+ import { insertDormantRow, listDormantSnapshots, replaceDormantEntry } from './dormant.js';
8
+ import { calculateStrength } from './memory.js';
9
+ import { deleteEntryRowInTx, removeEntryMirrors, selectAllEntries, writeEntryMirrors } from './store.js';
10
+ const ACTOR = 'cli';
11
+ function isImport(entry) {
12
+ return entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX);
13
+ }
14
+ function toolTag(source) {
15
+ const id = source.slice(AGENT_MEMORY_SOURCE_PREFIX.length).split(':')[0];
16
+ return AGENT_MEMORY_TOOLS.find((t) => t.id === id)?.tag ?? null;
17
+ }
18
+ /** Live rows per project name, newest write first, with how many of its imported notes are copies held elsewhere. */
19
+ export function listProjects(db, tenantId) {
20
+ const live = selectAllEntries(db, tenantId).filter((e) => !e.superseded_by);
21
+ const byOrigin = new Map();
22
+ const holders = new Map();
23
+ for (const e of live) {
24
+ const origin = e.origin_project ?? null;
25
+ byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), e]);
26
+ if (isImport(e))
27
+ holders.set(e.content, (holders.get(e.content) ?? new Set()).add(origin));
28
+ }
29
+ return [...byOrigin].map(([origin, rows]) => {
30
+ const imports = rows.filter(isImport);
31
+ return {
32
+ origin,
33
+ live: rows.length,
34
+ imported: imports.length,
35
+ newest: rows.reduce((max, e) => (e.created > max ? e.created : max), ''),
36
+ copiesElsewhere: imports.filter((e) => (holders.get(e.content)?.size ?? 0) > 1).length,
37
+ };
38
+ }).sort((a, b) => b.newest.localeCompare(a.newest));
39
+ }
40
+ /** Copies the database before a repair writes, so the audit ids plus this file are the way back. */
41
+ export function backupStore(db, hippoRoot, label, now = new Date()) {
42
+ const dir = path.join(hippoRoot, 'backups');
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const file = path.join(dir, `hippo-${label}-${now.toISOString().replace(/[:.]/g, '-')}.db`);
45
+ db.prepare('VACUUM INTO ?').run(file);
46
+ return file;
47
+ }
48
+ function inTransaction(db, dryRun, body) {
49
+ db.exec(dryRun ? 'BEGIN' : 'BEGIN IMMEDIATE');
50
+ try {
51
+ const out = body();
52
+ db.exec(dryRun ? 'ROLLBACK' : 'COMMIT');
53
+ return out;
54
+ }
55
+ catch (err) {
56
+ try {
57
+ db.exec('ROLLBACK');
58
+ }
59
+ catch { /* already rolled back; keep the original error */ }
60
+ throw err;
61
+ }
62
+ }
63
+ /** Refuses user-global and unknown: they are not projects, and folding them would leak or hide every row. */
64
+ export function validateMergeNames(from, into) {
65
+ if (from.trim() === '' || into.trim() === '')
66
+ return 'both project names are required; user-global and unknown rows cannot be merged';
67
+ if (from === into)
68
+ return `${from} is already ${into}`;
69
+ return null;
70
+ }
71
+ /** Folds project `from` into `into` for one tenant in one transaction; a dry run rolls back and writes nothing, mirrors included. */
72
+ export function mergeProjects(db, hippoRoot, opts) {
73
+ const refusal = validateMergeNames(opts.from, opts.into);
74
+ if (refusal)
75
+ throw new Error(refusal);
76
+ const { tenantId, from, into, dryRun } = opts;
77
+ const backup = dryRun ? null : backupStore(db, hippoRoot, 'before-merge');
78
+ const result = inTransaction(db, dryRun, () => {
79
+ const rows = selectAllEntries(db, tenantId).filter((e) => e.origin_project === from);
80
+ const setAside = [];
81
+ for (const row of rows) {
82
+ const tag = toolTag(row.source);
83
+ if (!isImport(row) || row.superseded_by || row.kind === 'raw' || tag === null)
84
+ continue;
85
+ if (setAsideRow(db, tag, { ...row, origin_project: into }, 'project-merge').kind === 'dormant')
86
+ setAside.push(row.id);
87
+ }
88
+ const gone = new Set(setAside);
89
+ const restamped = rows.filter((e) => !gone.has(e.id)).map((e) => e.id);
90
+ db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND origin_project = ?`)
91
+ .run(into, tenantId, from);
92
+ const dormantRestamped = [];
93
+ for (const snap of listDormantSnapshots(db, tenantId)) {
94
+ if (snap.entry.origin_project !== from || gone.has(snap.entry.id))
95
+ continue;
96
+ replaceDormantEntry(db, tenantId, snap.entry.id, { ...snap.entry, origin_project: into });
97
+ dormantRestamped.push(snap.entry.id);
98
+ }
99
+ const compactions = Number(db.prepare(`UPDATE compactions SET origin_project = ? WHERE tenant_id = ? AND origin_project = ?`)
100
+ .run(into, tenantId, from).changes ?? 0);
101
+ appendAuditEvent(db, {
102
+ tenantId, actor: ACTOR, op: 'project_merge',
103
+ metadata: { from, into, backup, setAside, restamped, dormantRestamped, compactions },
104
+ });
105
+ return { from, into, setAside, restamped, dormantRestamped, compactions, backup };
106
+ });
107
+ if (!dryRun)
108
+ refreshMirrors(db, hippoRoot, tenantId, result.restamped, result.setAside);
109
+ return result;
110
+ }
111
+ /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
112
+ export function planUserGlobalRepair(db, tenantId) {
113
+ const all = selectAllEntries(db, tenantId);
114
+ const origins = new Map(listDormantSnapshots(db, tenantId).map((s) => [s.entry.id, s.entry.origin_project ?? null]));
115
+ for (const e of all)
116
+ origins.set(e.id, e.origin_project ?? null);
117
+ const toProject = [];
118
+ const setAside = [];
119
+ const untraced = [];
120
+ for (const row of all) {
121
+ if (row.source !== 'consolidation' || row.origin_project !== '' || row.superseded_by)
122
+ continue;
123
+ const parents = new Set(row.parents.filter((id) => origins.has(id)).map((id) => origins.get(id) ?? null));
124
+ const [only] = parents;
125
+ if (parents.size === 1 && only === '')
126
+ continue;
127
+ if (parents.size === 1 && only)
128
+ toProject.push({ id: row.id, origin: only });
129
+ else if (parents.size > 1 && !row.pinned && row.kind !== 'raw')
130
+ setAside.push(row.id);
131
+ else
132
+ untraced.push(row.id);
133
+ }
134
+ return { toProject, setAside, untraced };
135
+ }
136
+ /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
137
+ export function repairUserGlobalMerges(db, hippoRoot, opts) {
138
+ const { tenantId, dryRun } = opts;
139
+ if (dryRun)
140
+ return { ...planUserGlobalRepair(db, tenantId), backup: null };
141
+ const backup = backupStore(db, hippoRoot, 'before-repair');
142
+ const result = inTransaction(db, false, () => {
143
+ const plan = planUserGlobalRepair(db, tenantId);
144
+ const stamp = db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND id = ?`);
145
+ for (const { id, origin } of plan.toProject)
146
+ stamp.run(origin, tenantId, id);
147
+ const aside = new Set(plan.setAside);
148
+ const now = new Date();
149
+ for (const row of selectAllEntries(db, tenantId).filter((e) => aside.has(e.id))) {
150
+ insertDormantRow(db, { entry: row, strength: calculateStrength(row, now), reason: 'project-repair', dormantAt: now.toISOString() });
151
+ deleteEntryRowInTx(db, row, ACTOR);
152
+ }
153
+ appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_repair', metadata: { backup, ...plan } });
154
+ return { ...plan, backup };
155
+ });
156
+ refreshMirrors(db, hippoRoot, tenantId, result.toProject.map((r) => r.id), result.setAside);
157
+ return result;
158
+ }
159
+ /** After commit, as the agent memory sync does: a stale mirror would bring the old tag back on the next rebuild. */
160
+ function refreshMirrors(db, hippoRoot, tenantId, rewrite, purge) {
161
+ const ids = new Set(rewrite);
162
+ for (const entry of selectAllEntries(db, tenantId))
163
+ if (ids.has(entry.id))
164
+ writeEntryMirrors(hippoRoot, entry);
165
+ for (const id of purge)
166
+ removeEntryMirrors(hippoRoot, id);
167
+ }
168
+ //# sourceMappingURL=project-merge.js.map
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { log } from './log.js';
3
4
  const LAYERS = ['episodic', 'buffer', 'semantic'];
4
5
  const MAX_WARN_LOGS = 5;
5
6
  /**
@@ -42,7 +43,7 @@ export function cleanupArchivedMirrors(hippoRoot, db) {
42
43
  catch (err) {
43
44
  allOk = false;
44
45
  if (warnCount < MAX_WARN_LOGS) {
45
- console.warn(`cleanupArchivedMirrors: unlink failed for ${filePath} (will retry on next DB open):`, err);
46
+ log.warn(`cleanupArchivedMirrors: unlink failed for ${filePath} (will retry on next DB open): ${err instanceof Error ? err.message : String(err)}`);
46
47
  warnCount += 1;
47
48
  }
48
49
  }
@@ -113,7 +113,7 @@ export declare function commonDerivationScope(scopes: readonly (string | null |
113
113
  } | {
114
114
  ok: false;
115
115
  };
116
- /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
117
- * so a derived row never blends two restricted scopes or a mixed pair. */
118
- export declare function derivationPartitionKey(tenantId: string, scope: string | null | undefined): string;
116
+ /** Map-partition key for consolidate/dag/dedup producers: tenant + derivation scope + origin project,
117
+ * so a derived row never blends two restricted scopes, a mixed pair, or two projects. */
118
+ export declare function derivationPartitionKey(tenantId: string, scope: string | null | undefined, origin: string | null | undefined): string;
119
119
  //# sourceMappingURL=recall-scope.d.ts.map
@@ -164,9 +164,10 @@ export function commonDerivationScope(scopes) {
164
164
  }
165
165
  return { ok: true, scope: common };
166
166
  }
167
- /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
168
- * so a derived row never blends two restricted scopes or a mixed pair. */
169
- export function derivationPartitionKey(tenantId, scope) {
170
- return `${tenantId}\u0000${derivationScope(scope) ?? ''}`;
167
+ /** Map-partition key for consolidate/dag/dedup producers: tenant + derivation scope + origin project,
168
+ * so a derived row never blends two restricted scopes, a mixed pair, or two projects. */
169
+ export function derivationPartitionKey(tenantId, scope, origin) {
170
+ const project = origin === undefined ? '\u0002' : origin ?? '\u0001'; // unstamped, unknown and named never share a bucket
171
+ return `${tenantId}\u0000${derivationScope(scope) ?? ''}\u0000${project}`;
171
172
  }
172
173
  //# sourceMappingURL=recall-scope.js.map
@@ -102,8 +102,8 @@ export interface RecordTraceOutcomeInput {
102
102
  * this function from caller-side state (`last_trace_id` / applied outcome
103
103
  * ids) that can go stale relative to the trace it names — a forgotten
104
104
  * memory, a tenant switch mid-session, or a race between two callers. Two
105
- * checks run before the insert, both skip silently (console.error one
106
- * line) rather than throw:
105
+ * checks run before the insert, both skip with one log.warn line
106
+ * rather than throw:
107
107
  * 1. The named trace must exist and belong to `input.tenantId` — a
108
108
  * tenant mismatch or a dangling id (deleted trace) skips.
109
109
  * 2. `input.memoryIds` is intersected against the trace's OWN
@@ -18,6 +18,7 @@
18
18
  import { createHash } from 'node:crypto';
19
19
  import { openHippoDb, closeHippoDb } from './db.js';
20
20
  import { DELIVERY_LEDGER_VERSION } from './delivery-recorder.js';
21
+ import { log } from './log.js';
21
22
  /**
22
23
  * Strip a RerankStep down to {stage, multiplier, scoreBefore, scoreAfter}
23
24
  * before persisting (F3 privacy fix, codex cross-model finding). `note` is
@@ -76,8 +77,7 @@ export function writeRecallTrace(db, input) {
76
77
  }
77
78
  }
78
79
  catch (error) {
79
- // eslint-disable-next-line no-console
80
- console.error(`[hippo] recall trace write failed: ${error instanceof Error ? error.message : String(error)}`);
80
+ log.error(`recall trace write failed: ${error instanceof Error ? error.message : String(error)}`);
81
81
  return null;
82
82
  }
83
83
  }
@@ -113,8 +113,7 @@ export function writeRecallTraceAtRoot(root, input) {
113
113
  db = openHippoDb(root);
114
114
  }
115
115
  catch (error) {
116
- // eslint-disable-next-line no-console
117
- console.error(`[hippo] recall trace connection failed: ${error instanceof Error ? error.message : String(error)}`);
116
+ log.error(`recall trace connection failed: ${error instanceof Error ? error.message : String(error)}`);
118
117
  return null;
119
118
  }
120
119
  try {
@@ -140,8 +139,8 @@ export function writeRecallTraceAtRoot(root, input) {
140
139
  * this function from caller-side state (`last_trace_id` / applied outcome
141
140
  * ids) that can go stale relative to the trace it names — a forgotten
142
141
  * memory, a tenant switch mid-session, or a race between two callers. Two
143
- * checks run before the insert, both skip silently (console.error one
144
- * line) rather than throw:
142
+ * checks run before the insert, both skip with one log.warn line
143
+ * rather than throw:
145
144
  * 1. The named trace must exist and belong to `input.tenantId` — a
146
145
  * tenant mismatch or a dangling id (deleted trace) skips.
147
146
  * 2. `input.memoryIds` is intersected against the trace's OWN
@@ -158,8 +157,7 @@ export function recordTraceOutcome(db, input) {
158
157
  // SELECT above; sqlite returns undefined when no row matches.
159
158
  const trace = db.prepare(`SELECT tenant_id FROM recall_traces WHERE id = ?`).get(input.traceId);
160
159
  if (!trace || trace.tenant_id !== input.tenantId) {
161
- // eslint-disable-next-line no-console
162
- console.error(`[hippo] recall trace outcome skipped: trace ${input.traceId} missing or tenant mismatch`);
160
+ log.warn(`recall trace outcome skipped: trace ${input.traceId} missing or tenant mismatch`);
163
161
  return;
164
162
  }
165
163
  // SAFETY: row shape matches the single `memory_id` column named in the
@@ -170,8 +168,7 @@ export function recordTraceOutcome(db, input) {
170
168
  const members = new Set(memberRows.map((r) => r.memory_id));
171
169
  const credited = input.memoryIds.filter((id) => members.has(id));
172
170
  if (credited.length === 0) {
173
- // eslint-disable-next-line no-console
174
- console.error(`[hippo] recall trace outcome skipped: no credited ids intersect trace ${input.traceId}'s results`);
171
+ log.warn(`recall trace outcome skipped: no credited ids intersect trace ${input.traceId}'s results`);
175
172
  return;
176
173
  }
177
174
  db.prepare(`
@@ -180,8 +177,7 @@ export function recordTraceOutcome(db, input) {
180
177
  `).run(input.traceId, new Date().toISOString(), input.tenantId, input.outcome, JSON.stringify(credited));
181
178
  }
182
179
  catch (error) {
183
- // eslint-disable-next-line no-console
184
- console.error(`[hippo] recall trace outcome write failed: ${error instanceof Error ? error.message : String(error)}`);
180
+ log.error(`recall trace outcome write failed: ${error instanceof Error ? error.message : String(error)}`);
185
181
  }
186
182
  }
187
183
  /** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
@@ -270,7 +266,7 @@ export function writeDeliveryEvent(db, input) {
270
266
  }
271
267
  }
272
268
  catch (error) {
273
- // eslint-disable-next-line no-console
269
+ // The prompt hook's stderr shows this exact `[hippo] delivery ledger` line, so it stays off the logger's format.
274
270
  console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
275
271
  return null;
276
272
  }
@@ -282,7 +278,7 @@ export function writeDeliveryEventAtRoot(root, input) {
282
278
  db = openHippoDb(root, { busyWaitMs: DELIVERY_LEDGER_WAIT_MS });
283
279
  }
284
280
  catch (error) {
285
- // eslint-disable-next-line no-console
281
+ // Same hook stderr line as writeDeliveryEvent above.
286
282
  console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
287
283
  return null;
288
284
  }
@@ -14,8 +14,9 @@
14
14
  */
15
15
  import { Layer } from './memory.js';
16
16
  import { loadAllEntries, readEntry, writeEntry } from './store.js';
17
- import { redactSecrets } from './secret-detect.js';
17
+ import { redactSecretsStrict } from './secret-detect.js';
18
18
  import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
19
+ import { log } from './log.js';
19
20
  const REFINED_TAG = 'llm-refined';
20
21
  const CONSOLIDATED_MARKERS = [
21
22
  '[Consolidated from',
@@ -31,7 +32,7 @@ export async function refineSemanticMemory(merged, sources, opts) {
31
32
  const fetchFn = opts.fetcher ?? fetch;
32
33
  const sourceBlock = sources
33
34
  .slice(0, 8)
34
- .map((s, i) => `[source ${i + 1}] ${redactSecrets(s.content).slice(0, 400)}`)
35
+ .map((s, i) => `[source ${i + 1}] ${redactSecretsStrict(s.content).slice(0, 400)}`)
35
36
  .join('\n\n');
36
37
  const prompt = `You are refining a semantic memory in an agent's memory store. The rule-based consolidator merged several related episodic memories into one, but the output is clumsy. Produce a single coherent semantic memory that captures the underlying principle.
37
38
 
@@ -43,7 +44,7 @@ Rules:
43
44
  - Do NOT include the "[Consolidated from N ...]" marker.
44
45
 
45
46
  Current merged content:
46
- ${redactSecrets(merged)}
47
+ ${redactSecretsStrict(merged)}
47
48
 
48
49
  Source memories (up to 8 shown):
49
50
  ${sourceBlock}`;
@@ -63,24 +64,31 @@ ${sourceBlock}`;
63
64
  }),
64
65
  }, { timeoutMs: llmTimeoutMs(), fetchFn });
65
66
  }
66
- catch {
67
+ catch (err) {
68
+ log.warn(`refine: request failed: ${err instanceof Error ? err.message : String(err)}`);
67
69
  return null;
68
70
  }
69
- if (!res.ok)
71
+ if (!res.ok) {
72
+ log.warn(`refine: API answered HTTP ${res.status}`);
70
73
  return null;
74
+ }
75
+ let text;
71
76
  try {
72
77
  // SAFETY: data is the Anthropic Messages API response body; the
73
78
  // documented response shape is `{ content: [{ type, text, ... }] }`
74
79
  // for a text-generating request like this one.
75
80
  const data = await res.json();
76
- const text = data.content?.[0]?.text?.trim() ?? '';
77
- if (text.length < 10)
78
- return null;
79
- return text;
81
+ text = data.content?.[0]?.text?.trim() ?? '';
82
+ }
83
+ catch (err) {
84
+ log.warn(`refine: unreadable response: ${err instanceof Error ? err.message : String(err)}`);
85
+ return null;
80
86
  }
81
- catch {
87
+ if (text.length < 10) {
88
+ log.warn('refine: response was empty or too short to use');
82
89
  return null;
83
90
  }
91
+ return text;
84
92
  }
85
93
  function isConsolidated(entry) {
86
94
  if (entry.layer !== Layer.Semantic)
@@ -0,0 +1,29 @@
1
+ import type { RerankerFn, RerankProvenance } from './types.js';
2
+ import { type JsonValue } from '../http-util.js';
3
+ /** The two pretrained CLEF decision models served by Cloudflare Workers AI. */
4
+ export type ClefModel = 'clef-flash' | 'clef';
5
+ /** True when `name` is one of the CLEF reranker names. */
6
+ export declare function isClefModel(name: string): name is ClefModel;
7
+ interface ClefRoute {
8
+ url: string;
9
+ token: string | undefined;
10
+ backend: Exclude<RerankProvenance['backend'], 'native'>;
11
+ }
12
+ interface ClefScores {
13
+ scores: number[];
14
+ actualModel?: string;
15
+ inputTokens?: number;
16
+ outputTokens?: number;
17
+ }
18
+ /** Transport from trusted local env, never call arguments: HIPPO_CLEF_ENDPOINT wins over hosted Workers AI. */
19
+ export declare function resolveClefRoute(model: ClefModel): ClefRoute;
20
+ /** Unwraps a Workers AI `{ result }` envelope or a bare System One reply and checks model and `c1..cN`; a string is the rejection reason. */
21
+ export declare function parseClefReply(body: JsonValue, n: number, model: ClefModel, requireModel: boolean): ClefScores | string;
22
+ /** A CLEF reranker for one model: Jev's request shape and pool; any failure keeps the native order (never paid Jev), warning once. */
23
+ export declare function createClefReranker(model: ClefModel): RerankerFn;
24
+ /** Opt-in CLEF-flash reranker (Cloudflare Workers AI or HIPPO_CLEF_ENDPOINT); off unless named, so defaults stay native. */
25
+ export declare const clefFlashReranker: RerankerFn;
26
+ /** Opt-in CLEF reranker, the larger model. Same transport and fallback as clef-flash. */
27
+ export declare const clefReranker: RerankerFn;
28
+ export {};
29
+ //# sourceMappingURL=clef.d.ts.map
@@ -0,0 +1,223 @@
1
+ import { buildRelevanceRequest, JEV_DEFAULT_TOP_K, rankByScores } from './jev.js';
2
+ import { isJsonObjectRecord } from '../http-util.js';
3
+ import { log } from '../log.js';
4
+ const CLEF_MODELS = ['clef-flash', 'clef'];
5
+ const DEFAULT_TIMEOUT_MS = 15_000;
6
+ const MAX_TIMEOUT_MS = 120_000;
7
+ // 64 answers fit in a few KB; 1 MiB leaves room for a verbose envelope.
8
+ const MAX_REPLY_BYTES = 1024 * 1024;
9
+ // Workers AI rejects a request with more than 64 questions, one per candidate here.
10
+ const MAX_CANDIDATES = 64;
11
+ const ACCOUNT_ID = /^[0-9a-f]{32}$/i;
12
+ /** True when `name` is one of the CLEF reranker names. */
13
+ export function isClefModel(name) {
14
+ return CLEF_MODELS.some((m) => m === name);
15
+ }
16
+ // Plain http would put the token and the memory text on the wire, so only the local machine may use it.
17
+ function isLoopback(hostname) {
18
+ return hostname === 'localhost' || hostname === '[::1]' || /^127(\.\d{1,3}){3}$/.test(hostname);
19
+ }
20
+ function isNumber(v) {
21
+ return Number.isFinite(v);
22
+ }
23
+ function isString(v) {
24
+ return v !== undefined && v !== null && v.constructor === String;
25
+ }
26
+ function isRejection(v) {
27
+ return v.constructor === String;
28
+ }
29
+ // fetch quotes a rejected header value in its error, so a token it would reject must never reach it.
30
+ function checkHeaderSafe(name, token) {
31
+ if (!/^[\x21-\x7e]+$/.test(token))
32
+ throw new Error(`${name} has characters a header cannot carry`);
33
+ }
34
+ /** Transport from trusted local env, never call arguments: HIPPO_CLEF_ENDPOINT wins over hosted Workers AI. */
35
+ export function resolveClefRoute(model) {
36
+ const endpoint = process.env.HIPPO_CLEF_ENDPOINT?.trim();
37
+ if (endpoint) {
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(endpoint);
41
+ }
42
+ catch {
43
+ throw new Error('HIPPO_CLEF_ENDPOINT is not a valid URL');
44
+ }
45
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
46
+ throw new Error('HIPPO_CLEF_ENDPOINT must be an http or https URL');
47
+ }
48
+ // fetch echoes a URL with credentials in its error text, which reaches stderr.
49
+ if (parsed.username || parsed.password) {
50
+ throw new Error('HIPPO_CLEF_ENDPOINT must not embed credentials; set HIPPO_CLEF_ENDPOINT_TOKEN');
51
+ }
52
+ if (parsed.protocol === 'http:' && !isLoopback(parsed.hostname)) {
53
+ throw new Error('HIPPO_CLEF_ENDPOINT must use https unless it is on this machine');
54
+ }
55
+ const endpointToken = process.env.HIPPO_CLEF_ENDPOINT_TOKEN?.trim() || undefined;
56
+ if (endpointToken)
57
+ checkHeaderSafe('HIPPO_CLEF_ENDPOINT_TOKEN', endpointToken);
58
+ return { url: parsed.href, token: endpointToken, backend: 'private-endpoint' };
59
+ }
60
+ const account = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() ?? '';
61
+ const token = process.env.CLOUDFLARE_API_TOKEN?.trim() ?? '';
62
+ if (!account || !token)
63
+ throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN not set');
64
+ if (!ACCOUNT_ID.test(account))
65
+ throw new Error('CLOUDFLARE_ACCOUNT_ID is not a 32-character hex id');
66
+ checkHeaderSafe('CLOUDFLARE_API_TOKEN', token);
67
+ return {
68
+ url: `https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/@cf/cloudflare/${model}`,
69
+ token,
70
+ backend: 'cloudflare',
71
+ };
72
+ }
73
+ /** Unwraps a Workers AI `{ result }` envelope or a bare System One reply and checks model and `c1..cN`; a string is the rejection reason. */
74
+ export function parseClefReply(body, n, model, requireModel) {
75
+ if (!isJsonObjectRecord(body))
76
+ return 'reply is not a JSON object';
77
+ if (body.success === false)
78
+ return 'provider reported failure';
79
+ const reply = body.result === undefined ? body : body.result;
80
+ if (!isJsonObjectRecord(reply))
81
+ return 'reply has no result object';
82
+ const actual = reply.model;
83
+ if (actual !== undefined && (!isString(actual) || actual.trim() !== model))
84
+ return 'reply names a different model';
85
+ if (actual === undefined && requireModel)
86
+ return 'reply does not name its model';
87
+ const answers = reply.answers;
88
+ if (!isJsonObjectRecord(answers))
89
+ return 'reply has no answers';
90
+ if (Object.keys(answers).length !== n)
91
+ return 'incomplete or out-of-range answers';
92
+ const scores = [];
93
+ for (let i = 1; i <= n; i++) {
94
+ const a = answers[`c${i}`];
95
+ if (!isJsonObjectRecord(a) || (a.type !== undefined && a.type !== 'noul'))
96
+ return 'incomplete or out-of-range answers';
97
+ const v = a.noul;
98
+ if (!isNumber(v) || v < 0 || v > 1)
99
+ return 'incomplete or out-of-range answers';
100
+ scores.push(v);
101
+ }
102
+ const usage = isJsonObjectRecord(reply.usage) ? reply.usage : {};
103
+ return {
104
+ scores,
105
+ actualModel: isString(actual) ? actual.trim() : undefined,
106
+ inputTokens: isNumber(usage.input_tokens) ? usage.input_tokens : undefined,
107
+ outputTokens: isNumber(usage.output_tokens) ? usage.output_tokens : undefined,
108
+ };
109
+ }
110
+ // The timeout bounds time, not bytes: a hostile endpoint could stream a huge 2xx body into memory.
111
+ async function readCappedJson(resp) {
112
+ if (!resp.body)
113
+ throw new Error('reply has no body');
114
+ const reader = resp.body.getReader();
115
+ const decoder = new TextDecoder();
116
+ let raw = '';
117
+ let received = 0;
118
+ for (;;) {
119
+ const { done, value } = await reader.read();
120
+ if (done)
121
+ break;
122
+ received += value.byteLength;
123
+ if (received > MAX_REPLY_BYTES) {
124
+ await reader.cancel();
125
+ throw new Error(`reply over ${MAX_REPLY_BYTES} bytes`);
126
+ }
127
+ raw += decoder.decode(value, { stream: true });
128
+ }
129
+ raw += decoder.decode();
130
+ try {
131
+ return JSON.parse(raw);
132
+ }
133
+ catch {
134
+ throw new Error('reply is not JSON');
135
+ }
136
+ }
137
+ async function requestScores(model, query, head, route) {
138
+ const { state, questions } = buildRelevanceRequest(query, head);
139
+ // Strict parse: parseInt would read "15s" as 15 ms, and Node clamps a delay past 2^31-1 to 1 ms.
140
+ const requested = Number(process.env.HIPPO_CLEF_TIMEOUT_MS);
141
+ const timeoutMs = Number.isInteger(requested) && requested > 0 && requested <= MAX_TIMEOUT_MS ? requested : DEFAULT_TIMEOUT_MS;
142
+ const headers = new Headers({ 'content-type': 'application/json' });
143
+ if (route.token)
144
+ headers.set('authorization', `Bearer ${route.token}`);
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
147
+ try {
148
+ const resp = await fetch(route.url, {
149
+ method: 'POST',
150
+ headers,
151
+ body: JSON.stringify({ state, model, questions }),
152
+ signal: controller.signal,
153
+ });
154
+ if (!resp.ok) {
155
+ // A third-party header ends up on stderr, so keep printable ASCII only.
156
+ const ray = resp.headers.get('cf-ray')?.replace(/[^\x20-\x7e]/g, '').slice(0, 64);
157
+ await resp.body?.cancel();
158
+ throw new Error(`HTTP ${resp.status}${ray ? `, ray ${ray}` : ''}`);
159
+ }
160
+ const body = await readCappedJson(resp);
161
+ const parsed = parseClefReply(body, head.length, model, route.backend === 'cloudflare');
162
+ if (isRejection(parsed))
163
+ throw new Error(parsed);
164
+ return parsed;
165
+ }
166
+ catch (err) {
167
+ if (err instanceof Error && err.name === 'AbortError')
168
+ throw new Error(`no answer within ${timeoutMs} ms`);
169
+ throw err;
170
+ }
171
+ finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
175
+ /** The input order, unchanged, with the reason recorded. Never a partial reorder. */
176
+ function nativeOrder(head, provenance) {
177
+ return head.map((r, i) => ({
178
+ ...r,
179
+ rerankScore: r.score,
180
+ preRerankRank: r.preRerankRank ?? i + 1,
181
+ postRerankRank: i + 1,
182
+ // A copy per row, so a caller editing one result cannot change another's provenance.
183
+ rerankProvenance: { ...provenance },
184
+ }));
185
+ }
186
+ /** A CLEF reranker for one model: Jev's request shape and pool; any failure keeps the native order (never paid Jev), warning once. */
187
+ export function createClefReranker(model) {
188
+ let warned = false;
189
+ return async (query, results, options) => {
190
+ const head = results.slice(0, options?.topK ?? JEV_DEFAULT_TOP_K);
191
+ if (head.length === 0)
192
+ return [];
193
+ let route;
194
+ let got;
195
+ try {
196
+ if (head.length > MAX_CANDIDATES)
197
+ throw new Error(`more than ${MAX_CANDIDATES} candidates`);
198
+ route = resolveClefRoute(model);
199
+ got = await requestScores(model, query, head, route);
200
+ }
201
+ catch (err) {
202
+ const reason = err instanceof Error ? err.message : 'unknown error';
203
+ if (!warned) {
204
+ warned = true;
205
+ log.warn(`${model} reranker unavailable (${reason}); keeping the native order. Subsequent calls will not repeat this warning.`);
206
+ }
207
+ return nativeOrder(head, { backend: 'native', requestedModel: model, fallbackReason: reason });
208
+ }
209
+ const rerankProvenance = {
210
+ backend: route.backend,
211
+ requestedModel: model,
212
+ actualModel: got.actualModel,
213
+ inputTokens: got.inputTokens,
214
+ outputTokens: got.outputTokens,
215
+ };
216
+ return rankByScores(head, got.scores).map((r) => ({ ...r, rerankProvenance: { ...rerankProvenance } }));
217
+ };
218
+ }
219
+ /** Opt-in CLEF-flash reranker (Cloudflare Workers AI or HIPPO_CLEF_ENDPOINT); off unless named, so defaults stay native. */
220
+ export const clefFlashReranker = createClefReranker('clef-flash');
221
+ /** Opt-in CLEF reranker, the larger model. Same transport and fallback as clef-flash. */
222
+ export const clefReranker = createClefReranker('clef');
223
+ //# sourceMappingURL=clef.js.map
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { pathToFileURL } from 'node:url';
3
+ import { log } from '../log.js';
3
4
  const MODEL_NAME = 'Xenova/ms-marco-MiniLM-L-6-v2';
4
5
  const _require = createRequire(import.meta.url);
5
6
  const TRANSFORMERS_PACKAGES = ['@huggingface/transformers', '@xenova/transformers'];
@@ -36,7 +37,8 @@ async function loadTransformersModule() {
36
37
  const seq = mod.AutoModelForSequenceClassification ?? mod.default?.AutoModelForSequenceClassification;
37
38
  return tok && seq ? { AutoTokenizer: tok, AutoModelForSequenceClassification: seq } : null;
38
39
  }
39
- catch {
40
+ catch (err) {
41
+ log.debug(`cross-encoder: transformers import failed: ${err instanceof Error ? err.message : String(err)}`);
40
42
  return null;
41
43
  }
42
44
  }
@@ -78,7 +80,8 @@ async function buildPipeline() {
78
80
  return score;
79
81
  };
80
82
  }
81
- catch {
83
+ catch (err) {
84
+ log.debug(`cross-encoder: model load failed: ${err instanceof Error ? err.message : String(err)}`);
82
85
  return null;
83
86
  }
84
87
  }
@@ -102,8 +105,7 @@ export const crossEncoderReranker = async (query, results, options) => {
102
105
  // working reranker.
103
106
  if (!warnedOnFallback) {
104
107
  warnedOnFallback = true;
105
- // eslint-disable-next-line no-console
106
- console.warn('[hippo] cross-encoder reranker unavailable (no Transformers.js backend, or model fetch blocked); falling back to identity ordering. Subsequent calls will not repeat this warning.');
108
+ log.warn('cross-encoder reranker unavailable (no Transformers.js backend, or model fetch blocked); falling back to identity ordering. Subsequent calls will not repeat this warning.');
107
109
  }
108
110
  return head.map((r, i) => ({
109
111
  ...r,