conductor-remote 1.112.0 → 1.113.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 +6 -2
- package/dist/assets/{PierrePatch-Bzmaa7gu.js → PierrePatch-awVutyVW.js} +1 -1
- package/dist/assets/{index-9nM3BADp.js → index-DDm_FLf-.js} +39 -37
- package/dist/assets/index-MmdttBBU.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/mcp-tools.js +143 -1
- package/dist-node/src/routes.js +4 -0
- package/dist-node/src/server.js +37 -1
- package/dist-node/src/voice/broker.js +44 -7
- package/dist-node/src/voice/history.js +452 -0
- package/dist-node/src/voice/transcription.js +10 -0
- package/dist-node/src/voice/webrtc.js +3 -8
- package/docs/voice-setup.md +24 -0
- package/package.json +1 -1
- package/dist/assets/index-iryu1HEQ.css +0 -1
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/** Durable text from the relay's Realtime sideband. Never opens Conductor's database. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { matchQuery } from "../search.js";
|
|
6
|
+
import { HIT_CLOSE, HIT_OPEN } from "../shared.js";
|
|
7
|
+
export const MAX_VOICE_SEARCH_CHARS = 500;
|
|
8
|
+
function object(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
10
|
+
}
|
|
11
|
+
function field(value, key) {
|
|
12
|
+
return typeof value[key] === 'string' ? value[key] : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Completion events can arrive out of order. Follow the conversation's item links, including tool items. */
|
|
15
|
+
function ordered(entries) {
|
|
16
|
+
const ids = new Set(entries.map(entry => entry.id));
|
|
17
|
+
const children = new Map();
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const parent = entry.previousId && ids.has(entry.previousId) ? entry.previousId : null;
|
|
20
|
+
const siblings = children.get(parent) ?? [];
|
|
21
|
+
siblings.push(entry);
|
|
22
|
+
children.set(parent, siblings);
|
|
23
|
+
}
|
|
24
|
+
const result = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const visit = (root) => {
|
|
27
|
+
const stack = [root];
|
|
28
|
+
while (stack.length) {
|
|
29
|
+
const entry = stack.pop();
|
|
30
|
+
if (seen.has(entry.id))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(entry.id);
|
|
33
|
+
result.push(entry);
|
|
34
|
+
stack.push(...(children.get(entry.id) ?? []).toReversed());
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
for (const entry of children.get(null) ?? [])
|
|
38
|
+
visit(entry);
|
|
39
|
+
// A missing predecessor or malformed cycle must never hide captured text.
|
|
40
|
+
for (const entry of entries)
|
|
41
|
+
visit(entry);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
export class VoiceHistory {
|
|
45
|
+
file;
|
|
46
|
+
db = null;
|
|
47
|
+
now;
|
|
48
|
+
log;
|
|
49
|
+
pending = new Map();
|
|
50
|
+
unstarted = new Map();
|
|
51
|
+
errors = new Map();
|
|
52
|
+
timer = null;
|
|
53
|
+
constructor(file, deps = {}) {
|
|
54
|
+
this.file = file;
|
|
55
|
+
this.now = deps.now ?? Date.now;
|
|
56
|
+
this.log = deps.log ?? console.warn;
|
|
57
|
+
}
|
|
58
|
+
connection() {
|
|
59
|
+
if (this.db)
|
|
60
|
+
return this.db;
|
|
61
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
62
|
+
fs.closeSync(fs.openSync(this.file, 'a', 0o600));
|
|
63
|
+
fs.chmodSync(this.file, 0o600);
|
|
64
|
+
const db = new DatabaseSync(this.file);
|
|
65
|
+
try {
|
|
66
|
+
db.exec('PRAGMA busy_timeout = 1000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL');
|
|
67
|
+
const version = db.prepare('PRAGMA user_version').get()?.user_version;
|
|
68
|
+
if (version !== 0 && version !== 1 && version !== 2)
|
|
69
|
+
throw new Error('Voice history was written by a newer relay');
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS calls (
|
|
72
|
+
call_id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, record TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS calls_started ON calls(started_at DESC, call_id);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
76
|
+
seq INTEGER PRIMARY KEY, call_id TEXT NOT NULL REFERENCES calls(call_id),
|
|
77
|
+
item_id TEXT NOT NULL, record TEXT NOT NULL, UNIQUE(call_id, item_id)
|
|
78
|
+
);
|
|
79
|
+
`);
|
|
80
|
+
if (version !== 2) {
|
|
81
|
+
// The archive is authoritative; search is derived and backfilled once.
|
|
82
|
+
// Keep its updates in the same transaction as the caption correction.
|
|
83
|
+
db.exec(`
|
|
84
|
+
BEGIN IMMEDIATE;
|
|
85
|
+
CREATE VIRTUAL TABLE voice_search USING fts5(text, tokenize='porter unicode61');
|
|
86
|
+
INSERT INTO voice_search(rowid, text)
|
|
87
|
+
SELECT seq, json_extract(record, '$.text') FROM entries
|
|
88
|
+
WHERE json_extract(record, '$.role') IN ('user', 'assistant');
|
|
89
|
+
CREATE TRIGGER voice_search_insert AFTER INSERT ON entries BEGIN
|
|
90
|
+
INSERT INTO voice_search(rowid, text) SELECT new.seq, json_extract(new.record, '$.text')
|
|
91
|
+
WHERE json_extract(new.record, '$.role') IN ('user', 'assistant');
|
|
92
|
+
END;
|
|
93
|
+
CREATE TRIGGER voice_search_update AFTER UPDATE ON entries BEGIN
|
|
94
|
+
DELETE FROM voice_search WHERE rowid = old.seq;
|
|
95
|
+
INSERT INTO voice_search(rowid, text) SELECT new.seq, json_extract(new.record, '$.text')
|
|
96
|
+
WHERE json_extract(new.record, '$.role') IN ('user', 'assistant');
|
|
97
|
+
END;
|
|
98
|
+
CREATE TRIGGER voice_search_delete AFTER DELETE ON entries BEGIN
|
|
99
|
+
DELETE FROM voice_search WHERE rowid = old.seq;
|
|
100
|
+
END;
|
|
101
|
+
PRAGMA user_version = 2;
|
|
102
|
+
COMMIT;
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
this.db = db;
|
|
106
|
+
return db;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
db.close();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
safely(callId, run) {
|
|
114
|
+
try {
|
|
115
|
+
run();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
// Keep this failure visible through the history API; never break the live call.
|
|
119
|
+
const message = 'Some of this call could not be saved. Check the relay logs.';
|
|
120
|
+
if (!this.errors.has(callId))
|
|
121
|
+
this.log(`[voice] transcript save failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
this.errors.set(callId, message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
summary(callId) {
|
|
126
|
+
const row = this.connection().prepare('SELECT record FROM calls WHERE call_id = ?').get(callId);
|
|
127
|
+
return row ? JSON.parse(row.record) : null;
|
|
128
|
+
}
|
|
129
|
+
saveSummary(call) {
|
|
130
|
+
this.connection()
|
|
131
|
+
.prepare('INSERT INTO calls(call_id, started_at, record) VALUES (?, ?, ?) ON CONFLICT(call_id) DO UPDATE SET record = excluded.record')
|
|
132
|
+
.run(call.callId, call.startedAt, JSON.stringify(call));
|
|
133
|
+
}
|
|
134
|
+
start(input, resumed = false) {
|
|
135
|
+
this.unstarted.set(input.callId, { input, resumed });
|
|
136
|
+
this.safely(input.callId, () => {
|
|
137
|
+
const previous = this.summary(input.callId);
|
|
138
|
+
this.saveSummary(previous
|
|
139
|
+
? {
|
|
140
|
+
...previous,
|
|
141
|
+
status: 'active',
|
|
142
|
+
endedAt: null,
|
|
143
|
+
hasGaps: previous.hasGaps || resumed || this.errors.has(input.callId)
|
|
144
|
+
}
|
|
145
|
+
: {
|
|
146
|
+
...input,
|
|
147
|
+
updatedAt: input.startedAt,
|
|
148
|
+
endedAt: null,
|
|
149
|
+
status: 'active',
|
|
150
|
+
hasGaps: resumed || this.errors.has(input.callId),
|
|
151
|
+
preview: '',
|
|
152
|
+
entryCount: 0
|
|
153
|
+
});
|
|
154
|
+
this.unstarted.delete(input.callId);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** A restart cannot prove what happened while the sideband was down. Preserve that gap. */
|
|
158
|
+
recover() {
|
|
159
|
+
this.safely('recovery', () => {
|
|
160
|
+
for (const row of this.connection().prepare('SELECT record FROM calls').all()) {
|
|
161
|
+
const call = JSON.parse(row.record);
|
|
162
|
+
if (call.status === 'active')
|
|
163
|
+
this.saveSummary({ ...call, status: 'interrupted', hasGaps: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
finish(callId, status) {
|
|
168
|
+
this.safely(callId, () => {
|
|
169
|
+
this.flush(callId);
|
|
170
|
+
const call = this.summary(callId);
|
|
171
|
+
if (!call)
|
|
172
|
+
return;
|
|
173
|
+
this.saveSummary({
|
|
174
|
+
...call,
|
|
175
|
+
status,
|
|
176
|
+
endedAt: status === 'ended' ? this.now() : null,
|
|
177
|
+
hasGaps: call.hasGaps || status === 'interrupted' || this.errors.has(callId),
|
|
178
|
+
captureError: this.errors.get(callId) ?? call.captureError
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Tag relay-authored nudges before sending them, so they can never impersonate the caller. */
|
|
183
|
+
internal(callId, itemId) {
|
|
184
|
+
this.safely(callId, () => {
|
|
185
|
+
this.entry(callId, itemId).role = 'relay';
|
|
186
|
+
this.flush(callId);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
entry(callId, itemId) {
|
|
190
|
+
let pending = this.pending.get(callId);
|
|
191
|
+
if (!pending) {
|
|
192
|
+
pending = new Map();
|
|
193
|
+
this.pending.set(callId, pending);
|
|
194
|
+
}
|
|
195
|
+
let entry = pending.get(itemId);
|
|
196
|
+
if (!entry) {
|
|
197
|
+
const row = this.connection()
|
|
198
|
+
.prepare('SELECT record FROM entries WHERE call_id = ? AND item_id = ?')
|
|
199
|
+
.get(callId, itemId);
|
|
200
|
+
entry = row
|
|
201
|
+
? JSON.parse(row.record)
|
|
202
|
+
: {
|
|
203
|
+
id: itemId,
|
|
204
|
+
role: 'relay',
|
|
205
|
+
text: '',
|
|
206
|
+
at: this.now(),
|
|
207
|
+
partial: true,
|
|
208
|
+
interrupted: false,
|
|
209
|
+
transcriptionFailed: false,
|
|
210
|
+
parts: {}
|
|
211
|
+
};
|
|
212
|
+
pending.set(itemId, entry);
|
|
213
|
+
}
|
|
214
|
+
return entry;
|
|
215
|
+
}
|
|
216
|
+
part(entry, index, text, final) {
|
|
217
|
+
// A final event replaces deltas; repeats and response.done snapshots are idempotent.
|
|
218
|
+
if (!final && entry.parts[index]?.final)
|
|
219
|
+
return;
|
|
220
|
+
entry.parts[index] = { text: final ? text : (entry.parts[index]?.text ?? '') + text, final };
|
|
221
|
+
entry.text = Object.entries(entry.parts)
|
|
222
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
223
|
+
.map(([, part]) => part.text)
|
|
224
|
+
.join('\n')
|
|
225
|
+
.trim();
|
|
226
|
+
entry.partial = Object.values(entry.parts).some(part => !part.final);
|
|
227
|
+
}
|
|
228
|
+
item(callId, raw, previousId, final = false) {
|
|
229
|
+
const item = object(raw);
|
|
230
|
+
if (!item || typeof item.id !== 'string')
|
|
231
|
+
return;
|
|
232
|
+
const entry = this.entry(callId, item.id);
|
|
233
|
+
if (typeof previousId === 'string' || previousId === null)
|
|
234
|
+
entry.previousId = previousId;
|
|
235
|
+
if (item.type === 'message') {
|
|
236
|
+
if (entry.role !== 'relay' || !item.id.startsWith('relay_')) {
|
|
237
|
+
if (item.role === 'user' || item.role === 'assistant')
|
|
238
|
+
entry.role = item.role;
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(item.content))
|
|
241
|
+
item.content.forEach((rawPart, index) => {
|
|
242
|
+
const part = object(rawPart);
|
|
243
|
+
if (!part)
|
|
244
|
+
return;
|
|
245
|
+
const text = field(part, 'text') ?? field(part, 'transcript');
|
|
246
|
+
if (text && (final || !entry.parts[index]?.text))
|
|
247
|
+
this.part(entry, index, text, final || part.type === 'input_text');
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else if ((item.type === 'function_call' || item.type === 'mcp_call') && typeof item.name === 'string') {
|
|
251
|
+
entry.role = 'tool';
|
|
252
|
+
entry.text = item.name;
|
|
253
|
+
entry.partial = false;
|
|
254
|
+
}
|
|
255
|
+
if (item.status === 'incomplete')
|
|
256
|
+
entry.interrupted = true;
|
|
257
|
+
}
|
|
258
|
+
/** Only allowlisted text fields are stored. Raw audio, tool arguments, tokens and headers never enter the archive. */
|
|
259
|
+
record(callId, event) {
|
|
260
|
+
const unstarted = this.unstarted.get(callId);
|
|
261
|
+
if (unstarted)
|
|
262
|
+
this.start(unstarted.input, unstarted.resumed);
|
|
263
|
+
this.safely(callId, () => {
|
|
264
|
+
const type = field(event, 'type') ?? '';
|
|
265
|
+
const itemId = field(event, 'item_id');
|
|
266
|
+
const index = typeof event.content_index === 'number' ? event.content_index : 0;
|
|
267
|
+
let flush = true;
|
|
268
|
+
if (type === 'conversation.item.added' ||
|
|
269
|
+
type === 'conversation.item.created' ||
|
|
270
|
+
type === 'conversation.item.done') {
|
|
271
|
+
this.item(callId, event.item, event.previous_item_id, type.endsWith('.done'));
|
|
272
|
+
}
|
|
273
|
+
else if (type === 'input_audio_buffer.committed' && itemId) {
|
|
274
|
+
const entry = this.entry(callId, itemId);
|
|
275
|
+
entry.role = 'user';
|
|
276
|
+
if (typeof event.previous_item_id === 'string' || event.previous_item_id === null)
|
|
277
|
+
entry.previousId = event.previous_item_id;
|
|
278
|
+
}
|
|
279
|
+
else if (itemId &&
|
|
280
|
+
(type === 'conversation.item.input_audio_transcription.completed' ||
|
|
281
|
+
type === 'conversation.item.input_audio_transcription.delta')) {
|
|
282
|
+
const entry = this.entry(callId, itemId);
|
|
283
|
+
entry.role = 'user';
|
|
284
|
+
const final = type.endsWith('.completed');
|
|
285
|
+
const text = field(event, final ? 'transcript' : 'delta');
|
|
286
|
+
if (text !== undefined)
|
|
287
|
+
this.part(entry, index, text, final);
|
|
288
|
+
flush = final;
|
|
289
|
+
}
|
|
290
|
+
else if (itemId &&
|
|
291
|
+
/^(response\.(output_audio_transcript|audio_transcript|output_text|text))\.(delta|done)$/.test(type)) {
|
|
292
|
+
const entry = this.entry(callId, itemId);
|
|
293
|
+
entry.role = 'assistant';
|
|
294
|
+
const final = type.endsWith('.done');
|
|
295
|
+
const text = field(event, final ? (type.includes('transcript') ? 'transcript' : 'text') : 'delta');
|
|
296
|
+
if (text !== undefined)
|
|
297
|
+
this.part(entry, index, text, final);
|
|
298
|
+
flush = final;
|
|
299
|
+
}
|
|
300
|
+
else if (type === 'conversation.item.input_audio_transcription.failed' && itemId) {
|
|
301
|
+
const entry = this.entry(callId, itemId);
|
|
302
|
+
entry.role = 'user';
|
|
303
|
+
entry.transcriptionFailed = true;
|
|
304
|
+
}
|
|
305
|
+
else if (type === 'conversation.item.truncated' && itemId) {
|
|
306
|
+
this.entry(callId, itemId).interrupted = true;
|
|
307
|
+
}
|
|
308
|
+
else if (type === 'response.output_item.added' || type === 'response.output_item.done') {
|
|
309
|
+
this.item(callId, event.item, undefined, type.endsWith('.done'));
|
|
310
|
+
}
|
|
311
|
+
else if (type === 'response.done') {
|
|
312
|
+
const response = object(event.response);
|
|
313
|
+
if (Array.isArray(response?.output))
|
|
314
|
+
for (const item of response.output) {
|
|
315
|
+
this.item(callId, item, undefined, true);
|
|
316
|
+
const id = field(object(item) ?? {}, 'id');
|
|
317
|
+
if (id &&
|
|
318
|
+
(response.status === 'cancelled' || response.status === 'incomplete' || response.status === 'failed'))
|
|
319
|
+
this.entry(callId, id).interrupted = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else
|
|
323
|
+
return;
|
|
324
|
+
if (flush)
|
|
325
|
+
this.flush(callId);
|
|
326
|
+
else if (!this.timer) {
|
|
327
|
+
this.timer = setTimeout(() => {
|
|
328
|
+
this.timer = null;
|
|
329
|
+
for (const id of this.pending.keys())
|
|
330
|
+
this.safely(id, () => this.flush(id));
|
|
331
|
+
}, 500);
|
|
332
|
+
this.timer.unref();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
flush(callId) {
|
|
337
|
+
const pending = this.pending.get(callId);
|
|
338
|
+
if (!pending?.size)
|
|
339
|
+
return;
|
|
340
|
+
const db = this.connection();
|
|
341
|
+
const call = this.summary(callId);
|
|
342
|
+
if (!call)
|
|
343
|
+
throw new Error('The voice call could not be saved before its transcript');
|
|
344
|
+
db.exec('BEGIN IMMEDIATE');
|
|
345
|
+
try {
|
|
346
|
+
const write = db.prepare('INSERT INTO entries(call_id, item_id, record) VALUES (?, ?, ?) ON CONFLICT(call_id, item_id) DO UPDATE SET record = excluded.record');
|
|
347
|
+
for (const entry of pending.values())
|
|
348
|
+
write.run(callId, entry.id, JSON.stringify(entry));
|
|
349
|
+
const entries = this.storedEntries(callId).filter(entry => entry.role !== 'relay' && (entry.text || entry.transcriptionFailed));
|
|
350
|
+
this.saveSummary({
|
|
351
|
+
...call,
|
|
352
|
+
updatedAt: this.now(),
|
|
353
|
+
entryCount: entries.length,
|
|
354
|
+
preview: (entries.find(entry => entry.role === 'user' && entry.text)?.text ??
|
|
355
|
+
entries.find(entry => entry.text)?.text ??
|
|
356
|
+
'').slice(0, 160),
|
|
357
|
+
hasGaps: call.hasGaps || this.errors.has(callId),
|
|
358
|
+
captureError: this.errors.get(callId) ?? call.captureError
|
|
359
|
+
});
|
|
360
|
+
db.exec('COMMIT');
|
|
361
|
+
this.pending.delete(callId);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
db.exec('ROLLBACK');
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
storedEntries(callId) {
|
|
369
|
+
const rows = this.connection()
|
|
370
|
+
.prepare('SELECT item_id, record FROM entries WHERE call_id = ? ORDER BY seq')
|
|
371
|
+
.all(callId);
|
|
372
|
+
return ordered(rows.map(row => JSON.parse(row.record)));
|
|
373
|
+
}
|
|
374
|
+
list(limit = 30, offset = 0) {
|
|
375
|
+
for (const id of this.pending.keys())
|
|
376
|
+
this.safely(id, () => this.flush(id));
|
|
377
|
+
const rows = this.connection()
|
|
378
|
+
.prepare('SELECT record FROM calls ORDER BY started_at DESC, call_id DESC LIMIT ? OFFSET ?')
|
|
379
|
+
.all(limit + 1, offset);
|
|
380
|
+
return {
|
|
381
|
+
calls: rows.slice(0, limit).map(row => {
|
|
382
|
+
const call = JSON.parse(row.record);
|
|
383
|
+
return { ...call, captureError: this.errors.get(call.callId) ?? call.captureError };
|
|
384
|
+
}),
|
|
385
|
+
hasMore: rows.length > limit
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
read(callId) {
|
|
389
|
+
this.safely(callId, () => this.flush(callId));
|
|
390
|
+
const call = this.status(callId);
|
|
391
|
+
if (!call)
|
|
392
|
+
return null;
|
|
393
|
+
const entries = this.storedEntries(callId)
|
|
394
|
+
.filter(entry => entry.role !== 'relay' && (entry.text || entry.transcriptionFailed))
|
|
395
|
+
.map(({ parts: _parts, previousId: _previous, ...entry }) => entry);
|
|
396
|
+
return { ...call, entries };
|
|
397
|
+
}
|
|
398
|
+
search(query, options = {}) {
|
|
399
|
+
if (query.length > MAX_VOICE_SEARCH_CHARS)
|
|
400
|
+
throw new Error(`query must be at most ${MAX_VOICE_SEARCH_CHARS} characters`);
|
|
401
|
+
const expression = matchQuery(query);
|
|
402
|
+
if (!expression)
|
|
403
|
+
return { query, hits: [], hasMore: false };
|
|
404
|
+
for (const id of this.pending.keys())
|
|
405
|
+
this.safely(id, () => this.flush(id));
|
|
406
|
+
const limit = Math.max(1, Math.min(50, Math.floor(options.limit ?? 12)));
|
|
407
|
+
const offset = Math.max(0, Math.floor(options.offset ?? 0));
|
|
408
|
+
const rows = this.connection()
|
|
409
|
+
.prepare(`
|
|
410
|
+
SELECT c.record AS call_record, e.record AS entry_record,
|
|
411
|
+
snippet(voice_search, 0, ?, ?, '…', 48) AS snippet
|
|
412
|
+
FROM voice_search JOIN entries e ON e.seq = voice_search.rowid
|
|
413
|
+
JOIN calls c ON c.call_id = e.call_id
|
|
414
|
+
WHERE voice_search MATCH ? AND (? IS NULL OR e.call_id = ?)
|
|
415
|
+
ORDER BY bm25(voice_search), c.started_at DESC, e.seq DESC LIMIT ? OFFSET ?
|
|
416
|
+
`)
|
|
417
|
+
.all(HIT_OPEN, HIT_CLOSE, expression, options.callId ?? null, options.callId ?? null, limit + 1, offset);
|
|
418
|
+
return {
|
|
419
|
+
query,
|
|
420
|
+
hasMore: rows.length > limit,
|
|
421
|
+
hits: rows.slice(0, limit).map(row => {
|
|
422
|
+
const call = JSON.parse(row.call_record);
|
|
423
|
+
const entry = JSON.parse(row.entry_record);
|
|
424
|
+
return {
|
|
425
|
+
call: { ...call, captureError: this.errors.get(call.callId) ?? call.captureError },
|
|
426
|
+
itemId: entry.id,
|
|
427
|
+
role: entry.role,
|
|
428
|
+
at: entry.at,
|
|
429
|
+
partial: entry.partial,
|
|
430
|
+
interrupted: entry.interrupted,
|
|
431
|
+
transcriptionFailed: entry.transcriptionFailed,
|
|
432
|
+
snippet: row.snippet
|
|
433
|
+
};
|
|
434
|
+
})
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
status(callId) {
|
|
438
|
+
const call = this.summary(callId);
|
|
439
|
+
if (!call && this.errors.has(callId))
|
|
440
|
+
throw new Error(this.errors.get(callId));
|
|
441
|
+
return call ? { ...call, captureError: this.errors.get(callId) ?? call.captureError } : null;
|
|
442
|
+
}
|
|
443
|
+
close() {
|
|
444
|
+
if (this.timer)
|
|
445
|
+
clearTimeout(this.timer);
|
|
446
|
+
this.timer = null;
|
|
447
|
+
for (const id of this.pending.keys())
|
|
448
|
+
this.safely(id, () => this.flush(id));
|
|
449
|
+
this.db?.close();
|
|
450
|
+
this.db = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const TRANSCRIPTION_MODEL = 'gpt-live-transcribe';
|
|
2
|
+
/** Both browser and dial-in calls need caller text for their durable transcript. */
|
|
3
|
+
export function voiceTranscription(language = 'auto') {
|
|
4
|
+
return {
|
|
5
|
+
model: TRANSCRIPTION_MODEL,
|
|
6
|
+
prompt: 'Software development fleet control. Likely terms include Conductor, Codex, TypeScript, React, WebRTC, Tailwind, Biome, workspace, pull request, branch names, and file paths.',
|
|
7
|
+
delay: 'low',
|
|
8
|
+
...(language === 'auto' ? {} : { languages: [language] })
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { oneLine } from "../speech.js";
|
|
2
2
|
import { VOICE_INSTRUCTIONS, workspaceVoiceInstructions } from "./prompt.js";
|
|
3
3
|
import { voiceFunctionTools } from "./tools.js";
|
|
4
|
-
|
|
4
|
+
import { voiceTranscription } from "./transcription.js";
|
|
5
|
+
export { TRANSCRIPTION_MODEL } from "./transcription.js";
|
|
5
6
|
export const MAX_SDP_CHARS = 100_000;
|
|
6
|
-
const TRANSCRIPTION_CONTEXT = 'Software development fleet control. Likely terms include Conductor, Codex, TypeScript, React, WebRTC, Tailwind, Biome, workspace, pull request, branch names, and file paths.';
|
|
7
7
|
function languageInstruction(language) {
|
|
8
8
|
switch (language) {
|
|
9
9
|
case 'no':
|
|
@@ -30,12 +30,7 @@ export function buildWebRtcSession(input) {
|
|
|
30
30
|
parallel_tool_calls: false,
|
|
31
31
|
audio: {
|
|
32
32
|
input: {
|
|
33
|
-
transcription:
|
|
34
|
-
model: TRANSCRIPTION_MODEL,
|
|
35
|
-
prompt: TRANSCRIPTION_CONTEXT,
|
|
36
|
-
delay: 'low',
|
|
37
|
-
...(input.language === 'auto' ? {} : { languages: [input.language] })
|
|
38
|
-
},
|
|
33
|
+
transcription: voiceTranscription(input.language),
|
|
39
34
|
noise_reduction: { type: 'near_field' },
|
|
40
35
|
turn_detection: {
|
|
41
36
|
type: 'server_vad',
|
package/docs/voice-setup.md
CHANGED
|
@@ -209,6 +209,30 @@ conductor-remote service logs
|
|
|
209
209
|
|
|
210
210
|
Each call starts with the Mac's lock state. A confirmed send returns to the voice session immediately and reuses the relay's existing transcript receipt, retry, idempotency, and parked-prompt path. A landed send stays silent; a locked or failed send is announced. Merely hearing a decision does not clear it—dispatching it or explicitly skipping it advances the read mark.
|
|
211
211
|
|
|
212
|
+
### Saved transcripts
|
|
213
|
+
|
|
214
|
+
New browser and dial-in calls are saved automatically on the Mac. Open **Control room → Call history** to read a past call, copy its text, or export a `.txt` file. Hanging up a browser call opens its saved transcript. History is available to every device authenticated to this relay, including when voice calling is no longer configured.
|
|
215
|
+
|
|
216
|
+
The archive lives at `~/Library/Application Support/conductor-remote/voice-history.db`, with owner-only file permissions. It is a separate SQLite database owned by the relay; Conductor's database remains read-only. Back it up using SQLite's backup facility, or stop the relay before copying it, since an active database can have a `-wal` file alongside it. Calls are kept indefinitely.
|
|
217
|
+
|
|
218
|
+
The relay saves caller transcriptions, typed messages, assistant text, tool names, and call timestamps through its existing OpenAI sideband connection. Completed utterances are committed immediately; partial captions are checkpointed every half-second and flushed on shutdown. Audio recordings, raw event payloads, tool arguments and authentication headers are not archived. Saving works while the call panel is hidden and does not depend on a final upload from the phone.
|
|
219
|
+
|
|
220
|
+
Transcription can finish out of order, so the archive follows conversation item IDs and predecessor links rather than the arrival order of captions. See OpenAI's [transcription events](https://developers.openai.com/api/docs/guides/realtime-transcription) and [sideband controls](https://developers.openai.com/api/docs/guides/realtime-server-controls).
|
|
221
|
+
|
|
222
|
+
An interrupted reply is labeled because generated text can include words that were never played. Failed transcription is shown explicitly. A lost observer connection or relay restart marks possible gaps; reconnecting does not promise to recover events missed while disconnected. Previously completed calls that were never captured cannot be backfilled. Storage failures appear in the call panel and history, with details in the relay logs.
|
|
223
|
+
|
|
224
|
+
Authenticated reads are `GET /api/voice/history?limit=30&offset=0` for call summaries and `GET /api/voice/history/:callId` for one complete transcript. `?summary=1` reads its recording status without transferring the conversation.
|
|
225
|
+
|
|
226
|
+
The standard conductor-remote MCP server exposes the same archive over both stdio and HTTP:
|
|
227
|
+
|
|
228
|
+
- `list_voice_calls` lists recent calls with previews and call IDs. Use `limit` and `offset` to page through older calls.
|
|
229
|
+
- `search_voice_calls` searches caller and assistant text, with the same words/quoted-phrase grammar as `search_chats`. Each hit includes a call ID and an item ID; `call_id` can restrict the search to one call. Partial captions are searchable and are replaced when their corrected final text arrives.
|
|
230
|
+
- `read_voice_call` reads a call's latest entries, or a window around `near` (an item ID from search). `before`, `after`, `limit`, and `max_chars` bound the result. `older_item` and `newer_item` let the agent continue through the conversation. Gaps, failed transcription and interrupted replies remain explicit in MCP output.
|
|
231
|
+
|
|
232
|
+
For example, an agent can call `search_voice_calls({"query":"\"release Friday\""})`, then `read_voice_call({"call_id":"rtc_…","near":"item_…","before":4,"after":4})` with IDs from the result. These tools only read the archive through authenticated relay routes and never drive Conductor's UI or start a call. They are part of the main MCP server; the live voice session's scoped action tools remain separate. After installing the release, reconnect an existing MCP client so it discovers the new tools.
|
|
233
|
+
|
|
234
|
+
Search uses `GET /api/voice/search?q=…&limit=12&offset=0`, optionally with `callId=…`. Its local full-text index contains only caller and assistant text; tool payloads and internal relay nudges are excluded. Existing archives are indexed automatically without replacing their saved transcripts.
|
|
235
|
+
|
|
212
236
|
### Cost
|
|
213
237
|
|
|
214
238
|
The default is `gpt-realtime-2.1-mini`. OpenAI currently publishes these per-million-token prices:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.113.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.",
|