ompchamber 1.1.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 (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.57b39ffa7f.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 +181 -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 +83 -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.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * WebSocket transport for a session's BTW frame stream — the default sibling
8
+ * of `GET /api/btw/:sessionId/events`, mirroring the agent stream's protocol:
9
+ * one duplex socket per session, bare JSON frames, `connected` first, a
10
+ * protocol-level ping/pong keepalive, and the same latest-wins coalescer.
11
+ *
12
+ * A client that attaches replays nothing: the socket's first `btw_state` is
13
+ * the session's current history, so a reload lands on the same panel a live
14
+ * stream would have produced.
15
+ */
16
+
17
+ import { Elysia, t } from 'elysia';
18
+ import { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
19
+ import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
20
+ import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
21
+ import type { BtwFrame } from '@/shared/types';
22
+
23
+ /** Coalesce frames once this many bytes sit unsent. */
24
+ const HIGH_WATER_BYTES = 256 * 1024;
25
+
26
+ /** Longest a coalesced frame may wait before it is flushed anyway. */
27
+ const FLUSH_DELAY_MS = 50;
28
+
29
+ interface ConnectionState {
30
+ closed: boolean;
31
+ unsubscribe: (() => void) | null;
32
+ heartbeat: ReturnType<typeof setInterval> | null;
33
+ awaitingPong: boolean;
34
+ coalescer: EventCoalescer | null;
35
+ }
36
+
37
+ const connections = new WeakMap<object, ConnectionState>();
38
+
39
+ export const btwWsRoutes = new Elysia({ prefix: '/api/btw' }).ws('/:sessionId/ws', {
40
+ params: t.Object({ sessionId: t.String() }),
41
+
42
+ open(ws) {
43
+ const sessionId = ws.data.params.sessionId;
44
+ const state: ConnectionState = { closed: false, unsubscribe: null, heartbeat: null, awaitingPong: false, coalescer: null };
45
+ connections.set(ws, state);
46
+ // Unchecked: Elysia exposes the server socket untyped. Both members are
47
+ // optional, so an absent one degrades (no byte count / no keepalive)
48
+ // instead of failing.
49
+ const raw = ws.raw as { bufferedAmount?: number; ping?: () => void };
50
+
51
+ const teardown = () => {
52
+ if (state.closed) return;
53
+ state.closed = true;
54
+ state.coalescer?.dispose();
55
+ state.coalescer = null;
56
+ if (state.heartbeat !== null) {
57
+ clearInterval(state.heartbeat);
58
+ state.heartbeat = null;
59
+ }
60
+ if (state.unsubscribe) {
61
+ try {
62
+ state.unsubscribe();
63
+ } catch {
64
+ // Subscriber cleanup is best-effort.
65
+ }
66
+ state.unsubscribe = null;
67
+ }
68
+ try {
69
+ ws.close();
70
+ } catch {
71
+ // Already gone.
72
+ }
73
+ };
74
+
75
+ state.coalescer = createEventCoalescer({
76
+ send: (_event, data) => {
77
+ try {
78
+ ws.send(JSON.stringify(data));
79
+ } catch {
80
+ teardown();
81
+ }
82
+ },
83
+ isBackpressured: () => (raw.bufferedAmount ?? 0) > HIGH_WATER_BYTES,
84
+ flushDelayMs: FLUSH_DELAY_MS,
85
+ });
86
+
87
+ state.heartbeat = setInterval(() => {
88
+ if (state.closed) return;
89
+ if (state.awaitingPong) {
90
+ teardown();
91
+ return;
92
+ }
93
+ state.awaitingPong = true;
94
+ if (!state.coalescer?.flush()) return;
95
+ try {
96
+ raw.ping?.();
97
+ } catch {
98
+ teardown();
99
+ }
100
+ }, STREAM_HEARTBEAT_MS);
101
+
102
+ const push = (frame: BtwFrame) => state.coalescer?.push('', frame);
103
+ state.unsubscribe = subscribeBtw(sessionId, push);
104
+ push({ type: 'connected', sessionId });
105
+ void btwStateFor(sessionId)
106
+ .then((snapshot) => push({ type: 'btw_state', state: snapshot }))
107
+ .catch((error: unknown) => {
108
+ push({ type: 'btw_error', topicId: null, message: error instanceof Error ? error.message : String(error) });
109
+ });
110
+ },
111
+
112
+ pong(ws) {
113
+ const state = connections.get(ws);
114
+ if (state) state.awaitingPong = false;
115
+ },
116
+
117
+ message() {
118
+ // Observer-only: commands go through POST /api/btw/:sessionId.
119
+ },
120
+
121
+ close(ws) {
122
+ const state = connections.get(ws);
123
+ if (!state || state.closed) return;
124
+ state.closed = true;
125
+ state.coalescer?.dispose();
126
+ state.coalescer = null;
127
+ if (state.heartbeat !== null) clearInterval(state.heartbeat);
128
+ if (state.unsubscribe) {
129
+ try {
130
+ state.unsubscribe();
131
+ } catch {
132
+ // Subscriber cleanup is best-effort.
133
+ }
134
+ }
135
+ },
136
+ });
@@ -3,10 +3,12 @@ import * as chatRoot from '@/server/routes/chat/root';
3
3
  import * as chatSession from '@/server/routes/chat/session';
4
4
  import * as chatRewind from '@/server/routes/chat/rewind';
5
5
  import * as chatStream from '@/server/routes/chat/stream';
6
+ import * as chatTurns from '@/server/routes/chat/turns';
6
7
 
7
8
  export const chatBindings: HandlerBinding[] = [
8
9
  ...bindingsFor(chatRoot, '/api/chat'),
9
10
  ...bindingsFor(chatSession, '/api/chat/:sessionId'),
10
11
  ...bindingsFor(chatRewind, '/api/chat/:sessionId/rewind'),
11
12
  ...bindingsFor(chatStream, '/api/chat/stream'),
13
+ ...bindingsFor(chatTurns, '/api/chat/:sessionId/turns'),
12
14
  ];
@@ -6,6 +6,7 @@ import { getSessionData } from '@/client/data/mock/chat';
6
6
  import { isMockMode } from '@/server/mock.server';
7
7
  import { findSessionFileById } from '@/server/lib/omp/session/locator';
8
8
  import { loadSessionMessages, loadSessionModel, loadSessionThinkingLevel, loadSessionTitle } from '@/server/lib/omp/session/messages';
9
+ import { mergeOmpAttachments } from '@/server/lib/omp/session/merge-stored';
9
10
  import { readRawHeaderLine } from '@/server/lib/omp/session/files';
10
11
  import { formatNewSessionTitle } from '@/shared/lib/omp/session/default-title';
11
12
 
@@ -15,121 +16,6 @@ import { formatNewSessionTitle } from '@/shared/lib/omp/session/default-title';
15
16
  * payload (and the mobile parse+mount cost) bounded. */
16
17
  const DEFAULT_MESSAGE_WINDOW = 120;
17
18
 
18
- interface StoredAttachment {
19
- name?: string;
20
- preview?: string;
21
- type?: string;
22
- size?: number;
23
- content?: string;
24
- }
25
-
26
- interface StoredMessage {
27
- id?: string;
28
- role: string;
29
- content: string;
30
- date?: string;
31
- timestamp?: string;
32
- attachments?: StoredAttachment[];
33
- }
34
-
35
- function isRawComposerInput(content: string): boolean {
36
- return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
37
- }
38
-
39
- /** Whether a JSONL-derived user turn (a) and a stored one (b) are the same
40
- * request despite omp rewriting the delivered prompt. */
41
- function userTurnsRelate(a: string, b: string): boolean {
42
- if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
43
- // `@agent` is delivered to omp as a task-tool delegation prompt.
44
- if (a.startsWith('Use the task tool to delegate this request') && /(^|\s)@[A-Za-z0-9_-]+/.test(b)) {
45
- return true;
46
- }
47
- // `/skill:<name> <args>`: the JSONL only records a synthesized `/skill:<name>`.
48
- const token = a.split(' ')[0];
49
- return a.startsWith('/skill:') && Boolean(token) && b.startsWith(token);
50
- }
51
-
52
- /** Merge user turns and attachments from the chamber DB copy onto the
53
- * JSONL-loaded messages. omp's JSONL rewrites raw composer input (`@agent` →
54
- * task-tool prompt, `/skill:a b` → `/skill:a`) and writes no user entry at all
55
- * for `/command` turns, so the raw DB copy restores both the displayed text
56
- * and the missing bubbles. */
57
- function mergeOmpAttachments(
58
- messages: StoredMessage[],
59
- storedMessagesJson: string | undefined,
60
- ): StoredMessage[] {
61
- if (!storedMessagesJson) return messages;
62
- let stored: StoredMessage[] = [];
63
- try {
64
- stored = JSON.parse(storedMessagesJson);
65
- } catch {
66
- return messages;
67
- }
68
- if (!Array.isArray(stored)) return messages;
69
- const storedUsers = stored.filter((m) => m?.role === 'user' && typeof m.content === 'string');
70
-
71
- const jsonlIndex = new Map<string, number>();
72
- messages.forEach((m, index) => {
73
- if (m.id) jsonlIndex.set(m.id, index);
74
- });
75
-
76
- const used = new Set<number>();
77
- const merged = messages.map((m) => {
78
- if (m.role !== 'user') return m;
79
- const storedIndex = storedUsers.findIndex((s, i) => !used.has(i) && userTurnsRelate(m.content, s.content));
80
- if (storedIndex === -1) return m;
81
- used.add(storedIndex);
82
- const storedMsg = storedUsers[storedIndex];
83
- const content = storedMsg.content !== m.content && isRawComposerInput(storedMsg.content)
84
- ? storedMsg.content
85
- : m.content;
86
- const attachments = m.attachments?.length ? m.attachments : storedMsg.attachments;
87
- const next = { ...m, content };
88
- return attachments?.length ? { ...next, attachments } : next;
89
- });
90
-
91
- // Unmatched raw turns are anchored before the next stored message the JSONL
92
- // still carries; splice highest-anchor-first so earlier indices stay valid.
93
- const insertions = new Map<number, StoredMessage[]>();
94
- storedUsers.forEach((storedMsg, userIndex) => {
95
- if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
96
- if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
97
- let anchor = merged.length;
98
- for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
99
- const id = stored[i]?.id;
100
- const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
101
- if (found !== undefined) {
102
- anchor = found;
103
- break;
104
- }
105
- }
106
- const group = insertions.get(anchor);
107
- if (group) group.push(storedMsg);
108
- else insertions.set(anchor, [storedMsg]);
109
- });
110
- for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
111
- const group = insertions.get(anchor);
112
- if (group) merged.splice(anchor, 0, ...group);
113
- }
114
-
115
- const byContent = new Map<string, StoredAttachment[]>();
116
- for (const m of stored) {
117
- if (m?.role === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0) {
118
- byContent.set(m.content, m.attachments);
119
- }
120
- }
121
- return merged.map((m) => {
122
- if (m.role !== 'user' || m.attachments?.length) return m;
123
- // The JSONL content may carry inlined text-file blocks appended to the
124
- // original prompt, so match by prefix instead of exact equality.
125
- const atts = byContent.get(m.content)
126
- ?? [...byContent.entries()].find(([storedContent]) =>
127
- m.content.startsWith(storedContent) || storedContent.startsWith(m.content)
128
- )?.[1];
129
- return atts ? { ...m, attachments: atts } : m;
130
- });
131
- }
132
-
133
19
  /** Slice a fully loaded message list into the requested tail window. */
134
20
  function windowMessages<T>(
135
21
  messages: T[],
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Full-history user-turn index for the timeline jump rail.
8
+ *
9
+ * The timeline only mounts a window of a long session (the newest 120 messages
10
+ * plus whatever "Load earlier messages" pulled in), so a rail built from the
11
+ * mounted rows shows a fraction of the conversation and cannot reach a turn
12
+ * that was never paged in. This endpoint answers the same id/position question
13
+ * the timeline does — the JSONL messages with the chamber DB overlay merged —
14
+ * but for the WHOLE session, and strips each row down to what a tick needs.
15
+ *
16
+ * Positions are indices into that full merged list, which is exactly the list
17
+ * `GET /api/chat/:sessionId` pages through with `?before=`: a rail click pages
18
+ * windows from that index until the target row is mounted, so the cursor the
19
+ * rail carries is the same coordinate space the loader uses.
20
+ */
21
+
22
+ import { json } from '@/server/lib/remix-compat';
23
+ import type { LoaderFunctionArgs } from '@/server/lib/remix-compat';
24
+ import { getDb } from '@/server/db.server';
25
+ import { getSessionData } from '@/client/data/mock/chat';
26
+ import { isMockMode } from '@/server/mock.server';
27
+ import { findSessionFileById } from '@/server/lib/omp/session/locator';
28
+ import { loadSessionMessages } from '@/server/lib/omp/session/messages';
29
+ import { mergeOmpAttachments } from '@/server/lib/omp/session/merge-stored';
30
+ import { TURN_PREVIEW_CHARS } from '@/shared/lib/chat/timeline/turns';
31
+ import type { UserTurnRef } from '@/shared/types/chat';
32
+
33
+ /** One rail entry per user row: its id (the DOM jump target), its position in
34
+ * the full list (the paging cursor), and enough text for the tooltip. */
35
+ function toUserTurns(messages: Array<{ id?: string; role?: string; content?: string; date?: string; timestamp?: string }>): UserTurnRef[] {
36
+ const turns: UserTurnRef[] = [];
37
+ messages.forEach((message, index) => {
38
+ if (message?.role !== 'user') return;
39
+ const content = typeof message.content === 'string' ? message.content : '';
40
+ turns.push({
41
+ id: typeof message.id === 'string' && message.id ? message.id : `turn-${index}`,
42
+ index,
43
+ preview: content.slice(0, TURN_PREVIEW_CHARS),
44
+ date: message.date,
45
+ timestamp: message.timestamp,
46
+ });
47
+ });
48
+ return turns;
49
+ }
50
+
51
+ export async function loader({ params }: LoaderFunctionArgs) {
52
+ const { sessionId } = params;
53
+ if (!sessionId) return json({ error: 'Session ID is required' }, { status: 400 });
54
+
55
+ const mock = isMockMode();
56
+ try {
57
+ if (!mock) {
58
+ const filePath = await findSessionFileById(sessionId);
59
+ if (filePath) {
60
+ const messages = await loadSessionMessages(filePath);
61
+ const db = await getDb();
62
+ const overlay = await db.get('SELECT messages FROM chat_sessions WHERE session_id = ?', [sessionId]);
63
+ const overlaid = mergeOmpAttachments(messages, overlay?.messages);
64
+ return json({ turns: toUserTurns(overlaid), total: overlaid.length, source: 'omp-jsonl' });
65
+ }
66
+ }
67
+
68
+ // Chamber-created sessions (mock or synthetic ids) answer from the DB copy,
69
+ // mirroring the timeline loader's fallback.
70
+ const db = await getDb();
71
+ const existing = await db.get('SELECT messages FROM chat_sessions WHERE session_id = ?', [sessionId]);
72
+ let messages: unknown[] = [];
73
+ if (existing?.messages) {
74
+ try {
75
+ const parsed: unknown = JSON.parse(existing.messages);
76
+ if (Array.isArray(parsed)) messages = parsed;
77
+ } catch {
78
+ messages = [];
79
+ }
80
+ } else if (mock) {
81
+ messages = getSessionData(sessionId)?.messages ?? [];
82
+ }
83
+ return json({ turns: toUserTurns(messages as Array<{ role?: string; content?: string }>), total: messages.length, source: mock ? 'mock' : 'db' });
84
+ } catch (error: unknown) {
85
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
86
+ }
87
+ }
@@ -1,5 +1,6 @@
1
1
  import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
- import { browseDirectories, listDirectory, listFiles, readFile } from '@/server/routes/fs/read';
2
+ import { browseDirectories, listDirectory, listFiles, readFile, readReferencedFile } from '@/server/routes/fs/read';
3
+ import { serveRawFile } from '@/server/routes/fs/raw';
3
4
  import * as fsAction from '@/server/routes/fs/action';
4
5
  import * as fsGit from '@/server/routes/fs/git';
5
6
  import * as fsReplace from '@/server/routes/fs/replace';
@@ -16,4 +17,6 @@ export const fsBindings: HandlerBinding[] = [
16
17
  { method: 'GET', path: '/api/fs/dir', handler: listDirectory },
17
18
  { method: 'GET', path: '/api/fs/list', handler: listFiles },
18
19
  { method: 'GET', path: '/api/fs/read', handler: readFile },
20
+ { method: 'GET', path: '/api/fs/read-reference', handler: readReferencedFile },
21
+ { method: 'GET', path: '/api/fs/raw', handler: serveRawFile },
19
22
  ];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
7
+ import { errorResponse } from '@/server/lib/route-adapter';
8
+ import { isMockMode } from '@/server/mock.server';
9
+ import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
10
+ import { getImageMimeType } from '@/shared/lib/fs/file-kind';
11
+
12
+ /**
13
+ * GET /api/fs/raw?path=<rel>&root=<abs>&repo=<rel> — the file's bytes, verbatim.
14
+ *
15
+ * Images reach the editor through this route instead of `/api/fs/read`, which
16
+ * answers with JSON and therefore cannot carry them: a PNG's bytes are not
17
+ * UTF-8, so `Bun.file().text()` returned replacement characters and the panel
18
+ * painted mojibake. The browser can only paint a picture from a real byte
19
+ * stream with a real `content-type`, which is exactly what this returns.
20
+ *
21
+ * Scope resolution is identical to `/api/fs/read` (same root allow-list, same
22
+ * `resolveWithinRoot` containment), so a path that reads as text can be served
23
+ * as bytes and vice versa.
24
+ */
25
+ export async function serveRawFile({ request }: LoaderFunctionArgs) {
26
+ const url = new URL(request.url);
27
+ const filePath = url.searchParams.get('path');
28
+
29
+ if (!filePath) {
30
+ return json({ error: 'Missing path' }, { status: 400 });
31
+ }
32
+
33
+ let baseDir = await resolveRoot(url.searchParams.get('root'), await getDefaultFsRoot(isMockMode()));
34
+
35
+ const repo = url.searchParams.get('repo');
36
+ if (repo && repo !== '.') {
37
+ const repoDir = resolveWithinRoot(baseDir, repo);
38
+ if (repoDir) {
39
+ baseDir = repoDir;
40
+ }
41
+ }
42
+
43
+ const cleanPath = filePath.replace(/^\/+/, '');
44
+ const fullPath = resolveWithinRoot(baseDir, cleanPath);
45
+
46
+ if (!fullPath) {
47
+ return json({ error: 'Invalid path' }, { status: 403 });
48
+ }
49
+
50
+ try {
51
+ const file = Bun.file(fullPath);
52
+ if (!(await file.exists())) {
53
+ return json({ error: 'File not found' }, { status: 404 });
54
+ }
55
+ const stat = await file.stat();
56
+ if (stat.isDirectory()) {
57
+ return json({ error: 'Cannot read a directory' }, { status: 400 });
58
+ }
59
+
60
+ // `no-store` because a raw read is how a refreshed view sees a file that
61
+ // changed on disk under the same path; an immutable image URL would pin the
62
+ // old bytes for the life of the tab.
63
+ return new Response(file.stream(), {
64
+ headers: {
65
+ 'content-type': getImageMimeType(cleanPath) ?? 'application/octet-stream',
66
+ 'content-length': String(stat.size),
67
+ 'content-disposition': `inline; filename="${safeFileName(cleanPath)}"`,
68
+ 'cache-control': 'no-store',
69
+ },
70
+ });
71
+ } catch (error) {
72
+ return errorResponse(error);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The filename for `content-disposition`. Only ASCII-printable characters
78
+ * survive: the header is latin-1, and a non-ASCII byte (or a quote) in a
79
+ * filename would either corrupt the header or break out of the quoted string.
80
+ */
81
+ function safeFileName(cleanPath: string): string {
82
+ const base = cleanPath.split('/').pop() ?? 'file';
83
+ const ascii = base.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
84
+ return ascii || 'file';
85
+ }
@@ -10,8 +10,9 @@ import { dirname, isAbsolute, join, resolve } from 'path';
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { isMockMode } from '@/server/mock.server';
13
- import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
13
+ import { getDefaultFsRoot, resolveReferencedPath, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
14
14
  import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
15
+ import { getImageMimeType } from '@/shared/lib/fs/file-kind';
15
16
 
16
17
  /**
17
18
  * GET /api/fs/browse?path=<abs> — list subdirectories of a folder for the
@@ -230,7 +231,6 @@ export async function readFile({ request }: LoaderFunctionArgs) {
230
231
  if (!filePath) {
231
232
  return json({ error: 'Missing path' }, { status: 400 });
232
233
  }
233
-
234
234
  let baseDir = await resolveRoot(url.searchParams.get('root'), await getDefaultFsRoot(isMockMode()));
235
235
 
236
236
  // If repo is specified (e.g. nested git project), resolve inside the repo
@@ -254,13 +254,68 @@ export async function readFile({ request }: LoaderFunctionArgs) {
254
254
  if (!(await file.exists())) {
255
255
  return json({ error: 'File not found' }, { status: 404 });
256
256
  }
257
- if ((await file.stat()).isDirectory()) {
257
+ const stat = await file.stat();
258
+ if (stat.isDirectory()) {
258
259
  return json({ error: 'Cannot read a directory' }, { status: 400 });
259
260
  }
260
261
 
262
+ // An image has no text: decoding its bytes here returned replacement
263
+ // characters, which every reader painted as mojibake. Every in-repo reader
264
+ // branches on the extension first and takes `/api/fs/raw`, so this answers
265
+ // the remaining callers with the real reason instead of garbage.
266
+ if (getImageMimeType(cleanPath)) {
267
+ return json({ error: 'Image files are served by /api/fs/raw' }, { status: 400 });
268
+ }
269
+
261
270
  const content = await file.text();
262
271
  return json({ content });
263
272
  } catch (error) {
264
273
  return errorResponse(error);
265
274
  }
266
275
  }
276
+
277
+ /**
278
+ * GET /api/fs/read-reference?path=<abs path or URL> — read a file a drop or a
279
+ * link pointed at.
280
+ *
281
+ * A download dragged out of a web page (WhatsApp Web, Gmail, Slack) carries no
282
+ * bytes, only a reference, and the page cannot fetch a `file://` path itself.
283
+ * The path is therefore resolved against the same allow-list that governs a
284
+ * client-supplied `root` — the app root, a path beneath it, or a registered
285
+ * workspace — so this cannot be used to read an arbitrary file.
286
+ *
287
+ * Text only: an image or binary has no string form to return, and the drop's
288
+ * own `File` is the path that carries those.
289
+ */
290
+ export async function readReferencedFile({ request }: LoaderFunctionArgs) {
291
+ const url = new URL(request.url);
292
+ const reference = url.searchParams.get('path');
293
+ if (!reference) {
294
+ return json({ error: 'Missing path' }, { status: 400 });
295
+ }
296
+
297
+ // A remote reference is not fetched here: the chamber has no policy for
298
+ // outbound requests and a drag from a page is not a reason to add one.
299
+ if (/^https?:\/\//i.test(reference)) {
300
+ return json({ error: 'Remote references are not supported' }, { status: 400 });
301
+ }
302
+
303
+ const filePath = await resolveReferencedPath(reference.replace(/^file:\/\//, ''));
304
+ if (!filePath) {
305
+ return json({ error: 'File not found' }, { status: 404 });
306
+ }
307
+
308
+ try {
309
+ const file = Bun.file(filePath);
310
+ const stat = await file.stat();
311
+ if (stat.isDirectory()) {
312
+ return json({ error: 'Cannot read a directory' }, { status: 400 });
313
+ }
314
+ if (getImageMimeType(filePath)) {
315
+ return json({ error: 'Image files are served by /api/fs/raw' }, { status: 400 });
316
+ }
317
+ return json({ content: await file.text(), name: path.basename(filePath) });
318
+ } catch (error) {
319
+ return errorResponse(error);
320
+ }
321
+ }
@@ -2,6 +2,8 @@ import { Elysia } from 'elysia';
2
2
  import { mountBindings, type HandlerBinding } from '@/server/lib/route-adapter';
3
3
  import { agentBindings } from '@/server/routes/agent';
4
4
  import { agentWsRoutes } from '@/server/routes/agent/ws';
5
+ import { btwBindings } from '@/server/routes/btw';
6
+ import { btwWsRoutes } from '@/server/routes/btw/ws';
5
7
  import { chatBindings } from '@/server/routes/chat';
6
8
  import { sessionsBindings } from '@/server/routes/sessions';
7
9
  import { settingsBindings } from '@/server/routes/settings';
@@ -20,6 +22,7 @@ import { wellKnownBindings } from '@/server/routes/well-known';
20
22
 
21
23
  const allBindings: HandlerBinding[] = [
22
24
  ...agentBindings,
25
+ ...btwBindings,
23
26
  ...chatBindings,
24
27
  ...sessionsBindings,
25
28
  ...settingsBindings,
@@ -36,4 +39,7 @@ const allBindings: HandlerBinding[] = [
36
39
  ...wellKnownBindings,
37
40
  ];
38
41
 
39
- export const apiRoutes = mountBindings(new Elysia(), allBindings).use(agentWsRoutes).use(terminalWsRoutes);
42
+ export const apiRoutes = mountBindings(new Elysia(), allBindings)
43
+ .use(agentWsRoutes)
44
+ .use(btwWsRoutes)
45
+ .use(terminalWsRoutes);