diff --git a/docs/superpowers/plans/2026-06-01-void-v2-plan5-companion-chat.md b/docs/superpowers/plans/2026-06-01-void-v2-plan5-companion-chat.md new file mode 100644 index 0000000..e2a9ddd --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-void-v2-plan5-companion-chat.md @@ -0,0 +1,1647 @@ +# Companion Chat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Void 2.0 right-rail companion chat — an always-visible, per-Space AI assistant that answers questions over the Void's knowledge and proposes changes through the existing `pending_changes` approval chain. + +**Architecture:** A lean agent runtime built directly on the Anthropic SDK (no Mastra) drives a tool-use loop over a small, shared, extensible tool registry (`search`, `read`, `context`, `propose_change`). An SSE endpoint streams tokens + tool-activity events to a rewritten `rightrail.js`. One ambient `conversation` per Space; drafts are `pending_changes` rows rendered inline in chat and in the Inbox. + +**Tech Stack:** Node 22 / Express 5 (ESM), `@anthropic-ai/sdk`, Postgres 16 + pgvector, vitest + supertest, vanilla-JS SPA with the safe-DOM helpers in `public/dom.js`. + +**Spec:** `docs/superpowers/specs/2026-06-01-void-v2-plan5-companion-chat-design.md` + +--- + +## Conventions (read before starting) + +- **ESM only.** All `import`/`export`, `"type":"module"` is set. +- **Repos** live in `lib/db/repos/*.js`, use `import { pool } from '../pool.js'`, return rows. Mutations emit audit via `recordAudit(actor, action, type, id, before, after)` from `./audit_stub.js`. +- **Routes** use `Router()`, `validate({ body|query|params: zodSchema })` (parsed body lands on `req.body`, query on `req.validatedQuery`), and `asyncWrap` from `../errors.js`. `req.actor` is set by `agentOrOwner`: owner = `{ kind:'user', id:null }`, agent = `{ kind:'agent', id, capabilities, scopes }`. +- **Capability:** `canAct(actor, action, entity_type)` → `'allow' | 'suggest' | 'deny'` (`lib/auth/capability.js`). +- **Tests** live under `tests/` mirroring `lib/` paths. Run with `npm test` (vitest, `fileParallelism:false`). DB tests use `resetDb()` from `tests/helpers/db.js` + `migrateUp()` from `lib/db/migrate.js`. The owner token in tests is `process.env.OWNER_TOKEN = 'test-token'`. +- **Commit after every task** (the repo commits directly to `main`, no remote). End commit messages with the Co-Authored-By trailer used elsewhere in this repo. + +--- + +## File structure + +**Create:** +- `lib/db/migrations/007_companion.sql` — `conversations.space_id` + index + seed default companion agent +- `lib/ai/secret.js` — `resolveSecret('env:KEY' | 'file:/path' | raw)` +- `lib/ai/anthropic.js` — Anthropic client factory + `makeCallModel({ client, model })` +- `lib/ai/agent/registry.js` — tool registry (`registerTool`, `getTool`, `listTools`, `toAnthropicTools`) +- `lib/ai/agent/tools/search.js`, `read.js`, `context.js`, `propose_change.js` +- `lib/ai/agent/tools/index.js` — registers all four tools, exports the registry +- `lib/ai/agent/runtime.js` — `runTurn(...)` tool-use loop +- `lib/api/routes/companion.js` — `GET /api/spaces/:space_id/companion`, `POST …/companion/turn` (SSE) +- `public/sse.js` — authenticated POST→SSE reader for the browser +- Tests mirroring each of the above under `tests/` + +**Modify:** +- `lib/db/repos/conversations.js` — add `findOrCreateForSpace(...)` +- `lib/api/index.js` — mount the companion router +- `public/components/rightrail.js` — replace the stub with the chat UI +- `public/style.css` — rail/turn/chip/draft-card styles +- `package.json` / `CHANGELOG.md` — dependency + version bump (final task) + +--- + +## Task 1: Add the Anthropic SDK dependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the SDK** + +Run: +```bash +cd /project/src/void-v2 && npm install @anthropic-ai/sdk@^0.40.0 +``` +Expected: `@anthropic-ai/sdk` appears in `dependencies`, `npm install` exits 0. + +- [ ] **Step 2: Verify it imports** + +Run: +```bash +node -e "import('@anthropic-ai/sdk').then(m => console.log(typeof m.default))" +``` +Expected: prints `function` (the `Anthropic` class constructor). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add @anthropic-ai/sdk for companion runtime + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: Secret resolver (`lib/ai/secret.js`) + +The companion's API key is configured as a `vault_path`-style string. v1 supports `env:` and `file:` (the Vaultwarden swap is deferred — see the spec). + +**Files:** +- Create: `lib/ai/secret.js` +- Test: `tests/ai/secret.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/secret.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveSecret } from '../../lib/ai/secret.js'; + +describe('resolveSecret', () => { + beforeEach(() => { delete process.env.__SECRET_TEST; }); + + it('resolves env: specs', () => { + process.env.__SECRET_TEST = 'sk-from-env'; + expect(resolveSecret('env:__SECRET_TEST')).toBe('sk-from-env'); + }); + + it('resolves file: specs (trimmed)', () => { + const dir = mkdtempSync(join(tmpdir(), 'sec-')); + const f = join(dir, 'key'); + writeFileSync(f, 'sk-from-file\n'); + expect(resolveSecret('file:' + f)).toBe('sk-from-file'); + }); + + it('returns a raw value unchanged', () => { + expect(resolveSecret('sk-raw')).toBe('sk-raw'); + }); + + it('returns null for empty/missing', () => { + expect(resolveSecret('')).toBeNull(); + expect(resolveSecret('env:__SECRET_TEST')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/secret.test.js` +Expected: FAIL — `resolveSecret is not a function` / module not found. + +- [ ] **Step 3: Implement `lib/ai/secret.js`** + +```javascript +import { readFileSync } from 'node:fs'; + +// Resolve a vault_path-style secret reference. v1 supports: +// env:NAME -> process.env.NAME +// file:/path -> file contents (trimmed) +// -> returned as-is +// Vaultwarden item-id resolution is a future swap (see spec). +export function resolveSecret(spec) { + if (!spec) return null; + if (spec.startsWith('env:')) { + return process.env[spec.slice(4)] ?? null; + } + if (spec.startsWith('file:')) { + try { return readFileSync(spec.slice(5), 'utf8').trim(); } + catch { return null; } + } + return spec; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/secret.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/secret.js tests/ai/secret.test.js +git commit -m "feat(ai): vault_path secret resolver (env:/file:) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: Migration 007 — `conversations.space_id` + seed companion agent + +`conversations` has no Space linkage and no agent is seeded. Add a nullable `space_id` FK + lookup index, and seed one default companion agent (`slug='companion'`, `kind='claude'`, default capabilities `read+suggest`, no write). + +**Files:** +- Create: `lib/db/migrations/007_companion.sql` +- Test: `tests/db/migration_007.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/db/migration_007.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../lib/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +beforeAll(async () => { await resetDb(); await migrateUp(); }); + +describe('migration 007', () => { + it('adds conversations.space_id column', async () => { + const { rows } = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name='conversations' AND column_name='space_id'` + ); + expect(rows).toHaveLength(1); + }); + + it('seeds a default companion agent', async () => { + const { rows } = await pool.query(`SELECT slug, kind, capabilities FROM agents WHERE slug='companion'`); + expect(rows).toHaveLength(1); + expect(rows[0].kind).toBe('claude'); + expect(rows[0].capabilities).toMatchObject({ read: true, suggest: true, write: false }); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/db/migration_007.test.js` +Expected: FAIL — column missing / no `companion` agent row. + +- [ ] **Step 3: Write the migration** + +```sql +-- lib/db/migrations/007_companion.sql +-- Plan 5: per-Space ambient companion conversation + default companion agent. + +ALTER TABLE conversations + ADD COLUMN space_id uuid REFERENCES spaces(id) ON DELETE CASCADE; + +CREATE INDEX idx_conversations_space ON conversations(space_id, started_at DESC); + +INSERT INTO agents (slug, name, kind, model, capabilities) +VALUES ( + 'companion', 'Companion', 'claude', NULL, + '{"read":true,"suggest":true,"write":false}'::jsonb +) +ON CONFLICT (slug) DO NOTHING; +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/db/migration_007.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/db/migrations/007_companion.sql tests/db/migration_007.test.js +git commit -m "feat(db): migration 007 — conversations.space_id + seed companion agent + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: `conversations.findOrCreateForSpace` + +One ambient conversation per Space, owned by a given agent. Reuse on subsequent turns. + +**Files:** +- Modify: `lib/db/repos/conversations.js` +- Test: `tests/db/conversations_companion.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/db/conversations_companion.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../lib/pool.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as conversations from '../../lib/db/repos/conversations.js'; + +const ACTOR = { kind: 'user', id: null }; +let spaceId, agentId; + +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: agentId }] } = await pool.query( + `SELECT id FROM agents WHERE slug='companion'`)); +}); + +describe('findOrCreateForSpace', () => { + it('creates once then returns the same row', async () => { + const a = await conversations.findOrCreateForSpace(spaceId, agentId, ACTOR); + const b = await conversations.findOrCreateForSpace(spaceId, agentId, ACTOR); + expect(a.id).toBe(b.id); + expect(a.space_id).toBe(spaceId); + expect(a.agent_id).toBe(agentId); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/db/conversations_companion.test.js` +Expected: FAIL — `findOrCreateForSpace is not a function`. + +- [ ] **Step 3: Implement (append to `lib/db/repos/conversations.js`)** + +```javascript +export async function findOrCreateForSpace(space_id, agent_id, actor) { + const { rows: [existing] } = await pool.query( + `SELECT * FROM conversations + WHERE space_id=$1 AND agent_id=$2 AND status='open' + ORDER BY started_at DESC LIMIT 1`, + [space_id, agent_id] + ); + if (existing) return existing; + const { rows: [r] } = await pool.query( + `INSERT INTO conversations(title, space_id, agent_id, metadata) + VALUES($1,$2,$3,$4) RETURNING *`, + ['Companion', space_id, agent_id, {}] + ); + await recordAudit(actor, 'create', 'conversation', r.id, null, r); + return r; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/db/conversations_companion.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/db/repos/conversations.js tests/db/conversations_companion.test.js +git commit -m "feat(db): conversations.findOrCreateForSpace for the ambient companion + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: Tool registry (`lib/ai/agent/registry.js`) + +A registry of `{ name, description, input_schema, handler }`. The runtime reads it now; a future MCP server re-exposes the same defs. **Extensible by design** (registering a new tool needs no runtime change — see spec §7). + +**Files:** +- Create: `lib/ai/agent/registry.js` +- Test: `tests/ai/agent/registry.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/registry.test.js +import { describe, it, expect } from 'vitest'; +import { createRegistry } from '../../../lib/ai/agent/registry.js'; + +describe('tool registry', () => { + const def = { + name: 'echo', + description: 'echo back', + input_schema: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] }, + handler: async ({ x }) => ({ ok: true, x }) + }; + + it('registers and retrieves a tool', () => { + const r = createRegistry(); + r.registerTool(def); + expect(r.getTool('echo')).toBe(def); + expect(r.listTools().map(t => t.name)).toEqual(['echo']); + }); + + it('rejects duplicate names', () => { + const r = createRegistry(); + r.registerTool(def); + expect(() => r.registerTool(def)).toThrow(/already registered/); + }); + + it('serialises to the Anthropic tools shape (no handler leak)', () => { + const r = createRegistry(); + r.registerTool(def); + expect(r.toAnthropicTools()).toEqual([ + { name: 'echo', description: 'echo back', input_schema: def.input_schema } + ]); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/registry.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/agent/registry.js`** + +```javascript +export function createRegistry() { + const tools = new Map(); + return { + registerTool(def) { + if (!def?.name) throw new Error('tool def needs a name'); + if (tools.has(def.name)) throw new Error(`tool "${def.name}" already registered`); + tools.set(def.name, def); + }, + getTool(name) { return tools.get(name); }, + listTools() { return [...tools.values()]; }, + // Anthropic tool-use schema — handlers are intentionally stripped. + toAnthropicTools() { + return [...tools.values()].map(({ name, description, input_schema }) => + ({ name, description, input_schema })); + } + }; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/registry.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/registry.js tests/ai/agent/registry.test.js +git commit -m "feat(ai): extensible agent tool registry + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 6: `search` and `read` tools + +Read-only grounding tools. `search` wraps the existing FTS repo (Space-scoped); `read` fetches an entity by type+id. Tool `ctx` = `{ agent, space_id, view, actor }`. + +**Files:** +- Create: `lib/ai/agent/tools/search.js`, `lib/ai/agent/tools/read.js` +- Test: `tests/ai/agent/tools/read_search.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/tools/read_search.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { searchTool } from '../../../../lib/ai/agent/tools/search.js'; +import { readTool } from '../../../../lib/ai/agent/tools/read.js'; + +let spaceId, pageId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: pageId }] } = await pool.query( + `INSERT INTO pages(space_id,title,body) VALUES($1,'Telemetry export','export CSV via phone') RETURNING id`, + [spaceId])); +}); + +describe('search tool', () => { + it('returns FTS hits scoped to the space', async () => { + const ctx = { space_id: spaceId, actor: { kind: 'user', id: null } }; + const out = await searchTool.handler({ q: 'telemetry' }, ctx); + expect(Array.isArray(out.results)).toBe(true); + expect(out.results.some(r => r.title?.includes('Telemetry'))).toBe(true); + }); +}); + +describe('read tool', () => { + it('fetches a page by id', async () => { + const out = await readTool.handler({ kind: 'page', id: pageId }, { space_id: spaceId }); + expect(out.title).toBe('Telemetry export'); + }); + it('reports not-found cleanly', async () => { + const out = await readTool.handler( + { kind: 'page', id: '00000000-0000-0000-0000-000000000000' }, { space_id: spaceId }); + expect(out.error).toMatch(/not found/i); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/tools/read_search.test.js` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Implement `lib/ai/agent/tools/search.js`** + +```javascript +import * as searchRepo from '../../../db/repos/search.js'; + +export const searchTool = { + name: 'search', + description: 'Full-text search across pages, refs, source docs and messages in the current Space. Use to find information before answering.', + input_schema: { + type: 'object', + properties: { + q: { type: 'string', description: 'search query' }, + kinds: { + type: 'array', + items: { type: 'string', enum: ['page', 'ref', 'source_doc', 'message'] }, + description: 'optional filter of result kinds' + } + }, + required: ['q'] + }, + async handler({ q, kinds }, ctx) { + const results = await searchRepo.fts({ + q, + space_id: ctx.space_id ?? null, + kinds: kinds?.length ? kinds : null, + limit: 8, + offset: 0 + }); + return { results }; + } +}; +``` + +- [ ] **Step 4: Implement `lib/ai/agent/tools/read.js`** + +```javascript +import { pool } from '../../../pool.js'; + +const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', conversation: 'conversations' }; + +export const readTool = { + name: 'read', + description: 'Read a single entity (page, ref, task, conversation) by id for grounding.', + input_schema: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['page', 'ref', 'task', 'conversation'] }, + id: { type: 'string', description: 'uuid of the entity' } + }, + required: ['kind', 'id'] + }, + async handler({ kind, id }, _ctx) { + const table = TABLE[kind]; + if (!table) return { error: `unknown kind "${kind}"` }; + const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]); + if (!row) return { error: `${kind} ${id} not found` }; + return row; + } +}; +``` + +- [ ] **Step 5: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/tools/read_search.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add lib/ai/agent/tools/search.js lib/ai/agent/tools/read.js tests/ai/agent/tools/read_search.test.js +git commit -m "feat(ai): search + read grounding tools + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 7: `context` tool + +Resolves the current view's entity so the agent knows what the owner is looking at. + +**Files:** +- Create: `lib/ai/agent/tools/context.js` +- Test: `tests/ai/agent/tools/context.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/tools/context.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { contextTool } from '../../../../lib/ai/agent/tools/context.js'; + +let spaceId, taskId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: taskId }] } = await pool.query( + `INSERT INTO tasks(space_id,title) VALUES($1,'Wire telemetry') RETURNING id`, [spaceId])); +}); + +describe('context tool', () => { + it('summarises the current view entity', async () => { + const out = await contextTool.handler({}, { space_id: spaceId, view: { entityType: 'task', entityId: taskId } }); + expect(out.entityType).toBe('task'); + expect(out.title).toBe('Wire telemetry'); + }); + it('handles no active view', async () => { + const out = await contextTool.handler({}, { space_id: spaceId, view: null }); + expect(out.note).toMatch(/no specific entity/i); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/tools/context.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/agent/tools/context.js`** + +```javascript +import { pool } from '../../../pool.js'; + +const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', project: 'projects', space: 'spaces' }; + +export const contextTool = { + name: 'context', + description: "Resolve what the owner is currently looking at (the active view). Call this first to ground your answer in the right entity.", + input_schema: { type: 'object', properties: {} }, + async handler(_args, ctx) { + const view = ctx.view; + if (!view?.entityType || !view?.entityId) { + return { note: 'The owner is not on a specific entity; only the Space context is available.', space_id: ctx.space_id }; + } + const table = TABLE[view.entityType]; + if (!table) return { entityType: view.entityType, entityId: view.entityId, note: 'unrecognised entity type' }; + const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [view.entityId]); + if (!row) return { entityType: view.entityType, entityId: view.entityId, error: 'not found' }; + return { entityType: view.entityType, ...row }; + } +}; +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/tools/context.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/tools/context.js tests/ai/agent/tools/context.test.js +git commit -m "feat(ai): context tool — resolve the active view entity + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 8: `propose_change` tool (the action seam) + +Writes one `pending_changes` row — **never** applies. Enforces the agent's capability tier via `canAct`; a denied agent gets a clean refusal. + +**Files:** +- Create: `lib/ai/agent/tools/propose_change.js` +- Test: `tests/ai/agent/tools/propose_change.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/tools/propose_change.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { proposeChangeTool } from '../../../../lib/ai/agent/tools/propose_change.js'; + +let spaceId, agentId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: agentId }] } = await pool.query(`SELECT id FROM agents WHERE slug='companion'`)); +}); + +const suggestAgent = (id) => ({ kind: 'agent', id, capabilities: { read: true, suggest: true, write: false }, scopes: {} }); + +describe('propose_change tool', () => { + it('writes a pending_changes row and never applies', async () => { + const ctx = { agent: suggestAgent(agentId), space_id: spaceId }; + const out = await proposeChangeTool.handler( + { entity_type: 'task', action: 'create', payload: { space_id: spaceId, title: 'Validate CSV' }, reason: 'tracking' }, + ctx + ); + expect(out.pending_change_id).toBeTruthy(); + expect(out.applied).toBe(false); + + const { rows } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [out.pending_change_id]); + expect(rows[0].status).toBe('pending'); + expect(rows[0].agent_id).toBe(agentId); + + const { rows: tasks } = await pool.query(`SELECT * FROM tasks WHERE title='Validate CSV'`); + expect(tasks).toHaveLength(0); // not applied + }); + + it('refuses when the agent cannot even suggest', async () => { + const denied = { kind: 'agent', id: agentId, capabilities: { read: true, suggest: false, write: false }, scopes: {} }; + const out = await proposeChangeTool.handler( + { entity_type: 'task', action: 'create', payload: { title: 'x' } }, + { agent: denied, space_id: spaceId } + ); + expect(out.error).toMatch(/not permitted/i); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/tools/propose_change.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/agent/tools/propose_change.js`** + +```javascript +import { canAct } from '../../../auth/capability.js'; +import * as pendingChanges from '../../../db/repos/pending_changes.js'; + +const ENTITY_TYPES = ['task', 'page', 'project', 'ref', 'resource', 'source_doc']; +const ACTIONS = ['create', 'update', 'delete']; + +export const proposeChangeTool = { + name: 'propose_change', + description: 'Propose a change to the Void. This NEVER applies directly — it creates a draft the owner must approve. Use for creating/updating/deleting tasks, pages, projects, refs, resources.', + input_schema: { + type: 'object', + properties: { + entity_type: { type: 'string', enum: ENTITY_TYPES }, + action: { type: 'string', enum: ACTIONS }, + entity_id: { type: 'string', description: 'uuid; required for update/delete' }, + payload: { type: 'object', description: 'fields for the change' }, + reason: { type: 'string', description: 'one-line rationale shown to the owner' } + }, + required: ['entity_type', 'action', 'payload'] + }, + async handler({ entity_type, action, entity_id, payload, reason }, ctx) { + const tier = canAct(ctx.agent, action, entity_type); + if (tier === 'deny') { + return { error: `not permitted to ${action} ${entity_type}` }; + } + // v1: drafting always routes through approval, even for allow-tier agents. + const change = await pendingChanges.create({ + agent_id: ctx.agent.id, + entity_type, + entity_id: entity_id ?? null, + action, + payload: payload ?? {}, + reason: reason ?? null + }); + return { + pending_change_id: change.id, + applied: false, + summary: `${action} ${entity_type}${payload?.title ? ` "${payload.title}"` : ''}` + }; + } +}; +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/tools/propose_change.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/tools/propose_change.js tests/ai/agent/tools/propose_change.test.js +git commit -m "feat(ai): propose_change tool — drafts to pending_changes, never applies + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 9: Tool index (wire the four tools into a registry) + +**Files:** +- Create: `lib/ai/agent/tools/index.js` +- Test: `tests/ai/agent/tools/index.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/tools/index.test.js +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', () => { + expect(companionRegistry.listTools().map(t => t.name).sort()) + .toEqual(['context', 'propose_change', 'read', 'search']); + }); + it('exposes them in Anthropic shape', () => { + const tools = companionRegistry.toAnthropicTools(); + expect(tools.every(t => t.name && t.input_schema && !('handler' in t))).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/tools/index.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/agent/tools/index.js`** + +```javascript +import { createRegistry } from '../registry.js'; +import { searchTool } from './search.js'; +import { readTool } from './read.js'; +import { contextTool } from './context.js'; +import { proposeChangeTool } from './propose_change.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 +// import this same registry and re-expose toAnthropicTools(). +export const companionRegistry = createRegistry(); +companionRegistry.registerTool(searchTool); +companionRegistry.registerTool(readTool); +companionRegistry.registerTool(contextTool); +companionRegistry.registerTool(proposeChangeTool); +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/tools/index.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/tools/index.js tests/ai/agent/tools/index.test.js +git commit -m "feat(ai): wire the four v1 companion tools into a shared registry + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 10: Anthropic client + `makeCallModel` + +Decouple the SDK from the runtime behind a `callModel({ system, messages, tools, onTextDelta })` function. Production wraps `client.messages.stream(...)`; tests inject a fake. Default model `claude-sonnet-4-6` (good tool-use, cheaper than Opus for a chat companion); overridable per-agent via `agent.model` and globally via `ANTHROPIC_MODEL`. + +**Files:** +- Create: `lib/ai/anthropic.js` +- Test: `tests/ai/anthropic.test.js` + +- [ ] **Step 1: Write the failing test** (tests the pure shaping logic, not the network) + +```javascript +// tests/ai/anthropic.test.js +import { describe, it, expect } from 'vitest'; +import { makeCallModel } from '../../lib/ai/anthropic.js'; + +// Fake SDK client whose stream() yields text deltas then a tool_use block. +function fakeClient(events) { + return { + messages: { + stream() { + return { + async *[Symbol.asyncIterator]() { for (const e of events) yield e; } + }; + } + } + }; +} + +describe('makeCallModel', () => { + it('accumulates text, forwards deltas, and collects tool_use', async () => { + const events = [ + { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hel' } }, + { type: 'content_block_delta', delta: { type: 'text_delta', text: 'lo' } }, + { type: 'content_block_start', content_block: { type: 'tool_use', id: 'tu_1', name: 'search', input: {} } }, + { type: 'input_json_delta_done', toolInput: { q: 'x' } }, // see impl note + { type: 'message_delta', usage: { output_tokens: 5 } }, + { type: 'message_stop', message: { stop_reason: 'tool_use', usage: { input_tokens: 10, output_tokens: 5 } } } + ]; + const callModel = makeCallModel({ client: fakeClient(events), model: 'm' }); + const deltas = []; + const out = await callModel({ system: 's', messages: [], tools: [], onTextDelta: t => deltas.push(t) }); + expect(deltas.join('')).toBe('Hello'); + expect(out.text).toBe('Hello'); + expect(out.toolUses[0]).toMatchObject({ id: 'tu_1', name: 'search' }); + expect(out.stopReason).toBe('tool_use'); + }); +}); +``` + +> **Impl note:** the real Anthropic stream emits `input_json_delta` events whose partial JSON must be accumulated and parsed at `content_block_stop`. To keep this testable and robust, the implementation listens to the SDK's higher-level `inputJson`/`finalMessage()` helpers. Use the SDK's `stream.finalMessage()` to collect the assembled `tool_use` blocks rather than hand-assembling JSON. The fake above simulates the assembled result; adjust event names to match `@anthropic-ai/sdk` if the SDK helper API differs, keeping the **return shape** `{ text, toolUses, stopReason, usage }` stable. + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/anthropic.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/anthropic.js`** + +```javascript +import Anthropic from '@anthropic-ai/sdk'; +import { resolveSecret } from './secret.js'; + +export const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6'; + +export function getAnthropicClient() { + const key = resolveSecret(process.env.ANTHROPIC_API_KEY_REF || 'env:ANTHROPIC_API_KEY'); + if (!key) throw new Error('Anthropic API key not configured (set ANTHROPIC_API_KEY or ANTHROPIC_API_KEY_REF)'); + return new Anthropic({ apiKey: key }); +} + +// Returns callModel({ system, messages, tools, onTextDelta }) -> { text, toolUses, stopReason, usage }. +// Streams text deltas through onTextDelta; collects tool_use blocks from the +// final assembled message so partial-JSON handling is the SDK's problem. +export function makeCallModel({ client, model = DEFAULT_MODEL, maxTokens = 1024 }) { + return async function callModel({ system, messages, tools, onTextDelta }) { + const stream = client.messages.stream({ + model, max_tokens: maxTokens, system, messages, tools + }); + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') { + onTextDelta?.(event.delta.text); + } + } + const final = await stream.finalMessage(); + const text = final.content.filter(b => b.type === 'text').map(b => b.text).join(''); + const toolUses = final.content + .filter(b => b.type === 'tool_use') + .map(b => ({ id: b.id, name: b.name, input: b.input })); + return { text, toolUses, stopReason: final.stop_reason, usage: final.usage }; + }; +} +``` + +> **If the SDK's streaming iterator/`finalMessage()` API differs** from the above in the installed version, adjust the event handling but keep the return shape identical so Task 11's runtime is unaffected. Verify against context7 docs for `@anthropic-ai/sdk` message streaming. + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/anthropic.test.js` +Expected: PASS. (If the fake event shape needs tweaking to match the SDK helper, update the test fake — the assertion on the return shape is what matters.) + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/anthropic.js tests/ai/anthropic.test.js +git commit -m "feat(ai): Anthropic client + streaming callModel adapter + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 11: Agent runtime (`runTurn`) + +The tool-use loop. Pure orchestration — `callModel` and the registry are injected, so it unit-tests with no network. Emits events via `onEvent`; returns the final assistant text + trace. + +**Files:** +- Create: `lib/ai/agent/runtime.js` +- Test: `tests/ai/agent/runtime.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/ai/agent/runtime.test.js +import { describe, it, expect } from 'vitest'; +import { createRegistry } from '../../../lib/ai/agent/registry.js'; +import { runTurn } from '../../../lib/ai/agent/runtime.js'; + +function scriptedCallModel(steps) { + let i = 0; + return async ({ onTextDelta }) => { + const step = steps[i++]; + if (step.text) for (const ch of step.text) onTextDelta?.(ch); + return { text: step.text || '', toolUses: step.toolUses || [], stopReason: step.toolUses ? 'tool_use' : 'end_turn', usage: { output_tokens: 1 } }; + }; +} + +describe('runTurn', () => { + it('runs a tool then produces a final answer', async () => { + const reg = createRegistry(); + reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} }, + handler: async () => ({ results: [{ title: 'Hit' }] }) }); + + const callModel = scriptedCallModel([ + { toolUses: [{ id: 'tu1', name: 'search', input: { q: 'x' } }] }, + { text: 'Found it.' } + ]); + + const events = []; + const out = await runTurn({ + callModel, registry: reg, system: 'sys', + messages: [{ role: 'user', content: 'find x' }], + ctx: { agent: { id: 'a' }, space_id: 's' }, + onEvent: e => events.push(e) + }); + + expect(out.text).toBe('Found it.'); + expect(events.filter(e => e.type === 'tool').map(e => e.tool)).toEqual(['search']); + expect(events.some(e => e.type === 'delta' && e.text)).toBe(true); + expect(out.toolTrace[0]).toMatchObject({ tool: 'search' }); + }); + + it('emits a draft event when propose_change runs', async () => { + const reg = createRegistry(); + reg.registerTool({ name: 'propose_change', description: 'p', input_schema: { type: 'object', properties: {} }, + handler: async () => ({ pending_change_id: 'pc1', applied: false, summary: 'create task "X"' }) }); + + const callModel = scriptedCallModel([ + { toolUses: [{ id: 'tu1', name: 'propose_change', input: {} }] }, + { text: 'Drafted.' } + ]); + + const events = []; + const out = await runTurn({ callModel, registry: reg, system: 's', + messages: [{ role: 'user', content: 'make a task' }], + ctx: { agent: { id: 'a' } }, onEvent: e => events.push(e) }); + + expect(out.draftIds).toEqual(['pc1']); + expect(events.find(e => e.type === 'draft')).toMatchObject({ pending_change_id: 'pc1' }); + }); + + it('stops at the iteration guard', async () => { + const reg = createRegistry(); + reg.registerTool({ name: 'search', description: 's', input_schema: { type: 'object', properties: {} }, + handler: async () => ({ results: [] }) }); + const always = async () => ({ text: '', toolUses: [{ id: 't', name: 'search', input: {} }], stopReason: 'tool_use', usage: {} }); + const out = await runTurn({ callModel: always, registry: reg, system: 's', + messages: [{ role: 'user', content: 'loop' }], ctx: { agent: { id: 'a' } }, onEvent: () => {}, maxIterations: 3 }); + expect(out.toolTrace.length).toBe(3); + expect(out.stoppedOnGuard).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/ai/agent/runtime.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `lib/ai/agent/runtime.js`** + +```javascript +// Tool-use loop. callModel + registry injected (no network here). +// Emits: { type:'tool', tool, args, status } | { type:'delta', text } +// | { type:'draft', pending_change_id, summary } +// Returns: { text, toolTrace, draftIds, usage, stoppedOnGuard } +export async function runTurn({ callModel, registry, system, messages, ctx, onEvent, maxIterations = 6 }) { + const convo = [...messages]; + const toolTrace = []; + const draftIds = []; + let usage = {}; + let stoppedOnGuard = false; + + for (let i = 0; i < maxIterations; i++) { + const res = await callModel({ + system, + messages: convo, + tools: registry.toAnthropicTools(), + onTextDelta: (t) => onEvent?.({ type: 'delta', text: t }) + }); + usage = res.usage || usage; + + if (!res.toolUses?.length) { + return { text: res.text, toolTrace, draftIds, usage, stoppedOnGuard }; + } + + // Record the assistant turn (text + tool_use blocks) for the next round. + convo.push({ + role: 'assistant', + content: [ + ...(res.text ? [{ type: 'text', text: res.text }] : []), + ...res.toolUses.map(t => ({ type: 'tool_use', id: t.id, name: t.name, input: t.input })) + ] + }); + + const toolResults = []; + for (const call of res.toolUses) { + onEvent?.({ type: 'tool', tool: call.name, args: call.input, status: 'running' }); + const tool = registry.getTool(call.name); + let result; + try { + result = tool ? await tool.handler(call.input, ctx) : { error: `unknown tool ${call.name}` }; + } catch (e) { + result = { error: String(e?.message || e) }; + } + toolTrace.push({ tool: call.name, args: call.input, ok: !result?.error }); + if (result?.pending_change_id) { + draftIds.push(result.pending_change_id); + onEvent?.({ type: 'draft', pending_change_id: result.pending_change_id, summary: result.summary }); + } + onEvent?.({ type: 'tool', tool: call.name, status: result?.error ? 'error' : 'done' }); + toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(result) }); + } + convo.push({ role: 'user', content: toolResults }); + + if (i === maxIterations - 1) stoppedOnGuard = true; + } + + return { text: '', toolTrace, draftIds, usage, stoppedOnGuard }; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** + +Run: `npx vitest run tests/ai/agent/runtime.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ai/agent/runtime.js tests/ai/agent/runtime.test.js +git commit -m "feat(ai): agent runtime tool-use loop with event streaming + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 12: Companion API — history + SSE turn endpoint + +`GET /api/spaces/:space_id/companion` → `{ conversation_id, agent, messages }`. +`POST /api/spaces/:space_id/companion/turn` (SSE) → persists the user message, runs `runTurn`, streams events, persists one assistant message with the trace in `metadata`. + +The runtime's `callModel` is taken from `req.app.locals.callModel` when present (tests inject a fake) and otherwise built from the real client — so the integration test never hits the network. + +**Files:** +- Create: `lib/api/routes/companion.js` +- Modify: `lib/api/index.js` +- Test: `tests/api/companion.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// tests/api/companion.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { pool } from '../../lib/pool.js'; +import { createApp } from '../../server.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +let app, spaceId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + app = createApp(); + // Inject a scripted callModel: one propose_change call, then a final answer. + let step = 0; + app.locals.callModel = async ({ onTextDelta }) => { + if (step++ === 0) return { text: '', toolUses: [{ id: 't1', name: 'propose_change', + input: { entity_type: 'task', action: 'create', payload: { space_id: spaceId, title: 'Validate CSV' } } }], stopReason: 'tool_use', usage: {} }; + for (const ch of 'Drafted a task.') onTextDelta?.(ch); + return { text: 'Drafted a task.', toolUses: [], stopReason: 'end_turn', usage: { output_tokens: 3 } }; + }; +}); + +const auth = (r) => r.set('Authorization', 'Bearer test-token'); + +describe('companion API', () => { + it('GET creates the conversation and returns empty history', async () => { + const res = await auth(request(app).get(`/api/spaces/${spaceId}/companion`)); + expect(res.status).toBe(200); + expect(res.body.conversation_id).toBeTruthy(); + expect(res.body.messages).toEqual([]); + }); + + it('POST /turn streams SSE events and persists messages + draft', async () => { + const res = await auth(request(app).post(`/api/spaces/${spaceId}/companion/turn`)) + .send({ text: 'make a task to validate the CSV' }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/event-stream/); + expect(res.text).toMatch(/event: tool/); + expect(res.text).toMatch(/event: draft/); + expect(res.text).toMatch(/event: delta/); + expect(res.text).toMatch(/event: done/); + + const { rows: msgs } = await pool.query( + `SELECT role, body, metadata FROM messages ORDER BY created_at`); + expect(msgs.map(m => m.role)).toEqual(['user', 'assistant']); + expect(msgs[1].body).toBe('Drafted a task.'); + expect(msgs[1].metadata.draft_ids).toHaveLength(1); + + const { rows: pc } = await pool.query(`SELECT * FROM pending_changes`); + expect(pc).toHaveLength(1); + expect(pc[0].status).toBe('pending'); + + const { rows: tasks } = await pool.query(`SELECT * FROM tasks WHERE title='Validate CSV'`); + expect(tasks).toHaveLength(0); // draft only, not applied + }); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `npx vitest run tests/api/companion.test.js` +Expected: FAIL — route not mounted (404) / module not found. + +- [ ] **Step 3: Implement `lib/api/routes/companion.js`** + +```javascript +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../validate.js'; +import { asyncWrap } from '../errors.js'; +import * as conversations from '../../db/repos/conversations.js'; +import * as messages from '../../db/repos/messages.js'; +import * as agents from '../../db/repos/agents.js'; +import { companionRegistry } from '../../ai/agent/tools/index.js'; +import { runTurn } from '../../ai/agent/runtime.js'; +import { makeCallModel, getAnthropicClient, DEFAULT_MODEL } from '../../ai/anthropic.js'; + +const COMPANION_SLUG = 'companion'; + +const SYSTEM = `You are the Void companion — a concise, helpful assistant embedded in a personal knowledge system. +Ground answers in the Void's content: call the context tool to see what the owner is looking at, and search/read before answering factual questions. +When the owner asks you to change something, use propose_change — it creates a draft they approve; you cannot apply changes directly. Be brief.`; + +async function resolveConversation(space_id) { + const agent = await agents.getBySlug(COMPANION_SLUG); + const convo = await conversations.findOrCreateForSpace(space_id, agent.id, { kind: 'user', id: null }); + return { agent, convo }; +} + +// Build Anthropic-format history from stored messages (text-only turns). +function toAnthropicHistory(rows) { + return rows + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ role: m.role, content: m.body })); +} + +export const spacesScopedRouter = Router({ mergeParams: true }); + +spacesScopedRouter.get('/', asyncWrap(async (req, res) => { + const { agent, convo } = await resolveConversation(req.params.space_id); + const rows = await messages.listByConversation(convo.id); + res.json({ + conversation_id: convo.id, + agent: { id: agent.id, slug: agent.slug, name: agent.name }, + messages: rows + }); +})); + +const turnSchema = z.object({ + text: z.string().min(1), + view: z.object({ entityType: z.string(), entityId: z.string() }).partial().optional() +}); + +spacesScopedRouter.post('/turn', + validate({ body: turnSchema }), + asyncWrap(async (req, res) => { + const { agent, convo } = await resolveConversation(req.params.space_id); + const { text, view } = req.body; + + await messages.append(convo.id, { role: 'user', body: text }); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + + const callModel = req.app.locals.callModel + || makeCallModel({ client: getAnthropicClient(), model: agent.model || DEFAULT_MODEL }); + + const history = toAnthropicHistory(await messages.listByConversation(convo.id)); + + let result; + try { + result = await runTurn({ + callModel, + registry: companionRegistry, + system: SYSTEM, + messages: history, + ctx: { + agent: { kind: 'agent', id: agent.id, capabilities: agent.capabilities, scopes: agent.scopes }, + space_id: req.params.space_id, + view: view ?? null, + actor: req.actor + }, + onEvent: (e) => send(e.type, e) + }); + } catch (e) { + send('error', { message: String(e?.message || e) }); + return res.end(); + } + + const assistant = await messages.append(convo.id, { + role: 'assistant', body: result.text, agent_id: agent.id, + metadata: { tool_trace: result.toolTrace, draft_ids: result.draftIds, usage: result.usage } + }); + + send('done', { assistant_message_id: assistant.id, draft_ids: result.draftIds, usage: result.usage }); + res.end(); + }) +); +``` + +- [ ] **Step 4: Add `getBySlug` to the agents repo if absent** + +Check `lib/db/repos/agents.js`. If there is no `getBySlug`, add: + +```javascript +export async function getBySlug(slug) { + const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE slug=$1`, [slug]); + return r; +} +``` + +- [ ] **Step 5: Mount the router in `lib/api/index.js`** + +Add the import near the other route imports: +```javascript +import { spacesScopedRouter as companionRouter } from './routes/companion.js'; +``` +Add the mount line alongside the other `/spaces/:space_id/...` mounts (e.g. after the resources line): +```javascript +api.use('/spaces/:space_id/companion', companionRouter); +``` + +- [ ] **Step 6: Run it to confirm it passes** + +Run: `npx vitest run tests/api/companion.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 7: Run the full suite (no regressions)** + +Run: `npm test` +Expected: all green (existing 247 Node tests + the new ones). + +- [ ] **Step 8: Commit** + +```bash +git add lib/api/routes/companion.js lib/api/index.js lib/db/repos/agents.js tests/api/companion.test.js +git commit -m "feat(api): companion SSE turn endpoint + per-Space history + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 13: Browser SSE reader (`public/sse.js`) + +`EventSource` can't send the `Authorization` header, so read the SSE stream from a `fetch` POST body. Parses `event:`/`data:` frames and invokes a handler per event. + +**Files:** +- Create: `public/sse.js` + +> No automated test (browser-only); verified in the UI smoke (Task 15). + +- [ ] **Step 1: Implement `public/sse.js`** + +```javascript +// Authenticated POST -> SSE reader. Calls onEvent({ type, ...data }) per frame. +const TOKEN_KEY = 'void_token'; + +export async function streamTurn(path, body, onEvent) { + const res = await fetch(path, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + (localStorage.getItem(TOKEN_KEY) || ''), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (!res.ok || !res.body) throw new Error('stream failed: ' + res.status); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const frames = buf.split('\n\n'); + buf = frames.pop(); // keep the trailing partial + for (const frame of frames) { + let event = 'message', data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (data) onEvent({ type: event, ...JSON.parse(data) }); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add public/sse.js +git commit -m "feat(ui): authenticated POST->SSE reader + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 14: Right-rail chat UI (`public/components/rightrail.js`) + +Replace the stub with the approved design: label-led turns (YOU / agent), left/right alignment with accent edges, live tool-activity chips, streamed answer, inline draft card. Uses safe-DOM (`el`/`mount`/`safeHref`); assistant markdown only via the sanitized `html:` path. + +**Files:** +- Modify: `public/components/rightrail.js` +- Modify: `public/style.css` +- Depends on: `public/api.js` (existing `api.get`), `public/sse.js` (Task 13), `marked` + `DOMPurify` (already vendored — confirm `public/vendor/`) + +- [ ] **Step 1: Confirm marked + DOMPurify are available to the browser** + +Run: `ls public/vendor/` +Expected: marked + dompurify bundles present (used by `markdown_editor.js`). If a shared `renderMarkdown(src)` helper exists (grep `public` for `DOMPurify.sanitize`), reuse it; otherwise add `public/markdown.js`: + +```javascript +import { marked } from './vendor/marked.esm.js'; +import DOMPurify from './vendor/purify.es.js'; +export function renderMarkdown(src) { + return DOMPurify.sanitize(marked.parse(src || '')); +} +``` +(Match the exact vendor filenames from `ls public/vendor/`.) + +- [ ] **Step 2: Implement `public/components/rightrail.js`** + +```javascript +// Plan 5: per-Space companion chat. Safe-DOM only; markdown via sanitized html:. +import { el, mount, clear } from '../dom.js'; +import { api } from '../api.js'; +import { streamTurn } from '../sse.js'; +import { renderMarkdown } from '../markdown.js'; +import { state } from '../state.js'; + +const COLLAPSE_KEY = 'void_rail_collapsed'; + +function turnEl(role, agentName, bodyNode) { + return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') }, + el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'COMPANION').toUpperCase()), + el('span', { class: 'msg' }, bodyNode)); +} + +function chipEl(tool, status) { + const icon = tool === 'search' ? '🔍' : tool === 'read' ? '📄' : tool === 'context' ? '🧭' : '📝'; + return el('div', { class: 'tools' }, el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, `${icon} ${tool}`)); +} + +function draftCardEl(d, onResolve) { + const card = el('div', { class: 'draftx', dataset: { pc: d.pending_change_id } }, + el('div', { class: 'dh' }, 'Proposed change'), + el('div', { class: 'dt' }, d.summary || 'a change'), + el('div', { class: 'row' }, + el('button', { class: 'ok', onclick: () => onResolve(d.pending_change_id, 'approved', card) }, 'Approve'), + el('button', { class: 'no', onclick: () => onResolve(d.pending_change_id, 'rejected', card) }, 'Reject'))); + return card; +} + +export async function renderRightrail(root) { + const shell = document.getElementById('shell'); + let collapsed = localStorage.getItem(COLLAPSE_KEY) === 'true'; + if (collapsed) shell.classList.add('rail-collapsed'); + const toggle = () => { + collapsed = !collapsed; + localStorage.setItem(COLLAPSE_KEY, String(collapsed)); + shell.classList.toggle('rail-collapsed', collapsed); + }; + + const log = el('div', { class: 'rail-log' }); + const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask the companion…' }); + const header = el('div', { class: 'rail-hd' }, + el('span', { class: 'who' }, '◆ Companion'), + el('span', { class: 'chev', onclick: toggle, title: 'Collapse' }, '⟩')); + + mount(root, el('div', { class: 'rail-toggle', onclick: toggle, title: 'Companion' }, 'CRADLE'), + el('div', { class: 'rail-chat' }, header, log, el('div', { class: 'rail-inputwrap' }, input))); + + const spaceId = state.spaceId; + if (!spaceId) { + mount(log, el('p', { class: 'muted' }, 'Open a Space to chat with its companion.')); + return; + } + + async function resolveDraft(id, status, cardNode) { + try { + await api.post(`/api/pending-changes/${id}/${status === 'approved' ? 'approve' : 'reject'}`); + cardNode.classList.add('resolved'); + cardNode.appendChild(el('div', { class: 'resolved-tag' }, status)); + } catch (e) { cardNode.appendChild(el('div', { class: 'err' }, 'failed: ' + e.message)); } + } + + function addTurn(role, text) { + const body = role === 'assistant' + ? el('span', { html: renderMarkdown(text) }) + : el('span', {}, text); + const t = turnEl(role, 'Companion', body); + log.appendChild(t); + log.scrollTop = log.scrollHeight; + return body; + } + + // Load history. + const { messages } = await api.get(`/api/spaces/${spaceId}/companion`); + clear(log); + for (const m of messages) { + addTurn(m.role, m.body); + for (const d of (m.metadata?.draft_ids || [])) + log.appendChild(draftCardEl({ pending_change_id: d, summary: 'a change' }, resolveDraft)); + } + + async function send() { + const text = input.value.trim(); + if (!text) return; + input.value = ''; + addTurn('user', text); + let assistantBody = null, acc = ''; + await streamTurn(`/api/spaces/${spaceId}/companion/turn`, { text, view: state.view || null }, (ev) => { + if (ev.type === 'tool' && ev.status !== 'done') log.appendChild(chipEl(ev.tool, ev.status)); + else if (ev.type === 'delta') { + if (!assistantBody) assistantBody = addTurn('assistant', ''); + acc += ev.text; assistantBody.innerHTML = renderMarkdown(acc); + } else if (ev.type === 'draft') log.appendChild(draftCardEl(ev, resolveDraft)); + else if (ev.type === 'error') log.appendChild(el('div', { class: 'err' }, ev.message)); + log.scrollTop = log.scrollHeight; + }); + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } + }); +} +``` + +> **Note:** confirm `public/state.js` exposes `spaceId` and `view`. If it currently only tracks one of these, extend it (small change) so the rail and router agree on the active Space + view. Check `router.js` for how the current Space is set and mirror it into `state`. + +- [ ] **Step 3: Add styles to `public/style.css`** + +Append rail/turn/chip/draft styles consistent with the approved mockup (label-led, left/right accent edges, dim monospace chips, violet draft card). Use the existing CSS variables in `style.css` for colours where present: + +```css +.rail-chat { display:flex; flex-direction:column; height:100%; } +.rail-hd { display:flex; justify-content:space-between; align-items:center; padding:9px 12px; border-bottom:1px solid var(--border,#262b38); } +.rail-hd .who { font-weight:600; color:var(--accent,#b69cff); } +.rail-hd .chev { cursor:pointer; color:#5b6478; } +.rail-log { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; } +.turn { display:flex; flex-direction:column; max-width:84%; } +.turn .lbl { font-size:9.5px; letter-spacing:.12em; margin-bottom:3px; } +.turn .msg { line-height:1.4; padding:6px 10px; border-radius:8px; background:#141826; } +.turn.you { align-self:flex-end; align-items:flex-end; } +.turn.you .lbl { color:#6f7ce0; } .turn.you .msg { border-right:2px solid #6f7ce0; } +.turn.ai { align-self:flex-start; } .turn.ai .lbl { color:var(--accent,#b69cff); } +.turn.ai .msg { border-left:2px solid var(--accent,#b69cff); } +.tools { align-self:flex-start; } +.tools .chip { font-family:ui-monospace,Menlo,monospace; font-size:10.5px; color:#7d869b; } +.tools .chip.err { color:#e08a8a; } +.draftx { align-self:flex-start; max-width:90%; border:1px solid #3a2f5e; background:#1a1530; border-radius:9px; padding:9px 11px; } +.draftx .dh { font-size:9.5px; text-transform:uppercase; letter-spacing:.1em; color:#9b7dff; } +.draftx .dt { color:#e3e0f5; margin:4px 0 9px; } +.draftx .row { display:flex; gap:6px; } +.draftx .ok { background:#2a6f4f; color:#d9ffe9; border:none; border-radius:6px; padding:4px 12px; } +.draftx .no { background:#2a2f3d; color:#aeb6c7; border:none; border-radius:6px; padding:4px 12px; } +.draftx.resolved { opacity:.55; } .resolved-tag { font-size:10px; text-transform:uppercase; color:#7d869b; margin-top:6px; } +.rail-inputwrap { border-top:1px solid var(--border,#262b38); padding:9px 12px; } +.rail-input { width:100%; resize:none; background:#0c0e14; color:#c9d1e0; border:1px solid #262b38; border-radius:8px; padding:7px 9px; } +.err { color:#e08a8a; font-size:12px; } +``` + +- [ ] **Step 4: Commit** + +```bash +git add public/components/rightrail.js public/markdown.js public/style.css public/state.js +git commit -m "feat(ui): right-rail companion chat — streaming, tool chips, inline drafts + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 15: Verify the pending-changes approve/reject endpoints exist for the rail + +The draft card POSTs to `/api/pending-changes/:id/approve` and `/reject`. Confirm those routes exist (Plan 2 built the inbox) and return the resolved row; if the paths differ, align the rail's `resolveDraft` calls to the real endpoints. + +**Files:** +- Inspect: `lib/api/routes/pending_changes.js` +- Modify (only if needed): `public/components/rightrail.js` + +- [ ] **Step 1: Inspect the routes** + +Run: `grep -nE "approve|reject|router\.(post|patch)" lib/api/routes/pending_changes.js` +Expected: approve/reject endpoints exist. Note the exact method + path. + +- [ ] **Step 2: Align the rail if the paths/methods differ** + +If e.g. the API uses `PATCH /api/pending-changes/:id` with `{ status }`, change `resolveDraft` accordingly: +```javascript +await api.patch(`/api/pending-changes/${id}`, { status }); +``` + +- [ ] **Step 3: Confirm the Inbox view reads the same rows** + +Run: `grep -n "pending-changes" public/views/inbox.js` +Expected: the Inbox lists `/api/pending-changes` — confirming a draft approved in chat disappears from the Inbox (same row, shared state). + +- [ ] **Step 4: Commit (only if the rail changed)** + +```bash +git add public/components/rightrail.js +git commit -m "fix(ui): align draft card with the pending-changes resolve endpoint + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 16: Manual UI smoke + deploy alpha-5 + +**Files:** +- Modify: `package.json` (version), `CHANGELOG.md` +- Create: `docs/plan-5-complete.md` + +- [ ] **Step 1: Full test suite green** + +Run: `npm test` +Expected: all green. + +- [ ] **Step 2: Local smoke against the dev DB** + +Set `ANTHROPIC_API_KEY` in the dev `.env`, start the server (`npm start`), open the SPA, pick a Space, and: +- ask a question → confirm tool chips appear, the answer streams in, markdown renders. +- ask it to "create a task to X" → confirm an inline draft card appears, **and** the same row shows in the Inbox. +- approve from chat → confirm the task now exists (`/api/tasks`) and the Inbox row clears. +- reject another draft → confirm it does not apply. + +- [ ] **Step 3: Bump version + changelog** + +Set `package.json` version to `2.0.0-alpha.5`. Add a `CHANGELOG.md` entry summarising the companion chat (scope B). Write `docs/plan-5-complete.md` (mirror the structure of `docs/plan-4-complete.md`: what shipped, files, security notes, deferred items → C personas/MCP/Vaultwarden). + +- [ ] **Step 4: Commit** + +```bash +git add package.json CHANGELOG.md docs/plan-5-complete.md +git commit -m "chore: version 2.0.0-alpha.5 + plan-5 completion doc + +Co-Authored-By: Claude Opus 4.8 " +``` + +- [ ] **Step 5: Deploy (standing rule: snapshot first)** + +On Z: `pct snapshot 310 pre_alpha5_deploy_` and `pct snapshot 311 pre_alpha5_deploy_`. +Ensure `ANTHROPIC_API_KEY` is set in `/opt/void-server/.env` on CT 311 (mode 600, owned by `void`). +Then from the repo: `TARGET=root@192.168.1.216 ./deploy/push.sh`. +Verify: `curl http://192.168.1.216:3000/health` → `version: 2.0.0-alpha.5`, then run the Step-2 smoke against the deployed instance. + +- [ ] **Step 6: Update memory** + +Update `project_void_v2_execution.md`: Plan 5 complete + alpha-5 deployed; companion chat live; note deferred C personas / MCP / Vaultwarden. + +--- + +## Self-review notes (for the planner) + +- **Spec coverage:** runtime (T10–11), shared/extensible registry (T5, T9), four tools (T6–8), SSE turn lifecycle + one-assistant-message-with-trace (T12), per-Space ambient conversation (T3–4, T12), inline draft card synced with Inbox (T14–15), env/file key handling (T2, T10), prompt-injection containment via approval-only `propose_change` (T8 test asserts no apply), cost guardrail via `maxTokens` + `maxIterations` (T10–11), tests incl. security (T8, T12). All spec §s map to a task. +- **Type/name consistency:** `callModel({system,messages,tools,onTextDelta}) -> {text,toolUses,stopReason,usage}` is used identically in T10, T11, T12. `ctx = {agent,space_id,view,actor}` consistent across T6–8 and T12. Event types `tool|delta|draft|done|error` consistent across T11–14. `companionRegistry` name consistent T9/T12. +- **Known external-API risk:** the exact `@anthropic-ai/sdk` streaming/`finalMessage()` surface (T10) may differ by version — flagged inline with a context7 check and a stable return-shape contract so the rest of the plan is insulated. +- **Assumptions to verify during execution (flagged in-task):** `agents.getBySlug` may need adding (T12 S4); `pending-changes` resolve path/method (T15); `state.js` exposing `spaceId`/`view` (T14); exact vendor filenames for marked/DOMPurify (T14 S1).