hippo-memory 1.64.0 → 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/README.md +1 -1
- 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/maintenance.d.ts +2 -1
- package/dist/cli/maintenance.js +17 -11
- 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/embedding-provider.js +4 -3
- 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/local-embedding.d.ts +2 -0
- package/dist/local-embedding.js +20 -4
- 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
|
@@ -28,7 +28,7 @@ import { computePredictionBaserate } from './store.js';
|
|
|
28
28
|
* test asserting that scope-set predictions surface via no-scope
|
|
29
29
|
* recalls (so future "fix" attempts that scope-filter trip CI).
|
|
30
30
|
*/
|
|
31
|
-
function resolveClassFromTokens(hippoRoot, tenantId, queryTokens) {
|
|
31
|
+
export function resolveClassFromTokens(hippoRoot, tenantId, queryTokens) {
|
|
32
32
|
if (queryTokens.length === 0)
|
|
33
33
|
return { classTag: null, tiebreak: false };
|
|
34
34
|
const db = openHippoDb(hippoRoot);
|
|
@@ -100,56 +100,69 @@ function resolveClassFromTokens(hippoRoot, tenantId, queryTokens) {
|
|
|
100
100
|
* Latency budget: well under 50ms; a miss pays only the regex.
|
|
101
101
|
*/
|
|
102
102
|
export function computePlanningFallacyOutput(hippoRoot, tenantId, queryText, opts = {}) {
|
|
103
|
-
|
|
104
|
-
// (rather than module-load cache) is deliberate: tests env-toggle this
|
|
105
|
-
// via process.env mutation without module reload.
|
|
106
|
-
const mode = opts.mode ?? (envAutodebiasOff() ? 'off' : 'regex');
|
|
107
|
-
if (mode === 'off')
|
|
108
|
-
return {};
|
|
109
|
-
if (!queryText)
|
|
110
|
-
return {};
|
|
111
|
-
const match = detectForwardClaim(queryText);
|
|
103
|
+
const match = detectPlanningClaim(queryText, opts);
|
|
112
104
|
if (!match)
|
|
113
105
|
return {};
|
|
114
106
|
const actor = opts.actor ?? 'recall';
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
107
|
+
const { output, audit } = decidePlanningFallacy(match, planningFallacyEvidenceAt(hippoRoot, tenantId, match.classQueryTokens), tenantId, actor);
|
|
108
|
+
if (audit)
|
|
109
|
+
appendAuditEventOnce(hippoRoot, audit);
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
/** The forward claim in a recall query; null when HIPPO_AUTODEBIAS=off, the query is empty or no claim matches. */
|
|
113
|
+
export function detectPlanningClaim(queryText, opts = {}) {
|
|
114
|
+
// Env read FIRST so AUTODEBIAS=off pays zero regex cost; read per call so tests can toggle it without a module reload.
|
|
115
|
+
const mode = opts.mode ?? (envAutodebiasOff() ? 'off' : 'regex');
|
|
116
|
+
if (mode === 'off')
|
|
117
|
+
return null;
|
|
118
|
+
if (!queryText)
|
|
119
|
+
return null;
|
|
120
|
+
return detectForwardClaim(queryText);
|
|
121
|
+
}
|
|
122
|
+
/** Resolves a claim's tokens to one class and reads its baserate, writing no audit row. */
|
|
123
|
+
export function planningFallacyEvidenceAt(hippoRoot, tenantId, classQueryTokens) {
|
|
124
|
+
const resolution = resolveClassFromTokens(hippoRoot, tenantId, classQueryTokens);
|
|
125
|
+
// emitAudit=false: the recall_autodebias_hint row carries n_closed and mean_ratio, so predict_baserate stays off.
|
|
126
|
+
const baserate = resolution.classTag ? computePredictionBaserate(hippoRoot, tenantId, resolution.classTag, 'recall', false) : null;
|
|
127
|
+
return { ...resolution, baserate };
|
|
128
|
+
}
|
|
129
|
+
/** The hint, the watching variant or nothing for a claim and its class evidence; reads and writes nothing itself. */
|
|
130
|
+
export function decidePlanningFallacy(match, evidence, tenantId, actor) {
|
|
131
|
+
if (evidence.tiebreak) {
|
|
117
132
|
// Telemetry: forward-claim detected, ≥2 classes tied at best overlap. The watching variant lets the
|
|
118
133
|
// caller render "watching but no baserate (tiebreak)"; the audit channel stays the source of truth.
|
|
119
|
-
return watchingWithAudit(
|
|
134
|
+
return watchingWithAudit(tenantId, actor, match, 'tiebreak', TIEBREAK_SUGGESTION);
|
|
120
135
|
}
|
|
121
|
-
if (!
|
|
122
|
-
// Telemetry:
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
return watchingWithAudit(hippoRoot, tenantId, actor, match, 'no_class_match', NO_CLASS_MATCH_SUGGESTION);
|
|
136
|
+
if (!evidence.classTag) {
|
|
137
|
+
// Telemetry: no class scored ≥ 1. High volume here means regex+token-overlap misses real forward-claims
|
|
138
|
+
// with no class signal, the case for an embedding fallback.
|
|
139
|
+
return watchingWithAudit(tenantId, actor, match, 'no_class_match', NO_CLASS_MATCH_SUGGESTION);
|
|
126
140
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/*emitAudit=*/ false);
|
|
131
|
-
if (baserate.nClosed === 0)
|
|
132
|
-
return {}; // Silent — wait for closed data.
|
|
133
|
-
appendAuditEventOnce(hippoRoot, {
|
|
134
|
-
tenantId,
|
|
135
|
-
actor,
|
|
136
|
-
op: 'recall_autodebias_hint',
|
|
137
|
-
targetId: resolution.classTag,
|
|
138
|
-
metadata: {
|
|
139
|
-
class_tag: resolution.classTag,
|
|
140
|
-
detected_phrase: match.phrase,
|
|
141
|
-
n_closed: baserate.nClosed,
|
|
142
|
-
mean_ratio: baserate.meanRatio,
|
|
143
|
-
},
|
|
144
|
-
});
|
|
141
|
+
const baserate = evidence.baserate;
|
|
142
|
+
if (!baserate || baserate.nClosed === 0)
|
|
143
|
+
return { output: {} }; // Silent — wait for closed data.
|
|
145
144
|
return {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
output: {
|
|
146
|
+
hint: {
|
|
147
|
+
classTag: evidence.classTag,
|
|
148
|
+
baserateSummary: baserate.summary,
|
|
149
|
+
source: 'j3.2-auto',
|
|
150
|
+
detectedPhrase: match.phrase,
|
|
151
|
+
nClosed: baserate.nClosed,
|
|
152
|
+
meanRatio: baserate.meanRatio,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
audit: {
|
|
156
|
+
tenantId,
|
|
157
|
+
actor,
|
|
158
|
+
op: 'recall_autodebias_hint',
|
|
159
|
+
targetId: evidence.classTag,
|
|
160
|
+
metadata: {
|
|
161
|
+
class_tag: evidence.classTag,
|
|
162
|
+
detected_phrase: match.phrase,
|
|
163
|
+
n_closed: baserate.nClosed,
|
|
164
|
+
mean_ratio: baserate.meanRatio,
|
|
165
|
+
},
|
|
153
166
|
},
|
|
154
167
|
};
|
|
155
168
|
}
|
|
@@ -165,19 +178,21 @@ function appendAuditEventOnce(hippoRoot, event) {
|
|
|
165
178
|
closeHippoDb(db);
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
|
-
function watchingWithAudit(
|
|
169
|
-
appendAuditEventOnce(hippoRoot, {
|
|
170
|
-
tenantId,
|
|
171
|
-
actor,
|
|
172
|
-
op: reason === 'tiebreak' ? 'recall_autodebias_hint_tiebreak' : 'recall_autodebias_hint_no_class_match',
|
|
173
|
-
targetId: match.phrase.slice(0, 100),
|
|
174
|
-
metadata: { detected_phrase: match.phrase, token_count: match.classQueryTokens.length },
|
|
175
|
-
});
|
|
181
|
+
function watchingWithAudit(tenantId, actor, match, reason, suggestion) {
|
|
176
182
|
return {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
output: {
|
|
184
|
+
watching: {
|
|
185
|
+
detectedPhrase: match.phrase,
|
|
186
|
+
reason,
|
|
187
|
+
suggestion,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
audit: {
|
|
191
|
+
tenantId,
|
|
192
|
+
actor,
|
|
193
|
+
op: reason === 'tiebreak' ? 'recall_autodebias_hint_tiebreak' : 'recall_autodebias_hint_no_class_match',
|
|
194
|
+
targetId: match.phrase.slice(0, 100),
|
|
195
|
+
metadata: { detected_phrase: match.phrase, token_count: match.classQueryTokens.length },
|
|
181
196
|
},
|
|
182
197
|
};
|
|
183
198
|
}
|
package/dist/recall-trace.d.ts
CHANGED
|
@@ -173,7 +173,7 @@ export interface DeliveryEventRow {
|
|
|
173
173
|
}
|
|
174
174
|
/** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
|
|
175
175
|
export declare function writeDeliveryEvent(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
|
|
176
|
-
/** Write on a short-lived
|
|
176
|
+
/** Write on the request's handle, or a short-lived one, waiting at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
|
|
177
177
|
export declare function writeDeliveryEventAtRoot(root: string, input: DeliveryEventInput): number | null;
|
|
178
178
|
/** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
|
|
179
179
|
export declare function writeDeliveryEventOnHandle(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
|
package/dist/recall-trace.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* new debug env var).
|
|
16
16
|
*/
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
|
-
import { openHippoDb, closeHippoDb } from './db.js';
|
|
18
|
+
import { openHippoDb, closeHippoDb, rethrowIfSqliteBlocked } from './db.js';
|
|
19
19
|
import { DELIVERY_LEDGER_VERSION } from './delivery-recorder.js';
|
|
20
20
|
import { log } from './log.js';
|
|
21
21
|
/**
|
|
@@ -109,6 +109,7 @@ export function writeRecallTraceAtRoot(root, input) {
|
|
|
109
109
|
db = openHippoDb(root);
|
|
110
110
|
}
|
|
111
111
|
catch (error) {
|
|
112
|
+
rethrowIfSqliteBlocked(error);
|
|
112
113
|
log.error(`recall trace connection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
@@ -267,11 +268,11 @@ export function writeDeliveryEvent(db, input) {
|
|
|
267
268
|
return null;
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
|
-
/** Write on a short-lived
|
|
271
|
+
/** Write on the request's handle, or a short-lived one, waiting at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
|
|
271
272
|
export function writeDeliveryEventAtRoot(root, input) {
|
|
272
273
|
let db;
|
|
273
274
|
try {
|
|
274
|
-
db = openHippoDb(root
|
|
275
|
+
db = openHippoDb(root);
|
|
275
276
|
}
|
|
276
277
|
catch (error) {
|
|
277
278
|
// Same hook stderr line as writeDeliveryEvent above.
|
|
@@ -279,7 +280,7 @@ export function writeDeliveryEventAtRoot(root, input) {
|
|
|
279
280
|
return null;
|
|
280
281
|
}
|
|
281
282
|
try {
|
|
282
|
-
return
|
|
283
|
+
return writeDeliveryEventOnHandle(db, input);
|
|
283
284
|
}
|
|
284
285
|
finally {
|
|
285
286
|
closeHippoDb(db);
|
|
@@ -6,7 +6,7 @@ import { resolveEmbeddingProvider } from '../embedding-provider.js';
|
|
|
6
6
|
import { physicsScore as computePhysicsScores, computeMass } from '../physics.js';
|
|
7
7
|
import { DEFAULT_PHYSICS_CONFIG } from '../physics-config.js';
|
|
8
8
|
import { loadPhysicsState } from '../physics-state.js';
|
|
9
|
-
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
9
|
+
import { openHippoDb, closeHippoDb, rethrowIfSqliteBlocked } from '../db.js';
|
|
10
10
|
import { compareScoredResults } from '../compare.js';
|
|
11
11
|
import { log } from '../log.js';
|
|
12
12
|
import { churnStaleFactor, summaryMultipliers, summaryScoring } from './boosts.js';
|
|
@@ -57,6 +57,7 @@ async function physicsQueryVector(query, root, given) {
|
|
|
57
57
|
return vec && vec.length > 0 ? vec : null;
|
|
58
58
|
}
|
|
59
59
|
catch (err) {
|
|
60
|
+
rethrowIfSqliteBlocked(err);
|
|
60
61
|
log.debug(`physics search: query embed failed, using hybrid: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
@@ -68,6 +69,7 @@ function withVectorCandidates(root, entries, queryVector, spec) {
|
|
|
68
69
|
return [...entries, ...vectorCandidatesOutside(root, entries, queryVector, spec)];
|
|
69
70
|
}
|
|
70
71
|
catch (err) {
|
|
72
|
+
rethrowIfSqliteBlocked(err);
|
|
71
73
|
log.warn(`physics search ranked the lexical pool only; the vector lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
74
|
return entries;
|
|
73
75
|
}
|
|
@@ -84,6 +86,7 @@ function loadCandidateParticles(root, pool) {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
catch (err) {
|
|
89
|
+
rethrowIfSqliteBlocked(err);
|
|
87
90
|
log.debug(`physics search: state load failed, using hybrid: ${err instanceof Error ? err.message : String(err)}`);
|
|
88
91
|
return null;
|
|
89
92
|
}
|
package/dist/search/vector.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cosineSimilarity, embeddingModelRequiresReindex, hasEmbeddings, loadStoredVectors } from '../embeddings.js';
|
|
2
2
|
import { loadVectorCandidateEntries } from '../store/search-rows.js';
|
|
3
3
|
import { resolveEmbeddingProvider } from '../embedding-provider.js';
|
|
4
|
+
import { rethrowIfSqliteBlocked } from '../db.js';
|
|
4
5
|
import { log } from '../log.js';
|
|
5
6
|
import { redactSecretsStrict } from '../secret-detect.js';
|
|
6
7
|
import { currentEntries } from './as-of.js';
|
|
@@ -22,6 +23,7 @@ export async function resolveVectorArm(query, entries, options) {
|
|
|
22
23
|
await fillVectorArm(arm, query, options.hippoRoot, options);
|
|
23
24
|
}
|
|
24
25
|
catch (err) {
|
|
26
|
+
rethrowIfSqliteBlocked(err);
|
|
25
27
|
warnBm25Fallback('error', err instanceof Error ? err.message : String(err));
|
|
26
28
|
}
|
|
27
29
|
return arm;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RouteRequest } from '../types.js';
|
|
2
|
-
/** Test-only: reset the
|
|
2
|
+
/** Test-only: reset the HTTP recall rings. Call from beforeEach. */
|
|
3
3
|
export declare function __resetSessionRecallHistoryHttp(): void;
|
|
4
4
|
export declare function handleRecallMemories({ req, res, opts, query }: RouteRequest): Promise<void>;
|
|
5
5
|
export declare function handleAssembleSession({ req, res, opts, query }: RouteRequest, assembleMatch: Record<string, string>): Promise<void>;
|
|
@@ -3,185 +3,47 @@ import { dirname, resolve } from 'node:path';
|
|
|
3
3
|
import { assertCallerProject, resolveProjectIdentity } from '../../project-identity.js';
|
|
4
4
|
import { isSharedStore } from '../../config.js';
|
|
5
5
|
import { assembleCost, contextCost, drillCost } from '../../context-render.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { appendRecall, biasHintEnabled, buildSessionKey, getOrCreateRing, hashQueryText, snapshotRing } from '../../recall-history.js';
|
|
9
|
-
import { appendAuditEvent, auditQueryFields } from '../../audit.js';
|
|
6
|
+
import { storeFor } from '../../store-port.js';
|
|
7
|
+
import { biasHintEnabled } from '../../recall-history.js';
|
|
10
8
|
import { assemble, drillDown, getContext, recordTokens, retrieve } from '../../api.js';
|
|
11
|
-
import {
|
|
9
|
+
import { httpParams, parseContextRequest, parseRecallRequest } from '../../api/recall-request.js';
|
|
10
|
+
import { anchorSkippedRows, noteRecall, peekSessionRing, resetSessionRings, sessionRing } from '../../api/recall-record.js';
|
|
11
|
+
import { HttpError, sendJson } from '../../http-util.js';
|
|
12
12
|
import { buildContextWithAuth } from '../auth.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
// for the HTTP pipeline. Separate from CLI/MCP rings (per-
|
|
16
|
-
// pipeline rings; no IPC). HTTP is the only caller that threads its
|
|
17
|
-
// snapshot through opts.recallHistory to api.recall — api.recall's
|
|
18
|
-
// anchoringHint on the returned RecallResult IS the user-visible hint
|
|
19
|
-
// here (no separate compute needed).
|
|
20
|
-
const sessionRecallHistoryHttp = new Map();
|
|
21
|
-
/** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
|
|
13
|
+
import { validateIdSegment } from '../validation.js';
|
|
14
|
+
/** Test-only: reset the HTTP recall rings. Call from beforeEach. */
|
|
22
15
|
export function __resetSessionRecallHistoryHttp() {
|
|
23
|
-
|
|
16
|
+
resetSessionRings('http');
|
|
24
17
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Cap session_id length consistent with the
|
|
34
|
-
// rest of the API. Untrimmed strings round-trip through the SQL layer
|
|
35
|
-
// and through any downstream metric/log; 256 is generous for a session
|
|
36
|
-
// id and matches the rest of this file's id-shaped param parsers.
|
|
37
|
-
const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
|
|
38
|
-
if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > MAX_ID_LEN) {
|
|
39
|
-
throw new HttpError(400, `fresh_tail_session_id exceeds ${MAX_ID_LEN}-character cap`);
|
|
40
|
-
}
|
|
41
|
-
const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
|
|
42
|
-
? freshTailSessionIdRaw
|
|
43
|
-
: undefined;
|
|
44
|
-
return { freshTailCount, freshTailSessionId };
|
|
45
|
-
}
|
|
46
|
-
function parseSessionId(query) {
|
|
47
|
-
// session_id for the dlPFC goal-stack boost. 256-char cap mirrors
|
|
48
|
-
// fresh_tail_session_id (above). Trim then drop if empty so api.recall
|
|
49
|
-
// sees undefined when the param is omitted or whitespace-only.
|
|
50
|
-
const sessionIdRaw = query.get('session_id');
|
|
51
|
-
if (sessionIdRaw !== null && sessionIdRaw.length > MAX_ID_LEN) {
|
|
52
|
-
throw new HttpError(400, `session_id exceeds ${MAX_ID_LEN}-character cap`);
|
|
53
|
-
}
|
|
54
|
-
return sessionIdRaw && sessionIdRaw.trim().length > 0
|
|
55
|
-
? sessionIdRaw.trim()
|
|
56
|
-
: undefined;
|
|
57
|
-
}
|
|
58
|
-
function parseRecallQuery(query) {
|
|
59
|
-
const q = query.get('q');
|
|
60
|
-
if (!q) {
|
|
61
|
-
throw new HttpError(400, 'q is required');
|
|
62
|
-
}
|
|
63
|
-
const limitRaw = query.get('limit');
|
|
64
|
-
const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
|
|
65
|
-
const mode = query.get('mode');
|
|
66
|
-
if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
|
|
67
|
-
throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
|
|
68
|
-
}
|
|
69
|
-
const scope = query.get('scope');
|
|
70
|
-
const includeContinuityRaw = query.get('include_continuity');
|
|
71
|
-
const includeContinuity = includeContinuityRaw === '1'
|
|
72
|
-
|| includeContinuityRaw === 'true';
|
|
73
|
-
const { freshTailCount, freshTailSessionId } = parseFreshTail(query);
|
|
74
|
-
// Strict parse matching the includeContinuity convention, so values
|
|
75
|
-
// like `?summarize_overflow=banana` or an empty value do not turn it on.
|
|
76
|
-
const summarizeOverflowRaw = query.get('summarize_overflow');
|
|
77
|
-
const summarizeOverflow = summarizeOverflowRaw === null
|
|
78
|
-
? undefined
|
|
79
|
-
: (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
|
|
80
|
-
// recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
|
|
81
|
-
const scorerWindowRaw = query.get('scorer_window');
|
|
82
|
-
const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
|
|
83
|
-
if (scorerWindow !== undefined && scorerWindow > 1000) {
|
|
84
|
-
throw new HttpError(400, 'scorer_window must be <= 1000');
|
|
85
|
-
}
|
|
86
|
-
const sessionId = parseSessionId(query);
|
|
87
|
-
// Recall-trace: opt-in explain flag. When set, api.recall attaches the
|
|
88
|
-
// lifecycle re-ranking trace (goal-boost step on the api pipeline) +
|
|
89
|
-
// rerankPipeline:'api' to each result item; the field then rides on the
|
|
90
|
-
// serialized RecallResult. Mirrors the include_continuity convention.
|
|
91
|
-
const explainRaw = query.get('explain');
|
|
92
|
-
const explain = explainRaw === '1' || explainRaw === 'true';
|
|
93
|
-
return { q, limit, mode, scope, includeContinuity, freshTailCount, freshTailSessionId, summarizeOverflow, scorerWindow, sessionId, explain };
|
|
94
|
-
}
|
|
95
|
-
// HTTP per-pipeline anchoring detector. HTTP threads its
|
|
96
|
-
// ring snapshot via opts.recallHistory so api.recall's own
|
|
97
|
-
// anchoringHint compute path activates. Unlike CLI (which computes
|
|
98
|
-
// its own hint separately because cmdRecall runs its own physics/
|
|
99
|
-
// hybrid pipeline outside api.recall), HTTP's /v1/memories response
|
|
100
|
-
// body IS api.recall's result directly. So the api.recall-computed
|
|
101
|
-
// hint flows through. HIPPO_ANCHORING=off short-circuits.
|
|
102
|
-
function snapshotSessionRing(ctx, hippoRoot, q, sessionId) {
|
|
103
|
-
let httpRecallHistory;
|
|
104
|
-
let httpRingKey;
|
|
105
|
-
if (biasHintEnabled('anchoring')) {
|
|
106
|
-
if (sessionId) {
|
|
107
|
-
// Do NOT mutate sessionRecallHistoryHttp before recall() preflight: a request
|
|
108
|
-
// that 400s would otherwise create-or-touch a ring and LRU-evict valid sessions.
|
|
109
|
-
httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
|
|
110
|
-
const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
|
|
111
|
-
httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
// Telemetry: caller had no session_id so ring tracking skipped.
|
|
115
|
-
// Per the normal recall-audit convention (api.ts:854 stores
|
|
116
|
-
// SHA-256/16 hash of the query, NOT raw text), avoid retaining
|
|
117
|
-
// prompts in audit_log here too — query content can contain
|
|
118
|
-
// secrets, PII, or RTBF-restricted material. hashQueryText is a 32-bit
|
|
119
|
-
// FNV-1a, NOT a privacy hash, so use the recall audit's SHA-256/16 truncation.
|
|
120
|
-
const dbForAudit = openHippoDb(hippoRoot);
|
|
121
|
-
try {
|
|
122
|
-
appendAuditEvent(dbForAudit, {
|
|
123
|
-
tenantId: ctx.tenantId,
|
|
124
|
-
actor: ctx.actor.subject,
|
|
125
|
-
op: 'recall_anchor_skipped_no_session',
|
|
126
|
-
targetId: undefined,
|
|
127
|
-
metadata: auditQueryFields(q),
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
finally {
|
|
131
|
-
closeHippoDb(dbForAudit);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return { httpRecallHistory, httpRingKey };
|
|
136
|
-
}
|
|
137
|
-
function recallExtraOpts(parsed, httpRecallHistory) {
|
|
138
|
-
const recallExtra = {};
|
|
139
|
-
if (parsed.freshTailCount !== undefined)
|
|
140
|
-
recallExtra.freshTailCount = parsed.freshTailCount;
|
|
141
|
-
if (parsed.freshTailSessionId !== undefined)
|
|
142
|
-
recallExtra.freshTailSessionId = parsed.freshTailSessionId;
|
|
143
|
-
if (parsed.summarizeOverflow !== undefined)
|
|
144
|
-
recallExtra.summarizeOverflow = parsed.summarizeOverflow;
|
|
145
|
-
if (parsed.scorerWindow !== undefined)
|
|
146
|
-
recallExtra.scorerWindow = parsed.scorerWindow;
|
|
147
|
-
if (parsed.sessionId !== undefined)
|
|
148
|
-
recallExtra.sessionId = parsed.sessionId;
|
|
149
|
-
if (httpRecallHistory !== undefined)
|
|
150
|
-
recallExtra.recallHistory = httpRecallHistory;
|
|
151
|
-
if (parsed.explain)
|
|
152
|
-
recallExtra.explain = parsed.explain;
|
|
153
|
-
return recallExtra;
|
|
18
|
+
// HTTP threads the ring through opts.recallHistory, so the hint retrieve() returns is the one the caller sees.
|
|
19
|
+
async function recallHistoryFor(ctx, q, sessionId) {
|
|
20
|
+
if (!biasHintEnabled('anchoring'))
|
|
21
|
+
return undefined;
|
|
22
|
+
if (sessionId)
|
|
23
|
+
return peekSessionRing('http', ctx.tenantId, sessionId);
|
|
24
|
+
await storeFor(ctx).appendAuditEvents(anchorSkippedRows({ tenantId: ctx.tenantId, actor: ctx.actor.subject }, q));
|
|
25
|
+
return undefined;
|
|
154
26
|
}
|
|
155
27
|
// GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
|
|
156
28
|
export async function handleRecallMemories({ req, res, opts, query }) {
|
|
157
|
-
const
|
|
158
|
-
const { q, includeContinuity, sessionId } =
|
|
29
|
+
const { opts: recallOpts, limit, mode, explain } = parseRecallRequest(httpParams(query));
|
|
30
|
+
const { query: q, includeContinuity, sessionId } = recallOpts;
|
|
159
31
|
const ctx = await buildContextWithAuth(req, opts);
|
|
160
|
-
const
|
|
161
|
-
const result = await retrieve(ctx, {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
includeContinuity,
|
|
167
|
-
...recallExtraOpts(parsed, httpRecallHistory),
|
|
168
|
-
});
|
|
169
|
-
// Append only after recall succeeds, so a 400 cannot LRU-evict valid sessions;
|
|
170
|
-
// anchoredOn feeds the cooldown logic for the NEXT recall on this session.
|
|
171
|
-
if (httpRingKey) {
|
|
172
|
-
const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
|
|
173
|
-
const topId = result.results[0]?.id ?? null;
|
|
174
|
-
appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
|
|
175
|
-
}
|
|
32
|
+
const recallHistory = await recallHistoryFor(ctx, q, sessionId);
|
|
33
|
+
const result = await retrieve(ctx, { ...recallOpts, limit, mode, explain, recallHistory });
|
|
34
|
+
// The ring is created only after recall succeeds, so a 400 cannot LRU-evict a live session.
|
|
35
|
+
const ring = sessionRing('http', ctx.tenantId, sessionId);
|
|
36
|
+
if (ring)
|
|
37
|
+
noteRecall(ring, q, result.results[0]?.id ?? null, result.anchoringHint?.memoryId);
|
|
176
38
|
// Each recall surface counts its own hits; api.recall is no chokepoint,
|
|
177
39
|
// since the CLI never calls it and MCP shows the user a different band.
|
|
178
|
-
|
|
40
|
+
await storeFor(ctx).bumpRecallStats(result.results.length);
|
|
179
41
|
// Continuity payloads should never be cached. The caller is asking for
|
|
180
42
|
// session-state-aware data; intermediaries must not reuse it across users.
|
|
181
43
|
if (includeContinuity) {
|
|
182
44
|
res.setHeader('Cache-Control', 'no-store');
|
|
183
45
|
}
|
|
184
|
-
recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
46
|
+
await recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
185
47
|
sendJson(res, 200, result);
|
|
186
48
|
return;
|
|
187
49
|
}
|
|
@@ -220,7 +82,7 @@ export async function handleAssembleSession({ req, res, opts, query }, assembleM
|
|
|
220
82
|
if (scope !== undefined)
|
|
221
83
|
assembleExtra.scope = scope;
|
|
222
84
|
const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
|
|
223
|
-
recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
85
|
+
await recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
224
86
|
sendJson(res, 200, result);
|
|
225
87
|
return;
|
|
226
88
|
}
|
|
@@ -290,64 +152,14 @@ function contextReader(hippoRoot, query) {
|
|
|
290
152
|
// via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
|
|
291
153
|
// (matches cmdContext); real-query hybrid search emits one 'recall' row.
|
|
292
154
|
export async function handleGetContext({ req, res, opts, query }) {
|
|
293
|
-
const
|
|
294
|
-
// DoS cap on q-param length. 1024 covers real multi-clause queries
|
|
295
|
-
// (pasted error messages, multi-stem searches) while bounding BM25
|
|
296
|
-
// tokenisation cost (~150 tokens worst case at 1024 chars).
|
|
297
|
-
if (q !== undefined && q.length > 1024) {
|
|
298
|
-
throw new HttpError(400, 'q exceeds 1024-character cap');
|
|
299
|
-
}
|
|
300
|
-
const budgetRaw = query.get('budget');
|
|
301
|
-
let budget;
|
|
302
|
-
if (budgetRaw !== null) {
|
|
303
|
-
budget = Number(budgetRaw);
|
|
304
|
-
if (!Number.isFinite(budget) || budget < 0) {
|
|
305
|
-
throw new HttpError(400, 'budget must be a non-negative number');
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
const limitRaw = query.get('limit');
|
|
309
|
-
let limit;
|
|
310
|
-
if (limitRaw !== null) {
|
|
311
|
-
limit = Number(limitRaw);
|
|
312
|
-
if (!Number.isFinite(limit) || limit <= 0) {
|
|
313
|
-
throw new HttpError(400, 'limit must be a positive number');
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
const pinnedOnlyRaw = query.get('pinned_only');
|
|
317
|
-
const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
|
|
318
|
-
const scopeRaw = query.get('scope');
|
|
319
|
-
if (scopeRaw !== null && scopeRaw.length > MAX_ID_LEN) {
|
|
320
|
-
throw new HttpError(400, `scope exceeds ${MAX_ID_LEN}-character cap`);
|
|
321
|
-
}
|
|
322
|
-
const scope = scopeRaw === null ? undefined : scopeRaw;
|
|
323
|
-
const includeRecentRaw = query.get('include_recent');
|
|
324
|
-
let includeRecent;
|
|
325
|
-
if (includeRecentRaw !== null) {
|
|
326
|
-
includeRecent = Number(includeRecentRaw);
|
|
327
|
-
if (!Number.isFinite(includeRecent) || includeRecent < 0) {
|
|
328
|
-
throw new HttpError(400, 'include_recent must be a non-negative number');
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
// v39 memory scope isolation: cross_project=1|true re-includes
|
|
332
|
-
// other-project rows (tagged category 'cross-project' in the response).
|
|
333
|
-
// The partition identity comes from the SERVED STORE's location, not the
|
|
334
|
-
// daemon's process cwd - a daemon started from anywhere still isolates
|
|
335
|
-
// the project it serves.
|
|
336
|
-
const crossProjectRaw = query.get('cross_project');
|
|
337
|
-
const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
|
|
155
|
+
const parsed = parseContextRequest(httpParams(query));
|
|
338
156
|
const ctx = await buildContextWithAuth(req, opts);
|
|
339
157
|
const result = await getContext(ctx, {
|
|
340
|
-
|
|
341
|
-
budget,
|
|
342
|
-
limit,
|
|
343
|
-
pinnedOnly,
|
|
344
|
-
scope,
|
|
345
|
-
includeRecent,
|
|
346
|
-
crossProject,
|
|
158
|
+
...parsed,
|
|
347
159
|
currentProject: contextReader(opts.hippoRoot, query),
|
|
348
160
|
cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
|
|
349
161
|
});
|
|
350
|
-
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
162
|
+
await recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
351
163
|
sendJson(res, 200, result);
|
|
352
164
|
return;
|
|
353
165
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -11,8 +11,19 @@ export { HttpError } from './http-util.js';
|
|
|
11
11
|
export { promptHookContext, type CallerProject } from './prompt-hook.js';
|
|
12
12
|
export type { JsonValue } from './json.js';
|
|
13
13
|
export { captureSessionTexts, type SessionCaptureRequest, type SessionCaptureResult } from './capture/session-texts.js';
|
|
14
|
-
export { sqliteStore, type HippoStore } from './store-port.js';
|
|
14
|
+
export { sqliteStore, type HippoStore, type RecallSearchArgs, type RecallWrites } from './store-port.js';
|
|
15
15
|
export type { ApiKeyRecord } from './auth.js';
|
|
16
|
+
export type { AppendAuditOpts } from './audit.js';
|
|
17
|
+
export type { ContinuityBlock } from './api/recall-types.js';
|
|
18
|
+
export type { ActiveGoals, GetActiveGoalsOpts, Goal, GoalRecallLogRow, RetrievalPolicy } from './goals.js';
|
|
19
|
+
export type { MemoryEntry } from './memory.js';
|
|
20
|
+
export type { ClassResolution, PlanningFallacyEvidence } from './predictions/planning-fallacy.js';
|
|
21
|
+
export type { PredictionBaserate } from './predictions/store.js';
|
|
22
|
+
export type { RecallTraceInput } from './recall-trace.js';
|
|
23
|
+
export type { StrengthenOptions } from './store/entry-writes.js';
|
|
24
|
+
export type { OriginFilter } from './store/search-rows.js';
|
|
25
|
+
export type { ContinuityKey } from './store/sessions.js';
|
|
26
|
+
export type { TokenUse } from './token-ledger.js';
|
|
16
27
|
export { StoreBusyError } from './db.js';
|
|
17
28
|
export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
|
|
18
29
|
export { isSharedStore } from './config.js';
|