hippo-memory 1.63.1 → 1.64.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 (275) hide show
  1. package/README.md +13 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/quality-repair-once.d.ts +3 -0
  76. package/dist/cli/quality-repair-once.js +36 -0
  77. package/dist/cli/recall.js +2 -1
  78. package/dist/cli/remember.d.ts +2 -0
  79. package/dist/cli/remember.js +4 -1
  80. package/dist/cli/session-hooks.d.ts +0 -3
  81. package/dist/cli/session-hooks.js +12 -49
  82. package/dist/cli/setup.js +10 -2
  83. package/dist/cli/shared.d.ts +7 -14
  84. package/dist/cli/shared.js +19 -78
  85. package/dist/cli/sleep.js +6 -3
  86. package/dist/cli/usage.js +18 -9
  87. package/dist/cli.d.ts +1 -1
  88. package/dist/cli.js +3 -2
  89. package/dist/client.js +1 -1
  90. package/dist/compaction-record.d.ts +19 -1
  91. package/dist/compaction-record.js +61 -132
  92. package/dist/compaction-spool.d.ts +45 -0
  93. package/dist/compaction-spool.js +454 -0
  94. package/dist/config.d.ts +8 -0
  95. package/dist/config.js +49 -0
  96. package/dist/connectors/github/deletion.d.ts +1 -1
  97. package/dist/connectors/github/deletion.js +5 -17
  98. package/dist/consolidate/conflicts.js +4 -0
  99. package/dist/consolidate/flush-units.d.ts +9 -0
  100. package/dist/consolidate/flush-units.js +63 -0
  101. package/dist/consolidate/llm-passes.js +5 -4
  102. package/dist/consolidate/merge.js +5 -1
  103. package/dist/consolidate/run.d.ts +2 -1
  104. package/dist/consolidate/sleep.d.ts +2 -0
  105. package/dist/consolidate/sleep.js +82 -15
  106. package/dist/context-render.d.ts +14 -0
  107. package/dist/context-render.js +28 -0
  108. package/dist/dag.js +6 -0
  109. package/dist/dashboard-actions.js +3 -2
  110. package/dist/dashboard.js +4 -3
  111. package/dist/db/backup.d.ts +6 -0
  112. package/dist/db/backup.js +29 -0
  113. package/dist/db/busy.d.ts +9 -0
  114. package/dist/db/busy.js +35 -0
  115. package/dist/db/continuity.d.ts +1 -0
  116. package/dist/db/continuity.js +31 -4
  117. package/dist/db/meta.d.ts +2 -0
  118. package/dist/db/meta.js +7 -0
  119. package/dist/db/migrate.js +2 -3
  120. package/dist/db/migrations/index.d.ts +1 -1
  121. package/dist/db/migrations/index.js +5 -1
  122. package/dist/db/migrations/v39.js +2 -7
  123. package/dist/db/migrations/v53.d.ts +3 -0
  124. package/dist/db/migrations/v53.js +16 -0
  125. package/dist/db/migrations/v54.d.ts +3 -0
  126. package/dist/db/migrations/v54.js +25 -0
  127. package/dist/db/open.d.ts +11 -0
  128. package/dist/db/open.js +27 -0
  129. package/dist/db/tables.d.ts +2 -0
  130. package/dist/db/tables.js +6 -2
  131. package/dist/db.d.ts +2 -2
  132. package/dist/db.js +2 -2
  133. package/dist/doctor.d.ts +1 -1
  134. package/dist/doctor.js +31 -15
  135. package/dist/dormant.d.ts +16 -20
  136. package/dist/dormant.js +24 -30
  137. package/dist/entry/json-hooks.d.ts +3 -0
  138. package/dist/entry/json-hooks.js +4 -0
  139. package/dist/entry/project-identity.d.ts +2 -0
  140. package/dist/entry/project-identity.js +3 -0
  141. package/dist/entry/session-text.d.ts +7 -0
  142. package/dist/entry/session-text.js +8 -0
  143. package/dist/extract.js +6 -0
  144. package/dist/failure-log.d.ts +10 -2
  145. package/dist/failure-log.js +19 -2
  146. package/dist/gated-write.js +1 -1
  147. package/dist/graph/delta.d.ts +44 -0
  148. package/dist/graph/delta.js +109 -0
  149. package/dist/graph/types.d.ts +5 -0
  150. package/dist/graph/write.d.ts +20 -20
  151. package/dist/graph/write.js +103 -36
  152. package/dist/graph-extract.d.ts +43 -9
  153. package/dist/graph-extract.js +140 -157
  154. package/dist/graph-recall.d.ts +1 -0
  155. package/dist/graph-recall.js +2 -2
  156. package/dist/graph-view.d.ts +1 -0
  157. package/dist/graph-view.js +25 -2
  158. package/dist/half-life-migration.js +5 -1
  159. package/dist/handoff-evidence.d.ts +5 -0
  160. package/dist/handoff-evidence.js +30 -0
  161. package/dist/home-path.d.ts +5 -0
  162. package/dist/home-path.js +24 -0
  163. package/dist/hooks/json-hooks.d.ts +13 -2
  164. package/dist/hooks/json-hooks.js +246 -117
  165. package/dist/hooks/opencode.js +1 -1
  166. package/dist/hooks/shared.d.ts +2 -0
  167. package/dist/hooks/shared.js +6 -1
  168. package/dist/http-util.d.ts +16 -7
  169. package/dist/http-util.js +65 -20
  170. package/dist/importers/vault.js +2 -0
  171. package/dist/index.d.ts +2 -1
  172. package/dist/index.js +1 -0
  173. package/dist/json.d.ts +2 -0
  174. package/dist/json.js +7 -0
  175. package/dist/ledger-db.d.ts +10 -0
  176. package/dist/ledger-db.js +42 -0
  177. package/dist/mcp/admin-tools.d.ts +2 -2
  178. package/dist/mcp/admin-tools.js +47 -5
  179. package/dist/mcp/memory-tools.js +7 -2
  180. package/dist/mcp/protocol.d.ts +6 -0
  181. package/dist/mcp/protocol.js +4 -2
  182. package/dist/mcp/recall-tools.js +19 -6
  183. package/dist/mcp/request.js +4 -0
  184. package/dist/mcp/session-state.d.ts +4 -0
  185. package/dist/mcp/session-state.js +15 -6
  186. package/dist/mcp/shared-gate.d.ts +4 -0
  187. package/dist/mcp/shared-gate.js +22 -0
  188. package/dist/mcp/tools.js +1 -0
  189. package/dist/memory-quality.d.ts +35 -0
  190. package/dist/memory-quality.js +191 -0
  191. package/dist/memory.d.ts +4 -4
  192. package/dist/physics-state.js +1 -1
  193. package/dist/pilot-arm.d.ts +14 -3
  194. package/dist/pilot-arm.js +32 -10
  195. package/dist/postinstall.js +2 -2
  196. package/dist/project-briefs.js +4 -4
  197. package/dist/project-identity.d.ts +14 -10
  198. package/dist/project-identity.js +29 -11
  199. package/dist/project-merge.d.ts +0 -2
  200. package/dist/project-merge.js +9 -13
  201. package/dist/prompt-hook.d.ts +65 -0
  202. package/dist/prompt-hook.js +213 -0
  203. package/dist/prompt-recall.js +1 -1
  204. package/dist/quality-repair.d.ts +27 -0
  205. package/dist/quality-repair.js +220 -0
  206. package/dist/rate-limit.d.ts +1 -0
  207. package/dist/rate-limit.js +5 -3
  208. package/dist/raw-archive.d.ts +1 -1
  209. package/dist/raw-archive.js +6 -16
  210. package/dist/recall-scope.d.ts +28 -5
  211. package/dist/recall-scope.js +52 -7
  212. package/dist/recall-trace.js +1 -1
  213. package/dist/reject-flow.d.ts +2 -0
  214. package/dist/reject-flow.js +20 -6
  215. package/dist/secret-detect.js +7 -8
  216. package/dist/server/auth.d.ts +8 -6
  217. package/dist/server/auth.js +85 -38
  218. package/dist/server/client-ip.d.ts +2 -0
  219. package/dist/server/client-ip.js +16 -1
  220. package/dist/server/mcp-http.d.ts +9 -3
  221. package/dist/server/mcp-http.js +56 -13
  222. package/dist/server/request.d.ts +1 -1
  223. package/dist/server/request.js +5 -5
  224. package/dist/server/routes/admin.js +12 -6
  225. package/dist/server/routes/memories.js +9 -1
  226. package/dist/server/routes/recall.js +14 -2
  227. package/dist/server/types.d.ts +42 -5
  228. package/dist/server/validation.d.ts +5 -2
  229. package/dist/server/validation.js +19 -2
  230. package/dist/server.d.ts +21 -4
  231. package/dist/server.js +207 -49
  232. package/dist/session-digest.d.ts +0 -1
  233. package/dist/session-digest.js +3 -12
  234. package/dist/session-owners.d.ts +7 -0
  235. package/dist/session-owners.js +54 -0
  236. package/dist/share-scrub.d.ts +3 -0
  237. package/dist/share-scrub.js +8 -0
  238. package/dist/shared.d.ts +1 -0
  239. package/dist/shared.js +26 -18
  240. package/dist/store/candidates.d.ts +14 -3
  241. package/dist/store/candidates.js +48 -26
  242. package/dist/store/conflicts.d.ts +15 -0
  243. package/dist/store/conflicts.js +68 -44
  244. package/dist/store/delete-and-batch.d.ts +25 -1
  245. package/dist/store/delete-and-batch.js +121 -69
  246. package/dist/store/entry-reads.d.ts +5 -6
  247. package/dist/store/entry-reads.js +24 -12
  248. package/dist/store/entry-row.d.ts +7 -10
  249. package/dist/store/entry-row.js +40 -18
  250. package/dist/store/entry-writes.d.ts +3 -3
  251. package/dist/store/entry-writes.js +9 -23
  252. package/dist/store/handoffs.d.ts +9 -6
  253. package/dist/store/handoffs.js +72 -59
  254. package/dist/store/index-and-stats.js +2 -2
  255. package/dist/store/open.d.ts +3 -1
  256. package/dist/store/open.js +3 -3
  257. package/dist/store/search-rows.d.ts +8 -5
  258. package/dist/store/search-rows.js +15 -18
  259. package/dist/store/sessions.d.ts +25 -6
  260. package/dist/store/sessions.js +61 -19
  261. package/dist/store/summaries.d.ts +1 -1
  262. package/dist/store/summaries.js +3 -18
  263. package/dist/store/tenant-lookup.d.ts +7 -3
  264. package/dist/store/tenant-lookup.js +9 -9
  265. package/dist/store-port.d.ts +13 -0
  266. package/dist/store-port.js +20 -0
  267. package/dist/version.d.ts +5 -1
  268. package/dist/version.js +5 -1
  269. package/dist/working-memory.js +1 -1
  270. package/dist/write-budget.d.ts +15 -0
  271. package/dist/write-budget.js +15 -0
  272. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  273. package/extensions/openclaw-plugin/package.json +1 -1
  274. package/openclaw.plugin.json +1 -1
  275. package/package.json +8 -4
@@ -6,35 +6,40 @@ import { insertDormantRow } from '../dormant.js';
6
6
  import { log } from '../log.js';
7
7
  import { MEMORY_SELECT_COLUMNS, rowToEntry } from './rows.js';
8
8
  import { audit } from './audit-event.js';
9
- import { deleteFtsRow, stampOriginProject, upsertEntryRow } from './entry-row.js';
9
+ import { deleteFtsRow, replaceFtsRows, stampOriginProject, upsertMemoryRow } from './entry-row.js';
10
10
  import { purgeMirrorBestEffort, mirrorBestEffort, writeMarkdownMirror } from './mirrors.js';
11
11
  import { openStore } from './open.js';
12
+ import { clock } from '../write-budget.js';
12
13
  /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
13
14
  export const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'incidents', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
14
15
  /** Deleting a memory that backs an object nulls the object's link, and no restore can repair it, so no automatic pass may. */
15
16
  const AUTOMATIC_DELETE_SQL = `${AUTO_DELETABLE_SQL}${MEMORY_BACKED_TABLES.map((t) => ` AND NOT EXISTS (SELECT 1 FROM ${t} WHERE ${t}.memory_id = memories.id)`).join('')}`;
16
17
  /** Ids of memories that back a first-class object, for passes that plan deletes before making them. A table missing from an older schema is skipped. */
17
18
  export function memoriesBackingObjects(hippoRoot) {
18
- const ids = new Set();
19
19
  const db = openHippoDb(hippoRoot);
20
20
  try {
21
- for (const table of MEMORY_BACKED_TABLES) {
22
- try {
23
- // SAFETY: SELECT of one nullable TEXT column, filtered to non-null.
24
- const rows = db.prepare(`SELECT memory_id FROM ${table} WHERE memory_id IS NOT NULL`).all();
25
- for (const r of rows)
26
- ids.add(r.memory_id);
27
- }
28
- catch (err) {
29
- // A missing table is an older schema; any other error could hide a backing memory, so the caller stops.
30
- if (!(err instanceof Error && err.message.includes('no such table')))
31
- throw err;
32
- }
33
- }
21
+ return memoriesBackingObjectsOn(db);
34
22
  }
35
23
  finally {
36
24
  closeHippoDb(db);
37
25
  }
26
+ }
27
+ /** memoriesBackingObjects on the caller's handle. */
28
+ export function memoriesBackingObjectsOn(db) {
29
+ const ids = new Set();
30
+ for (const table of MEMORY_BACKED_TABLES) {
31
+ try {
32
+ // SAFETY: SELECT of one nullable TEXT column, filtered to non-null.
33
+ const rows = db.prepare(`SELECT memory_id FROM ${table} WHERE memory_id IS NOT NULL`).all();
34
+ for (const r of rows)
35
+ ids.add(r.memory_id);
36
+ }
37
+ catch (err) {
38
+ // A missing table is an older schema; any other error could hide a backing memory, so the caller stops.
39
+ if (!(err instanceof Error && err.message.includes('no such table')))
40
+ throw err;
41
+ }
42
+ }
38
43
  return ids;
39
44
  }
40
45
  /**
@@ -117,7 +122,7 @@ function mergeOwnChanges(base, ours, live) {
117
122
  }
118
123
  return row;
119
124
  }
120
- /** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
125
+ /** Writes, deletes and dormant moves in one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
121
126
  * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
122
127
  *
123
128
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
@@ -125,77 +130,120 @@ function mergeOwnChanges(base, ours, live) {
125
130
  * is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
126
131
  * (pinned, raw, kept for good or backing an object since the caller decided). Returns the ids that left `memories`, deleted or moved. */
127
132
  export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
128
- const dormantMoves = opts?.dormant ?? [];
129
- if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
133
+ const dormant = opts?.dormant ?? [];
134
+ if (toWrite.length === 0 && toDeleteIds.length === 0 && dormant.length === 0)
130
135
  return [];
131
136
  const db = openStore(hippoRoot);
132
137
  try {
133
- // IMMEDIATE: the tombstone probes below read before the first write, and under a deferred BEGIN a
134
- // concurrent `hippo reject` would make the lock upgrade fail with SQLITE_BUSY and roll back the batch.
135
- db.exec('BEGIN IMMEDIATE');
136
- // Snapshot every doomed row's dag_parent_id before the deletes: consolidation flushes through here
137
- // every cycle, so without it parents would never be marked dirty for decay, merge or garbage-collect.
138
- const dirty = { parents: new Set(), tenantById: new Map() };
139
- const deletableIds = [];
140
- if (toDeleteIds.length > 0) {
141
- // A row pinned after the caller decided to delete it survives.
142
- for (const row of selectAutoDeletableRows(db, toDeleteIds, dirty))
143
- deletableIds.push(row.id);
144
- }
145
- // v39: batch writers bypass writeEntry, so stamp store-derived origins here too (a NULL origin hides new
146
- // memories from ambient context). A row queued twice keeps only its last version, the one the merge compares.
147
- const stampedWrites = [...new Map(toWrite.map((e) => [e.id, stampOriginProject(hippoRoot, e)])).values()];
148
- const { written, batchRejectedSkips } = applyBatchWrites(db, stampedWrites, opts?.snapshot, dirty);
149
- const removedIds = moveDormantAndDelete(db, dormantMoves, deletableIds, dirty);
138
+ // One component, so every op keeps the statement order this call has always had.
139
+ const all = { writes: toWrite, deletes: toDeleteIds, dormant };
140
+ return batchWriteAndDeleteOn(db, hippoRoot, [all], 0, { snapshot: opts?.snapshot, holdMs: Infinity }).removedIds;
141
+ }
142
+ finally {
143
+ closeHippoDb(db);
144
+ }
145
+ }
146
+ const failedUnits = new WeakMap();
147
+ /** Tags a flush error with the ids of the component it stopped at, for the partial sleep audit row; the first tag wins. */
148
+ export function noteFailedUnit(err, component) {
149
+ if (!component || failedUnits.has(err))
150
+ return;
151
+ const ids = [...component.writes.map((e) => e.id), ...component.deletes, ...component.dormant.map((m) => m.entry.id)];
152
+ failedUnits.set(err, [...new Set(ids)]);
153
+ }
154
+ /** The ids noteFailedUnit tagged `err` with, if a flush threw it. */
155
+ export function failedUnitOf(err) {
156
+ return err ? failedUnits.get(err) : undefined;
157
+ }
158
+ /** batchWriteAndDelete on the caller's open store from component `from`, each component whole, closing the transaction at the
159
+ * first component boundary after `holdMs`. Returns the next component's index and the ids that left `memories`. */
160
+ export function batchWriteAndDeleteOn(db, hippoRoot, components, from, opts) {
161
+ const now = opts.clock ?? clock;
162
+ // IMMEDIATE: the tombstone probes below read before the first write, and under a deferred BEGIN a
163
+ // concurrent `hippo reject` would make the lock upgrade fail with SQLITE_BUSY and roll back the batch.
164
+ db.exec('BEGIN IMMEDIATE');
165
+ const begunAt = now();
166
+ const out = { written: [], removedIds: [], rejectedSkips: 0, fts: { rows: [], staleIds: [] }, dirty: { parents: new Set(), tenantById: new Map() } };
167
+ let next = from;
168
+ try {
169
+ do {
170
+ const at = next++;
171
+ try {
172
+ applyComponent(db, hippoRoot, components[at], opts.snapshot, out);
173
+ }
174
+ catch (err) {
175
+ if (err instanceof Error)
176
+ noteFailedUnit(err, components[at]);
177
+ throw err;
178
+ }
179
+ } while (next < components.length && now() - begunAt < opts.holdMs);
180
+ const removed = new Set(out.removedIds);
181
+ replaceFtsRows(db, out.fts.rows.filter((row) => !removed.has(row.id)), [...out.fts.staleIds, ...out.removedIds]);
150
182
  // Fire dirty-mark for every collected parent INSIDE the BEGIN, so the
151
183
  // dirty flag commits atomically with the writes + deletes.
152
- for (const parentId of dirty.parents) {
153
- markSummaryDirtyInTx(db, parentId, dirty.tenantById.get(parentId) ?? 'default', 'batch');
184
+ for (const parentId of out.dirty.parents) {
185
+ markSummaryDirtyInTx(db, parentId, out.dirty.tenantById.get(parentId) ?? 'default', 'batch');
154
186
  }
155
187
  db.exec('COMMIT');
156
- if (batchRejectedSkips > 0) {
157
- log.warn(`batchWriteAndDelete: skipped ${batchRejectedSkips} write(s) whose content matches a rejected value (tombstone hit during the batch transaction)`);
158
- }
159
- // Sync mirrors once after all DB writes. Entries skipped above were
160
- // never inserted — writing their markdown mirror would resurrect the
161
- // exact content the skip just kept out of the DB.
162
- mirrorBestEffort('markdown mirrors', () => {
163
- for (const entry of written)
164
- writeMarkdownMirror(hippoRoot, entry);
165
- });
166
- for (const id of removedIds)
167
- purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
168
- return removedIds;
169
188
  }
170
189
  catch (error) {
171
- try {
172
- db.exec('ROLLBACK');
190
+ if (db.isTransaction !== false) {
191
+ try {
192
+ db.exec('ROLLBACK');
193
+ }
194
+ catch { /* preserve the original throw and its unit tag */ }
173
195
  }
174
- catch { /* ignore */ }
175
196
  throw error;
176
197
  }
177
- finally {
178
- closeHippoDb(db);
198
+ reportChunk(hippoRoot, out);
199
+ return { next, removedIds: out.removedIds };
200
+ }
201
+ /** One component in the open transaction, in the order a single batch always ran: pin-checked deletes, writes, dormant moves, row deletes. */
202
+ function applyComponent(db, hippoRoot, component, snapshot, out) {
203
+ // Snapshot every doomed row's dag_parent_id before the deletes: consolidation flushes through here
204
+ // every cycle, so without it parents would never be marked dirty for decay, merge or garbage-collect.
205
+ const deletableIds = [];
206
+ if (component.deletes.length > 0) {
207
+ // A row pinned after the caller decided to delete it survives.
208
+ for (const row of selectAutoDeletableRows(db, component.deletes, out.dirty))
209
+ deletableIds.push(row.id);
210
+ }
211
+ // v39: batch writers bypass writeEntry, so stamp store-derived origins here too (a NULL origin hides new
212
+ // memories from ambient context). A row queued twice keeps only its last version, the one the merge compares.
213
+ const stampedWrites = [...new Map(component.writes.map((e) => [e.id, stampOriginProject(hippoRoot, e)])).values()];
214
+ applyBatchWrites(db, stampedWrites, snapshot, out);
215
+ moveDormantAndDelete(db, component.dormant, deletableIds, out);
216
+ }
217
+ /** After COMMIT: warn about refused writes, then sync mirrors once for what landed. */
218
+ function reportChunk(hippoRoot, out) {
219
+ if (out.rejectedSkips > 0) {
220
+ log.warn(`batchWriteAndDelete: skipped ${out.rejectedSkips} write(s) whose content matches a rejected value (tombstone hit during the batch transaction)`);
179
221
  }
222
+ // A skipped write never landed, so mirroring it would resurrect the text the skip kept out of the DB.
223
+ mirrorBestEffort('markdown mirrors', () => {
224
+ for (const entry of out.written)
225
+ writeMarkdownMirror(hippoRoot, entry);
226
+ });
227
+ for (const id of out.removedIds)
228
+ purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
180
229
  }
181
- /** Moves eligible dormant rows out, deletes them with the doomed rows, and returns every removed id. */
182
- function moveDormantAndDelete(db, dormantMoves, deletableIds, dirty) {
230
+ /** Moves eligible dormant rows out, then deletes them with the doomed rows, recording every removed id. */
231
+ function moveDormantAndDelete(db, dormantMoves, deletableIds, out) {
183
232
  // Dormant moves: same eligibility and DAG bookkeeping as deletes.
184
233
  const movable = [];
185
234
  if (dormantMoves.length > 0) {
186
235
  const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
187
- for (const row of selectAutoDeletableRows(db, [...byId.keys()], dirty))
236
+ for (const row of selectAutoDeletableRows(db, [...byId.keys()], out.dirty))
188
237
  movable.push(byId.get(row.id));
189
238
  }
190
239
  for (const move of movable) {
191
240
  insertDormantRow(db, move);
192
241
  }
193
- const removedIds = [...deletableIds, ...movable.map((m) => m.entry.id)];
194
- for (const id of removedIds) {
195
- db.prepare('DELETE FROM memories WHERE id = ?').run(id);
196
- deleteFtsRow(db, id);
242
+ const deleteRow = db.prepare('DELETE FROM memories WHERE id = ?');
243
+ for (const id of [...deletableIds, ...movable.map((m) => m.entry.id)]) {
244
+ deleteRow.run(id);
245
+ out.removedIds.push(id);
197
246
  }
198
- return removedIds;
199
247
  }
200
248
  /** The still auto-deletable rows among `ids`, recording each one's DAG parent as dirty. Placeholders come from `ids` itself. */
201
249
  function selectAutoDeletableRows(db, ids, dirty) {
@@ -209,11 +257,10 @@ function selectAutoDeletableRows(db, ids, dirty) {
209
257
  }
210
258
  return rows;
211
259
  }
212
- function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
260
+ function applyBatchWrites(db, stampedWrites, snapshot, out) {
213
261
  // Probe tombstones per entry on THIS connection inside the transaction: the producer's check ran earlier
214
262
  // on another connection, so a reject committed in between would be re-inserted. Skip, never throw, so the rest lands.
215
- let batchRejectedSkips = 0;
216
- const written = [];
263
+ const { written, fts, dirty } = out;
217
264
  const readLiveRow = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`);
218
265
  for (const entry of stampedWrites) {
219
266
  const base = snapshot?.get(entry.id);
@@ -222,7 +269,7 @@ function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
222
269
  const live = liveRow ? rowToEntry(liveRow) : undefined;
223
270
  const row = base && live ? mergeOwnChanges(base, entry, live) : entry;
224
271
  if (isRejectedBatchWrite(db, row)) {
225
- batchRejectedSkips++;
272
+ out.rejectedSkips++;
226
273
  continue;
227
274
  }
228
275
  if (base && !live)
@@ -230,14 +277,19 @@ function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
230
277
  written.push(row);
231
278
  // Bypass the guard: merges concatenate already-guarded facts, the merge pass checks merged content, and
232
279
  // the probe above closes the race; a mid-batch refusal would abort the whole consolidation.
233
- upsertEntryRow(db, row, true);
280
+ upsertMemoryRow(db, row);
281
+ // A decay refresh changes no indexed text, and re-indexing it would cost a full index scan per row.
282
+ if (!live || row.content !== live.content || row.tags.join(' ') !== live.tags.join(' ')) {
283
+ fts.rows.push(row);
284
+ if (live)
285
+ fts.staleIds.push(row.id);
286
+ }
234
287
  // Hook for writes: a new child, or a change to what its summary reads, marks the parent dirty; decay alone does not.
235
288
  if (row.dag_parent_id && (!live || SUMMARY_INPUTS.some((k) => row[k] !== live[k]))) {
236
289
  dirty.parents.add(row.dag_parent_id);
237
290
  dirty.tenantById.set(row.dag_parent_id, row.tenantId);
238
291
  }
239
292
  }
240
- return { written, batchRejectedSkips };
241
293
  }
242
294
  /** True, after auditing the refusal, when the write would introduce a rejected value. */
243
295
  function isRejectedBatchWrite(db, row) {
@@ -32,7 +32,7 @@ export declare function loadEntriesByIds(hippoRoot: string, ids: readonly string
32
32
  *
33
33
  * Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`.
34
34
  */
35
- export declare function loadSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, cap?: number): MemoryEntry[];
35
+ export declare function loadSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, cap?: number, origins?: readonly string[]): MemoryEntry[];
36
36
  /**
37
37
  * Pre-cap, scope-aware row count for a session. Lets `assemble` report
38
38
  * the full session size even when `rowCap` truncates the loaded window,
@@ -41,13 +41,12 @@ export declare function loadSessionRawMemories(hippoRoot: string, sessionId: str
41
41
  * An unscoped COUNT would let a no-scope caller infer private rows by comparing `totalRaw`
42
42
  * against `items.length`, so this SQL-encodes the default-deny rule `passesScopeFilterForRecall` applies in TS:
43
43
  * - explicit scope passed: exact-match
44
- * - no scope: rows where scope IS NULL, or scope is NOT a `<source>:private:*`
45
- * pattern AND not the `unknown:legacy` quarantine bucket.
44
+ * - no scope: `scopeAdmitSql`'s default deny, which admits `ownScope`, the caller's personal scope.
46
45
  *
47
46
  * `tenantId` is optional for back-compat. Pass `undefined` only when
48
47
  * intentionally counting cross-tenant; `assemble()` passes `ctx.tenantId`.
49
48
  */
50
- export declare function countSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, scope?: string): number;
49
+ export declare function countSessionRawMemories(hippoRoot: string, sessionId: string, tenantId?: string, scope?: string, ownScope?: string, origins?: readonly string[]): number;
51
50
  /**
52
51
  * Last N kind='raw' memories by `created` desc. Tenant scoped. When
53
52
  * `sessionId` is supplied, also constrains to a specific session — that
@@ -62,7 +61,7 @@ export declare function countSessionRawMemories(hippoRoot: string, sessionId: st
62
61
  * The tenant-wide shape is the back-compat default but discouraged; `api.recall` throws
63
62
  * `RecallContractError` for it when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set.
64
63
  */
65
- export declare function loadFreshRawMemories(hippoRoot: string, count: number, tenantId?: string, sessionId?: string): MemoryEntry[];
64
+ export declare function loadFreshRawMemories(hippoRoot: string, count: number, tenantId?: string, sessionId?: string, origins?: readonly string[]): MemoryEntry[];
66
65
  /**
67
66
  * Direct DAG children of a parent summary. Tenant scoped. Returns only rows
68
67
  * whose `dag_parent_id` matches `parentId`; does NOT walk recursively.
@@ -80,5 +79,5 @@ export declare function loadAllEntries(hippoRoot: string, tenantId?: string): Me
80
79
  export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
81
80
  /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
82
81
  export declare function selectLiveEntriesBySourcePrefix(db: DatabaseSyncLike, tenantId: string, prefix: string): MemoryEntry[];
83
- export declare function loadContentsWithTag(hippoRoot: string, tenantId: string, tag: string): string[];
82
+ export declare function loadContentsWithTag(hippoRoot: string, tenantId: string, tag: string, origins?: readonly string[]): string[];
84
83
  //# sourceMappingURL=entry-reads.d.ts.map
@@ -2,6 +2,8 @@ import { closeHippoDb } from '../db.js';
2
2
  import { MEMORY_SELECT_COLUMNS, rowToEntry, parseJsonArray } from './rows.js';
3
3
  import { openStore } from './open.js';
4
4
  import { escapeLike } from '../escape.js';
5
+ import { originInSql } from '../project-identity.js';
6
+ import { scopeAdmitSql } from '../recall-scope.js';
5
7
  /**
6
8
  * Read a memory entry by ID.
7
9
  *
@@ -91,6 +93,9 @@ export function loadEntriesByIds(hippoRoot, ids, tenantId) {
91
93
  closeHippoDb(db);
92
94
  }
93
95
  }
96
+ function originClause(origins) {
97
+ return origins === undefined ? '' : ` AND (origin_project = '' OR ${originInSql(origins)})`;
98
+ }
94
99
  /**
95
100
  * All `kind='raw'` rows for a given session, tenant-scoped, returned
96
101
  * oldest-first. Used by `api.assemble` to walk a session's chronological
@@ -101,7 +106,7 @@ export function loadEntriesByIds(hippoRoot, ids, tenantId) {
101
106
  *
102
107
  * Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`.
103
108
  */
104
- export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap) {
109
+ export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap, origins) {
105
110
  if (!sessionId)
106
111
  return [];
107
112
  const db = openStore(hippoRoot);
@@ -113,6 +118,8 @@ export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap) {
113
118
  sql += ' AND tenant_id = ?';
114
119
  params.push(tenantId);
115
120
  }
121
+ sql += originClause(origins);
122
+ params.push(...(origins ?? []));
116
123
  if (cap !== undefined && cap > 0) {
117
124
  sql += ' ORDER BY created DESC, id DESC LIMIT ?';
118
125
  params.push(cap);
@@ -137,13 +144,12 @@ export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap) {
137
144
  * An unscoped COUNT would let a no-scope caller infer private rows by comparing `totalRaw`
138
145
  * against `items.length`, so this SQL-encodes the default-deny rule `passesScopeFilterForRecall` applies in TS:
139
146
  * - explicit scope passed: exact-match
140
- * - no scope: rows where scope IS NULL, or scope is NOT a `<source>:private:*`
141
- * pattern AND not the `unknown:legacy` quarantine bucket.
147
+ * - no scope: `scopeAdmitSql`'s default deny, which admits `ownScope`, the caller's personal scope.
142
148
  *
143
149
  * `tenantId` is optional for back-compat. Pass `undefined` only when
144
150
  * intentionally counting cross-tenant; `assemble()` passes `ctx.tenantId`.
145
151
  */
146
- export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope) {
152
+ export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope, ownScope, origins) {
147
153
  if (!sessionId)
148
154
  return 0;
149
155
  const db = openStore(hippoRoot);
@@ -155,14 +161,16 @@ export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope) {
155
161
  sql += ' AND tenant_id = ?';
156
162
  params.push(tenantId);
157
163
  }
164
+ sql += originClause(origins);
165
+ params.push(...(origins ?? []));
158
166
  if (scope !== undefined && scope !== '') {
159
167
  sql += ' AND scope = ?';
160
168
  params.push(scope);
161
169
  }
162
170
  else {
163
- // SQL-ify the TS default-deny: scope IS NULL OR (NOT LIKE '%:private:%'
164
- // AND != 'unknown:legacy'). Mirrors api.passesScopeFilterForRecall.
165
- sql += ` AND (scope IS NULL OR (scope NOT LIKE '%:private:%' AND scope != 'unknown:legacy'))`;
171
+ const admit = scopeAdmitSql('', ownScope);
172
+ sql += ` AND ${admit.sql}`;
173
+ params.push(...admit.params);
166
174
  }
167
175
  // SAFETY: row's shape matches the single `COUNT(*) AS c` column above.
168
176
  const row = db.prepare(sql).get(...params);
@@ -186,7 +194,7 @@ export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope) {
186
194
  * The tenant-wide shape is the back-compat default but discouraged; `api.recall` throws
187
195
  * `RecallContractError` for it when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set.
188
196
  */
189
- export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
197
+ export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId, origins) {
190
198
  if (count <= 0)
191
199
  return [];
192
200
  const capped = Math.min(count, 200);
@@ -202,6 +210,8 @@ export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
202
210
  sql += ' AND source_session_id = ?';
203
211
  params.push(sessionId);
204
212
  }
213
+ sql += originClause(origins);
214
+ params.push(...(origins ?? []));
205
215
  // Tie tail makes same-`created` rows deterministic; `content` before `id` because ids are random
206
216
  // UUIDs, so an id-only tail would pick which same-created rows make the window per instance.
207
217
  sql += ' ORDER BY created DESC, content ASC, id ASC LIMIT ?';
@@ -258,13 +268,15 @@ export function selectLiveEntriesBySourcePrefix(db, tenantId, prefix) {
258
268
  const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${escapeLike(prefix)}%`);
259
269
  return rows.map(rowToEntry).filter((entry) => entry.source.startsWith(prefix));
260
270
  }
261
- // Content of every tenant row tagged `tag`, without reading the rest of the store.
262
- // `instr` is a substring prefilter over the raw JSON; `includes` below re-checks exactly.
263
- export function loadContentsWithTag(hippoRoot, tenantId, tag) {
271
+ // Content of every team-visible tenant row tagged `tag`, without reading the rest of the store; `origins` keeps one project's rows and user-global ones.
272
+ // No owner: a personal or connector-private row must never answer `duplicate` for, or stop, a team copy. `instr` prefilters; `includes` re-checks.
273
+ export function loadContentsWithTag(hippoRoot, tenantId, tag, origins) {
264
274
  const db = openStore(hippoRoot);
265
275
  try {
276
+ const inProject = origins === undefined ? '' : ` AND (origin_project = '' OR ${originInSql(origins)})`;
277
+ const admit = scopeAdmitSql('');
266
278
  /** SAFETY: rows' shape matches the two columns named in the SELECT below. */
267
- const rows = db.prepare(`SELECT content, tags_json FROM memories WHERE tenant_id = ? AND instr(tags_json, ?) > 0`).all(tenantId, JSON.stringify(tag));
279
+ const rows = db.prepare(`SELECT content, tags_json FROM memories WHERE tenant_id = ? AND instr(tags_json, ?) > 0${inProject} AND ${admit.sql}`).all(tenantId, JSON.stringify(tag), ...(origins ?? []), ...admit.params);
268
280
  return rows.filter((r) => parseJsonArray(r.tags_json).includes(tag)).map((r) => r.content);
269
281
  }
270
282
  finally {
@@ -1,12 +1,12 @@
1
1
  import type { MemoryEntry } from '../memory.js';
2
2
  import { openHippoDb } from '../db.js';
3
- /**
4
- * `bypassRejectionGuard`: ONLY `batchWriteAndDelete` passes `true`; its merges concatenate
5
- * already-guarded facts, and it re-probes tombstones in-transaction before each upsert.
6
- */
7
- export declare function upsertEntryRow(db: ReturnType<typeof openHippoDb>, entry: MemoryEntry, bypassRejectionGuard?: boolean): void;
3
+ export declare function upsertEntryRow(db: ReturnType<typeof openHippoDb>, entry: MemoryEntry): void;
4
+ /** The row alone, with no rejection guard or full-text row, for `upsertEntryRow` and for `batchWriteAndDelete`, which probes tombstones and indexes per batch. Returns whether the row is new. */
5
+ export declare function upsertMemoryRow(db: ReturnType<typeof openHippoDb>, entry: MemoryEntry): boolean;
8
6
  export declare function syncFtsRow(db: ReturnType<typeof openHippoDb>, entry: MemoryEntry, isNewRow?: boolean): void;
9
7
  export declare function deleteFtsRow(db: ReturnType<typeof openHippoDb>, id: string): void;
8
+ /** One set delete for `staleIds`, because `id` is UNINDEXED and each delete by id scans the whole index; then one insert per row. */
9
+ export declare function replaceFtsRows(db: ReturnType<typeof openHippoDb>, rows: readonly MemoryEntry[], staleIds: readonly string[]): void;
10
10
  /**
11
11
  * Write a memory entry to SQLite and refresh compatibility mirrors.
12
12
  *
@@ -29,11 +29,8 @@ export declare function deleteFtsRow(db: ReturnType<typeof openHippoDb>, id: str
29
29
  * origin (shareMemory, syncGlobalToLocal) set entry.origin_project before
30
30
  * writing and this is a no-op. Returns a stamped copy; never mutates.
31
31
  *
32
- * NULL is deliberately PRESERVED, not re-stamped: null means "legacy row the
33
- * v39 migration found no evidence for" and is deny-by-default in ambient
34
- * context. A writeback (e.g. markRetrieved on a crossProject-included row)
35
- * must not launder it into an injectable origin - the migration is the only
36
- * evidence-based NULL converter.
32
+ * NULL is PRESERVED, not re-stamped: it means no known project (a legacy row with no evidence, or a write to a
33
+ * shared store that named none) and ambient context denies it, so a writeback must not launder it.
37
34
  */
38
35
  export declare function stampOriginProject(hippoRoot: string, entry: MemoryEntry): MemoryEntry;
39
36
  /**
@@ -1,19 +1,16 @@
1
- import * as path from 'path';
2
1
  import { isFtsAvailable } from '../db.js';
3
- import { deriveOriginProject, originFromSource } from '../project-identity.js';
2
+ import { fallbackOrigin, originFromSource } from '../project-identity.js';
4
3
  import { checkRejectionGuard } from '../rejection.js';
5
4
  import { log } from '../log.js';
6
- /**
7
- * `bypassRejectionGuard`: ONLY `batchWriteAndDelete` passes `true`; its merges concatenate
8
- * already-guarded facts, and it re-probes tombstones in-transaction before each upsert.
9
- */
10
- export function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
11
- if (!bypassRejectionGuard) {
12
- checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
13
- }
5
+ export function upsertEntryRow(db, entry) {
6
+ checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
7
+ syncFtsRow(db, entry, upsertMemoryRow(db, entry));
8
+ }
9
+ /** The row alone, with no rejection guard or full-text row, for `upsertEntryRow` and for `batchWriteAndDelete`, which probes tombstones and indexes per batch. Returns whether the row is new. */
10
+ export function upsertMemoryRow(db, entry) {
14
11
  const isNewRow = db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(entry.id) === undefined;
15
12
  db.prepare(UPSERT_MEMORY_SQL).run(...memoryRowValues(entry));
16
- syncFtsRow(db, entry, isNewRow);
13
+ return isNewRow;
17
14
  }
18
15
  const UPSERT_MEMORY_SQL = `
19
16
  INSERT INTO memories(
@@ -136,6 +133,34 @@ export function deleteFtsRow(db, id) {
136
133
  log.warnThenDebug('fts-delete', `FTS index delete failed for ${id}; recall may return a stale hit: ${err instanceof Error ? err.message : String(err)}`);
137
134
  }
138
135
  }
136
+ /** One set delete for `staleIds`, because `id` is UNINDEXED and each delete by id scans the whole index; then one insert per row. */
137
+ export function replaceFtsRows(db, rows, staleIds) {
138
+ if (!isFtsAvailable(db))
139
+ return;
140
+ let kept = new Set();
141
+ if (staleIds.length > 0) {
142
+ try {
143
+ db.prepare(`DELETE FROM memories_fts WHERE id IN (SELECT value FROM json_each(?))`).run(JSON.stringify(staleIds));
144
+ }
145
+ catch (err) {
146
+ // Their old rows are still indexed, so inserting them again would index an id twice; only new ids go in.
147
+ kept = new Set(staleIds);
148
+ log.warnThenDebug('fts-delete', `FTS index delete failed for ${staleIds.length} row(s); recall may return a stale hit: ${err instanceof Error ? err.message : String(err)}`);
149
+ }
150
+ }
151
+ const toInsert = rows.filter((row) => !kept.has(row.id));
152
+ if (toInsert.length === 0)
153
+ return;
154
+ const insert = db.prepare(`INSERT INTO memories_fts(id, content, tags) VALUES (?, ?, ?)`);
155
+ for (const row of toInsert) {
156
+ try {
157
+ insert.run(row.id, row.content, row.tags.join(' '));
158
+ }
159
+ catch (err) {
160
+ log.warnThenDebug('fts-sync', `FTS index update failed for ${row.id}; keyword recall may miss it: ${err instanceof Error ? err.message : String(err)}`);
161
+ }
162
+ }
163
+ }
139
164
  /**
140
165
  * Write a memory entry to SQLite and refresh compatibility mirrors.
141
166
  *
@@ -158,16 +183,13 @@ export function deleteFtsRow(db, id) {
158
183
  * origin (shareMemory, syncGlobalToLocal) set entry.origin_project before
159
184
  * writing and this is a no-op. Returns a stamped copy; never mutates.
160
185
  *
161
- * NULL is deliberately PRESERVED, not re-stamped: null means "legacy row the
162
- * v39 migration found no evidence for" and is deny-by-default in ambient
163
- * context. A writeback (e.g. markRetrieved on a crossProject-included row)
164
- * must not launder it into an injectable origin - the migration is the only
165
- * evidence-based NULL converter.
186
+ * NULL is PRESERVED, not re-stamped: it means no known project (a legacy row with no evidence, or a write to a
187
+ * shared store that named none) and ambient context denies it, so a writeback must not launder it.
166
188
  */
167
189
  export function stampOriginProject(hippoRoot, entry) {
168
190
  if (entry.origin_project !== undefined)
169
191
  return entry;
170
- return { ...entry, origin_project: deriveOriginProject(path.dirname(hippoRoot)) };
192
+ return { ...entry, origin_project: fallbackOrigin(hippoRoot) };
171
193
  }
172
194
  /**
173
195
  * Import-time variant for a mirror with no origin field (an explicit null stays null): used only where evidence exists
@@ -184,7 +206,7 @@ export function stampOriginProjectForImport(hippoRoot, entry) {
184
206
  const fromSource = originFromSource(entry.source);
185
207
  return {
186
208
  ...entry,
187
- origin_project: fromSource ?? deriveOriginProject(path.dirname(hippoRoot)),
209
+ origin_project: fromSource ?? fallbackOrigin(hippoRoot),
188
210
  };
189
211
  }
190
212
  //# sourceMappingURL=entry-row.js.map
@@ -10,14 +10,14 @@ export declare function writeEntry(hippoRoot: string, entry: MemoryEntry, opts?:
10
10
  /** writeEntry on the caller's open store, so a loop of writes opens the store once; each row still commits alone. */
11
11
  export declare function writeEntryOn(db: DatabaseSyncLike, hippoRoot: string, entry: MemoryEntry, opts?: WriteEntryOptions): void;
12
12
  /**
13
- * DB-only write path. Caller owns the open `db` handle. Runs SAVEPOINT +
14
- * upsert + afterWrite hook + audit row inside the SAVEPOINT scope. Caller
13
+ * DB-only write path. Caller owns the open `db` handle. Runs upsert +
14
+ * afterWrite hook + audit row inside one withWriteScope. Caller
15
15
  * is responsible for opening `db`, optionally wrapping in a larger BEGIN/
16
16
  * COMMIT (e.g. supersede's BEGIN IMMEDIATE), closing `db`, AND calling
17
17
  * `writeEntryMirrors` after the larger tx commits — mirrors must run
18
18
  * post-commit so a rolled-back tx never leaves orphan markdown.
19
19
  *
20
- * Audit-order note: the audit row is emitted INSIDE the SAVEPOINT, so audit
20
+ * Audit-order note: the audit row is emitted INSIDE the write scope, so audit
21
21
  * commits atomically with the row INSERT. A subsequent mirror failure cannot
22
22
  * leave a recorded audit entry without its corresponding DB row. This is a
23
23
  * documented hardening over the prior writeEntry-as-monolith ordering.
@@ -1,5 +1,5 @@
1
1
  import { markRetrieved } from '../memory.js';
2
- import { closeHippoDb, openHippoDb } from '../db.js';
2
+ import { closeHippoDb, openHippoDb, withWriteScope } from '../db.js';
3
3
  import { RejectedValueError } from '../rejection.js';
4
4
  import { markSummaryDirtyInTx } from '../summary-dirty.js';
5
5
  import { log } from '../log.js';
@@ -26,7 +26,7 @@ export function writeEntryOn(db, hippoRoot, entry, opts) {
26
26
  writeEntryMirrors(hippoRoot, stamped);
27
27
  }
28
28
  catch (error) {
29
- // writeEntryDbOnly's SAVEPOINT has already unwound here, so the refusal audit lands
29
+ // writeEntryDbOnly's write scope has already unwound here, so the refusal audit lands
30
30
  // post-rollback in a fresh implicit transaction; then rethrow so the caller sees it.
31
31
  if (error instanceof RejectedValueError) {
32
32
  auditRejectionRefusal(db, error, opts?.actor ?? 'cli');
@@ -35,25 +35,22 @@ export function writeEntryOn(db, hippoRoot, entry, opts) {
35
35
  }
36
36
  }
37
37
  /**
38
- * DB-only write path. Caller owns the open `db` handle. Runs SAVEPOINT +
39
- * upsert + afterWrite hook + audit row inside the SAVEPOINT scope. Caller
38
+ * DB-only write path. Caller owns the open `db` handle. Runs upsert +
39
+ * afterWrite hook + audit row inside one withWriteScope. Caller
40
40
  * is responsible for opening `db`, optionally wrapping in a larger BEGIN/
41
41
  * COMMIT (e.g. supersede's BEGIN IMMEDIATE), closing `db`, AND calling
42
42
  * `writeEntryMirrors` after the larger tx commits — mirrors must run
43
43
  * post-commit so a rolled-back tx never leaves orphan markdown.
44
44
  *
45
- * Audit-order note: the audit row is emitted INSIDE the SAVEPOINT, so audit
45
+ * Audit-order note: the audit row is emitted INSIDE the write scope, so audit
46
46
  * commits atomically with the row INSERT. A subsequent mirror failure cannot
47
47
  * leave a recorded audit entry without its corresponding DB row. This is a
48
48
  * documented hardening over the prior writeEntry-as-monolith ordering.
49
49
  */
50
50
  export function writeEntryDbOnly(db, entry, opts) {
51
- // SAVEPOINT (not BEGIN) so this nests safely inside any outer transaction
52
- // a caller might hold (e.g. supersede's BEGIN IMMEDIATE). SQLite refuses
53
- // BEGIN within a transaction; SAVEPOINT is the only way to scope rollback
54
- // without disturbing outers.
55
- db.exec('SAVEPOINT write_entry');
56
- try {
51
+ // Inside a caller's transaction (e.g. supersede's BEGIN IMMEDIATE) the scope is a SAVEPOINT,
52
+ // so a throw here rolls back only this write and leaves the outer open.
53
+ withWriteScope(db, 'write_entry', () => {
57
54
  upsertEntryRow(db, entry);
58
55
  if (opts?.afterWrite) {
59
56
  opts.afterWrite(db, entry.id);
@@ -67,18 +64,7 @@ export function writeEntryDbOnly(db, entry, opts) {
67
64
  if (entry.dag_parent_id) {
68
65
  markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, opts?.actor ?? 'cli');
69
66
  }
70
- db.exec('RELEASE SAVEPOINT write_entry');
71
- }
72
- catch (e) {
73
- try {
74
- db.exec('ROLLBACK TO SAVEPOINT write_entry');
75
- db.exec('RELEASE SAVEPOINT write_entry');
76
- }
77
- catch {
78
- // Ignore rollback failures — the throw below is what matters.
79
- }
80
- throw e;
81
- }
67
+ });
82
68
  }
83
69
  /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
84
70
  export function writeEntryMirrors(hippoRoot, entry) {