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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- 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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
+
let cwd = '';
|
|
40
50
|
|
|
41
51
|
await scanSessionEntries(filePath, (entry, index) => {
|
|
42
|
-
if (entry.type === 'session'
|
|
43
|
-
|
|
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
|
-
|
|
58
|
-
window.
|
|
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
|
|
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 =
|
|
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> = {
|
|
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
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
return
|
|
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
|
-
|
|
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
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
+
}
|