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.
- package/README.md +13 -1
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/legacy.js +4 -1
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +2 -0
- package/dist/cli/output.d.ts +3 -0
- package/dist/cli/output.js +7 -0
- package/dist/cli/projects.d.ts +4 -0
- package/dist/cli/projects.js +90 -0
- package/dist/cli/shared.js +14 -8
- package/dist/cli/sleep.js +5 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +389 -361
- package/dist/consolidate.js +12 -1
- package/dist/dag.js +4 -2
- package/dist/dedupe.js +1 -1
- package/dist/doctor.js +23 -0
- package/dist/dormant.d.ts +2 -2
- package/dist/extract.js +16 -16
- package/dist/project-merge.d.ts +52 -0
- package/dist/project-merge.js +168 -0
- package/dist/recall-scope.d.ts +3 -3
- package/dist/recall-scope.js +5 -4
- package/dist/rerankers/clef.d.ts +29 -0
- package/dist/rerankers/clef.js +182 -0
- package/dist/rerankers/index.js +3 -0
- package/dist/rerankers/jev.d.ts +11 -0
- package/dist/rerankers/jev.js +10 -5
- package/dist/rerankers/types.d.ts +16 -0
- package/dist/server.d.ts +6 -1
- package/dist/server.js +9 -3
- package/dist/store.js +6 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
package/dist/consolidate.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
package/dist/recall-scope.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/recall-scope.js
CHANGED
|
@@ -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
|
|
169
|
-
export function derivationPartitionKey(tenantId, scope) {
|
|
170
|
-
|
|
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
|
package/dist/rerankers/index.js
CHANGED
|
@@ -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,
|