conductor-remote 1.99.0 → 1.101.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/README.md +11 -0
- package/bin/cli.js +1 -0
- package/dist/assets/index-AS43kfu8.css +1 -0
- package/dist/assets/index-DH64usTv.js +52 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/service.js +202 -9
- package/dist-node/src/logbuf.js +4 -1
- package/dist-node/src/notify.js +4 -11
- package/dist-node/src/reads.js +29 -1
- package/dist-node/src/routes.js +8 -0
- package/dist-node/src/run-activity.js +103 -0
- package/dist-node/src/server.js +160 -1
- package/dist-node/src/shared.js +26 -0
- package/dist-node/src/speech.js +68 -0
- package/dist-node/src/voice/brief.js +260 -0
- package/dist-node/src/voice/broker.js +447 -0
- package/dist-node/src/voice/config.js +171 -0
- package/dist-node/src/voice/funnel.js +75 -0
- package/dist-node/src/voice/gateway.js +86 -0
- package/dist-node/src/voice/preview.js +67 -0
- package/dist-node/src/voice/prompt.js +12 -0
- package/dist-node/src/voice/server.js +137 -0
- package/dist-node/src/voice/ticket.js +23 -0
- package/dist-node/src/voice/tools.js +198 -0
- package/dist-node/src/voice/twiml.js +124 -0
- package/dist-node/src/voice/webhook.js +113 -0
- package/dist-node/src/voice/webrtc.js +107 -0
- package/docs/voice-setup.md +223 -0
- package/package.json +3 -2
- package/dist/assets/index-0UqQA-X1.css +0 -1
- package/dist/assets/index-CatXxUrf.js +0 -52
package/dist-node/src/server.js
CHANGED
|
@@ -31,14 +31,24 @@ import { readPrefs, writePrefs } from "./prefs.js";
|
|
|
31
31
|
import { Reads } from "./reads.js";
|
|
32
32
|
import { decodeRoles, RoleStore, resolveRole, roleModelIssues } from "./roles.js";
|
|
33
33
|
import { isRoute, routeParam, routes } from "./routes.js";
|
|
34
|
+
import { attachRunActivity } from "./run-activity.js";
|
|
34
35
|
import { foldHits, queryTokens, SearchIndex } from "./search.js";
|
|
35
36
|
import { SendOnce } from "./sendonce.js";
|
|
36
37
|
import { SessionPoller } from "./session-poller.js";
|
|
37
38
|
import { readSettings, writeSettings } from "./settings.js";
|
|
38
|
-
import { responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
|
|
39
|
+
import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES, responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
|
|
39
40
|
import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
|
|
40
41
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
41
42
|
import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
|
|
43
|
+
import { VoiceBriefBoard } from "./voice/brief.js";
|
|
44
|
+
import { VoiceBroker } from "./voice/broker.js";
|
|
45
|
+
import { openAIOriginForSipHost, readVoiceConfig, voicePort } from "./voice/config.js";
|
|
46
|
+
import { createVoiceGateway } from "./voice/gateway.js";
|
|
47
|
+
import { PreviewStore } from "./voice/preview.js";
|
|
48
|
+
import { createVoiceServer } from "./voice/server.js";
|
|
49
|
+
import { mintSipTicket, missingTicketConfig } from "./voice/ticket.js";
|
|
50
|
+
import { createVoiceTools } from "./voice/tools.js";
|
|
51
|
+
import { createWebRtcCall, MAX_SDP_CHARS } from "./voice/webrtc.js";
|
|
42
52
|
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
|
|
43
53
|
import { prepareWorkflowRoot, WORKFLOW_ROOT_ROLE } from "./workflow.js";
|
|
44
54
|
import { archiveWorkspace, closeChat, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
@@ -150,6 +160,83 @@ const mcpTools = createTools(async (route, opts = {}) => {
|
|
|
150
160
|
}
|
|
151
161
|
return payload;
|
|
152
162
|
});
|
|
163
|
+
// The voice process surface shares this process (and therefore the one UI lock) but not
|
|
164
|
+
// this server's port. Only its three scoped routes are mounted through Funnel.
|
|
165
|
+
const voiceConfig = readVoiceConfig();
|
|
166
|
+
// Stable but useless outside this OpenAI abuse-control header. Hashing the relay
|
|
167
|
+
// bearer means neither that bearer nor a device identifier leaves the Mac.
|
|
168
|
+
const voiceSafetyIdentifier = crypto.createHash('sha256').update(`conductor-remote:${cfg.token}`).digest('hex');
|
|
169
|
+
const voiceBoards = new Map();
|
|
170
|
+
const voicePreviews = new PreviewStore(path.join(stateDir(), 'voice-previews.json'));
|
|
171
|
+
const voiceBroker = voiceConfig.openaiKey
|
|
172
|
+
? new VoiceBroker({
|
|
173
|
+
apiKey: voiceConfig.openaiKey,
|
|
174
|
+
apiOrigin: openAIOriginForSipHost(voiceConfig.sipHost),
|
|
175
|
+
model: voiceConfig.model,
|
|
176
|
+
voice: voiceConfig.voice,
|
|
177
|
+
mcpUrl: voiceConfig.publicBaseUrl ? `${voiceConfig.publicBaseUrl}/mcp` : null,
|
|
178
|
+
mcpToken: voiceConfig.mcpToken,
|
|
179
|
+
stateFile: path.join(stateDir(), 'voice-calls.json'),
|
|
180
|
+
tools: callId => voiceToolsForCall(callId),
|
|
181
|
+
onClose: callId => voiceBoards.delete(callId)
|
|
182
|
+
})
|
|
183
|
+
: null;
|
|
184
|
+
function voiceBoard(callId) {
|
|
185
|
+
let board = voiceBoards.get(callId);
|
|
186
|
+
if (board)
|
|
187
|
+
return board;
|
|
188
|
+
board = new VoiceBriefBoard({ reads, locked: async () => (await screenLocked()) === true, readPrefs, writePrefs });
|
|
189
|
+
voiceBoards.set(callId, board);
|
|
190
|
+
return board;
|
|
191
|
+
}
|
|
192
|
+
async function dispatchVoicePreview(preview) {
|
|
193
|
+
const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
|
|
194
|
+
const timeoutMs = 75_000;
|
|
195
|
+
const res = await fetch(`http://${host}:${cfg.port}${routes.sendPrompt.path(preview.sessionId)}`, {
|
|
196
|
+
method: routes.sendPrompt.method,
|
|
197
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
198
|
+
headers: {
|
|
199
|
+
authorization: `Bearer ${cfg.token}`,
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'x-relay-client': 'voice',
|
|
202
|
+
'x-client-timeout-ms': String(timeoutMs)
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
workspaceId: preview.workspaceId,
|
|
206
|
+
text: preview.text,
|
|
207
|
+
clientId: preview.token
|
|
208
|
+
})
|
|
209
|
+
});
|
|
210
|
+
const payload = (await res.json().catch(() => ({})));
|
|
211
|
+
return {
|
|
212
|
+
ok: payload.ok === true,
|
|
213
|
+
parked: payload.parked === true,
|
|
214
|
+
error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function voiceToolsForCall(callId) {
|
|
218
|
+
return createVoiceTools({
|
|
219
|
+
callId,
|
|
220
|
+
board: voiceBoard(callId),
|
|
221
|
+
previews: voicePreviews,
|
|
222
|
+
findSession: sessionId => reads.listSessionStates().find(state => state.sessionId === sessionId) ?? null,
|
|
223
|
+
dispatch: dispatchVoicePreview,
|
|
224
|
+
announce: spoken => {
|
|
225
|
+
if (!voiceBroker?.inject(callId, spoken))
|
|
226
|
+
console.warn(`[voice] ${callId} could not receive a delivery nudge`);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const voiceGateway = createVoiceGateway({
|
|
231
|
+
config: () => voiceConfig,
|
|
232
|
+
broker: () => voiceBroker,
|
|
233
|
+
rpc: (callId, request) => handleRpc(voiceToolsForCall(callId), request)
|
|
234
|
+
});
|
|
235
|
+
const voiceServer = createVoiceServer({
|
|
236
|
+
routes: voiceGateway,
|
|
237
|
+
mcpToken: () => voiceConfig.mcpToken,
|
|
238
|
+
log: line => console.warn(line)
|
|
239
|
+
});
|
|
153
240
|
// A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
|
|
154
241
|
// by restarting it — and quitting takes any agent mid-turn down with it. So the
|
|
155
242
|
// write path may only do that while nothing is working, which is a DB fact, not
|
|
@@ -1357,6 +1444,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1357
1444
|
const workspaces = reads.listWorkspaces();
|
|
1358
1445
|
attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
|
|
1359
1446
|
attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
|
|
1447
|
+
attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
|
|
1360
1448
|
attachDelegationState(workspaces);
|
|
1361
1449
|
// An undelivered first prompt rides along with its workspace: the phone renders it
|
|
1362
1450
|
// in that chat rather than tracking delivery itself (see src/firstprompt.ts).
|
|
@@ -1565,6 +1653,73 @@ const server = http.createServer(async (req, res) => {
|
|
|
1565
1653
|
}
|
|
1566
1654
|
});
|
|
1567
1655
|
}
|
|
1656
|
+
// POST /api/voice/ticket — the native app presents the same relay bearer as
|
|
1657
|
+
// the PWA, and receives only a two-minute SIP URI. The OpenAI key, webhook
|
|
1658
|
+
// secret and marker key never leave this Mac.
|
|
1659
|
+
if (isRoute(routes.voiceTicket, req.method, pathname)) {
|
|
1660
|
+
const missing = missingTicketConfig(voiceConfig);
|
|
1661
|
+
if (missing.length || !voiceBroker) {
|
|
1662
|
+
return json(req, res, 503, {
|
|
1663
|
+
error: 'voice calls are not fully configured on this relay',
|
|
1664
|
+
missing
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
return json(req, res, 200, mintSipTicket(voiceConfig));
|
|
1668
|
+
}
|
|
1669
|
+
// POST /api/voice/calls — the PWA sends its SDP offer to this authenticated
|
|
1670
|
+
// relay. The relay combines it with the global orchestrator session and
|
|
1671
|
+
// keeps OpenAI's permanent key and every function tool on the Mac.
|
|
1672
|
+
if (isRoute(routes.voiceCall, req.method, pathname)) {
|
|
1673
|
+
if (!voiceConfig.openaiKey || !voiceBroker)
|
|
1674
|
+
return json(req, res, 503, { error: 'voice needs an OpenAI API key on this relay' });
|
|
1675
|
+
const raw = await readBody(req);
|
|
1676
|
+
if (raw.length > MAX_SDP_CHARS * 2)
|
|
1677
|
+
return json(req, res, 413, { error: 'WebRTC offer is too large' });
|
|
1678
|
+
const body = JSON.parse(raw || '{}');
|
|
1679
|
+
if (typeof body.sdp !== 'string' || !body.sdp.trim())
|
|
1680
|
+
return json(req, res, 400, { error: 'WebRTC offer is required' });
|
|
1681
|
+
if (body.sdp.length > MAX_SDP_CHARS)
|
|
1682
|
+
return json(req, res, 413, { error: 'WebRTC offer is too large' });
|
|
1683
|
+
if (!isOpenAIRealtimeVoice(body.voice))
|
|
1684
|
+
return json(req, res, 400, { error: `voice must be one of ${OPENAI_REALTIME_VOICES.join(', ')}` });
|
|
1685
|
+
if (!isVoiceLanguage(body.language))
|
|
1686
|
+
return json(req, res, 400, { error: 'unsupported voice language' });
|
|
1687
|
+
try {
|
|
1688
|
+
const call = await createWebRtcCall(voiceConfig.openaiKey, openAIOriginForSipHost(voiceConfig.sipHost), body.sdp, {
|
|
1689
|
+
model: voiceConfig.model,
|
|
1690
|
+
voice: body.voice,
|
|
1691
|
+
language: body.language
|
|
1692
|
+
}, voiceSafetyIdentifier);
|
|
1693
|
+
voiceBroker.registerWebRtc(call.callId);
|
|
1694
|
+
return json(req, res, 200, call);
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
console.warn('[voice] could not create WebRTC orchestrator call:', err);
|
|
1698
|
+
return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice call failed' });
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
const readyVoiceCall = routeParam(routes.voiceCallReady, req.method, pathname);
|
|
1702
|
+
if (readyVoiceCall) {
|
|
1703
|
+
if (!voiceBroker)
|
|
1704
|
+
return json(req, res, 503, { error: 'voice is not configured on this relay' });
|
|
1705
|
+
if (!voiceBroker.beginWebRtc(readyVoiceCall))
|
|
1706
|
+
return json(req, res, 404, { error: 'voice call not found' });
|
|
1707
|
+
return json(req, res, 200, { ok: true });
|
|
1708
|
+
}
|
|
1709
|
+
const endedVoiceCall = routeParam(routes.voiceCallEnd, req.method, pathname);
|
|
1710
|
+
if (endedVoiceCall) {
|
|
1711
|
+
if (!voiceBroker)
|
|
1712
|
+
return json(req, res, 503, { error: 'voice is not configured on this relay' });
|
|
1713
|
+
try {
|
|
1714
|
+
if (!(await voiceBroker.hangupWebRtc(endedVoiceCall)))
|
|
1715
|
+
return json(req, res, 404, { error: 'voice call not found' });
|
|
1716
|
+
return json(req, res, 200, { ok: true });
|
|
1717
|
+
}
|
|
1718
|
+
catch (err) {
|
|
1719
|
+
console.warn('[voice] could not hang up WebRTC orchestrator call:', err);
|
|
1720
|
+
return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice hangup failed' });
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1568
1723
|
// GET /api/settings — relay preferences plus what the phone needs to edit them:
|
|
1569
1724
|
// the SSIDs this Mac already holds credentials for, so the picker offers a choice
|
|
1570
1725
|
// instead of asking someone to type a network name from memory on a phone keyboard.
|
|
@@ -2757,6 +2912,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2757
2912
|
});
|
|
2758
2913
|
});
|
|
2759
2914
|
server.listen(cfg.port, cfg.host, () => {
|
|
2915
|
+
voiceServer.listen(voicePort(), '127.0.0.1', () => {
|
|
2916
|
+
console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
|
|
2917
|
+
void voiceBroker?.restore();
|
|
2918
|
+
});
|
|
2760
2919
|
// Under `yarn dev` the app comes from Vite and only /api comes from here, so the URL worth
|
|
2761
2920
|
// printing is Vite's — carrying the token, which Vite itself has no way to print.
|
|
2762
2921
|
const dev = cfg.devWebPort !== undefined;
|
package/dist-node/src/shared.js
CHANGED
|
@@ -231,6 +231,32 @@ export function responseErrorMessage(error, fallback) {
|
|
|
231
231
|
*/
|
|
232
232
|
export const HIT_OPEN = '\u0001';
|
|
233
233
|
export const HIT_CLOSE = '\u0002';
|
|
234
|
+
/**
|
|
235
|
+
* OpenAI's built-in Realtime voices, in the order the phone presents them. The
|
|
236
|
+
* two voices OpenAI recommends for quality lead the list; every value is shared
|
|
237
|
+
* with the relay so a stale or hand-written client cannot ask it to configure an
|
|
238
|
+
* arbitrary voice id.
|
|
239
|
+
*/
|
|
240
|
+
export const OPENAI_REALTIME_VOICES = [
|
|
241
|
+
'marin',
|
|
242
|
+
'cedar',
|
|
243
|
+
'alloy',
|
|
244
|
+
'ash',
|
|
245
|
+
'ballad',
|
|
246
|
+
'coral',
|
|
247
|
+
'echo',
|
|
248
|
+
'sage',
|
|
249
|
+
'shimmer',
|
|
250
|
+
'verse'
|
|
251
|
+
];
|
|
252
|
+
/** The deliberately small language picker for the first voice surface. */
|
|
253
|
+
export const VOICE_LANGUAGES = ['auto', 'no', 'en'];
|
|
254
|
+
export function isOpenAIRealtimeVoice(value) {
|
|
255
|
+
return typeof value === 'string' && OPENAI_REALTIME_VOICES.includes(value);
|
|
256
|
+
}
|
|
257
|
+
export function isVoiceLanguage(value) {
|
|
258
|
+
return typeof value === 'string' && VOICE_LANGUAGES.includes(value);
|
|
259
|
+
}
|
|
234
260
|
const ATTACHMENT_PREFIX = '.context/attachments/';
|
|
235
261
|
/**
|
|
236
262
|
* Read Conductor attachment tokens from prompt text.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speech bounding — the two helpers that keep a spoken field inside its budget.
|
|
3
|
+
*
|
|
4
|
+
* The voice orchestrator's caps are enforced in relay code rather than in the model's
|
|
5
|
+
* prompt (design ▸ Constraints), because a prompt that asks for brevity is a request and
|
|
6
|
+
* a cap is a guarantee. Both callers here are budgets someone else set: a push body has
|
|
7
|
+
* a payload ceiling, and a roll call has an attention ceiling — roughly 600 characters
|
|
8
|
+
* before a listener stops holding it.
|
|
9
|
+
*
|
|
10
|
+
* `clipExact` is named for the property `notify.ts`'s private `clip` did not have: the
|
|
11
|
+
* ellipsis counts against the cap, so the result is never longer than what was asked for.
|
|
12
|
+
* The old helper returned `max + 1` characters, which was invisible against a byte budget
|
|
13
|
+
* carrying eight bytes of slack and would not be invisible against a spoken one.
|
|
14
|
+
*
|
|
15
|
+
* Stdlib-free on purpose, so a fixture test needs nothing but the function.
|
|
16
|
+
*/
|
|
17
|
+
/** Cut back to the last word boundary when one is close enough to the cut to be worth it. */
|
|
18
|
+
const WORD_LOOKBACK = 0.2;
|
|
19
|
+
/**
|
|
20
|
+
* At most `max` characters, ellipsis included. Prefers a word boundary near the cut, so a
|
|
21
|
+
* clipped sentence ends on a word rather than mid-syllable, which matters when it is read
|
|
22
|
+
* aloud rather than shown.
|
|
23
|
+
*/
|
|
24
|
+
export function clipExact(text, max) {
|
|
25
|
+
if (max <= 0)
|
|
26
|
+
return '';
|
|
27
|
+
if (text.length <= max)
|
|
28
|
+
return text;
|
|
29
|
+
if (max === 1)
|
|
30
|
+
return '…';
|
|
31
|
+
const hard = text.slice(0, max - 1);
|
|
32
|
+
// Only back off when the cut actually lands inside a word. A cut that already falls on a
|
|
33
|
+
// space has nothing to repair, and backing off anyway throws away a whole word for free.
|
|
34
|
+
const splitsWord = !/\s/.test(text.charAt(max - 1));
|
|
35
|
+
const space = hard.lastIndexOf(' ');
|
|
36
|
+
const body = splitsWord && space >= Math.floor((max - 1) * (1 - WORD_LOOKBACK)) ? hard.slice(0, space) : hard;
|
|
37
|
+
return `${body.trimEnd()}…`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One spoken line: code fences become an ellipsis, every run of whitespace becomes one
|
|
41
|
+
* space, then the whole thing is clipped. A transcript entry is written to be read on a
|
|
42
|
+
* screen, so its newlines and fences are layout that speech has no use for.
|
|
43
|
+
*/
|
|
44
|
+
export function oneLine(text, max) {
|
|
45
|
+
return clipExact(text
|
|
46
|
+
.replace(/```[\s\S]*?```/g, '…')
|
|
47
|
+
.replace(/\s+/g, ' ')
|
|
48
|
+
.trim(), max);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Turn the Markdown that is useful on screen into prose that is useful in an
|
|
52
|
+
* ear. The visible transcript remains untouched and canonical: this only drops
|
|
53
|
+
* things that a speech engine would otherwise pronounce literally (URLs,
|
|
54
|
+
* backticks, heading marks, and fenced source code).
|
|
55
|
+
*/
|
|
56
|
+
export function speechText(text, max) {
|
|
57
|
+
return clipExact(text
|
|
58
|
+
.replace(/```[\s\S]*?```/g, '…')
|
|
59
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
60
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
61
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
62
|
+
.replace(/https?:\/\/\S+/g, 'link')
|
|
63
|
+
.replace(/^\s{0,3}(?:#{1,6}|>|[-*+])\s+/gm, '')
|
|
64
|
+
.replace(/<[^>]+>/g, ' ')
|
|
65
|
+
.replace(/[ \t]+/g, ' ')
|
|
66
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
67
|
+
.trim(), max);
|
|
68
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { clipExact, oneLine, speechText } from "../speech.js";
|
|
2
|
+
const DORMANT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3
|
+
const DORMANT_LABELS = new Set(['backlog', 'done', 'canceled', 'cancelled']);
|
|
4
|
+
const OVERVIEW_PAGE_SIZE = 3;
|
|
5
|
+
function clean(text) {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/[*_`#]+/g, '')
|
|
8
|
+
.replace(/\s+/g, ' ')
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
function lastParagraph(text) {
|
|
12
|
+
return (text
|
|
13
|
+
.trim()
|
|
14
|
+
.split(/\n\s*\n/)
|
|
15
|
+
.map(clean)
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.at(-1) ?? '');
|
|
18
|
+
}
|
|
19
|
+
/** Parse `D<N> —`, lettered choices, `(recommended)`, and `Recommendation:` briefs. */
|
|
20
|
+
export function parseProseDecision(text) {
|
|
21
|
+
const lines = text.split('\n');
|
|
22
|
+
const heading = lines.findIndex(line => /^\s*#{0,6}\s*\**D\d+\s*[—:-]\s*/i.test(line));
|
|
23
|
+
if (heading < 0)
|
|
24
|
+
return null;
|
|
25
|
+
const question = clean(lines[heading].replace(/^\s*#{0,6}\s*\**D\d+\s*[—:-]\s*/i, ''));
|
|
26
|
+
if (!question)
|
|
27
|
+
return null;
|
|
28
|
+
const options = [];
|
|
29
|
+
let consequence = '';
|
|
30
|
+
for (let i = heading + 1; i < lines.length; i++) {
|
|
31
|
+
const line = clean(lines[i]);
|
|
32
|
+
if (/^[A-Z][.)]\s+/.test(line))
|
|
33
|
+
options.push(line);
|
|
34
|
+
else if (/^Recommendation\s*:/i.test(line))
|
|
35
|
+
consequence = line.replace(/^Recommendation\s*:\s*/i, '');
|
|
36
|
+
}
|
|
37
|
+
if (!options.length)
|
|
38
|
+
return null;
|
|
39
|
+
const before = lines.slice(0, heading).join('\n');
|
|
40
|
+
return { situation: lastParagraph(before), question, options, consequence };
|
|
41
|
+
}
|
|
42
|
+
function object(value) {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
? value
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
/** Parse the input shape used by AskUserQuestion without depending on a provider SDK. */
|
|
48
|
+
export function parseStructuredQuestion(input) {
|
|
49
|
+
const root = object(input);
|
|
50
|
+
const first = Array.isArray(root?.questions) ? object(root.questions[0]) : null;
|
|
51
|
+
if (!first || typeof first.question !== 'string' || !first.question.trim())
|
|
52
|
+
return null;
|
|
53
|
+
const rawOptions = Array.isArray(first.options) ? first.options : [];
|
|
54
|
+
const options = rawOptions.flatMap((raw, index) => {
|
|
55
|
+
const option = object(raw);
|
|
56
|
+
if (!option || typeof option.label !== 'string' || !option.label.trim())
|
|
57
|
+
return [];
|
|
58
|
+
const letter = String.fromCharCode(65 + index);
|
|
59
|
+
const description = typeof option.description === 'string' ? clean(option.description) : '';
|
|
60
|
+
return [`${letter}. ${clean(option.label)}${description ? ` — ${description}` : ''}`];
|
|
61
|
+
});
|
|
62
|
+
if (!options.length)
|
|
63
|
+
return null;
|
|
64
|
+
return { situation: '', question: clean(first.question), options, consequence: '' };
|
|
65
|
+
}
|
|
66
|
+
function parseDate(value) {
|
|
67
|
+
if (!value)
|
|
68
|
+
return 0;
|
|
69
|
+
// SQLite values have no zone marker but are UTC in Conductor's DB.
|
|
70
|
+
const normalized = value.includes('T') || /Z$/.test(value) ? value : `${value.replace(' ', 'T')}Z`;
|
|
71
|
+
const parsed = Date.parse(normalized);
|
|
72
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
73
|
+
}
|
|
74
|
+
function statusLabel(workspace) {
|
|
75
|
+
return workspace.manual_status ?? workspace.derived_status;
|
|
76
|
+
}
|
|
77
|
+
function isDormant(state, workspace, now) {
|
|
78
|
+
if (state.status !== 'idle' && state.status)
|
|
79
|
+
return false;
|
|
80
|
+
const old = now - parseDate(state.updatedAt) > DORMANT_MS;
|
|
81
|
+
const labelled = DORMANT_LABELS.has((statusLabel(workspace) ?? '').toLowerCase());
|
|
82
|
+
return old || labelled;
|
|
83
|
+
}
|
|
84
|
+
function overviewRank(state) {
|
|
85
|
+
if (state.status === 'error')
|
|
86
|
+
return 0;
|
|
87
|
+
if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
|
|
88
|
+
return 1;
|
|
89
|
+
if (state.status === 'working')
|
|
90
|
+
return 2;
|
|
91
|
+
return 3;
|
|
92
|
+
}
|
|
93
|
+
function overviewStatus(state, workspace) {
|
|
94
|
+
if (state.status === 'error')
|
|
95
|
+
return 'has an error';
|
|
96
|
+
if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
|
|
97
|
+
return 'needs you';
|
|
98
|
+
if (state.status === 'working')
|
|
99
|
+
return 'is working';
|
|
100
|
+
if (workspace.unread_sessions.some(session => session.id === state.sessionId))
|
|
101
|
+
return 'has an unread update';
|
|
102
|
+
return 'is recently active';
|
|
103
|
+
}
|
|
104
|
+
function fallbackDecision(state, said) {
|
|
105
|
+
const last = lastParagraph(said) || `${state.workspaceTitle} needs attention.`;
|
|
106
|
+
return {
|
|
107
|
+
situation: last,
|
|
108
|
+
question: state.status === 'error' ? 'What should the agent try next?' : 'What should this agent do next?',
|
|
109
|
+
options: ['A. Send a focused instruction.', 'B. Skip this item for now.'],
|
|
110
|
+
consequence: 'Sending unblocks the owning workspace; skipping leaves it in the queue.'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function spokenDecision(item) {
|
|
114
|
+
const d = item.decision;
|
|
115
|
+
const fields = [
|
|
116
|
+
`Situation: ${item.title}. ${d.situation}`,
|
|
117
|
+
`Decision needed: ${d.question}`,
|
|
118
|
+
`Safe options: ${d.options.join(' ')}`,
|
|
119
|
+
d.consequence ? `Consequence: ${d.consequence}` : ''
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.map(field => oneLine(field, 180));
|
|
123
|
+
return clipExact(fields.join(' '), 400);
|
|
124
|
+
}
|
|
125
|
+
export class VoiceBriefBoard {
|
|
126
|
+
deps;
|
|
127
|
+
now;
|
|
128
|
+
cached = null;
|
|
129
|
+
constructor(deps) {
|
|
130
|
+
this.deps = deps;
|
|
131
|
+
this.now = deps.now ?? Date.now;
|
|
132
|
+
}
|
|
133
|
+
build() {
|
|
134
|
+
const workspaces = new Map(this.deps.reads.listWorkspaces().map(ws => [ws.id, ws]));
|
|
135
|
+
const marks = this.deps.readPrefs().readMarks;
|
|
136
|
+
let working = 0;
|
|
137
|
+
let dormant = 0;
|
|
138
|
+
const queue = [];
|
|
139
|
+
for (const state of this.deps.reads.listSessionStates()) {
|
|
140
|
+
const workspace = workspaces.get(state.workspaceId);
|
|
141
|
+
if (!workspace)
|
|
142
|
+
continue;
|
|
143
|
+
if (state.status === 'working') {
|
|
144
|
+
working++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (isDormant(state, workspace, this.now())) {
|
|
148
|
+
dormant++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if ((marks[state.sessionId] ?? '') >= state.updatedAt)
|
|
152
|
+
continue;
|
|
153
|
+
const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
|
|
154
|
+
const prose = parseProseDecision(said);
|
|
155
|
+
const structured = prose ? null : parseStructuredQuestion(this.deps.reads.lastQuestionInput(state.sessionId));
|
|
156
|
+
const hasQuestion = Boolean(prose ?? structured) || /\?\s*$/.test(said.trim());
|
|
157
|
+
const unread = workspace.unread_sessions.some(s => s.id === state.sessionId);
|
|
158
|
+
const priority = state.status === 'error'
|
|
159
|
+
? 0
|
|
160
|
+
: state.status === 'needs_user_input' || state.status === 'needs_plan_response'
|
|
161
|
+
? 1
|
|
162
|
+
: hasQuestion
|
|
163
|
+
? 2
|
|
164
|
+
: unread
|
|
165
|
+
? 3
|
|
166
|
+
: 4;
|
|
167
|
+
queue.push({
|
|
168
|
+
workspaceId: state.workspaceId,
|
|
169
|
+
sessionId: state.sessionId,
|
|
170
|
+
title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
|
|
171
|
+
updatedAt: state.updatedAt,
|
|
172
|
+
priority,
|
|
173
|
+
decision: prose ?? structured ?? fallbackDecision(state, said)
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
queue.sort((a, b) => a.priority - b.priority || b.updatedAt.localeCompare(a.updatedAt));
|
|
177
|
+
return { queue, working, dormant };
|
|
178
|
+
}
|
|
179
|
+
items() {
|
|
180
|
+
if (!this.cached)
|
|
181
|
+
this.cached = this.build().queue;
|
|
182
|
+
return this.cached;
|
|
183
|
+
}
|
|
184
|
+
/** A deliberately uncached read: a new overview must not replay the call-opening tally. */
|
|
185
|
+
async workspaceOverview(cursor = 0) {
|
|
186
|
+
const workspaces = new Map(this.deps.reads.listWorkspaces().map(workspace => [workspace.id, workspace]));
|
|
187
|
+
const grouped = new Map();
|
|
188
|
+
for (const state of this.deps.reads.listSessionStates()) {
|
|
189
|
+
if (!workspaces.has(state.workspaceId))
|
|
190
|
+
continue;
|
|
191
|
+
const states = grouped.get(state.workspaceId) ?? [];
|
|
192
|
+
states.push(state);
|
|
193
|
+
grouped.set(state.workspaceId, states);
|
|
194
|
+
}
|
|
195
|
+
let dormant = 0;
|
|
196
|
+
const items = [];
|
|
197
|
+
for (const [workspaceId, workspace] of workspaces) {
|
|
198
|
+
const current = (grouped.get(workspaceId) ?? []).filter(state => !isDormant(state, workspace, this.now()));
|
|
199
|
+
if (!current.length) {
|
|
200
|
+
dormant++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
current.sort((a, b) => overviewRank(a) - overviewRank(b) || parseDate(b.updatedAt) - parseDate(a.updatedAt));
|
|
204
|
+
const state = current[0];
|
|
205
|
+
const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
|
|
206
|
+
items.push({
|
|
207
|
+
workspaceId,
|
|
208
|
+
sessionId: state.sessionId,
|
|
209
|
+
title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
|
|
210
|
+
status: overviewStatus(state, workspace),
|
|
211
|
+
updatedAt: state.updatedAt,
|
|
212
|
+
update: oneLine(speechText(said, 150), 150),
|
|
213
|
+
rank: overviewRank(state)
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
items.sort((a, b) => a.rank - b.rank || parseDate(b.updatedAt) - parseDate(a.updatedAt));
|
|
217
|
+
const offset = Number.isFinite(cursor) ? Math.max(0, Math.floor(cursor)) : 0;
|
|
218
|
+
const rankedPage = items.slice(offset, offset + OVERVIEW_PAGE_SIZE);
|
|
219
|
+
const page = rankedPage.map(({ rank: _rank, ...item }) => item);
|
|
220
|
+
const next = offset + page.length < items.length ? offset + page.length : null;
|
|
221
|
+
const noun = items.length === 1 ? 'workspace' : 'workspaces';
|
|
222
|
+
const lines = page.map(item => `${item.title} ${item.status}.${item.update ? ` ${item.update}` : ' No agent update yet.'}`);
|
|
223
|
+
const more = next === null ? '' : ` ${items.length - next} more current; ask me to continue.`;
|
|
224
|
+
const none = items.length ? '' : ' Nothing is active right now.';
|
|
225
|
+
return {
|
|
226
|
+
spoken: clipExact(`Fresh overview: ${items.length} current ${noun}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
|
|
227
|
+
current: items.length,
|
|
228
|
+
dormant,
|
|
229
|
+
cursor: next,
|
|
230
|
+
workspaces: page
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async rollCall() {
|
|
234
|
+
const built = this.build();
|
|
235
|
+
this.cached = built.queue;
|
|
236
|
+
const locked = await this.deps.locked();
|
|
237
|
+
const needsYou = built.queue.filter(item => item.priority < 4).length;
|
|
238
|
+
const heads = built.queue.slice(0, 3).map(item => item.title);
|
|
239
|
+
const spoken = clipExact(`${locked ? 'Mac is locked; sends will park.' : 'Mac is unlocked; sends can land.'} ${built.working} working, ${needsYou} need you, ${built.dormant} dormant.${heads.length ? ` Queue starts with ${heads.join(', ')}.` : ''}`, 600);
|
|
240
|
+
return { spoken, working: built.working, needsYou, dormant: built.dormant, queue: built.queue };
|
|
241
|
+
}
|
|
242
|
+
async nextDecision(cursor = 0) {
|
|
243
|
+
const item = this.items()[Math.max(0, Math.floor(cursor))];
|
|
244
|
+
if (!item)
|
|
245
|
+
return null;
|
|
246
|
+
return {
|
|
247
|
+
spoken: spokenDecision(item),
|
|
248
|
+
cursor: Math.max(0, Math.floor(cursor)) + 1,
|
|
249
|
+
workspaceId: item.workspaceId,
|
|
250
|
+
sessionId: item.sessionId
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** A dispatch or an explicit spoken skip is handled; merely hearing the item is not. */
|
|
254
|
+
markHandled(sessionId) {
|
|
255
|
+
const item = this.items().find(candidate => candidate.sessionId === sessionId);
|
|
256
|
+
if (!item)
|
|
257
|
+
return;
|
|
258
|
+
this.deps.writePrefs({ readMarks: { [sessionId]: item.updatedAt } });
|
|
259
|
+
}
|
|
260
|
+
}
|