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.
Files changed (58) hide show
  1. package/dist/agent-memories/sync.js +3 -2
  2. package/dist/api/recall-record.d.ts +46 -0
  3. package/dist/api/recall-record.js +98 -0
  4. package/dist/api/recall-request.d.ts +32 -0
  5. package/dist/api/recall-request.js +121 -0
  6. package/dist/api/recall-types.d.ts +7 -1
  7. package/dist/api/recall.d.ts +3 -2
  8. package/dist/api/recall.js +174 -181
  9. package/dist/api/tokens.d.ts +1 -1
  10. package/dist/api/tokens.js +13 -17
  11. package/dist/cli/recall.d.ts +1 -1
  12. package/dist/cli/recall.js +18 -49
  13. package/dist/cli/shared.js +2 -2
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +18 -1
  16. package/dist/config.d.ts +2 -0
  17. package/dist/config.js +4 -0
  18. package/dist/db/connect.d.ts +5 -0
  19. package/dist/db/connect.js +52 -0
  20. package/dist/db/open.d.ts +9 -11
  21. package/dist/db/open.js +23 -116
  22. package/dist/db/request-stores.d.ts +30 -0
  23. package/dist/db/request-stores.js +100 -0
  24. package/dist/db/sqlite-blocked.d.ts +6 -0
  25. package/dist/db/sqlite-blocked.js +11 -0
  26. package/dist/db.d.ts +3 -1
  27. package/dist/db.js +3 -1
  28. package/dist/embeddings.js +2 -1
  29. package/dist/goals.d.ts +18 -11
  30. package/dist/goals.js +38 -36
  31. package/dist/http-util.d.ts +2 -0
  32. package/dist/http-util.js +5 -0
  33. package/dist/mcp/memory-tools.js +4 -1
  34. package/dist/mcp/recall-tools.js +28 -133
  35. package/dist/mcp/request.d.ts +7 -0
  36. package/dist/mcp/request.js +63 -41
  37. package/dist/mcp/session-state.d.ts +1 -3
  38. package/dist/mcp/session-state.js +3 -5
  39. package/dist/predictions/planning-fallacy.d.ts +52 -0
  40. package/dist/predictions/planning-fallacy.js +70 -55
  41. package/dist/recall-trace.d.ts +1 -1
  42. package/dist/recall-trace.js +5 -4
  43. package/dist/search/physics-search.js +4 -1
  44. package/dist/search/vector.js +2 -0
  45. package/dist/server/routes/recall.d.ts +1 -1
  46. package/dist/server/routes/recall.js +30 -218
  47. package/dist/server.d.ts +12 -1
  48. package/dist/server.js +33 -22
  49. package/dist/store/entry-writes.d.ts +2 -0
  50. package/dist/store/entry-writes.js +24 -16
  51. package/dist/store-port.d.ts +70 -1
  52. package/dist/store-port.js +87 -9
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  56. package/extensions/openclaw-plugin/package.json +1 -1
  57. package/openclaw.plugin.json +1 -1
  58. package/package.json +1 -1
@@ -1,4 +1,11 @@
1
1
  import { type McpContext, type McpRequest, type McpResponse } from './protocol.js';
2
+ /**
3
+ * Record the memory text a recall or context tool returned. Best-effort: a
4
+ * ledger failure never fails the tool call. Other tools are not recorded.
5
+ */
6
+ export declare function recordMcpTokens(toolName: string, output: string, ctx?: McpContext): Promise<void>;
7
+ /** The tools listed and run under a store other than hippo.db. */
8
+ export declare const STORE_READY_TOOLS: ReadonlySet<string>;
2
9
  /**
3
10
  * Transport-agnostic MCP dispatcher. Both the stdio loop (below) and the
4
11
  * HTTP/SSE transport in src/server.ts route every incoming JSON-RPC message
@@ -1,12 +1,15 @@
1
1
  // Transport-agnostic request handling: tool dispatch table, tool execution and the JSON-RPC method switch.
2
2
  import { log } from '../log.js';
3
+ import { STORE_NOT_PORTED_MESSAGE } from '../http-util.js';
3
4
  import { getGlobalRoot, initGlobal } from '../shared.js';
4
5
  import { loadConfig } from '../config.js';
5
6
  import { resolveTenantId } from '../tenant.js';
6
- import { openHippoDb, closeHippoDb } from '../db.js';
7
- import { estimateTokens, recordTokenUse } from '../token-ledger.js';
7
+ import { rethrowIfSqliteBlocked, runWithRequestStores } from '../db.js';
8
+ import { storeFor } from '../store-port.js';
9
+ import { estimateTokens } from '../token-ledger.js';
8
10
  import { PACKAGE_VERSION } from '../version.js';
9
11
  import { validateToolArgs } from './tool-args.js';
12
+ import { RecallRequestError } from '../api/recall-request.js';
10
13
  import { findHippoRoot, isJsonObjectRecord } from './protocol.js';
11
14
  import { TOOLS, TOOLS_BY_NAME, ARGS_CHECKED_BY_API } from './tools.js';
12
15
  import { runRecallTool, runAssembleTool, runDrillTool, runContextTool } from './recall-tools.js';
@@ -35,7 +38,7 @@ const MCP_TOKEN_SURFACES = new Map([
35
38
  * Record the memory text a recall or context tool returned. Best-effort: a
36
39
  * ledger failure never fails the tool call. Other tools are not recorded.
37
40
  */
38
- function recordMcpTokens(toolName, output, ctx) {
41
+ export async function recordMcpTokens(toolName, output, ctx) {
39
42
  const surface = MCP_TOKEN_SURFACES.get(toolName);
40
43
  if (!surface || !output)
41
44
  return;
@@ -43,40 +46,41 @@ function recordMcpTokens(toolName, output, ctx) {
43
46
  const hippoRoot = ctx?.hippoRoot ?? findHippoRoot();
44
47
  if (!hippoRoot)
45
48
  return;
46
- const db = openHippoDb(hippoRoot);
47
- try {
48
- recordTokenUse(db, {
49
- tenantId: ctx?.tenantId ?? resolveTenantId({}),
50
- surface,
51
- event: 'inject',
52
- items: 0,
53
- tokens: estimateTokens(output),
54
- });
55
- }
56
- finally {
57
- closeHippoDb(db);
58
- }
49
+ await storeFor({ hippoRoot, store: ctx?.store }).recordTokens({
50
+ tenantId: ctx?.tenantId ?? resolveTenantId({}),
51
+ surface,
52
+ event: 'inject',
53
+ items: 0,
54
+ tokens: estimateTokens(output),
55
+ });
59
56
  }
60
57
  catch (err) {
58
+ rethrowIfSqliteBlocked(err);
61
59
  log.warnThenDebug('mcp-token-ledger', `token ledger write failed; the tool reply is unaffected: ${err instanceof Error ? err.message : String(err)}`);
62
60
  }
63
61
  }
64
- // ── Tool execution ──
65
62
  const TOOL_HANDLERS = new Map([
66
- ['hippo_recall', runRecallTool],
67
- ['hippo_assemble', runAssembleTool],
68
- ['hippo_drill', runDrillTool],
69
- ['hippo_predict_baserate', runPredictBaserateTool],
70
- ['hippo_remember', runRememberTool],
71
- ['hippo_outcome', runOutcomeTool],
72
- ['hippo_context', runContextTool],
73
- ['hippo_status', runStatusTool],
74
- ['hippo_learn', runLearnTool],
75
- ['hippo_conflicts', runConflictsTool],
76
- ['hippo_resolve', runResolveTool],
77
- ['hippo_share', runShareTool],
78
- ['hippo_peers', runPeersTool],
63
+ ['hippo_recall', { handler: runRecallTool, storeReady: true }],
64
+ ['hippo_assemble', { handler: runAssembleTool }],
65
+ ['hippo_drill', { handler: runDrillTool }],
66
+ ['hippo_predict_baserate', { handler: runPredictBaserateTool }],
67
+ ['hippo_remember', { handler: runRememberTool }],
68
+ ['hippo_outcome', { handler: runOutcomeTool }],
69
+ ['hippo_context', { handler: runContextTool }],
70
+ ['hippo_status', { handler: runStatusTool }],
71
+ ['hippo_learn', { handler: runLearnTool }],
72
+ ['hippo_conflicts', { handler: runConflictsTool }],
73
+ ['hippo_resolve', { handler: runResolveTool }],
74
+ ['hippo_share', { handler: runShareTool }],
75
+ ['hippo_peers', { handler: runPeersTool }],
79
76
  ]);
77
+ /** The tools listed and run under a store other than hippo.db. */
78
+ export const STORE_READY_TOOLS = new Set([...TOOL_HANDLERS].filter(([, entry]) => entry.storeReady).map(([name]) => name));
79
+ /** The served store's kind when it is not hippo.db, else null. */
80
+ function otherStoreKind(ctx) {
81
+ const kind = ctx?.store?.kind;
82
+ return kind !== undefined && kind !== 'sqlite' ? kind : null;
83
+ }
80
84
  async function executeTool(name, args, ctx) {
81
85
  // When a transport hands us a context (HTTP path), trust it: the HTTP
82
86
  // server already resolved hippoRoot from its bound opts and tenantId
@@ -89,13 +93,21 @@ async function executeTool(name, args, ctx) {
89
93
  // tenant-isolated. Resolved once per tool call: prefer the transport's
90
94
  // ctx.tenantId so an HTTP Bearer for tenant B doesn't drop to HIPPO_TENANT.
91
95
  const tenantId = ctx?.tenantId ?? resolveTenantId({});
92
- const handler = TOOL_HANDLERS.get(name);
96
+ const handler = TOOL_HANDLERS.get(name)?.handler;
93
97
  // handleMcpRequest rejects names missing from TOOLS, so reaching here means TOOLS and this table drifted apart.
94
98
  if (!handler)
95
99
  throw new Error(`hippo-mcp: tool ${name} is declared but has no handler`);
96
100
  return handler({ args, ctx, hippoRoot, config, tenantId });
97
101
  }
98
102
  // ── Request handling ──
103
+ // The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
104
+ function invalidArgs(id, toolName, problems) {
105
+ return {
106
+ jsonrpc: '2.0',
107
+ id,
108
+ result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
109
+ };
110
+ }
99
111
  /**
100
112
  * Transport-agnostic MCP dispatcher. Both the stdio loop (below) and the
101
113
  * HTTP/SSE transport in src/server.ts route every incoming JSON-RPC message
@@ -119,7 +131,7 @@ export async function handleMcpRequest(req, ctx) {
119
131
  case 'notifications/initialized':
120
132
  return null;
121
133
  case 'tools/list':
122
- return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
134
+ return { jsonrpc: '2.0', id, result: { tools: otherStoreKind(ctx) ? TOOLS.filter((t) => STORE_READY_TOOLS.has(t.name)) : TOOLS } };
123
135
  case 'tools/call': {
124
136
  const nameValue = params?.name;
125
137
  const toolName = isJsonString(nameValue) ? nameValue : '';
@@ -127,6 +139,10 @@ export async function handleMcpRequest(req, ctx) {
127
139
  if (!tool) {
128
140
  return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${toolName.slice(0, 128)}` } };
129
141
  }
142
+ // The same refusal a ported tool gives when it reaches hippo.db, so a client handles one shape.
143
+ if (otherStoreKind(ctx) && !STORE_READY_TOOLS.has(toolName)) {
144
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: STORE_NOT_PORTED_MESSAGE } };
145
+ }
130
146
  const refusal = sharedStoreRefusal(toolName, ctx);
131
147
  if (refusal !== undefined)
132
148
  return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: refusal }], isError: true } };
@@ -135,17 +151,23 @@ export async function handleMcpRequest(req, ctx) {
135
151
  return { jsonrpc: '2.0', id, error: { code: -32602, message: `${toolName}: arguments must be an object` } };
136
152
  }
137
153
  const toolArgs = isJsonObjectRecord(argumentsValue) ? argumentsValue : {};
138
- // The MCP spec reports input validation as a tool result with isError, so the model can read it and retry.
139
154
  const problems = validateToolArgs(tool.inputSchema, toolArgs, ARGS_CHECKED_BY_API.get(toolName));
140
- if (problems.length > 0) {
141
- return {
142
- jsonrpc: '2.0',
143
- id,
144
- result: { content: [{ type: 'text', text: `Invalid arguments for ${toolName}: ${problems.join('; ')}` }], isError: true },
145
- };
155
+ if (problems.length > 0)
156
+ return invalidArgs(id, toolName, problems);
157
+ let output;
158
+ try {
159
+ // One handle per store for the tool and its ledger row; stdio interleaves calls, so each gets its own scope.
160
+ output = await runWithRequestStores(async () => {
161
+ const text = await executeTool(toolName, toolArgs, ctx);
162
+ await recordMcpTokens(toolName, text, ctx);
163
+ return text;
164
+ });
165
+ }
166
+ catch (err) {
167
+ if (!(err instanceof RecallRequestError))
168
+ throw err;
169
+ return invalidArgs(id, toolName, [err.message]);
146
170
  }
147
- const output = await executeTool(toolName, toolArgs, ctx);
148
- recordMcpTokens(toolName, output, ctx);
149
171
  return {
150
172
  jsonrpc: '2.0',
151
173
  id,
@@ -1,6 +1,4 @@
1
- import type { RingBuffer } from '../recall-history.js';
2
- export declare const sessionRecallHistoryMcp: Map<string, RingBuffer>;
3
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
1
+ /** Test-only: reset the MCP recall rings. Call from beforeEach. */
4
2
  export declare function __resetSessionRecallHistoryMcp(): void;
5
3
  export declare const MAX_RECALL_CLIENTS = 4096;
6
4
  export declare const lastRecalledIds: Map<string, string[]>;
@@ -1,10 +1,8 @@
1
1
  // Per-process state the MCP tools share across calls: recall rings, last recalled ids, auto-sleep runs.
2
- // Per-(tenant, session) recall-history rings for the MCP pipeline, kept apart from
3
- // the CLI/HTTP rings: each pipeline owns its rings, with no IPC between them.
4
- export const sessionRecallHistoryMcp = new Map();
5
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
2
+ import { resetSessionRings } from '../api/recall-record.js';
3
+ /** Test-only: reset the MCP recall rings. Call from beforeEach. */
6
4
  export function __resetSessionRecallHistoryMcp() {
7
- sessionRecallHistoryMcp.clear();
5
+ resetSessionRings('mcp');
8
6
  }
9
7
  // ── Track last recalled IDs for outcome feedback ──
10
8
  //
@@ -1,3 +1,6 @@
1
+ import { type AppendAuditOpts } from '../audit.js';
2
+ import { type ForwardClaimMatch } from '../forward-claim-detector.js';
3
+ import { type PredictionBaserate } from './store.js';
1
4
  /**
2
5
  * Surface delivered on `RecallResult.planningFallacyHint` when an
3
6
  * agent's recall query carries a forward-prediction phrase AND the closest
@@ -66,6 +69,49 @@ export interface ComputePlanningFallacyHintOpts {
66
69
  * carry the right attribution instead of the 'cli' default. */
67
70
  actor?: string;
68
71
  }
72
+ export interface ClassResolution {
73
+ classTag: string | null;
74
+ /** True when ≥2 classes tied at the best overlap score AND best ≥ 1.
75
+ * Caller emits `recall_autodebias_hint_tiebreak` audit and returns
76
+ * null hint (silent — prevents the "show wrong class half the time"
77
+ * failure mode the alphabetical-tiebreak alternative would create). */
78
+ tiebreak: boolean;
79
+ }
80
+ /** The prediction class a forward claim resolves to, and that class's closed-prediction stats when one resolved. */
81
+ export interface PlanningFallacyEvidence extends ClassResolution {
82
+ readonly baserate: PredictionBaserate | null;
83
+ }
84
+ /** The output a claim earns from its evidence, and the audit row that records the decision when there is one. */
85
+ export interface PlanningFallacyDecision {
86
+ output: PlanningFallacyOutput;
87
+ audit?: AppendAuditOpts;
88
+ }
89
+ /**
90
+ * Resolve a query-token set to a unique best-matching class_tag for the
91
+ * tenant. Scores by lower-cased token overlap; requires best score ≥ 1
92
+ * AND strictly greater than the 2nd-best score.
93
+ *
94
+ * Indexed via idx_predictions_tenant_class (db.ts:1015) → O(log n) seek
95
+ * plus a small DISTINCT scan over the per-tenant class-tag set.
96
+ *
97
+ * Scope behaviour (deliberate): class_tag selection is TENANT-GLOBAL,
98
+ * NOT scope-filtered against
99
+ * the recall's opts.scope. The class_tag is an aggregator label across
100
+ * historical predictions in the class, not a per-memory scope-bound
101
+ * property. A no-scope recall CAN surface a class_tag from a privately-
102
+ * scoped prediction's class in PlanningFallacyHint.classTag — by design,
103
+ * because base-rate reasoning needs the full historical sample.
104
+ * Implications:
105
+ * - The hint payload itself carries no memory content (only the aggregate
106
+ * summary string + numeric stats), so memory bodies do not leak.
107
+ * - The class_tag NAME is the side-channel. If sensitive labels are a
108
+ * concern, callers should either use opaque class names (e.g. hashes
109
+ * or numeric tokens) or set HIPPO_AUTODEBIAS=off.
110
+ * - tests/api-recall-autodebias.test.ts locks this with an explicit
111
+ * test asserting that scope-set predictions surface via no-scope
112
+ * recalls (so future "fix" attempts that scope-filter trip CI).
113
+ */
114
+ export declare function resolveClassFromTokens(hippoRoot: string, tenantId: string, queryTokens: readonly string[]): ClassResolution;
69
115
  /**
70
116
  * Planning-fallacy orchestrator.
71
117
  *
@@ -92,4 +138,10 @@ export interface ComputePlanningFallacyHintOpts {
92
138
  * Latency budget: well under 50ms; a miss pays only the regex.
93
139
  */
94
140
  export declare function computePlanningFallacyOutput(hippoRoot: string, tenantId: string, queryText: string, opts?: ComputePlanningFallacyHintOpts): PlanningFallacyOutput;
141
+ /** The forward claim in a recall query; null when HIPPO_AUTODEBIAS=off, the query is empty or no claim matches. */
142
+ export declare function detectPlanningClaim(queryText: string, opts?: ComputePlanningFallacyHintOpts): ForwardClaimMatch | null;
143
+ /** Resolves a claim's tokens to one class and reads its baserate, writing no audit row. */
144
+ export declare function planningFallacyEvidenceAt(hippoRoot: string, tenantId: string, classQueryTokens: readonly string[]): PlanningFallacyEvidence;
145
+ /** The hint, the watching variant or nothing for a claim and its class evidence; reads and writes nothing itself. */
146
+ export declare function decidePlanningFallacy(match: ForwardClaimMatch, evidence: PlanningFallacyEvidence, tenantId: string, actor: string): PlanningFallacyDecision;
95
147
  //# sourceMappingURL=planning-fallacy.d.ts.map
@@ -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>;