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/api/context.js
CHANGED
|
@@ -1,31 +1,29 @@
|
|
|
1
1
|
// Ambient context injection: the admission policy and getContext.
|
|
2
|
-
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
3
2
|
import { isInitialized } from '../store/open.js';
|
|
4
3
|
import { strengthenRetrieved } from '../store/entry-writes.js';
|
|
5
|
-
import { loadRecallSearchEntries
|
|
4
|
+
import { loadRecallSearchEntries } from '../store/search-rows.js';
|
|
5
|
+
import { DEFAULT_SEARCH_CANDIDATE_LIMIT } from '../store/rows.js';
|
|
6
6
|
import { loadAmbientCandidates, loadContextCandidates, } from '../store/candidates.js';
|
|
7
7
|
import { loadIndex, saveIndex, updateStats } from '../store/index-and-stats.js';
|
|
8
8
|
import { loadFreshActiveTaskSnapshot, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS } from '../store/sessions.js';
|
|
9
9
|
import { loadLatestHandoff } from '../store/handoffs.js';
|
|
10
10
|
import { estimateTokens } from '../token-ledger.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { getGlobalRoot
|
|
11
|
+
import { markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
|
|
12
|
+
import { isContentWorthStoring } from '../audit.js';
|
|
13
|
+
import { getGlobalRoot } from '../shared.js';
|
|
14
14
|
import { writeRecallTraceAtRoot } from '../recall-trace.js';
|
|
15
15
|
import { evalNow, isRecallBoostAblated } from '../ablation.js';
|
|
16
|
-
import { hybridSearch } from '../search/hybrid.js';
|
|
17
|
-
import { physicsSearch } from '../search/physics-search.js';
|
|
18
|
-
import { compareScoredResults } from '../compare.js';
|
|
19
16
|
import { dropHeldCopies } from '../same-text.js';
|
|
20
|
-
import { scopeMatch } from '../scope.js';
|
|
21
17
|
import { loadConfig } from '../config.js';
|
|
22
18
|
import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from '../project-identity.js';
|
|
23
|
-
import { promptTokens
|
|
19
|
+
import { promptTokens } from '../prompt-recall.js';
|
|
24
20
|
import { detectSecret } from '../secret-detect.js';
|
|
25
21
|
import { isSessionDigestRow } from '../session-digest.js';
|
|
26
22
|
import { addAmbientTallies, ambientStateFromTallies } from '../ambient.js';
|
|
27
23
|
import { loadAmbientTallies } from '../ambient-store.js';
|
|
28
24
|
import { passesScopeFilterForRecall, assertScopeRequestAllowed } from '../recall-scope.js';
|
|
25
|
+
import { finiteOr, selectBySearch, selectPinned, selectStrongest, } from './context-select.js';
|
|
26
|
+
export { oneCopyPerMemory } from './context-select.js';
|
|
29
27
|
/**
|
|
30
28
|
* v39: the single ambient-injection admission policy, shared by getContext
|
|
31
29
|
* and the CLI-side ambient-state summary so the two cannot drift.
|
|
@@ -67,7 +65,7 @@ export const CONTEXT_CANDIDATE_CAP = 2000;
|
|
|
67
65
|
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, window, recall, onQualityDrop) {
|
|
68
66
|
if (!pinnedOnly) {
|
|
69
67
|
const rows = 'query' in window
|
|
70
|
-
? loadRecallSearchEntries(hippoRoot, window.query,
|
|
68
|
+
? loadRecallSearchEntries(hippoRoot, window.query, DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, window.exactScope, 'exact', false, window.project)
|
|
71
69
|
: loadContextCandidates(hippoRoot, tenantId, window);
|
|
72
70
|
return { entries: rows.filter(admit) };
|
|
73
71
|
}
|
|
@@ -83,27 +81,6 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
|
|
|
83
81
|
};
|
|
84
82
|
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
|
|
85
83
|
}
|
|
86
|
-
// Share and promote copy a memory to the global store under a new id, so equal content is the only link.
|
|
87
|
-
// A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
|
|
88
|
-
export function oneCopyPerMemory(local, global, now) {
|
|
89
|
-
const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
|
|
90
|
-
const best = new Map();
|
|
91
|
-
const offer = (entry, isGlobal) => {
|
|
92
|
-
const held = best.get(entry.content);
|
|
93
|
-
const wins = !held || (held.entry.pinned !== entry.pinned
|
|
94
|
-
? entry.pinned
|
|
95
|
-
: score(entry, isGlobal) > score(held.entry, held.isGlobal));
|
|
96
|
-
if (wins)
|
|
97
|
-
best.set(entry.content, { entry, isGlobal });
|
|
98
|
-
};
|
|
99
|
-
for (const e of local)
|
|
100
|
-
offer(e, false);
|
|
101
|
-
for (const e of global)
|
|
102
|
-
offer(e, true);
|
|
103
|
-
const kept = new Set([...best.values()].map((b) => b.entry));
|
|
104
|
-
return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
|
|
105
|
-
}
|
|
106
|
-
const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
|
|
107
84
|
/**
|
|
108
85
|
* Assemble a context bundle: recalled memories (pinned-only / strength-sorted
|
|
109
86
|
* fallback / hybrid search) + active task snapshot + session handoff + recent
|
|
@@ -117,77 +94,134 @@ const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
|
|
|
117
94
|
* Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
|
|
118
95
|
* `ctx.tenantId`. Cross-tenant rows are filtered out.
|
|
119
96
|
*
|
|
120
|
-
*
|
|
97
|
+
* @returns An empty result (`entries: []`, snapshot/handoff/events undefined)
|
|
121
98
|
* when there's nothing to surface (no memories AND no snapshot AND no handoff
|
|
122
99
|
* AND no recent events).
|
|
123
100
|
*/
|
|
124
101
|
export async function getContext(ctx, opts = {}) {
|
|
125
|
-
const pinnedOnly = opts.pinnedOnly === true;
|
|
126
102
|
const budget = opts.budget ?? 1500;
|
|
127
|
-
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
|
|
128
|
-
const includeRecent = opts.includeRecent ?? 0;
|
|
129
|
-
const activeScope = opts.scope ?? '';
|
|
130
103
|
assertScopeRequestAllowed(ctx.actor, opts.exactScope);
|
|
131
|
-
const exactScope = opts.exactScope || undefined;
|
|
132
104
|
if (budget <= 0) {
|
|
133
105
|
return { entries: [], tokens: 0 };
|
|
134
106
|
}
|
|
107
|
+
const plan = planContext(ctx, opts);
|
|
108
|
+
const sections = loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
|
|
109
|
+
const admission = ambientAdmission(opts, plan, sections.shownHandoff);
|
|
110
|
+
const pools = loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
|
|
111
|
+
const noSections = !sections.shownSnapshot && !sections.shownHandoff && sections.shownEvents.length === 0;
|
|
112
|
+
if (!plan.promptRecallPending && pools.local.entries.length === 0 && pools.global.entries.length === 0 && noSections) {
|
|
113
|
+
return { entries: [], tokens: 0 };
|
|
114
|
+
}
|
|
115
|
+
const picked = plan.pinnedOnly
|
|
116
|
+
? selectPinned(ctx, opts, plan, sections.left, pools, admission)
|
|
117
|
+
: plan.query === '*'
|
|
118
|
+
? selectStrongest(plan, sections.left, pools)
|
|
119
|
+
: await selectBySearch(ctx, plan, sections.left, pools, admission);
|
|
120
|
+
if (picked === null) {
|
|
121
|
+
return { entries: [], tokens: 0 };
|
|
122
|
+
}
|
|
123
|
+
const { items, tokens } = finalizeSelection(picked, plan);
|
|
124
|
+
if (items.length === 0 && noSections) {
|
|
125
|
+
// A query that found nothing is the coverage-gap signal, so it still gets a trace; pinned-only stays read-only.
|
|
126
|
+
if (!plan.pinnedOnly)
|
|
127
|
+
traceEmptyContext(ctx, opts, plan.query);
|
|
128
|
+
return { entries: [], tokens: 0 };
|
|
129
|
+
}
|
|
130
|
+
// pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
|
|
131
|
+
// memories don't inflate retrieval_count or extend half_life every turn.
|
|
132
|
+
const recorded = plan.pinnedOnly
|
|
133
|
+
? { items, ambientState: undefined }
|
|
134
|
+
: recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
|
|
135
|
+
return {
|
|
136
|
+
entries: recorded.items,
|
|
137
|
+
tokens,
|
|
138
|
+
activeSnapshot: sections.shownSnapshot ?? undefined,
|
|
139
|
+
sessionHandoff: sections.shownHandoff ?? undefined,
|
|
140
|
+
recentEvents: sections.shownEvents.length > 0 ? sections.shownEvents : undefined,
|
|
141
|
+
ambientState: recorded.ambientState,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function planContext(ctx, opts) {
|
|
145
|
+
const pinnedOnly = opts.pinnedOnly === true;
|
|
135
146
|
// Global memories do not establish a project boundary for task state.
|
|
136
147
|
const hasLocal = isInitialized(ctx.hippoRoot);
|
|
137
148
|
const query = (opts.q ?? '').trim() || '*';
|
|
138
149
|
const globalRoot = getGlobalRoot();
|
|
139
150
|
const hasGlobal = isInitialized(globalRoot);
|
|
140
151
|
const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
// S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
|
|
144
|
-
// S3: origin partition - other-project memories are excluded unless the
|
|
145
|
-
// caller explicitly asks for them (crossProject) or isolation is disabled.
|
|
152
|
+
// opts.scope is only the tag boost, opts.exactScope the envelope request; other-project memories are
|
|
153
|
+
// excluded unless the caller asks for them (crossProject) or isolation is disabled.
|
|
146
154
|
const config = loadConfig(ctx.hippoRoot);
|
|
147
155
|
const isolationEnabled = config.contextProjectIsolation !== false;
|
|
148
156
|
const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
|
|
149
157
|
const includeCrossProject = opts.crossProject === true || !isolationEnabled;
|
|
150
|
-
//
|
|
151
|
-
// branch) can piggyback on that connection instead of opening its own.
|
|
158
|
+
// Decided before the ambient loads so the pinned-only FTS candidate query can share their connection.
|
|
152
159
|
const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
|
|
153
|
-
const promptRecallTerms = promptRecallPending && config.pinnedInject.enabled
|
|
154
|
-
? Array.from(promptTokens(opts.prompt ?? ''))
|
|
155
|
-
: [];
|
|
156
|
-
const recallRequest = promptRecallTerms.length > 0
|
|
157
|
-
? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
|
|
158
|
-
: undefined;
|
|
159
160
|
const cost = opts.cost;
|
|
160
161
|
const price = (entry, isGlobal, promptRecall) => cost
|
|
161
162
|
? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
|
|
162
163
|
: estimateTokens(entry.content);
|
|
164
|
+
return {
|
|
165
|
+
pinnedOnly,
|
|
166
|
+
limit: opts.limit ?? Number.POSITIVE_INFINITY,
|
|
167
|
+
includeRecent: opts.includeRecent ?? 0,
|
|
168
|
+
activeScope: opts.scope ?? '',
|
|
169
|
+
exactScope: opts.exactScope || undefined,
|
|
170
|
+
query,
|
|
171
|
+
hasLocal,
|
|
172
|
+
hasGlobal,
|
|
173
|
+
globalRoot,
|
|
174
|
+
primaryIsGlobal,
|
|
175
|
+
hasLocalTaskState: hasLocal && !primaryIsGlobal,
|
|
176
|
+
config,
|
|
177
|
+
currentProjectName,
|
|
178
|
+
includeCrossProject,
|
|
179
|
+
originProject: includeCrossProject || currentProjectName === '' ? undefined : currentProjectName,
|
|
180
|
+
promptRecallPending,
|
|
181
|
+
cost,
|
|
182
|
+
price,
|
|
183
|
+
obs: opts.deliveryObserver,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** The prompt-recall candidate request the ambient loads carry, when the prompt has any terms. */
|
|
187
|
+
function promptRecallRequest(opts, plan) {
|
|
188
|
+
const { pinnedInject } = plan.config;
|
|
189
|
+
const promptRecallTerms = plan.promptRecallPending && pinnedInject.enabled
|
|
190
|
+
? Array.from(promptTokens(opts.prompt ?? ''))
|
|
191
|
+
: [];
|
|
192
|
+
return promptRecallTerms.length > 0
|
|
193
|
+
? { terms: promptRecallTerms, limit: Math.floor(finiteOr(pinnedInject.promptRecallCandidates, 100, 1)) }
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
/** Reports the block's facts to the observer and returns the budget left after the fixed headers. */
|
|
197
|
+
function openBlockBudget(plan, opts, budget) {
|
|
198
|
+
const { config, cost, obs, pinnedOnly, promptRecallPending } = plan;
|
|
163
199
|
const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
|
|
164
|
-
|
|
165
|
-
obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
|
|
200
|
+
obs?.facts({ projectName: plan.currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
|
|
166
201
|
if (pinnedOnly && !config.pinnedInject.enabled)
|
|
167
202
|
obs?.disabled();
|
|
168
|
-
|
|
169
|
-
? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
|
|
203
|
+
return cost
|
|
204
|
+
? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: plan.includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
|
|
170
205
|
: blockBudget;
|
|
171
|
-
|
|
206
|
+
}
|
|
207
|
+
// Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
|
|
208
|
+
function loadTaskSections(ctx, opts, plan, startLeft) {
|
|
209
|
+
const { exactScope, hasLocalTaskState, cost } = plan;
|
|
210
|
+
let left = startLeft;
|
|
172
211
|
const pays = (tokens) => {
|
|
173
212
|
if (tokens > left)
|
|
174
213
|
return false;
|
|
175
214
|
left -= tokens;
|
|
176
215
|
return true;
|
|
177
216
|
};
|
|
178
|
-
//
|
|
179
|
-
// superseded it, no session-end closed it) must age out of this ambient
|
|
180
|
-
// surface instead of injecting into every future prompt forever. Owner
|
|
181
|
-
// reads (opts.currentSessionId matches the snapshot's session_id) stay
|
|
182
|
-
// unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
|
|
183
|
-
// exact null/empty-id matching rules.
|
|
217
|
+
// Bounded read: an orphaned snapshot ages out of this ambient surface; the owner session's read stays unbounded.
|
|
184
218
|
const rowScope = (r) => r?.scope ?? null;
|
|
185
219
|
const rawActiveSnapshot = hasLocalTaskState
|
|
186
220
|
? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
|
|
187
221
|
sessionId: opts.currentSessionId,
|
|
188
222
|
})
|
|
189
223
|
: null;
|
|
190
|
-
//
|
|
224
|
+
// The same envelope rule ambientAdmitEntry applies to memory rows.
|
|
191
225
|
const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
|
|
192
226
|
? rawActiveSnapshot
|
|
193
227
|
: null;
|
|
@@ -199,7 +233,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
199
233
|
: loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
|
|
200
234
|
unfinishedOnly: true,
|
|
201
235
|
maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
|
|
202
|
-
//
|
|
236
|
+
// Scope is admitted in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
|
|
203
237
|
scopeFilter: 'default-deny',
|
|
204
238
|
});
|
|
205
239
|
const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
|
|
@@ -215,7 +249,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
215
249
|
const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
|
|
216
250
|
const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
|
|
217
251
|
const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
|
|
218
|
-
obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
|
|
252
|
+
plan.obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
|
|
253
|
+
return { activeSnapshot, shownSnapshot, shownHandoff, shownEvents, left };
|
|
254
|
+
}
|
|
255
|
+
function ambientAdmission(opts, plan, shownHandoff) {
|
|
219
256
|
const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
|
|
220
257
|
let digestHiddenForHandoff = false;
|
|
221
258
|
const ambientAdmit = (e) => {
|
|
@@ -224,7 +261,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
224
261
|
digestHiddenForHandoff = true;
|
|
225
262
|
return false;
|
|
226
263
|
}
|
|
227
|
-
return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
|
|
264
|
+
return ambientAdmitEntry(e, plan.currentProjectName, plan.includeCrossProject, plan.exactScope);
|
|
228
265
|
};
|
|
229
266
|
const ownSessionId = opts.currentSessionId || '';
|
|
230
267
|
// Inside admit, not after the load, so the loader's window widens past a session's own items.
|
|
@@ -233,473 +270,109 @@ export async function getContext(ctx, opts = {}) {
|
|
|
233
270
|
e.tags.includes(COMPACTION_MEMORY_TAG);
|
|
234
271
|
// Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
|
|
235
272
|
const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
|
|
236
|
-
|
|
237
|
-
const
|
|
273
|
+
// The two-store search has always ranked a session's own compaction items; only the local search drops them.
|
|
274
|
+
const bothStoresAdmit = (e) => !e.superseded_by && ambientAdmit(e);
|
|
275
|
+
return { ambientAdmit, admit, bothStoresAdmit, digestHidden: () => digestHiddenForHandoff };
|
|
276
|
+
}
|
|
277
|
+
function loadPools(ctx, plan, admission, recallRequest) {
|
|
278
|
+
const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope } = plan;
|
|
279
|
+
const searches = plan.query !== '*' && !pinnedOnly;
|
|
280
|
+
const searchesBoth = searches && hasGlobal && !primaryIsGlobal;
|
|
281
|
+
const poolAdmit = searchesBoth ? admission.bothStoresAdmit : admission.admit;
|
|
282
|
+
const loadAdmit = obs ? obs.watchAdmit(poolAdmit) : poolAdmit;
|
|
283
|
+
const qualityDrop = (isGlobal) => obs && !plan.promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
|
|
238
284
|
// The window's predicates are ones admit applies anyway, so below the cap the admitted rows are unchanged.
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
const window = searchesLocalRows && !pinnedOnly
|
|
242
|
-
? { query, exactScope }
|
|
285
|
+
const window = searches
|
|
286
|
+
? { query: plan.query, exactScope, project: plan.originProject }
|
|
243
287
|
: {
|
|
244
288
|
exactScope,
|
|
245
|
-
project: originProject,
|
|
289
|
+
project: plan.originProject,
|
|
246
290
|
cap: CONTEXT_CANDIDATE_CAP,
|
|
247
291
|
now: evalNow(),
|
|
248
292
|
};
|
|
249
|
-
// Tenant-scoped loads
|
|
250
|
-
const
|
|
251
|
-
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
|
|
293
|
+
// Tenant-scoped loads: never resolveTenantId({}) here.
|
|
294
|
+
const local = plan.hasLocal
|
|
295
|
+
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
|
|
252
296
|
: { entries: [] };
|
|
253
|
-
const
|
|
254
|
-
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, window, recallRequest, qualityDrop(true))
|
|
297
|
+
const global = hasGlobal && !primaryIsGlobal
|
|
298
|
+
? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(true))
|
|
255
299
|
: { entries: [] };
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return { entries: [], tokens: 0 };
|
|
267
|
-
}
|
|
268
|
-
let selectedItems = [];
|
|
269
|
-
let totalTokens = 0;
|
|
270
|
-
if (pinnedOnly) {
|
|
271
|
-
// loadConfig is safe even when local isn't initialised — returns defaults.
|
|
272
|
-
const pinnedCfg = loadConfig(ctx.hippoRoot);
|
|
273
|
-
if (!pinnedCfg.pinnedInject.enabled) {
|
|
274
|
-
return { entries: [], tokens: 0 };
|
|
275
|
-
}
|
|
276
|
-
// Effective budget: explicit opts.budget wins over config, less what the sections took.
|
|
277
|
-
const effBudget = left;
|
|
278
|
-
const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
279
|
-
obs?.offer(localEntries, primaryIsGlobal);
|
|
280
|
-
obs?.offer(globalEntries, true);
|
|
281
|
-
const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
|
|
282
|
-
obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
|
|
283
|
-
const selectedIds = new Set();
|
|
284
|
-
let usedP = 0;
|
|
285
|
-
// Pinned entries are explicit user intent, the recent-N list an automatic
|
|
286
|
-
// backfill. Both loops share ONE budget and the recent loop runs first, so
|
|
287
|
-
// pins are ranked here and reserve their share before it can spend.
|
|
288
|
-
const pinnedLocal = localPool.filter((e) => e.pinned);
|
|
289
|
-
const pinnedGlobal = globalPool.filter((e) => e.pinned);
|
|
290
|
-
const rankedPinned = [
|
|
291
|
-
...pinnedLocal.map((e) => ({ entry: e, isGlobal: primaryIsGlobal })),
|
|
292
|
-
...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
|
|
293
|
-
]
|
|
294
|
-
.map(({ entry, isGlobal }) => {
|
|
295
|
-
const scopeSig = scopeMatch(entry.tags, activeScope);
|
|
296
|
-
const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
|
|
297
|
-
return {
|
|
298
|
-
entry,
|
|
299
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
|
|
300
|
-
tokens: price(entry, isGlobal),
|
|
301
|
-
isGlobal,
|
|
302
|
-
};
|
|
303
|
-
})
|
|
304
|
-
.sort(compareScoredResults);
|
|
305
|
-
// Mirror the pinned admission loop's own `continue`-not-`break`
|
|
306
|
-
// semantics (further down) so the reserve equals what that loop will
|
|
307
|
-
// actually admit -- a big pin near the front should not block smaller
|
|
308
|
-
// pins behind it from reserving their share too.
|
|
309
|
-
// Dedupe by id: `syncGlobalToLocal` copies global rows into the local
|
|
310
|
-
// store preserving `entry.id`, so a synced pin appears in BOTH
|
|
311
|
-
// `pinnedLocal` and `pinnedGlobal` and would otherwise reserve its cost
|
|
312
|
-
// twice. The admission loop already dedupes via `selectedIds`; the
|
|
313
|
-
// reserve has to mirror that or it silently starves recents of budget a
|
|
314
|
-
// single returned pin never needed.
|
|
315
|
-
let pinnedReserve = 0;
|
|
316
|
-
const reservedIds = new Set();
|
|
317
|
-
for (const r of rankedPinned) {
|
|
318
|
-
if (reservedIds.has(r.entry.id))
|
|
319
|
-
continue;
|
|
320
|
-
if (pinnedReserve + r.tokens <= effBudget) {
|
|
321
|
-
pinnedReserve += r.tokens;
|
|
322
|
-
reservedIds.add(r.entry.id);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
// Known, accepted tradeoff: a pin that also lands in the recent-N slice
|
|
326
|
-
// is counted once in `pinnedReserve` (here) AND admitted again by the
|
|
327
|
-
// recent loop below, so a little budget goes unused (`recentBudget` is
|
|
328
|
-
// more conservative than it needs to be in that case). That only
|
|
329
|
-
// under-fills recents slightly -- it never displaces a pin -- so it is
|
|
330
|
-
// the safe direction and is not worth extra bookkeeping to recover.
|
|
331
|
-
const recentBudget = Math.max(0, effBudget - pinnedReserve);
|
|
332
|
-
// Z1: gate the backfill on the prompt instead of recency (docs/plans/2026-09-26-z1-prompt-recall.md).
|
|
333
|
-
const promptRecallOn = promptRecallPending;
|
|
334
|
-
if (promptRecallOn) {
|
|
335
|
-
const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
|
|
336
|
-
const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
|
|
337
|
-
const gate = {
|
|
338
|
-
metric,
|
|
339
|
-
threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
|
|
340
|
-
minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
|
|
341
|
-
maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
|
|
342
|
-
};
|
|
343
|
-
const p = promptTokens(opts.prompt ?? '');
|
|
344
|
-
if (p.size > 0) {
|
|
345
|
-
// Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
|
|
346
|
-
// A candidate carrying a pin's text would inject that memory a second time.
|
|
347
|
-
const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
|
|
348
|
-
const ineligibleReason = (e) => !admit(e) ? 'scope'
|
|
349
|
-
: e.pinned ? 'pinned'
|
|
350
|
-
: !isContentWorthStoring(e.content) ? 'quality'
|
|
351
|
-
: pinnedText.has(e.content) ? 'duplicate'
|
|
352
|
-
: null;
|
|
353
|
-
const eligible = (e) => {
|
|
354
|
-
const why = ineligibleReason(e);
|
|
355
|
-
if (why !== null && why !== 'pinned')
|
|
356
|
-
obs?.reject(e, 'eligible', why);
|
|
357
|
-
return why === null;
|
|
358
|
-
};
|
|
359
|
-
obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
|
|
360
|
-
obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
|
|
361
|
-
const localEligible = (localLoad.recall ?? []).filter(eligible);
|
|
362
|
-
const globalEligible = (globalLoad.recall ?? []).filter(eligible);
|
|
363
|
-
const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
|
|
364
|
-
obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
|
|
365
|
-
const seenCandidateIds = new Set();
|
|
366
|
-
const candidateItems = [];
|
|
367
|
-
// Local wins the id collision (a global row synced into the local store).
|
|
368
|
-
for (const e of localCandidates) {
|
|
369
|
-
if (seenCandidateIds.has(e.id))
|
|
370
|
-
continue;
|
|
371
|
-
seenCandidateIds.add(e.id);
|
|
372
|
-
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
|
|
373
|
-
}
|
|
374
|
-
for (const e of globalCandidates) {
|
|
375
|
-
if (seenCandidateIds.has(e.id))
|
|
376
|
-
continue;
|
|
377
|
-
seenCandidateIds.add(e.id);
|
|
378
|
-
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
|
|
379
|
-
}
|
|
380
|
-
const gated = gatePromptRecall(p, candidateItems, gate);
|
|
381
|
-
obs?.gated(p, candidateItems, gate, gated);
|
|
382
|
-
for (const g of gated) {
|
|
383
|
-
if (selectedIds.has(g.item.id))
|
|
384
|
-
continue;
|
|
385
|
-
const tokens = price(g.item.entry, g.item.isGlobal, true);
|
|
386
|
-
if (usedP + tokens > recentBudget) {
|
|
387
|
-
obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
|
|
391
|
-
selectedIds.add(g.item.id);
|
|
392
|
-
usedP += tokens;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
else if (includeRecent > 0) {
|
|
397
|
-
const recent = [
|
|
398
|
-
...localPool.map((entry) => ({ entry, isGlobal: primaryIsGlobal })),
|
|
399
|
-
...globalPool.map((entry) => ({ entry, isGlobal: true })),
|
|
400
|
-
]
|
|
401
|
-
// T2 (src/compare.ts) note: this already carries an explicit
|
|
402
|
-
// per-instance tiebreak (created desc -> id localeCompare) and is
|
|
403
|
-
// deliberately left as-is rather than routed through
|
|
404
|
-
// compareEntryIdentity. `created` reflects ingest order, so it is
|
|
405
|
-
// cross-ingest stable at ms granularity; the residual is honest,
|
|
406
|
-
// not silently ignored — rows created in the same millisecond fall
|
|
407
|
-
// to `id.localeCompare`, which is per-instance random (id is
|
|
408
|
-
// crypto.randomUUID()), so this listing is per-instance-
|
|
409
|
-
// deterministic but NOT cross-ingest-stable under same-ms
|
|
410
|
-
// collisions.
|
|
411
|
-
.sort((a, b) => {
|
|
412
|
-
const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
|
|
413
|
-
return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
|
|
414
|
-
})
|
|
415
|
-
// DF3 (docs/plans/2026-08-23-df3-include-recent-quality-floor.md):
|
|
416
|
-
// filter before slice, not after — the caller asked for N recent
|
|
417
|
-
// *useful* entries, so a junk row must be skipped and backfilled
|
|
418
|
-
// past, not counted against the N. Skip-only: no mutation, no audit
|
|
419
|
-
// row, nothing becomes unrecoverable.
|
|
420
|
-
//
|
|
421
|
-
// `entry.pinned ||` bypass IS needed here (codex review finding,
|
|
422
|
-
// corrects the earlier claim in this comment that it wasn't): under
|
|
423
|
-
// budget pressure, a pinned entry that fails the heuristic gets
|
|
424
|
-
// dropped from this recent slice, and an unpinned entry backfills
|
|
425
|
-
// into its slot and consumes `usedP` in the loop below. By the time
|
|
426
|
-
// the pinned block runs (further down), the budget it needed is
|
|
427
|
-
// already spent, so it hits `continue` and the pinned entry is
|
|
428
|
-
// omitted entirely — the pinned block is NOT a safety net once the
|
|
429
|
-
// recent loop has already spent the shared budget.
|
|
430
|
-
.filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
|
|
431
|
-
.slice(0, includeRecent)
|
|
432
|
-
.map(({ entry, isGlobal }) => ({
|
|
433
|
-
entry,
|
|
434
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
|
|
435
|
-
tokens: price(entry, isGlobal),
|
|
436
|
-
isGlobal,
|
|
437
|
-
}));
|
|
438
|
-
for (const r of recent) {
|
|
439
|
-
if (selectedIds.has(r.entry.id))
|
|
440
|
-
continue;
|
|
441
|
-
if (usedP + r.tokens > recentBudget) {
|
|
442
|
-
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
selectedItems.push(r);
|
|
446
|
-
selectedIds.add(r.entry.id);
|
|
447
|
-
usedP += r.tokens;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (pinnedLocal.length === 0 &&
|
|
451
|
-
pinnedGlobal.length === 0 &&
|
|
452
|
-
selectedItems.length === 0 &&
|
|
453
|
-
!digestHiddenForHandoff) {
|
|
454
|
-
return { entries: [], tokens: 0 };
|
|
455
|
-
}
|
|
456
|
-
for (const r of rankedPinned) {
|
|
457
|
-
if (selectedIds.has(r.entry.id))
|
|
458
|
-
continue;
|
|
459
|
-
if (usedP + r.tokens > effBudget) {
|
|
460
|
-
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
selectedItems.push(r);
|
|
464
|
-
selectedIds.add(r.entry.id);
|
|
465
|
-
usedP += r.tokens;
|
|
466
|
-
}
|
|
467
|
-
totalTokens = usedP;
|
|
468
|
-
}
|
|
469
|
-
else if (query === '*') {
|
|
470
|
-
// No query: return strongest memories by strength, up to budget.
|
|
471
|
-
const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
472
|
-
const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, now);
|
|
473
|
-
const localRanked = localPool
|
|
474
|
-
.map((e) => ({
|
|
475
|
-
entry: e,
|
|
476
|
-
score: calculateStrength(e, now),
|
|
477
|
-
tokens: price(e, primaryIsGlobal),
|
|
478
|
-
isGlobal: primaryIsGlobal,
|
|
479
|
-
}))
|
|
480
|
-
.sort(compareScoredResults);
|
|
481
|
-
const globalRanked = globalPool
|
|
482
|
-
.map((e) => ({
|
|
483
|
-
entry: e,
|
|
484
|
-
score: calculateStrength(e, now) * (1 / 1.2),
|
|
485
|
-
tokens: price(e, true),
|
|
486
|
-
isGlobal: true,
|
|
487
|
-
}))
|
|
488
|
-
.sort(compareScoredResults);
|
|
489
|
-
const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
|
|
490
|
-
let used = 0;
|
|
491
|
-
for (const r of combined) {
|
|
492
|
-
if (used + r.tokens > left)
|
|
493
|
-
continue;
|
|
494
|
-
selectedItems.push(r);
|
|
495
|
-
used += r.tokens;
|
|
496
|
-
}
|
|
497
|
-
totalTokens = used;
|
|
498
|
-
}
|
|
499
|
-
else {
|
|
500
|
-
// Real query: hybrid search (global + local) or physics+hybrid (local only).
|
|
501
|
-
let results;
|
|
502
|
-
const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
|
|
503
|
-
if (hasGlobal && !primaryIsGlobal) {
|
|
504
|
-
// searchBothHybrid loads from the store roots itself, so the ambient
|
|
505
|
-
// filter above never saw its candidates. Admission runs INSIDE the
|
|
506
|
-
// search via the opt-in entryFilter, BEFORE ranking, cross-store
|
|
507
|
-
// content-dedupe, and budgeting - a post-filter instead would let an
|
|
508
|
-
// excluded row saturate the budget (codex rounds 1+3) or shadow its
|
|
509
|
-
// admitted duplicate in the dedupe pass (codex round 4). Recall paths
|
|
510
|
-
// never set entryFilter, so their behavior is unchanged.
|
|
511
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
512
|
-
const isGlobalHit = (e) => !localIndex.entries[e.id];
|
|
513
|
-
const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
|
|
514
|
-
budget: left,
|
|
515
|
-
minResults,
|
|
516
|
-
cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
|
|
517
|
-
scope: activeScope,
|
|
518
|
-
tenantId: ctx.tenantId,
|
|
519
|
-
entryFilter: ambientAdmit,
|
|
520
|
-
});
|
|
521
|
-
results = merged.map((r) => ({
|
|
522
|
-
entry: r.entry,
|
|
523
|
-
score: r.score,
|
|
524
|
-
tokens: price(r.entry, isGlobalHit(r.entry)),
|
|
525
|
-
isGlobal: isGlobalHit(r.entry),
|
|
526
|
-
}));
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
const ctxConfig = loadConfig(ctx.hippoRoot);
|
|
530
|
-
const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
|
|
531
|
-
const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
|
|
532
|
-
const vectorCandidates = {
|
|
533
|
-
tenantId: ctx.tenantId, scope: recallScopeFilter(exactScope, 'exact'), includeSuperseded: false, admit,
|
|
534
|
-
};
|
|
535
|
-
const ctxResults = usePhysicsCtx
|
|
536
|
-
? await physicsSearch(query, localEntries, {
|
|
537
|
-
budget: left,
|
|
538
|
-
minResults,
|
|
539
|
-
cost: localCost,
|
|
540
|
-
hippoRoot: ctx.hippoRoot,
|
|
541
|
-
physicsConfig: ctxConfig.physics,
|
|
542
|
-
scope: activeScope,
|
|
543
|
-
vectorCandidates,
|
|
544
|
-
})
|
|
545
|
-
: await hybridSearch(query, localEntries, {
|
|
546
|
-
budget: left,
|
|
547
|
-
minResults,
|
|
548
|
-
cost: localCost,
|
|
549
|
-
hippoRoot: ctx.hippoRoot,
|
|
550
|
-
scope: activeScope,
|
|
551
|
-
vectorCandidates,
|
|
552
|
-
});
|
|
553
|
-
results = ctxResults.map((r) => ({
|
|
554
|
-
entry: r.entry,
|
|
555
|
-
score: r.score,
|
|
556
|
-
tokens: price(r.entry, primaryIsGlobal),
|
|
557
|
-
isGlobal: primaryIsGlobal,
|
|
558
|
-
}));
|
|
559
|
-
}
|
|
560
|
-
selectedItems = results;
|
|
561
|
-
totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
|
|
562
|
-
// A5 H4: emit recall audit row for context-mode searches (matches the
|
|
563
|
-
// 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
|
|
564
|
-
// never hit the search engines, so they don't emit (matches cmdContext).
|
|
565
|
-
const ctxRecallMetadata = {
|
|
566
|
-
...auditQueryFields(query),
|
|
567
|
-
results: selectedItems.length,
|
|
568
|
-
mode: 'context',
|
|
569
|
-
};
|
|
570
|
-
if (hasLocal) {
|
|
571
|
-
const localDb = openHippoDb(ctx.hippoRoot);
|
|
572
|
-
try {
|
|
573
|
-
appendAuditEvent(localDb, {
|
|
574
|
-
tenantId: ctx.tenantId,
|
|
575
|
-
actor: ctx.actor.subject,
|
|
576
|
-
op: 'recall',
|
|
577
|
-
metadata: ctxRecallMetadata,
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
finally {
|
|
581
|
-
closeHippoDb(localDb);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (hasGlobal && !primaryIsGlobal) {
|
|
585
|
-
const globalDb = openHippoDb(globalRoot);
|
|
586
|
-
try {
|
|
587
|
-
appendAuditEvent(globalDb, {
|
|
588
|
-
tenantId: ctx.tenantId,
|
|
589
|
-
actor: ctx.actor.subject,
|
|
590
|
-
op: 'recall',
|
|
591
|
-
metadata: ctxRecallMetadata,
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
finally {
|
|
595
|
-
closeHippoDb(globalDb);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
if (limit < selectedItems.length) {
|
|
600
|
-
const cut = selectedItems.slice(0, limit);
|
|
601
|
-
obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
|
|
602
|
-
selectedItems = cut;
|
|
300
|
+
return { local, global };
|
|
301
|
+
}
|
|
302
|
+
/** The `limit` cut, then the held-copy drop, then each entry's origin annotation. */
|
|
303
|
+
function finalizeSelection(picked, plan) {
|
|
304
|
+
const { obs } = plan;
|
|
305
|
+
let selected = picked;
|
|
306
|
+
if (plan.limit < selected.length) {
|
|
307
|
+
const cut = selected.slice(0, plan.limit);
|
|
308
|
+
obs?.dropMissing(selected.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
|
|
309
|
+
selected = cut;
|
|
603
310
|
}
|
|
604
|
-
const heldDropped = dropHeldCopies(
|
|
605
|
-
obs?.dropMissing(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
selectedItems = selectedItems.map((r) => ({
|
|
311
|
+
const heldDropped = dropHeldCopies(selected, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
|
|
312
|
+
obs?.dropMissing(selected.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
|
|
313
|
+
selected = heldDropped;
|
|
314
|
+
const tokens = selected.reduce((sum, r) => sum + r.tokens, 0);
|
|
315
|
+
// Every returned entry carries its origin and how it relates to the active project, so renderers can mark cross-project rows.
|
|
316
|
+
selected = selected.map((r) => ({
|
|
611
317
|
...r,
|
|
612
318
|
origin: r.entry.origin_project ?? null,
|
|
613
|
-
category: classifyOriginProject(r.entry.origin_project, currentProjectName),
|
|
319
|
+
category: classifyOriginProject(r.entry.origin_project, plan.currentProjectName),
|
|
614
320
|
}));
|
|
615
|
-
obs?.selected(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
explainMode: false,
|
|
672
|
-
results: selectedItems.map((s) => ({
|
|
673
|
-
memoryId: s.entry.id,
|
|
674
|
-
score: s.score,
|
|
675
|
-
})),
|
|
676
|
-
});
|
|
677
|
-
localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
|
|
678
|
-
saveIndex(ctx.hippoRoot, localIndex);
|
|
679
|
-
updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
|
|
680
|
-
// Replace selectedItems entries with markRetrieved-updated copies so
|
|
681
|
-
// the returned ContextResult reflects post-recall state.
|
|
682
|
-
selectedItems = selectedItems.map((s) => ({
|
|
683
|
-
...s,
|
|
684
|
-
entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
|
|
685
|
-
}));
|
|
686
|
-
// Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
|
|
687
|
-
if (config.ambient.enabled) {
|
|
688
|
-
const filter = { exactScope, project: originProject, currentProject: currentProjectName, now: evalNow() };
|
|
689
|
-
const roots = [...(hasLocal ? [ctx.hippoRoot] : []), ...(hasGlobal && !primaryIsGlobal ? [globalRoot] : [])];
|
|
690
|
-
const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
|
|
691
|
-
const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
|
|
692
|
-
if (total && total.total > 0)
|
|
693
|
-
ambientState = ambientStateFromTallies(total);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
return {
|
|
697
|
-
entries: selectedItems,
|
|
698
|
-
tokens: totalTokens,
|
|
699
|
-
activeSnapshot: shownSnapshot ?? undefined,
|
|
700
|
-
sessionHandoff: shownHandoff ?? undefined,
|
|
701
|
-
recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
|
|
702
|
-
ambientState,
|
|
703
|
-
};
|
|
321
|
+
obs?.selected(selected);
|
|
322
|
+
return { items: selected, tokens };
|
|
323
|
+
}
|
|
324
|
+
// Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id; fail-soft, never throws.
|
|
325
|
+
function traceEmptyContext(ctx, opts, query) {
|
|
326
|
+
// No snapshot in this branch, so the caller's own id is the only session to stamp.
|
|
327
|
+
writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
328
|
+
tenantId: ctx.tenantId,
|
|
329
|
+
sessionId: opts.currentSessionId || null,
|
|
330
|
+
pipeline: 'context',
|
|
331
|
+
query,
|
|
332
|
+
explainMode: false,
|
|
333
|
+
results: [],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** Strengthens and marks the returned rows, then writes the trace and index together. */
|
|
337
|
+
function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
|
|
338
|
+
const toUpdate = selected.map((s) => s.entry);
|
|
339
|
+
const updatedEntries = markRetrieved(toUpdate);
|
|
340
|
+
const localIndex = loadIndex(ctx.hippoRoot);
|
|
341
|
+
const retrievedIds = updatedEntries.map((u) => u.id);
|
|
342
|
+
const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
|
|
343
|
+
if (plan.hasGlobal)
|
|
344
|
+
strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
|
|
345
|
+
localIndex.last_retrieval_ids = retrievedIds;
|
|
346
|
+
// Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
|
|
347
|
+
// last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
|
|
348
|
+
const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
349
|
+
tenantId: ctx.tenantId,
|
|
350
|
+
sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
|
|
351
|
+
pipeline: 'context',
|
|
352
|
+
query: plan.query,
|
|
353
|
+
explainMode: false,
|
|
354
|
+
results: selected.map((s) => ({
|
|
355
|
+
memoryId: s.entry.id,
|
|
356
|
+
score: s.score,
|
|
357
|
+
})),
|
|
358
|
+
});
|
|
359
|
+
localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
|
|
360
|
+
saveIndex(ctx.hippoRoot, localIndex);
|
|
361
|
+
updateStats(ctx.hippoRoot, { recalled: selected.length });
|
|
362
|
+
// Replace selectedItems entries with markRetrieved-updated copies so
|
|
363
|
+
// the returned ContextResult reflects post-recall state.
|
|
364
|
+
const items = selected.map((s) => ({
|
|
365
|
+
...s,
|
|
366
|
+
entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
|
|
367
|
+
}));
|
|
368
|
+
// Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
|
|
369
|
+
return { items, ambientState: plan.config.ambient.enabled ? readAmbientState(ctx, plan) : undefined };
|
|
370
|
+
}
|
|
371
|
+
function readAmbientState(ctx, plan) {
|
|
372
|
+
const filter = { exactScope: plan.exactScope, project: plan.originProject, currentProject: plan.currentProjectName, now: evalNow() };
|
|
373
|
+
const roots = [...(plan.hasLocal ? [ctx.hippoRoot] : []), ...(plan.hasGlobal && !plan.primaryIsGlobal ? [plan.globalRoot] : [])];
|
|
374
|
+
const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
|
|
375
|
+
const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
|
|
376
|
+
return total && total.total > 0 ? ambientStateFromTallies(total) : undefined;
|
|
704
377
|
}
|
|
705
378
|
//# sourceMappingURL=context.js.map
|