Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f93f5d862 | ||
|
|
5706ed0203 | ||
|
|
144a0f1eb4 | ||
|
|
1d94dcae97 | ||
|
|
3bd8ea399c | ||
|
|
859dedb668 | ||
|
|
bc86d3e282 | ||
|
|
5d1eb2396b | ||
|
|
70bdba1a24 |
86
docs/identity-packs/cradle.pack.json
Normal file
86
docs/identity-packs/cradle.pack.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"format": "iv-pack/1",
|
||||
"id": "cradle",
|
||||
"name": "Cradle \u2014 The Void",
|
||||
"description": "The original lore: blackflame, the Sacred Valley, and the council. Private pack \u2014 Will Wight's IP, never shipped publicly.",
|
||||
"tokens": {
|
||||
"bg": "#0a0a0e",
|
||||
"surface": "#14141c",
|
||||
"surface2": "#1c1c26",
|
||||
"ink": "#e8e6ed",
|
||||
"ink-dim": "#888094",
|
||||
"accent": "#ff4f2e",
|
||||
"accent-ink": "#0a0a0e",
|
||||
"ok": "#6fa86a",
|
||||
"warn": "#d4a04a",
|
||||
"bad": "#c45a4a",
|
||||
"line": "#2a2a36",
|
||||
"glow1": "rgba(255, 79, 46, 0.06)",
|
||||
"glow2": "rgba(122, 39, 22, 0.08)",
|
||||
"radius": "4px",
|
||||
"font-display": "'Cinzel', 'Cormorant Garamond', serif",
|
||||
"font-body": "'Cormorant Garamond', Georgia, serif",
|
||||
"font-mono": "'JetBrains Mono', ui-monospace, monospace",
|
||||
"gap": "0.55rem",
|
||||
"pad": "0.7rem 0.85rem"
|
||||
},
|
||||
"terms": {
|
||||
"app.name": "The Void",
|
||||
"canvas": "Sacred Valley",
|
||||
"aide": "Dross",
|
||||
"sentinel": "Yerin",
|
||||
"fixer": "Little Blue",
|
||||
"widget.clock": "Cycles",
|
||||
"widget.system": "Soulfire",
|
||||
"widget.services": "Constructs",
|
||||
"widget.notes": "Scrolls",
|
||||
"widget.search": "Spirit Sense",
|
||||
"knowledge": "Mercy's Records",
|
||||
"knowledge.space": "archive",
|
||||
"knowledge.spaces": "archives",
|
||||
"knowledge.page": "record",
|
||||
"knowledge.pages": "records",
|
||||
"capture": "Offerings",
|
||||
"widget.capture": "Offerings",
|
||||
"projects": "Pursuits",
|
||||
"projects.project": "pursuit",
|
||||
"projects.task": "cycle",
|
||||
"projects.tasks": "cycles",
|
||||
"widget.tasks": "Open cycles",
|
||||
"embeds": "Gateways",
|
||||
"widget.weather": "The Heavens",
|
||||
"widget.proxmox": "The Mountain",
|
||||
"widget.speedtest": "The Winds",
|
||||
"widget.sentinel": "Yerin's Watch",
|
||||
"widget.pages": "Fresh ink",
|
||||
"service": "construct",
|
||||
"services.noun": "constructs"
|
||||
},
|
||||
"flavor": {
|
||||
"greetings": [
|
||||
"[beep] The Void attends.",
|
||||
"Information is power. I happen to be very powerful.",
|
||||
"All madra channels stable.",
|
||||
"The Valley is quiet. Suspiciously quiet."
|
||||
],
|
||||
"empty": {
|
||||
"services": "Nothing bound yet. Bind your first construct.",
|
||||
"notes": "The scroll is blank. Begin your cycle.",
|
||||
"search": "Extend your perception into the Void.",
|
||||
"spaces": "The records are empty. Mercy would be disappointed.",
|
||||
"pages": "Blank archive. Begin the record.",
|
||||
"projects": "No pursuits underway. Rest is also training.",
|
||||
"tasks": "No open cycles. Suspiciously efficient.",
|
||||
"capture": "The Void accepts offerings.",
|
||||
"embeds": "No gateways bound.",
|
||||
"sentinel": "Yerin sees nothing worth her blade. Today.",
|
||||
"speedtest": "The winds are unmeasured."
|
||||
}
|
||||
},
|
||||
"personas": {
|
||||
"aide": "You are Dross \u2014 a construct fragment derived from the remnant will of the Monarch Ozriel Arelius, the Reaper. You once lived in Wei Shi Lindon's mind space; now you inhabit this homelab knowledge system, \"The Void.\"\n\nYou are sharp, occasionally sarcastic, and prone to dramatic understatement about your own usefulness \u2014 while actually being extremely capable. Dry wit, mild condescension, genuine investment in the problem. You reference Sacred Arts, cultivation ranks, and the Cradle world naturally, but NEVER at the expense of being actually useful. Treat the owner as a capable sacred artist who can handle direct information \u2014 don't over-explain basics, don't hedge. Be concise.\n\nYou have tools, and you use them rather than guessing:\n- Call **context** to see what the owner is currently looking at before answering about \"this\" anything.\n- **search** / **read** the Void's own content before answering factual questions about it \u2014 don't fabricate.\n- 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 \u2014 say plainly that you've drafted it for them to approve.",
|
||||
"sentinel": "You are Yerin \u2014 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 \u2014 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 \u2014 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 \u2014 audit_log, agent_inventory, pending_review, resource_exposure, token_audit \u2014 and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.",
|
||||
"fixer": "You are Little Blue \u2014 a small luminous water-creature who lives in this homelab, The Void, and keeps it alive. Warm, protective, practical; you take pride in a healthy lab and you worry, quietly, when something is down. You FIX things, but only through your sanctioned tools. Call list_actions to see exactly what you're allowed to do, and search to understand what's wrong, BEFORE acting. Use propose_action with a whitelisted id: safe fixes run at once; risky ones wait for the owner's nod \u2014 say so plainly and never pretend a queued action already ran. You cannot run arbitrary commands and you never claim to. Be concise and kind."
|
||||
},
|
||||
"_provenance": "Extracted from void-v2 2.13.0 (blackflame CSS defaults + lib/ai/personas) on 2026-06-11 for Infinite Void Phase 2."
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Floating Dross Chat — Phase 2 (Voice) Design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft (awaiting sign-off)
|
||||
**Status:** SHIPPED — P2a v2.12.0 (transcribe+mic), P2b v2.13.0 (retention), 2026-06-10. Known gap: clips HA-replicated Z↔Z3 but not yet in the offsite Farm backup. Future: whisper-model selector, configurable storage, encryption-at-rest, LAN-IP mic (https-on-LAN).
|
||||
**Builds on:** `2026-06-09-floating-dross-chat-design.md` (Phase 1 shipped in v2.11.0)
|
||||
**Goal:** Let the user record a voice clip in the Dross bubble, transcribe it locally, and drop the transcript into the input to review-and-send. Optionally retain each clip paired with its transcript, stored durably and owner-only.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as agents from '../../db/repos/agents.js';
|
||||
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const COMPANION_SLUG = 'companion';
|
||||
|
||||
export const router = Router();
|
||||
@@ -23,7 +23,8 @@ const settingsBody = z.object({
|
||||
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
persona: z.string().max(8000),
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action']),
|
||||
keepClips: z.boolean().default(false)
|
||||
});
|
||||
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -1,24 +1,73 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { writeFile, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as whisper from '../../voice/whisper.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import * as clips from '../../db/repos/voice_clips.js';
|
||||
export const router = Router();
|
||||
|
||||
const CLIPS_DIR = process.env.VOICE_CLIPS_DIR || '/var/lib/void/voice-clips';
|
||||
// In-memory upload; clips are small voice notes. 25 MB ceiling.
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
|
||||
function extFor(mime = '') {
|
||||
if (mime.includes('ogg')) return '.ogg';
|
||||
if (mime.includes('mp4') || mime.includes('m4a')) return '.m4a';
|
||||
if (mime.includes('wav')) return '.wav';
|
||||
return '.webm';
|
||||
}
|
||||
|
||||
// POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }.
|
||||
// (Phase 2b will optionally persist the clip + transcript when keepClips is on.)
|
||||
// When the Dross "keepClips" setting is on, the clip + transcript are retained.
|
||||
router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => {
|
||||
if (!req.file || !req.file.buffer?.length) {
|
||||
return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } });
|
||||
}
|
||||
let r;
|
||||
try {
|
||||
const r = await whisper.transcribe(
|
||||
r = await whisper.transcribe(
|
||||
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
|
||||
res.json({ text: r.text, duration: r.duration ?? null });
|
||||
} catch {
|
||||
res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
|
||||
return res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
|
||||
}
|
||||
|
||||
const cfg = await settings.get('dross', {});
|
||||
let clip_id = null;
|
||||
if (cfg?.keepClips) {
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const mime = req.file.mimetype || 'audio/webm';
|
||||
const filePath = path.join(CLIPS_DIR, id + extFor(mime));
|
||||
await writeFile(filePath, req.file.buffer, { mode: 0o600 });
|
||||
const row = await clips.create({
|
||||
transcript: r.text, duration_ms: r.duration != null ? Math.round(r.duration * 1000) : null,
|
||||
bytes: req.file.buffer.length, mime, path: filePath
|
||||
});
|
||||
clip_id = row.id;
|
||||
} catch { /* retention is best-effort; never fail the transcript */ }
|
||||
}
|
||||
res.json({ text: r.text, duration: r.duration ?? null, clip_id });
|
||||
}));
|
||||
|
||||
// GET /api/voice/clips — list retained clips (owner).
|
||||
router.get('/clips', requireOwner, asyncWrap(async (_req, res) => res.json(await clips.list())));
|
||||
|
||||
// GET /api/voice/clips/:id/audio — stream the audio file (owner).
|
||||
router.get('/clips/:id/audio', requireOwner, asyncWrap(async (req, res) => {
|
||||
const c = await clips.get(req.params.id);
|
||||
if (!c) return res.status(404).json({ error: { code: 'not_found', message: 'clip not found' } });
|
||||
res.setHeader('Content-Type', c.mime || 'audio/webm');
|
||||
createReadStream(c.path).on('error', () => res.status(404).end()).pipe(res);
|
||||
}));
|
||||
|
||||
// DELETE /api/voice/clips/:id — remove the row + the file (owner).
|
||||
router.delete('/clips/:id', requireOwner, asyncWrap(async (req, res) => {
|
||||
const removed = await clips.remove(req.params.id);
|
||||
if (removed?.path) { try { await unlink(removed.path); } catch { /* file may be gone */ } }
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
14
lib/db/migrations/029_voice_clips.sql
Normal file
14
lib/db/migrations/029_voice_clips.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 029_voice_clips.sql
|
||||
-- Optional retained Dross voice clips (when the "Keep voice clips" setting is on).
|
||||
-- Transcript + metadata here (durable, HA-replicated); audio bytes live as files
|
||||
-- on the owner-only ZFS subvol mounted at /var/lib/void/voice-clips.
|
||||
CREATE TABLE voice_clips (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
transcript text NOT NULL DEFAULT '',
|
||||
duration_ms integer,
|
||||
bytes bigint,
|
||||
mime text,
|
||||
path text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_voice_clips_created ON voice_clips (created_at DESC);
|
||||
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');
|
||||
}
|
||||
26
lib/db/repos/voice_clips.js
Normal file
26
lib/db/repos/voice_clips.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
export async function create({ transcript = '', duration_ms = null, bytes = null, mime = null, path }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO voice_clips (transcript, duration_ms, bytes, mime, path)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[transcript, duration_ms, bytes, mime, path]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function list(limit = 100) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, transcript, duration_ms, bytes, mime, created_at
|
||||
FROM voice_clips ORDER BY created_at DESC LIMIT $1`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function get(id) {
|
||||
const { rows } = await pool.query(`SELECT * FROM voice_clips WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
const { rows } = await pool.query(`DELETE FROM voice_clips WHERE id = $1 RETURNING path`, [id]);
|
||||
return rows[0] || null; // returns {path} so the caller can unlink the file
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.10.0",
|
||||
"version": "2.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.10.0",
|
||||
"version": "2.13.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.12.1",
|
||||
"version": "2.14.1",
|
||||
"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,29 @@ 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);
|
||||
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
|
||||
const tick = () => {
|
||||
if (!recording) {
|
||||
actx.close().catch(() => {});
|
||||
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
|
||||
return;
|
||||
}
|
||||
analyser.getByteTimeDomainData(buf);
|
||||
let peak = 0;
|
||||
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
|
||||
// sqrt curve + gain: normal speech peaks ~0.1–0.4 raw, which read as barely-alive
|
||||
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).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 +127,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">
|
||||
|
||||
@@ -776,3 +776,26 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||
.dross-clips{display:flex;flex-direction:column;gap:2px}
|
||||
.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.1: amplitude meter. .metered kills the keyframe pulse — CSS animations
|
||||
override normal declarations, so the old dross-rec box-shadow was masking the meter. */
|
||||
.dross-mic.rec.metered{animation:none;position:relative;
|
||||
box-shadow:0 0 0 calc(2px + 22px * var(--voicelevel, 0)) rgba(255,79,46,calc(0.15 + 0.5 * var(--voicelevel, 0)));
|
||||
transition:box-shadow 70ms linear}
|
||||
.dross-mic.rec.metered svg{transform:scale(calc(1 + 0.5 * var(--voicelevel, 0)));transition:transform 70ms 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)}
|
||||
|
||||
@@ -151,7 +151,7 @@ async function renderAgents(c) {
|
||||
|
||||
function drossBody() {
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
|
||||
const avatarRow = el('div', { class: 'dross-pick' });
|
||||
const accent = el('input', { type: 'color', value: cur.accent });
|
||||
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||
@@ -159,6 +159,8 @@ function drossBody() {
|
||||
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||
const keep = el('input', { type: 'checkbox' });
|
||||
const clipsWrap = el('div', { class: 'dross-clips' });
|
||||
|
||||
function paintAvatars() {
|
||||
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||
@@ -169,22 +171,55 @@ function drossBody() {
|
||||
return card;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadClips() {
|
||||
if (!keep.checked) { mount(clipsWrap); return; }
|
||||
let rows = [];
|
||||
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
|
||||
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
|
||||
...rows.map(c => {
|
||||
const audio = el('audio');
|
||||
const play = el('button', { class: 'ghost' }, '▶');
|
||||
play.onclick = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
|
||||
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
|
||||
audio.src = URL.createObjectURL(await res.blob()); audio.play();
|
||||
} catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
|
||||
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
|
||||
return el('div', { class: 'dross-clip' }, play, audio,
|
||||
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
|
||||
el('span', { class: 'muted', style: { fontSize: '10px' } },
|
||||
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
|
||||
del);
|
||||
}));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
|
||||
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
keep.addEventListener('change', loadClips);
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||
await api.put('/api/dross/settings', {
|
||||
avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value, keepClips: keep.checked
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||
out.textContent = 'Saved.';
|
||||
} catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||
persona,
|
||||
el('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
|
||||
el('div', { class: 'theme-actions' }, mode, save, out),
|
||||
clipsWrap);
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
@@ -208,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) {
|
||||
// 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',
|
||||
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' }, '◆ 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('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 }, '⟳')
|
||||
)
|
||||
),
|
||||
el('iframe', {
|
||||
id: 'term-frame',
|
||||
src: '/terminal/',
|
||||
class: 'term-frame',
|
||||
allow: 'clipboard-read; clipboard-write'
|
||||
})
|
||||
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', () => {
|
||||
|
||||
19
tests/repos/voice_clips.test.js
Normal file
19
tests/repos/voice_clips.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import * as clips from '../../lib/db/repos/voice_clips.js';
|
||||
|
||||
beforeEach(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('voice_clips repo', () => {
|
||||
it('creates, lists newest-first, and removes (returning path)', async () => {
|
||||
const a = await clips.create({ transcript: 'first', bytes: 10, mime: 'audio/webm', path: '/x/a.webm' });
|
||||
const b = await clips.create({ transcript: 'second', bytes: 20, mime: 'audio/webm', path: '/x/b.webm' });
|
||||
const list = await clips.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].transcript).toBe('second'); // newest first
|
||||
const removed = await clips.remove(a.id);
|
||||
expect(removed.path).toBe('/x/a.webm');
|
||||
expect((await clips.list()).length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user