conductor-remote 1.113.0 → 1.115.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 +5 -2
- package/dist/assets/{PierrePatch-awVutyVW.js → PierrePatch-DGi9B1-s.js} +1 -1
- package/dist/assets/index-DNLdIfZ2.css +1 -0
- package/dist/assets/index-Dkiq148q.js +61 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/context-breakdown.js +33 -8
- package/dist-node/src/git.js +12 -1
- package/dist-node/src/reads.js +6 -0
- package/dist-node/src/routes.js +5 -0
- package/dist-node/src/server.js +33 -1
- package/dist-node/src/tool-usage-service.js +61 -0
- package/dist-node/src/tool-usage-worker.js +6 -0
- package/dist-node/src/tool-usage.js +109 -0
- package/dist-node/src/voice/broker.js +27 -8
- package/dist-node/src/voice/config.js +1 -1
- package/dist-node/src/workflow-coordinator.js +1 -1
- package/dist-node/src/writes.js +35 -4
- package/docs/voice-setup.md +10 -4
- package/package.json +1 -1
- package/dist/assets/index-DDm_FLf-.js +0 -61
- package/dist/assets/index-MmdttBBU.css +0 -1
package/dist/index.html
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
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-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-Dkiq148q.js"></script>
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DNLdIfZ2.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="root"></div>
|
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
|
|
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 a=e=>i(e,l),t={module:{uri:l},exports:o,require:a};s[l]=Promise.all(n.map(e=>t[e]||a(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:"c0864e9c2073eea6a60e6105a3f60865"},{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-Dkiq148q.js",revision:null},{url:"assets/index-DNLdIfZ2.css",revision:null},{url:"assets/PierrePatch-DGi9B1-s.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")});
|
|
@@ -11,7 +11,7 @@ const BYTES_PER_TOKEN = 4;
|
|
|
11
11
|
export function estimateTextTokens(text) {
|
|
12
12
|
return text ? Math.ceil(Buffer.byteLength(text, 'utf8') / BYTES_PER_TOKEN) : 0;
|
|
13
13
|
}
|
|
14
|
-
function
|
|
14
|
+
export function parseContextFrame(row) {
|
|
15
15
|
const content = row.content ?? '';
|
|
16
16
|
if (!content.startsWith('{'))
|
|
17
17
|
return null;
|
|
@@ -42,17 +42,42 @@ function isCompactBoundary(frame) {
|
|
|
42
42
|
* ordinary text. Providers account for those specially; treating base64 as prose can
|
|
43
43
|
* turn one screenshot into hundreds of thousands of imaginary tool tokens.
|
|
44
44
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
45
|
+
export function contextBlockBytes(block) {
|
|
46
|
+
function visibleValue(key, value) {
|
|
47
47
|
if (key === 'signature' || key === 'encrypted_content')
|
|
48
48
|
return undefined;
|
|
49
49
|
if (typeof value === 'string' && /^data:[^;,]+;base64,/i.test(value))
|
|
50
50
|
return '[binary data]';
|
|
51
|
-
if (key === 'data' && typeof value === 'string' &&
|
|
52
|
-
return '[binary
|
|
51
|
+
if (key === 'data' && typeof value === 'string' && ['base64', 'image', 'audio'].includes(String(this.type))) {
|
|
52
|
+
return '[binary data]';
|
|
53
|
+
}
|
|
54
|
+
if (key === 'blob' && typeof value === 'string' && typeof this.mimeType === 'string')
|
|
55
|
+
return '[binary data]';
|
|
56
|
+
// Codex often saves an MCP result as serialized JSON inside the tool_result
|
|
57
|
+
// string. Its image.data is still binary, even though the outer frame looks
|
|
58
|
+
// textual. Preserve ordinary JSON formatting unless a payload was stripped.
|
|
59
|
+
if (key === 'content' &&
|
|
60
|
+
this.type === 'tool_result' &&
|
|
61
|
+
typeof value === 'string' &&
|
|
62
|
+
(/"(?:data|blob|signature|encrypted_content)"\s*:/.test(value) || /data:[^;,]+;base64,/i.test(value))) {
|
|
63
|
+
try {
|
|
64
|
+
let changed = false;
|
|
65
|
+
const cleaned = JSON.stringify(JSON.parse(value), function (nestedKey, nestedValue) {
|
|
66
|
+
const visible = visibleValue.call(this, nestedKey, nestedValue);
|
|
67
|
+
if (visible !== nestedValue)
|
|
68
|
+
changed = true;
|
|
69
|
+
return visible;
|
|
70
|
+
});
|
|
71
|
+
if (changed)
|
|
72
|
+
return cleaned;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Ordinary tool text need not be JSON.
|
|
76
|
+
}
|
|
53
77
|
}
|
|
54
78
|
return value;
|
|
55
|
-
}
|
|
79
|
+
}
|
|
80
|
+
const json = JSON.stringify(block, visibleValue);
|
|
56
81
|
return json ? Buffer.byteLength(json, 'utf8') : 0;
|
|
57
82
|
}
|
|
58
83
|
function fittedCategories(bytes, totalTokens) {
|
|
@@ -90,7 +115,7 @@ function fittedCategories(bytes, totalTokens) {
|
|
|
90
115
|
*/
|
|
91
116
|
export function estimateContextCategories(rows, totalTokens) {
|
|
92
117
|
const total = Number.isFinite(totalTokens) ? Math.max(0, Math.round(totalTokens)) : 0;
|
|
93
|
-
const parsed = rows.map(row => ({ ...row, frame:
|
|
118
|
+
const parsed = rows.map(row => ({ ...row, frame: parseContextFrame(row) }));
|
|
94
119
|
// A Claude subagent's frames are copied into its parent chat. Its own result and
|
|
95
120
|
// compaction markers describe the child's window, not the session meter beside this
|
|
96
121
|
// tab, so only root frames may define the completed cut or reset the active history.
|
|
@@ -125,7 +150,7 @@ export function estimateContextCategories(rows, totalTokens) {
|
|
|
125
150
|
if (!raw || typeof raw !== 'object')
|
|
126
151
|
continue;
|
|
127
152
|
const type = typeof raw.type === 'string' ? raw.type : '';
|
|
128
|
-
const size =
|
|
153
|
+
const size = contextBlockBytes(raw);
|
|
129
154
|
if (type === 'tool_use' || type === 'tool_result')
|
|
130
155
|
bytes.tools += size;
|
|
131
156
|
else if (/thinking|reasoning/.test(type))
|
package/dist-node/src/git.js
CHANGED
|
@@ -223,6 +223,8 @@ async function localState(worktree) {
|
|
|
223
223
|
* a mention only when it matches a real file. The same list lets a reviewer browse
|
|
224
224
|
* source beyond the changed set. Tracked plus untracked-not-ignored: an agent that just
|
|
225
225
|
* wrote a file should appear in both places long before anything commits it.
|
|
226
|
+
* The root `.context/` directory is included even when ignored, so workspace notes
|
|
227
|
+
* and plans are browsable too. Other ignored paths keep their normal exclusions.
|
|
226
228
|
*
|
|
227
229
|
* Two things keep the payload small. Only previewable extensions ship, because
|
|
228
230
|
* `/api/files` refuses everything else anyway, and 20,000 paths is the ceiling — a
|
|
@@ -232,7 +234,16 @@ async function localState(worktree) {
|
|
|
232
234
|
export async function listSourceFiles(worktree) {
|
|
233
235
|
let listing = '';
|
|
234
236
|
try {
|
|
235
|
-
listing = await git(worktree, [
|
|
237
|
+
listing = await git(worktree, [
|
|
238
|
+
'ls-files',
|
|
239
|
+
'--cached',
|
|
240
|
+
'--others',
|
|
241
|
+
'--exclude-standard',
|
|
242
|
+
// Unignore the directory itself to let Git descend, then all its contents.
|
|
243
|
+
'--exclude=!/.context/',
|
|
244
|
+
'--exclude=!/.context/**',
|
|
245
|
+
'-z'
|
|
246
|
+
]);
|
|
236
247
|
}
|
|
237
248
|
catch {
|
|
238
249
|
return { files: [], truncated: false };
|
package/dist-node/src/reads.js
CHANGED
|
@@ -350,6 +350,12 @@ export class Reads {
|
|
|
350
350
|
return toSessionRow(row, started === undefined ? [] : this.openBackgroundTasks(row.id, started));
|
|
351
351
|
});
|
|
352
352
|
}
|
|
353
|
+
listClosedSessions(workspaceId) {
|
|
354
|
+
return this.db.query(`SELECT id, title, model, agent_type, created_at, updated_at
|
|
355
|
+
FROM sessions
|
|
356
|
+
WHERE workspace_id = ? AND is_hidden = 1
|
|
357
|
+
ORDER BY REPLACE(REPLACE(updated_at, 'T', ' '), 'Z', '') DESC, created_at DESC, id ASC`, [workspaceId]);
|
|
358
|
+
}
|
|
353
359
|
/** One delegated child, including its provider-specific open-task guard. */
|
|
354
360
|
getSession(sessionId) {
|
|
355
361
|
const rows = this.db.query(`SELECT s.id, s.status, s.title, s.model, s.permission_mode,
|
package/dist-node/src/routes.js
CHANGED
|
@@ -52,6 +52,8 @@ export const routes = {
|
|
|
52
52
|
updateModelDefaults: flat('PATCH', '/api/models/defaults'),
|
|
53
53
|
/** Rolling subscription limits, read without a model request from each provider CLI. */
|
|
54
54
|
planUsage: flat('GET', '/api/usage'),
|
|
55
|
+
/** Estimated tool input/result tokens from recent saved chat activity. */
|
|
56
|
+
toolUsage: flat('GET', '/api/usage/tools'),
|
|
55
57
|
/** Mint a short-lived OpenAI SIP URI for an authenticated native client. */
|
|
56
58
|
voiceTicket: flat('POST', '/api/voice/ticket'),
|
|
57
59
|
/** Negotiate one app-wide PWA orchestrator call through the relay. */
|
|
@@ -123,6 +125,7 @@ export const routes = {
|
|
|
123
125
|
/** One workspace by id, archived included — what `/api/state` deliberately leaves out. */
|
|
124
126
|
workspace: param('GET', '/api/workspaces/:workspaceId'),
|
|
125
127
|
sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
|
|
128
|
+
closedSessions: param('GET', '/api/workspaces/:workspaceId/sessions/closed'),
|
|
126
129
|
newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
|
|
127
130
|
diff: param('GET', '/api/workspaces/:workspaceId/diff'),
|
|
128
131
|
/** One complete changed-file patch, addressed by the `path` query parameter. */
|
|
@@ -152,6 +155,8 @@ export const routes = {
|
|
|
152
155
|
stop: param('POST', '/api/sessions/:sessionId/stop'),
|
|
153
156
|
/** Hide one chat tab through Conductor's own Close tab action (Command-W). */
|
|
154
157
|
closeChat: param('DELETE', '/api/sessions/:sessionId'),
|
|
158
|
+
/** Restore the original chat by its workspace/session deep link. */
|
|
159
|
+
restoreChat: param('POST', '/api/sessions/:sessionId/restore'),
|
|
155
160
|
sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
|
|
156
161
|
/** Write a phone-selected file into Conductor's attachment layout for this chat's workspace. */
|
|
157
162
|
uploadAttachment: param('POST', '/api/sessions/:sessionId/attachments'),
|
package/dist-node/src/server.js
CHANGED
|
@@ -41,6 +41,8 @@ import { readSettings, writeSettings } from "./settings.js";
|
|
|
41
41
|
import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
|
|
42
42
|
import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
|
|
43
43
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
44
|
+
import { isToolUsageRange } from "./tool-usage.js";
|
|
45
|
+
import { ToolUsageService } from "./tool-usage-service.js";
|
|
44
46
|
import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
|
|
45
47
|
import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
|
|
46
48
|
import { VoiceBriefBoard } from "./voice/brief.js";
|
|
@@ -58,7 +60,7 @@ import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks }
|
|
|
58
60
|
import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
|
|
59
61
|
import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
|
|
60
62
|
import { WorkflowGuardError } from "./workflow-machine.js";
|
|
61
|
-
import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
|
|
63
|
+
import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, restoreChat, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
|
|
62
64
|
// Before anything that logs: from here on every console line is also kept in memory for
|
|
63
65
|
// `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
|
|
64
66
|
installLogCapture();
|
|
@@ -92,6 +94,7 @@ function stagedAttachmentIdsInObjective(objective) {
|
|
|
92
94
|
// from a list before Conductor has created its first chat.
|
|
93
95
|
const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
|
|
94
96
|
const planUsage = new PlanUsageService();
|
|
97
|
+
const toolUsage = new ToolUsageService(cfg.dbPath);
|
|
95
98
|
const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
|
|
96
99
|
const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
|
|
97
100
|
processProbe: processIdentityAlive
|
|
@@ -2340,6 +2343,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
2340
2343
|
if (isRoute(routes.planUsage, req.method, pathname)) {
|
|
2341
2344
|
return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
|
|
2342
2345
|
}
|
|
2346
|
+
if (isRoute(routes.toolUsage, req.method, pathname)) {
|
|
2347
|
+
const range = url.searchParams.get('range') ?? '24h';
|
|
2348
|
+
if (!isToolUsageRange(range))
|
|
2349
|
+
return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
|
|
2350
|
+
return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
|
|
2351
|
+
}
|
|
2343
2352
|
if (isRoute(routes.roles, req.method, pathname)) {
|
|
2344
2353
|
const stored = roleStore.read();
|
|
2345
2354
|
return json(req, res, 200, {
|
|
@@ -3059,6 +3068,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
3059
3068
|
...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
|
|
3060
3069
|
});
|
|
3061
3070
|
}
|
|
3071
|
+
const closedSessionsIn = routeParam(routes.closedSessions, req.method, pathname);
|
|
3072
|
+
if (closedSessionsIn) {
|
|
3073
|
+
if (!reads.getAnyWorkspace(closedSessionsIn))
|
|
3074
|
+
return json(req, res, 404, { error: 'workspace not found' });
|
|
3075
|
+
return json(req, res, 200, { sessions: reads.listClosedSessions(closedSessionsIn) });
|
|
3076
|
+
}
|
|
3062
3077
|
// POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
|
|
3063
3078
|
const newChatIn = routeParam(routes.newChat, req.method, pathname);
|
|
3064
3079
|
if (newChatIn) {
|
|
@@ -3422,6 +3437,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
3422
3437
|
session: reads.listSessions(ws.id).find(s => s.id === sessionId)
|
|
3423
3438
|
});
|
|
3424
3439
|
}
|
|
3440
|
+
const restoreChatId = routeParam(routes.restoreChat, req.method, pathname);
|
|
3441
|
+
if (restoreChatId) {
|
|
3442
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
3443
|
+
const ownerId = reads.sessionWorkspaceId(restoreChatId);
|
|
3444
|
+
if (!ownerId)
|
|
3445
|
+
return json(req, res, 404, { error: 'chat not found' });
|
|
3446
|
+
if (body.workspaceId && body.workspaceId !== ownerId) {
|
|
3447
|
+
return json(req, res, 409, { error: 'chat is not in that workspace' });
|
|
3448
|
+
}
|
|
3449
|
+
if (!reads.getWorkspace(ownerId)) {
|
|
3450
|
+
return json(req, res, 409, { error: 'Restore this workspace in Conductor before restoring its tabs.' });
|
|
3451
|
+
}
|
|
3452
|
+
const result = await restoreChat(ownerId, restoreChatId, () => reads.getSession(restoreChatId) !== null);
|
|
3453
|
+
if (!result.ok)
|
|
3454
|
+
return json(req, res, 502, result);
|
|
3455
|
+
return json(req, res, 200, { ...result, session: reads.getSession(restoreChatId) });
|
|
3456
|
+
}
|
|
3425
3457
|
// DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
|
|
3426
3458
|
// reversible Close tab action (Command-W). A running chat gets the same
|
|
3427
3459
|
// explicit "Close anyway" gate as the desktop app.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
const CACHE_MS = 60_000;
|
|
3
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* History can be large: keep SQLite and JSON parsing off the HTTP event loop.
|
|
6
|
+
* One disposable worker at a time reads a range; cached/in-flight reads are shared
|
|
7
|
+
* across phones. Nothing runs until Models requests it, and no index is persisted.
|
|
8
|
+
*/
|
|
9
|
+
export class ToolUsageService {
|
|
10
|
+
dbPath;
|
|
11
|
+
cache = new Map();
|
|
12
|
+
pending = new Map();
|
|
13
|
+
queue = Promise.resolve();
|
|
14
|
+
constructor(dbPath) {
|
|
15
|
+
this.dbPath = dbPath;
|
|
16
|
+
}
|
|
17
|
+
read(range, refresh = false) {
|
|
18
|
+
const running = this.pending.get(range);
|
|
19
|
+
if (running)
|
|
20
|
+
return running;
|
|
21
|
+
const cached = this.cache.get(range);
|
|
22
|
+
if (!refresh && cached && Date.now() - cached.fetchedAt < CACHE_MS)
|
|
23
|
+
return Promise.resolve(cached);
|
|
24
|
+
const read = this.queue.then(() => this.scan(range));
|
|
25
|
+
this.pending.set(range, read);
|
|
26
|
+
this.queue = read.catch(() => { });
|
|
27
|
+
void read.then(data => {
|
|
28
|
+
this.cache.set(range, data);
|
|
29
|
+
this.pending.delete(range);
|
|
30
|
+
}, () => this.pending.delete(range));
|
|
31
|
+
return read;
|
|
32
|
+
}
|
|
33
|
+
scan(range) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const module = import.meta.url.endsWith('.ts') ? './tool-usage-worker.ts' : './tool-usage-worker.js';
|
|
36
|
+
const worker = new Worker(new URL(module, import.meta.url), {
|
|
37
|
+
workerData: { dbPath: this.dbPath, range },
|
|
38
|
+
// Node's watch supervisor expands process flags that workers reject.
|
|
39
|
+
// These modules need only Node's built-in type stripping, not the
|
|
40
|
+
// parent's process-level V8, inspector, or watch configuration.
|
|
41
|
+
execArgv: ['--disable-warning=ExperimentalWarning']
|
|
42
|
+
});
|
|
43
|
+
let settled = false;
|
|
44
|
+
const finish = (error, data) => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
void worker.terminate();
|
|
50
|
+
if (error)
|
|
51
|
+
reject(error);
|
|
52
|
+
else if (data)
|
|
53
|
+
resolve(data);
|
|
54
|
+
};
|
|
55
|
+
const timeout = setTimeout(() => finish(new Error('Tool usage took too long to read. Try a shorter range.')), READ_TIMEOUT_MS);
|
|
56
|
+
worker.once('message', (data) => finish(undefined, data));
|
|
57
|
+
worker.once('error', error => finish(error));
|
|
58
|
+
worker.once('exit', () => finish(new Error('Could not read tool usage. Try again.')));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { ConductorDb } from "./db.js";
|
|
3
|
+
import { readToolUsage } from "./tool-usage.js";
|
|
4
|
+
const { dbPath, range } = workerData;
|
|
5
|
+
const db = new ConductorDb(dbPath);
|
|
6
|
+
parentPort?.postMessage(readToolUsage(db, range));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { contextBlockBytes, parseContextFrame } from "./context-breakdown.js";
|
|
2
|
+
export function isToolUsageRange(value) {
|
|
3
|
+
return value === '24h' || value === '7d' || value === '30d';
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Join calls and results within one chat, never across chats with reused call IDs.
|
|
7
|
+
* Older calls supply names only, so a result crossing the window's start still has
|
|
8
|
+
* an owner. Repeated snapshots of a call/result use the largest saved payload once.
|
|
9
|
+
* No prompts, arguments, or output text leave this accumulator.
|
|
10
|
+
*/
|
|
11
|
+
export class ToolUsageAccumulator {
|
|
12
|
+
calls = new Map();
|
|
13
|
+
add(row) {
|
|
14
|
+
const frame = parseContextFrame(row);
|
|
15
|
+
if (!frame || frame.parent_tool_use_id || !Array.isArray(frame.message?.content))
|
|
16
|
+
return;
|
|
17
|
+
for (const [index, raw] of frame.message.content.entries()) {
|
|
18
|
+
if (!raw || typeof raw !== 'object')
|
|
19
|
+
continue;
|
|
20
|
+
const block = raw;
|
|
21
|
+
const isCall = block.type === 'tool_use';
|
|
22
|
+
if (!isCall && (block.type !== 'tool_result' || !row.in_range))
|
|
23
|
+
continue;
|
|
24
|
+
const rawId = isCall ? block.id : block.tool_use_id;
|
|
25
|
+
const id = typeof rawId === 'string' && rawId ? `id:${rawId}` : `row:${row.rowid}:${index}`;
|
|
26
|
+
const call = this.calls.get(id) ?? { name: null, inputBytes: 0, outputBytes: 0 };
|
|
27
|
+
if (isCall && typeof block.name === 'string' && block.name.trim())
|
|
28
|
+
call.name = block.name.trim();
|
|
29
|
+
if (row.in_range) {
|
|
30
|
+
const key = isCall ? 'inputBytes' : 'outputBytes';
|
|
31
|
+
call[key] = Math.max(call[key], contextBlockBytes(block));
|
|
32
|
+
}
|
|
33
|
+
this.calls.set(id, call);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
tools() {
|
|
37
|
+
const tools = new Map();
|
|
38
|
+
for (const call of this.calls.values()) {
|
|
39
|
+
if (!call.inputBytes && !call.outputBytes)
|
|
40
|
+
continue;
|
|
41
|
+
const inputTokens = Math.ceil(call.inputBytes / 4);
|
|
42
|
+
const outputTokens = Math.ceil(call.outputBytes / 4);
|
|
43
|
+
mergeToolUsage(tools, {
|
|
44
|
+
name: call.name,
|
|
45
|
+
calls: 1,
|
|
46
|
+
inputTokens,
|
|
47
|
+
outputTokens,
|
|
48
|
+
totalTokens: inputTokens + outputTokens,
|
|
49
|
+
largestCallTokens: inputTokens + outputTokens
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return [...tools.values()].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function mergeToolUsage(tools, row) {
|
|
56
|
+
const previous = tools.get(row.name);
|
|
57
|
+
if (!previous) {
|
|
58
|
+
tools.set(row.name, { ...row });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
previous.calls += row.calls;
|
|
62
|
+
previous.inputTokens += row.inputTokens;
|
|
63
|
+
previous.outputTokens += row.outputTokens;
|
|
64
|
+
previous.totalTokens += row.totalTokens;
|
|
65
|
+
previous.largestCallTokens = Math.max(previous.largestCallTokens, row.largestCallTokens);
|
|
66
|
+
}
|
|
67
|
+
/** On-demand worker read. Includes archived workspaces and hidden chats. */
|
|
68
|
+
export function readToolUsage(db, range, now = Date.now()) {
|
|
69
|
+
const days = range === '24h' ? 1 : range === '7d' ? 7 : 30;
|
|
70
|
+
const since = new Date(now - days * 86_400_000).toISOString();
|
|
71
|
+
const until = new Date(now).toISOString();
|
|
72
|
+
const sessions = db.query(`SELECT id, agent_type FROM sessions WHERE julianday(updated_at) >= julianday(?)`, [since]);
|
|
73
|
+
const providers = new Map();
|
|
74
|
+
for (const session of sessions) {
|
|
75
|
+
const accumulator = new ToolUsageAccumulator();
|
|
76
|
+
// Conductor has a session_id index, but no global message-time index. Scope
|
|
77
|
+
// each scan to a recently active chat and keep large historical results out of
|
|
78
|
+
// JS. Prior calls are retained only to name results that cross the lower bound.
|
|
79
|
+
// julianday handles Conductor's mixture of SQLite and ISO timestamp formats.
|
|
80
|
+
const rows = db.query(`SELECT rowid, role, content, julianday(created_at) >= julianday(?) AS in_range
|
|
81
|
+
FROM session_messages
|
|
82
|
+
WHERE session_id = ? AND julianday(created_at) <= julianday(?)
|
|
83
|
+
AND (instr(content, '"tool_use"') > 0
|
|
84
|
+
OR (julianday(created_at) >= julianday(?) AND instr(content, '"tool_result"') > 0))
|
|
85
|
+
ORDER BY rowid ASC`, [since, session.id, until, since]);
|
|
86
|
+
for (const row of rows)
|
|
87
|
+
accumulator.add(row);
|
|
88
|
+
const tools = accumulator.tools();
|
|
89
|
+
if (!tools.length)
|
|
90
|
+
continue;
|
|
91
|
+
const provider = session.agent_type === 'acp' ? 'opencode' : (session.agent_type ?? 'unknown');
|
|
92
|
+
const group = providers.get(provider) ?? { sessionCount: 0, tools: new Map() };
|
|
93
|
+
group.sessionCount++;
|
|
94
|
+
for (const tool of tools)
|
|
95
|
+
mergeToolUsage(group.tools, tool);
|
|
96
|
+
providers.set(provider, group);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
range,
|
|
100
|
+
since,
|
|
101
|
+
until,
|
|
102
|
+
fetchedAt: now,
|
|
103
|
+
providers: [...providers].map(([provider, group]) => ({
|
|
104
|
+
provider,
|
|
105
|
+
sessionCount: group.sessionCount,
|
|
106
|
+
tools: [...group.tools.values()].sort((a, b) => b.totalTokens - a.totalTokens)
|
|
107
|
+
}))
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -28,9 +28,28 @@ export function buildAcceptBody(input) {
|
|
|
28
28
|
]
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
// USD per million tokens: https://developers.openai.com/api/docs/models/gpt-realtime-2.1
|
|
32
|
+
// Mini remains configurable: https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini
|
|
33
|
+
const REALTIME_RATES = new Map([
|
|
34
|
+
[
|
|
35
|
+
'gpt-realtime-2.1',
|
|
36
|
+
{
|
|
37
|
+
text: { input: 4, cached: 0.4, output: 24 },
|
|
38
|
+
audio: { input: 32, cached: 0.4, output: 64 }
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
'gpt-realtime-2.1-mini',
|
|
43
|
+
{
|
|
44
|
+
text: { input: 0.6, cached: 0.06, output: 2.4 },
|
|
45
|
+
audio: { input: 10, cached: 0.3, output: 20 }
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
]);
|
|
49
|
+
/** USD estimate from the configured model's published text and audio rates. */
|
|
32
50
|
export function estimateRealtimeCost(model, usage) {
|
|
33
|
-
|
|
51
|
+
const rates = REALTIME_RATES.get(model);
|
|
52
|
+
if (!rates)
|
|
34
53
|
return null;
|
|
35
54
|
const input = usage.input_token_details ?? {};
|
|
36
55
|
const output = usage.output_token_details ?? {};
|
|
@@ -38,12 +57,12 @@ export function estimateRealtimeCost(model, usage) {
|
|
|
38
57
|
const cachedAudio = input.cached_tokens_details?.audio_tokens ?? 0;
|
|
39
58
|
const uncachedText = Math.max(0, (input.text_tokens ?? 0) - cachedText);
|
|
40
59
|
const uncachedAudio = Math.max(0, (input.audio_tokens ?? 0) - cachedAudio);
|
|
41
|
-
return ((uncachedText *
|
|
42
|
-
cachedText *
|
|
43
|
-
uncachedAudio *
|
|
44
|
-
cachedAudio *
|
|
45
|
-
(output.text_tokens ?? 0) *
|
|
46
|
-
(output.audio_tokens ?? 0) *
|
|
60
|
+
return ((uncachedText * rates.text.input +
|
|
61
|
+
cachedText * rates.text.cached +
|
|
62
|
+
uncachedAudio * rates.audio.input +
|
|
63
|
+
cachedAudio * rates.audio.cached +
|
|
64
|
+
(output.text_tokens ?? 0) * rates.text.output +
|
|
65
|
+
(output.audio_tokens ?? 0) * rates.audio.output) /
|
|
47
66
|
1_000_000);
|
|
48
67
|
}
|
|
49
68
|
function defaultSocket(url, headers) {
|
|
@@ -168,10 +168,10 @@ function promptRole(role) {
|
|
|
168
168
|
return role;
|
|
169
169
|
}
|
|
170
170
|
export class WorkflowCoordinator {
|
|
171
|
+
waking = new Map();
|
|
171
172
|
db;
|
|
172
173
|
relay;
|
|
173
174
|
deps;
|
|
174
|
-
waking = new Map();
|
|
175
175
|
constructor(db, relay, deps) {
|
|
176
176
|
this.db = db;
|
|
177
177
|
this.relay = relay;
|
package/dist-node/src/writes.js
CHANGED
|
@@ -3,7 +3,7 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { isLockedError, modelPickerLabel } from "./shared.js";
|
|
6
|
+
import { isLockedError, MAC_LOCKED, modelPickerLabel } from "./shared.js";
|
|
7
7
|
import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
|
|
8
8
|
import { runGatedWorkflowCommand, WorkflowGatedCommandError } from "./workflow-effect-runner.js";
|
|
9
9
|
const execDirect = promisify(execFile);
|
|
@@ -412,9 +412,9 @@ const CONDUCTOR_SCHEME = process.env.RELAY_CONDUCTOR_SCHEME || 'conductor';
|
|
|
412
412
|
*
|
|
413
413
|
* `session` is optional and names a chat by `sessions.id`, the same id the relay
|
|
414
414
|
* already serves; omitted, Conductor opens whichever tab that workspace had.
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
415
|
+
* Current Conductor builds also unhide a session named by this route, through
|
|
416
|
+
* their own session service. `restoreChat` uses that behavior and confirms the
|
|
417
|
+
* exact id became visible; older builds may ignore it, so opening is no receipt.
|
|
418
418
|
*
|
|
419
419
|
* The https form Conductor copies for sharing —
|
|
420
420
|
* `https://app.conductor.build/workspace/<id>?session=<chat>` — reaches the same
|
|
@@ -681,6 +681,37 @@ return "ok"`.trim();
|
|
|
681
681
|
return { ok: false, strategy: 'applescript', error: osaError(err) };
|
|
682
682
|
}
|
|
683
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Conductor's workspace route restores a hidden session before selecting it.
|
|
686
|
+
* Keep the UI lease through the DB receipt: another focus operation must not
|
|
687
|
+
* navigate away while Conductor's asynchronous route handler is unhiding it.
|
|
688
|
+
* The callback is deliberately exact-id and read-only, supplied by the server.
|
|
689
|
+
*/
|
|
690
|
+
export async function restoreChat(workspaceId, sessionId, isVisible) {
|
|
691
|
+
return uiTurn(async () => {
|
|
692
|
+
if (isVisible())
|
|
693
|
+
return { ok: true, strategy: 'deep-link', alreadyOpen: true };
|
|
694
|
+
if ((await screenLocked()) === true) {
|
|
695
|
+
return { ok: false, strategy: 'deep-link', error: `${MAC_LOCKED}. Unlock it, then try again.` };
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
await exec('open', [workspaceLink(workspaceId, sessionId)], { timeout: 5_000 });
|
|
699
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
700
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
701
|
+
if (isVisible())
|
|
702
|
+
return { ok: true, strategy: 'deep-link' };
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
strategy: 'deep-link',
|
|
707
|
+
error: 'Conductor has not restored this tab yet. Try again, or update Conductor on your Mac.'
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
return { ok: false, strategy: 'deep-link', error: osaError(error) };
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
684
715
|
/**
|
|
685
716
|
* Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
|
|
686
717
|
* button a human clicks. `networksetup` can only join a network that is
|
package/docs/voice-setup.md
CHANGED
|
@@ -235,14 +235,20 @@ Search uses `GET /api/voice/search?q=…&limit=12&offset=0`, optionally with `ca
|
|
|
235
235
|
|
|
236
236
|
### Cost
|
|
237
237
|
|
|
238
|
-
The default is `gpt-realtime-2.1
|
|
238
|
+
The default is `gpt-realtime-2.1`. Existing installations keep their saved model; switch them with:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
conductor-remote config set voice.model gpt-realtime-2.1
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
This restarts the relay; start a new call to use the new model. OpenAI currently publishes these per-million-token prices for GPT-Realtime-2.1:
|
|
239
245
|
|
|
240
246
|
| Modality | Input | Cached input | Output |
|
|
241
247
|
| --- | ---: | ---: | ---: |
|
|
242
|
-
| Text | $
|
|
243
|
-
| Audio | $
|
|
248
|
+
| Text | $4.00 | $0.40 | $24.00 |
|
|
249
|
+
| Audio | $32.00 | $0.40 | $64.00 |
|
|
244
250
|
|
|
245
|
-
The broker logs actual token usage and an estimate for every completed response. Use those lines for this workflow's real per-call cost; Twilio phone-number and PSTN charges are separate and depend on the account/country. The rate source is the [GPT-Realtime-2.1 Mini model
|
|
251
|
+
The broker logs actual token usage and an estimate for every completed response. Use those lines for this workflow's real per-call cost; Twilio phone-number and PSTN charges are separate and depend on the account/country. The rate source is the [GPT-Realtime-2.1 model page](https://developers.openai.com/api/docs/models/gpt-realtime-2.1). `gpt-realtime-2.1-mini` remains available through `voice.model`, with its own cost estimates using the [Mini model rates](https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini).
|
|
246
252
|
|
|
247
253
|
### Disable or rotate
|
|
248
254
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.115.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|