hippo-memory 1.64.1 → 1.66.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 (66) 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 +8 -1
  7. package/dist/api/recall.d.ts +3 -2
  8. package/dist/api/recall.js +176 -183
  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 +13 -11
  21. package/dist/db/open.js +47 -119
  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 +16 -0
  25. package/dist/db/sqlite-blocked.js +29 -0
  26. package/dist/db.d.ts +3 -1
  27. package/dist/db.js +3 -1
  28. package/dist/embeddings.d.ts +11 -2
  29. package/dist/embeddings.js +22 -12
  30. package/dist/goals.d.ts +18 -11
  31. package/dist/goals.js +38 -36
  32. package/dist/http-util.d.ts +2 -0
  33. package/dist/http-util.js +5 -0
  34. package/dist/mcp/memory-tools.js +4 -1
  35. package/dist/mcp/recall-tools.js +28 -133
  36. package/dist/mcp/request.d.ts +7 -0
  37. package/dist/mcp/request.js +63 -41
  38. package/dist/mcp/session-state.d.ts +1 -3
  39. package/dist/mcp/session-state.js +3 -5
  40. package/dist/predictions/planning-fallacy.d.ts +52 -0
  41. package/dist/predictions/planning-fallacy.js +70 -55
  42. package/dist/quality-repair.js +3 -0
  43. package/dist/recall-trace.d.ts +1 -1
  44. package/dist/recall-trace.js +5 -4
  45. package/dist/search/hybrid.d.ts +3 -0
  46. package/dist/search/physics-search.d.ts +3 -0
  47. package/dist/search/physics-search.js +21 -19
  48. package/dist/search/vector.d.ts +7 -2
  49. package/dist/search/vector.js +29 -10
  50. package/dist/server/routes/recall.d.ts +1 -1
  51. package/dist/server/routes/recall.js +27 -218
  52. package/dist/server.d.ts +17 -1
  53. package/dist/server.js +38 -22
  54. package/dist/store/entry-writes.d.ts +2 -0
  55. package/dist/store/entry-writes.js +24 -16
  56. package/dist/store/open.js +2 -1
  57. package/dist/store-port.d.ts +88 -2
  58. package/dist/store-port.js +104 -9
  59. package/dist/vector-store.d.ts +8 -0
  60. package/dist/vector-store.js +31 -19
  61. package/dist/version.d.ts +1 -1
  62. package/dist/version.js +1 -1
  63. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  64. package/extensions/openclaw-plugin/package.json +1 -1
  65. package/openclaw.plugin.json +1 -1
  66. package/package.json +1 -1
@@ -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
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { appendAuditEvent } from './audit.js';
4
4
  import { withBackup } from './db/backup.js';
5
+ import { assertSqliteAllowed } from './db/open.js';
5
6
  import { DatabaseSync } from './db/sqlite.js';
6
7
  import { getMeta, setMeta } from './db/meta.js';
7
8
  import { tableColumns } from './db/tables.js';
@@ -187,6 +188,8 @@ function repairOn(db, root, opts) {
187
188
  return { ...result, warnings };
188
189
  }
189
190
  function openForRepair(root, readOnly, fn) {
191
+ // Opens without openHippoDb to skip migrations, so it takes the same refusal itself.
192
+ assertSqliteAllowed(root);
190
193
  const file = path.join(root, 'hippo.db');
191
194
  if (!fs.existsSync(file))
192
195
  throw new Error(`No existing Hippo database at ${file}`);
@@ -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);
@@ -4,6 +4,7 @@ import { type BM25Corpus } from './bm25.js';
4
4
  import { type HybridVectorCandidates } from './vector.js';
5
5
  import { type GraphStreamOptions } from './fusion.js';
6
6
  import { type ResultCost, type SearchResult } from './types.js';
7
+ import type { HippoStore } from '../store-port.js';
7
8
  export interface HybridSearchOptions {
8
9
  budget?: number;
9
10
  now?: Date;
@@ -40,6 +41,8 @@ export interface HybridSearchOptions {
40
41
  graphStream?: GraphStreamOptions;
41
42
  /** Add the rows nearest the query vector, not only rescore `entries`; without it a row no query word matches cannot surface. */
42
43
  vectorCandidates?: HybridVectorCandidates;
44
+ /** Where vectors are read; hippo.db under `hippoRoot` when unset. */
45
+ store?: HippoStore;
43
46
  }
44
47
  /** BM25 blended with cosine similarity when stored vectors and a provider are available, BM25 * strength * recency otherwise. */
45
48
  export declare function hybridSearch(query: string, entries: MemoryEntry[], options?: HybridSearchOptions): Promise<SearchResult[]>;
@@ -1,5 +1,6 @@
1
1
  import { type MemoryEntry } from '../memory.js';
2
2
  import { type PhysicsConfig } from '../physics-config.js';
3
+ import { type HippoStore } from '../store-port.js';
3
4
  import { type HybridVectorCandidates } from './vector.js';
4
5
  import { type ResultCost, type SearchResult } from './types.js';
5
6
  export interface PhysicsSearchOptions {
@@ -23,6 +24,8 @@ export interface PhysicsSearchOptions {
23
24
  summaryFreshness?: boolean;
24
25
  /** Same as hybridSearch's: rows nearest the query join the pool before physics scoring. */
25
26
  vectorCandidates?: HybridVectorCandidates;
27
+ /** Where vectors and particles are read; hippo.db under `hippoRoot` when unset. */
28
+ store?: HippoStore;
26
29
  }
27
30
  /** Scores memories by gravity, momentum and cluster amplification; rows without physics state rank through hybridSearch. */
28
31
  export declare function physicsSearch(query: string, entries: MemoryEntry[], options?: PhysicsSearchOptions): Promise<SearchResult[]>;
@@ -1,20 +1,20 @@
1
1
  import { calculateStrength } from '../memory.js';
2
2
  import { estimateTokens } from '../token-ledger.js';
3
3
  import { evalNow, isRecallBoostAblated } from '../ablation.js';
4
- import { embeddingModelRequiresReindex } from '../embeddings.js';
4
+ import { indexedModel, indexNeedsRebuild } from '../embeddings.js';
5
5
  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
- import { loadPhysicsState } from '../physics-state.js';
9
- import { openHippoDb, closeHippoDb } from '../db.js';
8
+ import { rethrowIfSqliteBlocked } from '../db.js';
10
9
  import { compareScoredResults } from '../compare.js';
11
10
  import { log } from '../log.js';
11
+ import { sqliteStore } from '../store-port.js';
12
12
  import { churnStaleFactor, summaryMultipliers, summaryScoring } from './boosts.js';
13
13
  import { addDagFields, ageInDays } from './breakdown.js';
14
14
  import { fitBudget } from './finalize.js';
15
15
  import { hybridSearch } from './hybrid.js';
16
16
  import { currentEntries } from './as-of.js';
17
- import { vectorCandidatesOutside } from './vector.js';
17
+ import { requireVectorReads, vectorCandidatesOutside } from './vector.js';
18
18
  import { DEFAULT_RECALL_BUDGET } from './types.js';
19
19
  /** Scores memories by gravity, momentum and cluster amplification; rows without physics state rank through hybridSearch. */
20
20
  export async function physicsSearch(query, entries, options = {}) {
@@ -27,11 +27,14 @@ export async function physicsSearch(query, entries, options = {}) {
27
27
  if (options.asOf)
28
28
  return hybridSearch(query, entries, options);
29
29
  const root = options.hippoRoot;
30
- const queryVector = await physicsQueryVector(query, root, options.queryEmbedding);
30
+ const store = options.store ?? sqliteStore(root);
31
+ const queryVector = await physicsQueryVector(query, root, store, options.queryEmbedding);
31
32
  if (!queryVector)
32
33
  return hybridSearch(query, entries, options);
33
- const pool = currentEntries(withVectorCandidates(root, entries, queryVector, options.vectorCandidates), options);
34
- const physicsMap = loadCandidateParticles(root, pool);
34
+ // Checked here too, since a caller's own query vector skips the check inside physicsQueryVector.
35
+ const reads = requireVectorReads(store);
36
+ const pool = currentEntries(await withVectorCandidates(reads, entries, queryVector, options.vectorCandidates), options);
37
+ const physicsMap = await loadCandidateParticles(reads, pool);
35
38
  if (!physicsMap)
36
39
  return hybridSearch(query, pool, options);
37
40
  const pools = splitByParticle(pool, physicsMap, queryVector, now);
@@ -45,45 +48,44 @@ export async function physicsSearch(query, entries, options = {}) {
45
48
  return fitBudget(merged, options.budget ?? DEFAULT_RECALL_BUDGET, options.minResults ?? 1, options.cost);
46
49
  }
47
50
  /** The caller's vector, else the provider's; null sends the caller to hybridSearch. */
48
- async function physicsQueryVector(query, root, given) {
51
+ async function physicsQueryVector(query, root, store, given) {
49
52
  if (given && given.length > 0)
50
53
  return given;
51
54
  // Physics scores against particle positions, not the stored vector index, so a pruned index must not block it.
52
55
  try {
53
56
  const provider = resolveEmbeddingProvider(root);
54
- if (!provider.isAvailable() || embeddingModelRequiresReindex(root, provider.id))
57
+ if (!provider.isAvailable())
58
+ return null;
59
+ if (indexNeedsRebuild(indexedModel(await requireVectorReads(store).embeddingIndexState()), provider.id))
55
60
  return null;
56
61
  const [vec] = await provider.embed([query], 'query');
57
62
  return vec && vec.length > 0 ? vec : null;
58
63
  }
59
64
  catch (err) {
65
+ rethrowIfSqliteBlocked(err);
60
66
  log.debug(`physics search: query embed failed, using hybrid: ${err instanceof Error ? err.message : String(err)}`);
61
67
  return null;
62
68
  }
63
69
  }
64
- function withVectorCandidates(root, entries, queryVector, spec) {
70
+ async function withVectorCandidates(reads, entries, queryVector, spec) {
65
71
  if (!spec)
66
72
  return entries;
67
73
  try {
68
- return [...entries, ...vectorCandidatesOutside(root, entries, queryVector, spec)];
74
+ return [...entries, ...await vectorCandidatesOutside(reads, entries, queryVector, spec)];
69
75
  }
70
76
  catch (err) {
77
+ rethrowIfSqliteBlocked(err);
71
78
  log.warn(`physics search ranked the lexical pool only; the vector lookup failed: ${err instanceof Error ? err.message : String(err)}`);
72
79
  return entries;
73
80
  }
74
81
  }
75
82
  /** Particles for the candidate rows only, so one tenant's search never reads every tenant's physics state. */
76
- function loadCandidateParticles(root, pool) {
83
+ async function loadCandidateParticles(reads, pool) {
77
84
  try {
78
- const db = openHippoDb(root);
79
- try {
80
- return loadPhysicsState(db, pool.map((e) => e.id));
81
- }
82
- finally {
83
- closeHippoDb(db);
84
- }
85
+ return await reads.physicsParticles(pool.map((e) => e.id));
85
86
  }
86
87
  catch (err) {
88
+ rethrowIfSqliteBlocked(err);
87
89
  log.debug(`physics search: state load failed, using hybrid: ${err instanceof Error ? err.message : String(err)}`);
88
90
  return null;
89
91
  }
@@ -1,5 +1,6 @@
1
1
  import type { MemoryEntry } from '../memory.js';
2
- import { type VectorCandidateSpec } from '../store/search-rows.js';
2
+ import type { VectorCandidateSpec } from '../store/search-rows.js';
3
+ import { type HippoStore, type VectorReads } from '../store-port.js';
3
4
  import { type CurrentnessOptions } from './as-of.js';
4
5
  /** hybridSearch's vector arm: which rows it may add, plus the caller's JS admission rules (exact private regex, entry filters). */
5
6
  export type HybridVectorCandidates = VectorCandidateSpec & {
@@ -16,9 +17,13 @@ export interface VectorArm {
16
17
  export interface VectorArmOptions extends CurrentnessOptions {
17
18
  hippoRoot?: string;
18
19
  vectorCandidates?: HybridVectorCandidates;
20
+ /** Where vectors are read; hippo.db under `hippoRoot` when unset. */
21
+ store?: HippoStore;
19
22
  }
23
+ /** The store's vector reads; a store without them answers 501, as any unported path does. */
24
+ export declare function requireVectorReads(store: HippoStore): VectorReads;
20
25
  /** The nearest admitted rows not already in `entries`. */
21
- export declare function vectorCandidatesOutside(hippoRoot: string, entries: readonly MemoryEntry[], queryVector: readonly number[], spec: HybridVectorCandidates): MemoryEntry[];
26
+ export declare function vectorCandidatesOutside(reads: VectorReads, entries: readonly MemoryEntry[], queryVector: readonly number[], spec: HybridVectorCandidates): Promise<MemoryEntry[]>;
22
27
  /** Embeds the query and loads stored vectors; any failure leaves BM25 to rank alone. */
23
28
  export declare function resolveVectorArm(query: string, entries: MemoryEntry[], options: VectorArmOptions): Promise<VectorArm>;
24
29
  export interface DenseScores {
@@ -1,17 +1,32 @@
1
- import { cosineSimilarity, embeddingModelRequiresReindex, hasEmbeddings, loadStoredVectors } from '../embeddings.js';
2
- import { loadVectorCandidateEntries } from '../store/search-rows.js';
1
+ import { cosineSimilarity, indexedModel, indexNeedsRebuild } from '../embeddings.js';
3
2
  import { resolveEmbeddingProvider } from '../embedding-provider.js';
3
+ import { rethrowIfSqliteBlocked } from '../db.js';
4
+ import { StoreNotPortedError } from '../db/sqlite-blocked.js';
4
5
  import { log } from '../log.js';
6
+ import { sqliteStore } from '../store-port.js';
5
7
  import { redactSecretsStrict } from '../secret-detect.js';
6
8
  import { currentEntries } from './as-of.js';
7
9
  // Search runs on every hook prompt, so one line per reason per process says why vectors went unused without flooding stderr.
8
10
  function warnBm25Fallback(reason, detail) {
9
11
  log.once(`search.bm25-fallback.${reason}`, 'warn', `hybrid search fell back to BM25 only: ${redactSecretsStrict(detail)}`);
10
12
  }
13
+ function reindexHint(storeKind) {
14
+ const why = 'the embedding index was built by another model or is being rebuilt';
15
+ // `hippo embed` writes only hippo.db, so another store gets its vectors by a rebuild from it.
16
+ return storeKind === 'sqlite'
17
+ ? `${why}; run 'hippo embed'`
18
+ : `${why}; run 'hippo embed' on the SQLite store, then rebuild the '${storeKind}' database from it`;
19
+ }
20
+ /** The store's vector reads; a store without them answers 501, as any unported path does. */
21
+ export function requireVectorReads(store) {
22
+ if (!store.vectors)
23
+ throw new StoreNotPortedError(store.kind, 'vectors');
24
+ return store.vectors;
25
+ }
11
26
  /** The nearest admitted rows not already in `entries`. */
12
- export function vectorCandidatesOutside(hippoRoot, entries, queryVector, spec) {
27
+ export async function vectorCandidatesOutside(reads, entries, queryVector, spec) {
13
28
  const inPool = new Set(entries.map((e) => e.id));
14
- return loadVectorCandidateEntries(hippoRoot, queryVector, spec).filter((e) => !inPool.has(e.id) && (spec.admit?.(e) ?? true));
29
+ return (await reads.nearestEntries(queryVector, spec)).filter((e) => !inPool.has(e.id) && (spec.admit?.(e) ?? true));
15
30
  }
16
31
  /** Embeds the query and loads stored vectors; any failure leaves BM25 to rank alone. */
17
32
  export async function resolveVectorArm(query, entries, options) {
@@ -22,6 +37,7 @@ export async function resolveVectorArm(query, entries, options) {
22
37
  await fillVectorArm(arm, query, options.hippoRoot, options);
23
38
  }
24
39
  catch (err) {
40
+ rethrowIfSqliteBlocked(err);
25
41
  warnBm25Fallback('error', err instanceof Error ? err.message : String(err));
26
42
  }
27
43
  return arm;
@@ -30,14 +46,17 @@ async function fillVectorArm(arm, query, root, options) {
30
46
  const provider = resolveEmbeddingProvider(root);
31
47
  if (!provider.isAvailable())
32
48
  return;
33
- if (embeddingModelRequiresReindex(root, provider.id)) {
34
- warnBm25Fallback('reindex', "the embedding index was built by another model or is being rebuilt; run 'hippo embed'");
49
+ const store = options.store ?? sqliteStore(root);
50
+ const reads = requireVectorReads(store);
51
+ const index = await reads.embeddingIndexState();
52
+ if (indexNeedsRebuild(indexedModel(index), provider.id)) {
53
+ warnBm25Fallback('reindex', reindexHint(store.kind));
35
54
  return;
36
55
  }
37
56
  const spec = options.vectorCandidates;
38
- const vectors = loadStoredVectors(root, arm.entries.map((e) => e.id));
57
+ const vectors = await reads.storedVectors(arm.entries.map((e) => e.id));
39
58
  // Only spend a (possibly paid, off-box) query embedding when there is a stored vector this search can use.
40
- if (vectors.size === 0 && !(spec !== undefined && hasEmbeddings(root)))
59
+ if (vectors.size === 0 && !(spec !== undefined && index.hasVectors))
41
60
  return;
42
61
  const [vec] = await provider.embed([query], 'query');
43
62
  arm.queryVector = vec ?? [];
@@ -45,11 +64,11 @@ async function fillVectorArm(arm, query, root, options) {
45
64
  warnBm25Fallback('empty-query-vector', 'the embedding provider returned no vector for the query');
46
65
  return;
47
66
  }
48
- const added = spec ? vectorCandidatesOutside(root, arm.entries, arm.queryVector, spec) : [];
67
+ const added = spec ? await vectorCandidatesOutside(reads, arm.entries, arm.queryVector, spec) : [];
49
68
  if (added.length > 0) {
50
69
  arm.addedRows = true;
51
70
  arm.entries = currentEntries([...arm.entries, ...added], options);
52
- for (const [id, v] of loadStoredVectors(root, added.map((e) => e.id)))
71
+ for (const [id, v] of await reads.storedVectors(added.map((e) => e.id)))
53
72
  vectors.set(id, v);
54
73
  }
55
74
  arm.embeddingIndex = Object.fromEntries(vectors);
@@ -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>;