@milpa/comal 0.2.4 → 0.2.5

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 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.4` or `npm install --global @milpa/comal@0.2.4`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
31
+ You can pin a version with `npx @milpa/comal@0.2.5` or `npm install --global @milpa/comal@0.2.5`. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Comal: governed operations, execution receipts, and programming assistance in any language",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.mjs CHANGED
@@ -7,6 +7,7 @@ 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';
@@ -375,6 +376,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
375
376
  const interactive = !print;
376
377
  const color = (code, text) => stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
377
378
  let remoteHost;
379
+ const remoteSpeech = speechFromEnv(process.env);
378
380
  // One desk per process: a permission card answered from the paired M5Stack races the terminal card.
379
381
  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
382
  let controller, authController, modelController, scanController, runtime, peerBus, listenTimer, listenFinish, swarmFeed, relayFollower, textOpen = false, lastGoalDisplay = '';
@@ -389,6 +391,8 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
389
391
  const signals = [options.signal, controller?.signal || startupSignal].filter(Boolean), signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
390
392
  const card = options.summary === undefined ? await house.confirmSummary(name, input, { signal }) : null;
391
393
  const summary = options.summary ?? card?.summary, terminal = shown => ui.confirm(name, input, shown, { summary, edit: card?.edit, remember: options.remember });
394
+ // Granito speaks the question whether or not the device is armed to answer it; synchronous, never awaited.
395
+ try { remoteHost?.sendSpeech(permissionSpeech({ name, input, armed: remoteHost?.permissionsArmed() === true, plan: policy.mode === 'plan', confirmAlways: policy.confirmAlways(name) === true }), 'permission'); } catch {}
392
396
  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
397
  } : undefined });
394
398
  // /add-dir rows are the human's decisions in this session's book (under .comal, which the model cannot write);
@@ -620,7 +624,7 @@ async function boot(args, createScreen, setInterrupt, startupSignal, host) {
620
624
  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
625
  const openRemoteControl=async()=>{
622
626
  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});
627
+ 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
628
  const connection = new AbortController(); authController = connection;
625
629
  const cancel = () => remoteHost.stop().catch(() => {});
626
630
  connection.signal.addEventListener('abort', cancel, { once: true });
@@ -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}));return true;}catch{return false;}
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
- const noDevice=features.filter(item=>item!=='device_v1'&&item!=='stats_v1');
205
- const attempts=[features,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);
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();
@@ -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
- return 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.');
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
+ }