diff --git a/docs/dev/FILE-SYSTEM-MAP.md b/docs/dev/FILE-SYSTEM-MAP.md index 4e70b6739..5408b62d7 100644 --- a/docs/dev/FILE-SYSTEM-MAP.md +++ b/docs/dev/FILE-SYSTEM-MAP.md @@ -169,7 +169,6 @@ | types.ts | AI Providers | Core types for models, APIs, streaming options | | env-api-keys.ts | AI Providers, Auth/OAuth | Environment variable API key resolution | | web-runtime-env-api-keys.ts | AI Providers, Auth/OAuth | Web runtime API key handling | -| web-runtime-oauth.ts | AI Providers, Auth/OAuth | Web runtime OAuth token management | | providers/register-builtins.ts | AI Providers | Registration of built-in provider implementations | | providers/anthropic.ts | AI Providers | Anthropic API provider | | providers/anthropic-shared.ts | AI Providers | Shared utilities for Anthropic provider variants | @@ -192,7 +191,6 @@ | providers/transform-messages.ts | AI Providers | Message transformation for provider compatibility | | utils/oauth/index.ts | Auth/OAuth | OAuth utilities export hub | | utils/oauth/types.ts | Auth/OAuth | OAuth credential and prompt types | -| utils/oauth/pkce.ts | Auth/OAuth | PKCE flow implementation | | utils/oauth/github-copilot.ts | Auth/OAuth | GitHub Copilot OAuth flow | | utils/oauth/google-oauth-utils.ts | Auth/OAuth | Shared Google OAuth utilities | | utils/oauth/google-gemini-cli.ts | Auth/OAuth | Google Gemini CLI OAuth flow | @@ -373,10 +371,8 @@ | core/discovery-cache.ts | Model System | Model discovery result caching | | core/keybindings.ts | TUI Components | Keybinding definitions | | core/footer-data-provider.ts | TUI Components | Footer information provider | -| core/index.ts | Agent Core | Core module exports | | index.ts | Agent Core | Package exports | | utils/clipboard.ts | Tool System | Clipboard read/write | -| utils/clipboard-native.ts | Tool System | Native clipboard implementation | | utils/clipboard-image.ts | Tool System | Clipboard image support | | utils/error.ts | Agent Core | Error message extraction/formatting | | utils/frontmatter.ts | Config | YAML frontmatter parsing | @@ -385,7 +381,6 @@ | utils/image-resize.ts | Image Processing | Image resizing and optimization | | utils/mime.ts | Tool System | MIME type detection | | utils/path-display.ts | TUI Components | Path formatting for display | -| utils/photon.ts | Agent Core | Photon scripting runtime support | | utils/shell.ts | Tool System | Shell detection and execution | | utils/changelog.ts | CLI | Changelog parsing | | utils/sleep.ts | Agent Core | Async sleep/delay utility | diff --git a/knip.json b/knip.json index f5df7ec26..ac128d436 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "**/*.d.ts", "packages/coding-agent/src/core/export-html/**", "packages/coding-agent/src/resources/extensions/**", + "packages/daemon/src/cli-dev.ts", "scripts/tmp-check-test-imports/**", "src/resources/extensions/**/dist/**", "src/resources/extensions/**", @@ -28,7 +29,6 @@ "@mariozechner/jiti", "@mistralai/mistralai", "@octokit/rest", - "@silvia-odwyer/photon-node", "@smithy/node-http-handler", "@types/diff", "@types/express", @@ -79,6 +79,7 @@ "src/headless.ts", "src/headless*.ts", "src/web-mode.ts", + "packages/daemon/src/cli-dev.ts", "scripts/**/*.{js,cjs,mjs,ts}", "tests/**/*.{js,cjs,mjs,ts}", "src/tests/**/*.{js,cjs,mjs,ts}", diff --git a/package-lock.json b/package-lock.json index 4a373ba89..5b2a0ae37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "@mistralai/mistralai": "^2.2.1", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.1", - "@silvia-odwyer/photon-node": "^0.3.4", "@sinclair/typebox": "^0.34.49", "@smithy/node-http-handler": "^4.7.3", "@types/mime-types": "^3.0.1", @@ -5886,12 +5885,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, "node_modules/@simple-git/args-pathspec": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", @@ -15324,7 +15317,6 @@ "version": "2.75.4", "dependencies": { "@mariozechner/jiti": "^2.6.2", - "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "diff": "^9.0.0", "express": "^5.2.1", diff --git a/package.json b/package.json index e0e02f9f1..254508e02 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,6 @@ "@mistralai/mistralai": "^2.2.1", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.1", - "@silvia-odwyer/photon-node": "^0.3.4", "@sinclair/typebox": "^0.34.49", "@smithy/node-http-handler": "^4.7.3", "@types/mime-types": "^3.0.1", diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/utils/oauth/pkce.ts deleted file mode 100644 index 007d25326..000000000 --- a/packages/ai/src/utils/oauth/pkce.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * PKCE utilities using Web Crypto API. - * Works in both Node.js 20+ and browsers. - */ - -/** - * Encode bytes as base64url string. - */ -function base64urlEncode(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} - -/** - * Generate PKCE code verifier and challenge. - * Uses Web Crypto API for cross-platform compatibility. - */ -export async function generatePKCE(): Promise<{ - verifier: string; - challenge: string; -}> { - // Generate random verifier - const verifierBytes = new Uint8Array(32); - crypto.getRandomValues(verifierBytes); - const verifier = base64urlEncode(verifierBytes); - - // Compute SHA-256 challenge - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const challenge = base64urlEncode(new Uint8Array(hashBuffer)); - - return { verifier, challenge }; -} diff --git a/packages/ai/src/web-runtime-oauth.ts b/packages/ai/src/web-runtime-oauth.ts deleted file mode 100644 index f43ef8514..000000000 --- a/packages/ai/src/web-runtime-oauth.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - getOAuthProvider, - getOAuthProviders, - type OAuthAuthInfo, - type OAuthCredentials, - type OAuthLoginCallbacks, - type OAuthPrompt, - type OAuthProviderInterface, -} from "./oauth.js"; diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5d5235387..a3e58fccb 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -21,7 +21,6 @@ }, "dependencies": { "@mariozechner/jiti": "^2.6.2", - "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "diff": "^9.0.0", "express": "^5.2.1", diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts deleted file mode 100644 index a67e76f3a..000000000 --- a/packages/coding-agent/src/core/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Core modules shared between all run modes. - */ - -export { - AgentSession, - type AgentSessionConfig, - type AgentSessionEvent, - type AgentSessionEventListener, - type ModelCycleResult, - type PromptOptions, - type SessionStats, -} from "./agent-session.js"; -export { - type BashExecutorOptions, - type BashResult, - executeBash, - executeBashWithOperations, -} from "./bash-executor.js"; -export type { CompactionResult } from "./compaction/index.js"; -export { ContextualTips, type TipContext } from "./contextual-tips.js"; -export { - createEventBus, - type EventBus, - type EventBusController, -} from "./event-bus.js"; - -// Extensions system -export { - type AgentEndEvent, - type AgentStartEvent, - type AgentToolResult, - type AgentToolUpdateCallback, - type BeforeAgentStartEvent, - type ContextEvent, - discoverAndLoadExtensions, - type ExecOptions, - type ExecResult, - type Extension, - type ExtensionAPI, - type ExtensionCommandContext, - type ExtensionContext, - type ExtensionError, - type ExtensionEvent, - type ExtensionFactory, - type ExtensionFlag, - type ExtensionHandler, - type ExtensionManifest, - ExtensionRunner, - type ExtensionShortcut, - type ExtensionUIContext, - type LoadExtensionsResult, - type MessageRenderer, - type RegisteredCommand, - readManifest, - readManifestFromEntryPath, - type SessionBeforeCompactEvent, - type SessionBeforeForkEvent, - type SessionBeforeSwitchEvent, - type SessionBeforeTreeEvent, - type SessionCompactEvent, - type SessionForkEvent, - type SessionShutdownEvent, - type SessionStartEvent, - type SessionSwitchEvent, - type SessionTreeEvent, - type SortResult, - type SortWarning, - sortExtensionPaths, - type ToolCallEvent, - type ToolDefinition, - type ToolRenderResultOptions, - type ToolResultEvent, - type TurnEndEvent, - type TurnStartEvent, - wrapToolsWithExtensions, -} from "./extensions/index.js"; -export { FallbackResolver, type FallbackResult } from "./fallback-resolver.js"; diff --git a/packages/coding-agent/src/utils/clipboard-native.ts b/packages/coding-agent/src/utils/clipboard-native.ts deleted file mode 100644 index 9f1643057..000000000 --- a/packages/coding-agent/src/utils/clipboard-native.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Re-export native clipboard utilities from "@singularity-forge/native. - * - * This module exists for backward compatibility. Prefer importing - * directly from "@singularity-forge/native/clipboard" in new code. - */ -export { - copyToClipboard, - readImageFromClipboard, - readTextFromClipboard, -} from "@singularity-forge/native/clipboard"; diff --git a/packages/coding-agent/src/utils/photon.ts b/packages/coding-agent/src/utils/photon.ts deleted file mode 100644 index cdffed0a7..000000000 --- a/packages/coding-agent/src/utils/photon.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export types from the main package -export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node"; diff --git a/packages/coding-agent/src/utils/proxy-server.ts b/packages/coding-agent/src/utils/proxy-server.ts deleted file mode 100644 index 40a1a0342..000000000 --- a/packages/coding-agent/src/utils/proxy-server.ts +++ /dev/null @@ -1,313 +0,0 @@ -import type { Server } from "node:http"; -import { - type Context, - getModels, - type StreamOptions, - stream, -} from "@singularity-forge/ai"; -import express from "express"; -import type { AuthStorage } from "../core/auth-storage.js"; -import type { ModelRegistry } from "../core/model-registry.js"; - -export type ProxyServerOptions = { - port: number; - authStorage: AuthStorage; - modelRegistry: ModelRegistry; - /** Per-family provider priority overrides from settings.proxy.providerPriority */ - priorityOverrides?: Record; - onLog?: (msg: string) => void; -}; - -// Per-family provider priority for bare model ID resolution. When the same model ID -// exists across multiple providers, the first matching family rule wins; within that -// rule providers are tried in order, preferring those with auth configured. Providers -// not listed in any rule fall back to insertion order. -const PROXY_FAMILY_PRIORITY: Array<{ match: RegExp; providers: string[] }> = [ - // MiniMax: international direct > CN endpoint - { match: /^MiniMax-/i, providers: ["minimax", "minimax-cn"] }, - // GLM: zai is the canonical direct provider > opencode aggregators - { match: /^glm-/i, providers: ["zai", "opencode", "opencode-go"] }, - // Kimi: kimi-coding direct > opencode aggregators - { match: /^kimi-/i, providers: ["kimi-coding", "opencode", "opencode-go"] }, - // Gemini/Gemma: proxy bare model IDs through cli-core only. - { - match: /^gemini-|^gemma-/i, - providers: ["google-gemini-cli"], - }, - // Claude: anthropic direct > opencode. Copilot is disabled. - { - match: /^claude-/i, - providers: ["anthropic", "opencode"], - }, - // GPT/OpenAI: openai direct > azure. Copilot is disabled. - { - match: /^gpt-|^o[0-9]|^codex-/i, - providers: ["openai", "azure-openai-responses"], - }, -]; - -function _sortByFamilyPriority( - models: T[], -): T[] { - if (models.length <= 1) return models; - const [first] = models; - const rule = PROXY_FAMILY_PRIORITY.find((r) => r.match.test(first.id)); - const order = rule?.providers ?? []; - return [...models].sort((a, b) => { - const pa = order.indexOf(a.provider); - const pb = order.indexOf(b.provider); - return (pa === -1 ? Infinity : pa) - (pb === -1 ? Infinity : pb); - }); -} - -export class ProxyServer { - private server: Server | null = null; - - constructor(private options: ProxyServerOptions) {} - - async start(): Promise { - if (this.server) return; - - const app = express(); - app.use(express.json()); - - const { authStorage, modelRegistry, onLog } = this.options; - const priorityOverrides = this.options.priorityOverrides ?? {}; - - const log = (msg: string) => onLog?.(msg); - - // 1. Model Listing - app.get(["/v1/models", "/v1beta/models"], async (req, res) => { - const providers = ["google-gemini-cli", "anthropic", "openai"]; - const allModels = providers.flatMap((p) => getModels(p as any)); - - const formatted = allModels.map((m) => ({ - id: m.id, - object: "model", - created: 1677610602, - owned_by: m.provider, - name: m.name, - capabilities: m.capabilities, - })); - - if (req.path.startsWith("/v1beta")) { - res.json({ models: formatted }); - } else { - res.json({ data: formatted, object: "list" }); - } - }); - - // 2. Chat Completions (OpenAI & GenAI) - const handleChat = async (req: express.Request, res: express.Response) => { - const body = req.body; - const isOpenAi = req.path.includes("/v1/chat/completions"); - const modelId = isOpenAi - ? body.model - : req.params.modelId?.replace(/:streamGenerateContent$/, ""); - - if (!modelId) { - return res.status(400).json({ error: "Model ID is required" }); - } - - try { - const candidates = modelRegistry.getModelsForProxy( - modelId, - priorityOverrides, - ); - if (candidates.length === 0) { - return res.status(404).json({ error: `Model ${modelId} not found` }); - } - - // Normalize messages once — shared across retry attempts - const context: Context = isOpenAi - ? this.normalizeOpenAi(body) - : this.normalizeGoogle(body); - - const streamOptions: StreamOptions = { - temperature: body.temperature, - maxTokens: isOpenAi - ? body.max_tokens - : body.generationConfig?.maxOutputTokens, - }; - - for (const resolvedModel of candidates) { - const apiKey = await authStorage.getApiKey(resolvedModel.provider); - if (!apiKey) continue; // no credentials — try next - - const streamOptionsWithKey: StreamOptions = { - ...streamOptions, - apiKey, - }; - - try { - const eventStream = stream( - resolvedModel as any, - context, - streamOptionsWithKey as any, - ); - - if (body.stream) { - this.handleStreamingResponse(eventStream, res, isOpenAi, modelId); - } else { - await this.handleStaticResponse( - eventStream, - res, - isOpenAi, - modelId, - ); - } - return; // success - } catch (err: any) { - const status = err?.status ?? err?.statusCode; - if (status === 429) { - log( - `Provider ${resolvedModel.provider} rate-limited (429), trying next candidate`, - ); - continue; - } - throw err; - } - } - - // All candidates exhausted - res - .status(429) - .json({ error: `All providers rate-limited for model ${modelId}` }); - } catch (err: any) { - log(`Proxy error: ${err.message}`); - res.status(500).json({ error: err.message }); - } - }; - - app.post("/v1/chat/completions", handleChat); - app.post("/v1beta/models/:modelId\\:streamGenerateContent", handleChat); - - return new Promise((resolve) => { - this.server = app.listen(this.options.port, () => { - log(`Proxy Server running on http://localhost:${this.options.port}`); - resolve(); - }); - }); - } - - stop(): void { - if (this.server) { - this.server.close(); - this.server = null; - } - } - - private normalizeOpenAi(body: any): Context { - const messages = body.messages || []; - const system = messages.find((m: any) => m.role === "system")?.content; - const history = messages - .filter((m: any) => m.role !== "system") - .map((m: any) => ({ - role: m.role === "user" ? "user" : "assistant", - content: - typeof m.content === "string" - ? [{ type: "text", text: m.content }] - : m.content, - })); - return { messages: history, systemPrompt: system }; - } - - private normalizeGoogle(body: any): Context { - const contents = body.contents || []; - const history = contents.map((c: any) => ({ - role: c.role === "user" ? "user" : "assistant", - content: (c.parts || []).map((p: any) => ({ - type: "text", - text: p.text, - })), - })); - const system = body.systemInstruction?.parts?.[0]?.text; - return { messages: history, systemPrompt: system }; - } - - private handleStreamingResponse( - eventStream: any, - res: express.Response, - isOpenAi: boolean, - modelId: string, - ) { - res.setHeader( - "Content-Type", - isOpenAi ? "text/event-stream" : "application/json", - ); - - eventStream.on("data", (ev: any) => { - if (ev.type === "text_delta") { - if (isOpenAi) { - const chunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [ - { index: 0, delta: { content: ev.delta }, finish_reason: null }, - ], - }; - res.write(`data: ${JSON.stringify(chunk)}\n\n`); - } else { - const chunk = { - candidates: [{ content: { parts: [{ text: ev.delta }] } }], - }; - res.write(JSON.stringify(chunk) + "\n"); - } - } - }); - - eventStream.on("done", () => { - if (isOpenAi) res.write("data: [DONE]\n\n"); - res.end(); - }); - - eventStream.on("error", (ev: any) => { - if (!res.headersSent) - res.status(500).json({ error: ev.error.errorMessage }); - else res.end(); - }); - } - - private async handleStaticResponse( - eventStream: any, - res: express.Response, - isOpenAi: boolean, - modelId: string, - ) { - let fullContent = ""; - eventStream.on("data", (ev: any) => { - if (ev.type === "text_delta") fullContent += ev.delta; - }); - - return new Promise((resolve) => { - eventStream.on("done", () => { - if (isOpenAi) { - res.json({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [ - { - index: 0, - message: { role: "assistant", content: fullContent }, - finish_reason: "stop", - }, - ], - }); - } else { - res.json({ - candidates: [{ content: { parts: [{ text: fullContent }] } }], - }); - } - resolve(); - }); - eventStream.on("error", (ev: any) => { - res.status(500).json({ error: ev.error.errorMessage }); - resolve(); - }); - }); - } -} diff --git a/packages/native/src/ps/types.ts b/packages/native/src/ps/types.ts deleted file mode 100644 index 84b3e443f..000000000 --- a/packages/native/src/ps/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Result of a process tree kill operation. */ -export interface KillTreeResult { - /** Number of processes successfully killed. */ - killed: number; -} diff --git a/src/traces.ts b/src/traces.ts deleted file mode 100644 index f051baa2f..000000000 --- a/src/traces.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * traces.ts — Structured trace data model and export utilities for autonomous mode execution. - * - * Purpose: provide a lightweight, hierarchical span model that captures the - * full lifecycle of an autonomous mode session (session → units → tools) so that - * post-hoc analysis, debugging, and cost attribution can be done from a - * single JSON artifact instead of piecing together scattered logs. - * - * Consumer: headless.ts (creates and finalizes traces), trace-collector.ts - * (appends spans and events), and any external tool that reads .sf/traces/. - */ - -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * Classify the role of a span in the trace hierarchy. - * - * Purpose: distinguish session roots, milestone/slice/task units, and - * individual tool calls so that renderers and aggregators can group or - * filter spans by semantic category. - * - * Consumer: trace-collector.ts when creating spans, and trace visualizers - * that colour-code or collapse spans by kind. - */ -export type SpanKind = "session" | "unit" | "tool"; - -/** - * Terminal state of a span. - * - * Purpose: capture whether a span finished successfully, failed, was - * cancelled, or is still running so that trace consumers can compute - * success rates and identify hung operations. - * - * Consumer: trace-collector.ts on unit/tool end, and trace analysis scripts - * that aggregate outcomes across sessions. - */ -export type SpanStatus = - | "ok" - | "error" - | "cancelled" - | "timeout" - | "in_progress"; - -/** - * A discrete event attached to a span, such as a checkpoint or decision. - * - * Purpose: record semantically meaningful moments (e.g. "planning meeting - * started", "model switched") inside a span without creating a child span - * for every micro-step. - * - * Consumer: trace-collector.ts when recording model switches, gate results, - * or other non-span lifecycle events. - */ -export interface TraceEvent { - name: string; - timestamp: number; - attributes?: Record; -} - -/** - * Optional metadata attached to a span. - * - * Purpose: carry dimensional data (tokens, cost, model, file paths) that - * lets downstream tools attribute spend and latency to specific units or - * tools without parsing free-form log lines. - * - * Consumer: trace-collector.ts when enriching spans after LLM responses, - * and cost-dashboard scripts that sum inputTokens / outputTokens. - */ -export interface SpanAttributes { - // Session-level - projectRoot?: string; - sessionId?: string; - cwd?: string; - command?: string; - model?: string; - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - costUsd?: number; - exitCode?: number; - - // Unit-level - unitType?: "milestone" | "slice" | "task"; - unitId?: string; - unitStatus?: SpanStatus; - unitErrorReason?: string; - - // Tool-level - toolName?: string; - toolCallId?: string; - toolStatus?: SpanStatus; - toolError?: string; - toolDurationMs?: number; -} - -/** - * A single node in the trace tree. - * - * Purpose: represent one scoped operation (session, unit, or tool call) with - * timing, status, attributes, nested children, and a timeline of events so - * that the full execution graph can be reconstructed from the trace file. - * - * Consumer: trace-collector.ts, headless.ts, and any trace reader/visualizer. - */ -export interface Span { - id: string; - name: string; - kind: SpanKind; - status: SpanStatus; - startTime: number; - endTime?: number; - attributes: SpanAttributes; - children: Span[]; - events: TraceEvent[]; -} - -/** - * The top-level trace container. - * - * Purpose: hold the root span and session metadata so that a single file - * contains everything needed to replay or analyse an autonomous mode session. - * - * Consumer: headless.ts (creates and finalizes), exportTrace/exportTraceToProject - * (serializes), and external trace consumers. - */ -export interface Trace { - id: string; - version: number; - projectRoot: string; - sessionId?: string; - startedAt: string; - completedAt?: string; - rootSpan: Span; -} - -// --------------------------------------------------------------------------- -// Span helpers -// --------------------------------------------------------------------------- - -/** - * Create a new span with a random UUID and current timestamp. - * - * Purpose: provide a single, correct construction site for spans so that - * every span has a stable ID and a consistent start-time baseline. - * - * Consumer: trace-collector.ts when starting a session, unit, or tool span. - */ -export function createSpan( - name: string, - kind: SpanKind, - attributes: SpanAttributes = {}, -): Span { - return { - id: randomUUID(), - name, - kind, - status: "in_progress", - startTime: Date.now(), - attributes, - children: [], - events: [], - }; -} - -/** - * Mark a span as complete and record end time. - * - * Purpose: ensure every finished span carries both a terminal status and an - * end timestamp so that duration calculations and success-rate metrics are - * accurate. - * - * Consumer: trace-collector.ts when a unit or tool finishes. - */ -export function endSpan(span: Span, status: SpanStatus = "ok"): Span { - span.status = status; - span.endTime = Date.now(); - return span; -} - -/** - * Append a named event to a span with optional attributes. - * - * Purpose: let collectors record semantically rich checkpoints (model - * switches, gate completions) inside an existing span without mutating the - * span's own fields. - * - * Consumer: trace-collector.ts during autonomous mode phase transitions. - */ -export function addEvent( - span: Span, - name: string, - attributes?: Record, -): void { - span.events.push({ - name, - timestamp: Date.now(), - attributes, - }); -} - -/** - * Append an error event to a span with message and optional stack. - * - * Purpose: capture failure details (including stack traces when available) - * inside the trace so that debugging can be done from the trace file alone - * without cross-referencing separate log files. - * - * Consumer: trace-collector.ts when a tool call or unit throws. - */ -export function addError(span: Span, message: string, stack?: string): void { - span.events.push({ - name: "error", - timestamp: Date.now(), - attributes: { - message, - ...(stack ? { stack } : {}), - }, - }); - span.status = "error"; - if (!span.endTime) span.endTime = Date.now(); -} - -// --------------------------------------------------------------------------- -// Trace helpers -// --------------------------------------------------------------------------- - -/** - * Create a new trace with a root session span. - * - * Purpose: establish the top-level trace container and its root session span - * in one call so that headless.ts never creates a trace without a valid root. - * - * Consumer: headless.ts at the start of an autonomous mode session. - */ -export function createTrace( - projectRoot: string, - sessionId?: string, - command?: string, - model?: string, -): Trace { - const rootSpan = createSpan(`session:${sessionId ?? "unknown"}`, "session", { - sessionId, - projectRoot, - command, - model, - }); - return { - id: randomUUID(), - version: 1, - projectRoot, - sessionId, - startedAt: new Date().toISOString(), - rootSpan, - }; -} - -/** - * Finalize a trace: set completedAt timestamp. - * - * Purpose: mark the trace as closed so that readers know the tree is - * complete and can safely compute session duration and aggregate costs. - * - * Consumer: headless.ts in the normal exit path and signal handlers. - */ -export function finalizeTrace(trace: Trace): Trace { - trace.completedAt = new Date().toISOString(); - return trace; -} - -/** - * Find a span in the tree by ID (linear walk). - * - * Purpose: let collectors locate an existing span (e.g. to attach a child - * or end it) without maintaining a separate ID-to-span map. - * - * Consumer: trace-collector.ts when bridging async tool-call results back - * to their original span. - */ -export function findSpan(span: Span, id: string): Span | undefined { - if (span.id === id) return span; - for (const child of span.children) { - const found = findSpan(child, id); - if (found) return found; - } - return undefined; -} - -/** - * Add a child span to a parent. - * - * Purpose: build the hierarchical tree (session → unit → tool) so that - * trace readers can collapse, expand, or aggregate by level. - * - * Consumer: trace-collector.ts when starting a unit or tool inside an - * already-running parent span. - */ -export function addChildSpan(parent: Span, child: Span): void { - parent.children.push(child); -} - -/** - * Walk all spans in a trace (root first, depth-first). Yields each span. - * - * Purpose: provide a simple, reusable traversal for aggregators, exporters, - * and debug printers that need to visit every span without writing recursive - * loops in every consumer. - * - * Consumer: trace analysis scripts, cost aggregators, and test assertions - * that verify span tree shape. - */ -export function* walkSpans(span: Span): Generator { - yield span; - for (const child of span.children) { - yield* walkSpans(child); - } -} - -// --------------------------------------------------------------------------- -// Export -// --------------------------------------------------------------------------- - -/** - * Serialize and write a trace to an arbitrary path. - * Creates parent directories as needed. - * - * Purpose: allow trace consumers (tests, CI scripts, manual debugging) to - * persist a trace anywhere on disk without hard-coding .sf/traces/ logic. - * - * Consumer: test suites that write traces to temp directories, and custom - * integrations that ship traces to external observability platforms. - */ -export function exportTrace(trace: Trace, path: string): void { - const dir = join(path, ".."); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8"); -} - -/** - * Serialize and write a trace to .sf/traces/ in the project root. - * Filename: trace-.json - * - * Purpose: provide the standard, project-local trace sink so that every - * autonomous mode session leaves a discoverable artifact in a known location. - * - * Consumer: headless.ts in the normal exit path and signal handlers. - */ -export function exportTraceToProject( - trace: Trace, - projectRoot: string, -): string { - const tracesDir = join(projectRoot, ".sf", "traces"); - if (!existsSync(tracesDir)) { - mkdirSync(tracesDir, { recursive: true }); - } - const filename = `trace-${Date.now()}.json`; - const path = join(tracesDir, filename); - writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8"); - return path; -} - -/** - * Read a trace from disk. - * - * Purpose: round-trip a trace file back into the typed model so that - * analysis tools, test assertions, and replay utilities can work with - * structured data instead of raw JSON. - * - * Consumer: trace analysis scripts, test helpers, and any tool that reads - * .sf/traces/ for post-session inspection. - */ -export function readTrace(path: string): Trace { - return JSON.parse(readFileSync(path, "utf-8")) as Trace; -}