diff --git a/src/headless.ts b/src/headless.ts index 3a0d87fa9..71ef6eef3 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1202,9 +1202,16 @@ async function runHeadlessOnce( const ame = eventObj.assistantMessageEvent as | Record | undefined; - const deltaText = String(ame?.delta ?? ame?.text ?? ""); - if (deltaText && isNewMilestone && options.auto) { - milestoneReady ||= isMilestoneReadyText(deltaText); + // Milestone-ready detection: only on text_delta events (exclude tool + // output and verbose log lines that could spuriously match the pattern). + // Accumulate a rolling 200-char buffer so patterns split across two + // consecutive deltas are still detected. + if (isNewMilestone && options.auto && ame?.type === "text_delta") { + const deltaText = String(ame?.delta ?? ame?.text ?? ""); + if (deltaText) { + milestoneDetectionBuffer = (milestoneDetectionBuffer + deltaText).slice(-200); + milestoneReady ||= isMilestoneReadyText(milestoneDetectionBuffer); + } } if (ame && options.verbose) { const ameType = String(ame.type ?? ""); diff --git a/src/resources/extensions/sf/auto-post-unit.ts b/src/resources/extensions/sf/auto-post-unit.ts index ffb61282f..814314d2d 100644 --- a/src/resources/extensions/sf/auto-post-unit.ts +++ b/src/resources/extensions/sf/auto-post-unit.ts @@ -1247,6 +1247,30 @@ export async function postUnitPreVerification( `Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — DB unavailable, skipping retry.${dbSkipDiag ? ` Expected: ${dbSkipDiag}` : ""}`, "error", ); + } else if ( + !triggerArtifactVerified && + s.lastToolInvocationError && + isDeterministicPolicyError(s.lastToolInvocationError) + ) { + // Deterministic policy rejection (#4973): structural write-gate failure + // that will recur on every retry — write a blocker placeholder to advance pipeline. + const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`; + debugLog("postUnit", { + phase: "deterministic-policy-error-placeholder", + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + error: s.lastToolInvocationError, + }); + const reason = `Deterministic policy rejection for ${s.currentUnit.type} "${s.currentUnit.id}": ${s.lastToolInvocationError}. Retrying cannot resolve this gate — writing blocker placeholder to advance pipeline.`; + s.lastToolInvocationError = null; + s.pendingVerificationRetry = null; + s.verificationRetryCount.delete(retryKey); + writeBlockerPlaceholder(s.currentUnit.type, s.currentUnit.id, s.basePath, reason); + ctx.ui.notify( + `${s.currentUnit.type} ${s.currentUnit.id} — deterministic policy rejection, wrote blocker placeholder (no retries) (#4973)`, + "warning", + ); + // Fall through to "continue" — do NOT enter the retry or db-unavailable paths. } else if (!triggerArtifactVerified) { const taskCompleteFailure = taskCompleteFailureForCurrentUnit(s); if (taskCompleteFailure) { diff --git a/src/resources/extensions/sf/auto-runtime-state.ts b/src/resources/extensions/sf/auto-runtime-state.ts new file mode 100644 index 000000000..6afb1cfba --- /dev/null +++ b/src/resources/extensions/sf/auto-runtime-state.ts @@ -0,0 +1,51 @@ +// SF auto-mode runtime state +import { AutoSession } from "./auto/session.js"; +import type { CurrentUnit } from "./auto/session.js"; +import { + isDeterministicPolicyError, + isQueuedUserMessageSkip, + isToolInvocationError, + markToolEnd as markTrackedToolEnd, + markToolStart as markTrackedToolStart, +} from "./auto-tool-tracking.js"; + +export const autoSession = new AutoSession(); + +export type AutoRuntimeSnapshot = { + active: boolean; + paused: boolean; + currentUnit: CurrentUnit | null; + basePath: string; +}; + +export function getAutoRuntimeSnapshot(): AutoRuntimeSnapshot { + return { + active: autoSession.active, + paused: autoSession.paused, + currentUnit: autoSession.currentUnit ? { ...autoSession.currentUnit } : null, + basePath: autoSession.basePath, + }; +} + +export function isAutoActive(): boolean { + return autoSession.active; +} + +export function isAutoPaused(): boolean { + return autoSession.paused; +} + +export function markToolStart(toolCallId: string, toolName?: string): void { + markTrackedToolStart(toolCallId, autoSession.active, toolName); +} + +export function markToolEnd(toolCallId: string): void { + markTrackedToolEnd(toolCallId); +} + +export function recordToolInvocationError(toolName: string, errorMsg: string): void { + if (!autoSession.active) return; + if (isToolInvocationError(errorMsg) || isQueuedUserMessageSkip(errorMsg) || isDeterministicPolicyError(errorMsg)) { + autoSession.lastToolInvocationError = `${toolName}: ${errorMsg}`; + } +} diff --git a/src/resources/extensions/sf/component-types.ts b/src/resources/extensions/sf/component-types.ts new file mode 100644 index 000000000..5fa1a58a8 --- /dev/null +++ b/src/resources/extensions/sf/component-types.ts @@ -0,0 +1,362 @@ +/** + * Unified Component Type Definitions + * + * Shared metadata for installable/discoverable skills and agents. + * + * Replaces the separate type systems in: + * - packages/pi-coding-agent/src/core/skills.ts (SkillFrontmatter, Skill) + * - src/resources/extensions/subagent/agents.ts (AgentConfig) + * + * Legacy skill and agent formats are supported via backward-compatible loading. + */ + +// ============================================================================ +// Component Kind +// ============================================================================ + +/** All supported component types for the first component-system slice. */ +export type ComponentKind = 'skill' | 'agent'; + +/** API version for component.yaml spec */ +export type ComponentApiVersion = 'sf/v1'; + +// ============================================================================ +// Component Metadata +// ============================================================================ + +export interface ComponentAuthor { + name: string; + email?: string; + url?: string; +} + +export interface ComponentMetadata { + /** Component name (lowercase a-z, 0-9, hyphens). Required. */ + name: string; + + /** Human-readable description. Required. */ + description: string; + + /** Namespace for plugin-sourced components (e.g., "my-plugin"). */ + namespace?: string; + + /** Semver version string. */ + version?: string; + + /** Author information. */ + author?: ComponentAuthor; + + /** Searchable tags. */ + tags?: string[]; + + /** SPDX license identifier. */ + license?: string; +} + +// ============================================================================ +// Skill Spec +// ============================================================================ + +export interface SkillSpec { + /** Path to the prompt content file (relative to component dir). */ + prompt: string; + + /** If true, skill is excluded from LLM system prompt (invoke-only). */ + disableModelInvocation?: boolean; +} + +// ============================================================================ +// Agent Spec +// ============================================================================ + +export interface AgentToolConfig { + /** Tools the agent is allowed to use. If set, only these tools are available. */ + allow?: string[]; + + /** Tools the agent is explicitly denied. Applied after allow. */ + deny?: string[]; +} + +export interface AgentContextConfig { + /** Files to always include in the agent's context. */ + alwaysInclude?: string[]; + + /** Whether to inject project context (project files, structure). */ + injectProjectContext?: boolean; + + /** Whether to inject current git status. */ + injectGitStatus?: boolean; +} + +export interface AgentOutputSchema { + type: 'object' | 'string'; + properties?: Record; + required?: string[]; +} + +export interface AgentSpec { + /** Path to the system prompt file (relative to component dir). */ + systemPrompt: string; + + /** Model override (e.g., "claude-sonnet-4-6"). */ + model?: string; + + /** Fallback models to try if primary fails. */ + modelFallbacks?: string[]; + + /** Tool access configuration. */ + tools?: AgentToolConfig | string[]; + + /** Maximum number of turns before the agent is stopped. */ + maxTurns?: number; + + /** Maximum tokens budget per invocation. */ + maxTokens?: number; + + /** Hard timeout in minutes. */ + timeoutMinutes?: number; + + /** Temperature override. */ + temperature?: number; + + /** Thinking level override. */ + thinking?: 'off' | 'minimal' | 'standard' | 'full'; + + /** Output format preference. */ + outputFormat?: 'text' | 'structured' | 'markdown'; + + /** Context injection configuration. */ + context?: AgentContextConfig; + + /** Isolation mode for execution. */ + isolation?: 'none' | 'worktree'; + + /** Merge strategy when isolation is used. */ + mergeStrategy?: 'patch' | 'squash' | 'manual'; + + /** Whether the agent accepts {previous} input from chain mode. */ + acceptsInput?: boolean; + + /** Structured output contract. */ + outputSchema?: AgentOutputSchema; + + /** Name of another agent to inherit configuration from. */ + extends?: string; +} + +// ============================================================================ +// Dependency & Compatibility +// ============================================================================ + +export interface ComponentDependencies { + /** Required skills that must be installed. */ + skills?: string[]; + + /** Required agents that must be installed. */ + agents?: string[]; + + /** Required MCP servers. */ + mcpServers?: string[]; +} + +export interface ComponentCompatibility { + /** Minimum SF version (semver range). */ + sf?: string; + + /** Minimum Node.js version (semver range). */ + node?: string; +} + +// ============================================================================ +// Agent Routing +// ============================================================================ + +export interface AgentRoutingRule { + /** Natural-language condition for when this agent should be used. */ + when: string; + + /** Confidence level for this rule. */ + confidence?: 'low' | 'medium' | 'high'; +} + +export type ComponentSpec = SkillSpec | AgentSpec; + +// ============================================================================ +// Full Component Definition +// ============================================================================ + +/** + * Complete component.yaml definition. + * This is the parsed representation of a component.yaml file. + */ +export interface ComponentDefinition { + apiVersion: ComponentApiVersion; + kind: ComponentKind; + metadata: ComponentMetadata; + spec: ComponentSpec; + + /** Dependencies on other components. */ + requires?: ComponentDependencies; + + /** Version compatibility constraints. */ + compatibility?: ComponentCompatibility; + + /** Agent routing rules (only for kind: agent). */ + routing?: AgentRoutingRule[]; +} + +// ============================================================================ +// Resolved Component (Runtime) +// ============================================================================ + +/** Source of a loaded component */ +export type ComponentSource = 'user' | 'project' | 'builtin' | 'plugin' | 'path'; + +/** + * A fully resolved component at runtime. + * Combines the definition with resolution metadata. + */ +export interface Component { + /** Unique identifier: `${namespace}:${name}` or bare `name`. */ + id: string; + + /** Component kind. */ + kind: ComponentKind; + + /** Component metadata. */ + metadata: ComponentMetadata; + + /** Kind-specific specification. */ + spec: ComponentSpec; + + /** Dependencies. */ + requires?: ComponentDependencies; + + /** Compatibility constraints. */ + compatibility?: ComponentCompatibility; + + /** Routing rules (agents only). */ + routing?: AgentRoutingRule[]; + + /** Absolute path to the component directory. */ + dirPath: string; + + /** Absolute path to the definition file (component.yaml or SKILL.md or agent.md). */ + filePath: string; + + /** How this component was discovered. */ + source: ComponentSource; + + /** Format of the original definition. */ + format: 'component-yaml' | 'skill-md' | 'agent-md'; + + /** Whether the component is currently enabled. */ + enabled: boolean; +} + +// ============================================================================ +// Registry Types +// ============================================================================ + +export interface ComponentFilter { + /** Filter by kind. */ + kind?: ComponentKind | ComponentKind[]; + + /** Filter by source. */ + source?: ComponentSource | ComponentSource[]; + + /** Filter by namespace. */ + namespace?: string; + + /** Filter by tags (any match). */ + tags?: string[]; + + /** Text search across name and description. */ + search?: string; + + /** Only enabled components. Default: true. */ + enabledOnly?: boolean; +} + +export interface ComponentDiagnostic { + type: 'warning' | 'error' | 'collision'; + message: string; + componentId?: string; + path?: string; + collision?: { + name: string; + winnerPath: string; + loserPath: string; + winnerSource?: string; + loserSource?: string; + }; +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** Max name length per spec */ +export const MAX_NAME_LENGTH = 64; + +/** Max description length per spec */ +export const MAX_DESCRIPTION_LENGTH = 1024; + +/** Valid name pattern: lowercase a-z, 0-9, hyphens, no leading/trailing/consecutive hyphens */ +export const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +/** + * Validate a component name. + * @returns Array of error messages (empty if valid). + */ +export function validateComponentName(name: string): string[] { + const errors: string[] = []; + + if (!name || name.trim() === '') { + errors.push('name is required'); + return errors; + } + + if (name.length > MAX_NAME_LENGTH) { + errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + } + + if (name.includes('--')) { + errors.push('name must not contain consecutive hyphens'); + } + + if (!NAME_PATTERN.test(name)) { + if (/[A-Z]/.test(name)) { + errors.push('name must be lowercase'); + } else if (name.startsWith('-') || name.endsWith('-')) { + errors.push('name must not start or end with a hyphen'); + } else if (!name.includes('--')) { + errors.push('name must contain only lowercase a-z, 0-9, and hyphens'); + } + } + + return errors; +} + +/** + * Validate a component description. + * @returns Array of error messages (empty if valid). + */ +export function validateComponentDescription(description: string | undefined): string[] { + const errors: string[] = []; + + if (!description || description.trim() === '') { + errors.push('description is required'); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + + return errors; +} + +/** + * Compute the canonical ID for a component. + */ +export function computeComponentId(name: string, namespace?: string): string { + return namespace ? `${namespace}:${name}` : name; +} diff --git a/src/resources/extensions/sf/context-injector.ts b/src/resources/extensions/sf/context-injector.ts index ed239239a..bfa8cbf7d 100644 --- a/src/resources/extensions/sf/context-injector.ts +++ b/src/resources/extensions/sf/context-injector.ts @@ -86,6 +86,10 @@ export function injectContext( `context-injector: truncating artifact "${relPath}" from step "${refStepId}" ` + `(${content.length} chars → ${MAX_CONTEXT_CHARS} chars)`, ); + // NOTE: truncation is raw character-level and will produce invalid JSON + // if the artifact is a JSON file. This is intentional — the injected + // context is always wrapped in a plain-text delimiter block (---), so + // downstream consumers must treat it as opaque text, not structured data. content = content.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]"; } diff --git a/src/resources/extensions/sf/doctor.ts b/src/resources/extensions/sf/doctor.ts index 428d75c00..36e20df9f 100644 --- a/src/resources/extensions/sf/doctor.ts +++ b/src/resources/extensions/sf/doctor.ts @@ -1173,7 +1173,7 @@ export async function runSFDoctor( git: gitMs, runtime: runtimeMs, environment: envMs, - sfState: Math.max(0, Date.now() - t0env - envMs), + sfState: Math.max(0, Date.now() - t0state), }, }; await appendDoctorHistory(basePath, report); diff --git a/src/resources/extensions/sf/exec-history.ts b/src/resources/extensions/sf/exec-history.ts index 79e13ea97..0e9f32cd7 100644 --- a/src/resources/extensions/sf/exec-history.ts +++ b/src/resources/extensions/sf/exec-history.ts @@ -95,6 +95,9 @@ function safeReadMeta(path: string): ExecHistoryEntry | null { } } +/** + * List all execution history entries, sorted by most recent first. + */ export function listExecHistory(baseDir: string): ExecHistoryEntry[] { const metas = listMetaFiles(baseDir) .map((path) => { diff --git a/src/resources/extensions/sf/memory-embeddings.ts b/src/resources/extensions/sf/memory-embeddings.ts new file mode 100644 index 000000000..399c26812 --- /dev/null +++ b/src/resources/extensions/sf/memory-embeddings.ts @@ -0,0 +1,235 @@ +// SF Memory Embeddings — provider-agnostic embedding layer +// +// Same model-discovery pattern as buildMemoryLLMCall: prefers a dedicated +// embedding-capable model when available, and returns null when none is +// found (which is the common case — not every provider exposes embeddings). +// +// When embeddings are unavailable, all calls become no-ops and +// queryMemoriesRanked falls back to keyword-only scoring. + +import type { ExtensionContext } from "@singularity-forge/pi-coding-agent"; + +import { _getAdapter, isDbAvailable, upsertMemoryEmbedding, deleteMemoryEmbedding } from "./sf-db.js"; +import { logWarning } from "./workflow-logger.js"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type EmbedFn = (texts: string[]) => Promise; + +export interface EmbeddingModelInfo { + id: string; +} + +export interface MemoryEmbeddingRow { + memoryId: string; + model: string; + dim: number; + vector: Float32Array; +} + +// ─── Model selection ──────────────────────────────────────────────────────── + +const EMBEDDING_ID_HINTS = [ + "embed", + "embedding", + "voyage", + "text-embedding", + "nomic", + "jina-embed", + "bge", + "mxbai-embed", +]; + +/** + * Try to build an embedding function from the model registry. Returns null + * when no embedding-capable model is obvious from the registry metadata. + * + * NOTE: the Pi SDK doesn't yet expose a dedicated embeddings API for every + * provider. This implementation currently targets Anthropic / OpenAI-shaped + * SDKs: when the caller has direct API access via `ctx.modelRegistry`, they + * can wire this up by providing an `embedFn` override. We ship the hint-based + * detection here so future providers can plug in without touching callers. + */ +export function buildEmbeddingFn(ctx: ExtensionContext): EmbedFn | null { + try { + const available = ctx.modelRegistry?.getAvailable?.(); + if (!available || available.length === 0) return null; + const candidate = available.find((model) => { + const id = typeof model?.id === "string" ? model.id.toLowerCase() : ""; + return EMBEDDING_ID_HINTS.some((hint) => id.includes(hint)); + }); + if (!candidate) return null; + // We don't currently have a provider-neutral embedding call in Pi; the + // detection surface is in place so wiring can happen once Pi offers it. + return null; + } catch (err) { + logWarning("memory-embeddings", `model discovery failed: ${(err as Error).message}`); + return null; + } +} + +// ─── Vector (de)serialization ─────────────────────────────────────────────── + +export function packFloat32(vec: Float32Array): Uint8Array { + return new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength); +} + +export function unpackFloat32(blob: unknown): Float32Array | null { + if (!blob) return null; + try { + if (blob instanceof Float32Array) return blob; + let view: Uint8Array; + if (blob instanceof Uint8Array) { + view = blob; + } else if (blob instanceof ArrayBuffer) { + view = new Uint8Array(blob); + } else if ((blob as Buffer).buffer && (blob as Buffer).byteLength != null) { + const buf = blob as Buffer; + view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } else if (Array.isArray(blob)) { + return new Float32Array(blob as number[]); + } else { + return null; + } + if (view.byteLength % 4 !== 0) return null; + // Copy into an aligned buffer — BLOBs may arrive at odd byte offsets. + const aligned = new ArrayBuffer(view.byteLength); + new Uint8Array(aligned).set(view); + return new Float32Array(aligned); + } catch { + return null; + } +} + +// ─── Math ─────────────────────────────────────────────────────────────────── + +export function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + dot += x * y; + normA += x * x; + normB += y * y; + } + if (normA === 0 || normB === 0) return 0; + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +// ─── Read helpers ─────────────────────────────────────────────────────────── + +export function getEmbeddingForMemory(memoryId: string): MemoryEmbeddingRow | null { + if (!isDbAvailable()) return null; + const adapter = _getAdapter(); + if (!adapter) return null; + try { + const row = adapter + .prepare( + "SELECT memory_id, model, dim, vector FROM memory_embeddings WHERE memory_id = :id", + ) + .get({ ":id": memoryId }); + if (!row) return null; + const vector = unpackFloat32(row["vector"]); + if (!vector) return null; + return { + memoryId: row["memory_id"] as string, + model: row["model"] as string, + dim: row["dim"] as number, + vector, + }; + } catch { + return null; + } +} + +export function loadAllEmbeddings(): MemoryEmbeddingRow[] { + if (!isDbAvailable()) return []; + const adapter = _getAdapter(); + if (!adapter) return []; + try { + const rows = adapter + .prepare( + `SELECT e.memory_id, e.model, e.dim, e.vector + FROM memory_embeddings e + JOIN memories m ON m.id = e.memory_id + WHERE m.superseded_by IS NULL`, + ) + .all(); + const out: MemoryEmbeddingRow[] = []; + for (const row of rows) { + const vector = unpackFloat32(row["vector"]); + if (!vector) continue; + out.push({ + memoryId: row["memory_id"] as string, + model: row["model"] as string, + dim: row["dim"] as number, + vector, + }); + } + return out; + } catch { + return []; + } +} + +// ─── Write helpers ────────────────────────────────────────────────────────── + +export function saveEmbedding( + memoryId: string, + vector: Float32Array, + model: string, +): boolean { + if (!isDbAvailable()) return false; + try { + upsertMemoryEmbedding({ + memoryId, + model, + dim: vector.length, + vector: packFloat32(vector), + updatedAt: new Date().toISOString(), + }); + return true; + } catch { + return false; + } +} + +export function removeEmbedding(memoryId: string): boolean { + if (!isDbAvailable()) return false; + try { + return deleteMemoryEmbedding(memoryId); + } catch { + return false; + } +} + +// ─── Orchestration ────────────────────────────────────────────────────────── + +/** + * Embed each memory's content via `embedFn` (if provided) and persist the + * resulting vectors. Returns the number of successfully embedded memories. + * Safe to call with embedFn=null — it becomes a no-op. + */ +export async function embedMemories( + memories: Array<{ id: string; content: string }>, + embedFn: EmbedFn | null, + model: string, +): Promise { + if (!embedFn || memories.length === 0) return 0; + try { + const vectors = await embedFn(memories.map((m) => m.content)); + let count = 0; + for (let i = 0; i < memories.length && i < vectors.length; i++) { + const vector = vectors[i]; + if (!vector || vector.length === 0) continue; + if (saveEmbedding(memories[i].id, vector, model)) count++; + } + return count; + } catch (err) { + logWarning("memory-embeddings", `embed failed: ${(err as Error).message}`); + return 0; + } +} diff --git a/src/resources/extensions/sf/model-cost-table.ts b/src/resources/extensions/sf/model-cost-table.ts index bb99fe314..4da9383d4 100644 --- a/src/resources/extensions/sf/model-cost-table.ts +++ b/src/resources/extensions/sf/model-cost-table.ts @@ -348,3 +348,89 @@ export function compareModelCost(modelIdA: string, modelIdB: string): number { const costB = lookupModelCost(modelIdB)?.inputPer1k ?? 999; return costA - costB; } + +/** + * Subscription config shape accepted by getEffectiveTokenCost. + * Mirrors SubscriptionConfig from preferences-types but kept local to avoid + * circular imports between model-cost-table and preferences-types. + */ +export interface SubscriptionCostContext { + monthly_cost_usd?: number; + provider?: string; + tokens_used_this_month?: number; +} + +/** + * Return the effective per-token cost (in USD per token, not per 1K) for a + * given provider/model pair, taking subscription amortization into account. + * + * Resolution order: + * 1. If the provider matches the configured subscription provider and + * `monthly_cost_usd` is set, compute: + * amortized = monthly_cost_usd / max(tokens_used_this_month, 1_000_000) + * (The denominator floor of 1 M tokens prevents unrealistically high cost + * estimates early in the month while keeping the number meaningful.) + * 2. Otherwise fall back to the static BUNDLED_COST_TABLE input rate / 1000. + * 3. If the model is not in the table either, return 0 (unknown / free). + * + * The returned value is in USD per single token (not per 1K), so callers can + * multiply directly by token counts. + */ +export function getEffectiveTokenCost( + provider: string, + modelId: string, + subscription?: SubscriptionCostContext, +): { inputPerToken: number; outputPerToken: number; isSubscription: boolean } { + const providerKey = provider.toLowerCase(); + const subProvider = subscription?.provider?.toLowerCase(); + + if ( + subProvider && + providerKey === subProvider && + subscription?.monthly_cost_usd != null && + subscription.monthly_cost_usd > 0 + ) { + // Amortize monthly cost over tokens consumed this month. + // Use a floor of 1_000_000 tokens so cost is non-trivially large early + // in the month (prevents showing $100/token in week 1). + const tokensUsed = Math.max( + subscription.tokens_used_this_month ?? 0, + 1_000_000, + ); + const amortized = subscription.monthly_cost_usd / tokensUsed; + return { + inputPerToken: amortized, + outputPerToken: amortized, // treat input/output symmetrically for subscriptions + isSubscription: true, + }; + } + + const entry = lookupModelCost(modelId); + if (!entry) { + return { inputPerToken: 0, outputPerToken: 0, isSubscription: false }; + } + return { + inputPerToken: entry.inputPer1k / 1000, + outputPerToken: entry.outputPer1k / 1000, + isSubscription: false, + }; +} + +/** + * Estimate total USD cost for a completed request given token counts. + * Uses getEffectiveTokenCost internally so subscription amortization applies. + */ +export function estimateRequestCost( + provider: string, + modelId: string, + inputTokens: number, + outputTokens: number, + subscription?: SubscriptionCostContext, +): number { + const { inputPerToken, outputPerToken } = getEffectiveTokenCost( + provider, + modelId, + subscription, + ); + return inputTokens * inputPerToken + outputTokens * outputPerToken; +} diff --git a/src/resources/extensions/sf/routing-history.ts b/src/resources/extensions/sf/routing-history.ts index 6d0143bb1..f7c781600 100644 --- a/src/resources/extensions/sf/routing-history.ts +++ b/src/resources/extensions/sf/routing-history.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { loadJsonFile, saveJsonFile } from "./json-persistence.js"; -import { sfRoot } from "./paths.js"; +import { sfRuntimeRoot } from "./paths.js"; import type { ComplexityTier } from "./types.js"; // ─── Types ─────────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/sf/sf-home.ts b/src/resources/extensions/sf/sf-home.ts new file mode 100644 index 000000000..7c4f0aa2f --- /dev/null +++ b/src/resources/extensions/sf/sf-home.ts @@ -0,0 +1,30 @@ +/** + * SF home directory resolution. + * + * Exports sfHome() which returns the SF configuration directory, + * defaulting to ~/.sf with a SF_HOME env var override. + * + * For the user's home directory, use os.homedir() directly — it handles + * platform-specific env lookup (USERPROFILE on Windows, HOME on POSIX) + * with appropriate fallbacks. + * + * @see https://github.com/gsd-build/gsd-2/issues/5015 + */ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * Resolve the SF home directory (typically ~/.sf). + * + * `SF_HOME` env var overrides the default location. + * Falls back to `homedir()/.sf`. + * + * Always returns an absolute, normalized path — `resolve()` canonicalizes + * any relative or non-canonical `SF_HOME` value so downstream comparison + * and redaction sites don't have to. + */ +export function sfHome(): string { + return process.env.SF_HOME + ? resolve(process.env.SF_HOME) + : join(homedir(), ".sf"); +} diff --git a/src/resources/extensions/sf/workflow-install.ts b/src/resources/extensions/sf/workflow-install.ts new file mode 100644 index 000000000..6da5fc49c --- /dev/null +++ b/src/resources/extensions/sf/workflow-install.ts @@ -0,0 +1,424 @@ +/** + * workflow-install.ts — Fetch, validate, and install remote workflow plugins. + * + * Accepts: + * - Full URL (https://raw.githubusercontent.com/... or gist raw URL) + * - gist:abc123 → https://gist.githubusercontent.com/anonymous/abc123/raw + * - gh:owner/repo/path[@ref] → raw.githubusercontent.com/owner/repo//path + * + * Installed files land in `~/.sf/workflows/.` by default, or + * `.sf/workflows/.` with the `--project` flag. + * + * A provenance file `~/.sf/workflows/.installed.json` (or project equivalent) + * records source URL, timestamp, and sha256 so `/sf workflow uninstall` can + * clean up and future `/sf workflow update` can refresh. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { extname, join, resolve, sep as pathSep } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; +import { parse as parseYaml } from "yaml"; + +import { validateDefinition } from "./definition-loader.js"; + +// ─── Constants ─────────────────────────────────────────────────────────── + +const MAX_RESPONSE_BYTES = 256 * 1024; +const FETCH_TIMEOUT_MS = 15_000; +const PROVENANCE_FILE = ".installed.json"; + +const sfHome = process.env.SF_HOME || join(homedir(), ".sf"); + +// ─── Provenance ────────────────────────────────────────────────────────── + +export interface ProvenanceEntry { + source: string; + installedAt: string; + sha256: string; + filename: string; +} + +type Provenance = Record; + +function provenancePath(dir: string): string { + return join(dir, PROVENANCE_FILE); +} + +function readProvenance(dir: string): Provenance { + const path = provenancePath(dir); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as Provenance; + } catch { + return {}; + } +} + +function writeProvenance(dir: string, data: Provenance): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(provenancePath(dir), JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +// ─── Install targets ───────────────────────────────────────────────────── + +export interface InstallTarget { + dir: string; + scope: "global" | "project"; +} + +export function globalInstallDir(): string { + return join(sfHome, "workflows"); +} + +export function projectInstallDir(basePath: string): string { + return join(basePath, ".sf", "workflows"); +} + +/** + * Reject plugin names that could escape the workflows directory. + * Allows a-z, A-Z, 0-9, dot, underscore, hyphen — no separators, no dot-segments. + */ +function assertSafePluginName(name: string): void { + if (!name || name === "." || name === "..") { + throw new Error(`Invalid plugin name: "${name}"`); + } + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + throw new Error( + `Invalid plugin name "${name}". Allowed characters: letters, digits, dot, underscore, hyphen.`, + ); + } +} + +/** + * Resolve `child` inside `dir` and refuse any result that escapes `dir`. + */ +function safeResolveInDir(dir: string, child: string): string { + const resolvedDir = resolve(dir); + const resolvedPath = resolve(resolvedDir, child); + if ( + resolvedPath !== resolvedDir && + !resolvedPath.startsWith(resolvedDir + pathSep) + ) { + throw new Error(`Refusing to operate outside ${dir}: ${child}`); + } + return resolvedPath; +} + +// ─── Source URL resolution ─────────────────────────────────────────────── + +/** + * Turn a user-supplied source specifier into a fetchable HTTPS URL. + * Throws on clearly unsafe inputs (file://, unsupported schemes). + */ +export function resolveSourceUrl(source: string): string { + const trimmed = source.trim(); + + // gist: + if (trimmed.startsWith("gist:")) { + const id = trimmed.slice("gist:".length).trim(); + if (!/^[a-f0-9]{6,}$/i.test(id)) { + throw new Error(`Invalid gist id: ${id}`); + } + return `https://gist.githubusercontent.com/anonymous/${id}/raw`; + } + + // gh:owner/repo/path[@ref] + if (trimmed.startsWith("gh:")) { + const rest = trimmed.slice("gh:".length); + const atIdx = rest.lastIndexOf("@"); + const pathPart = atIdx === -1 ? rest : rest.slice(0, atIdx); + const ref = atIdx === -1 ? "main" : rest.slice(atIdx + 1); + const parts = pathPart.split("/"); + if (parts.length < 3) { + throw new Error(`Expected gh://: ${trimmed}`); + } + const [owner, repo, ...filePath] = parts; + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.join("/")}`; + } + + // file:// — reject + if (trimmed.startsWith("file:")) { + throw new Error("file:// sources are not supported for security reasons."); + } + + // Must be https:// (or http://localhost for dev) + if (trimmed.startsWith("https://")) return trimmed; + if (trimmed.startsWith("http://")) { + const url = new URL(trimmed); + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + return trimmed; + } + throw new Error("http:// is only allowed for localhost. Use https://."); + } + + throw new Error( + `Unsupported source format: ${trimmed}\n` + + `Use one of: https://..., gist:, gh://[@ref]`, + ); +} + +// ─── Fetching ──────────────────────────────────────────────────────────── + +export interface FetchedContent { + url: string; + filename: string; + ext: ".yaml" | ".yml" | ".md"; + content: string; + sha256: string; +} + +/** + * Fetch the resolved URL with a timeout and a max response size. + * Injects a simple User-Agent so GitHub doesn't 403. + */ +export async function fetchWorkflowSource(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { "User-Agent": "sf-workflow-install" }, + }); + + if (!res.ok) { + throw new Error(`Fetch failed (${res.status} ${res.statusText}): ${url}`); + } + + // Cap size: read as a stream and bail if it exceeds MAX_RESPONSE_BYTES. + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_RESPONSE_BYTES) { + throw new Error( + `Response too large (${buf.byteLength} bytes, max ${MAX_RESPONSE_BYTES}): ${url}`, + ); + } + + const content = new TextDecoder().decode(buf); + + // Prefer the final response URL after redirects (e.g., gist /raw → /raw//file.ext). + const finalUrl = typeof res.url === "string" && res.url ? res.url : url; + let pathname: string; + try { + pathname = new URL(finalUrl).pathname; + } catch { + pathname = new URL(url).pathname; + } + let filebasename = pathname.slice(pathname.lastIndexOf("/") + 1); + let rawExt = extname(filebasename).toLowerCase(); + + let ext: ".yaml" | ".yml" | ".md"; + if (rawExt === ".yaml" || rawExt === ".yml" || rawExt === ".md") { + ext = rawExt as ".yaml" | ".yml" | ".md"; + } else { + // Fallback: sniff content. Gist /raw and similar URLs have no extension. + if (/[\s\S]*?<\/template_meta>/.test(content)) { + ext = ".md"; + } else { + let parsed: unknown; + try { + parsed = parseYaml(content); + } catch { + parsed = undefined; + } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + ext = ".yaml"; + } else { + throw new Error( + `Cannot determine workflow type from ${url}. ` + + `Expected .yaml/.yml/.md URL, a markdown file with , ` + + `or a YAML document.`, + ); + } + } + // Synthesize a filename so downstream sanitizers have something to chew on. + if (!filebasename) filebasename = "workflow"; + filebasename = `${filebasename}${ext}`; + } + + const filename = filebasename; + const sha256 = createHash("sha256").update(content).digest("hex"); + + return { url, filename, ext, content, sha256 }; + } finally { + clearTimeout(timer); + } +} + +// ─── Validation ────────────────────────────────────────────────────────── + +/** + * Validate fetched content: YAML must pass validateDefinition, markdown must + * have a `` block with at least `name`. + */ +export function validateFetchedContent(fetched: FetchedContent): void { + if (fetched.ext === ".yaml" || fetched.ext === ".yml") { + let parsed: unknown; + try { + parsed = parseYaml(fetched.content); + } catch (err) { + throw new Error( + `Installed YAML failed to parse: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const result = validateDefinition(parsed); + if (!result.valid) { + throw new Error( + `Installed YAML failed validation:\n - ${result.errors.join("\n - ")}`, + ); + } + // Optional: validate `mode:` if present. + if (parsed && typeof parsed === "object") { + const mode = (parsed as Record).mode; + if (mode !== undefined && mode !== "oneshot" && mode !== "yaml-step") { + throw new Error( + `YAML plugins must declare mode: oneshot or yaml-step (got "${String(mode)}")`, + ); + } + } + return; + } + + // Markdown: require a block with at least a name. + const metaMatch = fetched.content.match(/([\s\S]*?)<\/template_meta>/); + if (!metaMatch) { + throw new Error("Installed markdown must contain a block."); + } + if (!/\bname\s*:/i.test(metaMatch[1])) { + throw new Error("Installed markdown must declare at least `name:`."); + } + // Optional: validate `mode:` if declared. + const modeLine = metaMatch[1].match(/\bmode\s*:\s*(\S+)/i); + if (modeLine) { + const mode = modeLine[1]; + if (mode !== "oneshot" && mode !== "markdown-phase") { + throw new Error( + `Markdown plugins must declare mode: oneshot or markdown-phase (got "${mode}")`, + ); + } + } +} + +// ─── Name inference ────────────────────────────────────────────────────── + +/** + * Infer a plugin name from fetched content. For YAML, prefer the top-level + * `name:` field. For markdown, prefer `.name`. Fall back to + * the filename stem. + */ +export function inferPluginName(fetched: FetchedContent): string { + if (fetched.ext === ".yaml" || fetched.ext === ".yml") { + try { + const parsed = parseYaml(fetched.content); + if (parsed && typeof parsed === "object") { + const n = (parsed as Record).name; + if (typeof n === "string" && n.trim()) return sanitizeName(n); + } + } catch { + // Fall through to filename. + } + } else { + const metaMatch = fetched.content.match(/([\s\S]*?)<\/template_meta>/); + if (metaMatch) { + const nameMatch = metaMatch[1].match(/\bname\s*:\s*(\S+)/i); + if (nameMatch) return sanitizeName(nameMatch[1]); + } + } + const stem = fetched.filename.replace(/\.[^.]+$/, ""); + return sanitizeName(stem); +} + +function sanitizeName(raw: string): string { + return raw.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, ""); +} + +// ─── Install / uninstall ───────────────────────────────────────────────── + +export interface InstallResult { + path: string; + name: string; + ext: ".yaml" | ".yml" | ".md"; + source: string; +} + +/** + * Write the fetched plugin to disk and update the provenance file. + * Does NOT prompt — caller is responsible for confirming with the user first. + */ +export function installPlugin( + target: InstallTarget, + fetched: FetchedContent, + name: string, +): InstallResult { + assertSafePluginName(name); + mkdirSync(target.dir, { recursive: true }); + const filename = `${name}${fetched.ext}`; + const path = safeResolveInDir(target.dir, filename); + writeFileSync(path, fetched.content, "utf-8"); + + const prov = readProvenance(target.dir); + prov[name] = { + source: fetched.url, + installedAt: new Date().toISOString(), + sha256: fetched.sha256, + filename, + }; + writeProvenance(target.dir, prov); + + return { path, name, ext: fetched.ext, source: fetched.url }; +} + +export interface UninstallResult { + removed: boolean; + path?: string; + warnedNotInProvenance?: boolean; +} + +/** + * Remove an installed plugin and its provenance record. + * Checks global dir first, then project (same order as install default). + */ +export function uninstallPlugin(basePath: string, name: string): UninstallResult { + assertSafePluginName(name); + for (const dir of [globalInstallDir(), projectInstallDir(basePath)]) { + const prov = readProvenance(dir); + const entry = prov[name]; + if (entry) { + // Re-validate the filename recorded in provenance: a malicious provenance + // file must not trick us into deleting outside `dir`. + assertSafePluginName(entry.filename.replace(/\.(yaml|yml|md)$/i, "")); + const path = safeResolveInDir(dir, entry.filename); + if (existsSync(path)) unlinkSync(path); + delete prov[name]; + writeProvenance(dir, prov); + return { removed: true, path }; + } + + // No provenance, but file might still exist. + for (const ext of [".yaml", ".yml", ".md"]) { + const candidate = safeResolveInDir(dir, `${name}${ext}`); + if (existsSync(candidate) && statSync(candidate).isFile()) { + unlinkSync(candidate); + return { removed: true, path: candidate, warnedNotInProvenance: true }; + } + } + } + return { removed: false }; +} + +// ─── Preview helpers ───────────────────────────────────────────────────── + +/** + * First N lines of the fetched content, for the install confirmation UI. + */ +export function previewContent(content: string, maxLines = 20): string { + const lines = content.split(/\r?\n/).slice(0, maxLines); + return lines.join("\n"); +} diff --git a/src/resources/extensions/sf/workflow-manifest.ts b/src/resources/extensions/sf/workflow-manifest.ts index 1f0d4d026..a0f25880a 100644 --- a/src/resources/extensions/sf/workflow-manifest.ts +++ b/src/resources/extensions/sf/workflow-manifest.ts @@ -125,20 +125,18 @@ export function snapshotState(): StateManifest { id: r["id"] as string, title: r["title"] as string, status: r["status"] as string, - depends_on: JSON.parse((r["depends_on"] as string) || "[]"), + depends_on: parseStringArray(r["depends_on"]), created_at: r["created_at"] as string, completed_at: (r["completed_at"] as string) ?? null, vision: (r["vision"] as string) ?? "", - success_criteria: JSON.parse((r["success_criteria"] as string) || "[]"), - key_risks: JSON.parse((r["key_risks"] as string) || "[]"), - proof_strategy: JSON.parse((r["proof_strategy"] as string) || "[]"), + success_criteria: parseStringArray(r["success_criteria"]), + key_risks: parseStringArray(r["key_risks"]), + proof_strategy: parseStringArray(r["proof_strategy"]), verification_contract: (r["verification_contract"] as string) ?? "", verification_integration: (r["verification_integration"] as string) ?? "", verification_operational: (r["verification_operational"] as string) ?? "", verification_uat: (r["verification_uat"] as string) ?? "", - definition_of_done: JSON.parse( - (r["definition_of_done"] as string) || "[]", - ), + definition_of_done: parseStringArray(r["definition_of_done"]), requirement_coverage: (r["requirement_coverage"] as string) ?? "", boundary_map_markdown: (r["boundary_map_markdown"] as string) ?? "", vision_meeting: @@ -157,7 +155,7 @@ export function snapshotState(): StateManifest { title: r["title"] as string, status: r["status"] as string, risk: r["risk"] as string, - depends: JSON.parse((r["depends"] as string) || "[]"), + depends: parseStringArray(r["depends"]), demo: (r["demo"] as string) ?? "", created_at: r["created_at"] as string, completed_at: (r["completed_at"] as string) ?? null, diff --git a/src/resources/extensions/sf/workflow-templates.ts b/src/resources/extensions/sf/workflow-templates.ts index 74b9116f4..b62519305 100644 --- a/src/resources/extensions/sf/workflow-templates.ts +++ b/src/resources/extensions/sf/workflow-templates.ts @@ -152,6 +152,9 @@ export function workflowTemplateCommandDefinitions(): WorkflowTemplateCommandDef * * Consumer: `/sf start` when called without a resolvable template. */ +/** + * Format usage text for /sf start command with available templates. + */ export function formatStartUsage(): string { const templates = workflowTemplateCommandDefinitions() .map(({ cmd, desc }) => ` ${cmd.padEnd(16)} ${desc}`) @@ -175,6 +178,10 @@ export function formatStartUsage(): string { ); } +/** + * Resolve a template by exact name or alias. + * Returns null if no match found. + */ /** * Resolve a template by exact name or alias. * Returns null if no match found. diff --git a/src/resources/extensions/sf/worktree-command.ts b/src/resources/extensions/sf/worktree-command.ts index 8bb27c4fa..5718e1921 100644 --- a/src/resources/extensions/sf/worktree-command.ts +++ b/src/resources/extensions/sf/worktree-command.ts @@ -17,7 +17,7 @@ import { rmSync, unlinkSync, } from "node:fs"; -import { join, sep } from "node:path"; +import { basename, join, normalize, sep } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext,