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,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
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import fs from 'fs';
8
+ import { join } from 'path';
9
+
10
+ import { scanSessionInfo } from '@/server/lib/omp/session/files';
11
+
12
+ /**
13
+ * `modified` is the session's last real activity, and two signals that look
14
+ * like it are not:
15
+ *
16
+ * - the file mtime, which a title-slot rename bumps without a turn having
17
+ * happened (measured: 5 days of skew on a real session);
18
+ * - a `session_exit` lifecycle marker, written per process dispose, which
19
+ * floats a session quiet for days to the top of the sidebar (measured: 255
20
+ * of 319 sessions ranked by one).
21
+ *
22
+ * The cost of regressing either is a silently wrong sidebar order, which no
23
+ * other test observes.
24
+ */
25
+
26
+ const tempDirs: string[] = [];
27
+
28
+ /** Write a session file whose lines are exactly the given records, in order. */
29
+ async function writeSession(lines: unknown[]): Promise<string> {
30
+ const dir = await fs.promises.mkdtemp('omp-session-files-');
31
+ tempDirs.push(dir);
32
+ const file = join(dir, 'session.jsonl');
33
+ await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
34
+ return file;
35
+ }
36
+
37
+ afterAll(() => {
38
+ for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ const at = (day: number, second = 0) =>
42
+ `2026-09-${String(day).padStart(2, '0')}T03:00:${String(second).padStart(2, '0')}.000Z`;
43
+
44
+ const slot = (title: string) => ({ type: 'title', v: 1, title, updatedAt: at(1), pad: '' });
45
+ const header = { type: 'session', version: 3, id: 'session-id', cwd: '/tmp/proj', timestamp: at(1) };
46
+ const message = (day: number, text = 'hello') => ({
47
+ type: 'message',
48
+ id: `m-${day}`,
49
+ parentId: null,
50
+ timestamp: at(day),
51
+ message: { role: 'user', content: [{ type: 'text', text }] },
52
+ });
53
+ /** The marker omp appends every time it disposes the session process. */
54
+ const sessionExit = (day: number) => ({
55
+ type: 'custom',
56
+ customType: 'session_exit',
57
+ data: { reason: 'dispose', kind: 'normal', recordedAt: at(day) },
58
+ id: `x-${day}`,
59
+ parentId: null,
60
+ timestamp: at(day),
61
+ });
62
+ const toolStart = (day: number) => ({
63
+ type: 'custom',
64
+ customType: 'tool_execution_start',
65
+ data: { toolCallId: 'call', toolName: 'bash' },
66
+ id: `t-${day}`,
67
+ parentId: null,
68
+ timestamp: at(day),
69
+ });
70
+
71
+ describe('scanSessionInfo last activity', () => {
72
+ test('dates the session by its newest entry, not the file mtime', async () => {
73
+ // The mtime is "now" (just written); only the entry timestamp is the answer.
74
+ const file = await writeSession([slot('t'), header, message(10)]);
75
+ const info = await scanSessionInfo(file);
76
+ expect(info?.modified.toISOString()).toBe(at(10));
77
+ expect(info?.modified.getTime()).not.toBe(info?.fileMtime.getTime());
78
+ });
79
+
80
+ test('ignores a trailing session_exit marker', async () => {
81
+ const file = await writeSession([slot('t'), header, message(10), sessionExit(20)]);
82
+ const info = await scanSessionInfo(file);
83
+ expect(info?.modified.toISOString()).toBe(at(10));
84
+ });
85
+
86
+ test('ignores a stack of session_exit markers, however many accumulated', async () => {
87
+ const file = await writeSession([
88
+ slot('t'),
89
+ header,
90
+ message(11),
91
+ sessionExit(20),
92
+ sessionExit(21),
93
+ sessionExit(22),
94
+ ]);
95
+ const info = await scanSessionInfo(file);
96
+ expect(info?.modified.toISOString()).toBe(at(11));
97
+ });
98
+
99
+ test('still counts an in-flight tool call as activity', async () => {
100
+ // A run killed mid-tool-call has no later message, but it IS the newest
101
+ // session. Excluding every custom entry would misrank it.
102
+ const file = await writeSession([slot('t'), header, message(12), toolStart(13)]);
103
+ const info = await scanSessionInfo(file);
104
+ expect(info?.modified.toISOString()).toBe(at(13));
105
+ });
106
+
107
+ test('counts an unrecognised custom entry rather than assuming it is bookkeeping', async () => {
108
+ const file = await writeSession([
109
+ slot('t'),
110
+ header,
111
+ message(12),
112
+ { type: 'custom', customType: 'brand_new_marker', id: 'z', timestamp: at(19) },
113
+ ]);
114
+ const info = await scanSessionInfo(file);
115
+ expect(info?.modified.toISOString()).toBe(at(19));
116
+ });
117
+
118
+ test('uses a later message when the exit marker came first', async () => {
119
+ const file = await writeSession([slot('t'), header, sessionExit(5), message(6)]);
120
+ const info = await scanSessionInfo(file);
121
+ expect(info?.modified.toISOString()).toBe(at(6));
122
+ });
123
+
124
+ test('falls back to the creation time for a session with no activity at all', async () => {
125
+ const file = await writeSession([slot('t'), header, sessionExit(30)]);
126
+ const info = await scanSessionInfo(file);
127
+ expect(info?.modified.toISOString()).toBe(at(1));
128
+ });
129
+
130
+ test('reads the timestamp past an entry larger than the tail window', async () => {
131
+ // A tool result carrying a big file read is the newest entry and is
132
+ // usually the largest one; the window must widen to reach its line.
133
+ const file = await writeSession([
134
+ slot('t'),
135
+ header,
136
+ message(2, 'head'),
137
+ message(4, 'x'.repeat(200_000)),
138
+ ]);
139
+ const info = await scanSessionInfo(file);
140
+ expect(info?.modified.toISOString()).toBe(at(4));
141
+ });
142
+
143
+ test('returns undefined for a file with no session header', async () => {
144
+ const file = await writeSession([slot('t'), message(3)]);
145
+ expect(await scanSessionInfo(file)).toBeUndefined();
146
+ });
147
+ });
@@ -15,12 +15,15 @@
15
15
  * File layout: optional fixed-width 256-byte title-slot line, then the
16
16
  * {"type":"session"} header line, then message entries. Legacy pi v1/v2
17
17
  * shapes parse leniently (raw-text scans); nothing here mutates on-disk data.
18
- * The lenient JSONL + header parsing lives in ./session-jsonl.ts.
18
+ * The lenient JSONL + header parsing lives in
19
+ * `@/shared/lib/omp/session/jsonl`; the bounded window readers (head prefix
20
+ * plus escalating tail) live in ./file-scan.ts.
19
21
  */
20
22
 
21
23
  import fs from 'fs';
22
24
  import * as path from 'path';
23
25
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
26
+ import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
24
27
  import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
25
28
  import { isRecord } from '@/shared/lib/util/guards';
26
29
 
@@ -37,37 +40,30 @@ export interface OmpSessionInfo {
37
40
  title?: string;
38
41
  parentSessionPath?: string;
39
42
  created: Date;
43
+ /** Last activity: the newest JSONL entry's own `timestamp`, falling back to
44
+ * the file mtime for a file the tail window cannot date. */
40
45
  modified: Date;
46
+ /** Filesystem mtime. Scan-cache version only — never user-visible, and never
47
+ * an ordering key (see readLastEntryTimestamp for why). */
48
+ fileMtime: Date;
41
49
  messageCount: number;
42
50
  size: number;
43
51
  firstMessage: string;
44
52
  }
45
53
 
46
- // ============================================================================
47
- // Per-file scan (4 KiB prefix window)
48
- // ============================================================================
49
-
50
- const SESSION_LIST_PREFIX_BYTES = 4096;
51
-
52
- async function readTextPrefix(filePath: string, prefixBytes: number): Promise<[string, number, Date]> {
53
- const file = Bun.file(filePath);
54
- const stat = await file.stat();
55
- const prefixLength = Math.min(prefixBytes, stat.size);
56
- const prefix = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
57
- return [prefix, stat.size, new Date(stat.mtimeMs)];
58
- }
59
-
60
54
  /**
61
- * Scan a single session file into an OmpSessionInfo using only a 4 KiB prefix
62
- * window. Faithful port of omp's scanSessionFile — messageCount is a
63
- * prefix-derived lower bound. Missing/unreadable/header-less files yield
64
- * undefined instead of throwing.
55
+ * Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
56
+ * (header, title, first message) plus a bounded tail window (last activity).
57
+ * Faithful port of omp's scanSessionFile except for `modified`, which is the
58
+ * last entry's timestamp rather than the file mtime. Missing/unreadable/
59
+ * header-less files yield undefined instead of throwing.
65
60
  */
66
61
  export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
67
62
  try {
68
- const [content, size, mtime] = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
69
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
70
- const header = parseSessionListHeader(content, entries);
63
+ const { text: prefix, size, mtime } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
64
+ const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
65
+ const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
66
+ const header = parseSessionListHeader(prefix, entries);
71
67
  if (!header) return undefined;
72
68
 
73
69
  let parsedMessageCount = 0;
@@ -90,7 +86,7 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
90
86
  }
91
87
  }
92
88
 
93
- firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? '';
89
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
94
90
  return {
95
91
  path: filePath,
96
92
  id: header.id,
@@ -98,8 +94,9 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
98
94
  title: header.title ?? shortSummary,
99
95
  parentSessionPath: header.parentSession,
100
96
  created: new Date(header.timestamp ?? ''),
101
- modified: mtime,
102
- messageCount: Math.max(parsedMessageCount, countMessageMarkers(content)),
97
+ modified: lastEntryAt ?? mtime,
98
+ fileMtime: mtime,
99
+ messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
103
100
  size,
104
101
  firstMessage: firstMessage || '(no messages)',
105
102
  };
@@ -120,9 +117,24 @@ interface SessionFileListCacheEntry {
120
117
  files: string[];
121
118
  }
122
119
 
120
+ /**
121
+ * Shape version of a cached OmpSessionInfo. The caches hang off `globalThis`
122
+ * so they survive a module reload, which means an entry written by an older
123
+ * build of this file is still readable after an edit — and a field added or
124
+ * renamed since then reads as undefined at the point of use rather than
125
+ * missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
126
+ * discarded instead of misread.
127
+ */
128
+ const SESSION_SCAN_CACHE_VERSION = 2;
129
+
130
+ interface SessionScanCache {
131
+ version: number;
132
+ entries: Map<string, OmpSessionInfo>;
133
+ }
134
+
123
135
  declare global {
124
136
  var __ompChamberSessionFileListCache: Map<string, SessionFileListCacheEntry> | undefined;
125
- var __ompChamberSessionScanCache: Map<string, OmpSessionInfo> | undefined;
137
+ var __ompChamberSessionScanCache: SessionScanCache | undefined;
126
138
  }
127
139
 
128
140
  const MAX_SESSION_SCAN_CACHE_ENTRIES = 2048;
@@ -197,16 +209,18 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
197
209
  }
198
210
 
199
211
  function getSessionScanCache(): Map<string, OmpSessionInfo> {
200
- if (!globalThis.__ompChamberSessionScanCache) {
201
- globalThis.__ompChamberSessionScanCache = new Map();
202
- }
203
- return globalThis.__ompChamberSessionScanCache;
212
+ const slot = globalThis.__ompChamberSessionScanCache;
213
+ if (slot && slot.version === SESSION_SCAN_CACHE_VERSION) return slot.entries;
214
+ const fresh: SessionScanCache = { version: SESSION_SCAN_CACHE_VERSION, entries: new Map() };
215
+ globalThis.__ompChamberSessionScanCache = fresh;
216
+ return fresh.entries;
204
217
  }
205
218
 
206
219
  /** scanSessionInfo memoized on (path, size, mtimeMs) — an unchanged file costs
207
220
  * a single stat. Cache hits share one object; callers must treat results as
208
- * immutable. */
209
- async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
221
+ * immutable. Exported so the id locator shares this cache with the sidebar
222
+ * list instead of re-reading every prefix and tail on each resolve. */
223
+ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
210
224
  let stat: { size: number; mtimeMs: number };
211
225
  try {
212
226
  stat = await Bun.file(filePath).stat();
@@ -217,8 +231,11 @@ async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo |
217
231
  const cached = cache.get(filePath);
218
232
  if (cached) {
219
233
  const info = cached;
220
- // Re-scan when size or mtime changed.
221
- if (info.size === stat.size && info.modified.getTime() === stat.mtimeMs) {
234
+ // Re-scan when size or mtime changed. The version is the FILE mtime, not
235
+ // `modified` — the latter is now an entry timestamp, and a title-slot
236
+ // rewrite that bumps the file without appending an entry must still be
237
+ // picked up (it can change the title the row displays).
238
+ if (info.size === stat.size && info.fileMtime.getTime() === stat.mtimeMs) {
222
239
  cache.delete(filePath);
223
240
  cache.set(filePath, info);
224
241
  return info;
@@ -259,7 +276,7 @@ export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir(
259
276
  * that mtime keys cannot detect). */
260
277
  export function clearSessionFileCaches(): void {
261
278
  globalThis.__ompChamberSessionFileListCache?.clear();
262
- globalThis.__ompChamberSessionScanCache?.clear();
279
+ globalThis.__ompChamberSessionScanCache?.entries.clear();
263
280
  }
264
281
 
265
282
  /** Exported for tests/spot-checks: read a raw JSONL session header directly. */
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { json } from '@/server/lib/remix-compat';
13
13
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
14
- import { listSessionFiles, readRawHeaderLine, scanSessionInfo } from '@/server/lib/omp/session/files';
14
+ import { listSessionFiles, readRawHeaderLine, scanSessionInfoCached } from '@/server/lib/omp/session/files';
15
15
 
16
16
  /** Find the absolute path of the .jsonl whose header id matches. */
17
17
  export async function findSessionFileById(
@@ -20,7 +20,7 @@ export async function findSessionFileById(
20
20
  ): Promise<string | undefined> {
21
21
  const files = await listSessionFiles(sessionsRoot);
22
22
  for (const file of files) {
23
- const info = await scanSessionInfo(file);
23
+ const info = await scanSessionInfoCached(file);
24
24
  if (info?.id === sessionId) return file;
25
25
  }
26
26
  return undefined;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Merging the chamber's stored turns onto JSONL-loaded ones.
8
+ *
9
+ * The two sources see different halves of an attachment: the JSONL recovers a
10
+ * text file's name from the delivered prompt but has no preview or content,
11
+ * while the chamber row has those display fields but nothing about what omp
12
+ * received. Taking one list over the other dropped whichever entries only
13
+ * existed on the losing side — a dropped `.md` disappeared from a reloaded
14
+ * session as soon as the same turn carried an image.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
19
+
20
+ function userTurn(overrides: Partial<StoredMessage> = {}): StoredMessage {
21
+ return { id: 'u1', role: 'user', content: 'hello', ...overrides };
22
+ }
23
+
24
+ function storedJson(messages: StoredMessage[]): string {
25
+ return JSON.stringify(messages);
26
+ }
27
+
28
+ describe('mergeOmpAttachments attachments', () => {
29
+ test('keeps both an image from the JSONL and a text file from the store', () => {
30
+ const jsonl = [userTurn({
31
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
32
+ })];
33
+ const stored = [userTurn({
34
+ attachments: [
35
+ { name: 'notes.md', type: 'text/plain', content: '# hi' },
36
+ { name: 'shot.png', type: 'image/png', preview: 'blob:http://x/1' },
37
+ ],
38
+ })];
39
+
40
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
41
+ expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'notes.md']);
42
+ });
43
+
44
+ test('a text file recovered from the JSONL keeps its name when the store has none', () => {
45
+ const jsonl = [userTurn({
46
+ attachments: [{ name: 'script.py', type: 'text/plain', content: 'x=1' }],
47
+ })];
48
+ const [merged] = mergeOmpAttachments(jsonl, storedJson([userTurn()]));
49
+ expect(merged.attachments).toEqual([{ name: 'script.py', type: 'text/plain', content: 'x=1' }]);
50
+ });
51
+
52
+ test('a matching name fills in the fields the JSONL could not carry', () => {
53
+ const jsonl = [userTurn({
54
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
55
+ })];
56
+ const stored = [userTurn({
57
+ attachments: [{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored' }],
58
+ })];
59
+
60
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
61
+ expect(merged.attachments).toEqual([
62
+ { name: 'shot.png', type: 'image/png', size: 42, content: 'stored', preview: 'data:image/png;base64,AAAA' },
63
+ ]);
64
+ });
65
+
66
+ test('an attachment only the store knows about is still shown', () => {
67
+ const jsonl = [userTurn({
68
+ attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
69
+ })];
70
+ const stored = [userTurn({
71
+ attachments: [
72
+ { name: 'shot.png', type: 'image/png' },
73
+ { name: 'dropped.txt', type: 'text/plain', content: 'kept' },
74
+ ],
75
+ })];
76
+
77
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
78
+ expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'dropped.txt']);
79
+ });
80
+
81
+ test('a turn with no attachments on either side stays attachment-free', () => {
82
+ const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn()]));
83
+ expect(merged.attachments).toBeUndefined();
84
+ });
85
+
86
+ test('a stored turn that never reached the JSONL is still merged in', () => {
87
+ const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn({
88
+ attachments: [{ name: 'notes.md', type: 'text/plain', content: '# hi' }],
89
+ })]));
90
+ expect(merged.attachments).toEqual([{ name: 'notes.md', type: 'text/plain', content: '# hi' }]);
91
+ });
92
+ });
93
+
94
+ describe('mergeOmpAttachments text restoration', () => {
95
+ test('a raw composer turn keeps the text the user typed', () => {
96
+ // The JSONL records only a synthesized `/skill:<name>`; the DB copy has the
97
+ // invocation with its arguments, which is what the user actually typed.
98
+ const jsonl = [userTurn({ content: '/skill:capacity' })];
99
+ const stored = [userTurn({ content: '/skill:capacity summarize this repo' })];
100
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
101
+ expect(merged.content).toBe('/skill:capacity summarize this repo');
102
+ });
103
+
104
+ test('an unrelated stored turn does not overwrite the JSONL text', () => {
105
+ // Turns that cannot be recognized as the same request are left alone; the
106
+ // JSONL keeps what the model was actually sent.
107
+ const jsonl = [userTurn({ content: 'rewritten prompt' })];
108
+ const stored = [userTurn({ content: 'a completely different request' })];
109
+ const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
110
+ expect(merged.content).toBe('rewritten prompt');
111
+ });
112
+
113
+ test('malformed stored JSON leaves the JSONL messages untouched', () => {
114
+ const jsonl = [userTurn()];
115
+ expect(mergeOmpAttachments(jsonl, 'not json')).toBe(jsonl);
116
+ expect(mergeOmpAttachments(jsonl, undefined)).toBe(jsonl);
117
+ });
118
+ });