@namzu/cli 26.3.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +49 -34
  3. package/dist/bin.js +7 -0
  4. package/dist/bin.js.map +1 -1
  5. package/dist/checkpoints/store.d.ts +17 -4
  6. package/dist/checkpoints/store.d.ts.map +1 -1
  7. package/dist/checkpoints/store.js +20 -5
  8. package/dist/checkpoints/store.js.map +1 -1
  9. package/dist/cli.js +2 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/acp.d.ts +23 -1
  12. package/dist/commands/acp.d.ts.map +1 -1
  13. package/dist/commands/acp.js +41 -2
  14. package/dist/commands/acp.js.map +1 -1
  15. package/dist/commands/drain.d.ts +69 -2
  16. package/dist/commands/drain.d.ts.map +1 -1
  17. package/dist/commands/drain.js +214 -134
  18. package/dist/commands/drain.js.map +1 -1
  19. package/dist/commands/eval.js +1 -1
  20. package/dist/commands/eval.js.map +1 -1
  21. package/dist/commands/provider-wait.d.ts +5 -5
  22. package/dist/commands/provider-wait.d.ts.map +1 -1
  23. package/dist/commands/provider-wait.js +2 -2
  24. package/dist/commands/resident.d.ts.map +1 -1
  25. package/dist/commands/resident.js +2 -1
  26. package/dist/commands/resident.js.map +1 -1
  27. package/dist/commands/run-flags.d.ts +7 -7
  28. package/dist/commands/run-flags.js +4 -4
  29. package/dist/commands/run-stream.d.ts +16 -12
  30. package/dist/commands/run-stream.d.ts.map +1 -1
  31. package/dist/commands/run-stream.js +142 -98
  32. package/dist/commands/run-stream.js.map +1 -1
  33. package/dist/commands/run.d.ts +6 -4
  34. package/dist/commands/run.d.ts.map +1 -1
  35. package/dist/commands/run.js +129 -45
  36. package/dist/commands/run.js.map +1 -1
  37. package/dist/commands/serve.js +2 -2
  38. package/dist/commands/serve.js.map +1 -1
  39. package/dist/commands/state.js +8 -8
  40. package/dist/commands/state.js.map +1 -1
  41. package/dist/config/load.d.ts +1 -1
  42. package/dist/config/load.d.ts.map +1 -1
  43. package/dist/config/load.js +17 -5
  44. package/dist/config/load.js.map +1 -1
  45. package/dist/config/run-limits.d.ts +20 -8
  46. package/dist/config/run-limits.d.ts.map +1 -1
  47. package/dist/config/run-limits.js +28 -46
  48. package/dist/config/run-limits.js.map +1 -1
  49. package/dist/config/schema.d.ts +44 -19
  50. package/dist/config/schema.d.ts.map +1 -1
  51. package/dist/config/schema.js.map +1 -1
  52. package/dist/config/tool-result-screens.js +1 -1
  53. package/dist/context/directories.d.ts +37 -0
  54. package/dist/context/directories.d.ts.map +1 -0
  55. package/dist/context/directories.js +69 -0
  56. package/dist/context/directories.js.map +1 -0
  57. package/dist/context/environment.d.ts +55 -0
  58. package/dist/context/environment.d.ts.map +1 -1
  59. package/dist/context/environment.js +96 -0
  60. package/dist/context/environment.js.map +1 -1
  61. package/dist/context/project-tracker.d.ts +3 -3
  62. package/dist/context/project-tracker.d.ts.map +1 -1
  63. package/dist/context/project-tracker.js +3 -3
  64. package/dist/context/project-tracker.js.map +1 -1
  65. package/dist/context/project.d.ts +1 -1
  66. package/dist/context/project.js +1 -1
  67. package/dist/context/sandbox.d.ts +22 -3
  68. package/dist/context/sandbox.d.ts.map +1 -1
  69. package/dist/context/sandbox.js +42 -4
  70. package/dist/context/sandbox.js.map +1 -1
  71. package/dist/doctor/checks/chain.d.ts +1 -1
  72. package/dist/doctor/checks/chain.js +5 -5
  73. package/dist/doctor/checks/chain.js.map +1 -1
  74. package/dist/doctor/checks/invariants.d.ts +2 -2
  75. package/dist/doctor/checks/invariants.js +5 -5
  76. package/dist/doctor/checks/invariants.js.map +1 -1
  77. package/dist/doctor/checks/logging.js +1 -1
  78. package/dist/doctor/checks/logging.js.map +1 -1
  79. package/dist/doctor/checks/sandbox.js +1 -1
  80. package/dist/doctor/checks/sandbox.js.map +1 -1
  81. package/dist/doctor/checks/session-export.d.ts +1 -1
  82. package/dist/doctor/checks/session-export.js +2 -2
  83. package/dist/doctor/checks/session-export.js.map +1 -1
  84. package/dist/doctor/checks/telemetry.d.ts +1 -1
  85. package/dist/doctor/checks/telemetry.js +1 -1
  86. package/dist/doctor/registry.js +2 -2
  87. package/dist/doctor/registry.js.map +1 -1
  88. package/dist/exit-codes.d.ts +2 -2
  89. package/dist/exit-codes.js +2 -2
  90. package/dist/integrations/mcp/servers.d.ts +1 -1
  91. package/dist/integrations/providers/chain-capabilities.d.ts +6 -6
  92. package/dist/integrations/providers/chain-capabilities.js +8 -8
  93. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  94. package/dist/integrations/providers/register.d.ts +1 -1
  95. package/dist/integrations/providers/register.js +1 -1
  96. package/dist/integrations/providers/registry.d.ts +1 -1
  97. package/dist/integrations/resident/inspection.d.ts +4 -1
  98. package/dist/integrations/resident/inspection.d.ts.map +1 -1
  99. package/dist/integrations/resident/inspection.js +53 -34
  100. package/dist/integrations/resident/inspection.js.map +1 -1
  101. package/dist/integrations/resident/learning-storage.d.ts +7 -2
  102. package/dist/integrations/resident/learning-storage.d.ts.map +1 -1
  103. package/dist/integrations/resident/learning-storage.js +12 -8
  104. package/dist/integrations/resident/learning-storage.js.map +1 -1
  105. package/dist/integrations/resident/runner-store.d.ts +1 -1
  106. package/dist/integrations/resident/runner-store.d.ts.map +1 -1
  107. package/dist/integrations/resident/runner-store.js +5 -2
  108. package/dist/integrations/resident/runner-store.js.map +1 -1
  109. package/dist/integrations/resident/runner-worker.js +1 -0
  110. package/dist/integrations/resident/runner-worker.js.map +1 -1
  111. package/dist/integrations/resident/session-log-reads.d.ts +37 -0
  112. package/dist/integrations/resident/session-log-reads.d.ts.map +1 -0
  113. package/dist/integrations/resident/session-log-reads.js +125 -0
  114. package/dist/integrations/resident/session-log-reads.js.map +1 -0
  115. package/dist/integrations/resident/session-step.d.ts +2 -0
  116. package/dist/integrations/resident/session-step.d.ts.map +1 -1
  117. package/dist/integrations/resident/session-step.js +15 -13
  118. package/dist/integrations/resident/session-step.js.map +1 -1
  119. package/dist/integrations/resident/storage.d.ts +23 -1
  120. package/dist/integrations/resident/storage.d.ts.map +1 -1
  121. package/dist/integrations/resident/storage.js +60 -17
  122. package/dist/integrations/resident/storage.js.map +1 -1
  123. package/dist/integrations/resident/tool-evidence.d.ts +4 -2
  124. package/dist/integrations/resident/tool-evidence.d.ts.map +1 -1
  125. package/dist/integrations/resident/tool-evidence.js +36 -30
  126. package/dist/integrations/resident/tool-evidence.js.map +1 -1
  127. package/dist/integrations/resident/verification.d.ts +2 -2
  128. package/dist/integrations/resident/verification.d.ts.map +1 -1
  129. package/dist/integrations/resident/verification.js +4 -4
  130. package/dist/integrations/resident/verification.js.map +1 -1
  131. package/dist/integrations/sessions/checkpoint-view.d.ts +13 -0
  132. package/dist/integrations/sessions/checkpoint-view.d.ts.map +1 -0
  133. package/dist/integrations/sessions/checkpoint-view.js +48 -0
  134. package/dist/integrations/sessions/checkpoint-view.js.map +1 -0
  135. package/dist/integrations/sessions/context-inventory.js +1 -1
  136. package/dist/integrations/sessions/context-inventory.js.map +1 -1
  137. package/dist/integrations/sessions/conversation-search.d.ts +16 -20
  138. package/dist/integrations/sessions/conversation-search.d.ts.map +1 -1
  139. package/dist/integrations/sessions/conversation-search.js +305 -731
  140. package/dist/integrations/sessions/conversation-search.js.map +1 -1
  141. package/dist/integrations/sessions/evidence-page-validation.d.ts +4 -4
  142. package/dist/integrations/sessions/evidence-page-validation.d.ts.map +1 -1
  143. package/dist/integrations/sessions/evidence-page-validation.js +1 -1
  144. package/dist/integrations/sessions/evidence-page-validation.js.map +1 -1
  145. package/dist/integrations/sessions/evidence-recall.d.ts +40 -2
  146. package/dist/integrations/sessions/evidence-recall.d.ts.map +1 -1
  147. package/dist/integrations/sessions/evidence-recall.js +41 -97
  148. package/dist/integrations/sessions/evidence-recall.js.map +1 -1
  149. package/dist/integrations/sessions/store.d.ts +153 -85
  150. package/dist/integrations/sessions/store.d.ts.map +1 -1
  151. package/dist/integrations/sessions/store.js +414 -484
  152. package/dist/integrations/sessions/store.js.map +1 -1
  153. package/dist/integrations/sessions/task-context.d.ts +15 -2
  154. package/dist/integrations/sessions/task-context.d.ts.map +1 -1
  155. package/dist/integrations/sessions/task-context.js +34 -11
  156. package/dist/integrations/sessions/task-context.js.map +1 -1
  157. package/dist/integrations/sessions/transcript-export.d.ts +13 -6
  158. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  159. package/dist/integrations/sessions/transcript-export.js +148 -263
  160. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  161. package/dist/integrations/state/home.d.ts +4 -16
  162. package/dist/integrations/state/home.d.ts.map +1 -1
  163. package/dist/integrations/state/home.js +6 -42
  164. package/dist/integrations/state/home.js.map +1 -1
  165. package/dist/integrations/state/project.d.ts +6 -7
  166. package/dist/integrations/state/project.d.ts.map +1 -1
  167. package/dist/integrations/state/project.js +7 -9
  168. package/dist/integrations/state/project.js.map +1 -1
  169. package/dist/integrations/state/report.d.ts +27 -41
  170. package/dist/integrations/state/report.d.ts.map +1 -1
  171. package/dist/integrations/state/report.js +172 -464
  172. package/dist/integrations/state/report.js.map +1 -1
  173. package/dist/integrations/state/retention.d.ts +18 -0
  174. package/dist/integrations/state/retention.d.ts.map +1 -0
  175. package/dist/integrations/state/retention.js +18 -0
  176. package/dist/integrations/state/retention.js.map +1 -0
  177. package/dist/integrations/subagents/activity.d.ts +36 -31
  178. package/dist/integrations/subagents/activity.d.ts.map +1 -1
  179. package/dist/integrations/subagents/activity.js +27 -23
  180. package/dist/integrations/subagents/activity.js.map +1 -1
  181. package/dist/integrations/subagents/batches.d.ts +91 -0
  182. package/dist/integrations/subagents/batches.d.ts.map +1 -0
  183. package/dist/integrations/subagents/batches.js +172 -0
  184. package/dist/integrations/subagents/batches.js.map +1 -0
  185. package/dist/integrations/subagents/parent.d.ts +26 -11
  186. package/dist/integrations/subagents/parent.d.ts.map +1 -1
  187. package/dist/integrations/subagents/parent.js +13 -28
  188. package/dist/integrations/subagents/parent.js.map +1 -1
  189. package/dist/integrations/subagents/policy.d.ts +2 -2
  190. package/dist/integrations/subagents/policy.d.ts.map +1 -1
  191. package/dist/integrations/subagents/policy.js +2 -2
  192. package/dist/integrations/subagents/policy.js.map +1 -1
  193. package/dist/integrations/subagents/replay.d.ts +91 -26
  194. package/dist/integrations/subagents/replay.d.ts.map +1 -1
  195. package/dist/integrations/subagents/replay.js +216 -114
  196. package/dist/integrations/subagents/replay.js.map +1 -1
  197. package/dist/integrations/subagents/runtime.d.ts +50 -26
  198. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  199. package/dist/integrations/subagents/runtime.js +132 -126
  200. package/dist/integrations/subagents/runtime.js.map +1 -1
  201. package/dist/integrations/subagents/saved-agents.d.ts +57 -0
  202. package/dist/integrations/subagents/saved-agents.d.ts.map +1 -0
  203. package/dist/integrations/subagents/saved-agents.js +102 -0
  204. package/dist/integrations/subagents/saved-agents.js.map +1 -0
  205. package/dist/integrations/subagents/slash.d.ts +55 -0
  206. package/dist/integrations/subagents/slash.d.ts.map +1 -0
  207. package/dist/integrations/subagents/slash.js +45 -0
  208. package/dist/integrations/subagents/slash.js.map +1 -0
  209. package/dist/integrations/telemetry/session-export.d.ts +6 -6
  210. package/dist/integrations/telemetry/session-export.d.ts.map +1 -1
  211. package/dist/integrations/telemetry/session-export.js +2 -2
  212. package/dist/memory/presentation.d.ts +11 -0
  213. package/dist/memory/presentation.d.ts.map +1 -1
  214. package/dist/memory/presentation.js +25 -0
  215. package/dist/memory/presentation.js.map +1 -1
  216. package/dist/memory/store.d.ts +13 -7
  217. package/dist/memory/store.d.ts.map +1 -1
  218. package/dist/memory/store.js +37 -11
  219. package/dist/memory/store.js.map +1 -1
  220. package/dist/memory/typed.d.ts +116 -0
  221. package/dist/memory/typed.d.ts.map +1 -0
  222. package/dist/memory/typed.js +336 -0
  223. package/dist/memory/typed.js.map +1 -0
  224. package/dist/permissions/checks.d.ts +1 -1
  225. package/dist/permissions/checks.js +1 -1
  226. package/dist/permissions/headless-trust.d.ts +2 -2
  227. package/dist/permissions/headless-trust.d.ts.map +1 -1
  228. package/dist/permissions/headless-trust.js +3 -3
  229. package/dist/permissions/headless-trust.js.map +1 -1
  230. package/dist/permissions/mode.d.ts +4 -4
  231. package/dist/permissions/mode.js +1 -1
  232. package/dist/permissions/rules.d.ts +1 -1
  233. package/dist/permissions/rules.js +1 -1
  234. package/dist/termination.d.ts +75 -0
  235. package/dist/termination.d.ts.map +1 -0
  236. package/dist/termination.js +143 -0
  237. package/dist/termination.js.map +1 -0
  238. package/dist/tui/AgentExplorer.d.ts +2 -2
  239. package/dist/tui/AgentExplorer.js +4 -4
  240. package/dist/tui/AgentExplorer.js.map +1 -1
  241. package/dist/tui/App.d.ts +9 -1
  242. package/dist/tui/App.d.ts.map +1 -1
  243. package/dist/tui/App.js +452 -303
  244. package/dist/tui/App.js.map +1 -1
  245. package/dist/tui/LiveActivity.d.ts +1 -1
  246. package/dist/tui/LiveActivity.d.ts.map +1 -1
  247. package/dist/tui/PermissionOverlay.d.ts +8 -0
  248. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  249. package/dist/tui/PermissionOverlay.js +25 -2
  250. package/dist/tui/PermissionOverlay.js.map +1 -1
  251. package/dist/tui/Picker.js +1 -1
  252. package/dist/tui/agent.d.ts +208 -132
  253. package/dist/tui/agent.d.ts.map +1 -1
  254. package/dist/tui/agent.js +523 -313
  255. package/dist/tui/agent.js.map +1 -1
  256. package/dist/tui/conversation-evidence-view.js +3 -3
  257. package/dist/tui/conversation-evidence-view.js.map +1 -1
  258. package/dist/tui/conversation-history.d.ts +4 -21
  259. package/dist/tui/conversation-history.d.ts.map +1 -1
  260. package/dist/tui/conversation-history.js +4 -28
  261. package/dist/tui/conversation-history.js.map +1 -1
  262. package/dist/tui/index.d.ts.map +1 -1
  263. package/dist/tui/index.js +27 -1
  264. package/dist/tui/index.js.map +1 -1
  265. package/dist/tui/log-pane.js +1 -1
  266. package/dist/tui/markdownParser.d.ts +1 -1
  267. package/dist/tui/markdownParser.js +1 -1
  268. package/dist/tui/permission-review.d.ts +16 -0
  269. package/dist/tui/permission-review.d.ts.map +1 -1
  270. package/dist/tui/permission-review.js +25 -2
  271. package/dist/tui/permission-review.js.map +1 -1
  272. package/dist/tui/slashCommands.d.ts +29 -40
  273. package/dist/tui/slashCommands.d.ts.map +1 -1
  274. package/dist/tui/slashCommands.js +70 -61
  275. package/dist/tui/slashCommands.js.map +1 -1
  276. package/dist/tui/{run-interruption.d.ts → turn-interruption.d.ts} +4 -4
  277. package/dist/tui/turn-interruption.d.ts.map +1 -0
  278. package/dist/tui/{run-interruption.js → turn-interruption.js} +20 -20
  279. package/dist/tui/turn-interruption.js.map +1 -0
  280. package/dist/tui/turn-limits-settings.d.ts +39 -0
  281. package/dist/tui/turn-limits-settings.d.ts.map +1 -0
  282. package/dist/tui/{run-limits-settings.js → turn-limits-settings.js} +18 -18
  283. package/dist/tui/turn-limits-settings.js.map +1 -0
  284. package/dist/tui/types.d.ts +3 -3
  285. package/dist/tui/types.d.ts.map +1 -1
  286. package/dist/user-commands/store.d.ts +1 -1
  287. package/dist/user-commands/store.js +1 -1
  288. package/package.json +7 -7
  289. package/dist/integrations/sessions/compaction-evidence.d.ts +0 -5
  290. package/dist/integrations/sessions/compaction-evidence.d.ts.map +0 -1
  291. package/dist/integrations/sessions/compaction-evidence.js +0 -59
  292. package/dist/integrations/sessions/compaction-evidence.js.map +0 -1
  293. package/dist/integrations/sessions/database.d.ts +0 -5
  294. package/dist/integrations/sessions/database.d.ts.map +0 -1
  295. package/dist/integrations/sessions/database.js +0 -34
  296. package/dist/integrations/sessions/database.js.map +0 -1
  297. package/dist/integrations/sessions/paths.d.ts +0 -7
  298. package/dist/integrations/sessions/paths.d.ts.map +0 -1
  299. package/dist/integrations/sessions/paths.js +0 -14
  300. package/dist/integrations/sessions/paths.js.map +0 -1
  301. package/dist/integrations/sessions/run-discovery.d.ts +0 -24
  302. package/dist/integrations/sessions/run-discovery.d.ts.map +0 -1
  303. package/dist/integrations/sessions/run-discovery.js +0 -142
  304. package/dist/integrations/sessions/run-discovery.js.map +0 -1
  305. package/dist/integrations/sessions/turn-evidence.d.ts +0 -129
  306. package/dist/integrations/sessions/turn-evidence.d.ts.map +0 -1
  307. package/dist/integrations/sessions/turn-evidence.js +0 -517
  308. package/dist/integrations/sessions/turn-evidence.js.map +0 -1
  309. package/dist/integrations/subagents/history.d.ts +0 -26
  310. package/dist/integrations/subagents/history.d.ts.map +0 -1
  311. package/dist/integrations/subagents/history.js +0 -76
  312. package/dist/integrations/subagents/history.js.map +0 -1
  313. package/dist/integrations/subagents/runs.d.ts +0 -72
  314. package/dist/integrations/subagents/runs.d.ts.map +0 -1
  315. package/dist/integrations/subagents/runs.js +0 -155
  316. package/dist/integrations/subagents/runs.js.map +0 -1
  317. package/dist/tui/run-interruption.d.ts.map +0 -1
  318. package/dist/tui/run-interruption.js.map +0 -1
  319. package/dist/tui/run-limits-settings.d.ts +0 -39
  320. package/dist/tui/run-limits-settings.d.ts.map +0 -1
  321. package/dist/tui/run-limits-settings.js.map +0 -1
@@ -1,32 +1,46 @@
1
- import { sessionStore } from './database.js';
2
1
  /**
3
- * Conversation persistence for the TUI, built on the SDK's session
4
- * indexed SQLite session store. Each checkout is one Project (an immutable
5
- * root binding keeps its id stable across launches),
6
- * every conversation is a Session under a fixed CLI Topic, and the
7
- * conversation's messages are appended to the Session as turns complete.
2
+ * Conversation persistence for the CLI, built on the SDK's session log.
8
3
  *
9
- * This is what powers `/resume`: list recent sessions, load a chosen
10
- * session's messages, and keep chatting in it. New workspaces bind their
11
- * canonical checkout root to one Project below the application home.
12
- * Existing central bindings for individual working directories keep their history.
4
+ * Every conversation is one session: an append-only, hash-chained JSONL file
5
+ * at `$NAMZU_HOME/projects/<slug>/<session-id>.jsonl`. The log is the source
6
+ * of truth. The rebuildable index at `$NAMZU_HOME/index.sqlite` answers the
7
+ * questions a list needs (which sessions this project has, which one a
8
+ * desktop key names) and is refreshed from the log after every write here.
9
+ *
10
+ * The CLI writes only the records that happen outside a turn: the
11
+ * `session_started` that creates a conversation, `session_updated` for its
12
+ * title, archive flag and caller-side names, and the `compaction` record that
13
+ * seeds a fork with the history it copied. Messages and turns are appended by
14
+ * the kernel's turn recorder while `query()` runs, under the session lease.
15
+ *
16
+ * This is what powers `/resume`: list recent sessions, fold a chosen
17
+ * session's messages, and keep chatting in it. The project is the canonical
18
+ * checkout root, so every directory of one checkout shares its history.
13
19
  */
14
- import { createHash, randomBytes } from 'node:crypto';
15
- import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import { mkdirSync } from 'node:fs';
16
22
  import { realpath } from 'node:fs/promises';
17
- import { basename, join, resolve } from 'node:path';
23
+ import { basename, resolve } from 'node:path';
24
+ import { join } from 'node:path';
18
25
  import { isDeepStrictEqual } from 'node:util';
19
- import { DiskSessionGoalStore, asSessionId, asTopicId, isEntityId, requireOpenProject, } from '@namzu/sdk';
20
- import { restrictToOwner } from '../providers/credential-store.js';
26
+ import { DiskSessionGoalStore, DiskSessionLog, SessionPaths, asSessionId, asTopicId, ensureProject, foldSessionMessages, generateSessionId, isEntityId, openSessionIndex, readSessionLog, } from '@namzu/sdk';
21
27
  import { resolveNamzuHome } from '../state/home.js';
22
28
  import { loadIdentity } from '../state/identity.js';
23
29
  import { ensurePrivateStateDirectory } from '../state/private-directory.js';
24
- import { cliProjectRoot, findCliProject } from '../state/project.js';
25
- import { DiskConversationEvidence, } from './turn-evidence.js';
30
+ import { cliProjectRoot } from '../state/project.js';
31
+ /** Who created a conversation, as its `session_started.agent` records it. */
32
+ const CLI_AGENT = { id: 'namzu-cli', name: 'Namzu' };
33
+ /** How long a write outside a turn waits for a lease another writer holds. */
34
+ const LEASE_WAIT_MS = 5_000;
35
+ const LEASE_POLL_MS = 25;
36
+ const LEASE_TTL_MS = 30_000;
26
37
  /**
27
- * Select the existing central Project for this directory, or share the nearest
28
- * checkout's Project. A checkout is bounded by a `.git` file or directory, so
29
- * worktrees and nested repositories keep distinct state. Tool cwd is unchanged.
38
+ * Open (or initialize) the working directory's project: `projects/<slug>/`
39
+ * with its `project.json`, the installation's tenant, and the index.
40
+ *
41
+ * The project is the nearest checkout root (a `.git` file or directory), so
42
+ * worktrees and nested repositories keep distinct history while every
43
+ * directory of one checkout shares it. Tool cwd is unchanged.
30
44
  */
31
45
  export async function openSessions(cwd, options = {}) {
32
46
  const workingDirectory = await realpath(resolve(cwd));
@@ -35,436 +49,426 @@ export async function openSessions(cwd, options = {}) {
35
49
  ...(options.home !== undefined ? { home: options.home } : {}),
36
50
  ...(options.env !== undefined ? { env: options.env } : {}),
37
51
  }));
52
+ mkdirSync(root, { recursive: true });
38
53
  // The installation owns the tenant; the canonical checkout owns the Project.
39
54
  const tenantId = loadIdentity(root).tenantId;
40
- ensurePrivateStateDirectory(root, 'sessions');
41
- ensurePrivateStateDirectory(root, 'goals');
42
- const store = sessionStore(root);
43
- let project = await findCliProject(store, workingDirectory, tenantId);
44
- if (!project) {
45
- const projectRoot = cliProjectRoot(workingDirectory);
46
- try {
47
- project = await store.createProject({ tenantId, name: basename(projectRoot) || projectRoot, rootPath: projectRoot }, tenantId);
48
- }
49
- catch (error) {
50
- project = await findCliProject(store, workingDirectory, tenantId);
51
- if (!project)
52
- throw error;
53
- }
54
- }
55
- const projectId = project.id;
56
- const projectStateRoot = root;
57
- const controlRoot = ensurePrivateStateDirectory(projectStateRoot, 'cli');
55
+ const projectsDir = ensurePrivateStateDirectory(root, 'projects');
56
+ const project = await ensureProject({ home: root, cwd: cliProjectRoot(workingDirectory) });
57
+ ensurePrivateStateDirectory(projectsDir, project.slug);
58
+ const paths = new SessionPaths({ home: root, slug: project.slug });
59
+ const index = await openSessionIndex({
60
+ home: root,
61
+ ...(options.indexBackend ? { backend: options.indexBackend } : {}),
62
+ });
63
+ const partial = {
64
+ root,
65
+ paths,
66
+ slug: project.slug,
67
+ projectRoot: project.cwd,
68
+ projectId: project.projectId,
69
+ topicId: topicIdFor(project.projectId),
70
+ tenantId,
71
+ index,
72
+ };
73
+ const store = {
74
+ getSession: (sessionId, tenant) => readSessionEntity(partial, sessionId, tenant),
75
+ };
58
76
  return {
77
+ ...partial,
59
78
  store,
60
- goals: new DiskSessionGoalStore({ rootDir: root, sessions: store }),
61
- projectId,
62
- topicId: topicIdFor(projectId),
63
- tenantId,
64
- root,
65
- projectStateRoot,
66
- controlRoot,
67
- turnEvidence: new DiskConversationEvidence({ root, projectId }),
79
+ goals: new DiskSessionGoalStore({ rootDir: paths.projectDir(), sessions: store }),
68
80
  };
69
81
  }
82
+ /** Release the index's database handle. Idempotent. */
83
+ export function closeSessions(s) {
84
+ try {
85
+ s.index.close();
86
+ }
87
+ catch {
88
+ // Already closed.
89
+ }
90
+ }
70
91
  /** One stable CLI topic per logical workspace, without an extra binding file. */
71
92
  function topicIdFor(projectId) {
72
93
  const digest = createHash('sha256').update(`namzu:cli-topic:${projectId}`).digest('hex');
73
94
  return asTopicId(`${digest.slice(0, 8)}-${digest.slice(8, 12)}-8${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`);
74
95
  }
75
- // Maps an embedder's own session key (e.g. a desktop host's uuid) to a
76
- // namzu conversation id, so reopening that session resumes the same
77
- // transcript. Kept as a small JSON pointer beside cli.json.
78
- const DESKTOP_MAP = 'desktop-sessions.json';
79
- const DESKTOP_MAP_LOCK = `${DESKTOP_MAP}.lock`;
80
- const DESKTOP_MAP_LOCK_TIMEOUT_MS = 5_000;
81
- const DESKTOP_MAP_LOCK_POLL_MS = 10;
82
- function readDesktopMap(root) {
83
- const path = join(root, DESKTOP_MAP);
84
- let raw;
85
- try {
86
- raw = JSON.parse(readFileSync(path, 'utf8'));
87
- }
88
- catch (error) {
89
- if (error.code === 'ENOENT')
90
- return {};
91
- throw new Error(`Cannot read ${path}; refusing to replace an existing desktop-session map: ${error instanceof Error ? error.message : String(error)}`);
96
+ // ─── the log ──────────────────────────────────────────────────────────────
97
+ /** The file a root conversation's log lives in. */
98
+ export function conversationLogPath(s, sessionId) {
99
+ return s.paths.sessionLog({ sessionId });
100
+ }
101
+ /** A writer handle on one conversation's log. */
102
+ export function openConversationLog(s, sessionId) {
103
+ return DiskSessionLog.at(s.paths, { sessionId });
104
+ }
105
+ function holderName() {
106
+ return `namzu-cli:${process.pid}:${randomUUID()}`;
107
+ }
108
+ /**
109
+ * Take a conversation's writer lease, waiting briefly for another writer.
110
+ *
111
+ * A turn holds the lease while it runs, so a write between turns only ever
112
+ * waits for a turn that is settling. One that is still running when the wait
113
+ * ends is refused by name rather than waited on indefinitely.
114
+ */
115
+ async function claimLease(log, op) {
116
+ const deadline = Date.now() + LEASE_WAIT_MS;
117
+ const holder = holderName();
118
+ for (;;) {
119
+ const lease = await log.claim({ holder, ttlMs: LEASE_TTL_MS });
120
+ if (lease)
121
+ return lease;
122
+ if (Date.now() >= deadline) {
123
+ throw new Error(`Conversation ${log.sessionId} is busy: another writer holds it — ${op} rejected. Wait for its turn to finish and try again.`);
124
+ }
125
+ await new Promise((resolveWait) => setTimeout(resolveWait, LEASE_POLL_MS));
92
126
  }
93
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
94
- throw new Error(`Cannot read ${path}; its top level must be an object. Refusing to replace the existing desktop-session map.`);
127
+ }
128
+ /** Append records outside any turn, under a lease taken for just these records. */
129
+ async function appendOutsideTurn(s, sessionId, drafts, op) {
130
+ const log = openConversationLog(s, sessionId);
131
+ const lease = await claimLease(log, op);
132
+ try {
133
+ for (const draft of drafts)
134
+ await log.append(lease, draft);
95
135
  }
96
- const map = {};
97
- for (const [key, value] of Object.entries(raw)) {
98
- if (!isEntityId(value, 'session')) {
99
- throw new Error(`Cannot read ${path}; desktop session ${JSON.stringify(key)} does not name a Session id. Refusing to replace the existing map.`);
100
- }
101
- map[key] = value;
136
+ finally {
137
+ await log.release(lease);
102
138
  }
103
- return map;
139
+ await refreshIndex(s, sessionId);
104
140
  }
105
- function wait(ms) {
106
- return new Promise((resolveWait) => setTimeout(resolveWait, ms));
141
+ /** Bring the index's row for one conversation up to date with its log. */
142
+ export async function refreshIndex(s, sessionId) {
143
+ await s.index.refresh({ slug: s.slug, logPath: conversationLogPath(s, sessionId), sessionId });
107
144
  }
108
145
  /**
109
- * Hold one process-wide lease while mutating the shared desktop map.
146
+ * Read one conversation's log, or `null` when there is none.
110
147
  *
111
- * `open(..., "wx")` is the filesystem's exclusive-create primitive, so two
112
- * Namzu processes cannot both perform a stale read-modify-write. A crashed
113
- * owner deliberately leaves a lock behind: guessing that it is stale and
114
- * deleting it automatically would reintroduce the race this lease prevents.
148
+ * Strict by default: a log whose chain is broken is not something to resume
149
+ * or fork. A list passes `tolerant` and reads what precedes the break.
115
150
  */
116
- async function acquireDesktopMapLock(root) {
117
- mkdirSync(root, { recursive: true });
118
- const path = join(root, DESKTOP_MAP_LOCK);
119
- const token = `${process.pid}:${randomBytes(12).toString('hex')}`;
120
- const deadline = Date.now() + DESKTOP_MAP_LOCK_TIMEOUT_MS;
121
- for (;;) {
122
- let descriptor;
123
- try {
124
- descriptor = openSync(path, 'wx', 0o600);
125
- writeFileSync(descriptor, `${token}\n`, 'utf8');
126
- fsyncSync(descriptor);
127
- closeSync(descriptor);
128
- descriptor = undefined;
129
- if (process.platform !== 'win32')
130
- chmodSync(path, 0o600);
131
- restrictToOwner(path);
132
- return () => {
133
- try {
134
- if (readFileSync(path, 'utf8').trim() === token)
135
- rmSync(path, { force: true });
151
+ export async function readConversationFacts(s, sessionId, mode = 'strict') {
152
+ const read = await readSessionLog(conversationLogPath(s, sessionId), { mode, sessionId });
153
+ const records = read.entries.map((entry) => entry.record);
154
+ const first = records[0];
155
+ if (first === undefined || first.type !== 'session_started')
156
+ return null;
157
+ let title;
158
+ let named = false;
159
+ let archived = false;
160
+ let active;
161
+ for (const record of records) {
162
+ switch (record.type) {
163
+ case 'session_updated':
164
+ if (record.title !== undefined) {
165
+ title = record.title;
166
+ named = record.titleSource === 'named' && record.title.length > 0;
136
167
  }
137
- catch (error) {
138
- if (error.code !== 'ENOENT')
139
- throw error;
140
- }
141
- };
142
- }
143
- catch (error) {
144
- if (descriptor !== undefined)
145
- closeSync(descriptor);
146
- if (error.code !== 'EEXIST') {
147
- // We may have created the path and then failed to initialize it. It
148
- // cannot be mistaken for somebody else's lock because exclusive
149
- // create succeeded in this branch.
150
- try {
151
- if (readFileSync(path, 'utf8').trim() === token)
152
- rmSync(path, { force: true });
153
- }
154
- catch {
155
- // Preserve the original acquisition error.
156
- }
157
- throw error;
158
- }
159
- if (Date.now() >= deadline) {
160
- throw new Error(`Timed out waiting for ${path}. Another Namzu process may be updating desktop session bindings. If no Namzu process is running, inspect and remove this stale lock manually.`);
161
- }
162
- await wait(DESKTOP_MAP_LOCK_POLL_MS);
168
+ if (record.archived !== undefined)
169
+ archived = record.archived;
170
+ break;
171
+ case 'turn_started':
172
+ active = { turnId: record.turnId, paused: false };
173
+ break;
174
+ case 'turn_paused':
175
+ if (active?.turnId === record.turnId)
176
+ active = { ...active, paused: true };
177
+ break;
178
+ case 'turn_resuming':
179
+ if (active?.turnId === record.turnId)
180
+ active = { ...active, paused: false };
181
+ break;
182
+ case 'turn_completed':
183
+ case 'turn_failed':
184
+ if (active?.turnId === record.turnId)
185
+ active = undefined;
186
+ break;
163
187
  }
164
188
  }
189
+ return {
190
+ sessionId,
191
+ started: first,
192
+ ...(title !== undefined ? { title } : {}),
193
+ named,
194
+ archived,
195
+ createdAt: first.ts,
196
+ updatedAt: records.at(-1)?.ts ?? first.ts,
197
+ ...(active ? { activeTurn: active } : {}),
198
+ records,
199
+ };
200
+ }
201
+ /** The folded conversation a log's records describe, spills read back. */
202
+ async function foldConversation(s, sessionId, records) {
203
+ const log = openConversationLog(s, sessionId);
204
+ return await foldSessionMessages(records, { readSpill: (ref) => log.readSpill(ref) });
205
+ }
206
+ async function readSessionEntity(s, sessionId, tenantId) {
207
+ if (!isEntityId(sessionId, 'session'))
208
+ return null;
209
+ const facts = await readConversationFacts(s, sessionId, 'tolerant');
210
+ if (!facts)
211
+ return null;
212
+ const recordedTenant = facts.started.tenantId;
213
+ if (recordedTenant !== undefined && recordedTenant !== tenantId)
214
+ return null;
215
+ return {
216
+ id: sessionId,
217
+ topicId: facts.started.topicId ?? topicIdFor(facts.started.projectId),
218
+ projectId: facts.started.projectId,
219
+ tenantId,
220
+ status: facts.archived ? 'archived' : 'idle',
221
+ currentActor: null,
222
+ previousActors: [],
223
+ workspaceId: null,
224
+ ownerVersion: 0,
225
+ createdAt: new Date(facts.createdAt),
226
+ updatedAt: new Date(facts.updatedAt),
227
+ };
165
228
  }
166
229
  /**
167
- * Resolve (creating if needed) the namzu conversation bound to an embedder's
168
- * session key. The mapping persists so a later turn / a history load with the
169
- * same key reuses the same conversation. Falls back to a fresh conversation if
170
- * the mapped id was wiped.
230
+ * Start a fresh conversation; returns its session id.
231
+ *
232
+ * Writes the log's first record, `session_started`, naming the project, the
233
+ * tenant and the CLI topic. An id that already has a log is refused: two
234
+ * conversations never share one.
171
235
  */
172
- export async function resolveConversation(s, key) {
173
- const existing = await resolveExistingConversation(s, key, readDesktopMap(s.controlRoot));
174
- if (existing) {
175
- await requireWritableConversation(s, existing, 'continue keyed conversation');
176
- return existing;
177
- }
178
- const release = await acquireDesktopMapLock(s.controlRoot);
236
+ export async function startConversation(s, idOrOptions) {
237
+ const options = typeof idOrOptions === 'string' ? { id: idOrOptions } : (idOrOptions ?? {});
238
+ const id = options.id ?? generateSessionId();
239
+ const log = openConversationLog(s, id);
240
+ const lease = await claimLease(log, 'start conversation');
179
241
  try {
180
- // A different process may have published this key while we waited.
181
- const map = readDesktopMap(s.controlRoot);
182
- const winner = await resolveExistingConversation(s, key, map);
183
- if (winner) {
184
- await requireWritableConversation(s, winner, 'continue keyed conversation');
185
- return winner;
242
+ if ((await log.head()) !== null) {
243
+ throw new Error(`Conversation ${id} already exists — start conversation rejected.`);
186
244
  }
187
- const id = await startConversation(s);
188
- map[JSON.stringify([s.projectId, key])] = id;
189
- writePrivateJson(s.controlRoot, DESKTOP_MAP, map);
190
- return id;
245
+ await log.append(lease, {
246
+ type: 'session_started',
247
+ projectId: s.projectId,
248
+ tenantId: s.tenantId,
249
+ topicId: s.topicId,
250
+ cwd: s.projectRoot,
251
+ agent: { ...CLI_AGENT },
252
+ origin: options.origin ?? { protocol: 'cli' },
253
+ });
191
254
  }
192
255
  finally {
193
- release();
256
+ await log.release(lease);
194
257
  }
258
+ await refreshIndex(s, id);
259
+ return id;
195
260
  }
196
- /** Read an external-session binding without creating or widening its scope. */
197
- export async function findMappedConversation(s, key) {
198
- return await resolveExistingConversation(s, key, readDesktopMap(s.controlRoot));
199
- }
200
- async function resolveExistingConversation(s, key, map) {
201
- const existing = map[JSON.stringify([s.projectId, key])];
202
- // readDesktopMap has validated the binding. A missing session may be
203
- // recreated, but malformed identity metadata must never mint a replacement.
204
- if (existing !== undefined) {
205
- const mapped = asSessionId(existing);
206
- const session = await s.store.getSession(mapped, s.tenantId);
207
- if (session?.projectId === s.projectId)
208
- return mapped;
261
+ /**
262
+ * Give a session log its `session_started` if it has none yet, so a turn can
263
+ * begin in it. A log that already starts is left alone. Used where a session
264
+ * is brought into existence by its first turn rather than by
265
+ * {@link startConversation}: a headless scope, an in-memory log.
266
+ */
267
+ export async function ensureSessionStarted(log, session) {
268
+ if ((await log.head()) !== null)
269
+ return;
270
+ const lease = await claimLease(log, 'start session');
271
+ try {
272
+ if ((await log.head()) !== null)
273
+ return;
274
+ await log.append(lease, {
275
+ type: 'session_started',
276
+ projectId: session.projectId,
277
+ tenantId: session.tenantId,
278
+ topicId: session.topicId,
279
+ cwd: session.cwd,
280
+ agent: { ...session.agent },
281
+ origin: session.origin ?? { protocol: 'cli' },
282
+ });
283
+ }
284
+ finally {
285
+ await log.release(lease);
209
286
  }
210
- return null;
287
+ }
288
+ /** The external id a desktop key is filed under: scoped to the project, like the map it replaces. */
289
+ function desktopExternalId(projectId, key) {
290
+ return JSON.stringify([projectId, key]);
211
291
  }
212
292
  /**
213
- * Start a fresh conversation; returns its session id.
214
- *
215
- * The workspace gate runs HERE rather than being assumed from the caller,
216
- * because this is a store call and a store deliberately holds no view of
217
- * workspace status — the SDK's own note says a direct store caller bypasses
218
- * the invariant, and this was such a caller.
219
- *
220
- * `openSessions` reuses the central root-path binding across launches. Its
221
- * Project may have been closed since it was created, so session creation
222
- * checks that Project's current status again.
293
+ * Resolve (creating if needed) the conversation bound to an embedder's
294
+ * session key. The binding is the conversation's own `session_started.origin`,
295
+ * so a later turn or history load with the same key reuses it, and an index
296
+ * rebuild finds it again.
223
297
  */
224
- export async function startConversation(s, id) {
225
- const created = await createConversation(s, id);
226
- await s.turnEvidence?.recordOrigin(created, { kind: 'new' });
227
- return created;
298
+ export async function resolveConversation(s, key) {
299
+ const existing = await findMappedConversation(s, key);
300
+ if (existing) {
301
+ await requireWritableConversation(s, existing, 'continue keyed conversation');
302
+ return existing;
303
+ }
304
+ const id = await startConversation(s, {
305
+ origin: { protocol: 'desktop', externalSessionId: desktopExternalId(s.projectId, key) },
306
+ });
307
+ // Another process may have claimed the key while this one was creating. The
308
+ // earliest claim wins the name; a loser archives its empty conversation so it
309
+ // never shows up in `/resume` as a second, indistinguishable row.
310
+ const winner = await findMappedConversation(s, key);
311
+ if (winner && winner !== id) {
312
+ await archiveConversation(s, id).catch(() => undefined);
313
+ await requireWritableConversation(s, winner, 'continue keyed conversation');
314
+ return winner;
315
+ }
316
+ return id;
228
317
  }
229
- async function createConversation(s, id) {
230
- await requireOpenProject(s.store, s.projectId, s.tenantId, 'cli-session');
231
- const session = await s.store.createSession({ ...(id ? { id } : {}), topicId: s.topicId, projectId: s.projectId, currentActor: null }, s.tenantId);
232
- return session.id;
318
+ /** Read an external-session binding without creating or widening its scope. */
319
+ export async function findMappedConversation(s, key) {
320
+ const target = await s.index.resolveExternal('desktop', 'session', desktopExternalId(s.projectId, key));
321
+ if (!target)
322
+ return null;
323
+ const facts = await readConversationFacts(s, target.sessionId, 'tolerant');
324
+ return facts?.started.projectId === s.projectId ? target.sessionId : null;
233
325
  }
234
326
  /**
235
- * Resolve a conversation through the cwd-owned Project and CLI Topic.
327
+ * Resolve a conversation through this project.
236
328
  *
237
- * `SessionId` is globally locatable inside a store root, so successfully
238
- * loading one proves existence and tenant ownership, not that it belongs to
239
- * this `CliSessions` handle. The fixed CLI topic id makes the Project check
240
- * load-bearing: two Projects in one root can legitimately carry the same
241
- * topic id.
329
+ * A session id names a file under this project's directory, so a log that
330
+ * exists proves the conversation is this project's; the project id it
331
+ * recorded must also agree, and so must the tenant.
242
332
  */
243
333
  async function requireConversationInScope(s, sessionId, op) {
244
- const session = await s.store.getSession(sessionId, s.tenantId);
245
- if (!session) {
334
+ const facts = await readConversationFacts(s, sessionId);
335
+ if (!facts) {
246
336
  throw new Error(`Conversation ${sessionId} was not found — ${op} rejected`);
247
337
  }
248
- if (session.projectId !== s.projectId) {
338
+ if (facts.started.projectId !== s.projectId ||
339
+ (facts.started.tenantId !== undefined && facts.started.tenantId !== s.tenantId)) {
249
340
  throw new Error(`Conversation ${sessionId} does not belong to this workspace — ${op} rejected`);
250
341
  }
251
- return session;
342
+ return facts;
252
343
  }
253
344
  /**
254
- * Sequential admission gate for a turn or conversation mutation.
255
- *
256
- * This establishes the state observed immediately before the operation. It is
257
- * deliberately not described as a cross-process transaction: Project archive
258
- * and Session mutation live in separate store records, so serializing an
259
- * archive racing a live turn requires a durable lease shared by both paths.
260
- * What this gate does guarantee is that a target already closed, archived or
261
- * outside the current Project never silently reaches the requested operation.
345
+ * Sequential admission gate for a turn or conversation mutation: the target
346
+ * exists in this project and is not archived. It is not a cross-process
347
+ * transaction; the session lease is what serialises writers.
262
348
  */
263
349
  export async function requireWritableConversation(s, sessionId, op = 'continue conversation') {
264
- const session = await requireConversationInScope(s, sessionId, op);
265
- await requireOpenProject(s.store, s.projectId, s.tenantId, op);
266
- if (session.status === 'archived') {
350
+ const facts = await requireConversationInScope(s, sessionId, op);
351
+ if (facts.archived) {
267
352
  throw new Error(`Conversation ${sessionId} is archived and read-only — ${op} rejected. Its history remains available for inspection.`);
268
353
  }
269
354
  }
270
355
  /**
271
- * Turn the active conversation into a read-only tombstone.
272
- *
273
- * The caller owns the live-turn barrier; this function owns store scope and a
274
- * versioned publication. History remains readable, while `/resume`, later
275
- * appends and forks reject through {@link requireWritableConversation}.
356
+ * Turn the conversation into a read-only tombstone with `session_updated
357
+ * {archived: true}`. History remains readable, while `/resume`, later turns
358
+ * and forks reject through {@link requireWritableConversation}.
276
359
  */
277
360
  export async function archiveConversation(s, sessionId) {
278
- const session = await requireConversationInScope(s, sessionId, 'archive conversation');
279
- await requireOpenProject(s.store, s.projectId, s.tenantId, 'archive conversation');
280
- if (session.status === 'archived') {
361
+ const facts = await requireConversationInScope(s, sessionId, 'archive conversation');
362
+ if (facts.archived) {
281
363
  throw new Error(`Conversation ${sessionId} is already archived.`);
282
364
  }
283
- await s.store.updateSession({ ...session, status: 'archived', ownerVersion: session.ownerVersion + 1 }, s.tenantId, session.ownerVersion);
284
- }
285
- /** Append messages (in order) to a conversation. */
286
- export async function appendMessages(s, sessionId, messages) {
287
- await requireWritableConversation(s, sessionId, 'append conversation messages');
288
- for (const m of messages) {
289
- await s.store.appendMessage(sessionId, m, s.tenantId);
290
- }
291
- }
292
- /**
293
- * Replace the durable conversation view with a compacted history.
294
- *
295
- * The store writes this as one replacement record inside its append-only log,
296
- * so a crash cannot expose the first half of a compacted conversation. Before
297
- * replacing, pin the derived title without calling it a chosen name: the
298
- * opening user message may be among the turns compacted away, and `/resume`
299
- * must neither rename nor quote the conversation as a side effect of making
300
- * it smaller.
301
- */
302
- export async function replaceConversation(s, sessionId, messages) {
303
- await requireWritableConversation(s, sessionId, 'replace conversation history');
304
- const existing = await loadConversation(s, sessionId);
305
- const titles = readTitles(s.controlRoot);
306
- if ((titles[sessionId] === undefined ||
307
- (!titles[sessionId]?.named &&
308
- titles[sessionId]?.title === 'Conversation')) &&
309
- conversationTitle(existing) !== 'Conversation') {
310
- titles[sessionId] = {
311
- title: conversationTitle(existing),
312
- named: false,
313
- };
314
- writeTitles(s.controlRoot, titles);
315
- }
316
- await s.store.replaceMessages(sessionId, messages, s.tenantId);
365
+ await appendOutsideTurn(s, sessionId, [{ type: 'session_updated', archived: true }], 'archive conversation');
317
366
  }
318
- /** Load a conversation's full message history. */
367
+ /** Load a conversation's folded message history. */
319
368
  export async function loadConversation(s, sessionId) {
320
- await requireConversationInScope(s, sessionId, 'load conversation history');
321
- return [...(await s.store.loadMessages(sessionId, s.tenantId))];
369
+ const facts = await requireConversationInScope(s, sessionId, 'load conversation history');
370
+ return await foldConversation(s, sessionId, facts.records);
322
371
  }
323
372
  /**
324
373
  * Load one conversation for a new model turn.
325
374
  *
326
375
  * Reading an archived conversation remains legitimate — `history` and export
327
376
  * are inspection surfaces — but resuming it would turn a tombstone back into a
328
- * live writer without a restore operation. The Project gate is separate for
329
- * the same reason: closing a workspace must not make its history disappear,
330
- * while it must stop a later turn from starting there.
377
+ * live writer without a restore operation.
331
378
  */
332
379
  export async function loadResumableConversation(s, sessionId) {
333
380
  const checked = asSessionId(sessionId);
334
381
  await requireWritableConversation(s, checked, 'resume conversation');
335
- return [...(await s.store.loadMessages(checked, s.tenantId))];
382
+ return await loadConversation(s, checked);
383
+ }
384
+ /** The open turn of a conversation, if it has one: `paused` when it is parked. */
385
+ export async function activeConversationTurn(s, sessionId) {
386
+ return (await requireConversationInScope(s, sessionId, 'inspect conversation')).activeTurn;
336
387
  }
337
388
  /** Recent non-empty conversations, newest first — for the `/resume` list. */
338
389
  export async function listRecent(s, limit = 20) {
339
- await requireOpenProject(s.store, s.projectId, s.tenantId, 'list resumable conversations');
340
- const sessions = s.store.listSessionsByProject
341
- ? await s.store.listSessionsByProject(s.projectId, s.tenantId)
342
- : await s.store.listSessionsByTopic(s.topicId, s.tenantId);
343
- const titles = readTitles(s.controlRoot);
390
+ const rows = await s.index.listSessions({ slug: s.slug, rootsOnly: true, includeArchived: false });
344
391
  const out = [];
345
- for (const sess of sessions) {
346
- // The CLI Topic id is intentionally stable, so a store root that contains
347
- // an older Project can contain the same topic id more than once. The cwd's
348
- // selected Project — `s.projectId` — is the authority; Topic membership
349
- // alone is not. Archived Session records are tombstones, not resume rows.
350
- if (sess.projectId !== s.projectId || sess.topicId !== s.topicId || sess.status === 'archived')
392
+ for (const row of rows) {
393
+ if (row.projectId !== s.projectId || row.archived)
394
+ continue;
395
+ let facts;
396
+ let messages;
397
+ try {
398
+ facts = await readConversationFacts(s, row.id, 'tolerant');
399
+ if (!facts || facts.archived || facts.started.projectId !== s.projectId)
400
+ continue;
401
+ messages = await foldConversation(s, row.id, facts.records);
402
+ }
403
+ catch {
404
+ // A log this process cannot read is not a row a person can resume.
351
405
  continue;
352
- const messages = await s.store.loadMessages(sess.id, s.tenantId);
406
+ }
353
407
  if (messages.length === 0)
354
408
  continue;
355
- const stored = titles[sess.id];
409
+ const everything = recordedMessages(facts.records);
356
410
  out.push({
357
- id: sess.id,
358
- title: stored && (stored.named || stored.title !== 'Conversation')
359
- ? stored.title
360
- : conversationTitle(messages),
361
- preview: [...messages]
411
+ id: row.id,
412
+ title: facts.title !== undefined &&
413
+ facts.title.length > 0 &&
414
+ (facts.named || facts.title !== 'Conversation')
415
+ ? facts.title
416
+ : conversationTitle(everything),
417
+ preview: [...everything]
362
418
  .reverse()
363
419
  .find((message) => message.role === 'user' && message.source === undefined)
364
420
  ?.content.replace(/\s+/g, ' ')
365
421
  .trim()
366
422
  .slice(0, 240),
367
- named: stored?.named ?? false,
368
- updatedAt: toIso(sess.updatedAt),
423
+ named: facts.named,
424
+ updatedAt: facts.updatedAt,
369
425
  count: messages.length,
370
426
  });
371
427
  }
372
428
  return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
373
429
  }
374
- /**
375
- * Conversation titles that must survive their opening message, in a file
376
- * beside the sessions. `named` distinguishes a person's choice from a derived
377
- * title pinned before compaction removes the message it came from.
378
- *
379
- * A sidecar rather than a field on the SDK's `Session`. Naming a conversation
380
- * is an operator-application concern: the kernel has no view that lists them
381
- * and nothing in it would read the name. Putting it in the entity would widen
382
- * a store interface every host implements, to carry a string only this package
383
- * writes and only this package displays.
384
- *
385
- * The cost is that the two can disagree — a session deleted outside this
386
- * process leaves its name behind. That is why nothing here treats the file as
387
- * a list of sessions: it is consulted BY id, from a list the store produced,
388
- * so a stale entry is never reachable and never has to be reconciled.
389
- */
390
- const TITLES_FILE = 'titles.json';
391
- function titlesPath(root) {
392
- return join(root, TITLES_FILE);
393
- }
394
- function readTitles(root) {
395
- try {
396
- const parsed = JSON.parse(readFileSync(titlesPath(root), 'utf-8'));
397
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
398
- return {};
399
- // Filtered rather than trusted. This file is on disk where a person can
400
- // edit it, and a malformed value reaching the renderer as a title is a
401
- // crash in a list nobody could then get out of. Strings are the v1 shape:
402
- // every one was written by `/title`, so each remains a chosen name.
403
- const titles = {};
404
- for (const [id, value] of Object.entries(parsed)) {
405
- if (typeof value === 'string') {
406
- titles[id] = { title: value, named: true };
407
- continue;
408
- }
409
- if (typeof value !== 'object' || value === null)
410
- continue;
411
- const candidate = value;
412
- if (typeof candidate.title === 'string' && typeof candidate.named === 'boolean') {
413
- titles[id] = { title: candidate.title, named: candidate.named };
414
- }
415
- }
416
- return titles;
417
- }
418
- catch {
419
- // Absent, unreadable, or not JSON. A conversation with no chosen name
420
- // still has a derived one, so the honest fallback is "nobody named
421
- // anything" rather than a failure the operator cannot act on.
422
- return {};
423
- }
424
- }
425
- function writeTitles(root, titles) {
426
- writePrivateJson(root, TITLES_FILE, titles);
427
- }
428
- /** Crash-safe publication for the small CLI sidecars scoped to one Project. */
429
- function writePrivateJson(root, filename, value) {
430
- mkdirSync(root, { recursive: true });
431
- const path = join(root, filename);
432
- const temporary = `${path}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
433
- try {
434
- writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
435
- encoding: 'utf-8',
436
- flag: 'wx',
437
- mode: 0o600,
438
- });
439
- if (process.platform !== 'win32')
440
- chmodSync(temporary, 0o600);
441
- restrictToOwner(temporary);
442
- renameSync(temporary, path);
443
- }
444
- finally {
445
- rmSync(temporary, { force: true });
446
- }
447
- }
448
430
  /** The name a person gave this conversation, or `undefined`. */
449
- export function titleOf(s, sessionId) {
450
- const stored = readTitles(s.controlRoot)[sessionId];
451
- return stored?.named ? stored.title : undefined;
431
+ export async function titleOf(s, sessionId) {
432
+ const facts = await readConversationFacts(s, sessionId, 'tolerant');
433
+ return facts?.named ? facts.title : undefined;
452
434
  }
453
435
  /**
454
436
  * Name a conversation, or with an empty name, take the name away.
455
437
  *
456
- * Removing rather than storing `''` is what keeps "named" a real distinction:
457
- * an empty string is not a name, and leaving one behind would make `/resume`
458
- * show a blank row that reads as a conversation with nothing in it.
438
+ * Recorded as `session_updated{title, titleSource: 'named'}`; clearing records
439
+ * an empty derived title, so the list derives one from the first message
440
+ * again rather than showing a blank row.
459
441
  */
460
- export function setTitle(s, sessionId, title) {
461
- const titles = readTitles(s.controlRoot);
442
+ export async function setTitle(s, sessionId, title) {
443
+ await requireConversationInScope(s, sessionId, 'name conversation');
462
444
  const trimmed = title.trim();
463
- if (trimmed === '')
464
- delete titles[sessionId];
465
- else
466
- titles[sessionId] = { title: trimmed, named: true };
467
- writeTitles(s.controlRoot, titles);
445
+ await appendOutsideTurn(s, sessionId, [
446
+ trimmed === ''
447
+ ? { type: 'session_updated', title: '', titleSource: 'derived' }
448
+ : { type: 'session_updated', title: trimmed, titleSource: 'named' },
449
+ ], 'name conversation');
450
+ }
451
+ /**
452
+ * Seed a conversation that has no turns yet with a history, as one
453
+ * `compaction` record outside any turn: the fold starts from its summary, so
454
+ * the new conversation's context is exactly `messages`. Used by forks.
455
+ */
456
+ export async function seedConversationHistory(s, sessionId, messages, strategy = 'fork') {
457
+ if (messages.length === 0)
458
+ return;
459
+ await appendOutsideTurn(s, sessionId, [
460
+ {
461
+ type: 'compaction',
462
+ compactionId: randomUUID(),
463
+ strategy,
464
+ trigger: 'manual',
465
+ replacesSeqRange: [1, 1],
466
+ summary: [...messages],
467
+ keptMessageIds: [],
468
+ tokensBefore: 0,
469
+ tokensAfter: 0,
470
+ },
471
+ ], 'seed conversation history');
468
472
  }
469
473
  /**
470
474
  * Continue in a copy, leaving the original where it is.
@@ -475,11 +479,9 @@ export function setTitle(s, sessionId, title) {
475
479
  *
476
480
  * **The fork is always named, and that is the load-bearing part.** Both
477
481
  * conversations start with the same first message, so both DERIVE the same
478
- * title — and `/resume` would show two rows a person cannot tell apart, which
479
- * is a worse outcome than not being able to fork at all. The name is taken
480
- * from the source's own, so a fork of a fork stays readable, and it is
481
- * numbered against the names already in use so a second fork does not collide
482
- * with the first.
482
+ * title — and `/resume` would show two rows a person cannot tell apart. The
483
+ * name is taken from the source's own, so a fork of a fork stays readable, and
484
+ * it is numbered against the names already in use.
483
485
  */
484
486
  export async function forkConversation(s, sourceId) {
485
487
  await requireWritableConversation(s, sourceId, 'fork conversation');
@@ -489,9 +491,7 @@ export async function forkConversation(s, sourceId) {
489
491
  // that shows up in `/resume` forever and answers no question.
490
492
  throw new Error('There is nothing to fork yet — this conversation has no messages.');
491
493
  }
492
- const { id, title } = await writeFork(s, sourceId, messages, messages, {
493
- kind: 'all',
494
- });
494
+ const { id, title } = await writeFork(s, sourceId, messages, messages);
495
495
  return { id, title, copied: messages.length };
496
496
  }
497
497
  /**
@@ -504,8 +504,7 @@ export async function forkConversation(s, sourceId) {
504
504
  * a boundary the operator did not select.
505
505
  *
506
506
  * An empty prefix is valid. Editing the first prompt creates an empty branch
507
- * and restores that prompt to the composer; refusing it would make the most
508
- * common first-turn correction the one prompt this feature cannot edit.
507
+ * and restores that prompt to the composer.
509
508
  */
510
509
  export async function forkConversationBeforeUser(s, sourceId, userOrdinal, expected) {
511
510
  if (!Number.isInteger(userOrdinal) || userOrdinal < 0) {
@@ -528,129 +527,53 @@ export async function forkConversationBeforeUser(s, sourceId, userOrdinal, expec
528
527
  throw new Error('The conversation changed after the prompt was selected. Nothing was forked; open the editor again from the current history.');
529
528
  }
530
529
  const prefix = messages.slice(0, messageIndex);
531
- const { id, title } = await writeFork(s, sourceId, messages, prefix, {
532
- kind: 'before-user',
533
- userOrdinal,
534
- });
530
+ const { id, title } = await writeFork(s, sourceId, messages, prefix);
535
531
  return { id, title, messages: prefix, selected };
536
532
  }
537
- /** Create and name one fork after every boundary decision has been validated. */
538
- async function writeFork(s, sourceId, sourceMessages, copiedMessages, boundary) {
539
- const source = readTitles(s.controlRoot)[sourceId]?.title ?? conversationTitle(sourceMessages);
540
- const origin = await forkOrigin(s, sourceId, sourceMessages, copiedMessages.length, boundary);
541
- const id = await createConversation(s);
542
- // The copied model context is published as one replacement record and read
543
- // back before lineage is committed. If the process dies before origin, export
544
- // refuses; once origin exists, a restart cannot observe a half-copied prefix.
545
- await s.store.replaceMessages(id, copiedMessages, s.tenantId);
533
+ /** Create, seed and name one fork after every boundary decision has been validated. */
534
+ async function writeFork(s, sourceId, sourceMessages, copiedMessages) {
535
+ const sourceFacts = await readConversationFacts(s, sourceId, 'tolerant');
536
+ const source = sourceFacts?.title !== undefined && sourceFacts.title.length > 0
537
+ ? sourceFacts.title
538
+ : conversationTitle(sourceFacts ? recordedMessages(sourceFacts.records) : sourceMessages);
539
+ const id = await startConversation(s);
540
+ await seedConversationHistory(s, id, copiedMessages);
546
541
  const copiedBack = await loadConversation(s, id);
547
- if (!isDeepStrictEqual(copiedBack, copiedMessages)) {
548
- throw new Error(`The forked conversation did not preserve its exact copied history. No lineage record was published for ${id}.`);
542
+ if (!isDeepStrictEqual(copiedBack, [...copiedMessages])) {
543
+ throw new Error(`The forked conversation did not preserve its exact copied history (${id}).`);
549
544
  }
550
- await s.turnEvidence?.recordOrigin(id, origin);
551
- const title = nextForkName(Object.fromEntries(Object.entries(readTitles(s.controlRoot)).map(([key, value]) => [key, value.title])), source);
552
- setTitle(s, id, title);
545
+ const title = nextForkName(await takenTitles(s), source);
546
+ await setTitle(s, id, title);
553
547
  return { id, title };
554
548
  }
555
- async function forkOrigin(s, sourceId, sourceMessages, copiedMessages, boundary) {
556
- const unresolved = {
557
- kind: 'fork-unresolved',
558
- sourceSessionId: sourceId,
559
- copiedMessages,
560
- };
561
- if (!s.turnEvidence)
562
- return unresolved;
563
- try {
564
- const lineage = await s.turnEvidence.resolveLineage(sourceId);
565
- if (lineage.kind !== 'available')
566
- return unresolved;
567
- const durableTurns = durableTurnProjections(sourceMessages);
568
- if (!durableTurns || durableTurns.length === 0)
569
- return unresolved;
570
- const selected = boundary.kind === 'all'
571
- ? uniqueLineageIndex(durableTurns, lineage.turns, durableTurns.length - 1)
572
- : uniqueLineageIndex(durableTurns, lineage.turns, boundary.userOrdinal);
573
- if (selected === undefined)
574
- return unresolved;
575
- const copiedTurnCount = boundary.kind === 'all' ? selected + 1 : selected;
576
- return {
577
- kind: 'fork',
578
- sourceSessionId: sourceId,
579
- copiedMessages,
580
- turns: lineage.turns
581
- .slice(0, copiedTurnCount)
582
- .map((turn) => ({ ...turn.reference })),
583
- };
584
- }
585
- catch {
586
- // Forking model history remains useful when old/foreign evidence cannot
587
- // prove lineage. The origin says so explicitly, and complete export refuses.
588
- return unresolved;
549
+ /** Every title in use among this project's conversations, keyed by session id. */
550
+ async function takenTitles(s) {
551
+ const taken = {};
552
+ for (const row of await s.index.listSessions({ slug: s.slug, rootsOnly: true })) {
553
+ if (row.title !== undefined && row.title.length > 0)
554
+ taken[row.id] = row.title;
589
555
  }
556
+ return taken;
590
557
  }
591
- function durableTurnProjections(messages) {
592
- const turns = [];
593
- for (const message of messages) {
594
- if (message.role === 'user') {
595
- turns.push({ user: message });
596
- continue;
597
- }
598
- if (message.role !== 'assistant')
599
- continue;
600
- const turn = turns.at(-1);
601
- if (!turn || turn.assistantText !== undefined || typeof message.content !== 'string') {
602
- return undefined;
603
- }
604
- turn.assistantText = message.content;
605
- }
606
- return turns;
607
- }
608
- /** Unique source-turn boundary for one user selected from a possibly compacted suffix. */
609
- function uniqueLineageIndex(durable, lineage, selectedUser) {
610
- if (selectedUser < 0 || selectedUser >= durable.length)
611
- return undefined;
612
- const prefix = alignmentTable(durable, lineage);
613
- const reversedDurable = [...durable].reverse();
614
- const reversedLineage = [...lineage].reverse();
615
- const suffix = alignmentTable(reversedDurable, reversedLineage);
616
- const candidates = [];
617
- for (let index = 0; index < lineage.length; index += 1) {
618
- if (!projectionMatches(durable[selectedUser], lineage[index]))
619
- continue;
620
- const beforeFits = prefix[selectedUser]?.[index] === true;
621
- const durableAfter = durable.length - selectedUser - 1;
622
- const lineageAfter = lineage.length - index - 1;
623
- const afterFits = suffix[durableAfter]?.[lineageAfter] === true;
624
- if (beforeFits && afterFits)
625
- candidates.push(index);
626
- }
627
- return candidates.length === 1 ? candidates[0] : undefined;
628
- }
629
- /** DP table: whether the first i needles embed, in order, within the first j haystack values. */
630
- function alignmentTable(needles, haystack) {
631
- const table = Array.from({ length: needles.length + 1 }, () => Array.from({ length: haystack.length + 1 }, () => false));
632
- for (let column = 0; column <= haystack.length; column += 1)
633
- table[0][column] = true;
634
- for (let row = 1; row <= needles.length; row += 1) {
635
- for (let column = 1; column <= haystack.length; column += 1) {
636
- table[row][column] =
637
- table[row]?.[column - 1] === true ||
638
- (table[row - 1]?.[column - 1] === true &&
639
- projectionMatches(needles[row - 1], haystack[column - 1]));
558
+ /**
559
+ * Every message the log ever recorded, in order: a fork's seeded history and
560
+ * each `message` record, before any compaction or replacement folds them.
561
+ *
562
+ * A derived title and the list preview are read from this rather than from
563
+ * the fold, so compacting a conversation away from its opening question does
564
+ * not rename it: the opening message is still in the log.
565
+ */
566
+ function recordedMessages(records) {
567
+ const out = [];
568
+ for (const record of records) {
569
+ if (record.type === 'message')
570
+ out.push(record.content);
571
+ else if (record.type === 'compaction' && Array.isArray(record.summary)) {
572
+ if (record.strategy === 'fork')
573
+ out.push(...record.summary);
640
574
  }
641
575
  }
642
- return table;
643
- }
644
- function projectionMatches(durable, lineage) {
645
- if (!durable || !lineage || !isDeepStrictEqual(durable.user, lineage.evidence.started.user)) {
646
- return false;
647
- }
648
- const settled = lineage.evidence.settled;
649
- if (!settled)
650
- return false;
651
- return durable.assistantText === undefined
652
- ? settled.assistantText.trim().length === 0
653
- : settled.assistantText === durable.assistantText;
576
+ return out;
654
577
  }
655
578
  /**
656
579
  * `X (fork)`, then `X (fork 2)`, `X (fork 3)`.
@@ -673,7 +596,7 @@ export function nextForkName(taken, source) {
673
596
  // answer, and a name that repeats is better than a refusal here.
674
597
  return `${source} (fork)`;
675
598
  }
676
- function conversationTitle(messages) {
599
+ export function conversationTitle(messages) {
677
600
  const firstHuman = messages.find((message) => message.role === 'user' && message.source === undefined);
678
601
  const firstGoal = messages.find((message) => message.role === 'user' && message.source?.type === 'goal-round');
679
602
  const raw = firstHuman?.role === 'user'
@@ -684,9 +607,16 @@ function conversationTitle(messages) {
684
607
  const text = raw.replace(/\s+/g, ' ').trim();
685
608
  return text.length > 60 ? `${text.slice(0, 59)}…` : text || 'Conversation';
686
609
  }
687
- function toIso(value) {
688
- if (value instanceof Date)
689
- return value.toISOString();
690
- return typeof value === 'string' ? value : new Date(0).toISOString();
610
+ /** The project directory name, for messages that point a person at it. */
611
+ export function projectLabel(s) {
612
+ return basename(s.projectRoot) || s.projectRoot;
613
+ }
614
+ /** Where a conversation's per-session files live (`<session-id>/`). */
615
+ export function conversationDir(s, sessionId) {
616
+ return s.paths.sessionDir({ sessionId });
617
+ }
618
+ /** The project's directory under `NAMZU_HOME` (`projects/<slug>/`). */
619
+ export function projectDir(s) {
620
+ return join(s.root, 'projects', s.slug);
691
621
  }
692
622
  //# sourceMappingURL=store.js.map