hippo-memory 1.72.0 → 2.0.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 (259) hide show
  1. package/README.md +6 -4
  2. package/dist/agent-memories/apply.js +5 -5
  3. package/dist/api/assemble.js +2 -1
  4. package/dist/api/audit.d.ts +7 -7
  5. package/dist/api/audit.js +19 -17
  6. package/dist/api/auth.d.ts +5 -6
  7. package/dist/api/auth.js +28 -33
  8. package/dist/api/conflicts.js +1 -1
  9. package/dist/api/context-auto.js +4 -2
  10. package/dist/api/context-select.js +8 -20
  11. package/dist/api/context.js +12 -10
  12. package/dist/api/drill-down.js +2 -1
  13. package/dist/api/forget.d.ts +2 -2
  14. package/dist/api/forget.js +6 -7
  15. package/dist/api/graph.js +2 -1
  16. package/dist/api/learn.d.ts +1 -1
  17. package/dist/api/learn.js +45 -44
  18. package/dist/api/memories.d.ts +4 -2
  19. package/dist/api/memories.js +16 -5
  20. package/dist/api/objects.js +2 -1
  21. package/dist/api/outcome.d.ts +5 -5
  22. package/dist/api/outcome.js +16 -15
  23. package/dist/api/predictions.js +2 -1
  24. package/dist/api/promote.d.ts +3 -3
  25. package/dist/api/promote.js +25 -27
  26. package/dist/api/prompt-hook.d.ts +3 -1
  27. package/dist/api/prompt-hook.js +1 -0
  28. package/dist/api/quarantine.js +2 -1
  29. package/dist/api/recall-core.js +8 -3
  30. package/dist/api/recall-pipeline.js +17 -17
  31. package/dist/api/recall-types.d.ts +3 -1
  32. package/dist/api/recall.js +32 -29
  33. package/dist/api/remember-local.d.ts +5 -2
  34. package/dist/api/remember-local.js +14 -6
  35. package/dist/api/remember.d.ts +5 -2
  36. package/dist/api/remember.js +16 -14
  37. package/dist/api/session-end.d.ts +24 -0
  38. package/dist/api/session-end.js +31 -1
  39. package/dist/api/sleep-run.js +5 -4
  40. package/dist/api/sleep.js +4 -3
  41. package/dist/api/tokens.js +1 -1
  42. package/dist/api/types.d.ts +1 -7
  43. package/dist/capture/command.js +1 -1
  44. package/dist/capture/compaction-spool.d.ts +3 -5
  45. package/dist/capture/compaction-spool.js +41 -46
  46. package/dist/capture/session-end-caller.js +1 -1
  47. package/dist/capture/session-worker.js +19 -6
  48. package/dist/cli/audit.js +10 -10
  49. package/dist/cli/auth.js +4 -4
  50. package/dist/cli/client.d.ts +1 -2
  51. package/dist/cli/client.js +13 -13
  52. package/dist/cli/context.js +1 -1
  53. package/dist/cli/curate.d.ts +1 -1
  54. package/dist/cli/curate.js +9 -9
  55. package/dist/cli/decisions.js +4 -5
  56. package/dist/cli/hook-runtime.d.ts +3 -1
  57. package/dist/cli/hook-runtime.js +2 -2
  58. package/dist/cli/init.d.ts +1 -1
  59. package/dist/cli/init.js +9 -9
  60. package/dist/cli/object-verbs.d.ts +2 -2
  61. package/dist/cli/object-verbs.js +4 -4
  62. package/dist/cli/predictions.js +2 -3
  63. package/dist/cli/quality-repair-once.d.ts +1 -1
  64. package/dist/cli/quality-repair-once.js +2 -2
  65. package/dist/cli/refine-llm.js +2 -1
  66. package/dist/cli/remember.js +5 -5
  67. package/dist/cli/serve.js +9 -4
  68. package/dist/cli/session-hooks.d.ts +0 -4
  69. package/dist/cli/session-hooks.js +57 -66
  70. package/dist/cli/setup.d.ts +1 -1
  71. package/dist/cli/setup.js +2 -2
  72. package/dist/cli/shared.d.ts +1 -1
  73. package/dist/cli/shared.js +2 -2
  74. package/dist/cli/sleep.js +2 -2
  75. package/dist/cli/transfer.d.ts +3 -3
  76. package/dist/cli/transfer.js +34 -15
  77. package/dist/cli/usage.d.ts +1 -1
  78. package/dist/cli/usage.js +1 -1
  79. package/dist/cli/verb-row.d.ts +2 -0
  80. package/dist/cli/verbs/health.js +2 -0
  81. package/dist/cli/verbs/memory.js +43 -51
  82. package/dist/cli/verbs/objects.js +6 -3
  83. package/dist/cli/verbs/sessions.js +4 -2
  84. package/dist/cli.js +1 -2
  85. package/dist/connectors/dlq.js +2 -1
  86. package/dist/connectors/github/deletion.js +2 -1
  87. package/dist/connectors/github/ingest.js +2 -1
  88. package/dist/connectors/github/tenant-routing.js +2 -1
  89. package/dist/connectors/github/webhook.js +4 -2
  90. package/dist/connectors/ingest.js +2 -1
  91. package/dist/connectors/slack/deletion.js +2 -1
  92. package/dist/connectors/slack/ingest.js +2 -1
  93. package/dist/connectors/slack/tenant-routing.js +2 -1
  94. package/dist/consolidate/conflicts.js +1 -1
  95. package/dist/consolidate/dag.js +14 -9
  96. package/dist/consolidate/dedupe.js +1 -1
  97. package/dist/consolidate/memory-value.js +1 -1
  98. package/dist/consolidate/merge.js +6 -6
  99. package/dist/consolidate/sleep.js +1 -1
  100. package/dist/consolidate/traces.js +2 -2
  101. package/dist/core/config.js +15 -16
  102. package/dist/core/markdown.js +2 -2
  103. package/dist/core/memory.d.ts +4 -4
  104. package/dist/core/memory.js +10 -5
  105. package/dist/core/physics.d.ts +3 -1
  106. package/dist/core/physics.js +17 -2
  107. package/dist/core/project-identity.js +2 -1
  108. package/dist/core/search-types.d.ts +1 -1
  109. package/dist/dashboard/dashboard-actions.d.ts +2 -2
  110. package/dist/dashboard/dashboard-actions.js +5 -5
  111. package/dist/dashboard/dashboard-queries.js +2 -7
  112. package/dist/dashboard/dashboard-snapshot.js +1 -3
  113. package/dist/dashboard/dashboard.js +16 -5
  114. package/dist/db/connect.d.ts +2 -1
  115. package/dist/db/connect.js +2 -1
  116. package/dist/db/migrate.js +14 -7
  117. package/dist/db/migrations/v11.js +1 -0
  118. package/dist/db/migrations/v14.js +1 -0
  119. package/dist/db/physics-state.d.ts +0 -4
  120. package/dist/db/physics-state.js +5 -31
  121. package/dist/db/request-stores.d.ts +1 -0
  122. package/dist/db/request-stores.js +2 -1
  123. package/dist/db/vector-store.js +2 -1
  124. package/dist/doctor.d.ts +3 -0
  125. package/dist/doctor.js +7 -7
  126. package/dist/embeddings/local.js +2 -2
  127. package/dist/embeddings/provider.js +1 -10
  128. package/dist/entry/store-kit.d.ts +37 -0
  129. package/dist/entry/store-kit.js +25 -0
  130. package/dist/entry/testing.d.ts +6 -0
  131. package/dist/entry/testing.js +7 -0
  132. package/dist/eval/judgment.js +1 -1
  133. package/dist/hooks/json-hooks.js +6 -2
  134. package/dist/importers/core.js +3 -3
  135. package/dist/importers/vault.d.ts +1 -1
  136. package/dist/importers/vault.js +18 -18
  137. package/dist/index.d.ts +2 -2
  138. package/dist/index.js +1 -1
  139. package/dist/learn/autolearn.d.ts +6 -2
  140. package/dist/learn/autolearn.js +8 -6
  141. package/dist/learn/extract.js +1 -1
  142. package/dist/learn/invalidation.d.ts +2 -1
  143. package/dist/learn/invalidation.js +10 -5
  144. package/dist/mcp/memory-tools.d.ts +1 -1
  145. package/dist/mcp/memory-tools.js +6 -9
  146. package/dist/mcp/request.js +20 -19
  147. package/dist/mcp/stdio.js +13 -8
  148. package/dist/rerankers/cross-encoder.js +10 -3
  149. package/dist/search/embed-memories.d.ts +5 -2
  150. package/dist/search/embed-memories.js +40 -28
  151. package/dist/search/engine.d.ts +8 -0
  152. package/dist/search/engine.js +10 -0
  153. package/dist/search/vector.js +5 -5
  154. package/dist/server/auth.d.ts +1 -1
  155. package/dist/server/auth.js +7 -8
  156. package/dist/server/boot.d.ts +2 -1
  157. package/dist/server/boot.js +28 -19
  158. package/dist/server/client-ip.d.ts +9 -4
  159. package/dist/server/client-ip.js +35 -17
  160. package/dist/server/route-table.js +5 -6
  161. package/dist/server/routes/memories.js +4 -2
  162. package/dist/server/sleep-offload.js +3 -3
  163. package/dist/server/tls.d.ts +7 -2
  164. package/dist/server/tls.js +17 -5
  165. package/dist/server/types.d.ts +7 -2
  166. package/dist/server.d.ts +4 -41
  167. package/dist/server.js +3 -30
  168. package/dist/sharing/global-store.d.ts +4 -5
  169. package/dist/sharing/global-store.js +6 -11
  170. package/dist/sharing/global-sync.js +1 -1
  171. package/dist/sharing/search-both.js +9 -2
  172. package/dist/sharing/share.js +2 -4
  173. package/dist/store/ambient.js +2 -1
  174. package/dist/store/candidates.js +18 -8
  175. package/dist/store/cards.js +5 -4
  176. package/dist/store/compactions-record.js +8 -7
  177. package/dist/store/conflicts.d.ts +7 -1
  178. package/dist/store/conflicts.js +6 -5
  179. package/dist/store/connectors/dlq.js +2 -1
  180. package/dist/store/connectors/event-log.d.ts +13 -0
  181. package/dist/store/connectors/event-log.js +24 -0
  182. package/dist/store/connectors/github.js +6 -8
  183. package/dist/store/connectors/slack.js +6 -10
  184. package/dist/store/dashboard-reads.js +1 -0
  185. package/dist/store/delete-and-batch.js +5 -4
  186. package/dist/store/delivery-recorder.d.ts +2 -2
  187. package/dist/store/delivery-recorder.js +1 -6
  188. package/dist/store/descendant-page.js +2 -1
  189. package/dist/store/dormant.js +7 -3
  190. package/dist/store/entry-reads.d.ts +25 -3
  191. package/dist/store/entry-reads.js +43 -12
  192. package/dist/store/entry-row.js +9 -2
  193. package/dist/store/entry-writes.d.ts +10 -7
  194. package/dist/store/entry-writes.js +23 -11
  195. package/dist/store/goals.js +3 -2
  196. package/dist/store/graph-reads.js +5 -4
  197. package/dist/store/graph-view-rows.js +2 -1
  198. package/dist/store/half-life.js +2 -2
  199. package/dist/store/handoffs.d.ts +9 -3
  200. package/dist/store/handoffs.js +4 -3
  201. package/dist/store/index.d.ts +2 -2
  202. package/dist/store/index.js +1 -1
  203. package/dist/store/mirrors.js +9 -4
  204. package/dist/store/open.d.ts +2 -1
  205. package/dist/store/open.js +2 -1
  206. package/dist/store/port.d.ts +59 -3
  207. package/dist/store/quality-repair.js +1 -1
  208. package/dist/store/recall-trace.js +2 -1
  209. package/dist/store/rejected-values.js +14 -9
  210. package/dist/store/rows.js +2 -2
  211. package/dist/store/rule-sql.d.ts +2 -0
  212. package/dist/store/rule-sql.js +12 -6
  213. package/dist/store/search-rows.d.ts +5 -5
  214. package/dist/store/search-rows.js +9 -12
  215. package/dist/store/set-aside.js +1 -1
  216. package/dist/store/sqlite/connector-writes-group.js +3 -1
  217. package/dist/store/sqlite/entry-writes-group.d.ts +3 -3
  218. package/dist/store/sqlite/entry-writes-group.js +1 -1
  219. package/dist/store/sqlite/held-connection.d.ts +2 -0
  220. package/dist/store/sqlite/held-connection.js +1 -1
  221. package/dist/store/sqlite/last-recall-group.d.ts +13 -0
  222. package/dist/store/sqlite/last-recall-group.js +57 -0
  223. package/dist/store/sqlite/local.d.ts +0 -17
  224. package/dist/store/sqlite/local.js +0 -31
  225. package/dist/store/sqlite/maintenance-groups.d.ts +6 -0
  226. package/dist/store/sqlite/maintenance-groups.js +60 -0
  227. package/dist/store/sqlite/objects-group.js +14 -6
  228. package/dist/store/sqlite/store.d.ts +1 -8
  229. package/dist/store/sqlite/store.js +13 -24
  230. package/dist/store/summaries.d.ts +7 -0
  231. package/dist/store/summaries.js +49 -7
  232. package/dist/store/token-ledger-rows.js +3 -2
  233. package/dist/store/vector-index.js +5 -2
  234. package/dist/util/anthropic-messages.d.ts +3 -1
  235. package/dist/util/anthropic-messages.js +4 -2
  236. package/dist/util/atomic-write.d.ts +5 -2
  237. package/dist/util/atomic-write.js +12 -8
  238. package/dist/util/env.d.ts +10 -1
  239. package/dist/util/env.js +10 -1
  240. package/dist/util/format.d.ts +2 -0
  241. package/dist/util/format.js +4 -0
  242. package/dist/util/request-scope.d.ts +6 -2
  243. package/dist/util/request-scope.js +7 -3
  244. package/dist/util/spawn-argv.d.ts +9 -0
  245. package/dist/util/spawn-argv.js +57 -0
  246. package/dist/util/sql.d.ts +3 -0
  247. package/dist/util/sql.js +6 -0
  248. package/dist/util/vector-codec.d.ts +4 -0
  249. package/dist/util/vector-codec.js +15 -0
  250. package/dist/util/version.d.ts +1 -1
  251. package/dist/util/version.js +1 -1
  252. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  253. package/extensions/openclaw-plugin/package.json +1 -1
  254. package/openclaw.plugin.json +1 -1
  255. package/package.json +3 -1
  256. package/dist/api/on-store.d.ts +0 -16
  257. package/dist/api/on-store.js +0 -38
  258. package/dist/cli/lenient-id.d.ts +0 -2
  259. package/dist/cli/lenient-id.js +0 -13
@@ -2,6 +2,7 @@ import { strengthAblation } from '../core/ablation.js';
2
2
  import { markRetrieved } from '../core/memory.js';
3
3
  import { closeHippoDb, openHippoDb, withWriteScope } from '../db/index.js';
4
4
  import { rethrowIfSqliteBlocked } from '../util/sqlite-blocked.js';
5
+ import { chunked } from '../util/chunked.js';
5
6
  import { RejectedValueError } from '../core/api-errors.js';
6
7
  import { markSummaryDirtyInTx } from './summary-dirty.js';
7
8
  import { errorMessage, log } from '../util/log.js';
@@ -10,9 +11,13 @@ import { selectEntriesByIds } from './entry-reads.js';
10
11
  import { stampOriginProject, upsertEntryRow, syncFtsRow, deleteFtsRow } from './entry-row.js';
11
12
  import { mirrorBestEffort, writeMarkdownMirror } from './mirrors.js';
12
13
  import { onHandle, openStore } from './open.js';
13
- /** Write an entry to SQLite and refresh mirrors; `opts.afterWrite` runs in the same SAVEPOINT as the INSERT, so a throw rolls the row back.
14
- * `opts.actor` defaults to 'cli'; the api layer passes the resolved actor so the audit log gets one row per write. */
14
+ /** Write an entry to SQLite and refresh mirrors. `opts.actor` defaults to 'cli'; the api layer passes the resolved actor,
15
+ * so the audit log gets one row per write. */
15
16
  export function writeEntry(hippoRoot, entry, opts) {
17
+ writeEntryWithHook(hippoRoot, entry, opts);
18
+ }
19
+ /** writeEntry for store code that needs the write's own handle: `opts.afterWrite` shares the INSERT's SAVEPOINT. */
20
+ export function writeEntryWithHook(hippoRoot, entry, opts) {
16
21
  onHandle(hippoRoot, (db) => {
17
22
  writeEntryOn(db, hippoRoot, entry, opts);
18
23
  }, openStore);
@@ -117,11 +122,11 @@ export function auditEntryWrite(db, entry, actor) {
117
122
  audit(db, 'remember', { targetId: entry.id, metadata: {
118
123
  kind: entry.kind ?? 'distilled',
119
124
  scope: entry.scope ?? null,
120
- }, actor, tenantId: entry.tenantId });
125
+ }, actor, tenantId: entry.tenant_id });
121
126
  // A child write marks its summary parent dirty for the sleep-cycle rebuild; most writes
122
127
  // have no parent, so the hot path pays one null check.
123
128
  if (entry.dag_parent_id) {
124
- markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, actor);
129
+ markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenant_id, actor);
125
130
  }
126
131
  }
127
132
  /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
@@ -164,6 +169,8 @@ export function strengthenRetrievedInOwnTx(db, ids, opts) {
164
169
  function warnStrengthenFailed(error) {
165
170
  log.warn(`retrieval stats not saved (${errorMessage(error)})`);
166
171
  }
172
+ /** Rows per recall-write statement: five bound values each, far under SQLite's parameter limit. */
173
+ const RETRIEVAL_UPDATE_CHUNK = 100;
167
174
  /** strengthenRetrieved on the caller's handle, inside the caller's transaction. Throws; the caller decides. */
168
175
  export function strengthenRetrievedOn(db, ids, opts) {
169
176
  const found = new Set();
@@ -171,25 +178,30 @@ export function strengthenRetrievedOn(db, ids, opts) {
171
178
  return found;
172
179
  const byId = selectEntriesByIds(db, ids, opts.tenantId);
173
180
  const live = [...new Set(ids)].flatMap((id) => byId.get(id) ?? []);
174
- const update = db.prepare('UPDATE memories SET retrieval_count = ?, last_retrieved = ?, half_life_days = ?, strength = ? WHERE id = ?');
175
- for (const e of markRetrieved(live, undefined, { ablation: strengthAblation() })) {
176
- update.run(e.retrieval_count, e.last_retrieved, e.half_life_days, e.strength, e.id);
177
- found.add(e.id);
181
+ const updated = markRetrieved(live, undefined, { ablation: strengthAblation() });
182
+ // One UPDATE ... FROM per chunk of rows instead of one statement per row.
183
+ for (const chunk of chunked(updated, RETRIEVAL_UPDATE_CHUNK)) {
184
+ const values = chunk.map(() => '(?, ?, ?, ?, ?)').join(', ');
185
+ db.prepare(`WITH v(id, rc, lr, hl, st) AS (VALUES ${values})
186
+ UPDATE memories SET retrieval_count = v.rc, last_retrieved = v.lr, half_life_days = v.hl, strength = v.st
187
+ FROM v WHERE memories.id = v.id`).run(...chunk.flatMap((e) => [e.id, e.retrieval_count, e.last_retrieved, e.half_life_days, e.strength]));
188
+ for (const e of chunk)
189
+ found.add(e.id);
178
190
  }
179
191
  return found;
180
192
  }
181
193
  /** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
182
194
  export function setEntryTagsInTx(db, entry) {
183
195
  db.prepare(`UPDATE memories SET tags_json = ?, updated_at = datetime('now') WHERE id = ? AND tenant_id = ?`)
184
- .run(JSON.stringify(entry.tags), entry.id, entry.tenantId);
196
+ .run(JSON.stringify(entry.tags), entry.id, entry.tenant_id);
185
197
  syncFtsRow(db, entry);
186
198
  }
187
199
  /** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
188
200
  export function deleteEntryRowInTx(db, entry, actor) {
189
- db.prepare('DELETE FROM memories WHERE id = ? AND tenant_id = ?').run(entry.id, entry.tenantId);
201
+ db.prepare('DELETE FROM memories WHERE id = ? AND tenant_id = ?').run(entry.id, entry.tenant_id);
190
202
  deleteFtsRow(db, entry.id);
191
203
  if (entry.dag_parent_id)
192
- markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, actor);
204
+ markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenant_id, actor);
193
205
  }
194
206
  /** One half-life per memory id, on the caller's transaction. */
195
207
  export function setHalfLivesAt(db, rows) {
@@ -2,6 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { withWriteScope } from '../db/index.js';
4
4
  import { onHandle } from './open.js';
5
+ import { placeholders } from '../util/sql.js';
5
6
  const MAX_ACTIVE_GOAL_DEPTH = 3;
6
7
  export function rowToGoal(row) {
7
8
  return {
@@ -152,7 +153,7 @@ function loadGoalPolicies(db, active) {
152
153
  // SAFETY: the SELECT projects exactly these seven retrieval_policy columns; MAX_ACTIVE_GOAL_DEPTH bounds the IN list.
153
154
  const rows = db.prepare(`
154
155
  SELECT id, goal_id, policy_type, weight_schema_fit, weight_recency, weight_outcome, error_priority
155
- FROM retrieval_policy WHERE id IN (${policyIds.map(() => '?').join(',')})
156
+ FROM retrieval_policy WHERE id IN (${placeholders(policyIds.length)})
156
157
  `).all(...policyIds);
157
158
  const byId = new Map(rows.map((row) => [row.id, row]));
158
159
  for (const g of active) {
@@ -178,7 +179,7 @@ export function localGoalRecallRows(db, rows) {
178
179
  return [];
179
180
  const ids = [...new Set(rows.map((r) => r.memoryId))];
180
181
  // SAFETY: the SELECT projects exactly one column, `id`, from memories.
181
- const local = db.prepare(`SELECT id FROM memories WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
182
+ const local = db.prepare(`SELECT id FROM memories WHERE id IN (${placeholders(ids.length)})`).all(...ids);
182
183
  const localIds = new Set(local.map((r) => r.id));
183
184
  return rows.filter((r) => localIds.has(r.memoryId));
184
185
  }
@@ -4,6 +4,7 @@ import { openHippoDb, closeHippoDb, withReadSnapshot } from '../db/index.js';
4
4
  import { onHandle } from './open.js';
5
5
  import { assertTenantId } from './tenant.js';
6
6
  import { GRAPH_ENTITY_TYPES, rowToEntity, rowToRelation, ENTITY_COLS, RELATION_COLS } from './graph-rows.js';
7
+ import { placeholders } from '../util/sql.js';
7
8
  const DEFAULT_GRAPH_PAGE_SIZE = DEFAULT_LIST_LIMIT;
8
9
  const DEFAULT_GRAPH_SCAN_LIMIT = 1000;
9
10
  /** Entities with an exact `name`, bounded by `limit` in SQL with a deterministic order, so graph-view focus finds `--entity NAME` directly
@@ -100,7 +101,7 @@ export function loadEntitiesByIds(hippoRoot, tenantId, ids, txDb) {
100
101
  const out = [];
101
102
  for (let i = 0; i < ids.length; i += IN_LIST_CHUNK) {
102
103
  const slice = ids.slice(i, i + IN_LIST_CHUNK);
103
- const ph = slice.map(() => '?').join(',');
104
+ const ph = placeholders(slice.length);
104
105
  // SAFETY: rows' shape matches the columns named in ENTITY_COLS above.
105
106
  const rows = db.prepare(`
106
107
  SELECT ${ENTITY_COLS} FROM entities
@@ -133,7 +134,7 @@ export function loadNeighborRelations(hippoRoot, tenantId, entityIds, opts = {},
133
134
  const byId = new Map();
134
135
  for (let i = 0; i < entityIds.length; i += IN_LIST_CHUNK) {
135
136
  const slice = entityIds.slice(i, i + IN_LIST_CHUNK);
136
- const ph = slice.map(() => '?').join(',');
137
+ const ph = placeholders(slice.length);
137
138
  // SAFETY: rows' shape matches the columns named in RELATION_COLS above.
138
139
  const rows = db.prepare(`
139
140
  SELECT ${RELATION_COLS} FROM relations
@@ -164,7 +165,7 @@ export function loadRelationsAmong(hippoRoot, tenantId, entityIds, opts = {}, tx
164
165
  const ownDb = txDb ? null : openHippoDb(hippoRoot);
165
166
  const db = txDb ?? ownDb;
166
167
  try {
167
- const ph = entityIds.map(() => '?').join(',');
168
+ const ph = placeholders(entityIds.length);
168
169
  // SAFETY: rows' shape matches the columns named in RELATION_COLS above.
169
170
  const rows = db.prepare(`
170
171
  SELECT ${RELATION_COLS} FROM relations
@@ -189,7 +190,7 @@ export function loadEntitiesByMemoryId(hippoRoot, tenantId, memoryIds) {
189
190
  const out = [];
190
191
  for (let i = 0; i < memoryIds.length; i += IN_LIST_CHUNK) {
191
192
  const slice = memoryIds.slice(i, i + IN_LIST_CHUNK);
192
- const ph = slice.map(() => '?').join(',');
193
+ const ph = placeholders(slice.length);
193
194
  // id ASC so chunk-local scan order never decides ties (id is an autoincrement PK).
194
195
  // SAFETY: rows' shape matches the columns named in ENTITY_COLS above.
195
196
  const rows = db.prepare(`
@@ -1,4 +1,5 @@
1
1
  import { IN_LIST_CHUNK, loadEntities, loadEntitiesByIds, loadEntitiesByName, loadNeighborRelations, loadRelations, loadRelationsAmong } from './graph-reads.js';
2
+ import { placeholders } from '../util/sql.js';
2
3
  /** The items whose source memory `canRead` admits: a NULL memoryId has no scope to hide, and a missing memory fails closed. */
3
4
  function readableOnly(db, tenantId, items, canRead) {
4
5
  if (!canRead)
@@ -8,7 +9,7 @@ function readableOnly(db, tenantId, items, canRead) {
8
9
  for (let i = 0; i < ids.length; i += IN_LIST_CHUNK) {
9
10
  const slice = ids.slice(i, i + IN_LIST_CHUNK);
10
11
  // SAFETY: rows' shape matches the id and scope columns named in the SELECT.
11
- const rows = db.prepare(`SELECT id, scope FROM memories WHERE +tenant_id = ? AND id IN (${slice.map(() => '?').join(',')})`)
12
+ const rows = db.prepare(`SELECT id, scope FROM memories WHERE +tenant_id = ? AND id IN (${placeholders(slice.length)})`)
12
13
  .all(tenantId, ...slice);
13
14
  for (const r of rows)
14
15
  if (canRead(r.scope))
@@ -34,9 +34,9 @@ function writeMove(db, move, old, actor) {
34
34
  const byTenant = new Map();
35
35
  for (const e of move.entries) {
36
36
  // One record per tenant, filled in place: copying it per row made the write grow with the square of the store.
37
- let record = byTenant.get(e.tenantId);
37
+ let record = byTenant.get(e.tenant_id);
38
38
  if (!record)
39
- byTenant.set(e.tenantId, (record = {}));
39
+ byTenant.set(e.tenant_id, (record = {}));
40
40
  record[e.id] = old.get(e.id);
41
41
  }
42
42
  for (const [tenantId, oldHalfLives] of byTenant) {
@@ -17,10 +17,16 @@ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, s
17
17
  export declare function loadHandoffById(hippoRoot: string, tenantId: string, id: number): SessionHandoff | null;
18
18
  /** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */
19
19
  export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
20
+ export interface SessionEndHandoffRequest {
21
+ readonly tenantId: string;
22
+ readonly sessionId: string;
23
+ readonly evidence: HandoffEvidence | null;
24
+ readonly derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null;
25
+ readonly key?: ContinuityKey;
26
+ readonly inPlace?: boolean;
27
+ }
20
28
  /** Session-end handoff from the active snapshot, else `derived` transcript state; `inPlace` rewrites an earlier transcript read instead of adding a revision.
21
29
  * Returns null when no source is the session's, a newer handoff covers it, or the latest one is not transcript-derived or already holds `derived`. */
22
- export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null, key?: ContinuityKey, options?: {
23
- inPlace?: boolean;
24
- }): SessionHandoff | null;
30
+ export declare function writeSessionEndHandoff(hippoRoot: string, request: SessionEndHandoffRequest): SessionHandoff | null;
25
31
  export {};
26
32
  //# sourceMappingURL=handoffs.d.ts.map
@@ -169,7 +169,8 @@ function buildSessionEndHandoff(sessionId, snapshot, existing, handoffEvidence,
169
169
  }
170
170
  /** Session-end handoff from the active snapshot, else `derived` transcript state; `inPlace` rewrites an earlier transcript read instead of adding a revision.
171
171
  * Returns null when no source is the session's, a newer handoff covers it, or the latest one is not transcript-derived or already holds `derived`. */
172
- export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null, key, options = {}) {
172
+ export function writeSessionEndHandoff(hippoRoot, request) {
173
+ const { tenantId, sessionId, evidence, derived = null, key, inPlace } = request;
173
174
  assertTenantId('writeSessionEndHandoff', tenantId);
174
175
  if (key)
175
176
  continuityStamp(key); // a partial key is a caller bug, so fail loud rather than write nothing
@@ -179,7 +180,7 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
179
180
  let handoffEvidence = evidence;
180
181
  let fromTranscript = false;
181
182
  // After every reply the transcript is newer than the last compaction's snapshot, which the open chat keeps for the next one.
182
- const snapshotGivesWay = options.inPlace === true && derived !== null && active?.source === 'pre-compact';
183
+ const snapshotGivesWay = inPlace === true && derived !== null && active?.source === 'pre-compact';
183
184
  if (active && active.session_id === sessionId && !snapshotGivesWay) {
184
185
  // Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
185
186
  if (existing && existing.updatedAt > active.updated_at)
@@ -197,7 +198,7 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
197
198
  const outcome = sessionOutcome(hippoRoot, tenantId, sessionId);
198
199
  const handoff = buildSessionEndHandoff(sessionId, snapshot, existing, handoffEvidence, outcome);
199
200
  // A miss means a handoff landed since the read, and a new transcript row would hide it.
200
- if (fromTranscript && options.inPlace && existing)
201
+ if (fromTranscript && inPlace && existing)
201
202
  return replaceTranscriptHandoff(hippoRoot, tenantId, handoff, key);
202
203
  return saveSessionHandoff(hippoRoot, tenantId, handoff, key);
203
204
  }
@@ -1,4 +1,4 @@
1
- export type { AmbientCandidateRequest, ContextReads, EntryRemoval, EntryTarget, EntryWrite, EntryWrites, HippoStore, KeyAudit, KeyListQuery, KeyMint, KeyRevoke, KeyWrites, OutcomeWrite, RawArchive, RecallSearchArgs, RecallWrites, SelfKeyMint, StoreGroup, StoreGroups, SupersedeWrite, VectorBackfillQuery, VectorReads, VectorRowWrite, VectorWrite, VectorWriteResult, VectorWrites, } from './port.js';
1
+ export type { AmbientCandidateRequest, ContextReads, EntryRemoval, EntryTarget, EntryWrite, EntryWrites, HippoStore, KeyAudit, KeyListQuery, KeyMint, KeyRevoke, KeyWrites, LastRecall, LastRecallWrites, OutcomeWrite, RawArchive, RecallSearchArgs, RecallWrites, SelfKeyMint, StoreGroup, StoreGroups, SupersedeWrite, VectorBackfillQuery, VectorReads, VectorRowWrite, VectorWrite, VectorWriteResult, VectorWrites, } from './port.js';
2
2
  export { hasGroup, requireGroup } from './port.js';
3
- export * from './sqlite/store.js';
3
+ export { sqliteStore } from './sqlite/store.js';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,3 @@
1
1
  export { hasGroup, requireGroup } from './port.js';
2
- export * from './sqlite/store.js';
2
+ export { sqliteStore } from './sqlite/store.js';
3
3
  //# sourceMappingURL=index.js.map
@@ -8,7 +8,12 @@ import { oncePerStore } from '../db/connect.js';
8
8
  import { errorFields, errorMessage, log } from '../util/log.js';
9
9
  import { parseJsonArray, INDEX_VERSION, parseLastTraceId, MEMORY_SELECT_COLUMNS, rowToEntry, rowToMemoryConflict, } from './rows.js';
10
10
  import { serializeEntry } from '../core/markdown.js';
11
+ import { writeFileAtomic } from '../util/atomic-write.js';
11
12
  const SCORE_DECIMALS = 3;
13
+ // Mirrors are derived and rebuildable from the store, so they skip the fsyncs (the cost on Windows) and keep the temp-and-rename atomicity.
14
+ function writeMirror(file, text) {
15
+ writeFileAtomic(file, text, { durable: false });
16
+ }
12
17
  export function layerDir(root, layer) {
13
18
  return path.join(root, layer);
14
19
  }
@@ -61,7 +66,7 @@ export function writeActiveTaskMirror(hippoRoot, tenantId, snapshot) {
61
66
  body.push(`## Session`, snapshot.session_id, '');
62
67
  }
63
68
  fs.mkdirSync(path.dirname(filePath), MIRROR_FOLDER);
64
- fs.writeFileSync(filePath, `${fm}\n\n${body.join('\n')}`, 'utf8');
69
+ writeMirror(filePath, `${fm}\n\n${body.join('\n')}`);
65
70
  }
66
71
  export function removeActiveTaskMirror(hippoRoot, tenantId) {
67
72
  const filePath = activeTaskMirrorPath(hippoRoot, tenantId);
@@ -99,7 +104,7 @@ export function writeRecentSessionMirror(hippoRoot, tenantId, events) {
99
104
  }
100
105
  lines.push('');
101
106
  fs.mkdirSync(path.dirname(filePath), MIRROR_FOLDER);
102
- fs.writeFileSync(filePath, `${fm}\n\n${lines.join('\n')}`, 'utf8');
107
+ writeMirror(filePath, `${fm}\n\n${lines.join('\n')}`);
103
108
  }
104
109
  function writeConflictMirrors(hippoRoot, conflicts) {
105
110
  const conflictDir = path.join(hippoRoot, 'conflicts');
@@ -128,7 +133,7 @@ function writeConflictMirrors(hippoRoot, conflicts) {
128
133
  `- Status: ${conflict.status}`,
129
134
  '',
130
135
  ].join('\n');
131
- fs.writeFileSync(path.join(conflictDir, filename), `${fm}\n\n${body}`, 'utf8');
136
+ writeMirror(path.join(conflictDir, filename), `${fm}\n\n${body}`);
132
137
  }
133
138
  for (const existing of fs.readdirSync(conflictDir)) {
134
139
  if (existing === '.gitkeep')
@@ -142,7 +147,7 @@ export function writeMarkdownMirror(hippoRoot, entry) {
142
147
  removeEntryMirrors(hippoRoot, entry.id);
143
148
  const dir = layerDir(hippoRoot, entry.layer);
144
149
  fs.mkdirSync(dir, MIRROR_FOLDER);
145
- fs.writeFileSync(path.join(dir, `${entry.id}.md`), serializeEntry(entry), 'utf8');
150
+ writeMirror(path.join(dir, `${entry.id}.md`), serializeEntry(entry));
146
151
  }
147
152
  // Every layer, Trace included: `writeMarkdownMirror` writes any layer, and a stale trace mirror
148
153
  // is the resurrection channel bootstrapLegacyStore/rebuildIndex guard against.
@@ -5,7 +5,8 @@ export declare function initStore(hippoRoot: string): void;
5
5
  export declare function openStore(hippoRoot: string, opts?: {
6
6
  busyWaitMs?: number;
7
7
  }): DatabaseSyncLike;
8
- /** One call on a handle of its own, closed after; pass openStore where the call also sets up mirror folders and legacy rows. */
8
+ /** One call on a handle of its own, closed after (a request scope's handle stays open);
9
+ * pass openStore where the call also sets up mirror folders and legacy rows. */
9
10
  export declare function onHandle<T>(hippoRoot: string, fn: (db: DatabaseSyncLike) => T, open?: (hippoRoot: string) => DatabaseSyncLike): T;
10
11
  /** `meta` key holding the default half-life base a store's memories are on (src/consolidate/half-life-migration.ts). */
11
12
  export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
@@ -44,7 +44,8 @@ export function openStore(hippoRoot, opts) {
44
44
  throw error;
45
45
  }
46
46
  }
47
- /** One call on a handle of its own, closed after; pass openStore where the call also sets up mirror folders and legacy rows. */
47
+ /** One call on a handle of its own, closed after (a request scope's handle stays open);
48
+ * pass openStore where the call also sets up mirror folders and legacy rows. */
48
49
  export function onHandle(hippoRoot, fn, open = openHippoDb) {
49
50
  const db = open(hippoRoot);
50
51
  try {
@@ -1,7 +1,8 @@
1
1
  import type { AmbientTallies } from '../core/ambient.js';
2
2
  import type { AmbientStoreFilter } from './ambient.js';
3
3
  import type { ApiKeyListRow, ApiKeyRecord, ListApiKeysOpts, NewApiKey } from './auth.js';
4
- import type { AppendAuditOpts, AuditEvent, ListAuditAfterOpts, QueryAuditOpts } from './audit.js';
4
+ import type { AppendAuditOpts, AuditEvent, AuditPrune, ListAuditAfterOpts, QueryAuditOpts } from './audit.js';
5
+ import type { QualityRepairResult } from './quality-repair.js';
5
6
  import type { EmbeddingIndexState } from './vector-index.js';
6
7
  import type { Entity, Relation } from './graph-rows.js';
7
8
  import type { ActiveGoals, GetActiveGoalsOpts, GoalRecallLogRow } from './goals.js';
@@ -17,7 +18,7 @@ import type { ClosureState } from '../core/prediction.js';
17
18
  import type { QuarantineRow, QuarantineStatus } from './quarantine.js';
18
19
  import type { ScopeActor } from '../core/recall-scope.js';
19
20
  import type { RecallTraceInput } from './recall-trace.js';
20
- import type { AmbientLoadResult, AmbientRecallRequest, ContextCandidateFilter, RecentOrigins } from './candidates.js';
21
+ import type { AmbientLoadResult, AmbientRecallRequest, ContextCandidateFilter, HeldText, RecentOrigins } from './candidates.js';
21
22
  import type { GitHubDlqInsert, GitHubRouting } from './connectors/github.js';
22
23
  import type { SlackDlqInsert, SlackTeamRoute } from './connectors/slack.js';
23
24
  import type { StrengthenOptions } from './entry-writes.js';
@@ -411,6 +412,18 @@ export interface Readiness {
411
412
  /** Resolves once one cheap read has answered; rejects with the store's own error when it cannot. */
412
413
  ping(): Promise<void>;
413
414
  }
415
+ /** A recall's writes when the store keeps it as its last one: `strengthen.ids` are the ids it returned. */
416
+ export type LastRecallWrites = RecallWrites & Required<Pick<RecallWrites, 'strengthen'>>;
417
+ /** The last recall a store keeps, so a later outcome can name it, and the outcome linked to a recall trace. */
418
+ export interface LastRecall {
419
+ /** entryWrites.applyOutcome, then a link from recall trace `traceId` to the ids applied; resolves to those ids. */
420
+ applyTracedOutcome(outcome: OutcomeWrite, traceId: number): Promise<string[]>;
421
+ /** applyTracedOutcome on the ids and trace of the last recall, read together so an outcome never pairs them with an older trace. */
422
+ applyOutcomeToLast(target: EntryTarget, good: boolean): Promise<string[]>;
423
+ /** finishRecall, then the ids this store lacks strengthened under `globalRoot`, then the ids and their trace saved as the last
424
+ * recall in one write; resolves to the trace id, null when none was asked or its write failed. */
425
+ finishRecall(writes: LastRecallWrites, globalRoot: string | undefined): Promise<number | null>;
426
+ }
414
427
  /** Which of a tenant's quarantine records a list reads; `limit` is 100 when unset. */
415
428
  export interface QuarantineListQuery {
416
429
  readonly status: QuarantineStatus | 'all';
@@ -575,8 +588,49 @@ export interface VectorViews {
575
588
  /** The same ids and values as `VectorReads.storedVectors`, each value a Float32 view. */
576
589
  storedVectorViews(ids: readonly string[]): Promise<Map<string, Float32Array>>;
577
590
  }
591
+ /** The store is hippo.db and its files under `hippoRoot`, so a path that reopens them by folder (sleep, its child process, the global store) may run. */
592
+ export interface LocalFiles {
593
+ readonly hippoRoot: string;
594
+ }
595
+ /** The reads and writes behind the memory-quality audit, its repair and audit-log retention. */
596
+ export interface AuditMaintenance {
597
+ /** Every memory of the tenant, in `loadAllEntries` order. */
598
+ tenantEntries(tenantId: string): Promise<MemoryEntry[]>;
599
+ /** The ids of every memory a typed object row names, in any tenant; an automatic pass never deletes one. */
600
+ objectBackedIds(): Promise<Set<string>>;
601
+ /** Deletes each removal's row with its own forget row, leaving a pinned, raw, kept or object-backing row; resolves to the ids deleted. */
602
+ deleteAutomatically(removals: readonly {
603
+ readonly id: string;
604
+ readonly reason: string;
605
+ }[], actor: string): Promise<string[]>;
606
+ /** Counts (dryRun) or deletes the tenant's audit rows before `cutoff`, then appends one audit_prune row; resolves to the count. */
607
+ pruneAuditRows(prune: AuditPrune): Promise<number>;
608
+ /** A preview, or with `apply` the moves of certain defects to dormant storage behind a backup. */
609
+ repairQuality(tenantId: string, apply: boolean): Promise<QualityRepairResult>;
610
+ /** repairQuality applied once per store; null once it ran there. */
611
+ repairQualityOnce(tenantId: string): Promise<QualityRepairResult | null>;
612
+ }
613
+ /** The reads and writes behind git auto-learn's batch. */
614
+ export interface LessonWrites {
615
+ /** Every memory of the tenant, in `loadAllEntries` order. */
616
+ tenantEntries(tenantId: string): Promise<MemoryEntry[]>;
617
+ /** Content, source and origin of the tenant's rows holding any of `words`, among the rows a team member or `ownScope`'s owner may touch. */
618
+ textsHoldingWords(tenantId: string, words: readonly string[], ownScope: string | null): Promise<HeldText[]>;
619
+ /** Writes each entry in one transaction; a rejected value writes its refusal row and is skipped. Resolves to the ids written. */
620
+ writeSkippingRejected(entries: readonly MemoryEntry[], actor: string): Promise<string[]>;
621
+ /** Rewrites each entry in one transaction, all or none. */
622
+ writeTogether(entries: readonly MemoryEntry[]): Promise<void>;
623
+ /** Adds `count` to the store-wide remembered counter. */
624
+ bumpRemembered(count: number): Promise<void>;
625
+ }
578
626
  /** The optional groups: a store sets each one whole or leaves it unset, and a route or MCP tool names the one it needs. */
579
627
  export interface StoreGroups {
628
+ /** Unset on any store but hippo.db; a request then runs fenced from hippo.db, and a route or tool that names no group answers 501. */
629
+ readonly localFiles: LocalFiles;
630
+ /** Unset on a store without it, where the quality audit, its repair and audit prune answer 501. */
631
+ readonly auditMaintenance: AuditMaintenance;
632
+ /** Unset on a store without it, where git learn answers 501. */
633
+ readonly lessonWrites: LessonWrites;
580
634
  /** Unset on a store built before them, where hybrid and physics recall under an embedding provider answer 501. */
581
635
  readonly vectors: VectorReads;
582
636
  /** Unset on a store without them, where recall scores the number[] copies `vectors` returns, to the same ranking. */
@@ -599,6 +653,8 @@ export interface StoreGroups {
599
653
  readonly objects: Objects;
600
654
  /** Unset on a store built before it, where GET /ready answers 200 with `store: "unchecked"`. */
601
655
  readonly readiness: Readiness;
656
+ /** Unset on a store that keeps no last recall, where outcomeForLastRecall and a traced outcome answer 501 and context writes no last recall. */
657
+ readonly lastRecall: LastRecall;
602
658
  }
603
659
  export type StoreGroup = 'base' | keyof StoreGroups;
604
660
  export declare function hasGroup(store: HippoStore, group: StoreGroup): boolean;
@@ -612,7 +668,7 @@ export declare class StoreBusyError extends Error {
612
668
  /** What `serve()` reads and writes through. Each method is atomic and no transaction spans an
613
669
  * await, since SQLite's lock wait blocks the event loop; a lock timeout throws `StoreBusyError`. */
614
670
  export interface HippoStore extends Partial<StoreGroups> {
615
- /** 'sqlite' is hippo.db under the served root. Under any other kind, an unported route answers 501 and a hippo.db open inside a request throws. */
671
+ /** A name for logs and errors; what a store can do is the groups it sets, `localFiles` above all. */
616
672
  readonly kind: string;
617
673
  /** The api_keys row for `keyId` with its scope grants, revoked or not; null when no row matches. */
618
674
  findApiKey(keyId: string): Promise<ApiKeyRecord | null>;
@@ -133,7 +133,7 @@ function setAsideIssue(db, entry, reason, now, backup) {
133
133
  if (getMeta(db, 'fts5_available', '0') === '1' && ftsRowExists(db, entry.id)) {
134
134
  throw new Error(`Quality repair could not remove the full-text row for ${entry.id}`);
135
135
  }
136
- appendAuditEvent(db, { tenantId: entry.tenantId, actor, op: 'quality_repair', targetId: entry.id, metadata: { reason, backup } });
136
+ appendAuditEvent(db, { tenantId: entry.tenant_id, actor, op: 'quality_repair', targetId: entry.id, metadata: { reason, backup } });
137
137
  return true;
138
138
  }
139
139
  function applyPlan(db, root, tenantId, backup, doneKey) {
@@ -7,6 +7,7 @@ import { DELIVERY_LEDGER_VERSION, isBoundaryEvent } from './delivery-recorder.js
7
7
  import { errorFields, errorMessage, log } from '../util/log.js';
8
8
  import { DAY_MS } from '../util/time.js';
9
9
  import { blockHash } from '../util/token-text.js';
10
+ import { placeholders } from '../util/sql.js';
10
11
  /** Strip a RerankStep to {stage, multiplier, scoreBefore, scoreAfter}: `note` is free-form (the CLI goal-boost step embeds goal tag text) and would leak
11
12
  * user content into `rerank_json`; an allowlist, so any future free-form field is dropped too. */
12
13
  function sanitizeRerankSteps(steps) {
@@ -184,7 +185,7 @@ export function writeDeliveryEvent(db, input) {
184
185
  ];
185
186
  const eventId = Number(db.prepare(`
186
187
  INSERT INTO delivery_events (${DELIVERY_EVENT_COLUMNS.join(', ')})
187
- VALUES (${DELIVERY_EVENT_COLUMNS.map(() => '?').join(', ')})
188
+ VALUES (${placeholders(DELIVERY_EVENT_COLUMNS.length)})
188
189
  `).run(...values).lastInsertRowid);
189
190
  const insertCandidate = db.prepare(`
190
191
  INSERT INTO delivery_candidates (event_id, tenant_id, memory_id, source_store, pool, stage, outcome, reason, cand_rank, score, tokens)
@@ -5,7 +5,7 @@ import { heldTexts } from '../util/same-text.js';
5
5
  import { audit } from './audit-event.js';
6
6
  import { deleteEntryCore } from './delete-and-batch.js';
7
7
  import { deleteDormantRow, listDormantSnapshots, purgeDormantByDigest, replaceDormantEntry } from './dormant.js';
8
- import { entryRejectRowAt, selectAllEntries } from './entry-reads.js';
8
+ import { entryRejectRowAt, selectEntriesByIds, selectTenantTexts } from './entry-reads.js';
9
9
  import { stampOriginProject } from './entry-row.js';
10
10
  import { writeEntryDbOnly, writeEntryMirrors } from './entry-writes.js';
11
11
  import { purgeMirrorBestEffort } from './mirrors.js';
@@ -16,15 +16,20 @@ import { RejectedValueError } from '../core/api-errors.js';
16
16
  function removeLiveRows(db, hippoRoot, rejection, holdsValue, removal) {
17
17
  const { tenantId, actor, reason } = rejection;
18
18
  const { removedIds, removedRawIds, successors } = removal;
19
- const merged = [];
20
- for (const row of selectAllEntries(db, tenantId)) {
19
+ // Text columns decide which rows hold the value; only those rows are read whole.
20
+ const directIds = [];
21
+ const mergedIds = [];
22
+ for (const row of selectTenantTexts(db, tenantId)) {
21
23
  if (!rejection.inReach(row.scope))
22
24
  continue;
23
- if (!holdsValue(row.content)) {
24
- if (heldTexts(row).some(holdsValue))
25
- merged.push(row);
26
- continue;
27
- }
25
+ if (holdsValue(row.content))
26
+ directIds.push(row.id);
27
+ else if (heldTexts(row).some(holdsValue))
28
+ mergedIds.push(row.id);
29
+ }
30
+ const held = selectEntriesByIds(db, [...directIds, ...mergedIds], tenantId);
31
+ const merged = mergedIds.flatMap((id) => held.get(id) ?? []);
32
+ for (const row of directIds.flatMap((id) => held.get(id) ?? [])) {
28
33
  if (row.kind === 'raw') {
29
34
  // A raw row is append-only: archiving is its one removal path, and its savepoint nests in this write scope.
30
35
  archiveRawMemory(db, row.id, { reason, who: actor });
@@ -97,7 +102,7 @@ export function applyRejection(hippoRoot, rejection) {
97
102
  sourceMemoryId: memoryId ?? null,
98
103
  normalizedChars: normalizeValueForRejection(content).length,
99
104
  });
100
- // SHORTCUT: scans every row of the tenant, fine for a human-run command; a digest column on memories if stores grow a hundredfold.
105
+ // SHORTCUT: scans the tenant's text columns, fine for a human-run command; a digest column needs a schema migration, held until approved.
101
106
  const holdsValue = (text) => rejectionDigest(text) === digest;
102
107
  removeLiveRows(db, hippoRoot, rejection, holdsValue, removal);
103
108
  removeDormantCopies(db, rejection, digest, holdsValue, removal);
@@ -61,12 +61,12 @@ function rowToPlacementFields(row) {
61
61
  scope: row.scope ?? null,
62
62
  owner: row.owner ?? null,
63
63
  artifact_ref: row.artifact_ref ?? null,
64
- tenantId: row.tenant_id ?? DEFAULT_TENANT_ID,
64
+ tenant_id: row.tenant_id ?? DEFAULT_TENANT_ID,
65
65
  origin_project: row.origin_project ?? null,
66
66
  descendant_count: Number(row.descendant_count ?? 0),
67
67
  earliest_at: row.earliest_at ?? null,
68
68
  latest_at: row.latest_at ?? null,
69
- summary_dirty: (Number(row.summary_dirty ?? 0) === 1 ? 1 : 0),
69
+ summary_dirty: Number(row.summary_dirty) === 1,
70
70
  last_rebuilt_at: row.last_rebuilt_at ?? null,
71
71
  rebuild_count: Number(row.rebuild_count ?? 0),
72
72
  dag_level_3_built_at: row.dag_level_3_built_at ?? null,
@@ -1,3 +1,5 @@
1
+ /** calculateRewardFactor as SQL over `memories` columns; strengthSql and the context-candidate rank both build on it, so the slope has one source. */
2
+ export declare function rewardFactorSql(): string;
1
3
  /** calculateStrength's clock-basis formula as SQL over `memories` columns, flags and multipliers baked in; keep in step.
2
4
  * An unparseable date scores NULL here and 0 in JS, so sums agree. */
3
5
  export declare function strengthSql(now: Date): string;
@@ -3,7 +3,16 @@ import { DAY_MS } from '../util/time.js';
3
3
  import { isDecayAblated, isOutcomeSlowAblated, isRecallBoostAblated } from '../core/ablation.js';
4
4
  import { DECAY_BASE, EMOTIONAL_MULTIPLIERS, FALLBACK_HALF_LIFE_DAYS, KEEP_PAIRS, MAX_WRONG_HALVINGS, RETRIEVAL_BOOST_SLOPE, REWARD_SLOPE, applyLossAversionRatio, } from '../core/memory.js';
5
5
  import { PERSONAL_SCOPE_PREFIX, RECALL_DEFAULT_DENY_SCOPES } from '../core/recall-scope.js';
6
+ import { placeholders } from '../util/sql.js';
6
7
  const UNIX_EPOCH_JULIAN_DAY = 2440587.5;
8
+ /** calculateRewardFactor as SQL over `memories` columns; strengthSql and the context-candidate rank both build on it, so the slope has one source. */
9
+ export function rewardFactorSql() {
10
+ if (isOutcomeSlowAblated())
11
+ return '1.0';
12
+ const pos = 'COALESCE(outcome_positive, 0)';
13
+ const neg = 'COALESCE(outcome_negative, 0)';
14
+ return `(CASE WHEN ${pos} = 0 AND ${neg} = 0 THEN 1.0 ELSE 1.0 + ${REWARD_SLOPE} * (${pos} - ${neg}) / (${pos} + ${neg} + 1.0) END)`;
15
+ }
7
16
  /** calculateStrength's clock-basis formula as SQL over `memories` columns, flags and multipliers baked in; keep in step.
8
17
  * An unparseable date scores NULL here and 0 in JS, so sums agree. */
9
18
  export function strengthSql(now) {
@@ -11,10 +20,7 @@ export function strengthSql(now) {
11
20
  const pos = 'COALESCE(outcome_positive, 0)';
12
21
  const neg = 'COALESCE(outcome_negative, 0)';
13
22
  const wrong = isOutcomeSlowAblated() || isDecayAblated() ? '0' : `MAX(0, ${neg} - ${pos})`;
14
- const reward = isOutcomeSlowAblated()
15
- ? '1.0'
16
- : `(CASE WHEN ${pos} = 0 AND ${neg} = 0 THEN 1.0 ELSE 1.0 + ${REWARD_SLOPE} * (${pos} - ${neg}) / (${pos} + ${neg} + 1.0) END)`;
17
- const halfLife = `(COALESCE(half_life_days, ${FALLBACK_HALF_LIFE_DAYS}) * ${reward})`;
23
+ const halfLife = `(COALESCE(half_life_days, ${FALLBACK_HALF_LIFE_DAYS}) * ${rewardFactorSql()})`;
18
24
  const anchor = isRecallBoostAblated() ? 'created' : 'last_retrieved';
19
25
  const nowJulian = num(now.getTime() / DAY_MS + UNIX_EPOCH_JULIAN_DAY);
20
26
  const decay = isDecayAblated() ? '1.0' : `pow(${DECAY_BASE}, (${nowJulian} - julianday(${anchor})) / ${halfLife})`;
@@ -38,8 +44,8 @@ const keepPairSql = (p) => `(COALESCE(superseded_by, '') = '' AND EXISTS (SELECT
38
44
  export const AUTO_DELETABLE_SQL = `pinned = 0 AND kind != 'raw'${KEEP_PAIRS.map((p) => ` AND NOT ${keepPairSql(p)}`).join('')}`;
39
45
  /** SQL twin of the no-request arm of passesScopeFilterForRecall; `ownScope` is bound and compared with `=`, so `%` or `_` in an owner match nothing extra. */
40
46
  export function scopeAdmitSql(col, ownScope) {
41
- const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
42
- const admitted = `${col}scope IS NULL OR (${col}scope NOT IN (${placeholders}) AND ${col}scope NOT LIKE '%:private:%')`;
47
+ const marks = placeholders(RECALL_DEFAULT_DENY_SCOPES.length);
48
+ const admitted = `${col}scope IS NULL OR (${col}scope NOT IN (${marks}) AND ${col}scope NOT LIKE '%:private:%')`;
43
49
  if (ownScope == null)
44
50
  return { sql: `(${admitted})`, params: [...RECALL_DEFAULT_DENY_SCOPES] };
45
51
  // The own arm sits inside the outer parentheses so a caller's `AND ${sql}` cannot split it off.
@@ -18,13 +18,8 @@ export type OriginFilter = string | readonly string[];
18
18
  /** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set, unless it is personal,
19
19
  * which only `ownScope` opens. */
20
20
  export declare function recallScopeFilter(requestedScope: string | undefined, mode: 'exact' | 'additive', ownScope?: string): RecallScopeFilter;
21
- /** Sends term queries down the LIKE path on a build that has FTS. Only tests call it. */
22
- export declare function _forceLikePathForTests(on: boolean): void;
23
21
  /** Load likely search candidates: FTS5 when available, else LIKE, then full-store fallback. With `tenantId` every SELECT filters by it; omitted = no filter. */
24
22
  export declare function loadSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string): MemoryEntry[];
25
- /** Recall-mode loader: scope predicate in SQL so `unknown:legacy` cannot leak. Empty `requestedScope` default-denies (admits `ownScope`); else exact match.
26
- * SQL `NOT LIKE '%:private:%'` runs before the LIMIT so private rows cannot starve admitted ones; `passesScopeFilterForRecall` is the exact post-filter. */
27
- export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
28
23
  export interface RecallSearchEntriesOptions {
29
24
  readonly limit?: number;
30
25
  readonly tenantId?: string;
@@ -34,6 +29,11 @@ export interface RecallSearchEntriesOptions {
34
29
  readonly originProjects?: OriginFilter;
35
30
  readonly ownScope?: string;
36
31
  }
32
+ /** Recall-mode loader: scope predicate in SQL so `unknown:legacy` cannot leak. Empty `requestedScope` default-denies (admits `ownScope`); else exact match.
33
+ * SQL `NOT LIKE '%:private:%'` runs before the LIMIT so private rows cannot starve admitted ones; `passesScopeFilterForRecall` is the exact post-filter. */
34
+ export declare function loadRecallEntries(hippoRoot: string, query: string, options?: RecallSearchEntriesOptions): MemoryEntry[];
35
+ /** Positional form of `loadRecallEntries`, kept internal because the frozen q3b-ranker-floor benchmark imports it from dist. */
36
+ export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
37
37
  export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, options?: RecallSearchEntriesOptions): MemoryEntry[];
38
38
  /** Which rows the vector arm of hybrid search may add: the same tenant, scope and superseded rules as the lexical load. */
39
39
  export interface VectorCandidateSpec {