mu-harness 0.20.0 → 0.22.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/esm/channels/ws/client.js +21 -3
- package/esm/channels/ws/index.d.ts +1 -1
- package/esm/channels/ws/index.js +1 -1
- package/esm/channels/ws/protocol.d.ts +6 -1
- package/esm/channels/ws/protocol.js +16 -1
- package/esm/channels/ws/server.d.ts +10 -0
- package/esm/channels/ws/server.js +25 -1
- package/esm/channels/ws/sub-agent.js +1 -1
- package/esm/channels/ws/wire.d.ts +10 -1
- package/esm/channels/ws/wire.js +23 -2
- package/esm/tui/chat/ChatApp.d.ts +12 -0
- package/esm/tui/chat/ChatApp.js +97 -6
- package/package.json +3 -3
- package/script/channels/ws/client.js +20 -2
- package/script/channels/ws/index.d.ts +1 -1
- package/script/channels/ws/index.js +3 -1
- package/script/channels/ws/protocol.d.ts +6 -1
- package/script/channels/ws/protocol.js +16 -1
- package/script/channels/ws/server.d.ts +10 -0
- package/script/channels/ws/server.js +25 -1
- package/script/channels/ws/sub-agent.js +1 -1
- package/script/channels/ws/wire.d.ts +10 -1
- package/script/channels/ws/wire.js +26 -3
- package/script/tui/chat/ChatApp.d.ts +12 -0
- package/script/tui/chat/ChatApp.js +94 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWsClient } from './ws-client.js';
|
|
2
|
-
import { textOf } from './wire.js';
|
|
2
|
+
import { partsToAttachments, textOf } from './wire.js';
|
|
3
3
|
function deferred() {
|
|
4
4
|
let resolve;
|
|
5
5
|
const promise = new Promise((r) => (resolve = r));
|
|
@@ -31,6 +31,9 @@ export async function connectHarness(opts) {
|
|
|
31
31
|
let models = [];
|
|
32
32
|
let modelSelected = '';
|
|
33
33
|
let commands = [];
|
|
34
|
+
// Mutated in place by the connect-time `capabilities` frame so the host (read once by
|
|
35
|
+
// ChatApp after connect) reflects the server's actual model modalities.
|
|
36
|
+
const features = { ...(opts.features ?? {}) };
|
|
34
37
|
const sessionsById = new Map();
|
|
35
38
|
const subSessions = new Map();
|
|
36
39
|
const approvalListeners = new Set();
|
|
@@ -58,7 +61,18 @@ export async function connectHarness(opts) {
|
|
|
58
61
|
tools: [],
|
|
59
62
|
send: (input) => {
|
|
60
63
|
if (!readonly) {
|
|
61
|
-
|
|
64
|
+
if (typeof input === 'string') {
|
|
65
|
+
client.send({ type: 'chat', sessionId: id, text: input });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const attachments = partsToAttachments(input);
|
|
69
|
+
client.send({
|
|
70
|
+
type: 'chat',
|
|
71
|
+
sessionId: id,
|
|
72
|
+
text: textOf(input),
|
|
73
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
62
76
|
}
|
|
63
77
|
return Promise.resolve();
|
|
64
78
|
},
|
|
@@ -126,6 +140,10 @@ export async function connectHarness(opts) {
|
|
|
126
140
|
case 'commands':
|
|
127
141
|
commands = frame.commands;
|
|
128
142
|
return;
|
|
143
|
+
case 'capabilities':
|
|
144
|
+
features.vision = frame.vision;
|
|
145
|
+
features.audio = frame.audio;
|
|
146
|
+
return;
|
|
129
147
|
case 'models:listed': {
|
|
130
148
|
models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
|
|
131
149
|
modelSelected = frame.selected;
|
|
@@ -281,7 +299,7 @@ export async function connectHarness(opts) {
|
|
|
281
299
|
initialThinking: opts.initialThinking ?? false,
|
|
282
300
|
saveThinking: opts.saveThinking ?? (() => { }),
|
|
283
301
|
history: opts.history,
|
|
284
|
-
features
|
|
302
|
+
features,
|
|
285
303
|
banner: opts.banner,
|
|
286
304
|
minimal: opts.minimal,
|
|
287
305
|
commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from './protocol.js';
|
|
2
2
|
export { type SessionSummaryWire, type PersistedSessionWire, type SessionService, createSessionService } from './session-service.js';
|
|
3
|
-
export { type
|
|
3
|
+
export { attachmentsToParts, messagesToWire, partsToAttachments, type WireAttachment, type WireMessage } from './wire.js';
|
|
4
4
|
export { type WebSocketAdapter, type WebSocketAdapterOptions, webSocketAdapter } from './server.js';
|
|
5
5
|
export { type ConnectHarnessOptions, connectHarness, type RemoteHarness } from './client.js';
|
|
6
6
|
export { createWsClient, type WsClient, type WsClientOptions } from './ws-client.js';
|
package/esm/channels/ws/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from './protocol.js';
|
|
2
2
|
export { createSessionService } from './session-service.js';
|
|
3
|
-
export { messagesToWire } from './wire.js';
|
|
3
|
+
export { attachmentsToParts, messagesToWire, partsToAttachments } from './wire.js';
|
|
4
4
|
export { webSocketAdapter } from './server.js';
|
|
5
5
|
export { connectHarness } from './client.js';
|
|
6
6
|
export { createWsClient } from './ws-client.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PersistedSessionWire, SessionSummaryWire } from './session-service.js';
|
|
2
|
-
import type { WireMessage } from './wire.js';
|
|
2
|
+
import type { WireAttachment, WireMessage } from './wire.js';
|
|
3
3
|
import type { ApprovalAction, PendingApproval } from '../../permissions/index.js';
|
|
4
4
|
import type { Message, Usage } from 'mu-core';
|
|
5
5
|
export interface WireModel {
|
|
@@ -10,6 +10,7 @@ export type WsInbound = {
|
|
|
10
10
|
type: 'chat';
|
|
11
11
|
sessionId?: string;
|
|
12
12
|
text: string;
|
|
13
|
+
attachments?: WireAttachment[];
|
|
13
14
|
} | {
|
|
14
15
|
type: 'command';
|
|
15
16
|
sessionId?: string;
|
|
@@ -163,6 +164,10 @@ export type WireSchedulerEvent = {
|
|
|
163
164
|
export type WsOutbound = {
|
|
164
165
|
type: 'commands';
|
|
165
166
|
commands: WireCommand[];
|
|
167
|
+
} | {
|
|
168
|
+
type: 'capabilities';
|
|
169
|
+
vision: boolean;
|
|
170
|
+
audio: boolean;
|
|
166
171
|
} | {
|
|
167
172
|
type: 'agents';
|
|
168
173
|
agents: WireAgent[];
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
const APPROVAL_ACTIONS = new Set(['approve', 'approve_always', 'deny']);
|
|
2
2
|
const optionalString = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
3
|
+
function parseAttachments(v) {
|
|
4
|
+
if (!Array.isArray(v))
|
|
5
|
+
return undefined;
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const item of v) {
|
|
8
|
+
if (!item || typeof item !== 'object')
|
|
9
|
+
continue;
|
|
10
|
+
const a = item;
|
|
11
|
+
if ((a.kind === 'image' || a.kind === 'audio') && typeof a.mime === 'string' && typeof a.data === 'string') {
|
|
12
|
+
out.push({ kind: a.kind, mime: a.mime, data: a.data });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out.length > 0 ? out : undefined;
|
|
16
|
+
}
|
|
3
17
|
export function parseInbound(raw) {
|
|
4
18
|
if (!raw || typeof raw !== 'object')
|
|
5
19
|
return { error: 'not an object' };
|
|
@@ -9,7 +23,8 @@ export function parseInbound(raw) {
|
|
|
9
23
|
case 'chat': {
|
|
10
24
|
if (typeof o.text !== 'string')
|
|
11
25
|
return { error: 'chat requires text:string' };
|
|
12
|
-
|
|
26
|
+
const attachments = parseAttachments(o.attachments);
|
|
27
|
+
return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text, ...(attachments ? { attachments } : {}) };
|
|
13
28
|
}
|
|
14
29
|
case 'command': {
|
|
15
30
|
if (typeof o.text !== 'string')
|
|
@@ -6,10 +6,20 @@ export interface WebSocketAdapterOptions {
|
|
|
6
6
|
authToken?: string;
|
|
7
7
|
activeAgentId?: string;
|
|
8
8
|
listModels?: () => Promise<WireModel[]>;
|
|
9
|
+
/** Modalities the configured model accepts. Image/audio attachments are dropped when off. */
|
|
10
|
+
capabilities?: {
|
|
11
|
+
vision?: boolean;
|
|
12
|
+
audio?: boolean;
|
|
13
|
+
};
|
|
9
14
|
maxPayloadBytes?: number;
|
|
10
15
|
log?: (msg: string) => void;
|
|
11
16
|
}
|
|
12
17
|
export type WebSocketAdapter = ChannelAdapter & {
|
|
13
18
|
push(frame: WsOutbound): void;
|
|
19
|
+
/** Update the advertised model capabilities and broadcast a fresh `capabilities` frame. */
|
|
20
|
+
setCapabilities(caps: {
|
|
21
|
+
vision: boolean;
|
|
22
|
+
audio: boolean;
|
|
23
|
+
}): void;
|
|
14
24
|
};
|
|
15
25
|
export declare function webSocketAdapter(opts: WebSocketAdapterOptions): WebSocketAdapter;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
2
|
+
import { text as textPart } from 'mu-core';
|
|
2
3
|
import { createSessionService } from './session-service.js';
|
|
3
4
|
import { observeSubAgent } from './sub-agent.js';
|
|
5
|
+
import { attachmentsToParts } from './wire.js';
|
|
4
6
|
import { emitSessionEvent } from './wire-events.js';
|
|
5
7
|
import { approvalRequestToWire, parseInbound, } from './protocol.js';
|
|
6
8
|
const DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
@@ -11,9 +13,15 @@ function toWireCommands(commands) {
|
|
|
11
13
|
export function webSocketAdapter(opts) {
|
|
12
14
|
const log = opts.log ?? (() => { });
|
|
13
15
|
let pushFn = () => { };
|
|
16
|
+
const caps = { vision: opts.capabilities?.vision === true, audio: opts.capabilities?.audio === true };
|
|
14
17
|
const adapter = {
|
|
15
18
|
name: 'websocket',
|
|
16
19
|
push: (frame) => pushFn(frame),
|
|
20
|
+
setCapabilities: (next) => {
|
|
21
|
+
caps.vision = next.vision;
|
|
22
|
+
caps.audio = next.audio;
|
|
23
|
+
pushFn({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
|
|
24
|
+
},
|
|
17
25
|
start: (ctx) => start(ctx),
|
|
18
26
|
};
|
|
19
27
|
return adapter;
|
|
@@ -74,6 +82,20 @@ export function webSocketAdapter(opts) {
|
|
|
74
82
|
push({ type: 'sessions:changed', sessionId, kind });
|
|
75
83
|
push({ type: 'sessions:listed', sessions: await service.list() });
|
|
76
84
|
}
|
|
85
|
+
// Build the channel payload from text + attachments, dropping any modality the model lacks.
|
|
86
|
+
function buildChatPayload(sessionId, body, attachments) {
|
|
87
|
+
if (!attachments || attachments.length === 0)
|
|
88
|
+
return body;
|
|
89
|
+
const allowed = attachments.filter((a) => (a.kind === 'image' ? caps.vision : caps.audio));
|
|
90
|
+
if (allowed.length < attachments.length) {
|
|
91
|
+
const kinds = [...new Set(attachments.filter((a) => !allowed.includes(a)).map((a) => a.kind))].join('/');
|
|
92
|
+
push({ type: 'error', sessionId, message: `the active model has no ${kinds} capability — attachment(s) dropped` });
|
|
93
|
+
}
|
|
94
|
+
if (allowed.length === 0)
|
|
95
|
+
return body;
|
|
96
|
+
const parts = attachmentsToParts(allowed);
|
|
97
|
+
return body ? [textPart(body), ...parts] : parts;
|
|
98
|
+
}
|
|
77
99
|
async function dispatch(client, msg) {
|
|
78
100
|
switch (msg.type) {
|
|
79
101
|
case 'chat': {
|
|
@@ -81,7 +103,8 @@ export function webSocketAdapter(opts) {
|
|
|
81
103
|
client.sessionId = sessionId;
|
|
82
104
|
currentApprovalSessionId = sessionId;
|
|
83
105
|
const channel = manager.get(sessionId) ?? manager.open({ id: sessionId });
|
|
84
|
-
|
|
106
|
+
const payload = buildChatPayload(sessionId, msg.text, msg.attachments);
|
|
107
|
+
void channel.send(payload).catch((err) => {
|
|
85
108
|
push({ type: 'error', sessionId, message: err instanceof Error ? err.message : String(err) });
|
|
86
109
|
});
|
|
87
110
|
return;
|
|
@@ -239,6 +262,7 @@ export function webSocketAdapter(opts) {
|
|
|
239
262
|
const client = { ws, sessionId };
|
|
240
263
|
clients.set(ws, client);
|
|
241
264
|
send(ws, { type: 'commands', commands: toWireCommands(commands.list()) });
|
|
265
|
+
send(ws, { type: 'capabilities', vision: caps.vision, audio: caps.audio });
|
|
242
266
|
send(ws, agentsFrame());
|
|
243
267
|
void modelsFrame().then((frame) => frame && send(ws, frame));
|
|
244
268
|
void service.list().then((sessions) => send(ws, { type: 'sessions:listed', sessions }));
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ContentPart, type Message } from 'mu-core';
|
|
2
2
|
export type WireRole = 'user' | 'assistant' | 'system' | 'tool';
|
|
3
|
+
/** A non-text content part carried over the wire as base64. */
|
|
4
|
+
export interface WireAttachment {
|
|
5
|
+
kind: 'image' | 'audio';
|
|
6
|
+
mime: string;
|
|
7
|
+
data: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const partsToAttachments: (parts: readonly ContentPart[]) => WireAttachment[];
|
|
10
|
+
export declare const attachmentsToParts: (attachments: readonly WireAttachment[]) => ContentPart[];
|
|
3
11
|
export interface WireToolCall {
|
|
4
12
|
id: string;
|
|
5
13
|
function: {
|
|
@@ -27,6 +35,7 @@ export interface WireMessage {
|
|
|
27
35
|
toolCalls?: WireToolCall[];
|
|
28
36
|
toolCallId?: string;
|
|
29
37
|
toolResult?: WireToolResultInfo;
|
|
38
|
+
attachments?: WireAttachment[];
|
|
30
39
|
meta?: WireMessageMeta;
|
|
31
40
|
}
|
|
32
41
|
export declare const textOf: (parts: readonly ContentPart[]) => string;
|
package/esm/channels/ws/wire.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { audio, image } from 'mu-core';
|
|
3
|
+
export const partsToAttachments = (parts) => parts.flatMap((part) => part.type === 'image'
|
|
4
|
+
? [{ kind: 'image', mime: part.mime, data: Buffer.from(part.data).toString('base64') }]
|
|
5
|
+
: part.type === 'audio'
|
|
6
|
+
? [{ kind: 'audio', mime: part.mime, data: Buffer.from(part.data).toString('base64') }]
|
|
7
|
+
: part.type === 'tool_result'
|
|
8
|
+
? partsToAttachments(part.content)
|
|
9
|
+
: []);
|
|
10
|
+
export const attachmentsToParts = (attachments) => attachments.map((a) => {
|
|
11
|
+
const data = new Uint8Array(Buffer.from(a.data, 'base64'));
|
|
12
|
+
return a.kind === 'image' ? image(a.mime, data) : audio(a.mime, data);
|
|
13
|
+
});
|
|
1
14
|
export const textOf = (parts) => parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
2
15
|
export const argsToString = (input) => (typeof input === 'string' ? input : JSON.stringify(input ?? {}));
|
|
3
16
|
export const toolResultText = (parts) => parts
|
|
@@ -24,7 +37,8 @@ export function messageToWire(message, idBase, ts, toolNames) {
|
|
|
24
37
|
if (results.length > 0) {
|
|
25
38
|
return results.map((result, index) => {
|
|
26
39
|
const content = toolResultText(result.content);
|
|
27
|
-
|
|
40
|
+
const attachments = partsToAttachments(result.content);
|
|
41
|
+
const out = {
|
|
28
42
|
id: `${idBase}:t${index}`,
|
|
29
43
|
ts,
|
|
30
44
|
role: 'tool',
|
|
@@ -32,9 +46,16 @@ export function messageToWire(message, idBase, ts, toolNames) {
|
|
|
32
46
|
toolCallId: result.id,
|
|
33
47
|
toolResult: { name: toolNames.get(result.id) ?? '', content },
|
|
34
48
|
};
|
|
49
|
+
if (attachments.length > 0)
|
|
50
|
+
out.attachments = attachments;
|
|
51
|
+
return out;
|
|
35
52
|
});
|
|
36
53
|
}
|
|
37
|
-
|
|
54
|
+
const userOut = { id: `${idBase}:u`, ts, role: 'user', content: textOf(message.content) };
|
|
55
|
+
const userAttachments = partsToAttachments(message.content);
|
|
56
|
+
if (userAttachments.length > 0)
|
|
57
|
+
userOut.attachments = userAttachments;
|
|
58
|
+
return [userOut];
|
|
38
59
|
}
|
|
39
60
|
export function messagesToWire(messages, baseTs = 0) {
|
|
40
61
|
const toolNames = new Map();
|
|
@@ -10,6 +10,10 @@ export interface ChatFeatures {
|
|
|
10
10
|
subAgents?: boolean;
|
|
11
11
|
sessionPicker?: boolean;
|
|
12
12
|
modelPicker?: boolean;
|
|
13
|
+
/** The active model accepts image input. Default off — must be opted in. */
|
|
14
|
+
vision?: boolean;
|
|
15
|
+
/** The active model accepts audio input. Default off — must be opted in. */
|
|
16
|
+
audio?: boolean;
|
|
13
17
|
}
|
|
14
18
|
export interface ChatHost {
|
|
15
19
|
session?: AgentSession;
|
|
@@ -77,6 +81,8 @@ export declare class ChatApp {
|
|
|
77
81
|
private readonly pendingShell;
|
|
78
82
|
private readonly pastes;
|
|
79
83
|
private pasteSeq;
|
|
84
|
+
private readonly attachments;
|
|
85
|
+
private attachSeq;
|
|
80
86
|
private models;
|
|
81
87
|
private paletteCursor;
|
|
82
88
|
private paletteDismissedFor;
|
|
@@ -121,6 +127,12 @@ export declare class ChatApp {
|
|
|
121
127
|
private send;
|
|
122
128
|
private capturePaste;
|
|
123
129
|
private expandPastes;
|
|
130
|
+
private capable;
|
|
131
|
+
private pasteClipboardImage;
|
|
132
|
+
private attachFromFile;
|
|
133
|
+
private addAttachment;
|
|
134
|
+
private stripAttachmentPlaceholders;
|
|
135
|
+
private clearAttachments;
|
|
124
136
|
private flushShellContext;
|
|
125
137
|
private submit;
|
|
126
138
|
private clearPastes;
|
package/esm/tui/chat/ChatApp.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
-
import {
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { audio, image } from 'mu-core';
|
|
5
|
+
import { box, column, flex, measure, ProcessTerminal, readClipboardImage, scrollView, truncateToWidth, TUI, visibleWidth, } from 'mu-tui';
|
|
5
6
|
import { buildCommands, filterCommands } from './commands.js';
|
|
6
7
|
import { MultilineEditor } from './editor.js';
|
|
7
8
|
import { activeMention, collectCandidates, mentionRanges, rank } from './picker.js';
|
|
@@ -21,6 +22,23 @@ const APPROVAL_OPTIONS = [
|
|
|
21
22
|
{ label: 'Deny', value: 'deny' },
|
|
22
23
|
];
|
|
23
24
|
const DIM = '\x1b[2m';
|
|
25
|
+
const IMAGE_MIME = {
|
|
26
|
+
'.png': 'image/png',
|
|
27
|
+
'.jpg': 'image/jpeg',
|
|
28
|
+
'.jpeg': 'image/jpeg',
|
|
29
|
+
'.gif': 'image/gif',
|
|
30
|
+
'.webp': 'image/webp',
|
|
31
|
+
'.bmp': 'image/bmp',
|
|
32
|
+
};
|
|
33
|
+
const AUDIO_MIME = {
|
|
34
|
+
'.wav': 'audio/wav',
|
|
35
|
+
'.mp3': 'audio/mpeg',
|
|
36
|
+
'.m4a': 'audio/mp4',
|
|
37
|
+
'.ogg': 'audio/ogg',
|
|
38
|
+
'.flac': 'audio/flac',
|
|
39
|
+
'.webm': 'audio/webm',
|
|
40
|
+
};
|
|
41
|
+
const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
|
|
24
42
|
const lastAssistantText = (messages) => {
|
|
25
43
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
26
44
|
const message = messages[i];
|
|
@@ -90,6 +108,8 @@ export class ChatApp {
|
|
|
90
108
|
pendingShell = [];
|
|
91
109
|
pastes = new Map();
|
|
92
110
|
pasteSeq = 0;
|
|
111
|
+
attachments = new Map();
|
|
112
|
+
attachSeq = 0;
|
|
93
113
|
models = [];
|
|
94
114
|
paletteCursor = 0;
|
|
95
115
|
paletteDismissedFor = '__none__';
|
|
@@ -167,6 +187,7 @@ export class ChatApp {
|
|
|
167
187
|
this.tui.addGlobalKeybinding({ chord: { key: 't', ctrl: true }, handler: () => this.toggleTheme() });
|
|
168
188
|
this.tui.addGlobalKeybinding({ chord: { key: 'o', ctrl: true }, handler: () => this.toggleExpand() });
|
|
169
189
|
this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
|
|
190
|
+
this.tui.addGlobalKeybinding({ chord: { key: 'v', ctrl: true }, handler: () => void this.pasteClipboardImage() });
|
|
170
191
|
this.unsubscribeTheme = this.themeProvider.subscribe(() => {
|
|
171
192
|
this.tui.setBackgroundColor(this.theme().colors.background);
|
|
172
193
|
this.tui.setToastBackground(this.theme().colors.surface);
|
|
@@ -465,10 +486,13 @@ export class ChatApp {
|
|
|
465
486
|
const agents = new Set(this.host.agentNames());
|
|
466
487
|
return text.replace(/(^|\s)@(\S+)/g, (match, pre, token) => (agents.has(token) ? match : `${pre}${token}`));
|
|
467
488
|
}
|
|
468
|
-
send(value) {
|
|
489
|
+
send(value, attachments = []) {
|
|
469
490
|
const session = this.ensureSession();
|
|
470
491
|
this.transcript.appendUser(value);
|
|
471
|
-
const
|
|
492
|
+
const text = this.flushShellContext(this.stripFileMentions(this.stripAttachmentPlaceholders(value)));
|
|
493
|
+
const content = attachments.length > 0
|
|
494
|
+
? [...(text ? [{ type: 'text', text }] : []), ...attachments]
|
|
495
|
+
: text;
|
|
472
496
|
this.running = true;
|
|
473
497
|
this.status.busy = true;
|
|
474
498
|
this.setStatus('thinking…');
|
|
@@ -482,6 +506,20 @@ export class ChatApp {
|
|
|
482
506
|
});
|
|
483
507
|
}
|
|
484
508
|
capturePaste(text) {
|
|
509
|
+
// Empty bracketed paste: the terminal swallowed binary clipboard data (e.g. an image).
|
|
510
|
+
if (!text.trim()) {
|
|
511
|
+
void this.pasteClipboardImage();
|
|
512
|
+
return '';
|
|
513
|
+
}
|
|
514
|
+
// A pasted path to a local image/audio file becomes an attachment, not literal text.
|
|
515
|
+
const filePath = text.trim().replace(/^['"]|['"]$/g, '').replace(/\\ /g, ' ');
|
|
516
|
+
if (!filePath.includes('\n')) {
|
|
517
|
+
const ext = extname(filePath).toLowerCase();
|
|
518
|
+
if (IMAGE_MIME[ext] || AUDIO_MIME[ext]) {
|
|
519
|
+
void this.attachFromFile(filePath, ext);
|
|
520
|
+
return '';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
485
523
|
const lines = text.split('\n').length;
|
|
486
524
|
if (lines < 2 && text.length <= 200)
|
|
487
525
|
return undefined;
|
|
@@ -498,6 +536,50 @@ export class ChatApp {
|
|
|
498
536
|
}
|
|
499
537
|
return out;
|
|
500
538
|
}
|
|
539
|
+
capable(kind) {
|
|
540
|
+
return (kind === 'image' ? this.features.vision : this.features.audio) === true;
|
|
541
|
+
}
|
|
542
|
+
async pasteClipboardImage() {
|
|
543
|
+
const img = await readClipboardImage().catch(() => undefined);
|
|
544
|
+
if (!img) {
|
|
545
|
+
this.setStatus('no image in clipboard');
|
|
546
|
+
this.tui.requestRender();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
this.addAttachment('image', img.mime, img.data);
|
|
550
|
+
}
|
|
551
|
+
async attachFromFile(path, ext) {
|
|
552
|
+
const kind = IMAGE_MIME[ext] ? 'image' : 'audio';
|
|
553
|
+
const mime = IMAGE_MIME[ext] ?? AUDIO_MIME[ext];
|
|
554
|
+
try {
|
|
555
|
+
const buf = await readFile(resolve(this.host.cwd, path));
|
|
556
|
+
this.addAttachment(kind, mime, new Uint8Array(buf));
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
this.setStatus(`could not read ${path}`);
|
|
560
|
+
this.tui.requestRender();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
addAttachment(kind, mime, data) {
|
|
564
|
+
if (!this.capable(kind)) {
|
|
565
|
+
this.setStatus(`the active model has no ${kind} capability — ${kind} not attached`);
|
|
566
|
+
this.tui.requestRender();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const placeholder = `[${kind} #${++this.attachSeq}]`;
|
|
570
|
+
this.attachments.set(placeholder, kind === 'image' ? image(mime, data) : audio(mime, data));
|
|
571
|
+
const value = this.editor.getValue();
|
|
572
|
+
this.editor.setValue(value ? `${value} ${placeholder} ` : `${placeholder} `);
|
|
573
|
+
this.onInputChange(this.editor.getValue());
|
|
574
|
+
this.tui.requestRender();
|
|
575
|
+
}
|
|
576
|
+
stripAttachmentPlaceholders(text) {
|
|
577
|
+
return text.replace(ATTACHMENT_PLACEHOLDER, '').replace(/[ \t]{2,}/g, ' ').trim();
|
|
578
|
+
}
|
|
579
|
+
clearAttachments() {
|
|
580
|
+
this.attachments.clear();
|
|
581
|
+
this.attachSeq = 0;
|
|
582
|
+
}
|
|
501
583
|
flushShellContext(userText) {
|
|
502
584
|
if (this.pendingShell.length === 0)
|
|
503
585
|
return userText;
|
|
@@ -515,8 +597,10 @@ export class ChatApp {
|
|
|
515
597
|
return;
|
|
516
598
|
this.clearError();
|
|
517
599
|
const text = this.expandPastes(trimmed);
|
|
600
|
+
const attachments = [...this.attachments.values()];
|
|
518
601
|
this.editor.setValue('');
|
|
519
602
|
this.clearPastes();
|
|
603
|
+
this.clearAttachments();
|
|
520
604
|
this.pushHistory(text);
|
|
521
605
|
if (text.startsWith('!') || text.startsWith('$')) {
|
|
522
606
|
this.runShell(text.slice(1).trim());
|
|
@@ -529,11 +613,13 @@ export class ChatApp {
|
|
|
529
613
|
if (this.tryDispatch(text))
|
|
530
614
|
return;
|
|
531
615
|
if (this.running) {
|
|
616
|
+
if (attachments.length > 0)
|
|
617
|
+
this.setStatus('attachments are dropped for messages queued mid-turn');
|
|
532
618
|
this.queue.push(text);
|
|
533
619
|
this.tui.requestRender();
|
|
534
620
|
return;
|
|
535
621
|
}
|
|
536
|
-
this.send(text);
|
|
622
|
+
this.send(text, attachments);
|
|
537
623
|
}
|
|
538
624
|
clearPastes() {
|
|
539
625
|
this.pastes.clear();
|
|
@@ -546,6 +632,7 @@ export class ChatApp {
|
|
|
546
632
|
const text = this.expandPastes(value);
|
|
547
633
|
this.editor.setValue('');
|
|
548
634
|
this.clearPastes();
|
|
635
|
+
this.clearAttachments();
|
|
549
636
|
this.pushHistory(text);
|
|
550
637
|
this.queue.push(text);
|
|
551
638
|
this.tui.requestRender();
|
|
@@ -555,6 +642,10 @@ export class ChatApp {
|
|
|
555
642
|
if (!value.includes(placeholder))
|
|
556
643
|
this.pastes.delete(placeholder);
|
|
557
644
|
}
|
|
645
|
+
for (const placeholder of this.attachments.keys()) {
|
|
646
|
+
if (!value.includes(placeholder))
|
|
647
|
+
this.attachments.delete(placeholder);
|
|
648
|
+
}
|
|
558
649
|
if (value !== this.paletteDismissedFor)
|
|
559
650
|
this.paletteDismissedFor = '__none__';
|
|
560
651
|
const items = this.paletteItems();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Agent harness: createHarness wires mu-core into a host — XDG paths, model registry, plugins, disk-loaded agents & skills, sub-agents, sessions (JSONL + SQLite catalog), slash commands, permission/approval hooks, an optional scheduler, and a composable TUI chat app",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./script/index.js",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"@swc/wasm-typescript": "^1.15.0",
|
|
24
24
|
"cli-highlight": "^2.1.11",
|
|
25
25
|
"croner": "^9.0.0",
|
|
26
|
-
"mu-core": "^0.
|
|
27
|
-
"mu-tui": "^0.
|
|
26
|
+
"mu-core": "^0.22.0",
|
|
27
|
+
"mu-tui": "^0.22.0",
|
|
28
28
|
"ws": "^8.18.0"
|
|
29
29
|
},
|
|
30
30
|
"_generatedBy": "dnt@dev",
|
|
@@ -34,6 +34,9 @@ async function connectHarness(opts) {
|
|
|
34
34
|
let models = [];
|
|
35
35
|
let modelSelected = '';
|
|
36
36
|
let commands = [];
|
|
37
|
+
// Mutated in place by the connect-time `capabilities` frame so the host (read once by
|
|
38
|
+
// ChatApp after connect) reflects the server's actual model modalities.
|
|
39
|
+
const features = { ...(opts.features ?? {}) };
|
|
37
40
|
const sessionsById = new Map();
|
|
38
41
|
const subSessions = new Map();
|
|
39
42
|
const approvalListeners = new Set();
|
|
@@ -61,7 +64,18 @@ async function connectHarness(opts) {
|
|
|
61
64
|
tools: [],
|
|
62
65
|
send: (input) => {
|
|
63
66
|
if (!readonly) {
|
|
64
|
-
|
|
67
|
+
if (typeof input === 'string') {
|
|
68
|
+
client.send({ type: 'chat', sessionId: id, text: input });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const attachments = (0, wire_js_1.partsToAttachments)(input);
|
|
72
|
+
client.send({
|
|
73
|
+
type: 'chat',
|
|
74
|
+
sessionId: id,
|
|
75
|
+
text: (0, wire_js_1.textOf)(input),
|
|
76
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
65
79
|
}
|
|
66
80
|
return Promise.resolve();
|
|
67
81
|
},
|
|
@@ -129,6 +143,10 @@ async function connectHarness(opts) {
|
|
|
129
143
|
case 'commands':
|
|
130
144
|
commands = frame.commands;
|
|
131
145
|
return;
|
|
146
|
+
case 'capabilities':
|
|
147
|
+
features.vision = frame.vision;
|
|
148
|
+
features.audio = frame.audio;
|
|
149
|
+
return;
|
|
132
150
|
case 'models:listed': {
|
|
133
151
|
models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
|
|
134
152
|
modelSelected = frame.selected;
|
|
@@ -284,7 +302,7 @@ async function connectHarness(opts) {
|
|
|
284
302
|
initialThinking: opts.initialThinking ?? false,
|
|
285
303
|
saveThinking: opts.saveThinking ?? (() => { }),
|
|
286
304
|
history: opts.history,
|
|
287
|
-
features
|
|
305
|
+
features,
|
|
288
306
|
banner: opts.banner,
|
|
289
307
|
minimal: opts.minimal,
|
|
290
308
|
commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from './protocol.js';
|
|
2
2
|
export { type SessionSummaryWire, type PersistedSessionWire, type SessionService, createSessionService } from './session-service.js';
|
|
3
|
-
export { type
|
|
3
|
+
export { attachmentsToParts, messagesToWire, partsToAttachments, type WireAttachment, type WireMessage } from './wire.js';
|
|
4
4
|
export { type WebSocketAdapter, type WebSocketAdapterOptions, webSocketAdapter } from './server.js';
|
|
5
5
|
export { type ConnectHarnessOptions, connectHarness, type RemoteHarness } from './client.js';
|
|
6
6
|
export { createWsClient, type WsClient, type WsClientOptions } from './ws-client.js';
|
|
@@ -14,12 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.createWsClient = exports.connectHarness = exports.webSocketAdapter = exports.messagesToWire = exports.createSessionService = void 0;
|
|
17
|
+
exports.createWsClient = exports.connectHarness = exports.webSocketAdapter = exports.partsToAttachments = exports.messagesToWire = exports.attachmentsToParts = exports.createSessionService = void 0;
|
|
18
18
|
__exportStar(require("./protocol.js"), exports);
|
|
19
19
|
var session_service_js_1 = require("./session-service.js");
|
|
20
20
|
Object.defineProperty(exports, "createSessionService", { enumerable: true, get: function () { return session_service_js_1.createSessionService; } });
|
|
21
21
|
var wire_js_1 = require("./wire.js");
|
|
22
|
+
Object.defineProperty(exports, "attachmentsToParts", { enumerable: true, get: function () { return wire_js_1.attachmentsToParts; } });
|
|
22
23
|
Object.defineProperty(exports, "messagesToWire", { enumerable: true, get: function () { return wire_js_1.messagesToWire; } });
|
|
24
|
+
Object.defineProperty(exports, "partsToAttachments", { enumerable: true, get: function () { return wire_js_1.partsToAttachments; } });
|
|
23
25
|
var server_js_1 = require("./server.js");
|
|
24
26
|
Object.defineProperty(exports, "webSocketAdapter", { enumerable: true, get: function () { return server_js_1.webSocketAdapter; } });
|
|
25
27
|
var client_js_1 = require("./client.js");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PersistedSessionWire, SessionSummaryWire } from './session-service.js';
|
|
2
|
-
import type { WireMessage } from './wire.js';
|
|
2
|
+
import type { WireAttachment, WireMessage } from './wire.js';
|
|
3
3
|
import type { ApprovalAction, PendingApproval } from '../../permissions/index.js';
|
|
4
4
|
import type { Message, Usage } from 'mu-core';
|
|
5
5
|
export interface WireModel {
|
|
@@ -10,6 +10,7 @@ export type WsInbound = {
|
|
|
10
10
|
type: 'chat';
|
|
11
11
|
sessionId?: string;
|
|
12
12
|
text: string;
|
|
13
|
+
attachments?: WireAttachment[];
|
|
13
14
|
} | {
|
|
14
15
|
type: 'command';
|
|
15
16
|
sessionId?: string;
|
|
@@ -163,6 +164,10 @@ export type WireSchedulerEvent = {
|
|
|
163
164
|
export type WsOutbound = {
|
|
164
165
|
type: 'commands';
|
|
165
166
|
commands: WireCommand[];
|
|
167
|
+
} | {
|
|
168
|
+
type: 'capabilities';
|
|
169
|
+
vision: boolean;
|
|
170
|
+
audio: boolean;
|
|
166
171
|
} | {
|
|
167
172
|
type: 'agents';
|
|
168
173
|
agents: WireAgent[];
|
|
@@ -4,6 +4,20 @@ exports.parseInbound = parseInbound;
|
|
|
4
4
|
exports.approvalRequestToWire = approvalRequestToWire;
|
|
5
5
|
const APPROVAL_ACTIONS = new Set(['approve', 'approve_always', 'deny']);
|
|
6
6
|
const optionalString = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
7
|
+
function parseAttachments(v) {
|
|
8
|
+
if (!Array.isArray(v))
|
|
9
|
+
return undefined;
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const item of v) {
|
|
12
|
+
if (!item || typeof item !== 'object')
|
|
13
|
+
continue;
|
|
14
|
+
const a = item;
|
|
15
|
+
if ((a.kind === 'image' || a.kind === 'audio') && typeof a.mime === 'string' && typeof a.data === 'string') {
|
|
16
|
+
out.push({ kind: a.kind, mime: a.mime, data: a.data });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out.length > 0 ? out : undefined;
|
|
20
|
+
}
|
|
7
21
|
function parseInbound(raw) {
|
|
8
22
|
if (!raw || typeof raw !== 'object')
|
|
9
23
|
return { error: 'not an object' };
|
|
@@ -13,7 +27,8 @@ function parseInbound(raw) {
|
|
|
13
27
|
case 'chat': {
|
|
14
28
|
if (typeof o.text !== 'string')
|
|
15
29
|
return { error: 'chat requires text:string' };
|
|
16
|
-
|
|
30
|
+
const attachments = parseAttachments(o.attachments);
|
|
31
|
+
return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text, ...(attachments ? { attachments } : {}) };
|
|
17
32
|
}
|
|
18
33
|
case 'command': {
|
|
19
34
|
if (typeof o.text !== 'string')
|
|
@@ -6,10 +6,20 @@ export interface WebSocketAdapterOptions {
|
|
|
6
6
|
authToken?: string;
|
|
7
7
|
activeAgentId?: string;
|
|
8
8
|
listModels?: () => Promise<WireModel[]>;
|
|
9
|
+
/** Modalities the configured model accepts. Image/audio attachments are dropped when off. */
|
|
10
|
+
capabilities?: {
|
|
11
|
+
vision?: boolean;
|
|
12
|
+
audio?: boolean;
|
|
13
|
+
};
|
|
9
14
|
maxPayloadBytes?: number;
|
|
10
15
|
log?: (msg: string) => void;
|
|
11
16
|
}
|
|
12
17
|
export type WebSocketAdapter = ChannelAdapter & {
|
|
13
18
|
push(frame: WsOutbound): void;
|
|
19
|
+
/** Update the advertised model capabilities and broadcast a fresh `capabilities` frame. */
|
|
20
|
+
setCapabilities(caps: {
|
|
21
|
+
vision: boolean;
|
|
22
|
+
audio: boolean;
|
|
23
|
+
}): void;
|
|
14
24
|
};
|
|
15
25
|
export declare function webSocketAdapter(opts: WebSocketAdapterOptions): WebSocketAdapter;
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.webSocketAdapter = webSocketAdapter;
|
|
4
4
|
const ws_1 = require("ws");
|
|
5
|
+
const mu_core_1 = require("mu-core");
|
|
5
6
|
const session_service_js_1 = require("./session-service.js");
|
|
6
7
|
const sub_agent_js_1 = require("./sub-agent.js");
|
|
8
|
+
const wire_js_1 = require("./wire.js");
|
|
7
9
|
const wire_events_js_1 = require("./wire-events.js");
|
|
8
10
|
const protocol_js_1 = require("./protocol.js");
|
|
9
11
|
const DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
@@ -14,9 +16,15 @@ function toWireCommands(commands) {
|
|
|
14
16
|
function webSocketAdapter(opts) {
|
|
15
17
|
const log = opts.log ?? (() => { });
|
|
16
18
|
let pushFn = () => { };
|
|
19
|
+
const caps = { vision: opts.capabilities?.vision === true, audio: opts.capabilities?.audio === true };
|
|
17
20
|
const adapter = {
|
|
18
21
|
name: 'websocket',
|
|
19
22
|
push: (frame) => pushFn(frame),
|
|
23
|
+
setCapabilities: (next) => {
|
|
24
|
+
caps.vision = next.vision;
|
|
25
|
+
caps.audio = next.audio;
|
|
26
|
+
pushFn({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
|
|
27
|
+
},
|
|
20
28
|
start: (ctx) => start(ctx),
|
|
21
29
|
};
|
|
22
30
|
return adapter;
|
|
@@ -77,6 +85,20 @@ function webSocketAdapter(opts) {
|
|
|
77
85
|
push({ type: 'sessions:changed', sessionId, kind });
|
|
78
86
|
push({ type: 'sessions:listed', sessions: await service.list() });
|
|
79
87
|
}
|
|
88
|
+
// Build the channel payload from text + attachments, dropping any modality the model lacks.
|
|
89
|
+
function buildChatPayload(sessionId, body, attachments) {
|
|
90
|
+
if (!attachments || attachments.length === 0)
|
|
91
|
+
return body;
|
|
92
|
+
const allowed = attachments.filter((a) => (a.kind === 'image' ? caps.vision : caps.audio));
|
|
93
|
+
if (allowed.length < attachments.length) {
|
|
94
|
+
const kinds = [...new Set(attachments.filter((a) => !allowed.includes(a)).map((a) => a.kind))].join('/');
|
|
95
|
+
push({ type: 'error', sessionId, message: `the active model has no ${kinds} capability — attachment(s) dropped` });
|
|
96
|
+
}
|
|
97
|
+
if (allowed.length === 0)
|
|
98
|
+
return body;
|
|
99
|
+
const parts = (0, wire_js_1.attachmentsToParts)(allowed);
|
|
100
|
+
return body ? [(0, mu_core_1.text)(body), ...parts] : parts;
|
|
101
|
+
}
|
|
80
102
|
async function dispatch(client, msg) {
|
|
81
103
|
switch (msg.type) {
|
|
82
104
|
case 'chat': {
|
|
@@ -84,7 +106,8 @@ function webSocketAdapter(opts) {
|
|
|
84
106
|
client.sessionId = sessionId;
|
|
85
107
|
currentApprovalSessionId = sessionId;
|
|
86
108
|
const channel = manager.get(sessionId) ?? manager.open({ id: sessionId });
|
|
87
|
-
|
|
109
|
+
const payload = buildChatPayload(sessionId, msg.text, msg.attachments);
|
|
110
|
+
void channel.send(payload).catch((err) => {
|
|
88
111
|
push({ type: 'error', sessionId, message: err instanceof Error ? err.message : String(err) });
|
|
89
112
|
});
|
|
90
113
|
return;
|
|
@@ -242,6 +265,7 @@ function webSocketAdapter(opts) {
|
|
|
242
265
|
const client = { ws, sessionId };
|
|
243
266
|
clients.set(ws, client);
|
|
244
267
|
send(ws, { type: 'commands', commands: toWireCommands(commands.list()) });
|
|
268
|
+
send(ws, { type: 'capabilities', vision: caps.vision, audio: caps.audio });
|
|
245
269
|
send(ws, agentsFrame());
|
|
246
270
|
void modelsFrame().then((frame) => frame && send(ws, frame));
|
|
247
271
|
void service.list().then((sessions) => send(ws, { type: 'sessions:listed', sessions }));
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ContentPart, type Message } from 'mu-core';
|
|
2
2
|
export type WireRole = 'user' | 'assistant' | 'system' | 'tool';
|
|
3
|
+
/** A non-text content part carried over the wire as base64. */
|
|
4
|
+
export interface WireAttachment {
|
|
5
|
+
kind: 'image' | 'audio';
|
|
6
|
+
mime: string;
|
|
7
|
+
data: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const partsToAttachments: (parts: readonly ContentPart[]) => WireAttachment[];
|
|
10
|
+
export declare const attachmentsToParts: (attachments: readonly WireAttachment[]) => ContentPart[];
|
|
3
11
|
export interface WireToolCall {
|
|
4
12
|
id: string;
|
|
5
13
|
function: {
|
|
@@ -27,6 +35,7 @@ export interface WireMessage {
|
|
|
27
35
|
toolCalls?: WireToolCall[];
|
|
28
36
|
toolCallId?: string;
|
|
29
37
|
toolResult?: WireToolResultInfo;
|
|
38
|
+
attachments?: WireAttachment[];
|
|
30
39
|
meta?: WireMessageMeta;
|
|
31
40
|
}
|
|
32
41
|
export declare const textOf: (parts: readonly ContentPart[]) => string;
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.toolResultText = exports.argsToString = exports.textOf = void 0;
|
|
3
|
+
exports.toolResultText = exports.argsToString = exports.textOf = exports.attachmentsToParts = exports.partsToAttachments = void 0;
|
|
4
4
|
exports.messageToWire = messageToWire;
|
|
5
5
|
exports.messagesToWire = messagesToWire;
|
|
6
|
+
const node_buffer_1 = require("node:buffer");
|
|
7
|
+
const mu_core_1 = require("mu-core");
|
|
8
|
+
const partsToAttachments = (parts) => parts.flatMap((part) => part.type === 'image'
|
|
9
|
+
? [{ kind: 'image', mime: part.mime, data: node_buffer_1.Buffer.from(part.data).toString('base64') }]
|
|
10
|
+
: part.type === 'audio'
|
|
11
|
+
? [{ kind: 'audio', mime: part.mime, data: node_buffer_1.Buffer.from(part.data).toString('base64') }]
|
|
12
|
+
: part.type === 'tool_result'
|
|
13
|
+
? (0, exports.partsToAttachments)(part.content)
|
|
14
|
+
: []);
|
|
15
|
+
exports.partsToAttachments = partsToAttachments;
|
|
16
|
+
const attachmentsToParts = (attachments) => attachments.map((a) => {
|
|
17
|
+
const data = new Uint8Array(node_buffer_1.Buffer.from(a.data, 'base64'));
|
|
18
|
+
return a.kind === 'image' ? (0, mu_core_1.image)(a.mime, data) : (0, mu_core_1.audio)(a.mime, data);
|
|
19
|
+
});
|
|
20
|
+
exports.attachmentsToParts = attachmentsToParts;
|
|
6
21
|
const textOf = (parts) => parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
7
22
|
exports.textOf = textOf;
|
|
8
23
|
const argsToString = (input) => (typeof input === 'string' ? input : JSON.stringify(input ?? {}));
|
|
@@ -32,7 +47,8 @@ function messageToWire(message, idBase, ts, toolNames) {
|
|
|
32
47
|
if (results.length > 0) {
|
|
33
48
|
return results.map((result, index) => {
|
|
34
49
|
const content = (0, exports.toolResultText)(result.content);
|
|
35
|
-
|
|
50
|
+
const attachments = (0, exports.partsToAttachments)(result.content);
|
|
51
|
+
const out = {
|
|
36
52
|
id: `${idBase}:t${index}`,
|
|
37
53
|
ts,
|
|
38
54
|
role: 'tool',
|
|
@@ -40,9 +56,16 @@ function messageToWire(message, idBase, ts, toolNames) {
|
|
|
40
56
|
toolCallId: result.id,
|
|
41
57
|
toolResult: { name: toolNames.get(result.id) ?? '', content },
|
|
42
58
|
};
|
|
59
|
+
if (attachments.length > 0)
|
|
60
|
+
out.attachments = attachments;
|
|
61
|
+
return out;
|
|
43
62
|
});
|
|
44
63
|
}
|
|
45
|
-
|
|
64
|
+
const userOut = { id: `${idBase}:u`, ts, role: 'user', content: (0, exports.textOf)(message.content) };
|
|
65
|
+
const userAttachments = (0, exports.partsToAttachments)(message.content);
|
|
66
|
+
if (userAttachments.length > 0)
|
|
67
|
+
userOut.attachments = userAttachments;
|
|
68
|
+
return [userOut];
|
|
46
69
|
}
|
|
47
70
|
function messagesToWire(messages, baseTs = 0) {
|
|
48
71
|
const toolNames = new Map();
|
|
@@ -10,6 +10,10 @@ export interface ChatFeatures {
|
|
|
10
10
|
subAgents?: boolean;
|
|
11
11
|
sessionPicker?: boolean;
|
|
12
12
|
modelPicker?: boolean;
|
|
13
|
+
/** The active model accepts image input. Default off — must be opted in. */
|
|
14
|
+
vision?: boolean;
|
|
15
|
+
/** The active model accepts audio input. Default off — must be opted in. */
|
|
16
|
+
audio?: boolean;
|
|
13
17
|
}
|
|
14
18
|
export interface ChatHost {
|
|
15
19
|
session?: AgentSession;
|
|
@@ -77,6 +81,8 @@ export declare class ChatApp {
|
|
|
77
81
|
private readonly pendingShell;
|
|
78
82
|
private readonly pastes;
|
|
79
83
|
private pasteSeq;
|
|
84
|
+
private readonly attachments;
|
|
85
|
+
private attachSeq;
|
|
80
86
|
private models;
|
|
81
87
|
private paletteCursor;
|
|
82
88
|
private paletteDismissedFor;
|
|
@@ -121,6 +127,12 @@ export declare class ChatApp {
|
|
|
121
127
|
private send;
|
|
122
128
|
private capturePaste;
|
|
123
129
|
private expandPastes;
|
|
130
|
+
private capable;
|
|
131
|
+
private pasteClipboardImage;
|
|
132
|
+
private attachFromFile;
|
|
133
|
+
private addAttachment;
|
|
134
|
+
private stripAttachmentPlaceholders;
|
|
135
|
+
private clearAttachments;
|
|
124
136
|
private flushShellContext;
|
|
125
137
|
private submit;
|
|
126
138
|
private clearPastes;
|
|
@@ -4,6 +4,7 @@ exports.ChatApp = void 0;
|
|
|
4
4
|
const node_child_process_1 = require("node:child_process");
|
|
5
5
|
const promises_1 = require("node:fs/promises");
|
|
6
6
|
const node_path_1 = require("node:path");
|
|
7
|
+
const mu_core_1 = require("mu-core");
|
|
7
8
|
const mu_tui_1 = require("mu-tui");
|
|
8
9
|
const commands_js_1 = require("./commands.js");
|
|
9
10
|
const editor_js_1 = require("./editor.js");
|
|
@@ -24,6 +25,23 @@ const APPROVAL_OPTIONS = [
|
|
|
24
25
|
{ label: 'Deny', value: 'deny' },
|
|
25
26
|
];
|
|
26
27
|
const DIM = '\x1b[2m';
|
|
28
|
+
const IMAGE_MIME = {
|
|
29
|
+
'.png': 'image/png',
|
|
30
|
+
'.jpg': 'image/jpeg',
|
|
31
|
+
'.jpeg': 'image/jpeg',
|
|
32
|
+
'.gif': 'image/gif',
|
|
33
|
+
'.webp': 'image/webp',
|
|
34
|
+
'.bmp': 'image/bmp',
|
|
35
|
+
};
|
|
36
|
+
const AUDIO_MIME = {
|
|
37
|
+
'.wav': 'audio/wav',
|
|
38
|
+
'.mp3': 'audio/mpeg',
|
|
39
|
+
'.m4a': 'audio/mp4',
|
|
40
|
+
'.ogg': 'audio/ogg',
|
|
41
|
+
'.flac': 'audio/flac',
|
|
42
|
+
'.webm': 'audio/webm',
|
|
43
|
+
};
|
|
44
|
+
const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
|
|
27
45
|
const lastAssistantText = (messages) => {
|
|
28
46
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
29
47
|
const message = messages[i];
|
|
@@ -93,6 +111,8 @@ class ChatApp {
|
|
|
93
111
|
pendingShell = [];
|
|
94
112
|
pastes = new Map();
|
|
95
113
|
pasteSeq = 0;
|
|
114
|
+
attachments = new Map();
|
|
115
|
+
attachSeq = 0;
|
|
96
116
|
models = [];
|
|
97
117
|
paletteCursor = 0;
|
|
98
118
|
paletteDismissedFor = '__none__';
|
|
@@ -170,6 +190,7 @@ class ChatApp {
|
|
|
170
190
|
this.tui.addGlobalKeybinding({ chord: { key: 't', ctrl: true }, handler: () => this.toggleTheme() });
|
|
171
191
|
this.tui.addGlobalKeybinding({ chord: { key: 'o', ctrl: true }, handler: () => this.toggleExpand() });
|
|
172
192
|
this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
|
|
193
|
+
this.tui.addGlobalKeybinding({ chord: { key: 'v', ctrl: true }, handler: () => void this.pasteClipboardImage() });
|
|
173
194
|
this.unsubscribeTheme = this.themeProvider.subscribe(() => {
|
|
174
195
|
this.tui.setBackgroundColor(this.theme().colors.background);
|
|
175
196
|
this.tui.setToastBackground(this.theme().colors.surface);
|
|
@@ -468,10 +489,13 @@ class ChatApp {
|
|
|
468
489
|
const agents = new Set(this.host.agentNames());
|
|
469
490
|
return text.replace(/(^|\s)@(\S+)/g, (match, pre, token) => (agents.has(token) ? match : `${pre}${token}`));
|
|
470
491
|
}
|
|
471
|
-
send(value) {
|
|
492
|
+
send(value, attachments = []) {
|
|
472
493
|
const session = this.ensureSession();
|
|
473
494
|
this.transcript.appendUser(value);
|
|
474
|
-
const
|
|
495
|
+
const text = this.flushShellContext(this.stripFileMentions(this.stripAttachmentPlaceholders(value)));
|
|
496
|
+
const content = attachments.length > 0
|
|
497
|
+
? [...(text ? [{ type: 'text', text }] : []), ...attachments]
|
|
498
|
+
: text;
|
|
475
499
|
this.running = true;
|
|
476
500
|
this.status.busy = true;
|
|
477
501
|
this.setStatus('thinking…');
|
|
@@ -485,6 +509,20 @@ class ChatApp {
|
|
|
485
509
|
});
|
|
486
510
|
}
|
|
487
511
|
capturePaste(text) {
|
|
512
|
+
// Empty bracketed paste: the terminal swallowed binary clipboard data (e.g. an image).
|
|
513
|
+
if (!text.trim()) {
|
|
514
|
+
void this.pasteClipboardImage();
|
|
515
|
+
return '';
|
|
516
|
+
}
|
|
517
|
+
// A pasted path to a local image/audio file becomes an attachment, not literal text.
|
|
518
|
+
const filePath = text.trim().replace(/^['"]|['"]$/g, '').replace(/\\ /g, ' ');
|
|
519
|
+
if (!filePath.includes('\n')) {
|
|
520
|
+
const ext = (0, node_path_1.extname)(filePath).toLowerCase();
|
|
521
|
+
if (IMAGE_MIME[ext] || AUDIO_MIME[ext]) {
|
|
522
|
+
void this.attachFromFile(filePath, ext);
|
|
523
|
+
return '';
|
|
524
|
+
}
|
|
525
|
+
}
|
|
488
526
|
const lines = text.split('\n').length;
|
|
489
527
|
if (lines < 2 && text.length <= 200)
|
|
490
528
|
return undefined;
|
|
@@ -501,6 +539,50 @@ class ChatApp {
|
|
|
501
539
|
}
|
|
502
540
|
return out;
|
|
503
541
|
}
|
|
542
|
+
capable(kind) {
|
|
543
|
+
return (kind === 'image' ? this.features.vision : this.features.audio) === true;
|
|
544
|
+
}
|
|
545
|
+
async pasteClipboardImage() {
|
|
546
|
+
const img = await (0, mu_tui_1.readClipboardImage)().catch(() => undefined);
|
|
547
|
+
if (!img) {
|
|
548
|
+
this.setStatus('no image in clipboard');
|
|
549
|
+
this.tui.requestRender();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
this.addAttachment('image', img.mime, img.data);
|
|
553
|
+
}
|
|
554
|
+
async attachFromFile(path, ext) {
|
|
555
|
+
const kind = IMAGE_MIME[ext] ? 'image' : 'audio';
|
|
556
|
+
const mime = IMAGE_MIME[ext] ?? AUDIO_MIME[ext];
|
|
557
|
+
try {
|
|
558
|
+
const buf = await (0, promises_1.readFile)((0, node_path_1.resolve)(this.host.cwd, path));
|
|
559
|
+
this.addAttachment(kind, mime, new Uint8Array(buf));
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
this.setStatus(`could not read ${path}`);
|
|
563
|
+
this.tui.requestRender();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
addAttachment(kind, mime, data) {
|
|
567
|
+
if (!this.capable(kind)) {
|
|
568
|
+
this.setStatus(`the active model has no ${kind} capability — ${kind} not attached`);
|
|
569
|
+
this.tui.requestRender();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const placeholder = `[${kind} #${++this.attachSeq}]`;
|
|
573
|
+
this.attachments.set(placeholder, kind === 'image' ? (0, mu_core_1.image)(mime, data) : (0, mu_core_1.audio)(mime, data));
|
|
574
|
+
const value = this.editor.getValue();
|
|
575
|
+
this.editor.setValue(value ? `${value} ${placeholder} ` : `${placeholder} `);
|
|
576
|
+
this.onInputChange(this.editor.getValue());
|
|
577
|
+
this.tui.requestRender();
|
|
578
|
+
}
|
|
579
|
+
stripAttachmentPlaceholders(text) {
|
|
580
|
+
return text.replace(ATTACHMENT_PLACEHOLDER, '').replace(/[ \t]{2,}/g, ' ').trim();
|
|
581
|
+
}
|
|
582
|
+
clearAttachments() {
|
|
583
|
+
this.attachments.clear();
|
|
584
|
+
this.attachSeq = 0;
|
|
585
|
+
}
|
|
504
586
|
flushShellContext(userText) {
|
|
505
587
|
if (this.pendingShell.length === 0)
|
|
506
588
|
return userText;
|
|
@@ -518,8 +600,10 @@ class ChatApp {
|
|
|
518
600
|
return;
|
|
519
601
|
this.clearError();
|
|
520
602
|
const text = this.expandPastes(trimmed);
|
|
603
|
+
const attachments = [...this.attachments.values()];
|
|
521
604
|
this.editor.setValue('');
|
|
522
605
|
this.clearPastes();
|
|
606
|
+
this.clearAttachments();
|
|
523
607
|
this.pushHistory(text);
|
|
524
608
|
if (text.startsWith('!') || text.startsWith('$')) {
|
|
525
609
|
this.runShell(text.slice(1).trim());
|
|
@@ -532,11 +616,13 @@ class ChatApp {
|
|
|
532
616
|
if (this.tryDispatch(text))
|
|
533
617
|
return;
|
|
534
618
|
if (this.running) {
|
|
619
|
+
if (attachments.length > 0)
|
|
620
|
+
this.setStatus('attachments are dropped for messages queued mid-turn');
|
|
535
621
|
this.queue.push(text);
|
|
536
622
|
this.tui.requestRender();
|
|
537
623
|
return;
|
|
538
624
|
}
|
|
539
|
-
this.send(text);
|
|
625
|
+
this.send(text, attachments);
|
|
540
626
|
}
|
|
541
627
|
clearPastes() {
|
|
542
628
|
this.pastes.clear();
|
|
@@ -549,6 +635,7 @@ class ChatApp {
|
|
|
549
635
|
const text = this.expandPastes(value);
|
|
550
636
|
this.editor.setValue('');
|
|
551
637
|
this.clearPastes();
|
|
638
|
+
this.clearAttachments();
|
|
552
639
|
this.pushHistory(text);
|
|
553
640
|
this.queue.push(text);
|
|
554
641
|
this.tui.requestRender();
|
|
@@ -558,6 +645,10 @@ class ChatApp {
|
|
|
558
645
|
if (!value.includes(placeholder))
|
|
559
646
|
this.pastes.delete(placeholder);
|
|
560
647
|
}
|
|
648
|
+
for (const placeholder of this.attachments.keys()) {
|
|
649
|
+
if (!value.includes(placeholder))
|
|
650
|
+
this.attachments.delete(placeholder);
|
|
651
|
+
}
|
|
561
652
|
if (value !== this.paletteDismissedFor)
|
|
562
653
|
this.paletteDismissedFor = '__none__';
|
|
563
654
|
const items = this.paletteItems();
|