mu-harness 0.32.0 → 0.33.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 +59 -1
- package/esm/channels/ws/protocol.d.ts +20 -0
- package/esm/channels/ws/protocol.js +22 -1
- package/esm/channels/ws/server.js +24 -1
- package/esm/channels/ws/ws-client.d.ts +6 -1
- package/esm/channels/ws/ws-client.js +21 -1
- package/esm/harness/create.js +28 -12
- package/esm/harness/index.d.ts +1 -0
- package/esm/harness/index.js +1 -0
- package/esm/harness/types.d.ts +6 -0
- package/esm/harness/voice.d.ts +10 -0
- package/esm/harness/voice.js +38 -0
- package/esm/permissions/approval-manager.js +3 -6
- package/esm/session/decorate.d.ts +9 -0
- package/esm/session/decorate.js +25 -0
- package/esm/session/manager.js +8 -25
- package/esm/session/persist.js +15 -26
- package/esm/session/store.d.ts +1 -0
- package/esm/session/store.js +36 -3
- package/esm/tui/chat/ChatApp.d.ts +25 -0
- package/esm/tui/chat/ChatApp.js +337 -4
- package/esm/tui/chat/commands.d.ts +2 -0
- package/esm/tui/chat/commands.js +10 -0
- package/esm/tui/chat/index.d.ts +1 -0
- package/esm/tui/chat/index.js +1 -0
- package/esm/tui/chat/status.d.ts +4 -0
- package/esm/tui/chat/status.js +11 -1
- package/esm/tui/chat/voice.d.ts +52 -0
- package/esm/tui/chat/voice.js +244 -0
- package/package.json +3 -3
- package/script/channels/ws/client.js +59 -1
- package/script/channels/ws/protocol.d.ts +20 -0
- package/script/channels/ws/protocol.js +22 -1
- package/script/channels/ws/server.js +24 -1
- package/script/channels/ws/ws-client.d.ts +6 -1
- package/script/channels/ws/ws-client.js +21 -1
- package/script/harness/create.js +28 -12
- package/script/harness/index.d.ts +1 -0
- package/script/harness/index.js +4 -1
- package/script/harness/types.d.ts +6 -0
- package/script/harness/voice.d.ts +10 -0
- package/script/harness/voice.js +42 -0
- package/script/permissions/approval-manager.js +3 -6
- package/script/session/decorate.d.ts +9 -0
- package/script/session/decorate.js +29 -0
- package/script/session/manager.js +8 -25
- package/script/session/persist.js +15 -26
- package/script/session/store.d.ts +1 -0
- package/script/session/store.js +35 -2
- package/script/tui/chat/ChatApp.d.ts +25 -0
- package/script/tui/chat/ChatApp.js +337 -4
- package/script/tui/chat/commands.d.ts +2 -0
- package/script/tui/chat/commands.js +10 -0
- package/script/tui/chat/index.d.ts +1 -0
- package/script/tui/chat/index.js +1 -0
- package/script/tui/chat/status.d.ts +4 -0
- package/script/tui/chat/status.js +11 -1
- package/script/tui/chat/voice.d.ts +52 -0
- package/script/tui/chat/voice.js +252 -0
|
@@ -44,6 +44,26 @@ export async function connectHarness(opts) {
|
|
|
44
44
|
const listWaiters = [];
|
|
45
45
|
const modelWaiters = [];
|
|
46
46
|
const rawWaiters = new Map();
|
|
47
|
+
const voiceWaiters = new Map();
|
|
48
|
+
const voiceCheckWaiters = new Map();
|
|
49
|
+
// Settle every request-keyed voice waiter so transcribe()/unavailableReason()
|
|
50
|
+
// never hang when the socket drops (or the server tears the connection down,
|
|
51
|
+
// e.g. an oversized frame). voice:check resolves with a reason string (its
|
|
52
|
+
// contract is `string | undefined`) so the caller shows an error rather than
|
|
53
|
+
// throwing; voice:transcribe rejects.
|
|
54
|
+
const failVoiceWaiters = (message) => {
|
|
55
|
+
const err = new Error(message);
|
|
56
|
+
for (const w of voiceWaiters.values())
|
|
57
|
+
w.reject(err);
|
|
58
|
+
voiceWaiters.clear();
|
|
59
|
+
for (const resolve of voiceCheckWaiters.values())
|
|
60
|
+
resolve(message);
|
|
61
|
+
voiceCheckWaiters.clear();
|
|
62
|
+
for (const w of subagentWaiters.values())
|
|
63
|
+
w.reject(err);
|
|
64
|
+
subagentWaiters.clear();
|
|
65
|
+
};
|
|
66
|
+
client.onClose(() => failVoiceWaiters('connection lost'));
|
|
47
67
|
const agentsReady = deferred();
|
|
48
68
|
const route = (sessionId, event) => sessionsById.get(sessionId)?._emit(event);
|
|
49
69
|
const applyRaw = (sessionId, messages) => {
|
|
@@ -164,7 +184,9 @@ export async function connectHarness(opts) {
|
|
|
164
184
|
cwd: opts.cwd,
|
|
165
185
|
createdAt: s.createdAt,
|
|
166
186
|
}));
|
|
167
|
-
listWaiters.
|
|
187
|
+
const waiters = listWaiters.splice(0);
|
|
188
|
+
for (const w of waiters)
|
|
189
|
+
w(records);
|
|
168
190
|
return;
|
|
169
191
|
}
|
|
170
192
|
case 'sessions:raw':
|
|
@@ -182,6 +204,18 @@ export async function connectHarness(opts) {
|
|
|
182
204
|
subagentWaiters.get(frame.requestId)?.reject(new Error(frame.message));
|
|
183
205
|
subagentWaiters.delete(frame.requestId);
|
|
184
206
|
return;
|
|
207
|
+
case 'voice:availability':
|
|
208
|
+
voiceCheckWaiters.get(frame.requestId)?.(frame.reason);
|
|
209
|
+
voiceCheckWaiters.delete(frame.requestId);
|
|
210
|
+
return;
|
|
211
|
+
case 'voice:result':
|
|
212
|
+
voiceWaiters.get(frame.requestId)?.resolve(frame.text);
|
|
213
|
+
voiceWaiters.delete(frame.requestId);
|
|
214
|
+
return;
|
|
215
|
+
case 'voice:error':
|
|
216
|
+
voiceWaiters.get(frame.requestId)?.reject(new Error(frame.message));
|
|
217
|
+
voiceWaiters.delete(frame.requestId);
|
|
218
|
+
return;
|
|
185
219
|
case 'approval_request': {
|
|
186
220
|
const req = { id: frame.requestId, name: frame.toolName, input: parseArgs(frame.args) };
|
|
187
221
|
for (const l of [...approvalListeners])
|
|
@@ -309,6 +343,30 @@ export async function connectHarness(opts) {
|
|
|
309
343
|
modelLoadingListeners.add(listener);
|
|
310
344
|
return () => modelLoadingListeners.delete(listener);
|
|
311
345
|
},
|
|
346
|
+
voice: {
|
|
347
|
+
unavailableReason: () => new Promise((resolve) => {
|
|
348
|
+
const requestId = crypto.randomUUID();
|
|
349
|
+
voiceCheckWaiters.set(requestId, resolve);
|
|
350
|
+
if (!client.send({ type: 'voice:check', requestId })) {
|
|
351
|
+
voiceCheckWaiters.delete(requestId);
|
|
352
|
+
resolve('voice unavailable: not connected');
|
|
353
|
+
}
|
|
354
|
+
}),
|
|
355
|
+
transcribe: (data, mime) => new Promise((resolve, reject) => {
|
|
356
|
+
const requestId = crypto.randomUUID();
|
|
357
|
+
voiceWaiters.set(requestId, { resolve, reject });
|
|
358
|
+
const sent = client.send({
|
|
359
|
+
type: 'voice:transcribe',
|
|
360
|
+
requestId,
|
|
361
|
+
mime,
|
|
362
|
+
data: Buffer.from(data).toString('base64'),
|
|
363
|
+
});
|
|
364
|
+
if (!sent) {
|
|
365
|
+
voiceWaiters.delete(requestId);
|
|
366
|
+
reject(new Error('not connected'));
|
|
367
|
+
}
|
|
368
|
+
}),
|
|
369
|
+
},
|
|
312
370
|
banner: opts.banner,
|
|
313
371
|
minimal: opts.minimal,
|
|
314
372
|
commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
|
|
@@ -62,6 +62,14 @@ export type WsInbound = {
|
|
|
62
62
|
} | {
|
|
63
63
|
type: 'sessions:get';
|
|
64
64
|
sessionId: string;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'voice:check';
|
|
67
|
+
requestId: string;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'voice:transcribe';
|
|
70
|
+
requestId: string;
|
|
71
|
+
mime: string;
|
|
72
|
+
data: string;
|
|
65
73
|
};
|
|
66
74
|
export declare function parseInbound(raw: unknown): WsInbound | {
|
|
67
75
|
error: string;
|
|
@@ -217,6 +225,18 @@ export type WsOutbound = {
|
|
|
217
225
|
type: 'subagent:error';
|
|
218
226
|
requestId: string;
|
|
219
227
|
message: string;
|
|
228
|
+
} | {
|
|
229
|
+
type: 'voice:availability';
|
|
230
|
+
requestId: string;
|
|
231
|
+
reason?: string;
|
|
232
|
+
} | {
|
|
233
|
+
type: 'voice:result';
|
|
234
|
+
requestId: string;
|
|
235
|
+
text: string;
|
|
236
|
+
} | {
|
|
237
|
+
type: 'voice:error';
|
|
238
|
+
requestId: string;
|
|
239
|
+
message: string;
|
|
220
240
|
} | {
|
|
221
241
|
type: 'sessions:listed';
|
|
222
242
|
sessions: SessionSummaryWire[];
|
|
@@ -24,7 +24,12 @@ export function parseInbound(raw) {
|
|
|
24
24
|
if (typeof o.text !== 'string')
|
|
25
25
|
return { error: 'chat requires text:string' };
|
|
26
26
|
const attachments = parseAttachments(o.attachments);
|
|
27
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
type: 'chat',
|
|
29
|
+
sessionId: optionalString(o.sessionId),
|
|
30
|
+
text: o.text,
|
|
31
|
+
...(attachments ? { attachments } : {}),
|
|
32
|
+
};
|
|
28
33
|
}
|
|
29
34
|
case 'command': {
|
|
30
35
|
if (typeof o.text !== 'string')
|
|
@@ -102,6 +107,22 @@ export function parseInbound(raw) {
|
|
|
102
107
|
return { error: 'sessions:get requires sessionId' };
|
|
103
108
|
return { type: 'sessions:get', sessionId: o.sessionId };
|
|
104
109
|
}
|
|
110
|
+
case 'voice:check': {
|
|
111
|
+
const requestId = typeof o.requestId === 'string' ? o.requestId : '';
|
|
112
|
+
if (!requestId)
|
|
113
|
+
return { error: 'voice:check requires requestId' };
|
|
114
|
+
return { type: 'voice:check', requestId };
|
|
115
|
+
}
|
|
116
|
+
case 'voice:transcribe': {
|
|
117
|
+
const requestId = typeof o.requestId === 'string' ? o.requestId : '';
|
|
118
|
+
if (!requestId)
|
|
119
|
+
return { error: 'voice:transcribe requires requestId' };
|
|
120
|
+
if (typeof o.mime !== 'string' || !o.mime)
|
|
121
|
+
return { error: 'voice:transcribe requires mime:string' };
|
|
122
|
+
if (typeof o.data !== 'string' || !o.data)
|
|
123
|
+
return { error: 'voice:transcribe requires data:string (base64)' };
|
|
124
|
+
return { type: 'voice:transcribe', requestId, mime: o.mime, data: o.data };
|
|
125
|
+
}
|
|
105
126
|
default:
|
|
106
127
|
return { error: `unknown message type: ${type || '<empty>'}` };
|
|
107
128
|
}
|
|
@@ -89,7 +89,11 @@ export function webSocketAdapter(opts) {
|
|
|
89
89
|
const allowed = attachments.filter((a) => (a.kind === 'image' ? caps.vision : caps.audio));
|
|
90
90
|
if (allowed.length < attachments.length) {
|
|
91
91
|
const kinds = [...new Set(attachments.filter((a) => !allowed.includes(a)).map((a) => a.kind))].join('/');
|
|
92
|
-
push({
|
|
92
|
+
push({
|
|
93
|
+
type: 'error',
|
|
94
|
+
sessionId,
|
|
95
|
+
message: `the active model has no ${kinds} capability — attachment(s) dropped`,
|
|
96
|
+
});
|
|
93
97
|
}
|
|
94
98
|
if (allowed.length === 0)
|
|
95
99
|
return body;
|
|
@@ -233,6 +237,25 @@ export function webSocketAdapter(opts) {
|
|
|
233
237
|
});
|
|
234
238
|
return;
|
|
235
239
|
}
|
|
240
|
+
case 'voice:check': {
|
|
241
|
+
const reason = await harness.voice.unavailableReason().catch((err) => err instanceof Error ? err.message : String(err));
|
|
242
|
+
send(client.ws, { type: 'voice:availability', requestId: msg.requestId, reason: reason ?? undefined });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case 'voice:transcribe': {
|
|
246
|
+
try {
|
|
247
|
+
const text = await harness.voice.transcribe(new Uint8Array(Buffer.from(msg.data, 'base64')), msg.mime);
|
|
248
|
+
send(client.ws, { type: 'voice:result', requestId: msg.requestId, text });
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
send(client.ws, {
|
|
252
|
+
type: 'voice:error',
|
|
253
|
+
requestId: msg.requestId,
|
|
254
|
+
message: err instanceof Error ? err.message : String(err),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
236
259
|
}
|
|
237
260
|
}
|
|
238
261
|
function handleApprovalResponse(client, requestId, action) {
|
|
@@ -6,8 +6,13 @@ export interface WsClientOptions {
|
|
|
6
6
|
}
|
|
7
7
|
export interface WsClient {
|
|
8
8
|
connect(): Promise<void>;
|
|
9
|
-
|
|
9
|
+
/** Returns false (frame dropped) when the socket is not OPEN, so callers can
|
|
10
|
+
* settle a just-registered request waiter instead of hanging forever. */
|
|
11
|
+
send(frame: WsInbound): boolean;
|
|
10
12
|
on(handler: (frame: WsOutbound) => void): () => void;
|
|
13
|
+
/** Fires once when the socket closes (after a normal close or an error), so the
|
|
14
|
+
* caller can reject any in-flight request waiters. */
|
|
15
|
+
onClose(handler: () => void): () => void;
|
|
11
16
|
close(): Promise<void>;
|
|
12
17
|
}
|
|
13
18
|
export declare function createWsClient(opts: WsClientOptions): WsClient;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { WebSocket } from 'ws';
|
|
2
2
|
export function createWsClient(opts) {
|
|
3
3
|
const handlers = new Set();
|
|
4
|
+
const closeHandlers = new Set();
|
|
4
5
|
let ws = null;
|
|
6
|
+
let closeNotified = false;
|
|
5
7
|
const url = () => {
|
|
6
8
|
const u = new URL(opts.url);
|
|
7
9
|
if (opts.token)
|
|
@@ -10,14 +12,25 @@ export function createWsClient(opts) {
|
|
|
10
12
|
u.searchParams.set('sessionId', opts.sessionId);
|
|
11
13
|
return u.toString();
|
|
12
14
|
};
|
|
15
|
+
const notifyClose = () => {
|
|
16
|
+
if (closeNotified)
|
|
17
|
+
return;
|
|
18
|
+
closeNotified = true;
|
|
19
|
+
for (const h of [...closeHandlers])
|
|
20
|
+
h();
|
|
21
|
+
};
|
|
13
22
|
return {
|
|
14
23
|
connect: () => new Promise((resolve, reject) => {
|
|
15
24
|
const socket = new WebSocket(url());
|
|
16
25
|
ws = socket;
|
|
26
|
+
closeNotified = false;
|
|
17
27
|
socket.on('open', () => resolve());
|
|
18
28
|
// Pre-open failures reject connect(); post-open the promise is settled and
|
|
19
29
|
// this is a harmless no-op that keeps the error handled.
|
|
20
30
|
socket.on('error', (err) => reject(err));
|
|
31
|
+
// Fires on both a remote close and after an error tears the socket down;
|
|
32
|
+
// lets connectHarness reject in-flight request waiters instead of hanging.
|
|
33
|
+
socket.on('close', () => notifyClose());
|
|
21
34
|
socket.on('message', (data) => {
|
|
22
35
|
let frame;
|
|
23
36
|
try {
|
|
@@ -31,13 +44,20 @@ export function createWsClient(opts) {
|
|
|
31
44
|
});
|
|
32
45
|
}),
|
|
33
46
|
send: (frame) => {
|
|
34
|
-
if (ws && ws.readyState === WebSocket.OPEN)
|
|
47
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
35
48
|
ws.send(JSON.stringify(frame));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
36
52
|
},
|
|
37
53
|
on: (handler) => {
|
|
38
54
|
handlers.add(handler);
|
|
39
55
|
return () => handlers.delete(handler);
|
|
40
56
|
},
|
|
57
|
+
onClose: (handler) => {
|
|
58
|
+
closeHandlers.add(handler);
|
|
59
|
+
return () => closeHandlers.delete(handler);
|
|
60
|
+
},
|
|
41
61
|
close: () => new Promise((resolve) => {
|
|
42
62
|
const socket = ws;
|
|
43
63
|
ws = null;
|
package/esm/harness/create.js
CHANGED
|
@@ -16,6 +16,7 @@ import { dirsForPath, loadInstructions } from './instructions.js';
|
|
|
16
16
|
import { createMemoryStore, createRememberTool } from './memory.js';
|
|
17
17
|
import { createCompactionHook } from './compaction.js';
|
|
18
18
|
import { createModelRegistry } from './models.js';
|
|
19
|
+
import { createVoice } from './voice.js';
|
|
19
20
|
const TITLE_AGENT = {
|
|
20
21
|
name: 'title',
|
|
21
22
|
description: 'Generates a concise session title from the first user message.',
|
|
@@ -23,7 +24,18 @@ const TITLE_AGENT = {
|
|
|
23
24
|
tools: [],
|
|
24
25
|
};
|
|
25
26
|
/** Tool-input field names that carry filesystem paths — used to scope nested AGENTS.md. */
|
|
26
|
-
const PATH_KEYS = new Set([
|
|
27
|
+
const PATH_KEYS = new Set([
|
|
28
|
+
'path',
|
|
29
|
+
'file',
|
|
30
|
+
'filename',
|
|
31
|
+
'file_path',
|
|
32
|
+
'filepath',
|
|
33
|
+
'dir',
|
|
34
|
+
'directory',
|
|
35
|
+
'cwd',
|
|
36
|
+
'paths',
|
|
37
|
+
'files',
|
|
38
|
+
]);
|
|
27
39
|
function pathsFromInput(input) {
|
|
28
40
|
if (!input || typeof input !== 'object')
|
|
29
41
|
return [];
|
|
@@ -33,10 +45,11 @@ function pathsFromInput(input) {
|
|
|
33
45
|
continue;
|
|
34
46
|
if (typeof value === 'string')
|
|
35
47
|
out.push(value);
|
|
36
|
-
else if (Array.isArray(value))
|
|
48
|
+
else if (Array.isArray(value)) {
|
|
37
49
|
for (const el of value)
|
|
38
50
|
if (typeof el === 'string')
|
|
39
51
|
out.push(el);
|
|
52
|
+
}
|
|
40
53
|
}
|
|
41
54
|
return out;
|
|
42
55
|
}
|
|
@@ -45,6 +58,7 @@ export const createHarness = async (options) => {
|
|
|
45
58
|
const cwd = options.cwd ?? process.cwd();
|
|
46
59
|
const config = createHarnessConfig({ hostName, xdg });
|
|
47
60
|
const models = createModelRegistry({ providers, default: model });
|
|
61
|
+
const voice = createVoice(models, options.voice);
|
|
48
62
|
const pluginsDir = join(config.configDir, 'plugins');
|
|
49
63
|
const agentsDir = agentDirs?.config ?? join(config.configDir, 'agents');
|
|
50
64
|
const localAgentsDir = agentDirs?.local ?? join(cwd, 'agents');
|
|
@@ -155,17 +169,16 @@ export const createHarness = async (options) => {
|
|
|
155
169
|
...models.resolve(opts.model ?? agent.model),
|
|
156
170
|
id: newId(),
|
|
157
171
|
});
|
|
172
|
+
// Hooks every session gets regardless of how it was created (fresh spawn vs revived
|
|
173
|
+
// from disk). spawn/revive each prepend their own allow-list + approval hook to these.
|
|
174
|
+
const baseHooks = [envHook, instructionsHook, memoryHook, trackPathsHook, compactionHook];
|
|
158
175
|
const spawn = (agent) => persistTo(store, persona(agent, {
|
|
159
176
|
tools: sessionTools(agent),
|
|
160
177
|
hooks: mergeHooks([
|
|
161
178
|
sessionDefaults.hooks,
|
|
162
179
|
allowList(toolNames(agent)),
|
|
163
180
|
approvalHook(() => agent),
|
|
164
|
-
|
|
165
|
-
instructionsHook,
|
|
166
|
-
memoryHook,
|
|
167
|
-
trackPathsHook,
|
|
168
|
-
compactionHook,
|
|
181
|
+
...baseHooks,
|
|
169
182
|
]),
|
|
170
183
|
}));
|
|
171
184
|
const scheduler = enableScheduler && tasks
|
|
@@ -205,6 +218,12 @@ export const createHarness = async (options) => {
|
|
|
205
218
|
newId,
|
|
206
219
|
cwd,
|
|
207
220
|
title: title === false ? undefined : ({ id, text }) => {
|
|
221
|
+
// Internal/hidden sessions (id prefixed with `__`, e.g. a voice STT scratch
|
|
222
|
+
// session) are machinery, not conversations: don't spend a title-model turn
|
|
223
|
+
// on them — it both wastes a call and interleaves the main model into an
|
|
224
|
+
// otherwise single-model flow.
|
|
225
|
+
if (id.startsWith('__'))
|
|
226
|
+
return;
|
|
208
227
|
void runTitler({
|
|
209
228
|
id,
|
|
210
229
|
text,
|
|
@@ -217,11 +236,7 @@ export const createHarness = async (options) => {
|
|
|
217
236
|
hooks: mergeHooks([
|
|
218
237
|
sessionDefaults.hooks,
|
|
219
238
|
approvalHook(() => approvals?.activeAgent()),
|
|
220
|
-
|
|
221
|
-
instructionsHook,
|
|
222
|
-
memoryHook,
|
|
223
|
-
trackPathsHook,
|
|
224
|
-
compactionHook,
|
|
239
|
+
...baseHooks,
|
|
225
240
|
]),
|
|
226
241
|
tools: sessionTools(undefined, [createSubAgentTool({ registry: agents, spawn, runs, parentId: id })]),
|
|
227
242
|
...models.resolve(ref),
|
|
@@ -259,6 +274,7 @@ export const createHarness = async (options) => {
|
|
|
259
274
|
return {
|
|
260
275
|
config,
|
|
261
276
|
models,
|
|
277
|
+
voice,
|
|
262
278
|
plugins,
|
|
263
279
|
sessions,
|
|
264
280
|
agents,
|
package/esm/harness/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { type CompactionOptions, createCompactionHook } from './compaction.js';
|
|
|
4
4
|
export { createMemoryStore, createRememberTool, type MemoryScope, type MemoryStore } from './memory.js';
|
|
5
5
|
export { loadInstructions } from './instructions.js';
|
|
6
6
|
export { createModelRegistry, type ModelRegistry, type ModelRegistryOptions, type ResolvedModel } from './models.js';
|
|
7
|
+
export { createVoice, VOICE_UNAVAILABLE, type VoiceOptions, type VoiceTranscriber } from './voice.js';
|
package/esm/harness/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { createCompactionHook } from './compaction.js';
|
|
|
3
3
|
export { createMemoryStore, createRememberTool } from './memory.js';
|
|
4
4
|
export { loadInstructions } from './instructions.js';
|
|
5
5
|
export { createModelRegistry } from './models.js';
|
|
6
|
+
export { createVoice, VOICE_UNAVAILABLE } from './voice.js';
|
package/esm/harness/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Skill, SkillRegistry } from '../skills/index.js';
|
|
|
10
10
|
import type { SubAgentRegistry, SubAgentResult } from '../subAgents/index.js';
|
|
11
11
|
import type { CompactionOptions } from './compaction.js';
|
|
12
12
|
import type { ModelRegistry } from './models.js';
|
|
13
|
+
import type { VoiceTranscriber } from './voice.js';
|
|
13
14
|
export type HarnessOptions = HarnessConfigOptions & Omit<AgentSessionConfig, 'provider' | 'model' | 'id' | 'messages'> & {
|
|
14
15
|
providers: Record<string, Provider>;
|
|
15
16
|
model: string;
|
|
@@ -25,6 +26,10 @@ export type HarnessOptions = HarnessConfigOptions & Omit<AgentSessionConfig, 'pr
|
|
|
25
26
|
cwd?: string;
|
|
26
27
|
sourceUrl?: string;
|
|
27
28
|
scheduler?: boolean;
|
|
29
|
+
/** Speech-to-text for `/voice`. `model` is sent recorded audio; falls back to the selected model when unset. */
|
|
30
|
+
voice?: {
|
|
31
|
+
model?: string;
|
|
32
|
+
};
|
|
28
33
|
/** Auto-compaction settings, or `false` to disable. Default: enabled at 80% of the window. */
|
|
29
34
|
compaction?: CompactionOptions | false;
|
|
30
35
|
approvals?: {
|
|
@@ -39,6 +44,7 @@ export type HarnessOptions = HarnessConfigOptions & Omit<AgentSessionConfig, 'pr
|
|
|
39
44
|
export interface Harness {
|
|
40
45
|
readonly config: HarnessConfig;
|
|
41
46
|
readonly models: ModelRegistry;
|
|
47
|
+
readonly voice: VoiceTranscriber;
|
|
42
48
|
readonly plugins: PluginStore;
|
|
43
49
|
readonly sessions: SessionManager;
|
|
44
50
|
readonly agents: AgentRegistry;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModelRegistry } from './models.js';
|
|
2
|
+
export declare const VOICE_UNAVAILABLE = "voiceModel not configured and the current model doesn't support sound";
|
|
3
|
+
export interface VoiceOptions {
|
|
4
|
+
model?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface VoiceTranscriber {
|
|
7
|
+
transcribe(data: Uint8Array, mime: string): Promise<string>;
|
|
8
|
+
unavailableReason(): Promise<string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
export declare const createVoice: (models: ModelRegistry, options?: VoiceOptions) => VoiceTranscriber;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { audio } from 'mu-core';
|
|
2
|
+
const TRANSCRIBE_PROMPT = 'Transcribe the speech in this audio verbatim. Output only the transcribed words as plain text, with no preamble, quotes, commentary, or translation. If there is no intelligible speech, output nothing.';
|
|
3
|
+
export const VOICE_UNAVAILABLE = "voiceModel not configured and the current model doesn't support sound";
|
|
4
|
+
export const createVoice = (models, options = {}) => {
|
|
5
|
+
const ref = () => {
|
|
6
|
+
if (!options.model)
|
|
7
|
+
return models.selected;
|
|
8
|
+
if (options.model.includes('/'))
|
|
9
|
+
return options.model;
|
|
10
|
+
const prefix = models.selected.split('/')[0];
|
|
11
|
+
return `${prefix}/${options.model}`;
|
|
12
|
+
};
|
|
13
|
+
const unavailableReason = async () => {
|
|
14
|
+
if (options.model)
|
|
15
|
+
return undefined;
|
|
16
|
+
const caps = await models.capabilities(ref()).catch(() => undefined);
|
|
17
|
+
return caps?.audio ? undefined : VOICE_UNAVAILABLE;
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
unavailableReason,
|
|
21
|
+
transcribe: async (data, mime) => {
|
|
22
|
+
const reason = await unavailableReason();
|
|
23
|
+
if (reason)
|
|
24
|
+
throw new Error(reason);
|
|
25
|
+
const { provider, model } = models.resolve(ref());
|
|
26
|
+
const messages = [{
|
|
27
|
+
role: 'user',
|
|
28
|
+
content: [{ type: 'text', text: TRANSCRIBE_PROMPT }, audio(mime, data)],
|
|
29
|
+
}];
|
|
30
|
+
let text = '';
|
|
31
|
+
for await (const event of provider.stream({ model, messages, tools: [] })) {
|
|
32
|
+
if (event.type === 'text')
|
|
33
|
+
text += event.text;
|
|
34
|
+
}
|
|
35
|
+
return text.trim();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { requireApproval } from './approval.js';
|
|
2
1
|
const denied = (name) => [{ type: 'text', text: `Denied: ${name}` }];
|
|
3
2
|
export const createApprovalManager = (options = {}) => {
|
|
4
3
|
const askTools = options.askTools ? new Set(options.askTools) : undefined;
|
|
@@ -14,11 +13,6 @@ export const createApprovalManager = (options = {}) => {
|
|
|
14
13
|
listener(req);
|
|
15
14
|
});
|
|
16
15
|
const defaultNeeds = options.needsApproval ?? (({ name }) => (askTools ? askTools.has(name) : true));
|
|
17
|
-
const hooks = requireApproval({
|
|
18
|
-
needsApproval: (call) => defaultNeeds(call) && !alwaysAllow.has(keyOf(undefined, call.name)),
|
|
19
|
-
newId,
|
|
20
|
-
prompt: (call) => request(call.id, call.name, call.input, undefined),
|
|
21
|
-
});
|
|
22
16
|
const hooksFor = ({ decide, agent }) => ({
|
|
23
17
|
beforeToolCall: async (call) => {
|
|
24
18
|
const decision = decide(call);
|
|
@@ -33,6 +27,9 @@ export const createApprovalManager = (options = {}) => {
|
|
|
33
27
|
return allow ? undefined : denied(call.name);
|
|
34
28
|
},
|
|
35
29
|
});
|
|
30
|
+
// Default hooks (no per-agent policy): ask for every tool that needs approval, allow the
|
|
31
|
+
// rest. Defined via hooksFor so the beforeToolCall flow lives in exactly one place.
|
|
32
|
+
const hooks = hooksFor({ decide: (call) => (defaultNeeds(call) ? 'ask' : 'allow') });
|
|
36
33
|
return {
|
|
37
34
|
hooks,
|
|
38
35
|
hooksFor,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContentPart } from 'mu-core';
|
|
2
|
+
import type { AgentSession } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wrap an AgentSession, overriding ONLY `send`. Every other member is forwarded
|
|
5
|
+
* to the inner session verbatim. The "decorator skeleton" lives here so the
|
|
6
|
+
* persistence wrapper (persistTo) and the title-on-first-message wrapper share
|
|
7
|
+
* one passthrough definition instead of each re-listing every AgentSession field.
|
|
8
|
+
*/
|
|
9
|
+
export declare const decorateSession: (session: AgentSession, send: (input: string | ContentPart[]) => Promise<void>) => AgentSession;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap an AgentSession, overriding ONLY `send`. Every other member is forwarded
|
|
3
|
+
* to the inner session verbatim. The "decorator skeleton" lives here so the
|
|
4
|
+
* persistence wrapper (persistTo) and the title-on-first-message wrapper share
|
|
5
|
+
* one passthrough definition instead of each re-listing every AgentSession field.
|
|
6
|
+
*/
|
|
7
|
+
export const decorateSession = (session, send) => ({
|
|
8
|
+
get id() {
|
|
9
|
+
return session.id;
|
|
10
|
+
},
|
|
11
|
+
get messages() {
|
|
12
|
+
return session.messages;
|
|
13
|
+
},
|
|
14
|
+
get tools() {
|
|
15
|
+
return session.tools;
|
|
16
|
+
},
|
|
17
|
+
model: session.model,
|
|
18
|
+
assembleRequest: session.assembleRequest?.bind(session),
|
|
19
|
+
countTokens: session.countTokens?.bind(session),
|
|
20
|
+
contextWindow: session.contextWindow?.bind(session),
|
|
21
|
+
compact: session.compact?.bind(session),
|
|
22
|
+
send,
|
|
23
|
+
abort: session.abort,
|
|
24
|
+
subscribe: session.subscribe,
|
|
25
|
+
});
|
package/esm/session/manager.js
CHANGED
|
@@ -1,32 +1,15 @@
|
|
|
1
|
+
import { decorateSession } from './decorate.js';
|
|
1
2
|
import { persistTo } from './persist.js';
|
|
2
3
|
const inputText = (input) => typeof input === 'string' ? input : input.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
3
4
|
const onFirstMessage = (session, fire) => {
|
|
4
5
|
let pending = !session.messages.some((message) => message.role === 'user');
|
|
5
|
-
return {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
get tools() {
|
|
13
|
-
return session.tools;
|
|
14
|
-
},
|
|
15
|
-
model: session.model,
|
|
16
|
-
assembleRequest: session.assembleRequest?.bind(session),
|
|
17
|
-
countTokens: session.countTokens?.bind(session),
|
|
18
|
-
contextWindow: session.contextWindow?.bind(session),
|
|
19
|
-
compact: session.compact?.bind(session),
|
|
20
|
-
send: async (input) => {
|
|
21
|
-
if (pending) {
|
|
22
|
-
pending = false;
|
|
23
|
-
fire({ id: session.id, text: inputText(input) });
|
|
24
|
-
}
|
|
25
|
-
await session.send(input);
|
|
26
|
-
},
|
|
27
|
-
abort: session.abort,
|
|
28
|
-
subscribe: session.subscribe,
|
|
29
|
-
};
|
|
6
|
+
return decorateSession(session, async (input) => {
|
|
7
|
+
if (pending) {
|
|
8
|
+
pending = false;
|
|
9
|
+
fire({ id: session.id, text: inputText(input) });
|
|
10
|
+
}
|
|
11
|
+
await session.send(input);
|
|
12
|
+
});
|
|
30
13
|
};
|
|
31
14
|
export const createSessionManager = ({ store, catalog, revive, newId, cwd, title }) => ({
|
|
32
15
|
create: (options) => {
|
package/esm/session/persist.js
CHANGED
|
@@ -1,29 +1,18 @@
|
|
|
1
|
+
import { decorateSession } from './decorate.js';
|
|
1
2
|
export const persistTo = (store, session, persisted = 0) => {
|
|
2
3
|
let count = persisted;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
compact: session.compact?.bind(session),
|
|
18
|
-
send: async (input) => {
|
|
19
|
-
await session.send(input);
|
|
20
|
-
const all = session.messages;
|
|
21
|
-
if (all.length > count) {
|
|
22
|
-
await store.append(session.id, all.slice(count));
|
|
23
|
-
count = all.length;
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
|
-
abort: session.abort,
|
|
27
|
-
subscribe: session.subscribe,
|
|
28
|
-
};
|
|
4
|
+
let last = count > 0 ? session.messages[count - 1] : undefined;
|
|
5
|
+
return decorateSession(session, async (input) => {
|
|
6
|
+
await session.send(input);
|
|
7
|
+
const all = session.messages;
|
|
8
|
+
const intact = count === 0 || all[count - 1] === last;
|
|
9
|
+
if (!intact) {
|
|
10
|
+
await store.rewrite(session.id, all);
|
|
11
|
+
}
|
|
12
|
+
else if (all.length > count) {
|
|
13
|
+
await store.append(session.id, all.slice(count));
|
|
14
|
+
}
|
|
15
|
+
count = all.length;
|
|
16
|
+
last = all.length > 0 ? all[all.length - 1] : undefined;
|
|
17
|
+
});
|
|
29
18
|
};
|
package/esm/session/store.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface StoredSession {
|
|
|
6
6
|
export interface SessionStore {
|
|
7
7
|
load(id: string): Promise<StoredSession>;
|
|
8
8
|
append(id: string, messages: Message[]): Promise<void>;
|
|
9
|
+
rewrite(id: string, messages: readonly Message[]): Promise<void>;
|
|
9
10
|
delete(id: string): Promise<void>;
|
|
10
11
|
}
|
|
11
12
|
export declare const createSessionStore: ({ dir }: {
|