ompchamber 3.1.1 → 3.3.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 (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -21,22 +21,12 @@
21
21
  * goes stale).
22
22
  */
23
23
 
24
- import { parseSubagentLifecycle, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
25
- import { isRecord } from '@/shared/lib/util/guards';
26
- import type { SubagentProgress } from '@/shared/types/omp/subagent';
24
+ import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
27
25
 
28
26
  /** Statuses that end a subagent. Mirrors omp's own registry, which deletes an
29
27
  * entry the moment a non-`started` lifecycle arrives. */
30
28
  const TERMINAL_STATUS: Record<string, true> = { completed: true, failed: true, aborted: true };
31
29
 
32
- /** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
33
- function readProgress(payload: unknown): SubagentProgress | undefined {
34
- const direct = parseSubagentProgress(payload);
35
- if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
36
- const nested = parseSubagentProgress(payload.progress);
37
- return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
38
- }
39
-
40
30
  export class SubagentLiveness {
41
31
  private readonly lastSeen = new Map<string, number>();
42
32
  /** index → key, so an id-less progress frame lands on the entry its lifecycle
@@ -54,7 +44,7 @@ export class SubagentLiveness {
54
44
  }
55
45
 
56
46
  if (frame.type === 'subagent_progress') {
57
- const progress = readProgress(frame.payload);
47
+ const progress = readSubagentProgressFrame(frame.payload);
58
48
  if (!progress) return;
59
49
  const index = progress.index !== undefined && progress.index >= 0 ? progress.index : undefined;
60
50
  const key = progress.id ?? (index !== undefined ? this.keyByIndex.get(index) ?? `idx:${index}` : undefined);
@@ -37,6 +37,12 @@ export interface SessionFilePrefix {
37
37
  text: string;
38
38
  size: number;
39
39
  mtime: Date;
40
+ /**
41
+ * The same mtime as a raw `stat.mtimeMs`, for callers that key a cache on it.
42
+ * `Date.getTime()` truncates the fractional millisecond APFS reports, so a
43
+ * Date comparison never matches a fresh stat.
44
+ */
45
+ mtimeMs: number;
40
46
  }
41
47
 
42
48
  export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
@@ -44,7 +50,7 @@ export async function readTextPrefix(filePath: string, prefixBytes: number): Pro
44
50
  const stat = await file.stat();
45
51
  const prefixLength = Math.min(prefixBytes, stat.size);
46
52
  const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
47
- return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
53
+ return { text, size: stat.size, mtime: new Date(stat.mtimeMs), mtimeMs: stat.mtimeMs };
48
54
  }
49
55
 
50
56
  /**
@@ -4,106 +4,32 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Read-only session-file scanner for oh-my-pi (format v3).
7
+ * The session-file CACHE layer: which files exist, what each one's summary is,
8
+ * and how to resolve an id to a path without re-reading the tree.
8
9
  *
9
- * Faithful, trimmed port of omp-web/lib/omp/session-files.ts (itself a port of
10
- * oh-my-pi packages/coding-agent/src/session/session-listing.ts): it lists the
11
- * per-project session directories under ~/.omp/agent/sessions and derives a
12
- * compact summary per session from a small 4 KiB prefix window of each file —
13
- * enough for a sidebar list without ever loading a full session into memory.
10
+ * Three caches, all keyed so they invalidate for free:
14
11
  *
15
- * File layout: optional fixed-width 256-byte title-slot line, then the
16
- * {"type":"session"} header line, then message entries. Legacy pi v1/v2
17
- * shapes parse leniently (raw-text scans); nothing here mutates on-disk data.
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.
12
+ * - the file list, on the sessions root's per-project directory mtimes (a new
13
+ * session bumps its project dir, so add/remove invalidates it);
14
+ * - the per-file summary, on that file's `(size, mtimeMs)`;
15
+ * - the `id → path` index, rebuilt from the file list.
16
+ *
17
+ * The mtime key is the RAW `stat.mtimeMs`, never a `Date`: `getTime()`
18
+ * truncates the fractional millisecond APFS reports, which made the summary
19
+ * cache miss on every call (measured: 451 of 452 files).
20
+ *
21
+ * The read itself lives in `./scan`; `clearSessionFileCaches()` is the one
22
+ * invalidation point every mutation path already calls.
21
23
  */
22
24
 
23
25
  import fs from 'fs';
24
26
  import * as path from 'path';
25
27
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
26
- import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
27
- import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
28
28
  import { isRecord } from '@/shared/lib/util/guards';
29
+ import { SESSION_TITLE_SLOT_BYTES, scanSessionInfo, type OmpSessionInfo } from '@/server/lib/omp/session/scan';
29
30
 
30
- export const SESSION_TITLE_SLOT_BYTES = 256;
31
-
32
- /** Compact summary of one session file — the sidebar list row. */
33
- export interface OmpSessionInfo {
34
- /** Absolute path of the .jsonl file. */
35
- path: string;
36
- id: string;
37
- /** Working directory the session ran in (from the header). */
38
- cwd: string;
39
- /** User-facing title (title slot wins; falls back to header title). */
40
- title?: string;
41
- parentSessionPath?: string;
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. */
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;
49
- messageCount: number;
50
- size: number;
51
- firstMessage: string;
52
- }
53
-
54
- /**
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.
60
- */
61
- export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
62
- try {
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);
67
- if (!header) return undefined;
68
-
69
- let parsedMessageCount = 0;
70
- let firstMessage = '';
71
- let shortSummary: string | undefined;
72
- for (let i = 1; i < entries.length; i++) {
73
- const entry = entries[i] as {
74
- type?: string;
75
- message?: { role?: string; content?: unknown };
76
- shortSummary?: string;
77
- };
78
- if (entry.type === 'compaction' && typeof entry.shortSummary === 'string') {
79
- shortSummary = entry.shortSummary;
80
- }
81
- if (entry.type === 'message' && entry.message) {
82
- parsedMessageCount++;
83
- if (entry.message.role === 'user' && !firstMessage) {
84
- firstMessage = extractTextFromContent(entry.message.content);
85
- }
86
- }
87
- }
88
-
89
- firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
90
- return {
91
- path: filePath,
92
- id: header.id,
93
- cwd: header.cwd ?? '',
94
- title: header.title ?? shortSummary,
95
- parentSessionPath: header.parentSession,
96
- created: new Date(header.timestamp ?? ''),
97
- modified: lastEntryAt ?? mtime,
98
- fileMtime: mtime,
99
- messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
100
- size,
101
- firstMessage: firstMessage || '(no messages)',
102
- };
103
- } catch {
104
- return undefined;
105
- }
106
- }
31
+ export { SESSION_TITLE_SLOT_BYTES, scanSessionInfo };
32
+ export type { OmpSessionInfo };
107
33
 
108
34
  // ============================================================================
109
35
  // File-list walk + per-file memo, both mtime-keyed
@@ -125,7 +51,7 @@ interface SessionFileListCacheEntry {
125
51
  * missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
126
52
  * discarded instead of misread.
127
53
  */
128
- const SESSION_SCAN_CACHE_VERSION = 2;
54
+ const SESSION_SCAN_CACHE_VERSION = 3;
129
55
 
130
56
  interface SessionScanCache {
131
57
  version: number;
@@ -205,6 +131,10 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
205
131
  }
206
132
 
207
133
  cache.set(sessionsRoot, { dirMtimes, files });
134
+ // A rebuilt list means the directory moved (a session was added or removed),
135
+ // which is exactly when the `id → path` index is stale — it is derived from
136
+ // this list, and its own mtime key cannot see a file that is not in it yet.
137
+ globalThis.__ompChamberSessionIdIndex?.delete(sessionsRoot);
208
138
  return files;
209
139
  }
210
140
 
@@ -235,7 +165,11 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
235
165
  // `modified` — the latter is now an entry timestamp, and a title-slot
236
166
  // rewrite that bumps the file without appending an entry must still be
237
167
  // picked up (it can change the title the row displays).
238
- if (info.size === stat.size && info.fileMtime.getTime() === stat.mtimeMs) {
168
+ //
169
+ // The comparison is against the RAW `stat.mtimeMs`: `Date.getTime()`
170
+ // truncates the fractional millisecond APFS reports, so a Date comparison
171
+ // never matched and this cache never hit (measured: 451 of 452 files).
172
+ if (info.size === stat.size && info.fileMtimeMs === stat.mtimeMs) {
239
173
  cache.delete(filePath);
240
174
  cache.set(filePath, info);
241
175
  return info;
@@ -256,6 +190,32 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
256
190
  return info;
257
191
  }
258
192
 
193
+ /**
194
+ * Map `items` through an async `worker`, at most `limit` in flight.
195
+ *
196
+ * The session scans are I/O-bound, so a sequential loop serializes round trips
197
+ * the filesystem answers in parallel (measured 36 ms sequential against 12 ms
198
+ * for the same warm work over 452 files). The bound is what keeps an unbounded
199
+ * `Promise.all` from opening one descriptor per session file on a host with a
200
+ * low soft limit.
201
+ */
202
+ async function mapBounded<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
203
+ const out = new Array<R>(items.length);
204
+ let next = 0;
205
+ const runners = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
206
+ for (;;) {
207
+ const index = next++;
208
+ if (index >= items.length) return;
209
+ out[index] = await worker(items[index]);
210
+ }
211
+ });
212
+ await Promise.all(runners);
213
+ return out;
214
+ }
215
+
216
+ /** Concurrent per-file scans; each holds one descriptor while it stats/reads. */
217
+ const SESSION_SCAN_CONCURRENCY = 32;
218
+
259
219
  /**
260
220
  * List every session across all project subdirectories of the sessions root,
261
221
  * newest-modified first. Invalidates are automatic via mtime keys; callers
@@ -263,26 +223,65 @@ export async function scanSessionInfoCached(filePath: string): Promise<OmpSessio
263
223
  */
264
224
  export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir()): Promise<OmpSessionInfo[]> {
265
225
  const files = await listSessionFiles(sessionsRoot);
226
+ const scanned = await mapBounded(files, SESSION_SCAN_CONCURRENCY, (file) => scanSessionInfoCached(file));
266
227
  const sessions: OmpSessionInfo[] = [];
267
- for (const file of files) {
268
- const info = await scanSessionInfoCached(file);
228
+ for (const info of scanned) {
269
229
  if (info) sessions.push(info);
270
230
  }
271
231
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
272
232
  return sessions;
273
233
  }
274
234
 
235
+ /**
236
+ * `id → path` for every session file under `sessionsRoot`, built from the same
237
+ * mtime-keyed scan cache the sidebar list fills.
238
+ *
239
+ * Resolving one id used to mean scanning every file until it matched —
240
+ * measured 34.8 ms for the 452 files on this machine, on the hot path of eight
241
+ * routes. The index is derived from `listAllSessionInfos`' own results, so it
242
+ * costs one map build and invalidates with the caches it reads.
243
+ *
244
+ * Cached per root on `globalThis` (a `bun --hot` reload keeps it) and dropped
245
+ * by `clearSessionFileCaches()`, which every mutation path already calls.
246
+ */
247
+ interface SessionIdIndex {
248
+ entries: Map<string, string>;
249
+ }
250
+
251
+ declare global {
252
+ var __ompChamberSessionIdIndex: Map<string, SessionIdIndex> | undefined;
253
+ }
254
+
255
+ /** The `id → path` index for `sessionsRoot`, building it on first use. */
256
+ export async function sessionIdIndex(sessionsRoot: string = getSessionsDir()): Promise<Map<string, string>> {
257
+ const host = (globalThis.__ompChamberSessionIdIndex ??= new Map());
258
+ const cached = host.get(sessionsRoot);
259
+ if (cached) return cached.entries;
260
+ const entries = new Map<string, string>();
261
+ for (const info of await listAllSessionInfos(sessionsRoot)) {
262
+ // A duplicate id cannot happen (omp ids are UUIDs), but first-wins keeps
263
+ // the answer stable if a copied session file ever appears.
264
+ if (!entries.has(info.id)) entries.set(info.id, info.path);
265
+ }
266
+ host.set(sessionsRoot, { entries });
267
+ return entries;
268
+ }
269
+
275
270
  /** Clear in-memory file-list and per-file scan caches (e.g. after a mutation
276
271
  * that mtime keys cannot detect). */
277
272
  export function clearSessionFileCaches(): void {
278
273
  globalThis.__ompChamberSessionFileListCache?.clear();
279
274
  globalThis.__ompChamberSessionScanCache?.entries.clear();
275
+ globalThis.__ompChamberSessionIdIndex?.clear();
280
276
  }
281
277
 
282
278
  /** Exported for tests/spot-checks: read a raw JSONL session header directly. */
283
279
  export async function readRawHeaderLine(filePath: string): Promise<Record<string, unknown> | undefined> {
284
280
  try {
285
- const head = (await Bun.file(filePath).text()).slice(0, SESSION_TITLE_SLOT_BYTES + 4096);
281
+ // A bounded Blob slice, not `.text().slice(0, n)`: the header lives in the
282
+ // first few hundred bytes, and decoding a whole session to look at them
283
+ // cost 4.9 ms on an 11 MB file against 0.1 ms for the slice.
284
+ const head = await Bun.file(filePath).slice(0, SESSION_TITLE_SLOT_BYTES + 4096).text();
286
285
  const lines = head.split('\n').filter(Boolean);
287
286
  for (const line of lines) {
288
287
  try {
@@ -11,19 +11,29 @@
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, scanSessionInfoCached } from '@/server/lib/omp/session/files';
14
+ import { clearSessionFileCaches, readRawHeaderLine, sessionIdIndex } from '@/server/lib/omp/session/files';
15
15
 
16
- /** Find the absolute path of the .jsonl whose header id matches. */
16
+ /**
17
+ * Locate an oh-my-pi session file by its session UUID.
18
+ *
19
+ * Resolves through the `id → path` index (files.ts), which is built from the
20
+ * same mtime-keyed scan cache the sidebar list fills. A linear scan over every
21
+ * session file measured 34.8 ms on the 452 files this machine holds — and it
22
+ * runs on the hot path of eight routes — against an O(1) map lookup.
23
+ *
24
+ * A miss is re-checked once against a freshly built index: a session created
25
+ * since the index was built would otherwise 404 until something else cleared
26
+ * the caches.
27
+ */
17
28
  export async function findSessionFileById(
18
29
  sessionId: string,
19
30
  sessionsRoot: string = getSessionsDir(),
20
31
  ): Promise<string | undefined> {
21
- const files = await listSessionFiles(sessionsRoot);
22
- for (const file of files) {
23
- const info = await scanSessionInfoCached(file);
24
- if (info?.id === sessionId) return file;
25
- }
26
- return undefined;
32
+ const index = await sessionIdIndex(sessionsRoot);
33
+ const hit = index.get(sessionId);
34
+ if (hit) return hit;
35
+ clearSessionFileCaches();
36
+ return (await sessionIdIndex(sessionsRoot)).get(sessionId);
27
37
  }
28
38
 
29
39
  /** The JSON 404 envelope every session-resolve guard answers with. */
@@ -161,13 +161,13 @@ async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
161
161
  /** Derive the display title from the JSONL header/title slot (cheap read). */
162
162
  export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
163
163
  try {
164
- const file = Bun.file(filePath);
165
- if ((await file.stat()).size > 10 * 1024 * 1024) return undefined; // only need the head
166
- } catch {
167
- return undefined;
168
- }
169
- try {
170
- const head = (await Bun.file(filePath).text()).slice(0, 32 * 1024);
164
+ // A bounded Blob slice, and deliberately NO size guard. The title slot and
165
+ // the session header live in the first few hundred bytes, so the 32 KiB
166
+ // window is what makes the read cheap — a file-size refusal on top of it
167
+ // only discarded titles that were sitting right there (measured: an 11 MB
168
+ // session reported `title: undefined` while its title slot held
169
+ // "Debug file attachment read failure").
170
+ const head = await Bun.file(filePath).slice(0, 32 * 1024).text();
171
171
  const records = parseJsonlLenient<Record<string, unknown>>(head);
172
172
  const first = records[0];
173
173
  if (first?.type === 'title' && typeof first.title === 'string' && first.title.trim()) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One oh-my-pi session file → its compact sidebar summary.
8
+ *
9
+ * Split out of `./files` so that module holds only the cache layer (the
10
+ * mtime-keyed file list, the per-file memo, and the `id → path` index) while
11
+ * this one holds the read: a 4 KiB prefix window for the header, title and
12
+ * first message, plus a bounded tail window for last activity. Nothing here
13
+ * touches a cache, so a scan is always a real read of the file's current bytes.
14
+ *
15
+ * File layout: optional fixed-width 256-byte title-slot line, then the
16
+ * {"type":"session"} header line, then message entries. Legacy pi v1/v2 shapes
17
+ * parse leniently (raw-text scans); nothing here mutates on-disk data.
18
+ */
19
+
20
+ import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
21
+ import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
22
+
23
+ export const SESSION_TITLE_SLOT_BYTES = 256;
24
+
25
+ /** Compact summary of one session file — the sidebar list row. */
26
+ export interface OmpSessionInfo {
27
+ /** Absolute path of the .jsonl file. */
28
+ path: string;
29
+ id: string;
30
+ /** Working directory the session ran in (from the header). */
31
+ cwd: string;
32
+ /** User-facing title (title slot wins; falls back to header title). */
33
+ title?: string;
34
+ parentSessionPath?: string;
35
+ created: Date;
36
+ /** Last activity: the newest JSONL entry's own `timestamp`, falling back to
37
+ * the file mtime for a file the tail window cannot date. */
38
+ modified: Date;
39
+ /** Filesystem mtime. Scan-cache version only — never user-visible, and never
40
+ * an ordering key (see readLastEntryTimestamp for why). */
41
+ fileMtime: Date;
42
+ /**
43
+ * The same mtime as a raw `stat.mtimeMs`. The cache compares THIS, not
44
+ * `fileMtime`: `Date.getTime()` truncates the fractional millisecond that
45
+ * APFS/HFS+ report, so comparing the Date never matched and the cache never
46
+ * hit — every request re-read 452 files' prefix and tail (measured 36-45 ms
47
+ * warm against 5.6 ms with the raw key).
48
+ */
49
+ fileMtimeMs: number;
50
+ messageCount: number;
51
+ size: number;
52
+ firstMessage: string;
53
+ }
54
+
55
+ /**
56
+ * Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
57
+ * (header, title, first message) plus a bounded tail window (last activity).
58
+ * Faithful port of omp's scanSessionFile except for `modified`, which is the
59
+ * last entry's timestamp rather than the file mtime. Missing/unreadable/
60
+ * header-less files yield undefined instead of throwing.
61
+ */
62
+ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
63
+ try {
64
+ const { text: prefix, size, mtime, mtimeMs } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
65
+ const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
66
+ const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
67
+ const header = parseSessionListHeader(prefix, entries);
68
+ if (!header) return undefined;
69
+
70
+ let parsedMessageCount = 0;
71
+ let firstMessage = '';
72
+ let shortSummary: string | undefined;
73
+ for (let i = 1; i < entries.length; i++) {
74
+ const entry = entries[i] as {
75
+ type?: string;
76
+ message?: { role?: string; content?: unknown };
77
+ shortSummary?: string;
78
+ };
79
+ if (entry.type === 'compaction' && typeof entry.shortSummary === 'string') {
80
+ shortSummary = entry.shortSummary;
81
+ }
82
+ if (entry.type === 'message' && entry.message) {
83
+ parsedMessageCount++;
84
+ if (entry.message.role === 'user' && !firstMessage) {
85
+ firstMessage = extractTextFromContent(entry.message.content);
86
+ }
87
+ }
88
+ }
89
+
90
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
91
+ return {
92
+ path: filePath,
93
+ id: header.id,
94
+ cwd: header.cwd ?? '',
95
+ title: header.title ?? shortSummary,
96
+ parentSessionPath: header.parentSession,
97
+ created: new Date(header.timestamp ?? ''),
98
+ modified: lastEntryAt ?? mtime,
99
+ fileMtime: mtime,
100
+ fileMtimeMs: mtimeMs,
101
+ messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
102
+ size,
103
+ firstMessage: firstMessage || '(no messages)',
104
+ };
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
@@ -9,8 +9,7 @@
9
9
  * jsonl file (v3 format — see docs in lib/omp/session).
10
10
  */
11
11
 
12
- import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
13
- import type { OmpMessageEntry } from '@/shared/types/omp/session';
12
+ import { loadSessionEntries } from '@/server/lib/omp/session/telemetry/scan';
14
13
 
15
14
  export interface SessionStats {
16
15
  /** Model switch history (most settings tabs show only the latest). */
@@ -29,57 +28,58 @@ export interface SessionStats {
29
28
  const MAX_SESSION_BYTES = 64 * 1024 * 1024;
30
29
 
31
30
  export async function readSessionStats(filePath: string): Promise<SessionStats | undefined> {
31
+ let size: number;
32
32
  try {
33
- const file = Bun.file(filePath);
34
- if ((await file.stat()).size > MAX_SESSION_BYTES) return undefined;
35
- const entries = parseJsonlLenient<OmpMessageEntry>(await file.text());
36
- const stats: SessionStats = {
37
- models: [],
38
- thinkingLevels: [],
39
- compactions: 0,
40
- messageCount: 0,
41
- assistantMessageCount: 0,
42
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
43
- cost: { total: 0 },
44
- };
45
- for (const entry of entries) {
46
- const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
47
- if (ts) {
48
- stats.startedAt ??= ts;
49
- stats.lastActivityAt = ts;
50
- }
51
- switch (entry.type) {
52
- case 'model_change':
53
- if (typeof entry.model === 'string' && ts) stats.models.push({ model: entry.model, at: ts });
54
- break;
55
- case 'thinking_level_change':
56
- if (typeof entry.thinkingLevel === 'string' && ts) stats.thinkingLevels.push({ level: entry.thinkingLevel, at: ts });
57
- break;
58
- case 'compaction':
59
- stats.compactions++;
60
- break;
61
- case 'message': {
62
- const message = entry.message;
63
- if (!message) break;
64
- stats.messageCount++;
65
- if (message.role === 'assistant') stats.assistantMessageCount++;
66
- const usage = message.usage;
67
- if (usage) {
68
- stats.tokens.input += usage.input ?? 0;
69
- stats.tokens.output += usage.output ?? 0;
70
- stats.tokens.cacheRead += usage.cacheRead ?? 0;
71
- stats.tokens.cacheWrite += usage.cacheWrite ?? 0;
72
- stats.tokens.reasoning += usage.reasoningTokens ?? 0;
73
- stats.cost.total += usage.cost?.total ?? 0;
74
- }
75
- break;
33
+ size = (await Bun.file(filePath).stat()).size;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ if (size > MAX_SESSION_BYTES) return undefined;
38
+ const entries = await loadSessionEntries(filePath);
39
+ const stats: SessionStats = {
40
+ models: [],
41
+ thinkingLevels: [],
42
+ compactions: 0,
43
+ messageCount: 0,
44
+ assistantMessageCount: 0,
45
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
46
+ cost: { total: 0 },
47
+ };
48
+ for (const entry of entries) {
49
+ const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
50
+ if (ts) {
51
+ stats.startedAt ??= ts;
52
+ stats.lastActivityAt = ts;
53
+ }
54
+ switch (entry.type) {
55
+ case 'model_change':
56
+ if (typeof entry.model === 'string' && ts) stats.models.push({ model: entry.model, at: ts });
57
+ break;
58
+ case 'thinking_level_change':
59
+ if (typeof entry.thinkingLevel === 'string' && ts) stats.thinkingLevels.push({ level: entry.thinkingLevel, at: ts });
60
+ break;
61
+ case 'compaction':
62
+ stats.compactions++;
63
+ break;
64
+ case 'message': {
65
+ const message = entry.message;
66
+ if (!message) break;
67
+ stats.messageCount++;
68
+ if (message.role === 'assistant') stats.assistantMessageCount++;
69
+ const usage = message.usage;
70
+ if (usage) {
71
+ stats.tokens.input += usage.input ?? 0;
72
+ stats.tokens.output += usage.output ?? 0;
73
+ stats.tokens.cacheRead += usage.cacheRead ?? 0;
74
+ stats.tokens.cacheWrite += usage.cacheWrite ?? 0;
75
+ stats.tokens.reasoning += usage.reasoningTokens ?? 0;
76
+ stats.cost.total += usage.cost?.total ?? 0;
76
77
  }
77
- default:
78
- break;
78
+ break;
79
79
  }
80
+ default:
81
+ break;
80
82
  }
81
- return stats;
82
- } catch {
83
- return undefined;
84
83
  }
84
+ return stats;
85
85
  }