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

62 KiB
Raw Permalink Blame History

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.sqlconversations.space_id + index + seed default companion agent
  • lib/ai/secret.jsresolveSecret('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.jsrunTurn(...) tool-use loop
  • lib/api/routes/companion.jsGET /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:

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_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
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
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 getBySlug to 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 (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:

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.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:

.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/healthversion: 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).