diff --git a/lib/ai/agent/tools/index.js b/lib/ai/agent/tools/index.js index 186290a..98c770d 100644 --- a/lib/ai/agent/tools/index.js +++ b/lib/ai/agent/tools/index.js @@ -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); diff --git a/lib/ai/agent/tools/propose_improvement.js b/lib/ai/agent/tools/propose_improvement.js new file mode 100644 index 0000000..92fa87c --- /dev/null +++ b/lib/ai/agent/tools/propose_improvement.js @@ -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.' + }; + } +}; diff --git a/lib/ai/personas/index.js b/lib/ai/personas/index.js index 3fa0475..a0ca849 100644 --- a/lib/ai/personas/index.js +++ b/lib/ai/personas/index.js @@ -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.`, diff --git a/lib/api/index.js b/lib/api/index.js index ea873f7..6810161 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -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; } diff --git a/lib/api/routes/improvements.js b/lib/api/routes/improvements.js new file mode 100644 index 0000000..3b127a9 --- /dev/null +++ b/lib/api/routes/improvements.js @@ -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 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()); +} diff --git a/lib/db/migrations/030_improvements.sql b/lib/db/migrations/030_improvements.sql new file mode 100644 index 0000000..8507bcc --- /dev/null +++ b/lib/db/migrations/030_improvements.sql @@ -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); diff --git a/lib/db/repos/improvements.js b/lib/db/repos/improvements.js new file mode 100644 index 0000000..e181f3b --- /dev/null +++ b/lib/db/repos/improvements.js @@ -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'); +} diff --git a/package.json b/package.json index 07a1558..b915b26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.13.0", + "version": "2.14.0", "type": "module", "private": true, "scripts": { diff --git a/public/components/dross_bubble.js b/public/components/dross_bubble.js index 9fcf844..dd3933c 100644 --- a/public/components/dross_bubble.js +++ b/public/components/dross_bubble.js @@ -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); diff --git a/public/components/sidebar.js b/public/components/sidebar.js index c6a7f4b..8785522 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -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'), diff --git a/public/index.html b/public/index.html index 7ac0580..5e0c5d4 100644 --- a/public/index.html +++ b/public/index.html @@ -33,6 +33,7 @@ +