@sidevoice/uplink 0.4.2 → 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/mcp.mjs DELETED
@@ -1,198 +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
- const INSTRUCTIONS = `Sidevoice connects this conversation to the user's voice room.
22
- - Call voice_connect only when the user asks to join the voice room or enable voice for this conversation; never as a side effect.
23
- - Voice input arrives as a user message that starts with a JSON header ({"channel":"voice","session_id":...,"revision":...,"message_id":...}) followed by the user's literal words, and ends with a line marked [Sidevoice] that is not the user's words: it asks you to acknowledge by voice first. Treat the header as opaque reply metadata; if the same message_id arrives twice, it is a redelivery: do not act on it again.
24
- - For substantive work, one incoming voice message may receive multiple voice_say publications: an immediate acknowledgement that states what was understood and the next action, meaningful progress checkpoints while work continues, and a final result. Use the same original session_id and revision for every publication, with distinct utterances; do not manufacture filler or narrate every tool call.
25
- - A progress publication is not itself a listening point. Divide substantive execution into bounded steps and, after each tool result or operational boundary, process newly arrived user input before starting the next step. Do not add artificial sleeps or fixed pauses.
26
- - If a new user message arrives during active work, treat it as an addition, refinement, or replacement according to its meaning. Stop not-yet-started obsolete work, preserve completed work that remains useful, acknowledge the new interpretation before continuing, and do not later answer a stale request. A tool already running may finish before the correction takes effect; delegation is not a substitute for listening.
27
- - A "published" voice_say result means the room stored it, not that the user heard it. If publication fails, continue in writing.
28
- - If the user closes this conversation's voice channel from the room, the connection is removed: voice_say then fails saying so. Continue in writing and do not try to speak again; call voice_connect only when the user asks for voice again.
29
- - voice_status reports whether the room can currently reach this conversation, and which room this machine is paired with.
30
- - Pairing is the user's act, never yours. If voice_connect answers that 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 pairing code the room shows them under "Emparejar conector" (it expires in ten minutes), then call voice_pair with both and voice_connect again. Never try to obtain a code from the room yourself, and do not offer to: the room only shows it to the person in it.
31
- - On Claude Code, /voice-room is a shortcut for the same joining steps. Read receipts and working state need nothing from you: the room learns them from what the harness records about this conversation.
32
- - If voice_connect returns inbound.ok false, voice will look sent and never arrive: this harness holds or refuses messages posted by other local processes. Tell the user what inbound.reason says, offer inbound.remedy in your own words including what safeguard the machine-wide option removes, and let them choose. Do not change their settings without being asked to.`;
33
-
34
- // ----- one persistent connection to the connector -----
35
- let ipc = null, ipcBuffer = '', ipcSerial = 0;
36
- const ipcWaiting = new Map();
37
- function connectIpc() {
38
- return new Promise((resolve, reject) => {
39
- const socket = net.createConnection(socketPath);
40
- socket.once('error', reject);
41
- socket.on('connect', () => {
42
- socket.removeListener('error', reject);
43
- socket.on('error', () => {});
44
- socket.on('close', () => { if (ipc === socket) ipc = null; for (const w of ipcWaiting.values()) w.reject(new Error('Connector went away')); ipcWaiting.clear(); });
45
- socket.on('data', chunk => {
46
- ipcBuffer += chunk; let index;
47
- while ((index = ipcBuffer.indexOf('\n')) >= 0) {
48
- const line = ipcBuffer.slice(0, index); ipcBuffer = ipcBuffer.slice(index + 1);
49
- let reply; try { reply = JSON.parse(line); } catch { continue; }
50
- const waiting = ipcWaiting.get(reply.id); if (!waiting) continue; ipcWaiting.delete(reply.id);
51
- reply.ok ? waiting.resolve(reply.result) : waiting.reject(new Error(reply.error));
52
- }
53
- });
54
- ipc = socket; resolve(socket);
55
- });
56
- });
57
- }
58
- async function ensureConnector() {
59
- if (ipc) return ipc;
60
- try { return await connectIpc(); } catch {}
61
- const child = spawn(process.execPath, [connectorPath], { detached: true, stdio: 'ignore', env: process.env });
62
- child.unref();
63
- for (let attempt = 0; attempt < 40; attempt++) {
64
- await new Promise(r => setTimeout(r, 100));
65
- try { return await connectIpc(); } catch {}
66
- }
67
- throw new Error('The Sidevoice connector did not start (is this host paired? see docs/INSTALL.md)');
68
- }
69
- async function rpc(method, params) {
70
- await ensureConnector();
71
- const id = ++ipcSerial;
72
- return new Promise((resolve, reject) => { ipcWaiting.set(id, { resolve, reject }); ipc.write(JSON.stringify({ id, method, params }) + '\n'); });
73
- }
74
-
75
- // ----- tools -----
76
- const tools = [
77
- { 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.',
78
- 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 } },
79
- { 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.',
80
- 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 } },
81
- { 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.',
82
- 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 } },
83
- { name: 'voice_disconnect', description: 'Leave the voice room. The conversation and its work continue in writing.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
84
- { name: 'voice_status', description: 'Whether the room can currently reach this conversation.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
85
- ];
86
- let binding = null;
87
- function originOf(room) {
88
- try { return new URL(room).origin; } catch { throw new Error(`"${room}" is not a room address; expected something like https://voice.example`); }
89
- }
90
- /** Ask the user, do not guess: the code exists only on the room's screen. */
91
- function pairingNeeded(room, paired) {
92
- const target = room ? originOf(room) : null;
93
- 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.`;
94
- 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.`;
95
- return null;
96
- }
97
- /** A connector from another version serves this conversation with that version's behaviour. */
98
- function versionNote(connectorVersion) {
99
- if (!ipc || connectorVersion === VERSION) return {};
100
- 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.` };
101
- }
102
- function inboundFor(harness, thread) {
103
- return capabilityState(harness, 'inspectInbound') === SUPPORTED ? harness.inspectInbound(thread) : null;
104
- }
105
- async function invoke(name, args, meta) {
106
- if (name === 'voice_status') {
107
- const status = ipc ? await rpc('status', {}) : { connected: false, bindings: [], closed_by_room: [] };
108
- // The room may have closed this conversation's voice since we joined: the connector is the truth.
109
- const closed = !!binding && (status.closed_by_room || []).includes(binding.client_ref);
110
- if (closed) binding = null;
111
- const module = binding ? harnessFor(binding.harness) : null;
112
- const inbound = binding ? inboundFor(module, binding.client_ref) : null;
113
- return { joined: !!binding, room: status.room || pairedRoom()?.origin || null, room_reachable: status.connected, room_error: status.room_error || null,
114
- version: VERSION, connector_version: status.version || null, ...versionNote(status.version),
115
- binding_id: binding?.binding_id || null, harness: binding?.harness || null,
116
- capabilities: binding?.capabilities || null, inbound,
117
- ...(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.' } : {}) };
118
- }
119
- if (name === 'voice_pair') {
120
- if (!args.room || !args.code) throw new Error('voice_pair needs the room\'s address and the code the user read from it.');
121
- const previous = pairedRoom();
122
- // The room shows the code in upper case and compares it that way; a dictated one arrives however it was heard.
123
- const result = await pair(originOf(args.room), String(args.code).trim().toUpperCase());
124
- // The connector that is up, if any, was started for the previous credential: let go of it so it can
125
- // exit, and the next voice_connect starts one for this room. Other conversations still bound to the
126
- // previous room keep that connector alive until they leave; they are not moved.
127
- if (binding) { try { await rpc('unregister', { binding_id: binding.binding_id }); } catch {} binding = null; }
128
- if (ipc) { ipc.end(); ipc = null; }
129
- return { status: 'paired', room: result.origin, connector_id: result.connector_id,
130
- ...(previous && previous.origin !== result.origin ? { replaced: previous.origin, note: 'Conversations on this machine still joined to the previous room keep it until they leave.' } : {}),
131
- next: 'Call voice_connect to join.' };
132
- }
133
- if (name === 'voice_connect') {
134
- const needed = pairingNeeded(args.room, pairedRoom());
135
- if (needed) { const error = new Error(needed); error.data = { pairing_needed: true, room: args.room ? originOf(args.room) : null }; throw error; }
136
- const who = identifyHarness(meta);
137
- const title = (args.title || process.env.SIDEVOICE_TITLE || path.basename(process.cwd())).slice(0, 200);
138
- // Refuse rather than join a room we cannot hear from: a conversation whose harness holds
139
- // what the room posts would sit in the list looking present while the user talks to nobody.
140
- const inbound = inboundFor(who.module, who.thread);
141
- if (inbound?.ok === false) {
142
- const error = new Error(`No se conecta esta conversación: ${inbound.reason} ${inbound.remedy}`);
143
- error.data = { inbound };
144
- throw error;
145
- }
146
- const capabilities = advertisedCapabilities(who.module);
147
- // Which model is answering, read from the session's own launch line rather than asked of the model.
148
- let engine = null;
149
- try { engine = who.module.engine?.(who.thread) || null; } catch { engine = null; }
150
- const result = await rpc('register', { client_ref: who.thread, harness: who.harness, thread: who.thread,
151
- title, delivery: who.delivery, inbound, capabilities, engine });
152
- binding = { ...result, harness: who.harness, client_ref: who.thread, capabilities };
153
- let connectorVersion = null; try { connectorVersion = (await rpc('status', {})).version || null; } catch {}
154
- return { status: result.pending ? 'joining' : 'joined', harness: who.harness, conversation: who.thread,
155
- binding_id: result.binding_id, delivery: 'push', room_reachable: result.connected, capabilities, inbound,
156
- version: VERSION, connector_version: connectorVersion, ...versionNote(connectorVersion) };
157
- }
158
- if (!binding) throw new Error('Not connected to the voice room: call voice_connect first (only if the user asked).');
159
-
160
- if (name === 'voice_say') {
161
- let result;
162
- try {
163
- 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 });
164
- } catch (error) {
165
- if (error.message === 'CLOSED_BY_ROOM') {
166
- binding = null;
167
- 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.');
168
- }
169
- throw error;
170
- }
171
- return result.text_saved ? { status: 'published', text_saved: true, audio: result.status, reason: result.reason } : result;
172
- }
173
- if (name === 'voice_disconnect') { const result = await rpc('unregister', { binding_id: binding.binding_id }); binding = null; return { status: 'left', room_reachable: result.connected }; }
174
- throw new Error('Unknown tool');
175
- }
176
-
177
- // ----- JSON-RPC over stdio -----
178
- let input = '';
179
- process.stdin.setEncoding('utf8');
180
- process.stdin.on('data', async chunk => {
181
- input += chunk;
182
- while (input.includes('\n')) {
183
- const index = input.indexOf('\n'); const line = input.slice(0, index); input = input.slice(index + 1);
184
- if (!line.trim()) continue;
185
- let request; try { request = JSON.parse(line); } catch { continue; }
186
- if (request.id === undefined) continue; // notifications need no answer
187
- let result, error;
188
- try {
189
- if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'sidevoice', version: VERSION }, instructions: INSTRUCTIONS };
190
- else if (request.method === 'tools/list') result = { tools };
191
- 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) }] }; }
192
- else if (request.method === 'ping') result = {};
193
- else throw Object.assign(new Error('Method not found'), { code: -32601 });
194
- } catch (e) { error = { code: e.code || -32603, message: e.message }; }
195
- process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, ...(error ? { error } : { result }) }) + '\n');
196
- }
197
- });
198
- 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
- }
@@ -1,16 +0,0 @@
1
- ---
2
- name: voice-room
3
- description: Join the user's Sidevoice voice room with this conversation. Use when the user asks to enable voice, join the room or talk by voice; never as a side effect of other work.
4
- argument-hint: "[title for this conversation in the room]"
5
- metadata:
6
- sidevoice: installed copy; the source is skill/voice-room in @sidevoice/uplink, reinstall with `sidevoice skill install`
7
- ---
8
-
9
- Join the voice room for this conversation and keep it reachable.
10
-
11
- 1. Call `voice_status`. If it reports `joined` and `room_reachable`, say so in one line and stop.
12
- 2. Call `voice_connect` with the title `$ARGUMENTS` when given, otherwise a short label of what this conversation is about. If the user named a room, pass its address as `room`.
13
- 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.
14
- 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.
15
-
16
- Nothing else is registered: the room learns that a message was read and whether this conversation is working from what Claude Code itself records about the session. How to behave once joined is in the Sidevoice MCP server's own instructions.
package/skill.mjs DELETED
@@ -1,57 +0,0 @@
1
- /** `sidevoice skill install|remove|status [--dir <skills dir>]`: the Claude Code skill that joins the room
2
- * (`/voice-room`). One file; running install again repairs it. A directory of the same name that is not
3
- * ours is never touched. */
4
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const here = path.dirname(fileURLToPath(import.meta.url));
10
- export const SKILL_NAME = 'voice-room';
11
- const MARKER = 'sidevoice: installed copy';
12
-
13
- export function skillsDir(argv = process.argv.slice(2), env = process.env) {
14
- const index = argv.indexOf('--dir');
15
- if (index >= 0 && argv[index + 1]) return path.resolve(argv[index + 1]);
16
- return path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'skills');
17
- }
18
-
19
- export function status(dir) {
20
- const target = path.join(dir, SKILL_NAME);
21
- const manifest = path.join(target, 'SKILL.md');
22
- if (!existsSync(target)) return { state: 'absent', target };
23
- let ours = false;
24
- try { ours = readFileSync(manifest, 'utf8').includes(MARKER); } catch {}
25
- return { state: ours ? 'installed' : 'foreign', target };
26
- }
27
-
28
- export function install(dir) {
29
- const current = status(dir);
30
- if (current.state === 'foreign') throw new Error(`${current.target} already holds a skill that is not Sidevoice's; remove or rename it first.`);
31
- mkdirSync(current.target, { recursive: true });
32
- writeFileSync(path.join(current.target, 'SKILL.md'), readFileSync(path.join(here, 'skill', SKILL_NAME, 'SKILL.md'), 'utf8'));
33
- for (const stale of ['hook.mjs', 'harness-contract.mjs', 'harnesses.mjs', 'harness-claude.mjs', 'harness-codex.mjs', 'harness-http.mjs']) {
34
- rmSync(path.join(current.target, stale), { force: true }); // an older copy carried a hook runtime; it is gone
35
- }
36
- return { ...status(dir), action: current.state === 'installed' ? 'updated' : 'installed' };
37
- }
38
-
39
- export function remove(dir) {
40
- const current = status(dir);
41
- if (current.state === 'foreign') throw new Error(`${current.target} is not Sidevoice's skill; left as it is.`);
42
- if (current.state === 'installed') rmSync(current.target, { recursive: true, force: true });
43
- return { state: 'absent', target: current.target, action: current.state === 'installed' ? 'removed' : 'nothing to remove' };
44
- }
45
-
46
- if (process.env.SIDEVOICE_SKILL_MAIN === '1') {
47
- const [command] = process.argv.slice(2);
48
- const dir = skillsDir();
49
- try {
50
- const result = command === 'install' ? install(dir) : command === 'remove' ? remove(dir) : command === 'status' ? status(dir) : null;
51
- if (!result) { console.error('usage: sidevoice skill <install|remove|status> [--dir <skills dir>]'); process.exit(2); }
52
- console.log(`${result.action || result.state}: ${result.target}`);
53
- if (result.action === 'installed' || result.action === 'updated') {
54
- console.log('In Claude Code, /voice-room joins the room for that conversation. New sessions see the skill; a session already open needs a restart.');
55
- }
56
- } catch (error) { console.error(error.message); process.exit(1); }
57
- }