ompchamber 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) 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.a5ccbc19dc.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.5fe7a2cb64.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +183 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +88 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Merge the chamber's own copy of a conversation onto the messages loaded from
8
+ * an omp JSONL file.
9
+ *
10
+ * The JSONL is authoritative for what the model received, but it is not a
11
+ * complete record of the chamber's UI: omp rewrites raw composer input
12
+ * (`@agent` → a task-tool delegation prompt, `/skill:a b` → `/skill:a`) and
13
+ * writes no user entry at all for `/command` turns. The chamber DB keeps the
14
+ * turn as the user typed it, which is what this restores.
15
+ *
16
+ * Attachments are UNIONED rather than picked from one side. The two sources see
17
+ * different halves of the same attachment:
18
+ *
19
+ * - The JSONL recovers a text file's NAME from the delivered prompt (the
20
+ * composer inlines the file as `Attached file: <name>` plus a fenced block)
21
+ * and carries an image's bytes, but has no preview and no stored content.
22
+ * - The chamber row has the display fields — a text file's name and content, an
23
+ * image's name and preview — but nothing about what omp actually received.
24
+ *
25
+ * Choosing one list over the other is what made a dropped `.md`/`.py`/`.json`
26
+ * vanish from a reloaded session: the JSONL had an image, so its list won, and
27
+ * the text file only ever existed in the chamber row.
28
+ */
29
+
30
+ export interface StoredAttachment {
31
+ name?: string;
32
+ preview?: string;
33
+ type?: string;
34
+ size?: number;
35
+ content?: string;
36
+ }
37
+
38
+ export interface StoredMessage {
39
+ id?: string;
40
+ role: string;
41
+ content: string;
42
+ date?: string;
43
+ timestamp?: string;
44
+ attachments?: StoredAttachment[];
45
+ }
46
+
47
+ function isRawComposerInput(content: string): boolean {
48
+ return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
49
+ }
50
+
51
+ /** Whether a JSONL-derived user turn (a) and a stored one (b) are the same
52
+ * request despite omp rewriting the delivered prompt. */
53
+ function userTurnsRelate(a: string, b: string): boolean {
54
+ if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
55
+ // `@agent` is delivered to omp as a task-tool delegation prompt.
56
+ if (a.startsWith('Use the task tool to delegate this request') && /(^|\s)@[A-Za-z0-9_-]+/.test(b)) {
57
+ return true;
58
+ }
59
+ // `/skill:<name> <args>`: the JSONL only records a synthesized `/skill:<name>`.
60
+ const token = a.split(' ')[0];
61
+ return a.startsWith('/skill:') && Boolean(token) && b.startsWith(token);
62
+ }
63
+
64
+ /**
65
+ * Union two attachment lists by name.
66
+ *
67
+ * The JSONL entry comes first, so the order the model received wins; a stored
68
+ * entry with the same name fills in the fields the JSONL could not carry.
69
+ */
70
+ function mergeAttachmentLists(
71
+ fromJsonl: StoredAttachment[] | undefined,
72
+ fromStore: StoredAttachment[] | undefined,
73
+ ): StoredAttachment[] {
74
+ const jsonl = Array.isArray(fromJsonl) ? fromJsonl : [];
75
+ const stored = Array.isArray(fromStore) ? fromStore : [];
76
+ if (jsonl.length === 0) return stored;
77
+ if (stored.length === 0) return jsonl;
78
+ const remaining = [...stored];
79
+ const merged = jsonl.map((attachment) => {
80
+ const index = remaining.findIndex((candidate) => candidate.name === attachment.name);
81
+ if (index === -1) return attachment;
82
+ const [match] = remaining.splice(index, 1);
83
+ return {
84
+ ...match,
85
+ ...attachment,
86
+ content: attachment.content ?? match.content,
87
+ preview: attachment.preview || match.preview,
88
+ };
89
+ });
90
+ // Anything the JSONL could not see (an attachment omp dropped before the
91
+ // prompt was persisted) is still shown rather than silently discarded.
92
+ return [...merged, ...remaining];
93
+ }
94
+
95
+ /** Merge user turns and attachments from the chamber DB copy onto the
96
+ * JSONL-loaded messages. */
97
+ export function mergeOmpAttachments(
98
+ messages: StoredMessage[],
99
+ storedMessagesJson: string | undefined,
100
+ ): StoredMessage[] {
101
+ if (!storedMessagesJson) return messages;
102
+ let stored: StoredMessage[] = [];
103
+ try {
104
+ stored = JSON.parse(storedMessagesJson);
105
+ } catch {
106
+ return messages;
107
+ }
108
+ if (!Array.isArray(stored)) return messages;
109
+ const storedUsers = stored.filter((m) => m?.role === 'user' && typeof m.content === 'string');
110
+
111
+ const jsonlIndex = new Map<string, number>();
112
+ messages.forEach((m, index) => {
113
+ if (m.id) jsonlIndex.set(m.id, index);
114
+ });
115
+
116
+ const used = new Set<number>();
117
+ const merged = messages.map((m) => {
118
+ if (m.role !== 'user') return m;
119
+ const storedIndex = storedUsers.findIndex((s, i) => !used.has(i) && userTurnsRelate(m.content, s.content));
120
+ if (storedIndex === -1) return m;
121
+ used.add(storedIndex);
122
+ const storedMsg = storedUsers[storedIndex];
123
+ const content = storedMsg.content !== m.content && isRawComposerInput(storedMsg.content)
124
+ ? storedMsg.content
125
+ : m.content;
126
+ const attachments = mergeAttachmentLists(m.attachments, storedMsg.attachments);
127
+ const next = { ...m, content };
128
+ return attachments.length ? { ...next, attachments } : next;
129
+ });
130
+
131
+ // Unmatched raw turns are anchored before the next stored message the JSONL
132
+ // still carries; splice highest-anchor-first so earlier indices stay valid.
133
+ const insertions = new Map<number, StoredMessage[]>();
134
+ storedUsers.forEach((storedMsg, userIndex) => {
135
+ if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
136
+ if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
137
+ let anchor = merged.length;
138
+ for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
139
+ const id = stored[i]?.id;
140
+ const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
141
+ if (found !== undefined) {
142
+ anchor = found;
143
+ break;
144
+ }
145
+ }
146
+ const group = insertions.get(anchor);
147
+ if (group) group.push(storedMsg);
148
+ else insertions.set(anchor, [storedMsg]);
149
+ });
150
+ for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
151
+ const group = insertions.get(anchor);
152
+ if (group) merged.splice(anchor, 0, ...group);
153
+ }
154
+
155
+ const byContent = new Map<string, StoredAttachment[]>();
156
+ for (const m of stored) {
157
+ if (m?.role === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0) {
158
+ byContent.set(m.content, m.attachments);
159
+ }
160
+ }
161
+ return merged.map((m) => {
162
+ if (m.role !== 'user' || m.attachments?.length) return m;
163
+ // The JSONL content may carry inlined text-file blocks appended to the
164
+ // original prompt, so match by prefix instead of exact equality.
165
+ const atts = byContent.get(m.content)
166
+ ?? [...byContent.entries()].find(([storedContent]) =>
167
+ m.content.startsWith(storedContent) || storedContent.startsWith(m.content)
168
+ )?.[1];
169
+ return atts ? { ...m, attachments: atts } : m;
170
+ });
171
+ }
@@ -25,6 +25,7 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
25
25
  import type { ChatMessageData } from '@/shared/types/chat';
26
26
  import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
27
27
  import type { OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
28
+ import { readBlobRef } from '@/server/lib/omp/session/blobs.server';
28
29
 
29
30
  const MAX_SESSION_LOAD_BYTES = 512 * 1024 * 1024;
30
31
 
@@ -121,9 +122,42 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
121
122
  }
122
123
  state.messages.push(mapped);
123
124
  }
125
+ await resolveImageBlobs(state.messages);
124
126
  return state.messages;
125
127
  }
126
128
 
129
+ /**
130
+ * Replace `blob:sha256:` image references with the bytes they point at.
131
+ *
132
+ * omp externalizes a large attached image out of the session JSONL: the entry
133
+ * keeps the ref, the bytes go to the blob store. Only omp's own loader resolves
134
+ * them, so a reloaded chamber session used to render
135
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
136
+ * string, which the browser cannot decode. The ref itself is dropped once
137
+ * resolved; an unresolvable one leaves the attachment with no preview rather
138
+ * than a broken image.
139
+ */
140
+ async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
141
+ const pending: Promise<void>[] = [];
142
+ for (const message of messages) {
143
+ for (const attachment of message.attachments ?? []) {
144
+ // `blobRef` rides the attachment only until the bytes are read back; it is
145
+ // not part of the public attachment shape, so narrow before touching it.
146
+ if (!('blobRef' in attachment) || typeof attachment.blobRef !== 'string') continue;
147
+ const ref = attachment.blobRef;
148
+ delete attachment.blobRef;
149
+ pending.push(
150
+ readBlobRef(ref).then((bytes) => {
151
+ if (bytes) {
152
+ attachment.preview = `data:${attachment.type ?? 'image/png'};base64,${bytes.toString('base64')}`;
153
+ }
154
+ }),
155
+ );
156
+ }
157
+ }
158
+ await Promise.all(pending);
159
+ }
160
+
127
161
  /** Derive the display title from the JSONL header/title slot (cheap read). */
128
162
  export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
129
163
  try {
@@ -15,8 +15,8 @@
15
15
  * - cwd → repository root → lib/omp/worktree.ts
16
16
  * - managed projects registry → lib/omp/project-registry.ts
17
17
  *
18
- * All reads are bounded (4 KiB prefix per session file), cached by mtime, and
19
- * never mutate the agent's files.
18
+ * All reads are bounded (a 4 KiB prefix plus a 16 KiB tail window per session
19
+ * file), cached by file mtime, and never mutate the agent's files.
20
20
  */
21
21
 
22
22
  import { listAllSessionInfos, type OmpSessionInfo } from '@/server/lib/omp/session/files';
@@ -12,10 +12,13 @@
12
12
  * megabytes, and doing that per session per poll dominated the endpoint's cost
13
13
  * (the dataset scan itself is cached; this probe was not).
14
14
  *
15
- * The roster can only change when the session file or its sibling artifacts
16
- * directory changes, so that (session mtime, sibling-dir mtime) pair is a
17
- * sufficient cache version. Both cost one stat — orders of magnitude cheaper
18
- * than the parse they gate.
15
+ * The roster can only change when the session file gains entries or its
16
+ * sibling artifacts directory changes, so that (last-entry timestamp,
17
+ * sibling-dir mtime) pair is a sufficient cache version. Both cost one stat —
18
+ * orders of magnitude cheaper than the parse they gate. The timestamp is the
19
+ * file's last ENTRY time, not its mtime: a title-slot rewrite bumps mtime
20
+ * without appending anything, and a spurious version bump would re-run the
21
+ * parse it exists to avoid.
19
22
  */
20
23
 
21
24
  import { promises as fs } from 'fs';
@@ -42,10 +45,10 @@ async function siblingDirMtimeMs(dir: string): Promise<number> {
42
45
 
43
46
  export async function sessionHasSubagents(
44
47
  sessionPath: string,
45
- sessionModified: string,
48
+ sessionLastEntryAt: string,
46
49
  ): Promise<boolean> {
47
50
  const siblingDir = siblingDirForSession(sessionPath);
48
- const version = `${sessionModified}:${await siblingDirMtimeMs(siblingDir)}`;
51
+ const version = `${sessionLastEntryAt}:${await siblingDirMtimeMs(siblingDir)}`;
49
52
 
50
53
  const cached = cache.get(sessionPath);
51
54
  if (cached && cached.version === version) return cached.hasSubagents;
@@ -169,6 +169,63 @@ describe('queue delivery', () => {
169
169
  expect(queue.get(sessionId)).toEqual([]);
170
170
  });
171
171
 
172
+ // Attachments reach the server as the persisted display fields only: the
173
+ // composer's `File` never survives the queue row's JSON round trip, and its
174
+ // image preview is a `blob:` object URL the server cannot read. These pin the
175
+ // fields the delivery must use instead — `dataBase64` for the bytes, `type`
176
+ // for the media type, `content` for an inline-able text file.
177
+ test('a queued image rides its persisted base64 payload', async () => {
178
+ const item = queueItem('with image', null);
179
+ item.attachments = [
180
+ { id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
181
+ ] as QueuedMessage['attachments'];
182
+ queue.set(sessionId, [item]);
183
+ const { host, sent } = makeHost(sessionId);
184
+
185
+ scheduleQueueDelivery(host);
186
+ jest.advanceTimersByTime(600);
187
+ await flush();
188
+
189
+ expect(sent).toEqual([
190
+ {
191
+ type: 'prompt',
192
+ message: 'with image',
193
+ images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
194
+ },
195
+ ]);
196
+ });
197
+
198
+ test('a queued text file is inlined into the delivered prompt', async () => {
199
+ const item = queueItem('review this', null);
200
+ item.attachments = [
201
+ { id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
202
+ ] as QueuedMessage['attachments'];
203
+ queue.set(sessionId, [item]);
204
+ const { host, sent } = makeHost(sessionId);
205
+
206
+ scheduleQueueDelivery(host);
207
+ jest.advanceTimersByTime(600);
208
+ await flush();
209
+
210
+ const prompt = sent.find((c) => c.type === 'prompt');
211
+ expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
212
+ });
213
+
214
+ test('an attachment without a payload is not sent as a blank image', async () => {
215
+ const item = queueItem('no payload', null);
216
+ item.attachments = [
217
+ { id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
218
+ ] as QueuedMessage['attachments'];
219
+ queue.set(sessionId, [item]);
220
+ const { host, sent } = makeHost(sessionId);
221
+
222
+ scheduleQueueDelivery(host);
223
+ jest.advanceTimersByTime(600);
224
+ await flush();
225
+
226
+ expect(sent).toEqual([{ type: 'prompt', message: 'no payload' }]);
227
+ });
228
+
172
229
  test('a lost timer delays the queue instead of wedging it', async () => {
173
230
  queue.set(sessionId, [queueItem('recovered')]);
174
231
  const { host, sent } = makeHost(sessionId);
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
24
+ import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
24
25
  import type { QueuedMessage } from '@/shared/types/chat';
25
26
 
26
27
  /** Settling time between agent_end and the next prompt dispatch. Small on
@@ -71,12 +72,33 @@ async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessag
71
72
  await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
72
73
  }
73
74
  }
75
+ // Attachments reach this point as the persisted display fields — the live
76
+ // `File` never survived the queue's JSON round trip. The base64 payload and
77
+ // the MIME type are therefore read from the fields that DID persist:
78
+ // `dataBase64` for the bytes, `type` for the media type. (Matching on a
79
+ // `data:` preview used to yield zero images, because the composer's preview
80
+ // is a `blob:` object URL that the server cannot read.)
74
81
  const images = item.attachments
75
- .filter((a) => typeof a?.preview === 'string' && a.preview.startsWith('data:image/') && typeof a?.dataBase64 === 'string')
76
- .map((a) => ({ type: 'image' as const, data: a.dataBase64 as string, mimeType: a.preview.slice(5).split(';')[0] || 'image/png' }));
82
+ .filter((a) => typeof a?.dataBase64 === 'string' && a.dataBase64.length > 0)
83
+ .map((a) => ({
84
+ type: 'image' as const,
85
+ data: a.dataBase64 as string,
86
+ mimeType: typeof a.type === 'string' && a.type.startsWith('image/') ? a.type : 'image/png',
87
+ }));
88
+ // Text attachments are inlined into the prompt, exactly as the composer
89
+ // does at send time — the item's own `text` is the raw composer input, so
90
+ // without this a queued file would deliver as a bare filename mention.
91
+ const textFiles = item.attachments
92
+ .filter((a) => typeof a?.content === 'string' && a.content.length > 0)
93
+ .map((a) => ({
94
+ name: typeof a.name === 'string' && a.name ? a.name : 'attachment',
95
+ mimeType: typeof a.type === 'string' ? a.type : 'text/plain',
96
+ content: a.content as string,
97
+ size: typeof a.size === 'number' ? a.size : (a.content as string).length,
98
+ }));
77
99
  await session.send({
78
100
  type: 'prompt',
79
- message: item.text,
101
+ message: composeMessageWithTextAttachments(item.text, textFiles),
80
102
  ...(images.length ? { images } : {}),
81
103
  ...(item.model ? { accessMode: item.model.accessMode } : {}),
82
104
  });
@@ -36,7 +36,7 @@ function missingBuildResponse(): Response {
36
36
  <p>For a rebuild-on-change loop while developing:</p>
37
37
  <pre style="background:#f4f1ea;padding:1rem;border-radius:6px">bunx rsbuild build --watch</pre>
38
38
  </body>`,
39
- { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },
39
+ { status: 503, headers: HTML_HEADERS },
40
40
  );
41
41
  }
42
42
 
@@ -64,6 +64,33 @@ function themeColor(theme: string): string {
64
64
 
65
65
  const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
66
66
 
67
+ /**
68
+ * The HTML shell is generated per request, so its cacheability is declared
69
+ * rather than left to the browser's heuristics.
70
+ *
71
+ * It is not a static file: the theme and the bootstrap settings are injected on
72
+ * every request, and its only link to the bundle is a content hash inside the
73
+ * markup. A copy that outlives its build does not merely go stale — it points at
74
+ * an asset hash the build no longer emits, leaving the page running previous
75
+ * code with nothing in the response to signal it.
76
+ *
77
+ * Chrome does not currently cache it: the response carries no `Cache-Control`,
78
+ * `Last-Modified` or `ETag`, so there is no freshness to heuristically derive
79
+ * and every navigation re-fetches (measured: a second load of the shell reported
80
+ * the full 41 KB `transferSize`). That is the behaviour this header makes
81
+ * explicit — the day a validator is added for the template, heuristic caching
82
+ * would start and nothing else here would prevent it.
83
+ *
84
+ * `no-store` rather than `no-cache`: with no validator there is nothing to
85
+ * revalidate against, so a stored copy is never usable.
86
+ */
87
+ const SHELL_CACHE = 'no-store';
88
+
89
+ const HTML_HEADERS = {
90
+ 'content-type': 'text/html; charset=utf-8',
91
+ 'cache-control': SHELL_CACHE,
92
+ } as const;
93
+
67
94
  export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }) => {
68
95
  const pathname = new URL(request.url).pathname;
69
96
 
@@ -95,6 +122,6 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
95
122
  .replace('data-theme="paper"', `data-theme="${theme}"`)
96
123
  .replace('<!--app-head-->', `<meta name="theme-color" content="${themeColor(theme)}">`)
97
124
  .replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
98
- { headers: { 'content-type': 'text/html; charset=utf-8' } },
125
+ { headers: HTML_HEADERS },
99
126
  );
100
127
  });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `GET /api/btw/:sessionId/events` — SSE stream of a session's BTW frames.
8
+ *
9
+ * Unlike the agent stream this one is not observer-only: a side question's
10
+ * history must be readable for a session whose omp child is not running (that
11
+ * is the normal state of a session opened from the sidebar), so the route
12
+ * subscribes to the registry and never spawns anything.
13
+ */
14
+
15
+ import { createEventCoalescer, createSseStream, type EventCoalescer } from '@/server/lib/sse';
16
+ import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
17
+ import type { BtwFrame } from '@/shared/types';
18
+
19
+ /** Longest a coalesced frame may wait before it is flushed anyway. */
20
+ const FLUSH_DELAY_MS = 50;
21
+
22
+ export async function loader({ params, request }: { params: Record<string, string>; request: Request }) {
23
+ const { sessionId } = params;
24
+ if (!sessionId) return new Response('session id is required', { status: 400 });
25
+
26
+ let coalescer: EventCoalescer | null = null;
27
+
28
+ const stream = createSseStream({
29
+ heartbeatMs: 30_000,
30
+ signal: request.signal,
31
+ beforeHeartbeat: () => coalescer?.flush() ?? true,
32
+ onStart(handlers) {
33
+ let unsubscribe: (() => void) | null = null;
34
+ coalescer = createEventCoalescer({
35
+ send: (event, data) => handlers.send(event, data),
36
+ isBackpressured: handlers.isBackpressured,
37
+ flushDelayMs: FLUSH_DELAY_MS,
38
+ });
39
+ const push = (frame: BtwFrame) => coalescer?.push('', frame);
40
+
41
+ handlers.send('', { type: 'connected', sessionId });
42
+ if (handlers.isClosed()) return;
43
+ unsubscribe = subscribeBtw(sessionId, push);
44
+ void btwStateFor(sessionId)
45
+ .then((state) => push({ type: 'btw_state', state }))
46
+ .catch((error: unknown) => {
47
+ push({
48
+ type: 'btw_error',
49
+ topicId: null,
50
+ message: error instanceof Error ? error.message : String(error),
51
+ });
52
+ });
53
+
54
+ return () => {
55
+ coalescer?.dispose();
56
+ coalescer = null;
57
+ if (unsubscribe) {
58
+ try {
59
+ unsubscribe();
60
+ } catch {
61
+ // Subscriber cleanup is best-effort.
62
+ }
63
+ unsubscribe = null;
64
+ }
65
+ };
66
+ },
67
+ });
68
+
69
+ return stream.response;
70
+ }
@@ -0,0 +1,8 @@
1
+ import { actionBindings, bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import { action, loader } from '@/server/routes/btw/topics';
3
+ import * as btwEvents from '@/server/routes/btw/events';
4
+
5
+ export const btwBindings: HandlerBinding[] = [
6
+ ...actionBindings(action, '/api/btw/:sessionId', loader),
7
+ ...bindingsFor(btwEvents, '/api/btw/:sessionId/events'),
8
+ ];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW commands: `GET /api/btw/:sessionId` returns a session's side questions,
8
+ * `POST` runs one action against them.
9
+ *
10
+ * Mutating verbs stay in one handler with its own dispatch (the ported Remix
11
+ * shape the rest of the API uses), so an unsupported action gets a named error
12
+ * instead of a silent success.
13
+ */
14
+
15
+ import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
+ import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
17
+ import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
18
+ import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
19
+
20
+ /** Actions that can reject for a reason the client should show as-is. */
21
+ const STATUS_BY_CODE: Record<string, number> = {
22
+ btw_busy: 409,
23
+ btw_stale: 409,
24
+ session_not_found: 404,
25
+ btw_topic_not_found: 404,
26
+ };
27
+
28
+ function btwErrorResponse(error: unknown): Response {
29
+ if (error instanceof BtwError) {
30
+ return json({ error: error.message, code: error.code }, { status: STATUS_BY_CODE[error.code] ?? 400 });
31
+ }
32
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
33
+ }
34
+
35
+ function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
36
+ const problem = validateAgentImages(raw);
37
+ if (problem) return { error: problem };
38
+ const images = toImageContents(raw);
39
+ return images ? { images } : {};
40
+ }
41
+
42
+ export async function action({ params, request }: ActionFunctionArgs) {
43
+ const { sessionId } = params;
44
+ if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
45
+
46
+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
47
+ const action = typeof body?.action === 'string' ? body.action : '';
48
+ const topicId = typeof body?.topicId === 'string' ? body.topicId : undefined;
49
+ if (!action) return json({ error: 'action is required', code: 'action_required' }, { status: 400 });
50
+
51
+ try {
52
+ switch (action) {
53
+ case 'ask': {
54
+ const question = typeof body?.question === 'string' ? body.question : '';
55
+ const parsed = readImages(body?.images);
56
+ if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
57
+ return json({ success: true, data: await askBtw(sessionId, { topicId, question, images: parsed.images }) });
58
+ }
59
+ case 'abort': {
60
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
61
+ return json({ success: true, data: await abortBtw(sessionId, topicId) });
62
+ }
63
+ case 'promote': {
64
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
65
+ return json({ success: true, data: await promoteBtw(sessionId, topicId) });
66
+ }
67
+ case 'delete': {
68
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
69
+ return json({ success: true, data: await removeBtw(sessionId, topicId) });
70
+ }
71
+ default:
72
+ return json({ error: `Unknown btw action: ${action}`, code: 'unknown_action' }, { status: 400 });
73
+ }
74
+ } catch (error) {
75
+ return btwErrorResponse(error);
76
+ }
77
+ }
78
+
79
+ export async function loader({ params }: LoaderFunctionArgs) {
80
+ const { sessionId } = params;
81
+ if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
82
+ try {
83
+ return json({ success: true, data: await getBtwState(sessionId) });
84
+ } catch (error) {
85
+ return btwErrorResponse(error);
86
+ }
87
+ }