mu-harness 0.31.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.
Files changed (61) hide show
  1. package/esm/channels/ws/client.js +59 -1
  2. package/esm/channels/ws/protocol.d.ts +20 -0
  3. package/esm/channels/ws/protocol.js +22 -1
  4. package/esm/channels/ws/server.js +24 -1
  5. package/esm/channels/ws/ws-client.d.ts +6 -1
  6. package/esm/channels/ws/ws-client.js +21 -1
  7. package/esm/commands/defaults.js +7 -4
  8. package/esm/harness/create.js +28 -12
  9. package/esm/harness/index.d.ts +1 -0
  10. package/esm/harness/index.js +1 -0
  11. package/esm/harness/types.d.ts +6 -0
  12. package/esm/harness/voice.d.ts +10 -0
  13. package/esm/harness/voice.js +38 -0
  14. package/esm/permissions/approval-manager.js +3 -6
  15. package/esm/session/decorate.d.ts +9 -0
  16. package/esm/session/decorate.js +25 -0
  17. package/esm/session/manager.js +8 -25
  18. package/esm/session/persist.js +15 -26
  19. package/esm/session/store.d.ts +1 -0
  20. package/esm/session/store.js +36 -3
  21. package/esm/tui/chat/ChatApp.d.ts +25 -0
  22. package/esm/tui/chat/ChatApp.js +337 -4
  23. package/esm/tui/chat/commands.d.ts +2 -0
  24. package/esm/tui/chat/commands.js +10 -0
  25. package/esm/tui/chat/index.d.ts +1 -0
  26. package/esm/tui/chat/index.js +1 -0
  27. package/esm/tui/chat/status.d.ts +4 -0
  28. package/esm/tui/chat/status.js +11 -1
  29. package/esm/tui/chat/voice.d.ts +52 -0
  30. package/esm/tui/chat/voice.js +244 -0
  31. package/package.json +3 -3
  32. package/script/channels/ws/client.js +59 -1
  33. package/script/channels/ws/protocol.d.ts +20 -0
  34. package/script/channels/ws/protocol.js +22 -1
  35. package/script/channels/ws/server.js +24 -1
  36. package/script/channels/ws/ws-client.d.ts +6 -1
  37. package/script/channels/ws/ws-client.js +21 -1
  38. package/script/commands/defaults.js +7 -4
  39. package/script/harness/create.js +28 -12
  40. package/script/harness/index.d.ts +1 -0
  41. package/script/harness/index.js +4 -1
  42. package/script/harness/types.d.ts +6 -0
  43. package/script/harness/voice.d.ts +10 -0
  44. package/script/harness/voice.js +42 -0
  45. package/script/permissions/approval-manager.js +3 -6
  46. package/script/session/decorate.d.ts +9 -0
  47. package/script/session/decorate.js +29 -0
  48. package/script/session/manager.js +8 -25
  49. package/script/session/persist.js +15 -26
  50. package/script/session/store.d.ts +1 -0
  51. package/script/session/store.js +35 -2
  52. package/script/tui/chat/ChatApp.d.ts +25 -0
  53. package/script/tui/chat/ChatApp.js +337 -4
  54. package/script/tui/chat/commands.d.ts +2 -0
  55. package/script/tui/chat/commands.js +10 -0
  56. package/script/tui/chat/index.d.ts +1 -0
  57. package/script/tui/chat/index.js +1 -0
  58. package/script/tui/chat/status.d.ts +4 -0
  59. package/script/tui/chat/status.js +11 -1
  60. package/script/tui/chat/voice.d.ts +52 -0
  61. 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.shift()?.(records);
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 { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text, ...(attachments ? { attachments } : {}) };
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({ type: 'error', sessionId, message: `the active model has no ${kinds} capability — attachment(s) dropped` });
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
- send(frame: WsInbound): void;
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;
@@ -54,7 +54,10 @@ const GREY = '\x1b[38;2;153;153;153m';
54
54
  const BOLD = '\x1b[1m';
55
55
  const ITALIC = '\x1b[3m';
56
56
  const BUFFER_COLOR = '\x1b[38;2;255;193;7m'; // amber — the compaction reserve
57
- const paint = (s, color) => `${color}${s}${RESET}`;
57
+ // Reset BEFORE the colour too: the TUI note prefixes each line with a `dim` muted style,
58
+ // and a bare `\x1b[38;2;…m` only sets the foreground — it wouldn't clear the inherited dim,
59
+ // which darkened the first cell of every row. The leading reset wipes it.
60
+ const paint = (s, color) => `${RESET}${color}${s}${RESET}`;
58
61
  const fmtTok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
59
62
  const pctStr = (n, w) => {
60
63
  if (w <= 0)
@@ -144,13 +147,13 @@ export const createContextCommand = () => ({
144
147
  // Right-hand info column — model, totals, then per-category usage (Claude Code's /context).
145
148
  const info = [];
146
149
  if (model) {
147
- info.push(model.split('/').pop() ?? model);
150
+ info.push(`${RESET}${model.split('/').pop() ?? model}`);
148
151
  info.push(paint(model, GREY));
149
152
  }
150
153
  if (window > 0)
151
154
  info.push(paint(`${mark(total)}/${fmtTok(window)} tokens (${pctStr(total, window)})`, GREY));
152
155
  info.push('');
153
- info.push(`${ITALIC}${GREY}Estimated usage by category${RESET}`);
156
+ info.push(`${RESET}${ITALIC}${GREY}Estimated usage by category${RESET}`);
154
157
  for (const c of cats) {
155
158
  info.push(`${paint(USED, c.color)} ${c.label}: ${paint(`${mark(c.n)} tokens (${pctStr(c.n, window)})`, GREY)}`);
156
159
  }
@@ -158,7 +161,7 @@ export const createContextCommand = () => ({
158
161
  info.push(`${paint(USED, BUFFER_COLOR)} Compaction buffer: ${paint(`${fmtTok(buffer)} tokens (${pctStr(buffer, window)})`, GREY)}`);
159
162
  info.push(`${paint(FREEG, GREY)} Free space: ${paint(`${fmtTok(free)} (${pctStr(free, window)})`, GREY)}`);
160
163
  }
161
- const lines = [`${BOLD}Context Usage${RESET}`];
164
+ const lines = [`${RESET}${BOLD}Context Usage${RESET}`];
162
165
  if (window > 0) {
163
166
  // Grid: category cells, then free, with the compaction buffer at the very end.
164
167
  const cellTokens = Math.max(1, window / GRID_CELLS);
@@ -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(['path', 'file', 'filename', 'file_path', 'filepath', 'dir', 'directory', 'cwd', 'paths', 'files']);
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
- envHook,
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
- envHook,
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,
@@ -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';
@@ -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';
@@ -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
+ });
@@ -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
- get id() {
7
- return session.id;
8
- },
9
- get messages() {
10
- return session.messages;
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) => {
@@ -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
- return {
4
- get id() {
5
- return session.id;
6
- },
7
- get messages() {
8
- return session.messages;
9
- },
10
- get tools() {
11
- return session.tools;
12
- },
13
- model: session.model,
14
- assembleRequest: session.assembleRequest?.bind(session),
15
- countTokens: session.countTokens?.bind(session),
16
- contextWindow: session.contextWindow?.bind(session),
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
  };
@@ -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 }: {