hippo-memory 1.63.2 → 1.64.1

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 (280) hide show
  1. package/README.md +11 -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/maintenance.d.ts +2 -1
  76. package/dist/cli/maintenance.js +17 -11
  77. package/dist/cli/quality-repair-once.d.ts +3 -0
  78. package/dist/cli/quality-repair-once.js +36 -0
  79. package/dist/cli/recall.js +2 -1
  80. package/dist/cli/remember.d.ts +2 -0
  81. package/dist/cli/remember.js +4 -1
  82. package/dist/cli/session-hooks.d.ts +0 -3
  83. package/dist/cli/session-hooks.js +12 -49
  84. package/dist/cli/setup.js +10 -2
  85. package/dist/cli/shared.d.ts +7 -14
  86. package/dist/cli/shared.js +19 -78
  87. package/dist/cli/sleep.js +6 -3
  88. package/dist/cli/usage.js +18 -9
  89. package/dist/cli.d.ts +1 -1
  90. package/dist/cli.js +3 -2
  91. package/dist/client.js +1 -1
  92. package/dist/compaction-record.d.ts +19 -1
  93. package/dist/compaction-record.js +61 -132
  94. package/dist/compaction-spool.d.ts +45 -0
  95. package/dist/compaction-spool.js +454 -0
  96. package/dist/config.d.ts +8 -0
  97. package/dist/config.js +49 -0
  98. package/dist/connectors/github/deletion.d.ts +1 -1
  99. package/dist/connectors/github/deletion.js +5 -17
  100. package/dist/consolidate/conflicts.js +4 -0
  101. package/dist/consolidate/flush-units.d.ts +9 -0
  102. package/dist/consolidate/flush-units.js +63 -0
  103. package/dist/consolidate/llm-passes.js +5 -4
  104. package/dist/consolidate/merge.js +5 -1
  105. package/dist/consolidate/run.d.ts +2 -1
  106. package/dist/consolidate/sleep.d.ts +2 -0
  107. package/dist/consolidate/sleep.js +82 -15
  108. package/dist/context-render.d.ts +14 -0
  109. package/dist/context-render.js +28 -0
  110. package/dist/dag.js +6 -0
  111. package/dist/dashboard-actions.js +3 -2
  112. package/dist/dashboard.js +4 -3
  113. package/dist/db/backup.d.ts +6 -0
  114. package/dist/db/backup.js +29 -0
  115. package/dist/db/busy.d.ts +9 -0
  116. package/dist/db/busy.js +35 -0
  117. package/dist/db/continuity.d.ts +1 -0
  118. package/dist/db/continuity.js +31 -4
  119. package/dist/db/meta.d.ts +2 -0
  120. package/dist/db/meta.js +7 -0
  121. package/dist/db/migrate.js +2 -3
  122. package/dist/db/migrations/index.d.ts +1 -1
  123. package/dist/db/migrations/index.js +5 -1
  124. package/dist/db/migrations/v39.js +2 -7
  125. package/dist/db/migrations/v53.d.ts +3 -0
  126. package/dist/db/migrations/v53.js +16 -0
  127. package/dist/db/migrations/v54.d.ts +3 -0
  128. package/dist/db/migrations/v54.js +25 -0
  129. package/dist/db/open.d.ts +11 -0
  130. package/dist/db/open.js +27 -0
  131. package/dist/db/tables.d.ts +2 -0
  132. package/dist/db/tables.js +6 -2
  133. package/dist/db.d.ts +2 -2
  134. package/dist/db.js +2 -2
  135. package/dist/doctor.d.ts +1 -1
  136. package/dist/doctor.js +31 -15
  137. package/dist/dormant.d.ts +16 -20
  138. package/dist/dormant.js +24 -30
  139. package/dist/embedding-provider.js +4 -3
  140. package/dist/entry/json-hooks.d.ts +3 -0
  141. package/dist/entry/json-hooks.js +4 -0
  142. package/dist/entry/project-identity.d.ts +2 -0
  143. package/dist/entry/project-identity.js +3 -0
  144. package/dist/entry/session-text.d.ts +7 -0
  145. package/dist/entry/session-text.js +8 -0
  146. package/dist/extract.js +6 -0
  147. package/dist/failure-log.d.ts +10 -2
  148. package/dist/failure-log.js +19 -2
  149. package/dist/gated-write.js +1 -1
  150. package/dist/graph/delta.d.ts +44 -0
  151. package/dist/graph/delta.js +109 -0
  152. package/dist/graph/types.d.ts +5 -0
  153. package/dist/graph/write.d.ts +20 -20
  154. package/dist/graph/write.js +103 -36
  155. package/dist/graph-extract.d.ts +43 -9
  156. package/dist/graph-extract.js +140 -157
  157. package/dist/graph-recall.d.ts +1 -0
  158. package/dist/graph-recall.js +2 -2
  159. package/dist/graph-view.d.ts +1 -0
  160. package/dist/graph-view.js +25 -2
  161. package/dist/half-life-migration.js +5 -1
  162. package/dist/handoff-evidence.d.ts +5 -0
  163. package/dist/handoff-evidence.js +30 -0
  164. package/dist/home-path.d.ts +5 -0
  165. package/dist/home-path.js +24 -0
  166. package/dist/hooks/json-hooks.d.ts +13 -2
  167. package/dist/hooks/json-hooks.js +246 -117
  168. package/dist/hooks/opencode.js +1 -1
  169. package/dist/hooks/shared.d.ts +2 -0
  170. package/dist/hooks/shared.js +6 -1
  171. package/dist/http-util.d.ts +16 -7
  172. package/dist/http-util.js +65 -20
  173. package/dist/importers/vault.js +2 -0
  174. package/dist/index.d.ts +2 -1
  175. package/dist/index.js +1 -0
  176. package/dist/json.d.ts +2 -0
  177. package/dist/json.js +7 -0
  178. package/dist/ledger-db.d.ts +10 -0
  179. package/dist/ledger-db.js +42 -0
  180. package/dist/local-embedding.d.ts +2 -0
  181. package/dist/local-embedding.js +20 -4
  182. package/dist/mcp/admin-tools.d.ts +2 -2
  183. package/dist/mcp/admin-tools.js +47 -5
  184. package/dist/mcp/memory-tools.js +7 -2
  185. package/dist/mcp/protocol.d.ts +6 -0
  186. package/dist/mcp/protocol.js +4 -2
  187. package/dist/mcp/recall-tools.js +19 -6
  188. package/dist/mcp/request.js +4 -0
  189. package/dist/mcp/session-state.d.ts +4 -0
  190. package/dist/mcp/session-state.js +15 -6
  191. package/dist/mcp/shared-gate.d.ts +4 -0
  192. package/dist/mcp/shared-gate.js +22 -0
  193. package/dist/mcp/tools.js +1 -0
  194. package/dist/memory-quality.d.ts +35 -0
  195. package/dist/memory-quality.js +191 -0
  196. package/dist/memory.d.ts +4 -4
  197. package/dist/physics-state.js +1 -1
  198. package/dist/pilot-arm.d.ts +14 -3
  199. package/dist/pilot-arm.js +32 -10
  200. package/dist/postinstall.js +2 -2
  201. package/dist/project-briefs.js +4 -4
  202. package/dist/project-identity.d.ts +14 -10
  203. package/dist/project-identity.js +29 -11
  204. package/dist/project-merge.d.ts +0 -2
  205. package/dist/project-merge.js +9 -13
  206. package/dist/prompt-hook.d.ts +65 -0
  207. package/dist/prompt-hook.js +213 -0
  208. package/dist/prompt-recall.js +1 -1
  209. package/dist/quality-repair.d.ts +27 -0
  210. package/dist/quality-repair.js +220 -0
  211. package/dist/rate-limit.d.ts +1 -0
  212. package/dist/rate-limit.js +5 -3
  213. package/dist/raw-archive.d.ts +1 -1
  214. package/dist/raw-archive.js +6 -16
  215. package/dist/recall-scope.d.ts +28 -5
  216. package/dist/recall-scope.js +52 -7
  217. package/dist/recall-trace.js +1 -1
  218. package/dist/reject-flow.d.ts +2 -0
  219. package/dist/reject-flow.js +20 -6
  220. package/dist/secret-detect.js +7 -8
  221. package/dist/server/auth.d.ts +8 -6
  222. package/dist/server/auth.js +85 -38
  223. package/dist/server/client-ip.d.ts +2 -0
  224. package/dist/server/client-ip.js +16 -1
  225. package/dist/server/mcp-http.d.ts +9 -3
  226. package/dist/server/mcp-http.js +56 -13
  227. package/dist/server/request.d.ts +1 -1
  228. package/dist/server/request.js +5 -5
  229. package/dist/server/routes/admin.js +12 -6
  230. package/dist/server/routes/memories.js +9 -1
  231. package/dist/server/routes/recall.js +14 -2
  232. package/dist/server/types.d.ts +42 -5
  233. package/dist/server/validation.d.ts +5 -2
  234. package/dist/server/validation.js +19 -2
  235. package/dist/server.d.ts +21 -4
  236. package/dist/server.js +207 -49
  237. package/dist/session-digest.d.ts +0 -1
  238. package/dist/session-digest.js +3 -12
  239. package/dist/session-owners.d.ts +7 -0
  240. package/dist/session-owners.js +54 -0
  241. package/dist/share-scrub.d.ts +3 -0
  242. package/dist/share-scrub.js +8 -0
  243. package/dist/shared.d.ts +1 -0
  244. package/dist/shared.js +26 -18
  245. package/dist/store/candidates.d.ts +14 -3
  246. package/dist/store/candidates.js +48 -26
  247. package/dist/store/conflicts.d.ts +15 -0
  248. package/dist/store/conflicts.js +68 -44
  249. package/dist/store/delete-and-batch.d.ts +25 -1
  250. package/dist/store/delete-and-batch.js +121 -69
  251. package/dist/store/entry-reads.d.ts +5 -6
  252. package/dist/store/entry-reads.js +24 -12
  253. package/dist/store/entry-row.d.ts +7 -10
  254. package/dist/store/entry-row.js +40 -18
  255. package/dist/store/entry-writes.d.ts +3 -3
  256. package/dist/store/entry-writes.js +9 -23
  257. package/dist/store/handoffs.d.ts +9 -6
  258. package/dist/store/handoffs.js +72 -59
  259. package/dist/store/index-and-stats.js +2 -2
  260. package/dist/store/open.d.ts +3 -1
  261. package/dist/store/open.js +3 -3
  262. package/dist/store/search-rows.d.ts +8 -5
  263. package/dist/store/search-rows.js +15 -18
  264. package/dist/store/sessions.d.ts +25 -6
  265. package/dist/store/sessions.js +61 -19
  266. package/dist/store/summaries.d.ts +1 -1
  267. package/dist/store/summaries.js +3 -18
  268. package/dist/store/tenant-lookup.d.ts +7 -3
  269. package/dist/store/tenant-lookup.js +9 -9
  270. package/dist/store-port.d.ts +13 -0
  271. package/dist/store-port.js +20 -0
  272. package/dist/version.d.ts +5 -1
  273. package/dist/version.js +5 -1
  274. package/dist/working-memory.js +1 -1
  275. package/dist/write-budget.d.ts +15 -0
  276. package/dist/write-budget.js +15 -0
  277. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  278. package/extensions/openclaw-plugin/package.json +1 -1
  279. package/openclaw.plugin.json +1 -1
  280. package/package.json +8 -4
@@ -1,18 +1,20 @@
1
1
  import { type SessionHandoff, type HandoffOutcome, type HandoffEvidence } from '../handoff.js';
2
2
  import type { TaskSnapshot } from './rows.js';
3
+ import { type ContinuityKey } from './sessions.js';
3
4
  /** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
4
5
  export declare const HANDOFF_COLUMNS = "id, session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id";
5
6
  /**
6
7
  * Save a session handoff record. Returns the persisted handoff.
7
8
  */
8
- export declare function saveSessionHandoff(hippoRoot: string, tenantId: string, handoff: Omit<SessionHandoff, 'updatedAt'>): SessionHandoff;
9
- /** Load the most recent handoff, optionally filtered by session ID. */
10
- export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, sessionId?: string, opts?: {
9
+ export declare function saveSessionHandoff(hippoRoot: string, tenantId: string, handoff: Omit<SessionHandoff, 'updatedAt'>, key?: ContinuityKey): SessionHandoff;
10
+ interface HandoffFilter {
11
11
  unfinishedOnly?: boolean;
12
12
  maxAgeMs?: number;
13
13
  scopeFilter?: 'default-deny';
14
14
  excludeSessionId?: string;
15
- }): SessionHandoff | null;
15
+ }
16
+ /** Load the most recent handoff, optionally filtered by session ID. */
17
+ export declare function loadLatestHandoff(hippoRoot: string, tenantId: string, sessionId?: string, opts?: HandoffFilter, key?: ContinuityKey): SessionHandoff | null;
16
18
  /**
17
19
  * Load a specific handoff by its row ID.
18
20
  */
@@ -21,6 +23,7 @@ export declare function loadHandoffById(hippoRoot: string, tenantId: string, id:
21
23
  export declare function stampHandoffOutcome(hippoRoot: string, tenantId: string, sessionId: string, outcome: HandoffOutcome): number;
22
24
  /** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
23
25
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
24
- * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
25
- export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null): SessionHandoff | null;
26
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
27
+ export declare function writeSessionEndHandoff(hippoRoot: string, tenantId: string, sessionId: string, evidence: HandoffEvidence | null, derived?: Pick<TaskSnapshot, 'task' | 'summary' | 'next_step'> | null, key?: ContinuityKey): SessionHandoff | null;
28
+ export {};
26
29
  //# sourceMappingURL=handoffs.d.ts.map
@@ -1,32 +1,29 @@
1
1
  import { closeHippoDb, openHippoDb } from '../db.js';
2
2
  import { rowToSessionHandoff, isHandoffOutcome, } from '../handoff.js';
3
- import { RECALL_DEFAULT_DENY_SCOPES } from '../recall-scope.js';
3
+ import { scopeAdmitSql } from '../recall-scope.js';
4
4
  import { assertTenantId } from '../tenant.js';
5
5
  import { openStore } from './open.js';
6
- import { loadActiveTaskSnapshot } from './sessions.js';
6
+ import { continuityStamp, continuityWhere, loadActiveTaskSnapshot } from './sessions.js';
7
7
  /** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
8
8
  export const HANDOFF_COLUMNS = 'id, session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id';
9
9
  /**
10
10
  * Save a session handoff record. Returns the persisted handoff.
11
11
  */
12
- export function saveSessionHandoff(hippoRoot, tenantId, handoff) {
12
+ export function saveSessionHandoff(hippoRoot, tenantId, handoff, key) {
13
13
  assertTenantId('saveSessionHandoff', tenantId);
14
+ const stamp = key ? continuityStamp(key) : null;
14
15
  const db = openStore(hippoRoot);
15
16
  const now = new Date().toISOString();
16
17
  // Scope is stored as given; read-side default-deny in api.recall + cmdRecall
17
18
  // continuity excludes slack:private:* and 'unknown:legacy'.
18
19
  try {
19
20
  const result = db.prepare(`
20
- INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, tenant_id, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id)
21
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
- `).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, tenantId, now, JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null);
21
+ INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, tenant_id, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id, owner_subject, origin_project)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
23
+ `).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, tenantId, now, JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null, stamp?.owner ?? null, stamp?.origin ?? null);
23
24
  const id = Number(result.lastInsertRowid ?? 0);
24
25
  // SAFETY: row's shape matches HANDOFF_COLUMNS.
25
- const row = db.prepare(`
26
- SELECT ${HANDOFF_COLUMNS}
27
- FROM session_handoffs
28
- WHERE id = ?
29
- `).get(id);
26
+ const row = db.prepare(`SELECT ${HANDOFF_COLUMNS} FROM session_handoffs WHERE id = ?`).get(id);
30
27
  if (!row) {
31
28
  throw new Error('Failed to reload saved session handoff');
32
29
  }
@@ -36,38 +33,48 @@ export function saveSessionHandoff(hippoRoot, tenantId, handoff) {
36
33
  closeHippoDb(db);
37
34
  }
38
35
  }
36
+ function handoffConditions(tenantId, sessionId, opts, key) {
37
+ const owned = key ? continuityWhere(key) : null;
38
+ const conditions = ['tenant_id = ?'];
39
+ const params = [tenantId];
40
+ if (owned) {
41
+ conditions.push(owned.sql);
42
+ params.push(...owned.params);
43
+ }
44
+ if (sessionId) {
45
+ conditions.push('session_id = ?');
46
+ params.push(sessionId);
47
+ }
48
+ if (opts.excludeSessionId) {
49
+ conditions.push('session_id != ?');
50
+ params.push(opts.excludeSessionId);
51
+ }
52
+ if (opts.unfinishedOnly) {
53
+ // Restrict to each session's newest revision first: stampHandoffOutcome
54
+ // only stamps the newest row, so an older null-outcome revision must not resurrect.
55
+ // Keyed, the newest is the owner's own, so another owner's newer revision cannot hide it.
56
+ conditions.push(`id IN (SELECT MAX(id) FROM session_handoffs WHERE tenant_id = ?${owned ? ` AND ${owned.sql}` : ''} GROUP BY session_id)`);
57
+ params.push(tenantId, ...(owned?.params ?? []));
58
+ conditions.push(`(outcome IS NULL OR outcome IN ('partial','failure'))`);
59
+ }
60
+ if (opts.maxAgeMs != null) {
61
+ conditions.push('created_at >= ?');
62
+ params.push(new Date(Date.now() - opts.maxAgeMs).toISOString());
63
+ }
64
+ if (opts.scopeFilter === 'default-deny') {
65
+ // Admit scope before LIMIT 1, else a newer denied row hides an older eligible one.
66
+ const deny = scopeAdmitSql('');
67
+ conditions.push(deny.sql);
68
+ params.push(...deny.params);
69
+ }
70
+ return { conditions, params };
71
+ }
39
72
  /** Load the most recent handoff, optionally filtered by session ID. */
40
- export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
73
+ export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}, key) {
41
74
  assertTenantId('loadLatestHandoff', tenantId);
42
75
  const db = openStore(hippoRoot);
43
76
  try {
44
- const conditions = ['tenant_id = ?'];
45
- const params = [tenantId];
46
- if (sessionId) {
47
- conditions.push('session_id = ?');
48
- params.push(sessionId);
49
- }
50
- if (opts.excludeSessionId) {
51
- conditions.push('session_id != ?');
52
- params.push(opts.excludeSessionId);
53
- }
54
- if (opts.unfinishedOnly) {
55
- // Restrict to each session's newest revision first: stampHandoffOutcome
56
- // only stamps the newest row, so an older null-outcome revision must not resurrect.
57
- conditions.push(`id IN (SELECT MAX(id) FROM session_handoffs WHERE tenant_id = ? GROUP BY session_id)`);
58
- params.push(tenantId);
59
- conditions.push(`(outcome IS NULL OR outcome IN ('partial','failure'))`);
60
- }
61
- if (opts.maxAgeMs != null) {
62
- conditions.push('created_at >= ?');
63
- params.push(new Date(Date.now() - opts.maxAgeMs).toISOString());
64
- }
65
- if (opts.scopeFilter === 'default-deny') {
66
- // Admit scope before LIMIT 1, else a newer denied row hides an older eligible one.
67
- const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
68
- conditions.push(`(scope IS NULL OR (scope NOT IN (${placeholders}) AND scope NOT LIKE '%:private:%'))`);
69
- params.push(...RECALL_DEFAULT_DENY_SCOPES);
70
- }
77
+ const { conditions, params } = handoffConditions(tenantId, sessionId, opts, key);
71
78
  // SAFETY: row's shape matches HANDOFF_COLUMNS.
72
79
  const row = db.prepare(`
73
80
  SELECT ${HANDOFF_COLUMNS}
@@ -123,11 +130,13 @@ export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
123
130
  }
124
131
  /** Auto-write a handoff at session-end from the session's active snapshot, else from `derived`, its transcript state.
125
132
  * @param evidence best-effort git state; outcome comes from the newest session_complete event.
126
- * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
127
- export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
133
+ * @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript or already holds `derived`. */
134
+ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null, key) {
128
135
  assertTenantId('writeSessionEndHandoff', tenantId);
129
- const active = loadActiveTaskSnapshot(hippoRoot, tenantId);
130
- const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId);
136
+ if (key)
137
+ continuityStamp(key); // a partial key is a caller bug, so fail loud rather than write nothing
138
+ const active = loadActiveTaskSnapshot(hippoRoot, tenantId, key);
139
+ const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId, {}, key);
131
140
  let snapshot;
132
141
  let handoffEvidence = evidence;
133
142
  if (active && active.session_id === sessionId) {
@@ -140,24 +149,13 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
140
149
  // Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
141
150
  if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
142
151
  return null;
152
+ // A retried session end reads the same transcript, so it must not add a second revision.
153
+ if (existing && existing.taskId === derived.task && existing.summary === derived.summary && existing.nextAction === derived.next_step)
154
+ return null;
143
155
  snapshot = { ...derived, scope: null };
144
156
  handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
145
157
  }
146
- const db = openHippoDb(hippoRoot);
147
- let outcome = null;
148
- try {
149
- // SAFETY: row's shape matches the single `content` column below.
150
- const completeEvent = db.prepare(`
151
- SELECT content FROM session_events
152
- WHERE tenant_id = ? AND session_id = ? AND event_type = 'session_complete'
153
- ORDER BY created_at DESC, id DESC LIMIT 1
154
- `).get(tenantId, sessionId);
155
- if (isHandoffOutcome(completeEvent?.content))
156
- outcome = completeEvent.content;
157
- }
158
- finally {
159
- closeHippoDb(db);
160
- }
158
+ const outcome = sessionOutcome(hippoRoot, tenantId, sessionId);
161
159
  // A same-task refresh carries forward envelope fields nobody cleared; a scope
162
160
  // mismatch must not leak private metadata into an unscoped envelope.
163
161
  const carryForward = existing != null && existing.taskId === snapshot.task
@@ -176,6 +174,21 @@ export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence,
176
174
  constraints: carryForward ? existing.constraints : undefined,
177
175
  targetRuntime: carryForward ? existing.targetRuntime : undefined,
178
176
  cardId: carryForward ? existing.cardId : undefined,
179
- });
177
+ }, key);
178
+ }
179
+ function sessionOutcome(hippoRoot, tenantId, sessionId) {
180
+ const db = openHippoDb(hippoRoot);
181
+ try {
182
+ // SAFETY: row's shape matches the single `content` column below.
183
+ const content = db.prepare(`
184
+ SELECT content FROM session_events
185
+ WHERE tenant_id = ? AND session_id = ? AND event_type = 'session_complete'
186
+ ORDER BY created_at DESC, id DESC LIMIT 1
187
+ `).get(tenantId, sessionId)?.content;
188
+ return isHandoffOutcome(content) ? content : null;
189
+ }
190
+ finally {
191
+ closeHippoDb(db);
192
+ }
180
193
  }
181
194
  //# sourceMappingURL=handoffs.js.map
@@ -24,7 +24,7 @@ export function loadIndex(hippoRoot) {
24
24
  export function saveIndex(hippoRoot, index) {
25
25
  const db = openStore(hippoRoot);
26
26
  try {
27
- db.exec('BEGIN');
27
+ db.exec('BEGIN IMMEDIATE');
28
28
  try {
29
29
  setMeta(db, 'last_retrieval_ids', JSON.stringify(index.last_retrieval_ids ?? []));
30
30
  setMeta(db, 'last_trace_id', index.last_trace_id ?? '');
@@ -52,7 +52,7 @@ export function rebuildIndex(hippoRoot) {
52
52
  const existingIds = new Set(db.prepare(`SELECT id FROM memories`).all().map((row) => row.id));
53
53
  const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot).filter((entry) => !existingIds.has(entry.id));
54
54
  if (legacyEntries.length > 0) {
55
- db.exec('BEGIN');
55
+ db.exec('BEGIN IMMEDIATE');
56
56
  try {
57
57
  // Guard with per-row skip, like bootstrapLegacyStore: a stale markdown mirror could resurrect a
58
58
  // rejected value here. Refusal audit is written inline because nothing rolls back on a skip.
@@ -6,7 +6,9 @@ export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentity
6
6
  export declare function isInitialized(hippoRoot: string): boolean;
7
7
  export declare function initStore(hippoRoot: string): void;
8
8
  /** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
9
- export declare function openStore(hippoRoot: string): DatabaseSyncLike;
9
+ export declare function openStore(hippoRoot: string, opts?: {
10
+ busyWaitMs?: number;
11
+ }): DatabaseSyncLike;
10
12
  /** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
11
13
  export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
12
14
  /** `meta` key set once no memory of a decision, incident or other object sits on the old flat 90 days. */
@@ -24,9 +24,9 @@ export function initStore(hippoRoot) {
24
24
  closeHippoDb(openStore(hippoRoot));
25
25
  }
26
26
  /** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
27
- export function openStore(hippoRoot) {
27
+ export function openStore(hippoRoot, opts) {
28
28
  ensureMirrorDirectories(hippoRoot);
29
- const db = openHippoDb(hippoRoot);
29
+ const db = openHippoDb(hippoRoot, opts);
30
30
  try {
31
31
  const bootstrapped = bootstrapLegacyStore(db, hippoRoot);
32
32
  if (bootstrapped) {
@@ -74,7 +74,7 @@ function bootstrapLegacyStore(db, hippoRoot) {
74
74
  const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
75
75
  if (legacyEntries.length === 0)
76
76
  return false;
77
- db.exec('BEGIN');
77
+ db.exec('BEGIN IMMEDIATE');
78
78
  try {
79
79
  importLegacyEntries(db, hippoRoot, legacyEntries);
80
80
  importLegacyIndexAndStats(db, hippoRoot);
@@ -15,17 +15,19 @@ import { type DatabaseSyncLike } from '../db.js';
15
15
  * surface (not re-exported from `src/index.ts`). Subject to change. */
16
16
  export type RecallScopeFilter = {
17
17
  mode: 'default-deny';
18
+ ownScope?: string;
18
19
  } | {
19
20
  mode: 'exact';
20
21
  value: string;
21
22
  } | {
22
23
  mode: 'default-deny-or-exact';
23
24
  value: string;
25
+ ownScope?: string;
24
26
  };
25
27
  /** One project name, or every name a project's rows carry. */
26
28
  export type OriginFilter = string | readonly string[];
27
- /** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set. */
28
- export declare function recallScopeFilter(requestedScope: string | undefined, mode: 'exact' | 'additive'): RecallScopeFilter;
29
+ /** 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, which only `ownScope` opens. */
30
+ export declare function recallScopeFilter(requestedScope: string | undefined, mode: 'exact' | 'additive', ownScope?: string): RecallScopeFilter;
29
31
  /**
30
32
  * Load likely search candidates directly from SQLite.
31
33
  * Uses FTS5 when available, falls back to LIKE matching, then full-store fallback.
@@ -38,7 +40,7 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
38
40
  * Recall-mode loader. Pushes the recall-side scope predicate into SQL so
39
41
  * `unknown:legacy` cannot leak via any consumer that hasn't remembered to re-filter.
40
42
  *
41
- * - `requestedScope` undefined / '': default-deny on `unknown:legacy`.
43
+ * - `requestedScope` undefined / '': default-deny on `unknown:legacy`, admitting `ownScope`, the caller's personal scope.
42
44
  * - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`.
43
45
  *
44
46
  * Private scopes: SQL applies a conservative `NOT LIKE '%:private:%'` before the LIMIT window so private
@@ -51,8 +53,8 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
51
53
  *
52
54
  * `tenantId` is optional because searchBothHybrid's is; undefined means no tenant filter.
53
55
  */
54
- export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter): MemoryEntry[];
55
- export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter): MemoryEntry[];
56
+ export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
57
+ export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean, originProjects?: OriginFilter, ownScope?: string): MemoryEntry[];
56
58
  /** Which rows the vector arm of hybrid search may add: the same tenant, scope and superseded rules as the lexical load. */
57
59
  export interface VectorCandidateSpec {
58
60
  tenantId?: string;
@@ -60,6 +62,7 @@ export interface VectorCandidateSpec {
60
62
  includeSuperseded: boolean;
61
63
  /** How many nearest rows to add; default 50. */
62
64
  limit?: number;
65
+ origin?: OriginFilter;
63
66
  }
64
67
  /** The rows nearest `queryVector` that pass `spec`, nearest first. */
65
68
  export declare function loadVectorCandidateEntries(hippoRoot: string, queryVector: readonly number[], spec: VectorCandidateSpec): MemoryEntry[];
@@ -1,7 +1,7 @@
1
1
  import { envForceLikePath } from '../env.js';
2
2
  import { isFtsAvailable, closeHippoDb } from '../db.js';
3
3
  import { tokenize } from '../tokenize.js';
4
- import { RECALL_DEFAULT_DENY_SCOPES } from '../recall-scope.js';
4
+ import { isPersonalScope, scopeAdmitSql } from '../recall-scope.js';
5
5
  import { RAREST_TERM_COUNT, rarestPromptTerms } from '../prompt-recall.js';
6
6
  import { log } from '../log.js';
7
7
  import { originInSql } from '../project-identity.js';
@@ -15,14 +15,11 @@ function recallScopeClause(col, scopeFilter) {
15
15
  return { sql: '', params: [] };
16
16
  if (scopeFilter.mode === 'exact')
17
17
  return { sql: ` AND ${col}scope = ?`, params: [scopeFilter.value] };
18
- // `IS NULL OR` admits NULL scopes, which NOT IN alone drops. NOT LIKE denies a superset of private scopes before the
19
- // window cut so private rows cannot starve admitted ones; the anchored JS regex stays the exact post-filter.
20
- const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
21
- const admitted = `${col}scope IS NULL OR (${col}scope NOT IN (${placeholders}) AND ${col}scope NOT LIKE '%:private:%')`;
18
+ const admit = scopeAdmitSql(col, scopeFilter.ownScope);
22
19
  if (scopeFilter.mode === 'default-deny')
23
- return { sql: ` AND (${admitted})`, params: [...RECALL_DEFAULT_DENY_SCOPES] };
20
+ return { sql: ` AND ${admit.sql}`, params: admit.params };
24
21
  // The trailing arm keeps a deliberately requested scope loadable, private or quarantined included.
25
- return { sql: ` AND (${admitted} OR ${col}scope = ?)`, params: [...RECALL_DEFAULT_DENY_SCOPES, scopeFilter.value] };
22
+ return { sql: ` AND (${admit.sql} OR ${col}scope = ?)`, params: [...admit.params, scopeFilter.value] };
26
23
  }
27
24
  // In SQL, not after the window cut, so other projects' matches cannot crowd the project's own rows out of the LIMIT.
28
25
  function withProject(scope, col, origin) {
@@ -32,11 +29,11 @@ function withProject(scope, col, origin) {
32
29
  const originProjects = [origin].flat();
33
30
  return { sql: `${scope.sql} AND (${col}origin_project = '' OR ${originInSql(originProjects, `${col}origin_project`)})`, params: [...scope.params, ...originProjects] };
34
31
  }
35
- /** Scope rule for recall: none requested is default-deny; 'exact' narrows to the request; 'additive' adds it to the default set. */
36
- export function recallScopeFilter(requestedScope, mode) {
37
- if (!requestedScope)
38
- return { mode: 'default-deny' };
39
- return mode === 'additive' ? { mode: 'default-deny-or-exact', value: requestedScope } : { mode: 'exact', value: requestedScope };
32
+ /** 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, which only `ownScope` opens. */
33
+ export function recallScopeFilter(requestedScope, mode, ownScope) {
34
+ if (!requestedScope || (mode === 'additive' && isPersonalScope(requestedScope)))
35
+ return { mode: 'default-deny', ownScope };
36
+ return mode === 'additive' ? { mode: 'default-deny-or-exact', value: requestedScope, ownScope } : { mode: 'exact', value: requestedScope };
40
37
  }
41
38
  const FTS_QUERY_SYNTAX_RE = /fts5: syntax error|unterminated string/i;
42
39
  function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded = true, originProjects) {
@@ -158,7 +155,7 @@ export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDI
158
155
  * Recall-mode loader. Pushes the recall-side scope predicate into SQL so
159
156
  * `unknown:legacy` cannot leak via any consumer that hasn't remembered to re-filter.
160
157
  *
161
- * - `requestedScope` undefined / '': default-deny on `unknown:legacy`.
158
+ * - `requestedScope` undefined / '': default-deny on `unknown:legacy`, admitting `ownScope`, the caller's personal scope.
162
159
  * - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`.
163
160
  *
164
161
  * Private scopes: SQL applies a conservative `NOT LIKE '%:private:%'` before the LIMIT window so private
@@ -171,10 +168,10 @@ export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDI
171
168
  *
172
169
  * `tenantId` is optional because searchBothHybrid's is; undefined means no tenant filter.
173
170
  */
174
- export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects) {
171
+ export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects, ownScope) {
175
172
  const db = openStore(hippoRoot);
176
173
  try {
177
- return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded, originProjects);
174
+ return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded, originProjects, ownScope);
178
175
  }
179
176
  finally {
180
177
  closeHippoDb(db);
@@ -182,12 +179,12 @@ export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH
182
179
  }
183
180
  // Split out so callers with an already-open db (the prompt-recall path) skip
184
181
  // the initStore+open/close cycle per store per call.
185
- export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects) {
186
- return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode), includeSuperseded, originProjects).map(rowToEntry);
182
+ export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true, originProjects, ownScope) {
183
+ return loadSearchRows(db, query, limit, tenantId, recallScopeFilter(requestedScope, explicitScopeMode, ownScope), includeSuperseded, originProjects).map(rowToEntry);
187
184
  }
188
185
  /** The rows nearest `queryVector` that pass `spec`, nearest first. */
189
186
  export function loadVectorCandidateEntries(hippoRoot, queryVector, spec) {
190
- const scope = recallScopeClause('m.', spec.scope);
187
+ const scope = withProject(recallScopeClause('m.', spec.scope), 'm.', spec.origin);
191
188
  const tenant = spec.tenantId !== undefined ? ' AND m.tenant_id = ?' : '';
192
189
  const current = spec.includeSuperseded ? '' : ' AND m.superseded_by IS NULL';
193
190
  const params = [...(spec.tenantId !== undefined ? [spec.tenantId] : []), ...scope.params];
@@ -1,13 +1,31 @@
1
1
  import { type TaskSnapshot, type SessionEvent } from './rows.js';
2
- export declare function saveActiveTaskSnapshot(hippoRoot: string, tenantId: string, snapshot: {
2
+ /** A shared-store caller's task state bucket: its owner and every name its project's rows carry. */
3
+ export interface ContinuityKey {
4
+ readonly owner: string;
5
+ readonly project: readonly string[];
6
+ }
7
+ export interface ContinuityFilter {
8
+ readonly sql: string;
9
+ readonly params: readonly string[];
10
+ }
11
+ export interface ContinuityStamp {
12
+ readonly owner: string;
13
+ readonly origin: string;
14
+ }
15
+ /** All or nothing: a key missing its owner or every project name matches no row, never the tenant's newest. */
16
+ export declare function continuityWhere(key: ContinuityKey): ContinuityFilter;
17
+ /** The columns a keyed write stamps; a partial key throws, since a row it wrote would match no reader. */
18
+ export declare function continuityStamp(key: ContinuityKey): ContinuityStamp;
19
+ type SnapshotInput = {
3
20
  task: string;
4
21
  summary: string;
5
22
  next_step: string;
6
23
  source?: string;
7
24
  session_id?: string | null;
8
25
  scope?: string | null;
9
- }): TaskSnapshot;
10
- export declare function loadActiveTaskSnapshot(hippoRoot: string, tenantId: string): TaskSnapshot | null;
26
+ };
27
+ export declare function saveActiveTaskSnapshot(hippoRoot: string, tenantId: string, snapshot: SnapshotInput, key?: ContinuityKey): TaskSnapshot;
28
+ export declare function loadActiveTaskSnapshot(hippoRoot: string, tenantId: string, key?: ContinuityKey): TaskSnapshot | null;
11
29
  /**
12
30
  * Default freshness bound for AMBIENT active-task-snapshot reads: 72h, chosen over 48h so
13
31
  * a Friday-evening orphan still offers continuity on Monday morning.
@@ -41,18 +59,18 @@ export declare const SNAPSHOT_AMBIENT_MAX_AGE_MS: number;
41
59
  export declare function loadFreshActiveTaskSnapshot(hippoRoot: string, tenantId: string, opts?: {
42
60
  maxAgeMs?: number;
43
61
  sessionId?: string | null;
44
- }): TaskSnapshot | null;
62
+ }, key?: ContinuityKey): TaskSnapshot | null;
45
63
  export declare function clearActiveTaskSnapshot(hippoRoot: string, tenantId: string, clearedStatus?: string): boolean;
46
64
  /**
47
65
  * Close the `active` task snapshot(s) owned by `sessionId`, for the
48
66
  * session-end death path.
49
- * Only one `active` row exists per tenant in practice (supersession happens
67
+ * Only one `active` row exists per tenant (per owner and project when keyed) in practice (supersession happens
50
68
  * at save), but the WHERE clause scopes on `session_id` too — not just
51
69
  * `status='active' AND tenant_id=?` — so an ending session can never close a
52
70
  * different, newer session's active snapshot. Returns the number of rows
53
71
  * closed (0 when no active row is owned by `sessionId`).
54
72
  */
55
- export declare function closeTaskSnapshotsForSession(hippoRoot: string, tenantId: string, sessionId: string, status?: string): number;
73
+ export declare function closeTaskSnapshotsForSession(hippoRoot: string, tenantId: string, sessionId: string, status?: string, key?: ContinuityKey): number;
56
74
  export declare function appendSessionEvent(hippoRoot: string, tenantId: string, event: {
57
75
  session_id: string;
58
76
  event_type: string;
@@ -79,4 +97,5 @@ export declare function findPromotableSessions(hippoRoot: string, tenantId: stri
79
97
  * already exists.
80
98
  */
81
99
  export declare function traceExistsForSession(hippoRoot: string, tenantId: string, session_id: string): boolean;
100
+ export {};
82
101
  //# sourceMappingURL=sessions.d.ts.map
@@ -1,22 +1,55 @@
1
+ import { isSharedStore } from '../config.js';
1
2
  import { closeHippoDb } from '../db.js';
3
+ import { raiseMinBinary } from '../db/meta.js';
4
+ import { originInSql } from '../project-identity.js';
2
5
  import { assertTenantId } from '../tenant.js';
3
6
  import { redactSecretsStrict } from '../secret-detect.js';
7
+ import { TASK_OWNER_MIN_BINARY } from '../version.js';
4
8
  import { rowToTaskSnapshot, rowToSessionEvent, } from './rows.js';
5
9
  import { writeActiveTaskMirror, removeActiveTaskMirror, writeRecentSessionMirror } from './mirrors.js';
6
10
  import { openStore } from './open.js';
7
- export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
11
+ /** All or nothing: a key missing its owner or every project name matches no row, never the tenant's newest. */
12
+ export function continuityWhere(key) {
13
+ const names = key.project.filter((n) => n !== '');
14
+ if (key.owner === '' || names.length === 0)
15
+ return { sql: '0', params: [] };
16
+ return { sql: `owner_subject = ? AND ${originInSql(names)}`, params: [key.owner, ...names] };
17
+ }
18
+ /** The columns a keyed write stamps; a partial key throws, since a row it wrote would match no reader. */
19
+ export function continuityStamp(key) {
20
+ const origin = key.project.find((n) => n !== '');
21
+ if (key.owner === '' || origin === undefined)
22
+ throw new Error('continuity key needs an owner and a project');
23
+ return { owner: key.owner, origin };
24
+ }
25
+ // An unkeyed write acts on the tenant's newest or every active row, which on a shared store belong to other owners.
26
+ function assertKeyedOnSharedStore(fn, hippoRoot, key) {
27
+ if (!key && isSharedStore(hippoRoot))
28
+ throw new Error(`${fn}: a shared store keeps one task snapshot per owner and project, so this write needs their continuity key`);
29
+ }
30
+ function insertSnapshot(db, tenantId, snapshot, now, stamp) {
31
+ const result = db.prepare(`
32
+ INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at, owner_subject, origin_project)
33
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
34
+ `).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now, stamp?.owner ?? null, stamp?.origin ?? null);
35
+ return Number(result.lastInsertRowid ?? 0);
36
+ }
37
+ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot, key) {
8
38
  assertTenantId('saveActiveTaskSnapshot', tenantId);
39
+ assertKeyedOnSharedStore('saveActiveTaskSnapshot', hippoRoot, key);
40
+ const stamp = key ? continuityStamp(key) : null;
41
+ const owned = key ? continuityWhere(key) : null;
9
42
  const db = openStore(hippoRoot);
10
43
  const now = new Date().toISOString();
11
44
  try {
12
- db.exec('BEGIN');
13
- db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active' AND tenant_id = ?`).run(now, tenantId);
14
- const result = db.prepare(`
15
- INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at)
16
- VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
17
- `).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
45
+ db.exec('BEGIN IMMEDIATE');
46
+ db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active' AND tenant_id = ?${owned ? ` AND ${owned.sql}` : ''}`)
47
+ .run(now, tenantId, ...(owned?.params ?? []));
48
+ const id = insertSnapshot(db, tenantId, snapshot, now, stamp);
49
+ // An older binary's tenant-wide supersede would close other owners' rows, so the first owner row shuts it out.
50
+ if (stamp)
51
+ raiseMinBinary(db, TASK_OWNER_MIN_BINARY);
18
52
  db.exec('COMMIT');
19
- const id = Number(result.lastInsertRowid ?? 0);
20
53
  // SAFETY: row's shape matches the ten columns named in the SELECT above.
21
54
  const row = db.prepare(`
22
55
  SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
@@ -27,7 +60,9 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
27
60
  throw new Error('Failed to reload saved active task snapshot');
28
61
  }
29
62
  const loaded = rowToTaskSnapshot(row);
30
- writeActiveTaskMirror(hippoRoot, tenantId, loaded);
63
+ // The mirror is one file per store, so only the local, unkeyed path writes it.
64
+ if (!key)
65
+ writeActiveTaskMirror(hippoRoot, tenantId, loaded);
31
66
  return loaded;
32
67
  }
33
68
  catch (error) {
@@ -43,24 +78,27 @@ export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
43
78
  closeHippoDb(db);
44
79
  }
45
80
  }
46
- export function loadActiveTaskSnapshot(hippoRoot, tenantId) {
81
+ export function loadActiveTaskSnapshot(hippoRoot, tenantId, key) {
47
82
  assertTenantId('loadActiveTaskSnapshot', tenantId);
83
+ const owned = key ? continuityWhere(key) : null;
48
84
  const db = openStore(hippoRoot);
49
85
  try {
50
86
  // SAFETY: row's shape matches the ten columns named in the SELECT above.
51
87
  const row = db.prepare(`
52
88
  SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
53
89
  FROM task_snapshots
54
- WHERE status = 'active' AND tenant_id = ?
90
+ WHERE status = 'active' AND tenant_id = ?${owned ? ` AND ${owned.sql}` : ''}
55
91
  ORDER BY updated_at DESC, id DESC
56
92
  LIMIT 1
57
- `).get(tenantId);
93
+ `).get(tenantId, ...(owned?.params ?? []));
58
94
  if (!row) {
59
- removeActiveTaskMirror(hippoRoot, tenantId);
95
+ if (!key)
96
+ removeActiveTaskMirror(hippoRoot, tenantId);
60
97
  return null;
61
98
  }
62
99
  const loaded = rowToTaskSnapshot(row);
63
- writeActiveTaskMirror(hippoRoot, tenantId, loaded);
100
+ if (!key)
101
+ writeActiveTaskMirror(hippoRoot, tenantId, loaded);
64
102
  return loaded;
65
103
  }
66
104
  finally {
@@ -103,8 +141,8 @@ function isNonEmptySessionId(value) {
103
141
  *
104
142
  * No SQL change — age derives from the existing `updated_at` column.
105
143
  */
106
- export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}) {
107
- const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
144
+ export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}, key) {
145
+ const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId, key);
108
146
  if (!snapshot)
109
147
  return null;
110
148
  const callerSessionId = opts.sessionId;
@@ -119,6 +157,7 @@ export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}) {
119
157
  }
120
158
  export function clearActiveTaskSnapshot(hippoRoot, tenantId, clearedStatus = 'cleared') {
121
159
  assertTenantId('clearActiveTaskSnapshot', tenantId);
160
+ assertKeyedOnSharedStore('clearActiveTaskSnapshot', hippoRoot, undefined);
122
161
  const db = openStore(hippoRoot);
123
162
  const now = new Date().toISOString();
124
163
  try {
@@ -139,18 +178,21 @@ export function clearActiveTaskSnapshot(hippoRoot, tenantId, clearedStatus = 'cl
139
178
  /**
140
179
  * Close the `active` task snapshot(s) owned by `sessionId`, for the
141
180
  * session-end death path.
142
- * Only one `active` row exists per tenant in practice (supersession happens
181
+ * Only one `active` row exists per tenant (per owner and project when keyed) in practice (supersession happens
143
182
  * at save), but the WHERE clause scopes on `session_id` too — not just
144
183
  * `status='active' AND tenant_id=?` — so an ending session can never close a
145
184
  * different, newer session's active snapshot. Returns the number of rows
146
185
  * closed (0 when no active row is owned by `sessionId`).
147
186
  */
148
- export function closeTaskSnapshotsForSession(hippoRoot, tenantId, sessionId, status = 'session-ended') {
187
+ export function closeTaskSnapshotsForSession(hippoRoot, tenantId, sessionId, status = 'session-ended', key) {
149
188
  assertTenantId('closeTaskSnapshotsForSession', tenantId);
189
+ if (key)
190
+ continuityStamp(key); // a partial key is a caller bug, so fail loud rather than close nothing
191
+ const owned = key ? continuityWhere(key) : null;
150
192
  const db = openStore(hippoRoot);
151
193
  const now = new Date().toISOString();
152
194
  try {
153
- const result = db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE status = 'active' AND tenant_id = ? AND session_id = ?`).run(status, now, tenantId, sessionId);
195
+ const result = db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE status = 'active' AND tenant_id = ? AND session_id = ?${owned ? ` AND ${owned.sql}` : ''}`).run(status, now, tenantId, sessionId, ...(owned?.params ?? []));
154
196
  return Number(result.changes ?? 0);
155
197
  }
156
198
  finally {
@@ -65,7 +65,7 @@ export interface RebuildPatch {
65
65
  }
66
66
  /**
67
67
  * Apply a rebuild result to a dirty summary. Atomic: one
68
- * prepared UPDATE statement plus syncFtsRow inside one SAVEPOINT.
68
+ * prepared UPDATE statement plus syncFtsRow inside one write scope.
69
69
  * WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser
70
70
  * becomes a no-op (no rebuild_count bump, no audit row).
71
71
  *