ompchamber 3.1.1 → 3.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 (109) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +279 -280
  3. package/dist/client/static/js/index-01dv383b.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/cli/lib/output.js +15 -8
  7. package/src/client/components/common/Modal.tsx +14 -4
  8. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  10. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  11. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  12. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  13. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  15. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  16. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  17. package/src/client/components/settings/Modal.tsx +35 -6
  18. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  19. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  20. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  21. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  22. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  23. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  24. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  25. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  26. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  27. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  28. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  29. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  30. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  31. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  32. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  33. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  35. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/master-detail/index.tsx +68 -0
  38. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  41. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  50. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  51. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  52. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  53. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  54. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  55. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  56. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  57. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  58. package/src/client/hooks/chat/subagent/index.ts +37 -11
  59. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  60. package/src/client/hooks/settings/master-detail.ts +33 -0
  61. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  62. package/src/server/lib/db/client.ts +5 -1
  63. package/src/server/lib/fs/git-diff.ts +34 -10
  64. package/src/server/lib/fs/git-repos.ts +81 -0
  65. package/src/server/lib/lifecycle/flock.ts +199 -0
  66. package/src/server/lib/lifecycle/identity.ts +5 -1
  67. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  68. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  69. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  70. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  71. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  72. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  73. package/src/server/lib/observer-ws.ts +150 -0
  74. package/src/server/lib/omp/rpc/constants.ts +9 -0
  75. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  76. package/src/server/lib/omp/session/file-scan.ts +7 -1
  77. package/src/server/lib/omp/session/files.ts +95 -96
  78. package/src/server/lib/omp/session/locator.ts +18 -8
  79. package/src/server/lib/omp/session/messages.ts +7 -7
  80. package/src/server/lib/omp/session/scan.ts +108 -0
  81. package/src/server/lib/omp/session/stats.ts +50 -50
  82. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  83. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  84. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  85. package/src/server/lib/terminal/runtime.server.ts +3 -0
  86. package/src/server/lib/terminal/session.server.ts +30 -3
  87. package/src/server/routes/agent/command.test.ts +69 -0
  88. package/src/server/routes/agent/command.ts +14 -0
  89. package/src/server/routes/agent/ws.test.ts +159 -0
  90. package/src/server/routes/agent/ws.ts +22 -97
  91. package/src/server/routes/btw/ws.ts +22 -95
  92. package/src/server/routes/fs/git.ts +61 -70
  93. package/src/server/routes/omp/session-stats.ts +1 -1
  94. package/src/server/routes/telemetry/tokens.ts +6 -4
  95. package/src/server/routes/terminal/run.ts +4 -8
  96. package/src/server/routes/terminal/ws.ts +24 -2
  97. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  98. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  99. package/src/shared/lib/code/language.ts +146 -0
  100. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  101. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  102. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  103. package/src/shared/lib/fs/diff-parser.ts +67 -42
  104. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  105. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  106. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  107. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  108. package/src/shared/types/omp/subagent.ts +6 -0
  109. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -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
  }
@@ -1,18 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The JSONL pass behind both telemetry endpoints.
8
+ *
9
+ * `visit` is called per entry and may return `false` to stop early. The parse
10
+ * itself is cached by `(path, size, mtimeMs)`, because `/api/telemetry/context`
11
+ * and `/api/telemetry/raw-messages` are two polling endpoints that parse the
12
+ * SAME file — measured 70.6 ms and 64.2 ms on an 11 MB session, per request,
13
+ * with no cache between them. One parse now serves both until the file moves.
14
+ *
15
+ * The cache is keyed on the raw `stat.mtimeMs`, never a `Date`: `getTime()`
16
+ * truncates the fractional millisecond APFS reports, which is what made the
17
+ * session-scan cache miss on every call (see files.ts).
18
+ */
19
+
1
20
  import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
2
21
  import type { OmpMessageEntry } from '@/shared/types/omp/session';
3
22
 
4
- /** Streaming JSONL pass for the telemetry builders; `visit` returns `false` to stop early. */
5
- export async function scanSessionEntries(
6
- filePath: string,
7
- visit: (entry: OmpMessageEntry, index: number) => boolean | void,
8
- ): Promise<void> {
23
+ /** Sessions larger than this are not cached; a transcript past it is pathological. */
24
+ const MAX_CACHED_SESSION_BYTES = 64 * 1024 * 1024;
25
+ /** Bound on remembered transcripts, so a long session list cannot retain them all. */
26
+ const MAX_CACHED_SESSIONS = 16;
27
+
28
+ interface CachedEntries {
29
+ size: number;
30
+ mtimeMs: number;
31
+ entries: OmpMessageEntry[];
32
+ }
33
+
34
+ interface ScanCacheHost {
35
+ entries: Map<string, CachedEntries>;
36
+ }
37
+
38
+ declare global {
39
+ // eslint-disable-next-line no-var
40
+ var __ompChamberTelemetryScanCache: ScanCacheHost | undefined;
41
+ }
42
+
43
+ function cache(): Map<string, CachedEntries> {
44
+ globalThis.__ompChamberTelemetryScanCache ??= { entries: new Map() };
45
+ return globalThis.__ompChamberTelemetryScanCache.entries;
46
+ }
47
+
48
+ /**
49
+ * Every entry of `filePath`, parsed once per file version.
50
+ *
51
+ * Callers must treat the returned array (and its entries) as immutable — the
52
+ * same array is handed to the next caller, and the builders only read.
53
+ */
54
+ export async function loadSessionEntries(filePath: string): Promise<OmpMessageEntry[]> {
55
+ let size: number;
56
+ let mtimeMs: number;
57
+ try {
58
+ const stat = await Bun.file(filePath).stat();
59
+ size = stat.size;
60
+ mtimeMs = stat.mtimeMs;
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ const slot = cache();
66
+ const cached = slot.get(filePath);
67
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
68
+ // Refresh insertion order so the bound evicts the least recently used.
69
+ slot.delete(filePath);
70
+ slot.set(filePath, cached);
71
+ return cached.entries;
72
+ }
73
+
9
74
  let body: string;
10
75
  try {
11
76
  body = await Bun.file(filePath).text();
12
77
  } catch {
13
- return;
78
+ return [];
14
79
  }
15
- const records = parseJsonlLenient<OmpMessageEntry>(body);
80
+ const entries = parseJsonlLenient<OmpMessageEntry>(body);
81
+ if (size <= MAX_CACHED_SESSION_BYTES) {
82
+ slot.set(filePath, { size, mtimeMs, entries });
83
+ while (slot.size > MAX_CACHED_SESSIONS) {
84
+ const oldest = slot.keys().next().value;
85
+ if (oldest === undefined) break;
86
+ slot.delete(oldest);
87
+ }
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ /** JSONL pass for the telemetry builders; `visit` returns `false` to stop early. */
93
+ export async function scanSessionEntries(
94
+ filePath: string,
95
+ visit: (entry: OmpMessageEntry, index: number) => boolean | void,
96
+ ): Promise<void> {
97
+ const records = await loadSessionEntries(filePath);
16
98
  for (let index = 0; index < records.length; index++) {
17
99
  const entry = records[index];
18
100
  if (!entry) continue;
@@ -7,6 +7,10 @@
7
7
  * Server-side pagination for the raw messages panel. Scans the session JSONL
8
8
  * like ./telemetry.ts but materializes at most one page of `RawMessageItem`s,
9
9
  * so the payload-heavy entries never leave the server in bulk.
10
+ *
11
+ * The sliding window keeps MATCHES, and only the page that is returned is
12
+ * built — building every match to discard all but one page cost 48.9 ms on an
13
+ * 11 MB session against 1.9 ms for the page itself.
10
14
  */
11
15
 
12
16
  import type { RawMessageItem } from '@/shared/types/context';
@@ -33,14 +37,20 @@ export async function computeRawMessagesPage(
33
37
  role: RawMessageRole = 'all',
34
38
  ): Promise<RawMessagesPage> {
35
39
  const windowSize = Math.max(1, page) * pageSize;
36
- const window: RawMessageItem[] = [];
40
+ // The window holds the last `windowSize` MATCHES, not built items. Building
41
+ // every match and discarding all but one page was the whole cost of this
42
+ // endpoint: on an 11 MB session, `buildRawItem` over all 1017 matches took
43
+ // 48.9 ms while building only the 50 that are returned took 1.9 ms — the scan
44
+ // that collects them is 0.2 ms.
45
+ const window: { entry: OmpMessageEntry; msg: OmpMessage; index: number }[] = [];
46
+ let head = 0;
37
47
  let total = 0;
38
48
  let filteredTotal = 0;
39
- let header: OmpMessageEntry | undefined;
49
+ let cwd = '';
40
50
 
41
51
  await scanSessionEntries(filePath, (entry, index) => {
42
- if (entry.type === 'session' && !header) {
43
- header = entry;
52
+ if (entry.type === 'session') {
53
+ if (!cwd) cwd = entry.cwd ?? '';
44
54
  return;
45
55
  }
46
56
  if (entry.type !== 'message' || !entry.message) return;
@@ -54,8 +64,8 @@ export async function computeRawMessagesPage(
54
64
  if (role !== 'all' && msgRole !== role) return;
55
65
  filteredTotal++;
56
66
 
57
- if (window.length >= windowSize) window.shift();
58
- window.push(buildRawItem(entry, msg, header?.cwd ?? '', index));
67
+ window.push({ entry, msg, index });
68
+ if (window.length - head > windowSize) head++;
59
69
  });
60
70
 
61
71
  // `window` holds the last `windowSize` matches, oldest→newest, where
@@ -63,9 +73,15 @@ export async function computeRawMessagesPage(
63
73
  // rows: skip the (page-1) newest chunks, take one, flip for display. Both
64
74
  // bounds clamp at 0 so out-of-range pages return an empty list.
65
75
  const skipNewest = (Math.max(1, page) - 1) * pageSize;
66
- const end = Math.max(0, window.length - skipNewest);
76
+ const live = window.length - head;
77
+ const end = Math.max(0, live - skipNewest);
67
78
  const start = Math.max(0, end - pageSize);
68
- const items = window.slice(start, end).reverse();
79
+ const items: RawMessageItem[] = [];
80
+ for (let i = head + start; i < head + end; i++) {
81
+ const match = window[i];
82
+ items.push(buildRawItem(match.entry, match.msg, cwd, match.index));
83
+ }
84
+ items.reverse();
69
85
  return { items, total, filteredTotal };
70
86
  }
71
87
 
@@ -23,7 +23,10 @@ export type UsageWindow =
23
23
  | { kind: 'custom'; from: string; to: string }
24
24
  | { kind: 'all' };
25
25
 
26
- const PRESET_DAYS: Record<Exclude<TimeRangeType, 'custom' | 'all'>, number> = { today: 1, '7d': 7, '30d': 30, '90d': 90 };
26
+ const PRESET_DAYS: Record<Exclude<TimeRangeType, 'custom' | 'all' | 'today'>, number> = { '7d': 7, '30d': 30, '90d': 90 };
27
+
28
+ /** Every range the tokens endpoint reports, in display order. */
29
+ export const PRESET_RANGES: Exclude<TimeRangeType, 'custom'>[] = ['today', '7d', '30d', '90d', 'all'];
27
30
 
28
31
  const TTL_MS = 60_000;
29
32
  const cache = new Map<string, { at: number; data: UsageAggregate }>();
@@ -75,49 +78,65 @@ function bump(map: Map<string, { tokens: number; cost: number }>, key: string, t
75
78
  map.set(key, current);
76
79
  }
77
80
 
78
- function inRange(timestampIso: string | undefined, window: UsageWindow, now: number): boolean {
79
- if (window.kind === 'all') return true;
80
- if (!timestampIso) return true; // keep entries without timestamps (conservative)
81
- const ts = Date.parse(timestampIso);
82
- if (Number.isNaN(ts)) return true;
83
- if (window.kind === 'custom') {
84
- const from = Date.parse(`${window.from}T00:00:00`);
85
- const to = Date.parse(`${window.to}T23:59:59.999`);
86
- if (Number.isNaN(from) || Number.isNaN(to) || from > to) return true;
87
- return ts >= from && ts <= to;
88
- }
89
- if (window.range === 'today') {
90
- const d = new Date(ts);
91
- const n = new Date(now);
92
- return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
93
- }
94
- if (window.range === 'all') return true;
95
- return now - ts <= PRESET_DAYS[window.range] * 24 * 60 * 60 * 1000;
96
- }
97
-
98
81
  function cacheKey(window: UsageWindow): string {
99
82
  if (window.kind === 'all') return 'all';
100
83
  if (window.kind === 'custom') return `custom:${window.from}:${window.to}`;
101
84
  return window.range;
102
85
  }
103
86
 
104
- /** Aggregate all omp session files within a time window. Cached for 60s. */
105
- export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregate> {
106
- const key = cacheKey(window);
107
- const cached = cache.get(key);
108
- if (cached && Date.now() - cached.at < TTL_MS) return cached.data;
87
+ /**
88
+ * One usage record, with everything a window needs to decide whether it counts.
89
+ *
90
+ * The scan that produces these is the expensive part — measured 795 ms over 455
91
+ * transcripts / 327 MB — so it runs ONCE and every window is derived from the
92
+ * same array. Aggregating per window re-read the whole tree for each of the five
93
+ * presets plus the active window, which is why `/api/telemetry/tokens` took
94
+ * 3.3 s cold.
95
+ */
96
+ interface UsageRecord {
97
+ /** Epoch ms of the entry's timestamp, or undefined when it has none. */
98
+ at: number | undefined;
99
+ /** `YYYY-MM-DD` of `at`, or undefined. */
100
+ day: string | undefined;
101
+ model: string;
102
+ project: string;
103
+ cost: number;
104
+ costInput: number;
105
+ costOutput: number;
106
+ costCacheRead: number;
107
+ costCacheWrite: number;
108
+ input: number;
109
+ output: number;
110
+ cacheRead: number;
111
+ cacheWrite: number;
112
+ reasoning: number;
113
+ total: number;
114
+ }
115
+
116
+ interface UsageScan {
117
+ at: number;
118
+ records: UsageRecord[];
119
+ /** Transcripts visited, counted once for every window. */
120
+ scannedTranscripts: number;
121
+ }
122
+
123
+ let scanCache: UsageScan | null = null;
124
+
125
+ /** Every usage record in the sessions tree, read at most once per TTL. */
126
+ async function scanUsage(): Promise<UsageScan> {
127
+ if (scanCache && Date.now() - scanCache.at < TTL_MS) return scanCache;
109
128
 
110
129
  const sessionsRoot = getSessionsDir();
111
- const aggregate = emptyAggregate();
112
- const now = Date.now();
130
+ const records: UsageRecord[] = [];
131
+ let scannedTranscripts = 0;
113
132
  let projectDirs: string[] = [];
114
133
  try {
115
134
  projectDirs = (await fs.promises.readdir(sessionsRoot, { withFileTypes: true }))
116
135
  .filter((d) => d.isDirectory() || d.isFile())
117
136
  .map((d) => d.name);
118
137
  } catch {
119
- cache.set(key, { at: Date.now(), data: aggregate });
120
- return aggregate;
138
+ scanCache = { at: Date.now(), records, scannedTranscripts };
139
+ return scanCache;
121
140
  }
122
141
 
123
142
  for (const project of projectDirs) {
@@ -136,37 +155,33 @@ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregat
136
155
  }
137
156
 
138
157
  for (const file of files) {
139
- aggregate.scannedTranscripts++;
158
+ scannedTranscripts++;
140
159
  try {
141
160
  const ufile = Bun.file(file);
142
161
  if ((await ufile.stat()).size > 64 * 1024 * 1024) continue;
143
162
  const entries = parseJsonlLenient<OmpMessageEntry>(await ufile.text());
144
163
  for (const entry of entries) {
145
164
  if (entry.type !== 'message' || !entry.message?.usage) continue;
146
- const ts = entry.timestamp;
147
- if (!inRange(ts, window, now)) continue;
148
165
  const usage = entry.message.usage;
149
- const cost = usage.cost?.total ?? 0;
150
- const total = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
151
- aggregate.usageRecords++;
152
- aggregate.costTotal += cost;
153
- aggregate.costInput += usage.cost?.input ?? 0;
154
- aggregate.costOutput += usage.cost?.output ?? 0;
155
- aggregate.costCacheRead += usage.cost?.cacheRead ?? 0;
156
- aggregate.costCacheWrite += usage.cost?.cacheWrite ?? 0;
157
- aggregate.inputTokens += usage.input ?? 0;
158
- aggregate.outputTokens += usage.output ?? 0;
159
- aggregate.cacheReadTokens += usage.cacheRead ?? 0;
160
- aggregate.cacheWriteTokens += usage.cacheWrite ?? 0;
161
- aggregate.reasoningTokens += usage.reasoningTokens ?? 0;
162
- if (ts) {
163
- const day = ts.slice(0, 10);
164
- aggregate.activeDays.add(day);
165
- bump(aggregate.byDay, day, total, cost);
166
- }
167
- const model = entry.message.model || 'unknown';
168
- bump(aggregate.byModel, model, total, cost);
169
- bump(aggregate.byProject, project, total, cost);
166
+ const ts = entry.timestamp;
167
+ const parsed = ts ? Date.parse(ts) : Number.NaN;
168
+ records.push({
169
+ at: Number.isNaN(parsed) ? undefined : parsed,
170
+ day: ts ? ts.slice(0, 10) : undefined,
171
+ model: entry.message.model || 'unknown',
172
+ project,
173
+ cost: usage.cost?.total ?? 0,
174
+ costInput: usage.cost?.input ?? 0,
175
+ costOutput: usage.cost?.output ?? 0,
176
+ costCacheRead: usage.cost?.cacheRead ?? 0,
177
+ costCacheWrite: usage.cost?.cacheWrite ?? 0,
178
+ input: usage.input ?? 0,
179
+ output: usage.output ?? 0,
180
+ cacheRead: usage.cacheRead ?? 0,
181
+ cacheWrite: usage.cacheWrite ?? 0,
182
+ reasoning: usage.reasoningTokens ?? 0,
183
+ total: usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0),
184
+ });
170
185
  }
171
186
  } catch {
172
187
  // Unreadable transcript — skip rather than failing the whole report.
@@ -174,8 +189,108 @@ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregat
174
189
  }
175
190
  }
176
191
 
177
- const data = aggregate;
192
+ scanCache = { at: Date.now(), records, scannedTranscripts };
193
+ return scanCache;
194
+ }
195
+
196
+ /** Fold the scanned records that fall inside `window` into an aggregate. */
197
+ function foldWindow(records: readonly UsageRecord[], window: UsageWindow, now: number, scannedTranscripts: number): UsageAggregate {
198
+ const aggregate = emptyAggregate();
199
+ aggregate.scannedTranscripts = scannedTranscripts;
200
+ const bounds = windowBounds(window, now);
201
+ for (const record of records) {
202
+ // An entry with no usable timestamp is kept by EVERY window — a missing
203
+ // date is not evidence of age, and this matches the previous per-record
204
+ // behaviour.
205
+ if (record.at !== undefined && (record.at < bounds.from || record.at > bounds.to)) continue;
206
+ aggregate.usageRecords++;
207
+ aggregate.costTotal += record.cost;
208
+ aggregate.costInput += record.costInput;
209
+ aggregate.costOutput += record.costOutput;
210
+ aggregate.costCacheRead += record.costCacheRead;
211
+ aggregate.costCacheWrite += record.costCacheWrite;
212
+ aggregate.inputTokens += record.input;
213
+ aggregate.outputTokens += record.output;
214
+ aggregate.cacheReadTokens += record.cacheRead;
215
+ aggregate.cacheWriteTokens += record.cacheWrite;
216
+ aggregate.reasoningTokens += record.reasoning;
217
+ if (record.day) {
218
+ aggregate.activeDays.add(record.day);
219
+ bump(aggregate.byDay, record.day, record.total, record.cost);
220
+ }
221
+ bump(aggregate.byModel, record.model, record.total, record.cost);
222
+ bump(aggregate.byProject, record.project, record.total, record.cost);
223
+ }
224
+ return aggregate;
225
+ }
226
+
227
+ interface WindowBounds {
228
+ /** Inclusive epoch-ms bounds; `-Infinity`/`Infinity` for an unbounded side. */
229
+ from: number;
230
+ to: number;
231
+ }
232
+
233
+ /**
234
+ * Epoch-ms bounds for a window, computed once per fold.
235
+ *
236
+ * `today` is a local CALENDAR day (`setHours(0,0,0,0)`), not "the last 24h":
237
+ * the panel's "today" tab counts the day the user is in, and the previous
238
+ * implementation compared year/month/date for the same reason.
239
+ */
240
+ function windowBounds(window: UsageWindow, now: number): WindowBounds {
241
+ if (window.kind === 'custom') {
242
+ const from = Date.parse(`${window.from}T00:00:00`);
243
+ const to = Date.parse(`${window.to}T23:59:59.999`);
244
+ if (Number.isNaN(from) || Number.isNaN(to) || from > to) return { from: -Infinity, to: Infinity };
245
+ return { from, to };
246
+ }
247
+ if (window.kind === 'all') return { from: -Infinity, to: Infinity };
248
+ switch (window.range) {
249
+ case 'all':
250
+ return { from: -Infinity, to: Infinity };
251
+ case 'today': {
252
+ const start = new Date(now);
253
+ start.setHours(0, 0, 0, 0);
254
+ const end = new Date(start);
255
+ end.setDate(end.getDate() + 1);
256
+ return { from: start.getTime(), to: end.getTime() - 1 };
257
+ }
258
+ default:
259
+ return { from: now - PRESET_DAYS[window.range] * 24 * 60 * 60 * 1000, to: Infinity };
260
+ }
261
+ }
262
+
263
+ /** Aggregate all omp session files within a time window. Cached for 60s. */
264
+ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregate> {
265
+ const key = cacheKey(window);
266
+ const cached = cache.get(key);
267
+ if (cached && Date.now() - cached.at < TTL_MS) return cached.data;
268
+
269
+ const scan = await scanUsage();
270
+ const data = foldWindow(scan.records, window, Date.now(), scan.scannedTranscripts);
178
271
  if (cache.size > 20) cache.clear();
179
272
  cache.set(key, { at: Date.now(), data });
180
273
  return data;
181
274
  }
275
+
276
+ /**
277
+ * Aggregate EVERY preset range plus `window` from one scan.
278
+ *
279
+ * The tokens endpoint needs all six, and folding them from one scan costs the
280
+ * fold (linear in records) rather than six more tree reads.
281
+ */
282
+ export async function aggregateUsageWindows(
283
+ window: UsageWindow,
284
+ ): Promise<{ ranges: Record<Exclude<TimeRangeType, 'custom'>, UsageAggregate>; active: UsageAggregate }> {
285
+ const scan = await scanUsage();
286
+ const now = Date.now();
287
+ const ranges = {} as Record<Exclude<TimeRangeType, 'custom'>, UsageAggregate>;
288
+ for (const range of PRESET_RANGES) {
289
+ ranges[range] = foldWindow(scan.records, { kind: 'preset', range }, now, scan.scannedTranscripts);
290
+ }
291
+ const active = foldWindow(scan.records, window, now, scan.scannedTranscripts);
292
+ // Cache each fold under its own key so a later single-window call reuses it.
293
+ for (const range of PRESET_RANGES) cache.set(range, { at: now, data: ranges[range] });
294
+ cache.set(cacheKey(window), { at: now, data: active });
295
+ return { ranges, active };
296
+ }