ompchamber 3.3.2 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +275 -268
- package/dist/client/static/js/{index-e3axkbk1.js → index-h05z90mw.js} +581 -581
- package/package.json +1 -1
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/server/lib/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- package/src/shared/types/btw.ts +13 -34
|
@@ -12,32 +12,32 @@
|
|
|
12
12
|
* cancel — a second question is refused, exactly like the TUI);
|
|
13
13
|
* - a new question is its own topic; only a follow-up continues one, and it
|
|
14
14
|
* sees only that topic's turns (plus the parent's context);
|
|
15
|
+
* - the side conversation runs on the CHAT's model and thinking selector, read
|
|
16
|
+
* from the parent transcript at every ask — the panel has no pickers of its
|
|
17
|
+
* own, exactly like omp's `/btw`, which runs the side turn on
|
|
18
|
+
* `request.session.model`;
|
|
15
19
|
* - promotion is refused once the parent transcript has moved past the point
|
|
16
|
-
* the topic was snapshotted at,
|
|
20
|
+
* the topic was snapshotted at, while a turn is still running, and for a
|
|
21
|
+
* topic with follow-ups (a branch carries one question/answer pair).
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import { isMockMode } from '@/server/mock.server';
|
|
20
25
|
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
21
26
|
import { readRawHeaderLine } from '@/server/lib/omp/session/files';
|
|
22
|
-
import { loadSessionModel } from '@/server/lib/omp/session/messages';
|
|
23
|
-
import { resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
|
|
24
|
-
import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
|
|
25
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
27
|
+
import { loadSessionModel, loadSessionThinkingSelector } from '@/server/lib/omp/session/messages';
|
|
28
|
+
import { getRpcSession, resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
|
|
26
29
|
import { composeMessageWithTextAttachments, type AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
27
30
|
import {
|
|
28
31
|
createBtwTopic,
|
|
29
32
|
deleteBtwTopic,
|
|
30
33
|
getBtwTopic,
|
|
31
34
|
readBtwTopicLeaf,
|
|
32
|
-
setBtwTopicApprovalMode,
|
|
33
|
-
setBtwTopicModel,
|
|
34
35
|
setBtwTopicPromoted,
|
|
35
|
-
setBtwTopicThinkingLevel,
|
|
36
36
|
} from '@/server/lib/btw/store.server';
|
|
37
37
|
import { promoteBtwTopic as materializePromotedTopic, readTranscriptLeaf, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
|
|
38
38
|
import { btwStateFor, ensureBtwRuntime, findRunningBtwRuntime, forgetBtwRuntime, getBtwRuntime, publishBtwState } from '@/server/lib/btw/registry.server';
|
|
39
39
|
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
40
|
-
import type {
|
|
40
|
+
import type { BtwState } from '@/shared/types';
|
|
41
41
|
|
|
42
42
|
/** Longest a topic label may be, matching the sidebar's session-title length. */
|
|
43
43
|
const TOPIC_TITLE_CHARS = 60;
|
|
@@ -93,15 +93,6 @@ export interface AskBtwInput {
|
|
|
93
93
|
* be asked about a file the same way a chat turn can.
|
|
94
94
|
*/
|
|
95
95
|
textFiles?: AttachedTextFileData[];
|
|
96
|
-
/**
|
|
97
|
-
* Composer picks for the FIRST question of a topic, which has no row to write
|
|
98
|
-
* them to yet. Later questions address an existing topic and are re-targeted
|
|
99
|
-
* through the dedicated `set_model` / `set_thinking_level` / `set_access_mode`
|
|
100
|
-
* actions instead.
|
|
101
|
-
*/
|
|
102
|
-
model?: BtwModel;
|
|
103
|
-
thinkingLevel?: string;
|
|
104
|
-
approvalMode?: ApprovalMode;
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
export async function askBtw(sessionId: string, input: AskBtwInput): Promise<BtwState> {
|
|
@@ -119,15 +110,18 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
|
|
|
119
110
|
throw new BtwError('Unknown side question.', 'btw_topic_not_found');
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
|
|
113
|
+
// The side conversation mirrors the chat: model and thinking selector are read
|
|
114
|
+
// from the parent transcript at every ask, so a change made in the chat reaches
|
|
115
|
+
// the next side question (omp reads `request.session.model` the same way).
|
|
116
|
+
const model = await topicModel(parent.sessionFile);
|
|
117
|
+
const thinkingLevel = await loadSessionThinkingSelector(parent.sessionFile);
|
|
123
118
|
const topic =
|
|
124
119
|
existing ??
|
|
125
120
|
(await createBtwTopic({
|
|
126
121
|
sessionId,
|
|
127
122
|
title: topicTitle(question),
|
|
128
|
-
model
|
|
129
|
-
thinkingLevel
|
|
130
|
-
approvalMode,
|
|
123
|
+
model,
|
|
124
|
+
thinkingLevel,
|
|
131
125
|
}));
|
|
132
126
|
|
|
133
127
|
const runtime = ensureBtwRuntime({
|
|
@@ -135,68 +129,17 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
|
|
|
135
129
|
sessionId,
|
|
136
130
|
parentSessionFile: parent.sessionFile,
|
|
137
131
|
cwd: parent.cwd,
|
|
138
|
-
model
|
|
139
|
-
thinkingLevel
|
|
140
|
-
// The topic's own mode wins once set; a fresh topic adopts the composer's.
|
|
141
|
-
approvalMode: topic.approvalMode ?? approvalMode,
|
|
132
|
+
model,
|
|
133
|
+
thinkingLevel,
|
|
142
134
|
});
|
|
143
135
|
|
|
136
|
+
// A live child keeps the model it was spawned with; re-target it before the
|
|
137
|
+
// prompt so this question runs on what the chat is on NOW.
|
|
138
|
+
await runtime.retarget(model, thinkingLevel);
|
|
144
139
|
await runtime.ask(composeMessageWithTextAttachments(question, input.textFiles ?? []), input.images);
|
|
145
140
|
return btwStateFor(sessionId);
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
/** One topic of this session, or a `btw_topic_not_found` refusal. */
|
|
149
|
-
async function requireTopic(sessionId: string, topicId: string) {
|
|
150
|
-
const topic = await getBtwTopic(topicId);
|
|
151
|
-
if (!topic || topic.sessionId !== sessionId) throw new BtwError('Unknown side question.', 'btw_topic_not_found');
|
|
152
|
-
return topic;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Re-target a topic's model. With a live child the change reaches it through
|
|
157
|
-
* `set_model` (omp applies it to the running turn, which is what the dropdown
|
|
158
|
-
* promises); an idle topic simply records it for the next spawn.
|
|
159
|
-
*/
|
|
160
|
-
export async function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
|
|
161
|
-
await requireTopic(sessionId, topicId);
|
|
162
|
-
const runtime = getBtwRuntime(topicId);
|
|
163
|
-
if (runtime) {
|
|
164
|
-
await runtime.setModel(provider, modelId);
|
|
165
|
-
} else {
|
|
166
|
-
await setBtwTopicModel(topicId, { provider, id: modelId });
|
|
167
|
-
await publishBtwState(sessionId);
|
|
168
|
-
}
|
|
169
|
-
return btwStateFor(sessionId);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
|
|
173
|
-
await requireTopic(sessionId, topicId);
|
|
174
|
-
await setBtwTopicThinkingLevel(topicId, level === 'auto' ? null : level);
|
|
175
|
-
await getBtwRuntime(topicId)?.setThinkingLevel(level);
|
|
176
|
-
await publishBtwState(sessionId);
|
|
177
|
-
return btwStateFor(sessionId);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Adopt a new tool-approval mode for a topic. omp has no RPC for it, so an idle
|
|
182
|
-
* child is dropped and the next question respawns with the new flag; a running
|
|
183
|
-
* turn keeps its child (and therefore its mode) until it settles.
|
|
184
|
-
*/
|
|
185
|
-
export async function setBtwApprovalMode(sessionId: string, topicId: string, mode: ApprovalMode): Promise<BtwState> {
|
|
186
|
-
await requireTopic(sessionId, topicId);
|
|
187
|
-
await setBtwTopicApprovalMode(topicId, mode);
|
|
188
|
-
await getBtwRuntime(topicId)?.setApprovalMode(mode);
|
|
189
|
-
await publishBtwState(sessionId);
|
|
190
|
-
return btwStateFor(sessionId);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Answer an ask/approval dialog a side child is blocked on. */
|
|
194
|
-
export async function respondBtwDialog(sessionId: string, topicId: string, id: string, response: Record<string, unknown>): Promise<BtwState> {
|
|
195
|
-
await requireTopic(sessionId, topicId);
|
|
196
|
-
getBtwRuntime(topicId)?.respondToDialog(id, response);
|
|
197
|
-
return btwStateFor(sessionId);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
143
|
/** The parent's model, so the side conversation runs on the visible model. */
|
|
201
144
|
async function topicModel(sessionFile: string) {
|
|
202
145
|
const model = await loadSessionModel(sessionFile);
|
|
@@ -210,6 +153,19 @@ export async function abortBtw(sessionId: string, topicId: string): Promise<BtwS
|
|
|
210
153
|
return btwStateFor(sessionId);
|
|
211
154
|
}
|
|
212
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Refuse a session operation that would move the conversation out from under a
|
|
158
|
+
* running side question. omp blocks the same operations while a `/btw` answer is
|
|
159
|
+
* in flight or unsaved (`BtwController.withSessionMove` / `flush`); a chamber
|
|
160
|
+
* chat that switched sessions mid-question would leave the answer attached to a
|
|
161
|
+
* transcript nobody is looking at.
|
|
162
|
+
*/
|
|
163
|
+
export async function assertBtwIdle(sessionId: string, action: string): Promise<void> {
|
|
164
|
+
if (findRunningBtwRuntime(sessionId)) {
|
|
165
|
+
throw new BtwError(`A side question is still running — wait for it, or cancel it, before you ${action}.`, 'btw_busy');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
213
169
|
/**
|
|
214
170
|
* Turn the topic into a session of the chat's own: a branch of the parent
|
|
215
171
|
* conversation whose tip is the side answer, opened by the caller.
|
|
@@ -225,8 +181,21 @@ export async function promoteBtw(sessionId: string, topicId: string): Promise<{
|
|
|
225
181
|
if (topic.turns.some((turn) => turn.status === 'running')) {
|
|
226
182
|
throw new BtwError('Wait for the side answer to finish, or cancel it, before promoting it.', 'btw_busy');
|
|
227
183
|
}
|
|
184
|
+
// omp's branch action carries ONE question/answer pair and refuses a topic with
|
|
185
|
+
// follow-ups, so the earlier turns cannot be silently dropped from the branch.
|
|
186
|
+
if (topic.turns.length > 1) {
|
|
187
|
+
throw new BtwError(
|
|
188
|
+
'A side question with follow-ups cannot be promoted — omp branches one answer at a time.',
|
|
189
|
+
'btw_multi_turn',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
228
192
|
|
|
229
193
|
const parent = await resolveParent(sessionId);
|
|
194
|
+
// The branch is cut from the parent's transcript, so the chat must be idle:
|
|
195
|
+
// omp's guard refuses while `session.isStreaming`.
|
|
196
|
+
if (getRpcSession(sessionId)?.isRunning()) {
|
|
197
|
+
throw new BtwError('The chat is still running — wait for it to finish before promoting a side question.', 'btw_busy');
|
|
198
|
+
}
|
|
230
199
|
const snapshotLeaf = await readBtwTopicLeaf(topicId);
|
|
231
200
|
if (snapshotLeaf) {
|
|
232
201
|
const currentLeaf = await readTranscriptLeaf(parent.sessionFile);
|
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
* `leaf_id` is the parent session's transcript leaf when the topic started.
|
|
16
16
|
* Promotion refuses when the parent has moved on, exactly like omp's own
|
|
17
17
|
* `/btw` branch guard ("session changed since /btw started").
|
|
18
|
+
*
|
|
19
|
+
* `thinking_level` holds the chat's thinking SELECTOR (`auto` included), not the
|
|
20
|
+
* effort the side child resolved; `approval_mode` is no longer written — a side
|
|
21
|
+
* child runs `--no-tools`, so there is no approval gate to configure. Both
|
|
22
|
+
* columns stay in the schema for existing rows.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { getDb } from '@/server/db.server';
|
|
21
|
-
import { isApprovalMode, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
26
|
import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus, ChatMessageData } from '@/shared/types';
|
|
23
27
|
|
|
24
28
|
interface TopicRow {
|
|
@@ -51,7 +55,7 @@ const TURN_STATUSES: Record<string, true> = {
|
|
|
51
55
|
running: true,
|
|
52
56
|
complete: true,
|
|
53
57
|
cancelled: true,
|
|
54
|
-
|
|
58
|
+
error: true,
|
|
55
59
|
interrupted: true,
|
|
56
60
|
};
|
|
57
61
|
|
|
@@ -72,7 +76,6 @@ function rowToTopic(row: TopicRow): BtwTopic {
|
|
|
72
76
|
updatedAt: row.updated_at,
|
|
73
77
|
...(model ? { model } : {}),
|
|
74
78
|
...(row.thinking_level ? { thinkingLevel: row.thinking_level } : {}),
|
|
75
|
-
...(isApprovalMode(row.approval_mode) ? { approvalMode: row.approval_mode } : {}),
|
|
76
79
|
...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
|
|
77
80
|
turns: [],
|
|
78
81
|
};
|
|
@@ -138,10 +141,8 @@ export interface NewBtwTopicInput {
|
|
|
138
141
|
sessionId: string;
|
|
139
142
|
title: string;
|
|
140
143
|
model?: BtwModel;
|
|
144
|
+
/** The chat's thinking selector, so the side child classifies like the chat. */
|
|
141
145
|
thinkingLevel?: string;
|
|
142
|
-
approvalMode?: ApprovalMode;
|
|
143
|
-
/** Parent transcript leaf when the topic was created (promotion guard). */
|
|
144
|
-
leafId?: string | null;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic> {
|
|
@@ -155,8 +156,8 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
|
|
|
155
156
|
model_id: input.model?.id ?? null,
|
|
156
157
|
model_name: input.model?.name ?? null,
|
|
157
158
|
thinking_level: input.thinkingLevel ?? null,
|
|
158
|
-
approval_mode:
|
|
159
|
-
leaf_id:
|
|
159
|
+
approval_mode: null,
|
|
160
|
+
leaf_id: null,
|
|
160
161
|
promoted_session_id: null,
|
|
161
162
|
created_at: now,
|
|
162
163
|
updated_at: now,
|
|
@@ -262,18 +263,6 @@ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promis
|
|
|
262
263
|
]);
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
/** Record the thinking level the side child runs at (`null` clears it). */
|
|
266
|
-
export async function setBtwTopicThinkingLevel(topicId: string, level: string | null): Promise<void> {
|
|
267
|
-
const db = await getDb();
|
|
268
|
-
await db.run('UPDATE btw_topics SET thinking_level = ?, updated_at = ? WHERE id = ?', [level, Date.now(), topicId]);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Record the approval mode the side child was spawned with. */
|
|
272
|
-
export async function setBtwTopicApprovalMode(topicId: string, mode: ApprovalMode): Promise<void> {
|
|
273
|
-
const db = await getDb();
|
|
274
|
-
await db.run('UPDATE btw_topics SET approval_mode = ?, updated_at = ? WHERE id = ?', [mode, Date.now(), topicId]);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
266
|
export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
|
|
278
267
|
const db = await getDb();
|
|
279
268
|
await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
import { join, relative } from 'path';
|
|
34
34
|
import type { BuildOutput, BunPlugin } from 'bun';
|
|
35
35
|
|
|
36
|
-
import {
|
|
36
|
+
import { packageDir } from '@/server/lib/assets/fonts.server';
|
|
37
37
|
|
|
38
|
-
/** The shell bundle's entrypoint, relative to the package
|
|
38
|
+
/** The shell bundle's entrypoint, relative to the package directory. */
|
|
39
39
|
const SHELL_ENTRYPOINT = 'index.html';
|
|
40
40
|
|
|
41
41
|
/** Bun's own config file, and the section naming the bundle's plugins. */
|
|
@@ -109,8 +109,13 @@ function formatLog(log: BundleLog, root: string): string {
|
|
|
109
109
|
/**
|
|
110
110
|
* The bundle errors behind a shell that would not render, or null when this
|
|
111
111
|
* build is not the reason (a clean bundle means the failure lies elsewhere).
|
|
112
|
+
*
|
|
113
|
+
* `root` is the PACKAGE, not `packageRoot()`: the entrypoint is `index.html`
|
|
114
|
+
* beside the source, and in a hoisted install the first root holding
|
|
115
|
+
* `node_modules` is the parent, where `index.html` does not exist — so the probe
|
|
116
|
+
* would report no failure at all and the real build error would never be shown.
|
|
112
117
|
*/
|
|
113
|
-
export async function describeShellBuildFailure(root =
|
|
118
|
+
export async function describeShellBuildFailure(root = packageDir()): Promise<string | null> {
|
|
114
119
|
const entrypoint = join(root, SHELL_ENTRYPOINT);
|
|
115
120
|
if (!(await Bun.file(entrypoint).exists())) return null;
|
|
116
121
|
|
|
@@ -30,18 +30,17 @@
|
|
|
30
30
|
* rewrite inside the bundle can work.
|
|
31
31
|
*
|
|
32
32
|
* The faces therefore leave the bundle. Each block is rewritten to
|
|
33
|
-
* `<FONT_ROUTE_PREFIX><basename>` and
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* that
|
|
37
|
-
* shell links it, so the layout rules stay bundled while the faces stay
|
|
33
|
+
* `<FONT_ROUTE_PREFIX><basename>` and dropped from the module, and the server
|
|
34
|
+
* re-declares them on `/fonts.css` from the SAME source stylesheets
|
|
35
|
+
* (`lib/assets/font-css.server.ts`, `lib/assets/font-faces.ts`). The shell
|
|
36
|
+
* links that route, so the layout rules stay bundled while the faces stay
|
|
38
37
|
* cacheable files.
|
|
39
38
|
*
|
|
40
|
-
*
|
|
41
|
-
* invoked by `serve.static` (verified:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
39
|
+
* The server derives the faces from source rather than from a directory this
|
|
40
|
+
* plugin wrote, because `onEnd` is NOT invoked by `serve.static` (verified:
|
|
41
|
+
* `onStart` and `onLoad` fire, `onEnd` never does) and a written artifact is
|
|
42
|
+
* produced exactly once per process — a `rm -rf` after boot, or a fresh clone
|
|
43
|
+
* that never built, would leave `/fonts.css` empty for good.
|
|
45
44
|
*
|
|
46
45
|
* 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
|
|
47
46
|
* AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
|
|
@@ -53,57 +52,14 @@
|
|
|
53
52
|
* the measurement; this one only has to run it.
|
|
54
53
|
*/
|
|
55
54
|
|
|
56
|
-
import {
|
|
55
|
+
import { dirname } from 'path';
|
|
57
56
|
import postcss from 'postcss';
|
|
58
57
|
import tailwind from '@tailwindcss/postcss';
|
|
59
58
|
import type { BunPlugin } from 'bun';
|
|
60
59
|
|
|
61
|
-
import {
|
|
62
|
-
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
60
|
+
import { splitFontFaces } from '@/server/lib/assets/font-faces';
|
|
63
61
|
import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
|
|
64
62
|
|
|
65
|
-
/** A `url(...)` whose target is a font file. */
|
|
66
|
-
const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
67
|
-
const FONT_FILE = /\.(?:woff2?|ttf|otf)$/i;
|
|
68
|
-
/** Schemes Bun leaves alone, and the prefix this plugin itself emits. */
|
|
69
|
-
const EXTERNAL = /^(?:data:|https?:|blob:|\/\/)/i;
|
|
70
|
-
|
|
71
|
-
/** One `@font-face` block, braces included. Faces cannot nest. */
|
|
72
|
-
const FONT_FACE = /@font-face\s*\{[^{}]*\}/g;
|
|
73
|
-
|
|
74
|
-
/** Absolute path of a local font url, or null when it is not one. */
|
|
75
|
-
function localFont(specifier: string, cssDir: string): string | null {
|
|
76
|
-
if (EXTERNAL.test(specifier) || specifier.startsWith(FONT_ROUTE_PREFIX)) return null;
|
|
77
|
-
if (!FONT_FILE.test(specifier)) return null;
|
|
78
|
-
return isAbsolute(specifier) ? specifier : resolvePath(cssDir, specifier);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Point every font `url()` at the served path. */
|
|
82
|
-
function rewriteFontUrls(css: string, cssDir: string): string {
|
|
83
|
-
return css.replace(URL_FUNCTION, (whole, quote: string, specifier: string) => {
|
|
84
|
-
const absolute = localFont(specifier, cssDir);
|
|
85
|
-
if (!absolute) return whole;
|
|
86
|
-
return `url(${quote}${FONT_ROUTE_PREFIX}${basename(absolute)}${quote})`;
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Split `@font-face` blocks out of a stylesheet.
|
|
92
|
-
*
|
|
93
|
-
* Only blocks whose `src` names a local font move: a face pointing at a remote
|
|
94
|
-
* url is valid CSS the bundle can keep.
|
|
95
|
-
*/
|
|
96
|
-
function splitFontFaces(css: string, cssDir: string): { rest: string; faces: string[] } {
|
|
97
|
-
const faces: string[] = [];
|
|
98
|
-
const rest = css.replace(FONT_FACE, (block) => {
|
|
99
|
-
const hasLocal = [...block.matchAll(URL_FUNCTION)].some(([, , spec]) => localFont(spec, cssDir));
|
|
100
|
-
if (!hasLocal) return block;
|
|
101
|
-
faces.push(rewriteFontUrls(block, cssDir));
|
|
102
|
-
return '';
|
|
103
|
-
});
|
|
104
|
-
return { rest, faces };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
63
|
const plugin: BunPlugin = {
|
|
108
64
|
name: 'ompchamber-css',
|
|
109
65
|
setup(build) {
|
|
@@ -117,12 +73,7 @@ const plugin: BunPlugin = {
|
|
|
117
73
|
const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
|
|
118
74
|
from: args.path,
|
|
119
75
|
});
|
|
120
|
-
const { rest
|
|
121
|
-
if (faces.length > 0) {
|
|
122
|
-
const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
|
|
123
|
-
const header = `/* GENERATED from ${args.path} — do not edit. */\n`;
|
|
124
|
-
await Bun.write(target, header + faces.join('\n') + '\n');
|
|
125
|
-
}
|
|
76
|
+
const { rest } = splitFontFaces(expanded.css, dirname(args.path));
|
|
126
77
|
return { contents: rest, loader: 'css' };
|
|
127
78
|
});
|
|
128
79
|
},
|
|
@@ -134,6 +134,16 @@ export const PASSTHROUGH_COMMANDS = new Set([
|
|
|
134
134
|
// whose liveness probe had just reported `running: false`).
|
|
135
135
|
export const OBSERVER_ONLY_COMMANDS = new Set(['get_subagents', 'get_subagent_messages']);
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Commands that MOVE the conversation: they replace the transcript the session
|
|
139
|
+
* is running on, so a side question that snapshotted the old one would be left
|
|
140
|
+
* describing a conversation the chat has already left. omp blocks the same
|
|
141
|
+
* operations while a `/btw` answer is in flight or unsaved
|
|
142
|
+
* (`BtwController.withSessionMove` / `flush`); the chamber refuses them with
|
|
143
|
+
* `btw_busy` (409) instead.
|
|
144
|
+
*/
|
|
145
|
+
export const CONVERSATION_MOVING_COMMANDS = new Set(['switch_session', 'branch', 'new_session', 'handoff']);
|
|
146
|
+
|
|
137
147
|
// Commands that can carry user-attached images to the model. All of them must
|
|
138
148
|
// pass the same server-side per-image/count/aggregate validation before the
|
|
139
149
|
// payload reaches omp — a client is free to POST any of them directly.
|
|
@@ -15,6 +15,7 @@ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
|
15
15
|
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
16
16
|
import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
17
17
|
import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
|
|
18
|
+
import { isTuiOnlySlashCommand, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
|
|
18
19
|
|
|
19
20
|
/** Runtime surface AgentSessionWrapper exposes to the command dispatcher. */
|
|
20
21
|
export interface SessionCommandHost extends WebStateHost {
|
|
@@ -50,6 +51,44 @@ async function settleCommandTimeout(host: SessionCommandHost): Promise<never> {
|
|
|
50
51
|
throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Refuse a prompt that invokes a command omp implements only in its TUI, and
|
|
56
|
+
* report it the way omp reports a real command result.
|
|
57
|
+
*
|
|
58
|
+
* This is the SERVER half of the guard in
|
|
59
|
+
* `client/hooks/chat/timeline/tui-only-guard.ts`. It exists because one prompt
|
|
60
|
+
* path never passes through the composer: the follow-up queue's auto-delivery
|
|
61
|
+
* calls `session.send({ type: 'prompt' })` directly from
|
|
62
|
+
* `lib/queue/delivery.server.ts`. A `/plan` queued before a run would otherwise
|
|
63
|
+
* be delivered to the model as literal text once the run ends.
|
|
64
|
+
*
|
|
65
|
+
* The refusal is framed as a real command result — a `command_output` notice
|
|
66
|
+
* plus, for a non-streaming prompt, the `agentInvoked:false` ack — so the
|
|
67
|
+
* client's existing fold renders the notice and settles the optimistic spinner
|
|
68
|
+
* with no new protocol.
|
|
69
|
+
*
|
|
70
|
+
* `streaming` is the caller's own knowledge of whether a turn is running: a
|
|
71
|
+
* steer of a refused command must not clear the flags of the turn it was aimed
|
|
72
|
+
* at (omp runs `session.steer()` with no slash handling at all, so a steer of
|
|
73
|
+
* `/plan` is exactly as meaningless as a fresh one).
|
|
74
|
+
*
|
|
75
|
+
* Returns true when the prompt was refused (the caller must not dispatch).
|
|
76
|
+
*/
|
|
77
|
+
function refuseTuiOnlyPrompt(host: SessionCommandHost, message: unknown, streaming: boolean): boolean {
|
|
78
|
+
if (typeof message !== 'string' || !isTuiOnlySlashCommand(message)) return false;
|
|
79
|
+
host.emit({ type: 'command_output', text: tuiOnlyCommandNotice(message) });
|
|
80
|
+
if (!streaming) {
|
|
81
|
+
host.promptRunning = false;
|
|
82
|
+
host.awaitingAgentStart = false;
|
|
83
|
+
host.awaitingAgentStartDeadline = 0;
|
|
84
|
+
host.emit({ type: 'prompt_result', agentInvoked: false });
|
|
85
|
+
// Nothing ran, so the queue may hold the next item — give it the same
|
|
86
|
+
// delivery window a real consumed builtin or a run end would.
|
|
87
|
+
scheduleQueueDelivery(host);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
export async function dispatchSessionCommand(host: SessionCommandHost, command: Record<string, unknown>): Promise<unknown> {
|
|
54
93
|
if (host.restarting) throw new WebRpcError(RESTARTING_MESSAGE, 'session_restarting');
|
|
55
94
|
if (!host.isAlive()) throw new Error('Session is no longer running');
|
|
@@ -66,6 +105,11 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
66
105
|
if (host.bashRunning) {
|
|
67
106
|
throw new Error('Cannot send a prompt while a shell command is running');
|
|
68
107
|
}
|
|
108
|
+
// Checked before the streaming-behavior split: a steer of a TUI-only
|
|
109
|
+
// command is just as meaningless as a fresh one. `streaming` keeps the
|
|
110
|
+
// refusal from clearing a running turn's flags when the prompt was a
|
|
111
|
+
// steer aimed at that turn.
|
|
112
|
+
if (refuseTuiOnlyPrompt(host, command.message, Boolean(command.streamingBehavior))) return null;
|
|
69
113
|
const streamingBehavior = command.streamingBehavior as 'steer' | 'followUp' | undefined;
|
|
70
114
|
// The live `stream` row starts with the DISPATCH, not with agent_start:
|
|
71
115
|
// the spawn/ack round trip that precedes agent_start must not read as
|
|
@@ -229,6 +273,7 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
229
273
|
if (!host.isRunning()) {
|
|
230
274
|
throw new WebRpcError('The session is idle — start a prompt first.', 'session_idle');
|
|
231
275
|
}
|
|
276
|
+
if (refuseTuiOnlyPrompt(host, command.message, true)) return null;
|
|
232
277
|
await host.proc.sendCommand({
|
|
233
278
|
type,
|
|
234
279
|
message: command.message as string,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Builtin-command output must survive a reload. omp writes NOTHING to the
|
|
6
|
+
* session JSONL for a `/context` or `/usage` turn — the output exists only in
|
|
7
|
+
* the live `command_output` frame — so the chamber's own copy is the only place
|
|
8
|
+
* it can be kept, and `mergeOmpAttachments` has to splice it back in.
|
|
9
|
+
*
|
|
10
|
+
* The anchor is the user turn that produced it, matched by CONTENT: the
|
|
11
|
+
* chamber's optimistic user row carries a `msg-…-user` id while the JSONL
|
|
12
|
+
* records omp's timestamp id, so an id match never fires.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
18
|
+
|
|
19
|
+
const CONTEXT_NOTICE = 'Context window: 1000000 tokens (1% used)';
|
|
20
|
+
|
|
21
|
+
/** What the JSONL yields for a turn whose command wrote no entry. */
|
|
22
|
+
function jsonlTurn(): StoredMessage[] {
|
|
23
|
+
return [
|
|
24
|
+
{ id: 'omp-user-1', role: 'user', content: '/context' },
|
|
25
|
+
{ id: 'omp-ai-1', role: 'ai', content: 'Done.' },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The chamber's own copy of that same conversation, plus the notice row. */
|
|
30
|
+
function storedWithNotice(): string {
|
|
31
|
+
const stored: StoredMessage[] = [
|
|
32
|
+
{ id: 'msg-1-user', role: 'user', content: '/context' },
|
|
33
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
|
|
34
|
+
{ id: 'omp-ai-1', role: 'ai', content: 'Done.' },
|
|
35
|
+
];
|
|
36
|
+
return JSON.stringify(stored);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('mergeOmpAttachments notice restoration', () => {
|
|
40
|
+
test('splices a stored notice back under its user turn', () => {
|
|
41
|
+
const merged = mergeOmpAttachments(jsonlTurn(), storedWithNotice());
|
|
42
|
+
|
|
43
|
+
expect(merged.map((m) => m.notice ?? m.content)).toEqual([
|
|
44
|
+
'/context',
|
|
45
|
+
CONTEXT_NOTICE,
|
|
46
|
+
'Done.',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('leaves a conversation with no stored notices untouched', () => {
|
|
51
|
+
const stored = JSON.stringify([{ id: 'msg-1-user', role: 'user', content: '/context' }]);
|
|
52
|
+
const merged = mergeOmpAttachments(jsonlTurn(), stored);
|
|
53
|
+
expect(merged.some((m) => m.notice !== undefined)).toBe(false);
|
|
54
|
+
expect(merged).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('does not duplicate a notice the JSONL itself carried', () => {
|
|
58
|
+
const messages: StoredMessage[] = [
|
|
59
|
+
{ id: 'omp-user-1', role: 'user', content: 'run it' },
|
|
60
|
+
{ id: 'omp-notice-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
|
|
61
|
+
];
|
|
62
|
+
const merged = mergeOmpAttachments(messages, storedWithNotice());
|
|
63
|
+
expect(merged.filter((m) => m.notice === CONTEXT_NOTICE)).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('appends an unanchorable notice instead of dropping it', () => {
|
|
67
|
+
// The stored user turn has no counterpart in the JSONL at all (omp rewrote
|
|
68
|
+
// or pruned it), so there is nothing to anchor to — the row must still show.
|
|
69
|
+
const stored = JSON.stringify([
|
|
70
|
+
{ id: 'msg-x', role: 'user', content: 'a turn the JSONL never recorded' },
|
|
71
|
+
{ id: 'cmdout-x', role: 'ai', content: '', notice: 'Orphan notice' },
|
|
72
|
+
]);
|
|
73
|
+
const merged = mergeOmpAttachments(jsonlTurn(), stored);
|
|
74
|
+
expect(merged.map((m) => m.notice).filter(Boolean)).toContain('Orphan notice');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('tolerates malformed stored JSON', () => {
|
|
78
|
+
const merged = mergeOmpAttachments(jsonlTurn(), '{not json');
|
|
79
|
+
expect(merged).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -42,6 +42,14 @@ export interface StoredMessage {
|
|
|
42
42
|
date?: string;
|
|
43
43
|
timestamp?: string;
|
|
44
44
|
attachments?: StoredAttachment[];
|
|
45
|
+
/**
|
|
46
|
+
* A notice row — omp's own reminder/task-result entries, or the chamber's
|
|
47
|
+
* rendering of a builtin command's output. The JSONL cannot round-trip
|
|
48
|
+
* command output at all: a `/context` or `/usage` turn writes no entry to the
|
|
49
|
+
* session file, so the notice exists ONLY in the live frame, and without this
|
|
50
|
+
* the row vanished on reload.
|
|
51
|
+
*/
|
|
52
|
+
notice?: string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
function isRawComposerInput(content: string): boolean {
|
|
@@ -158,7 +166,7 @@ export function mergeOmpAttachments(
|
|
|
158
166
|
byContent.set(m.content, m.attachments);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
|
-
|
|
169
|
+
const merged2 = merged.map((m) => {
|
|
162
170
|
if (m.role !== 'user' || m.attachments?.length) return m;
|
|
163
171
|
// The JSONL content may carry inlined text-file blocks appended to the
|
|
164
172
|
// original prompt, so match by prefix instead of exact equality.
|
|
@@ -168,4 +176,65 @@ export function mergeOmpAttachments(
|
|
|
168
176
|
)?.[1];
|
|
169
177
|
return atts ? { ...m, attachments: atts } : m;
|
|
170
178
|
});
|
|
179
|
+
return spliceStoredNotices(merged2, stored);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Re-attach notice rows the chamber stored but the JSONL cannot carry.
|
|
184
|
+
*
|
|
185
|
+
* A builtin slash command writes NOTHING to the session file — omp answers it
|
|
186
|
+
* on the command path and the output lives only in the `command_output` frame —
|
|
187
|
+
* so a `/context` or `/usage` row disappeared on reload, leaving a timeline that
|
|
188
|
+
* had forgotten a command the user just ran. The chamber's own copy keeps them.
|
|
189
|
+
*
|
|
190
|
+
* Placement is by the USER TURN that produced them, never by id: the chamber's
|
|
191
|
+
* optimistic user row carries a `msg-…-user` id while the JSONL records omp's
|
|
192
|
+
* own timestamp id, so the two never match. The stored list is walked backwards
|
|
193
|
+
* to the nearest user message, and that message is located in the merged list
|
|
194
|
+
* with the same content relation the attachment merge uses (omp rewrites the
|
|
195
|
+
* delivered prompt, so equality alone would miss it). A notice whose anchor
|
|
196
|
+
* cannot be resolved is appended at the end rather than dropped — losing it
|
|
197
|
+
* again is the bug being fixed.
|
|
198
|
+
*/
|
|
199
|
+
function spliceStoredNotices(merged: StoredMessage[], stored: StoredMessage[]): StoredMessage[] {
|
|
200
|
+
const storedNotices = stored
|
|
201
|
+
.map((message, index) => ({ message, index }))
|
|
202
|
+
.filter(({ message }) => message?.role !== 'user' && typeof message?.notice === 'string' && message.notice.trim());
|
|
203
|
+
if (storedNotices.length === 0) return merged;
|
|
204
|
+
|
|
205
|
+
// Already present (a notice the JSONL itself carried, e.g. a task result):
|
|
206
|
+
// matching on the text keeps the JSONL's own row authoritative.
|
|
207
|
+
const present = new Set(merged.map((m) => (typeof m.notice === 'string' ? m.notice : '')).filter(Boolean));
|
|
208
|
+
|
|
209
|
+
const mergedUsers = merged
|
|
210
|
+
.map((message, index) => ({ message, index }))
|
|
211
|
+
.filter(({ message }) => message.role === 'user');
|
|
212
|
+
|
|
213
|
+
const insertions = new Map<number, StoredMessage[]>();
|
|
214
|
+
for (const { message, index } of storedNotices) {
|
|
215
|
+
if (present.has(message.notice as string)) continue;
|
|
216
|
+
let anchor = merged.length;
|
|
217
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
218
|
+
const candidate = stored[i];
|
|
219
|
+
if (candidate?.role !== 'user' || typeof candidate.content !== 'string') continue;
|
|
220
|
+
const hit = mergedUsers.find(({ message: user }) =>
|
|
221
|
+
user.id !== undefined && candidate.id !== undefined && user.id === candidate.id,
|
|
222
|
+
) ?? mergedUsers.find(({ message: user }) => userTurnsRelate(user.content, candidate.content));
|
|
223
|
+
if (hit) {
|
|
224
|
+
anchor = hit.index + 1;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const group = insertions.get(anchor);
|
|
229
|
+
if (group) group.push(message);
|
|
230
|
+
else insertions.set(anchor, [message]);
|
|
231
|
+
}
|
|
232
|
+
if (insertions.size === 0) return merged;
|
|
233
|
+
|
|
234
|
+
const next = [...merged];
|
|
235
|
+
for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
|
|
236
|
+
const group = insertions.get(anchor);
|
|
237
|
+
if (group) next.splice(anchor, 0, ...group);
|
|
238
|
+
}
|
|
239
|
+
return next;
|
|
171
240
|
}
|