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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/sync.js +3 -2
  3. package/dist/api/recall-record.d.ts +46 -0
  4. package/dist/api/recall-record.js +98 -0
  5. package/dist/api/recall-request.d.ts +32 -0
  6. package/dist/api/recall-request.js +121 -0
  7. package/dist/api/recall-types.d.ts +7 -1
  8. package/dist/api/recall.d.ts +3 -2
  9. package/dist/api/recall.js +174 -181
  10. package/dist/api/tokens.d.ts +1 -1
  11. package/dist/api/tokens.js +13 -17
  12. package/dist/cli/maintenance.d.ts +2 -1
  13. package/dist/cli/maintenance.js +17 -11
  14. package/dist/cli/recall.d.ts +1 -1
  15. package/dist/cli/recall.js +18 -49
  16. package/dist/cli/shared.js +2 -2
  17. package/dist/cli.d.ts +16 -0
  18. package/dist/cli.js +18 -1
  19. package/dist/config.d.ts +2 -0
  20. package/dist/config.js +4 -0
  21. package/dist/db/connect.d.ts +5 -0
  22. package/dist/db/connect.js +52 -0
  23. package/dist/db/open.d.ts +9 -11
  24. package/dist/db/open.js +23 -116
  25. package/dist/db/request-stores.d.ts +30 -0
  26. package/dist/db/request-stores.js +100 -0
  27. package/dist/db/sqlite-blocked.d.ts +6 -0
  28. package/dist/db/sqlite-blocked.js +11 -0
  29. package/dist/db.d.ts +3 -1
  30. package/dist/db.js +3 -1
  31. package/dist/embedding-provider.js +4 -3
  32. package/dist/embeddings.js +2 -1
  33. package/dist/goals.d.ts +18 -11
  34. package/dist/goals.js +38 -36
  35. package/dist/http-util.d.ts +2 -0
  36. package/dist/http-util.js +5 -0
  37. package/dist/local-embedding.d.ts +2 -0
  38. package/dist/local-embedding.js +20 -4
  39. package/dist/mcp/memory-tools.js +4 -1
  40. package/dist/mcp/recall-tools.js +28 -133
  41. package/dist/mcp/request.d.ts +7 -0
  42. package/dist/mcp/request.js +63 -41
  43. package/dist/mcp/session-state.d.ts +1 -3
  44. package/dist/mcp/session-state.js +3 -5
  45. package/dist/predictions/planning-fallacy.d.ts +52 -0
  46. package/dist/predictions/planning-fallacy.js +70 -55
  47. package/dist/recall-trace.d.ts +1 -1
  48. package/dist/recall-trace.js +5 -4
  49. package/dist/search/physics-search.js +4 -1
  50. package/dist/search/vector.js +2 -0
  51. package/dist/server/routes/recall.d.ts +1 -1
  52. package/dist/server/routes/recall.js +30 -218
  53. package/dist/server.d.ts +12 -1
  54. package/dist/server.js +33 -22
  55. package/dist/store/entry-writes.d.ts +2 -0
  56. package/dist/store/entry-writes.js +24 -16
  57. package/dist/store-port.d.ts +70 -1
  58. package/dist/store-port.js +87 -9
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  62. package/extensions/openclaw-plugin/package.json +1 -1
  63. package/openclaw.plugin.json +1 -1
  64. 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
- // Env read FIRST so AUTODEBIAS=off pays zero regex cost. Per-call read
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 resolution = resolveClassFromTokens(hippoRoot, tenantId, match.classQueryTokens);
116
- if (resolution.tiebreak) {
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(hippoRoot, tenantId, actor, match, 'tiebreak', TIEBREAK_SUGGESTION);
134
+ return watchingWithAudit(tenantId, actor, match, 'tiebreak', TIEBREAK_SUGGESTION);
120
135
  }
121
- if (!resolution.classTag) {
122
- // Telemetry: forward-claim detected, no class scored ≥ 1. High volume here means regex+token-overlap
123
- // misses real forward-claims with no class signal, the case for an embedding fallback.
124
- // The watching variant lets the caller render "watching but no baserate (no class match)".
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
- // emitAudit=false: avoid double-write to predict_baserate channel.
128
- // The recall_autodebias_hint audit below carries n_closed + mean_ratio.
129
- const baserate = computePredictionBaserate(hippoRoot, tenantId, resolution.classTag, actor,
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
- hint: {
147
- classTag: resolution.classTag,
148
- baserateSummary: baserate.summary,
149
- source: 'j3.2-auto',
150
- detectedPhrase: match.phrase,
151
- nClosed: baserate.nClosed,
152
- meanRatio: baserate.meanRatio,
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(hippoRoot, tenantId, actor, match, reason, suggestion) {
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
- watching: {
178
- detectedPhrase: match.phrase,
179
- reason,
180
- suggestion,
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
  }
@@ -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 connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
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;
@@ -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 connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
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, { busyWaitMs: DELIVERY_LEDGER_WAIT_MS });
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 writeDeliveryEvent(db, input);
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
  }
@@ -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 module-level recall-history Map. Call from beforeEach. */
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 { closeHippoDb, openHippoDb } from '../../db.js';
7
- import { updateStats } from '../../store/index-and-stats.js';
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 { HttpError, MAX_ID_LEN, sendJson } from '../../http-util.js';
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 { parseListLimit, validateIdSegment } from '../validation.js';
14
- // Module-level per-(tenant, session) recall-history ring map
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
- sessionRecallHistoryHttp.clear();
16
+ resetSessionRings('http');
24
17
  }
25
- function parseFreshTail(query) {
26
- // Surface the fresh-tail RecallOpts to HTTP callers so session-scoped
27
- // fresh-tail and summary substitution are not JS-only.
28
- const freshTailCountRaw = query.get('fresh_tail_count');
29
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
30
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
31
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
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 parsed = parseRecallQuery(query);
158
- const { q, includeContinuity, sessionId } = parsed;
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 { httpRecallHistory, httpRingKey } = snapshotSessionRing(ctx, opts.hippoRoot, q, sessionId);
161
- const result = await retrieve(ctx, {
162
- query: q,
163
- limit: parsed.limit,
164
- mode: parsed.mode ?? undefined,
165
- scope: parsed.scope ?? undefined,
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
- updateStats(opts.hippoRoot, { recalled: result.results.length });
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 q = query.get('q') ?? undefined;
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
- q,
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';