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
@@ -0,0 +1,344 @@
1
+ // getContext's selection stages: the pinned-only branch, the strongest-first branch and the search branch.
2
+ import { openHippoDb, closeHippoDb } from '../db.js';
3
+ import { recallScopeFilter } from '../store/search-rows.js';
4
+ import { loadIndex } from '../store/index-and-stats.js';
5
+ import { calculateStrength } from '../memory.js';
6
+ import { appendAuditEvent, auditQueryFields, isContentWorthStoring } from '../audit.js';
7
+ import { rankBothStores } from '../shared.js';
8
+ import { evalNow } from '../ablation.js';
9
+ import { hybridSearch } from '../search/hybrid.js';
10
+ import { physicsSearch } from '../search/physics-search.js';
11
+ import { compareScoredResults } from '../compare.js';
12
+ import { scopeMatch } from '../scope.js';
13
+ import { loadConfig } from '../config.js';
14
+ import { promptTokens, contentTokens, gatePromptRecall, } from '../prompt-recall.js';
15
+ // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
16
+ // A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
17
+ export function oneCopyPerMemory(local, global, now) {
18
+ const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
19
+ const best = new Map();
20
+ const offer = (entry, isGlobal) => {
21
+ const held = best.get(entry.content);
22
+ const wins = !held || (held.entry.pinned !== entry.pinned
23
+ ? entry.pinned
24
+ : score(entry, isGlobal) > score(held.entry, held.isGlobal));
25
+ if (wins)
26
+ best.set(entry.content, { entry, isGlobal });
27
+ };
28
+ for (const e of local)
29
+ offer(e, false);
30
+ for (const e of global)
31
+ offer(e, true);
32
+ const kept = new Set([...best.values()].map((b) => b.entry));
33
+ return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
34
+ }
35
+ export const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
36
+ /** Pins plus the prompt-recall or recent-N backfill; null means the block is empty. */
37
+ export function selectPinned(ctx, opts, plan, left, pools, admission) {
38
+ const { obs, primaryIsGlobal } = plan;
39
+ // loadConfig is safe even when local isn't initialised — returns defaults.
40
+ const pinnedCfg = loadConfig(ctx.hippoRoot);
41
+ if (!pinnedCfg.pinnedInject.enabled) {
42
+ return null;
43
+ }
44
+ // Effective budget: explicit opts.budget wins over config, less what the sections took.
45
+ const effBudget = left;
46
+ const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
47
+ const localEntries = pools.local.entries;
48
+ const globalEntries = pools.global.entries;
49
+ obs?.offer(localEntries, primaryIsGlobal);
50
+ obs?.offer(globalEntries, true);
51
+ const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
52
+ obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
53
+ const picked = { items: [], ids: new Set(), used: 0 };
54
+ // Pinned entries are explicit user intent, the recent-N list an automatic
55
+ // backfill. Both loops share ONE budget and the recent loop runs first, so
56
+ // pins are ranked here and reserve their share before it can spend.
57
+ const pinnedLocal = localPool.filter((e) => e.pinned);
58
+ const pinnedGlobal = globalPool.filter((e) => e.pinned);
59
+ const rankedPinned = rankPinned(plan, pinnedLocal, pinnedGlobal, nowP);
60
+ const recentBudget = Math.max(0, effBudget - reservePinned(rankedPinned, effBudget));
61
+ // Prompt recall gates the backfill on the prompt instead of recency.
62
+ if (plan.promptRecallPending) {
63
+ const candidates = () => promptRecallCandidates(plan, pools, admission.admit, rankedPinned, nowP);
64
+ backfillFromPrompt(opts, plan, pinnedCfg, candidates, picked, recentBudget);
65
+ }
66
+ else if (plan.includeRecent > 0) {
67
+ backfillRecent(plan, localPool, globalPool, picked, recentBudget, nowP);
68
+ }
69
+ if (pinnedLocal.length === 0 &&
70
+ pinnedGlobal.length === 0 &&
71
+ picked.items.length === 0 &&
72
+ !admission.digestHidden()) {
73
+ return null;
74
+ }
75
+ admitWithinBudget(rankedPinned, picked, effBudget, obs);
76
+ return picked.items;
77
+ }
78
+ function rankPinned(plan, pinnedLocal, pinnedGlobal, nowP) {
79
+ return [
80
+ ...pinnedLocal.map((e) => ({ entry: e, isGlobal: plan.primaryIsGlobal })),
81
+ ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
82
+ ]
83
+ .map(({ entry, isGlobal }) => {
84
+ const scopeSig = scopeMatch(entry.tags, plan.activeScope);
85
+ const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
86
+ return {
87
+ entry,
88
+ score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
89
+ tokens: plan.price(entry, isGlobal),
90
+ isGlobal,
91
+ };
92
+ })
93
+ .sort(compareScoredResults);
94
+ }
95
+ // Mirrors the pin loop's continue-not-break so a big pin cannot block smaller ones from reserving, and dedupes by id
96
+ // because a synced pin sits in both stores. A pin also in the recent slice is counted twice: recents under-fill, safely.
97
+ function reservePinned(rankedPinned, effBudget) {
98
+ let pinnedReserve = 0;
99
+ const reservedIds = new Set();
100
+ for (const r of rankedPinned) {
101
+ if (reservedIds.has(r.entry.id))
102
+ continue;
103
+ if (pinnedReserve + r.tokens <= effBudget) {
104
+ pinnedReserve += r.tokens;
105
+ reservedIds.add(r.entry.id);
106
+ }
107
+ }
108
+ return pinnedReserve;
109
+ }
110
+ /** Skips ids already picked and rows past the budget, so a large row never blocks smaller ones behind it. */
111
+ function admitWithinBudget(rows, picked, budget, obs) {
112
+ for (const r of rows) {
113
+ if (picked.ids.has(r.entry.id))
114
+ continue;
115
+ if (picked.used + r.tokens > budget) {
116
+ obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
117
+ continue;
118
+ }
119
+ picked.items.push(r);
120
+ picked.ids.add(r.entry.id);
121
+ picked.used += r.tokens;
122
+ }
123
+ }
124
+ function backfillFromPrompt(opts, plan, pinnedCfg, candidates, picked, recentBudget) {
125
+ const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
126
+ const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
127
+ const gate = {
128
+ metric,
129
+ threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
130
+ minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
131
+ maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
132
+ };
133
+ const p = promptTokens(opts.prompt ?? '');
134
+ if (p.size === 0)
135
+ return;
136
+ const candidateItems = candidates();
137
+ const gated = gatePromptRecall(p, candidateItems, gate);
138
+ plan.obs?.gated(p, candidateItems, gate, gated);
139
+ for (const g of gated) {
140
+ if (picked.ids.has(g.item.id))
141
+ continue;
142
+ const tokens = plan.price(g.item.entry, g.item.isGlobal, true);
143
+ if (picked.used + tokens > recentBudget) {
144
+ plan.obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
145
+ continue;
146
+ }
147
+ picked.items.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
148
+ picked.ids.add(g.item.id);
149
+ picked.used += tokens;
150
+ }
151
+ }
152
+ // Candidates came off the ambient load's own connection (the recall request), not a fresh open.
153
+ function promptRecallCandidates(plan, pools, admit, rankedPinned, nowP) {
154
+ const { obs, primaryIsGlobal } = plan;
155
+ // A candidate carrying a pin's text would inject that memory a second time.
156
+ const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
157
+ const ineligibleReason = (e) => !admit(e) ? 'scope'
158
+ : e.pinned ? 'pinned'
159
+ : !isContentWorthStoring(e.content) ? 'quality'
160
+ : pinnedText.has(e.content) ? 'duplicate'
161
+ : null;
162
+ const eligible = (e) => {
163
+ const why = ineligibleReason(e);
164
+ if (why !== null && why !== 'pinned')
165
+ obs?.reject(e, 'eligible', why);
166
+ return why === null;
167
+ };
168
+ obs?.offer(pools.local.recall ?? [], primaryIsGlobal, 'prompt-recall');
169
+ obs?.offer(pools.global.recall ?? [], true, 'prompt-recall');
170
+ const localEligible = (pools.local.recall ?? []).filter(eligible);
171
+ const globalEligible = (pools.global.recall ?? []).filter(eligible);
172
+ const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
173
+ obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
174
+ const seenCandidateIds = new Set();
175
+ const candidateItems = [];
176
+ // Local wins the id collision (a global row synced into the local store).
177
+ for (const e of localCandidates) {
178
+ if (seenCandidateIds.has(e.id))
179
+ continue;
180
+ seenCandidateIds.add(e.id);
181
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
182
+ }
183
+ for (const e of globalCandidates) {
184
+ if (seenCandidateIds.has(e.id))
185
+ continue;
186
+ seenCandidateIds.add(e.id);
187
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
188
+ }
189
+ return candidateItems;
190
+ }
191
+ function backfillRecent(plan, localPool, globalPool, picked, recentBudget, nowP) {
192
+ const recent = [
193
+ ...localPool.map((entry) => ({ entry, isGlobal: plan.primaryIsGlobal })),
194
+ ...globalPool.map((entry) => ({ entry, isGlobal: true })),
195
+ ]
196
+ // Newest first, then id: stable within one store, but same-millisecond rows fall to random ids across ingests.
197
+ .sort((a, b) => {
198
+ const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
199
+ return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
200
+ })
201
+ // Filter before slice so a junk row is backfilled past, not counted against N. Pins bypass the floor: a dropped
202
+ // pin's share of the shared budget would go to a backfilled row, and the pin loop could not win it back.
203
+ .filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
204
+ .slice(0, plan.includeRecent)
205
+ .map(({ entry, isGlobal }) => ({
206
+ entry,
207
+ score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
208
+ tokens: plan.price(entry, isGlobal),
209
+ isGlobal,
210
+ }));
211
+ admitWithinBudget(recent, picked, recentBudget, plan.obs);
212
+ }
213
+ /** No query: the strongest memories by strength, up to budget. */
214
+ export function selectStrongest(plan, left, pools) {
215
+ const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
216
+ const [localPool, globalPool] = oneCopyPerMemory(pools.local.entries, pools.global.entries, now);
217
+ const localRanked = localPool
218
+ .map((e) => ({
219
+ entry: e,
220
+ score: calculateStrength(e, now),
221
+ tokens: plan.price(e, plan.primaryIsGlobal),
222
+ isGlobal: plan.primaryIsGlobal,
223
+ }))
224
+ .sort(compareScoredResults);
225
+ const globalRanked = globalPool
226
+ .map((e) => ({
227
+ entry: e,
228
+ score: calculateStrength(e, now) * (1 / 1.2),
229
+ tokens: plan.price(e, true),
230
+ isGlobal: true,
231
+ }))
232
+ .sort(compareScoredResults);
233
+ const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
234
+ const selected = [];
235
+ let used = 0;
236
+ for (const r of combined) {
237
+ if (used + r.tokens > left)
238
+ continue;
239
+ selected.push(r);
240
+ used += r.tokens;
241
+ }
242
+ return selected;
243
+ }
244
+ /** Real query: hybrid search over both stores, or physics/hybrid over the local rows; emits the 'recall' audit row. */
245
+ export async function selectBySearch(ctx, plan, left, pools, admission) {
246
+ const minResults = plan.cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
247
+ const results = plan.hasGlobal && !plan.primaryIsGlobal
248
+ ? await searchBothStores(ctx, plan, left, minResults, pools, admission.bothStoresAdmit)
249
+ : await searchLocalRows(ctx, plan, left, minResults, pools.local.entries, admission.admit);
250
+ auditContextRecall(ctx, plan, results.length);
251
+ return results;
252
+ }
253
+ // The pools were admitted at load, before ranking, dedupe and budget: a post-filter would let an excluded row fill the
254
+ // budget or shadow its admitted duplicate.
255
+ async function searchBothStores(ctx, plan, left, minResults, pools, admit) {
256
+ const { cost, price } = plan;
257
+ const localIndex = loadIndex(ctx.hippoRoot);
258
+ const isGlobalHit = (e) => !localIndex.entries[e.id];
259
+ const roots = { local: ctx.hippoRoot, global: plan.globalRoot };
260
+ const merged = await rankBothStores(plan.query, roots, { local: pools.local.entries, global: pools.global.entries }, contextVectorSpec(ctx, plan, admit), {
261
+ budget: left,
262
+ minResults,
263
+ cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
264
+ scope: plan.activeScope,
265
+ });
266
+ return merged.map((r) => ({
267
+ entry: r.entry,
268
+ score: r.score,
269
+ tokens: price(r.entry, isGlobalHit(r.entry)),
270
+ isGlobal: isGlobalHit(r.entry),
271
+ }));
272
+ }
273
+ /** The vector arm under the lexical window's own tenant, scope and current-row rules. */
274
+ function contextVectorSpec(ctx, plan, admit) {
275
+ return { tenantId: ctx.tenantId, scope: recallScopeFilter(plan.exactScope, 'exact'), includeSuperseded: false, admit };
276
+ }
277
+ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit) {
278
+ const { cost, price, primaryIsGlobal, query } = plan;
279
+ const ctxConfig = loadConfig(ctx.hippoRoot);
280
+ const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
281
+ const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
282
+ const vectorCandidates = contextVectorSpec(ctx, plan, admit);
283
+ const ctxResults = usePhysicsCtx
284
+ ? await physicsSearch(query, localEntries, {
285
+ budget: left,
286
+ minResults,
287
+ cost: localCost,
288
+ hippoRoot: ctx.hippoRoot,
289
+ physicsConfig: ctxConfig.physics,
290
+ scope: plan.activeScope,
291
+ vectorCandidates,
292
+ })
293
+ : await hybridSearch(query, localEntries, {
294
+ budget: left,
295
+ minResults,
296
+ cost: localCost,
297
+ hippoRoot: ctx.hippoRoot,
298
+ scope: plan.activeScope,
299
+ vectorCandidates,
300
+ });
301
+ return ctxResults.map((r) => ({
302
+ entry: r.entry,
303
+ score: r.score,
304
+ tokens: price(r.entry, primaryIsGlobal),
305
+ isGlobal: primaryIsGlobal,
306
+ }));
307
+ }
308
+ // Same 'recall' op api.recall emits; the pinned-only and no-query branches never search, so they never emit.
309
+ function auditContextRecall(ctx, plan, resultCount) {
310
+ const ctxRecallMetadata = {
311
+ ...auditQueryFields(plan.query),
312
+ results: resultCount,
313
+ mode: 'context',
314
+ };
315
+ if (plan.hasLocal) {
316
+ const localDb = openHippoDb(ctx.hippoRoot);
317
+ try {
318
+ appendAuditEvent(localDb, {
319
+ tenantId: ctx.tenantId,
320
+ actor: ctx.actor.subject,
321
+ op: 'recall',
322
+ metadata: ctxRecallMetadata,
323
+ });
324
+ }
325
+ finally {
326
+ closeHippoDb(localDb);
327
+ }
328
+ }
329
+ if (plan.hasGlobal && !plan.primaryIsGlobal) {
330
+ const globalDb = openHippoDb(plan.globalRoot);
331
+ try {
332
+ appendAuditEvent(globalDb, {
333
+ tenantId: ctx.tenantId,
334
+ actor: ctx.actor.subject,
335
+ op: 'recall',
336
+ metadata: ctxRecallMetadata,
337
+ });
338
+ }
339
+ finally {
340
+ closeHippoDb(globalDb);
341
+ }
342
+ }
343
+ }
344
+ //# sourceMappingURL=context-select.js.map
@@ -1,6 +1,7 @@
1
1
  import { type MemoryEntry } from '../memory.js';
2
2
  import type { ContextOpts, ContextResult } from './context-types.js';
3
3
  import type { Context } from './types.js';
4
+ export { oneCopyPerMemory } from './context-select.js';
4
5
  /**
5
6
  * v39 S4: the secret half of the ambient policy on its own, for callers
6
7
  * that apply their own scope rule. A flagged row is only admitted inside its owning project;
@@ -9,7 +10,6 @@ import type { Context } from './types.js';
9
10
  export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean;
10
11
  /** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
11
12
  export declare const CONTEXT_CANDIDATE_CAP = 2000;
12
- export declare function oneCopyPerMemory(local: readonly MemoryEntry[], global: readonly MemoryEntry[], now: Date): [MemoryEntry[], MemoryEntry[]];
13
13
  /**
14
14
  * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
15
15
  * fallback / hybrid search) + active task snapshot + session handoff + recent
@@ -23,7 +23,7 @@ export declare function oneCopyPerMemory(local: readonly MemoryEntry[], global:
23
23
  * Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
24
24
  * `ctx.tenantId`. Cross-tenant rows are filtered out.
25
25
  *
26
- * Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
26
+ * @returns An empty result (`entries: []`, snapshot/handoff/events undefined)
27
27
  * when there's nothing to surface (no memories AND no snapshot AND no handoff
28
28
  * AND no recent events).
29
29
  */