hippo-memory 1.64.1 → 1.65.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/dist/agent-memories/sync.js +3 -2
- package/dist/api/recall-record.d.ts +46 -0
- package/dist/api/recall-record.js +98 -0
- package/dist/api/recall-request.d.ts +32 -0
- package/dist/api/recall-request.js +121 -0
- package/dist/api/recall-types.d.ts +7 -1
- package/dist/api/recall.d.ts +3 -2
- package/dist/api/recall.js +174 -181
- package/dist/api/tokens.d.ts +1 -1
- package/dist/api/tokens.js +13 -17
- package/dist/cli/recall.d.ts +1 -1
- package/dist/cli/recall.js +18 -49
- package/dist/cli/shared.js +2 -2
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +18 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +4 -0
- package/dist/db/connect.d.ts +5 -0
- package/dist/db/connect.js +52 -0
- package/dist/db/open.d.ts +9 -11
- package/dist/db/open.js +23 -116
- package/dist/db/request-stores.d.ts +30 -0
- package/dist/db/request-stores.js +100 -0
- package/dist/db/sqlite-blocked.d.ts +6 -0
- package/dist/db/sqlite-blocked.js +11 -0
- package/dist/db.d.ts +3 -1
- package/dist/db.js +3 -1
- package/dist/embeddings.js +2 -1
- package/dist/goals.d.ts +18 -11
- package/dist/goals.js +38 -36
- package/dist/http-util.d.ts +2 -0
- package/dist/http-util.js +5 -0
- package/dist/mcp/memory-tools.js +4 -1
- package/dist/mcp/recall-tools.js +28 -133
- package/dist/mcp/request.d.ts +7 -0
- package/dist/mcp/request.js +63 -41
- package/dist/mcp/session-state.d.ts +1 -3
- package/dist/mcp/session-state.js +3 -5
- package/dist/predictions/planning-fallacy.d.ts +52 -0
- package/dist/predictions/planning-fallacy.js +70 -55
- package/dist/recall-trace.d.ts +1 -1
- package/dist/recall-trace.js +5 -4
- package/dist/search/physics-search.js +4 -1
- package/dist/search/vector.js +2 -0
- package/dist/server/routes/recall.d.ts +1 -1
- package/dist/server/routes/recall.js +30 -218
- package/dist/server.d.ts +12 -1
- package/dist/server.js +33 -22
- package/dist/store/entry-writes.d.ts +2 -0
- package/dist/store/entry-writes.js +24 -16
- package/dist/store-port.d.ts +70 -1
- package/dist/store-port.js +87 -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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DatabaseSyncLike } from './sqlite.js';
|
|
2
|
+
export interface RequestStoresOptions {
|
|
3
|
+
/** Lock wait of every open in the scope that does not pass its own. */
|
|
4
|
+
readonly busyWaitMs?: number;
|
|
5
|
+
/** Set for hook scopes: after one lock wait runs out, {@link RequestStores.noteBusy} makes the scope's later writes skip at once. */
|
|
6
|
+
readonly failFastWhenBusy?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** The handles one request opened, keyed by resolved store path and lock wait. */
|
|
9
|
+
export declare class RequestStores {
|
|
10
|
+
#private;
|
|
11
|
+
readonly busyWaitMs: number | undefined;
|
|
12
|
+
constructor(opts?: RequestStoresOptions);
|
|
13
|
+
get closed(): boolean;
|
|
14
|
+
/** The scope's handle on `hippoRoot`, opened on first use. `opts.busyWaitMs` overrides the scope's lock wait. */
|
|
15
|
+
get(hippoRoot: string, opts?: {
|
|
16
|
+
busyWaitMs?: number;
|
|
17
|
+
}): DatabaseSyncLike;
|
|
18
|
+
/** A lock held past one full wait belongs to a long transaction, so a hook scope's later writes skip at once. */
|
|
19
|
+
noteBusy(): void;
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
/** Runs `fn` in a request scope whose handles close when `fn` settles; inside an open scope `fn` joins it and `opts` is ignored. */
|
|
23
|
+
export declare function runWithRequestStores<T>(fn: () => T | Promise<T>, opts?: RequestStoresOptions): Promise<T>;
|
|
24
|
+
/** The scope this code runs in, if any. A closed scope still answers: its `get` falls through to a plain open. */
|
|
25
|
+
export declare function currentRequestStores(): RequestStores | undefined;
|
|
26
|
+
/** Runs `fn` outside every scope, for a connection that must outlive the request that happens to open it. */
|
|
27
|
+
export declare function outsideRequestStores<T>(fn: () => T): T;
|
|
28
|
+
/** Whether a scope owns `db` and will close it itself. */
|
|
29
|
+
export declare function isScopedHandle(db: DatabaseSyncLike): boolean;
|
|
30
|
+
//# sourceMappingURL=request-stores.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// One store handle per store for each request (HTTP call, MCP tool call, scoped CLI command), opened lazily and closed with it.
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
import { connectHippoDb, getHippoDbPath } from './connect.js';
|
|
5
|
+
// Handles a scope owns, so closeHippoDb leaves them open for the rest of the request.
|
|
6
|
+
const scopedHandles = new WeakSet();
|
|
7
|
+
// process.exit skips every finally, so one exit listener closes the handles of scopes still open.
|
|
8
|
+
const liveScopes = new Set();
|
|
9
|
+
let exitListenerInstalled = false;
|
|
10
|
+
function closeLiveScopes() {
|
|
11
|
+
for (const scope of liveScopes)
|
|
12
|
+
scope.close();
|
|
13
|
+
}
|
|
14
|
+
function trackLive(scope) {
|
|
15
|
+
liveScopes.add(scope);
|
|
16
|
+
if (exitListenerInstalled)
|
|
17
|
+
return;
|
|
18
|
+
process.on('exit', closeLiveScopes);
|
|
19
|
+
exitListenerInstalled = true;
|
|
20
|
+
}
|
|
21
|
+
/** The handles one request opened, keyed by resolved store path and lock wait. */
|
|
22
|
+
export class RequestStores {
|
|
23
|
+
busyWaitMs;
|
|
24
|
+
#failFastWhenBusy;
|
|
25
|
+
#handles = new Map();
|
|
26
|
+
#closed = false;
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
this.busyWaitMs = opts.busyWaitMs;
|
|
29
|
+
this.#failFastWhenBusy = opts.failFastWhenBusy === true;
|
|
30
|
+
}
|
|
31
|
+
get closed() {
|
|
32
|
+
return this.#closed;
|
|
33
|
+
}
|
|
34
|
+
/** The scope's handle on `hippoRoot`, opened on first use. `opts.busyWaitMs` overrides the scope's lock wait. */
|
|
35
|
+
get(hippoRoot, opts) {
|
|
36
|
+
const busyWaitMs = opts?.busyWaitMs ?? this.busyWaitMs;
|
|
37
|
+
// A listener or timer that outlives its request still sees this scope, so it gets a connection of its own.
|
|
38
|
+
if (this.#closed)
|
|
39
|
+
return connectHippoDb(hippoRoot, busyWaitMs);
|
|
40
|
+
const key = `${path.resolve(getHippoDbPath(hippoRoot))}\0${busyWaitMs ?? ''}`;
|
|
41
|
+
const cached = this.#handles.get(key);
|
|
42
|
+
if (cached?.isOpen && !cached.isTransaction)
|
|
43
|
+
return cached;
|
|
44
|
+
const db = connectHippoDb(hippoRoot, busyWaitMs);
|
|
45
|
+
// An open nested inside a transaction gets its own connection, which its caller closes.
|
|
46
|
+
if (cached?.isOpen)
|
|
47
|
+
return db;
|
|
48
|
+
this.#handles.set(key, db);
|
|
49
|
+
scopedHandles.add(db);
|
|
50
|
+
trackLive(this);
|
|
51
|
+
return db;
|
|
52
|
+
}
|
|
53
|
+
/** A lock held past one full wait belongs to a long transaction, so a hook scope's later writes skip at once. */
|
|
54
|
+
noteBusy() {
|
|
55
|
+
if (!this.#failFastWhenBusy)
|
|
56
|
+
return;
|
|
57
|
+
for (const db of this.#handles.values()) {
|
|
58
|
+
if (db.isOpen !== false)
|
|
59
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
close() {
|
|
63
|
+
this.#closed = true;
|
|
64
|
+
liveScopes.delete(this);
|
|
65
|
+
for (const db of this.#handles.values()) {
|
|
66
|
+
if (db.isOpen !== false)
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
this.#handles.clear();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const scopes = new AsyncLocalStorage();
|
|
73
|
+
/** Runs `fn` in a request scope whose handles close when `fn` settles; inside an open scope `fn` joins it and `opts` is ignored. */
|
|
74
|
+
export async function runWithRequestStores(fn, opts) {
|
|
75
|
+
const outer = scopes.getStore();
|
|
76
|
+
if (outer && !outer.closed)
|
|
77
|
+
return fn();
|
|
78
|
+
const stores = new RequestStores(opts);
|
|
79
|
+
return scopes.run(stores, async () => {
|
|
80
|
+
try {
|
|
81
|
+
return await fn();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
stores.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** The scope this code runs in, if any. A closed scope still answers: its `get` falls through to a plain open. */
|
|
89
|
+
export function currentRequestStores() {
|
|
90
|
+
return scopes.getStore();
|
|
91
|
+
}
|
|
92
|
+
/** Runs `fn` outside every scope, for a connection that must outlive the request that happens to open it. */
|
|
93
|
+
export function outsideRequestStores(fn) {
|
|
94
|
+
return scopes.exit(fn);
|
|
95
|
+
}
|
|
96
|
+
/** Whether a scope owns `db` and will close it itself. */
|
|
97
|
+
export function isScopedHandle(db) {
|
|
98
|
+
return scopedHandles.has(db);
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=request-stores.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
|
|
2
|
+
export declare class SqliteBlockedError extends Error {
|
|
3
|
+
readonly storeKind: string;
|
|
4
|
+
constructor(storeKind: string);
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=sqlite-blocked.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A leaf module, so http-util can map this error to a 501 without importing db/open, whose migrations reach back to http-util.
|
|
2
|
+
/** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
|
|
3
|
+
export class SqliteBlockedError extends Error {
|
|
4
|
+
storeKind;
|
|
5
|
+
constructor(storeKind) {
|
|
6
|
+
super(`hippo.db is not opened while the '${storeKind}' store serves this request; this code path is not ported to the store yet`);
|
|
7
|
+
this.storeKind = storeKind;
|
|
8
|
+
this.name = 'SqliteBlockedError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=sqlite-blocked.js.map
|
package/dist/db.d.ts
CHANGED
|
@@ -3,5 +3,7 @@ export { isSqliteBusy, isStoreBusy, StoreBusyError, STORE_BUSY_MESSAGE, execWith
|
|
|
3
3
|
export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
|
|
4
4
|
export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
|
|
5
5
|
export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
|
|
6
|
-
export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS,
|
|
6
|
+
export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
|
|
7
|
+
export { RequestStores, runWithRequestStores, currentRequestStores, outsideRequestStores } from './db/request-stores.js';
|
|
8
|
+
export { SqliteBlockedError } from './db/sqlite-blocked.js';
|
|
7
9
|
//# sourceMappingURL=db.d.ts.map
|
package/dist/db.js
CHANGED
|
@@ -2,5 +2,7 @@ export { isSqliteBusy, isStoreBusy, StoreBusyError, STORE_BUSY_MESSAGE, execWith
|
|
|
2
2
|
export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
|
|
3
3
|
export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
|
|
4
4
|
export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
|
|
5
|
-
export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS,
|
|
5
|
+
export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
|
|
6
|
+
export { RequestStores, runWithRequestStores, currentRequestStores, outsideRequestStores } from './db/request-stores.js';
|
|
7
|
+
export { SqliteBlockedError } from './db/sqlite-blocked.js';
|
|
6
8
|
//# sourceMappingURL=db.js.map
|
package/dist/embeddings.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
10
|
import { loadAllEntries } from './store/entry-reads.js';
|
|
11
|
-
import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
|
|
11
|
+
import { openHippoDb, closeHippoDb, getMeta, rethrowIfSqliteBlocked, setMeta } from './db.js';
|
|
12
12
|
import { EMBEDDING_MODEL_META_KEY, deleteOrphanVectors, hasStoredVectors, loadVectors, replaceAllVectors, storedVectorIds, upsertVectors, } from './vector-store.js';
|
|
13
13
|
import { initializeParticle, savePhysicsState, loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
|
|
14
14
|
import { loadConfig } from './config.js';
|
|
@@ -81,6 +81,7 @@ function loadStoredEmbeddingModel(hippoRoot) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
catch (err) {
|
|
84
|
+
rethrowIfSqliteBlocked(err);
|
|
84
85
|
log.debug(`stored embedding model unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
86
|
return null;
|
|
86
87
|
}
|
package/dist/goals.d.ts
CHANGED
|
@@ -78,6 +78,13 @@ export interface GetActiveGoalsOpts {
|
|
|
78
78
|
tenantId: string;
|
|
79
79
|
}
|
|
80
80
|
export declare function getActiveGoals(hippoRoot: string, opts: GetActiveGoalsOpts): Goal[];
|
|
81
|
+
/** A session's active goals, oldest first, and the retrieval policy of each goal that names one. */
|
|
82
|
+
export interface ActiveGoals {
|
|
83
|
+
readonly goals: Goal[];
|
|
84
|
+
readonly policies: ReadonlyMap<string, RetrievalPolicy>;
|
|
85
|
+
}
|
|
86
|
+
/** The active goals and their policies on one handle, so a goal and its policy never disagree. */
|
|
87
|
+
export declare function activeGoalsWithPolicies(hippoRoot: string, opts: GetActiveGoalsOpts): ActiveGoals;
|
|
81
88
|
/** Every goal of a (tenant, session), whatever its status, oldest first. */
|
|
82
89
|
export declare function getSessionGoals(hippoRoot: string, opts: GetActiveGoalsOpts): Goal[];
|
|
83
90
|
export declare function getActiveGoalsWithDb(db: DatabaseSyncLike, opts: GetActiveGoalsOpts): Goal[];
|
|
@@ -90,7 +97,7 @@ export interface GoalRecallLogRow {
|
|
|
90
97
|
recalledAt: string;
|
|
91
98
|
score: number;
|
|
92
99
|
}
|
|
93
|
-
/** Options shared by {@link computeGoalStackBoost} and {@link
|
|
100
|
+
/** Options shared by {@link computeGoalStackBoost} and {@link boostByGoals}. */
|
|
94
101
|
export interface GoalStackBoostOpts {
|
|
95
102
|
sessionId: string;
|
|
96
103
|
tenantId: string;
|
|
@@ -107,6 +114,10 @@ export interface GoalStackBoost<R> {
|
|
|
107
114
|
results: R[];
|
|
108
115
|
log: GoalRecallLogRow[];
|
|
109
116
|
}
|
|
117
|
+
export declare function loadGoalPolicies(db: DatabaseSyncLike, active: readonly Goal[]): Map<string, RetrievalPolicy>;
|
|
118
|
+
/** The rows whose memory lives in this store: goal_recall_log.memory_id references memories, so a global row would fail
|
|
119
|
+
* the insert. A global result keeps its boost; it only earns no log row, so no outcome propagation. */
|
|
120
|
+
export declare function localGoalRecallRows(db: DatabaseSyncLike, rows: readonly GoalRecallLogRow[]): GoalRecallLogRow[];
|
|
110
121
|
/**
|
|
111
122
|
* dlPFC goal-stack boost. Applies the multi-goal boost to entry-backed scored
|
|
112
123
|
* rows when (tenant, session) has active goals and returns the log rows to
|
|
@@ -130,21 +141,17 @@ export declare function computeGoalStackBoost<R extends {
|
|
|
130
141
|
entry: MemoryEntry;
|
|
131
142
|
score: number;
|
|
132
143
|
}>(db: DatabaseSyncLike, results: R[], opts: GoalStackBoostOpts): GoalStackBoost<R>;
|
|
144
|
+
/** {@link computeGoalStackBoost} over goals already read, touching no store. Its log keeps rows whose memory is global,
|
|
145
|
+
* since only the store can tell them apart; {@link localGoalRecallRows} drops those before the write. */
|
|
146
|
+
export declare function boostByGoals<R extends {
|
|
147
|
+
entry: MemoryEntry;
|
|
148
|
+
score: number;
|
|
149
|
+
}>(results: R[], active: ActiveGoals, opts: GoalStackBoostOpts): GoalStackBoost<R>;
|
|
133
150
|
/**
|
|
134
151
|
* Writes goal-boost log rows. INSERT OR IGNORE because UNIQUE(memory_id, goal_id)
|
|
135
152
|
* makes a re-recall during the same goal life a no-op for outcome attribution.
|
|
136
153
|
*/
|
|
137
154
|
export declare function writeGoalRecallLog(db: DatabaseSyncLike, rows: readonly GoalRecallLogRow[]): void;
|
|
138
|
-
/**
|
|
139
|
-
* {@link computeGoalStackBoost} plus {@link writeGoalRecallLog} in one call, for
|
|
140
|
-
* pipelines that boost and log on the same handle.
|
|
141
|
-
*
|
|
142
|
-
* @internal Recall ranking helper. Subject to change.
|
|
143
|
-
*/
|
|
144
|
-
export declare function applyGoalStackBoost<R extends {
|
|
145
|
-
entry: MemoryEntry;
|
|
146
|
-
score: number;
|
|
147
|
-
}>(db: DatabaseSyncLike, results: R[], opts: GoalStackBoostOpts): R[];
|
|
148
155
|
export interface CompleteGoalOpts {
|
|
149
156
|
outcomeScore?: number;
|
|
150
157
|
/**
|
package/dist/goals.js
CHANGED
|
@@ -129,6 +129,17 @@ export function getActiveGoals(hippoRoot, opts) {
|
|
|
129
129
|
closeHippoDb(db);
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
/** The active goals and their policies on one handle, so a goal and its policy never disagree. */
|
|
133
|
+
export function activeGoalsWithPolicies(hippoRoot, opts) {
|
|
134
|
+
const db = openHippoDb(hippoRoot);
|
|
135
|
+
try {
|
|
136
|
+
const goals = getActiveGoalsWithDb(db, opts);
|
|
137
|
+
return { goals, policies: loadGoalPolicies(db, goals) };
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
closeHippoDb(db);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
132
143
|
/** Every goal of a (tenant, session), whatever its status, oldest first. */
|
|
133
144
|
export function getSessionGoals(hippoRoot, opts) {
|
|
134
145
|
const db = openHippoDb(hippoRoot);
|
|
@@ -163,7 +174,7 @@ export function getActiveGoalsWithDb(db, opts) {
|
|
|
163
174
|
// can compose onto the base goal-tag boost. Composed result is hard-capped
|
|
164
175
|
// at MAX_FINAL_MULTIPLIER (3.0x) BEFORE applying to score -- even an
|
|
165
176
|
// `errorPriority: 9.0` policy cannot exceed 3.0x.
|
|
166
|
-
function loadGoalPolicies(db, active) {
|
|
177
|
+
export function loadGoalPolicies(db, active) {
|
|
167
178
|
const policiesByGoalId = new Map();
|
|
168
179
|
for (const g of active) {
|
|
169
180
|
if (!g.retrievalPolicyId)
|
|
@@ -220,28 +231,11 @@ function goalBoostMultiplier(entry, tags, matches, goalsByTag, policiesByGoalId)
|
|
|
220
231
|
return Math.min(multiplier, MAX_FINAL_MULTIPLIER);
|
|
221
232
|
}
|
|
222
233
|
/** Log rows for the top `limit` boosted rows, one per matched goal. */
|
|
223
|
-
function buildGoalRecallLog(
|
|
234
|
+
function buildGoalRecallLog(boosted, matchesByEntryId, goalsByTag, opts) {
|
|
224
235
|
const { sessionId, tenantId, limit } = opts;
|
|
225
|
-
// Filter to local memories only -- global memory IDs aren't in this DB's
|
|
226
|
-
// memories table, so the FK on goal_recall_log.memory_id would fail.
|
|
227
|
-
// dlPFC depth's outcome propagation is session-scoped to local; boost on
|
|
228
|
-
// ranking still applies to global results, just no log row -> no
|
|
229
|
-
// propagation.
|
|
230
|
-
const topKIds = boosted.slice(0, limit).map((r) => r.entry.id);
|
|
231
|
-
const localIds = new Set();
|
|
232
|
-
if (topKIds.length > 0) {
|
|
233
|
-
const placeholders = topKIds.map(() => '?').join(',');
|
|
234
|
-
// SAFETY: rows come from the SELECT above, which projects exactly one
|
|
235
|
-
// column, `id`, from memories.
|
|
236
|
-
const localRows = db.prepare(`SELECT id FROM memories WHERE id IN (${placeholders})`).all(...topKIds);
|
|
237
|
-
for (const row of localRows)
|
|
238
|
-
localIds.add(row.id);
|
|
239
|
-
}
|
|
240
236
|
const recalledAt = new Date().toISOString();
|
|
241
237
|
const log = [];
|
|
242
238
|
for (const r of boosted.slice(0, limit)) {
|
|
243
|
-
if (!localIds.has(r.entry.id))
|
|
244
|
-
continue; // global -> skip log insert
|
|
245
239
|
const matches = matchesByEntryId.get(r.entry.id);
|
|
246
240
|
if (!matches || matches.length === 0)
|
|
247
241
|
continue;
|
|
@@ -254,6 +248,17 @@ function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
|
|
|
254
248
|
}
|
|
255
249
|
return log;
|
|
256
250
|
}
|
|
251
|
+
/** The rows whose memory lives in this store: goal_recall_log.memory_id references memories, so a global row would fail
|
|
252
|
+
* the insert. A global result keeps its boost; it only earns no log row, so no outcome propagation. */
|
|
253
|
+
export function localGoalRecallRows(db, rows) {
|
|
254
|
+
if (rows.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
const ids = [...new Set(rows.map((r) => r.memoryId))];
|
|
257
|
+
// SAFETY: the SELECT projects exactly one column, `id`, from memories.
|
|
258
|
+
const local = db.prepare(`SELECT id FROM memories WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
|
|
259
|
+
const localIds = new Set(local.map((r) => r.id));
|
|
260
|
+
return rows.filter((r) => localIds.has(r.memoryId));
|
|
261
|
+
}
|
|
257
262
|
/**
|
|
258
263
|
* dlPFC goal-stack boost. Applies the multi-goal boost to entry-backed scored
|
|
259
264
|
* rows when (tenant, session) has active goals and returns the log rows to
|
|
@@ -274,12 +279,20 @@ function buildGoalRecallLog(db, boosted, matchesByEntryId, goalsByTag, opts) {
|
|
|
274
279
|
* @internal Recall ranking helper. Subject to change.
|
|
275
280
|
*/
|
|
276
281
|
export function computeGoalStackBoost(db, results, opts) {
|
|
277
|
-
const { sessionId, tenantId
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
const goals = getActiveGoalsWithDb(db, { sessionId: opts.sessionId, tenantId: opts.tenantId });
|
|
283
|
+
if (goals.length === 0)
|
|
284
|
+
return { results, log: [] };
|
|
285
|
+
const boost = boostByGoals(results, { goals, policies: loadGoalPolicies(db, goals) }, opts);
|
|
286
|
+
return { results: boost.results, log: localGoalRecallRows(db, boost.log) };
|
|
287
|
+
}
|
|
288
|
+
/** {@link computeGoalStackBoost} over goals already read, touching no store. Its log keeps rows whose memory is global,
|
|
289
|
+
* since only the store can tell them apart; {@link localGoalRecallRows} drops those before the write. */
|
|
290
|
+
export function boostByGoals(results, active, opts) {
|
|
291
|
+
const { trace } = opts;
|
|
292
|
+
if (active.goals.length === 0)
|
|
280
293
|
return { results, log: [] };
|
|
281
|
-
const goalsByTag = new Map(active.map((g) => [g.goalName, g]));
|
|
282
|
-
const policiesByGoalId =
|
|
294
|
+
const goalsByTag = new Map(active.goals.map((g) => [g.goalName, g]));
|
|
295
|
+
const policiesByGoalId = active.policies;
|
|
283
296
|
// Goal-tag matches per boosted row, keyed by entry id. Kept as a side table
|
|
284
297
|
// (rather than a spread-on `_goalMatches` marker property) so `boosted`
|
|
285
298
|
// stays exactly R[] end to end, with no cast-tag-then-strip round trip.
|
|
@@ -313,7 +326,7 @@ export function computeGoalStackBoost(db, results, opts) {
|
|
|
313
326
|
// inherits its determinism via sort stability, and ties preserve the
|
|
314
327
|
// prior (meaningful) rank instead of reordering by content.
|
|
315
328
|
.sort((a, b) => b.score - a.score);
|
|
316
|
-
return { results: boosted, log: buildGoalRecallLog(
|
|
329
|
+
return { results: boosted, log: buildGoalRecallLog(boosted, matchesByEntryId, goalsByTag, opts) };
|
|
317
330
|
}
|
|
318
331
|
/**
|
|
319
332
|
* Writes goal-boost log rows. INSERT OR IGNORE because UNIQUE(memory_id, goal_id)
|
|
@@ -331,17 +344,6 @@ export function writeGoalRecallLog(db, rows) {
|
|
|
331
344
|
insertLog.run(row.goalId, row.memoryId, row.tenantId, row.sessionId, row.recalledAt, row.score);
|
|
332
345
|
}
|
|
333
346
|
}
|
|
334
|
-
/**
|
|
335
|
-
* {@link computeGoalStackBoost} plus {@link writeGoalRecallLog} in one call, for
|
|
336
|
-
* pipelines that boost and log on the same handle.
|
|
337
|
-
*
|
|
338
|
-
* @internal Recall ranking helper. Subject to change.
|
|
339
|
-
*/
|
|
340
|
-
export function applyGoalStackBoost(db, results, opts) {
|
|
341
|
-
const boost = computeGoalStackBoost(db, results, opts);
|
|
342
|
-
writeGoalRecallLog(db, boost.log);
|
|
343
|
-
return boost.results;
|
|
344
|
-
}
|
|
345
347
|
const POSITIVE_OUTCOME_THRESHOLD = 0.7;
|
|
346
348
|
const NEGATIVE_OUTCOME_THRESHOLD = 0.3;
|
|
347
349
|
const STRENGTH_BOOST = 1.10;
|
package/dist/http-util.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface ApiErrorReply {
|
|
|
23
23
|
message: string;
|
|
24
24
|
}
|
|
25
25
|
export declare const INTERNAL_ERROR_MESSAGE = "internal server error";
|
|
26
|
+
/** The reply when a request under another store reaches code that still opens hippo.db. */
|
|
27
|
+
export declare const STORE_NOT_PORTED_MESSAGE = "store_not_ported";
|
|
26
28
|
/** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
|
|
27
29
|
export declare function mapApiError<E>(err: E): ApiErrorReply;
|
|
28
30
|
/** Serialises before the head goes out, so a body that is not JSON still reaches the caller's error reply. */
|
package/dist/http-util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError } from './api-errors.js';
|
|
2
|
+
import { SqliteBlockedError } from './db/sqlite-blocked.js';
|
|
2
3
|
// Leaf module shared by server.ts and the connector webhook receivers; it must not import either.
|
|
3
4
|
export function isJsonObjectRecord(value) {
|
|
4
5
|
return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -35,6 +36,8 @@ export class BodyTooLargeError extends Error {
|
|
|
35
36
|
export class BodyTimeoutError extends Error {
|
|
36
37
|
}
|
|
37
38
|
export const INTERNAL_ERROR_MESSAGE = 'internal server error';
|
|
39
|
+
/** The reply when a request under another store reaches code that still opens hippo.db. */
|
|
40
|
+
export const STORE_NOT_PORTED_MESSAGE = 'store_not_ported';
|
|
38
41
|
/** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
|
|
39
42
|
export function mapApiError(err) {
|
|
40
43
|
// An add-on can build an HttpError from any number, and writeHead throws on one outside 100-999.
|
|
@@ -47,6 +50,8 @@ export function mapApiError(err) {
|
|
|
47
50
|
return { status: 413, message: err.message };
|
|
48
51
|
if (err instanceof BodyTimeoutError)
|
|
49
52
|
return { status: 408, message: err.message };
|
|
53
|
+
if (err instanceof SqliteBlockedError)
|
|
54
|
+
return { status: 501, message: STORE_NOT_PORTED_MESSAGE };
|
|
50
55
|
return { status: 500, message: INTERNAL_ERROR_MESSAGE };
|
|
51
56
|
}
|
|
52
57
|
/** Serialises before the head goes out, so a body that is not JSON still reaches the caller's error reply. */
|
package/dist/mcp/memory-tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { log } from '../log.js';
|
|
|
3
3
|
import { readEntry } from '../store/entry-reads.js';
|
|
4
4
|
import { countCreatedSinceLastSleep } from '../store/index-and-stats.js';
|
|
5
5
|
import { consolidate } from '../consolidate/sleep.js';
|
|
6
|
+
import { outsideRequestStores, runWithRequestStores, scopedBusyWait } from '../db.js';
|
|
6
7
|
import { resolveTenantId } from '../tenant.js';
|
|
7
8
|
import { remember as apiRemember, outcome as apiOutcome, learn as apiLearn, MCP_LEARN } from '../api.js';
|
|
8
9
|
import { mcpActor } from './protocol.js';
|
|
@@ -43,7 +44,9 @@ export function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
|
|
|
43
44
|
countCreatedSinceLastSleep(hippoRoot, tenantId) >= config.autoSleep.threshold) {
|
|
44
45
|
autoSleepInFlight.add(hippoRoot);
|
|
45
46
|
// Fire-and-forget (never block the response); an unhandled rejection would kill the server, so log it.
|
|
46
|
-
|
|
47
|
+
// Its own scope: the call's closes a handle the sleep still holds across its pauses, but the caller's lock wait carries over.
|
|
48
|
+
const busyWaitMs = scopedBusyWait();
|
|
49
|
+
outsideRequestStores(() => runWithRequestStores(() => consolidate(hippoRoot), { busyWaitMs }))
|
|
47
50
|
.catch((err) => {
|
|
48
51
|
log.error(`auto-sleep consolidate failed (tenant ${tenantId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
49
52
|
})
|
package/dist/mcp/recall-tools.js
CHANGED
|
@@ -6,58 +6,18 @@ import { retrieve as apiRetrieve, drillDown as apiDrillDown, assemble as apiAsse
|
|
|
6
6
|
import { autoDetectContext } from '../context-auto.js';
|
|
7
7
|
import { resolveProjectIdentity } from '../project-identity.js';
|
|
8
8
|
import { isSharedStore } from '../config.js';
|
|
9
|
-
import {
|
|
10
|
-
import { detectAnchoring, hashQueryText, biasHintEnabled, buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, } from '../recall-history.js';
|
|
9
|
+
import { detectAnchoring, hashQueryText, biasHintEnabled, snapshotRing } from '../recall-history.js';
|
|
11
10
|
import { detectAvailabilityBias } from '../availability.js';
|
|
12
|
-
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
13
11
|
import { estimateTokens } from '../token-ledger.js';
|
|
14
12
|
import { assembleCost, assembleText, drillCost, drillText } from '../context-render.js';
|
|
15
|
-
import { mcpActor
|
|
16
|
-
import {
|
|
13
|
+
import { mcpActor } from './protocol.js';
|
|
14
|
+
import { lastRecalledIds, resolveClientKey } from './session-state.js';
|
|
17
15
|
import { formatContinuityBlock, formatMemories, memoryCost, memoriesReserve, snapshotPiece, handoffPiece, trailPiece, contextCost, tailSection, planningSection, } from './format.js';
|
|
18
16
|
import { isJsonString } from '../json.js';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const includeContinuity = Boolean(args.include_continuity);
|
|
24
|
-
const explicitScope = isJsonString(args.scope) && args.scope.length > 0
|
|
25
|
-
? args.scope
|
|
26
|
-
: undefined;
|
|
27
|
-
const freshTailCountArg = Number(args.fresh_tail_count);
|
|
28
|
-
const freshTailCount = Number.isFinite(freshTailCountArg) && freshTailCountArg > 0
|
|
29
|
-
? freshTailCountArg
|
|
30
|
-
: undefined;
|
|
31
|
-
const freshTailSessionId = isJsonString(args.fresh_tail_session_id) && args.fresh_tail_session_id.length > 0
|
|
32
|
-
? args.fresh_tail_session_id
|
|
33
|
-
: undefined;
|
|
34
|
-
const summarizeOverflow = isJsonBoolean(args.summarize_overflow)
|
|
35
|
-
? args.summarize_overflow
|
|
36
|
-
: undefined;
|
|
37
|
-
// Number-coerce, never typeof-check: "abc" must reach api.retrieve and fail as invalid_scorer_window, the same code HTTP returns.
|
|
38
|
-
const scorerWindow = args.scorer_window === undefined
|
|
39
|
-
? undefined
|
|
40
|
-
: Number(args.scorer_window);
|
|
41
|
-
// session_id drives the goal-stack boost inside api.retrieve; same trim and MAX_ID_LEN cap as fresh_tail_session_id.
|
|
42
|
-
const sessionIdRaw = isJsonString(args.session_id) ? args.session_id.trim() : '';
|
|
43
|
-
const sessionId = sessionIdRaw.length > 0 && sessionIdRaw.length <= MAX_ID_LEN
|
|
44
|
-
? sessionIdRaw
|
|
45
|
-
: undefined;
|
|
46
|
-
const recallExtra = {};
|
|
47
|
-
if (freshTailCount !== undefined)
|
|
48
|
-
recallExtra.freshTailCount = freshTailCount;
|
|
49
|
-
if (freshTailSessionId !== undefined)
|
|
50
|
-
recallExtra.freshTailSessionId = freshTailSessionId;
|
|
51
|
-
if (summarizeOverflow !== undefined)
|
|
52
|
-
recallExtra.summarizeOverflow = summarizeOverflow;
|
|
53
|
-
if (scorerWindow !== undefined)
|
|
54
|
-
recallExtra.scorerWindow = scorerWindow;
|
|
55
|
-
if (sessionId !== undefined)
|
|
56
|
-
recallExtra.sessionId = sessionId;
|
|
57
|
-
return { query, budget, includeContinuity, explicitScope, sessionId, recallExtra };
|
|
58
|
-
}
|
|
59
|
-
/** Builds the showRanked callback that renders the list MCP shows and parks the render in `out`. */
|
|
60
|
-
function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out) {
|
|
17
|
+
import { parseContextRequest, parseRecallRequest, toolParams } from '../api/recall-request.js';
|
|
18
|
+
import { noteRecall, sessionRing, shownRecallRows } from '../api/recall-record.js';
|
|
19
|
+
/** Builds the showRanked callback that renders the list MCP shows, parks the render in `out` and hands back its hint rows. */
|
|
20
|
+
function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out, hintRows) {
|
|
61
21
|
return ({ ranked, pool, droppedByScope }, apiResult) => {
|
|
62
22
|
// Sections are paid in print order, ahead of the memories and after the heading; one that does not fit is dropped whole.
|
|
63
23
|
let left = budget - memoriesReserve(budget);
|
|
@@ -129,105 +89,46 @@ function recallPresenter(budget, includeContinuity, anchorRing, queryHash, out)
|
|
|
129
89
|
rendered = render(results);
|
|
130
90
|
}
|
|
131
91
|
out.rendered = rendered;
|
|
132
|
-
return rendered.list.map((r) => r.entry.id);
|
|
92
|
+
return { ids: rendered.list.map((r) => r.entry.id), audit: hintRows(rendered) };
|
|
133
93
|
};
|
|
134
94
|
}
|
|
135
|
-
function appendRecallAudit(hippoRoot, event) {
|
|
136
|
-
const dbForAudit = openHippoDb(hippoRoot);
|
|
137
|
-
try {
|
|
138
|
-
appendAuditEvent(dbForAudit, event);
|
|
139
|
-
}
|
|
140
|
-
finally {
|
|
141
|
-
closeHippoDb(dbForAudit);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
function auditRecallHints(call, query, anchorRing, queryHash, rendered) {
|
|
145
|
-
const { hippoRoot, tenantId, ctx } = call;
|
|
146
|
-
const { anchoring: mcpAnchoringHint, availability: mcpAvailabilityHint, list: shown } = rendered;
|
|
147
|
-
if (biasHintEnabled('anchoring')) {
|
|
148
|
-
if (anchorRing) {
|
|
149
|
-
// Appended after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
|
|
150
|
-
appendRecall(anchorRing, queryHash, shown[0]?.entry.id ?? null, mcpAnchoringHint?.memoryId);
|
|
151
|
-
if (mcpAnchoringHint?.reason === 'memory_dominance') {
|
|
152
|
-
appendRecallAudit(hippoRoot, {
|
|
153
|
-
tenantId,
|
|
154
|
-
actor: ctx?.actor ?? 'mcp',
|
|
155
|
-
op: 'recall_anchor_detected_memory_dominance',
|
|
156
|
-
targetId: mcpAnchoringHint.memoryId,
|
|
157
|
-
metadata: {
|
|
158
|
-
memory_id: mcpAnchoringHint.memoryId,
|
|
159
|
-
query_count: mcpAnchoringHint.queryCount ?? null,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
else if (mcpAnchoringHint?.reason === 'query_repeat') {
|
|
164
|
-
appendRecallAudit(hippoRoot, {
|
|
165
|
-
tenantId,
|
|
166
|
-
actor: ctx?.actor ?? 'mcp',
|
|
167
|
-
op: 'recall_anchor_detected_query_repeat',
|
|
168
|
-
targetId: mcpAnchoringHint.memoryId,
|
|
169
|
-
metadata: { memory_id: mcpAnchoringHint.memoryId },
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
// No sessionId, so no ring. Hash the prompt with SHA-256/16 as the recall audit does (api.ts:854):
|
|
175
|
-
// hashQueryText is FNV-1a 32-bit, trivial to brute-force on low-entropy queries.
|
|
176
|
-
appendRecallAudit(hippoRoot, {
|
|
177
|
-
tenantId,
|
|
178
|
-
actor: ctx?.actor ?? 'mcp',
|
|
179
|
-
op: 'recall_anchor_skipped_no_session',
|
|
180
|
-
targetId: undefined,
|
|
181
|
-
metadata: auditQueryFields(query),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (mcpAvailabilityHint) {
|
|
186
|
-
appendRecallAudit(hippoRoot, {
|
|
187
|
-
tenantId,
|
|
188
|
-
actor: ctx?.actor ?? 'mcp',
|
|
189
|
-
op: 'recall_availability_detected',
|
|
190
|
-
metadata: {
|
|
191
|
-
recent_fraction: mcpAvailabilityHint.recentFraction,
|
|
192
|
-
older_passed_over: mcpAvailabilityHint.olderCandidatesPassedOver,
|
|
193
|
-
returned_count: mcpAvailabilityHint.returnedCount,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
95
|
export async function runRecallTool(call) {
|
|
199
|
-
const { ctx, hippoRoot, config, tenantId } = call;
|
|
200
|
-
|
|
96
|
+
const { ctx, hippoRoot, config, tenantId, args } = call;
|
|
97
|
+
// MCP keeps its own band size and search mode, so limit, mode and explain are checked but not passed on.
|
|
98
|
+
const { opts: recallOpts } = parseRecallRequest(toolParams(args));
|
|
99
|
+
const { query, includeContinuity, sessionId } = recallOpts;
|
|
100
|
+
const budget = Number(args.budget) || config.defaultBudget;
|
|
201
101
|
const apiCtx = {
|
|
202
102
|
hippoRoot,
|
|
203
103
|
tenantId,
|
|
204
104
|
actor: mcpActor(ctx),
|
|
205
105
|
store: ctx?.store,
|
|
206
106
|
};
|
|
207
|
-
const anchorRing =
|
|
208
|
-
|
|
209
|
-
: null;
|
|
107
|
+
const anchorRing = sessionRing('mcp', tenantId, sessionId);
|
|
108
|
+
const who = { tenantId, actor: ctx?.actor ?? 'mcp' };
|
|
210
109
|
const queryHash = hashQueryText(query);
|
|
211
110
|
const out = {};
|
|
212
111
|
// RecallContractError throws reach the MCP caller raw, as mcp-recall-fresh-tail-policy.test.ts pins.
|
|
213
112
|
await apiRetrieve(apiCtx, {
|
|
214
|
-
|
|
113
|
+
...recallOpts,
|
|
215
114
|
limit: 50,
|
|
216
|
-
scope: explicitScope,
|
|
217
|
-
includeContinuity,
|
|
218
115
|
mode: config.physics?.enabled !== false ? 'physics' : 'hybrid',
|
|
219
116
|
// The hint is computed below over the list MCP shows; the window band's copy would emit its audit row twice.
|
|
220
117
|
suppressAvailabilityHint: true,
|
|
221
118
|
keepHeldCopies: true,
|
|
222
119
|
project: ctx?.project,
|
|
223
|
-
|
|
224
|
-
|
|
120
|
+
showRanked: recallPresenter(budget, includeContinuity, anchorRing, queryHash, out, ({ list, anchoring, availability }) => shownRecallRows(who, {
|
|
121
|
+
query, ring: anchorRing, topId: list[0]?.entry.id ?? null, anchoring, availability,
|
|
122
|
+
})),
|
|
225
123
|
});
|
|
226
|
-
|
|
124
|
+
const { rendered } = out;
|
|
125
|
+
if (!rendered)
|
|
227
126
|
throw new Error('hippo_recall: api.retrieve returned without calling showRanked');
|
|
228
|
-
lastRecalledIds.set(resolveClientKey(ctx),
|
|
229
|
-
|
|
230
|
-
|
|
127
|
+
lastRecalledIds.set(resolveClientKey(ctx), rendered.list.map((r) => r.entry.id));
|
|
128
|
+
// Fed once the recall's rows are written, after the final detect: anchoredOn feeds the cooldown for the next recall on this session.
|
|
129
|
+
if (anchorRing)
|
|
130
|
+
noteRecall(anchorRing, query, rendered.list[0]?.entry.id ?? null, rendered.anchoring?.memoryId);
|
|
131
|
+
return rendered.text;
|
|
231
132
|
}
|
|
232
133
|
export function runAssembleTool({ args, ctx, hippoRoot, tenantId }) {
|
|
233
134
|
const sessionId = String(args.session_id || '');
|
|
@@ -301,18 +202,12 @@ export async function runContextTool({ args, ctx, hippoRoot, config, tenantId })
|
|
|
301
202
|
// Stdio names no project, and the store's folder or the launch cwd is no caller's; HTTP sends X-Hippo-Project.
|
|
302
203
|
if (isSharedStore(hippoRoot) && ctx?.project === undefined)
|
|
303
204
|
return 'hippo_context needs a project on a shared store; use hippo_recall';
|
|
304
|
-
const budget = args
|
|
305
|
-
|
|
306
|
-
: Number(args.budget);
|
|
307
|
-
if (!Number.isFinite(budget) || budget < 0)
|
|
308
|
-
return 'budget must be a non-negative number.';
|
|
205
|
+
const { budget: budgetArg, scope: exactScope } = parseContextRequest(toolParams(args));
|
|
206
|
+
const budget = budgetArg ?? config.defaultContextBudget;
|
|
309
207
|
if (budget === 0)
|
|
310
208
|
return '';
|
|
311
209
|
if (budget < memoriesReserve(budget))
|
|
312
210
|
return ''; // not even the heading fits, so nothing prints, as at budget 0
|
|
313
|
-
const exactScope = isJsonString(args.scope) && args.scope.length > 0
|
|
314
|
-
? args.scope
|
|
315
|
-
: undefined;
|
|
316
211
|
const result = await apiGetContext({ hippoRoot, tenantId, actor: mcpActor(ctx), store: ctx?.store }, {
|
|
317
212
|
// The server's git state is no caller's, so a shared-store caller gets its project's rows without a query.
|
|
318
213
|
q: ctx?.project ? undefined : autoDetectContext(),
|