ompchamber 1.0.0 → 1.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/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
import { scanSessionInfo } from '@/server/lib/omp/session/files';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `modified` is the session's last real activity, and two signals that look
|
|
14
|
+
* like it are not:
|
|
15
|
+
*
|
|
16
|
+
* - the file mtime, which a title-slot rename bumps without a turn having
|
|
17
|
+
* happened (measured: 5 days of skew on a real session);
|
|
18
|
+
* - a `session_exit` lifecycle marker, written per process dispose, which
|
|
19
|
+
* floats a session quiet for days to the top of the sidebar (measured: 255
|
|
20
|
+
* of 319 sessions ranked by one).
|
|
21
|
+
*
|
|
22
|
+
* The cost of regressing either is a silently wrong sidebar order, which no
|
|
23
|
+
* other test observes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const tempDirs: string[] = [];
|
|
27
|
+
|
|
28
|
+
/** Write a session file whose lines are exactly the given records, in order. */
|
|
29
|
+
async function writeSession(lines: unknown[]): Promise<string> {
|
|
30
|
+
const dir = await fs.promises.mkdtemp('omp-session-files-');
|
|
31
|
+
tempDirs.push(dir);
|
|
32
|
+
const file = join(dir, 'session.jsonl');
|
|
33
|
+
await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const at = (day: number, second = 0) =>
|
|
42
|
+
`2026-09-${String(day).padStart(2, '0')}T03:00:${String(second).padStart(2, '0')}.000Z`;
|
|
43
|
+
|
|
44
|
+
const slot = (title: string) => ({ type: 'title', v: 1, title, updatedAt: at(1), pad: '' });
|
|
45
|
+
const header = { type: 'session', version: 3, id: 'session-id', cwd: '/tmp/proj', timestamp: at(1) };
|
|
46
|
+
const message = (day: number, text = 'hello') => ({
|
|
47
|
+
type: 'message',
|
|
48
|
+
id: `m-${day}`,
|
|
49
|
+
parentId: null,
|
|
50
|
+
timestamp: at(day),
|
|
51
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
52
|
+
});
|
|
53
|
+
/** The marker omp appends every time it disposes the session process. */
|
|
54
|
+
const sessionExit = (day: number) => ({
|
|
55
|
+
type: 'custom',
|
|
56
|
+
customType: 'session_exit',
|
|
57
|
+
data: { reason: 'dispose', kind: 'normal', recordedAt: at(day) },
|
|
58
|
+
id: `x-${day}`,
|
|
59
|
+
parentId: null,
|
|
60
|
+
timestamp: at(day),
|
|
61
|
+
});
|
|
62
|
+
const toolStart = (day: number) => ({
|
|
63
|
+
type: 'custom',
|
|
64
|
+
customType: 'tool_execution_start',
|
|
65
|
+
data: { toolCallId: 'call', toolName: 'bash' },
|
|
66
|
+
id: `t-${day}`,
|
|
67
|
+
parentId: null,
|
|
68
|
+
timestamp: at(day),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('scanSessionInfo last activity', () => {
|
|
72
|
+
test('dates the session by its newest entry, not the file mtime', async () => {
|
|
73
|
+
// The mtime is "now" (just written); only the entry timestamp is the answer.
|
|
74
|
+
const file = await writeSession([slot('t'), header, message(10)]);
|
|
75
|
+
const info = await scanSessionInfo(file);
|
|
76
|
+
expect(info?.modified.toISOString()).toBe(at(10));
|
|
77
|
+
expect(info?.modified.getTime()).not.toBe(info?.fileMtime.getTime());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('ignores a trailing session_exit marker', async () => {
|
|
81
|
+
const file = await writeSession([slot('t'), header, message(10), sessionExit(20)]);
|
|
82
|
+
const info = await scanSessionInfo(file);
|
|
83
|
+
expect(info?.modified.toISOString()).toBe(at(10));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ignores a stack of session_exit markers, however many accumulated', async () => {
|
|
87
|
+
const file = await writeSession([
|
|
88
|
+
slot('t'),
|
|
89
|
+
header,
|
|
90
|
+
message(11),
|
|
91
|
+
sessionExit(20),
|
|
92
|
+
sessionExit(21),
|
|
93
|
+
sessionExit(22),
|
|
94
|
+
]);
|
|
95
|
+
const info = await scanSessionInfo(file);
|
|
96
|
+
expect(info?.modified.toISOString()).toBe(at(11));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('still counts an in-flight tool call as activity', async () => {
|
|
100
|
+
// A run killed mid-tool-call has no later message, but it IS the newest
|
|
101
|
+
// session. Excluding every custom entry would misrank it.
|
|
102
|
+
const file = await writeSession([slot('t'), header, message(12), toolStart(13)]);
|
|
103
|
+
const info = await scanSessionInfo(file);
|
|
104
|
+
expect(info?.modified.toISOString()).toBe(at(13));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('counts an unrecognised custom entry rather than assuming it is bookkeeping', async () => {
|
|
108
|
+
const file = await writeSession([
|
|
109
|
+
slot('t'),
|
|
110
|
+
header,
|
|
111
|
+
message(12),
|
|
112
|
+
{ type: 'custom', customType: 'brand_new_marker', id: 'z', timestamp: at(19) },
|
|
113
|
+
]);
|
|
114
|
+
const info = await scanSessionInfo(file);
|
|
115
|
+
expect(info?.modified.toISOString()).toBe(at(19));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('uses a later message when the exit marker came first', async () => {
|
|
119
|
+
const file = await writeSession([slot('t'), header, sessionExit(5), message(6)]);
|
|
120
|
+
const info = await scanSessionInfo(file);
|
|
121
|
+
expect(info?.modified.toISOString()).toBe(at(6));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('falls back to the creation time for a session with no activity at all', async () => {
|
|
125
|
+
const file = await writeSession([slot('t'), header, sessionExit(30)]);
|
|
126
|
+
const info = await scanSessionInfo(file);
|
|
127
|
+
expect(info?.modified.toISOString()).toBe(at(1));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('reads the timestamp past an entry larger than the tail window', async () => {
|
|
131
|
+
// A tool result carrying a big file read is the newest entry and is
|
|
132
|
+
// usually the largest one; the window must widen to reach its line.
|
|
133
|
+
const file = await writeSession([
|
|
134
|
+
slot('t'),
|
|
135
|
+
header,
|
|
136
|
+
message(2, 'head'),
|
|
137
|
+
message(4, 'x'.repeat(200_000)),
|
|
138
|
+
]);
|
|
139
|
+
const info = await scanSessionInfo(file);
|
|
140
|
+
expect(info?.modified.toISOString()).toBe(at(4));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('returns undefined for a file with no session header', async () => {
|
|
144
|
+
const file = await writeSession([slot('t'), message(3)]);
|
|
145
|
+
expect(await scanSessionInfo(file)).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* File layout: optional fixed-width 256-byte title-slot line, then the
|
|
16
16
|
* {"type":"session"} header line, then message entries. Legacy pi v1/v2
|
|
17
17
|
* shapes parse leniently (raw-text scans); nothing here mutates on-disk data.
|
|
18
|
-
* The lenient JSONL + header parsing lives in
|
|
18
|
+
* The lenient JSONL + header parsing lives in
|
|
19
|
+
* `@/shared/lib/omp/session/jsonl`; the bounded window readers (head prefix
|
|
20
|
+
* plus escalating tail) live in ./file-scan.ts.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import fs from 'fs';
|
|
22
24
|
import * as path from 'path';
|
|
23
25
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
26
|
+
import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
|
|
24
27
|
import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
|
|
25
28
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
26
29
|
|
|
@@ -37,37 +40,30 @@ export interface OmpSessionInfo {
|
|
|
37
40
|
title?: string;
|
|
38
41
|
parentSessionPath?: string;
|
|
39
42
|
created: Date;
|
|
43
|
+
/** Last activity: the newest JSONL entry's own `timestamp`, falling back to
|
|
44
|
+
* the file mtime for a file the tail window cannot date. */
|
|
40
45
|
modified: Date;
|
|
46
|
+
/** Filesystem mtime. Scan-cache version only — never user-visible, and never
|
|
47
|
+
* an ordering key (see readLastEntryTimestamp for why). */
|
|
48
|
+
fileMtime: Date;
|
|
41
49
|
messageCount: number;
|
|
42
50
|
size: number;
|
|
43
51
|
firstMessage: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// ============================================================================
|
|
47
|
-
// Per-file scan (4 KiB prefix window)
|
|
48
|
-
// ============================================================================
|
|
49
|
-
|
|
50
|
-
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
51
|
-
|
|
52
|
-
async function readTextPrefix(filePath: string, prefixBytes: number): Promise<[string, number, Date]> {
|
|
53
|
-
const file = Bun.file(filePath);
|
|
54
|
-
const stat = await file.stat();
|
|
55
|
-
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
56
|
-
const prefix = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
57
|
-
return [prefix, stat.size, new Date(stat.mtimeMs)];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
54
|
/**
|
|
61
|
-
* Scan a single session file into an OmpSessionInfo
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
55
|
+
* Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
|
|
56
|
+
* (header, title, first message) plus a bounded tail window (last activity).
|
|
57
|
+
* Faithful port of omp's scanSessionFile except for `modified`, which is the
|
|
58
|
+
* last entry's timestamp rather than the file mtime. Missing/unreadable/
|
|
59
|
+
* header-less files yield undefined instead of throwing.
|
|
65
60
|
*/
|
|
66
61
|
export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
|
|
67
62
|
try {
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
63
|
+
const { text: prefix, size, mtime } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
|
|
64
|
+
const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
|
|
65
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
|
|
66
|
+
const header = parseSessionListHeader(prefix, entries);
|
|
71
67
|
if (!header) return undefined;
|
|
72
68
|
|
|
73
69
|
let parsedMessageCount = 0;
|
|
@@ -90,7 +86,7 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
|
|
|
90
86
|
}
|
|
91
87
|
}
|
|
92
88
|
|
|
93
|
-
firstMessage ||= extractFirstDisplayMessageFromPrefix(
|
|
89
|
+
firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
|
|
94
90
|
return {
|
|
95
91
|
path: filePath,
|
|
96
92
|
id: header.id,
|
|
@@ -98,8 +94,9 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
|
|
|
98
94
|
title: header.title ?? shortSummary,
|
|
99
95
|
parentSessionPath: header.parentSession,
|
|
100
96
|
created: new Date(header.timestamp ?? ''),
|
|
101
|
-
modified: mtime,
|
|
102
|
-
|
|
97
|
+
modified: lastEntryAt ?? mtime,
|
|
98
|
+
fileMtime: mtime,
|
|
99
|
+
messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
|
|
103
100
|
size,
|
|
104
101
|
firstMessage: firstMessage || '(no messages)',
|
|
105
102
|
};
|
|
@@ -120,9 +117,24 @@ interface SessionFileListCacheEntry {
|
|
|
120
117
|
files: string[];
|
|
121
118
|
}
|
|
122
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Shape version of a cached OmpSessionInfo. The caches hang off `globalThis`
|
|
122
|
+
* so they survive a module reload, which means an entry written by an older
|
|
123
|
+
* build of this file is still readable after an edit — and a field added or
|
|
124
|
+
* renamed since then reads as undefined at the point of use rather than
|
|
125
|
+
* missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
|
|
126
|
+
* discarded instead of misread.
|
|
127
|
+
*/
|
|
128
|
+
const SESSION_SCAN_CACHE_VERSION = 2;
|
|
129
|
+
|
|
130
|
+
interface SessionScanCache {
|
|
131
|
+
version: number;
|
|
132
|
+
entries: Map<string, OmpSessionInfo>;
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
declare global {
|
|
124
136
|
var __ompChamberSessionFileListCache: Map<string, SessionFileListCacheEntry> | undefined;
|
|
125
|
-
var __ompChamberSessionScanCache:
|
|
137
|
+
var __ompChamberSessionScanCache: SessionScanCache | undefined;
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
const MAX_SESSION_SCAN_CACHE_ENTRIES = 2048;
|
|
@@ -197,16 +209,18 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
|
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
function getSessionScanCache(): Map<string, OmpSessionInfo> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
212
|
+
const slot = globalThis.__ompChamberSessionScanCache;
|
|
213
|
+
if (slot && slot.version === SESSION_SCAN_CACHE_VERSION) return slot.entries;
|
|
214
|
+
const fresh: SessionScanCache = { version: SESSION_SCAN_CACHE_VERSION, entries: new Map() };
|
|
215
|
+
globalThis.__ompChamberSessionScanCache = fresh;
|
|
216
|
+
return fresh.entries;
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
/** scanSessionInfo memoized on (path, size, mtimeMs) — an unchanged file costs
|
|
207
220
|
* a single stat. Cache hits share one object; callers must treat results as
|
|
208
|
-
* immutable.
|
|
209
|
-
|
|
221
|
+
* immutable. Exported so the id locator shares this cache with the sidebar
|
|
222
|
+
* list instead of re-reading every prefix and tail on each resolve. */
|
|
223
|
+
export async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
|
|
210
224
|
let stat: { size: number; mtimeMs: number };
|
|
211
225
|
try {
|
|
212
226
|
stat = await Bun.file(filePath).stat();
|
|
@@ -217,8 +231,11 @@ async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo |
|
|
|
217
231
|
const cached = cache.get(filePath);
|
|
218
232
|
if (cached) {
|
|
219
233
|
const info = cached;
|
|
220
|
-
// Re-scan when size or mtime changed.
|
|
221
|
-
|
|
234
|
+
// Re-scan when size or mtime changed. The version is the FILE mtime, not
|
|
235
|
+
// `modified` — the latter is now an entry timestamp, and a title-slot
|
|
236
|
+
// rewrite that bumps the file without appending an entry must still be
|
|
237
|
+
// picked up (it can change the title the row displays).
|
|
238
|
+
if (info.size === stat.size && info.fileMtime.getTime() === stat.mtimeMs) {
|
|
222
239
|
cache.delete(filePath);
|
|
223
240
|
cache.set(filePath, info);
|
|
224
241
|
return info;
|
|
@@ -259,7 +276,7 @@ export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir(
|
|
|
259
276
|
* that mtime keys cannot detect). */
|
|
260
277
|
export function clearSessionFileCaches(): void {
|
|
261
278
|
globalThis.__ompChamberSessionFileListCache?.clear();
|
|
262
|
-
globalThis.__ompChamberSessionScanCache?.clear();
|
|
279
|
+
globalThis.__ompChamberSessionScanCache?.entries.clear();
|
|
263
280
|
}
|
|
264
281
|
|
|
265
282
|
/** Exported for tests/spot-checks: read a raw JSONL session header directly. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { json } from '@/server/lib/remix-compat';
|
|
13
13
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
14
|
-
import { listSessionFiles, readRawHeaderLine,
|
|
14
|
+
import { listSessionFiles, readRawHeaderLine, scanSessionInfoCached } from '@/server/lib/omp/session/files';
|
|
15
15
|
|
|
16
16
|
/** Find the absolute path of the .jsonl whose header id matches. */
|
|
17
17
|
export async function findSessionFileById(
|
|
@@ -20,7 +20,7 @@ export async function findSessionFileById(
|
|
|
20
20
|
): Promise<string | undefined> {
|
|
21
21
|
const files = await listSessionFiles(sessionsRoot);
|
|
22
22
|
for (const file of files) {
|
|
23
|
-
const info = await
|
|
23
|
+
const info = await scanSessionInfoCached(file);
|
|
24
24
|
if (info?.id === sessionId) return file;
|
|
25
25
|
}
|
|
26
26
|
return undefined;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merging the chamber's stored turns onto JSONL-loaded ones.
|
|
8
|
+
*
|
|
9
|
+
* The two sources see different halves of an attachment: the JSONL recovers a
|
|
10
|
+
* text file's name from the delivered prompt but has no preview or content,
|
|
11
|
+
* while the chamber row has those display fields but nothing about what omp
|
|
12
|
+
* received. Taking one list over the other dropped whichever entries only
|
|
13
|
+
* existed on the losing side — a dropped `.md` disappeared from a reloaded
|
|
14
|
+
* session as soon as the same turn carried an image.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
19
|
+
|
|
20
|
+
function userTurn(overrides: Partial<StoredMessage> = {}): StoredMessage {
|
|
21
|
+
return { id: 'u1', role: 'user', content: 'hello', ...overrides };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storedJson(messages: StoredMessage[]): string {
|
|
25
|
+
return JSON.stringify(messages);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('mergeOmpAttachments attachments', () => {
|
|
29
|
+
test('keeps both an image from the JSONL and a text file from the store', () => {
|
|
30
|
+
const jsonl = [userTurn({
|
|
31
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
32
|
+
})];
|
|
33
|
+
const stored = [userTurn({
|
|
34
|
+
attachments: [
|
|
35
|
+
{ name: 'notes.md', type: 'text/plain', content: '# hi' },
|
|
36
|
+
{ name: 'shot.png', type: 'image/png', preview: 'blob:http://x/1' },
|
|
37
|
+
],
|
|
38
|
+
})];
|
|
39
|
+
|
|
40
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
41
|
+
expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'notes.md']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a text file recovered from the JSONL keeps its name when the store has none', () => {
|
|
45
|
+
const jsonl = [userTurn({
|
|
46
|
+
attachments: [{ name: 'script.py', type: 'text/plain', content: 'x=1' }],
|
|
47
|
+
})];
|
|
48
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson([userTurn()]));
|
|
49
|
+
expect(merged.attachments).toEqual([{ name: 'script.py', type: 'text/plain', content: 'x=1' }]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a matching name fills in the fields the JSONL could not carry', () => {
|
|
53
|
+
const jsonl = [userTurn({
|
|
54
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
55
|
+
})];
|
|
56
|
+
const stored = [userTurn({
|
|
57
|
+
attachments: [{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored' }],
|
|
58
|
+
})];
|
|
59
|
+
|
|
60
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
61
|
+
expect(merged.attachments).toEqual([
|
|
62
|
+
{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored', preview: 'data:image/png;base64,AAAA' },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an attachment only the store knows about is still shown', () => {
|
|
67
|
+
const jsonl = [userTurn({
|
|
68
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
69
|
+
})];
|
|
70
|
+
const stored = [userTurn({
|
|
71
|
+
attachments: [
|
|
72
|
+
{ name: 'shot.png', type: 'image/png' },
|
|
73
|
+
{ name: 'dropped.txt', type: 'text/plain', content: 'kept' },
|
|
74
|
+
],
|
|
75
|
+
})];
|
|
76
|
+
|
|
77
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
78
|
+
expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'dropped.txt']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a turn with no attachments on either side stays attachment-free', () => {
|
|
82
|
+
const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn()]));
|
|
83
|
+
expect(merged.attachments).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a stored turn that never reached the JSONL is still merged in', () => {
|
|
87
|
+
const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn({
|
|
88
|
+
attachments: [{ name: 'notes.md', type: 'text/plain', content: '# hi' }],
|
|
89
|
+
})]));
|
|
90
|
+
expect(merged.attachments).toEqual([{ name: 'notes.md', type: 'text/plain', content: '# hi' }]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('mergeOmpAttachments text restoration', () => {
|
|
95
|
+
test('a raw composer turn keeps the text the user typed', () => {
|
|
96
|
+
// The JSONL records only a synthesized `/skill:<name>`; the DB copy has the
|
|
97
|
+
// invocation with its arguments, which is what the user actually typed.
|
|
98
|
+
const jsonl = [userTurn({ content: '/skill:capacity' })];
|
|
99
|
+
const stored = [userTurn({ content: '/skill:capacity summarize this repo' })];
|
|
100
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
101
|
+
expect(merged.content).toBe('/skill:capacity summarize this repo');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('an unrelated stored turn does not overwrite the JSONL text', () => {
|
|
105
|
+
// Turns that cannot be recognized as the same request are left alone; the
|
|
106
|
+
// JSONL keeps what the model was actually sent.
|
|
107
|
+
const jsonl = [userTurn({ content: 'rewritten prompt' })];
|
|
108
|
+
const stored = [userTurn({ content: 'a completely different request' })];
|
|
109
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
110
|
+
expect(merged.content).toBe('rewritten prompt');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('malformed stored JSON leaves the JSONL messages untouched', () => {
|
|
114
|
+
const jsonl = [userTurn()];
|
|
115
|
+
expect(mergeOmpAttachments(jsonl, 'not json')).toBe(jsonl);
|
|
116
|
+
expect(mergeOmpAttachments(jsonl, undefined)).toBe(jsonl);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merge the chamber's own copy of a conversation onto the messages loaded from
|
|
8
|
+
* an omp JSONL file.
|
|
9
|
+
*
|
|
10
|
+
* The JSONL is authoritative for what the model received, but it is not a
|
|
11
|
+
* complete record of the chamber's UI: omp rewrites raw composer input
|
|
12
|
+
* (`@agent` → a task-tool delegation prompt, `/skill:a b` → `/skill:a`) and
|
|
13
|
+
* writes no user entry at all for `/command` turns. The chamber DB keeps the
|
|
14
|
+
* turn as the user typed it, which is what this restores.
|
|
15
|
+
*
|
|
16
|
+
* Attachments are UNIONED rather than picked from one side. The two sources see
|
|
17
|
+
* different halves of the same attachment:
|
|
18
|
+
*
|
|
19
|
+
* - The JSONL recovers a text file's NAME from the delivered prompt (the
|
|
20
|
+
* composer inlines the file as `Attached file: <name>` plus a fenced block)
|
|
21
|
+
* and carries an image's bytes, but has no preview and no stored content.
|
|
22
|
+
* - The chamber row has the display fields — a text file's name and content, an
|
|
23
|
+
* image's name and preview — but nothing about what omp actually received.
|
|
24
|
+
*
|
|
25
|
+
* Choosing one list over the other is what made a dropped `.md`/`.py`/`.json`
|
|
26
|
+
* vanish from a reloaded session: the JSONL had an image, so its list won, and
|
|
27
|
+
* the text file only ever existed in the chamber row.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface StoredAttachment {
|
|
31
|
+
name?: string;
|
|
32
|
+
preview?: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
size?: number;
|
|
35
|
+
content?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StoredMessage {
|
|
39
|
+
id?: string;
|
|
40
|
+
role: string;
|
|
41
|
+
content: string;
|
|
42
|
+
date?: string;
|
|
43
|
+
timestamp?: string;
|
|
44
|
+
attachments?: StoredAttachment[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRawComposerInput(content: string): boolean {
|
|
48
|
+
return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a JSONL-derived user turn (a) and a stored one (b) are the same
|
|
52
|
+
* request despite omp rewriting the delivered prompt. */
|
|
53
|
+
function userTurnsRelate(a: string, b: string): boolean {
|
|
54
|
+
if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
|
|
55
|
+
// `@agent` is delivered to omp as a task-tool delegation prompt.
|
|
56
|
+
if (a.startsWith('Use the task tool to delegate this request') && /(^|\s)@[A-Za-z0-9_-]+/.test(b)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
// `/skill:<name> <args>`: the JSONL only records a synthesized `/skill:<name>`.
|
|
60
|
+
const token = a.split(' ')[0];
|
|
61
|
+
return a.startsWith('/skill:') && Boolean(token) && b.startsWith(token);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Union two attachment lists by name.
|
|
66
|
+
*
|
|
67
|
+
* The JSONL entry comes first, so the order the model received wins; a stored
|
|
68
|
+
* entry with the same name fills in the fields the JSONL could not carry.
|
|
69
|
+
*/
|
|
70
|
+
function mergeAttachmentLists(
|
|
71
|
+
fromJsonl: StoredAttachment[] | undefined,
|
|
72
|
+
fromStore: StoredAttachment[] | undefined,
|
|
73
|
+
): StoredAttachment[] {
|
|
74
|
+
const jsonl = Array.isArray(fromJsonl) ? fromJsonl : [];
|
|
75
|
+
const stored = Array.isArray(fromStore) ? fromStore : [];
|
|
76
|
+
if (jsonl.length === 0) return stored;
|
|
77
|
+
if (stored.length === 0) return jsonl;
|
|
78
|
+
const remaining = [...stored];
|
|
79
|
+
const merged = jsonl.map((attachment) => {
|
|
80
|
+
const index = remaining.findIndex((candidate) => candidate.name === attachment.name);
|
|
81
|
+
if (index === -1) return attachment;
|
|
82
|
+
const [match] = remaining.splice(index, 1);
|
|
83
|
+
return {
|
|
84
|
+
...match,
|
|
85
|
+
...attachment,
|
|
86
|
+
content: attachment.content ?? match.content,
|
|
87
|
+
preview: attachment.preview || match.preview,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// Anything the JSONL could not see (an attachment omp dropped before the
|
|
91
|
+
// prompt was persisted) is still shown rather than silently discarded.
|
|
92
|
+
return [...merged, ...remaining];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Merge user turns and attachments from the chamber DB copy onto the
|
|
96
|
+
* JSONL-loaded messages. */
|
|
97
|
+
export function mergeOmpAttachments(
|
|
98
|
+
messages: StoredMessage[],
|
|
99
|
+
storedMessagesJson: string | undefined,
|
|
100
|
+
): StoredMessage[] {
|
|
101
|
+
if (!storedMessagesJson) return messages;
|
|
102
|
+
let stored: StoredMessage[] = [];
|
|
103
|
+
try {
|
|
104
|
+
stored = JSON.parse(storedMessagesJson);
|
|
105
|
+
} catch {
|
|
106
|
+
return messages;
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(stored)) return messages;
|
|
109
|
+
const storedUsers = stored.filter((m) => m?.role === 'user' && typeof m.content === 'string');
|
|
110
|
+
|
|
111
|
+
const jsonlIndex = new Map<string, number>();
|
|
112
|
+
messages.forEach((m, index) => {
|
|
113
|
+
if (m.id) jsonlIndex.set(m.id, index);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const used = new Set<number>();
|
|
117
|
+
const merged = messages.map((m) => {
|
|
118
|
+
if (m.role !== 'user') return m;
|
|
119
|
+
const storedIndex = storedUsers.findIndex((s, i) => !used.has(i) && userTurnsRelate(m.content, s.content));
|
|
120
|
+
if (storedIndex === -1) return m;
|
|
121
|
+
used.add(storedIndex);
|
|
122
|
+
const storedMsg = storedUsers[storedIndex];
|
|
123
|
+
const content = storedMsg.content !== m.content && isRawComposerInput(storedMsg.content)
|
|
124
|
+
? storedMsg.content
|
|
125
|
+
: m.content;
|
|
126
|
+
const attachments = mergeAttachmentLists(m.attachments, storedMsg.attachments);
|
|
127
|
+
const next = { ...m, content };
|
|
128
|
+
return attachments.length ? { ...next, attachments } : next;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Unmatched raw turns are anchored before the next stored message the JSONL
|
|
132
|
+
// still carries; splice highest-anchor-first so earlier indices stay valid.
|
|
133
|
+
const insertions = new Map<number, StoredMessage[]>();
|
|
134
|
+
storedUsers.forEach((storedMsg, userIndex) => {
|
|
135
|
+
if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
|
|
136
|
+
if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
|
|
137
|
+
let anchor = merged.length;
|
|
138
|
+
for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
|
|
139
|
+
const id = stored[i]?.id;
|
|
140
|
+
const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
|
|
141
|
+
if (found !== undefined) {
|
|
142
|
+
anchor = found;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const group = insertions.get(anchor);
|
|
147
|
+
if (group) group.push(storedMsg);
|
|
148
|
+
else insertions.set(anchor, [storedMsg]);
|
|
149
|
+
});
|
|
150
|
+
for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
|
|
151
|
+
const group = insertions.get(anchor);
|
|
152
|
+
if (group) merged.splice(anchor, 0, ...group);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const byContent = new Map<string, StoredAttachment[]>();
|
|
156
|
+
for (const m of stored) {
|
|
157
|
+
if (m?.role === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0) {
|
|
158
|
+
byContent.set(m.content, m.attachments);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return merged.map((m) => {
|
|
162
|
+
if (m.role !== 'user' || m.attachments?.length) return m;
|
|
163
|
+
// The JSONL content may carry inlined text-file blocks appended to the
|
|
164
|
+
// original prompt, so match by prefix instead of exact equality.
|
|
165
|
+
const atts = byContent.get(m.content)
|
|
166
|
+
?? [...byContent.entries()].find(([storedContent]) =>
|
|
167
|
+
m.content.startsWith(storedContent) || storedContent.startsWith(m.content)
|
|
168
|
+
)?.[1];
|
|
169
|
+
return atts ? { ...m, attachments: atts } : m;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -25,6 +25,7 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
|
25
25
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
26
26
|
import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
|
|
27
27
|
import type { OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
28
|
+
import { readBlobRef } from '@/server/lib/omp/session/blobs.server';
|
|
28
29
|
|
|
29
30
|
const MAX_SESSION_LOAD_BYTES = 512 * 1024 * 1024;
|
|
30
31
|
|
|
@@ -121,9 +122,42 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
|
|
|
121
122
|
}
|
|
122
123
|
state.messages.push(mapped);
|
|
123
124
|
}
|
|
125
|
+
await resolveImageBlobs(state.messages);
|
|
124
126
|
return state.messages;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Replace `blob:sha256:` image references with the bytes they point at.
|
|
131
|
+
*
|
|
132
|
+
* omp externalizes a large attached image out of the session JSONL: the entry
|
|
133
|
+
* keeps the ref, the bytes go to the blob store. Only omp's own loader resolves
|
|
134
|
+
* them, so a reloaded chamber session used to render
|
|
135
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
136
|
+
* string, which the browser cannot decode. The ref itself is dropped once
|
|
137
|
+
* resolved; an unresolvable one leaves the attachment with no preview rather
|
|
138
|
+
* than a broken image.
|
|
139
|
+
*/
|
|
140
|
+
async function resolveImageBlobs(messages: ChatMessageData[]): Promise<void> {
|
|
141
|
+
const pending: Promise<void>[] = [];
|
|
142
|
+
for (const message of messages) {
|
|
143
|
+
for (const attachment of message.attachments ?? []) {
|
|
144
|
+
// `blobRef` rides the attachment only until the bytes are read back; it is
|
|
145
|
+
// not part of the public attachment shape, so narrow before touching it.
|
|
146
|
+
if (!('blobRef' in attachment) || typeof attachment.blobRef !== 'string') continue;
|
|
147
|
+
const ref = attachment.blobRef;
|
|
148
|
+
delete attachment.blobRef;
|
|
149
|
+
pending.push(
|
|
150
|
+
readBlobRef(ref).then((bytes) => {
|
|
151
|
+
if (bytes) {
|
|
152
|
+
attachment.preview = `data:${attachment.type ?? 'image/png'};base64,${bytes.toString('base64')}`;
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await Promise.all(pending);
|
|
159
|
+
}
|
|
160
|
+
|
|
127
161
|
/** Derive the display title from the JSONL header/title slot (cheap read). */
|
|
128
162
|
export async function loadSessionTitle(filePath: string): Promise<string | undefined> {
|
|
129
163
|
try {
|