hippo-memory 1.58.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.
@@ -689,7 +689,7 @@ export async function consolidate(hippoRoot, options = {}) {
689
689
  // before this fix — byte-identical behavior there.
690
690
  const mergeCandidatesByTenant = new Map();
691
691
  for (const entry of mergeCandidates) {
692
- const key = derivationPartitionKey(entry.tenantId, entry.scope);
692
+ const key = derivationPartitionKey(entry.tenantId, entry.scope, entry.origin_project);
693
693
  const bucket = mergeCandidatesByTenant.get(key);
694
694
  if (bucket)
695
695
  bucket.push(entry);
@@ -708,6 +708,7 @@ export async function consolidate(hippoRoot, options = {}) {
708
708
  for (const [, tenantCandidates] of mergeCandidatesByTenant) {
709
709
  const mergeTenant = tenantCandidates[0].tenantId;
710
710
  const mergeScope = derivationScope(tenantCandidates[0].scope);
711
+ const mergeOrigin = tenantCandidates[0].origin_project;
711
712
  const partnersOf = mergePartners(tenantCandidates.map((e) => e.content));
712
713
  for (let i = 0; i < tenantCandidates.length; i++) {
713
714
  if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
@@ -752,6 +753,7 @@ export async function consolidate(hippoRoot, options = {}) {
752
753
  scope: mergeScope,
753
754
  baseHalfLifeDays: config.defaultHalfLifeDays,
754
755
  }),
756
+ origin_project: mergeOrigin,
755
757
  parents: cluster.map((e) => e.id),
756
758
  };
757
759
  }
@@ -994,6 +996,8 @@ rescuedIds = new Set()) {
994
996
  continue;
995
997
  if (survivors[i].superseded_by || survivors[j].superseded_by)
996
998
  continue;
999
+ if (!recalledTogether(survivors[i], survivors[j]))
1000
+ continue;
997
1001
  if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
998
1002
  continue;
999
1003
  const reasonAndScore = describeConflict(profiles[i], profiles[j]);
@@ -1009,6 +1013,13 @@ rescuedIds = new Set()) {
1009
1013
  }
1010
1014
  return detected;
1011
1015
  }
1016
+ /** One project's ambient context can show both: same tenant, and the same project or a user-global row beside a project's. */
1017
+ function recalledTogether(a, b) {
1018
+ if (a.tenantId !== b.tenantId)
1019
+ return false;
1020
+ const [x, y] = [a.origin_project ?? null, b.origin_project ?? null];
1021
+ return x === y || (x === '' && y !== null) || (y === '' && x !== null);
1022
+ }
1012
1023
  function conflictProfile(text) {
1013
1024
  const opening = openingWindow(text);
1014
1025
  // Polarity is measured only in the first POLARITY_WINDOW_WORDS, so a stray
package/dist/dag.js CHANGED
@@ -98,7 +98,7 @@ export async function buildDag(hippoRoot, facts, opts) {
98
98
  // behavior there.
99
99
  const unparentedByTenant = new Map();
100
100
  for (const fact of unparented) {
101
- const key = derivationPartitionKey(fact.tenantId, fact.scope);
101
+ const key = derivationPartitionKey(fact.tenantId, fact.scope, fact.origin_project);
102
102
  const bucket = unparentedByTenant.get(key);
103
103
  if (bucket)
104
104
  bucket.push(fact);
@@ -129,6 +129,7 @@ export async function buildDag(hippoRoot, facts, opts) {
129
129
  scope: factScope,
130
130
  baseHalfLifeDays,
131
131
  });
132
+ summaryEntry.origin_project = tenantFacts[0].origin_project;
132
133
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
133
134
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
134
135
  // can reason about scope without walking the children.
@@ -310,7 +311,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
310
311
  const byTenant = new Map();
311
312
  for (const l2 of unparented) {
312
313
  const tid = l2.tenantId ?? 'default';
313
- const key = derivationPartitionKey(tid, l2.scope);
314
+ const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
314
315
  const list = byTenant.get(key) ?? [];
315
316
  list.push(l2);
316
317
  byTenant.set(key, list);
@@ -338,6 +339,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
338
339
  scope,
339
340
  baseHalfLifeDays,
340
341
  });
342
+ profileEntry.origin_project = tenantL2s[0].origin_project;
341
343
  profileEntry.descendant_count = cluster.members.length;
342
344
  profileEntry.earliest_at = memberCreatedAts[0];
343
345
  profileEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
package/dist/dedupe.js CHANGED
@@ -78,7 +78,7 @@ export function deduplicateStore(hippoRoot, options = {}) {
78
78
  const entriesByTenant = new Map();
79
79
  for (const entry of entries) {
80
80
  // EI2: also split by restricted scope, else a private copy can delete the readable one.
81
- const key = derivationPartitionKey(entry.tenantId, entry.scope);
81
+ const key = derivationPartitionKey(entry.tenantId, entry.scope, entry.origin_project);
82
82
  const bucket = entriesByTenant.get(key);
83
83
  if (bucket)
84
84
  bucket.push(entry);
package/dist/doctor.js CHANGED
@@ -15,6 +15,8 @@ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVe
15
15
  import { REPLAY_AFTER_MS, TRANSCRIPT_FILL_WINDOW_MS } from './compaction-record.js';
16
16
  import { isEmbeddingAvailable } from './embeddings.js';
17
17
  import { CODEX_TRUST_LINE, codexHomeDir, isCodexPresent, isJsonObject } from './hooks.js';
18
+ import { planUserGlobalRepair } from './project-merge.js';
19
+ import { resolveTenantId } from './tenant.js';
18
20
  /** Minimum Node.js version hippo supports (package.json engines). */
19
21
  export const MIN_NODE = '22.16.0';
20
22
  function versionAtLeast(actual, min) {
@@ -128,6 +130,25 @@ function compactionsCheck(db, now) {
128
130
  : { id: 'compactions', status: 'warn', detail: `cannot read the compaction records: ${message}` };
129
131
  }
130
132
  }
133
+ /** Merged rows older sleep saved as user-global, counted by the repair's own dry run so a truly user-global merge never warns. */
134
+ function projectsCheck(globalRoot) {
135
+ let db = null;
136
+ try {
137
+ db = openHippoDbReadOnly(globalRoot);
138
+ const r = planUserGlobalRepair(db, resolveTenantId({}));
139
+ const n = r.toProject.length + r.setAside.length;
140
+ return n === 0
141
+ ? { id: 'projects', status: 'pass', detail: 'no merged memories in the global store are tagged user-global by mistake' }
142
+ : { id: 'projects', status: 'warn', detail: `${n} merged memories in the global store are tagged user-global, so every project's context can show them`, fix: 'hippo projects repair --global (dry run; add --apply to write)' };
143
+ }
144
+ catch (err) {
145
+ return { id: 'projects', status: 'info', detail: `project tags not checked (${err instanceof Error ? err.message : String(err)})` };
146
+ }
147
+ finally {
148
+ if (db !== null)
149
+ closeHippoDb(db);
150
+ }
151
+ }
131
152
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
132
153
  export function runDoctor(opts) {
133
154
  const cwd = opts.cwd ?? process.cwd();
@@ -213,6 +234,8 @@ export function runDoctor(opts) {
213
234
  if (holdoutRateBp > 0) {
214
235
  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
236
  }
237
+ if (hasGlobal)
238
+ checks.push(projectsCheck(globalRoot));
216
239
  const claudeDir = path.join(home, '.claude');
217
240
  if (fs.existsSync(claudeDir)) {
218
241
  const settings = readJson(path.join(claudeDir, 'settings.json'));
package/dist/dormant.d.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import type { DatabaseSyncLike } from './db.js';
21
21
  import type { MemoryEntry } from './memory.js';
22
- /** Why a memory went dormant: sleep's decay pass, or an imported agent memory whose note was deleted. */
23
- export type DormantReason = 'decay' | 'source-deleted';
22
+ /** Why a memory went dormant: sleep's decay pass, an imported agent memory whose note was deleted, or `hippo projects repair` splitting a two-project merge. */
23
+ export type DormantReason = 'decay' | 'source-deleted' | 'project-repair';
24
24
  /** One memory that sleep is moving out of active memory into the dormant store. */
25
25
  export interface DormantMove {
26
26
  /** The memory as it stood when it faded; restored verbatim apart from its recall clock. */
package/dist/extract.js CHANGED
@@ -92,22 +92,22 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
92
92
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
93
93
  for (const fact of facts) {
94
94
  const tags = ['extracted', ...inheritedTags, ...fact.tags];
95
- const entry = createMemory(fact.content, {
96
- layer: Layer.Semantic,
97
- tags,
98
- emotional_valence: fact.valence,
99
- confidence: 'inferred',
100
- source: source.source,
101
- extracted_from: source.id,
102
- scope: source.scope,
103
- // T1 executor check (2026-08-15 hardening pass): same defect as the
104
- // consolidate.ts merge/trace passes — createMemory with no tenantId
105
- // option stamps 'default' (memory.ts:535) regardless of the source
106
- // entry's own tenant. Thread it through so extracted facts land in
107
- // the same tenant as the episodic memory they were extracted from.
108
- tenantId: source.tenantId,
109
- baseHalfLifeDays,
110
- });
95
+ const entry = { ...createMemory(fact.content, {
96
+ layer: Layer.Semantic,
97
+ tags,
98
+ emotional_valence: fact.valence,
99
+ confidence: 'inferred',
100
+ source: source.source,
101
+ extracted_from: source.id,
102
+ scope: source.scope,
103
+ // T1 executor check (2026-08-15 hardening pass): same defect as the
104
+ // consolidate.ts merge/trace passes — createMemory with no tenantId
105
+ // option stamps 'default' (memory.ts:535) regardless of the source
106
+ // entry's own tenant. Thread it through so extracted facts land in
107
+ // the same tenant as the episodic memory they were extracted from.
108
+ tenantId: source.tenantId,
109
+ baseHalfLifeDays,
110
+ }), origin_project: source.origin_project };
111
111
  // AT1 containment: a refusal is per-VALUE — one rejected fact must not
112
112
  // drop the rest of this batch. writeEntry has already audited the
113
113
  // refusal (reject_refusal) before rethrowing, so skip-and-count here.
@@ -0,0 +1,52 @@
1
+ import type { DatabaseSyncLike } from './db.js';
2
+ export interface ProjectSummary {
3
+ /** '' is user-global, null is unknown; neither can be merged. */
4
+ readonly origin: string | null;
5
+ readonly live: number;
6
+ readonly imported: number;
7
+ readonly newest: string;
8
+ /** Imported notes whose text another project name also holds: evidence of a duplicate import, never a merge target. */
9
+ readonly copiesElsewhere: number;
10
+ }
11
+ export interface MergeResult {
12
+ readonly from: string;
13
+ readonly into: string;
14
+ /** Imported note copies moved to dormant storage; the next sync under `into` imports the notes still on disk. */
15
+ readonly setAside: readonly string[];
16
+ readonly restamped: readonly string[];
17
+ readonly dormantRestamped: readonly string[];
18
+ readonly compactions: number;
19
+ readonly backup: string | null;
20
+ }
21
+ export interface RepairResult {
22
+ readonly toProject: ReadonlyArray<{
23
+ readonly id: string;
24
+ readonly origin: string;
25
+ }>;
26
+ /** Parents in two projects: the merged text blends them, so it goes dormant and the parents re-merge per project. */
27
+ readonly setAside: readonly string[];
28
+ /** No parent left to say which project, or pinned: left as they are, since hiding them would rest on no evidence. */
29
+ readonly untraced: readonly string[];
30
+ readonly backup: string | null;
31
+ }
32
+ /** Live rows per project name, newest write first, with how many of its imported notes are copies held elsewhere. */
33
+ export declare function listProjects(db: DatabaseSyncLike, tenantId: string): ProjectSummary[];
34
+ /** Copies the database before a repair writes, so the audit ids plus this file are the way back. */
35
+ export declare function backupStore(db: DatabaseSyncLike, hippoRoot: string, label: string, now?: Date): string;
36
+ /** Refuses user-global and unknown: they are not projects, and folding them would leak or hide every row. */
37
+ export declare function validateMergeNames(from: string, into: string): string | null;
38
+ /** Folds project `from` into `into` for one tenant in one transaction; a dry run rolls back and writes nothing, mirrors included. */
39
+ export declare function mergeProjects(db: DatabaseSyncLike, hippoRoot: string, opts: {
40
+ tenantId: string;
41
+ from: string;
42
+ into: string;
43
+ dryRun: boolean;
44
+ }): MergeResult;
45
+ /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
46
+ export declare function planUserGlobalRepair(db: DatabaseSyncLike, tenantId: string): Omit<RepairResult, 'backup'>;
47
+ /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
48
+ export declare function repairUserGlobalMerges(db: DatabaseSyncLike, hippoRoot: string, opts: {
49
+ tenantId: string;
50
+ dryRun: boolean;
51
+ }): RepairResult;
52
+ //# sourceMappingURL=project-merge.d.ts.map
@@ -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
@@ -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
@@ -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,