hippo-memory 1.61.0 → 1.62.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 +28 -53
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.d.ts +3 -1
- package/dist/agent-memories/claude-code.js +53 -9
- package/dist/agent-memories/sync.d.ts +3 -3
- package/dist/agent-memories/sync.js +14 -6
- package/dist/agent-memories/types.d.ts +0 -2
- package/dist/api/assemble.js +55 -54
- package/dist/api/context-select.d.ts +49 -0
- package/dist/api/context-select.js +344 -0
- package/dist/api/context.d.ts +2 -2
- package/dist/api/context.js +195 -522
- package/dist/api/drill-down.js +36 -33
- package/dist/api/promote.js +55 -66
- package/dist/api/recall.js +303 -438
- package/dist/api/sleep.js +203 -218
- package/dist/capture/compact.d.ts +1 -1
- package/dist/capture/compact.js +2 -2
- package/dist/cli/briefs.js +324 -306
- package/dist/cli/context.js +44 -34
- package/dist/cli/continuity.js +283 -271
- package/dist/cli/curate.js +35 -34
- package/dist/cli/decisions.js +333 -333
- package/dist/cli/explain.js +66 -60
- package/dist/cli/maintenance.js +62 -51
- package/dist/cli/playbooks.js +387 -370
- package/dist/cli/projects.js +8 -5
- package/dist/cli/recall.js +28 -43
- package/dist/cli/remember.js +113 -70
- package/dist/cli/session-hooks.js +100 -90
- package/dist/cli/setup.js +267 -246
- package/dist/cli/status.js +73 -64
- package/dist/cli/transfer.js +85 -99
- package/dist/compaction-record.d.ts +0 -2
- package/dist/compaction-record.js +1 -1
- package/dist/customer-notes.js +77 -68
- package/dist/dag.js +222 -186
- package/dist/decisions.js +93 -76
- package/dist/doctor.js +11 -7
- package/dist/goals.js +99 -86
- package/dist/incidents.js +45 -38
- package/dist/policies.js +85 -68
- package/dist/processes.js +87 -71
- package/dist/project-briefs.js +135 -108
- package/dist/project-merge.d.ts +12 -4
- package/dist/project-merge.js +130 -45
- package/dist/shared.d.ts +9 -0
- package/dist/shared.js +10 -8
- package/dist/skills.js +81 -65
- package/dist/store/search-rows.d.ts +2 -2
- package/dist/store/search-rows.js +15 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/project-merge.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
// `hippo projects`: list the project names a store holds, fold one into another, and
|
|
1
|
+
// `hippo projects`: list the project names a store holds, fold one into another, and repair old tags in one reversible pass.
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { setAsideRow } from './agent-memories/apply.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { transcriptNotesOrigin } from './agent-memories/claude-code.js';
|
|
6
|
+
import { containerId, containerPrefix } from './agent-memories/source.js';
|
|
7
|
+
import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS, toolSourcePrefix } from './agent-memories/tools.js';
|
|
8
|
+
import { appendAuditEvent, queryAuditEvents } from './audit.js';
|
|
7
9
|
import { insertDormantRow, listDormantSnapshots, replaceDormantEntry } from './dormant.js';
|
|
10
|
+
import { processEnv } from './env.js';
|
|
8
11
|
import { calculateStrength } from './memory.js';
|
|
12
|
+
import { deriveOriginProject, isGlobalStoreRoot } from './project-identity.js';
|
|
13
|
+
import { duplicateKey } from './same-text.js';
|
|
9
14
|
import { removeEntryMirrors } from './store/mirrors.js';
|
|
10
15
|
import { deleteEntryRowInTx, writeEntryMirrors } from './store/entry-writes.js';
|
|
11
|
-
import { selectAllEntries } from './store/entry-reads.js';
|
|
16
|
+
import { selectAllEntries, selectLiveEntriesBySourcePrefix } from './store/entry-reads.js';
|
|
12
17
|
const ACTOR = 'cli';
|
|
13
18
|
function isImport(entry) {
|
|
14
19
|
return entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX);
|
|
@@ -25,8 +30,9 @@ export function listProjects(db, tenantId) {
|
|
|
25
30
|
for (const e of live) {
|
|
26
31
|
const origin = e.origin_project ?? null;
|
|
27
32
|
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), e]);
|
|
33
|
+
const key = duplicateKey(e.content);
|
|
28
34
|
if (isImport(e))
|
|
29
|
-
holders.set(
|
|
35
|
+
holders.set(key, (holders.get(key) ?? new Set()).add(origin));
|
|
30
36
|
}
|
|
31
37
|
return [...byOrigin].map(([origin, rows]) => {
|
|
32
38
|
const imports = rows.filter(isImport);
|
|
@@ -35,7 +41,7 @@ export function listProjects(db, tenantId) {
|
|
|
35
41
|
live: rows.length,
|
|
36
42
|
imported: imports.length,
|
|
37
43
|
newest: rows.reduce((max, e) => (e.created > max ? e.created : max), ''),
|
|
38
|
-
copiesElsewhere: imports.filter((e) => (holders.get(e.content)?.size ?? 0) > 1).length,
|
|
44
|
+
copiesElsewhere: imports.filter((e) => (holders.get(duplicateKey(e.content))?.size ?? 0) > 1).length,
|
|
39
45
|
};
|
|
40
46
|
}).sort((a, b) => b.newest.localeCompare(a.newest));
|
|
41
47
|
}
|
|
@@ -78,44 +84,111 @@ export function mergeProjects(db, hippoRoot, opts) {
|
|
|
78
84
|
const { tenantId, from, into, dryRun } = opts;
|
|
79
85
|
const backup = dryRun ? null : backupStore(db, hippoRoot, 'before-merge');
|
|
80
86
|
const result = inTransaction(db, dryRun, () => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const tag = toolTag(row.source);
|
|
85
|
-
if (!isImport(row) || row.superseded_by || row.kind === 'raw' || tag === null)
|
|
86
|
-
continue;
|
|
87
|
-
if (setAsideRow(db, tag, { ...row, origin_project: into }, 'project-merge').kind === 'dormant')
|
|
88
|
-
setAside.push(row.id);
|
|
89
|
-
}
|
|
90
|
-
const gone = new Set(setAside);
|
|
91
|
-
const restamped = rows.filter((e) => !gone.has(e.id)).map((e) => e.id);
|
|
92
|
-
db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND origin_project = ?`)
|
|
93
|
-
.run(into, tenantId, from);
|
|
94
|
-
const dormantRestamped = [];
|
|
95
|
-
for (const snap of listDormantSnapshots(db, tenantId)) {
|
|
96
|
-
if (snap.entry.origin_project !== from || gone.has(snap.entry.id))
|
|
97
|
-
continue;
|
|
98
|
-
replaceDormantEntry(db, tenantId, snap.entry.id, { ...snap.entry, origin_project: into });
|
|
99
|
-
dormantRestamped.push(snap.entry.id);
|
|
100
|
-
}
|
|
101
|
-
const compactions = Number(db.prepare(`UPDATE compactions SET origin_project = ? WHERE tenant_id = ? AND origin_project = ?`)
|
|
102
|
-
.run(into, tenantId, from).changes ?? 0);
|
|
103
|
-
appendAuditEvent(db, {
|
|
104
|
-
tenantId, actor: ACTOR, op: 'project_merge',
|
|
105
|
-
metadata: { from, into, backup, setAside, restamped, dormantRestamped, compactions },
|
|
106
|
-
});
|
|
107
|
-
return { from, into, setAside, restamped, dormantRestamped, compactions, backup };
|
|
87
|
+
const folded = foldInTx(db, tenantId, from, into);
|
|
88
|
+
appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_merge', metadata: { from, into, backup, ...folded } });
|
|
89
|
+
return { from, into, ...folded, backup };
|
|
108
90
|
});
|
|
109
91
|
if (!dryRun)
|
|
110
92
|
refreshMirrors(db, hippoRoot, tenantId, result.restamped, result.setAside);
|
|
111
93
|
return result;
|
|
112
94
|
}
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
function foldInTx(db, tenantId, from, into) {
|
|
96
|
+
const rows = selectAllEntries(db, tenantId).filter((e) => e.origin_project === from);
|
|
97
|
+
const setAside = [];
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const tag = toolTag(row.source);
|
|
100
|
+
if (!isImport(row) || row.superseded_by || row.kind === 'raw' || tag === null)
|
|
101
|
+
continue;
|
|
102
|
+
if (setAsideRow(db, tag, { ...row, origin_project: into }, 'project-merge').kind === 'dormant')
|
|
103
|
+
setAside.push(row.id);
|
|
104
|
+
}
|
|
105
|
+
const gone = new Set(setAside);
|
|
106
|
+
const restamped = rows.filter((e) => !gone.has(e.id)).map((e) => e.id);
|
|
107
|
+
db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND origin_project = ?`)
|
|
108
|
+
.run(into, tenantId, from);
|
|
109
|
+
const dormantRestamped = [];
|
|
110
|
+
for (const snap of listDormantSnapshots(db, tenantId)) {
|
|
111
|
+
if (snap.entry.origin_project !== from || gone.has(snap.entry.id))
|
|
112
|
+
continue;
|
|
113
|
+
replaceDormantEntry(db, tenantId, snap.entry.id, { ...snap.entry, origin_project: into });
|
|
114
|
+
dormantRestamped.push(snap.entry.id);
|
|
115
|
+
}
|
|
116
|
+
const compactions = Number(db.prepare(`UPDATE compactions SET origin_project = ? WHERE tenant_id = ? AND origin_project = ?`)
|
|
117
|
+
.run(into, tenantId, from).changes ?? 0);
|
|
118
|
+
return { setAside, restamped, dormantRestamped, compactions };
|
|
119
|
+
}
|
|
120
|
+
/** Live imports under a project name whose exact text a user-global import holds: a session folder's notes stamped with the folder it ended in. */
|
|
121
|
+
function importCopies(db, tenantId) {
|
|
122
|
+
const live = selectAllEntries(db, tenantId).filter((e) => !e.superseded_by && isImport(e));
|
|
123
|
+
const userGlobal = new Set(live.filter((e) => e.origin_project === '').map((e) => duplicateKey(e.content)));
|
|
124
|
+
return live.filter((e) => e.origin_project && e.kind !== 'raw' && !e.pinned && toolTag(e.source) !== null && userGlobal.has(duplicateKey(e.content)));
|
|
125
|
+
}
|
|
126
|
+
/** Imports to set aside. The global store also checks each Claude session folder a compaction recorded: its notes under any other project are misfiled, edited or not, and a text copy in the right folder is kept. */
|
|
127
|
+
function strayImports(db, hippoRoot, tenantId) {
|
|
128
|
+
const copies = importCopies(db, tenantId);
|
|
129
|
+
if (!isGlobalStoreRoot(hippoRoot))
|
|
130
|
+
return copies;
|
|
131
|
+
const machine = { platform: process.platform, env: processEnv() };
|
|
132
|
+
const { platform } = machine;
|
|
133
|
+
// SAFETY: the SELECT names the two columns of the row type.
|
|
134
|
+
const sessions = db.prepare(`SELECT DISTINCT transcript_path AS transcript, cwd FROM compactions WHERE tenant_id = ? AND transcript_path IS NOT NULL`)
|
|
135
|
+
.all(tenantId);
|
|
136
|
+
const owners = new Map();
|
|
137
|
+
for (const { transcript, cwd } of sessions) {
|
|
138
|
+
const origin = transcriptNotesOrigin(transcript, cwd, machine);
|
|
139
|
+
const dir = path.join(path.dirname(transcript), 'memory');
|
|
140
|
+
if (origin !== null)
|
|
141
|
+
owners.set(dir, (owners.get(dir) ?? new Set()).add(origin));
|
|
142
|
+
}
|
|
143
|
+
const tool = toolSourcePrefix('claude-code');
|
|
144
|
+
const live = selectLiveEntriesBySourcePrefix(db, tenantId, tool).filter((e) => e.kind !== 'raw' && !e.pinned);
|
|
145
|
+
const origins = new Set(live.map((e) => e.origin_project ?? ''));
|
|
146
|
+
const right = new Set();
|
|
147
|
+
const wrong = new Set();
|
|
148
|
+
for (const [dir, owner] of owners) {
|
|
149
|
+
if (owner.size !== 1)
|
|
150
|
+
continue;
|
|
151
|
+
for (const origin of origins)
|
|
152
|
+
(owner.has(origin) ? right : wrong).add(containerPrefix('claude-code', containerId(dir, 'project', platform, origin)));
|
|
153
|
+
}
|
|
154
|
+
const prefix = (e) => e.source.slice(0, e.source.indexOf('/', tool.length) + 1);
|
|
155
|
+
const misfiled = live.filter((e) => wrong.has(prefix(e)));
|
|
156
|
+
const seen = new Set(misfiled.map((e) => e.id));
|
|
157
|
+
return [...misfiled, ...copies.filter((e) => !seen.has(e.id) && !right.has(prefix(e)))];
|
|
158
|
+
}
|
|
159
|
+
/** Global store only, where a compaction's name came from its cwd: a name whose every cwd still on disk resolves to one other project today. */
|
|
160
|
+
function planFolds(db, tenantId) {
|
|
161
|
+
// SAFETY: the SELECT names the two columns of the row type.
|
|
162
|
+
const rows = db.prepare(`SELECT DISTINCT origin_project AS origin, cwd FROM compactions WHERE tenant_id = ? AND origin_project <> '' AND cwd IS NOT NULL`)
|
|
163
|
+
.all(tenantId);
|
|
164
|
+
const today = new Map();
|
|
165
|
+
for (const { origin, cwd } of rows) {
|
|
166
|
+
if (fs.existsSync(cwd))
|
|
167
|
+
today.set(origin, (today.get(origin) ?? new Set()).add(deriveOriginProject(cwd)));
|
|
168
|
+
}
|
|
169
|
+
// A name someone merged into by hand stays: undoing their choice would rest on the resolver alone.
|
|
170
|
+
const chosen = new Set(queryAuditEvents(db, { tenantId, op: 'project_merge', limit: 10000 }).map((e) => e.metadata.into));
|
|
171
|
+
const folds = [...today].flatMap(([from, names]) => {
|
|
172
|
+
const [into] = names;
|
|
173
|
+
return names.size === 1 && into !== '' && into !== from && !chosen.has(from) ? [{ from, into }] : [];
|
|
174
|
+
});
|
|
175
|
+
// A fold into a name that itself folds would land rows by run order; the next repair takes the rest of the chain.
|
|
176
|
+
const sources = new Set(folds.map((f) => f.from));
|
|
177
|
+
return folds.filter((f) => !sources.has(f.into));
|
|
178
|
+
}
|
|
179
|
+
/** Reads only, so doctor and a dry run take no write lock; merged rows are planned before any fold, so a few may re-tag differently once folds apply. */
|
|
180
|
+
export function planProjectRepair(db, hippoRoot, tenantId) {
|
|
181
|
+
const folds = isGlobalStoreRoot(hippoRoot) ? planFolds(db, tenantId) : [];
|
|
182
|
+
return { copies: strayImports(db, hippoRoot, tenantId).map((e) => e.id), folds, ...planUserGlobalRepair(db, tenantId, folds) };
|
|
183
|
+
}
|
|
184
|
+
/** Parents read with `folds` already applied, so a plan matches what apply does after folding. */
|
|
185
|
+
function planUserGlobalRepair(db, tenantId, folds) {
|
|
115
186
|
const all = selectAllEntries(db, tenantId);
|
|
116
|
-
const
|
|
187
|
+
const renamed = new Map(folds.map((f) => [f.from, f.into]));
|
|
188
|
+
const after = (origin) => (origin ? renamed.get(origin) ?? origin : origin ?? null);
|
|
189
|
+
const origins = new Map(listDormantSnapshots(db, tenantId).map((s) => [s.entry.id, after(s.entry.origin_project)]));
|
|
117
190
|
for (const e of all)
|
|
118
|
-
origins.set(e.id, e.origin_project
|
|
191
|
+
origins.set(e.id, after(e.origin_project));
|
|
119
192
|
const toProject = [];
|
|
120
193
|
const setAside = [];
|
|
121
194
|
const untraced = [];
|
|
@@ -135,14 +208,22 @@ export function planUserGlobalRepair(db, tenantId) {
|
|
|
135
208
|
}
|
|
136
209
|
return { toProject, setAside, untraced };
|
|
137
210
|
}
|
|
138
|
-
/**
|
|
139
|
-
export function
|
|
211
|
+
/** Sets aside stray imports, folds the names the resolver now maps elsewhere, then re-tags sleep's user-global merges by their parents; a dry run only plans. */
|
|
212
|
+
export function repairProjects(db, hippoRoot, opts) {
|
|
140
213
|
const { tenantId, dryRun } = opts;
|
|
141
214
|
if (dryRun)
|
|
142
|
-
return { ...
|
|
215
|
+
return { ...planProjectRepair(db, hippoRoot, tenantId), backup: null };
|
|
143
216
|
const backup = backupStore(db, hippoRoot, 'before-repair');
|
|
144
|
-
const result = inTransaction(db, false, () => {
|
|
145
|
-
const
|
|
217
|
+
const { result, rewrite, purge } = inTransaction(db, false, () => {
|
|
218
|
+
const copies = [];
|
|
219
|
+
for (const row of strayImports(db, hippoRoot, tenantId)) {
|
|
220
|
+
const tag = toolTag(row.source);
|
|
221
|
+
if (tag !== null && setAsideRow(db, tag, row, 'project-repair').kind === 'dormant')
|
|
222
|
+
copies.push(row.id);
|
|
223
|
+
}
|
|
224
|
+
const folds = isGlobalStoreRoot(hippoRoot) ? planFolds(db, tenantId) : [];
|
|
225
|
+
const folded = folds.map((f) => foldInTx(db, tenantId, f.from, f.into));
|
|
226
|
+
const plan = planUserGlobalRepair(db, tenantId, []);
|
|
146
227
|
const stamp = db.prepare(`UPDATE memories SET origin_project = ?, updated_at = datetime('now') WHERE tenant_id = ? AND id = ?`);
|
|
147
228
|
for (const { id, origin } of plan.toProject)
|
|
148
229
|
stamp.run(origin, tenantId, id);
|
|
@@ -152,10 +233,14 @@ export function repairUserGlobalMerges(db, hippoRoot, opts) {
|
|
|
152
233
|
insertDormantRow(db, { entry: row, strength: calculateStrength(row, now), reason: 'project-repair', dormantAt: now.toISOString() });
|
|
153
234
|
deleteEntryRowInTx(db, row, ACTOR);
|
|
154
235
|
}
|
|
155
|
-
appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_repair', metadata: { backup, ...plan } });
|
|
156
|
-
return {
|
|
236
|
+
appendAuditEvent(db, { tenantId, actor: ACTOR, op: 'project_repair', metadata: { backup, copies, folds, folded, ...plan } });
|
|
237
|
+
return {
|
|
238
|
+
result: { copies, folds, ...plan, backup },
|
|
239
|
+
rewrite: [...plan.toProject.map((r) => r.id), ...folded.flatMap((f) => f.restamped)],
|
|
240
|
+
purge: [...copies, ...plan.setAside, ...folded.flatMap((f) => f.setAside)],
|
|
241
|
+
};
|
|
157
242
|
});
|
|
158
|
-
refreshMirrors(db, hippoRoot, tenantId,
|
|
243
|
+
refreshMirrors(db, hippoRoot, tenantId, rewrite, purge);
|
|
159
244
|
return result;
|
|
160
245
|
}
|
|
161
246
|
/** After commit, as the agent memory sync does: a stale mirror would bring the old tag back on the next rebuild. */
|
package/dist/shared.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { MemoryEntry } from './memory.js';
|
|
8
8
|
import type { SearchResult, ResultCost } from './search/types.js';
|
|
9
|
+
import type { HybridVectorCandidates } from './search/vector.js';
|
|
9
10
|
import type { DatabaseSyncLike } from './db.js';
|
|
10
11
|
/**
|
|
11
12
|
* Returns the path to the global Hippo store.
|
|
@@ -93,6 +94,14 @@ export interface HybridSearchOptions extends SearchOptions {
|
|
|
93
94
|
* Async version of searchBoth that calls hybridSearch instead of search.
|
|
94
95
|
*/
|
|
95
96
|
export declare function searchBothHybrid(query: string, localRoot: string, globalRoot: string, options?: HybridSearchOptions): Promise<SearchResult[]>;
|
|
97
|
+
/** Hybrid ranking of rows already loaded from each store: the local bump, one copy per text, then the shared budget. */
|
|
98
|
+
export declare function rankBothStores(query: string, roots: {
|
|
99
|
+
local: string;
|
|
100
|
+
global: string;
|
|
101
|
+
}, entries: {
|
|
102
|
+
local: MemoryEntry[];
|
|
103
|
+
global: MemoryEntry[];
|
|
104
|
+
}, vectorCandidates: HybridVectorCandidates, options?: HybridSearchOptions): Promise<SearchResult[]>;
|
|
96
105
|
/** Tags whose rows only a hand-run share or promote may copy to the global store; derived rows inherit them. */
|
|
97
106
|
export declare const NEVER_AUTO_SHARE_TAGS: ReadonlySet<string>;
|
|
98
107
|
export declare function neverAutoShareTags(sources: readonly MemoryEntry[]): string[];
|
package/dist/shared.js
CHANGED
|
@@ -148,13 +148,10 @@ export function searchBoth(query, localRoot, globalRoot, options = {}) {
|
|
|
148
148
|
* Async version of searchBoth that calls hybridSearch instead of search.
|
|
149
149
|
*/
|
|
150
150
|
export async function searchBothHybrid(query, localRoot, globalRoot, options = {}) {
|
|
151
|
-
const {
|
|
151
|
+
const { includeSuperseded, asOf, tenantId, entryFilter, recallScope } = options;
|
|
152
152
|
// When an admission filter is active, lift the per-store candidate cap
|
|
153
153
|
// (default 200): excluded rows matching the query could otherwise fill the
|
|
154
154
|
// window before any admitted row is even loaded (codex gating round 6).
|
|
155
|
-
// Only ambient-context query mode sets entryFilter, and that path is
|
|
156
|
-
// interactive - never the per-turn pinned-only hook - so ranking the full
|
|
157
|
-
// match set is acceptable.
|
|
158
155
|
// 5000 = 25x the default 200-row window: large enough that exclusion
|
|
159
156
|
// crowding is a non-issue on real stores, bounded so a common query term
|
|
160
157
|
// on a 100k-row store cannot stall an interactive call by ranking every
|
|
@@ -179,8 +176,6 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
|
|
|
179
176
|
const admit = (e) => passesScope(e) && (!entryFilter || entryFilter(e));
|
|
180
177
|
const localEntries = loadEntries(localRoot).filter(admit);
|
|
181
178
|
const globalEntries = loadEntries(globalRoot).filter(admit);
|
|
182
|
-
if (localEntries.length === 0 && globalEntries.length === 0)
|
|
183
|
-
return [];
|
|
184
179
|
// The vector arm loads under the same SQL rules as loadEntries, then the same JS admission.
|
|
185
180
|
const vectorCandidates = {
|
|
186
181
|
tenantId,
|
|
@@ -188,9 +183,16 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
|
|
|
188
183
|
includeSuperseded: !recallScope || Boolean(includeSuperseded) || Boolean(asOf),
|
|
189
184
|
admit,
|
|
190
185
|
};
|
|
186
|
+
return rankBothStores(query, { local: localRoot, global: globalRoot }, { local: localEntries, global: globalEntries }, vectorCandidates, options);
|
|
187
|
+
}
|
|
188
|
+
/** Hybrid ranking of rows already loaded from each store: the local bump, one copy per text, then the shared budget. */
|
|
189
|
+
export async function rankBothStores(query, roots, entries, vectorCandidates, options = {}) {
|
|
190
|
+
const { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness } = options;
|
|
191
|
+
if (entries.local.length === 0 && entries.global.length === 0)
|
|
192
|
+
return [];
|
|
191
193
|
const shared = { budget, now, embeddingWeight, explain, mmr, mmrLambda, minResults, cost, scope, includeSuperseded, asOf, summaryDeboost, summaryFreshness, vectorCandidates };
|
|
192
|
-
const localResults = await hybridSearch(query,
|
|
193
|
-
const globalResults = await hybridSearch(query,
|
|
194
|
+
const localResults = await hybridSearch(query, entries.local, { ...shared, hippoRoot: roots.local });
|
|
195
|
+
const globalResults = await hybridSearch(query, entries.global, { ...shared, hippoRoot: roots.global });
|
|
194
196
|
// Tag global results. Local memories get a configurable priority bump.
|
|
195
197
|
const tagged = [
|
|
196
198
|
...localResults.map((r) => ({
|
package/dist/skills.js
CHANGED
|
@@ -111,9 +111,76 @@ function buildSkillContent(skillName, instructions, trigger) {
|
|
|
111
111
|
content += `\n\n${instructions}`;
|
|
112
112
|
return content;
|
|
113
113
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
114
|
+
// Preflight the supersede target BEFORE inserting the new row (so the new
|
|
115
|
+
// autoincrement id can never be its own supersede target); read the
|
|
116
|
+
// predecessor version in the same SELECT for server-derived versioning.
|
|
117
|
+
// Mirrors saveProcess / savePolicy (codex P1 2026-05-28).
|
|
118
|
+
function preflightSkillSupersede(db, tenantId, supersedesId) {
|
|
119
|
+
// SAFETY: row shape matches the `status, version` columns named in the SELECT below.
|
|
120
|
+
const pred = db.prepare(`SELECT status, version FROM skills WHERE id = ? AND tenant_id = ?`).get(supersedesId, tenantId);
|
|
121
|
+
if (!pred) {
|
|
122
|
+
throw new NotFoundError(`saveSkill: skill ${supersedesId} to supersede not found for tenant ${tenantId}`);
|
|
123
|
+
}
|
|
124
|
+
if (pred.status !== 'active') {
|
|
125
|
+
throw new ConflictError(`saveSkill: skill ${supersedesId} is not active (status='${pred.status}'); only active skills can be superseded.`);
|
|
126
|
+
}
|
|
127
|
+
return pred.version + 1;
|
|
128
|
+
}
|
|
129
|
+
function insertSkillRow(db, memoryId, w, version) {
|
|
130
|
+
const result = db.prepare(`
|
|
131
|
+
INSERT INTO skills(
|
|
132
|
+
memory_id, tenant_id, skill_name, instructions, trigger_text, version,
|
|
133
|
+
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
134
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
135
|
+
`).run(memoryId, w.tenantId, w.name, w.instructions, w.trigger, version, w.changeSummary, w.now);
|
|
136
|
+
return Number(result.lastInsertRowid ?? 0);
|
|
137
|
+
}
|
|
138
|
+
function supersedeSkillRow(db, w, supersedesId, skillId, version) {
|
|
139
|
+
const sup = db.prepare(`
|
|
140
|
+
UPDATE skills
|
|
141
|
+
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
142
|
+
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
143
|
+
`).run(skillId, w.now, supersedesId, w.tenantId, skillId);
|
|
144
|
+
if (sup.changes === 0) {
|
|
145
|
+
throw new ConflictError(`saveSkill: skill ${supersedesId} could not be superseded (no longer active or self-reference).`);
|
|
146
|
+
}
|
|
147
|
+
appendAuditEvent(db, {
|
|
148
|
+
tenantId: w.tenantId,
|
|
149
|
+
actor: w.actor,
|
|
150
|
+
op: 'skill_supersede',
|
|
151
|
+
targetId: String(supersedesId),
|
|
152
|
+
metadata: {
|
|
153
|
+
skill_id: supersedesId,
|
|
154
|
+
superseded_by: skillId,
|
|
155
|
+
new_version: version,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** The afterWrite body: preflight, INSERT, supersede, reload, create audit, all in one SAVEPOINT. */
|
|
160
|
+
function writeSkillRow(db, memoryId, w) {
|
|
161
|
+
const version = w.supersedesId !== undefined ? preflightSkillSupersede(db, w.tenantId, w.supersedesId) : 1;
|
|
162
|
+
const skillId = insertSkillRow(db, memoryId, w, version);
|
|
163
|
+
if (w.supersedesId !== undefined)
|
|
164
|
+
supersedeSkillRow(db, w, w.supersedesId, skillId, version);
|
|
165
|
+
// SAFETY: row's shape matches the columns named in SKILL_COLS above.
|
|
166
|
+
const row = db.prepare(`SELECT ${SKILL_COLS} FROM skills WHERE id = ?`)
|
|
167
|
+
.get(skillId);
|
|
168
|
+
if (!row)
|
|
169
|
+
throw new Error('saveSkill: failed to reload saved skill row');
|
|
170
|
+
// GDPR-light metadata: ids + flags only, no skill text.
|
|
171
|
+
appendAuditEvent(db, {
|
|
172
|
+
tenantId: w.tenantId,
|
|
173
|
+
actor: w.actor,
|
|
174
|
+
op: 'skill_create',
|
|
175
|
+
targetId: String(skillId),
|
|
176
|
+
metadata: {
|
|
177
|
+
skill_id: skillId,
|
|
178
|
+
version,
|
|
179
|
+
has_trigger: w.trigger !== null,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return row;
|
|
183
|
+
}
|
|
117
184
|
/**
|
|
118
185
|
* Create a skill (or a new version that supersedes an existing one). Writes the
|
|
119
186
|
* memory mirror + the skills row atomically inside writeEntry's SAVEPOINT. When
|
|
@@ -137,72 +204,21 @@ export function saveSkill(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
137
204
|
baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
|
|
138
205
|
tenantId,
|
|
139
206
|
});
|
|
207
|
+
const w = {
|
|
208
|
+
tenantId,
|
|
209
|
+
actor,
|
|
210
|
+
name,
|
|
211
|
+
instructions: opts.instructions,
|
|
212
|
+
trigger,
|
|
213
|
+
changeSummary,
|
|
214
|
+
supersedesId: opts.supersedesSkillId,
|
|
215
|
+
now,
|
|
216
|
+
};
|
|
140
217
|
let savedRow;
|
|
141
218
|
writeEntry(hippoRoot, mem, {
|
|
142
219
|
actor,
|
|
143
220
|
afterWrite: (db, memoryId) => {
|
|
144
|
-
|
|
145
|
-
// autoincrement id can never be its own supersede target); read the
|
|
146
|
-
// predecessor version in the same SELECT for server-derived versioning.
|
|
147
|
-
// Mirrors saveProcess / savePolicy (codex P1 2026-05-28).
|
|
148
|
-
let version = 1;
|
|
149
|
-
if (opts.supersedesSkillId !== undefined) {
|
|
150
|
-
// SAFETY: row shape matches the `status, version` columns named in the SELECT below.
|
|
151
|
-
const pred = db.prepare(`SELECT status, version FROM skills WHERE id = ? AND tenant_id = ?`).get(opts.supersedesSkillId, tenantId);
|
|
152
|
-
if (!pred) {
|
|
153
|
-
throw new NotFoundError(`saveSkill: skill ${opts.supersedesSkillId} to supersede not found for tenant ${tenantId}`);
|
|
154
|
-
}
|
|
155
|
-
if (pred.status !== 'active') {
|
|
156
|
-
throw new ConflictError(`saveSkill: skill ${opts.supersedesSkillId} is not active (status='${pred.status}'); only active skills can be superseded.`);
|
|
157
|
-
}
|
|
158
|
-
version = pred.version + 1;
|
|
159
|
-
}
|
|
160
|
-
const result = db.prepare(`
|
|
161
|
-
INSERT INTO skills(
|
|
162
|
-
memory_id, tenant_id, skill_name, instructions, trigger_text, version,
|
|
163
|
-
status, superseded_by, superseded_at, change_summary, closed_at, created_at
|
|
164
|
-
) VALUES (?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, NULL, ?)
|
|
165
|
-
`).run(memoryId, tenantId, name, opts.instructions, trigger, version, changeSummary, now);
|
|
166
|
-
const skillId = Number(result.lastInsertRowid ?? 0);
|
|
167
|
-
if (opts.supersedesSkillId !== undefined) {
|
|
168
|
-
const sup = db.prepare(`
|
|
169
|
-
UPDATE skills
|
|
170
|
-
SET status = 'superseded', superseded_by = ?, superseded_at = ?
|
|
171
|
-
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
172
|
-
`).run(skillId, now, opts.supersedesSkillId, tenantId, skillId);
|
|
173
|
-
if (sup.changes === 0) {
|
|
174
|
-
throw new ConflictError(`saveSkill: skill ${opts.supersedesSkillId} could not be superseded (no longer active or self-reference).`);
|
|
175
|
-
}
|
|
176
|
-
appendAuditEvent(db, {
|
|
177
|
-
tenantId,
|
|
178
|
-
actor,
|
|
179
|
-
op: 'skill_supersede',
|
|
180
|
-
targetId: String(opts.supersedesSkillId),
|
|
181
|
-
metadata: {
|
|
182
|
-
skill_id: opts.supersedesSkillId,
|
|
183
|
-
superseded_by: skillId,
|
|
184
|
-
new_version: version,
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
// SAFETY: row's shape matches the columns named in SKILL_COLS above.
|
|
189
|
-
const row = db.prepare(`SELECT ${SKILL_COLS} FROM skills WHERE id = ?`)
|
|
190
|
-
.get(skillId);
|
|
191
|
-
if (!row)
|
|
192
|
-
throw new Error('saveSkill: failed to reload saved skill row');
|
|
193
|
-
savedRow = row;
|
|
194
|
-
// GDPR-light metadata: ids + flags only, no skill text.
|
|
195
|
-
appendAuditEvent(db, {
|
|
196
|
-
tenantId,
|
|
197
|
-
actor,
|
|
198
|
-
op: 'skill_create',
|
|
199
|
-
targetId: String(skillId),
|
|
200
|
-
metadata: {
|
|
201
|
-
skill_id: skillId,
|
|
202
|
-
version,
|
|
203
|
-
has_trigger: trigger !== null,
|
|
204
|
-
},
|
|
205
|
-
});
|
|
221
|
+
savedRow = writeSkillRow(db, memoryId, w);
|
|
206
222
|
},
|
|
207
223
|
});
|
|
208
224
|
if (!savedRow) {
|
|
@@ -68,8 +68,8 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
|
|
|
68
68
|
* mode (its `tenantId` option is optional); `loadSearchRows` already treats
|
|
69
69
|
* undefined as "no tenant filter" for legacy callers.
|
|
70
70
|
*/
|
|
71
|
-
export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean): MemoryEntry[];
|
|
72
|
-
export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean): MemoryEntry[];
|
|
71
|
+
export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProject?: string): MemoryEntry[];
|
|
72
|
+
export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProject?: string): MemoryEntry[];
|
|
73
73
|
/** Which rows the vector arm of hybrid search may add: the same tenant, scope and superseded rules as the lexical load. */
|
|
74
74
|
export interface VectorCandidateSpec {
|
|
75
75
|
tenantId?: string;
|
|
@@ -22,6 +22,12 @@ function recallScopeClause(col, scopeFilter) {
|
|
|
22
22
|
// The trailing arm keeps a deliberately requested scope loadable, private or quarantined included.
|
|
23
23
|
return { sql: ` AND (${admitted} OR ${col}scope = ?)`, params: [...RECALL_DEFAULT_DENY_SCOPES, scopeFilter.value] };
|
|
24
24
|
}
|
|
25
|
+
// In SQL, not after the window cut, so other projects' matches cannot crowd the project's own rows out of the LIMIT.
|
|
26
|
+
function withProject(scope, col, originProject) {
|
|
27
|
+
if (originProject === undefined)
|
|
28
|
+
return scope;
|
|
29
|
+
return { sql: `${scope.sql} AND (${col}origin_project = '' OR ${col}origin_project = ?)`, params: [...scope.params, originProject] };
|
|
30
|
+
}
|
|
25
31
|
/** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set. */
|
|
26
32
|
export function recallScopeFilter(requestedScope, mode) {
|
|
27
33
|
if (!requestedScope)
|
|
@@ -29,8 +35,8 @@ export function recallScopeFilter(requestedScope, mode) {
|
|
|
29
35
|
return mode === 'additive' ? { mode: 'default-deny-or-exact', value: requestedScope } : { mode: 'exact', value: requestedScope };
|
|
30
36
|
}
|
|
31
37
|
const FTS_QUERY_SYNTAX_RE = /fts5: syntax error|unterminated string/i;
|
|
32
|
-
function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded = true) {
|
|
33
|
-
const p = searchPredicates(tenantId, scopeFilter, includeSuperseded);
|
|
38
|
+
function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded = true, originProject) {
|
|
39
|
+
const p = searchPredicates(tenantId, scopeFilter, includeSuperseded, originProject);
|
|
34
40
|
const terms = Array.from(new Set(tokenize(query)));
|
|
35
41
|
if (terms.length === 0) {
|
|
36
42
|
// F3 (v1.7.0) self-review: empty-query path is the second uncapped
|
|
@@ -61,7 +67,7 @@ function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSupersed
|
|
|
61
67
|
// caller's cap.
|
|
62
68
|
return selectAllCandidates(db, p, limit);
|
|
63
69
|
}
|
|
64
|
-
function searchPredicates(tenantId, scopeFilter, includeSuperseded) {
|
|
70
|
+
function searchPredicates(tenantId, scopeFilter, includeSuperseded, originProject) {
|
|
65
71
|
// tenantId undefined = no tenant filter (legacy callers / cross-deployment
|
|
66
72
|
// helpers). tenantId set = strict tenant isolation, leveraging the composite
|
|
67
73
|
// idx_memories_tenant_created (leading column tenant_id, O(log n) lookup).
|
|
@@ -89,8 +95,8 @@ function searchPredicates(tenantId, scopeFilter, includeSuperseded) {
|
|
|
89
95
|
// by always joining `tenantOnlyPredicate + archivedClauseTenantOnly` where
|
|
90
96
|
// the latter switches between " AND" and " WHERE" based on caller context.
|
|
91
97
|
const archivedClauseTenantOnly = tenantId !== undefined ? ` AND kind != 'archived'` : ` WHERE kind != 'archived'`;
|
|
92
|
-
const aliasScope = recallScopeClause('m.', scopeFilter);
|
|
93
|
-
const plainScope = recallScopeClause('', scopeFilter);
|
|
98
|
+
const aliasScope = withProject(recallScopeClause('m.', scopeFilter), 'm.', originProject);
|
|
99
|
+
const plainScope = withProject(recallScopeClause('', scopeFilter), '', originProject);
|
|
94
100
|
const scopeParams = aliasScope.params;
|
|
95
101
|
const currentAlias = includeSuperseded ? '' : ' AND m.superseded_by IS NULL';
|
|
96
102
|
const currentNoAlias = includeSuperseded ? '' : ' AND superseded_by IS NULL';
|
|
@@ -193,10 +199,10 @@ export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDI
|
|
|
193
199
|
* mode (its `tenantId` option is optional); `loadSearchRows` already treats
|
|
194
200
|
* undefined as "no tenant filter" for legacy callers.
|
|
195
201
|
*/
|
|
196
|
-
export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true) {
|
|
202
|
+
export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProject) {
|
|
197
203
|
const db = openStore(hippoRoot);
|
|
198
204
|
try {
|
|
199
|
-
return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded);
|
|
205
|
+
return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded, originProject);
|
|
200
206
|
}
|
|
201
207
|
finally {
|
|
202
208
|
closeHippoDb(db);
|
|
@@ -204,8 +210,8 @@ export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH
|
|
|
204
210
|
}
|
|
205
211
|
// Split out so callers with an already-open db (Z1 prompt-recall path) skip
|
|
206
212
|
// the initStore+open/close cycle per store per call.
|
|
207
|
-
export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true) {
|
|
208
|
-
return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode), includeSuperseded).map(rowToEntry);
|
|
213
|
+
export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProject) {
|
|
214
|
+
return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode), includeSuperseded, originProject).map(rowToEntry);
|
|
209
215
|
}
|
|
210
216
|
/** The rows nearest `queryVector` that pass `spec`, nearest first. */
|
|
211
217
|
export function loadVectorCandidateEntries(hippoRoot, queryVector, spec) {
|
package/dist/version.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* an ESM `import` can resolve cleanly, and a hardcoded constant survives
|
|
17
17
|
* any packager that drops .json files.
|
|
18
18
|
*/
|
|
19
|
-
export declare const PACKAGE_VERSION = "1.
|
|
19
|
+
export declare const PACKAGE_VERSION = "1.62.0";
|
|
20
20
|
/** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
|
|
21
21
|
export declare function compareSemver(a: string, b: string): number;
|
|
22
22
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* an ESM `import` can resolve cleanly, and a hardcoded constant survives
|
|
17
17
|
* any packager that drops .json files.
|
|
18
18
|
*/
|
|
19
|
-
export const PACKAGE_VERSION = '1.
|
|
19
|
+
export const PACKAGE_VERSION = '1.62.0';
|
|
20
20
|
/** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
|
|
21
21
|
export function compareSemver(a, b) {
|
|
22
22
|
const parse = (v) => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hippo-memory",
|
|
3
3
|
"name": "Hippo Memory",
|
|
4
4
|
"description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.62.0",
|
|
6
6
|
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hippo-memory",
|
|
3
3
|
"name": "Hippo Memory",
|
|
4
4
|
"description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.62.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hippo-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.62.0",
|
|
4
4
|
"description": "Memory for AI agents that learns what is wrong and ranks it down. MCP server, hooks for Claude Code, OpenCode and Codex, AGENTS.md instructions for Codex, Cursor, OpenClaw and Pi. SQLite, zero runtime deps.",
|
|
5
5
|
"mcpName": "io.github.kitfunso/hippo-memory",
|
|
6
6
|
"type": "module",
|