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.
Files changed (57) hide show
  1. package/README.md +28 -53
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.d.ts +3 -1
  4. package/dist/agent-memories/claude-code.js +53 -9
  5. package/dist/agent-memories/sync.d.ts +3 -3
  6. package/dist/agent-memories/sync.js +14 -6
  7. package/dist/agent-memories/types.d.ts +0 -2
  8. package/dist/api/assemble.js +55 -54
  9. package/dist/api/context-select.d.ts +49 -0
  10. package/dist/api/context-select.js +344 -0
  11. package/dist/api/context.d.ts +2 -2
  12. package/dist/api/context.js +195 -522
  13. package/dist/api/drill-down.js +36 -33
  14. package/dist/api/promote.js +55 -66
  15. package/dist/api/recall.js +303 -438
  16. package/dist/api/sleep.js +203 -218
  17. package/dist/capture/compact.d.ts +1 -1
  18. package/dist/capture/compact.js +2 -2
  19. package/dist/cli/briefs.js +324 -306
  20. package/dist/cli/context.js +44 -34
  21. package/dist/cli/continuity.js +283 -271
  22. package/dist/cli/curate.js +35 -34
  23. package/dist/cli/decisions.js +333 -333
  24. package/dist/cli/explain.js +66 -60
  25. package/dist/cli/maintenance.js +62 -51
  26. package/dist/cli/playbooks.js +387 -370
  27. package/dist/cli/projects.js +8 -5
  28. package/dist/cli/recall.js +28 -43
  29. package/dist/cli/remember.js +113 -70
  30. package/dist/cli/session-hooks.js +100 -90
  31. package/dist/cli/setup.js +267 -246
  32. package/dist/cli/status.js +73 -64
  33. package/dist/cli/transfer.js +85 -99
  34. package/dist/compaction-record.d.ts +0 -2
  35. package/dist/compaction-record.js +1 -1
  36. package/dist/customer-notes.js +77 -68
  37. package/dist/dag.js +222 -186
  38. package/dist/decisions.js +93 -76
  39. package/dist/doctor.js +11 -7
  40. package/dist/goals.js +99 -86
  41. package/dist/incidents.js +45 -38
  42. package/dist/policies.js +85 -68
  43. package/dist/processes.js +87 -71
  44. package/dist/project-briefs.js +135 -108
  45. package/dist/project-merge.d.ts +12 -4
  46. package/dist/project-merge.js +130 -45
  47. package/dist/shared.d.ts +9 -0
  48. package/dist/shared.js +10 -8
  49. package/dist/skills.js +81 -65
  50. package/dist/store/search-rows.d.ts +2 -2
  51. package/dist/store/search-rows.js +15 -9
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  55. package/extensions/openclaw-plugin/package.json +1 -1
  56. package/openclaw.plugin.json +1 -1
  57. package/package.json +1 -1
@@ -1,14 +1,19 @@
1
- // `hippo projects`: list the project names a store holds, fold one into another, and re-tag sleep's old user-global merges.
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 { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS } from './agent-memories/tools.js';
6
- import { appendAuditEvent } from './audit.js';
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(e.content, (holders.get(e.content) ?? new Set()).add(origin));
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 rows = selectAllEntries(db, tenantId).filter((e) => e.origin_project === from);
82
- const setAside = [];
83
- for (const row of rows) {
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
- /** Reads only, so doctor can call it on a read-only handle: what the repair would do to each user-global merged row. */
114
- export function planUserGlobalRepair(db, tenantId) {
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 origins = new Map(listDormantSnapshots(db, tenantId).map((s) => [s.entry.id, s.entry.origin_project ?? null]));
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 ?? null);
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
- /** Re-tags sleep's merged rows saved as user-global before the fix, by the projects of their parents. */
139
- export function repairUserGlobalMerges(db, hippoRoot, opts) {
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 { ...planUserGlobalRepair(db, tenantId), backup: null };
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 plan = planUserGlobalRepair(db, tenantId);
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 { ...plan, backup };
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, result.toProject.map((r) => r.id), result.setAside);
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 { budget = 4000, now = evalNow(), embeddingWeight, explain, mmr, mmrLambda, localBump = 1.2, minResults, cost, scope, includeSuperseded, asOf, tenantId, summaryDeboost, summaryFreshness, entryFilter, recallScope } = options;
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, localEntries, { ...shared, hippoRoot: localRoot });
193
- const globalResults = await hybridSearch(query, globalEntries, { ...shared, hippoRoot: globalRoot });
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
- // Public API
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
- // Preflight the supersede target BEFORE inserting the new row (so the new
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.61.0";
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.61.0';
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.61.0",
5
+ "version": "1.62.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.61.0",
3
+ "version": "1.62.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -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.61.0",
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.61.0",
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",