diff --git a/apps/vis/server/src/app.ts b/apps/vis/server/src/app.ts index 41b9abb..e2670b1 100644 --- a/apps/vis/server/src/app.ts +++ b/apps/vis/server/src/app.ts @@ -8,7 +8,6 @@ import { contextRoute } from './routes/context'; import { sessionDetailRoute } from './routes/session-detail'; import { sessionsRoute } from './routes/sessions'; import { subagentsRoute } from './routes/subagents'; -import { toolResultsRoute } from './routes/tool-results'; import { wireRoute } from './routes/wire'; /** Resolve the SPA bundle directory next to the compiled server.mjs, if it @@ -87,9 +86,11 @@ export async function createApp(options: CreateAppOptions = {}): Promise { api.route('/sessions', sessionsRoute()); api.route('/sessions', sessionDetailRoute()); api.route('/sessions', wireRoute()); - api.route('/sessions', contextRoute()); api.route('/sessions', subagentsRoute()); - api.route('/sessions', toolResultsRoute()); + // Mount contextRoute last because it currently uses a catch-all stub + // (Phase C scope) that would otherwise shadow more specific routes + // registered below it. + api.route('/sessions', contextRoute()); app.route('/api', api); diff --git a/apps/vis/server/src/config.ts b/apps/vis/server/src/config.ts index 7c9421c..1a719f3 100644 --- a/apps/vis/server/src/config.ts +++ b/apps/vis/server/src/config.ts @@ -1,4 +1,3 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -53,104 +52,3 @@ export function resolveVisAuthToken(host: string = resolveHost()): string | unde } export const KIMI_CODE_HOME: string = resolveKimiCodeHome(); - -export class VisPathConfig { - readonly sessionsDir: string; - - constructor(readonly home: string) { - this.sessionsDir = join(home, 'sessions'); - } - - sessionDir(sessionId: string): string { - return ( - this.findIndexedSessionDir(sessionId) ?? - this.findNestedSessionDir(sessionId) ?? - join(this.sessionsDir, sessionId) - ); - } - - statePath(sessionId: string): string { - return join(this.sessionDir(sessionId), 'state.json'); - } - - mainAgentDir(sessionId: string): string { - const sessionDir = this.sessionDir(sessionId); - const mainDir = join(sessionDir, 'agents', 'main'); - return isDirectory(mainDir) ? mainDir : sessionDir; - } - - wirePath(sessionId: string): string { - return join(this.mainAgentDir(sessionId), 'wire.jsonl'); - } - - subagentDir(sessionId: string, agentId: string): string { - const sessionDir = this.sessionDir(sessionId); - const agentDir = join(sessionDir, 'agents', agentId); - return isDirectory(agentDir) ? agentDir : join(sessionDir, 'subagents', agentId); - } - - toolResultArchivePath(sessionId: string, toolCallId: string): string { - const sessionDir = this.sessionDir(sessionId); - const mainPath = join(sessionDir, 'agents', 'main', 'tool-results', `${toolCallId}.txt`); - return existsSync(mainPath) ? mainPath : join(sessionDir, 'tool-results', `${toolCallId}.txt`); - } - - private findIndexedSessionDir(sessionId: string): string | null { - const indexPath = join(this.home, 'session_index.jsonl'); - let raw: string; - try { - raw = readFileSync(indexPath, 'utf8'); - } catch { - return null; - } - for (const line of raw.split(/\r?\n/)) { - if (line.trim().length === 0) continue; - try { - const parsed = JSON.parse(line) as { - sessionId?: unknown; - session_id?: unknown; - sessionDir?: unknown; - session_dir?: unknown; - }; - const id = stringValue(parsed.sessionId) ?? stringValue(parsed.session_id); - if (id !== sessionId) continue; - const dir = stringValue(parsed.sessionDir) ?? stringValue(parsed.session_dir); - if (dir !== null && isDirectory(dir)) return dir; - } catch { - // Ignore malformed index lines. - } - } - return null; - } - - private findNestedSessionDir(sessionId: string): string | null { - const direct = join(this.sessionsDir, sessionId); - if (isDirectory(direct)) return direct; - let entries: string[]; - try { - entries = readdirSync(this.sessionsDir); - } catch { - return null; - } - for (const entry of entries) { - const candidate = join(this.sessionsDir, entry, sessionId); - if (isDirectory(candidate)) return candidate; - } - return null; - } -} - -function stringValue(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null; -} - -function isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } -} - -/** Singleton path config pinned to the resolved KIMI_CODE_HOME. */ -export const pathConfig = new VisPathConfig(KIMI_CODE_HOME); diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts new file mode 100644 index 0000000..af3f10c --- /dev/null +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -0,0 +1,104 @@ +// apps/vis/server/src/lib/agent-record-types.ts +// Single source of truth: everything below comes from agent-core directly. +// Do NOT add local interfaces that duplicate upstream shapes. + +export type { + AgentRecord, + AgentRecordEvents, + AgentRecordOf, + AgentConfigUpdateData, + CompactionBeginData, + CompactionResult, + PermissionApprovalResultRecord, + PermissionMode, + UsageRecordScope, + ToolStoreUpdate, + LoopRecordedEvent, + ContextMessage, + PromptOrigin, +} from '@moonshot-ai/agent-core'; +export { AGENT_WIRE_PROTOCOL_VERSION } from '@moonshot-ai/agent-core'; +export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/kosong'; + +// ── vis-only DTOs ────────────────────────────────────────────────────────── + +export interface ApiError { + error: string; + code: + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'READ_ERROR' + | 'PARSE_ERROR' + | 'DELETE_ERROR' + | 'UNSUPPORTED_PROTOCOL'; +} + +export type SessionHealth = + | 'ok' + | 'broken_state' + | 'broken_main_wire' + | 'missing_main_wire' + | 'unsupported_protocol'; + +export interface SessionSummary { + sessionId: string; + sessionDir: string; + workDir: string; + title: string | null; + lastPrompt: string | null; + isCustomTitle: boolean; + createdAt: number; + updatedAt: number; + agentCount: number; + mainAgentExists: boolean; + mainWireRecordCount: number; + wireProtocolVersion: string | null; + health: SessionHealth; +} + +export interface AgentInfo { + agentId: string; + type: 'main' | 'sub' | 'independent'; + parentAgentId: string | null; + homedir: string; + wireExists: boolean; + wireRecordCount: number; + wireProtocolVersion: string | null; +} + +export interface SessionDetail { + sessionId: string; + /** Canonical on-disk session directory. Routes derive agent wire paths + * from this rather than the mutable `homedir` field inside `state.json`, + * which can drift after fork/rename. */ + sessionDir: string; + workDir: string; + state: unknown; // 原样透传,前端按 state.json 真实形状渲染 + agents: AgentInfo[]; +} + +export type WireLine = { _lineNo: number } & { + // structural unification with AgentRecord; preserves discriminant + [K in keyof import('@moonshot-ai/agent-core').AgentRecordEvents]: import('@moonshot-ai/agent-core').AgentRecordOf & { + _lineNo: number; + }; +}[keyof import('@moonshot-ai/agent-core').AgentRecordEvents]; + +export interface WireResponse { + sessionId: string; + agentId: string; + protocolVersion: string; + metadata: { protocolVersion: string; createdAt: number }; + records: readonly WireLine[]; + warnings: string[]; +} + +export interface AgentNode extends AgentInfo { + children: AgentNode[]; +} + +export interface AgentTreeResponse { + sessionId: string; + tree: AgentNode[]; +} diff --git a/apps/vis/server/src/lib/agent-tree.ts b/apps/vis/server/src/lib/agent-tree.ts new file mode 100644 index 0000000..baa09f4 --- /dev/null +++ b/apps/vis/server/src/lib/agent-tree.ts @@ -0,0 +1,33 @@ +import type { AgentInfo } from './agent-record-types'; + +export interface AgentNode extends AgentInfo { + children: AgentNode[]; +} + +/** + * Build a parent/child tree from the flat agent inventory found on + * `state.json.agents`. Roots are agents with no `parentAgentId`, plus any + * agent whose `parentAgentId` does not resolve in the inventory (orphans). + * The returned roots are sorted so that the `main` agent always appears + * first; remaining roots fall back to a stable lexicographic order. + */ +export function buildAgentTree(agents: ReadonlyArray): AgentNode[] { + const byId = new Map(); + for (const a of agents) byId.set(a.agentId, { ...a, children: [] }); + + const roots: AgentNode[] = []; + for (const node of byId.values()) { + if (node.parentAgentId !== null && byId.has(node.parentAgentId)) { + byId.get(node.parentAgentId)!.children.push(node); + } else { + roots.push(node); + } + } + return roots.sort(sortAgents); +} + +function sortAgents(a: AgentNode, b: AgentNode): number { + if (a.agentId === 'main') return -1; + if (b.agentId === 'main') return 1; + return a.agentId.localeCompare(b.agentId); +} diff --git a/apps/vis/server/src/lib/context-builder.ts b/apps/vis/server/src/lib/context-builder.ts deleted file mode 100644 index 9cfdbe8..0000000 --- a/apps/vis/server/src/lib/context-builder.ts +++ /dev/null @@ -1,498 +0,0 @@ -import type { - AnnotatedMessage, - ContentPart, - NotificationRecord, - ProjectedStateSummary, - SessionInitializedRecord, - SessionState, - ToolCallEntry, - UserInputPart, - VisWireRecord, -} from './types'; - -const PERSISTED_OUTPUT_RE = /^/; - -/** - * Extract the on-disk path from a `` marker - * at the start of a tool-result output. Returns null when no marker is - * present. - */ -export function extractPersistedOutputPath(text: string): string | null { - const m = PERSISTED_OUTPUT_RE.exec(text.trimStart()); - return m?.[1] ?? null; -} - -/** - * Render a notification's `data` payload as the XML string that the live - * ContextMemory would inject as a user-role message. - */ -export function renderNotificationXml(data: NotificationRecord['data']): string { - const id = escAttr(data.id); - const category = escAttr(data.category); - const type = escAttr(data.type); - const sourceKind = escAttr(data.source_kind); - const sourceId = escAttr(data.source_id); - const title = data.title; - const severity = data.severity; - const body = data.body; - - const lines: string[] = [ - ``, - ]; - if (title.length > 0) lines.push(`Title: ${title}`); - if (severity.length > 0) lines.push(`Severity: ${severity}`); - if (body.length > 0) lines.push(body); - - // background_task notifications append a block - // with the last 20 lines / 3000 chars of tail_output, matching what - // the live projector would emit. - if (data.source_kind === 'background_task') { - const tail = (data as { tail_output?: string }).tail_output ?? ''; - if (tail.length > 0) { - const tailLines = tail.split('\n'); - const trimmed = (tailLines.length > 20 ? tailLines.slice(-20) : tailLines) - .join('\n') - .slice(-3000); - lines.push('', trimmed, ''); - } - } - - lines.push(''); - return lines.join('\n'); -} - -function escAttr(s: string): string { - if (s.length === 0) return 'unknown'; - return s.replaceAll('&', '&').replaceAll('"', '"'); -} - -export interface BuildAnnotatedOptions { - /** When true (default), rewind-orphaned records are kept with `out_of_context: true`. */ - preserveOutOfContext?: boolean; -} - -/** - * Walk `records` in order and emit an `AnnotatedMessage[]` that includes - * notification and system_reminder synthetic user messages. Tracks a - * numeric turn counter so `context_edit operation:'rewind' to_turn:N` - * can flag later messages as out-of-context. - */ -export function buildAnnotatedMessages( - records: readonly VisWireRecord[], - options?: BuildAnnotatedOptions, -): AnnotatedMessage[] { - const preserveOutOfContext = options?.preserveOutOfContext ?? true; - - // Track (messageIndex -> turnCounterAtEmission) so a rewind can mark - // later emissions as out_of_context. - let annotated: AnnotatedMessage[] = []; - const msgTurnIdx: number[] = []; - - let turnCounter = 0; - - // An assistant message is not a single record — it's coalesced from - // `step_begin → (content_part|tool_call)* → step_end` atoms anchored - // by `step_uuid`. We buffer the in-flight step here and emit a - // synthetic assistant message on step_end. - interface StepBuffer { - seq: number; - step_uuid: string; - text: string; - think: string; - think_encrypted?: string | undefined; - tool_calls: ToolCallEntry[]; - } - let currentStep: StepBuffer | null = null; - - const flushStep = (): void => { - if (currentStep === null) return; - const s = currentStep; - const content: ContentPart[] = []; - if (s.think.length > 0) { - const part: ContentPart = { type: 'think', think: s.think }; - if (s.think_encrypted !== undefined) part['encrypted'] = s.think_encrypted; - content.push(part); - } - if (s.text.length > 0) content.push({ type: 'text', text: s.text }); - if (content.length > 0 || s.tool_calls.length > 0) { - pushMsg({ - seq: s.seq, - message: { role: 'assistant', content, tool_calls: s.tool_calls }, - origin: { kind: 'assistant' }, - is_ephemeral: false, - out_of_context: false, - }); - } - currentStep = null; - }; - - const pushMsg = (m: AnnotatedMessage): void => { - annotated.push(m); - msgTurnIdx.push(turnCounter); - }; - - for (const r of records) { - switch (r.type) { - case 'turn_begin': { - // Close any dangling step so turn boundaries stay clean. - flushStep(); - turnCounter += 1; - break; - } - - case 'user_message': { - const text = userContentToText(r.content); - pushMsg({ - seq: r.seq, - message: { - role: 'user', - content: [{ type: 'text', text }], - tool_calls: [], - }, - origin: { kind: 'user' }, - is_ephemeral: false, - out_of_context: false, - }); - break; - } - - case 'step_begin': { - // Any prior unclosed step gets flushed; protocol guarantees order - // step_begin → … → step_end, but crashes can leave orphans. - flushStep(); - currentStep = { - seq: r.seq, - step_uuid: r.uuid, - text: '', - think: '', - tool_calls: [], - }; - break; - } - - case 'content_part': { - if (currentStep === null) break; - if (currentStep.step_uuid !== r.step_uuid) break; - if (r.part.kind === 'text') { - currentStep.text += r.part.text; - } else { - currentStep.think += r.part.think; - if (r.part.encrypted !== undefined) { - currentStep.think_encrypted = r.part.encrypted; - } - } - break; - } - - case 'tool_call': { - if (currentStep === null) break; - if (currentStep.step_uuid !== r.step_uuid) break; - currentStep.tool_calls.push({ - type: 'function', - id: r.data.tool_call_id, - name: r.data.tool_name, - arguments: r.data.args === undefined ? null : JSON.stringify(r.data.args), - }); - break; - } - - case 'step_end': { - // step_end may carry usage; we drop it here since the annotated - // message already represents what the LLM emitted. Usage lives - // on the wire record itself for the Wire tab to show. - flushStep(); - break; - } - - case 'tool_result': { - const text = typeof r.output === 'string' ? r.output : JSON.stringify(r.output); - const msg: AnnotatedMessage = { - seq: r.seq, - message: { - role: 'tool', - content: [{ type: 'text', text }], - tool_calls: [], - tool_call_id: r.tool_call_id, - }, - origin: { kind: 'tool', tool_call_id: r.tool_call_id }, - is_ephemeral: false, - out_of_context: false, - }; - if (typeof r.output === 'string') { - const persistedPath = extractPersistedOutputPath(r.output); - if (persistedPath !== null) { - msg.persisted_output_path = persistedPath; - } - } - pushMsg(msg); - break; - } - - case 'system_reminder': { - const text = `\n${r.content}\n`; - pushMsg({ - seq: r.seq, - message: { role: 'user', content: [{ type: 'text', text }], tool_calls: [] }, - origin: { kind: 'system_reminder', seq: r.seq }, - is_ephemeral: true, - out_of_context: false, - }); - break; - } - - case 'notification': { - if (!isLlmVisibleNotification(r)) break; - const text = renderNotificationXml(r.data); - pushMsg({ - seq: r.seq, - message: { role: 'user', content: [{ type: 'text', text }], tool_calls: [] }, - origin: { - kind: 'notification', - seq: r.seq, - notification_id: r.data.id, - severity: r.data.severity, - }, - is_ephemeral: true, - out_of_context: false, - }); - break; - } - - case 'compaction': { - // Wholesale replacement. Drops any open step buffer — atoms - // before compaction are superseded by the summary. - currentStep = null; - annotated = []; - msgTurnIdx.length = 0; - pushMsg({ - seq: r.seq, - message: { - role: 'assistant', - content: [{ type: 'text', text: r.summary }], - tool_calls: [], - }, - origin: { kind: 'assistant' }, - is_ephemeral: false, - out_of_context: false, - }); - break; - } - - case 'context_cleared': { - currentStep = null; - annotated = []; - msgTurnIdx.length = 0; - break; - } - - case 'context_edit': { - if (r.operation === 'rewind' && typeof r.to_turn === 'number') { - const toTurn = r.to_turn; - if (preserveOutOfContext) { - for (let i = 0; i < annotated.length; i += 1) { - const emittedAtTurn = msgTurnIdx[i] ?? 0; - if (emittedAtTurn > toTurn) { - // Replace with a marked copy (immutability not required but - // makes the "after rewind" behaviour obvious to readers). - const m = annotated[i]; - if (m !== undefined) { - annotated[i] = { ...m, out_of_context: true }; - } - } - } - } else { - // Drop messages emitted in turns past `to_turn`. - const keep: AnnotatedMessage[] = []; - const keepTurns: number[] = []; - for (let i = 0; i < annotated.length; i += 1) { - const emittedAtTurn = msgTurnIdx[i] ?? 0; - if (emittedAtTurn <= toTurn) { - const m = annotated[i]; - if (m !== undefined) { - keep.push(m); - keepTurns.push(emittedAtTurn); - } - } - } - annotated = keep; - msgTurnIdx.length = 0; - msgTurnIdx.push(...keepTurns); - } - } - break; - } - - case 'approval_request': - case 'approval_response': - case 'metadata': - case 'ownership_changed': - case 'session_initialized': - case 'skill_completed': - case 'skill_invoked': - case 'subagent_completed': - case 'subagent_failed': - case 'subagent_spawned': - case 'system_prompt_changed': - case 'team_mail': - case 'tool_denied': - case 'tools_changed': - case 'turn_end': - break; - - default: - break; - } - } - - // Flush any trailing open step (session ended mid-step). - flushStep(); - - return annotated; -} - -function userContentToText(content: string | readonly UserInputPart[]): string { - if (typeof content === 'string') return content; - return content - .map((p) => { - if (p.type === 'text') return p.text; - if (p.type === 'image_url') return ``; - return `