hippo-memory 1.57.0 → 1.59.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 (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -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
+ import { BadRequestError, NotFoundError } from './api-errors.js';
1
2
  import { isFtsAvailable } from './db.js';
2
- import { appendAuditEvent } from './audit.js';
3
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
3
4
  import { markSummaryDirtyInTx } from './store.js';
4
5
  export function archiveRawMemory(db, id, opts) {
5
6
  // SAFETY: SELECT * FROM memories returns every column of the memories table; only
@@ -7,9 +8,9 @@ export function archiveRawMemory(db, id, opts) {
7
8
  // null) by the memories schema.
8
9
  const row = db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
9
10
  if (!row)
10
- throw new Error(`memory not found: ${id}`);
11
+ throw new NotFoundError(`memory not found: ${id}`);
11
12
  if (row.kind !== 'raw') {
12
- throw new Error(`memory ${id} is not raw (kind=${String(row.kind)})`);
13
+ throw new BadRequestError(`memory ${id} is not raw (kind=${String(row.kind)})`);
13
14
  }
14
15
  // SAVEPOINT (not BEGIN) so this works whether or not we're already inside a
15
16
  // transaction. SQLite refuses BEGIN within a transaction; SAVEPOINT nests safely.
@@ -58,9 +59,9 @@ export function archiveRawMemory(db, id, opts) {
58
59
  metadata: { reason: opts.reason },
59
60
  });
60
61
  }
61
- catch {
62
- // Audit must not crash the archive. Failures here mean the audit table
63
- // is unwritable; the archive itself has already succeeded.
62
+ catch (error) {
63
+ // The archive itself has already succeeded; an unwritable audit table must not undo it.
64
+ reportAuditWriteFailure('archive_raw', String(error), id);
64
65
  }
65
66
  // v0.30 / E2 — DAG live-coupling: archive of a child under a level-2
66
67
  // summary marks parent dirty. Inside the SAVEPOINT so the dirty-mark
@@ -6,6 +6,7 @@
6
6
  * these for its own call sites AND re-exports them for back-compat
7
7
  * (`api.isPrivateScope`, test imports of `passesScopeFilterForRecall`).
8
8
  */
9
+ import { ForbiddenError } from './api-errors.js';
9
10
  /**
10
11
  * Literal scopes excluded from recall by default-deny when the
11
12
  * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
@@ -83,7 +84,7 @@ export declare function passesCliRecallScopeFilter(scope: string | null, request
83
84
  * Thrown when a caller requests a scope its role may not read. The HTTP layer
84
85
  * maps it to 403.
85
86
  */
86
- export declare class ScopeForbiddenError extends Error {
87
+ export declare class ScopeForbiddenError extends ForbiddenError {
87
88
  readonly scope: string;
88
89
  constructor(scope: string);
89
90
  }
@@ -112,7 +113,7 @@ export declare function commonDerivationScope(scopes: readonly (string | null |
112
113
  } | {
113
114
  ok: false;
114
115
  };
115
- /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
116
- * so a derived row never blends two restricted scopes or a mixed pair. */
117
- 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;
118
119
  //# sourceMappingURL=recall-scope.d.ts.map
@@ -6,6 +6,7 @@
6
6
  * these for its own call sites AND re-exports them for back-compat
7
7
  * (`api.isPrivateScope`, test imports of `passesScopeFilterForRecall`).
8
8
  */
9
+ import { ForbiddenError } from './api-errors.js';
9
10
  /**
10
11
  * Literal scopes excluded from recall by default-deny when the
11
12
  * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
@@ -105,7 +106,7 @@ export function passesCliRecallScopeFilter(scope, requested) {
105
106
  * Thrown when a caller requests a scope its role may not read. The HTTP layer
106
107
  * maps it to 403.
107
108
  */
108
- export class ScopeForbiddenError extends Error {
109
+ export class ScopeForbiddenError extends ForbiddenError {
109
110
  scope;
110
111
  constructor(scope) {
111
112
  super(`scope ${scope} requires admin role`);
@@ -163,9 +164,10 @@ export function commonDerivationScope(scopes) {
163
164
  }
164
165
  return { ok: true, scope: common };
165
166
  }
166
- /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
167
- * so a derived row never blends two restricted scopes or a mixed pair. */
168
- export function derivationPartitionKey(tenantId, scope) {
169
- 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}`;
170
172
  }
171
173
  //# sourceMappingURL=recall-scope.js.map
@@ -15,6 +15,7 @@
15
15
  import { Layer } from './memory.js';
16
16
  import { loadAllEntries, readEntry, writeEntry } from './store.js';
17
17
  import { redactSecrets } from './secret-detect.js';
18
+ import { fetchWithRetry, llmTimeoutMs } from './http-retry.js';
18
19
  const REFINED_TAG = 'llm-refined';
19
20
  const CONSOLIDATED_MARKERS = [
20
21
  '[Consolidated from',
@@ -48,7 +49,7 @@ Source memories (up to 8 shown):
48
49
  ${sourceBlock}`;
49
50
  let res;
50
51
  try {
51
- res = await fetchFn('https://api.anthropic.com/v1/messages', {
52
+ res = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
52
53
  method: 'POST',
53
54
  headers: {
54
55
  'content-type': 'application/json',
@@ -60,7 +61,7 @@ ${sourceBlock}`;
60
61
  max_tokens: 800,
61
62
  messages: [{ role: 'user', content: prompt }],
62
63
  }),
63
- });
64
+ }, { timeoutMs: llmTimeoutMs(), fetchFn });
64
65
  }
65
66
  catch {
66
67
  return null;
@@ -12,7 +12,7 @@
12
12
  * cli.ts and api.ts, so it introduces no cycle.
13
13
  */
14
14
  import { closeHippoDb } from './db.js';
15
- import { appendAuditEvent } from './audit.js';
15
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
17
  import { deleteDormantRow, listDormantSnapshots, purgeDormantByDigest, replaceDormantEntry } from './dormant.js';
18
18
  import { openStore, deleteEntryCore, purgeMirrorBestEffort, selectAllEntries, stampOriginProject, writeEntryDbOnly, writeEntryMirrors, } from './store.js';
@@ -144,12 +144,9 @@ export function rejectValue(opts) {
144
144
  metadata: { digest, removedIds, count: removedIds.length },
145
145
  });
146
146
  }
147
- catch {
148
- // Best-effort — mirrors store.ts's private audit() semantics. This
149
- // runs INSIDE the still-open transaction (COMMIT is the next
150
- // statement): a swallowed audit failure lets the tombstone +
151
- // removals commit without the trail row, rather than rolling the
152
- // whole reject back over bookkeeping.
147
+ catch (error) {
148
+ // Inside the open transaction: the reject commits without its trail row rather than rolling back over bookkeeping.
149
+ reportAuditWriteFailure('reject_value', String(error), opts.memoryId);
153
150
  }
154
151
  db.exec('COMMIT');
155
152
  }
@@ -217,8 +214,8 @@ export function unrejectValue(hippoRoot, tenantId, digestOrPrefix, actor) {
217
214
  metadata: { digest: target.digest, reason: target.reason },
218
215
  });
219
216
  }
220
- catch {
221
- // Best-effort, same as reject's audit call above.
217
+ catch (error) {
218
+ reportAuditWriteFailure('unreject_value', String(error), target.sourceMemoryId);
222
219
  }
223
220
  return { status: 'ok', digest: target.digest, reason: target.reason };
224
221
  }
@@ -12,6 +12,7 @@
12
12
  * be a cycle.
13
13
  */
14
14
  import type { DatabaseSyncLike } from './db.js';
15
+ import { BadRequestError } from './api-errors.js';
15
16
  /**
16
17
  * Normalize content for rejection-digest comparisons: Unicode NFC →
17
18
  * lowercase → collapse whitespace runs to a single space → trim. No
@@ -33,7 +34,7 @@ export declare function rejectionDigest(content: string): string;
33
34
  * blocks (writeEntry, api.supersede) to write a post-rollback
34
35
  * `reject_refusal` audit row via `auditRejectionRefusal` (plan §3).
35
36
  */
36
- export declare class RejectedValueError extends Error {
37
+ export declare class RejectedValueError extends BadRequestError {
37
38
  readonly digest: string;
38
39
  readonly tenantId: string;
39
40
  readonly entryId: string;
package/dist/rejection.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * be a cycle.
13
13
  */
14
14
  import { createHash } from 'node:crypto';
15
+ import { BadRequestError } from './api-errors.js';
15
16
  /**
16
17
  * Normalize content for rejection-digest comparisons: Unicode NFC →
17
18
  * lowercase → collapse whitespace runs to a single space → trim. No
@@ -37,7 +38,7 @@ export function rejectionDigest(content) {
37
38
  * blocks (writeEntry, api.supersede) to write a post-rollback
38
39
  * `reject_refusal` audit row via `auditRejectionRefusal` (plan §3).
39
40
  */
40
- export class RejectedValueError extends Error {
41
+ export class RejectedValueError extends BadRequestError {
41
42
  digest;
42
43
  tenantId;
43
44
  entryId;
@@ -0,0 +1,29 @@
1
+ import type { RerankerFn } 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: 'cloudflare' | 'private-endpoint';
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,182 @@
1
+ import { buildRelevanceRequest, JEV_DEFAULT_TOP_K } from './jev.js';
2
+ import { isJsonObjectRecord } from '../http-util.js';
3
+ const CLEF_MODELS = ['clef-flash', 'clef'];
4
+ const DEFAULT_TIMEOUT_MS = 15_000;
5
+ // Workers AI rejects a request with more than 64 questions, one per candidate here.
6
+ const MAX_CANDIDATES = 64;
7
+ const ACCOUNT_ID = /^[0-9a-f]{32}$/i;
8
+ /** True when `name` is one of the CLEF reranker names. */
9
+ export function isClefModel(name) {
10
+ return CLEF_MODELS.some((m) => m === name);
11
+ }
12
+ function isNumber(v) {
13
+ return Number.isFinite(v);
14
+ }
15
+ function isString(v) {
16
+ return v !== undefined && v !== null && v.constructor === String;
17
+ }
18
+ function isRejection(v) {
19
+ return typeof v === 'string';
20
+ }
21
+ /** Transport from trusted local env, never call arguments: HIPPO_CLEF_ENDPOINT wins over hosted Workers AI. */
22
+ export function resolveClefRoute(model) {
23
+ const endpoint = process.env.HIPPO_CLEF_ENDPOINT?.trim();
24
+ if (endpoint) {
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(endpoint);
28
+ }
29
+ catch {
30
+ throw new Error('HIPPO_CLEF_ENDPOINT is not a valid URL');
31
+ }
32
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
33
+ throw new Error('HIPPO_CLEF_ENDPOINT must be an http or https URL');
34
+ }
35
+ return { url: parsed.href, token: process.env.HIPPO_CLEF_ENDPOINT_TOKEN?.trim() || undefined, backend: 'private-endpoint' };
36
+ }
37
+ const account = process.env.CLOUDFLARE_ACCOUNT_ID?.trim() ?? '';
38
+ const token = process.env.CLOUDFLARE_API_TOKEN?.trim() ?? '';
39
+ if (!account || !token)
40
+ throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN not set');
41
+ if (!ACCOUNT_ID.test(account))
42
+ throw new Error('CLOUDFLARE_ACCOUNT_ID is not a 32-character hex id');
43
+ return {
44
+ url: `https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/@cf/cloudflare/${model}`,
45
+ token,
46
+ backend: 'cloudflare',
47
+ };
48
+ }
49
+ /** Unwraps a Workers AI `{ result }` envelope or a bare System One reply and checks model and `c1..cN`; a string is the rejection reason. */
50
+ export function parseClefReply(body, n, model, requireModel) {
51
+ if (!isJsonObjectRecord(body))
52
+ return 'reply is not a JSON object';
53
+ if (body.success === false)
54
+ return 'provider reported failure';
55
+ const reply = body.result === undefined ? body : body.result;
56
+ if (!isJsonObjectRecord(reply))
57
+ return 'reply has no result object';
58
+ const actual = reply.model;
59
+ if (actual !== undefined && (!isString(actual) || actual.trim() !== model))
60
+ return 'reply names a different model';
61
+ if (actual === undefined && requireModel)
62
+ return 'reply does not name its model';
63
+ const answers = reply.answers;
64
+ if (!isJsonObjectRecord(answers))
65
+ return 'reply has no answers';
66
+ if (Object.keys(answers).length !== n)
67
+ return 'incomplete or out-of-range answers';
68
+ const scores = [];
69
+ for (let i = 1; i <= n; i++) {
70
+ const a = answers[`c${i}`];
71
+ if (!isJsonObjectRecord(a) || (a.type !== undefined && a.type !== 'noul'))
72
+ return 'incomplete or out-of-range answers';
73
+ const v = a.noul;
74
+ if (!isNumber(v) || v < 0 || v > 1)
75
+ return 'incomplete or out-of-range answers';
76
+ scores.push(v);
77
+ }
78
+ const usage = isJsonObjectRecord(reply.usage) ? reply.usage : {};
79
+ return {
80
+ scores,
81
+ actualModel: isString(actual) ? actual.trim() : undefined,
82
+ inputTokens: isNumber(usage.input_tokens) ? usage.input_tokens : undefined,
83
+ outputTokens: isNumber(usage.output_tokens) ? usage.output_tokens : undefined,
84
+ };
85
+ }
86
+ async function requestScores(model, query, head, route) {
87
+ const { state, questions } = buildRelevanceRequest(query, head);
88
+ const parsedTimeout = Number.parseInt(process.env.HIPPO_CLEF_TIMEOUT_MS ?? '', 10);
89
+ const timeoutMs = parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS;
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
92
+ const headers = new Headers({ 'content-type': 'application/json' });
93
+ if (route.token)
94
+ headers.set('authorization', `Bearer ${route.token}`);
95
+ try {
96
+ const resp = await fetch(route.url, {
97
+ method: 'POST',
98
+ headers,
99
+ body: JSON.stringify({ state, model, questions }),
100
+ signal: controller.signal,
101
+ });
102
+ if (!resp.ok) {
103
+ // A third-party header ends up on stderr, so keep printable ASCII only.
104
+ const ray = resp.headers.get('cf-ray')?.replace(/[^\x20-\x7e]/g, '').slice(0, 64);
105
+ await resp.body?.cancel();
106
+ throw new Error(`HTTP ${resp.status}${ray ? `, ray ${ray}` : ''}`);
107
+ }
108
+ const body = await resp.json();
109
+ const parsed = parseClefReply(body, head.length, model, route.backend === 'cloudflare');
110
+ if (isRejection(parsed))
111
+ throw new Error(parsed);
112
+ return parsed;
113
+ }
114
+ catch (err) {
115
+ if (err instanceof Error && err.name === 'AbortError')
116
+ throw new Error(`no answer within ${timeoutMs} ms`);
117
+ throw err;
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+ /** The input order, unchanged, with the reason recorded. Never a partial reorder. */
124
+ function nativeOrder(head, provenance) {
125
+ return head.map((r, i) => ({
126
+ ...r,
127
+ rerankScore: r.score,
128
+ preRerankRank: r.preRerankRank ?? i + 1,
129
+ postRerankRank: i + 1,
130
+ rerankProvenance: provenance,
131
+ }));
132
+ }
133
+ /** A CLEF reranker for one model: Jev's request shape and pool; any failure keeps the native order (never paid Jev), warning once. */
134
+ export function createClefReranker(model) {
135
+ let warned = false;
136
+ return async (query, results, options) => {
137
+ const head = results.slice(0, options?.topK ?? JEV_DEFAULT_TOP_K);
138
+ if (head.length === 0)
139
+ return [];
140
+ let backend = 'native';
141
+ let got;
142
+ try {
143
+ if (head.length > MAX_CANDIDATES)
144
+ throw new Error(`more than ${MAX_CANDIDATES} candidates`);
145
+ const route = resolveClefRoute(model);
146
+ backend = route.backend;
147
+ got = await requestScores(model, query, head, route);
148
+ }
149
+ catch (err) {
150
+ const reason = err instanceof Error ? err.message : 'unknown error';
151
+ if (!warned) {
152
+ warned = true;
153
+ // eslint-disable-next-line no-console
154
+ console.warn(`[hippo] ${model} reranker unavailable (${reason}); keeping the native order. Subsequent calls will not repeat this warning.`);
155
+ }
156
+ return nativeOrder(head, { backend: 'native', requestedModel: model, fallbackReason: reason });
157
+ }
158
+ const provenance = {
159
+ backend,
160
+ requestedModel: model,
161
+ actualModel: got.actualModel,
162
+ inputTokens: got.inputTokens,
163
+ outputTokens: got.outputTokens,
164
+ };
165
+ const scored = head.map((r, i) => ({
166
+ ...r,
167
+ rerankScore: got.scores[i],
168
+ preRerankRank: r.preRerankRank ?? i + 1,
169
+ postRerankRank: 0,
170
+ rerankProvenance: provenance,
171
+ }));
172
+ // Stable sort: ties fall back to the prior relevance order.
173
+ scored.sort((a, b) => b.rerankScore - a.rerankScore);
174
+ scored.forEach((r, i) => (r.postRerankRank = i + 1));
175
+ return scored;
176
+ };
177
+ }
178
+ /** Opt-in CLEF-flash reranker (Cloudflare Workers AI or HIPPO_CLEF_ENDPOINT); off unless named, so defaults stay native. */
179
+ export const clefFlashReranker = createClefReranker('clef-flash');
180
+ /** Opt-in CLEF reranker, the larger model. Same transport and fallback as clef-flash. */
181
+ export const clefReranker = createClefReranker('clef');
182
+ //# sourceMappingURL=clef.js.map
@@ -1,7 +1,10 @@
1
+ import { clefFlashReranker, clefReranker } from './clef.js';
1
2
  import { crossEncoderReranker } from './cross-encoder.js';
2
3
  import { jevReranker } from './jev.js';
3
4
  import { llmReranker } from './llm.js';
4
5
  const REGISTRY = {
6
+ clef: clefReranker,
7
+ 'clef-flash': clefFlashReranker,
5
8
  'cross-encoder': crossEncoderReranker,
6
9
  jev: jevReranker,
7
10
  llm: llmReranker,
@@ -1,5 +1,16 @@
1
1
  import type { RerankerFn } from './types.js';
2
+ import type { SearchResult } from '../search.js';
2
3
  export declare const JEV_DEFAULT_TOP_K = 40;
4
+ /** The System One `state` and one `noul` question per candidate (`c1`..`cN`). */
5
+ export interface RelevanceRequest {
6
+ state: string;
7
+ questions: Record<string, {
8
+ type: 'noul';
9
+ instructions: string;
10
+ }>;
11
+ }
12
+ /** Redacted query plus numbered, redacted, truncated candidates. Shared with CLEF so both arms see matched input. */
13
+ export declare function buildRelevanceRequest(query: string, head: readonly SearchResult[]): RelevanceRequest;
3
14
  /** Builds a Jev reranker around the reranker it degrades to. Exported so a test can pass a stand-in. */
4
15
  export declare function createJevReranker(localFallback: RerankerFn): RerankerFn;
5
16
  /** Track 4 reranker: hosted TypeSafe Jev, opt-in and paid (TYPESAFE_API_KEY), one batched call per recall.
@@ -28,11 +28,8 @@ function parseScores(answers, n) {
28
28
  }
29
29
  return out;
30
30
  }
31
- /** One batched request for the whole candidate list. Rejects with the reason when there are no usable scores. */
32
- async function requestScores(query, head) {
33
- const key = process.env.TYPESAFE_API_KEY;
34
- if (!key)
35
- throw new Error('TYPESAFE_API_KEY not set');
31
+ /** Redacted query plus numbered, redacted, truncated candidates. Shared with CLEF so both arms see matched input. */
32
+ export function buildRelevanceRequest(query, head) {
36
33
  const lines = head.map((r, i) => `[${i + 1}] ${truncate(redactSecrets(r.entry.content), TRUNCATE_CHARS)}`);
37
34
  const state = `Query: ${redactSecrets(query)}\n\nNumbered candidate memories from an AI coding agent's project store:\n\n${lines.join('\n\n')}`;
38
35
  const questions = {};
@@ -42,6 +39,14 @@ async function requestScores(query, head) {
42
39
  instructions: `Probability that candidate ${i} (numbered in the state above) helps answer the query.`,
43
40
  };
44
41
  }
42
+ return { state, questions };
43
+ }
44
+ /** One batched request for the whole candidate list. Rejects with the reason when there are no usable scores. */
45
+ async function requestScores(query, head) {
46
+ const key = process.env.TYPESAFE_API_KEY;
47
+ if (!key)
48
+ throw new Error('TYPESAFE_API_KEY not set');
49
+ const { state, questions } = buildRelevanceRequest(query, head);
45
50
  const parsed = Number.parseInt(process.env.HIPPO_JEV_TIMEOUT_MS ?? '', 10);
46
51
  const timeoutMs = parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
47
52
  const controller = new AbortController();
@@ -26,6 +26,20 @@ export interface RerankerOptions {
26
26
  /** Per-track config blob; opaque to the seam. */
27
27
  config?: Record<string, RerankerConfigValue>;
28
28
  }
29
+ /** Which backend and model produced a rerank, or why it fell back. */
30
+ export interface RerankProvenance {
31
+ /** `cloudflare`, `private-endpoint`, or `native` when the input order was kept. */
32
+ backend: 'cloudflare' | 'private-endpoint' | 'native';
33
+ /** Model the caller asked for. */
34
+ requestedModel: string;
35
+ /** Model the provider says scored the request, when it reports one. */
36
+ actualModel?: string;
37
+ /** Set when the input order was kept instead of a model ranking. */
38
+ fallbackReason?: string;
39
+ /** Provider-reported token usage, when sent. */
40
+ inputTokens?: number;
41
+ outputTokens?: number;
42
+ }
29
43
  export interface RerankResult extends SearchResult {
30
44
  /** Score assigned by the reranker. Replaces `score` for downstream
31
45
  * ordering; original `score` preserved on the SearchResult. */
@@ -34,5 +48,7 @@ export interface RerankResult extends SearchResult {
34
48
  preRerankRank: number;
35
49
  /** 1-indexed rank in the reranker output. */
36
50
  postRerankRank: number;
51
+ /** Recorded by rerankers that track model identity (the CLEF rerankers). */
52
+ rerankProvenance?: RerankProvenance;
37
53
  }
38
54
  //# sourceMappingURL=types.d.ts.map