From d0a6c805a142b6a637913a6af876a08e72bcb1f7 Mon Sep 17 00:00:00 2001 From: Agando Date: Fri, 23 Oct 2026 19:36:52 +0200 Subject: feat(newos4): hermes command, auto-poll, chat_hermes tool, token reset (v4.6.73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C as repo edits (browser edits in _mods/ do not survive a reload). - `hermes `: sends through the chat endpoint and always prints an outcome β€” 'πŸ“¨ Sending…' then either 'βœ… sent id=… epoch=…' or a coded error with a hint (429 retry later, 503 host busy, 401 run hermes-reset). `feedback` stays as the alias. - NOT named `chat`: ChatStudioModule already registers `chat` and dispatch walks modules in order, so a second `chat` was silently unreachable. Found by test, not by reading. - `hermes-poll` / `feedback-inbox`, `hermes-reset` (token rotation). - Poller: 30 s, paused while the tab is hidden, polls on visibilitychange, first poll adopts the cursor and prints nothing, cursor persisted in webx11_chat_state. - `chat_hermes` tool: send + read from inside the agent loop. - Contract/help updated for the rename. Acceptance: 'hermes acceptance test' in the browser, reply written host-side with scripts/chat.py, appeared in the terminal by itself 30 s later with no command typed (cursor 1792776973800). --- modules/core/module-debug-supervisor.js | 156 ++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 26 deletions(-) (limited to 'modules/core/module-debug-supervisor.js') diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 78e3a46..6768df1 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -55,8 +55,14 @@ class DebugSupervisorModule { this.commands['debug-health'] = () => this._cmdHealth(); this.commands['debug-sync'] = (args) => this._cmdSync(args); this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args); + // v4.6.71: one channel. `feedback` keeps working; `hermes` is the explicit name. + // NOT `chat` β€” ChatStudioModule already owns that name and dispatch iterates + // modules in order, so a second `chat` is silently unreachable. this.commands['feedback'] = (args) => this._cmdFeedback(args); + this.commands['hermes'] = (args) => this._cmdFeedback(args); this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); + this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); + this.commands['hermes-reset'] = () => this._cmdChatReset(); } seed() { @@ -73,6 +79,104 @@ class DebugSupervisorModule { this._syncInterval = setInterval(() => this._syncToBackend(), 300000); // Also sync on first load after a short delay setTimeout(() => this._syncToBackend(), 5000); + // v4.6.71: replies land by themselves. 30 s, paused while the tab is hidden, + // and the first poll only records the cursor β€” a reload must not replay history. + this._chatTimer = setInterval(() => this._chatPoll(), 30000); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') this._chatPoll(); + }); + setTimeout(() => this._chatPoll(), 4000); + } + + // ── chat bridge (contract: _docs/chat-contract.md) ────────────────────── + + _chatToken() { + try { + let t = localStorage.getItem('webx11_chat_token'); + if (!t || !/^[a-f0-9]{64}$/.test(t)) { + const b = new Uint8Array(32); + (window.crypto || window.msCrypto).getRandomValues(b); + t = Array.from(b).map(x => x.toString(16).padStart(2, '0')).join(''); + localStorage.setItem('webx11_chat_token', t); + } + return t; + } catch (e) { return ''; } + } + + _chatState() { + try { return JSON.parse(localStorage.getItem('webx11_chat_state') || '{}') || {}; } + catch (e) { return {}; } + } + + _chatSaveState(st) { + try { localStorage.setItem('webx11_chat_state', JSON.stringify(st)); } catch (e) {} + } + + _chatThread() { + // sha256(token)[0:16] is computed server-side; the client only needs the + // cursor, which is scoped to the token it was issued for. + const st = this._chatState(); + const tok = this._chatToken(); + return st.token === tok ? st : { token: tok, cursor: 0, thread: '' }; + } + + async _chatPost(text, kind) { + const token = this._chatToken(); + if (!token) throw new Error('localStorage is blocked β€” no chat token'); + const resp = await fetch('/spaces/newos4/api/chat/post.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Chat-Token': token }, + body: JSON.stringify({ text: text, kind: kind || 'operator' }) + }); + let j = null; + try { j = await resp.json(); } catch (e) { j = null; } + if (!resp.ok || !j || !j.success) { + const err = new Error((j && j.error) || ('HTTP ' + resp.status)); + err.status = resp.status; + err.code = (j && j.code) || ''; + throw err; + } + const st = { token: token, cursor: j.epoch || 0, thread: j.thread || '' }; + this._chatSaveState(st); + return j; + } + + _chatPoll(force) { + const st = this._chatThread(); + const token = st.token; + if (!token) return Promise.resolve(null); + if (!force && document.visibilityState === 'hidden') return Promise.resolve(null); + const q = st.cursor ? '?since=' + st.cursor : ''; + return fetch('/spaces/newos4/api/chat/poll.php' + q, { headers: { 'X-Chat-Token': token } }) + .then(r => r.ok ? r.json() : null) + .then(j => { + if (!j || !j.success) return null; + const known = st.cursor > 0; + const msgs = j.messages || []; + if (!known) { + // first poll for this token: adopt the cursor, print nothing + this._chatSaveState({ token: token, cursor: j.cursor || 0, thread: j.thread || '' }); + return j; + } + msgs.forEach(m => { + if ((m.epoch || 0) > (this._chatThread().cursor || 0)) { + this._chatSaveState({ token: token, cursor: m.epoch || 0, thread: j.thread || '' }); + } + if (m.author === 'hermes') this.terminal.print('πŸ€– ' + m.text, ''); + }); + return j; + }) + .catch(() => null); + } + + _cmdChatReset() { + try { + localStorage.removeItem('webx11_chat_token'); + localStorage.removeItem('webx11_chat_state'); + this.terminal.print('πŸ”„ Chat token rotated β€” a new thread starts with your next message', 'success'); + } catch (e) { + this.terminal.print('❌ ' + e.message, 'error'); + } } destroy() { @@ -852,35 +956,35 @@ class DebugSupervisorModule { _cmdFeedback(args) { const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); - if (!msg) { this.terminal.print('usage: feedback ', 'error'); return; } - const payload = { - title: msg.slice(0, 120), - body: msg.slice(0, 4000), - thread: this._feedbackThread(), - kind: 'maintenance', - diagnostics: { version: window.NEWOS4_VERSION || '?', url: location.href, auto: false }, - }; - this.terminal.print('πŸ“¨ Sending feedback…', ''); - fetch('/spaces/newos4/api/debug/report.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).then(r => r.json()).then(j => { - if (j && j.success) this.terminal.print('βœ… Sent: ' + (j.id || 'ok') + (j.duplicate ? ' (merged)' : ''), 'success'); - else this.terminal.print('❌ ' + ((j && j.error) || 'send failed'), 'error'); - }).catch(e => this.terminal.print('❌ ' + e.message, 'error')); + if (!msg) { + this.terminal.print('usage: feedback (alias: chat )', 'error'); + return; + } + this.terminal.print('πŸ“¨ Sending…', ''); + this._chatPost(msg, 'operator') + .then(j => this.terminal.print( + 'βœ… sent id=' + String(j.id || '?').slice(-6) + ' epoch=' + (j.epoch || 0) + + ' β€” reply arrives here; `hermes-poll` to check now', 'success')) + .catch(e => { + // 503 is nginx's limit_req, not ours: it arrives as HTML, so status is all we have. + const hint = e.status === 429 ? ' (rate limited β€” retry later)' + : e.status === 503 ? ' (host busy β€” retry)' + : e.status === 401 ? ' (bad token β€” try chat-reset)' + : ''; + this.terminal.print('❌ ' + e.message + hint, 'error'); + }); } _cmdFeedbackInbox() { - this.terminal.print('πŸ“¬ Fetching replies…', ''); - fetch('/spaces/newos4/api/debug/agent-replies.php', { credentials: 'same-origin' }) - .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status + ' (admin session required)'))) - .then(j => { - const list = (j && j.replies) || []; - if (!list.length) { this.terminal.print('(no replies yet)', ''); return; } - list.slice(-10).forEach(r => this.terminal.print('πŸ€– ' + (r.created_at || '') + ' ' + r.body, '')); - }) - .catch(e => this.terminal.print('❌ ' + e.message, 'error')); + this.terminal.print('πŸ“¬ Checking…', ''); + this._chatPoll(true).then(j => { + if (!j) { this.terminal.print('❌ no answer from the chat endpoint', 'error'); return; } + const msgs = j.messages || []; + if (!msgs.length) this.terminal.print('(no new messages)', 'info'); + msgs.forEach(m => this.terminal.print( + (m.author === 'hermes' ? 'πŸ€– ' : m.author === 'agent' ? '🧠 ' : 'πŸ‘€ ') + m.text, '')); + if (j.awaiting) this.terminal.print('⏳ waiting for Hermes', 'info'); + }); } _cmdSync(args) { -- cgit v1.2.3