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,137 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ consumeAutoTitleOutput,
10
+ markTitleRequestSent,
11
+ triggerAutoSessionTitle,
12
+ type AutoTitleHost,
13
+ } from '@/server/lib/omp/session/auto-title.server';
14
+
15
+ /**
16
+ * The safety property of auto-titling, and the one thing that must never
17
+ * regress: the chamber fires omp's own `/rename` (which persists through
18
+ * `setSessionName(title, "user")`) in the background, and that write has no
19
+ * "don't overwrite" guard of its own — omp only refuses an `"auto"` write over
20
+ * a `"user"` title. So the decision to skip a NAMED session is ours, and a bug
21
+ * here silently destroys a name the operator typed.
22
+ *
23
+ * These tests drive the guard directly: a fake `get_state` reports a name, and
24
+ * the assertion is that no `/rename` reaches the wire.
25
+ */
26
+
27
+ interface FakeProc {
28
+ commands: Array<Record<string, unknown>>;
29
+ sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
30
+ }
31
+
32
+ function makeHost(options: {
33
+ sessionName?: string;
34
+ sessionId?: string;
35
+ inFlight?: boolean;
36
+ }): { host: AutoTitleHost; proc: FakeProc } {
37
+ const proc: FakeProc = {
38
+ commands: [],
39
+ async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
40
+ proc.commands.push(command);
41
+ if (command.type === 'get_state') {
42
+ return {
43
+ sessionId: options.sessionId ?? 'sess-1',
44
+ sessionName: options.sessionName,
45
+ } as T;
46
+ }
47
+ return undefined as T;
48
+ },
49
+ };
50
+ return {
51
+ host: {
52
+ sessionId: 'sess-1',
53
+ autoTitleInFlight: options.inFlight ?? false,
54
+ autoTitleWindowUntil: 0,
55
+ proc: proc as unknown as AutoTitleHost['proc'],
56
+ },
57
+ proc,
58
+ };
59
+ }
60
+
61
+ /** Commands other than the `get_state` probe the guard always performs. */
62
+ function sentRenames(proc: FakeProc): Array<Record<string, unknown>> {
63
+ return proc.commands.filter((command) => command.type === 'prompt');
64
+ }
65
+
66
+ describe('triggerAutoSessionTitle', () => {
67
+ test('names an unnamed session through omp own /rename', async () => {
68
+ const { host, proc } = makeHost({});
69
+ await triggerAutoSessionTitle(host);
70
+
71
+ const renames = sentRenames(proc);
72
+ expect(renames).toHaveLength(1);
73
+ expect(renames[0].message).toBe('/rename');
74
+ // The request window opens only after the command is accepted, so the
75
+ // diagnostics omp is about to emit can be attributed to us.
76
+ expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
77
+ });
78
+
79
+ test('never overwrites a name the operator set', async () => {
80
+ const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
81
+ await triggerAutoSessionTitle(host);
82
+
83
+ expect(sentRenames(proc)).toHaveLength(0);
84
+ // No request was sent, so nothing may claim the output window either.
85
+ expect(host.autoTitleWindowUntil).toBe(0);
86
+ });
87
+
88
+ test('treats a whitespace-only name as unnamed', async () => {
89
+ const { host, proc } = makeHost({ sessionName: ' ' });
90
+ await triggerAutoSessionTitle(host);
91
+
92
+ expect(sentRenames(proc)).toHaveLength(1);
93
+ });
94
+
95
+ test('skips when the session changed under the read', async () => {
96
+ // `get_state` answered for a different conversation (a reset or switch
97
+ // between the trigger and the read) — titling this session would name the
98
+ // wrong one.
99
+ const { host, proc } = makeHost({ sessionId: 'someone-else' });
100
+ await triggerAutoSessionTitle(host);
101
+
102
+ expect(sentRenames(proc)).toHaveLength(0);
103
+ });
104
+
105
+ test('does not stack a second request while one is in flight', async () => {
106
+ const { host, proc } = makeHost({ inFlight: true });
107
+ await triggerAutoSessionTitle(host);
108
+
109
+ expect(sentRenames(proc)).toHaveLength(0);
110
+ expect(proc.commands).toHaveLength(0);
111
+ });
112
+ });
113
+
114
+ describe('auto-title output window', () => {
115
+ test('claims exactly one frame per request', () => {
116
+ const { host } = makeHost({});
117
+ markTitleRequestSent(host);
118
+
119
+ expect(consumeAutoTitleOutput(host)).toBe(true);
120
+ // omp emits a single command_output per /rename; a second frame inside the
121
+ // window belongs to something the operator typed and must reach the UI.
122
+ expect(consumeAutoTitleOutput(host)).toBe(false);
123
+ });
124
+
125
+ test('claims nothing when no request is outstanding', () => {
126
+ const { host } = makeHost({});
127
+ expect(consumeAutoTitleOutput(host)).toBe(false);
128
+ });
129
+
130
+ test('expires so a stale window cannot swallow an operator command', () => {
131
+ const { host } = makeHost({});
132
+ markTitleRequestSent(host);
133
+ host.autoTitleWindowUntil = Date.now() - 1;
134
+
135
+ expect(consumeAutoTitleOutput(host)).toBe(false);
136
+ });
137
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Auto session titles for OMPChamber.
8
+ *
9
+ * Chamber spawns omp with `--mode rpc-ui`, and that mode force-sets
10
+ * `PI_NO_TITLE=1` (omp's main.ts). The flag gates only the two AUTOMATIC title
11
+ * paths — `AgentSession.maybeStartTitleGeneration` (first user message) and
12
+ * `#scheduleReplanTitleRefresh` (todo replan) — so a chamber session is never
13
+ * named on its own and the sidebar keeps the `New Session - <timestamp>`
14
+ * placeholder for its whole life.
15
+ *
16
+ * The generate path itself is NOT gated: `/rename` with no args reaches
17
+ * `session.generateTitle()` through `executeAcpBuiltinSlashCommand`, which
18
+ * rpc-mode dispatches for a `prompt` frame before any turn starts. Verified
19
+ * against omp 18.2.10 — the ack returns `{agentInvoked:false}` immediately, the
20
+ * generation lands ~4s later as `session_info_update` + `command_output`, and
21
+ * no entry is written to the transcript.
22
+ *
23
+ * WHY THE NAME MUST BE EMPTY FIRST: `/rename` persists through
24
+ * `setSessionName(title, "user")`. omp's own guard only refuses an `"auto"`
25
+ * write over a `"user"` title, so a `/rename` fired against an already-named
26
+ * session would overwrite the operator's own name. The unnamed check below is
27
+ * therefore the safety property, not a nicety — it is re-read from `get_state`
28
+ * on every attempt rather than cached, so a rename made in another tab is
29
+ * respected.
30
+ *
31
+ * Firing after every settled run (not just the first) mirrors omp: a session
32
+ * whose opening message was a bare greeting stays unnamed, and the next real
33
+ * message titles it. The low-signal filter on omp's side returns no title for
34
+ * such input, so the retry costs one tiny-model call and nothing else.
35
+ */
36
+
37
+ import { readSettingsJson } from '@/server/lib/db/settings-store';
38
+ import { getDb } from '@/server/db.server';
39
+ import { GET_STATE_TIMEOUT_MS, PROMPT_ACK_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
40
+
41
+ /** Chamber settings blob key; mirrors `streamTransport` and friends. */
42
+ const CHAMBER_SETTINGS_KEY = 'omp_chamber_settings';
43
+
44
+ /** Auto-titling is opt-in per chamber install. Default ON: an unnamed session
45
+ * renders as `New Session - <timestamp>` forever, which is the visible defect,
46
+ * and the cost is one tiny-model call per session. */
47
+ const AUTO_SESSION_TITLE_DEFAULT = true;
48
+
49
+ /** omp's builtin that generates a title from the conversation. Sent with no
50
+ * args so omp derives it through its own `generateRenameTitle`. */
51
+ const GENERATE_TITLE_COMMAND = '/rename';
52
+
53
+ /**
54
+ * How long the chamber's own `/rename` may keep emitting its diagnostic frames.
55
+ *
56
+ * The command answers on the prompt path with an immediate ack and then streams
57
+ * `session_info_update` + `command_output` once the tiny model returns
58
+ * (measured ~4s). Both carry NO correlation id, so the only way to attribute
59
+ * them to our background request is this window. It is deliberately generous —
60
+ * a slow provider must not leak the message — because the cost of over-holding
61
+ * it is only that a slash command typed in the same few seconds loses its
62
+ * output row.
63
+ */
64
+ const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
65
+
66
+ /** Wrapper state this module reads and writes. The in-flight flag and the
67
+ * output window are owned by the caller because they are per-session runtime
68
+ * state, not policy. */
69
+ export interface AutoTitleHost {
70
+ sessionId: string;
71
+ autoTitleInFlight: boolean;
72
+ /** Epoch ms until which frames belong to our own background rename; 0 when
73
+ * no request is outstanding. */
74
+ autoTitleWindowUntil: number;
75
+ proc: {
76
+ sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Claim the frames our own `/rename` is about to emit, so the wrapper can keep
82
+ * its diagnostics out of the timeline. Called once the request is accepted.
83
+ */
84
+ export function markTitleRequestSent(host: AutoTitleHost): void {
85
+ host.autoTitleWindowUntil = Date.now() + AUTO_TITLE_OUTPUT_WINDOW_MS;
86
+ }
87
+
88
+ /**
89
+ * True when this frame is the tail of our own background rename, consuming the
90
+ * window. One frame per request: omp emits exactly one `command_output` for it,
91
+ * on both the success and the "could not generate" path.
92
+ */
93
+ export function consumeAutoTitleOutput(host: AutoTitleHost): boolean {
94
+ if (host.autoTitleWindowUntil === 0 || Date.now() > host.autoTitleWindowUntil) return false;
95
+ host.autoTitleWindowUntil = 0;
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Read the `autoSessionTitle` flag from the chamber settings blob. Absent or
101
+ * malformed → the default. Never throws: a settings read must not be able to
102
+ * break a turn's settle path.
103
+ */
104
+ async function isAutoTitleEnabled(): Promise<boolean> {
105
+ try {
106
+ const db = await getDb();
107
+ const blob = await readSettingsJson<Record<string, unknown> | null>(db, CHAMBER_SETTINGS_KEY, null);
108
+ const value = blob?.autoSessionTitle;
109
+ return typeof value === 'boolean' ? value : AUTO_SESSION_TITLE_DEFAULT;
110
+ } catch {
111
+ return AUTO_SESSION_TITLE_DEFAULT;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Generate and persist a session title when the session has none yet.
117
+ *
118
+ * Called once per settled run. Every bail-out is deliberate: a named session
119
+ * must never be renamed by this path, and a generation already in flight must
120
+ * not be duplicated. Failures are swallowed — an auto-title is a convenience,
121
+ * and the next turn retries.
122
+ */
123
+ export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void> {
124
+ if (host.autoTitleInFlight || !host.sessionId) return;
125
+ if (!(await isAutoTitleEnabled())) return;
126
+
127
+ host.autoTitleInFlight = true;
128
+ try {
129
+ // Re-read the name from omp rather than trusting a cached value: a rename
130
+ // made in another tab (or by omp's own TUI on the same session file) would
131
+ // otherwise be overwritten by the `"user"` write `/rename` performs.
132
+ const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
133
+ if (state.sessionName?.trim()) return;
134
+ // The session changed under us (a reset, or a `switch_session`): the name
135
+ // we just read belongs to a different conversation.
136
+ if (state.sessionId !== host.sessionId) return;
137
+
138
+ // Direct `proc` write, NOT the chamber's prompt dispatch: this is
139
+ // background work, so it must not mark the session streaming or claim the
140
+ // sidebar's run row. omp's ack is immediate (`agentInvoked:false`); the
141
+ // generation itself streams back later as ordinary frames.
142
+ await host.proc.sendCommand({ type: 'prompt', message: GENERATE_TITLE_COMMAND }, PROMPT_ACK_TIMEOUT_MS);
143
+ // Accepted — the diagnostics omp is about to emit belong to this request,
144
+ // not to anything the operator typed.
145
+ markTitleRequestSent(host);
146
+ } catch {
147
+ // Provider error, timeout, or a child that went away — leave the session
148
+ // unnamed; the next settled run tries again.
149
+ } finally {
150
+ host.autoTitleInFlight = false;
151
+ }
152
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Blob-reference resolution for persisted session images.
8
+ *
9
+ * omp externalizes a large attached image out of the session JSONL: the entry
10
+ * keeps a content-addressed ref (`blob:sha256:<hash>`) and the bytes live in the
11
+ * blob store. Nothing else resolves them, so a reloaded session used to render
12
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
13
+ * string, which the browser cannot decode.
14
+ *
15
+ * The ref suffix feeds a `path.join` against the blob directory, so the
16
+ * canonical-hash guard is the security boundary and is tested as such.
17
+ *
18
+ * The agent dir is read per call (not at import time), so pointing
19
+ * `PI_CODING_AGENT_DIR` at a temp directory is enough to redirect the store.
20
+ */
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
23
+ import * as fsp from 'node:fs/promises';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { getBlobStoreDir, isBlobRef, readBlob, readBlobRef } from '@/server/lib/omp/session/blobs.server';
27
+ import { extractUserImageAttachments, isBlobImageRef } from '@/shared/lib/omp/session/parse-message-blocks';
28
+
29
+ /** A minimal but structurally valid PNG, so the assertion is about real bytes. */
30
+ const PNG_BYTES = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
31
+ const HASH = 'a'.repeat(64);
32
+
33
+ let agentDir = '';
34
+
35
+ beforeAll(async () => {
36
+ agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'omp-blob-test-'));
37
+ process.env.PI_CODING_AGENT_DIR = agentDir;
38
+ await fsp.mkdir(getBlobStoreDir(), { recursive: true });
39
+ await fsp.writeFile(path.join(getBlobStoreDir(), HASH), PNG_BYTES);
40
+ });
41
+
42
+ afterAll(async () => {
43
+ delete process.env.PI_CODING_AGENT_DIR;
44
+ await fsp.rm(agentDir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('blob store reads', () => {
48
+ test('reads the bytes a hash points at', async () => {
49
+ expect(await readBlob(HASH)).toEqual(PNG_BYTES);
50
+ });
51
+
52
+ test('reads through a blob reference', async () => {
53
+ expect(await readBlobRef(`blob:sha256:${HASH}`)).toEqual(PNG_BYTES);
54
+ });
55
+
56
+ test('a missing blob yields null instead of throwing', async () => {
57
+ expect(await readBlob('b'.repeat(64))).toBeNull();
58
+ });
59
+
60
+ test('a non-reference string yields null', async () => {
61
+ expect(await readBlobRef('iVBORw0KGgo=')).toBeNull();
62
+ });
63
+
64
+ // The hash is interpolated into a path, so anything but a canonical digest
65
+ // must be refused before the filesystem is touched.
66
+ test('rejects refs that would escape the blob directory', async () => {
67
+ for (const bad of ['../../../../etc/passwd', '..%2f..%2fetc%2fpasswd', `${'a'.repeat(63)}/`, 'A'.repeat(64), 'abc', '']) {
68
+ expect(await readBlob(bad)).toBeNull();
69
+ expect(await readBlobRef(`blob:sha256:${bad}`)).toBeNull();
70
+ }
71
+ });
72
+
73
+ test('recognises the reference prefix', () => {
74
+ expect(isBlobRef(`blob:sha256:${HASH}`)).toBe(true);
75
+ expect(isBlobRef('data:image/png;base64,AAAA')).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('image block extraction', () => {
80
+ test('an externalized block carries the ref instead of a fake data URL', () => {
81
+ const ref = `blob:sha256:${HASH}`;
82
+ const [attachment] = extractUserImageAttachments([{ type: 'image', data: ref, mimeType: 'image/png' }]);
83
+ expect(attachment.blobRef).toBe(ref);
84
+ // The bug: `data:image/png;base64,blob:sha256:…` is not decodable.
85
+ expect(attachment.preview).toBe('');
86
+ expect(attachment.preview).not.toContain('base64,blob:');
87
+ expect(attachment.type).toBe('image/png');
88
+ });
89
+
90
+ test('an inlined block still becomes a data URL', () => {
91
+ const [attachment] = extractUserImageAttachments([{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]);
92
+ expect(attachment.preview).toBe('data:image/png;base64,iVBORw0KGgo=');
93
+ expect(attachment.blobRef).toBeUndefined();
94
+ });
95
+
96
+ test('distinguishes the two payload shapes', () => {
97
+ expect(isBlobImageRef(`blob:sha256:${HASH}`)).toBe(true);
98
+ expect(isBlobImageRef('iVBORw0KGgo=')).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Blob reference resolution for persisted session images.
8
+ *
9
+ * omp keeps an image's bytes out of the session JSONL once they are large: the
10
+ * entry stores a content-addressed reference (`blob:sha256:<hash>`) and the raw
11
+ * bytes live in the blob store (`$OMP_AGENT_DIR/blobs/<hash>`, an XDG-aware
12
+ * path — see `getBlobStoreDir`). Verified against omp 18.2.10:
13
+ * `pi-coding-agent/src/session/blob-store.ts` is the producer, and only omp's
14
+ * own loader (`session-loader.ts:resolveBlobRefs`) resolves the refs back on
15
+ * read.
16
+ *
17
+ * Nothing in the chamber did that, so a reloaded session rendered
18
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
19
+ * string, which no browser can decode. The timeline showed a broken image and
20
+ * opening it dumped the ref as text.
21
+ *
22
+ * Resolution is confined to the blob store: the suffix must be a canonical
23
+ * 64-char lowercase hex digest, so a crafted ref cannot escape the directory
24
+ * (the same guard omp applies in `parseBlobRef`).
25
+ */
26
+
27
+ import * as fsp from 'node:fs/promises';
28
+ import { existsSync } from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { getAgentDir } from '@/server/lib/omp/core/paths';
31
+
32
+ const BLOB_PREFIX = 'blob:sha256:';
33
+
34
+ /** Canonical blob hash shape; anything else is rejected before touching the fs. */
35
+ const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
36
+
37
+ /** True when `value` is a content-addressed blob reference. */
38
+ export function isBlobRef(value: string): boolean {
39
+ return value.startsWith(BLOB_PREFIX);
40
+ }
41
+
42
+ /**
43
+ * The blob store directory. Mirrors omp's `getBlobsDir()`: `<agentDir>/blobs`,
44
+ * relocated to `$XDG_DATA_HOME/omp/blobs` when omp is running the XDG layout
45
+ * (which drops the `agent/` prefix). The existing-directory probe decides which
46
+ * layout is live, because omp's own switch is opt-in and file-based.
47
+ */
48
+ export function getBlobStoreDir(): string {
49
+ const xdgDataHome = Bun.env.XDG_DATA_HOME;
50
+ if (xdgDataHome) {
51
+ const xdgPath = path.join(xdgDataHome, 'omp', 'blobs');
52
+ if (existsSync(xdgPath)) return xdgPath;
53
+ }
54
+ return path.join(getAgentDir(), 'blobs');
55
+ }
56
+
57
+ /**
58
+ * Read a blob's raw bytes, or null when the ref is malformed or the blob is
59
+ * gone (a pruned store, a session copied between machines). A missing blob is
60
+ * not an error the caller can act on — the caller falls back to showing the
61
+ * attachment without a preview.
62
+ */
63
+ export async function readBlob(hash: string): Promise<Buffer | null> {
64
+ if (!BLOB_HASH_RE.test(hash)) return null;
65
+ try {
66
+ return await fsp.readFile(path.join(getBlobStoreDir(), hash));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** Read the bytes behind a `blob:sha256:<hash>` reference, or null. */
73
+ export async function readBlobRef(ref: string): Promise<Buffer | null> {
74
+ if (!isBlobRef(ref)) return null;
75
+ return readBlob(ref.slice(BLOB_PREFIX.length));
76
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Bounded readers for one oh-my-pi session file: a fixed-size head window and
8
+ * a tail window that dates the session's real last activity. Split out of
9
+ * ./files.ts so the scanner there stays under the file-size ceiling; both
10
+ * windows exist so a session file is never loaded whole into memory.
11
+ */
12
+
13
+ import { extractStringProperty } from '@/shared/lib/omp/session/jsonl';
14
+ import { isRecord } from '@/shared/lib/util/guards';
15
+
16
+ export const SESSION_LIST_PREFIX_BYTES = 4096;
17
+
18
+ /**
19
+ * Tail window for the last-activity timestamp. The distance from EOF to the
20
+ * newest timestamped line measured at most ~4 KiB across every session on this
21
+ * machine, so 16 KiB carries 4x headroom over the largest final entry seen.
22
+ */
23
+ const SESSION_TAIL_BYTES = 16 * 1024;
24
+
25
+ /**
26
+ * Ceiling for the escalating tail read. A session can legitimately end with an
27
+ * entry larger than the first window — a tool result carrying a big file read
28
+ * or build log — and that entry is usually the newest timestamped line, so
29
+ * falling back to mtime there would reintroduce the bug this exists to fix.
30
+ * The window quadruples up to this ceiling, which is one read per step and
31
+ * only for a session whose final entry is genuinely that large.
32
+ */
33
+ const SESSION_TAIL_MAX_BYTES = 1024 * 1024;
34
+
35
+ /** Head window of a session file plus the stat it was read with. */
36
+ export interface SessionFilePrefix {
37
+ text: string;
38
+ size: number;
39
+ mtime: Date;
40
+ }
41
+
42
+ export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
43
+ const file = Bun.file(filePath);
44
+ const stat = await file.stat();
45
+ const prefixLength = Math.min(prefixBytes, stat.size);
46
+ const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
47
+ return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
48
+ }
49
+
50
+ /**
51
+ * omp lifecycle markers: entries the agent writes about the SESSION PROCESS,
52
+ * not about the conversation. `session_exit` is emitted every time an omp
53
+ * child is disposed — an idle timeout, a chamber restart, a shutdown — and a
54
+ * session accumulates one per dispose (the file that prompted this rule holds
55
+ * three). Treating the newest one as last activity floats a session that has
56
+ * been quiet for days to the top of `LATEST_SESSION`: measured across a real
57
+ * agent dir, 255 of 319 sessions were ranked by a marker like that, the worst
58
+ * by ~14 days.
59
+ *
60
+ * Only markers KNOWN to be lifecycle are excluded. `tool_execution_start` is
61
+ * the commonest custom entry in the same files (16k occurrences) and IS
62
+ * activity — a run that ends mid-tool-call is still the newest one — so an
63
+ * unrecognised customType is counted rather than ignored.
64
+ */
65
+ const LIFECYCLE_CUSTOM_TYPES: Record<string, true> = { session_exit: true };
66
+
67
+ /** A parsed entry timestamp, flagged when the entry is process bookkeeping. */
68
+ interface EntryStamp {
69
+ at: Date;
70
+ lifecycle: boolean;
71
+ }
72
+
73
+ /** Timestamp + lifecycle flag from one JSONL line, or undefined when the line
74
+ * carries no usable timestamp. */
75
+ function entryStampOfLine(line: string): EntryStamp | undefined {
76
+ let type: unknown;
77
+ let customType: unknown;
78
+ let value: unknown;
79
+ let parsedOk = false;
80
+ try {
81
+ const parsed: unknown = JSON.parse(line);
82
+ if (isRecord(parsed)) {
83
+ parsedOk = true;
84
+ type = parsed.type;
85
+ customType = parsed.customType;
86
+ value = parsed.timestamp;
87
+ }
88
+ } catch {
89
+ // Torn or window-truncated line — fall back to the raw-text scan.
90
+ }
91
+ if (!parsedOk) {
92
+ value = extractStringProperty(line, 'timestamp');
93
+ type = extractStringProperty(line, 'type');
94
+ customType = extractStringProperty(line, 'customType');
95
+ }
96
+ let at: Date | undefined;
97
+ if (typeof value === 'number' && Number.isFinite(value)) at = new Date(value);
98
+ else if (typeof value === 'string') {
99
+ const parsed = new Date(value);
100
+ if (!Number.isNaN(parsed.getTime())) at = parsed;
101
+ }
102
+ if (!at) return undefined;
103
+ const lifecycle = type === 'custom'
104
+ && typeof customType === 'string'
105
+ && LIFECYCLE_CUSTOM_TYPES[customType] === true;
106
+ return { at, lifecycle };
107
+ }
108
+
109
+ /**
110
+ * Timestamp of the newest entry in a session file, read from a bounded tail
111
+ * window — the session's real last activity.
112
+ *
113
+ * File mtime is NOT that value. A title-slot rename rewrites the first 256
114
+ * bytes in place and bumps mtime without a turn having happened; a full-file
115
+ * title rewrite on a slot-less legacy file does the same; and a session
116
+ * restored or copied from a backup carries an mtime unrelated to its content.
117
+ * Every one of those moved the sidebar's `LATEST_SESSION` order. The last
118
+ * entry's own `timestamp` is what "latest session" means, and it survives all
119
+ * three.
120
+ *
121
+ * Process bookkeeping does not count either — see LIFECYCLE_CUSTOM_TYPES.
122
+ *
123
+ * `prefix` is the already-read 4 KiB head; a file that fits inside it needs no
124
+ * second read — a third of the sessions on a real machine are that small.
125
+ *
126
+ * The window starts at SESSION_TAIL_BYTES and widens toward
127
+ * SESSION_TAIL_MAX_BYTES while it holds no usable entry, so a session ending
128
+ * on one oversized entry is still dated correctly. Only when even the ceiling
129
+ * holds nothing but lifecycle markers — an empty file, or a single entry
130
+ * larger than a megabyte — does this return undefined and let the caller fall
131
+ * back to mtime.
132
+ */
133
+ export async function readLastEntryTimestamp(
134
+ filePath: string,
135
+ size: number,
136
+ prefix: string,
137
+ ): Promise<Date | undefined> {
138
+ if (size <= 0) return undefined;
139
+ let windowBytes = SESSION_TAIL_BYTES;
140
+ for (;;) {
141
+ const start = Math.max(0, size - windowBytes);
142
+ let text: string;
143
+ if (size <= SESSION_LIST_PREFIX_BYTES) {
144
+ // The whole file is inside the prefix window already.
145
+ text = prefix;
146
+ } else if (start === 0) {
147
+ try {
148
+ text = await Bun.file(filePath).text();
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ } else {
153
+ try {
154
+ // Read one byte before the window: if it is a newline the window opens
155
+ // on a line boundary, otherwise the first line is a fragment. Either
156
+ // way the leading split element is not a complete entry.
157
+ text = await Bun.file(filePath).slice(start - 1).text();
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ const lines = text.split('\n');
163
+ if (start > 0) lines.shift();
164
+ for (let i = lines.length - 1; i >= 0; i--) {
165
+ const line = lines[i].trim();
166
+ if (!line.includes('"timestamp"')) continue;
167
+ const stamp = entryStampOfLine(line);
168
+ if (stamp && !stamp.lifecycle) return stamp.at;
169
+ }
170
+ // The window is already the whole file, or it has reached its ceiling.
171
+ if (start === 0 || windowBytes >= SESSION_TAIL_MAX_BYTES) return undefined;
172
+ windowBytes = Math.min(windowBytes * 4, SESSION_TAIL_MAX_BYTES);
173
+ }
174
+ }