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,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
consumeAutoTitleOutput,
|
|
10
|
+
markTitleRequestSent,
|
|
11
|
+
triggerAutoSessionTitle,
|
|
12
|
+
type AutoTitleHost,
|
|
13
|
+
} from '@/server/lib/omp/session/auto-title.server';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The safety property of auto-titling, and the one thing that must never
|
|
17
|
+
* regress: the chamber fires omp's own `/rename` (which persists through
|
|
18
|
+
* `setSessionName(title, "user")`) in the background, and that write has no
|
|
19
|
+
* "don't overwrite" guard of its own — omp only refuses an `"auto"` write over
|
|
20
|
+
* a `"user"` title. So the decision to skip a NAMED session is ours, and a bug
|
|
21
|
+
* here silently destroys a name the operator typed.
|
|
22
|
+
*
|
|
23
|
+
* These tests drive the guard directly: a fake `get_state` reports a name, and
|
|
24
|
+
* the assertion is that no `/rename` reaches the wire.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface FakeProc {
|
|
28
|
+
commands: Array<Record<string, unknown>>;
|
|
29
|
+
sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeHost(options: {
|
|
33
|
+
sessionName?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
inFlight?: boolean;
|
|
36
|
+
}): { host: AutoTitleHost; proc: FakeProc } {
|
|
37
|
+
const proc: FakeProc = {
|
|
38
|
+
commands: [],
|
|
39
|
+
async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
|
|
40
|
+
proc.commands.push(command);
|
|
41
|
+
if (command.type === 'get_state') {
|
|
42
|
+
return {
|
|
43
|
+
sessionId: options.sessionId ?? 'sess-1',
|
|
44
|
+
sessionName: options.sessionName,
|
|
45
|
+
} as T;
|
|
46
|
+
}
|
|
47
|
+
return undefined as T;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
host: {
|
|
52
|
+
sessionId: 'sess-1',
|
|
53
|
+
autoTitleInFlight: options.inFlight ?? false,
|
|
54
|
+
autoTitleWindowUntil: 0,
|
|
55
|
+
proc: proc as unknown as AutoTitleHost['proc'],
|
|
56
|
+
},
|
|
57
|
+
proc,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Commands other than the `get_state` probe the guard always performs. */
|
|
62
|
+
function sentRenames(proc: FakeProc): Array<Record<string, unknown>> {
|
|
63
|
+
return proc.commands.filter((command) => command.type === 'prompt');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('triggerAutoSessionTitle', () => {
|
|
67
|
+
test('names an unnamed session through omp own /rename', async () => {
|
|
68
|
+
const { host, proc } = makeHost({});
|
|
69
|
+
await triggerAutoSessionTitle(host);
|
|
70
|
+
|
|
71
|
+
const renames = sentRenames(proc);
|
|
72
|
+
expect(renames).toHaveLength(1);
|
|
73
|
+
expect(renames[0].message).toBe('/rename');
|
|
74
|
+
// The request window opens only after the command is accepted, so the
|
|
75
|
+
// diagnostics omp is about to emit can be attributed to us.
|
|
76
|
+
expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('never overwrites a name the operator set', async () => {
|
|
80
|
+
const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
|
|
81
|
+
await triggerAutoSessionTitle(host);
|
|
82
|
+
|
|
83
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
84
|
+
// No request was sent, so nothing may claim the output window either.
|
|
85
|
+
expect(host.autoTitleWindowUntil).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('treats a whitespace-only name as unnamed', async () => {
|
|
89
|
+
const { host, proc } = makeHost({ sessionName: ' ' });
|
|
90
|
+
await triggerAutoSessionTitle(host);
|
|
91
|
+
|
|
92
|
+
expect(sentRenames(proc)).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('skips when the session changed under the read', async () => {
|
|
96
|
+
// `get_state` answered for a different conversation (a reset or switch
|
|
97
|
+
// between the trigger and the read) — titling this session would name the
|
|
98
|
+
// wrong one.
|
|
99
|
+
const { host, proc } = makeHost({ sessionId: 'someone-else' });
|
|
100
|
+
await triggerAutoSessionTitle(host);
|
|
101
|
+
|
|
102
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('does not stack a second request while one is in flight', async () => {
|
|
106
|
+
const { host, proc } = makeHost({ inFlight: true });
|
|
107
|
+
await triggerAutoSessionTitle(host);
|
|
108
|
+
|
|
109
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
110
|
+
expect(proc.commands).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('auto-title output window', () => {
|
|
115
|
+
test('claims exactly one frame per request', () => {
|
|
116
|
+
const { host } = makeHost({});
|
|
117
|
+
markTitleRequestSent(host);
|
|
118
|
+
|
|
119
|
+
expect(consumeAutoTitleOutput(host)).toBe(true);
|
|
120
|
+
// omp emits a single command_output per /rename; a second frame inside the
|
|
121
|
+
// window belongs to something the operator typed and must reach the UI.
|
|
122
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('claims nothing when no request is outstanding', () => {
|
|
126
|
+
const { host } = makeHost({});
|
|
127
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('expires so a stale window cannot swallow an operator command', () => {
|
|
131
|
+
const { host } = makeHost({});
|
|
132
|
+
markTitleRequestSent(host);
|
|
133
|
+
host.autoTitleWindowUntil = Date.now() - 1;
|
|
134
|
+
|
|
135
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto session titles for OMPChamber.
|
|
8
|
+
*
|
|
9
|
+
* Chamber spawns omp with `--mode rpc-ui`, and that mode force-sets
|
|
10
|
+
* `PI_NO_TITLE=1` (omp's main.ts). The flag gates only the two AUTOMATIC title
|
|
11
|
+
* paths — `AgentSession.maybeStartTitleGeneration` (first user message) and
|
|
12
|
+
* `#scheduleReplanTitleRefresh` (todo replan) — so a chamber session is never
|
|
13
|
+
* named on its own and the sidebar keeps the `New Session - <timestamp>`
|
|
14
|
+
* placeholder for its whole life.
|
|
15
|
+
*
|
|
16
|
+
* The generate path itself is NOT gated: `/rename` with no args reaches
|
|
17
|
+
* `session.generateTitle()` through `executeAcpBuiltinSlashCommand`, which
|
|
18
|
+
* rpc-mode dispatches for a `prompt` frame before any turn starts. Verified
|
|
19
|
+
* against omp 18.2.10 — the ack returns `{agentInvoked:false}` immediately, the
|
|
20
|
+
* generation lands ~4s later as `session_info_update` + `command_output`, and
|
|
21
|
+
* no entry is written to the transcript.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE NAME MUST BE EMPTY FIRST: `/rename` persists through
|
|
24
|
+
* `setSessionName(title, "user")`. omp's own guard only refuses an `"auto"`
|
|
25
|
+
* write over a `"user"` title, so a `/rename` fired against an already-named
|
|
26
|
+
* session would overwrite the operator's own name. The unnamed check below is
|
|
27
|
+
* therefore the safety property, not a nicety — it is re-read from `get_state`
|
|
28
|
+
* on every attempt rather than cached, so a rename made in another tab is
|
|
29
|
+
* respected.
|
|
30
|
+
*
|
|
31
|
+
* Firing after every settled run (not just the first) mirrors omp: a session
|
|
32
|
+
* whose opening message was a bare greeting stays unnamed, and the next real
|
|
33
|
+
* message titles it. The low-signal filter on omp's side returns no title for
|
|
34
|
+
* such input, so the retry costs one tiny-model call and nothing else.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readSettingsJson } from '@/server/lib/db/settings-store';
|
|
38
|
+
import { getDb } from '@/server/db.server';
|
|
39
|
+
import { GET_STATE_TIMEOUT_MS, PROMPT_ACK_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
40
|
+
|
|
41
|
+
/** Chamber settings blob key; mirrors `streamTransport` and friends. */
|
|
42
|
+
const CHAMBER_SETTINGS_KEY = 'omp_chamber_settings';
|
|
43
|
+
|
|
44
|
+
/** Auto-titling is opt-in per chamber install. Default ON: an unnamed session
|
|
45
|
+
* renders as `New Session - <timestamp>` forever, which is the visible defect,
|
|
46
|
+
* and the cost is one tiny-model call per session. */
|
|
47
|
+
const AUTO_SESSION_TITLE_DEFAULT = true;
|
|
48
|
+
|
|
49
|
+
/** omp's builtin that generates a title from the conversation. Sent with no
|
|
50
|
+
* args so omp derives it through its own `generateRenameTitle`. */
|
|
51
|
+
const GENERATE_TITLE_COMMAND = '/rename';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long the chamber's own `/rename` may keep emitting its diagnostic frames.
|
|
55
|
+
*
|
|
56
|
+
* The command answers on the prompt path with an immediate ack and then streams
|
|
57
|
+
* `session_info_update` + `command_output` once the tiny model returns
|
|
58
|
+
* (measured ~4s). Both carry NO correlation id, so the only way to attribute
|
|
59
|
+
* them to our background request is this window. It is deliberately generous —
|
|
60
|
+
* a slow provider must not leak the message — because the cost of over-holding
|
|
61
|
+
* it is only that a slash command typed in the same few seconds loses its
|
|
62
|
+
* output row.
|
|
63
|
+
*/
|
|
64
|
+
const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/** Wrapper state this module reads and writes. The in-flight flag and the
|
|
67
|
+
* output window are owned by the caller because they are per-session runtime
|
|
68
|
+
* state, not policy. */
|
|
69
|
+
export interface AutoTitleHost {
|
|
70
|
+
sessionId: string;
|
|
71
|
+
autoTitleInFlight: boolean;
|
|
72
|
+
/** Epoch ms until which frames belong to our own background rename; 0 when
|
|
73
|
+
* no request is outstanding. */
|
|
74
|
+
autoTitleWindowUntil: number;
|
|
75
|
+
proc: {
|
|
76
|
+
sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Claim the frames our own `/rename` is about to emit, so the wrapper can keep
|
|
82
|
+
* its diagnostics out of the timeline. Called once the request is accepted.
|
|
83
|
+
*/
|
|
84
|
+
export function markTitleRequestSent(host: AutoTitleHost): void {
|
|
85
|
+
host.autoTitleWindowUntil = Date.now() + AUTO_TITLE_OUTPUT_WINDOW_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when this frame is the tail of our own background rename, consuming the
|
|
90
|
+
* window. One frame per request: omp emits exactly one `command_output` for it,
|
|
91
|
+
* on both the success and the "could not generate" path.
|
|
92
|
+
*/
|
|
93
|
+
export function consumeAutoTitleOutput(host: AutoTitleHost): boolean {
|
|
94
|
+
if (host.autoTitleWindowUntil === 0 || Date.now() > host.autoTitleWindowUntil) return false;
|
|
95
|
+
host.autoTitleWindowUntil = 0;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the `autoSessionTitle` flag from the chamber settings blob. Absent or
|
|
101
|
+
* malformed → the default. Never throws: a settings read must not be able to
|
|
102
|
+
* break a turn's settle path.
|
|
103
|
+
*/
|
|
104
|
+
async function isAutoTitleEnabled(): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const db = await getDb();
|
|
107
|
+
const blob = await readSettingsJson<Record<string, unknown> | null>(db, CHAMBER_SETTINGS_KEY, null);
|
|
108
|
+
const value = blob?.autoSessionTitle;
|
|
109
|
+
return typeof value === 'boolean' ? value : AUTO_SESSION_TITLE_DEFAULT;
|
|
110
|
+
} catch {
|
|
111
|
+
return AUTO_SESSION_TITLE_DEFAULT;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate and persist a session title when the session has none yet.
|
|
117
|
+
*
|
|
118
|
+
* Called once per settled run. Every bail-out is deliberate: a named session
|
|
119
|
+
* must never be renamed by this path, and a generation already in flight must
|
|
120
|
+
* not be duplicated. Failures are swallowed — an auto-title is a convenience,
|
|
121
|
+
* and the next turn retries.
|
|
122
|
+
*/
|
|
123
|
+
export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void> {
|
|
124
|
+
if (host.autoTitleInFlight || !host.sessionId) return;
|
|
125
|
+
if (!(await isAutoTitleEnabled())) return;
|
|
126
|
+
|
|
127
|
+
host.autoTitleInFlight = true;
|
|
128
|
+
try {
|
|
129
|
+
// Re-read the name from omp rather than trusting a cached value: a rename
|
|
130
|
+
// made in another tab (or by omp's own TUI on the same session file) would
|
|
131
|
+
// otherwise be overwritten by the `"user"` write `/rename` performs.
|
|
132
|
+
const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
|
|
133
|
+
if (state.sessionName?.trim()) return;
|
|
134
|
+
// The session changed under us (a reset, or a `switch_session`): the name
|
|
135
|
+
// we just read belongs to a different conversation.
|
|
136
|
+
if (state.sessionId !== host.sessionId) return;
|
|
137
|
+
|
|
138
|
+
// Direct `proc` write, NOT the chamber's prompt dispatch: this is
|
|
139
|
+
// background work, so it must not mark the session streaming or claim the
|
|
140
|
+
// sidebar's run row. omp's ack is immediate (`agentInvoked:false`); the
|
|
141
|
+
// generation itself streams back later as ordinary frames.
|
|
142
|
+
await host.proc.sendCommand({ type: 'prompt', message: GENERATE_TITLE_COMMAND }, PROMPT_ACK_TIMEOUT_MS);
|
|
143
|
+
// Accepted — the diagnostics omp is about to emit belong to this request,
|
|
144
|
+
// not to anything the operator typed.
|
|
145
|
+
markTitleRequestSent(host);
|
|
146
|
+
} catch {
|
|
147
|
+
// Provider error, timeout, or a child that went away — leave the session
|
|
148
|
+
// unnamed; the next settled run tries again.
|
|
149
|
+
} finally {
|
|
150
|
+
host.autoTitleInFlight = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Blob-reference resolution for persisted session images.
|
|
8
|
+
*
|
|
9
|
+
* omp externalizes a large attached image out of the session JSONL: the entry
|
|
10
|
+
* keeps a content-addressed ref (`blob:sha256:<hash>`) and the bytes live in the
|
|
11
|
+
* blob store. Nothing else resolves them, so a reloaded session used to render
|
|
12
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
13
|
+
* string, which the browser cannot decode.
|
|
14
|
+
*
|
|
15
|
+
* The ref suffix feeds a `path.join` against the blob directory, so the
|
|
16
|
+
* canonical-hash guard is the security boundary and is tested as such.
|
|
17
|
+
*
|
|
18
|
+
* The agent dir is read per call (not at import time), so pointing
|
|
19
|
+
* `PI_CODING_AGENT_DIR` at a temp directory is enough to redirect the store.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import * as fsp from 'node:fs/promises';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { getBlobStoreDir, isBlobRef, readBlob, readBlobRef } from '@/server/lib/omp/session/blobs.server';
|
|
27
|
+
import { extractUserImageAttachments, isBlobImageRef } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
28
|
+
|
|
29
|
+
/** A minimal but structurally valid PNG, so the assertion is about real bytes. */
|
|
30
|
+
const PNG_BYTES = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
31
|
+
const HASH = 'a'.repeat(64);
|
|
32
|
+
|
|
33
|
+
let agentDir = '';
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'omp-blob-test-'));
|
|
37
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
38
|
+
await fsp.mkdir(getBlobStoreDir(), { recursive: true });
|
|
39
|
+
await fsp.writeFile(path.join(getBlobStoreDir(), HASH), PNG_BYTES);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
44
|
+
await fsp.rm(agentDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('blob store reads', () => {
|
|
48
|
+
test('reads the bytes a hash points at', async () => {
|
|
49
|
+
expect(await readBlob(HASH)).toEqual(PNG_BYTES);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('reads through a blob reference', async () => {
|
|
53
|
+
expect(await readBlobRef(`blob:sha256:${HASH}`)).toEqual(PNG_BYTES);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a missing blob yields null instead of throwing', async () => {
|
|
57
|
+
expect(await readBlob('b'.repeat(64))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a non-reference string yields null', async () => {
|
|
61
|
+
expect(await readBlobRef('iVBORw0KGgo=')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The hash is interpolated into a path, so anything but a canonical digest
|
|
65
|
+
// must be refused before the filesystem is touched.
|
|
66
|
+
test('rejects refs that would escape the blob directory', async () => {
|
|
67
|
+
for (const bad of ['../../../../etc/passwd', '..%2f..%2fetc%2fpasswd', `${'a'.repeat(63)}/`, 'A'.repeat(64), 'abc', '']) {
|
|
68
|
+
expect(await readBlob(bad)).toBeNull();
|
|
69
|
+
expect(await readBlobRef(`blob:sha256:${bad}`)).toBeNull();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('recognises the reference prefix', () => {
|
|
74
|
+
expect(isBlobRef(`blob:sha256:${HASH}`)).toBe(true);
|
|
75
|
+
expect(isBlobRef('data:image/png;base64,AAAA')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('image block extraction', () => {
|
|
80
|
+
test('an externalized block carries the ref instead of a fake data URL', () => {
|
|
81
|
+
const ref = `blob:sha256:${HASH}`;
|
|
82
|
+
const [attachment] = extractUserImageAttachments([{ type: 'image', data: ref, mimeType: 'image/png' }]);
|
|
83
|
+
expect(attachment.blobRef).toBe(ref);
|
|
84
|
+
// The bug: `data:image/png;base64,blob:sha256:…` is not decodable.
|
|
85
|
+
expect(attachment.preview).toBe('');
|
|
86
|
+
expect(attachment.preview).not.toContain('base64,blob:');
|
|
87
|
+
expect(attachment.type).toBe('image/png');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an inlined block still becomes a data URL', () => {
|
|
91
|
+
const [attachment] = extractUserImageAttachments([{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]);
|
|
92
|
+
expect(attachment.preview).toBe('data:image/png;base64,iVBORw0KGgo=');
|
|
93
|
+
expect(attachment.blobRef).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('distinguishes the two payload shapes', () => {
|
|
97
|
+
expect(isBlobImageRef(`blob:sha256:${HASH}`)).toBe(true);
|
|
98
|
+
expect(isBlobImageRef('iVBORw0KGgo=')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Blob reference resolution for persisted session images.
|
|
8
|
+
*
|
|
9
|
+
* omp keeps an image's bytes out of the session JSONL once they are large: the
|
|
10
|
+
* entry stores a content-addressed reference (`blob:sha256:<hash>`) and the raw
|
|
11
|
+
* bytes live in the blob store (`$OMP_AGENT_DIR/blobs/<hash>`, an XDG-aware
|
|
12
|
+
* path — see `getBlobStoreDir`). Verified against omp 18.2.10:
|
|
13
|
+
* `pi-coding-agent/src/session/blob-store.ts` is the producer, and only omp's
|
|
14
|
+
* own loader (`session-loader.ts:resolveBlobRefs`) resolves the refs back on
|
|
15
|
+
* read.
|
|
16
|
+
*
|
|
17
|
+
* Nothing in the chamber did that, so a reloaded session rendered
|
|
18
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
19
|
+
* string, which no browser can decode. The timeline showed a broken image and
|
|
20
|
+
* opening it dumped the ref as text.
|
|
21
|
+
*
|
|
22
|
+
* Resolution is confined to the blob store: the suffix must be a canonical
|
|
23
|
+
* 64-char lowercase hex digest, so a crafted ref cannot escape the directory
|
|
24
|
+
* (the same guard omp applies in `parseBlobRef`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fsp from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { getAgentDir } from '@/server/lib/omp/core/paths';
|
|
31
|
+
|
|
32
|
+
const BLOB_PREFIX = 'blob:sha256:';
|
|
33
|
+
|
|
34
|
+
/** Canonical blob hash shape; anything else is rejected before touching the fs. */
|
|
35
|
+
const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
|
|
36
|
+
|
|
37
|
+
/** True when `value` is a content-addressed blob reference. */
|
|
38
|
+
export function isBlobRef(value: string): boolean {
|
|
39
|
+
return value.startsWith(BLOB_PREFIX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The blob store directory. Mirrors omp's `getBlobsDir()`: `<agentDir>/blobs`,
|
|
44
|
+
* relocated to `$XDG_DATA_HOME/omp/blobs` when omp is running the XDG layout
|
|
45
|
+
* (which drops the `agent/` prefix). The existing-directory probe decides which
|
|
46
|
+
* layout is live, because omp's own switch is opt-in and file-based.
|
|
47
|
+
*/
|
|
48
|
+
export function getBlobStoreDir(): string {
|
|
49
|
+
const xdgDataHome = Bun.env.XDG_DATA_HOME;
|
|
50
|
+
if (xdgDataHome) {
|
|
51
|
+
const xdgPath = path.join(xdgDataHome, 'omp', 'blobs');
|
|
52
|
+
if (existsSync(xdgPath)) return xdgPath;
|
|
53
|
+
}
|
|
54
|
+
return path.join(getAgentDir(), 'blobs');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a blob's raw bytes, or null when the ref is malformed or the blob is
|
|
59
|
+
* gone (a pruned store, a session copied between machines). A missing blob is
|
|
60
|
+
* not an error the caller can act on — the caller falls back to showing the
|
|
61
|
+
* attachment without a preview.
|
|
62
|
+
*/
|
|
63
|
+
export async function readBlob(hash: string): Promise<Buffer | null> {
|
|
64
|
+
if (!BLOB_HASH_RE.test(hash)) return null;
|
|
65
|
+
try {
|
|
66
|
+
return await fsp.readFile(path.join(getBlobStoreDir(), hash));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the bytes behind a `blob:sha256:<hash>` reference, or null. */
|
|
73
|
+
export async function readBlobRef(ref: string): Promise<Buffer | null> {
|
|
74
|
+
if (!isBlobRef(ref)) return null;
|
|
75
|
+
return readBlob(ref.slice(BLOB_PREFIX.length));
|
|
76
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bounded readers for one oh-my-pi session file: a fixed-size head window and
|
|
8
|
+
* a tail window that dates the session's real last activity. Split out of
|
|
9
|
+
* ./files.ts so the scanner there stays under the file-size ceiling; both
|
|
10
|
+
* windows exist so a session file is never loaded whole into memory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { extractStringProperty } from '@/shared/lib/omp/session/jsonl';
|
|
14
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
15
|
+
|
|
16
|
+
export const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tail window for the last-activity timestamp. The distance from EOF to the
|
|
20
|
+
* newest timestamped line measured at most ~4 KiB across every session on this
|
|
21
|
+
* machine, so 16 KiB carries 4x headroom over the largest final entry seen.
|
|
22
|
+
*/
|
|
23
|
+
const SESSION_TAIL_BYTES = 16 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ceiling for the escalating tail read. A session can legitimately end with an
|
|
27
|
+
* entry larger than the first window — a tool result carrying a big file read
|
|
28
|
+
* or build log — and that entry is usually the newest timestamped line, so
|
|
29
|
+
* falling back to mtime there would reintroduce the bug this exists to fix.
|
|
30
|
+
* The window quadruples up to this ceiling, which is one read per step and
|
|
31
|
+
* only for a session whose final entry is genuinely that large.
|
|
32
|
+
*/
|
|
33
|
+
const SESSION_TAIL_MAX_BYTES = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Head window of a session file plus the stat it was read with. */
|
|
36
|
+
export interface SessionFilePrefix {
|
|
37
|
+
text: string;
|
|
38
|
+
size: number;
|
|
39
|
+
mtime: Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
|
|
43
|
+
const file = Bun.file(filePath);
|
|
44
|
+
const stat = await file.stat();
|
|
45
|
+
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
46
|
+
const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
47
|
+
return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* omp lifecycle markers: entries the agent writes about the SESSION PROCESS,
|
|
52
|
+
* not about the conversation. `session_exit` is emitted every time an omp
|
|
53
|
+
* child is disposed — an idle timeout, a chamber restart, a shutdown — and a
|
|
54
|
+
* session accumulates one per dispose (the file that prompted this rule holds
|
|
55
|
+
* three). Treating the newest one as last activity floats a session that has
|
|
56
|
+
* been quiet for days to the top of `LATEST_SESSION`: measured across a real
|
|
57
|
+
* agent dir, 255 of 319 sessions were ranked by a marker like that, the worst
|
|
58
|
+
* by ~14 days.
|
|
59
|
+
*
|
|
60
|
+
* Only markers KNOWN to be lifecycle are excluded. `tool_execution_start` is
|
|
61
|
+
* the commonest custom entry in the same files (16k occurrences) and IS
|
|
62
|
+
* activity — a run that ends mid-tool-call is still the newest one — so an
|
|
63
|
+
* unrecognised customType is counted rather than ignored.
|
|
64
|
+
*/
|
|
65
|
+
const LIFECYCLE_CUSTOM_TYPES: Record<string, true> = { session_exit: true };
|
|
66
|
+
|
|
67
|
+
/** A parsed entry timestamp, flagged when the entry is process bookkeeping. */
|
|
68
|
+
interface EntryStamp {
|
|
69
|
+
at: Date;
|
|
70
|
+
lifecycle: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Timestamp + lifecycle flag from one JSONL line, or undefined when the line
|
|
74
|
+
* carries no usable timestamp. */
|
|
75
|
+
function entryStampOfLine(line: string): EntryStamp | undefined {
|
|
76
|
+
let type: unknown;
|
|
77
|
+
let customType: unknown;
|
|
78
|
+
let value: unknown;
|
|
79
|
+
let parsedOk = false;
|
|
80
|
+
try {
|
|
81
|
+
const parsed: unknown = JSON.parse(line);
|
|
82
|
+
if (isRecord(parsed)) {
|
|
83
|
+
parsedOk = true;
|
|
84
|
+
type = parsed.type;
|
|
85
|
+
customType = parsed.customType;
|
|
86
|
+
value = parsed.timestamp;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Torn or window-truncated line — fall back to the raw-text scan.
|
|
90
|
+
}
|
|
91
|
+
if (!parsedOk) {
|
|
92
|
+
value = extractStringProperty(line, 'timestamp');
|
|
93
|
+
type = extractStringProperty(line, 'type');
|
|
94
|
+
customType = extractStringProperty(line, 'customType');
|
|
95
|
+
}
|
|
96
|
+
let at: Date | undefined;
|
|
97
|
+
if (typeof value === 'number' && Number.isFinite(value)) at = new Date(value);
|
|
98
|
+
else if (typeof value === 'string') {
|
|
99
|
+
const parsed = new Date(value);
|
|
100
|
+
if (!Number.isNaN(parsed.getTime())) at = parsed;
|
|
101
|
+
}
|
|
102
|
+
if (!at) return undefined;
|
|
103
|
+
const lifecycle = type === 'custom'
|
|
104
|
+
&& typeof customType === 'string'
|
|
105
|
+
&& LIFECYCLE_CUSTOM_TYPES[customType] === true;
|
|
106
|
+
return { at, lifecycle };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Timestamp of the newest entry in a session file, read from a bounded tail
|
|
111
|
+
* window — the session's real last activity.
|
|
112
|
+
*
|
|
113
|
+
* File mtime is NOT that value. A title-slot rename rewrites the first 256
|
|
114
|
+
* bytes in place and bumps mtime without a turn having happened; a full-file
|
|
115
|
+
* title rewrite on a slot-less legacy file does the same; and a session
|
|
116
|
+
* restored or copied from a backup carries an mtime unrelated to its content.
|
|
117
|
+
* Every one of those moved the sidebar's `LATEST_SESSION` order. The last
|
|
118
|
+
* entry's own `timestamp` is what "latest session" means, and it survives all
|
|
119
|
+
* three.
|
|
120
|
+
*
|
|
121
|
+
* Process bookkeeping does not count either — see LIFECYCLE_CUSTOM_TYPES.
|
|
122
|
+
*
|
|
123
|
+
* `prefix` is the already-read 4 KiB head; a file that fits inside it needs no
|
|
124
|
+
* second read — a third of the sessions on a real machine are that small.
|
|
125
|
+
*
|
|
126
|
+
* The window starts at SESSION_TAIL_BYTES and widens toward
|
|
127
|
+
* SESSION_TAIL_MAX_BYTES while it holds no usable entry, so a session ending
|
|
128
|
+
* on one oversized entry is still dated correctly. Only when even the ceiling
|
|
129
|
+
* holds nothing but lifecycle markers — an empty file, or a single entry
|
|
130
|
+
* larger than a megabyte — does this return undefined and let the caller fall
|
|
131
|
+
* back to mtime.
|
|
132
|
+
*/
|
|
133
|
+
export async function readLastEntryTimestamp(
|
|
134
|
+
filePath: string,
|
|
135
|
+
size: number,
|
|
136
|
+
prefix: string,
|
|
137
|
+
): Promise<Date | undefined> {
|
|
138
|
+
if (size <= 0) return undefined;
|
|
139
|
+
let windowBytes = SESSION_TAIL_BYTES;
|
|
140
|
+
for (;;) {
|
|
141
|
+
const start = Math.max(0, size - windowBytes);
|
|
142
|
+
let text: string;
|
|
143
|
+
if (size <= SESSION_LIST_PREFIX_BYTES) {
|
|
144
|
+
// The whole file is inside the prefix window already.
|
|
145
|
+
text = prefix;
|
|
146
|
+
} else if (start === 0) {
|
|
147
|
+
try {
|
|
148
|
+
text = await Bun.file(filePath).text();
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
try {
|
|
154
|
+
// Read one byte before the window: if it is a newline the window opens
|
|
155
|
+
// on a line boundary, otherwise the first line is a fragment. Either
|
|
156
|
+
// way the leading split element is not a complete entry.
|
|
157
|
+
text = await Bun.file(filePath).slice(start - 1).text();
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const lines = text.split('\n');
|
|
163
|
+
if (start > 0) lines.shift();
|
|
164
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
165
|
+
const line = lines[i].trim();
|
|
166
|
+
if (!line.includes('"timestamp"')) continue;
|
|
167
|
+
const stamp = entryStampOfLine(line);
|
|
168
|
+
if (stamp && !stamp.lifecycle) return stamp.at;
|
|
169
|
+
}
|
|
170
|
+
// The window is already the whole file, or it has reached its ceiling.
|
|
171
|
+
if (start === 0 || windowBytes >= SESSION_TAIL_MAX_BYTES) return undefined;
|
|
172
|
+
windowBytes = Math.min(windowBytes * 4, SESSION_TAIL_MAX_BYTES);
|
|
173
|
+
}
|
|
174
|
+
}
|