conductor-remote 1.116.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-BPp5Cf-p.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 +130 -9
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
- 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-DNLdIfZ2.css +0 -1
- package/dist/assets/index-QJR7tn2I.js +0 -61
- 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,194 @@
|
|
|
1
|
+
import { routes } from "../../routes.js";
|
|
2
|
+
import { isToolResult, timestampMs, workspaceTitle } from "../../shared.js";
|
|
3
|
+
import { chatCursor, parseChatCursor } from "../../transcript/cursor.js";
|
|
4
|
+
import { need, num, str } from "../arguments.js";
|
|
5
|
+
import { boundedTranscript, clip, unmark } from "../formatters.js";
|
|
6
|
+
export function createSearchChatsTool(call) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'search_chats',
|
|
9
|
+
description: 'Full-text search every Conductor chat on this Mac, archived workspaces included, and get back the workspaces that discussed it with the matching excerpts. Use this to answer "which workspace did I do X in" or "what did we decide about X". Searches the prompts the user typed, the agent replies and the agent\'s reasoning, not tool output. Each excerpt is tagged [user], [assistant] or [thinking] — a [thinking] hit is reasoning the agent never said out loud, so do not quote it back as its answer. Every excerpt carries its own session_id and cursor for a bounded nearby read_chat.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
query: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Plain words, ranked by relevance; wrap a "phrase in quotes" to require it word for word. Other punctuation and operators are ignored, not parsed.'
|
|
16
|
+
},
|
|
17
|
+
limit: { type: 'number', description: 'Max workspaces to return (default 12, max 50).' },
|
|
18
|
+
repo: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Only workspaces in this repo, by the exact name list_repos prints. Ranking happens inside the repo, so a rare mention there is not buried by busier repos.'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
required: ['query']
|
|
24
|
+
},
|
|
25
|
+
run: async (args) => {
|
|
26
|
+
const query = need(args, 'query');
|
|
27
|
+
const limit = num(args.limit);
|
|
28
|
+
const repo = typeof args.repo === 'string' && args.repo.trim() ? args.repo.trim() : null;
|
|
29
|
+
const params = new URLSearchParams({ q: query });
|
|
30
|
+
if (limit)
|
|
31
|
+
params.set('limit', String(limit));
|
|
32
|
+
if (repo)
|
|
33
|
+
params.set('repo', repo);
|
|
34
|
+
const data = await call(`${routes.search.path()}?${params}`);
|
|
35
|
+
const lines = [];
|
|
36
|
+
if (data.index.error)
|
|
37
|
+
lines.push(`! chat index unavailable (${data.index.error}) — names matched only`);
|
|
38
|
+
else if (!data.index.ready)
|
|
39
|
+
lines.push(`! still indexing (${Math.round(data.index.progress * 100)}%) — older chats not searchable yet`);
|
|
40
|
+
const scope = repo ? ` in ${repo}` : '';
|
|
41
|
+
if (!data.results.length)
|
|
42
|
+
return [...lines, `no workspace or chat matches ${JSON.stringify(query)}${scope}`].join('\n');
|
|
43
|
+
for (const r of data.results) {
|
|
44
|
+
const w = r.workspace;
|
|
45
|
+
const tags = [w.repo_name, w.branch, w.archived ? 'ARCHIVED' : w.state].filter(Boolean).join(' · ');
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push(`## ${workspaceTitle(w)}`);
|
|
48
|
+
lines.push(`${tags}${r.byName ? ' · name match' : ''}`);
|
|
49
|
+
lines.push(`workspace_id: ${w.id}${r.sessionId ? ` session_id: ${r.sessionId}` : ''}`);
|
|
50
|
+
if (r.hits)
|
|
51
|
+
lines.push(`${r.hits} matching message${r.hits === 1 ? '' : 's'}:`);
|
|
52
|
+
for (const s of r.snippets) {
|
|
53
|
+
lines.push(` [${s.role}] ${clip(unmark(s.text), 400)}`);
|
|
54
|
+
lines.push(` session_id: ${s.sessionId} cursor: ${s.cursor}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n').trim();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function createReadChatTool(call) {
|
|
62
|
+
return {
|
|
63
|
+
name: 'read_chat',
|
|
64
|
+
description: 'Read a bounded Conductor chat transcript by session_id, newest messages last. Pass a cursor from search_chats or a prior read_chat as near; before and after expand either direction or both together. For a [thinking] search hit, set include_thinking true to include the matching block. Without near, returns the trailing entries. Works for archived workspaces. Tool calls and failed tool output are summarised to one line each; prose is verbatim unless the output budget truncates it.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
session_id: { type: 'string', description: 'From search_chats or list_chats.' },
|
|
69
|
+
near: { type: 'string', description: 'A cursor from search_chats or a prior read_chat.' },
|
|
70
|
+
before: { type: 'number', description: 'Entries before near to return (default 6, max 100).' },
|
|
71
|
+
after: { type: 'number', description: 'Entries after near to return (default 6, max 100).' },
|
|
72
|
+
limit: {
|
|
73
|
+
type: 'number',
|
|
74
|
+
description: 'Trailing entries when near is omitted (default 12, max 400).'
|
|
75
|
+
},
|
|
76
|
+
max_chars: {
|
|
77
|
+
type: 'number',
|
|
78
|
+
description: 'Hard result-size budget (default 12000, min 1000, max 40000).'
|
|
79
|
+
},
|
|
80
|
+
include_thinking: { type: 'boolean', description: 'Include the agent’s reasoning (default false).' },
|
|
81
|
+
include_tools: {
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
description: 'Include tool calls and failed tool output (default false).'
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
required: ['session_id']
|
|
87
|
+
},
|
|
88
|
+
run: async (args) => {
|
|
89
|
+
const sessionId = need(args, 'session_id');
|
|
90
|
+
const near = str(args.near);
|
|
91
|
+
const anchor = near ? parseChatCursor(near) : null;
|
|
92
|
+
if (near && anchor === null)
|
|
93
|
+
throw new Error('near must be a cursor returned by search_chats');
|
|
94
|
+
const count = (value, fallback) => Math.min(100, Math.max(0, Math.floor(num(value) ?? fallback)));
|
|
95
|
+
const before = count(args.before, 6);
|
|
96
|
+
const after = count(args.after, 6);
|
|
97
|
+
const limit = Math.min(400, Math.max(1, Math.floor(num(args.limit) ?? 12)));
|
|
98
|
+
const maxChars = Math.min(40_000, Math.max(1_000, Math.floor(num(args.max_chars) ?? 12_000)));
|
|
99
|
+
// One flag each, because they answer different questions: tools are what the agent
|
|
100
|
+
// *did*, thinking is why it did it. Reading both off `include_tools` meant the only
|
|
101
|
+
// way to see the reasoning was to take the tool churn along with it.
|
|
102
|
+
const includeTools = args.include_tools === true;
|
|
103
|
+
const includeThinking = args.include_thinking === true;
|
|
104
|
+
const data = await call(`${routes.messages.path(sessionId)}?after=0`, {
|
|
105
|
+
timeoutMs: 30_000
|
|
106
|
+
});
|
|
107
|
+
if (anchor !== null && !data.entries.some(entry => entry.rowid === anchor)) {
|
|
108
|
+
throw new Error('near cursor is not in that session');
|
|
109
|
+
}
|
|
110
|
+
const wanted = data.entries.filter(e => {
|
|
111
|
+
if (e.role === 'thinking')
|
|
112
|
+
return includeThinking;
|
|
113
|
+
// A successful result carries the call's output, which is the churn `include_tools`
|
|
114
|
+
// exists to keep out of a context window — the call above it already says what ran.
|
|
115
|
+
// A failed one stays: one line, and it is why the turn changed course.
|
|
116
|
+
if (isToolResult(e) && !e.error)
|
|
117
|
+
return false;
|
|
118
|
+
if (e.role === 'tool')
|
|
119
|
+
return includeTools;
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
122
|
+
if (!wanted.length)
|
|
123
|
+
return `no messages in session ${sessionId}`;
|
|
124
|
+
let selected;
|
|
125
|
+
const head = [];
|
|
126
|
+
if (anchor === null) {
|
|
127
|
+
selected = wanted.slice(-limit);
|
|
128
|
+
if (selected.length < wanted.length) {
|
|
129
|
+
head.push(`(last ${selected.length} of ${wanted.length} entries · older_cursor: ${chatCursor(selected[0].rowid)})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const older = wanted.filter(entry => entry.rowid < anchor);
|
|
134
|
+
const at = wanted.filter(entry => entry.rowid === anchor);
|
|
135
|
+
const newer = wanted.filter(entry => entry.rowid > anchor);
|
|
136
|
+
selected = [...(before ? older.slice(-before) : []), ...at, ...newer.slice(0, after)];
|
|
137
|
+
const parts = [
|
|
138
|
+
`near ${near}`,
|
|
139
|
+
`${Math.min(before, older.length)} before`,
|
|
140
|
+
`${at.length} at`,
|
|
141
|
+
`${Math.min(after, newer.length)} after`
|
|
142
|
+
];
|
|
143
|
+
if (older.length > before && selected[0])
|
|
144
|
+
parts.push(`older_cursor: ${chatCursor(selected[0].rowid)}`);
|
|
145
|
+
if (newer.length > after && selected.at(-1)) {
|
|
146
|
+
parts.push(`newer_cursor: ${chatCursor(selected.at(-1).rowid)}`);
|
|
147
|
+
}
|
|
148
|
+
head.push(`(${parts.join(' · ')})`);
|
|
149
|
+
}
|
|
150
|
+
if (!selected.length) {
|
|
151
|
+
return boundedTranscript(head, ['(the matching entry is excluded by the current role filters)'], maxChars);
|
|
152
|
+
}
|
|
153
|
+
const rendered = selected.map(entry => {
|
|
154
|
+
if (entry.role === 'tool')
|
|
155
|
+
return `[tool ${entry.tool ?? ''}] ${clip(entry.text, 200)}${entry.detail ? ` — ${entry.detail}` : ''}`;
|
|
156
|
+
return `[${entry.role}] ${entry.text}`;
|
|
157
|
+
});
|
|
158
|
+
return boundedTranscript(head, rendered, maxChars);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function createListChatsTool(call) {
|
|
163
|
+
return {
|
|
164
|
+
name: 'list_chats',
|
|
165
|
+
description: 'List the chat tabs in a workspace with each one’s status, model and how full its context window is. A workspace can hold several conversations, each with its own context; send_prompt and read_chat address one of them.',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: { workspace_id: { type: 'string' } },
|
|
169
|
+
required: ['workspace_id']
|
|
170
|
+
},
|
|
171
|
+
run: async (args) => {
|
|
172
|
+
const id = need(args, 'workspace_id');
|
|
173
|
+
const data = await call(routes.sessions.path(id));
|
|
174
|
+
if (!data.sessions.length)
|
|
175
|
+
return `no chats in workspace ${id}`;
|
|
176
|
+
// Context is per chat and only per chat — the same workspace can hold a tab at 85%
|
|
177
|
+
// beside one at 28% — so it is printed on every row rather than summarised above.
|
|
178
|
+
return data.sessions
|
|
179
|
+
.map(s => {
|
|
180
|
+
const ctx = typeof s.context_used_percent === 'number' && s.context_used_percent > 0
|
|
181
|
+
? ` · ${Math.round(s.context_used_percent)}% context`
|
|
182
|
+
: '';
|
|
183
|
+
// A chat waiting on a background task reads `idle` in `status` and will resume
|
|
184
|
+
// itself; an agent that reads only the status would call it done and send a
|
|
185
|
+
// second prompt into the wait.
|
|
186
|
+
const waiting = (s.background_tasks ?? []).map(t => `\n waiting for task: ${t.description} (${Math.max(0, Math.round((Date.now() - timestampMs(t.since)) / 60_000))}m so far)`);
|
|
187
|
+
const glyph = s.status === 'working' ? '▶' : waiting.length ? '⧗' : '·';
|
|
188
|
+
const role = data.session_roles?.[s.id];
|
|
189
|
+
return `${glyph} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}${role ? ` · role ${role.role}` : ''}${ctx}\n session_id: ${s.id}${waiting.join('')}`;
|
|
190
|
+
})
|
|
191
|
+
.join('\n');
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { routes } from "../../routes.js";
|
|
2
|
+
import { parseChatCursor } from "../../transcript/cursor.js";
|
|
3
|
+
import { need, rejectUnknown, str } from "../arguments.js";
|
|
4
|
+
export function createListRolesTool(call) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'list_roles',
|
|
7
|
+
description: 'List configured models, effort, Fast mode, instructions, and validation issues before lightweight delegation. Choose a role whose current configuration fits the task. Invalid roles cannot start a child or new Workflow. Active Workflows use the frozen roles from their root envelope.',
|
|
8
|
+
inputSchema: { type: 'object', properties: {} },
|
|
9
|
+
run: async () => {
|
|
10
|
+
const data = await call(routes.roles.path());
|
|
11
|
+
const issueByRole = new Map(data.issues.map(issue => [issue.role, issue.error]));
|
|
12
|
+
const lines = Object.entries(data.roles).map(([name, role]) => {
|
|
13
|
+
const settings = [role.model, role.effort, role.fast === undefined ? null : role.fast ? 'fast on' : 'fast off']
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(' · ');
|
|
16
|
+
const issue = issueByRole.get(name);
|
|
17
|
+
const instructions = role.preamble?.trim();
|
|
18
|
+
return `${issue ? '!' : '·'} ${name}: ${settings}${issue ? ` — ${issue.code}: ${issue.message}` : ''}${instructions ? `\n Instructions: ${instructions.replaceAll('\n', '\n ')}` : ''}`;
|
|
19
|
+
});
|
|
20
|
+
if (data.warning)
|
|
21
|
+
lines.unshift(`! ${data.warning}`);
|
|
22
|
+
return lines.join('\n') || 'no delegated roles are configured';
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createDelegateTaskTool(call) {
|
|
27
|
+
return {
|
|
28
|
+
name: 'delegate_task',
|
|
29
|
+
description: 'Spawn one lightweight tracked sibling chat in a subtab. From an ordinary chat, pass session_id, a valid role from list_roles, and a focused prompt. Choose a configured role whose instructions fit the task; repeat for independent questions so children can run concurrently. This does not start a Workflow or turn the parent into a planner. The role must use a different provider than the parent. The child follows the configured preamble and assignment, receives a transcript attachment, and returns its result. Queue mode delivers behind the current turn by default; use steer if the result is needed during this turn. Keep working and integrate the result. For an active Workflow, only its root may call with workflow_id and current phase_capability from the private envelope; its frozen settings, role restrictions, transcript boundary, and Baton routing apply. Returns immediately with a delegation id; list_delegations observes progress. The relay later opens/configures/sends the child through the Mac UI. This tool cannot start or recover a Workflow.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
workflow_id: { type: 'string', description: 'Active Workflow only: id from the private root envelope.' },
|
|
34
|
+
phase_capability: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Opaque current-phase capability from that same envelope; never copy it elsewhere.'
|
|
37
|
+
},
|
|
38
|
+
session_id: { type: 'string', description: 'Parent chat id; an active Workflow requires its root session.' },
|
|
39
|
+
role: { type: 'string', description: 'Configured role name. Workflows allow exploration or implementation.' },
|
|
40
|
+
prompt: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'The independent question or implementation brief, including file ownership for edits.'
|
|
43
|
+
},
|
|
44
|
+
return_mode: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
enum: ['queue', 'steer'],
|
|
47
|
+
description: 'Ordinary chats only. Queue the Baton behind this turn (default), or steer it into a running turn.'
|
|
48
|
+
},
|
|
49
|
+
through: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Ordinary chats only. Optional read_chat cursor bounding the transcript handoff.'
|
|
52
|
+
},
|
|
53
|
+
include_thinking: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Ordinary chats only. Include parent reasoning in the handoff (default false).'
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
required: ['session_id', 'role', 'prompt'],
|
|
59
|
+
additionalProperties: false
|
|
60
|
+
},
|
|
61
|
+
run: async (args) => {
|
|
62
|
+
if (!Object.hasOwn(args, 'workflow_id') && !Object.hasOwn(args, 'phase_capability')) {
|
|
63
|
+
rejectUnknown(args, ['session_id', 'role', 'prompt', 'return_mode', 'through', 'include_thinking']);
|
|
64
|
+
const sessionId = need(args, 'session_id');
|
|
65
|
+
if (args.return_mode !== undefined && args.return_mode !== 'queue' && args.return_mode !== 'steer') {
|
|
66
|
+
throw new Error('return_mode must be queue or steer');
|
|
67
|
+
}
|
|
68
|
+
if (args.include_thinking !== undefined && typeof args.include_thinking !== 'boolean') {
|
|
69
|
+
throw new Error('include_thinking must be a boolean');
|
|
70
|
+
}
|
|
71
|
+
const through = args.through === undefined ? undefined : parseChatCursor(need(args, 'through'));
|
|
72
|
+
if (through === null)
|
|
73
|
+
throw new Error('through must be a read_chat cursor');
|
|
74
|
+
const body = {
|
|
75
|
+
role: need(args, 'role'),
|
|
76
|
+
prompt: need(args, 'prompt'),
|
|
77
|
+
...(args.return_mode === undefined ? {} : { returnMode: args.return_mode }),
|
|
78
|
+
...(through === undefined ? {} : { throughRowid: through }),
|
|
79
|
+
...(args.include_thinking === undefined ? {} : { includeThinking: args.include_thinking })
|
|
80
|
+
};
|
|
81
|
+
const result = await call(routes.delegateTask.path(sessionId), {
|
|
82
|
+
method: routes.delegateTask.method,
|
|
83
|
+
body
|
|
84
|
+
});
|
|
85
|
+
if (!result.ok)
|
|
86
|
+
throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
87
|
+
return `delegation ${result.delegationId} queued · ${result.role} · ${result.model}`;
|
|
88
|
+
}
|
|
89
|
+
rejectUnknown(args, ['workflow_id', 'phase_capability', 'session_id', 'role', 'prompt']);
|
|
90
|
+
const workflowId = need(args, 'workflow_id');
|
|
91
|
+
const sessionId = need(args, 'session_id');
|
|
92
|
+
const role = need(args, 'role');
|
|
93
|
+
if (role !== 'exploration' && role !== 'implementation')
|
|
94
|
+
throw new Error('role is invalid');
|
|
95
|
+
const body = {
|
|
96
|
+
workflow_id: workflowId,
|
|
97
|
+
phase_capability: need(args, 'phase_capability'),
|
|
98
|
+
session_id: sessionId,
|
|
99
|
+
role,
|
|
100
|
+
prompt: need(args, 'prompt')
|
|
101
|
+
};
|
|
102
|
+
const result = await call(routes.workflowDelegation.path(workflowId), {
|
|
103
|
+
method: routes.workflowDelegation.method,
|
|
104
|
+
body
|
|
105
|
+
});
|
|
106
|
+
if (!result.ok)
|
|
107
|
+
throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
108
|
+
return `delegation ${result.delegationId} queued · ${result.role} · ${result.model}`;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function createListDelegationsTool(call) {
|
|
113
|
+
return {
|
|
114
|
+
name: 'list_delegations',
|
|
115
|
+
description: 'List active and failed delegated jobs. Successful jobs leave their chats and role chips, not a second history log.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: { workspace_id: { type: 'string', description: 'Optional workspace filter.' } }
|
|
119
|
+
},
|
|
120
|
+
run: async (args) => {
|
|
121
|
+
const workspaceId = str(args.workspace_id);
|
|
122
|
+
const suffix = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : '';
|
|
123
|
+
const data = await call(`${routes.delegations.path()}${suffix}`);
|
|
124
|
+
if (!data.delegations.length)
|
|
125
|
+
return 'no active or failed delegations';
|
|
126
|
+
return data.delegations
|
|
127
|
+
.map(job => {
|
|
128
|
+
const child = job.childSessionId ? ` · child ${job.childSessionId}` : '';
|
|
129
|
+
const failure = job.failure ? ` · ${job.failure.code}: ${job.failure.message}` : '';
|
|
130
|
+
return `${job.status} · ${job.role} · ${job.resolvedRole.model} · ${job.id}${child}${failure}`;
|
|
131
|
+
})
|
|
132
|
+
.join('\n');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { routes } from "../../routes.js";
|
|
2
|
+
import { num, str } from "../arguments.js";
|
|
3
|
+
import { clip, relative, stamp } from "../formatters.js";
|
|
4
|
+
export function createPlanUsageTool(call) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'plan_usage',
|
|
7
|
+
description: 'Read rolling Claude Code and Codex subscription allowances from their local CLIs: percentage used and reset time. Sends no model prompt and touches no Conductor UI. Cursor Agent and OpenCode explain why no comparable plan quota is available.',
|
|
8
|
+
inputSchema: { type: 'object', properties: {} },
|
|
9
|
+
run: async () => {
|
|
10
|
+
const data = await call(routes.planUsage.path(), { timeoutMs: 15_000 });
|
|
11
|
+
const sections = data.providers.map(provider => {
|
|
12
|
+
const head = `## ${provider.label}${provider.plan ? ` · ${provider.plan}` : ''}`;
|
|
13
|
+
if (provider.status !== 'available')
|
|
14
|
+
return `${head}\n${provider.message ?? 'unavailable'}`;
|
|
15
|
+
const lines = provider.buckets.flatMap(bucket => bucket.windows.map(window => {
|
|
16
|
+
const bucketName = provider.buckets.length > 1 ? `${bucket.label} — ` : '';
|
|
17
|
+
const reset = window.resetsAt === null ? 'reset unavailable' : `resets ${new Date(window.resetsAt).toISOString()}`;
|
|
18
|
+
return `- ${bucketName}${window.label}: ${Math.round(window.usedPercent)}% used · ${reset}`;
|
|
19
|
+
}));
|
|
20
|
+
return `${head}\n${lines.join('\n')}`;
|
|
21
|
+
});
|
|
22
|
+
return `${sections.join('\n\n')}\n\nupdated ${new Date(data.fetchedAt).toISOString()}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createDismissPromptTool(call) {
|
|
27
|
+
return {
|
|
28
|
+
name: 'dismiss_prompt',
|
|
29
|
+
description: 'Throw away a prompt the relay is still holding — a new workspace’s first prompt waiting on setup, or one parked behind a locked Mac. Touches no UI. list_workspaces shows these; a failed one stays visible until it is dismissed, on purpose. Pass session_id for a parked prompt, workspace_id for a first prompt.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
session_id: { type: 'string', description: 'Dismiss the prompt parked for this chat.' },
|
|
34
|
+
workspace_id: { type: 'string', description: 'Dismiss this workspace’s undelivered first prompt.' }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
run: async (args) => {
|
|
38
|
+
const sessionId = str(args.session_id);
|
|
39
|
+
const workspaceId = str(args.workspace_id);
|
|
40
|
+
// Both would be two deletes wearing one name, and the caller could not tell which
|
|
41
|
+
// half failed — so it is one or the other, never both.
|
|
42
|
+
if (!sessionId && !workspaceId)
|
|
43
|
+
throw new Error('pass session_id or workspace_id');
|
|
44
|
+
if (sessionId && workspaceId)
|
|
45
|
+
throw new Error('pass session_id or workspace_id, not both');
|
|
46
|
+
const route = sessionId ? routes.dismissParkedPrompt : routes.dismissFirstPrompt;
|
|
47
|
+
await call(route.path((sessionId ?? workspaceId)), { method: route.method });
|
|
48
|
+
return 'dismissed';
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function createKeepAwakeTool(call) {
|
|
53
|
+
return {
|
|
54
|
+
name: 'keep_awake',
|
|
55
|
+
description: 'Read or set the window that holds this Mac awake with the lid shut. Touches no UI. This is what keeps the relay reachable at all, so a long unattended run wants it: without it a closed lid sleeps the Mac and every agent on it stops. Needs `conductor-remote nosleep setup` to have been run once; without that the status says so and holding fails. Releasing a window while the lid is shut sleeps the Mac straight away.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
action: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: ['status', 'hold', 'release'],
|
|
62
|
+
description: 'Default status.'
|
|
63
|
+
},
|
|
64
|
+
seconds: { type: 'number', description: 'How long to hold it awake. Required for hold.' }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
run: async (args) => {
|
|
68
|
+
const action = str(args.action) ?? 'status';
|
|
69
|
+
if (action === 'status') {
|
|
70
|
+
const s = await call(routes.nosleep.path());
|
|
71
|
+
if (!s.available)
|
|
72
|
+
return 'unavailable — run `conductor-remote nosleep setup` on the Mac first';
|
|
73
|
+
if (!s.armed)
|
|
74
|
+
return `sleeping normally; a window can be up to ${s.maxSeconds}s`;
|
|
75
|
+
return `awake${s.until ? `, expires ${relative(s.until)}` : ' until stopped'} (pid ${s.pid})`;
|
|
76
|
+
}
|
|
77
|
+
if (action === 'release') {
|
|
78
|
+
const r = await call(routes.disarmNoSleep.path(), { method: routes.disarmNoSleep.method });
|
|
79
|
+
if (!r.ok)
|
|
80
|
+
throw new Error(r.error ?? 'could not release the window');
|
|
81
|
+
return r.willSleep ? 'released — the lid is shut, so the Mac is going to sleep now' : 'released';
|
|
82
|
+
}
|
|
83
|
+
if (action !== 'hold')
|
|
84
|
+
throw new Error(`unknown action ${action}`);
|
|
85
|
+
const seconds = num(args.seconds);
|
|
86
|
+
if (seconds === undefined)
|
|
87
|
+
throw new Error('seconds is required for hold');
|
|
88
|
+
const r = await call(routes.armNoSleep.path(), {
|
|
89
|
+
method: routes.armNoSleep.method,
|
|
90
|
+
body: { seconds }
|
|
91
|
+
});
|
|
92
|
+
if (!r.ok)
|
|
93
|
+
throw new Error(r.error ?? 'could not hold the Mac awake');
|
|
94
|
+
return `awake${r.state.until ? `, expires ${relative(r.state.until)}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function createRelayLogsTool(call) {
|
|
99
|
+
return {
|
|
100
|
+
name: 'relay_logs',
|
|
101
|
+
description: 'The relay’s own log — why a send failed, whether Conductor refused Accessibility, what the network did. Touches no UI. Default is the running relay’s captured console; `file` tails the daemon’s log on disk, which is the only place a *previous* process’s crash survives. Secrets are redacted before it leaves the relay.',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
file: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
enum: ['relay.log', 'relay.err.log'],
|
|
108
|
+
description: 'Omit for this process’s live log.'
|
|
109
|
+
},
|
|
110
|
+
limit: { type: 'number', description: 'Most recent N lines. Default 200, max 2000.' },
|
|
111
|
+
contains: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: 'Keep only lines containing this (case-insensitive). Filtered here, not by the relay.'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
run: async (args) => {
|
|
118
|
+
const file = str(args.file);
|
|
119
|
+
const limit = Math.min(2000, Math.max(1, Math.trunc(num(args.limit) ?? 200)));
|
|
120
|
+
const q = new URLSearchParams({ limit: String(limit) });
|
|
121
|
+
if (file)
|
|
122
|
+
q.set('file', file);
|
|
123
|
+
const data = await call(`${routes.logs.path()}?${q}`);
|
|
124
|
+
const needle = str(args.contains)?.toLowerCase();
|
|
125
|
+
const kept = needle ? data.entries.filter(e => e.text.toLowerCase().includes(needle)) : data.entries;
|
|
126
|
+
if (!kept.length)
|
|
127
|
+
return needle ? `no line in ${data.source} matches ${needle}` : `${data.source} is empty`;
|
|
128
|
+
const lines = kept.map(e => `${stamp(e.t)} ${e.level === 'info' ? '' : `${e.level.toUpperCase()} `}${clip(e.text, 2000)}`);
|
|
129
|
+
// Whose log this is decides what it proves: an unmanaged relay's files belong to a
|
|
130
|
+
// *different* process, so a clean tail there says nothing about the one just called.
|
|
131
|
+
const head = data.managed ? data.source : `${data.source} (written by the LaunchAgent, not the relay just called)`;
|
|
132
|
+
return [`— ${head}, ${kept.length} lines —`, ...lines].join('\n');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { routes } from "../../routes.js";
|
|
2
|
+
import { need, num, rejectUnknown, str } from "../arguments.js";
|
|
3
|
+
import { boundedTranscript, clip, unmark, voiceCallHeader, voiceEntryFlags } from "../formatters.js";
|
|
4
|
+
export function createListVoiceCallsTool(call) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'list_voice_calls',
|
|
7
|
+
description: 'List saved fleet voice calls on this Mac, newest first, with dates, previews and call_id values for read_voice_call. These transcripts are separate from Conductor chat history. Read-only; no live call or UI interaction.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
limit: { type: 'integer', description: 'Calls per page (default 20, max 50).' },
|
|
12
|
+
offset: {
|
|
13
|
+
type: 'integer',
|
|
14
|
+
description: 'Skip this many calls; use next_offset from the previous page (default 0).'
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
additionalProperties: false
|
|
18
|
+
},
|
|
19
|
+
run: async (args) => {
|
|
20
|
+
rejectUnknown(args, ['limit', 'offset']);
|
|
21
|
+
const limit = Math.min(50, Math.max(1, Math.floor(num(args.limit) ?? 20)));
|
|
22
|
+
const offset = Math.min(1_000_000, Math.max(0, Math.floor(num(args.offset) ?? 0)));
|
|
23
|
+
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
|
24
|
+
const data = await call(`${routes.voiceHistory.path()}?${params}`);
|
|
25
|
+
if (!data.calls.length)
|
|
26
|
+
return 'No saved voice calls on this page. Only calls captured after transcript saving was enabled are available.';
|
|
27
|
+
return [
|
|
28
|
+
...data.calls.map(item => [...voiceCallHeader(item), clip(item.preview.replace(/\s+/g, ' '), 200)].join('\n')),
|
|
29
|
+
...(data.hasMore ? [`next_offset: ${offset + data.calls.length}`] : [])
|
|
30
|
+
].join('\n\n');
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function createSearchVoiceCallsTool(call) {
|
|
35
|
+
return {
|
|
36
|
+
name: 'search_voice_calls',
|
|
37
|
+
description: 'Search caller and assistant text across saved fleet calls. Results include matching excerpts, call_id and item_id; pass those to read_voice_call as call_id and near to read surrounding decisions. Partial and interrupted text is labeled. Read-only; no UI interaction.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
query: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
maxLength: 500,
|
|
44
|
+
description: 'Words ranked by relevance; quote an exact phrase. Uses the same query grammar as search_chats.'
|
|
45
|
+
},
|
|
46
|
+
call_id: { type: 'string', description: 'Optional: search only this saved call.' },
|
|
47
|
+
limit: { type: 'integer', description: 'Matching utterances per page (default 12, max 50).' },
|
|
48
|
+
offset: {
|
|
49
|
+
type: 'integer',
|
|
50
|
+
description: 'Skip this many hits; use next_offset from the previous page (default 0).'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
required: ['query'],
|
|
54
|
+
additionalProperties: false
|
|
55
|
+
},
|
|
56
|
+
run: async (args) => {
|
|
57
|
+
rejectUnknown(args, ['query', 'call_id', 'limit', 'offset']);
|
|
58
|
+
const query = need(args, 'query');
|
|
59
|
+
if (query.length > 500)
|
|
60
|
+
throw new Error('query must be at most 500 characters');
|
|
61
|
+
const limit = Math.min(50, Math.max(1, Math.floor(num(args.limit) ?? 12)));
|
|
62
|
+
const offset = Math.min(1_000_000, Math.max(0, Math.floor(num(args.offset) ?? 0)));
|
|
63
|
+
const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) });
|
|
64
|
+
if (str(args.call_id))
|
|
65
|
+
params.set('callId', str(args.call_id));
|
|
66
|
+
const data = await call(`${routes.voiceSearch.path()}?${params}`);
|
|
67
|
+
if (!data.hits.length)
|
|
68
|
+
return `No saved voice transcript matches ${JSON.stringify(query)} on this page.`;
|
|
69
|
+
return [
|
|
70
|
+
...data.hits.map(hit => [
|
|
71
|
+
...voiceCallHeader(hit.call),
|
|
72
|
+
`item_id: ${hit.itemId} · ${new Date(hit.at).toISOString()}`,
|
|
73
|
+
`[${hit.role}]${voiceEntryFlags(hit)} ${clip(unmark(hit.snippet), 500)}`
|
|
74
|
+
].join('\n')),
|
|
75
|
+
...(data.hasMore ? [`next_offset: ${offset + data.hits.length}`] : [])
|
|
76
|
+
].join('\n\n');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function createReadVoiceCallTool(call) {
|
|
81
|
+
return {
|
|
82
|
+
name: 'read_voice_call',
|
|
83
|
+
description: 'Read a saved fleet-call transcript by call_id, with caller, orchestrator and tool activity in conversation order. Pass an item_id from search_voice_calls as near for surrounding context, or use older_item/newer_item from a previous read. Without near, reads the latest entries. Text can be clipped to max_chars; narrow the window or raise that budget to read more. Interrupted replies can include generated words the caller never heard. Read-only; no UI interaction.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
call_id: { type: 'string', description: 'From list_voice_calls or search_voice_calls.' },
|
|
88
|
+
near: { type: 'string', description: 'A transcript item_id from a search hit or previous read.' },
|
|
89
|
+
before: { type: 'integer', description: 'Entries before near (default 6, max 100).' },
|
|
90
|
+
after: { type: 'integer', description: 'Entries after near (default 6, max 100).' },
|
|
91
|
+
limit: { type: 'integer', description: 'Latest entries when near is omitted (default 20, max 200).' },
|
|
92
|
+
max_chars: { type: 'integer', description: 'Hard output budget (default 12000, min 1000, max 40000).' }
|
|
93
|
+
},
|
|
94
|
+
required: ['call_id'],
|
|
95
|
+
additionalProperties: false
|
|
96
|
+
},
|
|
97
|
+
run: async (args) => {
|
|
98
|
+
rejectUnknown(args, ['call_id', 'near', 'before', 'after', 'limit', 'max_chars']);
|
|
99
|
+
const callId = need(args, 'call_id');
|
|
100
|
+
const data = await call(routes.voiceTranscript.path(callId));
|
|
101
|
+
const entries = data.entries.filter(entry => entry.role !== 'relay');
|
|
102
|
+
const near = str(args.near);
|
|
103
|
+
const anchor = near ? entries.findIndex(entry => entry.id === near) : -1;
|
|
104
|
+
if (near && anchor === -1)
|
|
105
|
+
throw new Error('near item is not in that saved call');
|
|
106
|
+
const count = (value, fallback, max) => Math.min(max, Math.max(0, Math.floor(num(value) ?? fallback)));
|
|
107
|
+
const limit = Math.max(1, count(args.limit, 20, 200));
|
|
108
|
+
let start = near ? Math.max(0, anchor - count(args.before, 6, 100)) : Math.max(0, entries.length - limit);
|
|
109
|
+
let end = near ? Math.min(entries.length, anchor + count(args.after, 6, 100) + 1) : entries.length;
|
|
110
|
+
const maxChars = Math.max(1_000, count(args.max_chars, 12_000, 40_000));
|
|
111
|
+
const heading = (entry) => `[${entry.role}] item_id: ${entry.id} · ${new Date(entry.at).toISOString()}${voiceEntryFlags(entry)}\n`;
|
|
112
|
+
// Keep identities and caveats readable even for a tiny budget. Shrink the
|
|
113
|
+
// window around its anchor, then advertise the omitted sides for paging.
|
|
114
|
+
while (end - start > 1) {
|
|
115
|
+
const minRow = Math.max(...entries.slice(start, end).map(entry => heading(entry).length)) + 80;
|
|
116
|
+
if (voiceCallHeader(data).join('\n').length + 240 + (end - start) * (minRow + 2) <= maxChars)
|
|
117
|
+
break;
|
|
118
|
+
if (!near || anchor - start > end - anchor - 1)
|
|
119
|
+
start += 1;
|
|
120
|
+
else
|
|
121
|
+
end -= 1;
|
|
122
|
+
}
|
|
123
|
+
const selected = entries.slice(start, end);
|
|
124
|
+
const head = [
|
|
125
|
+
...voiceCallHeader(data),
|
|
126
|
+
`Showing ${selected.length} of ${entries.length} entries.`,
|
|
127
|
+
...(start > 0 && selected[0] ? [`older_item: ${selected[0].id} (use as near with before > 0)`] : []),
|
|
128
|
+
...(end < entries.length && selected.at(-1)
|
|
129
|
+
? [`newer_item: ${selected.at(-1).id} (use as near with after > 0)`]
|
|
130
|
+
: [])
|
|
131
|
+
];
|
|
132
|
+
const rendered = selected.map(entry => `${heading(entry)}${entry.text}`);
|
|
133
|
+
return boundedTranscript(head, rendered, maxChars);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|