@milpa/comal 0.2.3 → 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.
@@ -0,0 +1,159 @@
1
+ // Permission cards answered from the paired M5Stack, racing the terminal card: the first surface
2
+ // that DECIDES wins. The desk is the single owner of "decided": a question is settled exactly once
3
+ // (terminal or device), synchronously on the event loop, and every later answer reads stale.
4
+ // A device answer is only ever "allow once" or "deny": it never saves a rule and never widens the
5
+ // policy. It is checked again here, whatever the backend and the relay already checked:
6
+ // arm on → not expired → from the armed device → the question still pending here → the card the
7
+ // person saw (digest) → a card the device may answer → no confirm:'always' → not plan → a decision.
8
+ import { readFileSync } from 'node:fs';
9
+ import { stat } from 'node:fs/promises';
10
+ import { t } from './i18n/index.mjs';
11
+ import { buildDeviceCard } from './remote-device-card.mjs';
12
+
13
+ const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8'));
14
+ const DECISIONS = new Set(CONTRACT.inputs.permission_answer.properties.decision.enum);
15
+ /** What a permission.answer ACK may say: its verdict states, and a decision or a contract refusal. */
16
+ export const PERMISSION_ACK_STATES = new Set(['applied', 'stale', 'refused', 'expired', 'rejected']);
17
+ export const PERMISSION_OUTCOMES = new Set([...DECISIONS, ...CONTRACT.refusals]);
18
+ const LEDGER_MAX = 512;
19
+ const live = value => typeof value === 'function' ? value() : value;
20
+
21
+ export class PermissionDesk {
22
+ constructor({ now = Date.now, sendCard = () => {}, closeCard = () => {}, record = async () => {} } = {}) {
23
+ Object.assign(this, { now, sendCard, closeCard, record });
24
+ this.pending = new Map();
25
+ // Same request_id (a lost ACK redelivers it) → the same answer, never a second decision.
26
+ this.ledger = new Map();
27
+ }
28
+ /** One open question. `decision` resolves once, with whichever surface settled it first. */
29
+ open({ questionId, name, input, confirmAlways = false, mode = 'default', card }) {
30
+ if (this.pending.has(questionId)) throw Error('This permission question is already open.');
31
+ const controller = new AbortController();
32
+ let resolve, reject;
33
+ const decision = new Promise((done, fail) => { resolve = done; reject = fail; });
34
+ decision.catch(() => {});
35
+ const entry = { questionId, name, input, confirmAlways, mode, card, controller, decided: false, closed: false };
36
+ const settle = (result, failed = false) => {
37
+ if (entry.decided) return false;
38
+ entry.decided = true;
39
+ this.#closeCard(entry);
40
+ if (failed) reject(result); else resolve(result);
41
+ controller.abort();
42
+ return true;
43
+ };
44
+ entry.settle = settle;
45
+ this.pending.set(questionId, entry);
46
+ try { this.sendCard(card); } catch {}
47
+ return {
48
+ signal: controller.signal,
49
+ decision,
50
+ /** The terminal card answered (or rejected): first wins, a later device answer reads stale. */
51
+ terminal: value => settle({ allowed: Boolean(value && typeof value === 'object' ? value.allowed : value), by: 'terminal', value }),
52
+ fail: error => settle(error, true),
53
+ close: () => {
54
+ if (this.pending.get(questionId) === entry) this.pending.delete(questionId);
55
+ settle(Object.assign(Error('The permission question closed.'), { name: 'AbortError' }), true);
56
+ this.#closeCard(entry);
57
+ },
58
+ };
59
+ }
60
+ #closeCard(entry) {
61
+ if (entry.closed) return;
62
+ entry.closed = true;
63
+ try { this.closeCard(entry.questionId); } catch {}
64
+ }
65
+ #remember(requestId, result) {
66
+ if (typeof requestId !== 'string' || !requestId) return result;
67
+ this.ledger.set(requestId, result);
68
+ if (this.ledger.size > LEDGER_MAX) this.ledger.delete(this.ledger.keys().next().value);
69
+ return result;
70
+ }
71
+ /** A claimed device input (kind permission.answer). Returns the ACK verdict {state, outcome}. */
72
+ async handle(input, { deviceId = null, armed = () => false } = {}) {
73
+ const requestId = input?.request_id;
74
+ if (typeof requestId === 'string' && this.ledger.has(requestId)) return this.ledger.get(requestId);
75
+ const refuse = (state, outcome) => this.#remember(requestId, { state, outcome });
76
+ const control = input?.control && typeof input.control === 'object' ? input.control : {};
77
+ let isArmed = false;
78
+ try { isArmed = armed() === true; } catch {}
79
+ if (!isArmed) return refuse('refused', 'disarmed');
80
+ const expires = Number(input?.expires_at);
81
+ if (input?.expires_at === undefined || input?.expires_at === null || !Number.isFinite(expires) || expires * 1000 <= this.now()) return refuse('expired', 'expired');
82
+ if (typeof deviceId !== 'string' || !deviceId || input?.device_id !== deviceId) return refuse('refused', 'other_device');
83
+ const entry = typeof control.question_id === 'string' ? this.pending.get(control.question_id) : undefined;
84
+ if (!entry || entry.decided) return refuse('stale', 'stale');
85
+ if (typeof control.card_digest !== 'string' || control.card_digest !== entry.card?.card_digest) return refuse('stale', 'stale');
86
+ if (entry.card.kind === 'terminal') return refuse('refused', 'terminal_only');
87
+ let always = true, mode = 'plan';
88
+ try { always = live(entry.confirmAlways) === true; mode = live(entry.mode); } catch {}
89
+ if (always) return refuse('refused', 'confirm_always');
90
+ if (mode === 'plan') return refuse('refused', 'plan_mode');
91
+ if (!DECISIONS.has(control.decision)) return refuse('refused', 'stale');
92
+ // Settled synchronously: nothing else can decide this question after this line.
93
+ if (!entry.settle({ allowed: control.decision === 'allow_once', by: 'device' })) return refuse('stale', 'stale');
94
+ const result = this.#remember(requestId, { state: 'applied', outcome: control.decision });
95
+ try { await this.record({ type: 'remote_control', action: 'permissions.answer', question_id: entry.questionId, decision: control.decision, device_id: input.device_id }); } catch {}
96
+ return result;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The confirm path with both surfaces: the terminal card (terminal(signal) → ui.confirm) and the
102
+ * device card. Returns what PermissionPolicy expects from confirm: the terminal's own value, or
103
+ * the device's boolean (never a remember choice).
104
+ */
105
+ export async function answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice = () => {} }) {
106
+ let entry;
107
+ // A desk that cannot open the question leaves the terminal card, as before the device existed.
108
+ try { entry = desk.open({ questionId, name, input, confirmAlways, mode, card }); } catch { return terminal(signal); }
109
+ try {
110
+ let shown;
111
+ try { shown = Promise.resolve(terminal(signal ? AbortSignal.any([signal, entry.signal]) : entry.signal)); }
112
+ catch (error) { shown = Promise.reject(error); }
113
+ shown.then(value => entry.terminal(value), error => entry.fail(error));
114
+ const result = await entry.decision;
115
+ if (result.by !== 'device') return result.value;
116
+ // The terminal card closed because the device answered: the terminal says so.
117
+ try { notice(deviceAnswerNotice({ allowed: result.allowed, tool: card?.tool ?? name })); } catch {}
118
+ return result.allowed;
119
+ } finally { entry.close(); }
120
+ }
121
+
122
+ /** The terminal's line when the M5Stack decided a card. */
123
+ export const deviceAnswerNotice = ({ allowed, tool }) => t('Answered on the M5Stack: {decision} · {tool}', { decision: t(allowed ? 'allowed once' : 'denied'), tool: String(tool ?? '') });
124
+
125
+ /**
126
+ * PermissionPolicy's confirm when /rc may carry the card to the M5Stack. Only an armed host with a
127
+ * question id engages the device; an aborted question, or any failure while preparing the device
128
+ * card, shows the plain terminal card instead (the device is a convenience, never a gate).
129
+ */
130
+ export async function confirmWithDevice({ armed, desk, questionId, name, input, summary, signal, terminal, resolveWrite, confirmAlways, mode, notice }) {
131
+ if (!armed || !questionId || signal?.aborted) return terminal(signal);
132
+ let card;
133
+ try {
134
+ // confirm:'always' and plan mode stay in the terminal: the device gets a card it cannot answer, and nothing of
135
+ // the command, the change or a summary leaves the machine (an unreadable policy counts as terminal-only too).
136
+ let terminalOnly = true;
137
+ try { terminalOnly = live(confirmAlways) === true || live(mode) === 'plan'; } catch {}
138
+ card = terminalOnly ? buildDeviceCard({ questionId, name, input, terminalOnly: true }) : buildDeviceCard({ questionId, name, input, summary, overwrite: await writeOverwrite(name, input, resolveWrite) });
139
+ }
140
+ catch { return terminal(signal); }
141
+ if (signal?.aborted) return terminal(signal);
142
+ return answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice });
143
+ }
144
+
145
+ export const OVERWRITE_TIMEOUT_MS = 250;
146
+ /** Write only: does the path the Write tool would write already exist? undefined when it cannot tell,
147
+ * including when resolving and stat take longer than `timeoutMs` (the terminal card must not wait). */
148
+ export async function writeOverwrite(name, input, resolve, { timeoutMs = OVERWRITE_TIMEOUT_MS } = {}) {
149
+ if (name !== 'Write' || typeof input?.file_path !== 'string') return undefined;
150
+ const probe = (async () => {
151
+ let file;
152
+ try { file = await resolve(input.file_path, { create: true }); } catch { return undefined; }
153
+ try { await stat(file); return true; }
154
+ catch (error) { return error?.code === 'ENOENT' ? false : undefined; }
155
+ })();
156
+ let timer;
157
+ const late = new Promise(done => { timer = setTimeout(() => done(undefined), timeoutMs); timer.unref?.(); });
158
+ try { return await Promise.race([probe, late]); } finally { clearTimeout(timer); }
159
+ }
@@ -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
+ }
@@ -0,0 +1,205 @@
1
+ // session.stats for /rc surfaces (browser and paired M5Stack): a bounded summary built from what
2
+ // /status, /context and the status bar already compute. Pure module: numbers, enums and bounded
3
+ // labels only — never paths, file names, tool arguments or the cwd.
4
+ import { readFileSync } from 'node:fs';
5
+ import { stripForDevice } from './remote-device-card.mjs';
6
+
7
+ const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8')).stats;
8
+ export const STATS_SCHEMA = CONTRACT.schema;
9
+ export const STATS_MAX_BYTES = CONTRACT.maxBytes;
10
+ // A change publishes a durable session.stats at most once a minute (each one is a stored row against the session's
11
+ // 10,000-event cap and the account's storage); a turn end and a device connecting publish at once (1 s floor).
12
+ export const STATS_INTERVAL_MS = 60000;
13
+ export const STATS_FLOOR_MS = 1000;
14
+
15
+ const GROUPS = STATS_SCHEMA.properties;
16
+ const limit = (group, field) => GROUPS[group].properties[field].maxLength;
17
+ const MODES = GROUPS.permissions.properties.mode.enum;
18
+ const TRANSPORTS = GROUPS.connection.properties.transport.enum;
19
+ const EFFORT_LEVELS = GROUPS.model.properties.effort_levels.maxItems;
20
+ const SOURCES = GROUPS.capabilities.properties.sources.maxProperties;
21
+ const SOURCE_NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
22
+
23
+ const count = value => Number.isSafeInteger(value) && value >= 0 ? value : undefined;
24
+ const flag = value => typeof value === 'boolean' ? value : null;
25
+ const cut = (text, max) => Array.from(text).slice(0, max).join('');
26
+ /** A label as the device shows it: invisible characters out, one line, cut to the schema's length. */
27
+ function label(value, max) {
28
+ if (typeof value !== 'string') return undefined;
29
+ const text = cut(stripForDevice(value).replace(/\s+/g, ' ').trim(), max).trim();
30
+ return text || undefined;
31
+ }
32
+ // A token is path-like when it holds a separator, starts at home, or names a file (word.ext).
33
+ const PATHISH = /[\\/]|^~|^[A-Za-z]:/;
34
+ const FILENAME = /\w\.[A-Za-z][A-Za-z0-9]{0,7}$/;
35
+ const MODEL_FILE = /\w\.(gguf|ggml|bin|safetensors|onnx|pt|pth|mlx|llamafile|json|ya?ml|toml)$/i;
36
+ /** Free text (objective, task title) without the cwd, paths or file names: each such token becomes «…». */
37
+ function redacted(value, cwd, max) {
38
+ if (typeof value !== 'string') return undefined;
39
+ let text = stripForDevice(value).replace(/\s+/g, ' ');
40
+ if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('…');
41
+ text = text.split(' ').map(token => {
42
+ const bare = token.replace(/^[("'«“‘[]+|[)"'»”’\],.;:!?]+$/g, '');
43
+ return bare && (PATHISH.test(bare) || FILENAME.test(bare)) ? token.replace(bare, '…') : token;
44
+ }).join(' ');
45
+ return label(text, max);
46
+ }
47
+ /** A model or profile name. Local providers name a weights or profile FILE: only its bare name is shown, never the
48
+ * path (or the cwd) — `/home/x/models/qwen-7b.gguf` reads `qwen-7b`; an id that is no path stays whole. */
49
+ function nameLabel(value, cwd, max) {
50
+ if (typeof value !== 'string') return undefined;
51
+ let text = stripForDevice(value).replace(/\s+/g, ' ').trim();
52
+ if (typeof cwd === 'string' && cwd.length > 1) text = text.split(cwd).join('');
53
+ text = text.split(' ').map(token => {
54
+ const path = PATHISH.test(token);
55
+ let name = path ? token.split(/[\\/]+/).filter(Boolean).at(-1) ?? '' : token;
56
+ if (/^~|^[A-Za-z]:$/.test(name)) name = '';
57
+ // The last segment of a path loses any extension; an id that is no path only a model file's (meta.llama3 stays).
58
+ return (path ? FILENAME.test(name) : MODEL_FILE.test(name)) ? name.replace(/\.[A-Za-z][A-Za-z0-9]{0,11}$/, '') : name;
59
+ }).filter(Boolean).join(' ');
60
+ return label(text, max);
61
+ }
62
+ const compact = group => {
63
+ for (const key of Object.keys(group)) if (group[key] === undefined) delete group[key];
64
+ return Object.keys(group).length ? group : undefined;
65
+ };
66
+ const tally = value => {
67
+ if (!value || typeof value !== 'object') return undefined;
68
+ return compact({ input: count(value.input), output: count(value.output), cached: count(value.cached) });
69
+ };
70
+
71
+ /**
72
+ * Sources (each optional; a missing source omits its group or field):
73
+ * metadata (cli metadata()), promptCaching, contextStatus / contextPlan (compactor status/plan as
74
+ * /context computes them), messages {active, original}, lastCompaction (context_projection row),
75
+ * usage {last, session, calls, call_limit}, timing (result.timing), work (projectRemoteWorkspace),
76
+ * permissions {armed, until ms} (RemoteHost status().permission_answers), transport, addDirCount, cwd.
77
+ */
78
+ export function buildSessionStats({ metadata, promptCaching, contextStatus, contextPlan, messages, lastCompaction, usage, timing, work, permissions, transport, addDirCount, cwd } = {}) {
79
+ const stats = {};
80
+ if (metadata && typeof metadata === 'object') {
81
+ const levels = metadata.modelCapabilities?.effort?.levels;
82
+ stats.model = compact({
83
+ provider: label(metadata.provider, limit('model', 'provider')),
84
+ profile: nameLabel(metadata.profile, cwd, limit('model', 'profile')),
85
+ model: nameLabel(metadata.model, cwd, limit('model', 'model')),
86
+ effort: label(metadata.effort, limit('model', 'effort')),
87
+ effort_levels: Array.isArray(levels) ? levels.map(level => label(level, GROUPS.model.properties.effort_levels.items.maxLength)).filter(Boolean).slice(0, EFFORT_LEVELS) : undefined,
88
+ });
89
+ }
90
+ const reported = metadata?.modelCapabilities?.capabilities;
91
+ if ((reported && typeof reported === 'object') || typeof promptCaching === 'boolean') {
92
+ const provenance = metadata?.modelCapabilities?.provenance || {}, sources = {};
93
+ const capabilities = {};
94
+ if (reported && typeof reported === 'object') for (const name of ['tools', 'vision', 'thinking']) {
95
+ capabilities[name] = flag(reported[name]);
96
+ const source = provenance[name]?.source;
97
+ if (typeof source === 'string' && SOURCE_NAME.test(source)) sources[name] = source;
98
+ }
99
+ capabilities.cache = typeof promptCaching === 'boolean' ? promptCaching : null;
100
+ if (typeof promptCaching === 'boolean') sources.cache = 'provider';
101
+ if (Object.keys(sources).length) capabilities.sources = Object.fromEntries(Object.entries(sources).slice(0, SOURCES));
102
+ stats.capabilities = capabilities;
103
+ }
104
+ {
105
+ const until = Number(permissions?.until);
106
+ const group = compact({
107
+ mode: MODES.includes(metadata?.mode) ? metadata.mode : undefined,
108
+ device_armed: typeof permissions?.armed === 'boolean' ? permissions.armed : undefined,
109
+ device_until: permissions ? (permissions.armed && Number.isFinite(until) && until > 0 ? Math.floor(until / 1000) : null) : undefined,
110
+ add_dirs: count(addDirCount),
111
+ });
112
+ if (group) stats.permissions = group;
113
+ }
114
+ if (contextStatus || contextPlan || messages || lastCompaction) {
115
+ const status = contextStatus || {}, plan = contextPlan || status;
116
+ const percent = typeof status.percent === 'number' && Number.isFinite(status.percent) ? Math.min(100, Math.max(0, status.percent)) : undefined;
117
+ const at = Date.parse(lastCompaction?.timestamp ?? lastCompaction?.at ?? '');
118
+ const group = compact({
119
+ input_tokens: count(status.input_tokens), window: count(status.context_window), output_reserve: count(status.output_reserve), percent,
120
+ compaction_window: count(plan.compaction_window), soft_threshold: count(plan.soft_threshold), hard_threshold: count(plan.hard_threshold),
121
+ last_compaction: lastCompaction ? compact({
122
+ at: Number.isFinite(at) && at >= 0 ? Math.floor(at / 1000) : undefined,
123
+ trigger: label(lastCompaction.compaction_trigger ?? lastCompaction.trigger, GROUPS.context.properties.last_compaction.properties.trigger.maxLength),
124
+ before: count(lastCompaction.before_tokens), after: count(lastCompaction.after_tokens),
125
+ }) ?? null : undefined,
126
+ messages_active: count(messages?.active), messages_original: count(messages?.original),
127
+ });
128
+ if (group) stats.context = group;
129
+ }
130
+ if (usage && typeof usage === 'object') {
131
+ const group = compact({ last: tally(usage.last), session: tally(usage.session), calls: count(usage.calls), call_limit: count(usage.call_limit) });
132
+ if (group) stats.usage = group;
133
+ }
134
+ if (timing && typeof timing === 'object') {
135
+ const group = compact({ comal_ms: count(timing.comal_ms), reasoning_ms: count(timing.reasoning_ms), first_response_ms: count(timing.first_output_ms ?? timing.first_text_ms), tools_ms: count(timing.tools_ms), human_wait_ms: count(timing.human_wait_ms) });
136
+ if (group) stats.timing = group;
137
+ }
138
+ if (work && typeof work === 'object' && (work.plan || work.tasks_total)) {
139
+ const tasks = Array.isArray(work.tasks) ? work.tasks : [], checks = tasks.flatMap(task => Array.isArray(task?.checks) ? task.checks : []);
140
+ const current = tasks.find(task => task?.status === 'in_progress');
141
+ const group = compact({
142
+ objective: redacted(work.plan?.objective, cwd, limit('work', 'objective')),
143
+ done: count(work.counts?.done), total: count(work.tasks_total),
144
+ current_task: current ? redacted(current.title, cwd, limit('work', 'current_task')) ?? null : null,
145
+ checks_passing: checks.filter(check => check?.status === 'passed').length,
146
+ checks_failing: checks.filter(check => ['failed', 'refused', 'interrupted', 'not_dispatched'].includes(check?.status)).length,
147
+ });
148
+ if (group) stats.work = group;
149
+ }
150
+ if (TRANSPORTS.includes(transport)) stats.connection = { transport, host_online: transport !== 'disconnected' };
151
+ for (const key of Object.keys(stats)) if (stats[key] === undefined) delete stats[key];
152
+ return fitStats(stats) ?? {};
153
+ }
154
+
155
+ // What goes first when a summary does not fit: free text, then timing, then the rest that is not essential.
156
+ const DROPS = [
157
+ stats => { delete stats.work?.objective; },
158
+ stats => { delete stats.work?.current_task; },
159
+ stats => { delete stats.timing; },
160
+ stats => { delete stats.capabilities?.sources; },
161
+ stats => { delete stats.model?.effort_levels; },
162
+ stats => { delete stats.work; },
163
+ stats => { delete stats.usage; },
164
+ ];
165
+ /** The summary itself when it fits in `max` bytes; otherwise a copy with optional parts dropped; null if nothing fits. */
166
+ export function fitStats(stats, max = STATS_MAX_BYTES) {
167
+ if (!stats || typeof stats !== 'object') return null;
168
+ const size = value => Buffer.byteLength(JSON.stringify(value));
169
+ if (size(stats) <= max) return stats;
170
+ const copy = structuredClone(stats);
171
+ for (const drop of DROPS) {
172
+ drop(copy);
173
+ for (const key of Object.keys(copy)) if (copy[key] && typeof copy[key] === 'object' && !Object.keys(copy[key]).length) delete copy[key];
174
+ if (size(copy) <= max) return copy;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /** Context numbers exactly as /context computes them (same compactor calls). */
180
+ export function readContext({ compactor, system, tools, maxTokens, session }) {
181
+ const contextStatus = compactor.status({ system, messages: session.messages, tools }, maxTokens);
182
+ const contextPlan = compactor.plan(contextStatus.output_reserve, contextStatus.overhead_tokens);
183
+ return { contextStatus, contextPlan, messages: { active: session.messages.length, original: session.historyMessages.length } };
184
+ }
185
+
186
+ const tokens = usage => ({ input: count(usage?.input_tokens) ?? 0, output: count(usage?.output_tokens) ?? 0 });
187
+ const cacheRead = row => count(row?.usage_accounting?.cache_read_tokens) ?? 0;
188
+ /** Usage, last timing and last compaction from the session book. Session totals count what the status
189
+ * bar counts (results not already including subagents, and failed requests with usage). */
190
+ export function statsFromRows(rows = []) {
191
+ const counted = rows.filter(row => (row.type === 'result' && !row.usage_includes_subagents) || (row.type === 'error' && row.usage));
192
+ const turns = rows.filter(row => row.type === 'result' && !row.usage_includes_subagents && row.usage && row.turns > 0);
193
+ const last = turns.at(-1), reads = rows.filter(row => row.type === 'model_usage');
194
+ const session = counted.reduce((sum, row) => { const n = tokens(row.usage); return { input: sum.input + n.input, output: sum.output + n.output }; }, { input: 0, output: 0 });
195
+ const usage = counted.length || reads.length ? {
196
+ ...(last ? { last: { ...tokens(last.usage), cached: last.turn_id ? reads.filter(row => row.turn_id === last.turn_id).reduce((n, row) => n + cacheRead(row), 0) : 0 } } : {}),
197
+ session: { ...session, cached: reads.reduce((n, row) => n + cacheRead(row), 0) },
198
+ ...(last ? { calls: last.turns } : {}),
199
+ } : null;
200
+ return {
201
+ usage,
202
+ timing: rows.findLast(row => row.type === 'result' && row.timing && typeof row.timing === 'object')?.timing ?? null,
203
+ lastCompaction: rows.findLast(row => row.type === 'context_projection') ?? null,
204
+ };
205
+ }