conductor-remote 1.117.0 → 1.118.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 +10 -8
- package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +10 -10
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-ByTwVInL.css +0 -1
- package/dist/assets/index-LAm9X7wv.js +0 -65
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "../../agents/conductor-settings.js";
|
|
2
|
+
import { decodeRoles, roleModelIssues } from "../../agents/roles.js";
|
|
3
|
+
import { attachRunActivity } from "../../dev-server/run-activity.js";
|
|
4
|
+
import { attachChangeStats } from "../../git/change-stats.js";
|
|
5
|
+
import { attachPrStatus } from "../../git/pr.js";
|
|
6
|
+
import { updateStatus } from "../../host/autoupdate.js";
|
|
7
|
+
import { isRoute, routes } from "../../routes.js";
|
|
8
|
+
import { foldHits, queryTokens } from "../../search/coordinator.js";
|
|
9
|
+
import { scrubWorkflowSecrets } from "../../shared.js";
|
|
10
|
+
import { isToolUsageRange } from "../../usage/tool-usage.js";
|
|
11
|
+
import { describeActuator } from "../../writes/actuator.js";
|
|
12
|
+
import { NOT_HANDLED } from "../router-types.js";
|
|
13
|
+
export function createStateRoutes(services) {
|
|
14
|
+
const { reads, attachDelegationState, attachWorkflowState, wireUiQuarantine, parkedPrompts, firstPrompts, json, orchestration, orchestrationUnavailableReason, actuator, search, modelCache, readBody, planUsage, toolUsage, roleStore } = services;
|
|
15
|
+
return async (req, res, url) => {
|
|
16
|
+
const { pathname } = url;
|
|
17
|
+
// GET /api/state — workspace list with active-session status
|
|
18
|
+
if (isRoute(routes.state, req.method, pathname)) {
|
|
19
|
+
const update = updateStatus();
|
|
20
|
+
const workspaces = reads.listWorkspaces();
|
|
21
|
+
attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
|
|
22
|
+
attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
|
|
23
|
+
attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
|
|
24
|
+
attachDelegationState(workspaces);
|
|
25
|
+
const workflows = attachWorkflowState(workspaces);
|
|
26
|
+
const uiQuarantine = wireUiQuarantine();
|
|
27
|
+
// An undelivered first prompt rides along with its workspace: the phone renders it
|
|
28
|
+
// in that chat rather than tracking delivery itself (see src/delivery/firstprompt.ts).
|
|
29
|
+
// Prompts parked for the lock screen ride the same way, one list per workspace,
|
|
30
|
+
// each entry naming its chat (src/delivery/parked.ts).
|
|
31
|
+
const parked = parkedPrompts.list();
|
|
32
|
+
for (const ws of workspaces) {
|
|
33
|
+
ws.pending_prompt = firstPrompts.get(ws.id);
|
|
34
|
+
const mine = parked.filter(p => p.workspaceId === ws.id);
|
|
35
|
+
if (mine.length)
|
|
36
|
+
ws.parked_prompts = mine;
|
|
37
|
+
}
|
|
38
|
+
return json(req, res, 200, {
|
|
39
|
+
workspaces,
|
|
40
|
+
workflows,
|
|
41
|
+
...(uiQuarantine ? { uiQuarantine } : {}),
|
|
42
|
+
...(orchestration.writable
|
|
43
|
+
? {}
|
|
44
|
+
: { workflowWarning: scrubWorkflowSecrets(orchestrationUnavailableReason()).slice(0, 500) }),
|
|
45
|
+
actuator: await describeActuator(actuator),
|
|
46
|
+
version: update.current,
|
|
47
|
+
update
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// GET /api/search?q= — find a workspace by its name or by what was said in its chats.
|
|
51
|
+
//
|
|
52
|
+
// Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
|
|
53
|
+
// and wins ties, because someone who types a name wants that workspace and not the
|
|
54
|
+
// twelve chats that mention it. The transcript index answers the harder question —
|
|
55
|
+
// "which workspace did I do this in" — and is the only one that can, since the
|
|
56
|
+
// words you remember are usually the agent's, not the branch's.
|
|
57
|
+
//
|
|
58
|
+
// Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
|
|
59
|
+
// archived, so a search limited to the live sidebar would miss almost everything.
|
|
60
|
+
//
|
|
61
|
+
// `repo=` (repeatable) and `archived=0` scope both halves. They are resolved to
|
|
62
|
+
// chat ids and pushed *into* the FTS query rather than applied to its top 300
|
|
63
|
+
// chunks, or excluded work would fill every slot (src/search/coordinator.ts ▸ search).
|
|
64
|
+
if (isRoute(routes.search, req.method, pathname)) {
|
|
65
|
+
const q = url.searchParams.get('q') ?? '';
|
|
66
|
+
const repos = [...new Set(url.searchParams.getAll('repo').filter(Boolean))];
|
|
67
|
+
// Archived search predates the toggle and stays the default for cached PWAs and MCP.
|
|
68
|
+
const includeArchived = url.searchParams.get('archived') !== '0';
|
|
69
|
+
// 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
|
|
70
|
+
// and past the first screenful nobody scrolls — they retype instead.
|
|
71
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
|
|
72
|
+
const index = search.status();
|
|
73
|
+
const tokens = queryTokens(q);
|
|
74
|
+
if (!tokens.length)
|
|
75
|
+
return json(req, res, 200, { query: q, repos, results: [], index });
|
|
76
|
+
const scoped = repos.length > 0 || !includeArchived;
|
|
77
|
+
const scope = scoped
|
|
78
|
+
? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
|
|
79
|
+
: {};
|
|
80
|
+
const hits = await search.search(q, scope);
|
|
81
|
+
const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
|
|
82
|
+
const fromChats = foldHits(hits, sid => {
|
|
83
|
+
const workspace = targets.get(sid)?.workspace ?? null;
|
|
84
|
+
return !includeArchived && workspace?.archived ? null : workspace;
|
|
85
|
+
});
|
|
86
|
+
const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
|
|
87
|
+
const merged = [];
|
|
88
|
+
for (const workspace of reads.findWorkspacesByName(tokens, limit, repos.length ? repos : undefined, includeArchived)) {
|
|
89
|
+
const evidence = remaining.get(workspace.id);
|
|
90
|
+
remaining.delete(workspace.id);
|
|
91
|
+
// Keep the chat evidence when there is any: the snippet is what tells you this
|
|
92
|
+
// is the right "fix-lamp-thing" out of three with similar names.
|
|
93
|
+
merged.push(evidence
|
|
94
|
+
? { ...evidence, byName: true }
|
|
95
|
+
: { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
|
|
96
|
+
}
|
|
97
|
+
merged.push(...remaining.values());
|
|
98
|
+
return json(req, res, 200, {
|
|
99
|
+
query: q,
|
|
100
|
+
repos,
|
|
101
|
+
index,
|
|
102
|
+
results: merged.slice(0, limit).map(r => ({
|
|
103
|
+
...r,
|
|
104
|
+
sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
|
|
105
|
+
}))
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// GET /api/repos — repos a new workspace can be created in
|
|
109
|
+
if (isRoute(routes.repos, req.method, pathname)) {
|
|
110
|
+
return json(req, res, 200, { repos: reads.listRepos() });
|
|
111
|
+
}
|
|
112
|
+
// GET /api/models — prior live picker reads, without activating Conductor. A
|
|
113
|
+
// new workspace has no chat yet, so this is its only safe source of choices.
|
|
114
|
+
if (isRoute(routes.modelCatalog, req.method, pathname)) {
|
|
115
|
+
return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
|
|
116
|
+
}
|
|
117
|
+
// GET/PATCH /api/models/defaults — the live user-wide effort defaults.
|
|
118
|
+
// These are file-backed settings, not the stale rows conductor.db still carries.
|
|
119
|
+
if (isRoute(routes.modelDefaults, req.method, pathname)) {
|
|
120
|
+
return json(req, res, 200, { defaultEfforts: readDefaultEfforts() });
|
|
121
|
+
}
|
|
122
|
+
if (isRoute(routes.updateModelDefaults, req.method, pathname)) {
|
|
123
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
124
|
+
const patch = {};
|
|
125
|
+
if (body.claude !== undefined) {
|
|
126
|
+
if (!isDefaultEffortLevel(body.claude))
|
|
127
|
+
return json(req, res, 400, { error: 'unknown Claude effort level' });
|
|
128
|
+
patch.claude = body.claude;
|
|
129
|
+
}
|
|
130
|
+
if (body.codex !== undefined) {
|
|
131
|
+
if (!isDefaultEffortLevel(body.codex))
|
|
132
|
+
return json(req, res, 400, { error: 'unknown Codex effort level' });
|
|
133
|
+
patch.codex = body.codex;
|
|
134
|
+
}
|
|
135
|
+
if (Object.keys(patch).length === 0)
|
|
136
|
+
return json(req, res, 400, { error: 'nothing to change' });
|
|
137
|
+
return json(req, res, 200, { defaultEfforts: writeDefaultEfforts(patch) });
|
|
138
|
+
}
|
|
139
|
+
// GET /api/usage — structured subscription limits from the CLIs Conductor
|
|
140
|
+
// itself bundles. Both reads are prompt-free and cached; `refresh=1` is the
|
|
141
|
+
// explicit user action in the sheet, never a background poll.
|
|
142
|
+
if (isRoute(routes.planUsage, req.method, pathname)) {
|
|
143
|
+
return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
|
|
144
|
+
}
|
|
145
|
+
if (isRoute(routes.toolUsage, req.method, pathname)) {
|
|
146
|
+
const range = url.searchParams.get('range') ?? '24h';
|
|
147
|
+
if (!isToolUsageRange(range))
|
|
148
|
+
return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
|
|
149
|
+
return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
|
|
150
|
+
}
|
|
151
|
+
if (isRoute(routes.roles, req.method, pathname)) {
|
|
152
|
+
const stored = roleStore.read();
|
|
153
|
+
return json(req, res, 200, {
|
|
154
|
+
...stored.config,
|
|
155
|
+
issues: roleModelIssues(stored.config, modelCache.list()),
|
|
156
|
+
...(stored.warning ? { warning: stored.warning } : {})
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (isRoute(routes.updateRoles, req.method, pathname)) {
|
|
160
|
+
let config;
|
|
161
|
+
try {
|
|
162
|
+
config = decodeRoles(JSON.parse((await readBody(req)) || '{}'));
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const error = {
|
|
166
|
+
code: 'invalid_request',
|
|
167
|
+
message: err instanceof Error ? err.message : String(err),
|
|
168
|
+
retryable: false
|
|
169
|
+
};
|
|
170
|
+
return json(req, res, 400, { ok: false, error });
|
|
171
|
+
}
|
|
172
|
+
const issues = roleModelIssues(config, modelCache.list());
|
|
173
|
+
if (issues.length)
|
|
174
|
+
return json(req, res, 409, { ok: false, error: issues[0].error, issues });
|
|
175
|
+
const written = roleStore.write(config);
|
|
176
|
+
if (!written.ok) {
|
|
177
|
+
return json(req, res, 500, {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: { code: 'state_invalid', message: written.error, retryable: true }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return json(req, res, 200, { ok: true, config: written.config });
|
|
183
|
+
}
|
|
184
|
+
return NOT_HANDLED;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { discardStagedAttachment, stageAttachment } from "../../files/staged-attachments.js";
|
|
2
|
+
import { isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "../../host/logbuf.js";
|
|
3
|
+
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState } from "../../host/nosleep.js";
|
|
4
|
+
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "../../host/wifi.js";
|
|
5
|
+
import { notifyDevice, pushConfig, subscribeDevice, unsubscribeDevice } from "../../notifications/notify.js";
|
|
6
|
+
import { readPrefs, writePrefs } from "../../prefs.js";
|
|
7
|
+
import { isRoute, routeParam, routes } from "../../routes.js";
|
|
8
|
+
import { readSettings, writeSettings } from "../../settings.js";
|
|
9
|
+
import { screenLocked } from "../../writes/guards.js";
|
|
10
|
+
import { restartConductorApp } from "../../writes/system.js";
|
|
11
|
+
import { NOT_HANDLED } from "../router-types.js";
|
|
12
|
+
export function createSystemRoutes(services) {
|
|
13
|
+
const { json, readBody, cfg, reads, attachmentHeaderName, readAttachmentBody, STAGED_ATTACHMENTS_DIR } = services;
|
|
14
|
+
return async (req, res, url) => {
|
|
15
|
+
const { pathname } = url;
|
|
16
|
+
// GET /api/settings — relay preferences plus what the phone needs to edit them:
|
|
17
|
+
// the SSIDs this Mac already holds credentials for, so the picker offers a choice
|
|
18
|
+
// instead of asking someone to type a network name from memory on a phone keyboard.
|
|
19
|
+
// `ssid` is best-effort and often null (macOS gates it behind Location Services).
|
|
20
|
+
if (isRoute(routes.settings, req.method, pathname)) {
|
|
21
|
+
// Five subprocesses, all concurrent: this is the one route that shells out more
|
|
22
|
+
// than once, and serialising them would put the phone's polls behind the sum.
|
|
23
|
+
const [known, current, autoJoinHotspot, nosleep, locked] = await Promise.all([
|
|
24
|
+
preferredNetworks(),
|
|
25
|
+
currentSsid(),
|
|
26
|
+
// macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
|
|
27
|
+
// your phone unprompted, which no amount of relay code can substitute for.
|
|
28
|
+
autoJoinHotspotMode(),
|
|
29
|
+
nosleepState(),
|
|
30
|
+
// Read here rather than polled: this sheet is where someone goes when the Mac
|
|
31
|
+
// stopped answering, and a keep-awake window that says "automatic screen lock
|
|
32
|
+
// is off" beside a Mac that is locked right now reads as the relay lying. The
|
|
33
|
+
// assertion blocks the *idle* lock; it cannot lift one already up, and a lid
|
|
34
|
+
// close or a manual lock puts one up whatever it holds.
|
|
35
|
+
screenLocked()
|
|
36
|
+
]);
|
|
37
|
+
return json(req, res, 200, {
|
|
38
|
+
settings: readSettings(),
|
|
39
|
+
wifi: {
|
|
40
|
+
current,
|
|
41
|
+
known,
|
|
42
|
+
// A guess from the name, never a fact — see src/host/wifi.ts. It only sorts the picker.
|
|
43
|
+
likelyHotspots: known.filter(looksLikeHotspot),
|
|
44
|
+
autoJoinHotspot
|
|
45
|
+
},
|
|
46
|
+
nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS },
|
|
47
|
+
screenLocked: locked
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
|
|
51
|
+
if (isRoute(routes.updateSettings, req.method, pathname)) {
|
|
52
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
53
|
+
const patch = {};
|
|
54
|
+
if (Array.isArray(body.fallbackSsids))
|
|
55
|
+
patch.fallbackSsids = body.fallbackSsids;
|
|
56
|
+
if (typeof body.autoRejoin === 'boolean')
|
|
57
|
+
patch.autoRejoin = body.autoRejoin;
|
|
58
|
+
if (Object.keys(patch).length === 0)
|
|
59
|
+
return json(req, res, 400, { error: 'nothing to change' });
|
|
60
|
+
return json(req, res, 200, { settings: writeSettings(patch) });
|
|
61
|
+
}
|
|
62
|
+
// PWA state remains local-first; this host copy survives origin changes and
|
|
63
|
+
// reconciles phones. PATCH accepts a full client snapshot and merges per key.
|
|
64
|
+
if (isRoute(routes.prefs, req.method, pathname)) {
|
|
65
|
+
return json(req, res, 200, { prefs: readPrefs() });
|
|
66
|
+
}
|
|
67
|
+
if (isRoute(routes.updatePrefs, req.method, pathname)) {
|
|
68
|
+
const raw = JSON.parse((await readBody(req)) || '{}');
|
|
69
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
70
|
+
return json(req, res, 400, { error: 'preferences must be an object' });
|
|
71
|
+
}
|
|
72
|
+
const body = raw;
|
|
73
|
+
if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
|
|
74
|
+
return json(req, res, 400, { error: 'nothing to sync' });
|
|
75
|
+
}
|
|
76
|
+
return json(req, res, 200, { prefs: writePrefs(body) });
|
|
77
|
+
}
|
|
78
|
+
// GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
|
|
79
|
+
if (isRoute(routes.nosleep, req.method, pathname)) {
|
|
80
|
+
return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
|
|
81
|
+
}
|
|
82
|
+
// POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
|
|
83
|
+
// Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
|
|
84
|
+
// rule; without it there is no way for a TTY-less daemon to reach root, and the
|
|
85
|
+
// response says so rather than failing vaguely.
|
|
86
|
+
if (isRoute(routes.armNoSleep, req.method, pathname)) {
|
|
87
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
88
|
+
const seconds = Number(body.seconds);
|
|
89
|
+
// Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
|
|
90
|
+
// truncates to 0 — an unbounded window from a request that looked bounded.
|
|
91
|
+
if (!Number.isInteger(seconds) || seconds < 1)
|
|
92
|
+
return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
|
|
93
|
+
const result = await armNoSleep(seconds, cfg.preventScreenLock);
|
|
94
|
+
return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
|
|
95
|
+
}
|
|
96
|
+
// DELETE /api/nosleep — let it sleep again now, rather than at the window's end
|
|
97
|
+
if (isRoute(routes.disarmNoSleep, req.method, pathname)) {
|
|
98
|
+
const result = await disarmNoSleep();
|
|
99
|
+
return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
|
|
100
|
+
}
|
|
101
|
+
// POST /api/conductor/restart { stopAgents? } — quit Conductor and start it again.
|
|
102
|
+
//
|
|
103
|
+
// The lever exists in the actuator already, but only as activateConductor's last
|
|
104
|
+
// resort, which fires exclusively for a *windowless* Conductor. This is for the
|
|
105
|
+
// other shape: window up, prompts landing as rows, and no agent output behind any
|
|
106
|
+
// of it (measured 2026-09-02 — 2h35m of user rows after the last agent frame).
|
|
107
|
+
// The running agents are counted from the DB before the UI is touched and refused
|
|
108
|
+
// unless the caller meant it, the same way archiving is: quitting ends every turn
|
|
109
|
+
// in flight. The lock screen is the actuator's own gate, since only it can ask.
|
|
110
|
+
if (isRoute(routes.restartConductor, req.method, pathname)) {
|
|
111
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
112
|
+
const working = reads.listSessionStates().filter(state => state.status === 'working').length;
|
|
113
|
+
if (working > 0 && body.stopAgents !== true) {
|
|
114
|
+
return json(req, res, 409, {
|
|
115
|
+
ok: false,
|
|
116
|
+
agentsRunning: true,
|
|
117
|
+
working,
|
|
118
|
+
error: `${working} chat${working === 1 ? ' is' : 's are'} mid-turn. Restarting Conductor ends ${working === 1 ? 'it' : 'them'}.`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
const result = await restartConductorApp();
|
|
123
|
+
const ms = Date.now() - startedAt;
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
console.warn(`[restart] Conductor restart failed after ${(ms / 1000).toFixed(1)}s: ${result.error}`);
|
|
126
|
+
return json(req, res, 502, { ok: false, ms, error: result.error });
|
|
127
|
+
}
|
|
128
|
+
console.log(`[restart] quit Conductor and relaunched it in ${(ms / 1000).toFixed(1)}s`);
|
|
129
|
+
return json(req, res, 200, { ok: true, ms });
|
|
130
|
+
}
|
|
131
|
+
// GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
|
|
132
|
+
// without reaching the Mac. Default is this process's captured console (ordered, timestamped);
|
|
133
|
+
// `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
|
|
134
|
+
// process's crash survives. Everything is redacted: the startup banner prints the token.
|
|
135
|
+
if (isRoute(routes.logs, req.method, pathname)) {
|
|
136
|
+
const file = url.searchParams.get('file');
|
|
137
|
+
if (file && !LOG_FILE_NAMES.includes(file)) {
|
|
138
|
+
return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
|
|
139
|
+
}
|
|
140
|
+
const asked = Number(url.searchParams.get('limit') ?? 300);
|
|
141
|
+
const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = file ? tailLogFile(file, limit) : recentLogs(limit);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// The file only exists once the LaunchAgent has run; say so instead of a bare 500.
|
|
148
|
+
return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
|
|
149
|
+
}
|
|
150
|
+
return json(req, res, 200, {
|
|
151
|
+
source: file ?? 'live',
|
|
152
|
+
// False → the files below are some *other* (daemon) process's output, not this relay's.
|
|
153
|
+
managed: isManaged(),
|
|
154
|
+
startedAt: processStartedAt(),
|
|
155
|
+
now: Date.now(),
|
|
156
|
+
files: logFiles(),
|
|
157
|
+
entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
|
|
161
|
+
if (isRoute(routes.push, req.method, pathname)) {
|
|
162
|
+
return json(req, res, 200, pushConfig());
|
|
163
|
+
}
|
|
164
|
+
// POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
|
|
165
|
+
// Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
|
|
166
|
+
// lost its store, or a subscription the browser silently renewed.
|
|
167
|
+
if (isRoute(routes.pushSubscribe, req.method, pathname)) {
|
|
168
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
169
|
+
const sub = body.subscription;
|
|
170
|
+
if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
|
|
171
|
+
return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
|
|
172
|
+
}
|
|
173
|
+
// An endpoint is a URL we will POST to — never accept a non-HTTPS one.
|
|
174
|
+
if (!/^https:\/\//i.test(sub.endpoint))
|
|
175
|
+
return json(req, res, 400, { error: 'endpoint must be https' });
|
|
176
|
+
const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
|
|
177
|
+
return json(req, res, 200, { ok: true, ...registered });
|
|
178
|
+
}
|
|
179
|
+
// POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
|
|
180
|
+
if (isRoute(routes.pushUnsubscribe, req.method, pathname)) {
|
|
181
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
182
|
+
if (!body.endpoint)
|
|
183
|
+
return json(req, res, 400, { error: 'need the endpoint' });
|
|
184
|
+
return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
|
|
185
|
+
}
|
|
186
|
+
// POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
|
|
187
|
+
if (isRoute(routes.pushTest, req.method, pathname)) {
|
|
188
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
189
|
+
if (!body.id)
|
|
190
|
+
return json(req, res, 400, { error: 'need the device id' });
|
|
191
|
+
const result = await notifyDevice(body.id, {
|
|
192
|
+
title: 'Conductor Remote',
|
|
193
|
+
body: 'Notifications are working. You’ll get one when an agent finishes.',
|
|
194
|
+
tag: 'test',
|
|
195
|
+
url: '/',
|
|
196
|
+
kind: 'test',
|
|
197
|
+
ts: Date.now()
|
|
198
|
+
});
|
|
199
|
+
return json(req, res, result.ok ? 200 : 502, result);
|
|
200
|
+
}
|
|
201
|
+
// POST /api/attachments — hold one phone file until its new workspace has a worktree.
|
|
202
|
+
if (isRoute(routes.stageAttachment, req.method, pathname)) {
|
|
203
|
+
const name = attachmentHeaderName(req);
|
|
204
|
+
if (!name)
|
|
205
|
+
return json(req, res, 400, { error: 'missing attachment name' });
|
|
206
|
+
const bytes = await readAttachmentBody(req);
|
|
207
|
+
if (!bytes.length)
|
|
208
|
+
return json(req, res, 400, { error: 'empty attachment' });
|
|
209
|
+
const attachment = stageAttachment(STAGED_ATTACHMENTS_DIR, name, bytes);
|
|
210
|
+
return json(req, res, 201, { ok: true, attachment });
|
|
211
|
+
}
|
|
212
|
+
// DELETE /api/attachments/:id — an upload cancelled before it became a synced draft attachment.
|
|
213
|
+
const stagedAttachment = routeParam(routes.discardStagedAttachment, req.method, pathname);
|
|
214
|
+
if (stagedAttachment)
|
|
215
|
+
return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
|
|
216
|
+
ok: true
|
|
217
|
+
});
|
|
218
|
+
return NOT_HANDLED;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { isRoute, routeParam, routes } from "../../routes.js";
|
|
2
|
+
import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES } from "../../shared.js";
|
|
3
|
+
import { openAIOriginForSipHost } from "../../voice/config.js";
|
|
4
|
+
import { parseVoiceCallTarget, readVoiceChatContext, VoiceContextError } from "../../voice/context.js";
|
|
5
|
+
import { MAX_VOICE_SEARCH_CHARS } from "../../voice/history.js";
|
|
6
|
+
import { mintSipTicket, missingTicketConfig } from "../../voice/ticket.js";
|
|
7
|
+
import { createWebRtcCall, MAX_SDP_CHARS } from "../../voice/webrtc.js";
|
|
8
|
+
import { NOT_HANDLED } from "../router-types.js";
|
|
9
|
+
export function createVoiceRoutes(services) {
|
|
10
|
+
const { json, voiceHistory, voiceConfig, voiceBroker, readBody, reads, voiceSafetyIdentifier } = services;
|
|
11
|
+
return async (req, res, url) => {
|
|
12
|
+
const { pathname } = url;
|
|
13
|
+
const voiceTranscript = routeParam(routes.voiceTranscript, req.method, pathname);
|
|
14
|
+
if (isRoute(routes.voiceHistory, req.method, pathname) ||
|
|
15
|
+
isRoute(routes.voiceSearch, req.method, pathname) ||
|
|
16
|
+
voiceTranscript) {
|
|
17
|
+
try {
|
|
18
|
+
if (isRoute(routes.voiceSearch, req.method, pathname)) {
|
|
19
|
+
const query = url.searchParams.get('q')?.trim() ?? '';
|
|
20
|
+
if (!query || query.length > MAX_VOICE_SEARCH_CHARS)
|
|
21
|
+
return json(req, res, 400, { error: `q must contain 1–${MAX_VOICE_SEARCH_CHARS} characters` });
|
|
22
|
+
return json(req, res, 200, voiceHistory.search(query, {
|
|
23
|
+
limit: Math.max(1, Math.min(50, Number(url.searchParams.get('limit')) || 12)),
|
|
24
|
+
offset: Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0)),
|
|
25
|
+
callId: url.searchParams.get('callId') || undefined
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
if (!voiceTranscript) {
|
|
29
|
+
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get('limit')) || 30));
|
|
30
|
+
const offset = Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0));
|
|
31
|
+
return json(req, res, 200, voiceHistory.list(Math.floor(limit), Math.floor(offset)));
|
|
32
|
+
}
|
|
33
|
+
const call = url.searchParams.get('summary') === '1'
|
|
34
|
+
? voiceHistory.status(voiceTranscript)
|
|
35
|
+
: voiceHistory.read(voiceTranscript);
|
|
36
|
+
return call ? json(req, res, 200, call) : json(req, res, 404, { error: 'Saved voice call not found' });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.warn('[voice] could not read call history:', error);
|
|
40
|
+
return json(req, res, 503, { error: 'Call history is unavailable. Check the relay logs.' });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// POST /api/voice/ticket — the native app presents the same relay bearer as
|
|
44
|
+
// the PWA, and receives only a two-minute SIP URI. The OpenAI key, webhook
|
|
45
|
+
// secret and marker key never leave this Mac.
|
|
46
|
+
if (isRoute(routes.voiceTicket, req.method, pathname)) {
|
|
47
|
+
const missing = missingTicketConfig(voiceConfig);
|
|
48
|
+
if (missing.length || !voiceBroker) {
|
|
49
|
+
return json(req, res, 503, {
|
|
50
|
+
error: 'voice calls are not fully configured on this relay',
|
|
51
|
+
missing
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return json(req, res, 200, mintSipTicket(voiceConfig));
|
|
55
|
+
}
|
|
56
|
+
// POST /api/voice/calls — the PWA sends its SDP offer to this authenticated
|
|
57
|
+
// relay. The relay loads the selected chat context or the fleet session and
|
|
58
|
+
// keeps OpenAI's permanent key and every function tool on the Mac.
|
|
59
|
+
if (isRoute(routes.voiceCall, req.method, pathname)) {
|
|
60
|
+
if (!voiceConfig.openaiKey || !voiceBroker)
|
|
61
|
+
return json(req, res, 503, { error: 'voice needs an OpenAI API key on this relay' });
|
|
62
|
+
const raw = await readBody(req);
|
|
63
|
+
if (raw.length > MAX_SDP_CHARS * 2)
|
|
64
|
+
return json(req, res, 413, { error: 'WebRTC offer is too large' });
|
|
65
|
+
const body = JSON.parse(raw || '{}');
|
|
66
|
+
if (typeof body.sdp !== 'string' || !body.sdp.trim())
|
|
67
|
+
return json(req, res, 400, { error: 'WebRTC offer is required' });
|
|
68
|
+
if (body.sdp.length > MAX_SDP_CHARS)
|
|
69
|
+
return json(req, res, 413, { error: 'WebRTC offer is too large' });
|
|
70
|
+
if (!isOpenAIRealtimeVoice(body.voice))
|
|
71
|
+
return json(req, res, 400, { error: `voice must be one of ${OPENAI_REALTIME_VOICES.join(', ')}` });
|
|
72
|
+
if (!isVoiceLanguage(body.language))
|
|
73
|
+
return json(req, res, 400, { error: 'unsupported voice language' });
|
|
74
|
+
try {
|
|
75
|
+
const target = parseVoiceCallTarget(body.target);
|
|
76
|
+
const context = target ? readVoiceChatContext(reads, target) : undefined;
|
|
77
|
+
const call = await createWebRtcCall(voiceConfig.openaiKey, openAIOriginForSipHost(voiceConfig.sipHost), body.sdp, {
|
|
78
|
+
model: voiceConfig.model,
|
|
79
|
+
voice: body.voice,
|
|
80
|
+
language: body.language,
|
|
81
|
+
context
|
|
82
|
+
}, voiceSafetyIdentifier);
|
|
83
|
+
voiceBroker.registerWebRtc(call.callId, { voice: body.voice, language: body.language });
|
|
84
|
+
return json(req, res, 200, call);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof VoiceContextError)
|
|
88
|
+
return json(req, res, err.status, { error: err.message });
|
|
89
|
+
console.warn('[voice] could not create WebRTC orchestrator call:', err);
|
|
90
|
+
return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice call failed' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const readyVoiceCall = routeParam(routes.voiceCallReady, req.method, pathname);
|
|
94
|
+
if (readyVoiceCall) {
|
|
95
|
+
if (!voiceBroker)
|
|
96
|
+
return json(req, res, 503, { error: 'voice is not configured on this relay' });
|
|
97
|
+
if (!voiceBroker.beginWebRtc(readyVoiceCall))
|
|
98
|
+
return json(req, res, 404, { error: 'voice call not found' });
|
|
99
|
+
return json(req, res, 200, { ok: true });
|
|
100
|
+
}
|
|
101
|
+
const endedVoiceCall = routeParam(routes.voiceCallEnd, req.method, pathname);
|
|
102
|
+
if (endedVoiceCall) {
|
|
103
|
+
if (!voiceBroker)
|
|
104
|
+
return json(req, res, 503, { error: 'voice is not configured on this relay' });
|
|
105
|
+
try {
|
|
106
|
+
if (!(await voiceBroker.hangupWebRtc(endedVoiceCall)))
|
|
107
|
+
return json(req, res, 404, { error: 'voice call not found' });
|
|
108
|
+
return json(req, res, 200, { ok: true });
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.warn('[voice] could not hang up WebRTC orchestrator call:', err);
|
|
112
|
+
return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice hangup failed' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return NOT_HANDLED;
|
|
116
|
+
};
|
|
117
|
+
}
|