hippo-memory 1.68.0 → 1.69.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 +1 -1
- package/dist/agent-memories/copilot.d.ts +3 -1
- package/dist/agent-memories/copilot.js +2 -2
- package/dist/api/context-select.d.ts +10 -0
- package/dist/api/context-select.js +16 -18
- package/dist/api/context.js +101 -60
- package/dist/api/forget.d.ts +2 -2
- package/dist/api/forget.js +11 -0
- package/dist/api/outcome.d.ts +4 -4
- package/dist/api/outcome.js +29 -6
- package/dist/api/promote.d.ts +3 -3
- package/dist/api/promote.js +45 -90
- package/dist/api/remember.d.ts +3 -2
- package/dist/api/remember.js +38 -11
- package/dist/capture/compact.js +21 -2
- package/dist/capture/copilot-transcript.d.ts +3 -0
- package/dist/capture/copilot-transcript.js +8 -1
- package/dist/capture/session-worker.d.ts +13 -0
- package/dist/capture/session-worker.js +194 -0
- package/dist/capture-contract.d.ts +3 -1
- package/dist/capture-contract.js +36 -0
- package/dist/cli/session-hooks.js +90 -22
- package/dist/cli/setup.js +70 -24
- package/dist/cli/usage.js +4 -1
- package/dist/cli.js +1 -1
- package/dist/connectors/github/ingest.js +2 -1
- package/dist/connectors/slack/deletion.js +2 -1
- package/dist/connectors/slack/ingest.js +2 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -0
- package/dist/hooks/copilot.d.ts +50 -8
- package/dist/hooks/copilot.js +116 -30
- package/dist/hooks/json-hooks.d.ts +2 -0
- package/dist/hooks/json-hooks.js +11 -2
- package/dist/hooks/shared.d.ts +7 -1
- package/dist/hooks/shared.js +18 -3
- package/dist/mcp/memory-tools.d.ts +2 -2
- package/dist/mcp/memory-tools.js +9 -8
- package/dist/mcp/request.js +3 -3
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +7 -0
- package/dist/prompt-recall.d.ts +5 -0
- package/dist/prompt-recall.js +13 -0
- package/dist/raw-archive.d.ts +2 -2
- package/dist/raw-archive.js +5 -3
- package/dist/recall-scope.d.ts +2 -5
- package/dist/recall-scope.js +5 -6
- package/dist/server/routes/memories.js +9 -6
- package/dist/server-detect.js +3 -2
- package/dist/server.d.ts +14 -1
- package/dist/server.js +16 -6
- package/dist/store/audit-event.d.ts +1 -1
- package/dist/store/audit-event.js +1 -1
- package/dist/store/entry-writes-group.d.ts +9 -0
- package/dist/store/entry-writes-group.js +157 -0
- package/dist/store/handoffs.d.ts +5 -2
- package/dist/store/handoffs.js +35 -5
- package/dist/store/open.d.ts +2 -0
- package/dist/store/open.js +10 -0
- package/dist/store/search-rows.js +3 -9
- package/dist/store/sessions.d.ts +5 -0
- package/dist/store/sessions.js +4 -1
- package/dist/store-port.d.ts +71 -0
- package/dist/store-port.js +23 -11
- 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/README.md
CHANGED
|
@@ -844,7 +844,7 @@ For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.j
|
|
|
844
844
|
|
|
845
845
|
**Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
|
|
846
846
|
|
|
847
|
-
For GitHub Copilot,
|
|
847
|
+
For GitHub Copilot, when `$COPILOT_HOME` (else `~/.copilot`) or a VS Code User folder exists, `hippo setup` writes hippo's own `hooks/hippo.json`. When the Copilot folder was there before setup, it also adds a `hippo` server to `mcp-config.json` and a block to `copilot-instructions.md`. The block asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses the session-start, tool-failure, pre-compact and session-end hooks, the server and the block. VS Code's agent mode (the Local agent, VS Code 1.109.3 or later) reads the same hooks file. It gets session start, the pre-compact snapshot and a capture after every reply. Setup also adds hippo to VS Code's own `mcp.json` and writes `prompts/hippo.instructions.md`, so its recall tools and instructions work too. The hooks, the VS Code files and how to remove them: [integrations/copilot.md](integrations/copilot.md).
|
|
848
848
|
|
|
849
849
|
### What the hook adds (Claude Code example)
|
|
850
850
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import type { Adapter } from './types.js';
|
|
1
|
+
import type { Adapter, AdapterContext } from './types.js';
|
|
2
|
+
/** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
|
|
3
|
+
export declare function vscodeDataFolders(ctx: Pick<AdapterContext, 'env' | 'home' | 'platform'>): string[];
|
|
2
4
|
export declare const copilotAdapter: Adapter;
|
|
3
5
|
//# sourceMappingURL=copilot.d.ts.map
|
|
@@ -7,7 +7,7 @@ import { gitLayout } from './git.js';
|
|
|
7
7
|
const PRODUCTS = ['Code', 'Code - Insiders'];
|
|
8
8
|
const MEMORY_TOOL = ['github.copilot-chat', 'memory-tool', 'memories'];
|
|
9
9
|
/** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
|
|
10
|
-
function
|
|
10
|
+
export function vscodeDataFolders(ctx) {
|
|
11
11
|
const { env, home, platform } = ctx;
|
|
12
12
|
if (env.VSCODE_PORTABLE)
|
|
13
13
|
return [path.join(env.VSCODE_PORTABLE, 'user-data')];
|
|
@@ -103,7 +103,7 @@ function projectContainers(data, ctx, matches, warnings) {
|
|
|
103
103
|
export const copilotAdapter = {
|
|
104
104
|
tool: 'copilot',
|
|
105
105
|
list(ctx, scope) {
|
|
106
|
-
const data =
|
|
106
|
+
const data = vscodeDataFolders(ctx);
|
|
107
107
|
const warnings = [];
|
|
108
108
|
const found = [];
|
|
109
109
|
if (scope === 'user') {
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import type { AmbientLoadResult } from '../store/candidates.js';
|
|
2
2
|
import { type MemoryEntry } from '../memory.js';
|
|
3
|
+
import type { ContextReads, HippoStore } from '../store-port.js';
|
|
3
4
|
import { type HippoConfig } from '../config.js';
|
|
4
5
|
import type { ProjectRef } from '../project-identity.js';
|
|
5
6
|
import type { DeliveryObserver } from '../delivery-recorder.js';
|
|
6
7
|
import type { ContextCost, ContextOpts, ContextResultEntry } from './context-types.js';
|
|
7
8
|
import type { Context } from './types.js';
|
|
9
|
+
/** One store getContext reads rows from: its base search and its context reads. */
|
|
10
|
+
export interface ContextSource {
|
|
11
|
+
readonly store: HippoStore;
|
|
12
|
+
readonly reads: ContextReads;
|
|
13
|
+
}
|
|
8
14
|
/** What getContext resolved from opts and config before it read a row. */
|
|
9
15
|
export interface ContextPlan {
|
|
16
|
+
/** The served store, else hippo.db under the root. */
|
|
17
|
+
local: ContextSource;
|
|
18
|
+
/** The served store when it is not hippo.db; then every read and write goes through it and no hippo.db opens. */
|
|
19
|
+
other: HippoStore | undefined;
|
|
10
20
|
pinnedOnly: boolean;
|
|
11
21
|
limit: number;
|
|
12
22
|
includeRecent: number;
|
|
@@ -241,7 +241,7 @@ export async function selectBySearch(ctx, plan, left, pools, admission) {
|
|
|
241
241
|
const results = plan.hasGlobal && !plan.primaryIsGlobal
|
|
242
242
|
? await searchBothStores(ctx, plan, left, minResults, pools, admission.bothStoresAdmit)
|
|
243
243
|
: await searchLocalRows(ctx, plan, left, minResults, pools.local.entries, admission.admit);
|
|
244
|
-
auditContextRecall(ctx, plan, results.length);
|
|
244
|
+
await auditContextRecall(ctx, plan, results.length);
|
|
245
245
|
return results;
|
|
246
246
|
}
|
|
247
247
|
// The pools were admitted at load, before ranking, dedupe and budget: a post-filter would let an excluded row fill the
|
|
@@ -273,6 +273,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
|
|
|
273
273
|
const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
|
|
274
274
|
const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
|
|
275
275
|
const vectorCandidates = contextVectorSpec(ctx, plan, admit);
|
|
276
|
+
const store = plan.local.store;
|
|
276
277
|
const ctxResults = usePhysicsCtx
|
|
277
278
|
? await physicsSearch(query, localEntries, {
|
|
278
279
|
budget: left,
|
|
@@ -282,6 +283,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
|
|
|
282
283
|
physicsConfig: ctxConfig.physics,
|
|
283
284
|
scope: plan.activeScope,
|
|
284
285
|
vectorCandidates,
|
|
286
|
+
store,
|
|
285
287
|
})
|
|
286
288
|
: await hybridSearch(query, localEntries, {
|
|
287
289
|
budget: left,
|
|
@@ -290,6 +292,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
|
|
|
290
292
|
hippoRoot: ctx.hippoRoot,
|
|
291
293
|
scope: plan.activeScope,
|
|
292
294
|
vectorCandidates,
|
|
295
|
+
store,
|
|
293
296
|
});
|
|
294
297
|
return ctxResults.map((r) => ({
|
|
295
298
|
entry: r.entry,
|
|
@@ -299,21 +302,21 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
|
|
|
299
302
|
}));
|
|
300
303
|
}
|
|
301
304
|
// Same 'recall' op api.recall emits; the pinned-only and no-query branches never search, so they never emit.
|
|
302
|
-
function auditContextRecall(ctx, plan, resultCount) {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
async function auditContextRecall(ctx, plan, resultCount) {
|
|
306
|
+
const row = {
|
|
307
|
+
tenantId: ctx.tenantId,
|
|
308
|
+
actor: ctx.actor.subject,
|
|
309
|
+
op: 'recall',
|
|
310
|
+
metadata: { ...auditQueryFields(plan.query), results: resultCount, mode: 'context' },
|
|
307
311
|
};
|
|
312
|
+
if (plan.other) {
|
|
313
|
+
await plan.other.appendAuditEvents([row]);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
308
316
|
if (plan.hasLocal) {
|
|
309
317
|
const localDb = openHippoDb(ctx.hippoRoot);
|
|
310
318
|
try {
|
|
311
|
-
appendAuditEvent(localDb,
|
|
312
|
-
tenantId: ctx.tenantId,
|
|
313
|
-
actor: ctx.actor.subject,
|
|
314
|
-
op: 'recall',
|
|
315
|
-
metadata: ctxRecallMetadata,
|
|
316
|
-
});
|
|
319
|
+
appendAuditEvent(localDb, row);
|
|
317
320
|
}
|
|
318
321
|
finally {
|
|
319
322
|
closeHippoDb(localDb);
|
|
@@ -322,12 +325,7 @@ function auditContextRecall(ctx, plan, resultCount) {
|
|
|
322
325
|
if (plan.hasGlobal && !plan.primaryIsGlobal) {
|
|
323
326
|
const globalDb = openHippoDb(plan.globalRoot);
|
|
324
327
|
try {
|
|
325
|
-
appendAuditEvent(globalDb,
|
|
326
|
-
tenantId: ctx.tenantId,
|
|
327
|
-
actor: ctx.actor.subject,
|
|
328
|
-
op: 'recall',
|
|
329
|
-
metadata: ctxRecallMetadata,
|
|
330
|
-
});
|
|
328
|
+
appendAuditEvent(globalDb, row);
|
|
331
329
|
}
|
|
332
330
|
finally {
|
|
333
331
|
closeHippoDb(globalDb);
|
package/dist/api/context.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// Ambient context injection: the admission policy and getContext.
|
|
2
2
|
import { isInitialized } from '../store/open.js';
|
|
3
3
|
import { strengthenRetrieved } from '../store/entry-writes.js';
|
|
4
|
-
import { loadRecallSearchEntries } from '../store/search-rows.js';
|
|
5
4
|
import { DEFAULT_SEARCH_CANDIDATE_LIMIT } from '../store/rows.js';
|
|
6
|
-
import { loadAmbientCandidates, loadContextCandidates, } from '../store/candidates.js';
|
|
7
5
|
import { loadIndex, saveIndex, updateStats } from '../store/index-and-stats.js';
|
|
8
|
-
import {
|
|
9
|
-
import { loadLatestHandoff } from '../store/handoffs.js';
|
|
6
|
+
import { freshActiveSnapshot, SNAPSHOT_AMBIENT_MAX_AGE_MS } from '../store/sessions.js';
|
|
10
7
|
import { estimateTokens } from '../token-ledger.js';
|
|
11
8
|
import { markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
|
|
12
9
|
import { isWorthSurfacing } from '../memory-quality.js';
|
|
@@ -22,7 +19,7 @@ import { promptTokens } from '../prompt-recall.js';
|
|
|
22
19
|
import { detectSecret } from '../secret-detect.js';
|
|
23
20
|
import { isSessionDigestRow } from '../session-digest.js';
|
|
24
21
|
import { addAmbientTallies, ambientStateFromTallies } from '../ambient.js';
|
|
25
|
-
import {
|
|
22
|
+
import { requireGroup, sqliteStore, storeFor } from '../store-port.js';
|
|
26
23
|
import { passesScopeFilterForRecall, assertScopeRequestAllowed, personalScopeOf } from '../recall-scope.js';
|
|
27
24
|
import { finiteOr, selectBySearch, selectPinned, selectStrongest, } from './context-select.js';
|
|
28
25
|
import { ownerOrSubject } from './types.js';
|
|
@@ -64,13 +61,27 @@ export function ambientSecretAdmit(e, currentProject) {
|
|
|
64
61
|
}
|
|
65
62
|
/** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
|
|
66
63
|
export const CONTEXT_CANDIDATE_CAP = 2000;
|
|
64
|
+
/** A store's base search and context reads; a store without the group throws StoreNotPortedError. */
|
|
65
|
+
function sourceOf(store) {
|
|
66
|
+
return { store, reads: requireGroup(store, 'contextReads') };
|
|
67
|
+
}
|
|
67
68
|
// The pinned-only branch needs pins and recent-N candidates, not the corpus; `recent` and `recall` apply there only.
|
|
68
|
-
|
|
69
|
+
// `recheck` is `admit` without the delivery observer, so a row checked twice is not refused twice in the ledger.
|
|
70
|
+
async function loadAmbientEntries(source, tenantId, pinnedOnly, recent, admit, recheck, window, recall, onQualityDrop) {
|
|
71
|
+
const ownTenant = (e) => e.tenantId === tenantId;
|
|
69
72
|
if (!pinnedOnly) {
|
|
70
73
|
const rows = 'query' in window
|
|
71
|
-
?
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
? await source.store.searchRecallEntries(window.query, {
|
|
75
|
+
limit: DEFAULT_SEARCH_CANDIDATE_LIMIT,
|
|
76
|
+
tenantId,
|
|
77
|
+
requestedScope: window.exactScope,
|
|
78
|
+
explicitScopeMode: 'exact',
|
|
79
|
+
includeSuperseded: false,
|
|
80
|
+
originProjects: window.project,
|
|
81
|
+
ownScope: window.ownScope,
|
|
82
|
+
})
|
|
83
|
+
: await source.reads.contextCandidates(tenantId, window);
|
|
84
|
+
return { entries: rows.filter((e) => ownTenant(e) && admit(e)) };
|
|
74
85
|
}
|
|
75
86
|
// The quality floor runs on the recent-N slice AFTER this load, so the load
|
|
76
87
|
// counts by it too, or it stops short of a store whose newest rows are junk.
|
|
@@ -82,7 +93,10 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, recent, admit, wind
|
|
|
82
93
|
onQualityDrop?.(e);
|
|
83
94
|
return false;
|
|
84
95
|
};
|
|
85
|
-
|
|
96
|
+
const loaded = await source.reads.ambientCandidates(tenantId, { recentNeeded: recent.needed, admit: admitAmbient, recall, origins: recent.origins });
|
|
97
|
+
// Pins and recent rows reach core only through the store's own call to admit, so core applies it again with the tenant.
|
|
98
|
+
const entries = loaded.entries.filter((e) => ownTenant(e) && recheck(e));
|
|
99
|
+
return loaded.recall ? { entries, recall: loaded.recall.filter(ownTenant) } : { entries };
|
|
86
100
|
}
|
|
87
101
|
/**
|
|
88
102
|
* Assemble a context bundle: recalled memories (pinned-only / strength-sorted
|
|
@@ -107,10 +121,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
107
121
|
if (budget <= 0) {
|
|
108
122
|
return { entries: [], tokens: 0 };
|
|
109
123
|
}
|
|
110
|
-
const plan = planContext(ctx, opts);
|
|
111
|
-
const sections = loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
|
|
124
|
+
const plan = planContext(ctx, opts, sourceOf(storeFor(ctx)));
|
|
125
|
+
const sections = await loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
|
|
112
126
|
const admission = ambientAdmission(opts, plan, sections.shownHandoff);
|
|
113
|
-
const pools = loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
|
|
127
|
+
const pools = await loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
|
|
114
128
|
const noSections = !sections.shownSnapshot && !sections.shownHandoff && sections.shownEvents.length === 0;
|
|
115
129
|
if (!plan.promptRecallPending && pools.local.entries.length === 0 && pools.global.entries.length === 0 && noSections) {
|
|
116
130
|
return { entries: [], tokens: 0 };
|
|
@@ -127,14 +141,14 @@ export async function getContext(ctx, opts = {}) {
|
|
|
127
141
|
if (items.length === 0 && noSections) {
|
|
128
142
|
// A query that found nothing is the coverage-gap signal, so it still gets a trace; pinned-only stays read-only.
|
|
129
143
|
if (!plan.pinnedOnly)
|
|
130
|
-
traceEmptyContext(ctx, opts, plan
|
|
144
|
+
await traceEmptyContext(ctx, opts, plan);
|
|
131
145
|
return { entries: [], tokens: 0 };
|
|
132
146
|
}
|
|
133
147
|
// pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
|
|
134
148
|
// memories don't inflate retrieval_count or extend half_life every turn.
|
|
135
149
|
const recorded = plan.pinnedOnly
|
|
136
150
|
? { items, ambientState: undefined }
|
|
137
|
-
: recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
|
|
151
|
+
: await recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
|
|
138
152
|
return {
|
|
139
153
|
entries: recorded.items,
|
|
140
154
|
tokens,
|
|
@@ -152,16 +166,17 @@ function warnIsolationIgnored(hippoRoot) {
|
|
|
152
166
|
isolationWarned.add(hippoRoot);
|
|
153
167
|
log.warn(`${hippoRoot}: "contextProjectIsolation": false is ignored on a shared store; a read opts in with cross_project.`);
|
|
154
168
|
}
|
|
155
|
-
function planContext(ctx, opts) {
|
|
169
|
+
function planContext(ctx, opts, local) {
|
|
156
170
|
const pinnedOnly = opts.pinnedOnly === true;
|
|
171
|
+
const other = local.store.kind !== 'sqlite' ? local.store : undefined;
|
|
157
172
|
// Global memories do not establish a project boundary for task state.
|
|
158
|
-
const hasLocal = isInitialized(ctx.hippoRoot);
|
|
173
|
+
const hasLocal = other !== undefined || isInitialized(ctx.hippoRoot);
|
|
159
174
|
const query = (opts.q ?? '').trim() || '*';
|
|
160
175
|
const globalRoot = getGlobalRoot();
|
|
161
176
|
// The store's own flag counts too, so no surface can read a shared store as its owner.
|
|
162
177
|
const sharedStore = opts.sharedStore === true || isSharedStore(ctx.hippoRoot);
|
|
163
178
|
// A store serving many people is not its operator's, so the operator's own global store stays out.
|
|
164
|
-
const hasGlobal = !sharedStore && isInitialized(globalRoot);
|
|
179
|
+
const hasGlobal = !sharedStore && other === undefined && isInitialized(globalRoot);
|
|
165
180
|
const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
|
|
166
181
|
// opts.scope is only the tag boost, opts.exactScope the envelope request; other-project memories are
|
|
167
182
|
// excluded unless the caller asks for them (crossProject) or isolation is disabled.
|
|
@@ -181,6 +196,8 @@ function planContext(ctx, opts) {
|
|
|
181
196
|
? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProject) })
|
|
182
197
|
: estimateTokens(entry.content);
|
|
183
198
|
return {
|
|
199
|
+
local,
|
|
200
|
+
other,
|
|
184
201
|
pinnedOnly,
|
|
185
202
|
limit: opts.limit ?? Number.POSITIVE_INFINITY,
|
|
186
203
|
includeRecent: opts.includeRecent ?? 0,
|
|
@@ -227,30 +244,25 @@ function openBlockBudget(plan, opts, budget) {
|
|
|
227
244
|
}
|
|
228
245
|
const NO_TASK_STATE = { snapshot: null, handoff: null, events: [] };
|
|
229
246
|
// Keyed on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
|
|
230
|
-
function loadRawTaskState(ctx, opts, plan) {
|
|
247
|
+
async function loadRawTaskState(ctx, opts, plan) {
|
|
231
248
|
if (!plan.hasLocalTaskState)
|
|
232
249
|
return NO_TASK_STATE;
|
|
250
|
+
const { store, reads } = plan.local;
|
|
233
251
|
// On a shared store task state is one person's, keyed by owner and project so it follows them into their next session.
|
|
234
252
|
const key = plan.sharedStore
|
|
235
253
|
? { owner: ownerOrSubject(ctx.actor), project: projectNames(plan.currentProject) }
|
|
236
|
-
:
|
|
254
|
+
: null;
|
|
255
|
+
const block = await store.continuity(ctx.tenantId, 5, key);
|
|
237
256
|
// Bounded read: an orphaned snapshot ages out of this ambient surface; the owner session's read stays unbounded.
|
|
238
|
-
const snapshot =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
|
|
245
|
-
// Scope is admitted in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
|
|
246
|
-
scopeFilter: 'default-deny',
|
|
247
|
-
}, key);
|
|
248
|
-
// Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
|
|
249
|
-
const events = sessionId ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 }) : [];
|
|
250
|
-
return { snapshot, handoff, events };
|
|
257
|
+
const snapshot = freshActiveSnapshot(block.activeSnapshot, { sessionId: opts.currentSessionId });
|
|
258
|
+
// The block's handoff and events are the raw snapshot's session's, so they stand only while that snapshot is fresh.
|
|
259
|
+
if (snapshot?.session_id)
|
|
260
|
+
return { snapshot, handoff: block.sessionHandoff, events: block.recentSessionEvents };
|
|
261
|
+
// An unfinished handoff's scope is admitted in the read, so a newer denied row can't hide an older eligible one.
|
|
262
|
+
return { snapshot, handoff: await reads.unfinishedHandoff(ctx.tenantId, SNAPSHOT_AMBIENT_MAX_AGE_MS, key), events: [] };
|
|
251
263
|
}
|
|
252
264
|
// Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
|
|
253
|
-
function loadTaskSections(ctx, opts, plan, startLeft) {
|
|
265
|
+
async function loadTaskSections(ctx, opts, plan, startLeft) {
|
|
254
266
|
const { exactScope, ownScope, cost } = plan;
|
|
255
267
|
let left = startLeft;
|
|
256
268
|
const pays = (tokens) => {
|
|
@@ -260,7 +272,7 @@ function loadTaskSections(ctx, opts, plan, startLeft) {
|
|
|
260
272
|
return true;
|
|
261
273
|
};
|
|
262
274
|
const rowScope = (r) => r?.scope ?? null;
|
|
263
|
-
const raw = loadRawTaskState(ctx, opts, plan);
|
|
275
|
+
const raw = await loadRawTaskState(ctx, opts, plan);
|
|
264
276
|
// The same envelope rule ambientAdmitEntry applies to memory rows.
|
|
265
277
|
const activeSnapshot = raw.snapshot && passesScopeFilterForRecall(rowScope(raw.snapshot), exactScope, ownScope) ? raw.snapshot : null;
|
|
266
278
|
const sessionHandoff = raw.handoff && passesScopeFilterForRecall(rowScope(raw.handoff), exactScope, ownScope) ? raw.handoff : null;
|
|
@@ -296,7 +308,7 @@ function recentOrigins(plan) {
|
|
|
296
308
|
return { names: projectNames(plan.currentProject).filter((n) => n !== ''), userGlobal: false };
|
|
297
309
|
return plan.originProject && { names: plan.originProject, userGlobal: true };
|
|
298
310
|
}
|
|
299
|
-
function loadPools(ctx, plan, admission, recallRequest) {
|
|
311
|
+
async function loadPools(ctx, plan, admission, recallRequest) {
|
|
300
312
|
const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope, ownScope } = plan;
|
|
301
313
|
const recent = { needed: plan.includeRecent, origins: recentOrigins(plan) };
|
|
302
314
|
const searches = plan.query !== '*' && !pinnedOnly;
|
|
@@ -316,10 +328,10 @@ function loadPools(ctx, plan, admission, recallRequest) {
|
|
|
316
328
|
};
|
|
317
329
|
// Tenant-scoped loads: never resolveTenantId({}) here.
|
|
318
330
|
const local = plan.hasLocal
|
|
319
|
-
? loadAmbientEntries(
|
|
331
|
+
? await loadAmbientEntries(plan.local, ctx.tenantId, pinnedOnly, recent, loadAdmit, poolAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
|
|
320
332
|
: { entries: [] };
|
|
321
333
|
const global = hasGlobal && !primaryIsGlobal
|
|
322
|
-
? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, recent, loadAdmit, window, recallRequest, qualityDrop(true))
|
|
334
|
+
? await loadAmbientEntries(sourceOf(sqliteStore(plan.globalRoot)), ctx.tenantId, pinnedOnly, recent, loadAdmit, poolAdmit, window, recallRequest, qualityDrop(true))
|
|
323
335
|
: { entries: [] };
|
|
324
336
|
return { local, global };
|
|
325
337
|
}
|
|
@@ -345,31 +357,35 @@ function finalizeSelection(picked, plan) {
|
|
|
345
357
|
obs?.selected(selected);
|
|
346
358
|
return { items: selected, tokens };
|
|
347
359
|
}
|
|
348
|
-
// Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id
|
|
349
|
-
function traceEmptyContext(ctx, opts,
|
|
360
|
+
// Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id.
|
|
361
|
+
async function traceEmptyContext(ctx, opts, plan) {
|
|
350
362
|
// No snapshot in this branch, so the caller's own id is the only session to stamp.
|
|
351
|
-
|
|
363
|
+
const trace = {
|
|
352
364
|
tenantId: ctx.tenantId,
|
|
353
365
|
sessionId: opts.currentSessionId || null,
|
|
354
366
|
pipeline: 'context',
|
|
355
|
-
query,
|
|
367
|
+
query: plan.query,
|
|
356
368
|
explainMode: false,
|
|
357
369
|
results: [],
|
|
358
|
-
}
|
|
370
|
+
};
|
|
371
|
+
if (!plan.other) {
|
|
372
|
+
writeRecallTraceAtRoot(ctx.hippoRoot, trace);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// The empty reply is already decided, so a lost trace logs as the hippo.db write does rather than failing the call.
|
|
376
|
+
try {
|
|
377
|
+
await plan.other.finishRecall({ goalLog: [], audit: [], trace });
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
log.error(`recall trace write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
381
|
+
}
|
|
359
382
|
}
|
|
360
383
|
/** Strengthens and marks the returned rows, then writes the trace and index together. */
|
|
361
|
-
function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
|
|
384
|
+
async function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
|
|
362
385
|
const toUpdate = selected.map((s) => s.entry);
|
|
363
386
|
const updatedEntries = markRetrieved(toUpdate);
|
|
364
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
365
387
|
const retrievedIds = updatedEntries.map((u) => u.id);
|
|
366
|
-
const
|
|
367
|
-
if (plan.hasGlobal)
|
|
368
|
-
strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
|
|
369
|
-
localIndex.last_retrieval_ids = retrievedIds;
|
|
370
|
-
// Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
|
|
371
|
-
// last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
|
|
372
|
-
const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
388
|
+
const trace = {
|
|
373
389
|
tenantId: ctx.tenantId,
|
|
374
390
|
sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
|
|
375
391
|
pipeline: 'context',
|
|
@@ -379,10 +395,11 @@ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
|
|
|
379
395
|
memoryId: s.entry.id,
|
|
380
396
|
score: s.score,
|
|
381
397
|
})),
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
398
|
+
};
|
|
399
|
+
if (plan.other)
|
|
400
|
+
await recordOnStore(ctx, plan.other, retrievedIds, trace, selected.length);
|
|
401
|
+
else
|
|
402
|
+
recordOnHippoDb(ctx, plan, retrievedIds, trace, selected.length);
|
|
386
403
|
// Replace selectedItems entries with markRetrieved-updated copies so
|
|
387
404
|
// the returned ContextResult reflects post-recall state.
|
|
388
405
|
const items = selected.map((s) => ({
|
|
@@ -390,12 +407,36 @@ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
|
|
|
390
407
|
entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
|
|
391
408
|
}));
|
|
392
409
|
// Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
|
|
393
|
-
return { items, ambientState: plan.config.ambient.enabled ? readAmbientState(ctx, plan) : undefined };
|
|
410
|
+
return { items, ambientState: plan.config.ambient.enabled ? await readAmbientState(ctx, plan) : undefined };
|
|
411
|
+
}
|
|
412
|
+
function recordOnHippoDb(ctx, plan, retrievedIds, trace, recalled) {
|
|
413
|
+
const localIndex = loadIndex(ctx.hippoRoot);
|
|
414
|
+
const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
|
|
415
|
+
if (plan.hasGlobal)
|
|
416
|
+
strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
|
|
417
|
+
localIndex.last_retrieval_ids = retrievedIds;
|
|
418
|
+
// Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
|
|
419
|
+
// last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
|
|
420
|
+
const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, trace);
|
|
421
|
+
localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
|
|
422
|
+
saveIndex(ctx.hippoRoot, localIndex);
|
|
423
|
+
updateStats(ctx.hippoRoot, { recalled });
|
|
424
|
+
}
|
|
425
|
+
// The index's last-recall ids feed only outcomeForLastRecall, which no other store serves, so they are not written here.
|
|
426
|
+
async function recordOnStore(ctx, store, retrievedIds, trace, recalled) {
|
|
427
|
+
const strengthen = { ids: retrievedIds, opts: { tenantId: ctx.tenantId, recallBoostAblated: isRecallBoostAblated() } };
|
|
428
|
+
await store.finishRecall({ goalLog: [], audit: [], trace, strengthen });
|
|
429
|
+
await store.bumpRecallStats(recalled);
|
|
394
430
|
}
|
|
395
|
-
function readAmbientState(ctx, plan) {
|
|
431
|
+
async function readAmbientState(ctx, plan) {
|
|
396
432
|
const filter = { exactScope: plan.exactScope, ownScope: plan.ownScope, project: plan.originProject, currentProject: projectNames(plan.currentProject), now: evalNow() };
|
|
397
|
-
const
|
|
398
|
-
|
|
433
|
+
const sources = [
|
|
434
|
+
...(plan.hasLocal ? [plan.local.reads] : []),
|
|
435
|
+
...(plan.hasGlobal && !plan.primaryIsGlobal ? [sourceOf(sqliteStore(plan.globalRoot)).reads] : []),
|
|
436
|
+
];
|
|
437
|
+
const tallies = [];
|
|
438
|
+
for (const reads of sources)
|
|
439
|
+
tallies.push(await reads.ambientTallies(ctx.tenantId, filter));
|
|
399
440
|
const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
|
|
400
441
|
return total && total.total > 0 ? ambientStateFromTallies(total) : undefined;
|
|
401
442
|
}
|
package/dist/api/forget.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RejectedValueRow } from '../rejection.js';
|
|
2
|
-
import type { Context } from './types.js';
|
|
2
|
+
import type { Context, StoreReply } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Delete a memory by id. `deleteEntry` threads ctx.actor.subject into its internal
|
|
5
5
|
* audit hook, so exactly one 'forget' event lands with the supplied actor.
|
|
@@ -14,7 +14,7 @@ export interface ForgetResult {
|
|
|
14
14
|
ok: true;
|
|
15
15
|
id: string;
|
|
16
16
|
}
|
|
17
|
-
export declare function forget(ctx:
|
|
17
|
+
export declare function forget<C extends Context>(ctx: C, id: string): StoreReply<C, ForgetResult>;
|
|
18
18
|
export interface RejectOpts {
|
|
19
19
|
/** By-id form: reject the CURRENT content of an existing memory. */
|
|
20
20
|
memoryId?: string;
|
package/dist/api/forget.js
CHANGED
|
@@ -4,9 +4,20 @@ import { BadRequestError, NotFoundError } from '../api-errors.js';
|
|
|
4
4
|
import { deleteEntry } from '../store/delete-and-batch.js';
|
|
5
5
|
import { updateStatsUnlessBusy } from '../store/index-and-stats.js';
|
|
6
6
|
import { rejectValue, unrejectValue, listRejectionsForTenant } from '../reject-flow.js';
|
|
7
|
+
import { requireGroup } from '../store-port.js';
|
|
7
8
|
import { selectMemoryReach } from '../store/tenant-lookup.js';
|
|
8
9
|
import { canTouchScope, personalScopeOf } from '../recall-scope.js';
|
|
9
10
|
export function forget(ctx, id) {
|
|
11
|
+
const reply = ctx.store ? forgetThroughStore(ctx, ctx.store, id) : forgetOnHippoDb(ctx, id);
|
|
12
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
13
|
+
return reply;
|
|
14
|
+
}
|
|
15
|
+
async function forgetThroughStore(ctx, store, id) {
|
|
16
|
+
const entryWrites = requireGroup(store, 'entryWrites');
|
|
17
|
+
await entryWrites.forget({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), id });
|
|
18
|
+
return { ok: true, id };
|
|
19
|
+
}
|
|
20
|
+
function forgetOnHippoDb(ctx, id) {
|
|
10
21
|
const db = openHippoDb(ctx.hippoRoot);
|
|
11
22
|
try {
|
|
12
23
|
const reach = selectMemoryReach(db, id);
|
package/dist/api/outcome.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Context } from './types.js';
|
|
1
|
+
import type { Context, StoreReply } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Apply a positive/negative outcome to a list of recently-recalled memory ids.
|
|
4
4
|
* Used by the MCP `hippo_outcome` tool and the HTTP `POST /v1/outcome` route.
|
|
@@ -27,9 +27,9 @@ export interface OutcomeResult {
|
|
|
27
27
|
applied: number;
|
|
28
28
|
appliedIds: string[];
|
|
29
29
|
}
|
|
30
|
-
export declare function outcome(ctx:
|
|
30
|
+
export declare function outcome<C extends Context>(ctx: C, ids: ReadonlyArray<string>, good: boolean, opts?: {
|
|
31
31
|
traceId?: number;
|
|
32
|
-
}): OutcomeResult
|
|
32
|
+
}): StoreReply<C, OutcomeResult>;
|
|
33
33
|
/**
|
|
34
34
|
* Apply an outcome to the ids most recently returned by `recall()`.
|
|
35
35
|
*
|
|
@@ -52,5 +52,5 @@ export interface OutcomeForLastRecallResult {
|
|
|
52
52
|
applied: number;
|
|
53
53
|
ids: string[];
|
|
54
54
|
}
|
|
55
|
-
export declare function outcomeForLastRecall(ctx:
|
|
55
|
+
export declare function outcomeForLastRecall<C extends Context>(ctx: C, good: boolean): StoreReply<C, OutcomeForLastRecallResult>;
|
|
56
56
|
//# sourceMappingURL=outcome.d.ts.map
|
package/dist/api/outcome.js
CHANGED
|
@@ -4,11 +4,26 @@ import { openStore } from '../store/open.js';
|
|
|
4
4
|
import { writeEntryOn } from '../store/entry-writes.js';
|
|
5
5
|
import { selectEntriesByIds } from '../store/entry-reads.js';
|
|
6
6
|
import { loadIndex } from '../store/index-and-stats.js';
|
|
7
|
-
import {
|
|
7
|
+
import { entryAfterOutcome } from '../memory.js';
|
|
8
8
|
import { appendAuditEvent } from '../audit.js';
|
|
9
9
|
import { recordTraceOutcome } from '../recall-trace.js';
|
|
10
|
-
import { canTouchScope } from '../recall-scope.js';
|
|
10
|
+
import { canTouchScope, personalScopeOf } from '../recall-scope.js';
|
|
11
|
+
import { SqliteBlockedError } from '../db/sqlite-blocked.js';
|
|
12
|
+
import { requireGroup } from '../store-port.js';
|
|
11
13
|
export function outcome(ctx, ids, good, opts) {
|
|
14
|
+
const reply = ctx.store ? outcomeThroughStore(ctx, ctx.store, ids, good, opts) : outcomeOnHippoDb(ctx, ids, good, opts);
|
|
15
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
16
|
+
return reply;
|
|
17
|
+
}
|
|
18
|
+
/** The rows and their audit rows commit together here; the trace link has no store method, since only the last-recall path sends one. */
|
|
19
|
+
async function outcomeThroughStore(ctx, store, ids, good, opts) {
|
|
20
|
+
const entryWrites = requireGroup(store, 'entryWrites');
|
|
21
|
+
if (opts?.traceId !== undefined)
|
|
22
|
+
throw new Error('an outcome links its recall trace on hippo.db only, never through a store');
|
|
23
|
+
const appliedIds = await entryWrites.applyOutcome({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), ids, good });
|
|
24
|
+
return { applied: appliedIds.length, appliedIds };
|
|
25
|
+
}
|
|
26
|
+
function outcomeOnHippoDb(ctx, ids, good, opts) {
|
|
12
27
|
const appliedIds = [];
|
|
13
28
|
const db = openStore(ctx.hippoRoot);
|
|
14
29
|
try {
|
|
@@ -17,10 +32,7 @@ export function outcome(ctx, ids, good, opts) {
|
|
|
17
32
|
const entry = live.get(id);
|
|
18
33
|
if (!entry || !canTouchScope(ctx.actor, entry.scope ?? null))
|
|
19
34
|
continue;
|
|
20
|
-
|
|
21
|
-
if (good && updated.tags.includes(CHURN_STALE_TAG)) { // a good outcome reconfirms the entry
|
|
22
|
-
updated = { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
|
|
23
|
-
}
|
|
35
|
+
const updated = entryAfterOutcome(entry, good);
|
|
24
36
|
writeEntryOn(db, ctx.hippoRoot, updated, { actor: ctx.actor.subject });
|
|
25
37
|
live.set(id, updated); // a repeated id builds on its first outcome, as a fresh read would
|
|
26
38
|
appendAuditEvent(db, {
|
|
@@ -50,6 +62,17 @@ export function outcome(ctx, ids, good, opts) {
|
|
|
50
62
|
return { applied: appliedIds.length, appliedIds };
|
|
51
63
|
}
|
|
52
64
|
export function outcomeForLastRecall(ctx, good) {
|
|
65
|
+
const reply = ctx.store ? lastRecallUnderStore(ctx, ctx.store, good) : outcomeForLastRecallOnHippoDb({ ...ctx, store: undefined }, good);
|
|
66
|
+
// SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
|
|
67
|
+
return reply;
|
|
68
|
+
}
|
|
69
|
+
/** The last recall's ids and trace sit in hippo.db's meta table, which only the CLI and context write, so no other store holds them. */
|
|
70
|
+
async function lastRecallUnderStore(ctx, store, good) {
|
|
71
|
+
if (store.kind !== 'sqlite')
|
|
72
|
+
throw new SqliteBlockedError(store.kind);
|
|
73
|
+
return outcomeForLastRecallOnHippoDb({ ...ctx, store: undefined }, good);
|
|
74
|
+
}
|
|
75
|
+
function outcomeForLastRecallOnHippoDb(ctx, good) {
|
|
53
76
|
const idx = loadIndex(ctx.hippoRoot);
|
|
54
77
|
const ids = idx.last_retrieval_ids;
|
|
55
78
|
if (ids.length === 0)
|
package/dist/api/promote.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DatabaseSyncLike } from '../db.js';
|
|
2
|
-
import type { Context } from './types.js';
|
|
2
|
+
import type { Context, StoreReply } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Copy a local memory into the global store. Mirrors `cmdPromote` in cli.ts:
|
|
5
5
|
* the `writeEntry` inside `promoteToGlobal` emits a 'remember' on the global
|
|
@@ -26,7 +26,7 @@ export interface SupersedeResult {
|
|
|
26
26
|
oldId: string;
|
|
27
27
|
newId: string;
|
|
28
28
|
}
|
|
29
|
-
export declare function supersede(ctx:
|
|
29
|
+
export declare function supersede<C extends Context>(ctx: C, oldId: string, newContent: string): StoreReply<C, SupersedeResult>;
|
|
30
30
|
/**
|
|
31
31
|
* Archive a kind='raw' memory: snapshot into raw_archive, mark archived, delete.
|
|
32
32
|
*
|
|
@@ -47,5 +47,5 @@ export interface ArchiveRawResult {
|
|
|
47
47
|
ok: true;
|
|
48
48
|
archivedAt: string;
|
|
49
49
|
}
|
|
50
|
-
export declare function archiveRaw(ctx:
|
|
50
|
+
export declare function archiveRaw<C extends Context>(ctx: C, id: string, reason: string, opts?: ArchiveRawOpts): StoreReply<C, ArchiveRawResult>;
|
|
51
51
|
//# sourceMappingURL=promote.d.ts.map
|