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:
@@ -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);
|
||||
|
||||
28
lib/ai/agent/tools/propose_improvement.js
Normal file
28
lib/ai/agent/tools/propose_improvement.js
Normal 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.'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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.`,
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
33
lib/api/routes/improvements.js
Normal file
33
lib/api/routes/improvements.js
Normal 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());
|
||||
}
|
||||
13
lib/db/migrations/030_improvements.sql
Normal file
13
lib/db/migrations/030_improvements.sql
Normal 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);
|
||||
58
lib/db/repos/improvements.js
Normal file
58
lib/db/repos/improvements.js
Normal 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');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.13.0",
|
||||
"version": "2.14.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { state } from '../state.js';
|
||||
import { wireAgentChat } from './agent_chat.js';
|
||||
import { drossAvatar } from './dross_avatar.js';
|
||||
|
||||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
|
||||
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
|
||||
function applyAccent(node, hex) {
|
||||
@@ -36,6 +36,13 @@ export async function renderDrossBubble() {
|
||||
document.getElementById('shell').append(fab, panel);
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
|
||||
// autogrow: 1 line at rest, expands with content up to ~5 lines
|
||||
function autogrow() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||
}
|
||||
input.addEventListener('input', autogrow);
|
||||
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||
@@ -81,6 +88,23 @@ export async function renderDrossBubble() {
|
||||
};
|
||||
media.start();
|
||||
recording = true; setMic('● Recording… tap to stop', true);
|
||||
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
|
||||
try {
|
||||
const actx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const src = actx.createMediaStreamSource(stream);
|
||||
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
|
||||
src.connect(analyser);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!recording) { actx.close().catch(() => {}); mic.style.removeProperty('--voicelevel'); return; }
|
||||
analyser.getByteTimeDomainData(buf);
|
||||
let peak = 0;
|
||||
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
|
||||
mic.style.setProperty('--voicelevel', (peak / 128).toFixed(3));
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
} catch { /* meter is decorative — recording works without it */ }
|
||||
} catch {
|
||||
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
|
||||
}
|
||||
@@ -97,7 +121,14 @@ export async function renderDrossBubble() {
|
||||
if (!res.ok) throw new Error('stt');
|
||||
const { text } = await res.json();
|
||||
setMic('Tap to record', false);
|
||||
if (text) { input.value = input.value ? (input.value + ' ' + text) : text; input.focus(); }
|
||||
if (text) {
|
||||
input.value = input.value ? (input.value + ' ' + text) : text;
|
||||
autogrow();
|
||||
// Focus only on fine-pointer devices — on mobile this popped the keyboard
|
||||
// right after every voice note (owner-reported). A brief highlight instead.
|
||||
if (matchMedia('(pointer: fine)').matches) input.focus();
|
||||
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
|
||||
}
|
||||
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
|
||||
} catch {
|
||||
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
|
||||
|
||||
@@ -123,7 +123,7 @@ export function renderSidebar(root) {
|
||||
el('div', { class: 'sb-title' }, 'Navigate'),
|
||||
navItem('Sacred Valley', '/sacred-valley'),
|
||||
navItem('Speedtest', '/speedtest'),
|
||||
navItem('Terminal', '/terminal'),
|
||||
navItem('Eithan', '/terminal'),
|
||||
navItem('Search', '/search'),
|
||||
inboxItem,
|
||||
navItem('Jobs', '/jobs'),
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/improvements.css" id="dross-improvements" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
|
||||
@@ -780,3 +780,19 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.dross-clip{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #ffffff08}
|
||||
.dross-clip-txt{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dross-clip audio{display:none}
|
||||
|
||||
/* voice 2.14: live level ring + textarea flash (post-transcribe, no keyboard pop) */
|
||||
.dross-mic.rec{position:relative;box-shadow:0 0 0 calc(2px + 14px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.12 + 0.45 * var(--voicelevel, 0)));transition:box-shadow 90ms linear}
|
||||
.dross-mic.rec svg{transform:scale(calc(1 + 0.35 * var(--voicelevel, 0)));transition:transform 90ms linear}
|
||||
.dross-inwrap textarea{overflow-y:auto;max-height:120px;transition:height 120ms ease}
|
||||
.dross-inwrap textarea.flash{border-color:var(--dross-glow);box-shadow:0 0 0 2px var(--dross-soft)}
|
||||
|
||||
/* dross improvements (2.14) */
|
||||
.imp-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
.imp-row:last-child{border-bottom:0}
|
||||
.imp-status{font-family:var(--font-mono);font-size:11px;white-space:nowrap;min-width:96px}
|
||||
.imp-status.s-active{color:var(--ok)}.imp-status.s-pending{color:var(--warn)}.imp-status.s-rolled_back{color:var(--muted)}.imp-status.s-rejected{color:var(--bad)}
|
||||
.imp-main{flex:1;min-width:0}
|
||||
.imp-actions{display:flex;gap:6px}
|
||||
button.sm{padding:4px 10px;font-size:12px}
|
||||
button.danger{border-color:var(--bad);color:var(--bad)}
|
||||
|
||||
@@ -243,10 +243,40 @@ export async function render(main) {
|
||||
});
|
||||
iconSetsWrap.appendChild(isToggle);
|
||||
|
||||
// ---- Dross improvements: versioned CSS changes, approve / rollback / restore ----
|
||||
const improvementsBody = el('div', {});
|
||||
const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' };
|
||||
async function renderImprovements() {
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ }
|
||||
const act = (id, verb) => async () => {
|
||||
try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ }
|
||||
renderImprovements();
|
||||
// re-pull the live stylesheet so the change lands without a page reload
|
||||
const link = document.getElementById('dross-improvements');
|
||||
if (link) link.href = '/improvements.css?v=' + Date.now();
|
||||
};
|
||||
mount(improvementsBody,
|
||||
rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null,
|
||||
...rows.map((r) => el('div', { class: 'imp-row' },
|
||||
el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status),
|
||||
el('div', { class: 'imp-main' },
|
||||
el('div', {}, r.summary),
|
||||
el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)),
|
||||
el('span', { class: 'imp-actions' },
|
||||
r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null,
|
||||
r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null,
|
||||
r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null,
|
||||
r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null)))
|
||||
);
|
||||
}
|
||||
renderImprovements();
|
||||
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
||||
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||
section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
// #/terminal — embeds the CT 300 web terminal (ttyd → persistent tmux/claude),
|
||||
// same-origin under /terminal so it shares the Void's CF Access session.
|
||||
// #/terminal — "Eithan": the CT 300 web terminal (ttyd → persistent tmux/claude),
|
||||
// same-origin under /terminal so it shares the Void's CF Access session AND lets
|
||||
// us reach the xterm instance for mobile copy/paste.
|
||||
import { el, mount } from '../dom.js';
|
||||
|
||||
const FS_KEY = 'void_term_fontsize';
|
||||
|
||||
export async function render(main) {
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ Terminal'),
|
||||
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'claude @ ct300 · persistent tmux'),
|
||||
el('button', { class: 'ghost', style: { marginLeft: 'auto' }, onclick: () => {
|
||||
const f = document.getElementById('term-frame'); if (f) f.src = f.src;
|
||||
} }, '⟳ Reconnect')
|
||||
),
|
||||
el('iframe', {
|
||||
// bigger default on touch screens; user-adjustable, remembered
|
||||
let fontSize = Number(localStorage.getItem(FS_KEY))
|
||||
|| (matchMedia('(pointer: coarse)').matches ? 17 : 14);
|
||||
|
||||
const frame = el('iframe', {
|
||||
id: 'term-frame',
|
||||
src: '/terminal/',
|
||||
class: 'term-frame',
|
||||
allow: 'clipboard-read; clipboard-write'
|
||||
})
|
||||
});
|
||||
const setSrc = () => { frame.src = `/terminal/?fontSize=${fontSize}`; };
|
||||
setSrc();
|
||||
|
||||
// ttyd exposes its xterm as window.term; the same-origin proxy makes it reachable.
|
||||
const term = () => { try { return frame.contentWindow?.term ?? null; } catch { return null; } };
|
||||
const note = el('span', { class: 'muted', style: { fontSize: '11px' } },
|
||||
'eithan @ ct300 · persistent tmux · swipe to scroll');
|
||||
const flash = (msg) => { const old = note.textContent; note.textContent = msg;
|
||||
setTimeout(() => { note.textContent = old; }, 1600); };
|
||||
|
||||
const bump = (d) => {
|
||||
fontSize = Math.max(10, Math.min(24, fontSize + d));
|
||||
localStorage.setItem(FS_KEY, String(fontSize));
|
||||
setSrc(); // reload reattaches tmux; the session itself persists
|
||||
};
|
||||
|
||||
mount(main,
|
||||
el('div', { class: 'term-bar' },
|
||||
el('span', { class: 'term-title' }, '◆ Eithan'),
|
||||
note,
|
||||
el('span', { style: { marginLeft: 'auto', display: 'flex', gap: '6px' } },
|
||||
el('button', { class: 'ghost', title: 'smaller text', onclick: () => bump(-2) }, 'A−'),
|
||||
el('button', { class: 'ghost', title: 'larger text', onclick: () => bump(+2) }, 'A+'),
|
||||
el('button', { class: 'ghost', title: 'copy terminal selection', onclick: async () => {
|
||||
const sel = term()?.getSelection?.();
|
||||
if (!sel) return flash('select text first (touch: long-press, then drag)');
|
||||
try { await navigator.clipboard.writeText(sel); flash('copied ✓'); }
|
||||
catch { flash('clipboard needs the https domain'); }
|
||||
} }, '⧉ Copy'),
|
||||
el('button', { class: 'ghost', title: 'paste clipboard into terminal', onclick: async () => {
|
||||
const t = term();
|
||||
if (!t) return flash('terminal not ready');
|
||||
try { t.paste(await navigator.clipboard.readText()); }
|
||||
catch { flash('clipboard needs the https domain'); }
|
||||
} }, '⇩ Paste'),
|
||||
el('button', { class: 'ghost', title: 'jump to live output', onclick: () => {
|
||||
term()?.scrollToBottom?.(); frame.contentWindow?.focus();
|
||||
} }, '↓ Live'),
|
||||
el('button', { class: 'ghost', title: 'reconnect', onclick: setSrc }, '⟳')
|
||||
)
|
||||
),
|
||||
frame
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { companionRegistry } from '../../../../lib/ai/agent/tools/index.js';
|
||||
|
||||
describe('companion registry', () => {
|
||||
it('registers exactly the four v1 tools', () => {
|
||||
it('registers exactly the five companion tools', () => {
|
||||
expect(companionRegistry.listTools().map(t => t.name).sort())
|
||||
.toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
.toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
it('exposes them in Anthropic shape', () => {
|
||||
const tools = companionRegistry.toAnthropicTools();
|
||||
|
||||
59
tests/api/improvements.test.js
Normal file
59
tests/api/improvements.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { createApp } from '../../server.js';
|
||||
import { proposeImprovementTool } from '../../lib/ai/agent/tools/propose_improvement.js';
|
||||
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
const auth = (r) => r.set('Authorization', 'Bearer test-token');
|
||||
const ctx = { agent: { slug: 'dross' } };
|
||||
|
||||
describe('dross improvements (2.14)', () => {
|
||||
let id;
|
||||
|
||||
it('tool drafts a pending improvement, never applies', async () => {
|
||||
const out = await proposeImprovementTool.handler(
|
||||
{ summary: 'Soften card borders', css: '.card { border-radius: 10px; }' }, ctx);
|
||||
expect(out.ok).toBe(true);
|
||||
expect(out.note).toMatch(/NOT live/);
|
||||
id = out.id;
|
||||
const css = await request(app).get('/improvements.css');
|
||||
expect(css.text).not.toContain('border-radius: 10px'); // pending ≠ live
|
||||
});
|
||||
|
||||
it('tool rejects exfil css', async () => {
|
||||
expect((await proposeImprovementTool.handler(
|
||||
{ summary: 'evil', css: '.x { background: url(http://evil.tld/p.png); }' }, ctx)).error)
|
||||
.toMatch(/url\(\)/);
|
||||
expect((await proposeImprovementTool.handler(
|
||||
{ summary: 'evil', css: '@import "http://evil.tld/x.css";' }, ctx)).error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('owner approves → live in the public stylesheet', async () => {
|
||||
const res = await auth(request(app).post(`/api/improvements/${id}/approve`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('active');
|
||||
const css = await request(app).get('/improvements.css'); // unauthenticated by design
|
||||
expect(css.headers['content-type']).toContain('text/css');
|
||||
expect(css.text).toContain('border-radius: 10px');
|
||||
expect(css.text).toContain('dross: Soften card borders');
|
||||
});
|
||||
|
||||
it('rollback removes it instantly; restore brings it back', async () => {
|
||||
await auth(request(app).post(`/api/improvements/${id}/rollback`));
|
||||
expect((await request(app).get('/improvements.css')).text).not.toContain('border-radius');
|
||||
await auth(request(app).post(`/api/improvements/${id}/restore`));
|
||||
expect((await request(app).get('/improvements.css')).text).toContain('border-radius');
|
||||
});
|
||||
|
||||
it('transitions are guarded (no approve on active, no anonymous verbs)', async () => {
|
||||
expect((await auth(request(app).post(`/api/improvements/${id}/approve`))).status).toBe(409);
|
||||
expect((await request(app).post(`/api/improvements/${id}/rollback`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,9 @@ const suggestAgent = (id) => ({
|
||||
});
|
||||
|
||||
describe('listMcpTools()', () => {
|
||||
it('returns exactly the four companion tools sorted by name', () => {
|
||||
it('returns exactly the five companion tools sorted by name', () => {
|
||||
const tools = listMcpTools();
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
|
||||
it('each tool has name, description, and input_schema', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js';
|
||||
describe('MCP registry selection', () => {
|
||||
it('defaults to the companion registry when VOID_TOOL_REGISTRY is unset', () => {
|
||||
const names = listMcpTools({}).map(t => t.name).sort();
|
||||
expect(names).toEqual(['context', 'propose_change', 'read', 'search']);
|
||||
expect(names).toEqual(['context', 'propose_change', 'propose_improvement', 'read', 'search']);
|
||||
});
|
||||
|
||||
it('selects the security registry when VOID_TOOL_REGISTRY=security', () => {
|
||||
|
||||
Reference in New Issue
Block a user