diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 19:36:52 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 19:36:52 +0200 |
| commit | d0a6c805a142b6a637913a6af876a08e72bcb1f7 (patch) | |
| tree | 05d9965d3c7086a707133bb5182a44e53c613102 /modules/core/core-bundle.js | |
| parent | 313f13955ad9c72895b0914ac828c61b3e469715 (diff) | |
feat(newos4): hermes command, auto-poll, chat_hermes tool, token reset (v4.6.73)
Phase C as repo edits (browser edits in _mods/ do not survive a reload).
- `hermes <msg>`: 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).
Diffstat (limited to 'modules/core/core-bundle.js')
| -rw-r--r-- | modules/core/core-bundle.js | 205 |
1 files changed, 179 insertions, 26 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 303c151..a689774 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -8820,6 +8820,20 @@ buildSystemPrompt() { }, { type: 'function', function: { + name: 'chat_hermes', + description: 'Send a message to Hermes, the platform operator agent, and read replies. Use when a task needs a decision only the operator can make: policy, a deploy approval, scope. Replies are asynchronous β call with action "read" to check for an answer.', + parameters: { + type: 'object', + properties: { + action: { type: 'string', enum: ['send', 'read'], description: 'send a message, or read replies' }, + text: { type: 'string', description: 'message body, required for send' } + }, + required: ['action'] + } + } + }, { + type: 'function', + function: { name: 'spawn_agent', description: '(DESTRUCTIVE) Spawn a new worker agent with a role and capabilities', parameters: { @@ -11063,6 +11077,41 @@ buildSystemPrompt() { } return JSON.stringify({ agents: results, count: results.length }); } + case 'chat_hermes': { + // v4.6.71: one channel, one store. Contract: _docs/chat-contract.md + const ds = this.terminal.moduleManager.modules['DebugSupervisorModule']; + if (!ds || typeof ds._chatPost !== 'function') { + return JSON.stringify({ error: 'chat bridge unavailable' }); + } + try { + if ((a.action || 'read') === 'send') { + const text = String(a.text || '').trim(); + if (!text) return JSON.stringify({ error: 'text is required for send' }); + const j = await ds._chatPost(text, 'agent'); + return JSON.stringify({ + sent: true, id: j.id, epoch: j.epoch, + note: 'asynchronous β call chat_hermes with action read for the answer' + }); + } + const st = ds._chatThread(); + const resp = await fetch( + '/spaces/newos4/api/chat/poll.php' + (st.cursor ? '?since=' + st.cursor : ''), + { headers: { 'X-Chat-Token': st.token } }); + const j = await resp.json().catch(() => null); + if (!resp.ok || !j || !j.success) { + return JSON.stringify({ error: (j && j.error) || ('HTTP ' + resp.status) }); + } + if (j.cursor) ds._chatSaveState({ token: st.token, cursor: j.cursor, thread: j.thread || '' }); + return JSON.stringify({ + awaiting: j.awaiting, + messages: (j.messages || []).slice(-10).map(m => ({ + author: m.author, ts: m.ts, text: m.text + })) + }); + } catch (e) { + return JSON.stringify({ error: e.message }); + } + } case 'delegate_task': { const task = a.task || ''; if (!task) return JSON.stringify({ error: 'No task provided' }); @@ -20612,8 +20661,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() { @@ -20630,6 +20685,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() { @@ -21409,35 +21562,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) { |