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
@@ -1,13 +1,16 @@
1
1
  // One record per Claude Code compaction, and what turns its summary into kept memories.
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { ConflictError } from './api-errors.js';
4
5
  import { isObjectLike, isStringValue } from './capture-contract.js';
5
- import { compactSummaryBody, parseCompactionItems, selectItemRows } from './compaction-items.js';
6
- import { loadConfig } from './config.js';
6
+ import { COMPACTION_ITEM_MAX_CHARS, compactSummaryBody, parseCompactionItems, selectItemRows } from './compaction-items.js';
7
+ import { importSpool, spool } from './compaction-spool.js';
8
+ import { isSharedStore, loadConfig } from './config.js';
7
9
  import { closeHippoDb, isSqliteBusy, openHippoDb } from './db.js';
8
10
  import { gatedWrite } from './gated-write.js';
9
11
  import { COMPACTION_MEMORY_TAG, COMPACTION_SOURCE_PREFIX, Layer, createMemory, generateId } from './memory.js';
10
- import { isGlobalStoreRoot, originInSql, projectId, projectNames, resolveProjectIdentity } from './project-identity.js';
12
+ import { fallbackOrigin, isGlobalStoreRoot, originInSql, projectId, projectNames, resolveProjectIdentity } from './project-identity.js';
13
+ import { scopeAdmitSql } from './recall-scope.js';
11
14
  import { maskEmails, redactSecretsStrict } from './secret-detect.js';
12
15
  import { strengthenRetrievedOn, writeEntryMirrors } from './store/entry-writes.js';
13
16
  import { isRecallBoostAblated } from './ablation.js';
@@ -25,17 +28,19 @@ export const REPLAY_AFTER_MS = 10 * 60_000;
25
28
  /** Claude Code deletes transcripts after 30 days, so an older gap can never be filled. */
26
29
  export const TRANSCRIPT_FILL_WINDOW_MS = 30 * 24 * 60 * 60_000;
27
30
  const TRANSCRIPT_TAIL_CAPS = [1 << 20, 8 << 20, 64 << 20];
28
- const SPOOL_DIR = 'compactions-spool';
29
- const CLAIMED_SUFFIX = '.claimed';
30
31
  /** The marker redactSecretsStrict writes; an item holding it was a secret before it was stored. */
31
32
  const REDACTED = '[REDACTED]';
32
- /** Where the session ran: rows written through the global store keep the project the session was in. */
33
+ /** Where the session ran: rows written through the global store or a shared store keep the project the session was in. */
33
34
  function compactionProject(hippoRoot, cwd) {
34
- if (!isGlobalStoreRoot(hippoRoot))
35
+ if (!isGlobalStoreRoot(hippoRoot) && !isSharedStore(hippoRoot))
35
36
  return resolveProjectIdentity(path.dirname(hippoRoot));
36
37
  // No cwd means user-global, as stampOriginProject gives the global store; undefined would fall back to the hook's own cwd.
37
38
  return cwd === null ? '' : resolveProjectIdentity(cwd);
38
39
  }
40
+ /** The record column cannot hold NULL, but an item from an unknown folder on a shared store must not read as user-global. */
41
+ function itemOrigin(hippoRoot, ctx) {
42
+ return ctx.cwd === null && isSharedStore(hippoRoot) ? fallbackOrigin(hippoRoot) : ctx.originProject;
43
+ }
39
44
  function compactionOrigin(hippoRoot, cwd) {
40
45
  return projectId(compactionProject(hippoRoot, cwd));
41
46
  }
@@ -53,6 +58,10 @@ export function readCompactionText(compactSummary) {
53
58
  const parsed = parseCompactionItems(body);
54
59
  return { summary: truncateCodePointSafe(scrub(body), SUMMARY_MAX_CHARS), items: parsed.items.map(scrub), found: parsed.found };
55
60
  }
61
+ /** A caller's items scrubbed as readCompactionText scrubs a summary's, since another machine's scrub is not trusted; cut after it, as a mask can run longer than what it hides. */
62
+ export function scrubCompactionItems(items) {
63
+ return items.map((item) => truncateCodePointSafe(scrub(item), COMPACTION_ITEM_MAX_CHARS));
64
+ }
56
65
  function toRecord(row) {
57
66
  const listed = row.items_json === null ? [] : JSON.parse(row.items_json);
58
67
  return {
@@ -88,13 +97,24 @@ export function startCompaction(db, tenantId, start, at = new Date()) {
88
97
  export function latestCompaction(db, tenantId, sessionId) {
89
98
  return selectRecords(db, 'tenant_id = ? AND session_id = ? ORDER BY started_at DESC, id DESC LIMIT 1', tenantId, sessionId)[0] ?? null;
90
99
  }
100
+ /** The record a caller's request made, whatever state it reached, so a retry neither writes its items twice nor starts a second record; another session's id is a ConflictError. */
101
+ export function compactionByRequest(db, tenantId, requestId, sessionId) {
102
+ const record = selectRecords(db, 'tenant_id = ? AND request_id = ?', tenantId, requestId)[0] ?? null;
103
+ // Sessions are owner-bound, so this also keeps one owner from reading or finishing another's record.
104
+ if (record !== null && record.sessionId !== sessionId)
105
+ throw new ConflictError('request id belongs to another session');
106
+ return record;
107
+ }
91
108
  /** Pre-compact's record for the compaction that is ending: the session's newest `started` one within REPLAY_AFTER_MS before `at`, so an older one is left for the transcript fill. */
92
109
  function latestStarted(db, tenantId, sessionId, at) {
93
110
  return selectRecords(db, `tenant_id = ? AND session_id = ? AND status = 'started' AND started_at <= ? AND started_at >= ? ORDER BY started_at DESC, id DESC LIMIT 1`, tenantId, sessionId, at.toISOString(), new Date(at.getTime() - REPLAY_AFTER_MS).toISOString())[0] ?? null;
94
111
  }
95
- /** Moves a `started` record to `summarised`; false when another process already moved it. */
96
- function markSummarised(db, tenantId, id, text, summarisedAt) {
97
- const result = db.prepare(`UPDATE compactions SET summary = ?, items_json = ?, summarised_at = ?, status = 'summarised' WHERE tenant_id = ? AND id = ? AND status = 'started'`).run(text.summary, JSON.stringify(text.items), summarisedAt, tenantId, id);
112
+ /** Moves a `started` record to `summarised`; false when another process already moved it. The request id lands in the same statement, so no crash leaves the record unfindable by its retry. */
113
+ function markSummarised(db, tenantId, id, text, summarisedAt, requestId) {
114
+ // Only a caller names the column, so a store from before it was added still takes local writes.
115
+ const stamp = requestId === undefined ? [] : [requestId];
116
+ const result = db.prepare(`UPDATE compactions SET summary = ?, items_json = ?, summarised_at = ?, status = 'summarised'${stamp.length === 0 ? '' : ', request_id = ?'}
117
+ WHERE tenant_id = ? AND id = ? AND status = 'started'`).run(text.summary, JSON.stringify(text.items), summarisedAt, ...stamp, tenantId, id);
98
118
  return (result.changes ?? 0) > 0;
99
119
  }
100
120
  /** Best effort, never throws: a compaction must not fail because its record could not be written. */
@@ -113,11 +133,15 @@ export function recordCompactionStart(hippoRoot, start, log) {
113
133
  closeHippoDb(db);
114
134
  }
115
135
  }
116
- export function recordSnapshotSaved(hippoRoot, recordId, log) {
136
+ /** On the caller's handle and tenant, so a server marks the record under the caller's tenant with its own wait. */
137
+ export function markSnapshotSaved(db, tenantId, recordId) {
138
+ db.prepare(`UPDATE compactions SET snapshot_saved = 1 WHERE tenant_id = ? AND id = ?`).run(tenantId, recordId);
139
+ }
140
+ export function recordSnapshotSaved(hippoRoot, tenantId, recordId, log) {
117
141
  let db;
118
142
  try {
119
143
  db = openHippoDb(hippoRoot, { busyWaitMs: COMPACTION_DB_WAIT_MS });
120
- db.prepare(`UPDATE compactions SET snapshot_saved = 1 WHERE tenant_id = ? AND id = ?`).run(resolveTenantId({}), recordId);
144
+ markSnapshotSaved(db, tenantId, recordId);
121
145
  }
122
146
  catch (err) {
123
147
  log(`compaction record not marked with its snapshot: ${errorMessage(err)}`);
@@ -128,17 +152,21 @@ export function recordSnapshotSaved(hippoRoot, recordId, log) {
128
152
  }
129
153
  }
130
154
  /** Puts the summary on the session's `started` record, or inserts a `summarised` one when pre-compact wrote none. One statement each. */
131
- function recordSummary(db, hippoRoot, tenantId, meta, text, at) {
155
+ export function recordSummary(db, hippoRoot, tenantId, meta, text, at, caller = {}) {
132
156
  const now = new Date().toISOString();
133
157
  const itemsJson = JSON.stringify(text.items);
158
+ const { requestId } = caller;
134
159
  const started = latestStarted(db, tenantId, meta.sessionId, at);
135
- if (started && markSummarised(db, tenantId, started.id, text, now)) {
160
+ if (started && markSummarised(db, tenantId, started.id, text, now, requestId)) {
136
161
  return { ...started, summary: text.summary, items: text.items, summarisedAt: now, status: 'summarised' };
137
162
  }
138
- const originProject = compactionOrigin(hippoRoot, meta.cwd);
163
+ const originProject = caller.originProject ?? compactionOrigin(hippoRoot, meta.cwd);
139
164
  const id = generateId('cmp');
140
- db.prepare(`INSERT INTO compactions(tenant_id, id, session_id, origin_project, compact_trigger, cwd, transcript_path, started_at, summarised_at, summary, items_json, status)
141
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'summarised')`).run(tenantId, id, meta.sessionId, originProject, meta.trigger, meta.cwd, meta.transcriptPath, at.toISOString(), now, text.summary, itemsJson);
165
+ // Only a caller names the column, so a store from before it was added still takes local writes.
166
+ const stamp = requestId === undefined ? [] : [requestId];
167
+ const [col, mark] = stamp.length === 0 ? ['', ''] : [', request_id', ', ?'];
168
+ db.prepare(`INSERT INTO compactions(tenant_id, id, session_id, origin_project, compact_trigger, cwd, transcript_path, started_at, summarised_at, summary, items_json, status${col})
169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'summarised'${mark})`).run(tenantId, id, meta.sessionId, originProject, meta.trigger, meta.cwd, meta.transcriptPath, at.toISOString(), now, text.summary, itemsJson, ...stamp);
142
170
  return {
143
171
  id, tenantId, sessionId: meta.sessionId, originProject, trigger: meta.trigger, cwd: meta.cwd, transcriptPath: meta.transcriptPath,
144
172
  snapshotSaved: false, startedAt: at.toISOString(), summarisedAt: now, summary: text.summary, items: text.items, itemsWritten: 0, status: 'summarised',
@@ -167,12 +195,13 @@ function restates(item, held) {
167
195
  }
168
196
  /** Live rows of one tenant and origin that default recall shows: a compaction often restates what an earlier one, or the user, already saved. */
169
197
  function heldRows(db, tenantId, origins) {
198
+ const deny = scopeAdmitSql('');
170
199
  // SAFETY: the SELECT names the id, source_session_id and content columns.
171
200
  const rows = db.prepare(`SELECT id, source_session_id, content FROM memories WHERE tenant_id = ? AND ${originInSql(origins)} AND superseded_by IS NULL AND kind != 'raw'
172
- AND (scope IS NULL OR (scope != 'unknown:legacy' AND scope NOT LIKE '%:private:%'))`).all(tenantId, ...origins);
201
+ AND ${deny.sql}`).all(tenantId, ...origins, ...deny.params);
173
202
  return rows.map((r) => ({ id: r.id, sessionId: r.source_session_id, words: words(r.content) }));
174
203
  }
175
- function compactionEntry(text, ctx, baseHalfLifeDays) {
204
+ function compactionEntry(text, ctx, origin, baseHalfLifeDays) {
176
205
  return {
177
206
  ...createMemory(text, {
178
207
  layer: Layer.Episodic,
@@ -184,13 +213,14 @@ function compactionEntry(text, ctx, baseHalfLifeDays) {
184
213
  tenantId: ctx.tenantId,
185
214
  baseHalfLifeDays,
186
215
  }),
187
- origin_project: ctx.originProject,
216
+ origin_project: origin,
188
217
  };
189
218
  }
190
219
  /** Runs inside saveItems' transaction: restatements of held rows are counted, the rest go through the write gate. */
191
220
  function writeItemRows(db, hippoRoot, ctx, rows, baseHalfLifeDays) {
192
221
  const out = { written: [], repeats: 0, refused: 0, restated: [] };
193
- const held = heldRows(db, ctx.tenantId, heldOrigins(hippoRoot, ctx.cwd, ctx.originProject));
222
+ const held = heldRows(db, ctx.tenantId, ctx.caller?.origins ?? heldOrigins(hippoRoot, ctx.cwd, ctx.originProject));
223
+ const origin = ctx.caller === undefined ? itemOrigin(hippoRoot, ctx) : ctx.originProject;
194
224
  const restated = new Set();
195
225
  for (const text of rows) {
196
226
  const itemWords = words(text);
@@ -206,8 +236,8 @@ function writeItemRows(db, hippoRoot, ctx, rows, baseHalfLifeDays) {
206
236
  out.refused++;
207
237
  continue;
208
238
  }
209
- const entry = compactionEntry(text, ctx, baseHalfLifeDays);
210
- if (gatedWrite(db, hippoRoot, entry, { actor: 'post-compact' }) === 'written') {
239
+ const entry = compactionEntry(text, ctx, origin, baseHalfLifeDays);
240
+ if (gatedWrite(db, hippoRoot, entry, { actor: ctx.caller?.actor ?? 'post-compact' }) === 'written') {
211
241
  out.written.push(entry);
212
242
  held.push({ id: null, sessionId: ctx.sessionId, words: itemWords });
213
243
  }
@@ -297,17 +327,6 @@ export function parsePostCompactPayload(stdinText) {
297
327
  compactSummary: 'compact_summary' in raw && isStringValue(raw.compact_summary) ? raw.compact_summary : null,
298
328
  };
299
329
  }
300
- function spoolFile(hippoRoot, sessionId) {
301
- return path.join(hippoRoot, SPOOL_DIR, `${sessionId.replace(/[^\w-]/g, '_').slice(0, 80)}-${Date.now()}.json`);
302
- }
303
- function spool(hippoRoot, tenantId, payload, text, at) {
304
- const file = spoolFile(hippoRoot, payload.sessionId);
305
- fs.mkdirSync(path.dirname(file), { recursive: true });
306
- const body = { tenantId, sessionId: payload.sessionId, trigger: payload.trigger, cwd: payload.cwd, transcriptPath: payload.transcriptPath, at: at.toISOString(), summary: text.summary, items: text.items };
307
- // Renamed into place so a replayer listing `.json` files never reads half a file.
308
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(body), 'utf8');
309
- fs.renameSync(`${file}.tmp`, file);
310
- }
311
330
  /** The PostCompact work: record the summary, then write its items. Each step is independent; a busy store spools or defers. Never throws. */
312
331
  export function saveCompaction(hippoRoot, payload, log) {
313
332
  const result = { written: null, deferred: false, snapshotSaved: false };
@@ -427,105 +446,15 @@ function nextStartedAt(db, record) {
427
446
  .get(record.tenantId, record.sessionId, record.startedAt);
428
447
  return row?.at ?? null;
429
448
  }
430
- function readSpooled(file, fallbackTenantId) {
431
- let raw;
432
- try {
433
- raw = JSON.parse(fs.readFileSync(file, 'utf8'));
434
- }
435
- catch {
436
- return null; // a half-written or vanished spool file is skipped; the caller treats null as unreadable
437
- }
438
- if (!isObjectLike(raw) || !('sessionId' in raw) || !isStringValue(raw.sessionId) || !('summary' in raw) || !isStringValue(raw.summary))
439
- return null;
440
- if (!('at' in raw) || !isStringValue(raw.at) || Number.isNaN(Date.parse(raw.at)))
441
- return null;
442
- const items = 'items' in raw && Array.isArray(raw.items) ? raw.items.filter(isStringValue) : [];
443
- return {
444
- tenantId: 'tenantId' in raw && isStringValue(raw.tenantId) && raw.tenantId !== '' ? raw.tenantId : fallbackTenantId,
445
- payload: {
446
- sessionId: raw.sessionId,
447
- trigger: 'trigger' in raw && isStringValue(raw.trigger) ? raw.trigger : null,
448
- cwd: 'cwd' in raw && isStringValue(raw.cwd) ? raw.cwd : null,
449
- transcriptPath: 'transcriptPath' in raw && isStringValue(raw.transcriptPath) ? raw.transcriptPath : null,
450
- compactSummary: null,
451
- },
452
- text: { summary: raw.summary, items },
453
- at: new Date(raw.at),
449
+ /** Records a spooled summary, then writes its items. */
450
+ function spoolImporter(db, hippoRoot, log) {
451
+ return (spooled, recorded) => {
452
+ const record = recordSummary(db, hippoRoot, spooled.tenantId, spooled.payload, spooled.text, spooled.at);
453
+ // The record holds the items now, so the file is done even if the write below fails.
454
+ recorded();
455
+ saveItems(db, hippoRoot, { tenantId: spooled.tenantId, recordId: record.id, sessionId: record.sessionId, originProject: record.originProject, cwd: record.cwd, items: record.items }, log);
454
456
  };
455
457
  }
456
- function isMissingFile(cause) {
457
- return cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
458
- }
459
- /** A live replayer refreshes its claim's mtime when it takes it, so an old claim means the replayer died. */
460
- function recoverStaleClaims(dir, log) {
461
- const staleBefore = Date.now() - REPLAY_AFTER_MS;
462
- for (const name of fs.readdirSync(dir).filter((n) => n.endsWith(`.json${CLAIMED_SUFFIX}`))) {
463
- const claimed = path.join(dir, name);
464
- try {
465
- if (fs.statSync(claimed).mtimeMs >= staleBefore)
466
- continue;
467
- fs.renameSync(claimed, claimed.slice(0, -CLAIMED_SUFFIX.length));
468
- log(`spool file ${name} was claimed by a replayer that never finished, put back`);
469
- }
470
- catch (err) {
471
- if (!isMissingFile(err))
472
- log(`spool file ${name} not recovered: ${errorMessage(err)}`);
473
- }
474
- }
475
- }
476
- function releaseClaim(claimed, file, log) {
477
- try {
478
- fs.renameSync(claimed, file);
479
- }
480
- catch (err) {
481
- log(`spool file ${path.basename(file)} could not be put back: ${errorMessage(err)}`);
482
- }
483
- }
484
- /** Each file is claimed by rename before it is read, so two replayers never import the same one. */
485
- function importSpool(db, hippoRoot, tenantId, log, deadline) {
486
- const dir = path.join(hippoRoot, SPOOL_DIR);
487
- if (!fs.existsSync(dir))
488
- return 0;
489
- recoverStaleClaims(dir, log);
490
- let finished = 0;
491
- for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
492
- if (Date.now() > deadline)
493
- break;
494
- const file = path.join(dir, name);
495
- const claimed = `${file}${CLAIMED_SUFFIX}`;
496
- try {
497
- fs.renameSync(file, claimed);
498
- const now = new Date();
499
- fs.utimesSync(claimed, now, now);
500
- }
501
- catch (err) {
502
- if (!isMissingFile(err))
503
- log(`spool file ${name} not claimed: ${errorMessage(err)}`);
504
- continue;
505
- }
506
- const spooled = readSpooled(claimed, tenantId);
507
- if (!spooled) {
508
- log(`spool file ${name} is not readable, set aside`);
509
- fs.renameSync(claimed, `${file}.bad`);
510
- continue;
511
- }
512
- let removed = false;
513
- try {
514
- const record = recordSummary(db, hippoRoot, spooled.tenantId, spooled.payload, spooled.text, spooled.at);
515
- // The record holds the items now, so the file is done even if the write below fails.
516
- fs.rmSync(claimed, { force: true });
517
- removed = true;
518
- saveItems(db, hippoRoot, { tenantId: spooled.tenantId, recordId: record.id, sessionId: record.sessionId, originProject: record.originProject, cwd: record.cwd, items: record.items }, log);
519
- finished++;
520
- }
521
- catch (err) {
522
- log(`spool file ${name} not imported: ${errorMessage(err)}`);
523
- if (!removed)
524
- releaseClaim(claimed, file, log);
525
- }
526
- }
527
- return finished;
528
- }
529
458
  /** Finishes what a killed hook or a busy store left: `summarised` records, spool files, and `started` records the transcript can fill. Returns how many compactions it saved. */
530
459
  export function replayCompactions(db, hippoRoot, log, deadline = Number.POSITIVE_INFINITY) {
531
460
  const tenantId = resolveTenantId({});
@@ -544,7 +473,7 @@ export function replayCompactions(db, hippoRoot, log, deadline = Number.POSITIVE
544
473
  }
545
474
  }
546
475
  try {
547
- finished += importSpool(db, hippoRoot, tenantId, log, deadline);
476
+ finished += importSpool(hippoRoot, tenantId, log, deadline, spoolImporter(db, hippoRoot, log));
548
477
  }
549
478
  catch (err) {
550
479
  log(`spool import failed: ${errorMessage(err)}`);
@@ -0,0 +1,45 @@
1
+ import type { CompactionText, Log, PostCompactPayload } from './compaction-record.js';
2
+ export declare const SPOOL_DIR = "compactions-spool";
3
+ /** Starts the log line of a step that broke with an unexpected error, so the session-start banner counts it; a set-aside file is counted by its `.bad` name instead. */
4
+ export declare const SPOOL_PROBLEM = "spool problem: ";
5
+ /** Only the fs calls the spool makes, so a test can stand in for one of them. */
6
+ export interface SpoolFs {
7
+ existsSync(file: string): boolean;
8
+ mkdirSync(dir: string, options: {
9
+ recursive: true;
10
+ }): void;
11
+ readdirSync(dir: string): string[];
12
+ readFileSync(file: string, encoding: 'utf8'): string;
13
+ renameSync(from: string, to: string): void;
14
+ statSync(file: string): {
15
+ mtimeMs: number;
16
+ };
17
+ unlinkSync(file: string): void;
18
+ writeFileSync(file: string, data: string, options: {
19
+ encoding: 'utf8';
20
+ flag?: 'wx';
21
+ }): void;
22
+ }
23
+ /** Test-only seam, so a test can make one fs call fail; null restores the real fs. */
24
+ export declare function __setSpoolFs(next: SpoolFs | null): void;
25
+ export interface SpooledCompaction {
26
+ tenantId: string;
27
+ payload: PostCompactPayload;
28
+ text: CompactionText;
29
+ at: Date;
30
+ }
31
+ /** Records one spooled compaction; it calls `recorded` once the store holds the summary, so the file is done even if a later step throws. */
32
+ export type SpoolImporter = (spooled: SpooledCompaction, recorded: () => void) => void;
33
+ export declare function spool(hippoRoot: string, tenantId: string, payload: PostCompactPayload, text: CompactionText, at: Date): void;
34
+ /** null when the text is not a spooled compaction. */
35
+ export declare function parseSpooled(text: string, fallbackTenantId: string): SpooledCompaction | null;
36
+ export interface SpoolCounts {
37
+ waiting: number;
38
+ stale: number;
39
+ bad: number;
40
+ }
41
+ /** What doctor reports, read only: files a replay should have taken by now, claims and tmp files a dead process left, and `.bad` files. */
42
+ export declare function spoolCounts(hippoRoot: string, now: Date, waitingAfterMs: number): SpoolCounts;
43
+ /** One replayer at a time holds `replay.lock`, so only one process claims a file even where Windows lets two renames of it both land. */
44
+ export declare function importSpool(hippoRoot: string, tenantId: string, log: Log, deadline: number, importOne: SpoolImporter): number;
45
+ //# sourceMappingURL=compaction-spool.d.ts.map