ompchamber 1.0.0 → 1.2.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 (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Per-topic session workspaces, and promotion of a topic into the chat.
8
+ *
9
+ * A topic's side conversation runs in an omp child that resumes a *copy* of
10
+ * the parent session file. That copy is what makes the side question cheap and
11
+ * honest: the parent's transcript arrives as real provider messages (no
12
+ * re-prompting it as text), the parent file is never written by the side
13
+ * child, and the copy itself becomes the topic's durable history — a follow-up
14
+ * after the child was reclaimed resumes it and still sees every earlier turn.
15
+ *
16
+ * Promotion turns the copy into a session of the chamber's own: the file is
17
+ * rewritten with a fresh session id and a title taken from the question, then
18
+ * placed beside the parent session — the same end state omp's `/btw` reaches
19
+ * with its branch action, where the side answer becomes the tip of a new
20
+ * conversation that carries the parent's history.
21
+ *
22
+ * Session-file layout details that matter here (all verified against omp
23
+ * 18.2.6 files):
24
+ * - Line 1 is the title slot: `{"type":"title","v":1,"title":…,"source":…,
25
+ * "updatedAt":…,"pad":"…"}` padded so the serialized line is exactly
26
+ * `SESSION_TITLE_SLOT_BYTES` bytes — omp rewrites it in place, and the
27
+ * serializer in `omp/session/title-slot.ts` owns both the truncation and the
28
+ * padding arithmetic.
29
+ * - Line 2 is the session header (`type:"session"`), carrying `id`, `cwd`,
30
+ * and for a branch `parentSession` — the file it branched from.
31
+ * - File names are `<ISO with [:. ] → ->_<session id>.jsonl`.
32
+ */
33
+
34
+ import fs from 'fs';
35
+ import * as path from 'path';
36
+ import { getDatabasePath } from '@/server/db.server';
37
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
38
+ import { serializeTitleSlot } from '@/server/lib/omp/session/title-slot';
39
+ import { isRecord } from '@/shared/lib/util/guards';
40
+
41
+ /** Root holding every topic workspace, beside the chamber database. */
42
+ export async function getBtwRoot(): Promise<string> {
43
+ const root = path.join(path.dirname(await getDatabasePath()), 'btw');
44
+ await fs.promises.mkdir(root, { recursive: true });
45
+ return root;
46
+ }
47
+
48
+ function parseJsonObject(line: string): Record<string, unknown> | null {
49
+ try {
50
+ const value: unknown = JSON.parse(line);
51
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The file's lines, minus a torn tail. A snapshot taken while the parent is
59
+ * mid-append can end in a partial line; keeping it would make the side child
60
+ * fail to load the transcript.
61
+ */
62
+ function completeLines(text: string): string[] {
63
+ const lines = text.split('\n');
64
+ if (lines[lines.length - 1] === '') lines.pop();
65
+ const last = lines[lines.length - 1];
66
+ if (last !== undefined && parseJsonObject(last) === null) lines.pop();
67
+ return lines;
68
+ }
69
+
70
+ /** The transcript's leaf: the id of its last entry. */
71
+ export function lastEntryId(lines: string[]): string | null {
72
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
73
+ const record = parseJsonObject(lines[index]);
74
+ if (record && typeof record.id === 'string') return record.id;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** Read a transcript's leaf id straight from disk. */
80
+ export async function readTranscriptLeaf(sessionFile: string): Promise<string | null> {
81
+ try {
82
+ return lastEntryId(completeLines(await Bun.file(sessionFile).text()));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** The title slot line, padded to the fixed slot omp rewrites in place, with
89
+ * the trailing newline removed (the writer here joins lines itself).
90
+ *
91
+ * Delegates to the repo's slot serializer rather than re-deriving the width:
92
+ * that one truncates the title by code point, so a question written in CJK
93
+ * (up to 3 UTF-8 bytes each) still fits — clipping by character count alone
94
+ * would overflow the slot and fail promotion outright. */
95
+ export function buildTitleLine(title: string, source: string, updatedAt: string): string {
96
+ const line = serializeTitleSlot({ title, source: source === 'auto' ? 'auto' : 'user', updatedAt });
97
+ return line.endsWith('\n') ? line.slice(0, -1) : line;
98
+ }
99
+
100
+ export interface BtwWorkspace {
101
+ dir: string;
102
+ sessionFile: string;
103
+ /** Parent transcript leaf at snapshot time — the promotion guard's baseline. */
104
+ leafId: string | null;
105
+ }
106
+
107
+ /** Where a topic's transcript lives. Derived, never stored: a topic that lost
108
+ * its runtime still finds the same file, which is what makes a follow-up
109
+ * continue the side conversation instead of starting it over. */
110
+ export async function resolveBtwWorkspacePaths(sessionId: string, topicId: string): Promise<Omit<BtwWorkspace, 'leafId'>> {
111
+ const dir = path.join(await getBtwRoot(), sessionId, topicId);
112
+ return { dir, sessionFile: path.join(dir, 'session.jsonl') };
113
+ }
114
+
115
+ /** Snapshot the parent transcript into a topic's private session file. */
116
+ export async function createBtwWorkspace(input: {
117
+ parentSessionId: string;
118
+ topicId: string;
119
+ parentSessionFile: string;
120
+ }): Promise<BtwWorkspace> {
121
+ const dir = path.join(await getBtwRoot(), input.parentSessionId, input.topicId);
122
+ await fs.promises.mkdir(dir, { recursive: true });
123
+ const lines = completeLines(await Bun.file(input.parentSessionFile).text());
124
+ const sessionFile = path.join(dir, 'session.jsonl');
125
+ await Bun.write(sessionFile, lines.length > 0 ? `${lines.join('\n')}\n` : '');
126
+ return { dir, sessionFile, leafId: lastEntryId(lines) };
127
+ }
128
+
129
+ /**
130
+ * The visible question behind a side-question prompt.
131
+ *
132
+ * The side session's own transcript keeps the full `<btw>` template (that is
133
+ * its record, and the shape the side child was resumed with). Promoting lifts
134
+ * the pair into a chat, where the boilerplate would be noise the user never
135
+ * typed — omp's own branch action appends the plain question, and so does this.
136
+ */
137
+ function unwrapBtwQuestion(text: string): string {
138
+ if (!text.startsWith('<btw>')) return text;
139
+ const marker = '\nQuestion:\n';
140
+ const start = text.indexOf(marker);
141
+ if (start < 0) return text;
142
+ const end = text.lastIndexOf('</btw>');
143
+ const question = text.slice(start + marker.length, end > start ? end : undefined).trim();
144
+ return question || text;
145
+ }
146
+
147
+ function unwrapBtwPrompts(record: Record<string, unknown>): Record<string, unknown> {
148
+ const message = record.message;
149
+ if (!isRecord(message) || message.role !== 'user' || !Array.isArray(message.content)) return record;
150
+ const content = message.content.map((part) =>
151
+ isRecord(part) && part.type === 'text' && typeof part.text === 'string'
152
+ ? { ...part, text: unwrapBtwQuestion(part.text) }
153
+ : part,
154
+ );
155
+ return { ...record, message: { ...message, content } };
156
+ }
157
+
158
+ /**
159
+ * Promote a topic: write its transcript next to the parent session under a
160
+ * fresh session id, so the chamber (and omp) treat it as a branch of the
161
+ * parent conversation whose tip is the side answer. Returns the new id.
162
+ */
163
+ export async function promoteBtwTopic(input: {
164
+ parentSessionFile: string;
165
+ topicSessionFile: string;
166
+ title: string;
167
+ }): Promise<string> {
168
+ const lines = completeLines(await Bun.file(input.topicSessionFile).text());
169
+ const newId = crypto.randomUUID();
170
+ const timestamp = new Date().toISOString();
171
+
172
+ let titleDropped = false;
173
+ let headerWritten = false;
174
+ const body: string[] = [];
175
+ for (const line of lines) {
176
+ const record = parseJsonObject(line);
177
+ if (record?.type === 'title' && !titleDropped) {
178
+ // The slot moves to line 1 below; a later title record is left alone.
179
+ titleDropped = true;
180
+ continue;
181
+ }
182
+ if (record?.type === 'session' && !headerWritten) {
183
+ headerWritten = true;
184
+ body.push(
185
+ JSON.stringify({
186
+ ...record,
187
+ id: newId,
188
+ timestamp,
189
+ title: input.title,
190
+ titleSource: 'user',
191
+ parentSession: input.parentSessionFile,
192
+ }),
193
+ );
194
+ continue;
195
+ }
196
+ body.push(record?.type === 'message' ? JSON.stringify(unwrapBtwPrompts(record)) : line);
197
+ }
198
+ if (!headerWritten) throw new Error('BTW topic transcript has no session header');
199
+
200
+ const fileName = `${timestamp.replace(/[:.]/g, '-')}_${newId}.jsonl`;
201
+ const target = path.join(path.dirname(input.parentSessionFile), fileName);
202
+ // An existing file at this name would mean the id collided; never truncate.
203
+ if (await Bun.file(target).exists()) throw new Error(`Promoted session file already exists: ${fileName}`);
204
+ await Bun.write(target, `${[buildTitleLine(input.title, 'user', timestamp), ...body].join('\n')}\n`);
205
+ // The sidebar list and the id→path scan are mtime-keyed caches.
206
+ clearSessionFileCaches();
207
+ return newId;
208
+ }
209
+
210
+ /** Drop a topic's workspace (its transcript lives in the database after this). */
211
+ export async function removeBtwWorkspace(dir: string): Promise<void> {
212
+ await fs.promises.rm(dir, { recursive: true, force: true });
213
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The topic transcript and its promotion are file surgery on a format omp
6
+ * owns, and every failure mode here is silent: a title slot that is not the
7
+ * fixed width stops omp rewriting it in place, a stale session id makes two
8
+ * sessions claim one identity, and a torn tail line makes the side child fail
9
+ * to load the transcript at all. These cases pin those invariants.
10
+ */
11
+
12
+ import { afterEach, describe, expect, test } from 'bun:test';
13
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
14
+ import { tmpdir } from 'os';
15
+ import { join } from 'path';
16
+ import { buildTitleLine, createBtwWorkspace, getBtwRoot, promoteBtwTopic, readTranscriptLeaf } from '@/server/lib/btw/session-copy.server';
17
+
18
+ const timestamp = '2026-09-23T08:41:10.683Z';
19
+ const createdDirs: string[] = [];
20
+ const createdSessions: string[] = [];
21
+
22
+ function tempDir(): string {
23
+ const dir = mkdtempSync(join(tmpdir(), 'btw-test-'));
24
+ createdDirs.push(dir);
25
+ return dir;
26
+ }
27
+
28
+ function sessionLine(id: string, cwd = '/tmp'): string {
29
+ return JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd });
30
+ }
31
+
32
+ function userLine(id: string, parentId: string | null, text: string): string {
33
+ return JSON.stringify({ type: 'message', id, parentId, timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
34
+ }
35
+
36
+ function assistantLine(id: string, parentId: string | null, text: string): string {
37
+ return JSON.stringify({ type: 'message', id, parentId, timestamp, message: { role: 'assistant', content: [{ type: 'text', text }] } });
38
+ }
39
+
40
+ afterEach(() => {
41
+ for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
42
+ for (const dir of createdSessions.splice(0)) rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ describe('buildTitleLine', () => {
46
+ test('pads the title record to the fixed slot omp rewrites in place', () => {
47
+ const line = buildTitleLine('Which codeword did we agree on?', 'user', timestamp);
48
+ expect(Buffer.byteLength(line, 'utf8')).toBe(255);
49
+ const record = JSON.parse(line);
50
+ expect(record.type).toBe('title');
51
+ expect(record.title).toBe('Which codeword did we agree on?');
52
+ expect(record.source).toBe('user');
53
+ });
54
+
55
+ test('fits a title the model wrote in multi-byte characters', () => {
56
+ // A 60-character topic label is 180 UTF-8 bytes in CJK: the slot holds
57
+ // ~158, so the title is truncated rather than thrown — otherwise promoting
58
+ // a perfectly ordinary question fails the whole request.
59
+ const title = 'この質問は日本語で書かれています。プロモートできるか確認します。ここから先は切り詰められます';
60
+ const line = buildTitleLine(title, 'user', timestamp);
61
+ expect(Buffer.byteLength(line, 'utf8')).toBe(255);
62
+ const record = JSON.parse(line);
63
+ expect(record.title.length).toBeGreaterThan(0);
64
+ expect(title.startsWith(record.title)).toBe(true);
65
+ });
66
+ });
67
+
68
+ describe('createBtwWorkspace', () => {
69
+ test('snapshots the parent transcript and reports its leaf', async () => {
70
+ const sessionId = crypto.randomUUID();
71
+ const parentDir = tempDir();
72
+ const parentFile = join(parentDir, 'parent.jsonl');
73
+ createdSessions.push(join(await getBtwRoot(), sessionId));
74
+ writeFileSync(
75
+ parentFile,
76
+ [buildTitleLine('parent', 'auto', timestamp), sessionLine(sessionId), userLine('a1', null, 'hello'), assistantLine('a2', 'a1', 'hi')].join('\n') + '\n',
77
+ );
78
+
79
+ const workspace = await createBtwWorkspace({ parentSessionId: sessionId, topicId: 'topic-1', parentSessionFile: parentFile });
80
+
81
+ expect(workspace.leafId).toBe('a2');
82
+ expect(existsSync(workspace.sessionFile)).toBe(true);
83
+ const copied = readFileSync(workspace.sessionFile, 'utf8');
84
+ expect(copied).toBe(readFileSync(parentFile, 'utf8'));
85
+ });
86
+
87
+ test('drops a torn tail line from a parent caught mid-append', async () => {
88
+ const sessionId = crypto.randomUUID();
89
+ const parentDir = tempDir();
90
+ const parentFile = join(parentDir, 'parent.jsonl');
91
+ createdSessions.push(join(await getBtwRoot(), sessionId));
92
+ const torn = '{"type":"message","id":"torn';
93
+ writeFileSync(
94
+ parentFile,
95
+ [buildTitleLine('parent', 'auto', timestamp), sessionLine(sessionId), userLine('a1', null, 'hello')].join('\n') + '\n' + torn,
96
+ );
97
+
98
+ const workspace = await createBtwWorkspace({ parentSessionId: sessionId, topicId: 'topic-2', parentSessionFile: parentFile });
99
+
100
+ const copied = readFileSync(workspace.sessionFile, 'utf8');
101
+ expect(copied.endsWith('\n')).toBe(true);
102
+ expect(copied).not.toContain('torn');
103
+ expect(workspace.leafId).toBe('a1');
104
+ });
105
+ });
106
+
107
+ describe('promoteBtwTopic', () => {
108
+ function topicFile(dir: string): { path: string; parentFile: string } {
109
+ const parentFile = join(dir, 'parent.jsonl');
110
+ const path = join(dir, 'topic.jsonl');
111
+ writeFileSync(parentFile, [buildTitleLine('parent', 'auto', timestamp), sessionLine('parent-session').replace('parent-session', 'aaaa1111')].join('\n') + '\n');
112
+ writeFileSync(
113
+ path,
114
+ [
115
+ buildTitleLine('side question', 'user', timestamp),
116
+ sessionLine('parent-id'),
117
+ userLine('u1', null, 'remember ORANGE-TAPIR'),
118
+ assistantLine('a1', 'u1', 'ok'),
119
+ userLine('u2', 'a1', '<btw>\nEphemeral side question for current interactive session.\nAnswer briefly, directly; use conversation context already provided.\nNEVER use tools.\nNEVER ask follow-up questions.\nQuestion:\nWhat is the codeword?\n</btw>'),
120
+ assistantLine('a2', 'u2', 'ORANGE-TAPIR'),
121
+ ].join('\n') + '\n',
122
+ );
123
+ return { path, parentFile };
124
+ }
125
+
126
+ test('writes a branch session beside the parent under a fresh identity', async () => {
127
+ const dir = tempDir();
128
+ const { path, parentFile } = topicFile(dir);
129
+
130
+ const created = await promoteBtwTopic({ parentSessionFile: parentFile, topicSessionFile: path, title: 'What is the codeword?' });
131
+
132
+ const promotedFiles = readdirSync(dir).filter((name) => name.endsWith(`_${created}.jsonl`));
133
+ expect(promotedFiles.length).toBe(1);
134
+ // omp names session files `<ISO with [:. ] → ->_<id>.jsonl`.
135
+ expect(promotedFiles[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f-]{36}\.jsonl$/);
136
+ const promoted = join(dir, promotedFiles[0]);
137
+ const lines = readFileSync(promoted, 'utf8').trimEnd().split('\n');
138
+
139
+ expect(Buffer.byteLength(lines[0], 'utf8')).toBe(255);
140
+ expect(JSON.parse(lines[0]).title).toBe('What is the codeword?');
141
+
142
+ const header = JSON.parse(lines[1]);
143
+ expect(header.id).toBe(created);
144
+ expect(header.id).not.toBe('parent-id');
145
+ expect(header.parentSession).toBe(parentFile);
146
+ expect(header.title).toBe('What is the codeword?');
147
+ expect(header.cwd).toBe('/tmp');
148
+
149
+ // Exactly one title record, at the top: a second one would confuse the slot.
150
+ expect(lines.filter((line) => line.includes('"type":"title"')).length).toBe(1);
151
+ // The promoted chat shows the question the user asked, not the template.
152
+ expect(readFileSync(promoted, 'utf8')).toContain('"text":"What is the codeword?"');
153
+ expect(readFileSync(promoted, 'utf8')).not.toContain('NEVER use tools');
154
+ expect(await readTranscriptLeaf(promoted)).toBe('a2');
155
+ });
156
+
157
+ test('refuses a transcript with no session header instead of guessing one', async () => {
158
+ const dir = tempDir();
159
+ const topicPath = join(dir, 'headerless.jsonl');
160
+ writeFileSync(topicPath, userLine('u1', null, 'no header here') + '\n');
161
+
162
+ expect(
163
+ promoteBtwTopic({ parentSessionFile: join(dir, 'parent.jsonl'), topicSessionFile: topicPath, title: 'x' }),
164
+ ).rejects.toThrow('no session header');
165
+ });
166
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * SQLite-backed BTW store (`btw_topics` / `btw_turns`).
8
+ *
9
+ * A side question is a topic: an independent first question plus explicit
10
+ * follow-ups, scoped to the parent session. Rows mirror what the panel shows,
11
+ * so history survives a reload, a server restart, and the death of the side
12
+ * session's omp child — a follow-up resumes the topic's own session file,
13
+ * which already carries the parent's context and every turn before it.
14
+ *
15
+ * `leaf_id` is the parent session's transcript leaf when the topic started.
16
+ * Promotion refuses when the parent has moved on, exactly like omp's own
17
+ * `/btw` branch guard ("session changed since /btw started").
18
+ */
19
+
20
+ import { getDb } from '@/server/db.server';
21
+ import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus } from '@/shared/types';
22
+
23
+ interface TopicRow {
24
+ id: string;
25
+ session_id: string;
26
+ title: string;
27
+ provider: string | null;
28
+ model_id: string | null;
29
+ model_name: string | null;
30
+ leaf_id: string | null;
31
+ promoted_session_id: string | null;
32
+ created_at: number;
33
+ updated_at: number;
34
+ }
35
+
36
+ interface TurnRow {
37
+ topic_id: string;
38
+ turn_index: number;
39
+ question: string;
40
+ answer: string;
41
+ status: string;
42
+ created_at: number;
43
+ updated_at: number;
44
+ }
45
+
46
+ const TURN_STATUSES: Record<string, true> = {
47
+ running: true,
48
+ complete: true,
49
+ cancelled: true,
50
+ failed: true,
51
+ interrupted: true,
52
+ };
53
+
54
+ export function isBtwTurnStatus(value: unknown): value is BtwTurnStatus {
55
+ return typeof value === 'string' && value in TURN_STATUSES;
56
+ }
57
+
58
+ function rowToTopic(row: TopicRow): BtwTopic {
59
+ const model: BtwModel | undefined =
60
+ row.provider && row.model_id
61
+ ? { provider: row.provider, id: row.model_id, ...(row.model_name ? { name: row.model_name } : {}) }
62
+ : undefined;
63
+ return {
64
+ id: row.id,
65
+ sessionId: row.session_id,
66
+ title: row.title,
67
+ createdAt: row.created_at,
68
+ updatedAt: row.updated_at,
69
+ ...(model ? { model } : {}),
70
+ ...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
71
+ turns: [],
72
+ };
73
+ }
74
+
75
+ function rowToTurn(row: TurnRow): BtwTurn {
76
+ return {
77
+ index: row.turn_index,
78
+ question: row.question,
79
+ answer: row.answer,
80
+ status: isBtwTurnStatus(row.status) ? row.status : 'interrupted',
81
+ createdAt: row.created_at,
82
+ updatedAt: row.updated_at,
83
+ };
84
+ }
85
+
86
+ /** Every topic of a session, oldest first, each with its turns in order. */
87
+ export async function listBtwTopics(sessionId: string): Promise<BtwTopic[]> {
88
+ const db = await getDb();
89
+ const topics = ((await db.all(
90
+ 'SELECT * FROM btw_topics WHERE session_id = ? ORDER BY created_at ASC, id ASC',
91
+ [sessionId],
92
+ )) as TopicRow[]).map(rowToTopic);
93
+ if (topics.length === 0) return topics;
94
+
95
+ const placeholders = topics.map(() => '?').join(', ');
96
+ const turns = (await db.all(
97
+ `SELECT * FROM btw_turns WHERE topic_id IN (${placeholders}) ORDER BY topic_id ASC, turn_index ASC`,
98
+ topics.map((topic) => topic.id),
99
+ )) as TurnRow[];
100
+
101
+ const byTopic = new Map<string, BtwTurn[]>(topics.map((topic) => [topic.id, []]));
102
+ for (const row of turns) byTopic.get(row.topic_id)?.push(rowToTurn(row));
103
+ return topics.map((topic) => ({ ...topic, turns: byTopic.get(topic.id) ?? [] }));
104
+ }
105
+
106
+ /** One topic with its turns, or undefined when the id is unknown. */
107
+ export async function getBtwTopic(topicId: string): Promise<BtwTopic | undefined> {
108
+ const db = await getDb();
109
+ const row = (await db.get('SELECT * FROM btw_topics WHERE id = ?', [topicId])) as TopicRow | undefined;
110
+ if (!row) return undefined;
111
+ const turns = (await db.all(
112
+ 'SELECT * FROM btw_turns WHERE topic_id = ? ORDER BY turn_index ASC',
113
+ [topicId],
114
+ )) as TurnRow[];
115
+ return { ...rowToTopic(row), turns: turns.map(rowToTurn) };
116
+ }
117
+
118
+ export interface NewBtwTopicInput {
119
+ sessionId: string;
120
+ title: string;
121
+ model?: BtwModel;
122
+ /** Parent transcript leaf when the topic was created (promotion guard). */
123
+ leafId?: string | null;
124
+ }
125
+
126
+ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic> {
127
+ const db = await getDb();
128
+ const now = Date.now();
129
+ const row: TopicRow = {
130
+ id: crypto.randomUUID(),
131
+ session_id: input.sessionId,
132
+ title: input.title,
133
+ provider: input.model?.provider ?? null,
134
+ model_id: input.model?.id ?? null,
135
+ model_name: input.model?.name ?? null,
136
+ leaf_id: input.leafId ?? null,
137
+ promoted_session_id: null,
138
+ created_at: now,
139
+ updated_at: now,
140
+ };
141
+ await db.run(
142
+ `INSERT INTO btw_topics
143
+ (id, session_id, title, provider, model_id, model_name, leaf_id, promoted_session_id, created_at, updated_at)
144
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
145
+ [row.id, row.session_id, row.title, row.provider, row.model_id, row.model_name, row.leaf_id, now, now],
146
+ );
147
+ return rowToTopic(row);
148
+ }
149
+
150
+ /** Append a question to a topic; returns the new turn's index. */
151
+ export async function appendBtwTurn(topicId: string, question: string): Promise<number> {
152
+ const db = await getDb();
153
+ const now = Date.now();
154
+ const next = (await db.get(
155
+ 'SELECT COALESCE(MAX(turn_index), -1) + 1 AS next FROM btw_turns WHERE topic_id = ?',
156
+ [topicId],
157
+ )) as { next: number } | undefined;
158
+ const turnIndex = next?.next ?? 0;
159
+ await db.run(
160
+ `INSERT INTO btw_turns (topic_id, turn_index, question, answer, status, created_at, updated_at)
161
+ VALUES (?, ?, ?, '', 'running', ?, ?)`,
162
+ [topicId, turnIndex, question, now, now],
163
+ );
164
+ await db.run('UPDATE btw_topics SET updated_at = ? WHERE id = ?', [now, topicId]);
165
+ return turnIndex;
166
+ }
167
+
168
+ /** Patch one turn's answer and/or status. */
169
+ export async function updateBtwTurn(
170
+ topicId: string,
171
+ turnIndex: number,
172
+ patch: { answer?: string; status?: BtwTurnStatus },
173
+ ): Promise<void> {
174
+ const db = await getDb();
175
+ const sets: string[] = ['updated_at = ?'];
176
+ const values: (string | number)[] = [Date.now()];
177
+ if (typeof patch.answer === 'string') {
178
+ sets.push('answer = ?');
179
+ values.push(patch.answer);
180
+ }
181
+ if (patch.status !== undefined) {
182
+ sets.push('status = ?');
183
+ values.push(patch.status);
184
+ }
185
+ await db.run(`UPDATE btw_turns SET ${sets.join(', ')} WHERE topic_id = ? AND turn_index = ?`, [
186
+ ...values,
187
+ topicId,
188
+ turnIndex,
189
+ ]);
190
+ }
191
+
192
+ /** Settle every still-running turn of a topic — the side session died or was
193
+ * cancelled, so nothing will ever complete them. */
194
+ export async function settleRunningBtwTurns(topicId: string, status: BtwTurnStatus): Promise<void> {
195
+ const db = await getDb();
196
+ await db.run(
197
+ "UPDATE btw_turns SET status = ?, updated_at = ? WHERE topic_id = ? AND status = 'running'",
198
+ [status, Date.now(), topicId],
199
+ );
200
+ }
201
+
202
+ /** Record the parent-transcript leaf a topic was snapshotted at. */
203
+ export async function setBtwTopicLeaf(topicId: string, leafId: string): Promise<void> {
204
+ const db = await getDb();
205
+ await db.run('UPDATE btw_topics SET leaf_id = ? WHERE id = ?', [leafId, topicId]);
206
+ }
207
+
208
+ /** The parent-transcript leaf this topic was snapshotted at, when known. */
209
+ export async function readBtwTopicLeaf(topicId: string): Promise<string | null> {
210
+ const db = await getDb();
211
+ const row = (await db.get('SELECT leaf_id FROM btw_topics WHERE id = ?', [topicId])) as { leaf_id: string | null } | undefined;
212
+ return row?.leaf_id ?? null;
213
+ }
214
+
215
+ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promise<void> {
216
+ const db = await getDb();
217
+ await db.run('UPDATE btw_topics SET provider = ?, model_id = ?, model_name = ?, updated_at = ? WHERE id = ?', [
218
+ model.provider,
219
+ model.id,
220
+ model.name ?? null,
221
+ Date.now(),
222
+ topicId,
223
+ ]);
224
+ }
225
+
226
+ export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
227
+ const db = await getDb();
228
+ await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
229
+ promotedSessionId,
230
+ Date.now(),
231
+ topicId,
232
+ ]);
233
+ }
234
+
235
+ export async function deleteBtwTopic(topicId: string): Promise<boolean> {
236
+ const db = await getDb();
237
+ const result = await db.run('DELETE FROM btw_topics WHERE id = ?', [topicId]);
238
+ await db.run('DELETE FROM btw_turns WHERE topic_id = ?', [topicId]);
239
+ return result.changes > 0;
240
+ }