62 KiB
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, useimport { pool } from '../pool.js', return rows. Mutations emit audit viarecordAudit(actor, action, type, id, before, after)from./audit_stub.js. - Routes use
Router(),validate({ body|query|params: zodSchema })(parsed body lands onreq.body, query onreq.validatedQuery), andasyncWrapfrom../errors.js.req.actoris set byagentOrOwner: 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/mirroringlib/paths. Run withnpm test(vitest,fileParallelism:false). DB tests useresetDb()fromtests/helpers/db.js+migrateUp()fromlib/db/migrate.js. The owner token in tests isprocess.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 agentlib/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.jslib/ai/agent/tools/index.js— registers all four tools, exports the registrylib/ai/agent/runtime.js—runTurn(...)tool-use looplib/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— addfindOrCreateForSpace(...)lib/api/index.js— mount the companion routerpublic/components/rightrail.js— replace the stub with the chat UIpublic/style.css— rail/turn/chip/draft-card stylespackage.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:
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:
node -e "import('@anthropic-ai/sdk').then(m => console.log(typeof m.default))"
Expected: prints function (the Anthropic class constructor).
- Step 3: Commit
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
// 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
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
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
// 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
-- 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
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
// 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)
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
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
// 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
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
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
// 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
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
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
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
// 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
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
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
// 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
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
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
// 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
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
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)
// 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_deltaevents whose partial JSON must be accumulated and parsed atcontent_block_stop. To keep this testable and robust, the implementation listens to the SDK's higher-levelinputJson/finalMessage()helpers. Use the SDK'sstream.finalMessage()to collect the assembledtool_useblocks rather than hand-assembling JSON. The fake above simulates the assembled result; adjust event names to match@anthropic-ai/sdkif 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
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/sdkmessage 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
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
// 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
// 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
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
// 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
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
getBySlugto the agents repo if absent
Check lib/db/repos/agents.js. If there is no getBySlug, add:
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:
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):
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
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
// 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
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(existingapi.get),public/sse.js(Task 13),marked+DOMPurify(already vendored — confirmpublic/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:
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
// 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.jsexposesspaceIdandview. If it currently only tracks one of these, extend it (small change) so the rail and router agree on the active Space + view. Checkrouter.jsfor how the current Space is set and mirror it intostate.
- 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:
.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
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:
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)
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
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 (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 viamaxTokens+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 typestool|delta|draft|done|errorconsistent across T11–14.companionRegistryname consistent T9/T12. - Known external-API risk: the exact
@anthropic-ai/sdkstreaming/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.getBySlugmay need adding (T12 S4);pending-changesresolve path/method (T15);state.jsexposingspaceId/view(T14); exact vendor filenames for marked/DOMPurify (T14 S1).