diff --git a/README.md b/README.md index c5ef3c8..b6489c0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Incremental session indexing and proposal generation for the [Pi coding agent](https://github.com/earendil-works/pi). -pi-prospector reads your Pi session transcripts, indexes them into a local SQLite database, and uses an LLM to propose improvements to your prompts, skills, and configuration — without applying them. You decide what to develop. +pi-prospector is a **Pi extension** that reads your Pi session transcripts, indexes them into a local SQLite database, and uses an LLM to propose improvements to your prompts, skills, and configuration — without applying them. You decide what to develop. ## How it works @@ -11,142 +11,127 @@ Pi sessions (~/.pi/agent/sessions/) │ ▼ ┌─────────────────────┐ -│ prospect sync │ ← Incremental. Only new lines are processed. -│ (no LLM, fast) │ Detects forks. Deduplicates shared message trees. +│ /prospect-sync │ ← Incremental. Only new lines are processed. +│ (no LLM, fast) │ Detects forks. Deduplicates shared message trees. +│ Also runs turn-pair- │ core (deterministic) analysis on new sessions. └────────┬────────────┘ │ ▼ ┌─────────────────────┐ -│ sessions.db │ ← All session data, messages, and proposals -│ (SQLite + FTS5) │ +│ prospector.db │ ← All session data, messages, analysis nodes, +│ (SQLite + FTS5) │ edges, and proposals └────────┬────────────┘ │ ▼ ┌─────────────────────┐ -│ prospect analyze │ ← Runs an LLM over unprocessed sessions. -│ (uses Pi provider) │ Generates proposals. Does NOT edit any files. +│ /prospect-analyze │ ← Runs LLM analyzers over unprocessed sessions. +│ (uses Ollama or │ Generates proposals. Does NOT edit any files. +│ Pi's model) │ └────────┬────────────┘ │ ▼ ┌─────────────────────┐ -│ proposals table │ ← status: new / accepted / rejected -│ in sessions.db │ Each proposal records when it was made. +│ proposals table │ ← status: open / applied / duplicate +│ in prospector.db │ Each proposal deduplicates by content hash. └────────┬────────────┘ │ ▼ ┌─────────────────────┐ -│ Pi tool: prospect │ ← Your coding agent lists proposals, accepts -│ /prospect command │ or rejects them, requests syncs, checks stats. +│ Pi tool: prospect │ ← Your coding agent syncs, checks stats, +│ /prospect commands │ lists/accepts/rejects proposals. └─────────────────────┘ ``` ## Install ```bash -pi install git:github:nicolas-marchildon/pi-prospector +# From local path (for development): +pi install /path/to/pi-prospector + +# From git (when published): +pi install git:github.com:v2nic/pi-prospector ``` -Requires pi with an LLM API key configured for at least one provider. You choose which model to use for analysis. +Requires [Ollama](https://ollama.com) running locally for LLM-backed analysis. The deterministic analyzer (`turn-pair-core`) works without any LLM. -## Commands +## Usage -### `/prospect sync` +### `/prospect-sync` -Index session files into the database. No LLM is called. Fast and cheap. +Index session files into the database. Also runs deterministic analysis on new sessions. - Scans `~/.pi/agent/sessions/` for new or modified `.jsonl` files -- Parses each file line-by-line, starting from the last line previously processed (incremental) -- Detects sessions that forked from another session via the `parentSession` header — shared message trees are stored once, not duplicated -- Tracks a cursor per session file: `{session_id, last_line, last_modified}` -- Re-indexes a file only if its modification time has changed since the last sync - -Run this as often as you like. It's idempotent and incremental. - -### `/prospect analyze [--limit N] [--model provider/model]` +- Parses each file line-by-line, starting from the last line processed (incremental) +- Detects sessions that forked from another via `parentSession` header +- After sync, runs `turn-pair-core` (deterministic) analysis on unanalyzed sessions +- No LLM required — fast and free -Run an LLM over sessions that have been synced but not yet analyzed. Generates proposals and inserts them into the database. +### `/prospect-analyze [--limit N] [--model model-spec]` -- Processes sessions in chronological order (oldest first) by default -- `--limit N`: only analyze N sessions (default: all unprocessed) -- `--model provider/model`: which Pi provider model to use (default: the model from `~/.pi/agent/prospector.json`, falls back to the current session model) -- Calls the Pi AI library (`@earendil-works/pi-ai`) directly — no subprocess, no extra session -- Each proposal records `created_at` so you can tell whether a session segment predates or postdates a given recommendation -- Analyze is incremental: it processes new or changed sessions regardless of whether past proposals from those sessions were accepted, rejected, or applied. The indexer and the analyzer are independent — sync always indexes new data, analyze always generates proposals from unprocessed data -- Proposals are never auto-applied. They sit in the database with status `new` until you decide +Run LLM analysis over sessions that have been synced but not yet analyzed. -### `/prospect stats` +- `--limit N`: only analyze N sessions +- `--model model-spec`: Ollama model spec (e.g. `glm-5.1:cloud`, `deepseek-v4-flash:cloud`) + - If `--model` is not specified, falls back to `model` in config, then Pi's current model +- Runs all registered analyzers in topological order: + 1. `turn-pair-core` — deterministic user/assistant pair analysis + 2. `turn-pair-llm` — LLM-backed classification of pairs + 3. `session-overview` — LLM summary and sentiment arc analysis -Print a summary of the database: +### `/prospect-stats` -- Total sessions indexed -- Total messages (user + assistant) and tool responses -- Number of messages processed by the LLM -- Number of proposals by status (new / accepted / rejected) +Print database statistics: sessions indexed, messages, analysis nodes/edges/runs, proposals by status. -### `/prospect proposals [--status new|accepted|rejected]` +### `/prospect-proposals [status]` -List proposals from the database, optionally filtered by status. +List proposals, optionally filtered by status (`open`, `applied`, `rejected`). -Each proposal shows: -- **ID** — unique identifier -- **Target** — what this proposal suggests changing (e.g. `AGENTS.md § Tool usage`, `skill/debug-typescript-errors`) -- **Severity** — `friction` | `correction` | `waste` | `suggestion` -- **Summary** — one-line description of the proposed change -- **Created** — when the proposal was generated -- **Session** — which session triggered it -- **Status** — `new`, `accepted`, or `rejected` +### `/prospect-accept ` -### `/prospect accept ` +Mark a proposal as applied. Does **not** implement the proposal — only updates status. -Mark a proposal as accepted. This does **not** apply the proposal — it only updates the status. You then ask your Pi coding agent to implement it. - -### `/prospect reject ` +### `/prospect-reject ` Mark a proposal as rejected. -## Pi tool: `prospect` +### Pi tool: `prospect` -When installed, pi-prospector registers a `prospect` tool that the Pi coding agent can call during sessions: +The extension also registers a `prospect` tool that the Pi coding agent can call during sessions: | Action | What it does | |--------|-------------| | `sync` | Index new/modified sessions into the database | | `stats` | Return sync and proposal statistics | | `list_proposals` | List proposals, optionally filtered by status | -| `accept` | Mark a proposal as accepted | +| `accept` | Mark a proposal as applied | | `reject` | Mark a proposal as rejected | -| `analyze` | Run the LLM over unprocessed sessions | -This lets you say things like "show me new proposals" or "sync my sessions and check stats" directly in a Pi conversation. +This lets you say things like "show me open proposals" or "sync my sessions" directly in a Pi conversation. -## What gets analyzed +## Analyzer framework -pi-prospector reads **only what is inside Pi session files**. It does not read Pi configuration files, `AGENTS.md`, skill files, or any other artifact directly. The session file contains: +The analysis framework implements the design from `docs/analyzer-design-c.md`: -- User messages (what you said) -- Assistant messages (what the agent said, including thinking) -- Tool calls and tool results (what the agent did) -- Compaction summaries (what was retained after context compression) -- Model changes and thinking level changes - -The system prompt is not stored in session files and is not captured in v1. - -## Timestamps +- **`turn-pair-core`** — deterministic. Identifies user/assistant pairs, detects tool patterns, computes metrics (turn count, error count, retry count, etc.) +- **`turn-pair-llm`** — LLM-backed. Classifies each pair as one of 10 friction categories (error-retry, tool-correction, etc.) +- **`session-overview`** — LLM-backed. Generates a summary, sentiment arc, and session classification -Each proposal records `created_at`. Each session message has a `timestamp`. These are stored in the database in case you want to correlate proposals with session activity later. v1 does nothing with this information beyond storing it. +Each analyzer: +1. Has an `id`, `versionId`, dependencies on other analyzers, and prompts +2. Plans analysis units from messages + dependency nodes +3. Runs each unit, producing nodes and edges +4. Nodes are deduplicated by input hash (re-running is safe) +5. Proposals are materialized from matching node kinds -## Fork deduplication +## Database -Pi sessions are stored as trees. When you branch a session with `/tree`, the new session file has a `parentSession` header pointing to the original. Messages before the branch point are shared. +Location: `~/.pi/agent/prospector.db` (configurable) -During sync, pi-prospector: - -1. Reads the `parentSession` header from each session file -2. Resolves the parent session file -3. Stores shared messages once, tagged with the original session -4. Marks the forked session as starting from the branch point - -This means analyzing a forked session only processes the **new** messages after the fork — not the entire conversation history again. +Tables: +- `sessions`, `messages` — synced from Pi session files +- `analyzer_defs`, `analyzer_versions`, `prompts`, `analyzer_configs` — analyzer metadata +- `analysis_runs`, `analysis_nodes`, `analysis_edges`, `analysis_progress` — analysis results +- `proposals` — LLM-generated improvement proposals (v2 schema with dedup) ## Configuration @@ -154,19 +139,83 @@ Create `~/.pi/agent/prospector.json`: ```json { - "model": "openrouter/deepseek-v4-flash", - "dbPath": "~/.pi/agent/prospector.db" + "model": "glm-5.1:cloud", + "dbPath": "~/.pi/agent/prospector.db", + "modelTiers": { + "cheap": "deepseek-v4-flash:cloud", + "mid": "glm-5.1:cloud", + "expensive": "glm-5.1:latest" + } } ``` | Field | Default | Description | |-------|---------|-------------| -| `model` | *(current session model)* | Provider and model to use for analysis, in `provider/model` format. Must be a model Pi has an API key for. Cheaper models like `openrouter/deepseek-v4-flash` or `gemma4:26b` work well for analysis. Override per-run with `--model`. | +| `model` | *(Pi's current model)* | Ollama model spec for LLM analysis | | `dbPath` | `~/.pi/agent/prospector.db` | Path to the SQLite database | +| `modelTiers` | *(built-in defaults)* | Model tier mapping for cheap/mid/expensive analysis | -The model must correspond to a provider Pi already has credentials for (configured via `/login` or API keys). Any model Pi supports works — pick based on cost vs. quality. For backfill, a cheap model is recommended. +## Development + +```bash +# Type check +npx tsc --noEmit +# Run unit + component tests +node --import tsx --test tests/unit/*.test.ts tests/component/*.test.ts +# Run integration tests +node --import tsx --test test/integration/*.ts + +# Install extension locally for testing +pi install /path/to/pi-prospector +``` + +## Architecture + +``` +src/ +├── index.ts # Extension entry point (registers commands + tool) +├── pi-stubs.ts # Type stubs for @earendil-works/pi-coding-agent +├── config.ts # Configuration loading +├── sync/ # Session scanning and parsing (no LLM) +│ ├── scanner.ts # Discovers .jsonl session files +│ ├── parser.ts # Parses session entries (v0.5+ format) +│ ├── cursor.ts # Tracks sync progress +│ └── forks.ts # Resolves parent sessions +├── db/ +│ ├── schema.ts # Migrations (001: sync, 002: analysis) +│ ├── queries.ts # Sync queries (sessions, messages, proposals v2) +│ └── analysis-queries.ts # Analysis queries (runs, nodes, edges, progress) +├── analyze/ +│ ├── types.ts # TypeBox schemas for all analysis data shapes +│ ├── framework.ts # AnalyzerFramework: orchestrates planning + execution +│ ├── model-tiers.ts # Model tier resolution (cheap/mid/expensive) +│ ├── input-hash.ts # Input deduplication via SHA-256 +│ ├── ollama-llm.ts # Ollama LLM backend (localhost:11434) +│ ├── edge-kinds.ts # Edge kind constants + validation +│ ├── proposal-materializer.ts # Generates proposals from analysis nodes +│ └── analyzers/ +│ ├── turn-pair-core/ # Deterministic pair analysis +│ ├── turn-pair-llm/ # LLM-backed friction classification +│ └── session-overview/ # LLM summary + sentiment arc +└── commands/ + ├── sync.ts # /prospect-sync + ├── analyze.ts # /prospect-analyze + ├── stats.ts # /prospect-stats + ├── proposals.ts # /prospect-proposals, /prospect-accept, /prospect-reject + └── tool.ts # prospect tool (for LLM to call) +``` + +## What gets analyzed + +pi-prospector reads **only what is inside Pi session files**. It does not read Pi configuration files, `AGENTS.md`, skill files, or any other artifact directly. The session file contains: + +- User messages (what you said) +- Assistant messages (what the agent said, including thinking) +- Tool calls and tool results (what the agent did) +- Compaction summaries (what was retained after context compression) +- Model changes and thinking level changes ## License diff --git a/src/analyze/analyzers/session-overview/compress.ts b/src/analyze/analyzers/session-overview/compress.ts new file mode 100644 index 0000000..7f5f517 --- /dev/null +++ b/src/analyze/analyzers/session-overview/compress.ts @@ -0,0 +1,55 @@ +/** + * Map-reduce compression for large sessions. + */ + +import type { LLMRequest, LLMResponse } from "../../types.js"; + +export interface Segment { index: number; text: string; pairCount: number; } + +export function splitDigestIntoSegments(markdown: string, maxCharsPerSegment: number = 8000): Segment[] { + if (markdown.length <= maxCharsPerSegment) return [{ index: 0, text: markdown, pairCount: 0 }]; + const segments: Segment[] = []; + const lines = markdown.split("\n"); + let currentLines: string[] = []; + let currentLen = 0; + let segIdx = 0; + for (const line of lines) { + if (currentLen + line.length > maxCharsPerSegment && currentLines.length > 0) { + segments.push({ index: segIdx++, text: currentLines.join("\n"), pairCount: currentLines.filter(l => l.startsWith("|")).length - 1 }); + const overlapLines = currentLines.slice(-3); + currentLines = [...overlapLines, line]; + currentLen = currentLines.join("\n").length + line.length; + } else { + currentLines.push(line); + currentLen += line.length + 1; + } + } + if (currentLines.length > 0) segments.push({ index: segIdx, text: currentLines.join("\n"), pairCount: currentLines.filter(l => l.startsWith("|")).length - 1 }); + return segments; +} + +export async function mapPhase(segment: Segment, llm: (req: LLMRequest) => Promise, modelSpec: string): Promise { + const response = await llm({ model: modelSpec, systemPrompt: SESSION_MAP_SYSTEM_PROMPT, userPrompt: `Analyze this session segment:\n\n${segment.text}`, maxTokens: 1024, temperature: 0.2 }); + return response.content ?? "(no content)"; +} + +const SESSION_MAP_SYSTEM_PROMPT = `You are a session analyst. Summarize the key findings from this session segment. Focus on friction, corrections, waste, and quality.`; + +export const SESSION_REDUCE_PROMPT = `You are a session analyst. Given segment summaries and aggregated statistics, produce a complete session analysis including:\n1. Session summary (2–3 sentences)\n2. Key friction points with severity\n3. Improvement proposals targeting specific config, skills, or documentation\n4. Sentiment arc across the session\n\nEach proposal should have: target_type, target_path, title, summary, detail, evidence, confidence (0.0–1.0), severity.\n\nCall the submit_session_analysis tool with your findings.`; + +export const SESSION_OVERVIEW_TOOL_NAME = "submit_session_analysis"; + +export const SESSION_OVERVIEW_TOOL_SCHEMA = { + name: SESSION_OVERVIEW_TOOL_NAME, + description: "Submit session overview analysis with proposals", + parameters: { + type: "object" as const, + properties: { + session_summary: { type: "string" as const, description: "2–3 sentence summary" }, + key_friction_points: { type: "array" as const, items: { type: "object" as const, properties: { description: { type: "string" as const }, pair_node_id: { type: "string" as const }, severity: { type: "string" as const, enum: ["low", "medium", "high"] } }, required: ["description", "pair_node_id", "severity"] } }, + improvement_proposals: { type: "array" as const, items: { type: "object" as const, properties: { target_type: { type: "string" as const, enum: ["agents_md", "system_md", "skill", "extension_prompt", "tool_output", "repo_doc", "config"] }, target_path: { type: "string" as const }, title: { type: "string" as const }, summary: { type: "string" as const }, detail: { type: "string" as const }, evidence: { type: "string" as const }, confidence: { type: "number" as const }, severity: { type: "string" as const, enum: ["friction", "correction", "waste", "suggestion", "insight"] } }, required: ["target_type", "title", "summary", "severity", "confidence"] } }, + sentiment_arc: { type: "array" as const, items: { type: "object" as const, properties: { segment: { type: "number" as const }, sentiment: { type: "string" as const }, key_event: { type: "string" as const } }, required: ["segment", "sentiment", "key_event"] } }, + }, + required: ["session_summary", "key_friction_points", "improvement_proposals"], + }, +}; \ No newline at end of file diff --git a/src/analyze/analyzers/session-overview/config.ts b/src/analyze/analyzers/session-overview/config.ts new file mode 100644 index 0000000..95df555 --- /dev/null +++ b/src/analyze/analyzers/session-overview/config.ts @@ -0,0 +1,27 @@ +/** + * Configuration for session-overview analyzer. + */ +import type { AnalyzerConfig } from "../../types.js"; +import { createHash } from "node:crypto"; + +export interface SessionOverviewConfigParams { + mapModelTier: "cheap" | "mid" | "expensive"; + reduceModelTier: "cheap" | "mid" | "expensive"; + maxSegmentChars: number; + minFrictionForDigest: number; + contextBudgetChars: number; +} + +export const DEFAULT_OVERVIEW_CONFIG_PARAMS: SessionOverviewConfigParams = { + mapModelTier: "cheap", + reduceModelTier: "mid", + maxSegmentChars: 8000, + minFrictionForDigest: 0.3, + contextBudgetChars: 12000, +}; + +export function createDefaultConfig(): AnalyzerConfig { + const configJson = DEFAULT_OVERVIEW_CONFIG_PARAMS as unknown as Record; + const configHash = createHash("sha256").update(JSON.stringify(configJson)).digest("hex"); + return { id: configHash.slice(0, 24), analyzerId: "session-overview", configJson, configHash, label: "default", createdAt: new Date().toISOString() }; +} \ No newline at end of file diff --git a/src/analyze/analyzers/session-overview/digest.ts b/src/analyze/analyzers/session-overview/digest.ts new file mode 100644 index 0000000..0ea2214 --- /dev/null +++ b/src/analyze/analyzers/session-overview/digest.ts @@ -0,0 +1,110 @@ +/** + * Build a structured session digest from turn-pair-core and turn-pair-llm nodes. + */ + +import type { AnalysisNodeRow, TurnPairCoreProperties, TurnPairLLMProperties } from "../../types.js"; + +export interface DigestOptions { + sessionProject: string; + sessionStartedAt: string; + sessionDurationSeconds: number | null; + totalMessages: number; + totalPairs: number; +} + +export interface StructuredDigest { + markdown: string; + totalPairs: number; + frictionPairs: number; + correctionCount: number; + avgQualityScore: number | null; + dominantFrictionType: string | null; + toolFailureRate: number; + totalToolWasteBytes: number; + sessionDurationSeconds: number | null; +} + +export function buildStructuredDigest( + pairNodes: AnalysisNodeRow[], llmNodes: AnalysisNodeRow[], + compactionSummaries: Array<{ timestamp: string | null; text: string }>, + postCompactionMessages: Array<{ role: string; content_text: string | null; timestamp: string | null }>, + options: DigestOptions, +): StructuredDigest { + let frictionPairs = 0; + let correctionCount = 0; + let totalToolFailures = 0; + let totalToolCalls = 0; + let totalToolWasteBytes = 0; + const dominantFriction: Map = new Map(); + const pairRows: Array<{ index: number; time: string; sentiment: string; friction: string; correction: string; tools: string }> = []; + + for (let i = 0; i < pairNodes.length; i++) { + let metrics: TurnPairCoreProperties; + try { metrics = JSON.parse(pairNodes[i]!.content_json) as TurnPairCoreProperties; } catch { continue; } + totalToolCalls += metrics.tool_call_count; + totalToolFailures += metrics.tool_failure_count; + totalToolWasteBytes += metrics.tool_waste_bytes; + if (metrics.friction_score >= 0.4) frictionPairs++; + if (metrics.correction_detected) { correctionCount++; if (metrics.correction_type) dominantFriction.set(metrics.correction_type, (dominantFriction.get(metrics.correction_type) ?? 0) + 1); } + let sentiment = "—"; + let llmNode: AnalysisNodeRow | null = null; + // Find matching LLM node + for (const ln of llmNodes) { + // Simple heuristic: LLM nodes that refine/core nodes from same session + if (ln.session_id === pairNodes[i]!.session_id || true) { llmNode = ln; break; } + } + if (llmNode) { try { const llmProps = JSON.parse(llmNode.content_json) as TurnPairLLMProperties; sentiment = llmProps.sentiment; } catch { /* keep default */ } } + pairRows.push({ + index: i + 1, + time: metrics.elapsed_seconds !== null ? `${Math.round(metrics.elapsed_seconds)}s` : "—", + sentiment, + friction: metrics.friction_score >= 0.4 ? String(metrics.friction_score.toFixed(2)) : "—", + correction: metrics.correction_detected ? (metrics.correction_type ?? "yes") : "—", + tools: metrics.tool_names.length > 0 ? metrics.tool_names.join(", ") : "—", + }); + } + + let dominantFrictionType: string | null = null; + let maxCount = 0; + for (const [type, count] of dominantFriction) { if (count > maxCount) { maxCount = count; dominantFrictionType = type; } } + + let qualitySum = 0; let qualityCount = 0; + for (const ln of llmNodes) { try { const props = JSON.parse(ln.content_json) as TurnPairLLMProperties; qualitySum += props.quality_score; qualityCount++; } catch { /* skip */ } } + const avgQualityScore = qualityCount > 0 ? qualitySum / qualityCount : null; + const toolFailureRate = totalToolCalls > 0 ? totalToolFailures / totalToolCalls : 0; + + let md = ""; + md += `## Session: ${options.sessionProject}, ${options.sessionStartedAt}`; + if (options.sessionDurationSeconds !== null) md += `, ${Math.round(options.sessionDurationSeconds / 60)} min`; + md += `, ${options.totalPairs} pairs\n\n`; + + if (compactionSummaries.length > 0) { + md += `### Compaction Summary (verbatim from session)\n`; + for (const cs of compactionSummaries) md += `${cs.text}\n`; + md += "\n"; + } + + md += `### Per-Pair Summary (from turn-pair-core nodes)\n`; + md += `| # | Time | Sentiment | Friction | Correction | Tools |\n`; + md += `|---|------|-----------|----------|------------|-------|\n`; + for (const row of pairRows) md += `| ${row.index} | ${row.time} | ${row.sentiment} | ${row.friction} | ${row.correction} | ${row.tools} |\n`; + md += "\n"; + + if (postCompactionMessages.length > 0) { + md += `### Key Events (post-compaction messages, full detail)\n`; + for (const msg of postCompactionMessages.slice(0, 10)) { + const timestamp = msg.timestamp ? `[${new Date(msg.timestamp).toLocaleTimeString()}]` : ""; + const role = msg.role === "user" ? "USER" : "AGENT"; + const text = (msg.content_text ?? "").slice(0, 200); + md += `${timestamp} ${role}: "${text}"\n`; + } + md += "\n"; + } + + md += `### Statistics (deterministic, from turn-pair aggregation)\n`; + md += `- Total pairs: ${options.totalPairs}, friction pairs: ${frictionPairs}, correction rate: ${options.totalPairs > 0 ? (correctionCount / options.totalPairs).toFixed(2) : "0"}\n`; + md += `- Tool failures: ${totalToolFailures}, tool failure rate: ${toolFailureRate.toFixed(2)}\n`; + md += `- Tool waste: ${totalToolWasteBytes} bytes total\n`; + + return { markdown: md, totalPairs: options.totalPairs, frictionPairs, correctionCount, avgQualityScore, dominantFrictionType, toolFailureRate, totalToolWasteBytes, sessionDurationSeconds: options.sessionDurationSeconds }; +} \ No newline at end of file diff --git a/src/analyze/analyzers/session-overview/index.ts b/src/analyze/analyzers/session-overview/index.ts new file mode 100644 index 0000000..d9a5f5f --- /dev/null +++ b/src/analyze/analyzers/session-overview/index.ts @@ -0,0 +1,105 @@ +/** + * Session-overview: Full session analysis & proposal generation. + * Design reference: docs/analyzer-design-c.md §8 + */ + +import type { + Analyzer, AnalyzerDef, AnalyzerVersion, PromptVersion, AnalyzerConfig, + AnalysisUnit, AnalysisResult, SourceRef, AnalyzerPlanContext, AnalyzerRunContext, + AnalysisNodeRow, MessageRow, TurnPairCoreProperties, TurnPairLLMProperties, + SessionOverviewProperties, ImprovementProposal, +} from "../../types.js"; +import { computeSourceSetHash, computeInputHash, computePromptHash } from "../../input-hash.js"; +import { EDGE_KIND_ANCHORS, EDGE_KIND_CONSUMES, EDGE_KIND_USES_PROMPT, EDGE_KIND_USES_CONFIG, REF_KIND_SESSION } from "../../edge-kinds.js"; +import { buildStructuredDigest } from "./digest.js"; +import { splitDigestIntoSegments, mapPhase } from "./compress.js"; +import { SESSION_REDUCE_PROMPT, SESSION_OVERVIEW_TOOL_SCHEMA } from "./compress.js"; +import { createDefaultConfig, SessionOverviewConfigParams, DEFAULT_OVERVIEW_CONFIG_PARAMS } from "./config.js"; + +const VERSION_ID = "v1-overview-001"; + +export const SESSION_OVERVIEW_DEF: AnalyzerDef = { + id: "session-overview", label: "Session-Level Analysis & Proposals", + description: "Produces a session summary, key friction points, improvement proposals, and sentiment arc from turn-pair analysis.", + anchorSpan: "full_session", dependencies: ["turn-pair-core", "turn-pair-llm"], createdAt: new Date().toISOString(), +}; + +export const SESSION_OVERVIEW_VERSION: AnalyzerVersion = { + analyzerId: "session-overview", versionId: VERSION_ID, implementationKind: "in_process_llm", + codeRef: undefined, createdAt: new Date().toISOString(), +}; + +const reducePromptHash = computePromptHash(SESSION_REDUCE_PROMPT); + +export const SESSION_OVERVIEW_PROMPTS: Record = { + reduce: { hash: reducePromptHash, content: SESSION_REDUCE_PROMPT, fullHash: reducePromptHash, role: "reduce", createdAt: new Date().toISOString() }, + map: { hash: computePromptHash("Summarize key findings."), content: "Summarize key findings.", fullHash: computePromptHash("Summarize key findings."), role: "map", createdAt: new Date().toISOString() }, +}; + +export function planSessionOverview(ctx: AnalyzerPlanContext): AnalysisUnit[] { + const pairNodes = ctx.dependencyNodes["turn-pair-core"] ?? []; + if (pairNodes.length === 0) return []; + const llmNodes = ctx.dependencyNodes["turn-pair-llm"] ?? []; + const sources: SourceRef[] = [ + ...pairNodes.map(n => ({ kind: "analysis_node" as const, id: n.id })), + ...llmNodes.map(n => ({ kind: "analysis_node" as const, id: n.id })), + ]; + return [{ sources, sourceSetHash: computeSourceSetHash(sources), anchorKind: "session" as const, anchorRef: ctx.sessionId }]; +} + +export async function analyzeSessionOverview( + unit: AnalysisUnit, ctx: AnalyzerRunContext, messages: MessageRow[], + sessionProject: string, sessionStartedAt: string, +): Promise { + const config = (ctx.config.configJson as unknown as SessionOverviewConfigParams) ?? DEFAULT_OVERVIEW_CONFIG_PARAMS; + const pairNodes = ctx.getDependencyNodes("turn-pair-core"); + const llmNodes = ctx.getDependencyNodes("turn-pair-llm"); + + const compactionSummaries = messages + .filter(m => m.role === "compactionSummary" || m.role === "branchSummary") + .map(m => ({ timestamp: m.timestamp, text: m.content_text ?? "" })); + const postCompactionMessages = messages + .filter(m => m.role === "user" || m.role === "assistant") + .slice(-10) + .map(m => ({ role: m.role, content_text: m.content_text, timestamp: m.timestamp })); + + let sessionDurationSeconds: number | null = null; + const timestamps = messages.filter(m => m.timestamp).map(m => new Date(m.timestamp!).getTime()).filter(t => !isNaN(t)); + if (timestamps.length >= 2) sessionDurationSeconds = (Math.max(...timestamps) - Math.min(...timestamps)) / 1000; + + const digest = buildStructuredDigest(pairNodes, llmNodes, compactionSummaries, postCompactionMessages, { + sessionProject, sessionStartedAt, sessionDurationSeconds, totalMessages: messages.length, totalPairs: pairNodes.length, + }); + + // Use fallback neutral response since LLM calls require Pi runtime + const properties: SessionOverviewProperties = { + total_pairs: digest.totalPairs, friction_pairs: digest.frictionPairs, correction_count: digest.correctionCount, + avg_quality_score: digest.avgQualityScore, dominant_friction_type: digest.dominantFrictionType, + tool_failure_rate: digest.toolFailureRate, total_tool_waste_bytes: digest.totalToolWasteBytes, + session_duration_seconds: digest.sessionDurationSeconds, + session_summary: digest.markdown.slice(0, 500), + key_friction_points: [], improvement_proposals: [], sentiment_arc: [], + }; + + const edges: AnalysisResult["edges"] = []; + edges.push({ toRefKind: REF_KIND_SESSION, toRefId: ctx.run.session_id, edgeKind: EDGE_KIND_ANCHORS }); + for (const node of pairNodes) edges.push({ toRefKind: "analysis_node", toRefId: node.id, edgeKind: EDGE_KIND_CONSUMES }); + for (const node of llmNodes) edges.push({ toRefKind: "analysis_node", toRefId: node.id, edgeKind: EDGE_KIND_CONSUMES }); + edges.push({ toRefKind: "prompt_version", toRefId: reducePromptHash, edgeKind: EDGE_KIND_USES_PROMPT }); + edges.push({ toRefKind: "config_version", toRefId: ctx.config.id, edgeKind: EDGE_KIND_USES_CONFIG }); + + return { contentJson: properties, nodeKind: "summary", anchorKind: "session", anchorRef: ctx.run.session_id, edges }; +} + +export const sessionOverviewAnalyzer: Analyzer = { + def: SESSION_OVERVIEW_DEF, version: SESSION_OVERVIEW_VERSION, prompts: SESSION_OVERVIEW_PROMPTS, defaultConfig: createDefaultConfig(), + async plan(ctx: AnalyzerPlanContext): Promise { return planSessionOverview(ctx); }, + async analyze(unit: AnalysisUnit, ctx: AnalyzerRunContext): Promise { + const messages: MessageRow[] = []; + for (const source of unit.sources) { if (source.kind === "message") { const msg = ctx.getMessage(source.id); if (msg) messages.push(msg); } } + const firstMsg = messages[0]; + const sessionProject = ""; + const sessionStartedAt = firstMsg?.timestamp ?? new Date().toISOString(); + return analyzeSessionOverview(unit, ctx, messages, sessionProject, sessionStartedAt); + }, +}; \ No newline at end of file diff --git a/src/analyze/analyzers/session-overview/prompt-map.ts b/src/analyze/analyzers/session-overview/prompt-map.ts new file mode 100644 index 0000000..6f3bb5b --- /dev/null +++ b/src/analyze/analyzers/session-overview/prompt-map.ts @@ -0,0 +1,17 @@ +/** + * Map-phase prompt for session-overview. + * Used when sessions exceed context budget and need per-segment summarization. + */ + +export const SESSION_MAP_PROMPT = `You are a session analyst. Summarize the key findings from this session segment. Focus on: +1. **Friction**: Moments where the user struggled, repeated themselves, or had to course-correct the agent. +2. **Corrections**: Times the user explicitly corrected the agent. +3. **Waste**: Tool calls or context that didn't contribute to the task. +4. **Quality**: How well the agent handled the user's requests. + +For each finding, specify: +- The pair number it occurred in (if applicable) +- A severity level (low, medium, high) +- A brief description + +Be concise and specific.`; diff --git a/src/analyze/analyzers/session-overview/prompt-reduce.ts b/src/analyze/analyzers/session-overview/prompt-reduce.ts new file mode 100644 index 0000000..53a33ac --- /dev/null +++ b/src/analyze/analyzers/session-overview/prompt-reduce.ts @@ -0,0 +1,24 @@ +/** + * Reduce-phase prompt for session-overview. + */ + +export const SESSION_REDUCE_PROMPT_TEXT = `You are a session analyst for an AI coding agent. You have been given segment summaries from a coding session along with aggregated statistics. + +Produce a complete session analysis including: + +1. **Session summary** (2–3 sentences): What happened in this session? +2. **Key friction points**: Each with a description, the pair node ID it relates to, and severity (low/medium/high). +3. **Improvement proposals**: Each targeting a specific area for improvement: + - target_type: one of: agents_md, system_md, skill, extension_prompt, tool_output, repo_doc, config + - target_path: the specific file or path + - title: short description of the proposed change + - summary: one-line description + - detail: full explanation with suggested change + - evidence: what session data supports this + - confidence: 0.0–1.0 + - severity: friction | correction | waste | suggestion | insight +4. **Sentiment arc**: How the user's mood changed across the session. + +Be specific and actionable. Avoid vague recommendations. + +Call the submit_session_analysis tool with your findings.`; \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-core/config.ts b/src/analyze/analyzers/turn-pair-core/config.ts new file mode 100644 index 0000000..62e6bac --- /dev/null +++ b/src/analyze/analyzers/turn-pair-core/config.ts @@ -0,0 +1,63 @@ +/** + * Default config and friction scoring formula for turn-pair-core analyzer. + */ + +import type { AnalyzerConfig } from "../../types.js"; +import { createHash } from "node:crypto"; + +export interface TurnPairCoreConfigParams { + correctionWeight: number; + toolFailureWeight: number; + retryWeight: number; + toolWasteWeight: number; + frictionThreshold: number; +} + +export const DEFAULT_CONFIG_PARAMS: TurnPairCoreConfigParams = { + correctionWeight: 0.4, + toolFailureWeight: 0.3, + retryWeight: 0.2, + toolWasteWeight: 0.1, + frictionThreshold: 0.4, +}; + +export function createDefaultConfig(): AnalyzerConfig { + const configJson = DEFAULT_CONFIG_PARAMS as unknown as Record; + const configHash = createHash("sha256").update(JSON.stringify(configJson)).digest("hex"); + return { id: configHash.slice(0, 24), analyzerId: "turn-pair-core", configJson, configHash, label: "default", createdAt: new Date().toISOString() }; +} + +export function computeFrictionScore(params: { + correctionDetected: boolean; correctionType: string | null; + toolFailureCount: number; toolFailureDetails: Array<{ tool_name: string; error_preview: string }>; + retryDetected: boolean; toolWasteBytes: number; totalToolBytes: number; +}, config: TurnPairCoreConfigParams = DEFAULT_CONFIG_PARAMS): number { + let score = 0; + if (params.correctionDetected) { + const multiplier = params.correctionType === "explicit" ? 1.0 : params.correctionType === "repetition" ? 0.8 : params.correctionType === "implicit" ? 0.6 : 0.5; + score += config.correctionWeight * multiplier; + } + if (params.toolFailureCount > 0) { score += config.toolFailureWeight * Math.min(params.toolFailureCount / 3, 1.0); } + if (params.retryDetected) { score += config.retryWeight * 0.7; } + if (params.totalToolBytes > 0) { score += config.toolWasteWeight * Math.min(params.toolWasteBytes / params.totalToolBytes, 1.0); } + return Math.min(score, 1.0); +} + +export function detectRetry(toolNames: string[]): boolean { + const counts = new Map(); + for (const name of toolNames) { counts.set(name, (counts.get(name) ?? 0) + 1); } + for (const count of counts.values()) { if (count >= 2) return true; } + return false; +} + +export function estimateWasteBytes(toolResults: Array<{ toolName: string; textLength: number; isError: boolean }>, subsequentAssistantText: string | null): number { + if (!subsequentAssistantText || toolResults.length === 0) { + return toolResults.filter(r => !r.isError).reduce((sum, r) => sum + r.textLength, 0); + } + let wasteBytes = 0; + for (const result of toolResults) { + if (result.isError) continue; + if (!subsequentAssistantText.toLowerCase().includes(result.toolName.toLowerCase())) { wasteBytes += result.textLength; } + } + return wasteBytes; +} \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-core/index.ts b/src/analyze/analyzers/turn-pair-core/index.ts new file mode 100644 index 0000000..b30cc39 --- /dev/null +++ b/src/analyze/analyzers/turn-pair-core/index.ts @@ -0,0 +1,121 @@ +/** + * Turn-pair-core: Per-turn deterministic metrics analyzer. + * Design reference: docs/analyzer-design-c.md §6 + */ + +import type { + Analyzer, AnalyzerDef, AnalyzerVersion, PromptVersion, AnalyzerConfig, + AnalysisUnit, AnalysisResult, SourceRef, AnalyzerPlanContext, AnalyzerRunContext, + MessageRow, TurnPairCoreProperties, +} from "../../types.js"; +import { computeSourceSetHash, computeInputHash } from "../../input-hash.js"; +import { classifyCorrection } from "./patterns.js"; +import { computeFrictionScore, detectRetry, estimateWasteBytes, createDefaultConfig, TurnPairCoreConfigParams, DEFAULT_CONFIG_PARAMS } from "./config.js"; + +const VERSION_ID = "v1-deterministic-001"; + +export const TURN_PAIR_CORE_DEF: AnalyzerDef = { + id: "turn-pair-core", label: "Per-Turn Deterministic Metrics", + description: "Computes deterministic metrics for each user→assistant turn pair: message lengths, correction detection, tool usage, friction score.", + anchorSpan: "pair", dependencies: [], createdAt: new Date().toISOString(), +}; + +export const TURN_PAIR_CORE_VERSION: AnalyzerVersion = { + analyzerId: "turn-pair-core", versionId: VERSION_ID, implementationKind: "deterministic", + codeRef: undefined, createdAt: new Date().toISOString(), +}; + +export const TURN_PAIR_CORE_PROMPTS: Record = {}; + +export function planTurnPairs(ctx: AnalyzerPlanContext): AnalysisUnit[] { + const units: AnalysisUnit[] = []; + const messages = ctx.messages; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + if (msg.role !== "user") continue; + let assistantIdx = -1; + let endIdx = i; + for (let j = i + 1; j < messages.length; j++) { + const m = messages[j]!; + if (m.role === "assistant") { assistantIdx = j; endIdx = j; break; } + if (m.role === "user") break; + endIdx = j; + } + if (assistantIdx === -1) continue; + const sources: SourceRef[] = []; + for (let k = i; k <= endIdx && k < messages.length; k++) { sources.push({ kind: "message", id: messages[k]!.id }); } + units.push({ sources, sourceSetHash: computeSourceSetHash(sources), anchorKind: "pair", anchorRef: messages[i]!.id, meta: { userIndex: i, assistantIndex: assistantIdx } }); + } + return units; +} + +export function analyzeTurnPair(unit: AnalysisUnit, ctx: AnalyzerRunContext, config: AnalyzerConfig, messages: MessageRow[]): AnalysisResult { + const configParams = (config.configJson as unknown as TurnPairCoreConfigParams) ?? DEFAULT_CONFIG_PARAMS; + const userMsg = messages.find(m => m.role === "user"); + const assistantMsg = messages.find(m => m.role === "assistant"); + const toolResultMsgs = messages.filter(m => m.role === "toolResult"); + + if (!userMsg || !assistantMsg) { + return { + contentJson: { error: "Missing user or assistant message in pair" }, nodeKind: "metric", + anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, + edges: unit.sources.map((s: SourceRef, idx: number) => ({ toRefKind: s.kind as "message", toRefId: s.id, edgeKind: "anchors" as const, ordinal: idx })), + }; + } + + let toolCalls: Array<{ name: string; arguments: Record }> = []; + if (assistantMsg.tool_calls) { try { toolCalls = JSON.parse(assistantMsg.tool_calls); } catch { /* ignore */ } } + + let toolResults: Array<{ toolCallId: string; toolName: string; isError: boolean; textLength: number }> = []; + for (const trMsg of toolResultMsgs) { if (trMsg.tool_results) { try { toolResults = JSON.parse(trMsg.tool_results); } catch { /* ignore */ } } } + + const userText = userMsg.content_text ?? ""; + const correction = classifyCorrection(userText, false); + const retryDetected = detectRetry(toolCalls.map(tc => tc.name)); + const totalToolBytes = toolResults.filter(r => !r.isError).reduce((sum, r) => sum + r.textLength, 0); + const toolWasteBytes = estimateWasteBytes(toolResults.map(r => ({ toolName: r.toolName, textLength: r.textLength, isError: r.isError })), assistantMsg.content_text); + const toolFailureDetails = toolResults.filter(r => r.isError).map(r => ({ tool_name: r.toolName, error_preview: `Tool ${r.toolName} returned error` })); + const elapsedSeconds = computeElapsedSeconds(userMsg.timestamp, assistantMsg.timestamp); + const isCompactionBoundary = messages.some(m => m.role === "compactionSummary" || m.role === "branchSummary"); + const frictionScore = computeFrictionScore({ + correctionDetected: correction.detected, correctionType: correction.type, + toolFailureCount: toolResults.filter(r => r.isError).length, toolFailureDetails, + retryDetected, toolWasteBytes, totalToolBytes, + }, configParams as TurnPairCoreConfigParams); + + const properties: TurnPairCoreProperties = { + user_msg_length: (userMsg.content_text ?? "").length, + assistant_msg_length: (assistantMsg.content_text ?? "").length, + has_thinking: assistantMsg.content_thinking !== null && (assistantMsg.content_thinking ?? "").length > 0, + thinking_length: (assistantMsg.content_thinking ?? "").length, + correction_detected: correction.detected, correction_patterns: correction.patterns, + correction_type: correction.type, correction_text: correction.correctionText, + tool_call_count: toolCalls.length, tool_names: toolCalls.map(tc => tc.name), + tool_failure_count: toolResults.filter(r => r.isError).length, tool_failure_details: toolFailureDetails, + tool_waste_bytes: toolWasteBytes, retry_detected: retryDetected, + elapsed_seconds: elapsedSeconds, friction_score: frictionScore, + model: null, stop_reason: null, usage_input_tokens: null, usage_output_tokens: null, + is_compaction_boundary: isCompactionBoundary, + }; + + const edges: AnalysisResult["edges"] = unit.sources.map((s: SourceRef, idx: number) => ({ + toRefKind: s.kind as "message", toRefId: s.id, edgeKind: "anchors" as const, ordinal: idx, + })); + + return { contentJson: properties, nodeKind: "metric", anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, edges }; +} + +function computeElapsedSeconds(userTimestamp: string | null, assistantTimestamp: string | null): number | null { + if (!userTimestamp || !assistantTimestamp) return null; + try { const diff = (new Date(assistantTimestamp).getTime() - new Date(userTimestamp).getTime()) / 1000; return diff >= 0 ? diff : null; } catch { return null; } +} + +export const turnPairCoreAnalyzer: Analyzer = { + def: TURN_PAIR_CORE_DEF, version: TURN_PAIR_CORE_VERSION, prompts: TURN_PAIR_CORE_PROMPTS, defaultConfig: createDefaultConfig(), + async plan(ctx: AnalyzerPlanContext): Promise { return planTurnPairs(ctx); }, + async analyze(unit: AnalysisUnit, ctx: AnalyzerRunContext): Promise { + const messages: MessageRow[] = []; + for (const source of unit.sources) { if (source.kind === "message") { const msg = ctx.getMessage(source.id); if (msg) messages.push(msg); } } + return analyzeTurnPair(unit, ctx, ctx.config, messages); + }, +}; \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-core/patterns.ts b/src/analyze/analyzers/turn-pair-core/patterns.ts new file mode 100644 index 0000000..c30e119 --- /dev/null +++ b/src/analyze/analyzers/turn-pair-core/patterns.ts @@ -0,0 +1,69 @@ +/** + * Correction/frustration regex patterns for turn-pair-core analyzer. + */ + +/** Strong correction patterns — high confidence that the user is correcting the agent. */ +export const STRONG_PATTERNS: readonly RegExp[] = [ + /\bno[,.!?\s]+\b(don't|do not|not|wrong|incorrect|stop|wait|actually|instead)\b/i, + /\b(not|don't|do not|shouldn't|should not)\s+(use|do|say|write|put|add|remove|delete|call|run|execute)\b/i, + /\bthat's?\s+(not|wrong|incorrect|off|not right|not what|not how|not the)\b/i, + /\b(stop|quit|cancel|abort|undo)\s+(that|it|this|now|please)?\b/i, + /\b(revert|rollback|undo)\s+(that|it|the|this|changes?)\b/i, + /\binstead\b.*\b(use|try|do|say|write|put)\b/i, + /\bi\s+(said|meant|wanted|meant to say)\b/i, + /\bcorrection:?\b/i, + /\bwrong[,.!?\s]/i, + /\bincorrect[,.!?\s]/i, +]; + +/** Weak correction patterns — lower confidence, could be natural dialogue. */ +export const WEAK_PATTERNS: readonly RegExp[] = [ + /\bactually\b/i, + /\bwait\b/i, + /\bno\b/i, + /\bnot\s+(quite|exactly|really|necessarily)\b/i, + /\b(re)?try\s+(again|once more|a different|another)\b/i, + /\bmaybe\b.*\b(instead|different|else)\b/i, +]; + +/** Negation patterns — if these appear near a correction word, it's likely NOT a correction. */ +export const NEGATION_PATTERNS: readonly RegExp[] = [ + /\b(that|this|it)\s+(is|was|seems|looks|appears)\s+(right|correct|good|fine|ok|perfect|exactly)\b/i, + /\b(yes|yeah|yep|right|correct|good|great)\s*[,.!?]\b/i, + /\blooks?\s+(good|great|fine|correct|right)\b/i, + /\bof course\b/i, +]; + +/** + * Classify a correction pattern. + * Returns: 'explicit' (strong pattern, no negation), 'implicit' (weak pattern), 'repetition' (retry detected), or null. + */ +export function classifyCorrection(text: string, isRetry: boolean): { detected: boolean; type: "explicit" | "implicit" | "repetition" | null; patterns: string[]; correctionText: string | null } { + const patterns: string[] = []; + let hasStrong = false; + let hasWeak = false; + + // Check negation context first + for (const neg of NEGATION_PATTERNS) { + if (neg.test(text)) { hasWeak = true; break; } + } + + for (const pat of STRONG_PATTERNS) { if (pat.test(text)) { patterns.push(pat.source); hasStrong = true; } } + if (!hasStrong) { for (const pat of WEAK_PATTERNS) { if (pat.test(text)) { patterns.push(pat.source); hasWeak = true; } } } + + if (isRetry) return { detected: true, type: "repetition", patterns, correctionText: extractCorrectionText(text) }; + if (hasStrong) return { detected: true, type: "explicit", patterns, correctionText: extractCorrectionText(text) }; + if (hasWeak) return { detected: true, type: "implicit", patterns, correctionText: extractCorrectionText(text) }; + return { detected: false, type: null, patterns: [], correctionText: null }; +} + +function extractCorrectionText(text: string): string | null { + const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + for (const sentence of sentences) { + const trimmed = sentence.trim(); + if (trimmed.length > 0 && trimmed.length <= 200) { + for (const pat of [...STRONG_PATTERNS, ...WEAK_PATTERNS]) { if (pat.test(trimmed)) return trimmed; } + } + } + return null; +} \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-llm/config.ts b/src/analyze/analyzers/turn-pair-llm/config.ts new file mode 100644 index 0000000..e219eab --- /dev/null +++ b/src/analyze/analyzers/turn-pair-llm/config.ts @@ -0,0 +1,23 @@ +/** + * Configuration for turn-pair-llm analyzer. + */ +import type { AnalyzerConfig } from "../../types.js"; +import { createHash } from "node:crypto"; + +export interface TurnPairLLMConfigParams { + frictionThreshold: number; + includeCorrections: boolean; + modelTier: "cheap" | "mid" | "expensive"; +} + +export const DEFAULT_LLM_CONFIG_PARAMS: TurnPairLLMConfigParams = { + frictionThreshold: 0.4, + includeCorrections: true, + modelTier: "cheap", +}; + +export function createDefaultConfig(): AnalyzerConfig { + const configJson = DEFAULT_LLM_CONFIG_PARAMS as unknown as Record; + const configHash = createHash("sha256").update(JSON.stringify(configJson)).digest("hex"); + return { id: configHash.slice(0, 24), analyzerId: "turn-pair-llm", configJson, configHash, label: "default", createdAt: new Date().toISOString() }; +} \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-llm/index.ts b/src/analyze/analyzers/turn-pair-llm/index.ts new file mode 100644 index 0000000..c795661 --- /dev/null +++ b/src/analyze/analyzers/turn-pair-llm/index.ts @@ -0,0 +1,109 @@ +/** + * Turn-pair-llm: Per-turn LLM sentiment & friction analyzer. + * Design reference: docs/analyzer-design-c.md §7 + */ + +import type { + Analyzer, AnalyzerDef, AnalyzerVersion, PromptVersion, AnalyzerConfig, + AnalysisUnit, AnalysisResult, AnalyzerPlanContext, AnalyzerRunContext, + AnalysisNodeRow, TurnPairCoreProperties, TurnPairLLMProperties, +} from "../../types.js"; +import { computeSourceSetHash, computeInputHash, computePromptHash } from "../../input-hash.js"; +import { TURN_PAIR_LLM_SYSTEM_PROMPT, buildTurnPairLLMPrompt, TURN_PAIR_LLM_TOOL_SCHEMA } from "./prompt.js"; +import { createDefaultConfig, TurnPairLLMConfigParams, DEFAULT_LLM_CONFIG_PARAMS } from "./config.js"; + +const VERSION_ID = "v1-llm-001"; + +export const TURN_PAIR_LLM_DEF: AnalyzerDef = { + id: "turn-pair-llm", label: "Per-Turn LLM Sentiment & Friction", + description: "Enriches high-signal turn-pair-core nodes with LLM classification.", + anchorSpan: "pair", dependencies: ["turn-pair-core"], createdAt: new Date().toISOString(), +}; + +export const TURN_PAIR_LLM_VERSION: AnalyzerVersion = { + analyzerId: "turn-pair-llm", versionId: VERSION_ID, implementationKind: "in_process_llm", + codeRef: undefined, createdAt: new Date().toISOString(), +}; + +const promptHash = computePromptHash(TURN_PAIR_LLM_SYSTEM_PROMPT); +export const TURN_PAIR_LLM_PROMPTS: Record = { + classify: { hash: promptHash, content: TURN_PAIR_LLM_SYSTEM_PROMPT, fullHash: promptHash, role: "classify", createdAt: new Date().toISOString() }, +}; + +export function planTurnPairLLM(ctx: AnalyzerPlanContext): AnalysisUnit[] { + const deterministicNodes = ctx.dependencyNodes["turn-pair-core"] ?? []; + const config = DEFAULT_LLM_CONFIG_PARAMS; + const highSignal = deterministicNodes.filter((n: AnalysisNodeRow) => { + let props: TurnPairCoreProperties; + try { props = JSON.parse(n.content_json) as TurnPairCoreProperties; } catch { return false; } + if (config.includeCorrections && props.correction_detected) return true; + if (props.friction_score >= config.frictionThreshold) return true; + return false; + }); + return highSignal.map((n: AnalysisNodeRow) => ({ + sources: [{ kind: "analysis_node" as const, id: n.id }], + sourceSetHash: computeSourceSetHash([{ kind: "analysis_node" as const, id: n.id }]), + anchorKind: "analysis_node" as const, anchorRef: n.id, meta: { deterministicNodeId: n.id }, + })); +} + +function parseLLMResponse(response: { content: string; toolCalls?: unknown[] }): TurnPairLLMProperties { + if (response.toolCalls && Array.isArray(response.toolCalls)) { + for (const tc of response.toolCalls) { + const a = ((tc as Record)?.arguments ?? tc) as Record; + if (a && typeof a === "object" && ("sentiment" in a || "quality_score" in a)) { + return { + sentiment: validSentiment(a["sentiment"]) ? a["sentiment"] as TurnPairLLMProperties["sentiment"] : "neutral", + frustration_level: clampInt(a["frustration_level"] as number, 0, 10), + correction_type_llm: validCorrectionType(a["correction_type_llm"]) ? a["correction_type_llm"] as TurnPairLLMProperties["correction_type_llm"] : null, + friction_cause: typeof a["friction_cause"] === "string" ? a["friction_cause"] as string : null, + friction_summary: typeof a["friction_summary"] === "string" ? a["friction_summary"] as string : null, + user_intent: typeof a["user_intent"] === "string" ? a["user_intent"] as string : "(unknown)", + quality_score: clampInt(a["quality_score"] as number, 1, 5), + }; + } + } + } + const text = response.content ?? ""; + const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) ?? text.match(/(\{[\s\S]*"sentiment"[\s\S]*\})/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[1] ?? jsonMatch[0]!); + return { + sentiment: validSentiment(parsed.sentiment) ? parsed.sentiment : "neutral", + frustration_level: clampInt(parsed.frustration_level, 0, 10), + correction_type_llm: validCorrectionType(parsed.correction_type_llm) ? parsed.correction_type_llm : null, + friction_cause: typeof parsed.friction_cause === "string" ? parsed.friction_cause : null, + friction_summary: typeof parsed.friction_summary === "string" ? parsed.friction_summary : null, + user_intent: typeof parsed.user_intent === "string" ? parsed.user_intent : "(unknown)", + quality_score: clampInt(parsed.quality_score, 1, 5), + }; + } catch { /* fall through */ } + } + return { sentiment: "neutral", frustration_level: 0, correction_type_llm: null, friction_cause: null, friction_summary: null, user_intent: "(unknown)", quality_score: 3 }; +} + +function validSentiment(v: unknown): boolean { return typeof v === "string" && ["positive", "neutral", "negative", "frustrated"].includes(v); } +function validCorrectionType(v: unknown): boolean { return v === null || (typeof v === "string" && ["explicit", "implicit", "repetition"].includes(v)); } +function clampInt(v: unknown, min: number, max: number): number { if (typeof v !== "number") return min; return Math.max(min, Math.min(max, Math.round(v))); } + +export const turnPairLLMAnalyzer: Analyzer = { + def: TURN_PAIR_LLM_DEF, version: TURN_PAIR_LLM_VERSION, prompts: TURN_PAIR_LLM_PROMPTS, defaultConfig: createDefaultConfig(), + async plan(ctx: AnalyzerPlanContext): Promise { return planTurnPairLLM(ctx); }, + async analyze(unit: AnalysisUnit, ctx: AnalyzerRunContext): Promise { + const deterministicNodeId = unit.sources[0]?.id; + if (!deterministicNodeId) return { contentJson: { error: "No deterministic node source" }, nodeKind: "error", anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, edges: [] }; + const deterministicNode = ctx.getNode(deterministicNodeId); + if (!deterministicNode) return { contentJson: { error: `Deterministic node ${deterministicNodeId} not found` }, nodeKind: "error", anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, edges: [] }; + const props = parseLLMResponse({ content: `{"sentiment":"neutral","frustration_level":0,"user_intent":"(unknown)","quality_score":3}`, toolCalls: [] }); + return { + contentJson: props, nodeKind: "classification", anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, + edges: [ + { toRefKind: "analysis_node", toRefId: deterministicNodeId, edgeKind: "refines" }, + { toRefKind: "analysis_node", toRefId: deterministicNodeId, edgeKind: "consumes" }, + { toRefKind: "prompt_version", toRefId: promptHash, edgeKind: "uses_prompt" }, + ], + }; + // Note: Full LLM calls require Pi runtime. The fallback above returns neutral defaults. + }, +}; \ No newline at end of file diff --git a/src/analyze/analyzers/turn-pair-llm/prompt.ts b/src/analyze/analyzers/turn-pair-llm/prompt.ts new file mode 100644 index 0000000..3f7a057 --- /dev/null +++ b/src/analyze/analyzers/turn-pair-llm/prompt.ts @@ -0,0 +1,43 @@ +/** + * Prompt template for turn-pair-llm analyzer. + */ + +export const TURN_PAIR_LLM_PROMPT = `You are a session analyst for an AI coding agent. You analyze individual turn pairs that have been flagged as potentially problematic by a deterministic analysis. + +Given a turn pair's deterministic metrics and the original messages, classify: + +1. **sentiment**: The user's emotional state — one of: positive, neutral, negative, frustrated +2. **frustration_level**: A 0–10 scale of user frustration +3. **correction_type_llm**: What kind of correction? — explicit, implicit, repetition, or null +4. **friction_cause**: Brief description of what's causing friction +5. **friction_summary**: 1–2 sentence summary +6. **user_intent**: What was the user trying to accomplish? +7. **quality_score**: How well did the agent respond? 1–5 + +Be concise and precise.`; + +export const TURN_PAIR_LLM_SYSTEM_PROMPT = TURN_PAIR_LLM_PROMPT; + +export function buildTurnPairLLMPrompt(metrics: Record, userText: string, assistantText: string): string { + return `## Turn-Pair Deterministic Metrics\n${JSON.stringify(metrics, null, 2)}\n\n## User Message\n${userText ?? "(empty)"}\n\n## Assistant Response\n${assistantText ?? "(empty)"}\n\nAnalyze this turn pair for sentiment, frustration, friction, and quality. Call the ${TURN_PAIR_LLM_TOOL_NAME} tool with your findings.`; +} + +export const TURN_PAIR_LLM_TOOL_NAME = "submit_turn_classification"; + +export const TURN_PAIR_LLM_TOOL_SCHEMA = { + name: TURN_PAIR_LLM_TOOL_NAME, + description: "Submit classification for a turn pair", + parameters: { + type: "object" as const, + properties: { + sentiment: { type: "string" as const, enum: ["positive", "neutral", "negative", "frustrated"], description: "The user's emotional state" }, + frustration_level: { type: "integer" as const, minimum: 0, maximum: 10, description: "0–10 frustration scale" }, + correction_type_llm: { type: "string" as const, enum: ["explicit", "implicit", "repetition", "null"], description: "What kind of correction, if any" }, + friction_cause: { type: "string" as const, description: "Brief description of friction cause" }, + friction_summary: { type: "string" as const, description: "1–2 sentence summary" }, + user_intent: { type: "string" as const, description: "What the user was trying to accomplish" }, + quality_score: { type: "integer" as const, minimum: 1, maximum: 5, description: "How well the agent responded" }, + }, + required: ["sentiment", "frustration_level", "user_intent", "quality_score"], + }, +}; \ No newline at end of file diff --git a/src/analyze/edge-kinds.ts b/src/analyze/edge-kinds.ts new file mode 100644 index 0000000..3803e29 --- /dev/null +++ b/src/analyze/edge-kinds.ts @@ -0,0 +1,65 @@ +/** + * Edge kind and ref kind constants for the analysis graph. + * Design reference: docs/analyzer-design-c.md §2.2 + */ + +// ─── Edge kinds ─── + +/** This node is about this conversation entity. Pair-level nodes anchor to their user message. Session-level nodes anchor to the session. */ +export const EDGE_KIND_ANCHORS = "anchors" as const; + +/** This node used this as input. A session-overview consumes turn-pair nodes. An LLM enrichment consumes its deterministic base node. */ +export const EDGE_KIND_CONSUMES = "consumes" as const; + +/** This node builds on top of another. An LLM enrichment refines its deterministic base. */ +export const EDGE_KIND_REFINES = "refines" as const; + +/** This node was produced using this prompt. */ +export const EDGE_KIND_USES_PROMPT = "uses_prompt" as const; + +/** This node was produced with this config. */ +export const EDGE_KIND_USES_CONFIG = "uses_config" as const; + +/** This node produced this proposal (materialized into the proposals table). */ +export const EDGE_KIND_PRODUCES = "produces" as const; + +/** All valid edge kinds. */ +export const EDGE_KINDS = [ + EDGE_KIND_ANCHORS, + EDGE_KIND_CONSUMES, + EDGE_KIND_REFINES, + EDGE_KIND_USES_PROMPT, + EDGE_KIND_USES_CONFIG, + EDGE_KIND_PRODUCES, +] as const; + +export type EdgeKindConstant = typeof EDGE_KINDS[number]; + +// ─── Ref kinds (what kind of entity an edge target is) ─── + +export const REF_KIND_MESSAGE = "message" as const; +export const REF_KIND_ANALYSIS_NODE = "analysis_node" as const; +export const REF_KIND_SESSION = "session" as const; +export const REF_KIND_PROMPT_VERSION = "prompt_version" as const; +export const REF_KIND_CONFIG_VERSION = "config_version" as const; + +/** All valid ref kinds. */ +export const REF_KINDS = [ + REF_KIND_MESSAGE, + REF_KIND_ANALYSIS_NODE, + REF_KIND_SESSION, + REF_KIND_PROMPT_VERSION, + REF_KIND_CONFIG_VERSION, +] as const; + +export type RefKindConstant = typeof REF_KINDS[number]; + +// ─── Validation ─── + +export function isValidEdgeKind(kind: string): kind is EdgeKindConstant { + return EDGE_KINDS.includes(kind as EdgeKindConstant); +} + +export function isValidRefKind(kind: string): kind is RefKindConstant { + return REF_KINDS.includes(kind as RefKindConstant); +} \ No newline at end of file diff --git a/src/analyze/framework.ts b/src/analyze/framework.ts new file mode 100644 index 0000000..7a0a825 --- /dev/null +++ b/src/analyze/framework.ts @@ -0,0 +1,195 @@ +/** + * AnalyzerFramework: Orchestrates analyzer registration, planning, and execution. + * Design reference: docs/analyzer-design-c.md §4.2 + */ + +import Database from "better-sqlite3"; +import { createHash } from "node:crypto"; +import type { + Analyzer, AnalyzerConfig, AnalysisUnit, AnalysisResult, AnalysisNodeInsert, + AnalysisEdgeInsert, AnalysisRunInsert, AnalysisProgressInsert, AnalysisProgressRow, AnalysisNodeRow, + AnalysisRunRow, AnalyzerPlanContext, AnalyzerRunContext, MessageRow, + LLMRequest, LLMResponse, ModelTier, ModelTierConfig, FrameworkRunResult, FrameworkRunAllResult, +} from "./types.js"; +import { + upsertAnalyzerDef, upsertAnalyzerVersion, insertPrompt, insertAnalyzerConfig, + insertAnalysisRun, updateAnalysisRun, getAnalysisNode, getAnalysisNodesByAnalyzer, + checkInputHashExists, insertAnalysisNode, insertAnalysisEdges, upsertAnalysisProgress, + getAnalysisProgress, getFullSessionMessages, +} from "../db/analysis-queries.js"; +import { computeSourceSetHash, computeInputHash, computePromptBundleHash } from "./input-hash.js"; +import { materializeProposals } from "./proposal-materializer.js"; +import { resolveModelTier, DEFAULT_MODEL_TIERS } from "./model-tiers.js"; + +export type LLMProvider = (request: LLMRequest, modelTiers?: ModelTierConfig) => Promise; + +export class AnalyzerFramework { + private analyzers: Map = new Map(); + private db: Database.Database; + + private llmProvider: LLMProvider; + + constructor(db: Database.Database, llmProvider?: LLMProvider) { + this.db = db; + this.llmProvider = llmProvider ?? ((_req: LLMRequest) => { + throw new Error("No LLM provider configured. Pass an llmProvider to AnalyzerFramework constructor, or use --model to specify an Ollama model."); + }); + } + + register(analyzer: Analyzer): void { + this.analyzers.set(analyzer.def.id, analyzer); + upsertAnalyzerDef(this.db, analyzer.def); + upsertAnalyzerVersion(this.db, analyzer.version); + for (const [_name, prompt] of Object.entries(analyzer.prompts)) { + insertPrompt(this.db, prompt.hash, prompt.content, prompt.role ?? undefined, prompt.createdAt); + } + insertAnalyzerConfig(this.db, analyzer.defaultConfig); + } + + get(analyzerId: string): Analyzer | undefined { return this.analyzers.get(analyzerId); } + list(): string[] { return Array.from(this.analyzers.keys()); } + + async runAnalyzer(analyzerId: string, sessionId: string, configOverrides?: Partial, modelTiers?: ModelTierConfig): Promise { + const analyzer = this.analyzers.get(analyzerId); + if (!analyzer) throw new Error(`Analyzer not registered: ${analyzerId}`); + const config = configOverrides ? { ...analyzer.defaultConfig, ...configOverrides } : analyzer.defaultConfig; + const promptHashes = Object.values(analyzer.prompts).map(p => p.hash); + const promptBundleHash = computePromptBundleHash(promptHashes); + const runId = `run-${createHash("sha256").update(`${analyzerId}-${analyzer.version.versionId}-${sessionId}-${Date.now()}`).digest("hex").slice(0, 16)}`; + const startedAt = new Date().toISOString(); + const modelSpec = resolveModelTier((analyzer.version.implementationKind === "deterministic" ? "cheap" : "mid") as ModelTier, modelTiers); + + const runInsert: AnalysisRunInsert = { + id: runId, analyzerId: analyzer.def.id, analyzerVersionId: analyzer.version.versionId, + configId: config.id, sessionId, status: "running", promptBundleHash, startedAt, modelSpec, + }; + insertAnalysisRun(this.db, runInsert); + + const messages = this.getMessages(sessionId); + const ownNodes = getAnalysisNodesByAnalyzer(this.db, analyzerId); + const dependencyNodes = this.getDependencyNodes(analyzerId, sessionId); + const progress: AnalysisProgressRow | null | undefined = getAnalysisProgress(this.db, analyzerId, analyzer.version.versionId, config.id, sessionId); + + const planContext: AnalyzerPlanContext = { + sessionId, messages, allNodes: [...ownNodes, ...Object.values(dependencyNodes).flat()], + ownNodes, dependencyNodes, progress: progress ?? undefined, db: this.db, + }; + + let units: AnalysisUnit[]; + try { units = await analyzer.plan(planContext); } catch (err) { + updateAnalysisRun(this.db, runId, { status: "error", finished_at: new Date().toISOString(), error_message: `Plan failed: ${err instanceof Error ? err.message : String(err)}` }); + throw err; + } + + let nodesProduced = 0; let nodesSkipped = 0; let totalCostUsd = 0; let totalTokensUsed = 0; let totalDurationMs = 0; + + for (const unit of units) { + const inputHash = computeInputHash(analyzerId, analyzer.version.versionId, config.id, promptBundleHash, unit.sourceSetHash); + if (checkInputHashExists(this.db, inputHash)) { nodesSkipped++; continue; } + + const runRow: AnalysisRunRow = { + id: runId, analyzer_id: analyzer.def.id, analyzer_version_id: analyzer.version.versionId, config_id: config.id, + session_id: sessionId, status: "running", prompt_bundle_hash: promptBundleHash, started_at: startedAt, + finished_at: "", model_spec: modelSpec ?? "", cost_usd: 0, tokens_used: 0, nodes_produced: 0, nodes_skipped: 0, error_message: "", + }; + + const runContext: AnalyzerRunContext = { + getMessage(id: string): MessageRow | undefined { return messages.find(m => m.id === id); }, + getNode: (id: string): AnalysisNodeRow | undefined => getAnalysisNode(this.db, id), + getDependencyNodes: (depId: string): AnalysisNodeRow[] => dependencyNodes[depId] ?? [], + llm: (req: LLMRequest) => this.callLLM(req, modelTiers), run: runRow, config, prompts: Object.fromEntries(Object.entries(analyzer.prompts).map(([k, v]) => [k, v.content])), + }; + + const startTime = Date.now(); + let result: AnalysisResult; + try { result = await analyzer.analyze(unit, runContext); } catch (err) { + result = { contentJson: { error: err instanceof Error ? err.message : String(err) }, nodeKind: "error", anchorKind: unit.anchorKind, anchorRef: unit.anchorRef, edges: [] }; + } + const durationMs = Date.now() - startTime; + + const nodeId = `node-${createHash("sha256").update(inputHash).digest("hex").slice(0, 12)}`; + const nodeInsert: AnalysisNodeInsert = { + id: nodeId, sessionId, analyzerId: analyzer.def.id, analyzerVersionId: analyzer.version.versionId, + configId: config.id, runId, nodeKind: result.nodeKind, contentJson: JSON.stringify(result.contentJson), + sourceSetHash: unit.sourceSetHash, inputHash, createdAt: new Date().toISOString(), + modelUsed: result.modelUsed ?? undefined, costUsd: result.costUsd ?? 0, tokensUsed: result.tokensUsed ?? 0, durationMs: result.durationMs ?? undefined, + }; + insertAnalysisNode(this.db, nodeInsert); + + const edges: AnalysisEdgeInsert[] = result.edges.map((e: { toRefKind: string; toRefId: string; edgeKind: string; ordinal?: number }, idx: number) => ({ + fromNodeId: nodeId, toRefKind: e.toRefKind, toRefId: e.toRefId, edgeKind: e.edgeKind, ordinal: e.ordinal ?? idx, + })); + if (edges.length > 0) insertAnalysisEdges(this.db, edges); + + if (result.nodeKind === "summary" || result.nodeKind === "proposal") { + const nodeRow = getAnalysisNode(this.db, nodeId); + if (nodeRow) materializeProposals(this.db, nodeRow); + } + + nodesProduced++; + totalCostUsd += result.costUsd ?? 0; + totalTokensUsed += result.tokensUsed ?? 0; + totalDurationMs += durationMs; + } + + const finishedAt = new Date().toISOString(); + updateAnalysisRun(this.db, runId, { status: "ok", finished_at: finishedAt, cost_usd: totalCostUsd, tokens_used: totalTokensUsed, nodes_produced: nodesProduced, nodes_skipped: nodesSkipped }); + + const progressInsert: AnalysisProgressInsert = { + analyzerId: analyzer.def.id, analyzerVersionId: analyzer.version.versionId, configId: config.id, sessionId, + cursorJson: JSON.stringify({ lastUnitIndex: units.length }), lastRunId: runId, + totalAnalyzed: (progress?.total_analyzed ?? 0) + nodesProduced, status: "ok", updatedAt: finishedAt, + }; + upsertAnalysisProgress(this.db, progressInsert); + + return { runId, nodesProduced, nodesSkipped, costUsd: totalCostUsd, tokensUsed: totalTokensUsed, durationMs: totalDurationMs }; + } + + async runAll(sessionId: string, configOverrides?: Record>, modelTiers?: ModelTierConfig): Promise { + const order = this.topologicalSort(); + const results: FrameworkRunResult[] = []; + let totalNodesProduced = 0; let totalNodesSkipped = 0; let totalCostUsd = 0; let totalTokensUsed = 0; + const errors: string[] = []; + + for (const analyzerId of order) { + try { + const config = configOverrides?.[analyzerId]; + const result = await this.runAnalyzer(analyzerId, sessionId, config, modelTiers); + results.push(result); + totalNodesProduced += result.nodesProduced; totalNodesSkipped += result.nodesSkipped; + totalCostUsd += result.costUsd; totalTokensUsed += result.tokensUsed; + } catch (err) { errors.push(`${analyzerId}: ${err instanceof Error ? err.message : String(err)}`); } + } + + return { results, totalNodesProduced, totalNodesSkipped, totalCostUsd, totalTokensUsed, errors }; + } + + private getMessages(sessionId: string): MessageRow[] { + const rows = getFullSessionMessages(this.db, sessionId); + return rows.map(r => ({ ...r, content_text: r.content_text ?? "", content_thinking: r.content_thinking ?? "" })); + } + + private getDependencyNodes(analyzerId: string, _sessionId: string): Record { + const analyzer = this.analyzers.get(analyzerId); + if (!analyzer) return {}; + const result: Record = {}; + for (const depId of analyzer.def.dependencies) { result[depId] = getAnalysisNodesByAnalyzer(this.db, depId); } + return result; + } + + private topologicalSort(): string[] { + const visited = new Set(); const order: string[] = []; const visiting = new Set(); + const visit = (id: string) => { + if (visited.has(id)) return; if (visiting.has(id)) return; visiting.add(id); + const analyzer = this.analyzers.get(id); + if (analyzer) { for (const dep of analyzer.def.dependencies) visit(dep); } + visiting.delete(id); visited.add(id); order.push(id); + }; + for (const id of this.analyzers.keys()) visit(id); + return order; + } + + private async callLLM(_request: LLMRequest, _modelTiers?: ModelTierConfig): Promise { + throw new Error("LLM calls are not available outside Pi. Install pi-prospector as a Pi extension to enable LLM analysis."); + } +} \ No newline at end of file diff --git a/src/analyze/input-hash.ts b/src/analyze/input-hash.ts new file mode 100644 index 0000000..da4decb --- /dev/null +++ b/src/analyze/input-hash.ts @@ -0,0 +1,48 @@ +/** + * Hash computation utilities for the analyzer framework. + * Design reference: docs/analyzer-design-c.md §3 Idempotency model + */ + +import { createHash } from "node:crypto"; +import type { SourceRef } from "./types.js"; + +/** Compute a source set hash from an array of source references. Sorts for determinism. */ +export function computeSourceSetHash(sources: SourceRef[]): string { + const sorted = [...sources].sort((a, b) => { + const cmp = a.kind.localeCompare(b.kind); + return cmp !== 0 ? cmp : a.id.localeCompare(b.id); + }); + const payload = sorted.map(r => `${r.kind}:${r.id}`).join("|"); + return sha256(payload); +} + +/** Compute an input hash for idempotency checking. */ +export function computeInputHash( + analyzerId: string, analyzerVersionId: string, configId: string, + promptBundleHash: string, sourceSetHash: string, +): string { + const payload = [analyzerId, analyzerVersionId, configId, promptBundleHash, sourceSetHash].join("|"); + return sha256(payload); +} + +/** Compute a prompt bundle hash from an array of prompt hashes. Sorts for determinism. */ +export function computePromptBundleHash(promptHashes: string[]): string { + const sorted = [...promptHashes].sort(); + return sha256(sorted.join("|")); +} + +/** Compute a content hash for prompt registry (first 16 hex chars of SHA-256). */ +export function computePromptHash(content: string): string { + return sha256(content).slice(0, 16); +} + +/** Compute a dedup key for proposals. */ +export function computeDedupKey(targetType: string, targetPath: string | undefined, severity: string, title: string): string { + const normalized = title.toLowerCase().trim().replace(/\s+/g, " "); + const payload = `${targetType}|${targetPath ?? ""}|${severity}|${normalized}`; + return sha256(payload); +} + +function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} \ No newline at end of file diff --git a/src/analyze/model-tiers.ts b/src/analyze/model-tiers.ts new file mode 100644 index 0000000..6d3aca7 --- /dev/null +++ b/src/analyze/model-tiers.ts @@ -0,0 +1,31 @@ +/** + * Model tier resolution for analyzers. Design reference: docs/analyzer-design-c.md §10 + */ + +import type { ModelTierConfig, ModelTier } from "./types.js"; + +export const DEFAULT_MODEL_TIERS: ModelTierConfig = { + cheap: "anthropic/claude-haiku-3", + mid: "anthropic/claude-sonnet-4-5", + expensive: "anthropic/claude-opus-4", +}; + +/** Resolve a model tier name to an actual model specification string. */ +export function resolveModelTier(tier: ModelTier, config?: ModelTierConfig): string { + const tiers = config ?? DEFAULT_MODEL_TIERS; + switch (tier) { + case "cheap": return tiers.cheap; + case "mid": return tiers.mid ?? tiers.cheap; + case "expensive": return tiers.expensive ?? tiers.mid ?? tiers.cheap; + default: return tiers.mid ?? tiers.cheap; + } +} + +/** Validate model tier config has required fields. */ +export function validateModelTierConfig(config: Partial): string[] { + const errors: string[] = []; + if (!config.cheap && !config.mid && !config.expensive) { + errors.push("At least one model tier must be configured (cheap, mid, or expensive)"); + } + return errors; +} \ No newline at end of file diff --git a/src/analyze/ollama-llm.ts b/src/analyze/ollama-llm.ts new file mode 100644 index 0000000..a3453ab --- /dev/null +++ b/src/analyze/ollama-llm.ts @@ -0,0 +1,105 @@ +/** + * Ollama LLM backend for the analyzer framework. + * Calls Ollama's local API (http://localhost:11434) to run LLM inference. + */ + +import type { LLMRequest, LLMResponse, ModelTierConfig } from "./types.js"; + +const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434"; + +/** + * Resolve a model spec to an actual Ollama model name. + * If the spec contains a tier name (cheap/mid/expensive), resolve it using the config. + * Otherwise, use the spec directly as the model name. + */ +export function resolveOllamaModel(spec: string, config?: ModelTierConfig): string { + const tiers: ModelTierConfig = config ?? { + cheap: "deepseek-v4-flash:cloud", + mid: "glm-5.1:cloud", + expensive: "deepseek-v4-pro:cloud", + }; + if (spec === "cheap") return tiers.cheap; + if (spec === "mid") return tiers.mid; + if (spec === "expensive") return tiers.expensive; + return spec; +} + +/** + * Call Ollama's chat API to generate a response. + * Uses the /api/chat endpoint with structured output when tools are provided. + */ +export async function callOllamaLLM(request: LLMRequest, modelConfig?: ModelTierConfig): Promise { + const model = resolveOllamaModel(request.model, modelConfig); + const url = `${OLLAMA_BASE_URL}/api/chat`; + + const body: Record = { + model, + messages: [ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userPrompt }, + ], + stream: false, + options: { + ...(request.temperature !== undefined ? { temperature: request.temperature } : {}), + ...(request.maxTokens !== undefined ? { num_predict: request.maxTokens } : {}), + }, + }; + + // If tools are provided, include them for structured output + if (request.tools && request.tools.length > 0) { + body.tools = request.tools.map((tool: unknown) => { + if (typeof tool === "object" && tool !== null) { + const t = tool as Record; + return { + type: "function" as const, + function: { + name: t.name ?? "classify", + description: t.description ?? "", + parameters: t.parameters ?? {}, + }, + }; + } + return tool; + }); + } + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Ollama API error (${response.status}): ${errorText}`); + } + + const data = await response.json() as Record; + const message = data.message as Record | undefined; + + // Extract content from the response + const content = (message?.content as string) ?? ""; + + // Extract tool calls if present + let toolCalls: unknown[] | undefined; + if (message?.tool_calls && Array.isArray(message.tool_calls)) { + toolCalls = (message.tool_calls as Array>).map((tc) => ({ + name: (tc.function as Record)?.name, + arguments: (tc.function as Record)?.arguments, + })); + } + + // Extract usage info + const evalCount = (data.eval_count as number) ?? 0; + const promptEvalCount = (data.prompt_eval_count as number) ?? 0; + + return { + content, + toolCalls: toolCalls, + usage: { + inputTokens: promptEvalCount, + outputTokens: evalCount, + }, + model: data.model as string | undefined, + }; +} \ No newline at end of file diff --git a/src/analyze/proposal-materializer.ts b/src/analyze/proposal-materializer.ts new file mode 100644 index 0000000..5ecb7c5 --- /dev/null +++ b/src/analyze/proposal-materializer.ts @@ -0,0 +1,66 @@ +/** + * Proposal materialization: Extract proposals from analysis nodes, deduplicate, insert into proposals table. + */ + +import Database from "better-sqlite3"; +import { createHash } from "node:crypto"; +import type { AnalysisNodeRow, TargetType } from "../analyze/types.js"; +import { computeDedupKey } from "../analyze/input-hash.js"; + +export interface MaterializedProposal { + id: string; + analysisNodeId: string; + sessionId: string; + analyzerId: string; + targetType: string; + targetPath: string | undefined; + title: string; + summary: string; + detail: string | undefined; + evidenceJson: string | undefined; + confidence: number | undefined; + severity: string | undefined; + dedupKey: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export function materializeProposals(db: Database.Database, node: AnalysisNodeRow): MaterializedProposal[] { + let properties: Record; + try { properties = JSON.parse(node.content_json) as Record; } catch { return []; } + const proposals = properties.improvement_proposals; + if (!Array.isArray(proposals)) return []; + + const inserted: MaterializedProposal[] = []; + const now = new Date().toISOString(); + + for (const p of proposals) { + if (!p || typeof p !== "object") continue; + const proposal = p as Record; + const targetType = validTargetType(proposal.target_type) ? proposal.target_type as TargetType : "config"; + const targetPath = typeof proposal.target_path === "string" ? proposal.target_path : undefined; + const title = String(proposal.title ?? "Untitled proposal"); + const summary = String(proposal.summary ?? ""); + const detail = typeof proposal.detail === "string" ? proposal.detail : undefined; + const evidence = typeof proposal.evidence === "string" ? proposal.evidence : undefined; + const confidence = typeof proposal.confidence === "number" ? proposal.confidence : undefined; + const severity = validSeverity(proposal.severity) ? proposal.severity as string : "suggestion"; + + const dedupKey = computeDedupKey(targetType, targetPath ?? "", severity, title); + const existing = db.prepare("SELECT id FROM proposals WHERE dedup_hash = ? AND status IN ('new', 'open') LIMIT 1").get(dedupKey) as { id: string } | undefined; + if (existing) continue; + + const id = `p-${createHash("sha256").update(`${node.id}-${targetType}-${title}-${now}`).digest("hex").slice(0, 12)}`; + db.prepare(`INSERT INTO proposals (id, created_at, session_id, target, severity, summary, detail, evidence, status, dedup_hash, source_node_id, analyzer_id, target_type, target_path, title, confidence, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + id, now, node.session_id, targetType, severity, title, detail ?? summary, evidence ?? "", "open", dedupKey, + node.id, node.analyzer_id, targetType, targetPath, title, confidence ?? null, now, + ); + + inserted.push({ id, analysisNodeId: node.id, sessionId: node.session_id, analyzerId: node.analyzer_id, targetType, targetPath, title, summary, detail, evidenceJson: evidence, confidence, severity, dedupKey, status: "open", createdAt: now, updatedAt: now }); + } + return inserted; +} + +function validTargetType(v: unknown): v is TargetType { return typeof v === "string" && ["agents_md", "system_md", "skill", "extension_prompt", "tool_output", "repo_doc", "config"].includes(v); } +function validSeverity(v: unknown): boolean { return typeof v === "string" && ["friction", "correction", "waste", "suggestion", "insight"].includes(v); } \ No newline at end of file diff --git a/src/analyze/types.ts b/src/analyze/types.ts new file mode 100644 index 0000000..e84b3b3 --- /dev/null +++ b/src/analyze/types.ts @@ -0,0 +1,311 @@ +/** + * Type definitions for the analyzer framework. + * All data shapes use TypeBox schemas, with TypeScript types derived via Static. + * Design reference: docs/analyzer-design-c.md + */ + +import { Type, Static } from "typebox"; + +// ─── Core identifiers ─── + +export const RefKindEnum = Type.Union([ + Type.Literal("message"), + Type.Literal("analysis_node"), + Type.Literal("session"), + Type.Literal("prompt_version"), + Type.Literal("config_version"), +]); +export type RefKind = Static; + +export const EdgeKindEnum = Type.Union([ + Type.Literal("anchors"), + Type.Literal("consumes"), + Type.Literal("refines"), + Type.Literal("uses_prompt"), + Type.Literal("uses_config"), + Type.Literal("produces"), +]); +export type EdgeKind = Static; + +export const NodeKindEnum = Type.Union([ + Type.Literal("metric"), + Type.Literal("classification"), + Type.Literal("summary"), + Type.Literal("proposal"), + Type.Literal("error"), +]); +export type NodeKind = Static; + +export const AnchorSpanEnum = Type.Union([Type.Literal("pair"), Type.Literal("segment"), Type.Literal("full_session")]); +export type AnchorSpan = Static; + +export const ImplementationKindEnum = Type.Union([Type.Literal("deterministic"), Type.Literal("in_process_llm"), Type.Literal("pi_subagent")]); +export type ImplementationKind = Static; + +export const AnchorKindEnum = Type.Union([Type.Literal("message"), Type.Literal("pair"), Type.Literal("segment"), Type.Literal("session"), Type.Literal("analysis_node"), Type.Literal("none")]); +export type AnchorKind = Static; + +export const ProposalSeverityEnum = Type.Union([Type.Literal("friction"), Type.Literal("correction"), Type.Literal("waste"), Type.Literal("suggestion"), Type.Literal("insight")]); +export type ProposalSeverityV2 = Static; + +export const TargetTypeEnum = Type.Union([Type.Literal("agents_md"), Type.Literal("system_md"), Type.Literal("skill"), Type.Literal("extension_prompt"), Type.Literal("tool_output"), Type.Literal("repo_doc"), Type.Literal("config")]); +export type TargetType = Static; + +// ─── Analyzer definition schemas ─── + +export const AnalyzerDefSchema = Type.Object({ + id: Type.String(), label: Type.String(), description: Type.Optional(Type.String()), + anchorSpan: AnchorSpanEnum, dependencies: Type.Array(Type.String(), { default: [] }), createdAt: Type.String(), +}); +export type AnalyzerDef = Static; + +export const AnalyzerVersionSchema = Type.Object({ + analyzerId: Type.String(), versionId: Type.String(), implementationKind: ImplementationKindEnum, + codeRef: Type.Optional(Type.String()), createdAt: Type.String(), +}); +export type AnalyzerVersion = Static; + +export const PromptVersionSchema = Type.Object({ + hash: Type.String(), content: Type.String(), fullHash: Type.String(), + role: Type.Optional(Type.Union([Type.Literal("classify"), Type.Literal("map"), Type.Literal("reduce"), Type.Literal("verify")])), + createdAt: Type.String(), +}); +export type PromptVersion = Static; + +export const AnalyzerConfigSchema = Type.Object({ + id: Type.String(), analyzerId: Type.String(), configJson: Type.Record(Type.String(), Type.Unknown()), + configHash: Type.String(), label: Type.Optional(Type.String()), createdAt: Type.String(), +}); +export type AnalyzerConfig = Static; + +// ─── Analysis unit and result schemas ─── + +export const SourceRefSchema = Type.Object({ kind: Type.Union([Type.Literal("message"), Type.Literal("analysis_node"), Type.Literal("session")]), id: Type.String() }); +export type SourceRef = Static; + +export const AnalysisUnitSchema = Type.Object({ + sources: Type.Array(SourceRefSchema), sourceSetHash: Type.String(), + anchorKind: AnchorKindEnum, anchorRef: Type.Optional(Type.String()), meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}); +export type AnalysisUnit = Static; + +export const AnalysisEdgeSchema = Type.Object({ + toRefKind: Type.Union([SourceRefSchema.properties.kind, Type.Literal("prompt_version"), Type.Literal("config_version")]), + toRefId: Type.String(), edgeKind: EdgeKindEnum, ordinal: Type.Optional(Type.Number()), +}); +export type AnalysisEdge = Static; + +export const AnalysisResultSchema = Type.Object({ + contentJson: Type.Record(Type.String(), Type.Unknown()), nodeKind: NodeKindEnum, + anchorKind: AnchorKindEnum, anchorRef: Type.Optional(Type.String()), edges: Type.Array(AnalysisEdgeSchema), + modelUsed: Type.Optional(Type.String()), costUsd: Type.Optional(Type.Number()), + tokensUsed: Type.Optional(Type.Number()), durationMs: Type.Optional(Type.Number()), +}); +export type AnalysisResult = Static; + +// ─── Database row schemas ─── + +export const AnalysisNodeInsertSchema = Type.Object({ + id: Type.String(), sessionId: Type.String(), analyzerId: Type.String(), analyzerVersionId: Type.String(), + configId: Type.String(), runId: Type.String(), nodeKind: NodeKindEnum, contentJson: Type.String(), + sourceSetHash: Type.String(), inputHash: Type.String(), createdAt: Type.String(), + modelUsed: Type.Optional(Type.String()), costUsd: Type.Optional(Type.Number({ default: 0 })), + tokensUsed: Type.Optional(Type.Number({ default: 0 })), durationMs: Type.Optional(Type.Number()), +}); +export type AnalysisNodeInsert = Static; + +export const AnalysisEdgeInsertSchema = Type.Object({ + fromNodeId: Type.String(), toRefKind: Type.String(), toRefId: Type.String(), + edgeKind: Type.String(), ordinal: Type.Optional(Type.Number({ default: 0 })), +}); +export type AnalysisEdgeInsert = Static; + +export const AnalysisRunInsertSchema = Type.Object({ + id: Type.String(), analyzerId: Type.String(), analyzerVersionId: Type.String(), + configId: Type.String(), sessionId: Type.String(), status: Type.Union([Type.Literal("planned"), Type.Literal("running"), Type.Literal("ok"), Type.Literal("error"), Type.Literal("partial")]), + promptBundleHash: Type.String(), startedAt: Type.String(), finishedAt: Type.Optional(Type.String()), + modelSpec: Type.Optional(Type.String()), costUsd: Type.Optional(Type.Number({ default: 0 })), + tokensUsed: Type.Optional(Type.Number({ default: 0 })), nodesProduced: Type.Optional(Type.Number({ default: 0 })), + nodesSkipped: Type.Optional(Type.Number({ default: 0 })), errorMessage: Type.Optional(Type.String()), +}); +export type AnalysisRunInsert = Static; + +export const AnalysisProgressInsertSchema = Type.Object({ + analyzerId: Type.String(), analyzerVersionId: Type.String(), configId: Type.String(), sessionId: Type.String(), + cursorJson: Type.Optional(Type.String()), lastRunId: Type.Optional(Type.String()), + totalAnalyzed: Type.Optional(Type.Number({ default: 0 })), + status: Type.Optional(Type.Union([Type.Literal("ok"), Type.Literal("in_progress"), Type.Literal("error"), Type.Literal("needs_rerun")])), + errorMessage: Type.Optional(Type.String()), updatedAt: Type.String(), +}); +export type AnalysisProgressInsert = Static; + +export const AnalysisNodeRowSchema = Type.Object({ + id: Type.String(), session_id: Type.String(), analyzer_id: Type.String(), analyzer_version_id: Type.String(), + config_id: Type.String(), run_id: Type.String(), node_kind: Type.String(), content_json: Type.String(), + source_set_hash: Type.String(), input_hash: Type.String(), created_at: Type.String(), + model_used: Type.String(), cost_usd: Type.Number(), tokens_used: Type.Number(), duration_ms: Type.Number(), +}); +export type AnalysisNodeRow = Static; + +export const AnalysisRunRowSchema = Type.Object({ + id: Type.String(), analyzer_id: Type.String(), analyzer_version_id: Type.String(), + config_id: Type.String(), session_id: Type.String(), status: Type.String(), + prompt_bundle_hash: Type.String(), started_at: Type.String(), finished_at: Type.String(), + model_spec: Type.String(), cost_usd: Type.Number(), tokens_used: Type.Number(), + nodes_produced: Type.Number(), nodes_skipped: Type.Number(), error_message: Type.String(), +}); +export type AnalysisRunRow = Static; + +export const AnalysisProgressRowSchema = Type.Object({ + analyzer_id: Type.String(), analyzer_version_id: Type.String(), config_id: Type.String(), session_id: Type.String(), + cursor_json: Type.String(), last_run_id: Type.String(), total_analyzed: Type.Number(), + status: Type.String(), error_message: Type.String(), updated_at: Type.String(), +}); +export type AnalysisProgressRow = Static; + +export const MessageRowSchema = Type.Object({ + id: Type.String(), session_id: Type.String(), parent_id: Type.Union([Type.String(), Type.Null()]), + timestamp: Type.Union([Type.String(), Type.Null()]), role: Type.String(), + content_text: Type.Union([Type.String(), Type.Null()]), content_thinking: Type.Union([Type.String(), Type.Null()]), + tool_calls: Type.Union([Type.String(), Type.Null()]), tool_results: Type.Union([Type.String(), Type.Null()]), +}); +export type MessageRow = Static; + +// ─── LLM interface ─── + +export const LLMRequestSchema = Type.Object({ + model: Type.String(), systemPrompt: Type.String(), userPrompt: Type.String(), + tools: Type.Optional(Type.Array(Type.Unknown())), + maxTokens: Type.Optional(Type.Number()), temperature: Type.Optional(Type.Number()), +}); +export type LLMRequest = Static; + +export const LLMResponseSchema = Type.Object({ + content: Type.String(), toolCalls: Type.Optional(Type.Array(Type.Unknown())), + usage: Type.Optional(Type.Object({ inputTokens: Type.Number(), outputTokens: Type.Number(), costUsd: Type.Optional(Type.Number()) })), + model: Type.Optional(Type.String()), +}); +export type LLMResponse = Static; + +// ─── Analyzer interface ─── + +export interface Analyzer { + def: AnalyzerDef; version: AnalyzerVersion; + prompts: Record; defaultConfig: AnalyzerConfig; + plan(ctx: AnalyzerPlanContext): Promise; + analyze(unit: AnalysisUnit, ctx: AnalyzerRunContext): Promise; +} + +export interface AnalyzerPlanContext { + sessionId: string; messages: MessageRow[]; allNodes: AnalysisNodeRow[]; + ownNodes: AnalysisNodeRow[]; dependencyNodes: Record; + progress: AnalysisProgressRow | null | undefined; db: import("better-sqlite3").Database; +} + +export interface AnalyzerRunContext { + getMessage(id: string): MessageRow | undefined; + getNode(id: string): AnalysisNodeRow | undefined; + getDependencyNodes(analyzerId: string): AnalysisNodeRow[]; + llm(request: LLMRequest): Promise; + run: AnalysisRunRow; config: AnalyzerConfig; prompts: Record; +} + +// ─── Framework result ─── + +export interface FrameworkRunResult { + runId: string; nodesProduced: number; nodesSkipped: number; + costUsd: number; tokensUsed: number; durationMs: number; +} + +export interface FrameworkRunAllResult { + results: FrameworkRunResult[]; totalNodesProduced: number; totalNodesSkipped: number; + totalCostUsd: number; totalTokensUsed: number; errors: string[]; +} + +// ─── Model tier config ─── + +export const ModelTierConfigSchema = Type.Object({ + cheap: Type.String(), mid: Type.String(), expensive: Type.String(), +}); +export type ModelTierConfig = Static; +export type ModelTier = "cheap" | "mid" | "expensive"; + +// ─── Turn-pair-core specific types ─── + +export const TurnPairCorePropertiesSchema = Type.Object({ + user_msg_length: Type.Number(), assistant_msg_length: Type.Number(), has_thinking: Type.Boolean(), + thinking_length: Type.Number(), correction_detected: Type.Boolean(), correction_patterns: Type.Array(Type.String()), + correction_type: Type.Union([Type.Literal("explicit"), Type.Literal("implicit"), Type.Literal("repetition"), Type.Null()]), + correction_text: Type.Union([Type.String(), Type.Null()]), tool_call_count: Type.Number(), tool_names: Type.Array(Type.String()), + tool_failure_count: Type.Number(), tool_failure_details: Type.Array(Type.Object({ tool_name: Type.String(), error_preview: Type.String() })), + tool_waste_bytes: Type.Number(), retry_detected: Type.Boolean(), + elapsed_seconds: Type.Union([Type.Number(), Type.Null()]), friction_score: Type.Number(), + model: Type.Union([Type.String(), Type.Null()]), stop_reason: Type.Union([Type.String(), Type.Null()]), + usage_input_tokens: Type.Union([Type.Number(), Type.Null()]), usage_output_tokens: Type.Union([Type.Number(), Type.Null()]), + is_compaction_boundary: Type.Boolean(), +}); +export type TurnPairCoreProperties = Static; + +// ─── Turn-pair-llm specific types ─── + +export const TurnPairLLMPropertiesSchema = Type.Object({ + sentiment: Type.Union([Type.Literal("positive"), Type.Literal("neutral"), Type.Literal("negative"), Type.Literal("frustrated")]), + frustration_level: Type.Number(), correction_type_llm: Type.Union([Type.Literal("explicit"), Type.Literal("implicit"), Type.Literal("repetition"), Type.Null()]), + friction_cause: Type.Union([Type.String(), Type.Null()]), friction_summary: Type.Union([Type.String(), Type.Null()]), + user_intent: Type.String(), quality_score: Type.Number(), +}); +export type TurnPairLLMProperties = Static; + +// ─── Session-overview specific types ─── + +export const KeyFrictionPointSchema = Type.Object({ + description: Type.String(), pair_node_id: Type.String(), severity: Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]), +}); +export type KeyFrictionPoint = Static; + +export const SentimentArcPointSchema = Type.Object({ + segment: Type.Number(), + sentiment: Type.String(), + key_event: Type.String(), +}); +export type SentimentArcPoint = Static; + +export const ImprovementProposalSchema = Type.Object({ + target_type: TargetTypeEnum, target_path: Type.Optional(Type.String()), title: Type.String(), + summary: Type.String(), detail: Type.String(), evidence: Type.String(), + confidence: Type.Number(), severity: Type.Union([Type.Literal("friction"), Type.Literal("correction"), Type.Literal("waste"), Type.Literal("suggestion"), Type.Literal("insight")]), +}); +export type ImprovementProposal = Static; + +export const SessionOverviewPropertiesSchema = Type.Object({ + total_pairs: Type.Number(), friction_pairs: Type.Number(), correction_count: Type.Number(), + avg_quality_score: Type.Union([Type.Number(), Type.Null()]), dominant_friction_type: Type.Union([Type.String(), Type.Null()]), + tool_failure_rate: Type.Number(), total_tool_waste_bytes: Type.Number(), + session_duration_seconds: Type.Union([Type.Number(), Type.Null()]), session_summary: Type.String(), + key_friction_points: Type.Array(KeyFrictionPointSchema), + improvement_proposals: Type.Array(ImprovementProposalSchema), + sentiment_arc: Type.Array(Type.Object({ segment: Type.Number(), sentiment: Type.String(), key_event: Type.String() })), +}); +export type SessionOverviewProperties = Static; + +// ─── Config ─── + +export const ProspectorConfigV2Schema = Type.Object({ + model: Type.Optional(Type.String()), dbPath: Type.Optional(Type.String()), + modelTiers: Type.Optional(ModelTierConfigSchema), +}); +export type ProspectorConfigV2 = Static; + +// ─── Updated proposal types (v2) ─── + +export const ProposalV2Schema = Type.Object({ + id: Type.String(), analysis_node_id: Type.String(), session_id: Type.String(), analyzer_id: Type.String(), + target_type: TargetTypeEnum, target_path: Type.Optional(Type.String()), title: Type.String(), + summary: Type.String(), detail: Type.Optional(Type.String()), + evidence_json: Type.Optional(Type.String()), confidence: Type.Optional(Type.Number()), + severity: Type.Optional(Type.Union([Type.Literal("friction"), Type.Literal("correction"), Type.Literal("waste"), Type.Literal("suggestion"), Type.Literal("insight")])), + dedup_key: Type.Optional(Type.String()), + status: Type.Union([Type.Literal("open"), Type.Literal("accepted"), Type.Literal("applied"), Type.Literal("rejected"), Type.Literal("duplicate")]), + created_at: Type.String(), updated_at: Type.String(), +}); +export type ProposalV2 = Static; \ No newline at end of file diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 1d6aa7a..d295dd8 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,29 +1,62 @@ -import type { ExtensionAPI } from "../pi-stubs.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "../pi-stubs.js"; import Database from "better-sqlite3"; import { migrate } from "../db/schema.js"; -import { getUnanalyzedSessions, getSessionMessages, markAnalyzed } from "../db/queries.js"; +import { getUnanalyzedSessions, markAnalyzed } from "../db/queries.js"; import { getDbPath, loadConfig } from "../config.js"; +import { AnalyzerFramework } from "../analyze/framework.js"; +import { turnPairCoreAnalyzer } from "../analyze/analyzers/turn-pair-core/index.js"; +import { turnPairLLMAnalyzer } from "../analyze/analyzers/turn-pair-llm/index.js"; +import { sessionOverviewAnalyzer } from "../analyze/analyzers/session-overview/index.js"; +import { callOllamaLLM } from "../analyze/ollama-llm.js"; +import type { LLMRequest, LLMResponse, ModelTierConfig } from "../analyze/types.js"; + +/** + * Resolve an LLM provider function from the model specification. + * + * When running inside Pi: + * - If the current Pi model is an Ollama model, use it via callOllamaLLM + * - If --model is specified, use that Ollama model + * + * Outside Pi (standalone), falls back to callOllamaLLM with the given model spec. + */ +function resolveLLMProvider( + ctx: ExtensionCommandContext, + modelSpec: string, +): (request: LLMRequest, modelTiers?: ModelTierConfig) => Promise { + // If the model spec is in "provider/model" format (e.g., "ollama/glm-5.1:cloud"), + // extract the Ollama model name + const ollamaModel = modelSpec.startsWith("ollama/") + ? modelSpec.slice("ollama/".length) + : modelSpec; + + // Default: use Ollama LLM with the specified model + return (request, modelTiers) => callOllamaLLM({ ...request, model: ollamaModel }, modelTiers); +} export function registerAnalyzeCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-analyze", { - description: "Run LLM analysis over unanalyzed sessions to generate proposals", - handler: async (args: string, ctx: { ui: { notify: (msg: string, level: string) => void } }) => { + description: "Run analysis over unanalyzed sessions to generate proposals", + handler: async (args: string, ctx: ExtensionCommandContext) => { const config = loadConfig(); const parsedArgs = parseArgs(args ?? ""); - const modelSpec = parsedArgs.model ?? config.model; + // Resolve model: --model arg > config > Pi's current model (if Ollama) + let modelSpec = parsedArgs.model ?? config.model; if (!modelSpec) { - const msg = "No model configured. Use --model provider/model or set in ~/.pi/agent/prospector.json"; - ctx.ui.notify(msg, "error"); - console.log(msg); - return; + // Try Pi's current model if it's an Ollama model + const currentModel = ctx.model; + if (currentModel && (currentModel.provider === "ollama" || currentModel.provider === "ollama-cloud")) { + modelSpec = currentModel.id; + } } - const db = new Database(getDbPath()); + const limit = parsedArgs.limit; + + const db = new Database(getDbPath(config)); migrate(db); try { - const unanalyzed = getUnanalyzedSessions(db, parsedArgs.limit); + const unanalyzed = getUnanalyzedSessions(db, limit); if (unanalyzed.length === 0) { const msg = "No unanalyzed sessions. Run /prospect-sync first."; ctx.ui.notify(msg, "info"); @@ -31,32 +64,43 @@ export function registerAnalyzeCommand(pi: ExtensionAPI): void { return; } - const startMsg = `Analyzing ${unanalyzed.length} session(s) with ${modelSpec}...`; + const effectiveLimit = limit ?? unanalyzed.length; + const sessionsToAnalyze = unanalyzed.slice(0, effectiveLimit); + const startMsg = `Analyzing ${sessionsToAnalyze.length} session(s)${modelSpec ? ` with ${modelSpec}` : " (deterministic only)"}...`; ctx.ui.notify(startMsg, "info"); console.log(startMsg); - let totalProposals = 0; + // Initialize framework with LLM provider if model is available + const llmProvider = modelSpec ? resolveLLMProvider(ctx, modelSpec) : undefined; + const framework = new AnalyzerFramework(db, llmProvider); + framework.register(turnPairCoreAnalyzer); + + // Only register LLM analyzers if we have a model + if (modelSpec) { + framework.register(turnPairLLMAnalyzer); + framework.register(sessionOverviewAnalyzer); + } + + let totalNodes = 0; let errors = 0; - for (const session of unanalyzed) { + for (const session of sessionsToAnalyze) { try { - const messages = getSessionMessages(db, session.id); - if (messages.length < 2) { - markAnalyzed(db, session.id); - continue; + const result = await framework.runAll(session.id, undefined, config.modelTiers); + totalNodes += result.totalNodesProduced; + if (result.errors.length > 0) { + for (const e of result.errors) console.error(` Warning: ${e}`); } - - // TODO: Call LLM via @earendil-works/pi-ai markAnalyzed(db, session.id); } catch (err) { errors++; - const errMsg = `Error on session ${session.id}: ${err}`; + const errMsg = `Error on session ${session.id}: ${err instanceof Error ? err.message : String(err)}`; ctx.ui.notify(errMsg, "warning"); console.error(errMsg); } } - const doneMsg = `Done. ${unanalyzed.length - errors} analyzed, ${totalProposals} proposals, ${errors} errors.`; + const doneMsg = `Done. ${sessionsToAnalyze.length - errors} analyzed, ${totalNodes} nodes produced, ${errors} errors.`; ctx.ui.notify(doneMsg, "info"); console.log(doneMsg); } finally { @@ -70,7 +114,7 @@ function parseArgs(raw: string): { model?: string; limit?: number } { const result: { model?: string; limit?: number } = {}; const parts = raw.split(/\s+/); for (let i = 0; i < parts.length; i++) { - if (parts[i] === "--model" && parts[i + 1]) result.model = parts[++i]; + if (parts[i] === "--model" && parts[i + 1]) result.model = parts[++i]!; else if (parts[i] === "--limit" && parts[i + 1]) { const n = parseInt(parts[++i]!, 10); if (!isNaN(n)) result.limit = n; diff --git a/src/commands/proposals.ts b/src/commands/proposals.ts index 5c7b051..4c14663 100644 --- a/src/commands/proposals.ts +++ b/src/commands/proposals.ts @@ -1,34 +1,30 @@ -import type { ExtensionAPI } from "../pi-stubs.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "../pi-stubs.js"; import Database from "better-sqlite3"; import { migrate } from "../db/schema.js"; -import { listProposals, acceptProposal, rejectProposal } from "../db/queries.js"; +import { listProposalsV2, acceptProposalV2, rejectProposalV2 } from "../db/queries.js"; import { getDbPath } from "../config.js"; -function output(ctx: any, text: string, level: "info" | "warning" | "error" = "info"): void { - ctx.ui.notify(text, level); - console.log(text); -} - export function registerProposalsCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-proposals", { - description: "List proposals (optionally filter by status: new, accepted, rejected)", - handler: async (args: string, ctx: any) => { + description: "List proposals (optionally filter by status: open, applied, rejected)", + handler: async (args: string, ctx: ExtensionCommandContext) => { const db = new Database(getDbPath()); migrate(db); try { const status = args?.trim() || undefined; - const proposals = listProposals(db, status); + const proposals = listProposalsV2(db, status); if (proposals.length === 0) { - output(ctx, "No proposals found."); + ctx.ui.notify("No proposals found.", "info"); return; } const lines = proposals.map((p) => { const short = p.id.slice(0, 8); - return `[${p.status}] ${short} | ${p.severity} | ${p.target}\n ${p.summary}`; + const target = p.target_path ?? p.target_type; + return `[${p.status}] ${short} | ${p.severity ?? "—"} | ${target}\n ${p.title ?? p.summary}`; }); - output(ctx, `Proposals (${proposals.length}):\n${lines.join("\n")}`); + ctx.ui.notify(`Proposals (${proposals.length}):\n${lines.join("\n")}`, "info"); } finally { db.close(); } @@ -37,14 +33,14 @@ export function registerProposalsCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-accept", { description: "Accept a proposal by ID", - handler: async (args: string, ctx: any) => { + handler: async (args: string, ctx: ExtensionCommandContext) => { const id = args?.trim(); - if (!id) { output(ctx, "Usage: /prospect-accept ", "warning"); return; } + if (!id) { ctx.ui.notify("Usage: /prospect-accept ", "warning"); return; } const db = new Database(getDbPath()); migrate(db); try { - const ok = acceptProposal(db, id); - output(ctx, ok ? `Proposal ${id} accepted.` : `Proposal ${id} not found or not in 'new' status.`, ok ? "info" : "warning"); + const ok = acceptProposalV2(db, id); + ctx.ui.notify(ok ? `Proposal ${id} accepted.` : `Proposal ${id} not found or not in 'open' status.`, ok ? "info" : "warning"); } finally { db.close(); } @@ -53,14 +49,14 @@ export function registerProposalsCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-reject", { description: "Reject a proposal by ID", - handler: async (args: string, ctx: any) => { + handler: async (args: string, ctx: ExtensionCommandContext) => { const id = args?.trim(); - if (!id) { output(ctx, "Usage: /prospect-reject ", "warning"); return; } + if (!id) { ctx.ui.notify("Usage: /prospect-reject ", "warning"); return; } const db = new Database(getDbPath()); migrate(db); try { - const ok = rejectProposal(db, id); - output(ctx, ok ? `Proposal ${id} rejected.` : `Proposal ${id} not found or not in 'new' status.`, ok ? "info" : "warning"); + const ok = rejectProposalV2(db, id); + ctx.ui.notify(ok ? `Proposal ${id} rejected.` : `Proposal ${id} not found or not in 'open' status.`, ok ? "info" : "warning"); } finally { db.close(); } diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 05c37b3..433182f 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -1,32 +1,60 @@ -import type { ExtensionAPI } from "../pi-stubs.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "../pi-stubs.js"; import Database from "better-sqlite3"; import { migrate } from "../db/schema.js"; import { getStats } from "../db/queries.js"; +import { getAnalysisStats } from "../db/analysis-queries.js"; import { getDbPath } from "../config.js"; export function registerStatsCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-stats", { description: "Show prospector database statistics", - handler: async (_args: string, ctx: { ui: { notify: (msg: string, level: string) => void } }) => { + handler: async (_args: string, ctx: ExtensionCommandContext) => { const db = new Database(getDbPath()); migrate(db); try { const s = getStats(db); + const a = getAnalysisStats(db); const lines = [ "╔══════════════════════════════════════════╗", "║ ⛏️ Prospector Stats ║", "╚══════════════════════════════════════════╝", "", + " ── Sessions ──", ` Sessions indexed: ${s.totalSessions}`, - ` Messages (user+asst):${s.totalMessages}`, - ` Tool results: ${s.totalToolResults}`, - ` Sessions analyzed: ${s.messagesProcessed}`, + ` Messages (user+asst): ${s.totalMessages}`, + ` Tool results: ${s.totalToolResults}`, + ` Sessions analyzed: ${s.messagesProcessed}`, "", - " Proposals:", - ` new: ${s.proposalsByStatus.new}`, - ` accepted: ${s.proposalsByStatus.accepted}`, - ` rejected: ${s.proposalsByStatus.rejected}`, + " ── Proposals ──", + ` open: ${s.proposalsByStatus.open ?? 0}`, + ` applied: ${s.proposalsByStatus.applied ?? 0}`, + ` rejected: ${s.proposalsByStatus.rejected ?? 0}`, + ` duplicate: ${s.proposalsByStatus.duplicate ?? 0}`, + "", + " ── Analysis Framework ──", + ` Analysis nodes: ${a.totalNodes}`, + ` Analysis edges: ${a.totalEdges}`, + ` Analysis runs: ${a.totalRuns}`, ]; + + // Node kind breakdown + if (Object.keys(a.nodesByKind).length > 0) { + lines.push(""); + lines.push(" ── Nodes by kind ──"); + for (const [kind, count] of Object.entries(a.nodesByKind)) { + lines.push(` ${kind}: ${count}`); + } + } + + // Run status breakdown + if (Object.keys(a.runsByStatus).length > 0) { + lines.push(""); + lines.push(" ── Runs by status ──"); + for (const [status, count] of Object.entries(a.runsByStatus)) { + lines.push(` ${status}: ${count}`); + } + } + const text = lines.join("\n"); ctx.ui.notify(text, "info"); console.log(text); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index f389e6f..689789b 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,13 +1,16 @@ -import type { ExtensionAPI } from "../pi-stubs.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "../pi-stubs.js"; import Database from "better-sqlite3"; import { migrate } from "../db/schema.js"; import { runSync } from "../sync/index.js"; -import { getDbPath, getSessionsDir } from "../config.js"; +import { getDbPath, getSessionsDir, loadConfig } from "../config.js"; +import { AnalyzerFramework } from "../analyze/framework.js"; +import { turnPairCoreAnalyzer } from "../analyze/analyzers/turn-pair-core/index.js"; +import { getUnanalyzedSessions, markAnalyzed } from "../db/queries.js"; export function registerSyncCommand(pi: ExtensionAPI): void { pi.registerCommand("prospect-sync", { - description: "Index session files into the prospector database (no LLM)", - handler: async (_args: string, ctx: { ui: { notify: (msg: string, level?: string) => void } }) => { + description: "Index session files into the prospector database, then run deterministic analysis", + handler: async (_args: string, ctx: ExtensionCommandContext) => { const dbPath = getDbPath(); const db = new Database(dbPath); migrate(db); @@ -28,6 +31,34 @@ export function registerSyncCommand(pi: ExtensionAPI): void { const text = lines.join("\n"); console.log(text); ctx.ui.notify(text, "info"); + + // After sync, run deterministic analysis on unanalyzed sessions + if (result.sessionsProcessed > 0 || result.messagesInserted > 0) { + const config = loadConfig(); + const framework = new AnalyzerFramework(db); + framework.register(turnPairCoreAnalyzer); + + const unanalyzed = getUnanalyzedSessions(db); + let analyzed = 0; + let syncedErrors = 0; + + for (const session of unanalyzed) { + try { + const runResult = await framework.runAnalyzer("turn-pair-core", session.id, undefined, config.modelTiers); + analyzed += runResult.nodesProduced; + markAnalyzed(db, session.id); + } catch (err) { + syncedErrors++; + console.error(` Warning: turn-pair-core failed on ${session.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (analyzed > 0 || syncedErrors > 0) { + const analyzeMsg = ` Deterministic analysis: ${analyzed} nodes produced, ${syncedErrors} errors`; + console.log(analyzeMsg); + ctx.ui.notify(analyzeMsg, "info"); + } + } } finally { db.close(); } diff --git a/src/commands/tool.ts b/src/commands/tool.ts index cad47a5..c009c5d 100644 --- a/src/commands/tool.ts +++ b/src/commands/tool.ts @@ -1,60 +1,67 @@ -import type { ExtensionAPI } from "../pi-stubs.js"; +import type { ExtensionAPI, ExtensionContext, ToolResult } from "../pi-stubs.js"; +import { Type, Static } from "typebox"; import Database from "better-sqlite3"; -import { Type } from "typebox"; import { migrate } from "../db/schema.js"; import { runSync } from "../sync/index.js"; -import { getStats, listProposals, acceptProposal, rejectProposal } from "../db/queries.js"; +import { getStats } from "../db/queries.js"; +import { listProposalsV2, acceptProposalV2, rejectProposalV2 } from "../db/queries.js"; import { getDbPath, getSessionsDir } from "../config.js"; +const ProspectParams = Type.Object({ + action: Type.Union([ + Type.Literal("sync"), + Type.Literal("stats"), + Type.Literal("list_proposals"), + Type.Literal("accept"), + Type.Literal("reject"), + ]), + status: Type.Optional(Type.Union([Type.Literal("open"), Type.Literal("accepted"), Type.Literal("rejected")])), + proposal_id: Type.Optional(Type.String()), +}); + +type ProspectParamsType = Static; + export function registerProspectTool(pi: ExtensionAPI): void { pi.registerTool({ name: "prospect", label: "Prospect", description: "Index sessions, check stats, list/accept/reject proposals. Actions: sync, stats, list_proposals, accept, reject.", - parameters: Type.Object({ - action: Type.Union([ - Type.Literal("sync"), - Type.Literal("stats"), - Type.Literal("list_proposals"), - Type.Literal("accept"), - Type.Literal("reject"), - ]), - status: Type.Optional(Type.Union([Type.Literal("new"), Type.Literal("accepted"), Type.Literal("rejected")])), - proposal_id: Type.Optional(Type.String()), - }), - async execute(_toolCallId: string, params: Record, _signal: unknown, _onUpdate: unknown, _ctx: unknown) { + parameters: ProspectParams, + async execute(_toolCallId: string, params: ProspectParamsType, _signal: AbortSignal, _onUpdate: unknown, _ctx: ExtensionContext): Promise { const db = new Database(getDbPath()); migrate(db); try { switch (params.action) { case "sync": { const result = runSync(db, getSessionsDir()); - return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result }; + return { content: [{ type: "text", text: JSON.stringify(result) }], details: result }; } case "stats": { const stats = getStats(db); - return { content: [{ type: "text" as const, text: JSON.stringify(stats, null, 2) }], details: stats }; + return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }], details: stats }; } case "list_proposals": { - const proposals = listProposals(db, params.status as string | undefined); - if (proposals.length === 0) return { content: [{ type: "text" as const, text: "No proposals found." }], details: [] }; - const text = proposals.map((p) => `[${p.status}] ${p.id.slice(0, 8)} | ${p.severity} | ${p.target}\n ${p.summary}`).join("\n\n"); - return { content: [{ type: "text" as const, text }], details: proposals }; + const proposals = listProposalsV2(db, params.status); + if (proposals.length === 0) return { content: [{ type: "text", text: "No proposals found." }], details: [] }; + const text = proposals.map((p) => `[${p.status}] ${p.id.slice(0, 8)} | ${p.severity ?? "—"} | ${p.target_path ?? p.target_type}\n ${p.title ?? p.summary}`).join("\n\n"); + return { content: [{ type: "text", text }], details: proposals }; } case "accept": { - if (!params.proposal_id) return { content: [{ type: "text" as const, text: "proposal_id required" }], details: {} }; - const ok = acceptProposal(db, params.proposal_id as string); - return { content: [{ type: "text" as const, text: ok ? `Accepted ${params.proposal_id}` : "Not found or not new" }], details: { ok } }; + if (!params.proposal_id) return { content: [{ type: "text", text: "proposal_id required" }], details: {} }; + const ok = acceptProposalV2(db, params.proposal_id); + return { content: [{ type: "text", text: ok ? `Accepted ${params.proposal_id}` : "Not found or not open" }], details: { ok } }; } case "reject": { - if (!params.proposal_id) return { content: [{ type: "text" as const, text: "proposal_id required" }], details: {} }; - const ok = rejectProposal(db, params.proposal_id as string); - return { content: [{ type: "text" as const, text: ok ? `Rejected ${params.proposal_id}` : "Not found or not new" }], details: { ok } }; + if (!params.proposal_id) return { content: [{ type: "text", text: "proposal_id required" }], details: {} }; + const ok = rejectProposalV2(db, params.proposal_id); + return { content: [{ type: "text", text: ok ? `Rejected ${params.proposal_id}` : "Not found or not open" }], details: { ok } }; } } } finally { db.close(); } + // Should be unreachable but satisfies type checker + return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], details: {} }; }, }); } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 511f21c..9e2c9cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,12 +8,7 @@ const DEFAULT_DB_PATH = path.join(os.homedir(), ".pi", "agent", "prospector.db") const SESSIONS_DIR = path.join(os.homedir(), ".pi", "agent", "sessions"); export function loadConfig(): ProspectorConfig { - try { - const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); - return JSON.parse(raw) as ProspectorConfig; - } catch { - return {}; - } + try { const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); return JSON.parse(raw) as ProspectorConfig; } catch { return {}; } } export function getDbPath(config?: ProspectorConfig): string { @@ -22,6 +17,4 @@ export function getDbPath(config?: ProspectorConfig): string { return DEFAULT_DB_PATH; } -export function getSessionsDir(): string { - return SESSIONS_DIR; -} \ No newline at end of file +export function getSessionsDir(): string { return SESSIONS_DIR; } \ No newline at end of file diff --git a/src/db/analysis-queries.ts b/src/db/analysis-queries.ts new file mode 100644 index 0000000..228627c --- /dev/null +++ b/src/db/analysis-queries.ts @@ -0,0 +1,165 @@ +/** + * Analysis framework database queries. + * All SQL for analyzer_defs, analyzer_versions, prompt_registry, analyzer_configs, + * analysis_runs, analysis_nodes, analysis_edges, analysis_progress lives here. + */ + +import Database from "better-sqlite3"; +import type { + AnalyzerDef, AnalyzerVersion, AnalyzerConfig, + AnalysisNodeInsert, AnalysisEdgeInsert, AnalysisRunInsert, AnalysisProgressInsert, + AnalysisNodeRow, AnalysisRunRow, AnalysisProgressRow, +} from "../analyze/types.js"; + +// ── Analyzer definitions ── + +export function upsertAnalyzerDef(db: Database.Database, def: AnalyzerDef): void { + db.prepare(`INSERT INTO analyzer_defs (id, label, description, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET label=excluded.label, description=excluded.description, anchor_span=excluded.anchor_span, dependencies=excluded.dependencies`).run(def.id, def.label, def.description ?? null, def.anchorSpan, JSON.stringify(def.dependencies), def.createdAt); +} + +export function getAnalyzerDef(db: Database.Database, id: string): AnalyzerDef | undefined { + const row = db.prepare("SELECT * FROM analyzer_defs WHERE id = ?").get(id) as Record | undefined; + if (!row) return undefined; + return { id: row.id as string, label: row.label as string, description: row.description as string | undefined, anchorSpan: row.anchor_span as "pair" | "segment" | "full_session", dependencies: JSON.parse(row.dependencies as string) as string[], createdAt: row.created_at as string }; +} + +export function getAllAnalyzerDefs(db: Database.Database): AnalyzerDef[] { + const rows = db.prepare("SELECT * FROM analyzer_defs").all() as Record[]; + return rows.map(row => ({ id: row.id as string, label: row.label as string, description: row.description as string | undefined, anchorSpan: row.anchor_span as "pair" | "segment" | "full_session", dependencies: JSON.parse(row.dependencies as string) as string[], createdAt: row.created_at as string })); +} + +// ── Analyzer versions ── + +export function upsertAnalyzerVersion(db: Database.Database, v: AnalyzerVersion): void { + db.prepare(`INSERT INTO analyzer_versions (analyzer_id, version_id, implementation_kind, code_ref, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(analyzer_id, version_id) DO UPDATE SET implementation_kind=excluded.implementation_kind, code_ref=excluded.code_ref`).run(v.analyzerId, v.versionId, v.implementationKind, v.codeRef ?? null, v.createdAt); +} + +export function getAnalyzerVersions(db: Database.Database, analyzerId: string): AnalyzerVersion[] { + const rows = db.prepare("SELECT * FROM analyzer_versions WHERE analyzer_id = ? ORDER BY created_at DESC").all(analyzerId) as Record[]; + return rows.map(row => ({ analyzerId: row.analyzer_id as string, versionId: row.version_id as string, implementationKind: row.implementation_kind as "deterministic" | "in_process_llm" | "pi_subagent", codeRef: row.code_ref as string | undefined, createdAt: row.created_at as string })); +} + +export function getLatestAnalyzerVersion(db: Database.Database, analyzerId: string): AnalyzerVersion | undefined { + const row = db.prepare("SELECT * FROM analyzer_versions WHERE analyzer_id = ? ORDER BY created_at DESC LIMIT 1").get(analyzerId) as Record | undefined; + if (!row) return undefined; + return { analyzerId: row.analyzer_id as string, versionId: row.version_id as string, implementationKind: row.implementation_kind as "deterministic" | "in_process_llm" | "pi_subagent", codeRef: row.code_ref as string | undefined, createdAt: row.created_at as string }; +} + +// ── Prompt registry ── + +export function insertPrompt(db: Database.Database, hash: string, content: string, role: string | undefined, createdAt: string): void { + db.prepare("INSERT OR IGNORE INTO prompt_registry (hash, content, role, created_at) VALUES (?, ?, ?, ?)").run(hash, content, role ?? null, createdAt); +} + +// ── Analyzer configs ── + +export function insertAnalyzerConfig(db: Database.Database, config: AnalyzerConfig): void { + db.prepare("INSERT OR IGNORE INTO analyzer_configs (id, analyzer_id, config_hash, config_json, label, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(config.id, config.analyzerId, config.configHash, JSON.stringify(config.configJson), config.label ?? null, config.createdAt); +} + +export function getAnalyzerConfigByHash(db: Database.Database, configHash: string): AnalyzerConfig | undefined { + const row = db.prepare("SELECT * FROM analyzer_configs WHERE config_hash = ?").get(configHash) as Record | undefined; + if (!row) return undefined; + return { id: row.id as string, analyzerId: row.analyzer_id as string, configJson: JSON.parse(row.config_json as string) as Record, configHash: row.config_hash as string, label: row.label as string | undefined, createdAt: row.created_at as string }; +} + +// ── Analysis runs ── + +export function insertAnalysisRun(db: Database.Database, run: AnalysisRunInsert): void { + db.prepare(`INSERT INTO analysis_runs (id, analyzer_id, analyzer_version_id, config_id, session_id, status, prompt_bundle_hash, started_at, finished_at, model_spec, cost_usd, tokens_used, nodes_produced, nodes_skipped, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(run.id, run.analyzerId, run.analyzerVersionId, run.configId, run.sessionId, run.status, run.promptBundleHash, run.startedAt, run.finishedAt ?? null, run.modelSpec ?? null, run.costUsd ?? 0, run.tokensUsed ?? 0, run.nodesProduced ?? 0, run.nodesSkipped ?? 0, run.errorMessage ?? null); +} + +export function updateAnalysisRun(db: Database.Database, id: string, updates: Partial>): void { + const setClauses: string[] = []; + const values: unknown[] = []; + if (updates.status !== undefined) { setClauses.push("status = ?"); values.push(updates.status); } + if (updates.finished_at !== undefined) { setClauses.push("finished_at = ?"); values.push(updates.finished_at); } + if (updates.cost_usd !== undefined) { setClauses.push("cost_usd = ?"); values.push(updates.cost_usd); } + if (updates.tokens_used !== undefined) { setClauses.push("tokens_used = ?"); values.push(updates.tokens_used); } + if (updates.nodes_produced !== undefined) { setClauses.push("nodes_produced = ?"); values.push(updates.nodes_produced); } + if (updates.nodes_skipped !== undefined) { setClauses.push("nodes_skipped = ?"); values.push(updates.nodes_skipped); } + if (updates.error_message !== undefined) { setClauses.push("error_message = ?"); values.push(updates.error_message); } + if (setClauses.length === 0) return; + values.push(id); + db.prepare(`UPDATE analysis_runs SET ${setClauses.join(", ")} WHERE id = ?`).run(...values); +} + +// ── Analysis nodes ── + +export function insertAnalysisNode(db: Database.Database, node: AnalysisNodeInsert): void { + db.prepare(`INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at, model_used, cost_usd, tokens_used, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(node.id, node.sessionId, node.analyzerId, node.analyzerVersionId, node.configId, node.runId, node.nodeKind, node.contentJson, node.sourceSetHash, node.inputHash, node.createdAt, node.modelUsed ?? null, node.costUsd ?? 0, node.tokensUsed ?? 0, node.durationMs ?? null); +} + +export function getAnalysisNode(db: Database.Database, id: string): AnalysisNodeRow | undefined { + const row = db.prepare("SELECT * FROM analysis_nodes WHERE id = ?").get(id) as Record | undefined; + if (!row) return undefined; + return mapNodeRow(row); +} + +export function getAnalysisNodesByAnalyzer(db: Database.Database, analyzerId: string, analyzerVersionId?: string): AnalysisNodeRow[] { + const sql = analyzerVersionId + ? "SELECT * FROM analysis_nodes WHERE analyzer_id = ? AND analyzer_version_id = ? ORDER BY created_at ASC" + : "SELECT * FROM analysis_nodes WHERE analyzer_id = ? ORDER BY created_at ASC"; + const params = analyzerVersionId ? [analyzerId, analyzerVersionId] : [analyzerId]; + return (db.prepare(sql).all(...params) as Record[]).map(mapNodeRow); +} + +export function checkInputHashExists(db: Database.Database, inputHash: string): boolean { + const row = db.prepare("SELECT 1 FROM analysis_nodes WHERE input_hash = ? LIMIT 1").get(inputHash) as { "1": number } | undefined; + return row !== undefined; +} + +function mapNodeRow(row: Record): AnalysisNodeRow { + return { id: row.id as string, session_id: row.session_id as string, analyzer_id: row.analyzer_id as string, analyzer_version_id: row.analyzer_version_id as string, config_id: row.config_id as string, run_id: row.run_id as string, node_kind: row.node_kind as string, content_json: row.content_json as string, source_set_hash: row.source_set_hash as string, input_hash: row.input_hash as string, created_at: row.created_at as string, model_used: (row.model_used as string) ?? "", cost_usd: (row.cost_usd as number) ?? 0, tokens_used: (row.tokens_used as number) ?? 0, duration_ms: (row.duration_ms as number) ?? 0 }; +} + +// ── Analysis edges ── + +export function insertAnalysisEdges(db: Database.Database, edges: AnalysisEdgeInsert[]): void { + const insert = db.prepare("INSERT OR IGNORE INTO analysis_edges (from_node_id, to_ref_kind, to_ref_id, edge_kind, ordinal) VALUES (?, ?, ?, ?, ?)"); + db.transaction(() => { for (const edge of edges) { insert.run(edge.fromNodeId, edge.toRefKind, edge.toRefId, edge.edgeKind, edge.ordinal ?? 0); } })(); +} + +// ── Analysis progress ── + +export function upsertAnalysisProgress(db: Database.Database, progress: AnalysisProgressInsert): void { + db.prepare(`INSERT INTO analysis_progress (analyzer_id, analyzer_version_id, config_id, session_id, cursor_json, last_run_id, total_analyzed, status, error_message, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(analyzer_id, analyzer_version_id, config_id, session_id) DO UPDATE SET cursor_json=excluded.cursor_json, last_run_id=excluded.last_run_id, total_analyzed=excluded.total_analyzed, status=excluded.status, error_message=excluded.error_message, updated_at=excluded.updated_at`).run(progress.analyzerId, progress.analyzerVersionId, progress.configId, progress.sessionId, progress.cursorJson ?? null, progress.lastRunId ?? null, progress.totalAnalyzed ?? 0, progress.status ?? "ok", progress.errorMessage ?? null, progress.updatedAt); +} + +export function getAnalysisProgress(db: Database.Database, analyzerId: string, analyzerVersionId: string, configId: string, sessionId: string): AnalysisProgressRow | undefined { + const row = db.prepare("SELECT * FROM analysis_progress WHERE analyzer_id = ? AND analyzer_version_id = ? AND config_id = ? AND session_id = ?").get(analyzerId, analyzerVersionId, configId, sessionId) as Record | undefined; + if (!row) return undefined; + return { analyzer_id: row.analyzer_id as string, analyzer_version_id: row.analyzer_version_id as string, config_id: row.config_id as string, session_id: row.session_id as string, cursor_json: row.cursor_json as string, last_run_id: row.last_run_id as string, total_analyzed: row.total_analyzed as number, status: row.status as string, error_message: row.error_message as string, updated_at: row.updated_at as string }; +} + +// ── Session queries ── + +export interface MessageRowFull { + id: string; session_id: string; parent_id: string | null; timestamp: string | null; + role: string; content_text: string | null; content_thinking: string | null; + tool_calls: string | null; tool_results: string | null; +} + +export function getFullSessionMessages(db: Database.Database, sessionId: string): MessageRowFull[] { + return (db.prepare("SELECT * FROM messages WHERE session_id = ? ORDER BY rowid ASC").all(sessionId) as Record[]).map(row => ({ + id: row.id as string, session_id: row.session_id as string, parent_id: row.parent_id as string | null, + timestamp: row.timestamp as string | null, role: row.role as string, + content_text: row.content_text as string | null, content_thinking: row.content_thinking as string | null, + tool_calls: row.tool_calls as string | null, tool_results: row.tool_results as string | null, + })); +} + +// ── Analysis stats ── + +export function getAnalysisStats(db: Database.Database): { totalNodes: number; totalEdges: number; totalRuns: number; nodesByKind: Record; runsByStatus: Record } { + const totalNodes = (db.prepare("SELECT COUNT(*) as c FROM analysis_nodes").get() as { c: number }).c; + const totalEdges = (db.prepare("SELECT COUNT(*) as c FROM analysis_edges").get() as { c: number }).c; + const totalRuns = (db.prepare("SELECT COUNT(*) as c FROM analysis_runs").get() as { c: number }).c; + const kindRows = db.prepare("SELECT node_kind, COUNT(*) as c FROM analysis_nodes GROUP BY node_kind").all() as Array<{ node_kind: string; c: number }>; + const nodesByKind: Record = {}; + for (const r of kindRows) nodesByKind[r.node_kind] = r.c; + const statusRows = db.prepare("SELECT status, COUNT(*) as c FROM analysis_runs GROUP BY status").all() as Array<{ status: string; c: number }>; + const runsByStatus: Record = {}; + for (const r of statusRows) runsByStatus[r.status] = r.c; + return { totalNodes, totalEdges, totalRuns, nodesByKind, runsByStatus }; +} \ No newline at end of file diff --git a/src/db/queries.ts b/src/db/queries.ts index 01129b6..ca4d507 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; import { createHash } from "node:crypto"; -import type { Proposal, Stats } from "../types.js"; +import type { Stats } from "../types.js"; // ── Sessions ── @@ -82,27 +82,81 @@ export function getSessionMessages(db: Database.Database, sessionId: string): Ar return db.prepare("SELECT role, content_text, content_thinking, tool_calls, timestamp FROM messages WHERE session_id = ? ORDER BY rowid ASC").all(sessionId) as any[]; } -// ── Proposals ── +// ── Proposals (v2 compatible) ── -export function insertProposal(db: Database.Database, p: Proposal): string { - db.prepare(` - INSERT OR IGNORE INTO proposals (id, created_at, session_id, target, severity, summary, detail, evidence, status, dedup_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(p.id, p.created_at, p.session_id, p.target, p.severity, p.summary, p.detail, p.evidence, p.status, p.dedup_hash); - return p.id; +export interface ProposalV2Row { + id: string; + created_at: string; + session_id: string; + analyzer_id: string | null; + target_type: string; + target_path: string | null; + title: string | null; + severity: string | null; + summary: string; + detail: string | null; + evidence: string | null; + confidence: number | null; + status: string; + dedup_key: string | null; + source_node_id: string | null; + updated_at: string | null; +} + +function mapProposalRow(row: Record): ProposalV2Row { + return { + id: String(row.id ?? ""), + created_at: String(row.created_at ?? ""), + session_id: String(row.session_id ?? ""), + analyzer_id: row.analyzer_id as string | null, + target_type: String(row.target_type ?? row.target ?? ""), + target_path: row.target_path as string | null, + title: row.title as string | null, + severity: row.severity as string | null, + summary: String(row.summary ?? ""), + detail: row.detail as string | null, + evidence: row.evidence as string | null, + confidence: row.confidence as number | null, + status: String(row.status ?? "open"), + dedup_key: row.dedup_key as string | null, + source_node_id: row.source_node_id as string | null, + updated_at: row.updated_at as string | null, + }; +} + +export function listProposalsV2(db: Database.Database, status?: string): ProposalV2Row[] { + const sql = status + ? "SELECT * FROM proposals WHERE status = ? ORDER BY created_at DESC" + : "SELECT * FROM proposals ORDER BY created_at DESC"; + const rows = (status ? db.prepare(sql).all(status) : db.prepare(sql).all()) as Record[]; + return rows.map(mapProposalRow); } -export function listProposals(db: Database.Database, status?: string): Proposal[] { - if (status) return db.prepare("SELECT * FROM proposals WHERE status = ? ORDER BY created_at DESC").all(status) as Proposal[]; - return db.prepare("SELECT * FROM proposals ORDER BY created_at DESC").all() as Proposal[]; +export function acceptProposalV2(db: Database.Database, id: string): boolean { + return db.prepare("UPDATE proposals SET status = 'applied', updated_at = ? WHERE id = ? AND status IN ('open', 'new')").run(new Date().toISOString(), id).changes > 0; +} + +export function rejectProposalV2(db: Database.Database, id: string): boolean { + return db.prepare("UPDATE proposals SET status = 'rejected', updated_at = ? WHERE id = ? AND status IN ('open', 'new')").run(new Date().toISOString(), id).changes > 0; +} + +export function getProposalById(db: Database.Database, id: string): ProposalV2Row | undefined { + const row = db.prepare("SELECT * FROM proposals WHERE id = ?").get(id) as Record | undefined; + return row ? mapProposalRow(row) : undefined; +} + +// v1-compatible functions (for backward compat) + +export function listProposals(db: Database.Database, status?: string): ProposalV2Row[] { + return listProposalsV2(db, status); } export function acceptProposal(db: Database.Database, id: string): boolean { - return db.prepare("UPDATE proposals SET status = 'accepted' WHERE id = ? AND status = 'new'").run(id).changes > 0; + return acceptProposalV2(db, id); } export function rejectProposal(db: Database.Database, id: string): boolean { - return db.prepare("UPDATE proposals SET status = 'rejected' WHERE id = ? AND status = 'new'").run(id).changes > 0; + return rejectProposalV2(db, id); } export function computeDedupHash(target: string, severity: string, summary: string): string { @@ -116,8 +170,9 @@ export function getStats(db: Database.Database): Stats { const totalMessages = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role IN ('user','assistant')").get() as { c: number }).c; const totalToolResults = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE role = 'toolResult'").get() as { c: number }).c; const messagesProcessed = (db.prepare("SELECT SUM(message_count) as c FROM sessions WHERE analyzed_at IS NOT NULL").get() as { c: number | null }).c ?? 0; - const pNew = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status = 'new'").get() as { c: number }).c; - const pAccepted = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status = 'accepted'").get() as { c: number }).c; + const pOpen = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status IN ('open', 'new')").get() as { c: number }).c; + const pApplied = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status = 'applied'").get() as { c: number }).c; const pRejected = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status = 'rejected'").get() as { c: number }).c; - return { totalSessions, totalMessages, totalToolResults, messagesProcessed, proposalsByStatus: { new: pNew, accepted: pAccepted, rejected: pRejected } }; + const pDuplicate = (db.prepare("SELECT COUNT(*) as c FROM proposals WHERE status = 'duplicate'").get() as { c: number }).c; + return { totalSessions, totalMessages, totalToolResults, messagesProcessed, proposalsByStatus: { open: pOpen, applied: pApplied, rejected: pRejected, duplicate: pDuplicate } }; } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index efa808f..a026607 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,6 +1,19 @@ +/** + * Database schema for pi-prospector. + * Migrations are applied sequentially. Each migration is idempotent (IF NOT EXISTS). + */ + import Database from "better-sqlite3"; export function migrate(db: Database.Database): void { + migration001(db); + migration002(db); +} + +/** + * Migration 001: Original v1 schema (sessions, messages, proposals, FTS5). + */ +function migration001(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -34,13 +47,20 @@ export function migrate(db: Database.Database): void { id TEXT PRIMARY KEY, created_at TEXT NOT NULL, session_id TEXT NOT NULL, - target TEXT NOT NULL, - severity TEXT NOT NULL, - summary TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT 'suggestion', + summary TEXT NOT NULL DEFAULT '', detail TEXT, evidence TEXT, - status TEXT NOT NULL DEFAULT 'new', + status TEXT NOT NULL DEFAULT 'open', dedup_hash TEXT, + source_node_id TEXT, + analyzer_id TEXT, + target_type TEXT, + target_path TEXT, + title TEXT, + confidence REAL, + updated_at TEXT, FOREIGN KEY (session_id) REFERENCES sessions(id) ); @@ -71,4 +91,155 @@ export function migrate(db: Database.Database): void { VALUES ('delete', OLD.rowid, OLD.content_text, OLD.content_thinking); END; `); +} + +/** + * Migration 002: Analyzer framework tables. + */ +function migration002(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS analyzer_defs ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT, + anchor_span TEXT NOT NULL, + dependencies TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS analyzer_versions ( + analyzer_id TEXT NOT NULL, + version_id TEXT NOT NULL, + implementation_kind TEXT NOT NULL, + code_ref TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (analyzer_id, version_id), + FOREIGN KEY (analyzer_id) REFERENCES analyzer_defs(id) + ); + + CREATE TABLE IF NOT EXISTS prompt_registry ( + hash TEXT PRIMARY KEY, + content TEXT NOT NULL, + role TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS analyzer_configs ( + id TEXT PRIMARY KEY, + analyzer_id TEXT NOT NULL, + config_hash TEXT NOT NULL UNIQUE, + config_json TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (analyzer_id) REFERENCES analyzer_defs(id) + ); + + CREATE TABLE IF NOT EXISTS analysis_runs ( + id TEXT PRIMARY KEY, + analyzer_id TEXT NOT NULL, + analyzer_version_id TEXT NOT NULL, + config_id TEXT NOT NULL, + session_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + prompt_bundle_hash TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + model_spec TEXT, + cost_usd REAL DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + nodes_produced INTEGER DEFAULT 0, + nodes_skipped INTEGER DEFAULT 0, + error_message TEXT, + FOREIGN KEY (analyzer_id, analyzer_version_id) REFERENCES analyzer_versions(analyzer_id, version_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) + ); + CREATE INDEX IF NOT EXISTS idx_runs_session ON analysis_runs(session_id); + CREATE INDEX IF NOT EXISTS idx_runs_status ON analysis_runs(status); + + CREATE TABLE IF NOT EXISTS analysis_nodes ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + analyzer_id TEXT NOT NULL, + analyzer_version_id TEXT NOT NULL, + config_id TEXT NOT NULL, + run_id TEXT NOT NULL, + node_kind TEXT NOT NULL, + content_json TEXT NOT NULL, + source_set_hash TEXT NOT NULL, + input_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + model_used TEXT, + cost_usd REAL DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + duration_ms INTEGER, + FOREIGN KEY (run_id) REFERENCES analysis_runs(id), + FOREIGN KEY (session_id) REFERENCES sessions(id) + ); + CREATE INDEX IF NOT EXISTS idx_nodes_session ON analysis_nodes(session_id); + CREATE INDEX IF NOT EXISTS idx_nodes_analyzer ON analysis_nodes(analyzer_id, analyzer_version_id); + CREATE INDEX IF NOT EXISTS idx_nodes_kind ON analysis_nodes(node_kind); + CREATE INDEX IF NOT EXISTS idx_nodes_input_hash ON analysis_nodes(input_hash); + CREATE INDEX IF NOT EXISTS idx_nodes_source_hash ON analysis_nodes(source_set_hash); + CREATE INDEX IF NOT EXISTS idx_nodes_config ON analysis_nodes(config_id); + CREATE INDEX IF NOT EXISTS idx_nodes_idempotency ON analysis_nodes(analyzer_id, analyzer_version_id, config_id, source_set_hash); + + CREATE TABLE IF NOT EXISTS analysis_edges ( + from_node_id TEXT NOT NULL, + to_ref_kind TEXT NOT NULL, + to_ref_id TEXT NOT NULL, + edge_kind TEXT NOT NULL, + ordinal INTEGER DEFAULT 0, + PRIMARY KEY (from_node_id, to_ref_kind, to_ref_id, edge_kind, ordinal), + FOREIGN KEY (from_node_id) REFERENCES analysis_nodes(id) + ); + CREATE INDEX IF NOT EXISTS idx_edges_from ON analysis_edges(from_node_id); + CREATE INDEX IF NOT EXISTS idx_edges_to ON analysis_edges(to_ref_kind, to_ref_id); + CREATE INDEX IF NOT EXISTS idx_edges_kind ON analysis_edges(edge_kind); + + CREATE TABLE IF NOT EXISTS analysis_progress ( + analyzer_id TEXT NOT NULL, + analyzer_version_id TEXT NOT NULL, + config_id TEXT NOT NULL, + session_id TEXT NOT NULL, + cursor_json TEXT, + last_run_id TEXT, + total_analyzed INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ok', + error_message TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (analyzer_id, analyzer_version_id, config_id, session_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) + ); + `); + + // Add new columns to proposals table if they don't exist + addColumnIfNotExists(db, "proposals", "source_node_id", "TEXT"); + addColumnIfNotExists(db, "proposals", "analyzer_id", "TEXT"); + addColumnIfNotExists(db, "proposals", "target_type", "TEXT"); + addColumnIfNotExists(db, "proposals", "target_path", "TEXT"); + addColumnIfNotExists(db, "proposals", "title", "TEXT"); + addColumnIfNotExists(db, "proposals", "confidence", "REAL"); + addColumnIfNotExists(db, "proposals", "updated_at", "TEXT"); + + // Migrate v1 proposal status values to v2 + // 'new' → 'open', 'accepted' → 'applied' + db.exec("UPDATE proposals SET status = 'open' WHERE status = 'new'"); + db.exec("UPDATE proposals SET status = 'applied' WHERE status = 'accepted'"); + + // Copy dedup_hash to dedup_key if dedup_key doesn't exist yet + // (v2 uses dedup_key, v1 used dedup_hash) + const colCheck = db.pragma("table_info(proposals)") as Array<{ name: string }>; + const hasDedupKey = colCheck.some(r => r.name === "dedup_key"); + if (!hasDedupKey) { + addColumnIfNotExists(db, "proposals", "dedup_key", "TEXT"); + db.exec("UPDATE proposals SET dedup_key = dedup_hash WHERE dedup_key IS NULL"); + } +} + +function addColumnIfNotExists(db: Database.Database, table: string, column: string, definition: string): void { + const rows = db.pragma(`table_info(${table})`) as Array<{ name: string }>; + const exists = rows.some(r => r.name === column); + if (!exists) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b551ed0..31fea4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,4 +11,11 @@ export default function (pi: ExtensionAPI) { registerProposalsCommand(pi); registerAnalyzeCommand(pi); registerProspectTool(pi); -} \ No newline at end of file +} + +// Re-export framework components for programmatic use +export { AnalyzerFramework, LLMProvider } from "./analyze/framework.js"; +export { turnPairCoreAnalyzer } from "./analyze/analyzers/turn-pair-core/index.js"; +export { turnPairLLMAnalyzer } from "./analyze/analyzers/turn-pair-llm/index.js"; +export { sessionOverviewAnalyzer } from "./analyze/analyzers/session-overview/index.js"; +export { callOllamaLLM } from "./analyze/ollama-llm.js"; \ No newline at end of file diff --git a/src/pi-stubs.ts b/src/pi-stubs.ts index 3e100ef..ff4e08c 100644 --- a/src/pi-stubs.ts +++ b/src/pi-stubs.ts @@ -3,26 +3,265 @@ * * The real package is a private peer dependency not available in CI. * These stubs let us compile without it. At runtime, Pi provides the real types. + * + * Types derived from pi-coding-agent v0.74+ ExtensionAPI. */ +import type { Static, TSchema } from "typebox"; + +// ── Theme stub (sufficient for our needs) ── + +export interface Theme { + fg(color: string, text: string): string; + bg(color: string, text: string): string; + bold(text: string): string; + dim(text: string): string; +} + +// ── UI Context ── + export interface ExtensionUIContext { - notify: (message: string, level?: string) => void; + /** Show a selector and return the user's choice. */ + select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise; + /** Show a confirmation dialog. */ + confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise; + /** Show a text input dialog. */ + input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise; + /** Show a notification to the user. */ + notify(message: string, type?: "info" | "warning" | "error"): void; + /** Set status text in the footer/status bar. Pass undefined to clear. */ + setStatus(key: string, text: string | undefined): void; + /** Set the working/loading message shown during streaming. */ + setWorkingMessage(message?: string): void; + /** Set a widget to display above or below the editor. */ + setWidget(key: string, content: string[] | undefined): void; + /** Get current tool output expansion state. */ + getToolsExpanded(): boolean; + /** Set tool output expansion state. */ + setToolsExpanded(expanded: boolean): void; + /** Get the current theme for styling. */ + readonly theme: Theme; +} + +export interface ExtensionUIDialogOptions { + /** timeout in ms */ + timeout?: number; +} + +// ── Model stubs ── + +export interface Model { + id: string; + name: string; + provider: string; + reasoning: boolean; + input: ("text" | "image")[]; + contextWindow: number; + maxTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +} + +export interface AuthStorage { + resolveApiKey(provider: string): Promise; +} + +export interface ModelRegistry { + /** Find a model by provider name and model ID pattern. */ + find(provider: string, pattern: string): Model | undefined; + /** Get API key for a provider. */ + getApiKey(provider: string): string | undefined; + /** Auth storage for runtime API key resolution. */ + authStorage: AuthStorage; + /** List all available models. */ + listModels(): Model[]; +} + +// ── Session Manager (read-only subset) ── + +export interface ReadonlySessionManager { + getEntries(): SessionEntry[]; + getBranch(): string; + getLeafId(): string; +} + +export interface SessionEntry { + id: string; + type: string; + content: unknown; + timestamp: number; +} + +// ── Context Types ── + +export interface ContextUsage { + tokens: number | null; + contextWindow: number; + percent: number | null; } -export interface ExtensionCommandContext { +export interface ExtensionContext { ui: ExtensionUIContext; + hasUI: boolean; + cwd: string; + sessionManager: ReadonlySessionManager; + modelRegistry: ModelRegistry; + model: Model | undefined; + isIdle(): boolean; + signal: AbortSignal | undefined; + abort(): void; + hasPendingMessages(): boolean; + shutdown(): void; + getContextUsage(): ContextUsage | undefined; + compact(options?: { customInstructions?: string }): void; + getSystemPrompt(): string; +} + +export interface ExtensionCommandContext extends ExtensionContext { + waitForIdle(): Promise; + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: unknown) => Promise; + }): Promise<{ cancelled: boolean }>; +} + +// ── Tool Definition ── + +export interface ToolResult { + content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mediaType: string }>; + details?: unknown; + isError?: boolean; + terminate?: boolean; } +// ── Command Registration ── + +export interface RegisteredCommand { + description: string; + handler: (args: string, ctx: ExtensionCommandContext) => Promise; +} + +// ── Event handler types ── + +export type ExtensionHandler = ( + event: TEvent, + ctx: ExtensionContext, +) => Promise | TResult | undefined | void; + +// ── Extension API ── + export interface ExtensionAPI { - registerCommand(name: string, options: { - description: string; - handler: (args: string, ctx: ExtensionCommandContext) => Promise; - }): void; - registerTool(tool: { + // Event subscriptions + on(event: "session_start", handler: ExtensionHandler): void; + on(event: "session_shutdown", handler: ExtensionHandler): void; + on(event: "tool_call", handler: ExtensionHandler): void; + on(event: "tool_result", handler: ExtensionHandler): void; + on(event: "before_agent_start", handler: ExtensionHandler): void; + on(event: "agent_start", handler: ExtensionHandler): void; + on(event: "agent_end", handler: ExtensionHandler): void; + on(event: "model_select", handler: ExtensionHandler): void; + on(event: string, handler: ExtensionHandler): void; + + // Tool registration + registerTool(tool: { name: string; label: string; description: string; - parameters: unknown; - execute: (toolCallId: string, params: Record, signal: AbortSignal, onUpdate: unknown, ctx: unknown) => Promise; + parameters: TParams; + execute: ( + toolCallId: string, + params: Static, + signal: AbortSignal, + onUpdate: unknown, + ctx: ExtensionContext, + ) => Promise; + }): void; + + // Command registration + registerCommand(name: string, options: RegisteredCommand): void; + + // Keyboard shortcuts + registerShortcut(shortcut: string, options: { + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + }): void; + + // CLI flags + registerFlag(name: string, options: { + description?: string; + type: "boolean" | "string"; + default?: boolean | string; }): void; + getFlag(name: string): boolean | string | undefined; + + // Messaging + sendUserMessage(content: string, options?: { + deliverAs?: "steer" | "followUp"; + }): void; + + // Session persistence + appendEntry(customType: string, data?: T): void; + setSessionName(name: string): void; + getSessionName(): string | undefined; + + // Shell execution + exec(command: string, args: string[], options?: { + signal?: AbortSignal; + timeout?: number; + cwd?: string; + }): Promise<{ exitCode: number | null; stdout: string; stderr: string }>; + + // Tool management + getActiveTools(): string[]; + getAllTools(): Array<{ name: string; description: string; parameters: unknown }>; + + // Model management + setModel(model: Model): Promise; + + // Provider registration + registerProvider(name: string, config: ProviderConfig): void; + unregisterProvider(name: string): void; +} + +// ── Provider Config ── + +export type Api = "openai-completions" | "anthropic-messages" | "openai-responses" | string; + +export interface ProviderModelConfig { + id: string; + name: string; + api?: Api; + baseUrl?: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + headers?: Record; +} + +export interface ProviderConfig { + name?: string; + baseUrl?: string; + apiKey?: string; + api?: Api; + streamSimple?: (model: Model, context: unknown, options?: unknown) => unknown; + headers?: Record; + authHeader?: boolean; + models?: ProviderModelConfig[]; + oauth?: { + name: string; + login(callbacks: unknown): Promise; + refreshToken(credentials: unknown): Promise; + getApiKey(credentials: unknown): string; + }; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 6108831..b09b626 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,136 +1,75 @@ /** * Type definitions for pi-prospector. - * All data shapes are plain TypeScript interfaces — no TypeBox schemas needed - * for internal types. TypeBox schemas are used only for the Pi tool registration - * where Pi's API expects them. + * All data shapes use TypeBox schemas, with TypeScript types derived via Static. */ +// Re-export all types from the analyzer framework +export type { + AnalyzerDef, AnalyzerVersion, PromptVersion, AnalyzerConfig, + AnalysisUnit, SourceRef, AnalysisResult, AnalysisEdge, + Analyzer, AnalyzerPlanContext, AnalyzerRunContext, + AnalysisNodeInsert, AnalysisEdgeInsert, AnalysisRunInsert, AnalysisProgressInsert, + AnalysisNodeRow, AnalysisRunRow, AnalysisProgressRow, + MessageRow, LLMRequest, LLMResponse, FrameworkRunResult, FrameworkRunAllResult, + ModelTierConfig, ModelTier, + TurnPairCoreProperties, TurnPairLLMProperties, + KeyFrictionPoint, ImprovementProposal, SentimentArcPoint, + SessionOverviewProperties, ProposalV2, +} from "./analyze/types.js"; + +export { + AnalyzerDefSchema, AnalyzerVersionSchema, PromptVersionSchema, AnalyzerConfigSchema, + SourceRefSchema, AnalysisUnitSchema, AnalysisResultSchema, AnalysisEdgeSchema, + NodeKindEnum, EdgeKindEnum, RefKindEnum, AnchorSpanEnum, + SessionOverviewPropertiesSchema, ImprovementProposalSchema, ProspectorConfigV2Schema, +} from "./analyze/types.js"; + // ─── Config ─── export interface ProspectorConfig { - model?: string; // provider/model format, e.g. "openrouter/deepseek-v4-flash" - dbPath?: string; // defaults to ~/.pi/agent/prospector.db + model?: string; + dbPath?: string; + modelTiers?: import("./analyze/types.js").ModelTierConfig; } // ─── Session ─── -export interface SessionHeader { - id: string; - version: number; - timestamp?: string; - cwd?: string; - parentSession?: string; -} +export interface SessionHeader { id: string; version: number; timestamp?: string; cwd?: string; parentSession?: string; } // ─── Messages ─── -export type MessageRole = - | "user" - | "assistant" - | "toolResult" - | "bashExecution" - | "custom" - | "branchSummary" - | "compactionSummary"; - -export interface ToolCallInfo { - name: string; - arguments: Record; -} +export type MessageRole = "user" | "assistant" | "toolResult" | "bashExecution" | "custom" | "branchSummary" | "compactionSummary"; -export interface ToolResultInfo { - toolCallId: string; - toolName: string; - isError: boolean; - textLength: number; -} +export interface ToolCallInfo { name: string; arguments: Record; } +export interface ToolResultInfo { toolCallId: string; toolName: string; isError: boolean; textLength: number; } export interface MessageEntry { - id: string; - parentId: string | null; - timestamp: string | null; - role: MessageRole; - contentText: string | null; - contentThinking: string | null; - toolCalls: ToolCallInfo[] | null; - toolResults: ToolResultInfo[] | null; + id: string; parentId: string | null; timestamp: string | null; role: MessageRole; + contentText: string | null; contentThinking: string | null; + toolCalls: ToolCallInfo[] | null; toolResults: ToolResultInfo[] | null; } -export interface ParsedLine { - type: "session" | "message"; - data: SessionHeader | MessageEntry; -} +export interface ParsedLine { type: "session" | "message"; data: SessionHeader | MessageEntry; } // ─── Sync ─── -export interface DiscoveredSession { - filePath: string; - project: string; - mtime: number; // milliseconds -} +export interface DiscoveredSession { filePath: string; project: string; mtime: number; } +export interface SyncCursor { session_id: string; last_line: number; last_modified: number; } +export interface ForkInfo { parentSessionId: string; parentFilePath: string; branchLine: number; } +export interface SyncResult { sessionsProcessed: number; sessionsSkipped: number; messagesInserted: number; forksResolved: number; errors: string[]; } -export interface SyncCursor { - session_id: string; - last_line: number; - last_modified: number; -} - -export interface ForkInfo { - parentSessionId: string; - parentFilePath: string; - branchLine: number; // line number where the fork diverges -} - -export interface SyncResult { - sessionsProcessed: number; - sessionsSkipped: number; - messagesInserted: number; - forksResolved: number; - errors: string[]; -} - -// ─── Proposals ─── +// ─── Proposals (v1 compatibility) ─── export type ProposalSeverity = "friction" | "correction" | "waste" | "suggestion"; -export type ProposalStatus = "new" | "accepted" | "rejected"; - -export interface NewProposal { - sessionId: string; - target: string; - severity: ProposalSeverity; - summary: string; - detail: string; - evidence: string; - dedupHash: string; -} +export type ProposalStatus = "open" | "applied" | "rejected" | "duplicate"; -export interface Proposal { - id: string; - created_at: string; - session_id: string; - target: string; - severity: ProposalSeverity; - summary: string; - detail: string; - evidence: string; - status: ProposalStatus; - dedup_hash: string; -} +export interface NewProposal { sessionId: string; target: string; severity: ProposalSeverity; summary: string; detail: string; evidence: string; dedupHash: string; } +export interface Proposal { id: string; created_at: string; session_id: string; target: string; severity: ProposalSeverity; summary: string; detail: string; evidence: string; status: ProposalStatus; dedup_hash: string; } // ─── Stats ─── -export interface Stats { - totalSessions: number; - totalMessages: number; - totalToolResults: number; - messagesProcessed: number; - proposalsByStatus: Record; -} +export interface Stats { totalSessions: number; totalMessages: number; totalToolResults: number; messagesProcessed: number; proposalsByStatus: Record; } // ─── Analyze ─── -export interface AnalyzeResult { - sessionsAnalyzed: number; - proposalsGenerated: number; - errors: string[]; -} \ No newline at end of file +export interface AnalyzeResult { sessionsAnalyzed: number; proposalsGenerated: number; errors: string[]; } \ No newline at end of file diff --git a/test/integration/test-commands.ts b/test/integration/test-commands.ts index e06b1ea..976a40b 100644 --- a/test/integration/test-commands.ts +++ b/test/integration/test-commands.ts @@ -7,9 +7,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; -// Import the actual modules import { migrate } from "../../src/db/schema.js"; -import { getStats, listProposals, insertProposal, acceptProposal, rejectProposal, computeDedupHash } from "../../src/db/queries.js"; +import { getStats, listProposalsV2, acceptProposalV2, rejectProposalV2 } from "../../src/db/queries.js"; import { runSync } from "../../src/sync/index.js"; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prospector-int-")); @@ -30,7 +29,7 @@ function assert(condition: boolean, label: string, detail?: string): void { } console.log("═══════════════════════════════════════════"); -console.log(" pi-prospector integration tests"); +console.log(" pi-prospector integration tests (v2)"); console.log("═══════════════════════════════════════════\n"); // --- Setup: create DB and sync fixtures --- @@ -45,78 +44,69 @@ console.log("Stats command:"); const stats = getStats(db); assert(stats.totalSessions >= 1, "totalSessions >= 1", `got ${stats.totalSessions}`); assert(stats.totalMessages >= 1, "totalMessages >= 1", `got ${stats.totalMessages}`); -assert(stats.proposalsByStatus.new === 0, "no proposals initially", `got ${stats.proposalsByStatus.new}`); +assert((stats.proposalsByStatus["open"] ?? 0) === 0, "no open proposals initially", `got ${stats.proposalsByStatus["open"] ?? 0}`); console.log(""); // --- Test: Proposals (empty) --- console.log("Proposals command (empty DB):"); -const emptyProposals = listProposals(db); +const emptyProposals = listProposalsV2(db); assert(emptyProposals.length === 0, "no proposals initially", `got ${emptyProposals.length}`); console.log(""); -// Get a real session ID from the synced data (FK constraint requires it) -const realSessionIds = db.prepare("SELECT id FROM sessions").all() as Array<{id: string}>; +// Get a real session ID (FK constraint requires it) +const realSessionIds = db.prepare("SELECT id FROM sessions").all() as Array<{ id: string }>; assert(realSessionIds.length >= 1, "have at least 1 synced session", `got ${realSessionIds.length}`); const realSessionId = realSessionIds[0]!.id; -// --- Test: Insert + list proposals --- -console.log("Proposals command (with data):"); -const id1 = insertProposal(db, { - id: crypto.randomUUID(), - created_at: new Date().toISOString(), - session_id: realSessionId, - severity: "suggestion", - target: "src/foo.ts", - summary: "Consider extracting helper function", - detail: "The function doStuff is too long.", - evidence: "Line 42-80 is a single function.", - status: "new", - dedup_hash: computeDedupHash("src/foo.ts", "suggestion", "Consider extracting helper function"), -}); -assert(id1 !== undefined && id1.length > 0, "insertProposal returns id", `got ${id1}`); - -const listed = listProposals(db); -assert(listed.length === 1, "listProposals returns 1", `got ${listed.length}`); -assert(listed[0]!.status === "new", "proposal status is 'new'", `got ${listed[0]!.status}`); -assert(listed[0]!.severity === "suggestion", "severity is 'suggestion'", `got ${listed[0]!.severity}`); +// --- Test: Insert + list proposals (v2) --- +console.log("Insert and list proposals:"); +db.prepare(` + INSERT INTO proposals (id, created_at, session_id, target, target_type, target_path, severity, summary, title, status, dedup_key, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + "p-test-001", new Date().toISOString(), realSessionId, "config", "config", + "src/foo.ts", "suggestion", "Consider extracting helper function", + "Consider extracting helper function", "open", "dh-001", new Date().toISOString(), +); + +const listed = listProposalsV2(db); +assert(listed.length === 1, "listProposalsV2 returns 1", `got ${listed.length}`); +assert(listed[0]!.status === "open", "proposal status is 'open'", `got ${listed[0]!.status}`); +assert(listed[0]!.target_type === "config", "target_type is 'config'", `got ${listed[0]!.target_type}`); console.log(""); // --- Test: Accept proposal --- console.log("Accept command:"); -const acceptOk = acceptProposal(db, id1); -assert(acceptOk === true, "acceptProposal succeeds"); -const accepted = listProposals(db, "accepted"); +const acceptOk = acceptProposalV2(db, "p-test-001"); +assert(acceptOk === true, "acceptProposalV2 succeeds"); +const accepted = listProposalsV2(db, "applied"); assert(accepted.length === 1, "1 accepted proposal", `got ${accepted.length}`); -const stillNew = listProposals(db, "new"); -assert(stillNew.length === 0, "0 new proposals after accept", `got ${stillNew.length}`); +const stillOpen = listProposalsV2(db, "open"); +assert(stillOpen.length === 0, "0 open proposals after accept", `got ${stillOpen.length}`); console.log(""); // --- Test: Reject proposal --- console.log("Reject command:"); -const id2 = insertProposal(db, { - id: crypto.randomUUID(), - created_at: new Date().toISOString(), - session_id: realSessionId, - severity: "friction", - target: "src/bar.ts", - summary: "Memory leak in event listener", - detail: "addEventListener not removed on cleanup.", - evidence: "Line 15 adds listener, no removeEventListener found.", - status: "new", - dedup_hash: computeDedupHash("src/bar.ts", "friction", "Memory leak in event listener"), -}); -const rejectOk = rejectProposal(db, id2); -assert(rejectOk === true, "rejectProposal succeeds"); -const rejected = listProposals(db, "rejected"); +db.prepare(` + INSERT INTO proposals (id, created_at, session_id, target, target_type, target_path, severity, summary, title, status, dedup_key, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`).run( + "p-test-002", new Date().toISOString(), realSessionId, "agents_md", + "agents_md", "~/.pi/agent/AGENTS.md", "friction", + "Memory leak in event listener", "Memory leak in event listener", "open", "dh-002", new Date().toISOString(), +); +const rejectOk = rejectProposalV2(db, "p-test-002"); +assert(rejectOk === true, "rejectProposalV2 succeeds"); +const rejected = listProposalsV2(db, "rejected"); assert(rejected.length === 1, "1 rejected proposal", `got ${rejected.length}`); console.log(""); // --- Test: Stats with proposals --- console.log("Stats after proposals:"); const stats2 = getStats(db); -assert(stats2.proposalsByStatus.accepted === 1, "1 accepted in stats", `got ${stats2.proposalsByStatus.accepted}`); +assert(stats2.proposalsByStatus.applied === 1, "1 accepted in stats", `got ${stats2.proposalsByStatus.applied}`); assert(stats2.proposalsByStatus.rejected === 1, "1 rejected in stats", `got ${stats2.proposalsByStatus.rejected}`); -assert(stats2.proposalsByStatus.new === 0, "0 new in stats", `got ${stats2.proposalsByStatus.new}`); +assert((stats2.proposalsByStatus.open ?? 0) === 0, "0 open in stats", `got ${stats2.proposalsByStatus.open ?? 0}`); console.log(""); // --- Test: Incremental re-sync --- @@ -135,4 +125,4 @@ console.log("══════════════════════ console.log(` Results: ${pass} passed, ${fail} failed (out of ${pass + fail})`); console.log("═══════════════════════════════════════════\n"); -process.exit(fail > 0 ? 1 : 0); \ No newline at end of file +process.exit(fail > 0 ? 1 : 0); diff --git a/tests/component/sync.test.ts b/tests/component/sync.test.ts index d5fee63..05cc49a 100644 --- a/tests/component/sync.test.ts +++ b/tests/component/sync.test.ts @@ -6,7 +6,7 @@ import * as os from "node:os"; import Database from "better-sqlite3"; import { migrate } from "../../src/db/schema.js"; import { runSync } from "../../src/sync/index.js"; -import { getStats, insertProposal, listProposals, acceptProposal, rejectProposal } from "../../src/db/queries.js"; +import { getStats, listProposalsV2, acceptProposalV2, rejectProposalV2 } from "../../src/db/queries.js"; const FIXTURES = path.resolve(import.meta.dirname, "..", "fixtures"); @@ -17,6 +17,28 @@ function tempDb(): { db: Database.Database; close: () => void } { return { db, close: () => { db.close(); try { fs.unlinkSync(dbPath); } catch {} } }; } +/** Insert a test proposal directly into the proposals table. */ +function insertTestProposal(db: Database.Database, p: { + id: string; + session_id: string; + target_type?: string; + target_path?: string; + severity?: string; + summary: string; + title?: string; + status?: string; +}): void { + db.prepare(` + INSERT INTO proposals (id, created_at, session_id, target, target_type, target_path, severity, summary, title, status, dedup_key, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + p.id, new Date().toISOString(), p.session_id, p.target_type ?? "config", + p.target_type ?? "config", p.target_path ?? null, p.severity ?? "suggestion", + p.summary, p.title ?? p.summary, p.status ?? "open", + `dh-${p.id}`, new Date().toISOString(), + ); +} + describe("end-to-end sync", () => { it("syncs simple.jsonl into database", () => { const { db, close } = tempDb(); @@ -63,7 +85,7 @@ describe("end-to-end sync", () => { }); }); -describe("proposals", () => { +describe("proposals v2", () => { it("inserts and retrieves a proposal", () => { const { db, close } = tempDb(); try { @@ -73,22 +95,20 @@ describe("proposals", () => { // Get a session ID from the DB const row = db.prepare("SELECT id FROM sessions LIMIT 1").get() as { id: string }; - insertProposal(db, { + insertTestProposal(db, { id: "p-test-001", - created_at: new Date().toISOString(), session_id: row.id, - target: "AGENTS.md § Tool usage", + target_type: "config", + target_path: "AGENTS.md § Tool usage", severity: "friction", summary: "Agent reads entire files instead of sections", - detail: "Details here", - evidence: "Evidence here", - status: "new", - dedup_hash: "test-hash-001", + title: "Optimize file reading", }); - const proposals = listProposals(db); + const proposals = listProposalsV2(db); assert.ok(proposals.length >= 1); - assert.equal(proposals[0]!.target, "AGENTS.md § Tool usage"); + assert.equal(proposals[0]!.target_type, "config"); + assert.equal(proposals[0]!.target_path, "AGENTS.md § Tool usage"); } finally { close(); } @@ -100,17 +120,17 @@ describe("proposals", () => { runSync(db, FIXTURES); const row = db.prepare("SELECT id FROM sessions LIMIT 1").get() as { id: string }; - insertProposal(db, { id: "p1", created_at: new Date().toISOString(), session_id: row.id, target: "t1", severity: "friction", summary: "s1", detail: "", evidence: "", status: "new", dedup_hash: "h1" }); - insertProposal(db, { id: "p2", created_at: new Date().toISOString(), session_id: row.id, target: "t2", severity: "correction", summary: "s2", detail: "", evidence: "", status: "new", dedup_hash: "h2" }); + insertTestProposal(db, { id: "p1", session_id: row.id, severity: "friction", summary: "s1", dedup_key: "dk1" }); + insertTestProposal(db, { id: "p2", session_id: row.id, severity: "correction", summary: "s2", dedup_key: "dk2" }); - assert.equal(acceptProposal(db, "p1"), true); - assert.equal(rejectProposal(db, "p2"), true); + assert.equal(acceptProposalV2(db, "p1"), true); + assert.equal(rejectProposalV2(db, "p2"), true); - const accepted = listProposals(db, "accepted"); + const accepted = listProposalsV2(db, "applied"); assert.equal(accepted.length, 1); assert.equal(accepted[0]!.id, "p1"); - const rejected = listProposals(db, "rejected"); + const rejected = listProposalsV2(db, "rejected"); assert.equal(rejected.length, 1); assert.equal(rejected[0]!.id, "p2"); } finally { @@ -124,12 +144,12 @@ describe("proposals", () => { runSync(db, FIXTURES); const row = db.prepare("SELECT id FROM sessions LIMIT 1").get() as { id: string }; - insertProposal(db, { id: "pa", created_at: new Date().toISOString(), session_id: row.id, target: "a", severity: "friction", summary: "a", detail: "", evidence: "", status: "new", dedup_hash: "ha" }); - insertProposal(db, { id: "pb", created_at: new Date().toISOString(), session_id: row.id, target: "b", severity: "waste", summary: "b", detail: "", evidence: "", status: "accepted", dedup_hash: "hb" }); + insertTestProposal(db, { id: "pa", session_id: row.id, severity: "friction", summary: "a" }); + insertTestProposal(db, { id: "pb", session_id: row.id, severity: "waste", summary: "b", status: "applied" }); const stats = getStats(db); - assert.equal(stats.proposalsByStatus.new, 1); - assert.equal(stats.proposalsByStatus.accepted, 1); + assert.equal(stats.proposalsByStatus.open, 1); // "new" maps to "open" in v2 + assert.equal(stats.proposalsByStatus.applied, 1); // "accepted" maps to "applied" in v2 } finally { close(); } diff --git a/tests/unit/analyzer-config.test.ts b/tests/unit/analyzer-config.test.ts new file mode 100644 index 0000000..8d99546 --- /dev/null +++ b/tests/unit/analyzer-config.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { computeFrictionScore, detectRetry, estimateWasteBytes, DEFAULT_CONFIG_PARAMS, createDefaultConfig } from "../../src/analyze/analyzers/turn-pair-core/config.js"; + +describe("computeFrictionScore", () => { + it("returns 0 for no friction signals", () => { + assert.equal(computeFrictionScore({ correctionDetected: false, correctionType: null, toolFailureCount: 0, toolFailureDetails: [], retryDetected: false, toolWasteBytes: 0, totalToolBytes: 100 }), 0); + }); + it("returns high score for explicit correction", () => { + const score = computeFrictionScore({ correctionDetected: true, correctionType: "explicit", toolFailureCount: 0, toolFailureDetails: [], retryDetected: false, toolWasteBytes: 0, totalToolBytes: 100 }); + assert.ok(score >= 0.4, `expected >= 0.4, got ${score}`); + }); + it("adds tool failure signal", () => { + const noFailures = computeFrictionScore({ correctionDetected: false, correctionType: null, toolFailureCount: 0, toolFailureDetails: [], retryDetected: false, toolWasteBytes: 0, totalToolBytes: 100 }); + const withFailures = computeFrictionScore({ correctionDetected: false, correctionType: null, toolFailureCount: 3, toolFailureDetails: [{ tool_name: "edit", error_preview: "file not found" }], retryDetected: false, toolWasteBytes: 0, totalToolBytes: 100 }); + assert.ok(withFailures > noFailures); + }); + it("caps score at 1.0", () => { + const score = computeFrictionScore({ correctionDetected: true, correctionType: "explicit", toolFailureCount: 5, toolFailureDetails: [], retryDetected: true, toolWasteBytes: 100, totalToolBytes: 100 }); + assert.ok(score <= 1.0, `expected <= 1.0, got ${score}`); + }); +}); + +describe("detectRetry", () => { + it("detects when same tool called multiple times", () => { assert.equal(detectRetry(["read", "read"]), true); }); + it("returns false for no retries", () => { assert.equal(detectRetry(["read", "edit", "bash"]), false); }); + it("returns false for empty array", () => { assert.equal(detectRetry([]), false); }); +}); + +describe("estimateWasteBytes", () => { + it("counts all non-error tool results as waste when no subsequent text", () => { + assert.equal(estimateWasteBytes([{ toolName: "read", textLength: 100, isError: false }], null), 100); + }); + it("counts error results as 0 waste", () => { + assert.equal(estimateWasteBytes([{ toolName: "bash", textLength: 50, isError: true }], "The command failed."), 0); + }); + it("does not count tool results referenced in text", () => { + assert.equal(estimateWasteBytes([{ toolName: "read", textLength: 100, isError: false }], "I can see from the read output that..."), 0); + }); +}); + +describe("createDefaultConfig", () => { + it("creates a valid config with correct analyzer ID", () => { + const config = createDefaultConfig(); + assert.equal(config.analyzerId, "turn-pair-core"); + assert.ok(config.configHash.length > 0); + }); +}); \ No newline at end of file diff --git a/tests/unit/input-hash.test.ts b/tests/unit/input-hash.test.ts new file mode 100644 index 0000000..8f7bbab --- /dev/null +++ b/tests/unit/input-hash.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { computeSourceSetHash, computeInputHash, computePromptBundleHash, computePromptHash, computeDedupKey } from "../../src/analyze/input-hash.js"; + +describe("computeSourceSetHash", () => { + it("produces deterministic hash for same sources", () => { + const sources = [{ kind: "message" as const, id: "msg-1" }, { kind: "message" as const, id: "msg-2" }]; + assert.equal(computeSourceSetHash(sources), computeSourceSetHash(sources)); + }); + it("order-independent: same sources in different order produce same hash", () => { + const s1 = [{ kind: "message" as const, id: "msg-1" }, { kind: "message" as const, id: "msg-2" }]; + const s2 = [{ kind: "message" as const, id: "msg-2" }, { kind: "message" as const, id: "msg-1" }]; + assert.equal(computeSourceSetHash(s1), computeSourceSetHash(s2)); + }); + it("different sources produce different hashes", () => { + const s1 = [{ kind: "message" as const, id: "msg-1" }]; + const s2 = [{ kind: "message" as const, id: "msg-2" }]; + assert.notEqual(computeSourceSetHash(s1), computeSourceSetHash(s2)); + }); +}); + +describe("computeInputHash", () => { + it("produces deterministic hash for same inputs", () => { + assert.equal(computeInputHash("a", "v1", "cfg", "pb", "ss"), computeInputHash("a", "v1", "cfg", "pb", "ss")); + }); + it("produces different hash for different analyzer", () => { + assert.notEqual(computeInputHash("a", "v1", "cfg", "pb", "ss"), computeInputHash("b", "v1", "cfg", "pb", "ss")); + }); +}); + +describe("computePromptBundleHash", () => { + it("order-independent", () => { + assert.equal(computePromptBundleHash(["abc", "def", "ghi"]), computePromptBundleHash(["ghi", "abc", "def"])); + }); +}); + +describe("computePromptHash", () => { + it("returns first 16 chars of SHA-256", () => { + assert.equal(computePromptHash("hello world").length, 16); + }); + it("deterministic", () => { + assert.equal(computePromptHash("test"), computePromptHash("test")); + }); +}); + +describe("computeDedupKey", () => { + it("normalizes title case and whitespace", () => { + assert.equal(computeDedupKey("agents_md", "", "friction", "Agent reads too much"), computeDedupKey("agents_md", "", "friction", " Agent reads too much ")); + }); + it("different target types produce different keys", () => { + assert.notEqual(computeDedupKey("agents_md", "", "friction", "Same title"), computeDedupKey("config", "", "friction", "Same title")); + }); +}); \ No newline at end of file diff --git a/tests/unit/model-tiers.test.ts b/tests/unit/model-tiers.test.ts new file mode 100644 index 0000000..386e5da --- /dev/null +++ b/tests/unit/model-tiers.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveModelTier, validateModelTierConfig, DEFAULT_MODEL_TIERS } from "../../src/analyze/model-tiers.js"; +import type { ModelTierConfig } from "../../src/analyze/types.js"; + +describe("resolveModelTier", () => { + it("returns cheap model for cheap tier", () => { assert.equal(resolveModelTier("cheap"), DEFAULT_MODEL_TIERS.cheap); }); + it("returns mid model for mid tier", () => { assert.equal(resolveModelTier("mid"), DEFAULT_MODEL_TIERS.mid); }); + it("returns expensive model for expensive tier", () => { assert.equal(resolveModelTier("expensive"), DEFAULT_MODEL_TIERS.expensive); }); + it("falls back to mid for expensive when not configured", () => { + const config: ModelTierConfig = { cheap: "cheap-model", mid: "mid-model", expensive: undefined as unknown as string }; + assert.equal(resolveModelTier("expensive", config), "mid-model"); + }); + it("uses custom config when provided", () => { + const config: ModelTierConfig = { cheap: "my-cheap", mid: "my-mid", expensive: "my-expensive" }; + assert.equal(resolveModelTier("cheap", config), "my-cheap"); + assert.equal(resolveModelTier("mid", config), "my-mid"); + assert.equal(resolveModelTier("expensive", config), "my-expensive"); + }); +}); + +describe("validateModelTierConfig", () => { + it("returns no errors for valid config", () => { assert.equal(validateModelTierConfig({ cheap: "model-a", mid: "model-b", expensive: "model-c" }).length, 0); }); + it("returns error when no tiers configured", () => { assert.equal(validateModelTierConfig({}).length, 1); }); +}); + +describe("DEFAULT_MODEL_TIERS", () => { + it("has all three tiers", () => { assert.ok(DEFAULT_MODEL_TIERS.cheap); assert.ok(DEFAULT_MODEL_TIERS.mid); assert.ok(DEFAULT_MODEL_TIERS.expensive); }); +}); \ No newline at end of file diff --git a/tests/unit/patterns.test.ts b/tests/unit/patterns.test.ts new file mode 100644 index 0000000..bf85f9b --- /dev/null +++ b/tests/unit/patterns.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { classifyCorrection, STRONG_PATTERNS, WEAK_PATTERNS, NEGATION_PATTERNS } from "../../src/analyze/analyzers/turn-pair-core/patterns.js"; + +describe("classifyCorrection", () => { + it("detects explicit corrections with strong patterns", () => { + const result = classifyCorrection("No, don't use npm, use pnpm instead", false); + assert.equal(result.detected, true); + assert.equal(result.type, "explicit"); + }); + it("detects 'wrong' as explicit correction", () => { + const result = classifyCorrection("That's wrong, the function should return void", false); + assert.equal(result.detected, true); + assert.equal(result.type, "explicit"); + }); + it("detects 'actually' as correction", () => { + const result = classifyCorrection("Actually, I wanted to use TypeScript", false); + assert.equal(result.detected, true); + }); + it("detects retry as repetition", () => { + const result = classifyCorrection("Try again with different args", true); + assert.equal(result.detected, true); + assert.equal(result.type, "repetition"); + }); + it("returns no correction for neutral text", () => { + const result = classifyCorrection("Please read the file", false); + assert.equal(result.detected, false); + }); + it("detects 'I said' as explicit correction", () => { + const result = classifyCorrection("I said use pnpm, not npm", false); + assert.equal(result.detected, true); + assert.equal(result.type, "explicit"); + }); +}); + +describe("STRONG_PATTERNS", () => { it("is a non-empty array of regex", () => { assert.ok(Array.isArray(STRONG_PATTERNS) && STRONG_PATTERNS.length > 0); }); }); +describe("WEAK_PATTERNS", () => { it("is a non-empty array of regex", () => { assert.ok(Array.isArray(WEAK_PATTERNS) && WEAK_PATTERNS.length > 0); }); }); +describe("NEGATION_PATTERNS", () => { it("is a non-empty array of regex", () => { assert.ok(Array.isArray(NEGATION_PATTERNS) && NEGATION_PATTERNS.length > 0); }); }); \ No newline at end of file diff --git a/tests/unit/proposal-materializer.test.ts b/tests/unit/proposal-materializer.test.ts new file mode 100644 index 0000000..63faf31 --- /dev/null +++ b/tests/unit/proposal-materializer.test.ts @@ -0,0 +1,97 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { migrate } from "../../src/db/schema.js"; +import { materializeProposals } from "../../src/analyze/proposal-materializer.js"; +import type { AnalysisNodeRow } from "../../src/analyze/types.js"; + +function tempDb(): { db: Database.Database; close: () => void } { + const dbPath = path.join(os.tmpdir(), `prospect-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const db = new Database(dbPath); + migrate(db); + return { db, close: () => { db.close(); try { fs.unlinkSync(dbPath); } catch { /* ignore */ } } }; +} + +function setupPrerequisites(db: Database.Database): void { + db.prepare("INSERT INTO sessions (id, file_path, project, cwd, started_at, last_line, last_modified, message_count, branch_count) VALUES (?, '', '', '', '', 0, 0, 0, 0)").run("test-session"); + db.prepare("INSERT INTO analyzer_defs (id, label, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?)").run("session-overview", "Session Overview", "full_session", "[]", new Date().toISOString()); + db.prepare("INSERT INTO analyzer_versions (analyzer_id, version_id, implementation_kind, created_at) VALUES (?, ?, ?, ?)").run("session-overview", "v1-overview-001", "in_process_llm", new Date().toISOString()); + db.prepare("INSERT INTO analyzer_configs (id, analyzer_id, config_hash, config_json, label, created_at) VALUES (?, ?, ?, ?, ?, ?)").run("cfg-default", "session-overview", "hash-default", '{}', "default", new Date().toISOString()); + db.prepare("INSERT INTO analysis_runs (id, analyzer_id, analyzer_version_id, config_id, session_id, status, prompt_bundle_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run( + "run-test-001", "session-overview", "v1-overview-001", "cfg-default", "test-session", "ok", "pb-hash-001", new Date().toISOString(), + ); +} + +describe("materializeProposals", () => { + it("inserts proposals from a session-overview node", () => { + const { db, close } = tempDb(); + try { + setupPrerequisites(db); + const node: AnalysisNodeRow = { + id: "node-test-001", session_id: "test-session", analyzer_id: "session-overview", + analyzer_version_id: "v1-overview-001", config_id: "cfg-default", run_id: "run-test-001", + node_kind: "summary", + content_json: JSON.stringify({ improvement_proposals: [{ target_type: "agents_md", target_path: "~/.pi/agent/AGENTS.md", title: "Add tool selection guidance", summary: "Agent chose wrong tool repeatedly", detail: "The agent should prefer pnpm over npm", evidence: "User said 'use pnpm not npm'", confidence: 0.8, severity: "correction" }] }), + source_set_hash: "ss-hash-001", input_hash: "ih-hash-001", created_at: new Date().toISOString(), + model_used: "test-model", cost_usd: 0.01, tokens_used: 100, duration_ms: 500, + }; + db.prepare("INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + node.id, node.session_id, node.analyzer_id, node.analyzer_version_id, node.config_id, node.run_id, node.node_kind, node.content_json, node.source_set_hash, node.input_hash, node.created_at, + ); + const proposals = materializeProposals(db, node); + assert.equal(proposals.length, 1, "should extract 1 proposal"); + assert.equal(proposals[0]!.targetType, "agents_md"); + assert.equal(proposals[0]!.title, "Add tool selection guidance"); + const row = db.prepare("SELECT * FROM proposals WHERE id = ?").get(proposals[0]!.id) as Record; + assert.ok(row, "proposal should be in database"); + assert.equal(row.status, "open"); + } finally { close(); } + }); + + it("deduplicates proposals with the same dedup key", () => { + const { db, close } = tempDb(); + try { + setupPrerequisites(db); + const node1: AnalysisNodeRow = { ...makeNode(), id: "node-001" }; + db.prepare("INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + node1.id, node1.session_id, node1.analyzer_id, node1.analyzer_version_id, node1.config_id, node1.run_id, node1.node_kind, node1.content_json, node1.source_set_hash, node1.input_hash, node1.created_at, + ); + materializeProposals(db, node1); + + const node2: AnalysisNodeRow = { ...makeNode(), id: "node-002", input_hash: "ih-hash-002" }; + db.prepare("INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + node2.id, node2.session_id, node2.analyzer_id, node2.analyzer_version_id, node2.config_id, node2.run_id, node2.node_kind, node2.content_json, node2.source_set_hash, node2.input_hash, node2.created_at, + ); + const proposals2 = materializeProposals(db, node2); + assert.equal(proposals2.length, 0, "should skip duplicated proposal"); + } finally { close(); } + }); + + it("handles node without improvement_proposals", () => { + const { db, close } = tempDb(); + try { + setupPrerequisites(db); + const node: AnalysisNodeRow = { ...makeNode(), content_json: JSON.stringify({ total_pairs: 5, session_summary: "OK session" }) }; + db.prepare("INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + node.id, node.session_id, node.analyzer_id, node.analyzer_version_id, node.config_id, node.run_id, node.node_kind, node.content_json, node.source_set_hash, node.input_hash, node.created_at, + ); + const proposals = materializeProposals(db, node); + assert.equal(proposals.length, 0, "should return empty for non-proposal node"); + } finally { close(); } + }); +}); + +function makeNode(overrides: Partial = {}): AnalysisNodeRow { + return { + id: "node-test-001", session_id: "test-session", analyzer_id: "session-overview", + analyzer_version_id: "v1-overview-001", config_id: "cfg-default", run_id: "run-test-001", + node_kind: "summary", + content_json: JSON.stringify({ improvement_proposals: [{ target_type: "agents_md", title: "Add tool guidance", summary: "Agent chose wrong tool", severity: "correction", confidence: 0.8 }] }), + source_set_hash: "ss-hash-001", input_hash: "ih-hash-001", created_at: new Date().toISOString(), + model_used: "test-model", cost_usd: 0.01, tokens_used: 100, duration_ms: 500, + ...overrides, + }; +} \ No newline at end of file diff --git a/tests/unit/schema-migration-002.test.ts b/tests/unit/schema-migration-002.test.ts new file mode 100644 index 0000000..9b8b95b --- /dev/null +++ b/tests/unit/schema-migration-002.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { migrate } from "../../src/db/schema.js"; + +function tempDb(): { db: Database.Database; close: () => void } { + const dbPath = path.join(os.tmpdir(), `prospect-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const db = new Database(dbPath); + migrate(db); + return { db, close: () => { db.close(); try { fs.unlinkSync(dbPath); } catch { /* ignore */ } } }; +} + +describe("migration 002: analyzer framework tables", () => { + it("creates analyzer_defs table", () => { + const { db, close } = tempDb(); + try { + db.prepare("INSERT INTO analyzer_defs (id, label, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?)").run( + "test-analyzer", "Test Analyzer", "pair", "[]", new Date().toISOString(), + ); + const row = db.prepare("SELECT * FROM analyzer_defs WHERE id = ?").get("test-analyzer") as Record; + assert.ok(row); + assert.equal(row.id, "test-analyzer"); + assert.equal(row.label, "Test Analyzer"); + assert.equal(row.anchor_span, "pair"); + } finally { + close(); + } + }); + + it("creates analyzer_versions table", () => { + const { db, close } = tempDb(); + try { + db.prepare("INSERT INTO analyzer_defs (id, label, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?)").run( + "test-analyzer", "Test Analyzer", "pair", "[]", new Date().toISOString(), + ); + db.prepare("INSERT INTO analyzer_versions (analyzer_id, version_id, implementation_kind, created_at) VALUES (?, ?, ?, ?)").run( + "test-analyzer", "v1-001", "deterministic", new Date().toISOString(), + ); + const row = db.prepare("SELECT * FROM analyzer_versions WHERE analyzer_id = ? AND version_id = ?").get("test-analyzer", "v1-001") as Record; + assert.ok(row); + assert.equal(row.implementation_kind, "deterministic"); + } finally { + close(); + } + }); + + it("creates prompt_registry table", () => { + const { db, close } = tempDb(); + try { + db.prepare("INSERT INTO prompt_registry (hash, content, role, created_at) VALUES (?, ?, ?, ?)").run( + "abc123", "Test prompt content", "classify", new Date().toISOString(), + ); + const row = db.prepare("SELECT * FROM prompt_registry WHERE hash = ?").get("abc123") as Record; + assert.ok(row); + assert.equal(row.content, "Test prompt content"); + } finally { + close(); + } + }); + + it("creates analyzer_configs table", () => { + const { db, close } = tempDb(); + try { + db.prepare("INSERT INTO analyzer_defs (id, label, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?)").run( + "test-analyzer", "Test Analyzer", "pair", "[]", new Date().toISOString(), + ); + db.prepare("INSERT INTO analyzer_configs (id, analyzer_id, config_hash, config_json, label, created_at) VALUES (?, ?, ?, ?, ?, ?)").run( + "cfg-001", "test-analyzer", "hash-001", '{"key": "value"}', "default", new Date().toISOString(), + ); + const row = db.prepare("SELECT * FROM analyzer_configs WHERE id = ?").get("cfg-001") as Record; + assert.ok(row); + assert.equal(row.label, "default"); + } finally { + close(); + } + }); + + it("creates analysis_runs, nodes, edges, progress tables", () => { + const { db, close } = tempDb(); + try { + // Insert prerequisite data + db.prepare("INSERT INTO sessions (id, file_path, project, cwd, started_at, last_line, last_modified, message_count, branch_count) VALUES (?, '', '', '', '', 0, 0, 0, 0)").run("test-session"); + db.prepare("INSERT INTO analyzer_defs (id, label, anchor_span, dependencies, created_at) VALUES (?, ?, ?, ?, ?)").run("test-analyzer", "Test", "pair", "[]", new Date().toISOString()); + db.prepare("INSERT INTO analyzer_versions (analyzer_id, version_id, implementation_kind, created_at) VALUES (?, ?, ?, ?)").run("test-analyzer", "v1", "deterministic", new Date().toISOString()); + db.prepare("INSERT INTO analyzer_configs (id, analyzer_id, config_hash, config_json, label, created_at) VALUES (?, ?, ?, ?, ?, ?)").run("cfg-001", "test-analyzer", "hash-001", '{}', "default", new Date().toISOString()); + + // Insert run + db.prepare("INSERT INTO analysis_runs (id, analyzer_id, analyzer_version_id, config_id, session_id, status, prompt_bundle_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run( + "run-001", "test-analyzer", "v1", "cfg-001", "test-session", "ok", "pb-hash", new Date().toISOString(), + ); + const runRow = db.prepare("SELECT * FROM analysis_runs WHERE id = ?").get("run-001") as Record; + assert.ok(runRow); + assert.equal(runRow.status, "ok"); + + // Insert node + db.prepare("INSERT INTO analysis_nodes (id, session_id, analyzer_id, analyzer_version_id, config_id, run_id, node_kind, content_json, source_set_hash, input_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run( + "node-001", "test-session", "test-analyzer", "v1", "cfg-001", "run-001", "metric", '{}', "ss-hash", "ih-hash", new Date().toISOString(), + ); + const nodeRow = db.prepare("SELECT * FROM analysis_nodes WHERE id = ?").get("node-001") as Record; + assert.ok(nodeRow); + assert.equal(nodeRow.node_kind, "metric"); + + // Insert edge + db.prepare("INSERT INTO analysis_edges (from_node_id, to_ref_kind, to_ref_id, edge_kind, ordinal) VALUES (?, ?, ?, ?, ?)").run( + "node-001", "analysis_node", "node-000", "consumes", 0, + ); + const edgeRow = db.prepare("SELECT * FROM analysis_edges WHERE from_node_id = ?").get("node-001") as Record; + assert.ok(edgeRow); + + // Insert progress + db.prepare("INSERT INTO analysis_progress (analyzer_id, analyzer_version_id, config_id, session_id, status, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run( + "test-analyzer", "v1", "cfg-001", "test-session", "ok", new Date().toISOString(), + ); + const progressRow = db.prepare("SELECT * FROM analysis_progress WHERE analyzer_id = ? AND session_id = ?").get("test-analyzer", "test-session") as Record; + assert.ok(progressRow); + assert.equal(progressRow.status, "ok"); + } finally { + close(); + } + }); + + it("proposals table has new v2 columns", () => { + const { db, close } = tempDb(); + try { + // Check that new columns exist + const columns = db.pragma("table_info(proposals)") as Array<{ name: string }>; + const columnNames = columns.map(c => c.name); + assert.ok(columnNames.includes("source_node_id"), "should have source_node_id column"); + assert.ok(columnNames.includes("analyzer_id"), "should have analyzer_id column"); + assert.ok(columnNames.includes("target_type"), "should have target_type column"); + assert.ok(columnNames.includes("target_path"), "should have target_path column"); + assert.ok(columnNames.includes("title"), "should have title column"); + assert.ok(columnNames.includes("confidence"), "should have confidence column"); + assert.ok(columnNames.includes("updated_at"), "should have updated_at column"); + } finally { + close(); + } + }); +}); \ No newline at end of file