docs: Yerin online implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
607
docs/superpowers/plans/2026-06-04-yerin-online.md
Normal file
607
docs/superpowers/plans/2026-06-04-yerin-online.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# Yerin Online 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:** Bring Yerin (read-only security agent) online with a global chat surface, extracting the shared agent-chat foundation (backend `run_turn` + frontend `agent_chat`) that Dross also rides on.
|
||||
|
||||
**Architecture:** Extract the turn-runner from `companion.js` into `lib/ai/agent/run_turn.js` and personas into `lib/ai/personas/`. Add a global (`space_id NULL`) Yerin endpoint reusing them with `VOID_TOOL_REGISTRY=security`. On the frontend, extract `components/agent_chat.js` from `rightrail.js`; add a `#/sentinel` view that uses it (no draft cards).
|
||||
|
||||
**Tech Stack:** Node 22 ESM, Express 5, `@modelcontextprotocol/sdk`, vanilla-JS no-build SPA, vitest + supertest (serial).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-04-yerin-online-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `conversations.findOrCreateGlobal`
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/db/repos/conversations.js`
|
||||
- Test: `tests/db/conversations_global.test.js`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
// tests/db/conversations_global.test.js
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import * as agents from '../../lib/db/repos/agents.js';
|
||||
import * as conversations from '../../lib/db/repos/conversations.js';
|
||||
|
||||
const owner = { kind: 'user', id: null };
|
||||
beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||||
|
||||
describe('findOrCreateGlobal', () => {
|
||||
it('creates one space-less open conversation and reuses it', async () => {
|
||||
const y = await agents.getBySlug('yerin'); // seeded by 011_yerin.sql
|
||||
const c1 = await conversations.findOrCreateGlobal(y.id, owner);
|
||||
expect(c1.space_id).toBeNull();
|
||||
expect(c1.agent_id).toBe(y.id);
|
||||
const c2 = await conversations.findOrCreateGlobal(y.id, owner);
|
||||
expect(c2.id).toBe(c1.id);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → FAIL** (`npx vitest run tests/db/conversations_global.test.js`) — `findOrCreateGlobal is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement** — append to `lib/db/repos/conversations.js`:
|
||||
|
||||
```js
|
||||
export async function findOrCreateGlobal(agent_id, actor) {
|
||||
const { rows: [existing] } = await pool.query(
|
||||
`SELECT * FROM conversations
|
||||
WHERE agent_id=$1 AND space_id IS NULL AND status='open'
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[agent_id]
|
||||
);
|
||||
if (existing) return existing;
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO conversations(title, agent_id, metadata) VALUES($1,$2,$3) RETURNING *`,
|
||||
[null, agent_id, {}]
|
||||
);
|
||||
await recordAudit(actor, 'create', 'conversation', r.id, null, r);
|
||||
return r;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → PASS.**
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): conversations.findOrCreateGlobal for space-less agents"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Personas module
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/ai/personas/index.js`
|
||||
- Test: `tests/ai/personas.test.js`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
// tests/ai/personas.test.js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PERSONAS, personaFor } from '../../lib/ai/personas/index.js';
|
||||
|
||||
describe('personas', () => {
|
||||
it('has companion (Dross) and yerin personas keyed by agent slug', () => {
|
||||
expect(PERSONAS.companion).toMatch(/Dross/);
|
||||
expect(PERSONAS.yerin).toMatch(/Yerin/);
|
||||
});
|
||||
it('personaFor falls back to companion for unknown slugs', () => {
|
||||
expect(personaFor('yerin')).toBe(PERSONAS.yerin);
|
||||
expect(personaFor('nope')).toBe(PERSONAS.companion);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → FAIL** (module missing).
|
||||
|
||||
- [ ] **Step 3: Implement** — create `lib/ai/personas/index.js`. Move the existing `SYSTEM` constant's value **verbatim** out of `lib/api/routes/companion.js` into `PERSONAS.companion`, and add Yerin's:
|
||||
|
||||
```js
|
||||
// Agent personas keyed by agent slug. Dross's text moved verbatim from
|
||||
// companion.js. persona_path file loading is a later (migration) concern.
|
||||
export const PERSONAS = {
|
||||
companion: `<<PASTE the exact current SYSTEM string from companion.js here>>`,
|
||||
yerin: `You are Yerin — once the Sage of the Endless Sword, blade of the Akura clan; now the sentinel of this homelab, The Void. You notice the threat first and you call it. Disciplined, direct, economical with words — a blade wastes no motion. You investigate with your tools and report plainly: what you found, how serious it is, and what the owner should do about it. You never speculate without evidence, and you NEVER pretend to have fixed anything — you have eyes to see and a voice to warn, not hands to act; remediation is the owner's to perform. Before answering, call the relevant tools — audit_log, agent_inventory, pending_review, resource_exposure, token_audit — and read the evidence; do not guess. Reference the Cradle world naturally but never at the cost of being useful. Be concise.`
|
||||
};
|
||||
|
||||
export function personaFor(slug) {
|
||||
return PERSONAS[slug] || PERSONAS.companion;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → PASS.**
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): personas module (Dross + Yerin), keyed by slug"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `run_turn.js` shared turn-runner
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/ai/agent/run_turn.js`
|
||||
- Test: `tests/ai/agent/run_turn.test.js`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```js
|
||||
// tests/ai/agent/run_turn.test.js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
vi.mock('../../../lib/ai/claude_cli.js', () => ({ runClaudeTurn: vi.fn() }));
|
||||
import { runClaudeTurn } from '../../../lib/ai/claude_cli.js';
|
||||
import { runAgentTurn } from '../../../lib/ai/agent/run_turn.js';
|
||||
|
||||
beforeEach(() => runClaudeTurn.mockReset());
|
||||
|
||||
describe('runAgentTurn', () => {
|
||||
it('builds the MCP config (registry + tools + agent + space) and forwards to runClaudeTurn', async () => {
|
||||
let captured;
|
||||
runClaudeTurn.mockImplementation(async (opts) => {
|
||||
captured = { ...opts, cfg: JSON.parse(await readFile(opts.mcpConfigPath, 'utf8')) };
|
||||
return { text: 'ok', toolTrace: [], usage: null };
|
||||
});
|
||||
const out = await runAgentTurn({
|
||||
agent: { id: 'a1', slug: 'yerin', capabilities: { read: true }, scopes: {} },
|
||||
persona: 'YERIN', registryName: 'security',
|
||||
toolNames: ['mcp__void__audit_log'], spaceId: null,
|
||||
sessionId: 'c1', userText: 'check', claudeExe: 'claude'
|
||||
});
|
||||
expect(out.text).toBe('ok');
|
||||
expect(captured.systemPrompt).toBe('YERIN');
|
||||
expect(captured.tools).toEqual(['mcp__void__audit_log']);
|
||||
expect(captured.allowedTools).toEqual(['mcp__void__audit_log']);
|
||||
const env = captured.cfg.mcpServers.void.env;
|
||||
expect(env.VOID_TOOL_REGISTRY).toBe('security');
|
||||
expect(env.VOID_SPACE_ID).toBe('');
|
||||
expect(JSON.parse(env.VOID_AGENT_JSON).id).toBe('a1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → FAIL** (module missing).
|
||||
|
||||
- [ ] **Step 3: Implement** — create `lib/ai/agent/run_turn.js`:
|
||||
|
||||
```js
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { runClaudeTurn } from '../claude_cli.js';
|
||||
|
||||
// Absolute path to the MCP stdio server the claude child spawns.
|
||||
const STDIO_PATH = fileURLToPath(new URL('../../mcp/companion-stdio.js', import.meta.url));
|
||||
|
||||
/**
|
||||
* Shared agent turn-runner: builds the per-turn MCP config (selecting the tool
|
||||
* registry + injecting agent/space/view), runs one claude turn, cleans up.
|
||||
* SSE/persistence stay in the route. Returns runClaudeTurn's result.
|
||||
*/
|
||||
export async function runAgentTurn({
|
||||
agent, persona, registryName, toolNames, spaceId = null, view = null,
|
||||
sessionId, resume = false, userText, claudeExe = 'claude', home, onEvent
|
||||
}) {
|
||||
const agentActor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities, scopes: agent.scopes };
|
||||
const mcpConfigPath = join(tmpdir(), `void-mcp-${randomUUID()}.json`);
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
void: {
|
||||
command: process.execPath,
|
||||
args: [STDIO_PATH],
|
||||
env: {
|
||||
VOID_TOOL_REGISTRY: registryName || '',
|
||||
VOID_SPACE_ID: spaceId || '',
|
||||
VOID_AGENT_JSON: JSON.stringify(agentActor),
|
||||
VOID_VIEW_JSON: view ? JSON.stringify(view) : '',
|
||||
DATABASE_URL: process.env.DATABASE_URL || '',
|
||||
OLLAMA_URL: process.env.OLLAMA_URL || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await writeFile(mcpConfigPath, JSON.stringify(mcpConfig));
|
||||
try {
|
||||
return await runClaudeTurn({
|
||||
sessionId, resume, systemPrompt: persona, userText,
|
||||
mcpConfigPath, tools: toolNames, allowedTools: toolNames,
|
||||
claudeExe, home, onEvent
|
||||
});
|
||||
} finally {
|
||||
unlink(mcpConfigPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → PASS.**
|
||||
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(agents): shared runAgentTurn turn-runner"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Refactor `companion.js` onto the shared foundation
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/api/routes/companion.js`
|
||||
|
||||
- [ ] **Step 1: Refactor.** In `lib/api/routes/companion.js`:
|
||||
- Delete the local `SYSTEM` constant; `import { personaFor } from '../../ai/personas/index.js';`.
|
||||
- Delete now-unused imports `writeFile, unlink` (fs/promises), `join` (path), `tmpdir` (os), `randomUUID` (crypto), and the `COMPANION_STDIO_PATH` constant — all now inside `run_turn.js`. Keep `fileURLToPath` only if still used elsewhere (it isn't after removing `COMPANION_STDIO_PATH` → remove it too).
|
||||
- `import { runAgentTurn } from '../../ai/agent/run_turn.js';`
|
||||
- In the `/turn` handler, **remove** the `mcpConfigPath`/`mcpConfig`/`writeFile` block and the trailing `unlink(mcpConfigPath)` calls. Replace the `runClaudeTurn({...})` call with:
|
||||
|
||||
```js
|
||||
result = await runAgentTurn({
|
||||
agent,
|
||||
persona: personaFor(agent.slug),
|
||||
registryName: undefined, // default → companionRegistry
|
||||
toolNames: companionTools,
|
||||
spaceId: req.params.space_id,
|
||||
view,
|
||||
sessionId: convo.id,
|
||||
resume,
|
||||
userText: text,
|
||||
claudeExe,
|
||||
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||
onEvent: (e) => { /* keep the EXISTING onEvent body unchanged: delta/tool/tool_result(draft parse)/error */ }
|
||||
});
|
||||
```
|
||||
Keep the existing `onEvent` body (delta/tool/tool_result draft-parsing/error → `send(...)`) exactly as-is. Keep `companionTools`, the post-stream `messages.append(assistant…)` with `draft_ids`, and the `done` event. The `catch` block keeps `send('error',…)` + `res.end()` but drop its `unlink(mcpConfigPath)` line.
|
||||
|
||||
- [ ] **Step 2: Run companion + related tests → PASS (regression)**
|
||||
|
||||
Run: `npx vitest run tests/api/companion.test.js`
|
||||
Expected: PASS unchanged (SSE delta/tool/draft/done + user/assistant persistence).
|
||||
|
||||
- [ ] **Step 3: Commit** — `git add -A && git commit -m "refactor(companion): ride on shared runAgentTurn + personas"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Yerin route + mount + fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/api/routes/security.js`
|
||||
- Create: `tests/fixtures/fake-claude-security.js`
|
||||
- Modify: `lib/api/index.js`
|
||||
- Test: `tests/api/security_yerin.test.js`
|
||||
|
||||
- [ ] **Step 1: Fake claude fixture** — `tests/fixtures/fake-claude-security.js` (shebang; emits text deltas + one security tool call + result, NO draft):
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
const TOOL_USE_ID = 'toolu_yerin_01';
|
||||
const lines = [
|
||||
{ type: 'system', subtype: 'init', session_id: 'fake-yerin', tools: [], cwd: '/tmp' },
|
||||
{ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } },
|
||||
{ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'No new threats.' } } },
|
||||
{ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } },
|
||||
{ type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: TOOL_USE_ID, name: 'mcp__void__audit_log', input: {} } } },
|
||||
{ type: 'stream_event', event: { type: 'content_block_stop', index: 1 } },
|
||||
{ type: 'tool_result', tool_use_id: TOOL_USE_ID, content: [{ type: 'text', text: JSON.stringify({ entries: [] }) }] },
|
||||
{ type: 'result', subtype: 'success', is_error: false, result: 'No new threats.', stop_reason: 'end_turn', session_id: 'fake-yerin', total_cost_usd: 0.0001, usage: { input_tokens: 40, output_tokens: 4 } }
|
||||
];
|
||||
for (const l of lines) process.stdout.write(JSON.stringify(l) + '\n');
|
||||
process.exit(0);
|
||||
```
|
||||
Then `chmod +x tests/fixtures/fake-claude-security.js`.
|
||||
|
||||
- [ ] **Step 2: Failing test** — `tests/api/security_yerin.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { fileURLToPath } from 'url';
|
||||
import request from 'supertest';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import { createApp } from '../../server.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
|
||||
const FAKE = fileURLToPath(new URL('../fixtures/fake-claude-security.js', import.meta.url));
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
await resetDb(); await migrateUp();
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
app.locals.claudeExe = FAKE;
|
||||
});
|
||||
const auth = (r) => r.set('Authorization', 'Bearer test-token');
|
||||
|
||||
describe('Yerin security API', () => {
|
||||
it('GET creates the global conversation and returns Yerin + empty history', async () => {
|
||||
const res = await auth(request(app).get('/api/security/yerin'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.agent.slug).toBe('yerin');
|
||||
expect(res.body.conversation_id).toBeTruthy();
|
||||
expect(res.body.messages).toEqual([]);
|
||||
});
|
||||
it('POST /turn streams SSE and persists user+assistant; no draft event', async () => {
|
||||
const res = await auth(request(app).post('/api/security/yerin/turn')).send({ text: 'any new threats?' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
||||
expect(res.text).toMatch(/event: delta/);
|
||||
expect(res.text).toMatch(/event: tool/);
|
||||
expect(res.text).toMatch(/event: done/);
|
||||
expect(res.text).not.toMatch(/event: draft/);
|
||||
const { rows: msgs } = await pool.query(`SELECT role, body FROM messages ORDER BY created_at`);
|
||||
expect(msgs.map(m => m.role)).toEqual(['user', 'assistant']);
|
||||
expect(msgs[1].body).toBe('No new threats.');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run → FAIL** (route 404).
|
||||
|
||||
- [ ] **Step 4: Implement the route** — `lib/api/routes/security.js`:
|
||||
|
||||
```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 { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const YERIN_SLUG = 'yerin';
|
||||
const SECURITY_TOOLS = [
|
||||
'mcp__void__audit_log', 'mcp__void__agent_inventory', 'mcp__void__pending_review',
|
||||
'mcp__void__resource_exposure', 'mcp__void__token_audit'
|
||||
];
|
||||
|
||||
async function resolveYerin() {
|
||||
const agent = await agents.getBySlug(YERIN_SLUG);
|
||||
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||
return { agent, convo };
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/yerin', asyncWrap(async (_req, res) => {
|
||||
const { agent, convo } = await resolveYerin();
|
||||
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) });
|
||||
|
||||
router.post('/yerin/turn', validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||
const { agent, convo } = await resolveYerin();
|
||||
const { text } = req.body;
|
||||
const resume = (await messages.listByConversation(convo.id)).length > 0;
|
||||
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 claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAgentTurn({
|
||||
agent, persona: personaFor(agent.slug), registryName: 'security',
|
||||
toolNames: SECURITY_TOOLS, spaceId: null, view: null,
|
||||
sessionId: convo.id, resume, userText: text, claudeExe,
|
||||
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'delta') send('delta', { type: 'delta', text: e.text });
|
||||
else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||
else if (e.type === 'error') send('error', { type: 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
} 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, usage: result.usage }
|
||||
});
|
||||
send('done', { assistant_message_id: assistant.id, usage: result.usage });
|
||||
res.end();
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Mount it** — in `lib/api/index.js`: `import { router as securityRouter } from './routes/security.js';` and, alongside the other `api.use(...)` lines, `api.use('/security', securityRouter);`.
|
||||
|
||||
- [ ] **Step 6: Run → PASS.** (`npx vitest run tests/api/security_yerin.test.js`)
|
||||
- [ ] **Step 7: Commit** — `git add -A && git commit -m "feat(yerin): global security chat endpoint /api/security/yerin"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — extract `agent_chat` component
|
||||
|
||||
**Files:**
|
||||
- Create: `public/components/agent_chat.js`
|
||||
|
||||
No automated test (no-build vanilla-JS convention; verified manually in Task 8).
|
||||
|
||||
- [ ] **Step 1: Create `public/components/agent_chat.js`** — generalize the chat mechanics currently in `rightrail.js`. It owns the log + input + send loop; the host supplies URLs/labels:
|
||||
|
||||
```js
|
||||
// Reusable agent chat panel. 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';
|
||||
|
||||
function turnEl(role, agentName, bodyNode) {
|
||||
return el('div', { class: 'turn ' + (role === 'user' ? 'you' : 'ai') },
|
||||
el('span', { class: 'lbl' }, role === 'user' ? 'YOU' : (agentName || 'AGENT').toUpperCase()),
|
||||
el('span', { class: 'msg' }, bodyNode));
|
||||
}
|
||||
function chipEl(tool, status, toolLabels) {
|
||||
const name = String(tool || '').replace(/^mcp__void__/, '');
|
||||
return el('div', { class: 'tools' },
|
||||
el('span', { class: 'chip' + (status === 'error' ? ' err' : '') }, (toolLabels[name] || name)));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts log + input into `logEl`/`inputEl` and wires send.
|
||||
* opts: { historyUrl, turnUrl, agentName, showDrafts, toolLabels, turnBody }
|
||||
* turnBody(text) → POST body object (lets callers add view, etc.)
|
||||
*/
|
||||
export function wireAgentChat({ logEl, inputEl, historyUrl, turnUrl, agentName, showDrafts = false, toolLabels = {}, turnBody = (text) => ({ text }) }) {
|
||||
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)); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
clear(logEl);
|
||||
let data;
|
||||
try { data = await api.get(historyUrl); }
|
||||
catch (e) { mount(logEl, el('p', { class: 'muted' }, 'Could not load history.')); return; }
|
||||
for (const m of (data.messages || [])) {
|
||||
const body = el('div', { class: 'md' }); body.innerHTML = renderMarkdown(m.body || '');
|
||||
logEl.appendChild(turnEl(m.role, agentName, body));
|
||||
}
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
inputEl.value = '';
|
||||
logEl.appendChild(turnEl('user', agentName, document.createTextNode(text)));
|
||||
const aiBody = el('div', { class: 'md' });
|
||||
const aiTurn = turnEl('assistant', agentName, aiBody);
|
||||
logEl.appendChild(aiTurn);
|
||||
let acc = '';
|
||||
try {
|
||||
await streamTurn(turnUrl, turnBody(text), (e) => {
|
||||
if (e.type === 'delta') { acc += e.text || ''; aiBody.innerHTML = renderMarkdown(acc); }
|
||||
else if (e.type === 'tool') aiTurn.appendChild(chipEl(e.tool, e.status, toolLabels));
|
||||
else if (e.type === 'draft' && showDrafts) aiTurn.appendChild(draftCardEl(e, resolveDraft));
|
||||
else if (e.type === 'error') aiTurn.appendChild(el('div', { class: 'err' }, e.message || 'error'));
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
});
|
||||
} catch (e) { aiTurn.appendChild(el('div', { class: 'err' }, 'stream failed: ' + e.message)); }
|
||||
}
|
||||
|
||||
inputEl.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); send(); }
|
||||
});
|
||||
return { load, send };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit** — `git add -A && git commit -m "feat(ui): extract reusable agent_chat panel"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — point Dross's rail at `agent_chat`
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/components/rightrail.js`
|
||||
|
||||
- [ ] **Step 1: Refactor `rightrail.js`** to keep its rail chrome (toggle/collapse/header) but delegate chat to `wireAgentChat`. Replace the internal history/`streamTurn`/turn/chip/draft logic with a call to `wireAgentChat` using the Dross URLs:
|
||||
|
||||
```js
|
||||
import { wireAgentChat } from './agent_chat.js';
|
||||
// …inside initChat(spaceId), after building `log` + `input`:
|
||||
const COMPANION_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input,
|
||||
historyUrl: `/api/spaces/${spaceId}/companion`,
|
||||
turnUrl: `/api/spaces/${spaceId}/companion/turn`,
|
||||
agentName: 'Companion', showDrafts: true, toolLabels: COMPANION_LABELS,
|
||||
turnBody: (text) => ({ text, view: state.view || null })
|
||||
});
|
||||
await chat.load();
|
||||
```
|
||||
Keep the `!spaceId` guard ("Open a Space to chat…") and the Space-change re-init. Remove the now-duplicated helper functions from `rightrail.js`.
|
||||
|
||||
- [ ] **Step 2: Manual verify** (after Task 9 deploy, or `node server.js` locally): open a Space → Dross rail loads history, a turn streams deltas + tool chips, a `propose_change` still shows an Approve/Reject card that resolves. (No automated test — vanilla no-build UI.)
|
||||
|
||||
- [ ] **Step 3: Commit** — `git add -A && git commit -m "refactor(ui): Dross rail uses agent_chat"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Frontend — `#/sentinel` Yerin view + nav
|
||||
|
||||
**Files:**
|
||||
- Create: `public/views/sentinel.js`
|
||||
- Modify: `public/router.js`, `public/app.js`, `public/components/sidebar.js`
|
||||
|
||||
- [ ] **Step 1: Create `public/views/sentinel.js`**:
|
||||
|
||||
```js
|
||||
import { el, mount } from '../dom.js';
|
||||
import { wireAgentChat } from '../components/agent_chat.js';
|
||||
|
||||
const YERIN_LABELS = {
|
||||
audit_log: '🗒️ reading the audit trail', agent_inventory: '👁️ reviewing agents',
|
||||
pending_review: '⏳ checking the approval queue', resource_exposure: '🛡️ checking exposure',
|
||||
token_audit: '🔑 auditing tokens'
|
||||
};
|
||||
|
||||
export async function render(main) {
|
||||
const log = el('div', { class: 'rail-log sentinel-log' });
|
||||
const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask Yerin about the Void’s security…' });
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Sentinel — Yerin'),
|
||||
el('p', { class: 'view-sub' }, 'Read-only security & observability. She watches, reports, and warns — she never acts.'),
|
||||
el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input)));
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input,
|
||||
historyUrl: '/api/security/yerin', turnUrl: '/api/security/yerin/turn',
|
||||
agentName: 'Yerin', showDrafts: false, toolLabels: YERIN_LABELS
|
||||
});
|
||||
await chat.load();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route** — in `public/router.js` add to `ROUTES` (before `home`): `{ name: 'sentinel', re: /^\/sentinel$/, keys: [] },` and add `// #/sentinel Yerin security view` to the header comment.
|
||||
|
||||
- [ ] **Step 3: Register the view loader** — in `public/app.js` `VIEWS`: `sentinel: () => import('./views/sentinel.js'),`.
|
||||
|
||||
- [ ] **Step 4: Sidebar nav** — in `public/components/sidebar.js`, alongside the global links (near `navItem('Sacred Valley', '/sacred-valley')`), add `navItem('Sentinel', '/sentinel'),`.
|
||||
|
||||
- [ ] **Step 5: Manual verify** — nav to `#/sentinel`: Yerin view renders, a question streams deltas + security tool chips, NO approve/reject cards appear, history persists across reload. Dross rail still works on Space views.
|
||||
|
||||
- [ ] **Step 6: Commit** — `git add -A && git commit -m "feat(ui): Sentinel view — Yerin global security chat"`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Release alpha.15 + deploy
|
||||
|
||||
**Files:** `package.json`, `server.js`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Bump** `package.json` + `server.js` `VERSION` → `2.0.0-alpha.15`.
|
||||
- [ ] **Step 2: CHANGELOG** — prepend:
|
||||
|
||||
```markdown
|
||||
## 2.0.0-alpha.15 — Yerin online (Agent Layer brick 1)
|
||||
- **Yerin**, the read-only security agent, is now a usable agent: a global `#/sentinel` chat surface backed by her 5 security tools (audit/agents/pending/exposure/tokens). She investigates + reports; she never acts.
|
||||
- Extracted the **shared agent-chat foundation** — `runAgentTurn` (backend) + `agent_chat` (frontend) — now used by both Dross and Yerin. Personas live in `lib/ai/personas/`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full suite** — `npx vitest run` → all green (serial).
|
||||
- [ ] **Step 4: Commit** — `git add -A && git commit -m "chore: release 2.0.0-alpha.15 (Yerin online)"`
|
||||
- [ ] **Step 5: Deploy** — `bash deploy/push.sh` → `/health` reports `2.0.0-alpha.15`, `db_ok`. (No new migration; code-only.)
|
||||
- [ ] **Step 6: Prod smoke** — `https://void2-app.hynesy.com` → `#/sentinel`, send Yerin a question, confirm streamed reply + tool chips; confirm Dross still works on a Space.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** Shared backend service → Task 3; personas module → Task 2; Yerin global endpoint + `findOrCreateGlobal` → Tasks 1, 5; Dross refactor (regression) → Task 4; `agent_chat` extraction → Task 6; rail refactor → Task 7; `#/sentinel` view + nav → Task 8; read-only/no-drafts → Tasks 5 (`not /draft/`) + 8 (`showDrafts:false`); release/deploy → Task 9. All covered.
|
||||
|
||||
**Placeholder scan:** One intentional move-marker — `<<PASTE the exact current SYSTEM string from companion.js>>` in Task 2 (verbatim relocation of an existing constant, not new content).
|
||||
|
||||
**Type consistency:** `runAgentTurn({agent, persona, registryName, toolNames, spaceId, view, sessionId, resume, userText, claudeExe, home, onEvent})` — same shape in Task 3 def + Tasks 4/5 calls. `personaFor(slug)` keyed by agent slug (`companion`/`yerin`) — consistent Tasks 2/4/5. `wireAgentChat({logEl,inputEl,historyUrl,turnUrl,agentName,showDrafts,toolLabels,turnBody})` — same in Task 6 def + Tasks 7/8 calls. `findOrCreateGlobal(agent_id, actor)` — Tasks 1/5 consistent.
|
||||
Reference in New Issue
Block a user