aboutsummaryrefslogtreecommitdiff
path: root/modules/core/core-bundle.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-26 13:03:11 +0100
committerAgando <roduccomm@gmail.com>2026-10-26 13:03:11 +0100
commita72e86bcb11d379289e02d868065f4dc86d0f23e (patch)
treec5e0202766c935c5b62b29e629b9d4d755772f59 /modules/core/core-bundle.js
parent5b05af91fa3fcc9ae7aff00feedc26d062babbc2 (diff)
feat(feedback): review dashboard, public guide, terminal commands (v4.6.94)
Dashboard (/admin/feedback.html, admin-gated) - User-feedback tab over the live :8908 API: list, filter, search, publish reply, archive/reopen. Reply is what the reporter sees in /stear -> Your reports & replies. - Agent-items tab: 3 plans parked at autonomy:review + 19 defect reports, one click each. - Snapshot age line + stale warning (>30 min), and the last apply outcome with failures. - Existing /admin/ tab relabelled "Client error log" (it renders feedback_log.json, not the feedback store) and now links to the review dashboard. Decision path - admin/api/feedback-decide.php: exact-match nginx location + auth_request, Origin check, item_id allowlist, append-only decisions.jsonl with fwrite+fsync verification. - scripts/newos4-feedback-apply.py (darkbot): last decision per item wins, superseded rows recorded, reject reverts safe->review, per-item try/except, ledger saved per mutation, realpath containment, exact standalone-line marker flip, file mode preserved, queue rotation at 1 MB. Steering directives now carry the report title. - scripts/newos4-feedback-collect.py: snapshot for the dashboard, 40-line plan head, steering shape assert (-1 = unknown), recent_apply outcomes. - newos4-feedback-sync.sh: flock -n so overlapping runs cannot double-apply. Guide + terminal - feedback-guide.html: which channel (terminal feedback / /stear / automatic reports), expectations, exact payload per channel, how to write a report that gets fixed. Linked from /stear, index.html footer and help.html. - newOS4: feedback list|read|reply|archive|reopen|stats|dashboard|help (+ feedback-admin alias), admin-token derived per call, argument-validated dispatch so a sentence like "feedback list is broken on mobile" is still a message, "feedback -- <text>" forces it. - help.html: 8 rows, coverage 362/362. Review: glm-5.3 REWORK -> 15 findings fixed, 5 refuted with evidence, 2 doc-only. Guide corrected (invented "60 MB per batch" replaced by the real 512 KB/5-per-10-min limits).
Diffstat (limited to 'modules/core/core-bundle.js')
-rw-r--r--modules/core/core-bundle.js159
1 files changed, 158 insertions, 1 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 8e19189..32ff73b 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -20753,6 +20753,9 @@ class DebugSupervisorModule {
this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox();
this.commands['hermes-poll'] = () => this._cmdFeedbackInbox();
this.commands['hermes-reset'] = () => this._cmdChatReset();
+ // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still
+ // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback).
+ this.commands['feedback-admin'] = (args) => this._fbDispatch(args);
}
seed() {
@@ -21650,9 +21653,25 @@ class DebugSupervisorModule {
}
_cmdFeedback(args) {
- const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim();
+ const argv = raw.split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ // A subcommand only when its ARGUMENTS are plausible. Measured failure without this
+ // check: "feedback list is broken on mobile" became a filtered list of reports.
+ const STATUS = ['', 'new', 'read', 'archived'];
+ const isNum = (v) => /^\d+$/.test(v || '');
+ const forced = raw.startsWith('--');
+ const isSub = !forced && (
+ (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) ||
+ ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) ||
+ (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) ||
+ (sub === 'reply' && isNum(argv[1]) && argv.length >= 3)
+ );
+ if (isSub) { this._fbDispatch(args); return; }
+ const msg = forced ? raw.replace(/^--\s*/, '') : raw;
if (!msg) {
this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error');
+ this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', '');
return;
}
this.terminal.print('πŸ“¨ Sending…', '');
@@ -21688,6 +21707,144 @@ class DebugSupervisorModule {
});
}
+ // ── feedback management (admin) ──────────────────────────────────────────
+ _fbHelp() {
+ const rows = [
+ 'feedback <message> send a message to the operator agent (alias: hermes)',
+ 'feedback list [status] list stored /stear reports (new|read|archived, default all)',
+ 'feedback read <id> show one report in full',
+ 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)',
+ 'feedback archive <id> archive a report',
+ 'feedback reopen <id> move a report back to new',
+ 'feedback stats counts per status',
+ 'feedback dashboard open /admin/feedback.html in a new tab',
+ 'feedback-inbox read agent replies from the chat bridge',
+ ];
+ this.terminal.print('feedback β€” commands', '');
+ rows.forEach(r => this.terminal.print(' ' + r, ''));
+ this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', '');
+ }
+
+ async _fbToken() {
+ if (!this._fbKey) {
+ const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' });
+ if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status);
+ const d = await r.json();
+ if (!d || !d.token) throw new Error('no admin token in response');
+ this._fbKey = d.token;
+ }
+ const w = Math.floor(Date.now() / 1000 / 300) * 300;
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w));
+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ async _fbCall(path, opts) {
+ const tok = await this._fbToken();
+ const o = Object.assign({ credentials: 'same-origin' }, opts || {});
+ o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' },
+ (opts && opts.headers) || {});
+ const r = await fetch('/api/feedback' + path, o);
+ if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); }
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ }
+
+ _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); }
+
+ _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); }
+
+ _fbList(status) {
+ const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50';
+ this.terminal.print('πŸ“‹ loading…', '');
+ this._fbCall('/list' + q).then(d => {
+ const items = d.items || [];
+ if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; }
+ this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) +
+ this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', '');
+ items.forEach(it => this.terminal.print(
+ this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) +
+ this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) +
+ String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), ''));
+ const total = (typeof d.total === 'number' ? d.total : items.length);
+ const more = total > items.length ? ' (showing ' + items.length + ' of ' + total +
+ ' β€” feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : '';
+ this.terminal.print(total + ' item(s)' + more + ' β€” feedback read <id>, reply <id> <text>', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbRead(id) {
+ if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; }
+ const want = String(id);
+ // The server understands ?id= since v4.6.93's API change; an older process ignores it and
+ // returns the newest row instead, so verify the id before trusting the answer.
+ this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => {
+ const hit = (d.items || []).find(x => String(x.id) === want);
+ if (hit) return hit;
+ return this._fbCall('/list?limit=200').then(d2 =>
+ (d2.items || []).find(x => String(x.id) === want) || null);
+ }).then(it => {
+ if (!it) { this.terminal.print('no report #' + want, 'error'); return; }
+ this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), '');
+ this.terminal.print('page: ' + (it.page || 'β€”') + ' at: ' + (it.created_at || ''), '');
+ this.terminal.print('', '');
+ this.terminal.print(String(it.message || ''), '');
+ if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); }
+ this.terminal.print('', '');
+ this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbReply(id, text) {
+ if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH',
+ body: JSON.stringify({ status: 'read', admin_reply: text }) })
+ .then(() => this.terminal.print('βœ… reply published on #' + id + ' (the user sees it in /stear β†’ My reports)', ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStatus(id, status) {
+ if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; }
+ this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) })
+ .then(() => this.terminal.print('βœ… #' + id + ' β†’ ' + status, ''))
+ .catch(e => this._fbFail(e));
+ }
+
+ _fbStats() {
+ const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : ''))
+ .then(d => {
+ if (typeof d.total === 'number') return d.total; // COUNT(*) from the server
+ return this._fbCall('/list?limit=200' + (st ? '&status=' + st : ''))
+ .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length);
+ });
+ Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => {
+ this.terminal.print('feedback: ' + all + ' total β€” new ' + nw + ', read ' + rd +
+ ', archived ' + ar, '');
+ this.terminal.print('dashboard: /admin/feedback.html', '');
+ }).catch(e => this._fbFail(e));
+ }
+
+ _fbDashboard() {
+ this.terminal.print('πŸͺŸ opening /admin/feedback.html …', '');
+ window.open('/admin/feedback.html', '_blank');
+ }
+
+ _fbDispatch(args) {
+ const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/);
+ const sub = (argv[0] || '').toLowerCase();
+ const arg = argv[1];
+ const rest = argv.slice(2).join(' ');
+ switch (sub) {
+ case 'list': return this._fbList(arg || '');
+ case 'read': return this._fbRead(arg);
+ case 'reply': return this._fbReply(arg, rest);
+ case 'archive': return this._fbStatus(arg, 'archived');
+ case 'reopen': return this._fbStatus(arg, 'new');
+ case 'stats': return this._fbStats();
+ case 'dashboard': return this._fbDashboard();
+ case 'help': default: return this._fbHelp();
+ }
+ }
+
_cmdSync(args) {
const url = (args && args[0]) ? args[0] : null;
if (url) {