# 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).