@milpa/comal 0.2.4 → 0.2.6
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 +1 -1
- package/backend/contracts/remote-device-v1.json +48 -1
- package/package.json +1 -1
- package/src/cli.mjs +16 -5
- package/src/i18n/cli-es.mjs +1 -0
- package/src/onboarding.mjs +1 -1
- package/src/recent-connections.mjs +82 -0
- package/src/remote-control.mjs +58 -13
- package/src/remote-device.mjs +6 -1
- package/src/remote-speech.mjs +275 -0
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ comal --help
|
|
|
28
28
|
|
|
29
29
|
Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
|
|
30
30
|
|
|
31
|
-
You can pin a version with `npx @milpa/comal@0.2.
|
|
31
|
+
You can pin a version with `npx @milpa/comal@0.2.6` or `npm install --global @milpa/comal@0.2.6`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
|
|
32
32
|
|
|
33
33
|
## Test without account or model
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"protocol": "comal.remote.device.v1",
|
|
3
|
-
"description": "Contract between a Comal Remote Control (/rc) session and an M5Stack device: pairing, the two inputs a device may send, the ephemeral permission card, the session.stats projection and the reasons a device is refused. The device is a pre-check surface only: the local Comal host revalidates every answer (arm, expiry, card digest, session, device) before mutating. Owned by the Comal Node repository; the backend keeps a byte-identical copy.",
|
|
3
|
+
"description": "Contract between a Comal Remote Control (/rc) session and an M5Stack device: pairing, the two inputs a device may send, the ephemeral permission card, the session.stats projection, the ephemeral speech chunks (8-bit unsigned mono PCM the host sends the paired device through the relay; never stored) and the reasons a device is refused. The device is a pre-check surface only: the local Comal host revalidates every answer (arm, expiry, card digest, session, device) before mutating. Owned by the Comal Node repository; the backend keeps a byte-identical copy.",
|
|
4
4
|
"pairing": {
|
|
5
5
|
"code_alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
|
|
6
6
|
"code_length": 8,
|
|
@@ -406,6 +406,53 @@
|
|
|
406
406
|
]
|
|
407
407
|
}
|
|
408
408
|
},
|
|
409
|
+
"speech": {
|
|
410
|
+
"maxBytes": 6144,
|
|
411
|
+
"format": {
|
|
412
|
+
"encoding": "pcm_u8",
|
|
413
|
+
"channels": 1,
|
|
414
|
+
"sample_rate": 11025,
|
|
415
|
+
"chunk_max_bytes": 4096,
|
|
416
|
+
"phrase_max_seconds": 6
|
|
417
|
+
},
|
|
418
|
+
"schema": {
|
|
419
|
+
"type": "object",
|
|
420
|
+
"additionalProperties": false,
|
|
421
|
+
"properties": {
|
|
422
|
+
"id": {
|
|
423
|
+
"type": "string",
|
|
424
|
+
"pattern": "^[0-9a-f]{16}$"
|
|
425
|
+
},
|
|
426
|
+
"seq": {
|
|
427
|
+
"type": "string",
|
|
428
|
+
"pattern": "^(0|[1-9][0-9]?)$"
|
|
429
|
+
},
|
|
430
|
+
"last": {
|
|
431
|
+
"type": "boolean"
|
|
432
|
+
},
|
|
433
|
+
"about": {
|
|
434
|
+
"type": "string",
|
|
435
|
+
"enum": [
|
|
436
|
+
"permission",
|
|
437
|
+
"failure",
|
|
438
|
+
"turn"
|
|
439
|
+
]
|
|
440
|
+
},
|
|
441
|
+
"audio": {
|
|
442
|
+
"type": "string",
|
|
443
|
+
"maxLength": 5464,
|
|
444
|
+
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$"
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
"required": [
|
|
448
|
+
"id",
|
|
449
|
+
"seq",
|
|
450
|
+
"last",
|
|
451
|
+
"about",
|
|
452
|
+
"audio"
|
|
453
|
+
]
|
|
454
|
+
}
|
|
455
|
+
},
|
|
409
456
|
"arm": {
|
|
410
457
|
"default_seconds": 1800,
|
|
411
458
|
"max_seconds": 28800
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -7,11 +7,13 @@ import { t, registerMessages, configureLanguage, languageArguments } from './i18
|
|
|
7
7
|
import cliSpanish from './i18n/cli-es.mjs';
|
|
8
8
|
registerMessages(cliSpanish);
|
|
9
9
|
import { RemoteHost } from './remote-control.mjs';
|
|
10
|
+
import { speechFromEnv, permissionSpeech } from './remote-speech.mjs';
|
|
10
11
|
import { PermissionDesk, confirmWithDevice } from './remote-permissions.mjs';
|
|
11
12
|
import { remoteCommandHelp, isRemoteCommand } from './remote-commands.mjs';
|
|
12
13
|
import { codeRunDefinition } from './code-run.mjs';
|
|
13
14
|
import {installCodeRunSession} from './code-run-session.mjs';
|
|
14
15
|
import { hasProviderSelection, loadConnection, saveConnection, existingLoginConnection, onboard, loadSavedLimits } from './onboarding.mjs';
|
|
16
|
+
import { loadRecentConnection, rememberConnection, recentSelection } from './recent-connections.mjs';
|
|
15
17
|
import { SessionSettings, resolveStartupLimits, modelAwareLimits } from './connection-settings.mjs';
|
|
16
18
|
import { loginProvider, parseLoginArguments, withoutCredentialHeaders } from './secret-login.mjs';
|
|
17
19
|
import { stageImage, descriptorForStage, persistHumanImage } from './image-attachments.mjs';
|
|
@@ -251,7 +253,9 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
251
253
|
const explicitConnection = hasProviderSelection(v, profiles);
|
|
252
254
|
let savedConnection, recoveredLogin, knownLogins = [];
|
|
253
255
|
if (!explicitConnection) {
|
|
254
|
-
|
|
256
|
+
// Interactive: the project's last connection, else the last one used anywhere, else connection.json. A print run
|
|
257
|
+
// (scripts, cron) keeps reading only connection.json, so a model tried interactively never changes it.
|
|
258
|
+
try { savedConnection = (print ? null : await loadRecentConnection({ cwd })) || await loadConnection(); }
|
|
255
259
|
catch (error) { stderr.write(t('Saved connection could not be loaded: ') + error.message + '\n'); }
|
|
256
260
|
if (!savedConnection && !print) {
|
|
257
261
|
const existing = await existingLoginConnection(authManager); knownLogins = existing.logins;
|
|
@@ -357,6 +361,9 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
357
361
|
await store.append(session.id,row);
|
|
358
362
|
Object.assign(session,{model:row.model,provider:row.provider,provider_profile:row.profile,provider_connection:row.connection});
|
|
359
363
|
};
|
|
364
|
+
// Interactive sessions remember the connection chosen without startup flags, and each /model or /provider, for the
|
|
365
|
+
// next start (per project and overall). A print run, a resumed session and a one-off flag or variable never do.
|
|
366
|
+
const rememberActive=async()=>{if(v.demo || print || !provider)return;const selection=recentSelection(settings,provider);if(!selection)return;try{await rememberConnection(selection,{cwd});}catch(error){ui?.notice(t('Connection not remembered for the next start: ')+error.message,true);}};
|
|
360
367
|
const saveProvider=async()=>{if(!v.demo)await persistProvider(provider,settings,modelCapabilities);};
|
|
361
368
|
if(v.name){if(v.name.trim().length<1 || v.name.length>120)throw new Error(t("--name must be 1 to 120 characters."));session.name=clean(v.name.trim());await store.append(session.id,{type:'name',name:session.name});}
|
|
362
369
|
if(v.inbound && !['accept','hold','refuse'].includes(v.inbound))throw new Error(t("--inbound must be accept, hold, or refuse."));
|
|
@@ -375,6 +382,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
375
382
|
const interactive = !print;
|
|
376
383
|
const color = (code, text) => stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
377
384
|
let remoteHost;
|
|
385
|
+
const remoteSpeech = speechFromEnv(process.env);
|
|
378
386
|
// One desk per process: a permission card answered from the paired M5Stack races the terminal card.
|
|
379
387
|
const permissionDesk = new PermissionDesk({ sendCard: card => remoteHost?.sendCard(card), closeCard: id => remoteHost?.closeCard(id), record: row => store.append(session.id, { ...row, source: 'remote_device', at: new Date().toISOString() }) });
|
|
380
388
|
let controller, authController, modelController, scanController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
|
|
@@ -389,6 +397,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
389
397
|
const signals = [options.signal, controller?.signal || startupSignal].filter(Boolean), signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
|
|
390
398
|
const card = options.summary === undefined ? await house.confirmSummary(name, input, { signal }) : null;
|
|
391
399
|
const summary = options.summary ?? card?.summary, terminal = shown => ui.confirm(name, input, shown, { summary, edit: card?.edit, remember: options.remember });
|
|
400
|
+
// Granito speaks the question whether or not the device is armed to answer it; synchronous, never awaited.
|
|
401
|
+
try { remoteHost?.sendSpeech(permissionSpeech({ name, input, armed: remoteHost?.permissionsArmed() === true, plan: policy.mode === 'plan', confirmAlways: policy.confirmAlways(name) === true }), 'permission'); } catch {}
|
|
392
402
|
return confirmWithDevice({ armed: remoteHost?.permissionsArmed() === true, desk: permissionDesk, questionId: options.questionId, name, input, summary, signal, terminal, resolveWrite: (file, how) => tools.resolve(file, how), confirmAlways: () => policy.confirmAlways(name), mode: () => policy.mode, notice: text => ui.notice(text) });
|
|
393
403
|
} : undefined });
|
|
394
404
|
// /add-dir rows are the human's decisions in this session's book (under .comal, which the model cannot write);
|
|
@@ -620,7 +630,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
620
630
|
house.registry.add({name:'CloudAccountLogout',description:'Human detaches Remote Control and removes the local Comal account credential.',input_schema:emptyInput,audience:'human',surfaces:['tui'],permission:null,effects:{workspace:'none',external:'write',process:'none',authority:'credential_control'},run:async()=>{await remoteHost?.stop();return cloudClient.logout();}});
|
|
621
631
|
const openRemoteControl=async()=>{
|
|
622
632
|
if(!runtime)throw Error('Remote Control requires an interactive local session.');
|
|
623
|
-
if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text),getWorkspace:async snapshot=>projectRemoteWorkspace(snapshot || await store.load(session.id),house.stateContext()),relay:relayRemote,onRelay:interactive?(input,binding)=>relayAnswers.handle(input,binding):null,onPermission:interactive?(input,binding)=>permissionDesk.handle(input,binding):null,onPermissionsDisarmed:reason=>{store.append(session.id,{type:'remote_control',action:'permissions.arm',armed:false,until:null,reason,source:'host',at:new Date().toISOString()}).catch(()=>{});},getStats:sessionStats});
|
|
633
|
+
if(!remoteHost || remoteHost.stopped)remoteHost=new RemoteHost({client:new CloudClient({origin:v['cloud-url']}),speech:remoteSpeech,onInput:(input,binding)=>runtime.admitRemote(input,binding),onStop:id=>runtime.stopRemote(id),onNotice:text=>ui.notice(text),getWorkspace:async snapshot=>projectRemoteWorkspace(snapshot || await store.load(session.id),house.stateContext()),relay:relayRemote,onRelay:interactive?(input,binding)=>relayAnswers.handle(input,binding):null,onPermission:interactive?(input,binding)=>permissionDesk.handle(input,binding):null,onPermissionsDisarmed:reason=>{store.append(session.id,{type:'remote_control',action:'permissions.arm',armed:false,until:null,reason,source:'host',at:new Date().toISOString()}).catch(()=>{});},getStats:sessionStats});
|
|
624
634
|
const connection = new AbortController(); authController = connection;
|
|
625
635
|
const cancel = () => remoteHost.stop().catch(() => {});
|
|
626
636
|
connection.signal.addEventListener('abort', cancel, { once: true });
|
|
@@ -860,7 +870,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
860
870
|
{ name: 'SessionMode', description: 'Human changes the permission mode.', input_schema: { type: 'object', properties: { mode: { type: 'string', enum: PERMISSION_MODES } }, required: ['mode'], additionalProperties: false }, run: async ({ mode }) => { policy.mode = mode; await store.append(session.id, { type: 'mode', mode }); return t("Permissions: {v0}", {v0: mode}); } },
|
|
861
871
|
{ name:'SessionModel',description:'Human selects a model without replacing session history.',input_schema:{type:'object',properties:{model:{type:'string',minLength:1,maxLength:256}},required:['model'],additionalProperties:false},run:async({model})=>{
|
|
862
872
|
if(!model.trim() || /\s/.test(model))throw new Error(t("Use /model ID, without spaces."));
|
|
863
|
-
const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});
|
|
873
|
+
const verification=await replaceProvider({...settings,model,effort:'auto'},{refresh:true,validate:true});await rememberActive();
|
|
864
874
|
return t("Model: {v0} · effort {v1} · same session", {v0: provider.model, v1: provider.effort || 'auto'}) + (verification?.compatibilityFallback? ' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
|
|
865
875
|
}},
|
|
866
876
|
{name:'SessionEffort',description:'Human selects a supported reasoning effort for the next request.',input_schema:{type:'object',properties:{effort:{type:'string',minLength:1,maxLength:16}},required:['effort'],additionalProperties:false},run:async({effort})=>{
|
|
@@ -881,7 +891,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
881
891
|
if(v.demo)throw new Error(t("The demo does not allow changing provider."));
|
|
882
892
|
const selected=profiles.profiles.find(item=>item.name===profile);
|
|
883
893
|
const config=selected ? {provider:selected.provider,profile:selected,namedProfile:true,authProvider:selected.authProvider,model:selected.model,baseURL:selected.baseURL,effort:selected.effort || 'auto',maxTokens:contextConfig.maxTokens} : {...authProviderDefaults(profile),effort:'auto',maxTokens:contextConfig.maxTokens};
|
|
884
|
-
const verification=await replaceProvider(config,{validate:true});
|
|
894
|
+
const verification=await replaceProvider(config,{validate:true});await rememberActive();
|
|
885
895
|
return t("Provider: {v0} · {v1} · {v2}", {v0: selected?.name || provider.authProvider, v1: settings.provider, v2: provider.model}) + (verification?.compatibilityFallback?' · '+t('Model catalog unavailable; the active connection is unverified. No inference was sent.'):'');
|
|
886
896
|
}},
|
|
887
897
|
{name:'SessionLogin',description:'Human authorizes a provider login. Only the environment variable name is recorded.',input_schema:{type:'object',properties:{provider:{type:'string',enum:AUTH_PROVIDERS.map(item=>item.id)},apiKeyEnv:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},hidden:{type:'boolean'}},required:['provider'],additionalProperties:false},effects:{workspace:'none',external:'unknown',process:'none',authority:'credential_control'},run:async({provider:target,apiKeyEnv,hidden})=>loginControl(target+(apiKeyEnv?' --api-key-env '+apiKeyEnv:'')+(hidden?' --key':''))},
|
|
@@ -959,7 +969,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
959
969
|
try{return JSON.stringify(await loginProvider({authManager,ui,provider:target,...login,signal,onEvent:event=>{if(ui){if(event.type==='device_code')ui.notice(t("Login {v0}: {v1} · code {v2}", {v0: event.provider, v1: event.verification_uri, v2: event.user_code}));else if(event.message)ui.notice(event.message);}else authNotice(event);}}),null,2);}
|
|
960
970
|
finally{authController=null;if(ui)ui.setBusy(wasBusy);}
|
|
961
971
|
};
|
|
962
|
-
const sessionSettings=new SessionSettings({limits:contextConfig,flags:{maxTokens:v['max-tokens'],contextWindow:v['context-window']},authManager,delegate:(command,arg)=>backend.control(command,arg),
|
|
972
|
+
const sessionSettings=new SessionSettings({persistConnection:async(selection,options)=>{await saveConnection(selection,options);try{await rememberConnection(selection,{cwd,env:options?.env});}catch{}},limits:contextConfig,flags:{maxTokens:v['max-tokens'],contextWindow:v['context-window']},authManager,delegate:(command,arg)=>backend.control(command,arg),
|
|
963
973
|
applyLimits:async()=>{if(provider)provider.maxTokens=contextConfig.maxTokens;agent.compactor.contextWindow=effectiveContextWindow(modelCapabilities);await saveProvider();},
|
|
964
974
|
state:()=>({demo:Boolean(v.demo),provider:settings.provider,authProvider:provider?.authProvider,profile:provider?.profileName,endpoint:provider?.baseURL || (provider?.url?String(provider.url):undefined),model:provider?.model,effort:provider?.effort,thinking:provider?.thinking,protocol:provider?.protocol,fallback:provider?.fallback,apiKeyEnv:session.provider_connection?.apiKeyEnv,modelCapabilities,mode:policy.mode,plugins:plugins.status(),paths:{profiles:profiles.configPath,plugins:plugins.status().config_path}})});
|
|
965
975
|
const backend = {
|
|
@@ -1161,6 +1171,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
|
|
|
1161
1171
|
if(peerBus.availability?.available===false && !['PEER_DISABLED','PEER_UNAVAILABLE'].includes(peerBus.availability.code)){const note=t('Cross-session messaging is unavailable on this platform. Local work remains available.');if(ui)ui.notice(note,true);else stderr.write(note+'\n');}
|
|
1162
1172
|
await refreshConnections();
|
|
1163
1173
|
await saveProvider();
|
|
1174
|
+
if (!explicitConnection) await rememberActive();
|
|
1164
1175
|
await lifecycle.execute('PluginInitialize', { trust: Boolean(v['trust-plugins'] || skipPermissions) }, { signal: startupSignal });
|
|
1165
1176
|
await lifecycle.execute('McpInitialize', { trust: Boolean(v['trust-mcp'] || skipPermissions) }, { signal: startupSignal });
|
|
1166
1177
|
if(host.codeRunSession) {
|
package/src/i18n/cli-es.mjs
CHANGED
|
@@ -108,5 +108,6 @@ export default Object.freeze({
|
|
|
108
108
|
"Use /permissions default|plan|acceptEdits|auto.": "Usa /permissions default|plan|acceptEdits|auto.",
|
|
109
109
|
"Model: {v0} · effort {v1}\nUse /models, /model ID, or /effort LEVEL.": "Modelo: {v0} · effort {v1}\nUsa /models, /model ID o /effort NIVEL.",
|
|
110
110
|
"There are no saved sessions.\n": "No hay sesiones guardadas.\n",
|
|
111
|
+
"Connection not remembered for the next start: ": "La conexión no se recordó para el próximo inicio: ",
|
|
111
112
|
"Comal · standalone CLI programming assistant\n\nUsage: node bin/comal.mjs [options] [prompt]\n node bin/comal.mjs sessions | evidence | audit --resume UUID\n\n -p, --print Runs a turn and exits (also accepts stdin)\n --demo Deterministic demo without credentials; allows its edits\n --cwd <path> Workspace (default: current directory)\n --provider <protocol> openai (default) | anthropic | gemini\n --profile <name> Profile from .comal/providers.json (endpoint/model/capabilities)\n --providers-config <file> Profiles file; keys only via apiKeyEnv\n --name <name> Name visible to other sessions\n --listen Keeps the CLI listening for messages and responds when idle\n --inbound <mode> accept (default) | hold | refuse\n --no-peers No cross-session bus (also COMAL_PEERS=off)\n --auth-provider <id> Comal login: codex | kimi | minimax | grok\n --effort <level> auto | off | minimal | low | medium | high | xhigh | max (if the model supports it)\n --model <id> Model; by default discovers the local one at /v1/models\n --base-url <url> Endpoint; default http://llama.local:11438/\n --permission-mode <mode> default | acceptEdits | plan | auto\n --allowed-tools <names> Authorizes comma-separated names\n --disallowed-tools <names> Denies names; overrides authorization\n --resume <uuid> Resumes session; with -p and no text continues pending work\n -c, --continue Resumes the most recent session\n --output-format <format> text | json | stream-json (with --print)\n --max-turns <n> Calls before extending work with progress (12)\n --work-max-calls <n> Total limit with task continuation (80; 0 disables)\n --context-window <n> Explicit limit; automatic based on model, fallback 45000\n --max-tokens <n> Initial maximum output tokens (8192; up to 32000 when the model allows)\n --autocompact <n|auto|off> Compaction window: N or Nk tokens, auto or off (also COMAL_AUTOCOMPACT)\n --thinking <mode> auto | on | off | low | medium | high (llama.cpp)\n --vision <mode> auto | on | off (auto queries /props when receiving images)\n --image-tokens <n> Estimated reservation per image (4096, maximum 2 images)\n --model-timeout-ms <n> Total time for each OpenAI response (600000)\n --model-idle-timeout-ms <n> Time without model token progress (180000)\n --system-prompt <text> Additional instructions\n --state-dir <path> State location; default .comal/ (reuses existing .harness/)\n --mcp-config <file> Connects MCP stdio, Streamable HTTP, or SSE from a JSON\n --trust-mcp Authorizes initial connections; changes are reviewed again\n --plugin-config <file> Local Node plugins (JSON); default .comal/plugins.json\n --lang <en|es|auto> UI language (default: en; auto opts into locale detection)\n --codemode Read-only JavaScript tool composition (default: on)\n --no-codemode Disable CodeRun for this process\n --trust-plugins Authorizes loading local code from the initial config\n --chrome-task <file.json> Explicit Chrome task; the house coordinates publication, checks, and closure\n --goal <condition> Runs a goal until verification or limit\n --goal-max-turns <n> Maximum goal turns (20)\n --goal-max-calls <n> Total calls: agent + verifiers (80)\n --goal-max-tokens <n> Goal token budget (100000)\n -h, --help Shows this help\n -v, --version Shows version\n\nVariables (HARNESS_* remains as alias): COMAL_PROVIDER, COMAL_BASE_URL, COMAL_MODEL,\n COMAL_CONTEXT_WINDOW, COMAL_MAX_TOKENS, COMAL_WORK_MAX_CALLS,\n COMAL_MODEL_TIMEOUT_MS, COMAL_MODEL_IDLE_TIMEOUT_MS,\n OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY (optional for local),\n ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL (with --provider anthropic).\nRead authorized by default; write and Bash require permission.\nIn --print without a terminal, unauthorized tools are denied.\nShift+Tab: default → acceptEdits → plan → auto → default.\nInteractive commands: /help /status /tools /house /operations /sessions /resume UUID /clear\n /compact /context /state /audit /board /work resume|recover\n /model ID /permissions MODE /goal [condition] /exit\n /queue [pause|resume|clear|edit N] /rename NAME /table FILE\nTUI: F1 help · F2 sessions · F4 queue · F5 or /select selection · F6 discover commands/tools.\n / and Tab complete; ↑/↓ chooses; Enter on suggestions only completes. Alt+C or /copy copies.\n Ctrl+G editor · Alt+Enter multiline.\nMCP: /mcp · /mcp reload [file] · /mcp add NAME {JSON} · /mcp remove NAME\n /mcp reconnect NAME. Default file: .mcp.json in the workspace.\nWeb: /search QUERY · WebSearch/WebFetch · COMAL_SEARCH_PROVIDER, SEARXNG_URL, BRAVE_SEARCH_API_KEY.\nSubagents: /agent [explore|general] TASK · /agents [UUID] · Agent/AgentBatch.\nPlugins: /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].\nLive sessions: /list-agents · /send ID_OR_NAME TEXT · /inbox [accept|hold|refuse|release ID|all]\nComal account: comal cloud login|status|logout [--cloud-url ORIGIN] (separate from the model provider)\nArtifacts: comal artifacts list|inspect ID|verify ID|publish DIR --manifest JSON|release ID VERSION JOB\nLogin: comal login PROVIDER [--api-key-env VARIABLE] · comal auth status [PROVIDER] · comal logout PROVIDER\nModels: /model [ID] · /models · /effort [LEVEL] · /capabilities [refresh] · /login PROVIDER · /logout PROVIDER\nProviders: /providers · /provider PROFILE · ProviderSearch/AnalyzeMedia according to capabilities.\nCLI: comal peers | providers | message DESTINATION TEXT (use --listen to receive in CLI).\n/goal status|pause|resume|clear controls the goal; Ctrl+C pauses it.\nLocal skills: .comal/skills/<name>/SKILL.md → /<name> [task]\n\nRemote Control: /rc [start|status|stop] · TUI account: /cloud [login|status|logout] · CLI: comal cloud login\n": "Comal · asistente de programación CLI independiente\n\nUso: node bin/comal.mjs [opciones] [prompt]\n node bin/comal.mjs sessions | evidence | audit --resume UUID\n\n -p, --print Ejecuta un turno y termina (también acepta stdin)\n --demo Demo determinista sin credenciales; permite sus ediciones\n --cwd <ruta> Workspace (por defecto: directorio actual)\n --provider <protocolo> openai (predeterminado) | anthropic | gemini\n --profile <nombre> Perfil de .comal/providers.json (endpoint/modelo/capacidades)\n --providers-config <archivo> Archivo de perfiles; claves sólo vía apiKeyEnv\n --name <nombre> Nombre visible a otras sesiones\n --listen Mantiene el CLI escuchando mensajes y responde al quedar idle\n --inbound <modo> accept (predeterminado) | hold | refuse\n --no-peers Sin bus entre sesiones (también COMAL_PEERS=off)\n --auth-provider <id> Login de Comal: codex | kimi | minimax | grok\n --effort <nivel> auto | off | minimal | low | medium | high | xhigh | max (si el modelo lo admite)\n --model <id> Modelo; por defecto descubre el local en /v1/models\n --base-url <url> Endpoint; por defecto http://llama.local:11438/\n --permission-mode <modo> default | acceptEdits | plan | auto\n --allowed-tools <nombres> Autoriza nombres separados por comas\n --disallowed-tools <nombres> Deniega nombres; prevalece sobre autorización\n --resume <uuid> Reanuda sesión; con -p sin texto continúa trabajo pendiente\n -c, --continue Reanuda la sesión más reciente\n --output-format <formato> text | json | stream-json (con --print)\n --max-turns <n> Llamadas antes de extender trabajo con progreso (12)\n --work-max-calls <n> Límite total con continuación de tareas (80; 0 desactiva)\n --context-window <n> Límite explícito; automático según modelo, fallback 45000\n --max-tokens <n> Máximo inicial de tokens de salida (8192; hasta 32000 si el modelo lo admite)\n --autocompact <n|auto|off> Ventana de compactación: N o Nk tokens, auto u off (también COMAL_AUTOCOMPACT)\n --thinking <modo> auto | on | off | low | medium | high (llama.cpp)\n --vision <modo> auto | on | off (auto consulta /props al recibir imágenes)\n --image-tokens <n> Reserva estimada por imagen (4096, máximo 2 imágenes)\n --model-timeout-ms <n> Tiempo total de cada respuesta OpenAI (600000)\n --model-idle-timeout-ms <n> Tiempo sin progreso de tokens del modelo (180000)\n --system-prompt <texto> Instrucciones adicionales\n --state-dir <ruta> Ubicación del estado; por defecto .comal/ (reutiliza .harness/ existente)\n --mcp-config <archivo> Conecta MCP stdio, Streamable HTTP o SSE de un JSON\n --trust-mcp Autoriza conexiones iniciales; cambios se revisan de nuevo\n --plugin-config <archivo> Plugins Node locales (JSON); por defecto .comal/plugins.json\n --trust-plugins Autoriza cargar código local de la config inicial\n --codemode Experimental read-only JavaScript tool composition\n --chrome-task <archivo.json> Tarea Chrome explícita; la casa coordina publicación, checks y cierre\n --goal <condición> Ejecuta un objetivo hasta verificación o límite\n --goal-max-turns <n> Máximo de turnos del objetivo (20)\n --goal-max-calls <n> Llamadas totales: agente + verificadores (80)\n --goal-max-tokens <n> Presupuesto de tokens del objetivo (100000)\n -h, --help Muestra esta ayuda\n -v, --version Muestra versión\n\nVariables (HARNESS_* sigue como alias): COMAL_PROVIDER, COMAL_BASE_URL, COMAL_MODEL,\n COMAL_CONTEXT_WINDOW, COMAL_MAX_TOKENS, COMAL_WORK_MAX_CALLS,\n COMAL_MODEL_TIMEOUT_MS, COMAL_MODEL_IDLE_TIMEOUT_MS,\n OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY (opcional para local),\n ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL (con --provider anthropic).\nLectura autorizada por defecto; escritura y Bash requieren permiso.\nEn --print sin terminal, las herramientas no autorizadas se deniegan.\nShift+Tab: default → acceptEdits → plan → auto → default.\nRemote Control: /rc [start|status|stop] · TUI account: /cloud [login|status|logout] · CLI: comal cloud login\nComandos interactivos: /help /status /tools /house /operations /sessions /resume UUID /clear\n /compact /context /state /audit /board /work resume|recover\n /model ID /permissions MODO /goal [condición] /exit\n /queue [pause|resume|clear|edit N] /rename NOMBRE /table ARCHIVO\nTUI: F1 ayuda · F2 sesiones · F4 cola · F5 o /select selección · F6 descubrir comandos/herramientas.\n / y Tab completan; ↑/↓ elige; Enter en sugerencias sólo completa. Alt+C o /copy copiar.\n Ctrl+G editor · Alt+Enter multilínea.\nMCP: /mcp · /mcp reload [archivo] · /mcp add NOMBRE {JSON} · /mcp remove NOMBRE\n /mcp reconnect NOMBRE. Archivo predeterminado: .mcp.json del workspace.\nWeb: /search CONSULTA · WebSearch/WebFetch · COMAL_SEARCH_PROVIDER, SEARXNG_URL, BRAVE_SEARCH_API_KEY.\nSubagentes: /agent [explore|general] TAREA · /agents [UUID] · Agent/AgentBatch.\nPlugins: /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERACIÓN {JSON}].\nSesiones vivas: /list-agents · /send ID_O_NOMBRE TEXTO · /inbox [accept|hold|refuse|release ID|all]\nCuenta Comal: comal cloud login|status|logout [--cloud-url ORIGEN] (separada del proveedor de modelos)\nArtifacts: comal artifacts list|inspect ID|verify ID|publish DIR --manifest JSON|release ID VERSION JOB\nLogin: comal login PROVEEDOR [--api-key-env VARIABLE] · comal auth status [PROVEEDOR] · comal logout PROVEEDOR\nModelos: /model [ID] · /models · /effort [NIVEL] · /capabilities [refresh] · /login PROVEEDOR · /logout PROVEEDOR\nProveedores: /providers · /provider PERFIL · ProviderSearch/AnalyzeMedia según capacidades.\nCLI: comal peers | providers | message DESTINO TEXTO (usa --listen para recibir en CLI).\n/goal status|pause|resume|clear controla el objetivo; Ctrl+C lo pausa.\nSkills locales: .comal/skills/<nombre>/SKILL.md → /<nombre> [tarea]\n\n --lang <en|es|auto> Idioma de interfaz (predeterminado: en)\n\n --no-codemode Desactiva CodeRun para este proceso\n"
|
|
112
113
|
});
|
package/src/onboarding.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function normalizeFile(value) {
|
|
|
49
49
|
if (!Object.keys(selected).length && !Object.keys(limits).length) throw Error('Invalid saved Comal connection.');
|
|
50
50
|
return { selection: Object.keys(selected).length ? normalizeSelection(selected) : null, limits };
|
|
51
51
|
}
|
|
52
|
-
function normalizeConnection(value) {
|
|
52
|
+
export function normalizeConnection(value) {
|
|
53
53
|
if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !SELECTION_KEYS.includes(key))) throw Error('Invalid saved Comal connection.');
|
|
54
54
|
return normalizeSelection(value);
|
|
55
55
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, rename, unlink, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { normalizeConnection } from './onboarding.mjs';
|
|
6
|
+
import { AUTH_PROVIDERS } from './provider-auth.mjs';
|
|
7
|
+
|
|
8
|
+
// The last connection used, per project and overall. Comal writes it on its own
|
|
9
|
+
// whenever an interactive session connects or changes provider or model; a new
|
|
10
|
+
// session opens with the project's last one, else the last one used anywhere.
|
|
11
|
+
// Startup flags, environment variables and named profiles still win. Holds the
|
|
12
|
+
// same routing data as connection.json (login name or endpoint, and model),
|
|
13
|
+
// never a key.
|
|
14
|
+
export const RECENT_PROJECTS_MAX = 100;
|
|
15
|
+
const MAX_BYTES = 65536, VERSION = 1;
|
|
16
|
+
export const recentConnectionsFile = (env = process.env) => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'recent-connections.json');
|
|
17
|
+
|
|
18
|
+
const valid = selection => { try { return normalizeConnection(selection); } catch { return null; } };
|
|
19
|
+
const projectKey = async cwd => { try { return await realpath(cwd); } catch { return path.resolve(cwd); } };
|
|
20
|
+
|
|
21
|
+
async function read(env) {
|
|
22
|
+
try {
|
|
23
|
+
const text = await readFile(recentConnectionsFile(env), 'utf8');
|
|
24
|
+
if (Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
25
|
+
const data = JSON.parse(text);
|
|
26
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || data.version !== VERSION) return null;
|
|
27
|
+
const projects = {};
|
|
28
|
+
if (data.projects && typeof data.projects === 'object' && !Array.isArray(data.projects))
|
|
29
|
+
for (const [dir, entry] of Object.entries(data.projects)) {
|
|
30
|
+
const selection = valid(entry?.selection);
|
|
31
|
+
if (selection && path.isAbsolute(dir) && Number.isSafeInteger(entry.at)) projects[dir] = { selection, at: entry.at };
|
|
32
|
+
}
|
|
33
|
+
return { global: valid(data.global), projects };
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The last connection of this folder or the nearest folder above it that has one (a subfolder of a project counts as
|
|
39
|
+
* the project), else the last one used anywhere, else null. A damaged file reads as nothing.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadRecentConnection({ cwd = process.cwd(), env = process.env } = {}) {
|
|
42
|
+
const data = await read(env);
|
|
43
|
+
if (!data) return null;
|
|
44
|
+
for (let dir = await projectKey(cwd); ; dir = path.dirname(dir)) {
|
|
45
|
+
if (data.projects[dir]) return data.projects[dir].selection;
|
|
46
|
+
if (path.dirname(dir) === dir) return data.global || null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remembers a connection for this project and as the last one used. Throws on a selection connection.json would refuse. */
|
|
51
|
+
export async function rememberConnection(selection, { cwd = process.cwd(), env = process.env, now = Date.now() } = {}) {
|
|
52
|
+
const clean = normalizeConnection(selection);
|
|
53
|
+
const data = (await read(env)) || { global: null, projects: {} };
|
|
54
|
+
const key = await projectKey(cwd);
|
|
55
|
+
delete data.projects[key];
|
|
56
|
+
data.projects[key] = { selection: clean, at: now };
|
|
57
|
+
// Newest first; within the same instant, the one written last counts as newer. The file keeps them oldest first.
|
|
58
|
+
const kept = Object.entries(data.projects).reverse().sort((a, b) => b[1].at - a[1].at).slice(0, RECENT_PROJECTS_MAX);
|
|
59
|
+
// Past the size cap the oldest folders go first; this folder's own entry and the global one always stay.
|
|
60
|
+
const encode = () => JSON.stringify({ version: VERSION, global: clean, projects: Object.fromEntries([...kept].reverse()) }) + '\n';
|
|
61
|
+
let text = encode();
|
|
62
|
+
while (Buffer.byteLength(text) > MAX_BYTES && kept.length > 1) { kept.pop(); text = encode(); }
|
|
63
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw Error('Recent connections exceed 64 KiB.');
|
|
64
|
+
const file = recentConnectionsFile(env), temporary = file + '.' + randomUUID();
|
|
65
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
66
|
+
try { await writeFile(temporary, text, { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
|
|
67
|
+
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The routing data worth remembering for an active connection: a subscription login with its model, or an
|
|
72
|
+
* OpenAI-compatible endpoint with its model. A named profile is chosen by defaultProfile, and a provider that only
|
|
73
|
+
* reads a key from the environment is chosen by that environment: neither is remembered (null).
|
|
74
|
+
*/
|
|
75
|
+
export function recentSelection(config = {}, provider = {}) {
|
|
76
|
+
const model = typeof provider.model === 'string' && provider.model.trim() ? { model: provider.model } : {};
|
|
77
|
+
if (config.namedProfile) return null;
|
|
78
|
+
const login = config.authProvider || provider.authProvider;
|
|
79
|
+
if (login) return AUTH_PROVIDERS.some(item => item.id === login) ? valid({ 'auth-provider': login, ...model }) : null;
|
|
80
|
+
if (config.provider === 'openai' && provider.baseURL) return valid({ provider: 'openai', 'base-url': String(provider.baseURL), ...model });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
package/src/remote-control.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { CloudClient } from './cloud-client.mjs';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { PERMISSION_ACK_STATES, PERMISSION_OUTCOMES } from './remote-permissions.mjs';
|
|
5
5
|
import { fitStats, STATS_FLOOR_MS, STATS_INTERVAL_MS, STATS_MAX_BYTES } from './remote-stats.mjs';
|
|
6
|
+
import { failurePhrase, speechFrames, SpeechPacer, SPEECH_MAX_BYTES, SPEECH_MAX_BUFFERED, SPEECH_QUIET_AFTER_DELTA_MS, SPEECH_WINDOW_CEILING, SPEECH_WINDOW_MS, turnPhrase } from './remote-speech.mjs';
|
|
6
7
|
|
|
7
8
|
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
9
|
export const remoteInputDigest = (session, request, text) => digest([session,request,text]);
|
|
@@ -63,6 +64,8 @@ export const CARD_RESEND_MS = 10000;
|
|
|
63
64
|
// After an arm, a kept card goes once the pulse landed and again this long after: past the relay's 2 s arm cache.
|
|
64
65
|
export const CARD_ARM_RECHECK_MS = 2500;
|
|
65
66
|
const CARD_CLOSES_KEPT = 32;
|
|
67
|
+
// What a phrase may be about (the contract's speech.schema.properties.about.enum).
|
|
68
|
+
const SPEECH_ABOUT = new Set(['permission','failure','turn']);
|
|
66
69
|
|
|
67
70
|
// Código de emparejamiento del M5Stack: Crockford en mayúsculas, XXXX-XXXX. O→0, I/L→1.
|
|
68
71
|
const DEVICE_CODE=/^[0-9A-HJKMNP-TV-Z]{8}$/;
|
|
@@ -79,10 +82,13 @@ let quickReplyList=null;
|
|
|
79
82
|
/** The three fixed replies come from the device contract, read once. */
|
|
80
83
|
function quickReplies(){return quickReplyList??=JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json',import.meta.url),'utf8')).inputs.quick_reply.properties.text.enum;}
|
|
81
84
|
export class RemoteHost {
|
|
82
|
-
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,onDeviceConnected=null,onPermissionsDisarmed=null,onPermission=null,getStats=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
83
|
-
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,onDeviceConnected,onPermissionsDisarmed,onPermission,getStats,now,socketFactory});this.cards=new Map();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
85
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,onDeviceConnected=null,onPermissionsDisarmed=null,onPermission=null,getStats=null,speech=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
86
|
+
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,onDeviceConnected,onPermissionsDisarmed,onPermission,getStats,speech,now,socketFactory});this.cards=new Map();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
87
|
+
// Granito's voice: frames sent over the host socket in the last SPEECH_WINDOW_MS, the last delta, the render in course.
|
|
88
|
+
this.sentFrames=[];this.lastDeltaAt=-Infinity;this.speechRender=null;
|
|
89
|
+
this.speechPacer=new SpeechPacer({send:frame=>this.speechAvailable()&&this.cardFrame(frame),room:()=>this.speechRoom(),now:this.now});
|
|
84
90
|
}
|
|
85
|
-
status(){const run=this.lifecycle;return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only',relay:run?.relay?(run.answers?'answers':'read_only'):'off',...(run?.answers?{relay_answers:{armed:this.armed(),until:this.armed()?this.answersUntil:null}}:{}),...(run?.device?{device:this.deviceInfo??null,permission_answers:{armed:this.permissionsArmed(),until:this.permissionsArmed()?this.permissionUntil:null}}:{})}:{enabled:false};}
|
|
91
|
+
status(){const run=this.lifecycle;return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only',relay:run?.relay?(run.answers?'answers':'read_only'):'off',...(run?.answers?{relay_answers:{armed:this.armed(),until:this.armed()?this.answersUntil:null}}:{}),...(run?.device?{device:this.deviceInfo??null,permission_answers:{armed:this.permissionsArmed(),until:this.permissionsArmed()?this.permissionUntil:null},speech:this.speechStatus()}:{})}:{enabled:false};}
|
|
86
92
|
deviceAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.device);}
|
|
87
93
|
permissionsArmed(){return this.deviceAvailable()&&this.permissionUntil>this.now()&&Boolean(this.deviceInfo)&&this.deviceInfo.id===this.permissionDevice;}
|
|
88
94
|
/** The arm belongs to the device it was granted for: when that device is gone or another takes its place, it ends and the book hears why. */
|
|
@@ -139,7 +145,43 @@ export class RemoteHost {
|
|
|
139
145
|
/** One frame over the host WebSocket, only when it is the live transport. Never over remote.events.publish. */
|
|
140
146
|
cardFrame(frame){
|
|
141
147
|
if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
|
|
142
|
-
try{this.socket.send(JSON.stringify({protocol:'comal.remote.v1',...frame}));
|
|
148
|
+
try{this.socket.send(JSON.stringify({protocol:'comal.remote.v1',...frame}));}catch{return false;}
|
|
149
|
+
this.noteFrame(frame.kind);return true;
|
|
150
|
+
}
|
|
151
|
+
/** Counts a frame sent over the host socket, for the relay's window; a delta also restarts the quiet speech waits for. */
|
|
152
|
+
noteFrame(kind){
|
|
153
|
+
const now=this.now();
|
|
154
|
+
this.sentFrames=this.sentFrames.filter(at=>now-at<SPEECH_WINDOW_MS);this.sentFrames.push(now);
|
|
155
|
+
if(kind==='delta')this.lastDeltaAt=now;
|
|
156
|
+
}
|
|
157
|
+
/** May a speech chunk go now without pushing the host past the relay's budget? */
|
|
158
|
+
speechRoom(){
|
|
159
|
+
if(this.transport!=='websocket'||this.socket?.readyState!==1)return false;
|
|
160
|
+
const now=this.now();
|
|
161
|
+
if(now-this.lastDeltaAt<SPEECH_QUIET_AFTER_DELTA_MS)return false;
|
|
162
|
+
this.sentFrames=this.sentFrames.filter(at=>now-at<SPEECH_WINDOW_MS);
|
|
163
|
+
if(this.sentFrames.length>=SPEECH_WINDOW_CEILING)return false;
|
|
164
|
+
return (this.socket.bufferedAmount??0)<SPEECH_MAX_BUFFERED;
|
|
165
|
+
}
|
|
166
|
+
/** Speech goes only where it cannot hurt: speech_v1 granted, a relay that said it accepts speech (an older one closes
|
|
167
|
+
* the host socket on an unknown frame), the WebSocket transport, a paired device and a Piper voice. */
|
|
168
|
+
speechAvailable(){const run=this.lifecycle;return Boolean(this.current(run)&&run.speech&&run.speechRelay&&this.transport==='websocket'&&this.deviceInfo&&this.speech?.ready===true);}
|
|
169
|
+
speechStatus(){const run=this.lifecycle;if(this.speech?.ready!==true)return 'no_piper';if(!(run?.speech&&run?.speechRelay))return 'not_offered';return 'ready';}
|
|
170
|
+
/** Speaks a phrase on the paired device. Synchronous and fire-and-forget: rendering and sending never hold a turn or
|
|
171
|
+
* the permission flow, and every failure is swallowed. A newer phrase replaces the one rendering or playing. */
|
|
172
|
+
sendSpeech(text,about){
|
|
173
|
+
try{
|
|
174
|
+
if(typeof text!=='string'||!text.trim()||!SPEECH_ABOUT.has(about)||!this.speechAvailable())return false;
|
|
175
|
+
this.speechRender?.abort();
|
|
176
|
+
const controller=new AbortController(),run=this.lifecycle;this.speechRender=controller;
|
|
177
|
+
Promise.resolve(this.speech.render(text,{signal:controller.signal})).then(pcm8=>{
|
|
178
|
+
if(this.speechRender!==controller||controller.signal.aborted||!this.current(run))return;
|
|
179
|
+
this.speechRender=null;
|
|
180
|
+
if(!Buffer.isBuffer(pcm8)||!pcm8.length||!this.speechAvailable())return;
|
|
181
|
+
this.speechPacer.play({frames:speechFrames({id:randomBytes(8).toString('hex'),about,pcm8:pcm8.subarray(0,SPEECH_MAX_BYTES)})});
|
|
182
|
+
}).catch(()=>{});
|
|
183
|
+
return true;
|
|
184
|
+
}catch{return false;}
|
|
143
185
|
}
|
|
144
186
|
/** Permission card for the paired device (Task 10's buildDeviceCard). Nothing is kept or sent while not armed. */
|
|
145
187
|
sendCard(card){
|
|
@@ -196,13 +238,14 @@ export class RemoteHost {
|
|
|
196
238
|
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
197
239
|
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
198
240
|
const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
|
|
199
|
-
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[]),'device_v1','stats_v1'];
|
|
241
|
+
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[]),'device_v1','stats_v1','speech_v1'];
|
|
200
242
|
const open=list=>this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:list},{expectedAccount:run.account,signal:run.controller.signal});
|
|
201
243
|
// Un backend anterior rechaza la capacidad desconocida en el esquema de Milpa (no se crea
|
|
202
244
|
// sesión): se reintenta sin relay_answers_v1 (backend de la fase «ver») y después sin
|
|
203
245
|
// relay_v1 (backend anterior a /relay). /rc se abre igual con lo que el backend admite.
|
|
204
|
-
|
|
205
|
-
const
|
|
246
|
+
// speech_v1 goes first (a backend before Granito's voice), then the device features with it.
|
|
247
|
+
const noDevice=features.filter(item=>item!=='device_v1'&&item!=='stats_v1'&&item!=='speech_v1');
|
|
248
|
+
const attempts=[features,features.filter(item=>item!=='speech_v1'),noDevice,noDevice.filter(item=>item!=='relay_answers_v1'),noDevice.filter(item=>!item.startsWith('relay_'))].filter((list,index,all)=>index===0||list.length<all[index-1].length);
|
|
206
249
|
let opened,used=attempts[0];
|
|
207
250
|
for(const [index,list] of attempts.entries()){
|
|
208
251
|
try{used=list;opened=await open(list);break;}
|
|
@@ -211,7 +254,7 @@ export class RemoteHost {
|
|
|
211
254
|
run.relayRefused=Boolean(this.relay)&&!used.includes('relay_v1');
|
|
212
255
|
run.session=opened.session;
|
|
213
256
|
const granted=feature=>used.includes(feature)&&(!Array.isArray(opened.session?.features)||opened.session.features.includes(feature));
|
|
214
|
-
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;run.device=granted('device_v1');run.stats=Boolean(this.getStats)&&granted('stats_v1');this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;
|
|
257
|
+
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;run.device=granted('device_v1');run.speech=run.device&&granted('speech_v1');run.speechRelay=false;run.stats=Boolean(this.getStats)&&granted('stats_v1');this.permissionUntil=0;this.permissionDevice=null;this.deviceInfo=null;
|
|
215
258
|
// Abort can race a successful response. Revoke a late-created grant even
|
|
216
259
|
// when the local stop completed before its identifier became available.
|
|
217
260
|
if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
|
|
@@ -262,9 +305,9 @@ export class RemoteHost {
|
|
|
262
305
|
const ws=this.socket=this.socketFactory(url.href,'comal.remote.v1');
|
|
263
306
|
const timeout=this.socketTimeout=setTimeout(()=>ws.close(),10000);timeout.unref();
|
|
264
307
|
ws.addEventListener('open',()=>{if(!this.current(run)||this.socket!==ws){ws.close();return;}ws.send(JSON.stringify({protocol:'comal.remote.v1',kind:'auth',ticket:ticket.ticket,after:'0'}));});
|
|
265
|
-
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);this.transport='websocket';this.flushCardCloses();this.sendCards();}else if(m.kind==='state'&&run.device)this.noteSession(m.session);}catch{ws.close();}});
|
|
308
|
+
ws.addEventListener('message',event=>{if(!this.current(run)||this.socket!==ws)return;try{const m=JSON.parse(event.data);if(m.kind==='ready'){clearTimeout(timeout);run.speechRelay=Array.isArray(m.accepts)&&m.accepts.includes('speech');this.transport='websocket';this.flushCardCloses();this.sendCards();}else if(m.kind==='state'&&run.device)this.noteSession(m.session);}catch{ws.close();}});
|
|
266
309
|
ws.addEventListener('error',()=>{});
|
|
267
|
-
ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';}});
|
|
310
|
+
ws.addEventListener('close',()=>{clearTimeout(timeout);if(this.current(run)&&this.socket===ws){this.socket=null;this.transport='https-polling';run.speechRelay=false;this.speechPacer.cancel();}});
|
|
268
311
|
}catch{/* HTTPS is already active. A later tick retries WSS. */}
|
|
269
312
|
finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
|
|
270
313
|
}
|
|
@@ -358,6 +401,8 @@ export class RemoteHost {
|
|
|
358
401
|
}
|
|
359
402
|
observe(event) {
|
|
360
403
|
if(this.stopped)return;
|
|
404
|
+
if(event.type==='tool_result'&&event.is_error===true&&!String(event.content??'').includes('HOUSE_REFUSED'))try{this.sendSpeech(failurePhrase(event.name),'failure');}catch{}
|
|
405
|
+
if(event.type==='result'&&event.turns>0&&typeof event.result==='string')try{const phrase=turnPhrase(event.result);if(phrase)this.sendSpeech(phrase,'turn');}catch{}
|
|
361
406
|
if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
|
|
362
407
|
const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
|
|
363
408
|
if(['result','context_projection','usage_accounting'].includes(event.type))this.publishStats().catch(()=>{});
|
|
@@ -373,7 +418,7 @@ export class RemoteHost {
|
|
|
373
418
|
if(event.type==='text_delta'){
|
|
374
419
|
if(this.transport==='websocket'&&this.socket?.readyState===1&&this.turnId){
|
|
375
420
|
this.delta=boundText((this.delta || '')+event.text,32000);
|
|
376
|
-
if(!this.deltaTimer){const run=this.lifecycle;this.deltaTimer=setTimeout(()=>{if(!this.current(run))return;this.deltaTimer=null;const text=this.delta;this.delta='';if(this.socket?.readyState===1)for(let offset=0;offset<text.length;offset+=1800)this.socket.send(JSON.stringify({protocol:'comal.remote.v1',kind:'delta',turn_id:this.turnId,text:text.slice(offset,offset+1800)}));},120);}
|
|
421
|
+
if(!this.deltaTimer){const run=this.lifecycle;this.deltaTimer=setTimeout(()=>{if(!this.current(run))return;this.deltaTimer=null;const text=this.delta;this.delta='';if(this.socket?.readyState===1)for(let offset=0;offset<text.length;offset+=1800){this.socket.send(JSON.stringify({protocol:'comal.remote.v1',kind:'delta',turn_id:this.turnId,text:text.slice(offset,offset+1800)}));this.noteFrame('delta');}},120);}
|
|
377
422
|
}
|
|
378
423
|
return;
|
|
379
424
|
}
|
|
@@ -465,7 +510,7 @@ export class RemoteHost {
|
|
|
465
510
|
}
|
|
466
511
|
async stop(){
|
|
467
512
|
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
468
|
-
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);clearTimeout(run.statsTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
513
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();this.speechRender?.abort();this.speechRender=null;this.speechPacer.cancel();this.sentFrames=[];clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);clearTimeout(run.statsTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
469
514
|
this.stopped=true;this.answersUntil=0;this.permissionUntil=0;this.permissionDevice=null;this.cards.clear();this.cardCloses=[];this.cardsSentAt=0;this.cardsShown=false;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
470
515
|
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
471
516
|
await this.revoke(run);return this.status();
|
package/src/remote-device.mjs
CHANGED
|
@@ -10,6 +10,9 @@ registerMessages({
|
|
|
10
10
|
'The camera read the code {code}. Pair only if it is the code on the M5Stack\'s screen.': 'La cámara leyó el código {code}. Empareja sólo si es el código de la pantalla del M5Stack.',
|
|
11
11
|
'Pair {code}': 'Emparejar {code}',
|
|
12
12
|
'Do not pair': 'No emparejar',
|
|
13
|
+
'voice: ready': 'voz: lista',
|
|
14
|
+
'voice: no Piper (COMAL_PIPER_VOICE)': 'voz: sin Piper (COMAL_PIPER_VOICE)',
|
|
15
|
+
'voice: not available on this connection': 'voz: no disponible en esta conexión',
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
// Forward the House's permission rows to the Remote Control projection.
|
|
@@ -44,7 +47,9 @@ export async function runRcDevice(parsed, host, t, { record } = {}) {
|
|
|
44
47
|
if (!host || host.stopped || !host.deviceAvailable()) throw Error(t('Open /rc first, with a Comal backend that supports devices.'));
|
|
45
48
|
if (parsed.kind === 'status') {
|
|
46
49
|
const device = host.status().device;
|
|
47
|
-
|
|
50
|
+
const line = device ? t('Device: {label} ({id}){connected}.', { label: device.label, id: device.id, connected: device.connected_at ? t(', connected {time}', { time: String(device.connected_at) }) : '' }) : t('No device paired.');
|
|
51
|
+
const voice = { ready: t('voice: ready'), no_piper: t('voice: no Piper (COMAL_PIPER_VOICE)') }[host.speechStatus()] ?? t('voice: not available on this connection');
|
|
52
|
+
return line + '\n' + voice;
|
|
48
53
|
}
|
|
49
54
|
if (parsed.kind === 'forget') { const wasArmed = host.permissionsArmed(); await host.forgetDevice(); if (wasArmed) await record?.(ARM_ROW(false, 0)); return t('Device removed; its permission answers are off.'); }
|
|
50
55
|
const paired = await host.pairDevice(parsed.code);
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Granito's voice, host side: the short phrases a paired device speaks, and their conversion to the
|
|
2
|
+
// device's PCM format. Pure module: the only I/O is the one read of the contract.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
5
|
+
import { stripForDevice } from './remote-device-card.mjs';
|
|
6
|
+
|
|
7
|
+
const SPEECH = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8')).speech;
|
|
8
|
+
export const SPEECH_RATE = SPEECH.format.sample_rate;
|
|
9
|
+
export const SPEECH_CHUNK_BYTES = SPEECH.format.chunk_max_bytes;
|
|
10
|
+
export const SPEECH_MAX_BYTES = SPEECH_RATE * SPEECH.format.phrase_max_seconds;
|
|
11
|
+
// About 6 s of the voice (Piper speaks ~14-16 characters a second): a longer sentence would be cut mid-word.
|
|
12
|
+
export const TURN_PHRASE_CHARS = 90;
|
|
13
|
+
const TOOL_NAME_CHARS = 40;
|
|
14
|
+
const BASENAME_CHARS = 60;
|
|
15
|
+
|
|
16
|
+
const chars = text => Array.from(text);
|
|
17
|
+
const collapse = text => text.replace(/\s+/g, ' ').trim();
|
|
18
|
+
|
|
19
|
+
/** Cut to `limit` code points, ending on a whole word when one fits. */
|
|
20
|
+
function capWords(text, limit) {
|
|
21
|
+
const all = chars(text);
|
|
22
|
+
if (all.length <= limit) return text;
|
|
23
|
+
if (all[limit] === ' ') return all.slice(0, limit).join('').trimEnd();
|
|
24
|
+
const space = all.slice(0, limit).lastIndexOf(' ');
|
|
25
|
+
return all.slice(0, space > 0 ? space : limit).join('').trimEnd();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function speakableToolName(name) {
|
|
29
|
+
let text = stripForDevice(name).replace(/^mcp__.+?__/, '');
|
|
30
|
+
text = collapse(text.replace(/_/g, ' '));
|
|
31
|
+
return capWords(text, TOOL_NAME_CHARS);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fileLabel(path) {
|
|
35
|
+
if (typeof path !== 'string') return '';
|
|
36
|
+
const base = path.split(/[\\/]/).filter(Boolean).pop() ?? '';
|
|
37
|
+
return chars(collapse(stripForDevice(base))).slice(0, BASENAME_CHARS).join('').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function permissionPhrase({ name, input, terminalOnly = false }) {
|
|
41
|
+
let what;
|
|
42
|
+
if (name === 'Write' || name === 'Edit') {
|
|
43
|
+
what = 'escribir ' + ((!terminalOnly && fileLabel(input?.file_path)) || 'un archivo');
|
|
44
|
+
} else if (name === 'Bash') what = 'correr un comando';
|
|
45
|
+
else what = 'usar ' + (speakableToolName(name) || 'una herramienta');
|
|
46
|
+
return `Necesito tu permiso para ${what}.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The permission phrase as spoken: the file is named only where the device itself may show it (armed, not plan
|
|
50
|
+
* mode, not confirm-always); otherwise the name never leaves the terminal. */
|
|
51
|
+
export function permissionSpeech({ name, input, armed = false, plan = false, confirmAlways = false }) {
|
|
52
|
+
return permissionPhrase({ name, input, terminalOnly: armed !== true || plan === true || confirmAlways === true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function failurePhrase(name) {
|
|
56
|
+
return `Falló ${speakableToolName(name) || 'una herramienta'}.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HTML_ONLY = /^\s*(?:<\/?[A-Za-z][^>]*>\s*)+$/;
|
|
60
|
+
const BOX_DRAWING = /[─-╿]/;
|
|
61
|
+
const URL_RE = /\b(?:https?|ftp):\/\/\S+|\bwww\.\S+/g;
|
|
62
|
+
const WRAP = '()[\\]"\'«»,;:';
|
|
63
|
+
const PATH_TOKEN = new RegExp(`^([${WRAP}]*)(.*?)([${WRAP}]*)$`, 'u');
|
|
64
|
+
|
|
65
|
+
function withoutFences(text) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let fence = null;
|
|
68
|
+
for (const line of text.split('\n')) {
|
|
69
|
+
const open = /^\s*(```|~~~)/.exec(line);
|
|
70
|
+
if (fence) { if (open && open[1] === fence) fence = null; continue; }
|
|
71
|
+
if (open) { fence = open[1]; continue; }
|
|
72
|
+
out.push(line);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function reducePath(token) {
|
|
78
|
+
const m = PATH_TOKEN.exec(token);
|
|
79
|
+
const [, pre, core, post] = m;
|
|
80
|
+
if (!core.includes('/') || /^(?:https?|ftp):/i.test(core) || /^www\./i.test(core)) return token;
|
|
81
|
+
const segments = core.split('/').filter(Boolean);
|
|
82
|
+
if (!segments.length) return token;
|
|
83
|
+
const last = segments[segments.length - 1];
|
|
84
|
+
const pathLike = /^(?:\/|~\/|\.\/|\.\.\/)/.test(core) || core.split('/').length - 1 >= 2 || last.includes('.');
|
|
85
|
+
return pathLike ? pre + last + post : token;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function turnPhrase(text) {
|
|
89
|
+
let lines = withoutFences(String(text ?? '').replace(/\r\n?/g, '\n'))
|
|
90
|
+
.filter(l => !/^\s{0,3}#{1,6}\s/.test(l) && !/^\s*\|/.test(l) && !HTML_ONLY.test(l) && !BOX_DRAWING.test(l))
|
|
91
|
+
.map(l => l.replace(/^\s*(?:(?:[-*+]|\d+[.)]|>)\s+)+/, ''));
|
|
92
|
+
lines = lines.map(line => line
|
|
93
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
94
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
95
|
+
.replace(/`([^`]*)`/g, (_, code) => (!/\s/.test(code) && chars(code).length <= 60 ? code : ''))
|
|
96
|
+
.replace(URL_RE, '')
|
|
97
|
+
.replace(/\*\*|__|~~/g, '')
|
|
98
|
+
.replace(/(^|[\s(])([*_])(\S[^*_]*?)\2(?=[\s).,;:!?]|$)/g, '$1$3')
|
|
99
|
+
.replace(/<\/?[A-Za-z][^>]*>/g, '')
|
|
100
|
+
.replace(/\p{Extended_Pictographic}|️/gu, ''))
|
|
101
|
+
.map(line => stripForDevice(line).replace(/[ \t]+/g, ' ').split(' ').map(reducePath).join(' '));
|
|
102
|
+
const first = lines.find(l => l.trim());
|
|
103
|
+
if (first === undefined) return null;
|
|
104
|
+
const sentence = /^(.*?[.!?…])(?=\s|$)/.exec(first.trim());
|
|
105
|
+
let out = collapse(sentence ? sentence[1] : first).replace(/^[:;,\-–— ]+/, '');
|
|
106
|
+
if (!out) return null;
|
|
107
|
+
const all = chars(out);
|
|
108
|
+
if (all.length > TURN_PHRASE_CHARS) {
|
|
109
|
+
const space = all.lastIndexOf(' ', TURN_PHRASE_CHARS - 1);
|
|
110
|
+
out = all.slice(0, space > 0 ? space : TURN_PHRASE_CHARS - 1).join('').trimEnd() + '…';
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 16-bit little-endian PCM at `rate` Hz to the device's 8-bit unsigned mono at 11025 Hz. */
|
|
116
|
+
export function toDevicePcm(pcm16le, rate) {
|
|
117
|
+
const samples = Math.floor(pcm16le.length / 2);
|
|
118
|
+
const ratio = rate / SPEECH_RATE;
|
|
119
|
+
const outLen = Math.min(Math.floor(samples / ratio), SPEECH_MAX_BYTES);
|
|
120
|
+
const out = Buffer.alloc(outLen);
|
|
121
|
+
for (let i = 0; i < outLen; i++) {
|
|
122
|
+
const start = Math.floor(i * ratio);
|
|
123
|
+
const end = Math.min(samples, Math.max(start + 1, Math.floor((i + 1) * ratio)));
|
|
124
|
+
let sum = 0;
|
|
125
|
+
for (let j = start; j < end; j++) sum += pcm16le.readInt16LE(j * 2);
|
|
126
|
+
out[i] = Math.min(255, Math.max(0, Math.round(sum / (end - start) / 256) + 128));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function speechChunks(pcm8, max = SPEECH_CHUNK_BYTES) {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (let at = 0; at < pcm8.length; at += max) out.push(pcm8.subarray(at, at + max));
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function speechFrames({ id, about, pcm8 }) {
|
|
138
|
+
const chunks = speechChunks(pcm8);
|
|
139
|
+
return chunks.map((chunk, index) => ({ kind: 'speech', id, seq: String(index), last: index === chunks.length - 1, about, audio: chunk.toString('base64') }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const PIPER_TIMEOUT_MS = 8000;
|
|
143
|
+
const DEFAULT_VOICE_RATE = 22050;
|
|
144
|
+
|
|
145
|
+
/** The sample rate Piper writes for `voice`, from its `.onnx.json`; 22050 when unreadable. */
|
|
146
|
+
export function voiceRate(voice, { readFile = readFileSync } = {}) {
|
|
147
|
+
try {
|
|
148
|
+
const rate = JSON.parse(readFile(voice + '.json')).audio.sample_rate;
|
|
149
|
+
return Number.isInteger(rate) && rate >= 8000 && rate <= 48000 ? rate : DEFAULT_VOICE_RATE;
|
|
150
|
+
} catch { return DEFAULT_VOICE_RATE; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Renders a phrase with a local Piper process. Never rejects; null means "no voice this time". */
|
|
154
|
+
export class PiperSpeech {
|
|
155
|
+
#piper; #voice; #spawn; #readFile; #timeoutMs; #rate = null;
|
|
156
|
+
|
|
157
|
+
constructor({ piper = 'piper', voice = null, spawn = nodeSpawn, readFile = readFileSync, timeoutMs = PIPER_TIMEOUT_MS } = {}) {
|
|
158
|
+
this.#piper = piper; this.#voice = voice; this.#spawn = spawn; this.#readFile = readFile; this.#timeoutMs = timeoutMs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get ready() { return typeof this.#voice === 'string' && this.#voice !== ''; }
|
|
162
|
+
|
|
163
|
+
async render(text, { signal } = {}) {
|
|
164
|
+
try {
|
|
165
|
+
const line = typeof text === 'string' ? collapse(text) : '';
|
|
166
|
+
if (!this.ready || !line || signal?.aborted) return null;
|
|
167
|
+
this.#rate ??= voiceRate(this.#voice, { readFile: this.#readFile });
|
|
168
|
+
return await this.#run(line, this.#rate, signal);
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#run(line, rate, signal) {
|
|
173
|
+
return new Promise(resolve => {
|
|
174
|
+
const cap = rate * 2 * 7;
|
|
175
|
+
const parts = [];
|
|
176
|
+
let size = 0, capped = false, timer = null, child = null, done = false;
|
|
177
|
+
const onAbort = () => { kill(); finish(null); };
|
|
178
|
+
const kill = () => { try { child?.kill('SIGKILL'); } catch { /* already gone */ } };
|
|
179
|
+
const finish = value => {
|
|
180
|
+
if (done) return;
|
|
181
|
+
done = true;
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
signal?.removeEventListener('abort', onAbort);
|
|
184
|
+
child?.stdout?.removeAllListeners('data');
|
|
185
|
+
child?.removeAllListeners('close');
|
|
186
|
+
child?.removeAllListeners('error');
|
|
187
|
+
resolve(value);
|
|
188
|
+
};
|
|
189
|
+
const pcm = () => {
|
|
190
|
+
const out = toDevicePcm(Buffer.concat(parts), rate);
|
|
191
|
+
return out.length ? out : null;
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
child = this.#spawn(this.#piper, ['--model', this.#voice, '--output-raw'], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true });
|
|
195
|
+
child.stdin.on('error', () => {});
|
|
196
|
+
child.stdout.on('data', chunk => {
|
|
197
|
+
if (capped) return;
|
|
198
|
+
const room = cap - size;
|
|
199
|
+
if (chunk.length >= room) {
|
|
200
|
+
parts.push(chunk.subarray(0, room)); size = cap; capped = true;
|
|
201
|
+
kill();
|
|
202
|
+
} else { parts.push(chunk); size += chunk.length; }
|
|
203
|
+
});
|
|
204
|
+
child.on('error', () => finish(null));
|
|
205
|
+
child.on('close', code => finish(capped || code === 0 ? pcm() : null));
|
|
206
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
207
|
+
timer = setTimeout(() => { kill(); finish(null); }, this.#timeoutMs);
|
|
208
|
+
child.stdin.write(line + '\n');
|
|
209
|
+
child.stdin.end();
|
|
210
|
+
} catch { kill(); finish(null); }
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function speechFromEnv(env = process.env, deps = {}) {
|
|
216
|
+
const set = value => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
217
|
+
return new PiperSpeech({ ...deps, piper: set(env.COMAL_PIPER) ?? 'piper', voice: set(env.COMAL_PIPER_VOICE) });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Pacing of speech frames over the host socket. The relay allows a host socket 100 frames / 10 s and 16 pending, and
|
|
221
|
+
// silently drops a speech frame past 70 frames in its window or 4 pending: one chunk every 300 ms at most, only once
|
|
222
|
+
// deltas have been quiet for a second, below 70 frames in the last 10 s and with little left in the send buffer.
|
|
223
|
+
export const SPEECH_CHUNK_INTERVAL_MS = 300;
|
|
224
|
+
export const SPEECH_QUIET_AFTER_DELTA_MS = 1000;
|
|
225
|
+
export const SPEECH_WINDOW_MS = 10000;
|
|
226
|
+
export const SPEECH_WINDOW_CEILING = 70;
|
|
227
|
+
export const SPEECH_MAX_BUFFERED = 16384;
|
|
228
|
+
export const SPEECH_PHRASE_DEADLINE_MS = 15000;
|
|
229
|
+
|
|
230
|
+
/** Plays one phrase at a time, a chunk per tick while `room()` says so. A newer phrase replaces the current one; a
|
|
231
|
+
* failed send or a phrase older than its deadline is dropped, never retried. Never throws. */
|
|
232
|
+
export class SpeechPacer {
|
|
233
|
+
#send; #room; #now; #intervalMs; #deadlineMs; #setTimer; #clearTimer;
|
|
234
|
+
#frames = null; #next = 0; #startedAt = 0; #timer = null;
|
|
235
|
+
|
|
236
|
+
constructor({ send, room, now = Date.now, intervalMs = SPEECH_CHUNK_INTERVAL_MS, deadlineMs = SPEECH_PHRASE_DEADLINE_MS, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
237
|
+
this.#send = send; this.#room = room; this.#now = now; this.#intervalMs = intervalMs; this.#deadlineMs = deadlineMs;
|
|
238
|
+
this.#setTimer = setTimer; this.#clearTimer = clearTimer;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get busy() { return this.#frames !== null && this.#now() - this.#startedAt <= this.#deadlineMs; }
|
|
242
|
+
|
|
243
|
+
play({ frames }) {
|
|
244
|
+
this.cancel();
|
|
245
|
+
if (!Array.isArray(frames) || !frames.length) return;
|
|
246
|
+
this.#frames = frames; this.#next = 0; this.#startedAt = this.#now();
|
|
247
|
+
this.#schedule(0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
cancel() {
|
|
251
|
+
if (this.#timer !== null) { try { this.#clearTimer(this.#timer); } catch { /* gone */ } }
|
|
252
|
+
this.#timer = null; this.#frames = null; this.#next = 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#schedule(ms) {
|
|
256
|
+
try {
|
|
257
|
+
const timer = this.#setTimer(() => this.#tick(), ms);
|
|
258
|
+
timer?.unref?.();
|
|
259
|
+
this.#timer = timer;
|
|
260
|
+
} catch { this.cancel(); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#tick() {
|
|
264
|
+
this.#timer = null;
|
|
265
|
+
if (!this.#frames) return;
|
|
266
|
+
try {
|
|
267
|
+
if (this.#now() - this.#startedAt > this.#deadlineMs) { this.cancel(); return; }
|
|
268
|
+
if (this.#room()) {
|
|
269
|
+
if (this.#send(this.#frames[this.#next]) === false) { this.cancel(); return; }
|
|
270
|
+
if (++this.#next >= this.#frames.length) { this.cancel(); return; }
|
|
271
|
+
}
|
|
272
|
+
} catch { this.cancel(); return; }
|
|
273
|
+
this.#schedule(this.#intervalMs);
|
|
274
|
+
}
|
|
275
|
+
}
|