conductor-remote 1.110.0 → 1.111.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 +3 -3
- package/dist/assets/{PierrePatch-YcYgYId9.js → PierrePatch-QISkVnaE.js} +1 -1
- package/dist/assets/{index-Bi66ei7k.js → index-Dg6_e4df.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/server.js +46 -3
- package/dist-node/src/voice/brief.js +157 -10
- package/dist-node/src/voice/preview.js +49 -6
- package/dist-node/src/voice/prompt.js +3 -1
- package/dist-node/src/voice/tools.js +164 -7
- package/docs/voice-setup.md +22 -10
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<title>Conductor Remote</title>
|
|
26
26
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
27
27
|
<script src="/self-heal.js"></script>
|
|
28
|
-
<script type="module" crossorigin src="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-Dg6_e4df.js"></script>
|
|
29
29
|
<link rel="stylesheet" crossorigin href="/assets/index-iryu1HEQ.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"
|
|
1
|
+
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"5c4dca29374e8cbef37b975e6844f509"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-iryu1HEQ.css",revision:null},{url:"assets/index-Dg6_e4df.js",revision:null},{url:"assets/PierrePatch-QISkVnaE.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
|
package/dist-node/src/server.js
CHANGED
|
@@ -224,14 +224,31 @@ function voiceBoard(callId) {
|
|
|
224
224
|
let board = voiceBoards.get(callId);
|
|
225
225
|
if (board)
|
|
226
226
|
return board;
|
|
227
|
-
board = new VoiceBriefBoard({
|
|
227
|
+
board = new VoiceBriefBoard({
|
|
228
|
+
reads: {
|
|
229
|
+
listWorkspaces: () => {
|
|
230
|
+
const workspaces = reads.listWorkspaces();
|
|
231
|
+
attachPrStatus(workspaces);
|
|
232
|
+
return workspaces;
|
|
233
|
+
},
|
|
234
|
+
listSessionStates: () => reads.listSessionStates(),
|
|
235
|
+
lastAssistantText: sessionId => reads.lastAssistantText(sessionId),
|
|
236
|
+
lastQuestionInput: sessionId => reads.lastQuestionInput(sessionId)
|
|
237
|
+
},
|
|
238
|
+
locked: async () => (await screenLocked()) === true,
|
|
239
|
+
readPrefs,
|
|
240
|
+
writePrefs
|
|
241
|
+
});
|
|
228
242
|
voiceBoards.set(callId, board);
|
|
229
243
|
return board;
|
|
230
244
|
}
|
|
231
|
-
|
|
245
|
+
function voiceRelayOrigin() {
|
|
232
246
|
const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
|
|
247
|
+
return `http://${host}:${cfg.port}`;
|
|
248
|
+
}
|
|
249
|
+
async function dispatchVoicePreview(preview) {
|
|
233
250
|
const timeoutMs = 75_000;
|
|
234
|
-
const res = await fetch(
|
|
251
|
+
const res = await fetch(`${voiceRelayOrigin()}${routes.sendPrompt.path(preview.sessionId)}`, {
|
|
235
252
|
method: routes.sendPrompt.method,
|
|
236
253
|
signal: AbortSignal.timeout(timeoutMs),
|
|
237
254
|
headers: {
|
|
@@ -253,12 +270,38 @@ async function dispatchVoicePreview(preview) {
|
|
|
253
270
|
error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
|
|
254
271
|
};
|
|
255
272
|
}
|
|
273
|
+
async function createVoiceWorkspace(preview) {
|
|
274
|
+
// Creation runs behind the shared Conductor UI lease. The call already returned
|
|
275
|
+
// "queued", so keep enough room for one in-flight interactive action to finish
|
|
276
|
+
// before the deep link and its workspace-row receipt run.
|
|
277
|
+
const timeoutMs = 75_000;
|
|
278
|
+
const res = await fetch(`${voiceRelayOrigin()}${routes.createWorkspace.path()}`, {
|
|
279
|
+
method: routes.createWorkspace.method,
|
|
280
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
281
|
+
headers: {
|
|
282
|
+
authorization: `Bearer ${cfg.token}`,
|
|
283
|
+
'content-type': 'application/json',
|
|
284
|
+
'x-relay-client': 'voice',
|
|
285
|
+
'x-client-timeout-ms': String(timeoutMs)
|
|
286
|
+
},
|
|
287
|
+
body: JSON.stringify({ repo: preview.repo, prompt: preview.prompt, sendImmediately: true })
|
|
288
|
+
});
|
|
289
|
+
const payload = (await res.json().catch(() => ({})));
|
|
290
|
+
return {
|
|
291
|
+
ok: res.ok && payload.ok === true,
|
|
292
|
+
workspaceId: payload.workspaceId,
|
|
293
|
+
warning: payload.warning,
|
|
294
|
+
error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
256
297
|
function voiceToolsForCall(callId) {
|
|
257
298
|
return createVoiceTools({
|
|
258
299
|
callId,
|
|
259
300
|
board: voiceBoard(callId),
|
|
260
301
|
previews: voicePreviews,
|
|
261
302
|
findSession: sessionId => reads.listSessionStates().find(state => state.sessionId === sessionId) ?? null,
|
|
303
|
+
listRepos: () => reads.listRepos().map(repo => ({ name: repo.name, defaultBranch: repo.default_branch })),
|
|
304
|
+
createWorkspace: createVoiceWorkspace,
|
|
262
305
|
dispatch: dispatchVoicePreview,
|
|
263
306
|
announce: spoken => {
|
|
264
307
|
if (!voiceBroker?.inject(callId, spoken))
|
|
@@ -2,6 +2,7 @@ import { clipExact, oneLine, speechText } from "../speech.js";
|
|
|
2
2
|
const DORMANT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3
3
|
const DORMANT_LABELS = new Set(['backlog', 'done', 'canceled', 'cancelled']);
|
|
4
4
|
const OVERVIEW_PAGE_SIZE = 3;
|
|
5
|
+
const OVERVIEW_AGENT_STATUSES = new Set(['working', 'idle', 'error', 'needs-you']);
|
|
5
6
|
function clean(text) {
|
|
6
7
|
return text
|
|
7
8
|
.replace(/[*_`#]+/g, '')
|
|
@@ -74,12 +75,102 @@ function parseDate(value) {
|
|
|
74
75
|
function statusLabel(workspace) {
|
|
75
76
|
return workspace.manual_status ?? workspace.derived_status;
|
|
76
77
|
}
|
|
77
|
-
function
|
|
78
|
+
function normalizedStatus(value) {
|
|
79
|
+
return (value ?? '').trim().toLowerCase().replaceAll('_', '-').replaceAll(' ', '-');
|
|
80
|
+
}
|
|
81
|
+
function isDormant(state, workspace, now, options = {}) {
|
|
78
82
|
if (state.status !== 'idle' && state.status)
|
|
79
83
|
return false;
|
|
80
84
|
const old = now - parseDate(state.updatedAt) > DORMANT_MS;
|
|
81
|
-
const
|
|
82
|
-
|
|
85
|
+
const label = normalizedStatus(statusLabel(workspace));
|
|
86
|
+
const explicitlyIncluded = options.includedWorkspaceStatus === label || (options.includeDone && label === 'done');
|
|
87
|
+
const labelled = DORMANT_LABELS.has(label) && !explicitlyIncluded;
|
|
88
|
+
return (!options.ignoreAge && old) || labelled;
|
|
89
|
+
}
|
|
90
|
+
function isDone(workspace) {
|
|
91
|
+
return normalizedStatus(statusLabel(workspace)) === 'done';
|
|
92
|
+
}
|
|
93
|
+
function isMerged(workspace) {
|
|
94
|
+
return workspace.pr_status === 'merged';
|
|
95
|
+
}
|
|
96
|
+
function parseOverviewDate(value, now, field) {
|
|
97
|
+
const normalized = value.trim().toLowerCase();
|
|
98
|
+
if (normalized === 'today' ||
|
|
99
|
+
normalized === 'yesterday' ||
|
|
100
|
+
normalized === 'this-week' ||
|
|
101
|
+
normalized === 'this-month') {
|
|
102
|
+
const current = new Date(now);
|
|
103
|
+
const boundary = new Date(current.getFullYear(), current.getMonth(), current.getDate());
|
|
104
|
+
if (normalized === 'yesterday')
|
|
105
|
+
boundary.setDate(boundary.getDate() - 1);
|
|
106
|
+
if (normalized === 'this-week') {
|
|
107
|
+
const daysSinceMonday = (boundary.getDay() + 6) % 7;
|
|
108
|
+
boundary.setDate(boundary.getDate() - daysSinceMonday);
|
|
109
|
+
}
|
|
110
|
+
if (normalized === 'this-month')
|
|
111
|
+
boundary.setDate(1);
|
|
112
|
+
return boundary.getTime();
|
|
113
|
+
}
|
|
114
|
+
const relative = /^(\d+)(h|d|w)$/.exec(normalized);
|
|
115
|
+
if (relative) {
|
|
116
|
+
const amount = Number(relative[1]);
|
|
117
|
+
const unit = relative[2];
|
|
118
|
+
const multiplier = unit === 'h' ? 60 * 60 * 1000 : unit === 'd' ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000;
|
|
119
|
+
if (amount > 0 && amount <= 10_000)
|
|
120
|
+
return now - amount * multiplier;
|
|
121
|
+
}
|
|
122
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
|
123
|
+
const [year, month, day] = normalized.split('-').map(Number);
|
|
124
|
+
const date = new Date(year, month - 1, day);
|
|
125
|
+
if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day)
|
|
126
|
+
return date.getTime();
|
|
127
|
+
}
|
|
128
|
+
const parsed = Date.parse(value);
|
|
129
|
+
if (Number.isFinite(parsed))
|
|
130
|
+
return parsed;
|
|
131
|
+
throw new Error(`${field} must be today, yesterday, this-week, this-month, a duration like 24h or 7d, or an ISO date/time`);
|
|
132
|
+
}
|
|
133
|
+
function agentStatus(state) {
|
|
134
|
+
if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
|
|
135
|
+
return 'needs-you';
|
|
136
|
+
if (state.status === 'working' || state.status === 'error')
|
|
137
|
+
return state.status;
|
|
138
|
+
return 'idle';
|
|
139
|
+
}
|
|
140
|
+
function spokenUpdateAge(value, now) {
|
|
141
|
+
const at = parseDate(value);
|
|
142
|
+
if (!at)
|
|
143
|
+
return 'Update time unavailable.';
|
|
144
|
+
const elapsed = Math.max(0, now - at);
|
|
145
|
+
const minutes = Math.floor(elapsed / (60 * 1000));
|
|
146
|
+
if (minutes < 1)
|
|
147
|
+
return 'Updated just now.';
|
|
148
|
+
if (minutes < 60)
|
|
149
|
+
return `Updated ${minutes} minute${minutes === 1 ? '' : 's'} ago.`;
|
|
150
|
+
const hours = Math.floor(elapsed / (60 * 60 * 1000));
|
|
151
|
+
if (hours < 24)
|
|
152
|
+
return `Updated ${hours} hour${hours === 1 ? '' : 's'} ago.`;
|
|
153
|
+
const days = Math.floor(elapsed / (24 * 60 * 60 * 1000));
|
|
154
|
+
if (days === 1)
|
|
155
|
+
return 'Updated yesterday.';
|
|
156
|
+
if (days < 31)
|
|
157
|
+
return `Updated ${days} days ago.`;
|
|
158
|
+
const date = new Date(at);
|
|
159
|
+
const month = [
|
|
160
|
+
'January',
|
|
161
|
+
'February',
|
|
162
|
+
'March',
|
|
163
|
+
'April',
|
|
164
|
+
'May',
|
|
165
|
+
'June',
|
|
166
|
+
'July',
|
|
167
|
+
'August',
|
|
168
|
+
'September',
|
|
169
|
+
'October',
|
|
170
|
+
'November',
|
|
171
|
+
'December'
|
|
172
|
+
][date.getUTCMonth()];
|
|
173
|
+
return `Updated on ${month} ${date.getUTCDate()}, ${date.getUTCFullYear()}.`;
|
|
83
174
|
}
|
|
84
175
|
function overviewRank(state) {
|
|
85
176
|
if (state.status === 'error')
|
|
@@ -182,7 +273,22 @@ export class VoiceBriefBoard {
|
|
|
182
273
|
return this.cached;
|
|
183
274
|
}
|
|
184
275
|
/** A deliberately uncached read: a new overview must not replay the call-opening tally. */
|
|
185
|
-
async workspaceOverview(cursor = 0) {
|
|
276
|
+
async workspaceOverview(cursor = 0, filters = {}) {
|
|
277
|
+
if (filters.agentStatus && !OVERVIEW_AGENT_STATUSES.has(filters.agentStatus))
|
|
278
|
+
throw new Error('agent_status must be working, idle, error, or needs-you');
|
|
279
|
+
const now = this.now();
|
|
280
|
+
const updatedSince = filters.updatedSince ? parseOverviewDate(filters.updatedSince, now, 'updated_since') : null;
|
|
281
|
+
const updatedBefore = filters.updatedBefore ? parseOverviewDate(filters.updatedBefore, now, 'updated_before') : null;
|
|
282
|
+
if (updatedSince !== null && updatedBefore !== null && updatedSince >= updatedBefore)
|
|
283
|
+
throw new Error('updated_since must be earlier than updated_before');
|
|
284
|
+
const wantedWorkspaceStatus = filters.workspaceStatus ? normalizedStatus(filters.workspaceStatus) : null;
|
|
285
|
+
const completedOnly = wantedWorkspaceStatus === 'done' || filters.prStatus === 'merged';
|
|
286
|
+
const includeDone = filters.includeDone === true || completedOnly;
|
|
287
|
+
const includeMerged = filters.includeMerged === true || completedOnly;
|
|
288
|
+
const ignoreDormantAge = updatedSince !== null ||
|
|
289
|
+
updatedBefore !== null ||
|
|
290
|
+
wantedWorkspaceStatus !== null ||
|
|
291
|
+
filters.prStatus !== undefined;
|
|
186
292
|
const workspaces = new Map(this.deps.reads.listWorkspaces().map(workspace => [workspace.id, workspace]));
|
|
187
293
|
const grouped = new Map();
|
|
188
294
|
for (const state of this.deps.reads.listSessionStates()) {
|
|
@@ -193,20 +299,56 @@ export class VoiceBriefBoard {
|
|
|
193
299
|
grouped.set(state.workspaceId, states);
|
|
194
300
|
}
|
|
195
301
|
let dormant = 0;
|
|
302
|
+
let completed = 0;
|
|
303
|
+
let filtered = 0;
|
|
196
304
|
const items = [];
|
|
197
305
|
for (const [workspaceId, workspace] of workspaces) {
|
|
198
|
-
|
|
199
|
-
|
|
306
|
+
if (filters.repo && workspace.repo_name?.toLowerCase() !== filters.repo.toLowerCase()) {
|
|
307
|
+
filtered++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (wantedWorkspaceStatus && normalizedStatus(statusLabel(workspace)) !== wantedWorkspaceStatus) {
|
|
311
|
+
filtered++;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (filters.prStatus && (workspace.pr_status ?? 'none') !== filters.prStatus) {
|
|
315
|
+
filtered++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if ((!includeDone && isDone(workspace)) || (!includeMerged && isMerged(workspace))) {
|
|
319
|
+
completed++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const live = (grouped.get(workspaceId) ?? []).filter(state => !isDormant(state, workspace, now, {
|
|
323
|
+
ignoreAge: ignoreDormantAge,
|
|
324
|
+
includeDone,
|
|
325
|
+
includedWorkspaceStatus: wantedWorkspaceStatus ?? undefined
|
|
326
|
+
}));
|
|
327
|
+
if (!live.length) {
|
|
200
328
|
dormant++;
|
|
201
329
|
continue;
|
|
202
330
|
}
|
|
331
|
+
const current = live.filter(state => {
|
|
332
|
+
if (filters.agentStatus && agentStatus(state) !== filters.agentStatus)
|
|
333
|
+
return false;
|
|
334
|
+
const at = parseDate(state.updatedAt);
|
|
335
|
+
if (updatedSince !== null && at < updatedSince)
|
|
336
|
+
return false;
|
|
337
|
+
if (updatedBefore !== null && at >= updatedBefore)
|
|
338
|
+
return false;
|
|
339
|
+
return true;
|
|
340
|
+
});
|
|
341
|
+
if (!current.length) {
|
|
342
|
+
filtered++;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
203
345
|
current.sort((a, b) => overviewRank(a) - overviewRank(b) || parseDate(b.updatedAt) - parseDate(a.updatedAt));
|
|
204
346
|
const state = current[0];
|
|
205
347
|
const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
|
|
206
348
|
items.push({
|
|
207
349
|
workspaceId,
|
|
208
350
|
sessionId: state.sessionId,
|
|
209
|
-
title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
|
|
351
|
+
title: oneLine(state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle, 100),
|
|
210
352
|
status: overviewStatus(state, workspace),
|
|
211
353
|
updatedAt: state.updatedAt,
|
|
212
354
|
update: oneLine(speechText(said, 150), 150),
|
|
@@ -219,13 +361,18 @@ export class VoiceBriefBoard {
|
|
|
219
361
|
const page = rankedPage.map(({ rank: _rank, ...item }) => item);
|
|
220
362
|
const next = offset + page.length < items.length ? offset + page.length : null;
|
|
221
363
|
const noun = items.length === 1 ? 'workspace' : 'workspaces';
|
|
222
|
-
const lines = page.map(item => `${item.title} ${item.status}
|
|
364
|
+
const lines = page.map(item => `${item.title} ${item.status}. ${spokenUpdateAge(item.updatedAt, now)}${item.update ? ` ${item.update}` : ' No agent update yet.'}`);
|
|
223
365
|
const more = next === null ? '' : ` ${items.length - next} more current; ask me to continue.`;
|
|
224
|
-
const none = items.length ? '' : ' Nothing is active right now.';
|
|
366
|
+
const none = items.length ? '' : filtered ? ' Nothing matches those filters.' : ' Nothing is active right now.';
|
|
367
|
+
const completedSummary = completed ? `, ${completed} completed hidden` : '';
|
|
368
|
+
const filteredSummary = filtered ? `, ${filtered} outside filters` : '';
|
|
225
369
|
return {
|
|
226
|
-
spoken: clipExact(`Fresh overview: ${items.length} current ${noun}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
|
|
370
|
+
spoken: clipExact(`Fresh overview: ${items.length} current ${noun}${completedSummary}${filteredSummary}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
|
|
371
|
+
asOf: new Date(now).toISOString(),
|
|
227
372
|
current: items.length,
|
|
228
373
|
dormant,
|
|
374
|
+
completed,
|
|
375
|
+
filtered,
|
|
229
376
|
cursor: next,
|
|
230
377
|
workspaces: page
|
|
231
378
|
};
|
|
@@ -16,7 +16,9 @@ export class PreviewStore {
|
|
|
16
16
|
return this.previews;
|
|
17
17
|
try {
|
|
18
18
|
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
19
|
-
this.previews = Array.isArray(parsed)
|
|
19
|
+
this.previews = Array.isArray(parsed)
|
|
20
|
+
? parsed.map(preview => preview.kind ? preview : { ...preview, kind: 'send_prompt' })
|
|
21
|
+
: [];
|
|
20
22
|
}
|
|
21
23
|
catch {
|
|
22
24
|
this.previews = [];
|
|
@@ -32,6 +34,7 @@ export class PreviewStore {
|
|
|
32
34
|
const createdAt = this.now();
|
|
33
35
|
const preview = {
|
|
34
36
|
...input,
|
|
37
|
+
kind: 'send_prompt',
|
|
35
38
|
token: crypto.randomBytes(18).toString('base64url'),
|
|
36
39
|
createdAt,
|
|
37
40
|
expiresAt: createdAt + PREVIEW_TTL_MS,
|
|
@@ -44,7 +47,22 @@ export class PreviewStore {
|
|
|
44
47
|
this.write();
|
|
45
48
|
return preview;
|
|
46
49
|
}
|
|
47
|
-
|
|
50
|
+
createWorkspace(input) {
|
|
51
|
+
const createdAt = this.now();
|
|
52
|
+
const preview = {
|
|
53
|
+
...input,
|
|
54
|
+
kind: 'create_workspace',
|
|
55
|
+
token: crypto.randomBytes(18).toString('base64url'),
|
|
56
|
+
createdAt,
|
|
57
|
+
expiresAt: createdAt + PREVIEW_TTL_MS,
|
|
58
|
+
status: 'ready'
|
|
59
|
+
};
|
|
60
|
+
this.previews = this.read().filter(candidate => candidate.expiresAt >= createdAt);
|
|
61
|
+
this.previews.push(preview);
|
|
62
|
+
this.write();
|
|
63
|
+
return preview;
|
|
64
|
+
}
|
|
65
|
+
available(token, callId) {
|
|
48
66
|
const preview = this.read().find(candidate => candidate.token === token);
|
|
49
67
|
if (!preview)
|
|
50
68
|
return { ok: false, reason: 'unknown' };
|
|
@@ -52,16 +70,41 @@ export class PreviewStore {
|
|
|
52
70
|
return { ok: false, reason: 'expired' };
|
|
53
71
|
if (preview.status !== 'ready')
|
|
54
72
|
return { ok: false, reason: 'already-used' };
|
|
55
|
-
if (preview.callId !==
|
|
73
|
+
if (preview.callId !== callId)
|
|
56
74
|
return { ok: false, reason: 'foreign-call' };
|
|
75
|
+
return { ok: true, preview };
|
|
76
|
+
}
|
|
77
|
+
markClaimed(preview) {
|
|
78
|
+
preview.status = 'claimed';
|
|
79
|
+
this.write();
|
|
80
|
+
return { ...preview };
|
|
81
|
+
}
|
|
82
|
+
claim(token, input) {
|
|
83
|
+
const found = this.available(token, input.callId);
|
|
84
|
+
if (!found.ok)
|
|
85
|
+
return found;
|
|
86
|
+
const preview = found.preview;
|
|
87
|
+
if (preview.kind !== 'send_prompt')
|
|
88
|
+
return { ok: false, reason: 'wrong-action' };
|
|
57
89
|
if (preview.sessionId !== input.sessionId)
|
|
58
90
|
return { ok: false, reason: 'foreign-session' };
|
|
59
91
|
if (preview.text !== input.text)
|
|
60
92
|
return { ok: false, reason: 'text-mismatch' };
|
|
61
93
|
// Persist the claim before any caller starts an async UI delivery. A crash can lose a
|
|
62
94
|
// send, but cannot replay one whose outcome became unknowable.
|
|
63
|
-
preview.
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
return { ok: true, preview: this.markClaimed(preview) };
|
|
96
|
+
}
|
|
97
|
+
claimWorkspace(token, input) {
|
|
98
|
+
const found = this.available(token, input.callId);
|
|
99
|
+
if (!found.ok)
|
|
100
|
+
return found;
|
|
101
|
+
const preview = found.preview;
|
|
102
|
+
if (preview.kind !== 'create_workspace')
|
|
103
|
+
return { ok: false, reason: 'wrong-action' };
|
|
104
|
+
if (preview.repo !== input.repo)
|
|
105
|
+
return { ok: false, reason: 'foreign-repo' };
|
|
106
|
+
if (preview.prompt !== input.prompt)
|
|
107
|
+
return { ok: false, reason: 'prompt-mismatch' };
|
|
108
|
+
return { ok: true, preview: this.markClaimed(preview) };
|
|
66
109
|
}
|
|
67
110
|
}
|
|
@@ -3,7 +3,9 @@ export const VOICE_INSTRUCTIONS = `You are a voice switchboard for the user's Co
|
|
|
3
3
|
|
|
4
4
|
Start with voice_roll_call. Work through one decision at a time with voice_next_decision. Speak only the tool result's spoken field; never read ids, cursors, JSON keys, or tokens aloud. Keep replies short enough for someone walking.
|
|
5
5
|
|
|
6
|
-
Every time the user asks for a workspace overview, status, progress, or what is happening across the fleet, call voice_workspace_overview starting at cursor zero. This is a fresh read, so never answer
|
|
6
|
+
Every time the user asks for a workspace overview, status, progress, or what is happening across the fleet, call voice_workspace_overview starting at cursor zero. This is a fresh read, so never answer from the opening roll call or an earlier overview. Merged and Done workspaces are hidden by default; include either only when the user asks for completed work or explicitly names that state. Translate time requests into updated_since or updated_before, and use repo, agent_status, workspace_status, or pr_status when requested. If they ask to continue, pass the prior cursor and the same filters.
|
|
7
|
+
|
|
8
|
+
When the user wants a new workspace, call voice_list_repos to resolve its exact repository, then voice_create_workspace_preview with the exact first prompt. Read the exact repository and prompt back and ask for yes. Only after yes, call voice_create_workspace with the returned token and unchanged repository and prompt. Creation runs asynchronously and its result will be announced.
|
|
7
9
|
|
|
8
10
|
When the user wants to dispatch text, call voice_send_preview with the exact target and text. Read the exact preview back, including the target, and ask for an explicit yes. Only after yes, call voice_send with the returned token and exactly the same session and text. Never call voice_send without that confirmation. A send queues asynchronously; success is silent, while parked or failed delivery will be announced.
|
|
9
11
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { oneLine } from "../speech.js";
|
|
1
|
+
import { clipExact, oneLine } from "../speech.js";
|
|
2
2
|
/**
|
|
3
3
|
* One definition table feeds both transports: SIP exposes it through the scoped
|
|
4
4
|
* MCP server, while the PWA's private sideband exposes the same entries as
|
|
@@ -9,6 +9,9 @@ export const VOICE_TOOL_NAMES = [
|
|
|
9
9
|
'voice_roll_call',
|
|
10
10
|
'voice_workspace_overview',
|
|
11
11
|
'voice_next_decision',
|
|
12
|
+
'voice_list_repos',
|
|
13
|
+
'voice_create_workspace_preview',
|
|
14
|
+
'voice_create_workspace',
|
|
12
15
|
'voice_send_preview',
|
|
13
16
|
'voice_send'
|
|
14
17
|
];
|
|
@@ -20,11 +23,37 @@ export const VOICE_TOOL_DEFINITIONS = [
|
|
|
20
23
|
},
|
|
21
24
|
{
|
|
22
25
|
name: 'voice_workspace_overview',
|
|
23
|
-
description: 'Get a fresh overview of current workspaces with
|
|
26
|
+
description: 'Get a fresh, dated overview of current workspaces with filters and the relay as-of time. Merged and Done workspaces are excluded unless explicitly included. Call this every time the user asks for an overview or workspace status, even if one was already given. Pass the same filters with the returned cursor to continue.',
|
|
24
27
|
inputSchema: {
|
|
25
28
|
type: 'object',
|
|
26
29
|
properties: {
|
|
27
|
-
cursor: { type: 'number', description: 'The cursor returned by the previous overview page; default 0.' }
|
|
30
|
+
cursor: { type: 'number', description: 'The cursor returned by the previous overview page; default 0.' },
|
|
31
|
+
repo: { type: 'string', description: 'Only this exact repository name.' },
|
|
32
|
+
agent_status: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: ['working', 'idle', 'error', 'needs-you'],
|
|
35
|
+
description: 'Only workspaces whose matching chat has this live agent status.'
|
|
36
|
+
},
|
|
37
|
+
workspace_status: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['backlog', 'in-progress', 'in-review', 'done', 'canceled'],
|
|
40
|
+
description: 'Only workspaces in this Conductor sidebar status. Requesting done includes Done workspaces.'
|
|
41
|
+
},
|
|
42
|
+
pr_status: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
enum: ['merged', 'draft', 'conflicts', 'checks_failed', 'checks_pending', 'mergeable', 'none'],
|
|
45
|
+
description: 'Only workspaces with this pull-request status. Requesting merged includes merged workspaces.'
|
|
46
|
+
},
|
|
47
|
+
updated_since: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'Only chat activity since today, yesterday, this-week, this-month, a relative duration like 24h or 7d, or an ISO date/time.'
|
|
50
|
+
},
|
|
51
|
+
updated_before: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Only chat activity before this exclusive named boundary, relative duration, ISO date/time, or date. For yesterday alone, use updated_since yesterday and updated_before today.'
|
|
54
|
+
},
|
|
55
|
+
include_done: { type: 'boolean', description: 'Include Done workspaces; default false.' },
|
|
56
|
+
include_merged: { type: 'boolean', description: 'Include merged workspaces; default false.' }
|
|
28
57
|
}
|
|
29
58
|
}
|
|
30
59
|
},
|
|
@@ -42,6 +71,36 @@ export const VOICE_TOOL_DEFINITIONS = [
|
|
|
42
71
|
}
|
|
43
72
|
}
|
|
44
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: 'voice_list_repos',
|
|
76
|
+
description: 'List the repositories where Conductor can create a workspace. Use before a creation preview.',
|
|
77
|
+
inputSchema: { type: 'object', properties: {} }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'voice_create_workspace_preview',
|
|
81
|
+
description: 'Create a two-minute preview for a new workspace in an exact repository, with an optional first prompt. Speak its exact target and prompt and ask for yes before using voice_create_workspace.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
repo: { type: 'string', description: 'Exact repository name from voice_list_repos.' },
|
|
86
|
+
prompt: { type: 'string', description: 'Optional first prompt. Omit to create an empty workspace.' }
|
|
87
|
+
},
|
|
88
|
+
required: ['repo']
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'voice_create_workspace',
|
|
93
|
+
description: 'Create an exact workspace preview after the user said yes. Requires its token, repository, and unchanged prompt; never accepts raw unpreviewed creation.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
token: { type: 'string' },
|
|
98
|
+
repo: { type: 'string' },
|
|
99
|
+
prompt: { type: 'string', description: 'The unchanged preview prompt; omit only when the preview was empty.' }
|
|
100
|
+
},
|
|
101
|
+
required: ['token', 'repo']
|
|
102
|
+
}
|
|
103
|
+
},
|
|
45
104
|
{
|
|
46
105
|
name: 'voice_send_preview',
|
|
47
106
|
description: 'Create a two-minute exact-text send preview. Speak its exact target and text and ask for yes before using voice_send.',
|
|
@@ -84,23 +143,33 @@ function need(args, key) {
|
|
|
84
143
|
throw new Error(`${key} is required`);
|
|
85
144
|
return value.trim();
|
|
86
145
|
}
|
|
146
|
+
function optional(args, key) {
|
|
147
|
+
const value = args[key];
|
|
148
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
149
|
+
}
|
|
87
150
|
function answer(value) {
|
|
88
151
|
return JSON.stringify(value);
|
|
89
152
|
}
|
|
90
153
|
function refusal(reason) {
|
|
91
154
|
switch (reason) {
|
|
92
155
|
case 'expired':
|
|
93
|
-
return 'That preview expired. Read the exact
|
|
156
|
+
return 'That preview expired. Read the exact action back again before approving it.';
|
|
94
157
|
case 'foreign-call':
|
|
95
158
|
return 'That preview belongs to another call and cannot be used here.';
|
|
96
159
|
case 'foreign-session':
|
|
97
160
|
return 'That preview belongs to a different chat. Preview this target again.';
|
|
161
|
+
case 'foreign-repo':
|
|
162
|
+
return 'That preview belongs to a different repository. Preview this workspace again.';
|
|
98
163
|
case 'text-mismatch':
|
|
99
164
|
return 'The send text does not exactly match the preview. Preview the changed text first.';
|
|
165
|
+
case 'prompt-mismatch':
|
|
166
|
+
return 'The first prompt does not exactly match the preview. Preview the changed workspace first.';
|
|
167
|
+
case 'wrong-action':
|
|
168
|
+
return 'That preview is for a different action. Make a new preview first.';
|
|
100
169
|
case 'already-used':
|
|
101
|
-
return 'That preview was already used. The relay will not
|
|
170
|
+
return 'That preview was already used. The relay will not run it twice.';
|
|
102
171
|
case 'unknown':
|
|
103
|
-
return 'That preview token is unknown. Make a new preview before
|
|
172
|
+
return 'That preview token is unknown. Make a new preview before continuing.';
|
|
104
173
|
}
|
|
105
174
|
}
|
|
106
175
|
function later(task) {
|
|
@@ -123,7 +192,30 @@ export function createVoiceTools(context) {
|
|
|
123
192
|
...definition('voice_workspace_overview'),
|
|
124
193
|
run: async (args) => {
|
|
125
194
|
const cursor = typeof args.cursor === 'number' && Number.isFinite(args.cursor) ? args.cursor : 0;
|
|
126
|
-
|
|
195
|
+
const filters = {};
|
|
196
|
+
const repo = optional(args, 'repo');
|
|
197
|
+
const agentStatus = optional(args, 'agent_status');
|
|
198
|
+
const workspaceStatus = optional(args, 'workspace_status');
|
|
199
|
+
const prStatus = optional(args, 'pr_status');
|
|
200
|
+
const updatedSince = optional(args, 'updated_since');
|
|
201
|
+
const updatedBefore = optional(args, 'updated_before');
|
|
202
|
+
if (repo)
|
|
203
|
+
filters.repo = repo;
|
|
204
|
+
if (agentStatus)
|
|
205
|
+
filters.agentStatus = agentStatus;
|
|
206
|
+
if (workspaceStatus)
|
|
207
|
+
filters.workspaceStatus = workspaceStatus;
|
|
208
|
+
if (prStatus)
|
|
209
|
+
filters.prStatus = prStatus;
|
|
210
|
+
if (updatedSince)
|
|
211
|
+
filters.updatedSince = updatedSince;
|
|
212
|
+
if (updatedBefore)
|
|
213
|
+
filters.updatedBefore = updatedBefore;
|
|
214
|
+
if (typeof args.include_done === 'boolean')
|
|
215
|
+
filters.includeDone = args.include_done;
|
|
216
|
+
if (typeof args.include_merged === 'boolean')
|
|
217
|
+
filters.includeMerged = args.include_merged;
|
|
218
|
+
return answer(await context.board.workspaceOverview(cursor, filters));
|
|
127
219
|
}
|
|
128
220
|
},
|
|
129
221
|
{
|
|
@@ -136,6 +228,71 @@ export function createVoiceTools(context) {
|
|
|
136
228
|
return answer(next ?? { spoken: 'There are no more decisions in this call.', cursor, done: true });
|
|
137
229
|
}
|
|
138
230
|
},
|
|
231
|
+
{
|
|
232
|
+
...definition('voice_list_repos'),
|
|
233
|
+
run: async () => {
|
|
234
|
+
const repos = context.listRepos();
|
|
235
|
+
return answer({
|
|
236
|
+
spoken: repos.length
|
|
237
|
+
? clipExact(`Available repositories: ${repos.map(repo => repo.name).join(', ')}.`, 600)
|
|
238
|
+
: 'Conductor has no repository available for a new workspace.',
|
|
239
|
+
repos
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
...definition('voice_create_workspace_preview'),
|
|
245
|
+
run: async (args) => {
|
|
246
|
+
const requestedRepo = need(args, 'repo');
|
|
247
|
+
const repo = context.listRepos().find(candidate => candidate.name.toLowerCase() === requestedRepo.toLowerCase());
|
|
248
|
+
if (!repo)
|
|
249
|
+
return answer({
|
|
250
|
+
status: 'refused',
|
|
251
|
+
spoken: `I could not find the ${oneLine(requestedRepo, 80)} repository. Ask me to list repositories first.`
|
|
252
|
+
});
|
|
253
|
+
const prompt = optional(args, 'prompt') ?? '';
|
|
254
|
+
const preview = context.previews.createWorkspace({ callId: context.callId, repo: repo.name, prompt });
|
|
255
|
+
const detail = prompt ? ` with this first prompt: “${oneLine(prompt, 220)}”` : ' with no first prompt.';
|
|
256
|
+
return answer({
|
|
257
|
+
status: 'preview',
|
|
258
|
+
token: preview.token,
|
|
259
|
+
repo: repo.name,
|
|
260
|
+
prompt,
|
|
261
|
+
spoken: `Create a new workspace in ${oneLine(repo.name, 80)}${detail} Say yes to create it.`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
...definition('voice_create_workspace'),
|
|
267
|
+
run: async (args) => {
|
|
268
|
+
const token = need(args, 'token');
|
|
269
|
+
const repo = need(args, 'repo');
|
|
270
|
+
const prompt = optional(args, 'prompt') ?? '';
|
|
271
|
+
const claimed = context.previews.claimWorkspace(token, { callId: context.callId, repo, prompt });
|
|
272
|
+
if (!claimed.ok)
|
|
273
|
+
return answer({ status: 'refused', spoken: refusal(claimed.reason) });
|
|
274
|
+
later(async () => {
|
|
275
|
+
try {
|
|
276
|
+
const result = await context.createWorkspace(claimed.preview);
|
|
277
|
+
if (!result.ok) {
|
|
278
|
+
await context.announce(`The new ${oneLine(repo, 80)} workspace was not created. ${oneLine(result.error ?? 'Try again later.', 220)}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const created = prompt
|
|
282
|
+
? `Created a new ${oneLine(repo, 80)} workspace and queued its first prompt.`
|
|
283
|
+
: `Created a new empty ${oneLine(repo, 80)} workspace.`;
|
|
284
|
+
await context.announce(result.warning ? `${created} ${oneLine(result.warning, 220)}` : created);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
await context.announce(`The new ${oneLine(repo, 80)} workspace was not created. ${oneLine(error instanceof Error ? error.message : String(error), 220)}`);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return answer({
|
|
291
|
+
status: 'queued',
|
|
292
|
+
spoken: `Creating a new workspace in ${oneLine(repo, 80)}. I will say when it is ready.`
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
},
|
|
139
296
|
{
|
|
140
297
|
...definition('voice_send_preview'),
|
|
141
298
|
run: async (args) => {
|