diff options
Diffstat (limited to 'modules/core/module-debug-supervisor.js')
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 156 |
1 files changed, 130 insertions, 26 deletions
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 <message>', '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 <message> (alias: chat <message>)', '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) { |