Files
Void-Homelab/docs/superpowers/plans/2026-06-01-void-v2-plan5-companion-chat.md
2026-06-01 18:01:01 +10:00

1648 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <noreply@anthropic.com>"
```
---
## 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)
// <raw> -> 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
- [ ] **Step 5: Deploy (standing rule: snapshot first)**
On Z: `pct snapshot 310 pre_alpha5_deploy_<ts>` and `pct snapshot 311 pre_alpha5_deploy_<ts>`.
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 (T1011), shared/extensible registry (T5, T9), four tools (T68), SSE turn lifecycle + one-assistant-message-with-trace (T12), per-Space ambient conversation (T34, T12), inline draft card synced with Inbox (T1415), env/file key handling (T2, T10), prompt-injection containment via approval-only `propose_change` (T8 test asserts no apply), cost guardrail via `maxTokens` + `maxIterations` (T1011), 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 T68 and T12. Event types `tool|delta|draft|done|error` consistent across T1114. `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).