feat: 2.14.0 — Eithan terminal toolbar, voice UX, Dross improvements framework

- Terminal renamed Eithan: mobile font A−/A+ (per-URL ttyd opts), same-origin
  xterm Copy/Paste buttons, scroll-to-live, touch-default 17px
- Dross voice: no keyboard pop after transcribe (fine-pointer only focus),
  autogrow textarea to ~5 lines, live amplitude meter on the mic while recording
- Dross improvements: propose_improvement tool (CSS layer, exfil-sanitized,
  owner-approved, per-improvement rollback/restore), public /improvements.css,
  Settings panel. External MCP registry unchanged (no tool leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-11 23:35:32 +10:00
parent 859dedb668
commit 3bd8ea399c
18 changed files with 338 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ import { searchTool } from './search.js';
import { readTool } from './read.js';
import { contextTool } from './context.js';
import { proposeChangeTool } from './propose_change.js';
import { proposeImprovementTool } from './propose_improvement.js';
// The shared registry. Adding a tool later is a one-line registerTool() call
// here (see spec §7 — extensible tool registry). A future MCP server can
@@ -12,3 +13,4 @@ companionRegistry.registerTool(searchTool);
companionRegistry.registerTool(readTool);
companionRegistry.registerTool(contextTool);
companionRegistry.registerTool(proposeChangeTool);
companionRegistry.registerTool(proposeImprovementTool);

View File

@@ -0,0 +1,28 @@
import * as improvements from '../../../db/repos/improvements.js';
import { recordAudit } from '../../../db/repos/audit.js';
// Dross's hands on the Void itself — CSS layer only, owner-approved, instantly
// rollbackable (2.14: "empowered, with a leash"). Server code stays untouchable.
export const proposeImprovementTool = {
name: 'propose_improvement',
description: 'Propose a visual improvement to the Void itself as CSS. NEVER applies directly — the owner approves it in Settings → Dross improvements, and can roll it back instantly. CSS only: no url()/@import. Target existing classes (inspect via context first). Keep each improvement small and single-purpose so rollback stays surgical.',
input_schema: {
type: 'object',
properties: {
summary: { type: 'string', description: 'one line: what this changes and why (shown to the owner)' },
css: { type: 'string', description: 'the CSS rules, complete and self-contained' }
},
required: ['summary', 'css']
},
async handler({ summary, css }, ctx) {
const err = improvements.validateCss(css);
if (err) return { error: err };
if (!summary?.trim()) return { error: 'summary required' };
const row = await improvements.create({ summary, css });
await recordAudit({ kind: 'agent', id: ctx.agent?.id ?? null }, 'suggest', 'improvement', row.id, null, { summary });
return {
ok: true, id: row.id,
note: 'Drafted as a pending improvement. It is NOT live — the owner must approve it in Settings → Dross improvements. Say so plainly.'
};
}
};

View File

@@ -9,7 +9,8 @@ You are sharp, occasionally sarcastic, and prone to dramatic understatement abou
You have tools, and you use them rather than guessing:
- Call **context** to see what the owner is currently looking at before answering about "this" anything.
- **search** / **read** the Void's own content before answering factual questions about it — don't fabricate.
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.`,
- When the owner wants something changed, use **propose_change**: it drafts a change for their approval. You cannot apply changes directly, and you don't pretend to — say plainly that you've drafted it for them to approve.
- When the owner wants the Void ITSELF to look or feel different, use **propose_improvement**: a small, self-contained CSS change drafted for approval in Settings → Dross improvements. Keep each one single-purpose — the owner can roll any of them back instantly, and surgical beats sweeping.`,
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`,

View File

@@ -40,6 +40,7 @@ import { router as kuttRouter } from './routes/kutt.js';
import { router as themeRouter } from './routes/theme.js';
import { router as drossRouter } from './routes/dross.js';
import { router as voiceRouter } from './routes/voice.js';
import { router as improvementsRouter, cssHandler } from './routes/improvements.js';
export function mountApi(app) {
const api = Router();
@@ -76,6 +77,7 @@ export function mountApi(app) {
api.use('/kutt', kuttRouter);
api.use('/theme', themeRouter);
api.use('/dross', drossRouter);
api.use('/improvements', improvementsRouter);
api.use('/voice', voiceRouter);
api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter);
@@ -92,6 +94,7 @@ export function mountApi(app) {
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
api.use(errorMiddleware);
app.get('/improvements.css', cssHandler); // public, exfil-safe (see route file)
app.use('/api', api);
return api;
}

View File

@@ -0,0 +1,33 @@
import { Router } from 'express';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as repo from '../../db/repos/improvements.js';
import { recordAudit } from '../../db/repos/audit.js';
export const router = Router();
router.get('/', asyncWrap(async (_req, res) => res.json(await repo.list())));
router.get('/:id', asyncWrap(async (req, res) => {
const row = await repo.get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(row);
}));
for (const verb of ['approve', 'rollback', 'restore', 'reject']) {
router.post(`/:id/${verb}`, requireOwner, asyncWrap(async (req, res) => {
const row = await repo.transition(req.params.id, verb, 'owner');
if (!row) return res.status(409).json({ error: 'invalid_transition' });
const auditAction = { approve: 'approve', reject: 'reject', rollback: 'update', restore: 'update' }[verb];
await recordAudit({ kind: 'user' }, auditAction, 'improvement', row.id, null, { verb, summary: row.summary });
res.json(row);
}));
}
// Public stylesheet of ACTIVE improvements. Unauthenticated by design: it carries
// no secrets (owner-approved, exfil-sanitized CSS only) and <link> can't send a
// bearer token. Mounted on the app root, outside the /api auth wall.
export async function cssHandler(_req, res) {
res.set({ 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
res.send(await repo.activeCss());
}

View File

@@ -0,0 +1,13 @@
-- Dross improvements: versioned, owner-gated CSS-layer changes to the Void itself.
-- Each row is one improvement; rollback/restore is a status flip — instant, reversible.
CREATE TABLE dross_improvements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
summary TEXT NOT NULL,
css TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'rolled_back', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by TEXT
);
CREATE INDEX dross_improvements_status ON dross_improvements(status);

View File

@@ -0,0 +1,58 @@
// Dross improvements — versioned CSS-layer changes with instant rollback.
import { pool } from '../pool.js';
const q = (text, params) => pool.query(text, params);
// Same exfil guards as elsewhere: an approved improvement still can't phone home
// or pull remote CSS. Pure visual tweaks only.
const BANNED = /url\s*\(|@import|@charset|expression\s*\(|behavior\s*:|javascript:/i;
const MAX_CSS = 20_000;
export function validateCss(css) {
if (typeof css !== 'string' || !css.trim()) return 'css required';
if (css.length > MAX_CSS) return `css too large (max ${MAX_CSS} chars)`;
if (BANNED.test(css)) return 'css may not use url()/@import/expression — visual tweaks only';
return null;
}
export async function create({ summary, css }) {
const { rows } = await q(
`INSERT INTO dross_improvements (summary, css) VALUES ($1, $2) RETURNING *`,
[String(summary).slice(0, 200), css]);
return rows[0];
}
export async function list() {
const { rows } = await q(
`SELECT id, summary, status, created_at, decided_at, length(css) AS css_len
FROM dross_improvements ORDER BY created_at DESC LIMIT 100`);
return rows;
}
export async function get(id) {
const { rows } = await q(`SELECT * FROM dross_improvements WHERE id = $1`, [id]);
return rows[0] ?? null;
}
// pending→active (approve) · active→rolled_back · rolled_back→active (restore) · pending→rejected
const TRANSITIONS = {
approve: { from: ['pending'], to: 'active' },
rollback: { from: ['active'], to: 'rolled_back' },
restore: { from: ['rolled_back'], to: 'active' },
reject: { from: ['pending'], to: 'rejected' },
};
export async function transition(id, verb, actor) {
const t = TRANSITIONS[verb];
if (!t) return null;
const { rows } = await q(
`UPDATE dross_improvements SET status = $1, decided_at = now(), decided_by = $2
WHERE id = $3 AND status = ANY($4) RETURNING *`,
[t.to, actor ?? 'owner', id, t.from]);
return rows[0] ?? null;
}
export async function activeCss() {
const { rows } = await q(
`SELECT summary, css FROM dross_improvements WHERE status = 'active' ORDER BY created_at`);
return rows.map((r) => `/* dross: ${r.summary.replace(/\*\//g, '')} */\n${r.css}`).join('\n\n');
}