@sidevoice/uplink 0.4.3 → 0.5.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/install.mjs DELETED
@@ -1,256 +0,0 @@
1
- #!/usr/bin/env node
2
- /** `sidevoice install` — put this version of Sidevoice in front of the harnesses on this machine.
3
- *
4
- * It registers the MCP server (re-pinned to this version when an older one was registered), installs
5
- * the skill, says what it changed, and is safe to run twice: run it again after an upgrade and the
6
- * harness points at the new version. It pairs with nothing. Pairing is a person's act — the room shows
7
- * a one-time code to whoever is in it, and a conversation asks for it the first time it joins — so
8
- * the installer only reports whether this machine is paired, and with which room.
9
- *
10
- * What it does not do is decide for the person: it never edits a machine-wide Codex configuration it
11
- * does not own, and it never relaxes Claude Code's inbound safeguard — those it prints, with the reason. */
12
- import { execFileSync } from 'node:child_process';
13
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
14
- import net from 'node:net';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
- import { pairedRoom } from './pair.mjs';
19
- import { remove as removeSkill, skillsDir, status as skillStatus } from './skill.mjs';
20
-
21
- const here = path.dirname(fileURLToPath(import.meta.url));
22
- const VERSION = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).version;
23
- /** The files that make up this package, copied as they are. */
24
- const PACKAGE_FILES = JSON.parse(readFileSync(path.join(here, 'package.json'), 'utf8')).files.concat('package.json');
25
-
26
- function fromSource(env) {
27
- if (env.SIDEVOICE_INSTALL_FROM_SOURCE === '0') return false;
28
- return env.SIDEVOICE_INSTALL_FROM_SOURCE === '1' || existsSync(path.join(here, '..', '..', '.git'));
29
- }
30
-
31
- /** Where installed copies live: one directory per version, under the XDG data home. */
32
- export function copiesDir(env = process.env) {
33
- return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'sidevoice');
34
- }
35
-
36
- /** What a harness should run to start the server. From a checkout it names that checkout, so a machine that
37
- * installed from source keeps working when the published version moves. Otherwise it names a copy of this
38
- * package that install placed on disk — never `npx`: a session start is not the moment to resolve a package
39
- * (a cold cache, a bin whose name differs from the package's, a 30 s startup budget; one session found no
40
- * `sidevoice` binary at all, 2026-09-21). */
41
- export function serverCommand(env = process.env) {
42
- const cli = fromSource(env) ? path.join(here, 'cli.mjs') : path.join(copiesDir(env), VERSION, 'cli.mjs');
43
- return { command: 'node', args: [cli, 'mcp'] };
44
- }
45
-
46
- /** Put this version's files where serverCommand points, and drop the other versions: an installed copy is
47
- * disposable and there is one current one. From a checkout nothing is copied. */
48
- export function materialize(env = process.env) {
49
- if (fromSource(env)) return { action: 'checkout', target: here };
50
- const root = copiesDir(env), target = path.join(root, VERSION);
51
- mkdirSync(target, { recursive: true });
52
- for (const file of PACKAGE_FILES) {
53
- const source = path.join(here, file);
54
- if (existsSync(source)) cpSync(source, path.join(target, file), { recursive: true });
55
- }
56
- const removed = [];
57
- for (const name of readdirSync(root)) {
58
- if (name !== VERSION) { rmSync(path.join(root, name), { recursive: true, force: true }); removed.push(name); }
59
- }
60
- return { action: 'copied', target, removed };
61
- }
62
-
63
- /** The connector that holds this machine's socket, if any, and which version it is: a façade uses whatever
64
- * connector is running, so one left over from before an upgrade serves every new session with old code. */
65
- export function runningConnector(env = process.env) {
66
- const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
67
- const socketPath = env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
68
- let pid = null;
69
- try { pid = Number(readFileSync(socketPath + '.lock', 'utf8')) || null; } catch { return null; }
70
- return new Promise(resolve => {
71
- const socket = net.createConnection(socketPath);
72
- const done = value => { clearTimeout(timer); socket.destroy(); resolve(value); };
73
- const timer = setTimeout(() => done(null), 1500);
74
- let buffer = '';
75
- socket.on('error', () => done(null));
76
- socket.on('connect', () => socket.write(JSON.stringify({ id: 1, method: 'status', params: {} }) + '\n'));
77
- socket.on('data', chunk => {
78
- buffer += chunk; const index = buffer.indexOf('\n'); if (index < 0) return;
79
- try { const reply = JSON.parse(buffer.slice(0, index)); done({ pid, version: reply.result?.version || null, bindings: reply.result?.bindings?.length ?? null }); }
80
- catch { done({ pid, version: null, bindings: null }); }
81
- });
82
- });
83
- }
84
-
85
- export function flag(argv, name) {
86
- const index = argv.indexOf(name);
87
- return index >= 0 ? argv[index + 1] : undefined;
88
- }
89
-
90
- /** Which harnesses this machine has, by what they leave behind. */
91
- export function harnessesPresent(env = process.env) {
92
- const found = [];
93
- if (existsSync(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'))) found.push('claude');
94
- if (existsSync(env.CODEX_HOME || path.join(os.homedir(), '.codex'))) found.push('codex');
95
- return found;
96
- }
97
-
98
- function claude(args, env) {
99
- return execFileSync(env.SIDEVOICE_CLAUDE_BIN || 'claude', args, { encoding: 'utf8', timeout: 30_000, env, stdio: ['ignore', 'pipe', 'pipe'] });
100
- }
101
-
102
- /** What Claude Code currently runs for `sidevoice`, read from its own `mcp get`: null when nothing is
103
- * registered or there is no `claude` to ask. The scope matters — only a user-scope entry is ours to move. */
104
- export function claudeRegistration(env = process.env) {
105
- let output;
106
- try { output = claude(['mcp', 'get', 'sidevoice'], env); } catch { return null; }
107
- const field = name => (output.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, 'm')) || [])[1]?.trim() ?? '';
108
- const command = field('Command'), args = field('Args');
109
- if (!command) return null;
110
- return { scope: /user/i.test(field('Scope')) ? 'user' : 'other', line: [command, args].filter(Boolean).join(' ') };
111
- }
112
-
113
- function registerWithClaude(done, env) {
114
- const { command, args } = serverCommand(env);
115
- const wanted = [command, ...args].join(' ');
116
- const manual = `claude mcp add --scope user sidevoice -- ${wanted}`;
117
- const current = claudeRegistration(env);
118
- if (current?.line === wanted) { done.push('Claude Code already runs this version of the MCP server.'); return; }
119
- if (current && current.scope !== 'user') {
120
- done.push(`Claude Code has a sidevoice MCP server registered outside user scope (${current.line}); not touched. To move it:\n ${manual}`);
121
- return;
122
- }
123
- try {
124
- if (current) claude(['mcp', 'remove', '--scope', 'user', 'sidevoice'], env);
125
- claude(['mcp', 'add', '--scope', 'user', 'sidevoice', '--', command, ...args], env);
126
- done.push(current ? `Re-pointed Claude Code's MCP server to this version (was: ${current.line}).`
127
- : 'Registered the MCP server with Claude Code (user scope).');
128
- } catch (error) {
129
- done.push(`Could not register with Claude Code automatically (${(error.message || '').split('\n')[0]}). Run:\n ${manual}`);
130
- }
131
- }
132
-
133
- /** Codex keeps one machine-wide file that may hold anything its user put there: we never rewrite it. */
134
- export function codexInstructions(env = process.env) {
135
- const { command, args } = serverCommand(env);
136
- return [
137
- `Add to ${env.CODEX_HOME || path.join(os.homedir(), '.codex')}/config.toml — it is machine-wide and`,
138
- 'this package does not rewrite it:',
139
- '',
140
- ' [mcp_servers.sidevoice]',
141
- ` command = "${command}"`,
142
- ` args = [${args.map(a => `"${a}"`).join(', ')}]`,
143
- '',
144
- 'Then restart Codex. That is all: read receipts and working state come from what Codex records about the thread.',
145
- ].join('\n');
146
- }
147
-
148
- /** Claude Code holds messages from other local processes when a session bypasses permission prompts. */
149
- export function inboundWarning(env = process.env) {
150
- const settings = path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
151
- let parsed = {};
152
- try { parsed = JSON.parse(readFileSync(settings, 'utf8')); } catch { return null; }
153
- if (parsed.crossSessionInbound) return null;
154
- if (parsed.permissions?.defaultMode !== 'bypassPermissions') return null;
155
- return [
156
- 'This machine runs Claude Code sessions in bypassPermissions, and those hold what the room sends',
157
- 'instead of delivering it — voice looks sent and never arrives. Either start a session with',
158
- ` --settings '{"crossSessionInbound":"accept"}'`,
159
- `or add "crossSessionInbound": "accept" to ${settings}. That second one lets any local process post`,
160
- 'into every Claude session on this machine, which is the safeguard it removes: your call, not ours.',
161
- ].join('\n');
162
- }
163
-
164
- export async function install(argv = process.argv.slice(2), env = process.env) {
165
- const stray = argv.find(item => !item.startsWith('-') && argv[argv.indexOf(item) - 1] !== '--harness');
166
- if (stray) throw new Error(`usage: sidevoice install [--harness claude|codex]\n` +
167
- `Pairing is not part of installing: a conversation asks for the room's code the first time it joins, ` +
168
- `or run sidevoice pair <room-url> <code> with the code the room shows under "Emparejar conector".`);
169
- const wanted = flag(argv, '--harness');
170
- const harnesses = wanted ? [wanted] : harnessesPresent(env);
171
- const done = [], next = [];
172
-
173
- done.push(`Sidevoice ${VERSION}.`);
174
- const copy = materialize(env);
175
- if (copy.action === 'copied') done.push(`Copied this version to ${copy.target}${copy.removed.length ? ` (removed: ${copy.removed.join(', ')})` : ''}.`);
176
- if (harnesses.includes('claude')) {
177
- registerWithClaude(done, env);
178
- // The join shortcut is a prompt the server offers; a skill copy from an earlier version is taken away.
179
- if (skillStatus(skillsDir([], env)).state === 'installed') done.push(`Removed the voice-room skill copy at ${removeSkill(skillsDir([], env)).target}: the server offers it as the prompt /mcp__sidevoice__voice-room.`);
180
- }
181
-
182
- const paired = pairedRoom(env);
183
- done.push(paired ? `This machine is paired with ${paired.origin} (connector ${paired.connector_id}).`
184
- : 'This machine is not paired with any room yet.');
185
- const running = await runningConnector(env);
186
- if (running && running.version !== VERSION) {
187
- next.push(`A connector from ${running.version ? 'version ' + running.version : 'an older version'} is still running (pid ${running.pid}) and every conversation on this machine uses it. ` +
188
- `It exits by itself 15 s after the last conversation leaves it; to switch now: kill ${running.pid}, then join again from each conversation.`);
189
- }
190
-
191
- if (harnesses.includes('claude')) {
192
- next.push('In a conversation, ask to join the voice room (or run /mcp__sidevoice__voice-room).' +
193
- (paired ? '' : ' The first time, the conversation asks you for the room\'s address and the one-time code the room shows under "Emparejar conector".'));
194
- next.push('Sessions already open need a restart before they see the server.');
195
- const warning = inboundWarning(env);
196
- if (warning) next.push(warning);
197
- }
198
- if (harnesses.includes('codex')) next.push(codexInstructions(env));
199
- if (!harnesses.length) next.push('No harness found on this machine. Pass --harness claude or --harness codex.');
200
- return { done, next };
201
- }
202
-
203
- /** `sidevoice uninstall`: the reverse of install, for this machine. Unregisters the MCP server from Claude
204
- * Code, stops the connector, removes the installed copies, the skill copy an older version left, and the
205
- * pairing credential. The room keeps this machine's pairing until it is revoked from the room's page —
206
- * say so. Codex's machine-wide file is, as always, printed and not touched. */
207
- export async function uninstall(argv = process.argv.slice(2), env = process.env) {
208
- const wanted = flag(argv, '--harness');
209
- const harnesses = wanted ? [wanted] : harnessesPresent(env);
210
- const done = [], next = [];
211
- if (harnesses.includes('claude')) {
212
- const current = claudeRegistration(env);
213
- if (current?.scope === 'user') {
214
- try { claude(['mcp', 'remove', '--scope', 'user', 'sidevoice'], env); done.push('Unregistered the MCP server from Claude Code.'); }
215
- catch (error) { done.push(`Could not unregister from Claude Code (${(error.message || '').split('\n')[0]}). Run:\n claude mcp remove --scope user sidevoice`); }
216
- } else if (current) {
217
- next.push(`Claude Code has a sidevoice MCP server registered outside user scope (${current.line}); remove it where it was added.`);
218
- } else done.push('Claude Code had no sidevoice MCP server registered.');
219
- if (skillStatus(skillsDir([], env)).state === 'installed') done.push(`Removed the voice-room skill copy at ${removeSkill(skillsDir([], env)).target}.`);
220
- }
221
- const running = await runningConnector(env);
222
- if (running?.pid) {
223
- try { process.kill(running.pid, 'SIGTERM'); done.push(`Stopped the connector (pid ${running.pid}${running.version ? ', version ' + running.version : ''}).`); }
224
- catch (error) { next.push(`A connector is running (pid ${running.pid}) and could not be stopped (${error.code || error.message}); stop it yourself.`); }
225
- }
226
- if (!fromSource(env) && existsSync(copiesDir(env))) { rmSync(copiesDir(env), { recursive: true, force: true }); done.push(`Removed the installed copies under ${copiesDir(env)}.`); }
227
- const dataDir = env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
228
- const paired = pairedRoom(env);
229
- if (existsSync(dataDir)) {
230
- rmSync(dataDir, { recursive: true, force: true });
231
- done.push(`Removed ${dataDir} (credential, socket, outbox, log).`);
232
- if (paired) next.push(`The room at ${paired.origin} still lists this machine as paired (connector ${paired.connector_id}) until you revoke it from the room's page.`);
233
- }
234
- if (harnesses.includes('codex')) next.push(`Remove the [mcp_servers.sidevoice] table from ${env.CODEX_HOME || path.join(os.homedir(), '.codex')}/config.toml — it is machine-wide and this package does not rewrite it.`);
235
- next.push('Sessions already open keep their MCP server until they end.');
236
- return { done, next };
237
- }
238
-
239
- if (process.env.SIDEVOICE_UNINSTALL_MAIN === '1') {
240
- try {
241
- const { done, next } = await uninstall();
242
- for (const line of done) console.log('· ' + line);
243
- if (next.length) { console.log('\nLeft for you:'); for (const line of next) console.log('\n' + line); }
244
- } catch (error) { console.error(error.message); process.exit(1); }
245
- }
246
-
247
- if (process.env.SIDEVOICE_INSTALL_MAIN === '1') {
248
- try {
249
- const { done, next } = await install();
250
- for (const line of done) console.log('· ' + line);
251
- if (next.length) {
252
- console.log('\nLeft for you:');
253
- for (const line of next) console.log('\n' + line);
254
- }
255
- } catch (error) { console.error(error.message); process.exit(1); }
256
- }
package/mcp.mjs DELETED
@@ -1,222 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Stdio MCP façade for one conversation. It holds no connection to the room: it starts or reuses
3
- * the host's connector and keeps one local connection to it for as long as this session lives. */
4
- import net from 'node:net';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import { spawn } from 'node:child_process';
8
- import { randomUUID } from 'node:crypto';
9
- import { fileURLToPath } from 'node:url';
10
- import { advertisedCapabilities, capabilityState, SUPPORTED } from './harness-contract.mjs';
11
- import { harnessFor, identifyHarness } from './harnesses.mjs';
12
- import { pair, pairedRoom } from './pair.mjs';
13
- import { readFileSync } from 'node:fs';
14
-
15
- const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
16
-
17
- const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
18
- const socketPath = process.env.SIDEVOICE_CONNECTOR_SOCKET || path.join(dataDir, 'connector.sock');
19
- const connectorPath = fileURLToPath(new URL('./connector.mjs', import.meta.url));
20
-
21
- // Claude Code keeps at most 2048 characters of these; the rest is cut (measured 2026-09-21).
22
- const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
23
- - Call voice_connect only when the user asks to join the room or enable voice; never as a side effect.
24
- - Voice input is a user message: a JSON header ({"channel":"voice","session_id","revision","message_id"}), the user's literal words, then a [Sidevoice] line that is not the user's. The header is opaque reply metadata. A repeated message_id is a redelivery: do not act on it again.
25
- - Reply by voice with voice_say, using that message's session_id and revision for every publication. For substantive work: first a short acknowledgement (what you understood, what you will do next), then meaningful checkpoints, then the result. No filler, no narrating tool calls. Publish questions too, and wait.
26
- - Between steps, at each tool result, take in newly arrived user input before starting the next step: an addition, a refinement or a replacement, by its meaning. Drop obsolete work not yet started; keep what remains useful; say what you now understand. No artificial pauses.
27
- - "published" means the room stored it, not that the user heard it. If publishing fails, continue in writing.
28
- - If the user closes this conversation's voice from the room, voice_say fails saying so: continue in writing, do not retry, and call voice_connect again only if asked.
29
- - Pairing is the user's act. If voice_connect says this machine is not paired with the room, ask the user for the room's address and the one-time code the room shows under "Emparejar conector", then call voice_pair and voice_connect again. Never try to obtain a code from the room yourself.
30
- - If voice_connect returns inbound.ok false, voice will look sent and never arrive: tell the user inbound.reason, offer inbound.remedy in your own words including the safeguard the machine-wide option removes, and change no settings unasked.
31
- - Read receipts and working state need nothing from you: the room observes what the harness records.`;
32
-
33
- // ----- one persistent connection to the connector -----
34
- let ipc = null, ipcBuffer = '', ipcSerial = 0;
35
- const ipcWaiting = new Map();
36
- function connectIpc() {
37
- return new Promise((resolve, reject) => {
38
- const socket = net.createConnection(socketPath);
39
- socket.once('error', reject);
40
- socket.on('connect', () => {
41
- socket.removeListener('error', reject);
42
- socket.on('error', () => {});
43
- socket.on('close', () => { if (ipc === socket) ipc = null; for (const w of ipcWaiting.values()) w.reject(new Error('Connector went away')); ipcWaiting.clear(); });
44
- socket.on('data', chunk => {
45
- ipcBuffer += chunk; let index;
46
- while ((index = ipcBuffer.indexOf('\n')) >= 0) {
47
- const line = ipcBuffer.slice(0, index); ipcBuffer = ipcBuffer.slice(index + 1);
48
- let reply; try { reply = JSON.parse(line); } catch { continue; }
49
- const waiting = ipcWaiting.get(reply.id); if (!waiting) continue; ipcWaiting.delete(reply.id);
50
- reply.ok ? waiting.resolve(reply.result) : waiting.reject(new Error(reply.error));
51
- }
52
- });
53
- ipc = socket; resolve(socket);
54
- });
55
- });
56
- }
57
- async function ensureConnector() {
58
- if (ipc) return ipc;
59
- try { return await connectIpc(); } catch {}
60
- const child = spawn(process.execPath, [connectorPath], { detached: true, stdio: 'ignore', env: process.env });
61
- child.unref();
62
- for (let attempt = 0; attempt < 40; attempt++) {
63
- await new Promise(r => setTimeout(r, 100));
64
- try { return await connectIpc(); } catch {}
65
- }
66
- throw new Error('The Sidevoice connector did not start (is this host paired? see docs/INSTALL.md)');
67
- }
68
- async function rpc(method, params) {
69
- await ensureConnector();
70
- const id = ++ipcSerial;
71
- return new Promise((resolve, reject) => { ipcWaiting.set(id, { resolve, reject }); ipc.write(JSON.stringify({ id, method, params }) + '\n'); });
72
- }
73
-
74
- // ----- tools -----
75
- const tools = [
76
- { name: 'voice_connect', description: 'Connect this conversation to the voice room. Only on an explicit request to join or enable voice. Fails, saying what to ask the user, when this machine is not paired with the room.',
77
- inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Short label for this conversation in the room' }, room: { type: 'string', description: 'The room\'s address (https://…) when the user names one; omitted, the room this machine is paired with' } }, additionalProperties: false } },
78
- { name: 'voice_pair', description: 'Pair this machine with a room using the one-time code the user read from the room\'s interface ("Emparejar conector"). Only with a code the user gave you; one room per machine, a new pairing replaces the previous one.',
79
- inputSchema: { type: 'object', properties: { room: { type: 'string', description: 'The room\'s address (https://…)' }, code: { type: 'string', description: 'The one-time pairing code shown by the room' } }, required: ['room', 'code'], additionalProperties: false } },
80
- { name: 'voice_say', description: 'Publish a concise spoken version of your reply to the room, with the session_id and revision from the voice message header.',
81
- inputSchema: { type: 'object', properties: { text: { type: 'string' }, session_id: { type: 'string' }, revision: { type: 'integer', minimum: 0 }, utterance_id: { type: 'string' }, language: { type: 'string', enum: ['es', 'en', 'fr', 'it', 'pt', 'hi'] } }, required: ['text', 'session_id', 'revision'], additionalProperties: false } },
82
- { name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
83
- { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
84
- ];
85
- /** The joining steps as a prompt: what the voice-room skill used to be, now carried by the server itself so
86
- * nothing is copied into any harness and the steps move with the version. */
87
- const PROMPTS = [{
88
- name: 'voice-room',
89
- description: 'Join the user\'s Sidevoice voice room with this conversation.',
90
- arguments: [{ name: 'title', description: 'Title for this conversation in the room', required: false }],
91
- }];
92
- function promptText(args = {}) {
93
- const title = (args.title || '').trim();
94
- return [
95
- 'Join the voice room for this conversation and keep it reachable.',
96
- '',
97
- '1. Call voice_status. If it reports joined and room_reachable, say so in one line and stop.',
98
- `2. Call voice_connect with the title ${title ? JSON.stringify(title) : 'a short label of what this conversation is about'}. If the user named a room, pass its address as room.`,
99
- ' If it fails saying this machine is not paired with the room (or is paired with a different one), ask the user for the room\'s address and the one-time code the room shows them under "Emparejar conector"; call voice_pair with both, then voice_connect again. Never try to get a code from the room yourself.',
100
- '3. Tell the user in one line whether the room can reach this conversation. If inbound.ok is false, relay inbound.reason and offer inbound.remedy in your own words, including what safeguard it removes; change nothing yourself.',
101
- '',
102
- 'Nothing else is registered: the room learns that a message was read and whether this conversation is working from what the harness itself records about it. How to behave once joined is in this server\'s instructions.',
103
- ].join('\n');
104
- }
105
- let binding = null;
106
- function originOf(room) {
107
- try { return new URL(room).origin; } catch { throw new Error(`"${room}" is not a room address; expected something like https://voice.example`); }
108
- }
109
- /** Ask the user, do not guess: the code exists only on the room's screen. */
110
- function pairingNeeded(room, paired) {
111
- const target = room ? originOf(room) : null;
112
- if (!paired) return `This machine is not paired with ${target ? 'the room at ' + target : 'any room'}. Ask the user for the room's address${target ? ' (confirm ' + target + ')' : ''} and the one-time pairing code the room shows under "Emparejar conector", then call voice_pair with both. Do not fetch a code yourself.`;
113
- if (target && target !== paired.origin) return `This machine is paired with ${paired.origin}, not ${target}. One room per machine: to switch, ask the user for the pairing code that ${target} shows under "Emparejar conector" and call voice_pair (it replaces the current pairing); to stay, call voice_connect without a room.`;
114
- return null;
115
- }
116
- /** A connector from another version serves this conversation with that version's behaviour. */
117
- function versionNote(connectorVersion) {
118
- if (!ipc || connectorVersion === VERSION) return {};
119
- return { note: `The connector running on this machine is ${connectorVersion ? 'version ' + connectorVersion : 'older than this server'}; this conversation runs ${VERSION}. It exits 15 s after the last conversation leaves it; until then behaviour is that version's.` };
120
- }
121
- function inboundFor(harness, thread) {
122
- return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
123
- }
124
- async function invoke(name, args, meta) {
125
- if (name === 'voice_status') {
126
- const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [], closed_by_room: [] };
127
- // The room may have closed this conversation's voice since we joined: the connector is the truth.
128
- const closed = !!binding && (status.closed_by_room || []).includes(binding.client_ref);
129
- if (closed) binding = null;
130
- const module = binding ? harnessFor(binding.harness) : null;
131
- const inbound = binding ? inboundFor(module, binding.client_ref) : null;
132
- return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null, socket_error: status.socket_error || null,
133
- version: VERSION, connector_version: status.version || null, ...versionNote(status.version),
134
- binding_id: binding?.binding_id || null, harness: binding?.harness || null,
135
- capabilities: binding?.capabilities || null, inbound,
136
- ...(closed ? { closed_by_room: true, note: 'The user closed this conversation\'s voice channel from the room. Continue in writing; call voice_connect again only if they ask for voice.' } : {}) };
137
- }
138
- if (name === 'voice_pair') {
139
- if (!args.room || !args.code) throw new Error('voice_pair needs the room\'s address and the code the user read from it.');
140
- const previous = pairedRoom();
141
- // The room shows the code in upper case and compares it that way; a dictated one arrives however it was heard.
142
- const result = await pair(originOf(args.room), String(args.code).trim().toUpperCase());
143
- // The connector that is up, if any, was started for the previous credential: let go of it so it can
144
- // exit, and the next voice_connect starts one for this room. Other conversations still bound to the
145
- // previous room keep that connector alive until they leave; they are not moved.
146
- if (binding) { try { await rpc('unregister', { binding_id: binding.binding_id }); } catch {} binding = null; }
147
- if (ipc) { ipc.end(); ipc = null; }
148
- return { status: 'paired', room: result.origin, connector_id: result.connector_id,
149
- ...(previous && previous.origin !== result.origin ? { replaced: previous.origin, note: 'Conversations on this machine still joined to the previous room keep it until they leave.' } : {}),
150
- next: 'Call voice_connect to join.' };
151
- }
152
- if (name === 'voice_connect') {
153
- const needed = pairingNeeded(args.room, pairedRoom());
154
- if (needed) { const error = new Error(needed); error.data = { pairing_needed: true, room: args.room ? originOf(args.room) : null }; throw error; }
155
- const who = identifyHarness(meta);
156
- const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
157
- // Refuse rather than join a room we cannot hear from: a conversation whose harness holds
158
- // what the room posts would sit in the list looking present while the user talks to nobody.
159
- const inbound = inboundFor(who.module, who.thread);
160
- if (inbound?.ok === false) {
161
- const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
162
- error.data = { inbound };
163
- throw error;
164
- }
165
- const capabilities = advertisedCapabilities(who.module);
166
- // Which model is answering, read from the session's own launch line rather than asked of the model.
167
- let engine = null;
168
- try { engine = who.module.engine?.(who.thread) || null; } catch { engine = null; }
169
- const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
170
- title, delivery: who.delivery, inbound, capabilities, engine });
171
- binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
172
- let connectorVersion = null; try { connectorVersion = (await rpc('status', {})).version || null; } catch {}
173
- return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
174
- binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound,
175
- version: VERSION, connector_version: connectorVersion, ...versionNote(connectorVersion) };
176
- }
177
- if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
178
-
179
- if (name === 'voice_say') {
180
- let result;
181
- try {
182
- result = await rpc('publish', { binding_id: binding.binding_id, client_ref: binding.client_ref, text: args.text, session_id: args.session_id, revision: args.revision, utterance_id: args.utterance_id, language: args.language });
183
- } catch (error) {
184
- if (error.message === 'CLOSED_BY_ROOM') {
185
- binding = null;
186
- throw new Error('The user closed this conversation\'s voice channel from the room. Continue in writing and do not publish speech; call voice_connect again only if the user asks for voice.');
187
- }
188
- throw error;
189
- }
190
- return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
191
- }
192
- if (name === 'voice_disconnect') { const result = await rpc('unregister', { binding_id: binding.binding_id }); binding = null; return { status: 'left', room_reachable: result.connected }; }
193
- throw new Error('Unknown tool');
194
- }
195
-
196
- // ----- JSON-RPC over stdio -----
197
- let input = '';
198
- process.stdin.setEncoding('utf8');
199
- process.stdin.on('data', async chunk => {
200
- input += chunk;
201
- while (input.includes('\n')) {
202
- const index = input.indexOf('\n'); const line = input.slice(0, index); input = input.slice(index + 1);
203
- if (!line.trim()) continue;
204
- let request; try { request = JSON.parse(line); } catch { continue; }
205
- if (request.id === undefined) continue; // notifications need no answer
206
- let result, error;
207
- try {
208
- if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {}, prompts: {} }, serverInfo: { name: 'sidevoice', version: VERSION }, instructions: INSTRUCTIONS };
209
- else if (request.method === 'prompts/list') result = { prompts: PROMPTS };
210
- else if (request.method === 'prompts/get') {
211
- if (request.params?.name !== 'voice-room') throw Object.assign(new Error('Unknown prompt'), { code: -32602 });
212
- result = { description: PROMPTS[0].description, messages: [{ role: 'user', content: { type: 'text', text: promptText(request.params?.arguments) } }] };
213
- }
214
- else if (request.method === 'tools/list') result = { tools };
215
- else if (request.method === 'tools/call') { const value = await invoke(request.params.name, request.params.arguments || {}, request.params._meta); result = { content: [{ type: 'text', text: JSON.stringify(value) }] }; }
216
- else if (request.method === 'ping') result = {};
217
- else throw Object.assign(new Error('Method not found'), { code: -32601 });
218
- } catch (e) { error = { code: e.code || -32603, message: e.message }; }
219
- process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, ...(error ? { error } : { result }) }) + '\n');
220
- }
221
- });
222
- process.stdin.on('end', () => { ipc?.end(); process.exit(0); });
package/pair.mjs DELETED
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env node
2
- /** One-time pairing: redeem the code shown by the room for this host's connector credential.
3
- *
4
- * The code is the room's to give and the person's to carry: the room shows it to whoever is in it
5
- * ("Emparejar conector"), and only that person can hand it to this machine. Nothing here asks the
6
- * room for one — a caller that could would turn "you can reach the address" into "you are in the room". */
7
- import os from 'node:os';
8
- import path from 'node:path';
9
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
-
11
- export function dataDir(env = process.env) {
12
- return env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
- }
14
-
15
- /** Where a plaintext connection is acceptable: the token must never cross a network we do not own. Loopback,
16
- * and a Kubernetes service name (`<svc>.<ns>.svc`, `<svc>.<ns>.svc.<cluster domain>`), which by construction
17
- * resolves only inside the cluster and is routed there. Anything else — a private IP included — needs TLS: a
18
- * host we cannot classify is not a reason to send a credential in clear. */
19
- export function privateNetwork(hostname) {
20
- if (['127.0.0.1', 'localhost', '::1', '[::1]'].includes(hostname)) return true;
21
- return /^[a-z0-9-]+\.[a-z0-9-]+\.svc(\.[a-z0-9.-]+)?$/i.test(hostname);
22
- }
23
-
24
- /** The room's http(s) origin from the socket address the credential stores. */
25
- export function roomOrigin(wsUrl) {
26
- const url = new URL(wsUrl); url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; return url.origin;
27
- }
28
-
29
- /** Which room this machine is paired with, or null. */
30
- export function pairedRoom(env = process.env) {
31
- try {
32
- const saved = JSON.parse(readFileSync(path.join(dataDir(env), 'credentials.json'), 'utf8'));
33
- if (!saved.url || !saved.connector_id || !saved.token) return null;
34
- return { origin: roomOrigin(saved.url), connector_id: saved.connector_id };
35
- } catch { return null; }
36
- }
37
-
38
- /** Redeem a code for this host's credential. Returns where it was written. */
39
- export async function pair(room, code, env = process.env) {
40
- const base = new URL(room);
41
- if (base.protocol !== 'https:' && !privateNetwork(base.hostname)) {
42
- throw new Error(`${base.origin} is reached in clear over a network this machine does not own; the room must be https:// there (loopback and Kubernetes service names are the exceptions).`);
43
- }
44
- const response = await fetch(new URL('/api/connectors/pair', base), {
45
- method: 'POST', headers: { 'content-type': 'application/json' },
46
- body: JSON.stringify({ code, host: os.hostname() }), signal: AbortSignal.timeout(15_000),
47
- });
48
- const body = await response.json().catch(() => ({}));
49
- if (!response.ok) throw new Error('Pairing failed: ' + (body.detail || response.status));
50
- const ws = new URL('/api/connectors/ws', base); ws.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
51
- const directory = dataDir(env);
52
- mkdirSync(directory, { recursive: true, mode: 0o700 });
53
- const file = path.join(directory, 'credentials.json');
54
- writeFileSync(file, JSON.stringify({ url: ws.toString(), connector_id: body.connector_id, token: body.token, protocol: body.protocol }, null, 2), { mode: 0o600 });
55
- return { file, connector_id: body.connector_id, origin: base.origin };
56
- }
57
-
58
- if (process.env.SIDEVOICE_PAIR_MAIN === '1') {
59
- const [room, code] = process.argv.slice(2);
60
- if (!room || !code) { console.error('usage: sidevoice pair <room-url> <pairing-code> (the code is shown in the room under "Emparejar conector")'); process.exit(2); }
61
- try {
62
- const result = await pair(room, code);
63
- console.log(`Paired with ${result.origin} as connector ${result.connector_id}; credential saved to ${result.file}`);
64
- } catch (error) { console.error(error.message); process.exit(1); }
65
- }
package/skill.mjs DELETED
@@ -1,41 +0,0 @@
1
- /** `sidevoice skill remove|status [--dir <skills dir>]`: the voice-room skill is no longer installed — the
2
- * server carries the same steps as an MCP prompt — but copies from earlier versions are still on disk, and
3
- * this takes ours away. A directory of the same name that is not ours is never touched. */
4
- import { existsSync, readFileSync, rmSync } from 'node:fs';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
-
8
- export const SKILL_NAME = 'voice-room';
9
- const MARKER = 'sidevoice: installed copy';
10
-
11
- export function skillsDir(argv = process.argv.slice(2), env = process.env) {
12
- const index = argv.indexOf('--dir');
13
- if (index >= 0 && argv[index + 1]) return path.resolve(argv[index + 1]);
14
- return path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'skills');
15
- }
16
-
17
- export function status(dir) {
18
- const target = path.join(dir, SKILL_NAME);
19
- const manifest = path.join(target, 'SKILL.md');
20
- if (!existsSync(target)) return { state: 'absent', target };
21
- let ours = false;
22
- try { ours = readFileSync(manifest, 'utf8').includes(MARKER); } catch {}
23
- return { state: ours ? 'installed' : 'foreign', target };
24
- }
25
-
26
- export function remove(dir) {
27
- const current = status(dir);
28
- if (current.state === 'foreign') throw new Error(`${current.target} is not Sidevoice's skill; left as it is.`);
29
- if (current.state === 'installed') rmSync(current.target, { recursive: true, force: true });
30
- return { state: 'absent', target: current.target, action: current.state === 'installed' ? 'removed' : 'nothing to remove' };
31
- }
32
-
33
- if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
34
- const [command] = process.argv.slice(2);
35
- const dir = skillsDir();
36
- try {
37
- const result = command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
38
- if (!result) { console.error('usage: sidevoice skill <remove|status> [--dir <skills dir>] (the skill is no longer installed: the MCP server offers the voice-room prompt)'); process.exit(2); }
39
- console.log(`${result.action || result.state}: ${result.target}`);
40
- } catch (error) { console.error(error.message); process.exit(1); }
41
- }