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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- 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-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- 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/AppearanceSettings.tsx +3 -0
- 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/appearance-settings/EditorFontSection.tsx +78 -0
- 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 +19 -12
- 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/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- 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/font.ts +53 -0
- 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/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -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/listener.ts +29 -0
- 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/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- 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/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -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/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- 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/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- 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,
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
14
|
+
import { clearSessionFileCaches, readRawHeaderLine, sessionIdIndex } from '@/server/lib/omp/session/files';
|
|
15
15
|
|
|
16
|
-
/**
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const head =
|
|
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 {
|
|
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
|
}
|