From dae4d8fbeed08f5a9fe9e9f86d4688268118f62e Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 13 Jun 2026 06:07:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(agents):=20add=20AgentArtifactIndex=20?= =?UTF-8?q?=E2=80=94=20join=20subagents=20to=20files=20they=20produce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribes to subagentWatcher's subagent:tool_call events and attributes every Write/Edit/MultiEdit/NotebookEdit file_path to its agent. Dedupes repeat writes, classifies by extension (html/image/pdf/document/markdown/text/code/other), bounds memory (FIFO per-agent + per-index caps), and exposes query-by-agent/session/all. Metadata only — records paths, never reads/serves contents; guard enforcement stays in the serving layer. recordExternalArtifact() reserved for image-watcher correlation of Bash-written screenshots. Load-bearing piece for the Agent Gallery view. Verified against live w7 transcripts: 8 mockups correctly attributed with write counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent-artifact-index.ts | 292 ++++++++++++++++++++++++++++++ src/config/map-limits.ts | 6 + test/agent-artifact-index.test.ts | 207 +++++++++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 src/agent-artifact-index.ts create mode 100644 test/agent-artifact-index.test.ts diff --git a/src/agent-artifact-index.ts b/src/agent-artifact-index.ts new file mode 100644 index 00000000..61bbdd1d --- /dev/null +++ b/src/agent-artifact-index.ts @@ -0,0 +1,292 @@ +/** + * @fileoverview Agent Artifact Index — joins each subagent to the files it produced. + * + * The load-bearing piece behind the Agent Gallery view. It answers "which files did + * this subagent write?" by subscribing to {@link subagentWatcher} events rather than + * re-reading transcripts: every `subagent:tool_call` for a file-producing tool + * (Write/Edit/MultiEdit/NotebookEdit) carries the agent id, session id, and the full + * tool input — including `file_path` — so the join is exact and free of duplicate I/O. + * + * What it stores is METADATA ONLY (paths, kinds, write counts). It never reads or + * serves file contents — guard enforcement (realpath, sensitive-path, attachment-guard, + * extension allowlist) lives in the serving layer that turns these paths into + * attachment/artifact ids. Treat the recorded path as untrusted: it originates from a + * subagent transcript and must pass the guard chain before anything reads it. + * + * Key exports: + * - `AgentArtifactIndex` class — EventEmitter; `start(source?)` / `stop()` lifecycle + * - `agentArtifactIndex` — pre-instantiated singleton + * - `AgentArtifact`, `ArtifactKind`, `ArtifactSource`, `AgentArtifactIndexEvents` — types + * + * Extension point: `recordExternalArtifact()` lets a later image-watcher correlation + * attribute Bash-written screenshots (which never appear as a Write tool_use) to the + * agent that was active when the file landed. The transcript join below stands alone. + * + * @emits artifact:added (new path for an agent), artifact:updated (re-write of a known + * path), artifact:error + * @dependencies subagent-watcher (event source), config/map-limits (bounds) + * @consumedby web/server (SSE broadcast as `agent:artifact`), gallery route + * @module agent-artifact-index + */ + +import { EventEmitter } from 'node:events'; +import { basename, extname, isAbsolute, resolve } from 'node:path'; +import { MAX_ARTIFACTS_PER_AGENT, MAX_TRACKED_AGENTS } from './config/map-limits.js'; +import { subagentWatcher, type SubagentToolCall } from './subagent-watcher.js'; + +// ========== Types ========== + +export type ArtifactKind = 'html' | 'image' | 'pdf' | 'document' | 'markdown' | 'text' | 'code' | 'other'; + +/** How the artifact was attributed to its agent. */ +export type ArtifactSource = 'write' | 'edit' | 'notebook' | 'external'; + +export interface AgentArtifact { + agentId: string; + sessionId: string; + /** Absolute path, normalized (`resolve`d) when absolute; left raw otherwise. */ + path: string; + /** `basename(path)`. */ + filename: string; + /** Lowercased extension including the dot (e.g. `.html`); `''` when none. */ + ext: string; + /** Render hint derived from {@link ext}. Drives the gallery's preview mode. */ + kind: ArtifactKind; + source: ArtifactSource; + /** Epoch ms when this path was first attributed to the agent. */ + firstSeenAt: number; + /** Epoch ms of the most recent write/edit of this path. */ + lastWriteAt: number; + /** Number of write/edit calls observed against this path. */ + writeCount: number; +} + +/** Typed event map (Node 22 typed EventEmitter, matching {@link BashToolParser}). */ +export interface AgentArtifactIndexEvents { + 'artifact:added': [AgentArtifact]; + 'artifact:updated': [AgentArtifact]; + 'artifact:error': [Error]; +} + +/** Minimal structural type for the event source, for testability without the real watcher. */ +type ToolCallEmitter = Pick; + +// ========== Constants ========== + +/** Tools whose successful call means the agent produced/changed a file. */ +const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']); + +const KIND_BY_EXT: Readonly> = { + '.html': 'html', + '.htm': 'html', + '.png': 'image', + '.jpg': 'image', + '.jpeg': 'image', + '.gif': 'image', + '.webp': 'image', + '.avif': 'image', + '.svg': 'image', + '.pdf': 'pdf', + '.docx': 'document', + '.pptx': 'document', + '.xlsx': 'document', + '.md': 'markdown', + '.markdown': 'markdown', + '.txt': 'text', + '.log': 'text', + '.csv': 'text', + '.js': 'code', + '.mjs': 'code', + '.cjs': 'code', + '.ts': 'code', + '.tsx': 'code', + '.jsx': 'code', + '.css': 'code', + '.json': 'code', + '.py': 'code', + '.sh': 'code', + '.yml': 'code', + '.yaml': 'code', +}; + +// ========== Helpers ========== + +function classifyKind(ext: string): ArtifactKind { + return KIND_BY_EXT[ext] ?? 'other'; +} + +function sourceForTool(tool: string): ArtifactSource { + if (tool === 'Write') return 'write'; + if (tool === 'NotebookEdit') return 'notebook'; + return 'edit'; // Edit, MultiEdit +} + +/** + * Resolve `..`/`.` for absolute paths so repeat writes dedupe to one artifact. + * Relative paths are left raw: `resolve()` would anchor them to the SERVER cwd, not the + * agent's, producing a wrong absolute path. Write requires absolute paths, so this is rare. + */ +function normalizePath(p: string): string { + return isAbsolute(p) ? resolve(p) : p; +} + +/** ISO transcript timestamp → epoch ms, falling back to now. */ +function timestampToMs(ts: string | undefined): number { + if (ts) { + const n = Date.parse(ts); + if (!Number.isNaN(n)) return n; + } + return Date.now(); +} + +/** Extract the target path from a file-tool's input (Write/Edit use `file_path`, NotebookEdit `notebook_path`). */ +function extractFilePath(input: Record | undefined): string | undefined { + const candidate = input?.file_path ?? input?.notebook_path; + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined; +} + +// ========== AgentArtifactIndex ========== + +export class AgentArtifactIndex extends EventEmitter { + /** agentId → (normalized path → artifact). Outer map is insertion-ordered for FIFO eviction. */ + private readonly byAgent = new Map>(); + + private source: ToolCallEmitter | null = null; + private readonly onToolCall = (call: SubagentToolCall): void => { + try { + if (!FILE_WRITE_TOOLS.has(call.tool)) return; + const rawPath = extractFilePath(call.fullInput); + if (!rawPath) return; + this.record(call.agentId, call.sessionId, rawPath, sourceForTool(call.tool), timestampToMs(call.timestamp)); + } catch (err) { + this.emit('artifact:error', err instanceof Error ? err : new Error(String(err))); + } + }; + + /** + * Begin attributing artifacts. Idempotent. Defaults to the real {@link subagentWatcher}; + * tests pass a bare EventEmitter and drive `subagent:tool_call` directly. + */ + start(source: ToolCallEmitter = subagentWatcher): void { + if (this.source) { + if (this.source === source) return; + this.stop(); + } + this.source = source; + source.on('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void); + } + + /** Detach from the event source. Retains recorded artifacts (use {@link clear} to drop them). */ + stop(): void { + if (this.source) { + this.source.off('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void); + this.source = null; + } + } + + /** + * Attribute a file discovered outside the transcript — e.g. a screenshot written by a + * Bash `capture.mjs`, correlated by the image-watcher to the agent active when it landed. + * Marked `source: 'external'`. (Wired for a follow-up; not yet called by the image-watcher.) + */ + recordExternalArtifact(agentId: string, sessionId: string, absPath: string, atMs: number = Date.now()): void { + if (typeof absPath !== 'string' || absPath.length === 0) return; + this.record(agentId, sessionId, absPath, 'external', atMs); + } + + private record(agentId: string, sessionId: string, rawPath: string, source: ArtifactSource, atMs: number): void { + const path = normalizePath(rawPath); + + let bucket = this.byAgent.get(agentId); + if (!bucket) { + // Bound the number of tracked agents (FIFO eviction of the oldest agent). + if (this.byAgent.size >= MAX_TRACKED_AGENTS) { + const oldest = this.byAgent.keys().next().value; + if (oldest !== undefined) this.byAgent.delete(oldest); + } + bucket = new Map(); + this.byAgent.set(agentId, bucket); + } + + const existing = bucket.get(path); + if (existing) { + existing.lastWriteAt = Math.max(existing.lastWriteAt, atMs); + existing.firstSeenAt = Math.min(existing.firstSeenAt, atMs); + existing.writeCount += 1; + existing.sessionId = sessionId; // tolerate late/corrected session attribution + this.emit('artifact:updated', existing); + return; + } + + // Bound artifacts per agent (FIFO eviction of the oldest path for this agent). + if (bucket.size >= MAX_ARTIFACTS_PER_AGENT) { + const oldest = bucket.keys().next().value; + if (oldest !== undefined) bucket.delete(oldest); + } + + const ext = extname(path).toLowerCase(); + const artifact: AgentArtifact = { + agentId, + sessionId, + path, + filename: basename(path), + ext, + kind: classifyKind(ext), + source, + firstSeenAt: atMs, + lastWriteAt: atMs, + writeCount: 1, + }; + bucket.set(path, artifact); + this.emit('artifact:added', artifact); + } + + /** Artifacts written by one agent, most-recently-written first. */ + getArtifactsForAgent(agentId: string): AgentArtifact[] { + const bucket = this.byAgent.get(agentId); + if (!bucket) return []; + return [...bucket.values()].sort((a, b) => b.lastWriteAt - a.lastWriteAt); + } + + /** Artifacts across every agent in a Codeman session, most-recently-written first. */ + getArtifactsForSession(sessionId: string): AgentArtifact[] { + const out: AgentArtifact[] = []; + for (const bucket of this.byAgent.values()) { + for (const artifact of bucket.values()) { + if (artifact.sessionId === sessionId) out.push(artifact); + } + } + return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt); + } + + /** Every tracked artifact, most-recently-written first. */ + getAllArtifacts(): AgentArtifact[] { + const out: AgentArtifact[] = []; + for (const bucket of this.byAgent.values()) out.push(...bucket.values()); + return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt); + } + + getArtifact(agentId: string, path: string): AgentArtifact | undefined { + return this.byAgent.get(agentId)?.get(normalizePath(path)); + } + + /** Total number of tracked artifacts across all agents. */ + count(): number { + let n = 0; + for (const bucket of this.byAgent.values()) n += bucket.size; + return n; + } + + /** Drop all artifacts for one agent (e.g. when its subagent window is dismissed). */ + clearAgent(agentId: string): void { + this.byAgent.delete(agentId); + } + + /** Drop everything. */ + clear(): void { + this.byAgent.clear(); + } +} + +/** Pre-instantiated singleton, mirroring `subagentWatcher`. Call `.start()` to attach. */ +export const agentArtifactIndex = new AgentArtifactIndex(); diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index d67082b4..932e8686 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -62,6 +62,12 @@ export const MAX_TRACKED_AGENTS = 500; */ export const MAX_PENDING_TOOL_CALLS = 100; +/** + * Maximum distinct file artifacts tracked per subagent in the artifact index. + * Bounds memory for agents that write many files (FIFO eviction when exceeded). + */ +export const MAX_ARTIFACTS_PER_AGENT = 200; + /** * TTL for orphaned pending tool calls (5 minutes). * If no tool_result received, entry is cleaned up. diff --git a/test/agent-artifact-index.test.ts b/test/agent-artifact-index.test.ts new file mode 100644 index 00000000..921adf4f --- /dev/null +++ b/test/agent-artifact-index.test.ts @@ -0,0 +1,207 @@ +/** + * Tests for AgentArtifactIndex — the subagent → produced-files join. + * + * Driven through a bare EventEmitter standing in for `subagentWatcher`, so no real + * transcript files, tmux, or server are involved. + */ +import { EventEmitter } from 'node:events'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { AgentArtifactIndex, type AgentArtifact } from '../src/agent-artifact-index.js'; +import { MAX_ARTIFACTS_PER_AGENT } from '../src/config/map-limits.js'; + +/** Build a `subagent:tool_call` payload like the watcher emits. */ +function toolCall(over: { + agentId?: string; + sessionId?: string; + tool: string; + input: Record; + timestamp?: string; +}) { + return { + agentId: over.agentId ?? 'agent-1', + sessionId: over.sessionId ?? 'sess-1', + timestamp: over.timestamp ?? '2026-06-13T05:44:00.000Z', + tool: over.tool, + input: over.input, + toolUseId: 'toolu_x', + fullInput: over.input, + }; +} + +describe('AgentArtifactIndex', () => { + let index: AgentArtifactIndex; + let source: EventEmitter; + + beforeEach(() => { + source = new EventEmitter(); + index = new AgentArtifactIndex(); + index.start(source); + }); + + function emitCall(over: Parameters[0]) { + source.emit('subagent:tool_call', toolCall(over)); + } + + it('records a Write as an artifact and classifies HTML', () => { + const added: AgentArtifact[] = []; + index.on('artifact:added', (a) => added.push(a)); + + emitCall({ + tool: 'Write', + input: { file_path: '/home/arkon/proj/mockups/01-aurora.html', content: '' }, + }); + + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ + agentId: 'agent-1', + sessionId: 'sess-1', + path: '/home/arkon/proj/mockups/01-aurora.html', + filename: '01-aurora.html', + ext: '.html', + kind: 'html', + source: 'write', + writeCount: 1, + }); + expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1); + }); + + it('dedupes repeated writes/edits of the same path and counts them', () => { + const updated: AgentArtifact[] = []; + index.on('artifact:updated', (a) => updated.push(a)); + + emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '1' }, timestamp: '2026-06-13T05:00:00.000Z' }); + emitCall({ + tool: 'Edit', + input: { file_path: '/p/a.html', old_string: '1', new_string: '2' }, + timestamp: '2026-06-13T05:01:00.000Z', + }); + emitCall({ tool: 'Edit', input: { file_path: '/p/a.html' }, timestamp: '2026-06-13T05:02:00.000Z' }); + + const arts = index.getArtifactsForAgent('agent-1'); + expect(arts).toHaveLength(1); + expect(arts[0].writeCount).toBe(3); + expect(arts[0].source).toBe('write'); // first attribution wins + expect(arts[0].lastWriteAt).toBe(Date.parse('2026-06-13T05:02:00.000Z')); + expect(arts[0].firstSeenAt).toBe(Date.parse('2026-06-13T05:00:00.000Z')); + expect(updated).toHaveLength(2); + }); + + it('normalizes ../. in absolute paths so they dedupe', () => { + emitCall({ tool: 'Write', input: { file_path: '/p/sub/../a.html', content: '' } }); + emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } }); + expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1); + expect(index.getArtifactsForAgent('agent-1')[0].path).toBe('/p/a.html'); + }); + + it('ignores non-file tools and writes missing a path', () => { + emitCall({ tool: 'Read', input: { file_path: '/p/a.html' } }); + emitCall({ tool: 'Bash', input: { command: 'node capture.mjs' } }); + emitCall({ tool: 'Write', input: { content: 'no path' } }); + expect(index.count()).toBe(0); + }); + + it('handles NotebookEdit via notebook_path with notebook source', () => { + emitCall({ tool: 'NotebookEdit', input: { notebook_path: '/p/analysis.ipynb', new_source: 'x' } }); + const arts = index.getArtifactsForAgent('agent-1'); + expect(arts).toHaveLength(1); + expect(arts[0].source).toBe('notebook'); + expect(arts[0].kind).toBe('other'); // .ipynb not in the render map + }); + + it.each([ + ['/p/x.png', 'image'], + ['/p/x.PNG', 'image'], + ['/p/x.svg', 'image'], + ['/p/x.pdf', 'pdf'], + ['/p/x.docx', 'document'], + ['/p/x.pptx', 'document'], + ['/p/x.md', 'markdown'], + ['/p/x.txt', 'text'], + ['/p/x.css', 'code'], + ['/p/x.ts', 'code'], + ['/p/x.bin', 'other'], + ['/p/Makefile', 'other'], + ])('classifies %s as %s', (file, kind) => { + emitCall({ tool: 'Write', input: { file_path: file, content: '' } }); + expect(index.getAllArtifacts()[0].kind).toBe(kind); + }); + + it('groups artifacts by agent and by session', () => { + emitCall({ agentId: 'a1', sessionId: 's1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } }); + emitCall({ agentId: 'a2', sessionId: 's1', tool: 'Write', input: { file_path: '/p/2.html', content: '' } }); + emitCall({ agentId: 'a3', sessionId: 's2', tool: 'Write', input: { file_path: '/p/3.html', content: '' } }); + + expect(index.getArtifactsForAgent('a1')).toHaveLength(1); + expect( + index + .getArtifactsForSession('s1') + .map((a) => a.agentId) + .sort() + ).toEqual(['a1', 'a2']); + expect(index.getArtifactsForSession('s2')).toHaveLength(1); + expect(index.count()).toBe(3); + }); + + it('sorts results most-recently-written first', () => { + emitCall({ + tool: 'Write', + input: { file_path: '/p/old.html', content: '' }, + timestamp: '2026-06-13T01:00:00.000Z', + }); + emitCall({ + tool: 'Write', + input: { file_path: '/p/new.html', content: '' }, + timestamp: '2026-06-13T09:00:00.000Z', + }); + expect(index.getArtifactsForAgent('agent-1').map((a) => a.filename)).toEqual(['new.html', 'old.html']); + }); + + it('records external artifacts (image-watcher correlation hook)', () => { + index.recordExternalArtifact( + 'agent-1', + 'sess-1', + '/p/shots/01-desktop.png', + Date.parse('2026-06-13T05:45:00.000Z') + ); + const arts = index.getArtifactsForAgent('agent-1'); + expect(arts).toHaveLength(1); + expect(arts[0]).toMatchObject({ source: 'external', kind: 'image', filename: '01-desktop.png' }); + }); + + it('bounds artifacts per agent with FIFO eviction', () => { + for (let i = 0; i < MAX_ARTIFACTS_PER_AGENT + 5; i++) { + emitCall({ tool: 'Write', input: { file_path: `/p/f${i}.html`, content: '' } }); + } + expect(index.getArtifactsForAgent('agent-1')).toHaveLength(MAX_ARTIFACTS_PER_AGENT); + // Oldest (f0..f4) evicted; newest retained. + expect(index.getArtifact('agent-1', '/p/f0.html')).toBeUndefined(); + expect(index.getArtifact('agent-1', `/p/f${MAX_ARTIFACTS_PER_AGENT + 4}.html`)).toBeDefined(); + }); + + it('stop() detaches from the source; clear() drops artifacts', () => { + emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } }); + index.stop(); + emitCall({ tool: 'Write', input: { file_path: '/p/b.html', content: '' } }); + expect(index.count()).toBe(1); // second call ignored after stop + + index.clear(); + expect(index.count()).toBe(0); + }); + + it('start() is idempotent — no double counting on repeat start with same source', () => { + index.start(source); + index.start(source); + const added: AgentArtifact[] = []; + index.on('artifact:added', (a) => added.push(a)); + emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } }); + expect(added).toHaveLength(1); + }); + + it('clearAgent() drops only the named agent', () => { + emitCall({ agentId: 'a1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } }); + emitCall({ agentId: 'a2', tool: 'Write', input: { file_path: '/p/2.html', content: '' } }); + index.clearAgent('a1'); + expect(index.getArtifactsForAgent('a1')).toHaveLength(0); + expect(index.getArtifactsForAgent('a2')).toHaveLength(1); + }); +});