diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 371fb92..a4204a5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -39,6 +39,101 @@ program .option('--auto', 'Automatically apply recommended changes') .action(optimizeCommand); +program + .command('observations') + .description('View or manage the persistent observation log') + .argument('[path]', 'Path to the repository', '.') + .option('--purge', 'Drop all observations and start fresh') + .action(async (repoPath: string, options: { purge?: boolean }) => { + const path = await import('path'); + const resolved = path.resolve(repoPath); + + // Dynamically import the observation log from the UI package server + // module. This works because the CLI and UI share a workspace and + // the module has no Svelte/browser deps. + const { openObservationLog } = await import('../../ui/src/lib/server/observation-log.js'); + const log = openObservationLog(resolved); + + if (options.purge) { + log.purge(); + console.log('Observations purged.'); + return; + } + + const s = log.stats(); + console.log(`Observation log: ${log.filePath}`); + console.log(`Total events: ${s.totalEvents}`); + console.log(`File size: ${(s.fileSizeBytes / 1024).toFixed(1)} KB`); + + if (Object.keys(s.byTool).length > 0) { + console.log('\nBy tool:'); + for (const [tool, count] of Object.entries(s.byTool).sort((a, b) => b[1] - a[1])) { + console.log(` ${tool}: ${count}`); + } + } + + if (Object.keys(s.bySession).length > 0) { + console.log(`\nSessions: ${Object.keys(s.bySession).length}`); + } + + if (Object.keys(s.byTopFolder).length > 0) { + console.log('\nBy top folder:'); + for (const [folder, count] of Object.entries(s.byTopFolder).sort((a, b) => b[1] - a[1]).slice(0, 10)) { + console.log(` ${folder}: ${count}`); + } + } + }); + +program + .command('learn') + .description('Compute learning scores from observation log') + .argument('[path]', 'Path to the repository', '.') + .option('--json', 'Output raw JSON instead of formatted text') + .action(async (repoPath: string, options: { json?: boolean }) => { + const path = await import('path'); + const resolved = path.resolve(repoPath); + + const { openObservationLog } = await import('../../ui/src/lib/server/observation-log.js'); + const { computeLearningState, saveLearningState } = await import('../../ui/src/lib/server/learning.js'); + + const log = openObservationLog(resolved); + const observations = log.readAll(); + + if (observations.length === 0) { + console.log('No observations recorded yet. Use Klonode with the live watcher to accumulate data.'); + return; + } + + // Compute learning state from observations (emotion events from JSONL + // parsing is a separate, heavier step — for now we compute repetition only). + const state = computeLearningState(observations); + saveLearningState(resolved, state); + + if (options.json) { + console.log(JSON.stringify(state, null, 2)); + return; + } + + console.log(`Learning state computed from ${state.observationCount} observations across ${state.sessionCount} session(s).\n`); + + // Sort by confidence descending. + const sorted = Object.values(state.nodes).sort((a, b) => b.confidence - a.confidence); + + if (sorted.length === 0) { + console.log('No folder-level data yet.'); + return; + } + + console.log('Top folders by confidence:\n'); + for (const node of sorted.slice(0, 15)) { + const bar = '█'.repeat(Math.round(node.confidence * 20)); + const urgencyStr = node.urgency > 0 ? ` urgency: ${node.urgency.toFixed(1)}` : ''; + console.log(` ${node.path.padEnd(45)} ${bar} ${(node.confidence * 100).toFixed(0)}% (${node.signals.readCount}R ${node.signals.writeCount}W ${node.signals.sessionsCount}S)${urgencyStr}`); + } + + console.log(`\nSaved to .klonode/learning.json`); + }); + program .command('update') .description('Regenerate routing for changed directories') diff --git a/packages/core/src/agents/agent-registry.ts b/packages/core/src/agents/agent-registry.ts deleted file mode 100644 index 92e6024..0000000 --- a/packages/core/src/agents/agent-registry.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Agent Registry — manages the team of CLI agents for a Klonode project. - * - * Each agent is a Claude Code instance with specific context paths and tool permissions. - * Agents are auto-generated from the project's Layer 1 directory structure, - * with the Chief Organizer (CO) always present as the oversight agent. - */ - -import type { RoutingGraph, RoutingNode } from '../model/routing-graph.js'; -import type { DetectedTool } from '../analyzer/tool-detector.js'; - -export type AgentRole = 'co' | 'worker'; - -export type ContextDepth = 'minimal' | 'light' | 'standard' | 'heavy' | 'full'; - -export interface AgentDefinition { - /** Unique identifier */ - id: string; - /** Display name (e.g., "Frontend", "Backend", "CO") */ - name: string; - /** Agent role */ - role: AgentRole; - /** Description of what this agent specializes in */ - description: string; - /** CONTEXT.md paths this agent has access to */ - contextPaths: string[]; - /** Tool permissions for this agent */ - toolPermissions: string[]; - /** Max turns per interaction */ - maxTurns: number; - /** Default context depth */ - defaultContextDepth: ContextDepth; - /** Icon/emoji for UI */ - icon: string; - /** Color for UI (hex) */ - color: string; - /** Relevant detected tools */ - tools: string[]; -} - -export interface AgentRegistry { - /** Project root path */ - repoPath: string; - /** All registered agents */ - agents: AgentDefinition[]; - /** CO agent (always first) */ - co: AgentDefinition; -} - -const AGENT_COLORS = [ - '#a78bfa', // violet - '#22d3ee', // cyan - '#10b981', // emerald - '#f59e0b', // amber - '#f87171', // red - '#818cf8', // indigo - '#34d399', // green - '#fb923c', // orange -]; - -const AGENT_ICONS: Record = { - frontend: '🎨', - backend: '⚙', - api: '🔌', - database: '🗄', - testing: '🧪', - devops: '🚀', - docs: '📝', - auth: '🔐', - game: '🎮', - ui: '🖥', - lib: '📦', - scripts: '⚡', - public: '🌐', - types: '📋', - config: '⚙', -}; - -/** - * Create the Chief Organizer agent definition. - */ -function createCO(repoPath: string): AgentDefinition { - return { - id: 'co', - name: 'CO', - role: 'co', - description: 'Chief Organizer — oversees the project, improves context and tools, analyzes interaction logs', - contextPaths: ['*'], // CO sees everything - toolPermissions: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], - maxTurns: 25, - defaultContextDepth: 'heavy', - icon: '🧠', - color: '#a78bfa', - tools: [], - }; -} - -/** - * Auto-generate worker agents from a project's Layer 1 directories. - * Each significant top-level directory becomes a potential agent specialization. - */ -export function buildAgentRegistry( - graph: RoutingGraph, - detectedTools: DetectedTool[] = [], -): AgentRegistry { - const co = createCO(graph.repoPath); - const agents: AgentDefinition[] = [co]; - - const root = graph.nodes.get(graph.rootNodeId); - if (!root) return { repoPath: graph.repoPath, agents, co }; - - // Map detected tools to directory paths - const toolsByDir = new Map(); - for (const tool of detectedTools) { - const dirPart = tool.configPath.split('/')[0]; - const existing = toolsByDir.get(dirPart) || []; - existing.push(tool.id); - toolsByDir.set(dirPart, existing); - } - - let colorIdx = 1; // 0 is CO's violet - - for (const childId of root.children) { - const node = graph.nodes.get(childId); - if (!node || node.type !== 'directory') continue; - - // Skip unimportant directories - if (node.children.length === 0) continue; - - const nameLower = node.name.toLowerCase(); - const icon = AGENT_ICONS[nameLower] || '📁'; - const color = AGENT_COLORS[colorIdx % AGENT_COLORS.length]; - colorIdx++; - - // Collect context paths: this dir + its children - const contextPaths = [node.path]; - for (const grandchildId of node.children) { - const gc = graph.nodes.get(grandchildId); - if (gc) contextPaths.push(gc.path); - } - - // Determine tools for this agent - const agentTools = toolsByDir.get(node.name) || []; - - // Determine appropriate permissions based on directory type - const isConfig = ['config', 'types', '.github', 'docs'].includes(nameLower); - const toolPerms = isConfig - ? ['Read', 'Glob', 'Grep'] - : ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; - - agents.push({ - id: `agent-${node.name}`, - name: node.name.charAt(0).toUpperCase() + node.name.slice(1), - role: 'worker', - description: node.summary || `Specialist for ${node.path}`, - contextPaths, - toolPermissions: toolPerms, - maxTurns: 15, - defaultContextDepth: 'standard', - icon, - color, - tools: agentTools, - }); - } - - // Update CO with all detected tools - co.tools = detectedTools.map(t => t.id); - - return { repoPath: graph.repoPath, agents, co }; -} - -/** - * Get context for a specific agent based on context depth. - */ -export function getAgentContext( - agent: AgentDefinition, - graph: RoutingGraph, - depth: ContextDepth, -): { context: string; files: string[]; folderPaths: string[] } { - const parts: string[] = []; - const files: string[] = []; - const folderPaths: string[] = []; - - const root = graph.nodes.get(graph.rootNodeId); - - // Minimal: just CLAUDE.md - if (root?.contextFile?.rawMarkdown) { - parts.push(`## ${root.path}/CONTEXT.md\n${root.contextFile.rawMarkdown}`); - files.push(root.path + '/CONTEXT.md'); - } - if (depth === 'minimal') return { context: parts.join('\n\n---\n\n'), files, folderPaths }; - - // Light: + L1 nodes - if (root) { - for (const childId of root.children) { - const node = graph.nodes.get(childId); - if (!node) continue; - - // CO sees all L1, workers only see their paths - const isRelevant = agent.role === 'co' || agent.contextPaths.includes('*') || - agent.contextPaths.some(p => node.path.startsWith(p) || p.startsWith(node.path)); - - if (isRelevant && node.contextFile?.rawMarkdown) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - folderPaths.push(node.path); - } - } - } - if (depth === 'light') return { context: parts.join('\n\n---\n\n'), files, folderPaths }; - - // Standard: + relevant L2 children - for (const [, node] of graph.nodes) { - if (node.layer !== 2) continue; - - const isRelevant = agent.role === 'co' || agent.contextPaths.includes('*') || - agent.contextPaths.some(p => node.path.startsWith(p)); - - if (isRelevant && node.contextFile?.rawMarkdown) { - if (!files.includes(node.path + '/CONTEXT.md')) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - folderPaths.push(node.path); - } - } - } - if (depth === 'standard') return { context: parts.join('\n\n---\n\n'), files, folderPaths }; - - // Heavy: + L3 references + dependency chain - for (const [, node] of graph.nodes) { - if (node.layer !== 3) continue; - - const isRelevant = agent.role === 'co' || agent.contextPaths.includes('*') || - agent.contextPaths.some(p => node.path.startsWith(p)); - - if (isRelevant && node.contextFile?.rawMarkdown) { - if (!files.includes(node.path + '/CONTEXT.md')) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - folderPaths.push(node.path); - } - } - } - - // Follow dependency edges - for (const edge of graph.edges) { - if (edge.type !== 'depends_on') continue; - const depNode = graph.nodes.get(edge.to); - if (depNode?.contextFile?.rawMarkdown && !files.includes(depNode.path + '/CONTEXT.md')) { - parts.push(`## ${depNode.path}/CONTEXT.md\n${depNode.contextFile.rawMarkdown}`); - files.push(depNode.path + '/CONTEXT.md'); - folderPaths.push(depNode.path); - } - } - if (depth === 'heavy') return { context: parts.join('\n\n---\n\n'), files, folderPaths }; - - // Full: everything - for (const [, node] of graph.nodes) { - if (node.contextFile?.rawMarkdown && !files.includes(node.path + '/CONTEXT.md')) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - folderPaths.push(node.path); - } - } - - return { context: parts.join('\n\n---\n\n'), files, folderPaths }; -} diff --git a/packages/core/src/agents/chief-organizer.ts b/packages/core/src/agents/chief-organizer.ts deleted file mode 100644 index 580712b..0000000 --- a/packages/core/src/agents/chief-organizer.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Chief Organizer (CO) — the oversight agent that improves the project. - * - * CO responsibilities: - * 1. Analyze interaction logs to find pain points - * 2. Suggest context improvements (better CONTEXT.md, new routing) - * 3. Detect when new tools are added to the project - * 4. Remember project decisions across sessions - * 5. Generate improvement suggestions for the user - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import type { InteractionAnalysis, InteractionMessage } from './message-bus.js'; -import type { DetectedTool } from '../analyzer/tool-detector.js'; - -export interface CODecision { - timestamp: string; - type: 'improvement' | 'observation' | 'tool-detected' | 'routing-change'; - description: string; - actionTaken?: string; -} - -export interface COState { - /** Known detected tools (last scan) */ - knownTools: string[]; - /** Decisions and observations log */ - decisions: CODecision[]; - /** Last analysis timestamp */ - lastAnalysis: string | null; - /** Interaction count since last analysis */ - interactionsSinceAnalysis: number; - /** Analysis interval (number of interactions before auto-analysis) */ - analysisInterval: number; -} - -export interface ImprovementSuggestion { - type: 'add-context' | 'update-context' | 'add-tool' | 'restructure' | 'agent-config'; - priority: 'high' | 'medium' | 'low'; - title: string; - description: string; - affectedPaths: string[]; -} - -const DEFAULT_STATE: COState = { - knownTools: [], - decisions: [], - lastAnalysis: null, - interactionsSinceAnalysis: 0, - analysisInterval: 10, -}; - -/** - * Load or initialize CO state for a project. - */ -export function loadCOState(repoPath: string): COState { - const coDir = join(repoPath, '.klonode', 'co'); - const statePath = join(coDir, 'state.json'); - - if (existsSync(statePath)) { - try { - return { ...DEFAULT_STATE, ...JSON.parse(readFileSync(statePath, 'utf-8')) }; - } catch { /* corrupt file */ } - } - - return { ...DEFAULT_STATE }; -} - -/** - * Save CO state. - */ -export function saveCOState(repoPath: string, state: COState): void { - const coDir = join(repoPath, '.klonode', 'co'); - if (!existsSync(coDir)) mkdirSync(coDir, { recursive: true }); - - writeFileSync(join(coDir, 'state.json'), JSON.stringify(state, null, 2), 'utf-8'); -} - -/** - * Record that an interaction happened (CO counts toward auto-analysis threshold). - */ -export function recordInteraction(repoPath: string): { shouldAnalyze: boolean } { - const state = loadCOState(repoPath); - state.interactionsSinceAnalysis++; - const shouldAnalyze = state.interactionsSinceAnalysis >= state.analysisInterval; - saveCOState(repoPath, state); - return { shouldAnalyze }; -} - -/** - * Check if new tools have been added since last scan. - */ -export function checkForNewTools( - repoPath: string, - currentTools: DetectedTool[], -): DetectedTool[] { - const state = loadCOState(repoPath); - const knownSet = new Set(state.knownTools); - const newTools = currentTools.filter(t => !knownSet.has(t.id)); - - if (newTools.length > 0) { - // Update known tools - state.knownTools = currentTools.map(t => t.id); - for (const tool of newTools) { - state.decisions.push({ - timestamp: new Date().toISOString(), - type: 'tool-detected', - description: `New tool detected: ${tool.name} (${tool.category}) via ${tool.configPath}`, - }); - } - saveCOState(repoPath, state); - } - - return newTools; -} - -/** - * Generate improvement suggestions based on interaction analysis. - */ -export function generateSuggestions( - analysis: InteractionAnalysis, - state: COState, -): ImprovementSuggestion[] { - const suggestions: ImprovementSuggestion[] = []; - - // High token usage agents → need better context - for (const [agent, avgTokens] of Object.entries(analysis.avgTokensByAgent)) { - if (avgTokens > 30000) { - suggestions.push({ - type: 'update-context', - priority: 'high', - title: `${agent} bruker for mange tokens (snitt ${Math.round(avgTokens / 1000)}k)`, - description: 'Agenten bruker mye tokens per interaksjon. CONTEXT.md filene kan forbedres med mer spesifikk routing.', - affectedPaths: [agent], - }); - } - } - - // Failed queries → routing gaps - if (analysis.failedQueries.length > 0) { - suggestions.push({ - type: 'add-context', - priority: 'high', - title: `${analysis.failedQueries.length} spørsmål brukte for lang tid`, - description: `Disse spørsmålene tok for lang tid og brukte mange tokens:\n${analysis.failedQueries.map(q => `- "${q}"`).join('\n')}`, - affectedPaths: [], - }); - } - - // Unused agents → maybe remove or merge - const totalInteractions = analysis.totalInteractions; - if (totalInteractions > 10) { - for (const [agent, count] of Object.entries(analysis.agentUsage)) { - if (count < 2 && agent !== 'co') { - suggestions.push({ - type: 'agent-config', - priority: 'low', - title: `${agent} brukes sjelden (${count}/${totalInteractions} interaksjoner)`, - description: 'Vurder å slå sammen denne agenten med en annen, eller fjern den.', - affectedPaths: [agent], - }); - } - } - } - - return suggestions; -} - -/** - * Generate the CO's project overview — a CONTEXT.md-style summary for the CO agent. - */ -export function generateCOContext( - state: COState, - tools: DetectedTool[], - analysis?: InteractionAnalysis, -): string { - const lines: string[] = [ - '# Chief Organizer — Project State', - '', - '## Detected Tools', - ]; - - if (tools.length > 0) { - for (const t of tools) { - lines.push(`- **${t.name}**${t.version ? ` v${t.version}` : ''} (${t.category}) — ${t.contextHint.slice(0, 80)}`); - } - } else { - lines.push('- No tools detected yet'); - } - - lines.push('', '## Recent Decisions'); - const recent = state.decisions.slice(-10); - if (recent.length > 0) { - for (const d of recent) { - lines.push(`- [${d.timestamp.slice(0, 10)}] ${d.type}: ${d.description}`); - } - } else { - lines.push('- No decisions recorded yet'); - } - - if (analysis) { - lines.push('', '## Interaction Stats'); - lines.push(`- Total interactions: ${analysis.totalInteractions}`); - lines.push(`- Total tokens: ${Math.round(analysis.totalTokens / 1000)}k`); - lines.push(`- Total cost: $${analysis.totalCost.toFixed(2)}`); - - if (Object.keys(analysis.agentUsage).length > 0) { - lines.push('', '### Agent Usage'); - for (const [agent, count] of Object.entries(analysis.agentUsage)) { - const avg = analysis.avgTokensByAgent[agent]; - lines.push(`- ${agent}: ${count} queries, avg ${Math.round((avg || 0) / 1000)}k tokens`); - } - } - } - - return lines.join('\n'); -} diff --git a/packages/core/src/agents/message-bus.ts b/packages/core/src/agents/message-bus.ts deleted file mode 100644 index 4703a29..0000000 --- a/packages/core/src/agents/message-bus.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Message Bus — logs all interactions between user and CLI agents. - * CO uses these logs to analyze patterns and improve the project. - * - * Storage: .klonode/logs/ as JSONL files (one per session). - */ - -import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync } from 'fs'; -import { join } from 'path'; - -export interface InteractionMessage { - /** Unique message ID */ - id: string; - /** Timestamp */ - timestamp: string; - /** Who sent the message */ - from: 'user' | string; // 'user' or agentId - /** Who received the message */ - to: string; // agentId - /** The message content */ - message: string; - /** Token usage */ - tokens?: { - input: number; - output: number; - total: number; - costUsd?: number; - }; - /** Context depth used */ - contextDepth?: string; - /** Files that were routed */ - routedFiles?: string[]; - /** Execution mode */ - executionMode?: string; - /** Duration in ms */ - durationMs?: number; -} - -export interface InteractionSession { - id: string; - startedAt: string; - messages: InteractionMessage[]; -} - -export interface MessageBus { - /** Project root path */ - repoPath: string; - /** Current session ID */ - sessionId: string; - /** Log directory path */ - logDir: string; -} - -function makeId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -/** - * Initialize the message bus for a project. - */ -export function createMessageBus(repoPath: string): MessageBus { - const logDir = join(repoPath, '.klonode', 'logs'); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - - return { - repoPath, - sessionId: `session-${Date.now()}`, - logDir, - }; -} - -/** - * Log an interaction message to the session log. - */ -export function logInteraction(bus: MessageBus, msg: Omit): InteractionMessage { - const fullMsg: InteractionMessage = { - id: makeId(), - timestamp: new Date().toISOString(), - ...msg, - }; - - const logFile = join(bus.logDir, `${bus.sessionId}.jsonl`); - appendFileSync(logFile, JSON.stringify(fullMsg) + '\n', 'utf-8'); - - return fullMsg; -} - -/** - * Read all messages from a session log. - */ -export function readSessionLog(bus: MessageBus, sessionId?: string): InteractionMessage[] { - const logFile = join(bus.logDir, `${sessionId || bus.sessionId}.jsonl`); - if (!existsSync(logFile)) return []; - - const lines = readFileSync(logFile, 'utf-8').split('\n').filter(l => l.trim()); - return lines.map(l => { - try { return JSON.parse(l); } catch { return null; } - }).filter(Boolean); -} - -/** - * List all session IDs available. - */ -export function listSessionLogs(bus: MessageBus): string[] { - if (!existsSync(bus.logDir)) return []; - return readdirSync(bus.logDir) - .filter(f => f.endsWith('.jsonl')) - .map(f => f.replace('.jsonl', '')) - .sort() - .reverse(); -} - -/** - * Analyze interaction logs to find patterns for CO improvement suggestions. - */ -export function analyzeInteractions(messages: InteractionMessage[]): InteractionAnalysis { - const agentUsage = new Map(); - const avgTokensByAgent = new Map(); - const failedQueries: string[] = []; - let totalTokens = 0; - let totalCost = 0; - - for (const msg of messages) { - if (msg.from === 'user') { - const to = msg.to; - agentUsage.set(to, (agentUsage.get(to) || 0) + 1); - } - - if (msg.tokens) { - totalTokens += msg.tokens.total; - if (msg.tokens.costUsd) totalCost += msg.tokens.costUsd; - - const list = avgTokensByAgent.get(msg.to) || []; - list.push(msg.tokens.total); - avgTokensByAgent.set(msg.to, list); - } - - // Detect failed interactions (high tokens, likely exploration) - if (msg.tokens && msg.tokens.total > 50000 && msg.durationMs && msg.durationMs > 120000) { - failedQueries.push(msg.message.slice(0, 100)); - } - } - - const avgTokens = new Map(); - for (const [agent, tokens] of avgTokensByAgent) { - avgTokens.set(agent, Math.round(tokens.reduce((a, b) => a + b, 0) / tokens.length)); - } - - return { - totalInteractions: messages.filter(m => m.from === 'user').length, - agentUsage: Object.fromEntries(agentUsage), - avgTokensByAgent: Object.fromEntries(avgTokens), - totalTokens, - totalCost, - failedQueries, - }; -} - -export interface InteractionAnalysis { - totalInteractions: number; - agentUsage: Record; - avgTokensByAgent: Record; - totalTokens: number; - totalCost: number; - failedQueries: string[]; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0796c0e..4647dee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -71,15 +71,5 @@ export type { SanitizeResult } from './security/sanitize.js'; export { scanFile, scanRepository as scanRepositoryForInjection, formatReport as formatInjectionReport } from './security/injection-scanner.js'; export type { ScanHit, ScanReport } from './security/injection-scanner.js'; -// Agents -export { buildAgentRegistry, getAgentContext } from './agents/agent-registry.js'; -export type { AgentDefinition, AgentRegistry, AgentRole } from './agents/agent-registry.js'; - -export { createMessageBus, logInteraction, readSessionLog, listSessionLogs, analyzeInteractions } from './agents/message-bus.js'; -export type { InteractionMessage, InteractionSession, InteractionAnalysis, MessageBus } from './agents/message-bus.js'; - -export { loadCOState, saveCOState, recordInteraction, checkForNewTools, generateSuggestions, generateCOContext } from './agents/chief-organizer.js'; -export type { COState, CODecision, ImprovementSuggestion } from './agents/chief-organizer.js'; - // Serializer export { serializeGraph, deserializeGraph, saveGraph, loadGraph } from './serializer/serializer.js'; diff --git a/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte b/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte deleted file mode 100644 index d24b882..0000000 --- a/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte +++ /dev/null @@ -1,1375 +0,0 @@ - - -
- -
-
- - Klonode Chat - {#if $chatStore.isLoading} - - ● streaming - - {/if} -
-
- {#if $chatStore.messages.length > 0} - - {/if} - -
-
- - -
- - -
- - -
- {#each sessions as session (session.id)} - - {/each} - -
- - - - - {#if isCO} -
-
- Kontekst -
-
60} - class:danger={contextPct > 85} - style="width: {contextPct}%" - /> -
- {contextPct}% -
- - - {#if pendingClosedSessions > 0} - {pendingClosedSessions} lukkede chats - {/if} -
- {/if} - - - {#if showSettings} -
-
Claude-tilkobling
- -
- -
- - -
-
- - {#if $settingsStore.connectionMode === 'cli'} -
- -
- updateSettings({ cliPath: e.currentTarget.value })} - /> - -
- {#if $settingsStore.cliPath} -
✓ {$settingsStore.cliPath.split('\\').pop()}
- {:else} -
Klikk «Finn» for auto-deteksjon
- {/if} -
- {:else} -
- - updateSettings({ apiKey: e.currentTarget.value })} - /> -
Hent nøkkel fra console.anthropic.com
-
- {/if} - -
- - -
- -
- -
- - - - -
-
- {#if $settingsStore.executionMode === 'auto'} - Velger automatisk mellom spørsmål, plan og direkte - {:else if $settingsStore.executionMode === 'question'} - Bare kontekst — rask, billig, ingen fillesing - {:else if $settingsStore.executionMode === 'plan'} - Leser kode → lager plan → du godkjenner → utfører - {:else} - Full tilgang — leser og skriver filer direkte - {/if} -
-
- - -
- {/if} - - -
- {#if $chatStore.messages.length === 0} -
-
- {#if chatMode === 'chat'} -
Chat med kodebasen din
-
- Klonode ruter automatisk til riktig kontekst. Bare skriv hva du trenger. -
- {:else} -
Sammenlign med og uten Klonode
-
- Still et spørsmål — du ser svaret fra begge varianter med faktisk token-bruk. -
- {/if} -
- - - -
-
- {:else} - {#each $chatStore.messages as msg (msg.id)} - {#if msg.role === 'user'} -
-
{msg.content}
-
- {:else if msg.role === 'assistant'} -
- - {#if msg.mode} -
- {#if msg.mode === 'with-klonode'} - ⟐ Med Klonode - {:else} - ⊟ Uten Klonode - {/if} - {#if msg.tokens} - - {formatTokens(msg.tokens.input)} inn · {formatTokens(msg.tokens.output)} ut - - {/if} -
- {/if} - - {#if msg.loading} - - {#if msg.routedFiles && msg.routedFiles.length > 0} -
Rutet {msg.routedFiles.length} filer
- {/if} - - - {#each activityLog as entry} -
- {entry.tool} - {entry.input} -
- {/each} - - - {#if streamingText} -
{streamingText}
- {/if} - - - {#if activityLog.length === 0 && !streamingText} -
- - tenker -
- {/if} - {:else} - {#if msg.interrupted} -
- ⚠ response interrupted by reload -
- {/if} -
{msg.content}
- - {#if msg.isPlan} -
- - Les planen og klikk for å utføre med full tilgang -
- {/if} - - {#if !msg.mode && msg.routedFiles && msg.routedFiles.length > 0} -
- ⟐ {msg.routedFiles.length} filer rutet - {#if msg.tokens} - {formatTokens(msg.tokens.input)} inn · {formatTokens(msg.tokens.output)} ut - {/if} -
- {/if} - {/if} -
- {/if} - {/each} - - - {#if $chatStore.lastComparison?.withKlonode?.tokens && $chatStore.lastComparison?.withoutKlonode?.tokens} - {@const w = $chatStore.lastComparison.withKlonode.tokens} - {@const wo = $chatStore.lastComparison.withoutKlonode.tokens} - {@const wCost = w.costUsd || 0} - {@const woCost = wo.costUsd || 0} - {@const savedCost = woCost - wCost} - {@const pctCost = woCost > 0 ? Math.round((savedCost / woCost) * 100) : 0} - {@const wTime = Math.round((w.elapsed || 0) / 1000)} - {@const woTime = Math.round((wo.elapsed || 0) / 1000)} - {@const savedTime = woTime - wTime} - {@const pctTime = woTime > 0 ? Math.round((savedTime / woTime) * 100) : 0} -
0} class:savings-negative={savedCost <= 0}> - {#if savedCost > 0} - {pctCost}% billigere med Klonode - {:else} - Likt resultat - {/if} -
-
- ${wCost.toFixed(2)} - Klonode -
-
- ${woCost.toFixed(2)} - Vanlig -
-
- 0}>{savedCost > 0 ? '-' : '+'}{Math.abs(savedCost).toFixed(2)} - Spart -
-
-
- {w.numTurns || '?'} vs {wo.numTurns || '?'} steg - · - {wTime}s vs {woTime}s - {#if pctTime > 0} - · - {pctTime}% raskere - {/if} -
-
- {/if} - {/if} - - {#if $chatStore.error} -
- - {$chatStore.error} - -
- {/if} -
- - -
- - {#if attachments.length > 0} -
- {#each attachments as att, i} -
- {#if att.type.startsWith('image/')} - {att.name} - {:else} - F - {/if} - {att.name} - -
- {/each} -
- {/if} -
- - - - {#if $chatStore.isLoading} - - {:else} - - {/if} -
-
-
- - diff --git a/packages/ui/src/lib/components/GraphView/GraphView.svelte b/packages/ui/src/lib/components/GraphView/GraphView.svelte index 2aded0d..a8cf152 100644 --- a/packages/ui/src/lib/components/GraphView/GraphView.svelte +++ b/packages/ui/src/lib/components/GraphView/GraphView.svelte @@ -6,6 +6,7 @@ import { routingGraphToFlow } from '../../utils/tree-to-graph'; import { simulatorStore, activePathIds, pulsingNodeId, runSimulation, resetSimulation } from '../../stores/simulator'; import { activeNodePaths } from '../../stores/activity'; + import { learningScores } from '../../stores/learning'; import { defineComponent, defineComponentAction, @@ -380,6 +381,11 @@ : activity?.kind === 'search' ? '#a78bfa' : activity?.kind === 'read' ? '#3b82f6' : null} + {@const learning = $learningScores.get(node.data.path ?? '')} + {@const confidenceOpacity = learning ? 0.5 + learning.confidence * 0.5 : 1} + {@const urgencyColor = learning && learning.urgency > 2 ? '#ef4444' + : learning && learning.urgency > 0.5 ? '#f97316' + : null} {@const heatBg = $showHeatmap && node.data.heatValue > 0 ? `rgba(245, 158, 11, ${node.data.heatValue * 0.4})` : 'rgba(15, 15, 20, 0.85)'} @@ -390,7 +396,7 @@ transform="translate({node.position.x}, {node.position.y})" on:click|stopPropagation={() => selectNode(node.id)} on:dblclick|stopPropagation={() => toggleGroup(node.id)} - style="cursor: pointer" + style="cursor: pointer; opacity: {dimmed ? undefined : confidenceOpacity}" class="graph-node" class:dimmed class:on-path={isOnPath} @@ -407,8 +413,8 @@ {#if activityColor} diff --git a/packages/ui/src/lib/components/SuggestionsPanel/SuggestionsPanel.svelte b/packages/ui/src/lib/components/SuggestionsPanel/SuggestionsPanel.svelte new file mode 100644 index 0000000..d347f80 --- /dev/null +++ b/packages/ui/src/lib/components/SuggestionsPanel/SuggestionsPanel.svelte @@ -0,0 +1,272 @@ + + +
+
+

Suggestions

+ +
+ + {#if error} +

{error}

+ {:else if suggestions.length === 0 && !loading} +
+

No suggestions yet.

+

Click Analyze to generate suggestions from observation data, or wait for a session to end.

+
+ {:else} +
+ {#each suggestions as s (s.id)} +
+
+
+
+ {s.type} + {s.urgency.toFixed(1)} +
+

{s.title}

+

{s.reason}

+
{s.path}
+
+ + + +
+
+
+ {/each} +
+ {/if} +
+ + diff --git a/packages/ui/src/lib/server/learning.ts b/packages/ui/src/lib/server/learning.ts new file mode 100644 index 0000000..fc8a4ac --- /dev/null +++ b/packages/ui/src/lib/server/learning.ts @@ -0,0 +1,435 @@ +/** + * Learning model — the brain of the contextualizer. + * + * Works like human memory via two signal families: + * + * **Repetition** (boring, accumulative → `confidence` per node): + * - Same file read 3+ times across sessions → load-bearing, promote. + * - Folder missed by routing → Claude had to grep to find it → gap. + * - Same bash command repeated → document as a tool. + * - Same grep pattern recurring → missing cross-reference. + * + * **Emotion** (rare, high-weight, time-decayed → `urgency` per node): + * - User correction ("no", "stop", "wrong") → path Claude was on = wrong. + * - Long/expensive turns (high token count) → routing failed. + * - max_turns hit → routing definitely failed. + * - Fast user acceptance → routing worked, reinforce. + * + * Repetition is background hum. Emotion makes the suggestions panel light up. + * + * All functions are pure: `(observations, graph) → scores`. No side effects, + * no ML, no weighting magic — just counted signals with published formulas + * in the docstrings so the scores are interpretable. + * + * Part of the #77 pivot roadmap. See #80 (repetition) and #81 (emotion). + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +/** Observation entry shape — matches what observation-log.ts produces. + * Duplicated here to avoid a cross-package import from core→ui. */ +export interface ObservationEntry { + at: string; + sessionId: string; + cwd: string; + tool: string; + kind: string; + path: string; + isSidechain: boolean; + id: string; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface NodeScore { + /** Folder path (forward-slashed, relative to repo root). */ + path: string; + /** Repetition-driven score in [0, 1]. How load-bearing this folder is. */ + confidence: number; + /** Emotion-driven score in [0, ∞). How recently/badly something went wrong. */ + urgency: number; + /** Breakdown of the signals that produced these scores. */ + signals: { + /** Distinct sessions that touched this folder. */ + sessionsCount: number; + /** Total reads across all sessions. */ + readCount: number; + /** Total writes (Edit/Write) across all sessions. */ + writeCount: number; + /** Total tool calls of any kind in this folder. */ + totalOps: number; + /** Unique grep patterns used to find things in this folder. */ + grepPatterns: number; + /** Number of emotion events (corrections, max_turns, etc). */ + emotionEvents: number; + }; +} + +export interface LearningState { + /** ISO timestamp when this state was last computed. */ + computedAt: string; + /** Per-node scores, keyed by folder path. */ + nodes: Record; + /** Total observations analyzed. */ + observationCount: number; + /** Distinct sessions in the observation window. */ + sessionCount: number; +} + +// --------------------------------------------------------------------------- +// Emotion event parsing +// --------------------------------------------------------------------------- + +/** Keywords in user messages that signal correction/frustration. */ +const CORRECTION_KEYWORDS = [ + 'no', 'stop', 'actually', 'wrong', 'that\'s wrong', 'not that', + 'nei', 'ikke', 'feil', 'stopp', 'vent', 'galt', +]; + +/** Keywords in assistant messages that signal routing failure. */ +const HEDGE_KEYWORDS = [ + 'i couldn\'t find', 'i can\'t locate', 'doesn\'t seem to exist', + 'not found', 'unable to find', 'finner ikke', 'kan ikke finne', +]; + +/** Keywords in user messages that signal satisfaction. */ +const PRAISE_KEYWORDS = [ + 'perfect', 'exactly', 'great', 'thanks', 'nailed it', 'good', + 'flott', 'perfekt', 'takk', 'bra', +]; + +export interface EmotionEvent { + type: 'correction' | 'hedge' | 'max_turns' | 'expensive_turn' | 'praise'; + weight: number; + /** ISO timestamp. */ + at: string; + /** Folder path this event pertains to (from the surrounding tool calls). */ + path: string; + /** Session id. */ + sessionId: string; +} + +// --------------------------------------------------------------------------- +// Repetition scoring +// --------------------------------------------------------------------------- + +/** + * Compute `confidence` for every folder that appears in the observations. + * + * Formula: + * confidence = min(1, sessionFraction * 0.5 + editBoost * 0.3 + diversityBoost * 0.2) + * + * Where: + * - sessionFraction = (sessions touching this folder) / (total sessions) + * - editBoost = min(1, editCount / 10) — edits weight more than reads + * - diversityBoost = min(1, distinctTools / 4) — variety signals importance + * + * All factors are in [0, 1]. The formula is deliberately simple and + * interpretable — no hidden knobs, no training, no gradient descent. + */ +export function computeRepetition(observations: ObservationEntry[]): Map { + if (observations.length === 0) return new Map(); + + // Group by folder path (strip file from path to get containing folder). + const byFolder = new Map(); + for (const obs of observations) { + if (!obs.path || obs.path === '.') continue; + const folder = toFolderPath(obs.path); + if (!folder) continue; + const list = byFolder.get(folder) || []; + list.push(obs); + byFolder.set(folder, list); + } + + const allSessions = new Set(observations.map(o => o.sessionId)); + const totalSessions = Math.max(1, allSessions.size); + + const scores = new Map(); + + for (const [folder, entries] of byFolder) { + const sessions = new Set(entries.map(e => e.sessionId)); + const reads = entries.filter(e => e.kind === 'read' || e.kind === 'search').length; + const writes = entries.filter(e => e.kind === 'write').length; + const tools = new Set(entries.map(e => e.tool)); + const grepEntries = entries.filter(e => e.tool === 'Grep'); + const grepPatterns = new Set(grepEntries.map(e => e.id)).size; // rough proxy + + const sessionFraction = sessions.size / totalSessions; + const editBoost = Math.min(1, writes / 10); + const diversityBoost = Math.min(1, tools.size / 4); + + const confidence = Math.min(1, + sessionFraction * 0.5 + editBoost * 0.3 + diversityBoost * 0.2 + ); + + scores.set(folder, { + path: folder, + confidence, + urgency: 0, // filled by computeUrgency + signals: { + sessionsCount: sessions.size, + readCount: reads, + writeCount: writes, + totalOps: entries.length, + grepPatterns, + emotionEvents: 0, + }, + }); + } + + return scores; +} + +// --------------------------------------------------------------------------- +// Emotion scoring +// --------------------------------------------------------------------------- + +/** + * Parse emotion events from raw JSONL session data. + * + * This reads the original Claude Code session files (not the observation + * log) because emotion signals come from user/assistant message text, not + * just tool_use blocks. + * + * Returns a list of EmotionEvents that `computeUrgency` can process. + */ +export function extractEmotionEvents( + sessionLines: string[], +): EmotionEvent[] { + const events: EmotionEvent[] = []; + let lastToolPath = ''; + + for (const line of sessionLines) { + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const at = parsed.timestamp || new Date().toISOString(); + const sessionId = parsed.sessionId || ''; + + // Track the last tool path for attribution. + if (parsed.type === 'assistant' && parsed.message?.content) { + for (const block of parsed.message.content) { + if (block.type === 'tool_use' && block.input) { + const path = block.input.file_path || block.input.path || ''; + if (path) lastToolPath = toFolderPath(normalizePath(path, parsed.cwd || '')) || ''; + } + } + + // Check for max_turns + if (parsed.message?.stop_reason === 'max_tokens' || parsed.subtype === 'error_max_turns') { + if (lastToolPath) { + events.push({ type: 'max_turns', weight: 3, at, path: lastToolPath, sessionId }); + } + } + + // Check for hedges in assistant text + for (const block of parsed.message.content) { + if (block.type === 'text' && typeof block.text === 'string') { + const lower = block.text.toLowerCase(); + for (const kw of HEDGE_KEYWORDS) { + if (lower.includes(kw)) { + if (lastToolPath) { + events.push({ type: 'hedge', weight: 1, at, path: lastToolPath, sessionId }); + } + break; + } + } + } + } + } + + // User messages: check for corrections or praise + if (parsed.type === 'user' && parsed.message?.content) { + let text = ''; + if (typeof parsed.message.content === 'string') { + text = parsed.message.content; + } else if (Array.isArray(parsed.message.content)) { + text = parsed.message.content + .filter((b: any) => b.type === 'text') + .map((b: any) => b.text) + .join(' '); + } + const lower = text.toLowerCase().trim(); + if (!lower) continue; + + // Check corrections + for (const kw of CORRECTION_KEYWORDS) { + if (lower.startsWith(kw) || lower.includes(` ${kw} `) || lower === kw) { + if (lastToolPath) { + events.push({ type: 'correction', weight: 5, at, path: lastToolPath, sessionId }); + } + break; + } + } + + // Check praise + for (const kw of PRAISE_KEYWORDS) { + if (lower.includes(kw)) { + if (lastToolPath) { + events.push({ type: 'praise', weight: -2, at, path: lastToolPath, sessionId }); + } + break; + } + } + } + } + + return events; +} + +/** + * Compute `urgency` for every folder that has emotion events. + * + * Formula (per node): + * urgency = Σ weight(event) * exp(-λ * ageInDays(event)) + * + * Where λ = ln(2) / 7 (half-life of 7 days). + * + * Positive weights (corrections, max_turns) increase urgency. + * Negative weights (praise) decrease it (capped at 0). + */ +export function computeUrgency( + emotionEvents: EmotionEvent[], + now: Date = new Date(), +): Map { + const HALF_LIFE_DAYS = 7; + const LAMBDA = Math.LN2 / HALF_LIFE_DAYS; + const nowMs = now.getTime(); + + const byFolder = new Map(); + for (const ev of emotionEvents) { + if (!ev.path) continue; + const list = byFolder.get(ev.path) || []; + list.push(ev); + byFolder.set(ev.path, list); + } + + const urgencyMap = new Map(); + for (const [folder, events] of byFolder) { + let urgency = 0; + for (const ev of events) { + const ageMs = nowMs - new Date(ev.at).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + urgency += ev.weight * Math.exp(-LAMBDA * ageDays); + } + urgencyMap.set(folder, Math.max(0, urgency)); + } + + return urgencyMap; +} + +// --------------------------------------------------------------------------- +// Combined scoring +// --------------------------------------------------------------------------- + +/** + * Compute the full learning state from observations and (optionally) + * emotion events. + */ +export function computeLearningState( + observations: ObservationEntry[], + emotionEvents: EmotionEvent[] = [], +): LearningState { + const repetitionScores = computeRepetition(observations); + const urgencyScores = computeUrgency(emotionEvents); + + // Merge urgency into repetition scores. + for (const [folder, urgency] of urgencyScores) { + const existing = repetitionScores.get(folder); + if (existing) { + existing.urgency = urgency; + existing.signals.emotionEvents = emotionEvents.filter(e => e.path === folder).length; + } else { + // Folder only has emotion events, no repetition data. + repetitionScores.set(folder, { + path: folder, + confidence: 0, + urgency, + signals: { + sessionsCount: 0, + readCount: 0, + writeCount: 0, + totalOps: 0, + grepPatterns: 0, + emotionEvents: emotionEvents.filter(e => e.path === folder).length, + }, + }); + } + } + + const allSessions = new Set(observations.map(o => o.sessionId)); + + const nodes: Record = {}; + for (const [folder, score] of repetitionScores) { + nodes[folder] = score; + } + + return { + computedAt: new Date().toISOString(), + nodes, + observationCount: observations.length, + sessionCount: allSessions.size, + }; +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +/** + * Save learning state to `.klonode/learning.json`. + */ +export function saveLearningState(repoPath: string, state: LearningState): void { + const klonodeDir = join(repoPath, '.klonode'); + if (!existsSync(klonodeDir)) mkdirSync(klonodeDir, { recursive: true }); + writeFileSync( + join(klonodeDir, 'learning.json'), + JSON.stringify(state, null, 2), + 'utf-8', + ); +} + +/** + * Load learning state from `.klonode/learning.json`, or null if not computed yet. + */ +export function loadLearningState(repoPath: string): LearningState | null { + const filePath = join(repoPath, '.klonode', 'learning.json'); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Strip the file name to get the containing folder. */ +function toFolderPath(filePath: string): string | null { + const normalized = filePath.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash <= 0) return normalized; // top-level file → folder is "." + return normalized.slice(0, lastSlash); +} + +/** Normalize a path to forward slashes and make it relative to cwd. */ +function normalizePath(rawPath: string, cwd: string): string { + if (!rawPath) return ''; + let p = rawPath.replace(/\\/g, '/'); + if (cwd) { + const normalizedCwd = cwd.replace(/\\/g, '/').replace(/\/$/, ''); + if (p.startsWith(normalizedCwd + '/')) { + p = p.slice(normalizedCwd.length + 1); + } + } + return p; +} diff --git a/packages/ui/src/lib/server/observation-log.ts b/packages/ui/src/lib/server/observation-log.ts new file mode 100644 index 0000000..406a3a3 --- /dev/null +++ b/packages/ui/src/lib/server/observation-log.ts @@ -0,0 +1,238 @@ +/** + * Persistent observation log — append-only JSONL store of every tool_use + * event the session watcher extracts from Claude Code session files. + * + * Stored at `.klonode/observations.jsonl` per project. This is the raw data + * the learning model reads to compute confidence (repetition) and urgency + * (emotion) scores per folder node. Without this, events flow through the + * 8-second pulse lifetime and are lost. + * + * Design constraints: + * - Append-only: never rewrite the whole file. Rotation at 50 MB. + * - Dedup by `uuid:toolUseId` so server restarts and reconnects don't + * double-record. Dedup state is in-memory with a bounded set. + * - Paths normalized to forward slashes and relative to the session's cwd + * (same normalization the activity store uses) so downstream learning + * can join directly against graph node paths. + * + * Part of the #77 pivot roadmap. See #79 for the issue. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync } from 'node:fs'; +import { join } from 'node:path'; +import type { SessionActivityEvent } from './session-watcher.js'; + +export interface ObservationEntry { + /** ISO timestamp from the JSONL entry (when Claude Code recorded it). */ + at: string; + /** Claude session id. */ + sessionId: string; + /** Absolute cwd reported by Claude Code. */ + cwd: string; + /** Raw tool name. */ + tool: string; + /** Simplified kind. */ + kind: string; + /** File path — normalized to forward slashes, relative to cwd. */ + path: string; + /** True if from a subagent/Task sidechain. */ + isSidechain: boolean; + /** Dedup key: `uuid:toolUseId`. */ + id: string; +} + +/** Max size of the active observations file before rotation. */ +const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB + +/** How many dedup IDs we keep in memory. */ +const DEDUP_LIMIT = 10_000; + +/** + * Normalize a path to forward slashes and make it relative to cwd. + */ +function normalizePath(rawPath: string, cwd: string): string { + if (!rawPath) return ''; + let p = rawPath.replace(/\\/g, '/'); + if (cwd) { + const normalizedCwd = cwd.replace(/\\/g, '/').replace(/\/$/, ''); + if (p.startsWith(normalizedCwd + '/')) { + p = p.slice(normalizedCwd.length + 1); + } else if (p === normalizedCwd) { + p = '.'; + } + } + return p; +} + +export interface ObservationLog { + /** Append a single event. Returns false if deduped (already recorded). */ + append: (event: SessionActivityEvent) => boolean; + /** Read all entries. Optional filters. */ + readAll: (opts?: { sessionId?: string; pathPrefix?: string }) => ObservationEntry[]; + /** Read entries added after a given line offset (for tailing). */ + readFrom: (lineOffset: number) => { entries: ObservationEntry[]; nextOffset: number }; + /** Stats: count per tool, per session, per top-level folder. */ + stats: () => ObservationStats; + /** Drop everything — start fresh. */ + purge: () => void; + /** Absolute path to the observations file. */ + filePath: string; +} + +export interface ObservationStats { + totalEvents: number; + byTool: Record; + bySession: Record; + byTopFolder: Record; + fileSizeBytes: number; +} + +/** + * Create or open an observation log for a project. + */ +export function openObservationLog(repoPath: string): ObservationLog { + const klonodeDir = join(repoPath, '.klonode'); + if (!existsSync(klonodeDir)) mkdirSync(klonodeDir, { recursive: true }); + + const filePath = join(klonodeDir, 'observations.jsonl'); + const archivePath = join(klonodeDir, 'observations.archive.jsonl'); + + // In-memory dedup set. Load last DEDUP_LIMIT IDs from the existing file + // so restarts don't re-record the tail. + const seen = new Set(); + if (existsSync(filePath)) { + try { + const lines = readFileSync(filePath, 'utf-8').split(/\r?\n/).filter(Boolean); + const startIdx = Math.max(0, lines.length - DEDUP_LIMIT); + for (let i = startIdx; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + if (entry.id) seen.add(entry.id); + } catch { /* skip corrupt line */ } + } + } catch { /* file may be empty or corrupt */ } + } + + function maybeRotate(): void { + try { + if (!existsSync(filePath)) return; + const size = statSync(filePath).size; + if (size < MAX_FILE_BYTES) return; + // Append current to archive, then truncate current. + if (existsSync(archivePath)) { + const current = readFileSync(filePath, 'utf-8'); + appendFileSync(archivePath, current); + } else { + renameSync(filePath, archivePath); + } + writeFileSync(filePath, '', 'utf-8'); + seen.clear(); + } catch { /* rotation failed — not fatal, just keep appending */ } + } + + function append(event: SessionActivityEvent): boolean { + if (!event.id || seen.has(event.id)) return false; + + // Bound the dedup set + if (seen.size >= DEDUP_LIMIT) { + const firstKey = seen.values().next().value; + if (firstKey !== undefined) seen.delete(firstKey); + } + seen.add(event.id); + + const entry: ObservationEntry = { + at: event.at, + sessionId: event.sessionId, + cwd: event.cwd, + tool: event.tool, + kind: event.kind, + path: normalizePath(event.path, event.cwd), + isSidechain: event.isSidechain, + id: event.id, + }; + + try { + appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8'); + } catch { /* disk full, permissions, etc. — not fatal */ } + + maybeRotate(); + return true; + } + + function readAll(opts?: { sessionId?: string; pathPrefix?: string }): ObservationEntry[] { + if (!existsSync(filePath)) return []; + try { + const lines = readFileSync(filePath, 'utf-8').split(/\r?\n/).filter(Boolean); + let entries: ObservationEntry[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line)); + } catch { /* skip */ } + } + if (opts?.sessionId) { + entries = entries.filter(e => e.sessionId === opts.sessionId); + } + if (opts?.pathPrefix) { + entries = entries.filter(e => e.path.startsWith(opts.pathPrefix!)); + } + return entries; + } catch { + return []; + } + } + + function readFrom(lineOffset: number): { entries: ObservationEntry[]; nextOffset: number } { + if (!existsSync(filePath)) return { entries: [], nextOffset: 0 }; + try { + const lines = readFileSync(filePath, 'utf-8').split(/\r?\n/).filter(Boolean); + const entries: ObservationEntry[] = []; + for (let i = lineOffset; i < lines.length; i++) { + try { + entries.push(JSON.parse(lines[i])); + } catch { /* skip */ } + } + return { entries, nextOffset: lines.length }; + } catch { + return { entries: [], nextOffset: lineOffset }; + } + } + + function stats(): ObservationStats { + const entries = readAll(); + const byTool: Record = {}; + const bySession: Record = {}; + const byTopFolder: Record = {}; + + for (const e of entries) { + byTool[e.tool] = (byTool[e.tool] || 0) + 1; + bySession[e.sessionId] = (bySession[e.sessionId] || 0) + 1; + if (e.path) { + const topFolder = e.path.split('/')[0] || '.'; + byTopFolder[topFolder] = (byTopFolder[topFolder] || 0) + 1; + } + } + + let fileSizeBytes = 0; + try { + if (existsSync(filePath)) fileSizeBytes = statSync(filePath).size; + } catch { /* ignore */ } + + return { + totalEvents: entries.length, + byTool, + bySession, + byTopFolder, + fileSizeBytes, + }; + } + + function purge(): void { + try { + if (existsSync(filePath)) writeFileSync(filePath, '', 'utf-8'); + if (existsSync(archivePath)) writeFileSync(archivePath, '', 'utf-8'); + seen.clear(); + } catch { /* ignore */ } + } + + return { append, readAll, readFrom, stats, purge, filePath }; +} diff --git a/packages/ui/src/lib/server/session-watcher.ts b/packages/ui/src/lib/server/session-watcher.ts new file mode 100644 index 0000000..2c73e3c --- /dev/null +++ b/packages/ui/src/lib/server/session-watcher.ts @@ -0,0 +1,428 @@ +/** + * Session watcher — tails Claude Code session JSONL files and emits activity + * events. This is the data source that feeds the live node graph after the + * contextualizer pivot (#77, PR #78). See feat: pivot to contextualizer-only + * for the motivation. + * + * Claude Code appends to `~/.claude/projects//.jsonl` + * after every message and every tool call, before the tool even returns. That + * means polling the file size and reading new bytes is enough to stream tool + * use events in near-real-time (verified manually in #77 — a `tail -1` saw a + * Bash call recorded before the command itself returned). + * + * Why polling instead of `fs.watch`? `fs.watch` on Windows is flaky for files + * being appended to by another process — events drop, sizes lie, and we'd + * miss tool calls. A 500ms poll on `fs.stat` is cheap (we have at most a + * handful of files to watch) and reliable across platforms. + */ +import { EventEmitter } from 'node:events'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** How often we poll each session file for new bytes. */ +const POLL_INTERVAL_MS = 500; + +/** Max bytes to read per poll — prevents runaway memory if a session file + * grows thousands of lines in one tick (unlikely but cheap to guard). */ +const MAX_READ_PER_POLL = 1_000_000; + +export type ActivityKind = 'read' | 'write' | 'command' | 'search' | 'other'; + +export interface SessionActivityEvent { + /** Claude session id (filename without extension). */ + sessionId: string; + /** Absolute cwd reported by Claude Code in the JSONL message. */ + cwd: string; + /** Raw tool name (Read, Edit, Write, Bash, Glob, Grep, NotebookEdit...). */ + tool: string; + /** Simplified kind for coloring in the graph. */ + kind: ActivityKind; + /** File path the tool acted on — absolute or relative, as Claude saw it. + * Empty string when the tool has no path (e.g. Bash with a non-file + * command). */ + path: string; + /** ISO timestamp from the JSONL entry. */ + at: string; + /** Unique event id for client-side dedup (JSONL message uuid + tool_use id). */ + id: string; + /** True if the tool_use came from a subagent / Task sidechain. */ + isSidechain: boolean; +} + +interface WatchedFile { + /** Absolute path to the JSONL file. */ + path: string; + /** Bytes we've already parsed. */ + offset: number; + /** Leftover bytes from a previous poll that didn't end on a newline. */ + buffer: string; + /** Session id (filename without .jsonl). */ + sessionId: string; + /** Timestamp of last observed growth (for session-end detection). */ + lastGrowthAt: number; + /** Whether we've already fired a session-ended event for this file. */ + endedFired: boolean; +} + +export interface SessionEndedEvent { + sessionId: string; + /** How long the session was idle before being declared ended (ms). */ + idleMs: number; +} + +/** How long a session must be idle before we declare it ended (ms). */ +const SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + +interface WatcherHandle { + /** Stop polling and release resources. */ + stop: () => void; + /** Add a listener that receives every tool_use event as it's parsed. */ + onEvent: (listener: (event: SessionActivityEvent) => void) => () => void; + /** Add a listener for session-end events (fired when a JSONL file stops + * growing for SESSION_IDLE_TIMEOUT_MS). */ + onSessionEnded: (listener: (event: SessionEndedEvent) => void) => () => void; + /** Current count of watched files (observability for the UI). */ + watchedFileCount: () => number; + /** Approximate events emitted so far. */ + eventCount: () => number; +} + +/** Root directory Claude Code stores sessions in. */ +export function claudeProjectsDir(): string { + return join(homedir(), '.claude', 'projects'); +} + +/** + * Encode a repo cwd to the folder name Claude Code uses. + * + * Observed rule (verified against the current worktree's session file): every + * occurrence of `:`, `\`, `/`, or `.` becomes `-`. Note the leading drive + * letter encoding: `C:\Users` → `C--Users` (colon + backslash become two + * dashes). This matches what we saw at + * `~/.claude/projects/C--Users-smorc-Desktop-KlonodeV2--claude-worktrees-laughing-poincare/`. + */ +export function encodeCwdToProjectDir(cwd: string): string { + return cwd.replace(/[:\\/.]/g, '-'); +} + +/** + * Resolve the JSONL directory for a specific cwd. Returns null if Claude Code + * hasn't created one for that repo yet (no sessions run there) or if the + * path encoding rule above doesn't match an existing directory. + */ +export function projectDirForCwd(cwd: string): string | null { + const encoded = encodeCwdToProjectDir(cwd); + const dir = join(claudeProjectsDir(), encoded); + return existsSync(dir) ? dir : null; +} + +/** + * Resolve the JSONL directory for a cwd, or the nearest ancestor. Walks up + * the path one segment at a time until it finds a matching encoded dir or + * runs out of segments. + * + * This handles two real cases: + * - The dev server's `process.cwd()` is a subdirectory (`packages/ui`) + * while Claude Code recorded the session under the repo root. + * - The graph was generated in the main checkout but the user is running + * the server from a git worktree sibling — the worktree's own dir is + * what we want to tail. + */ +export function projectDirForCwdOrAncestor(cwd: string): string | null { + if (!cwd) return null; + let current = cwd; + // Safety bound: no sane repo path is more than 20 deep. + for (let i = 0; i < 20; i++) { + const direct = projectDirForCwd(current); + if (direct) return direct; + const parent = current.replace(/[\\/][^\\/]+$/, ''); + if (!parent || parent === current) break; + current = parent; + } + return null; +} + +/** Classify a raw tool name into a coarser kind the graph uses for colors. */ +function classifyTool(tool: string): ActivityKind { + const t = tool.toLowerCase(); + if (t === 'read' || t === 'glob') return 'read'; + if (t === 'grep') return 'search'; + if (t === 'write' || t === 'edit' || t === 'notebookedit') return 'write'; + if (t === 'bash') return 'command'; + return 'other'; +} + +/** + * Extract a path from a tool_use input block. The field name varies per tool + * and we want the one most likely to correspond to a node in the folder + * graph. Returns an empty string when there's nothing meaningful to attach + * the event to (e.g. `Bash: ls` with no path argument). + */ +function extractPath(toolName: string, input: Record | undefined): string { + if (!input || typeof input !== 'object') return ''; + + // Most file tools agree on `file_path`. + if (typeof input.file_path === 'string') return input.file_path; + if (typeof input.notebook_path === 'string') return input.notebook_path; + + // Grep/Glob either has a path root or a pattern we can use as a hint. + if (typeof input.path === 'string' && input.path.length > 0) return input.path; + if (typeof input.pattern === 'string' && (input.pattern.includes('/') || input.pattern.includes('\\'))) { + return input.pattern; + } + + // Bash has no first-class path field. Heuristically pull the first + // path-looking token out of the command. This is a best effort — if Claude + // runs `npm install` there's nothing to highlight, and that's fine. + if (toolName.toLowerCase() === 'bash' && typeof input.command === 'string') { + const match = input.command.match(/[\w.\-@]+[\\/][\w.\-/\\]+/); + if (match) return match[0]; + } + + return ''; +} + +/** + * Parse a single JSONL line and emit zero or more activity events. Each + * assistant message can contain multiple tool_use blocks so one input line + * maps to N events. + */ +function parseLine(line: string): SessionActivityEvent[] { + if (!line.trim()) return []; + + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + + // We only care about assistant messages that carry tool_use content. + if (parsed.type !== 'assistant') return []; + const message = parsed.message; + if (!message || !Array.isArray(message.content)) return []; + + const cwd: string = typeof parsed.cwd === 'string' ? parsed.cwd : ''; + const sessionId: string = typeof parsed.sessionId === 'string' ? parsed.sessionId : ''; + const at: string = typeof parsed.timestamp === 'string' ? parsed.timestamp : new Date().toISOString(); + const baseUuid: string = typeof parsed.uuid === 'string' ? parsed.uuid : ''; + const isSidechain: boolean = parsed.isSidechain === true; + + const events: SessionActivityEvent[] = []; + + for (const block of message.content) { + if (!block || block.type !== 'tool_use') continue; + const toolName: string = typeof block.name === 'string' ? block.name : 'Unknown'; + const input: Record | undefined = block.input && typeof block.input === 'object' ? block.input : undefined; + const path = extractPath(toolName, input); + const toolUseId: string = typeof block.id === 'string' ? block.id : ''; + + events.push({ + sessionId, + cwd, + tool: toolName, + kind: classifyTool(toolName), + path, + at, + id: `${baseUuid}:${toolUseId}`, + isSidechain, + }); + } + + return events; +} + +/** + * Create a watcher that tails every JSONL file in one or more project + * directories and emits activity events as new tool_use entries appear. + * + * @param dirs Absolute paths to project directories (the `` dirs + * under `~/.claude/projects/`). Use one for project-only scope, or pass + * every subdirectory for machine-wide scope. + */ +export function createSessionWatcher(dirs: string[]): WatcherHandle { + const emitter = new EventEmitter(); + // Practically unlimited — SSE responses attach a listener each and we'd + // rather not accidentally cap simultaneous clients at 10. + emitter.setMaxListeners(0); + + /** path -> watched state */ + const watched = new Map(); + let events = 0; + let stopped = false; + + function discoverFiles(): void { + for (const dir of dirs) { + if (!existsSync(dir)) continue; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith('.jsonl')) continue; + const full = join(dir, entry); + if (watched.has(full)) continue; + + let size = 0; + try { + size = statSync(full).size; + } catch { + continue; + } + // On first discovery of a session file, start at the current size so + // we don't replay the whole session history. The live graph only + // cares about what happens from "now" forward. Replay of past + // sessions will be a separate mode later (time scrub). + watched.set(full, { + path: full, + offset: size, + buffer: '', + sessionId: entry.replace(/\.jsonl$/, ''), + lastGrowthAt: Date.now(), + endedFired: false, + }); + } + } + } + + function pollFile(state: WatchedFile): void { + let size: number; + try { + size = statSync(state.path).size; + } catch { + // File was deleted or rotated — drop it, re-discovery will add it back + // if it reappears. + watched.delete(state.path); + return; + } + if (size === state.offset) { + // No growth — check for session-end timeout. + if (!state.endedFired && Date.now() - state.lastGrowthAt > SESSION_IDLE_TIMEOUT_MS) { + state.endedFired = true; + emitter.emit('session-ended', { + sessionId: state.sessionId, + idleMs: Date.now() - state.lastGrowthAt, + } satisfies SessionEndedEvent); + } + return; + } + // File grew — reset idle timer and re-arm session-end detection. + state.lastGrowthAt = Date.now(); + state.endedFired = false; + if (size < state.offset) { + // File truncated (rare — probably a Claude Code reset). Reset offset. + state.offset = 0; + state.buffer = ''; + } + + const readLen = Math.min(size - state.offset, MAX_READ_PER_POLL); + if (readLen <= 0) return; + + // Read a slice from offset. Node doesn't have a fs.readSync-with-offset + // helper that plays nicely here, so we read the whole file and slice. + // These files stay small enough (tens of MB max for long sessions) that + // this is fine. If profiling shows this hurts, switch to + // fs.createReadStream with { start: offset } and accumulate. + let chunk: string; + try { + const buf = readFileSync(state.path); + chunk = buf.subarray(state.offset, state.offset + readLen).toString('utf-8'); + } catch { + return; + } + state.offset += readLen; + + const combined = state.buffer + chunk; + const lines = combined.split(/\r?\n/); + // Keep the trailing partial line for the next poll. + state.buffer = lines.pop() ?? ''; + + for (const line of lines) { + const parsed = parseLine(line); + for (const event of parsed) { + events++; + emitter.emit('event', event); + } + } + } + + const interval = setInterval(() => { + if (stopped) return; + discoverFiles(); + for (const state of watched.values()) { + pollFile(state); + } + }, POLL_INTERVAL_MS); + + // Discover immediately so the caller doesn't have to wait POLL_INTERVAL_MS + // before knowing how many files we're watching. + discoverFiles(); + + return { + stop(): void { + stopped = true; + clearInterval(interval); + emitter.removeAllListeners(); + watched.clear(); + }, + onEvent(listener): () => void { + emitter.on('event', listener); + return () => emitter.off('event', listener); + }, + onSessionEnded(listener): () => void { + emitter.on('session-ended', listener); + return () => emitter.off('session-ended', listener); + }, + watchedFileCount(): number { + return watched.size; + }, + eventCount(): number { + return events; + }, + }; +} + +/** + * Resolve the list of directories to watch given a scope and repo cwd. + * + * - `project`: the dir matching the requested cwd, with a fallback to the + * server process cwd if the requested one doesn't resolve. This matters + * when the graph was generated for one checkout (e.g. the main worktree) + * but the server is running in a sibling worktree — without the fallback + * we'd silently watch the wrong session files. + * - `machine`: every subdirectory of `~/.claude/projects/`. + */ +export function resolveWatchDirs(scope: 'project' | 'machine', cwd: string): string[] { + if (scope === 'project') { + const dirs = new Set(); + // Try the client-supplied cwd (usually the graph's repoPath) and also + // the server process cwd. For each, walk up ancestors until we find a + // matching Claude projects dir. This covers three cases together: + // - graph repoPath matches the worktree root → direct hit + // - dev server launched from `packages/ui` → ancestor walk finds root + // - graph generated for a sibling worktree → server cwd fallback hits + // the one we actually want + const primary = projectDirForCwdOrAncestor(cwd); + if (primary) dirs.add(primary); + const serverCwd = process.cwd(); + if (serverCwd && serverCwd !== cwd) { + const secondary = projectDirForCwdOrAncestor(serverCwd); + if (secondary) dirs.add(secondary); + } + return [...dirs]; + } + + // Machine-wide: enumerate every subdirectory of ~/.claude/projects. + const root = claudeProjectsDir(); + if (!existsSync(root)) return []; + try { + return readdirSync(root, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => join(root, e.name)); + } catch { + return []; + } +} diff --git a/packages/ui/src/lib/server/suggestions.ts b/packages/ui/src/lib/server/suggestions.ts new file mode 100644 index 0000000..c830ca3 --- /dev/null +++ b/packages/ui/src/lib/server/suggestions.ts @@ -0,0 +1,169 @@ +/** + * Suggestion engine — converts learning model scores into actionable + * CONTEXT.md improvement proposals. + * + * Pure functions: `(learningState, graph) → Suggestion[]`. No side effects. + * Each suggestion type is a separate strategy function so new types can be + * added without touching the core loop. + * + * See #83 for the issue. + */ + +import type { LearningState, NodeScore } from './learning.js'; +import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export type SuggestionType = + | 'add-routing' // folder missed by routing, Claude had to find it + | 'remove-stale' // folder in routing but never accessed + | 'document-tool' // repeated bash command should be in CONTEXT.md + | 'split-node' // folder gets too many reads, split into sub-nodes + | 'add-reference'; // repeated grep pattern → add cross-reference + +export type SuggestionStatus = 'pending' | 'approved' | 'dismissed' | 'snoozed'; + +export interface Suggestion { + id: string; + type: SuggestionType; + status: SuggestionStatus; + /** Urgency score — controls sort order. Higher = more prominent. */ + urgency: number; + /** Human-readable title. */ + title: string; + /** Why the contextualizer thinks this. */ + reason: string; + /** Folder path this suggestion pertains to. */ + path: string; + /** When this suggestion was generated. */ + createdAt: string; + /** When status was last changed. */ + updatedAt: string; + /** If snoozed, until when. */ + snoozedUntil?: string; +} + +// --------------------------------------------------------------------------- +// Strategy functions +// --------------------------------------------------------------------------- + +/** + * Detect folders that Claude reads/edits frequently but that don't have + * their own CONTEXT.md mentioned in the parent's routing. + */ +function addRoutingSuggestions(state: LearningState): Suggestion[] { + const suggestions: Suggestion[] = []; + + for (const [path, score] of Object.entries(state.nodes)) { + if (score.confidence >= 0.4 && score.signals.totalOps >= 3) { + suggestions.push({ + id: `add-routing:${path}`, + type: 'add-routing', + status: 'pending', + urgency: score.confidence + (score.urgency || 0), + title: `Add routing for ${path}`, + reason: `Claude accessed this folder ${score.signals.totalOps} times across ${score.signals.sessionsCount} session(s) (${score.signals.readCount} reads, ${score.signals.writeCount} writes). Confidence: ${(score.confidence * 100).toFixed(0)}%.`, + path, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + } + + return suggestions; +} + +/** + * Detect folders with high urgency from emotion signals. + */ +function urgentFixSuggestions(state: LearningState): Suggestion[] { + const suggestions: Suggestion[] = []; + + for (const [path, score] of Object.entries(state.nodes)) { + if (score.urgency > 1) { + suggestions.push({ + id: `urgent-fix:${path}`, + type: 'add-routing', + status: 'pending', + urgency: score.urgency, + title: `Fix routing for ${path} (${score.signals.emotionEvents} correction events)`, + reason: `Claude encountered problems in this folder: ${score.signals.emotionEvents} emotion events detected (corrections, hedges, or max_turns hits). Urgency score: ${score.urgency.toFixed(1)}.`, + path, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + } + + return suggestions; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate suggestions from learning state. + */ +export function generateSuggestions(state: LearningState): Suggestion[] { + const all = [ + ...addRoutingSuggestions(state), + ...urgentFixSuggestions(state), + ]; + + // Dedup by id (prefer higher urgency). + const byId = new Map(); + for (const s of all) { + const existing = byId.get(s.id); + if (!existing || s.urgency > existing.urgency) { + byId.set(s.id, s); + } + } + + // Sort by urgency descending. + return [...byId.values()].sort((a, b) => b.urgency - a.urgency); +} + +// --------------------------------------------------------------------------- +// Persistence (`.klonode/suggestions.json`) +// --------------------------------------------------------------------------- + +export interface SuggestionsFile { + suggestions: Suggestion[]; + generatedAt: string; +} + +export function saveSuggestions(repoPath: string, suggestions: Suggestion[]): void { + const klonodeDir = join(repoPath, '.klonode'); + if (!existsSync(klonodeDir)) mkdirSync(klonodeDir, { recursive: true }); + const data: SuggestionsFile = { + suggestions, + generatedAt: new Date().toISOString(), + }; + writeFileSync(join(klonodeDir, 'suggestions.json'), JSON.stringify(data, null, 2), 'utf-8'); +} + +export function loadSuggestions(repoPath: string): Suggestion[] { + const filePath = join(repoPath, '.klonode', 'suggestions.json'); + if (!existsSync(filePath)) return []; + try { + const data: SuggestionsFile = JSON.parse(readFileSync(filePath, 'utf-8')); + return data.suggestions || []; + } catch { + return []; + } +} + +export function updateSuggestionStatus( + repoPath: string, + suggestionId: string, + status: SuggestionStatus, + snoozedUntil?: string, +): void { + const suggestions = loadSuggestions(repoPath); + const idx = suggestions.findIndex(s => s.id === suggestionId); + if (idx === -1) return; + suggestions[idx].status = status; + suggestions[idx].updatedAt = new Date().toISOString(); + if (snoozedUntil) suggestions[idx].snoozedUntil = snoozedUntil; + saveSuggestions(repoPath, suggestions); +} diff --git a/packages/ui/src/lib/stores/activity.ts b/packages/ui/src/lib/stores/activity.ts index 00a369e..f496f87 100644 --- a/packages/ui/src/lib/stores/activity.ts +++ b/packages/ui/src/lib/stores/activity.ts @@ -20,8 +20,18 @@ export interface ActivityEvent { at: number; } -/** How long a node stays highlighted after a tool call (ms). */ -export const ACTIVITY_LIFETIME_MS = 2500; +/** How long a node stays highlighted after a tool call (ms). + * + * Tuned for the JSONL tail watcher introduced in #77. The legacy chat + * streaming handler pushed events with near-zero latency, so 2.5s was + * enough. The watcher has measurable end-to-end delay: JSONL append → + * 500ms poll tick → SSE → client → `recordActivity`, plus whatever time + * passes before the user's eye lands on the pulsing node. Verified + * against the current Claude Code session: observed event ages of 5+ + * seconds between tool use and graph render. 8s gives a visible pulse + * even on the slow edge without cluttering the graph with everything + * from the last 20s. */ +export const ACTIVITY_LIFETIME_MS = 8000; const MAX_RECENT = 50; let nextId = 0; @@ -72,7 +82,7 @@ function normalizePath(input: string, repoPath: string): string { } /** - * Record a tool activity event. Called from the chat streaming handler. + * Record a tool activity event. Called from the session watcher client. */ export function recordActivity(tool: string, input: string, repoPath: string = ''): void { const path = normalizePath(input, repoPath); @@ -98,14 +108,34 @@ export function clearActivity(): void { /** * Derived map from path → {kind, timestamp} for active (not-yet-expired) events. * Components subscribe to this to highlight nodes. + * + * NOTE on the break vs. continue fix: we used to `break` when we hit the + * first expired event, on the assumption that `recent` was sorted + * newest-first by `at`. But `recordActivity` prepends via + * `[event, ...s.recent]` which keeps insertion order, not timestamp order — + * so a single out-of-order event (e.g. two events that arrive via SSE in + * the same tick but with slightly drifted timestamps) would terminate the + * loop early and drop every subsequent active event. Using `continue` + * costs one extra iteration per expired entry (cheap, bounded by + * MAX_RECENT) and is correct regardless of ordering. */ +// Module-level interval handle so consecutive derived re-runs don't stack +// timers. Every time `activityStore` emits, Svelte calls the derived body +// again — and if we don't clear the previous interval here, each run +// accumulates a new timer that closes over a stale `$s.recent`. Those +// stale timers then clobber the fresh ones with empty activity maps, +// which is why pulse rings never rendered even though events were +// arriving correctly. The cleanup arrow is still returned for the +// last-subscriber-leaves case, but we also defensively clear here. +let _activeNodePathsInterval: ReturnType | null = null; + export const activeNodePaths = derived(activityStore, ($s, set) => { function computeActive() { const now = Date.now(); const active = new Map(); for (const event of $s.recent) { if (!event.path) continue; - if (now - event.at > ACTIVITY_LIFETIME_MS) break; // recent is sorted newest-first + if (now - event.at > ACTIVITY_LIFETIME_MS) continue; // Keep the newest event per path if (!active.has(event.path)) { active.set(event.path, { kind: event.kind, at: event.at }); @@ -121,10 +151,20 @@ export const activeNodePaths = derived(activityStore, ($s, set) => { } set(active); } + if (_activeNodePathsInterval) { + clearInterval(_activeNodePathsInterval); + _activeNodePathsInterval = null; + } computeActive(); - // Re-compute every 250ms so expired events fade out - const interval = setInterval(computeActive, 250); - return () => clearInterval(interval); + // Re-compute every 250ms so expired events fade out even when the store + // isn't being updated. + _activeNodePathsInterval = setInterval(computeActive, 250); + return () => { + if (_activeNodePathsInterval) { + clearInterval(_activeNodePathsInterval); + _activeNodePathsInterval = null; + } + }; }, new Map()); /** diff --git a/packages/ui/src/lib/stores/agents.ts b/packages/ui/src/lib/stores/agents.ts deleted file mode 100644 index af99be0..0000000 --- a/packages/ui/src/lib/stores/agents.ts +++ /dev/null @@ -1,502 +0,0 @@ -/** - * Sessions store — manages multiple concurrent CLI chat sessions. - * Each session is an independent chat with full Klonode routing. - * Context routing happens automatically — user just picks which session to talk to. - */ - -import { writable, derived, get } from 'svelte/store'; - -export type ContextDepth = 'minimal' | 'light' | 'standard' | 'heavy' | 'full'; - -export interface ChatSession { - id: string; - /** Short label for the tab (auto-generated from first message, or "Chat N") */ - label: string; - /** When the session was created */ - createdAt: Date; - /** Whether this is the CO (Chief Organizer) session */ - isCO?: boolean; -} - -/** Tracked file operations from CLI tool use */ -export interface FileOperation { - type: 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'bash'; - path?: string; - /** For grep: the pattern searched */ - pattern?: string; - /** For bash: the command run */ - command?: string; -} - -/** Metadata about what happened in a session (for CO analysis) */ -export interface SessionMeta { - /** Files that were read during this session */ - filesRead: string[]; - /** Files that were written or edited */ - filesChanged: string[]; - /** Commands that were run */ - commandsRun: string[]; - /** All file operations in order */ - operations: FileOperation[]; - /** Total tokens used across all messages */ - totalTokens: number; - /** Total cost */ - totalCost: number; - /** Estimated context usage (tokens) for CO */ - contextUsage?: number; -} - -export interface SessionMessage { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - tokens?: { input: number; output: number; total: number; costUsd?: number; numTurns?: number; elapsed?: number }; - timestamp: Date; - loading?: boolean; - routedFiles?: string[]; - isPlan?: boolean; - planContext?: { query: string; context: string; files: string[]; folderPaths: string[]; repoPath: string }; - /** File operations extracted from this message's CLI response */ - fileOps?: FileOperation[]; - /** - * True if this message was mid-stream (`loading: true`) when the app was - * reloaded. Set on hydrate so the UI can render a "response interrupted by - * reload" indicator instead of a spinner that never resolves. Cleared on - * the next successful assistant response. - */ - interrupted?: boolean; -} - -interface SessionsState { - sessions: ChatSession[]; - activeSessionId: string; - messages: Record; - metadata: Record; - contextDepth: ContextDepth; - nextNum: number; - /** CO memory content (persisted) */ - coMemory: string; - /** Closed session summaries waiting for CO to process */ - closedSessionQueue: { label: string; meta: SessionMeta; messageCount: number; summary: string }[]; - /** - * Klonode session tab ID → Claude CLI session ID. Persisted so that after a - * page reload or Vite server restart the next message in a tab resumes the - * same Claude conversation instead of spawning a fresh one. See - * self-hosting session survival work (#53 follow-up / #65 sibling). - */ - cliSessionIds: Record; -} - -const STORAGE_KEY = 'klonode-sessions'; - -/** - * Maximum messages kept per session in localStorage. When a session exceeds - * this, the oldest messages are dropped at save time. Keeps the storage - * footprint bounded so a long conversation doesn't blow through the ~5 MB - * localStorage quota. - */ -const MAX_PERSISTED_MESSAGES_PER_SESSION = 50; - -function makeId(): string { - return `s-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; -} - -function createCO(): ChatSession { - return { - id: 'co', - label: 'CO', - createdAt: new Date(), - isCO: true, - }; -} - -function createSession(num: number): ChatSession { - return { - id: makeId(), - label: `Chat ${num}`, - createdAt: new Date(), - }; -} - -function emptyMeta(): SessionMeta { - return { filesRead: [], filesChanged: [], commandsRun: [], operations: [], totalTokens: 0, totalCost: 0 }; -} - -function loadState(): SessionsState { - const co = createCO(); - const first = createSession(1); - const defaults: SessionsState = { - sessions: [co, first], - activeSessionId: first.id, - messages: {}, - metadata: {}, - contextDepth: 'standard', - nextNum: 2, - coMemory: '', - closedSessionQueue: [], - cliSessionIds: {}, - }; - - if (typeof localStorage === 'undefined') return defaults; - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const saved = JSON.parse(raw); - if (saved.sessions?.length > 0) { - const hasCO = saved.sessions.some((s: ChatSession) => s.isCO); - if (!hasCO) saved.sessions.unshift(co); - // Rehydrate Date objects that JSON.stringify turned into strings on - // save — otherwise any code that calls .toISOString() on a message - // timestamp throws after a reload. - const rehydratedMessages: Record = {}; - for (const [sid, list] of Object.entries(saved.messages || {})) { - rehydratedMessages[sid] = (list as SessionMessage[]).map(m => ({ - ...m, - timestamp: new Date(m.timestamp as unknown as string), - // A message flagged as loading at save time must have been - // interrupted by the reload — mark it so the UI can render a - // "this response was interrupted" indicator instead of a spinner - // that never resolves. - loading: false, - interrupted: m.loading === true || (m as SessionMessage & { interrupted?: boolean }).interrupted === true, - } as SessionMessage)); - } - return { - ...defaults, ...saved, - messages: rehydratedMessages, - metadata: saved.metadata || {}, - closedSessionQueue: saved.closedSessionQueue || [], - coMemory: saved.coMemory || '', - cliSessionIds: saved.cliSessionIds || {}, - }; - } - } - } catch { /* ignore */ } - return defaults; -} - -function saveState(state: SessionsState): void { - if (typeof localStorage === 'undefined') return; - // Persist messages with a per-session cap so a long conversation doesn't - // blow through the localStorage quota. Keeps the last N messages per - // session, drops everything older. Session list, CLI session IDs, metadata, - // and CO memory are all persisted in full. - const cappedMessages: Record = {}; - for (const [sid, list] of Object.entries(state.messages)) { - if (list.length > MAX_PERSISTED_MESSAGES_PER_SESSION) { - cappedMessages[sid] = list.slice(-MAX_PERSISTED_MESSAGES_PER_SESSION); - } else { - cappedMessages[sid] = list; - } - } - const toSave = { ...state, messages: cappedMessages }; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); - } catch (err) { - // QuotaExceededError — drop messages and try again with session list - // only. Better to lose the backlog than to silently fail to persist the - // session list and CLI session IDs (which are the critical path for - // self-hosting survival). - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...toSave, messages: {} })); - } catch { /* nothing more we can do */ } - } -} - -export const sessionsStore = writable(loadState()); - -let skipSave = false; -sessionsStore.subscribe(s => { - if (!skipSave) saveState(s); -}); - -// For backwards compatibility with ChatPanel imports -export const agentsStore = derived(sessionsStore, $s => ({ - agents: $s.sessions.map(s => ({ - id: s.id, - name: s.label, - role: 'worker' as const, - description: '', - contextPaths: ['*'], - toolPermissions: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], - maxTurns: 25, - defaultContextDepth: $s.contextDepth, - icon: '', - color: '', - tools: [], - })), - activeAgentId: $s.activeSessionId, -})); - -export const activeSession = derived(sessionsStore, $s => - $s.sessions.find(s => s.id === $s.activeSessionId) || $s.sessions[0] -); - -export const activeMessages = derived(sessionsStore, $s => - $s.messages[$s.activeSessionId] || [] -); - -export const contextDepth = derived(sessionsStore, $s => $s.contextDepth); - -export function setActiveSession(sessionId: string): void { - sessionsStore.update(s => ({ ...s, activeSessionId: sessionId })); -} - -// Alias for ChatPanel compatibility -export function setActiveAgent(sessionId: string): void { - setActiveSession(sessionId); -} - -export function setContextDepth(depth: ContextDepth): void { - sessionsStore.update(s => ({ ...s, contextDepth: depth })); -} - -/** - * Look up the Claude CLI session ID associated with a Klonode tab, or - * `undefined` if there's no mapping yet (i.e. next message will start a - * fresh conversation). Persisted via `saveState` so reloads and server - * restarts preserve conversation continuity. - */ -export function getCliSessionId(klonodeSessionId: string): string | undefined { - return get(sessionsStore).cliSessionIds[klonodeSessionId]; -} - -/** - * Record the Claude CLI session ID for a Klonode tab. Called whenever a - * streaming response emits its session id so follow-up messages in the - * same tab resume the same Claude conversation. - */ -export function setCliSessionId(klonodeSessionId: string, cliSessionId: string): void { - sessionsStore.update(s => ({ - ...s, - cliSessionIds: { ...s.cliSessionIds, [klonodeSessionId]: cliSessionId }, - })); -} - -/** - * Drop the CLI session ID mapping for a tab. Called when the user clears - * the chat so the next message starts fresh. - */ -export function clearCliSessionId(klonodeSessionId: string): void { - sessionsStore.update(s => { - if (!(klonodeSessionId in s.cliSessionIds)) return s; - const next = { ...s.cliSessionIds }; - delete next[klonodeSessionId]; - return { ...s, cliSessionIds: next }; - }); -} - -/** - * Clear the `interrupted` flag on a message once a new response has - * successfully arrived for the same session. The flag is set at hydrate - * time (see loadState) for any message that was `loading: true` when the - * page unloaded. - */ -export function clearInterruptedFlag(sessionId: string, messageId: string): void { - sessionsStore.update(s => { - const list = s.messages[sessionId]; - if (!list) return s; - const idx = list.findIndex(m => m.id === messageId); - if (idx === -1 || !list[idx].interrupted) return s; - const nextList = list.slice(); - nextList[idx] = { ...nextList[idx], interrupted: false }; - return { ...s, messages: { ...s.messages, [sessionId]: nextList } }; - }); -} - -export function addSession(): string { - let newId = ''; - sessionsStore.update(s => { - const session = createSession(s.nextNum); - newId = session.id; - return { - ...s, - sessions: [...s.sessions, session], - activeSessionId: session.id, - nextNum: s.nextNum + 1, - }; - }); - return newId; -} - -export function removeSession(sessionId: string): void { - sessionsStore.update(s => { - const session = s.sessions.find(sess => sess.id === sessionId); - if (!session || session.isCO) return s; - if (s.sessions.length <= 2) return s; - - // Capture session data for CO before removing - const sessionMessages = s.messages[sessionId] || []; - const meta = s.metadata[sessionId] || emptyMeta(); - - // Build a summary of what happened in this session - const userMsgs = sessionMessages.filter(m => m.role === 'user').map(m => m.content); - const summary = [ - `Sesjon: "${session.label}"`, - `Meldinger: ${sessionMessages.length}`, - `Tokens: ${meta.totalTokens}`, - meta.filesRead.length > 0 ? `Leste filer: ${meta.filesRead.join(', ')}` : '', - meta.filesChanged.length > 0 ? `Endrede filer: ${meta.filesChanged.join(', ')}` : '', - meta.commandsRun.length > 0 ? `Kommandoer: ${meta.commandsRun.join(', ')}` : '', - userMsgs.length > 0 ? `Oppgaver: ${userMsgs.map(m => m.slice(0, 80)).join(' | ')}` : '', - ].filter(Boolean).join('\n'); - - const remaining = s.sessions.filter(sess => sess.id !== sessionId); - const newMessages = { ...s.messages }; - delete newMessages[sessionId]; - const newMeta = { ...s.metadata }; - delete newMeta[sessionId]; - - return { - ...s, - sessions: remaining, - activeSessionId: s.activeSessionId === sessionId ? remaining[remaining.length - 1].id : s.activeSessionId, - messages: newMessages, - metadata: newMeta, - closedSessionQueue: [...s.closedSessionQueue, { - label: session.label, - meta, - messageCount: sessionMessages.length, - summary, - }], - }; - }); -} - -export function renameSession(sessionId: string, label: string): void { - sessionsStore.update(s => ({ - ...s, - sessions: s.sessions.map(sess => - sess.id === sessionId ? { ...sess, label } : sess - ), - })); -} - -/** Auto-label a session from its first user message */ -export function autoLabelSession(sessionId: string, firstMessage: string): void { - const label = firstMessage.slice(0, 30) + (firstMessage.length > 30 ? '...' : ''); - renameSession(sessionId, label); -} - -export function addSessionMessage(sessionId: string, msg: SessionMessage): void { - skipSave = true; - sessionsStore.update(s => { - const existing = s.messages[sessionId] || []; - return { ...s, messages: { ...s.messages, [sessionId]: [...existing, msg] } }; - }); - skipSave = false; -} - -export function updateSessionMessage(sessionId: string, msgId: string, update: Partial): void { - skipSave = true; - sessionsStore.update(s => { - const existing = s.messages[sessionId] || []; - return { - ...s, - messages: { - ...s.messages, - [sessionId]: existing.map(m => m.id === msgId ? { ...m, ...update } : m), - }, - }; - }); - skipSave = false; -} - -export function clearSessionMessages(sessionId: string): void { - sessionsStore.update(s => ({ - ...s, - messages: { ...s.messages, [sessionId]: [] }, - })); -} - -/** Track a file operation in a session's metadata */ -export function trackFileOp(sessionId: string, op: FileOperation): void { - sessionsStore.update(s => { - const meta = s.metadata[sessionId] || emptyMeta(); - meta.operations.push(op); - - if (op.path) { - if (op.type === 'read' || op.type === 'glob' || op.type === 'grep') { - if (!meta.filesRead.includes(op.path)) meta.filesRead.push(op.path); - } else if (op.type === 'write' || op.type === 'edit') { - if (!meta.filesChanged.includes(op.path)) meta.filesChanged.push(op.path); - } - } - if (op.type === 'bash' && op.command) { - meta.commandsRun.push(op.command.slice(0, 100)); - } - - return { ...s, metadata: { ...s.metadata, [sessionId]: meta } }; - }); -} - -/** Update token totals for a session */ -export function trackSessionTokens(sessionId: string, tokens: number, cost: number): void { - sessionsStore.update(s => { - const meta = s.metadata[sessionId] || emptyMeta(); - meta.totalTokens += tokens; - meta.totalCost += cost; - return { ...s, metadata: { ...s.metadata, [sessionId]: meta } }; - }); -} - -/** Get CO memory content */ -export function getCOMemory(): string { - const state = get(sessionsStore); - return state.coMemory; -} - -/** Update CO memory */ -export function setCOMemory(content: string): void { - sessionsStore.update(s => ({ ...s, coMemory: content })); -} - -/** Get and clear the closed session queue */ -export function popClosedSessions(): typeof get extends (s: any) => infer R ? R extends { closedSessionQueue: infer Q } ? Q : never : never { - let queue: any[] = []; - sessionsStore.update(s => { - queue = s.closedSessionQueue; - return { ...s, closedSessionQueue: [] }; - }); - return queue; -} - -/** Get closed session queue without clearing */ -export const closedSessionQueue = derived(sessionsStore, $s => $s.closedSessionQueue); - -/** Get metadata for a session */ -export const activeSessionMeta = derived(sessionsStore, $s => - $s.metadata[$s.activeSessionId] || emptyMeta() -); - -/** Estimate CO context usage in tokens */ -export const coContextUsage = derived(sessionsStore, $s => { - const coMessages = $s.messages['co'] || []; - let tokens = 0; - for (const msg of coMessages) { - // Rough estimate: 4 chars per token - tokens += Math.ceil(msg.content.length / 4); - if (msg.tokens) tokens += msg.tokens.total; - } - // Add CO memory - tokens += Math.ceil($s.coMemory.length / 4); - return tokens; -}); - -/** Max context for CO (1M tokens) */ -export const CO_MAX_CONTEXT = 1_000_000; - -// Keep old exports for compatibility -export function setAgents(_agents: any[]): void { - // No-op — sessions are not generated from graph agents anymore -} - -export type AgentDef = ChatSession & { role: string; icon: string; color: string; description: string; contextPaths: string[]; toolPermissions: string[]; maxTurns: number; defaultContextDepth: ContextDepth; tools: string[] }; - -export const CONTEXT_DEPTH_LABELS: Record = { - minimal: { name: 'Minimal', desc: 'Bare CLAUDE.md' }, - light: { name: 'Lett', desc: 'Root + L1 routing' }, - standard: { name: 'Standard', desc: 'Root + relevante mapper' }, - heavy: { name: 'Tung', desc: 'Alt + referanser + deps' }, - full: { name: 'Full', desc: 'Hele prosjektet' }, -}; diff --git a/packages/ui/src/lib/stores/chat.ts b/packages/ui/src/lib/stores/chat.ts deleted file mode 100644 index d9392eb..0000000 --- a/packages/ui/src/lib/stores/chat.ts +++ /dev/null @@ -1,598 +0,0 @@ -/** - * Chat store — manages Claude conversations with Klonode routing. - * - * Default mode: normal chat with Klonode routing in the background. - * Compare mode: sends both with and without Klonode to show token savings. - */ - -import { writable, get } from 'svelte/store'; -import { graphStore } from './graph'; -import { settingsStore } from './settings'; -import { sessionsStore } from './agents'; - -export interface ChatMessage { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - mode?: 'with-klonode' | 'without-klonode'; - tokens?: { - input: number; - output: number; - total: number; - numTurns?: number; - elapsed?: number; - costUsd?: number; - }; - timestamp: Date; - loading?: boolean; - /** Which files were routed for this response */ - routedFiles?: string[]; - /** If this is a plan awaiting approval */ - isPlan?: boolean; - /** The original query + context for re-executing after plan approval */ - planContext?: { query: string; context: string; files: string[]; folderPaths: string[]; repoPath: string }; - /** - * True if this message was mid-stream (`loading: true`) when the app - * was reloaded. Set at hydrate time so the UI can render a "response - * interrupted by reload" indicator instead of a spinner that never - * resolves. - */ - interrupted?: boolean; -} - -export interface ChatComparison { - withKlonode: ChatMessage | null; - withoutKlonode: ChatMessage | null; -} - -export interface ChatState { - messages: ChatMessage[]; - isLoading: boolean; - lastComparison: ChatComparison | null; - error: string | null; - /** 'chat' = normal working chat, 'compare' = side-by-side comparison */ - chatMode: 'chat' | 'compare'; -} - -const initial: ChatState = { - messages: [], - isLoading: false, - lastComparison: null, - error: null, - chatMode: 'chat', -}; - -/** - * Self-hosting survival: persist the user-facing chat conversation to - * localStorage so reloads (including Vite server restarts triggered by - * editing a server-side file) preserve the active chat. Cap the persisted - * list so a long conversation doesn't blow through the localStorage quota, - * and leave `isLoading` / `error` / `lastComparison` out of the snapshot - * since they're transient UI state. - */ -const CHAT_STORAGE_KEY = 'klonode-chat'; -const MAX_PERSISTED_CHAT_MESSAGES = 80; - -function loadChatState(): ChatState { - if (typeof localStorage === 'undefined') return initial; - try { - const raw = localStorage.getItem(CHAT_STORAGE_KEY); - if (!raw) return initial; - const saved = JSON.parse(raw) as Partial; - const messages = Array.isArray(saved.messages) ? saved.messages : []; - // Rehydrate Date timestamps (JSON.stringify turns Date into an ISO - // string). Any message that was `loading: true` at save time must have - // been interrupted by the reload — mark it so the UI renders a - // "response interrupted" indicator instead of a spinner that never - // resolves. - const rehydrated: ChatMessage[] = messages.map((m: ChatMessage & { interrupted?: boolean }) => ({ - ...m, - timestamp: new Date(m.timestamp as unknown as string), - loading: false, - interrupted: m.loading === true || m.interrupted === true, - })); - return { - ...initial, - messages: rehydrated, - chatMode: saved.chatMode === 'compare' ? 'compare' : 'chat', - }; - } catch { - return initial; - } -} - -function saveChatState(state: ChatState): void { - if (typeof localStorage === 'undefined') return; - const capped = - state.messages.length > MAX_PERSISTED_CHAT_MESSAGES - ? state.messages.slice(-MAX_PERSISTED_CHAT_MESSAGES) - : state.messages; - const toSave = { messages: capped, chatMode: state.chatMode }; - try { - localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toSave)); - } catch { - // QuotaExceededError — drop messages entirely rather than lose the mode - try { - localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ messages: [], chatMode: state.chatMode })); - } catch { /* nothing more we can do */ } - } -} - -export const chatStore = writable(loadChatState()); - -// Auto-persist on every change. The update is synchronous and cheap -// relative to a JSON.stringify of under 100 messages so we don't bother -// debouncing. -chatStore.subscribe(saveChatState); - -/** - * Route the query through the graph to find relevant CONTEXT.md files. - * Returns the routed context string and list of file paths. - */ -function routeQuery(query: string): { context: string; files: string[]; folderPaths: string[]; repoPath: string } { - const graph = get(graphStore); - if (!graph) return { context: 'Ingen graf lastet.', files: [], folderPaths: [], repoPath: '' }; - - const parts: string[] = []; - const files: string[] = []; - const queryLower = query.toLowerCase(); - - // Always include root CONTEXT.md (L0/L1) - const root = graph.nodes.get(graph.rootNodeId); - if (root?.contextFile?.rawMarkdown) { - parts.push(`## ${root.path}/CONTEXT.md\n${root.contextFile.rawMarkdown}`); - files.push(root.path + '/CONTEXT.md'); - } - - // Score each L1 node against the query - const scored: { id: string; score: number }[] = []; - for (const childId of root?.children || []) { - const node = graph.nodes.get(childId); - if (!node) continue; - - let score = 0; - const nameLower = node.name.toLowerCase(); - const summaryLower = (node.summary || '').toLowerCase(); - - // Direct name match - if (queryLower.includes(nameLower)) score += 10; - // Keyword matching - const keywords = queryLower.split(/\s+/); - for (const kw of keywords) { - if (nameLower.includes(kw)) score += 5; - if (summaryLower.includes(kw)) score += 3; - } - // Domain keyword mapping - const DOMAIN_MAP: Record = { - game: ['game', 'physics', 'combat', 'player', 'world', 'map', 'enemy', 'level'], - api: ['api', 'endpoint', 'route', 'server', 'rest', 'request'], - components: ['component', 'ui', 'button', 'form', 'layout'], - auth: ['auth', 'login', 'session', 'token', 'bruker', 'user', 'password'], - prisma: ['database', 'db', 'prisma', 'schema', 'migration', 'query', 'sql'], - app: ['app', 'page', 'routing', 'navigation'], - public: ['asset', 'image', 'model', 'texture', '3d', 'bilde'], - scripts: ['script', 'agent', 'scraper', 'automation'], - lib: ['lib', 'util', 'helper', 'shared'], - types: ['type', 'interface', 'typing'], - }; - for (const [domain, domainKws] of Object.entries(DOMAIN_MAP)) { - if (nameLower.includes(domain)) { - for (const dk of domainKws) { - if (queryLower.includes(dk)) score += 8; - } - } - } - - if (score > 0) scored.push({ id: childId, score }); - } - - // Sort by score, take top matches - scored.sort((a, b) => b.score - a.score); - const topMatches = scored.slice(0, 3); - - // Collect folder paths for actual source file reading - const folderPaths: string[] = []; - - // Add matched nodes and their children - for (const match of topMatches) { - const node = graph.nodes.get(match.id); - if (!node) continue; - folderPaths.push(node.path); - if (node.contextFile?.rawMarkdown) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - } - // Also include relevant children - for (const childId of node.children) { - const child = graph.nodes.get(childId); - if (!child) continue; - const childNameLower = child.name.toLowerCase(); - const isRelevant = queryLower.split(/\s+/).some(kw => childNameLower.includes(kw)); - if (isRelevant && child.contextFile?.rawMarkdown) { - folderPaths.push(child.path); - parts.push(`## ${child.path}/CONTEXT.md\n${child.contextFile.rawMarkdown}`); - files.push(child.path + '/CONTEXT.md'); - } - } - } - - // CROSS-LAYER ROUTING: follow dependency edges to connected folders - const includedPaths = new Set(folderPaths); - for (const match of topMatches) { - const matchNode = graph.nodes.get(match.id); - if (!matchNode) continue; - // Follow outgoing dependencies (what this folder imports from) - const outEdges = graph.edges.filter(e => e.from === match.id && e.type === 'depends_on'); - for (const edge of outEdges.slice(0, 2)) { - const depNode = graph.nodes.get(edge.to); - if (depNode && depNode.contextFile?.rawMarkdown && !includedPaths.has(depNode.path)) { - includedPaths.add(depNode.path); - folderPaths.push(depNode.path); - parts.push(`## ${depNode.path}/CONTEXT.md\n${depNode.contextFile.rawMarkdown}`); - files.push(depNode.path + '/CONTEXT.md'); - } - } - } - - // If no matches, include a broad overview (top 3 by file count) - if (topMatches.length === 0) { - const allChildren = (root?.children || []) - .map(id => graph.nodes.get(id)) - .filter(Boolean) - .sort((a, b) => (b!.children.length - a!.children.length)) - .slice(0, 3); - for (const node of allChildren) { - if (node) folderPaths.push(node.path); - if (node?.contextFile?.rawMarkdown) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - files.push(node.path + '/CONTEXT.md'); - } - } - } - - return { context: parts.join('\n\n---\n\n'), files, folderPaths, repoPath: graph.repoPath }; -} - -/** - * Build context string from ALL nodes (without-Klonode mode for comparison). - */ -function buildFullContext(): { context: string; fileCount: number } { - const graph = get(graphStore); - if (!graph) return { context: 'Ingen graf lastet.', fileCount: 0 }; - - const parts: string[] = []; - let fileCount = 0; - - parts.push('## Prosjektstruktur'); - const treeLines: string[] = []; - function walkTree(nodeId: string, depth: number) { - const node = graph!.nodes.get(nodeId); - if (!node) return; - treeLines.push(`${' '.repeat(depth)}${node.name}/ — ${node.summary || ''}`); - for (const childId of node.children) { - walkTree(childId, depth + 1); - } - } - walkTree(graph.rootNodeId, 0); - parts.push(treeLines.join('\n')); - - for (const [, node] of graph.nodes) { - if (node.contextFile?.rawMarkdown) { - parts.push(`## ${node.path}/CONTEXT.md\n${node.contextFile.rawMarkdown}`); - fileCount++; - } - } - - return { context: parts.join('\n\n---\n\n'), fileCount }; -} - -function makeId(): string { - return Math.random().toString(36).slice(2, 10); -} - -/** - * Auto-classify a query by asking Claude to interpret the intent. - * Returns 'question', 'plan', or 'bypass'. - */ -async function classifyQuery(query: string): Promise<'question' | 'plan' | 'bypass'> { - const settings = get(settingsStore); - - try { - const res = await fetch('/api/chat/classify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: query, - connectionMode: settings.connectionMode, - cliPath: settings.cliPath, - apiKey: settings.apiKey, - }), - }); - const data = await res.json(); - const mode = data.mode; - if (mode === 'question' || mode === 'plan' || mode === 'bypass') { - console.log(`[Klonode] Auto-classified as: ${mode}`); - return mode; - } - } catch (err) { - console.warn('[Klonode] Classification failed, defaulting to bypass:', err); - } - return 'bypass'; -} - -/** - * Send a normal chat message — routes context via Klonode automatically. - * In plan mode: first generates a read-only plan. In bypass mode: executes directly with full permissions. - */ -/** - * Check if the current active session is the CO (Chief Organizer). - */ -function isActiveSessionCO(): boolean { - const state = get(sessionsStore); - const active = state.sessions.find(s => s.id === state.activeSessionId); - return active?.isCO === true; -} - -export async function sendMessage(userMessage: string): Promise { - const settings = get(settingsStore); - const isCO = isActiveSessionCO(); - - if (settings.connectionMode === 'cli' && !settings.cliPath) { - chatStore.update(s => ({ ...s, error: 'Claude CLI-sti mangler. Klikk innstillinger.' })); - return; - } - if (settings.connectionMode === 'api' && !settings.apiKey) { - chatStore.update(s => ({ ...s, error: 'API-nøkkel mangler. Klikk innstillinger.' })); - return; - } - - const { context, files, folderPaths, repoPath } = routeQuery(userMessage); - - const userMsg: ChatMessage = { - id: makeId(), role: 'user', content: userMessage, - timestamp: new Date(), - }; - const loadingMsg: ChatMessage = { - id: makeId(), role: 'assistant', content: '', - loading: true, timestamp: new Date(), routedFiles: isCO ? [] : files, - }; - - chatStore.update(s => ({ - ...s, isLoading: true, error: null, - messages: [...s.messages, userMsg, loadingMsg], - })); - - try { - // CO always gets bypass mode with all tools — no classification needed - // Regular sessions resolve execution mode normally - const execMode = isCO - ? 'bypass' - : (settings.executionMode === 'auto' - ? await classifyQuery(userMessage) - : settings.executionMode); - console.log(`[Klonode] ${isCO ? 'CO' : 'Chat'} mode: ${execMode}`); - - // CO gets heavy context (or routing when relevant), regular sessions use normal routing - const chatContext = isCO ? '' : context; // CO's context comes from its system prompt - const data = await callChat(userMessage, chatContext, 'with-klonode', repoPath, folderPaths, execMode, isCO); - - const assistantMsg: ChatMessage = { - id: loadingMsg.id, - role: 'assistant', - content: data.text, - tokens: { input: data.inputTokens, output: data.outputTokens, total: data.totalTokens }, - timestamp: new Date(), - routedFiles: files, - // If plan mode, mark as plan awaiting approval - isPlan: execMode === 'plan', - planContext: execMode === 'plan' ? { query: userMessage, context, files, folderPaths, repoPath } : undefined, - }; - - chatStore.update(s => ({ - ...s, isLoading: false, - messages: s.messages.map(m => m.id === loadingMsg.id ? assistantMsg : m), - })); - } catch (err) { - chatStore.update(s => ({ - ...s, isLoading: false, - messages: s.messages.map(m => m.id === loadingMsg.id - ? { ...m, loading: false, content: `Feil: ${err instanceof Error ? err.message : 'Ukjent feil'}` } - : m), - })); - } -} - -/** - * Approve a plan and execute it with full bypass permissions. - */ -export async function approvePlan(planMessageId: string): Promise { - const state = get(chatStore); - const planMsg = state.messages.find(m => m.id === planMessageId); - if (!planMsg?.planContext) return; - - const { query, context, files, folderPaths, repoPath } = planMsg.planContext; - - // Mark plan as approved (no longer a pending plan) - chatStore.update(s => ({ - ...s, - messages: s.messages.map(m => m.id === planMessageId ? { ...m, isPlan: false } : m), - })); - - // Add loading message for execution - const loadingMsg: ChatMessage = { - id: makeId(), role: 'assistant', content: '', - loading: true, timestamp: new Date(), routedFiles: files, - }; - - chatStore.update(s => ({ - ...s, isLoading: true, error: null, - messages: [...s.messages, loadingMsg], - })); - - try { - // Execute the plan with bypass permissions — include the plan as context - const executePrompt = `Utfør denne planen:\n\n${planMsg.content}\n\nOpprinnelig spørsmål: ${query}`; - const data = await callChat(executePrompt, context, 'with-klonode', repoPath, folderPaths, 'bypass'); - - const resultMsg: ChatMessage = { - id: loadingMsg.id, - role: 'assistant', - content: data.text, - tokens: { input: data.inputTokens, output: data.outputTokens, total: data.totalTokens }, - timestamp: new Date(), - routedFiles: files, - }; - - chatStore.update(s => ({ - ...s, isLoading: false, - messages: s.messages.map(m => m.id === loadingMsg.id ? resultMsg : m), - })); - } catch (err) { - chatStore.update(s => ({ - ...s, isLoading: false, - messages: s.messages.map(m => m.id === loadingMsg.id - ? { ...m, loading: false, content: `Feil: ${err instanceof Error ? err.message : 'Ukjent feil'}` } - : m), - })); - } -} - -/** - * Send comparison — fires both with and without Klonode in parallel. - */ -export async function sendComparison(userMessage: string): Promise { - const settings = get(settingsStore); - - if (settings.connectionMode === 'cli' && !settings.cliPath) { - chatStore.update(s => ({ ...s, error: 'Claude CLI-sti mangler. Klikk innstillinger.' })); - return; - } - if (settings.connectionMode === 'api' && !settings.apiKey) { - chatStore.update(s => ({ ...s, error: 'API-nøkkel mangler. Klikk innstillinger.' })); - return; - } - - const { context: routedContext, files, folderPaths, repoPath } = routeQuery(userMessage); - - const userMsg: ChatMessage = { - id: makeId(), role: 'user', content: userMessage, - timestamp: new Date(), - }; - const loadingWith: ChatMessage = { - id: makeId(), role: 'assistant', content: '', - mode: 'with-klonode', loading: true, timestamp: new Date(), routedFiles: files, - }; - const loadingWithout: ChatMessage = { - id: makeId(), role: 'assistant', content: '', - mode: 'without-klonode', loading: true, timestamp: new Date(), - }; - - chatStore.update(s => ({ - ...s, isLoading: true, error: null, - messages: [...s.messages, userMsg, loadingWith, loadingWithout], - lastComparison: null, - })); - - // Helper to build message from result - const toMsg = (result: { status: string; value?: any; reason?: any }, loadId: string, mode: 'with-klonode' | 'without-klonode'): ChatMessage => ({ - id: loadId, - role: 'assistant', - content: result.status === 'fulfilled' ? result.value.text : `Feil: ${result.reason}`, - mode, - tokens: result.status === 'fulfilled' ? { - input: result.value.inputTokens, - output: result.value.outputTokens, - total: result.value.totalTokens, - numTurns: result.value.numTurns, - elapsed: result.value.elapsed, - costUsd: result.value.costUsd, - } : undefined, - timestamp: new Date(), - routedFiles: mode === 'with-klonode' ? files : undefined, - }); - - // Run SEQUENTIALLY — without-Klonode FIRST so it gets no cache benefit from Klonode - // This ensures a fair comparison: without-Klonode starts cold, just like a normal user - const withoutResult = await callChat(userMessage, '', 'without-klonode', repoPath) - .then(r => ({ status: 'fulfilled' as const, value: r })) - .catch(e => ({ status: 'rejected' as const, reason: e })); - - // Update the without-Klonode message immediately so user sees progress - const withoutMsg = toMsg(withoutResult, loadingWithout.id, 'without-klonode'); - chatStore.update(s => ({ - ...s, - messages: s.messages.map(m => m.id === loadingWithout.id ? withoutMsg : m), - })); - - // Now run with-Klonode (may benefit from some cache, but that's OK — Klonode is the product) - const withResult = await callChat(userMessage, routedContext, 'with-klonode', repoPath, folderPaths) - .then(r => ({ status: 'fulfilled' as const, value: r })) - .catch(e => ({ status: 'rejected' as const, reason: e })); - - const withMsg = toMsg(withResult, loadingWith.id, 'with-klonode'); - - chatStore.update(s => ({ - ...s, - isLoading: false, - messages: s.messages.map(m => { - if (m.id === loadingWith.id) return withMsg; - if (m.id === loadingWithout.id) return withoutMsg; - return m; - }), - lastComparison: { withKlonode: withMsg, withoutKlonode: withoutMsg }, - })); -} - -async function callChat( - message: string, - context: string, - mode: 'with-klonode' | 'without-klonode', - repoPath?: string, - routedPaths?: string[], - executionMode?: 'question' | 'plan' | 'bypass', - isCO?: boolean, -): Promise<{ text: string; inputTokens: number; outputTokens: number; totalTokens: number; numTurns?: number; elapsed?: number; costUsd?: number }> { - const settings = get(settingsStore); - - const res = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message, - context, - connectionMode: settings.connectionMode, - cliPath: settings.cliPath, - apiKey: settings.apiKey, - model: settings.model, - maxTokens: settings.maxTokens, - mode, - repoPath, - routedPaths, - executionMode: executionMode || (settings.executionMode === 'auto' ? 'bypass' : settings.executionMode), - isCO: isCO || false, - }), - }); - - const data = await res.json(); - if (!res.ok || data.error) { - throw new Error(data.error || `HTTP ${res.status}`); - } - - return { - text: data.text, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - totalTokens: data.totalTokens, - numTurns: data.numTurns, - elapsed: data.elapsed, - costUsd: data.costUsd, - }; -} - -export function setChatMode(mode: 'chat' | 'compare'): void { - chatStore.update(s => ({ ...s, chatMode: mode })); -} - -export function clearChat(): void { - chatStore.set({ ...initial }); -} diff --git a/packages/ui/src/lib/stores/learning.ts b/packages/ui/src/lib/stores/learning.ts new file mode 100644 index 0000000..eb0eb22 --- /dev/null +++ b/packages/ui/src/lib/stores/learning.ts @@ -0,0 +1,76 @@ +/** + * Learning store — loads `.klonode/learning.json` into a client-side store + * so GraphView and TreeView can visualize confidence/urgency per node. + * + * Fetches on mount and after session-ended events. + */ +import { writable, derived } from 'svelte/store'; + +export interface NodeScore { + path: string; + confidence: number; + urgency: number; + signals: { + sessionsCount: number; + readCount: number; + writeCount: number; + totalOps: number; + grepPatterns: number; + emotionEvents: number; + }; +} + +export interface LearningState { + computedAt: string; + nodes: Record; + observationCount: number; + sessionCount: number; +} + +interface LearningStoreState { + loaded: boolean; + state: LearningState | null; +} + +export const learningStore = writable({ + loaded: false, + state: null, +}); + +/** + * Fetch learning state from the server and populate the store. + */ +export async function loadLearning(): Promise { + try { + const res = await fetch('/api/learning'); + const data = await res.json(); + if (data.computed) { + learningStore.set({ + loaded: true, + state: { + computedAt: data.computedAt, + nodes: data.nodes, + observationCount: data.observationCount, + sessionCount: data.sessionCount, + }, + }); + } else { + learningStore.set({ loaded: true, state: null }); + } + } catch { + learningStore.set({ loaded: true, state: null }); + } +} + +/** + * Derived map: folder path → { confidence, urgency }. + * GraphView uses this to modulate node appearance. + */ +export const learningScores = derived(learningStore, ($s) => { + const map = new Map(); + if (!$s.state) return map; + for (const [path, score] of Object.entries($s.state.nodes)) { + map.set(path, { confidence: score.confidence, urgency: score.urgency }); + } + return map; +}); diff --git a/packages/ui/src/lib/stores/loader.ts b/packages/ui/src/lib/stores/loader.ts index 9b7018c..64bfe15 100644 --- a/packages/ui/src/lib/stores/loader.ts +++ b/packages/ui/src/lib/stores/loader.ts @@ -70,6 +70,16 @@ function hydrateGraph(data: SerializedGraph): RoutingGraph { }; } + // Normalize path separators to forward slashes. The graph.json is + // generated on whatever platform built it (Windows serializes with + // backslashes). The live activity watcher normalizes every tool_use + // path to forward slashes via activity.normalizePath — if we don't + // normalize the graph side too, the `activeNodePaths.get(node.path)` + // lookup in GraphView/TreeNode fails and pulse rings never render. + if (typeof node.path === 'string') { + node.path = node.path.replace(/\\/g, '/'); + } + // Generate contextFile preview from node data if missing if (!node.contextFile && node.type !== 'file') { node.contextFile = buildContextPreview(node, data); diff --git a/packages/ui/src/lib/stores/sessionWatcher.ts b/packages/ui/src/lib/stores/sessionWatcher.ts new file mode 100644 index 0000000..db1e04c --- /dev/null +++ b/packages/ui/src/lib/stores/sessionWatcher.ts @@ -0,0 +1,203 @@ +/** + * Client-side session watcher — opens an EventSource to + * `/api/sessions/stream`, parses activity events, and feeds them into the + * existing `activity.ts` store so GraphView and TreeView pulse rings light + * up as Claude Code touches files. + * + * Lifecycle: call `startSessionWatcher` once on mount (from +layout.svelte). + * It reacts to `watchSettings.scope` changes by tearing down and re-opening + * the EventSource. Returns a cleanup function for onMount. + * + * Why EventSource instead of WebSocket or polling? EventSource is built into + * every browser, automatically reconnects on network hiccups, and the server + * side is a plain ReadableStream with no framing — much simpler than WS for + * a unidirectional feed. + */ +import { writable, get } from 'svelte/store'; +import { recordActivity } from './activity'; +import { graphStore } from './graph'; +import { watchSettings, type WatchScope } from './watchSettings'; + +export interface SessionWatcherStatus { + /** True while an EventSource is open and the `hello` event has arrived. */ + connected: boolean; + /** Current scope the watcher is running under (echoed from the server). */ + scope: WatchScope; + /** How many JSONL files the server is tailing. */ + watchedFileCount: number; + /** Running tally of events received this session (client side). */ + eventCount: number; + /** Short message for the UI (last error or connection state). */ + message: string; + /** Number of pending suggestions after the last learning recompute. */ + pendingSuggestions: number; +} + +const initial: SessionWatcherStatus = { + connected: false, + scope: 'project', + watchedFileCount: 0, + eventCount: 0, + message: 'idle', + pendingSuggestions: 0, +}; + +export const sessionWatcherStatus = writable(initial); + +interface WatcherEvent { + sessionId: string; + cwd: string; + tool: string; + kind: string; + path: string; + at: string; + id: string; + isSidechain: boolean; +} + +/** Currently-open EventSource, if any. Tracked so scope changes can tear it + * down before opening a new one. */ +let current: EventSource | null = null; +/** Dedup set for event ids we've already forwarded to the activity store. + * Uses a bounded ring buffer via Map-with-insertion-order to avoid unbounded + * growth on long-running sessions. */ +const seenIds = new Map(); +const SEEN_IDS_LIMIT = 2000; + +function rememberId(id: string): boolean { + if (!id) return false; + if (seenIds.has(id)) return true; + seenIds.set(id, true); + if (seenIds.size > SEEN_IDS_LIMIT) { + // Drop the oldest ~20% to keep amortized O(1). + const toDrop = Math.floor(SEEN_IDS_LIMIT / 5); + let i = 0; + for (const key of seenIds.keys()) { + seenIds.delete(key); + if (++i >= toDrop) break; + } + } + return false; +} + +function openStream(scope: WatchScope): void { + closeStream(); + + const graph = get(graphStore); + const cwd = graph?.repoPath || ''; + const qs = new URLSearchParams({ scope, cwd }); + const url = `/api/sessions/stream?${qs.toString()}`; + + let es: EventSource; + try { + es = new EventSource(url); + } catch (err) { + sessionWatcherStatus.update(s => ({ + ...s, + connected: false, + message: `EventSource failed: ${err instanceof Error ? err.message : 'unknown'}`, + })); + return; + } + current = es; + + sessionWatcherStatus.update(s => ({ ...s, scope, message: 'connecting...' })); + + es.addEventListener('hello', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data); + sessionWatcherStatus.update(s => ({ + ...s, + connected: true, + scope, + watchedFileCount: data.watchedFileCount ?? 0, + message: `watching ${data.watchedFileCount ?? 0} session file(s)`, + })); + } catch { /* ignore malformed hello */ } + }); + + es.addEventListener('ping', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data); + sessionWatcherStatus.update(s => ({ + ...s, + watchedFileCount: data.watchedFileCount ?? s.watchedFileCount, + })); + } catch { /* ignore */ } + }); + + es.addEventListener('activity', (ev: MessageEvent) => { + let data: WatcherEvent; + try { + data = JSON.parse(ev.data); + } catch { + return; + } + if (rememberId(data.id)) return; + + // The activity store expects a path relative to the repo root. The + // server sends the raw path as Claude Code recorded it — usually + // absolute. Pass it through `recordActivity` which already handles + // normalization against the repo path. + const graphNow = get(graphStore); + const repoPath = data.cwd || graphNow?.repoPath || ''; + recordActivity(data.tool, data.path, repoPath); + + sessionWatcherStatus.update(s => ({ + ...s, + eventCount: s.eventCount + 1, + message: `${data.tool}: ${data.path || '(no path)'}`, + })); + }); + + es.addEventListener('session-ended', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data); + const nodeCount = data.nodeCount || 0; + sessionWatcherStatus.update(s => ({ + ...s, + pendingSuggestions: nodeCount, + message: data.learningRecomputed + ? `Session ended — ${nodeCount} nodes scored` + : `Session ended (learning recompute failed)`, + })); + } catch { /* ignore */ } + }); + + es.onerror = () => { + // EventSource auto-reconnects; we just reflect the state. If it's + // hopelessly broken the browser will keep retrying — nothing we can do + // server-side. + sessionWatcherStatus.update(s => ({ ...s, connected: false, message: 'reconnecting...' })); + }; +} + +function closeStream(): void { + if (current) { + current.close(); + current = null; + } +} + +/** + * Start the watcher. Subscribes to scope changes and re-opens the stream + * whenever the user flips between project-only and machine-wide. Returns a + * teardown function suitable for Svelte's onMount return value. + */ +export function startSessionWatcher(): () => void { + let currentScope: WatchScope | null = null; + + const unsubscribe = watchSettings.subscribe(s => { + if (s.scope === currentScope) return; + currentScope = s.scope; + // Reset dedup state when switching scope — different files may expose + // overlapping ids in theory, and we want a fresh start anyway. + seenIds.clear(); + openStream(s.scope); + }); + + return () => { + unsubscribe(); + closeStream(); + }; +} diff --git a/packages/ui/src/lib/stores/settings.ts b/packages/ui/src/lib/stores/settings.ts deleted file mode 100644 index a40e458..0000000 --- a/packages/ui/src/lib/stores/settings.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Settings store — persisted to localStorage. - * Users configure their Claude connection here (CLI path or API key). - */ - -import { writable } from 'svelte/store'; - -export interface KlonodeSettings { - /** 'cli' uses Claude Code CLI, 'api' uses Anthropic API directly */ - connectionMode: 'cli' | 'api'; - /** Path to claude CLI binary (auto-detected or user-set) */ - cliPath: string; - /** Anthropic API key (only used if connectionMode === 'api') */ - apiKey: string; - /** Model to use */ - model: string; - /** Max tokens for response */ - maxTokens: number; - /** Execution mode for chat */ - executionMode: 'auto' | 'plan' | 'question' | 'bypass'; -} - -const STORAGE_KEY = 'klonode-settings'; - -const defaults: KlonodeSettings = { - connectionMode: 'cli', - cliPath: '', // auto-detected on first open via /api/chat GET - apiKey: '', - model: 'claude-sonnet-4-20250514', - maxTokens: 1024, - executionMode: 'auto', -}; - -const VALID_MODELS = [ - 'claude-sonnet-4-20250514', - 'claude-opus-4-20250514', - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', -]; - -function loadSettings(): KlonodeSettings { - if (typeof localStorage === 'undefined') return defaults; - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const saved = { ...defaults, ...JSON.parse(raw) }; - // Reset model if it's stale/invalid - if (!VALID_MODELS.includes(saved.model)) { - saved.model = defaults.model; - } - return saved; - } - } catch { /* ignore */ } - return defaults; -} - -function saveSettings(settings: KlonodeSettings): void { - if (typeof localStorage === 'undefined') return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); -} - -export const settingsStore = writable(loadSettings()); - -// Auto-persist on change -settingsStore.subscribe(saveSettings); - -export function updateSettings(partial: Partial): void { - settingsStore.update(s => ({ ...s, ...partial })); -} diff --git a/packages/ui/src/lib/stores/watchSettings.ts b/packages/ui/src/lib/stores/watchSettings.ts new file mode 100644 index 0000000..1dac6de --- /dev/null +++ b/packages/ui/src/lib/stores/watchSettings.ts @@ -0,0 +1,51 @@ +/** + * Watch settings — small persisted store for the session watcher scope + * toggle (project-only vs machine-wide). Introduced as part of the + * contextualizer pivot (#77) after `stores/settings.ts` was deleted along + * with the rest of the chat wrapper surface. + * + * Kept deliberately minimal: one field, localStorage-backed, no server + * sync. If we ever need more watcher-related settings they can live here + * too rather than resurrecting the old monolithic settings store. + */ +import { writable } from 'svelte/store'; + +export type WatchScope = 'project' | 'machine'; + +export interface WatchSettings { + scope: WatchScope; +} + +const STORAGE_KEY = 'klonode-watch-settings'; + +const defaults: WatchSettings = { + scope: 'project', +}; + +function load(): WatchSettings { + if (typeof localStorage === 'undefined') return defaults; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaults; + const saved = JSON.parse(raw) as Partial; + return { + scope: saved.scope === 'machine' ? 'machine' : 'project', + }; + } catch { + return defaults; + } +} + +function save(settings: WatchSettings): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { /* quota full — not critical, setting just won't persist */ } +} + +export const watchSettings = writable(load()); +watchSettings.subscribe(save); + +export function setWatchScope(scope: WatchScope): void { + watchSettings.update(s => ({ ...s, scope })); +} diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 66c8b7d..0ad8b32 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -5,9 +5,10 @@ import { viewMode } from '$lib/stores/graph'; import { graphStore, selectedNodeId } from '$lib/stores/graph'; import { pullLatest, githubStore } from '$lib/stores/github'; - import { chatStore } from '$lib/stores/chat'; - import { sessionsStore } from '$lib/stores/agents'; import { simulatorStore } from '$lib/stores/simulator'; + import { watchSettings, setWatchScope } from '$lib/stores/watchSettings'; + import { sessionWatcherStatus, startSessionWatcher } from '$lib/stores/sessionWatcher'; + import { loadLearning } from '$lib/stores/learning'; import { defineComponent, defineComponentAction, @@ -53,17 +54,24 @@ // every store whose value is reflected in a registered component's state // reader so the sync re-pushes whenever any of them changes. onMount(() => { - const stop = startWorkstationSync([ + const stopSync = startWorkstationSync([ viewMode, locale, graphStore, selectedNodeId, githubStore, - chatStore, - sessionsStore, simulatorStore, ]); - return stop; + // Live node graph feed — tails Claude Code session JSONLs and feeds + // activeNodePaths so GraphView/TreeNode pulse rings light up in real + // time. See stores/sessionWatcher.ts and server/session-watcher.ts. + const stopWatcher = startSessionWatcher(); + // Load learning state (confidence/urgency per node) for graph visualization. + loadLearning(); + return () => { + stopSync(); + stopWatcher(); + }; }); let pulling = false; @@ -112,6 +120,25 @@
{/if} - - {#if showChat} -
- + + {#if showSuggestions} +
+
{/if}
- + {/if} @@ -100,56 +100,51 @@ transition: grid-template-columns 0.2s ease; } - /* With chat panel open: add 340px on the right */ - .main-layout.chat-open { - grid-template-columns: 280px 1fr 340px; + .main-layout.suggestions-open { + grid-template-columns: 280px 1fr 300px; } - .main-layout.chat-open .editor-panel { + .main-layout.suggestions-open .editor-panel { grid-column: 1 / 3; } .main-layout.tree-only { grid-template-columns: 1fr; grid-template-rows: 1fr 200px; } - .main-layout.tree-only.chat-open { grid-template-columns: 1fr 340px; } + .main-layout.tree-only.suggestions-open { grid-template-columns: 1fr 300px; } .main-layout.graph-only { grid-template-columns: 1fr; grid-template-rows: 1fr; } .main-layout.graph-only .editor-panel { display: none; } - .main-layout.graph-only.chat-open { grid-template-columns: 1fr 340px; } + .main-layout.graph-only.suggestions-open { grid-template-columns: 1fr 300px; } .main-layout.github-only { grid-template-columns: 1fr; grid-template-rows: 1fr; } - .main-layout.github-only.chat-open { grid-template-columns: 1fr 340px; } + .main-layout.github-only.suggestions-open { grid-template-columns: 1fr 300px; } .github-panel { grid-row: 1; grid-column: 1; overflow: hidden; min-height: 0; } .tree-panel { grid-row: 1; overflow: hidden; min-height: 0; border-right: 1px solid #1a1a28; } .graph-panel { grid-row: 1; overflow: hidden; min-height: 0; } .editor-panel { grid-column: 1 / -1; grid-row: 2; overflow: hidden; border-top: 1px solid #1a1a28; } - .chat-panel { grid-column: 3; grid-row: 1 / 3; overflow: hidden; min-height: 0; } + .suggestions-panel { grid-row: 1 / 3; overflow: hidden; min-height: 0; } .panel { min-width: 0; min-height: 0; } - /* Chat toggle button — when closed: bottom-right; when open: top of chat column */ - .chat-toggle { + .suggestions-toggle { position: fixed; bottom: 16px; right: 16px; - padding: 8px 14px; - background: rgba(167, 139, 250, 0.15); - border: 1px solid rgba(167, 139, 250, 0.3); - border-radius: 20px; color: #a78bfa; - font-size: 12px; font-weight: 700; + padding: 6px 14px; + background: rgba(139, 92, 246, 0.15); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 16px; color: #a78bfa; + font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.2s; z-index: 100; font-family: Inter, system-ui, sans-serif; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } - .chat-toggle:hover { background: rgba(167, 139, 250, 0.25); } - .chat-toggle.active { - /* Move to top-right when chat is open, out of the way of send button */ - bottom: auto; top: 52px; right: 346px; - background: rgba(167, 139, 250, 0.1); - border-color: rgba(167, 139, 250, 0.2); + .suggestions-toggle:hover { background: rgba(139, 92, 246, 0.25); } + .suggestions-toggle.active { + bottom: auto; top: 52px; right: 306px; + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.2); color: #6b7280; - border-radius: 8px 0 0 8px; - padding: 6px 8px; - box-shadow: none; + border-radius: 6px 0 0 6px; + padding: 4px 8px; } .loading { diff --git a/packages/ui/src/routes/api/agents/+server.ts b/packages/ui/src/routes/api/agents/+server.ts deleted file mode 100644 index 340bc1e..0000000 --- a/packages/ui/src/routes/api/agents/+server.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Agents API — manages agent registry and CO operations. - * - * GET: Load agent registry for a project - * POST: Trigger CO analysis, log interaction - */ - -import { json } from '@sveltejs/kit'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import type { RequestHandler } from './$types'; - -const execAsync = promisify(exec); - -interface AgentRequest { - action: 'load-registry' | 'log-interaction' | 'analyze' | 'detect-tools'; - repoPath: string; - data?: Record; -} - -export const POST: RequestHandler = async ({ request }) => { - const body: AgentRequest = await request.json(); - const repoPath = body.repoPath || process.cwd(); - - try { - switch (body.action) { - case 'detect-tools': - return await handleDetectTools(repoPath); - - case 'log-interaction': - return await handleLogInteraction(repoPath, body.data || {}); - - case 'analyze': - return await handleAnalyze(repoPath); - - case 'load-registry': - return await handleLoadRegistry(repoPath); - - default: - return json({ error: `Ukjent aksjon: ${body.action}` }, { status: 400 }); - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Ukjent feil'; - return json({ error: msg }, { status: 500 }); - } -}; - -async function handleDetectTools(repoPath: string) { - // Simple tool detection via file existence checks - const tools: { id: string; name: string; category: string; configPath: string; contextHint: string }[] = []; - - const checks: [string, string, string, string, string][] = [ - ['prisma/schema.prisma', 'prisma', 'Prisma ORM', 'database', 'Prisma ORM: schema defines models/relations'], - ['tsconfig.json', 'typescript', 'TypeScript', 'language', 'TypeScript: check tsconfig.json for config'], - ['next.config.js', 'nextjs', 'Next.js', 'framework', 'Next.js: App Router in app/'], - ['next.config.mjs', 'nextjs', 'Next.js', 'framework', 'Next.js: App Router in app/'], - ['next.config.ts', 'nextjs', 'Next.js', 'framework', 'Next.js: App Router in app/'], - ['svelte.config.js', 'sveltekit', 'SvelteKit', 'framework', 'SvelteKit: routes in src/routes/'], - ['tailwind.config.js', 'tailwind', 'Tailwind CSS', 'styling', 'Tailwind CSS: utility classes'], - ['tailwind.config.ts', 'tailwind', 'Tailwind CSS', 'styling', 'Tailwind CSS: utility classes'], - ['Dockerfile', 'docker', 'Docker', 'devops', 'Docker: containerized deployment'], - ['docker-compose.yml', 'docker', 'Docker', 'devops', 'Docker Compose: multi-container'], - ['.github/workflows', 'github-actions', 'GitHub Actions', 'devops', 'CI/CD in .github/workflows/'], - ['turbo.json', 'turbo', 'Turborepo', 'build', 'Monorepo: turbo.json defines pipeline'], - ['vitest.config.ts', 'vitest', 'Vitest', 'testing', 'Vitest: fast unit tests'], - ['jest.config.js', 'jest', 'Jest', 'testing', 'Jest: unit tests'], - ['playwright.config.ts', 'playwright', 'Playwright', 'testing', 'E2E tests'], - ]; - - const seen = new Set(); - for (const [file, id, name, category, hint] of checks) { - if (seen.has(id)) continue; - if (existsSync(join(repoPath, file))) { - seen.add(id); - tools.push({ id, name, category, configPath: file, contextHint: hint }); - } - } - - return json({ tools }); -} - -async function handleLogInteraction(repoPath: string, data: Record) { - const logDir = join(repoPath, '.klonode', 'logs'); - if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); - - const today = new Date().toISOString().slice(0, 10); - const logFile = join(logDir, `${today}.jsonl`); - - const entry = { - timestamp: new Date().toISOString(), - agentId: data.agentId || 'unknown', - from: data.from || 'user', - message: (data.message || '').slice(0, 200), - tokens: data.tokens, - contextDepth: data.contextDepth, - durationMs: data.durationMs, - }; - - const fs = await import('fs'); - fs.appendFileSync(logFile, JSON.stringify(entry) + '\n', 'utf-8'); - - // Check if CO should auto-analyze - const coDir = join(repoPath, '.klonode', 'co'); - if (!existsSync(coDir)) mkdirSync(coDir, { recursive: true }); - - const statePath = join(coDir, 'state.json'); - let state = { interactionsSinceAnalysis: 0, analysisInterval: 10 }; - if (existsSync(statePath)) { - try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8')) }; } catch { /* */ } - } - state.interactionsSinceAnalysis++; - const shouldAnalyze = state.interactionsSinceAnalysis >= state.analysisInterval; - writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8'); - - return json({ logged: true, shouldAnalyze }); -} - -async function handleAnalyze(repoPath: string) { - const logDir = join(repoPath, '.klonode', 'logs'); - if (!existsSync(logDir)) return json({ suggestions: [], stats: null }); - - // Read all logs - const fs = await import('fs'); - const files = fs.readdirSync(logDir).filter((f: string) => f.endsWith('.jsonl')); - const allMessages: any[] = []; - - for (const file of files) { - const lines = fs.readFileSync(join(logDir, file), 'utf-8').split('\n').filter((l: string) => l.trim()); - for (const line of lines) { - try { allMessages.push(JSON.parse(line)); } catch { /* skip */ } - } - } - - // Simple analysis - const agentUsage: Record = {}; - const tokensByAgent: Record = {}; - let totalTokens = 0; - let totalCost = 0; - - for (const msg of allMessages) { - const agent = msg.agentId || 'unknown'; - agentUsage[agent] = (agentUsage[agent] || 0) + 1; - if (msg.tokens?.total) { - totalTokens += msg.tokens.total; - if (!tokensByAgent[agent]) tokensByAgent[agent] = []; - tokensByAgent[agent].push(msg.tokens.total); - if (msg.tokens.costUsd) totalCost += msg.tokens.costUsd; - } - } - - const avgTokens: Record = {}; - for (const [agent, tokens] of Object.entries(tokensByAgent)) { - avgTokens[agent] = Math.round(tokens.reduce((a, b) => a + b, 0) / tokens.length); - } - - // Generate suggestions - const suggestions: any[] = []; - for (const [agent, avg] of Object.entries(avgTokens)) { - if (avg > 30000) { - suggestions.push({ - type: 'update-context', - priority: 'high', - title: `${agent} bruker snitt ${Math.round(avg / 1000)}k tokens`, - description: 'Forbedre CONTEXT.md for denne agenten', - }); - } - } - - // Reset counter - const coDir = join(repoPath, '.klonode', 'co'); - const statePath = join(coDir, 'state.json'); - if (existsSync(statePath)) { - const state = JSON.parse(readFileSync(statePath, 'utf-8')); - state.interactionsSinceAnalysis = 0; - state.lastAnalysis = new Date().toISOString(); - writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8'); - } - - return json({ - stats: { - totalInteractions: allMessages.length, - agentUsage, - avgTokensByAgent: avgTokens, - totalTokens, - totalCost, - }, - suggestions, - }); -} - -async function handleLoadRegistry(repoPath: string) { - // Check for existing registry - const registryPath = join(repoPath, '.klonode', 'agents.json'); - if (existsSync(registryPath)) { - try { - const registry = JSON.parse(readFileSync(registryPath, 'utf-8')); - return json({ registry }); - } catch { /* fall through */ } - } - - return json({ registry: null }); -} diff --git a/packages/ui/src/routes/api/chat/+server.ts b/packages/ui/src/routes/api/chat/+server.ts deleted file mode 100644 index 598ad50..0000000 --- a/packages/ui/src/routes/api/chat/+server.ts +++ /dev/null @@ -1,469 +0,0 @@ -/** - * Chat API endpoint — proxies to Claude CLI or Anthropic API. - * CLI mode: spawns claude CLI with -p flag - * API mode: calls Anthropic API directly - */ - -import { json } from '@sveltejs/kit'; -import { exec, spawn } from 'child_process'; -import { promisify } from 'util'; -import type { RequestHandler } from './$types'; - -const execAsync = promisify(exec); - -interface ChatRequest { - message: string; - context: string; - connectionMode: 'cli' | 'api'; - cliPath?: string; - apiKey?: string; - model?: string; - maxTokens?: number; - mode: 'with-klonode' | 'without-klonode'; - /** Absolute path to the project root (from graph.repoPath) */ - repoPath?: string; - /** Relative folder paths to read source files from (routed folders) */ - routedPaths?: string[]; - /** Execution mode: question (context only), plan (read+plan), bypass (full access) */ - executionMode?: 'question' | 'plan' | 'bypass'; - /** Whether this is the Chief Organizer session */ - isCO?: boolean; -} - -export const POST: RequestHandler = async ({ request }) => { - const body: ChatRequest = await request.json(); - - try { - // Build prompt based on execution mode - const execMode = body.executionMode || 'bypass'; - let systemPrompt: string; - - if (body.isCO) { - systemPrompt = `You are an experienced developer with full access to all tools. Work directly in the project directory. - -Answer in Norwegian unless the user writes in English. Write all code and CONTEXT.md files in English.`; - } else if (body.mode === 'without-klonode') { - // Comparison mode: NO Klonode context — raw Claude, like normal users - systemPrompt = `Du er en erfaren programvareutvikler. Du jobber direkte i prosjektmappen. Bruk dine verktøy (Read, Grep, Edit, etc.) til å utforske kodebasen og løse oppgaven. Svar på norsk med mindre brukeren skriver på engelsk.`; - } else { - systemPrompt = buildKlonodePrompt(body.context, execMode); - } - - console.log(`[Klonode] Prompt size: ${systemPrompt.length} chars, repo: ${body.repoPath || 'none'}`); - - if (body.connectionMode === 'cli') { - return await handleCli(body, systemPrompt); - } else { - return await handleApi(body, systemPrompt); - } - } catch (err) { - console.error('[Klonode] Error:', err); - const msg = err instanceof Error ? err.message : 'Ukjent feil'; - return json({ error: msg }, { status: 500 }); - } -}; - -/** - * Auto-detect Claude CLI location on the system. - * Scans known install paths directly via filesystem (fast, no PowerShell). - */ -export const GET: RequestHandler = async () => { - const fs = await import('fs'); - const path = await import('path'); - - // 1. Check versioned paths under AppData/Roaming/Claude/claude-code/ - const appDataDir = process.env.APPDATA; - if (appDataDir) { - const codeDir = path.join(appDataDir, 'Claude', 'claude-code'); - try { - const versions = fs.readdirSync(codeDir) - .filter((d: string) => /^\d+\.\d+/.test(d)) - .sort((a: string, b: string) => b.localeCompare(a, undefined, { numeric: true })); - for (const ver of versions) { - const exe = path.join(codeDir, ver, 'claude.exe'); - if (fs.existsSync(exe)) { - return json({ cliPath: exe, detected: true }); - } - } - } catch { /* dir doesn't exist */ } - } - - // 2. Check common flat paths - const candidates = [ - appDataDir && path.join(appDataDir, 'Claude', 'claude-code', 'claude.exe'), - process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'claude', 'claude.exe'), - // macOS/Linux - '/usr/local/bin/claude', - path.join(process.env.HOME || '', '.claude', 'bin', 'claude'), - ].filter(Boolean) as string[]; - - for (const c of candidates) { - if (fs.existsSync(c)) { - return json({ cliPath: c, detected: true }); - } - } - - // 3. Try running 'claude' directly (in PATH) - try { - await execAsync('claude --version', { timeout: 5000 }); - return json({ cliPath: 'claude', detected: true }); - } catch { /* not in PATH */ } - - return json({ cliPath: '', detected: false }); -}; - -async function handleCli(body: ChatRequest, systemPrompt: string): Promise { - const cliPath = body.cliPath || 'claude'; - - if (!cliPath) { - return json({ error: 'Claude CLI-sti er ikke konfigurert. Gå til innstillinger.' }, { status: 400 }); - } - - const fullPrompt = `${systemPrompt}\n\nBrukerens spørsmål: ${body.message}`; - - const startTime = Date.now(); - - // Write prompt to temp file, use bash stdin redirection to feed it to CLI - // Clean env removes CLAUDECODE (nesting flag) and ensures HOME is set - const fs = await import('fs'); - const pathMod = await import('path'); - const tmpDir = process.env.TEMP || process.env.TMP || '/tmp'; - const tmpFile = pathMod.join(tmpDir, `klonode-prompt-${Date.now()}.txt`); - fs.writeFileSync(tmpFile, fullPrompt, 'utf-8'); - - const bashTmpPath = tmpFile.replace(/\\/g, '/'); - const bashCliPath = cliPath.replace(/\\/g, '/'); - - // Build CLI flags based on execution mode - const execMode = body.executionMode || 'bypass'; - let maxTurns: number; - let allowedTools: string; - - if (body.isCO) { - // CO: same as a real Claude Code session — no turn limit, all tools, opus 1M - maxTurns = 200; - allowedTools = '--allowedTools "Read,Write,Edit,Bash,Glob,Grep" --model claude-opus-4-6'; - } else { - switch (execMode) { - case 'question': - maxTurns = 1; - allowedTools = ''; - break; - case 'plan': - maxTurns = body.mode === 'with-klonode' ? 6 : 15; - allowedTools = '--allowedTools "Read,Glob,Grep"'; - break; - case 'bypass': - default: - maxTurns = body.mode === 'with-klonode' ? 4 : 25; - allowedTools = '--allowedTools "Read,Write,Edit,Bash,Glob,Grep"'; - break; - } - } - - const shellCmd = `"${bashCliPath}" -p --max-turns ${maxTurns} ${allowedTools} --output-format json < "${bashTmpPath}"`.replace(/ +/g, ' '); - // Build clean env for CLI subprocess - // Must include CLAUDE_CODE_OAUTH_TOKEN for auth but remove CLAUDECODE (nesting block) - const cleanEnv = { ...process.env }; - // Remove ALL Claude nesting flags to prevent subprocess from being blocked - delete cleanEnv.CLAUDECODE; - delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; - delete cleanEnv.CLAUDE_AGENT_SDK_VERSION; - delete cleanEnv.CLAUDE_CODE_DISABLE_CRON; - delete cleanEnv.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES; - delete cleanEnv.CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL; - delete cleanEnv.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST; - delete cleanEnv.DEFAULT_LLM_MODEL; - - // If OAuth token not in env (e.g. server started by preview tool), try reading from token file - if (!cleanEnv.CLAUDE_CODE_OAUTH_TOKEN) { - try { - const tokenPath = pathMod.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'klonode-oauth-token'); - if (fs.existsSync(tokenPath)) { - cleanEnv.CLAUDE_CODE_OAUTH_TOKEN = fs.readFileSync(tokenPath, 'utf-8').trim(); - } - } catch { /* ignore */ } - } - - if (!cleanEnv.HOME && cleanEnv.USERPROFILE) { - cleanEnv.HOME = cleanEnv.USERPROFILE; - } - - console.log(`[Klonode CLI] cmd: ${shellCmd}`); - console.log(`[Klonode CLI] has oauth token: ${!!cleanEnv.CLAUDE_CODE_OAUTH_TOKEN}`); - - // Run CLI from the repo directory so it can access project files with tools - const cwd = body.repoPath || process.cwd(); - console.log(`[Klonode CLI] cwd: ${cwd}`); - - let result: { stdout: string; stderr: string }; - try { - result = await execAsync(shellCmd, { - timeout: body.isCO ? 1800000 : 300000, // CO: 30 min, regular: 5 min - maxBuffer: 10 * 1024 * 1024, // 10MB — tool use generates more output - shell: process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\bash.exe' : '/bin/bash', - env: cleanEnv as any, - cwd, - }); - } catch (execErr: any) { - // exec throws on non-zero exit — capture stdout/stderr anyway - console.error(`[Klonode CLI] exec error:`, execErr.message); - console.error(`[Klonode CLI] exec stdout:`, (execErr.stdout || '').slice(0, 500)); - console.error(`[Klonode CLI] exec stderr:`, (execErr.stderr || '').slice(0, 500)); - console.error(`[Klonode CLI] exec code:`, execErr.code, 'signal:', execErr.signal, 'killed:', execErr.killed); - if (execErr.stdout || execErr.stderr) { - result = { stdout: execErr.stdout || '', stderr: execErr.stderr || '' }; - } else { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - throw new Error(execErr.stderr || execErr.message || 'CLI feilet'); - } - } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - } - - const elapsed = Date.now() - startTime; - console.log(`[Klonode CLI] stdout (first 500): ${result.stdout.slice(0, 500)}`); - console.log(`[Klonode CLI] stderr (first 500): ${result.stderr.slice(0, 500)}`); - - // Parse CLI JSON output — format: { type: "result", result: "...", usage: {...}, ... } - let text = ''; - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let costUsd = 0; - let numTurns = 0; - let model = 'claude-cli'; - - try { - const lines = result.stdout.trim().split('\n'); - for (const line of lines) { - try { - const parsed = JSON.parse(line); - - // Extract text from result field - if (parsed.result !== undefined && parsed.result !== null) { - console.log(`[Klonode CLI] result type: ${typeof parsed.result}, subtype: ${parsed.subtype}`); - if (typeof parsed.result === 'string') { - text = parsed.result; - } else if (Array.isArray(parsed.result)) { - // Content blocks: [{type: "text", text: "..."}, {type: "tool_use", ...}] - const textBlocks = parsed.result - .filter((b: any) => b.type === 'text' && b.text) - .map((b: any) => b.text); - if (textBlocks.length > 0) { - text = textBlocks.join('\n'); - } - } - } - - // If result is missing but we hit max_turns, that's OK — Claude was working with tools - // The work was done (files read/edited), just no final summary text - if (parsed.subtype === 'error_max_turns' && !text) { - text = 'Claude brukte alle tilgjengelige steg på å lese og analysere koden. Prøv å stille et mer spesifikt spørsmål, eller øk maks-steg i innstillingene.'; - } - - if (parsed.usage) { - inputTokens = parsed.usage.input_tokens || 0; - outputTokens = parsed.usage.output_tokens || 0; - cacheCreationTokens = parsed.usage.cache_creation_input_tokens || 0; - cacheReadTokens = parsed.usage.cache_read_input_tokens || 0; - } - if (parsed.total_cost_usd) { - costUsd = parsed.total_cost_usd; - } - if (parsed.num_turns) { - numTurns = parsed.num_turns; - } - if (parsed.modelUsage) { - const models = Object.keys(parsed.modelUsage); - if (models.length > 0) model = models[0]; - } - } catch { /* skip non-JSON lines */ } - } - } catch { - text = result.stdout.trim(); - } - - // Extract file operations from the result text - const fileOps: { type: string; path?: string; command?: string }[] = []; - // Look for common patterns in Claude's output mentioning files - const fileRefPatterns = [ - // "I read file.ts" / "Reading file.ts" - /(?:read|reading|les(?:er|te)|opened)\s+[`"']?([^\s`"']+\.\w{1,5})[`"']?/gi, - // "edited file.ts" / "wrote to file.ts" - /(?:edit(?:ed|ing)|wrote|writ(?:ing|ten)|endret|opprettet|updated)\s+(?:to\s+)?[`"']?([^\s`"']+\.\w{1,5})[`"']?/gi, - // backtick file references like `src/components/Foo.tsx` - /`((?:[\w.-]+\/)+[\w.-]+\.\w{1,5})`/g, - ]; - const seenFiles = new Set(); - for (const pattern of fileRefPatterns) { - for (const m of text.matchAll(pattern)) { - const filePath = m[1]; - if (filePath && !seenFiles.has(filePath) && filePath.includes('/') || filePath.includes('.')) { - seenFiles.add(filePath); - const isWrite = /edit|writ|endr|opprett|updat/i.test(m[0]); - fileOps.push({ type: isWrite ? 'edit' : 'read', path: filePath }); - } - } - } - - // Total input = direct + cache creation + cache read - const totalInput = inputTokens + cacheCreationTokens + cacheReadTokens; - - return json({ - text: text || result.stderr.trim() || 'Ingen respons fra Claude CLI', - inputTokens: totalInput, - outputTokens, - totalTokens: totalInput + outputTokens, - cacheCreationTokens, - cacheReadTokens, - fileOps, - costUsd, - numTurns, - model, - mode: body.mode, - elapsed, - }); -} - -async function handleApi(body: ChatRequest, systemPrompt: string): Promise { - if (!body.apiKey) { - return json({ error: 'API-nøkkel mangler. Legg til din Anthropic API-nøkkel i innstillinger.' }, { status: 400 }); - } - - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': body.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: body.model || 'claude-sonnet-4-20250514', - max_tokens: body.maxTokens || 1024, - system: systemPrompt, - messages: [{ role: 'user', content: body.message }], - }), - }); - - if (!response.ok) { - const err = await response.text(); - return json({ error: `Anthropic API feil: ${response.status} — ${err}` }, { status: response.status }); - } - - const data = await response.json(); - const text = data.content?.[0]?.text || 'Ingen respons'; - const usage = data.usage || { input_tokens: 0, output_tokens: 0 }; - - return json({ - text, - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - totalTokens: usage.input_tokens + usage.output_tokens, - model: data.model, - mode: body.mode, - }); -} - -function getModeInstructions(execMode: string): string { - switch (execMode) { - case 'question': - return `Du er i SPØRSMÅLS-MODUS. Svar BARE basert på konteksten ovenfor. Ikke bruk verktøy — du har all info du trenger. Gi et konsist, nyttig svar.`; - case 'plan': - return `Du er i PLAN-MODUS. Les koden med Read/Grep og lag en detaljert plan for hva som må endres. IKKE gjør noen endringer — bare les, analyser og beskriv planen steg-for-steg med filstier og linjenummer. Brukeren godkjenner planen før du utfører.`; - case 'bypass': - default: - return `Du har FULL TILGANG til å lese og skrive filer. Utfør endringene direkte med Edit/Write. Les relevante filer først med Read, gjør endringene, og bekreft hva du har gjort.`; - } -} - -function buildKlonodePrompt(routedContext: string, execMode: string): string { - // Extract just the file paths and key one-liners from routed context - // Keep the hint ultra-concise (<500 chars) to minimize per-turn overhead - const microHint = extractMicroHint(routedContext); - - return `Erfaren utvikler. Minimer verktøybruk — svar direkte fra konteksten når mulig. -${microHint} -${getModeInstructions(execMode)} -Svar på norsk med mindre brukeren skriver på engelsk.`; -} - -function buildFullPrompt(fullContext: string, execMode: string): string { - return `Du er en erfaren programvareutvikler som jobber med en kodebase. - -Her er en oversikt over hele prosjektet: - ---- PROSJEKTOVERSIKT --- -${fullContext} ---- SLUTT OVERSIKT --- - -${getModeInstructions(execMode)} - -VIKTIG: -- Du jobber direkte i prosjektmappen — alle filstier er relative -- Gi konkrete svar med eksakt kode, filstier og linjenummer -- Svar på norsk med mindre brukeren skriver på engelsk`; -} - -/** - * Extract a micro routing hint from the full CONTEXT.md content. - * Keeps only: folder paths, API methods, key exports, patterns. - * Target: <500 chars total to minimize per-turn token overhead. - */ -function extractMicroHint(routedContext: string): string { - if (!routedContext || routedContext === 'Ingen graf lastet.') return ''; - - const hints: string[] = []; - - // Extract folder paths from ## headers - const folderPaths: string[] = []; - for (const m of routedContext.matchAll(/^## (.+?)\/CONTEXT\.md$/gm)) { - folderPaths.push(m[1]); - } - - // Extract API routes - const apiMethods: string[] = []; - for (const m of routedContext.matchAll(/Methods: \*\*(.+?)\*\*/g)) { - apiMethods.push(m[1]); - } - - // Extract key patterns (auth, prisma models, etc) - const patterns: string[] = []; - for (const m of routedContext.matchAll(/^- (Uses |Admin|Prisma models|Three\.js|NextAuth|Standard).+$/gm)) { - patterns.push(m[1].trim()); - } - - // Extract key exports (just function/class names, not signatures) - const exports: string[] = []; - for (const m of routedContext.matchAll(/^\- \*\*(function|class|component)\*\*: (.+)$/gm)) { - const names = m[2].split(', ').slice(0, 3).map(n => n.replace(/\(.*/, '')); - exports.push(...names); - } - - // Build concise hint - if (folderPaths.length > 0) { - hints.push(`Start i: ${folderPaths.join(', ')}`); - } - if (apiMethods.length > 0) { - hints.push(`API: ${apiMethods.join('; ')}`); - } - if (patterns.length > 0) { - hints.push(patterns.slice(0, 3).join('. ')); - } - if (exports.length > 0) { - hints.push(`Nøkkeleksporter: ${exports.slice(0, 5).join(', ')}`); - } - - // Also tell Claude about CONTEXT.md files it can read for more detail - if (folderPaths.length > 0) { - hints.push(`Les CONTEXT.md i disse mappene for detaljer.`); - } - - const hint = hints.join('\n'); - - // Hard cap at 500 chars - if (hint.length > 500) return hint.slice(0, 497) + '...'; - return hint; -} - diff --git a/packages/ui/src/routes/api/chat/classify/+server.ts b/packages/ui/src/routes/api/chat/classify/+server.ts deleted file mode 100644 index 645bb99..0000000 --- a/packages/ui/src/routes/api/chat/classify/+server.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Classify endpoint — asks Claude to determine the best execution mode for a query. - * Uses a tiny prompt with --max-turns 1 and no tools for speed. - * Returns { mode: 'question' | 'plan' | 'bypass' } - */ - -import { json } from '@sveltejs/kit'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import type { RequestHandler } from './$types'; - -const execAsync = promisify(exec); - -const CLASSIFY_PROMPT = `You are a query classifier. Given a user message about a codebase, respond with EXACTLY one word — the best execution mode: - -- "question" — The user just wants information, an explanation, or an overview. No code changes needed. -- "plan" — The user wants complex changes that affect multiple files, need careful planning, or carry risk (refactoring, migrations, architecture changes, setting up new systems). -- "bypass" — The user wants a direct code change: a fix, adding something, removing something, updating code. Straightforward enough to just do it. - -Respond with ONLY the single word: question, plan, or bypass. Nothing else. - -User message:`; - -export const POST: RequestHandler = async ({ request }) => { - const body = await request.json(); - const { message, connectionMode, cliPath, apiKey } = body; - - try { - let mode = 'bypass'; // default fallback - - if (connectionMode === 'cli') { - mode = await classifyViaCli(message, cliPath); - } else if (connectionMode === 'api' && apiKey) { - mode = await classifyViaApi(message, apiKey); - } - - return json({ mode }); - } catch (err) { - console.warn('[Klonode Classify] Error, defaulting to bypass:', err); - return json({ mode: 'bypass' }); - } -}; - -async function classifyViaCli(message: string, cliPath: string): Promise { - const fs = await import('fs'); - const pathMod = await import('path'); - const tmpDir = process.env.TEMP || process.env.TMP || '/tmp'; - const tmpFile = pathMod.join(tmpDir, `klonode-classify-${Date.now()}.txt`); - fs.writeFileSync(tmpFile, `${CLASSIFY_PROMPT}\n${message}`, 'utf-8'); - - const bashTmpPath = tmpFile.replace(/\\/g, '/'); - const bashCliPath = (cliPath || 'claude').replace(/\\/g, '/'); - const shellCmd = `"${bashCliPath}" -p --max-turns 1 --output-format json < "${bashTmpPath}"`; - - const cleanEnv = { ...process.env }; - delete cleanEnv.CLAUDECODE; - delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; - delete cleanEnv.CLAUDE_AGENT_SDK_VERSION; - delete cleanEnv.CLAUDE_CODE_DISABLE_CRON; - delete cleanEnv.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES; - delete cleanEnv.CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL; - - if (!cleanEnv.CLAUDE_CODE_OAUTH_TOKEN) { - try { - const tokenPath = pathMod.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'klonode-oauth-token'); - if (fs.existsSync(tokenPath)) { - cleanEnv.CLAUDE_CODE_OAUTH_TOKEN = fs.readFileSync(tokenPath, 'utf-8').trim(); - } - } catch { /* ignore */ } - } - - if (!cleanEnv.HOME && cleanEnv.USERPROFILE) { - cleanEnv.HOME = cleanEnv.USERPROFILE; - } - - try { - const result = await execAsync(shellCmd, { - timeout: 15000, // 15 sec max for classification - maxBuffer: 512 * 1024, - shell: process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\bash.exe' : '/bin/bash', - env: cleanEnv as any, - }); - - // Parse the result - const lines = result.stdout.trim().split('\n'); - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (typeof parsed.result === 'string') { - const word = parsed.result.trim().toLowerCase(); - if (word === 'question' || word === 'plan' || word === 'bypass') { - return word; - } - } - } catch { /* skip */ } - } - } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - } - - return 'bypass'; -} - -async function classifyViaApi(message: string, apiKey: string): Promise { - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: 'claude-3-5-haiku-20241022', // Use cheapest model for classification - max_tokens: 10, - messages: [{ role: 'user', content: `${CLASSIFY_PROMPT}\n${message}` }], - }), - }); - - if (!response.ok) return 'bypass'; - - const data = await response.json(); - const text = (data.content?.[0]?.text || '').trim().toLowerCase(); - if (text === 'question' || text === 'plan' || text === 'bypass') { - return text; - } - return 'bypass'; -} diff --git a/packages/ui/src/routes/api/chat/stream/+server.ts b/packages/ui/src/routes/api/chat/stream/+server.ts deleted file mode 100644 index 6890823..0000000 --- a/packages/ui/src/routes/api/chat/stream/+server.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Streaming chat endpoint — uses Server-Sent Events to stream CLI progress. - * Shows tool_use events (file reads, edits, commands) in real-time. - */ - -import { spawn } from 'child_process'; -import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import type { RequestHandler } from './$types'; - -interface StreamRequest { - message: string; - context: string; - cliPath: string; - model?: string; - mode: 'with-klonode' | 'without-klonode'; - repoPath?: string; - routedPaths?: string[]; - executionMode?: 'question' | 'plan' | 'bypass'; - isCO?: boolean; - /** Session ID for persistent conversations. Same session = Claude remembers history. */ - sessionId?: string; -} - -export const POST: RequestHandler = async ({ request }) => { - const body: StreamRequest = await request.json(); - const cliPath = body.cliPath || 'claude'; - // Validate repoPath exists on disk — otherwise fall back to the server's - // cwd. The shipped demo graph has `/path/to/your/project` as a placeholder, - // and if the user never configured a real repo path the first chat send - // would spawn the CLI in a nonexistent directory and fail with an opaque - // error. Falling back to process.cwd() gives a sane default. - let cwd = body.repoPath || process.cwd(); - if (body.repoPath && !existsSync(body.repoPath)) { - console.warn( - `[Klonode Stream] repoPath "${body.repoPath}" does not exist on disk; ` + - `falling back to process.cwd() = "${process.cwd()}". ` + - `Configure a real repo path via settings or reinitialize the graph.`, - ); - cwd = process.cwd(); - } - - // Build system prompt - let systemPrompt: string; - if (body.isCO) { - systemPrompt = `You are an experienced developer with full access to all tools. Work directly in the project directory. - -Answer in Norwegian unless the user writes in English. Write all code and CONTEXT.md files in English.`; - } else if (body.mode === 'without-klonode') { - systemPrompt = `Du er en erfaren programvareutvikler. Du jobber direkte i prosjektmappen. Bruk dine verktoy til a utforske kodebasen og lose oppgaven. Svar pa norsk med mindre brukeren skriver pa engelsk.`; - } else { - systemPrompt = body.context - ? `Erfaren utvikler. Minimer verktoybruk.\n${body.context.slice(0, 500)}\nSvar pa norsk med mindre brukeren skriver pa engelsk.` - : `Erfaren utvikler. Svar pa norsk med mindre brukeren skriver pa engelsk.`; - } - - // Every message is a fresh spawn — always prepend the system prompt so - // Claude has routing context. See the note below about why --resume was - // dropped. - const fullPrompt = `${systemPrompt}\n\nBrukerens sporsmaal: ${body.message}`; - - // Write prompt to temp file - const tmpDir = process.env.TEMP || process.env.TMP || '/tmp'; - const tmpFile = join(tmpDir, `klonode-stream-${Date.now()}.txt`); - writeFileSync(tmpFile, fullPrompt, 'utf-8'); - - // Build CLI args - const isCO = body.isCO; - // Turn budget. Claude Code's interactive default is effectively unlimited; - // a real coding task like "add a language extractor + tests" routinely - // needs 80-150 tool calls. Previously bypass mode was capped at 50, which - // hit the limit mid-task and made the session appear unresponsive with a - // generic "Claude brukte alle steg" fallback. Raise bypass to 500 so tasks - // of that shape actually complete. question/plan stay tight because they - // exist precisely to cap turn spend. - const maxTurns = isCO ? 500 - : body.executionMode === 'question' ? 1 - : body.executionMode === 'plan' ? 15 - : 500; - const tools = body.executionMode === 'question' ? [] : - body.executionMode === 'plan' ? ['Read', 'Glob', 'Grep'] : - ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']; - - const args = ['-p', '--verbose', '--max-turns', String(maxTurns), '--output-format', 'stream-json']; - - // NOTE: `-p` mode session IDs are NOT reliably resumable — Claude CLI - // returns "No conversation found with session ID: ..." on the second - // invocation, which Klonode then renders as "Claude brukte alle steg". - // Every send gets a fresh spawn. Continuity on the user side lives in the - // persisted chatStore.messages; Claude re-routes against the graph on - // every message, which matches Klonode's per-query routing philosophy - // anyway. If/when we add real resume (interactive transport or prompt - // replay), it goes here. - - if (tools.length > 0) { - args.push('--allowedTools', tools.join(',')); - } - if (isCO) { - args.push('--model', 'claude-opus-4-6'); - } - - // Clean env - const cleanEnv = { ...process.env }; - delete cleanEnv.CLAUDECODE; - delete cleanEnv.CLAUDE_CODE_ENTRYPOINT; - delete cleanEnv.CLAUDE_AGENT_SDK_VERSION; - delete cleanEnv.CLAUDE_CODE_DISABLE_CRON; - delete cleanEnv.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES; - delete cleanEnv.CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL; - delete cleanEnv.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST; - delete cleanEnv.DEFAULT_LLM_MODEL; - - if (!cleanEnv.CLAUDE_CODE_OAUTH_TOKEN) { - try { - const tokenPath = join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'klonode-oauth-token'); - if (existsSync(tokenPath)) { - cleanEnv.CLAUDE_CODE_OAUTH_TOKEN = readFileSync(tokenPath, 'utf-8').trim(); - } - } catch { /* ignore */ } - } - if (!cleanEnv.HOME && cleanEnv.USERPROFILE) { - cleanEnv.HOME = cleanEnv.USERPROFILE; - } - - const bashCliPath = cliPath.replace(/\\/g, '/'); - const bashTmpPath = tmpFile.replace(/\\/g, '/'); - - console.log(`[Klonode Stream] Starting: ${bashCliPath} ${args.join(' ')}`); - console.log(`[Klonode Stream] cwd: ${cwd}`); - - // Use ReadableStream for SSE - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - - function send(event: string, data: any) { - controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); - } - - // Spawn CLI with stdin from file - const shell = process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\bash.exe' : '/bin/bash'; - const shellCmd = `"${bashCliPath}" ${args.join(' ')} < "${bashTmpPath}"`; - - const child = spawn(shell, ['-c', shellCmd], { - cwd, - env: cleanEnv as any, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - let buffer = ''; - - child.stdout.on('data', (chunk: Buffer) => { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep incomplete line - - for (const line of lines) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line); - - // Capture session ID from init event - if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) { - send('session', { sessionId: parsed.session_id }); - } - - // Stream different event types - if (parsed.type === 'assistant' && parsed.message?.content) { - for (const block of parsed.message.content) { - if (block.type === 'tool_use') { - send('tool', { - tool: block.name, - input: summarizeToolInput(block.name, block.input), - }); - } else if (block.type === 'text' && block.text) { - send('text', { text: block.text }); - } - } - } else if (parsed.type === 'result') { - send('result', { - text: typeof parsed.result === 'string' ? parsed.result : - Array.isArray(parsed.result) ? - parsed.result.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n') : '', - usage: parsed.usage, - costUsd: parsed.total_cost_usd, - numTurns: parsed.num_turns, - subtype: parsed.subtype, - }); - } - } catch { /* skip non-JSON lines */ } - } - }); - - child.stderr.on('data', (chunk: Buffer) => { - const text = chunk.toString().trim(); - if (text) send('stderr', { text: text.slice(0, 200) }); - }); - - child.on('close', (code) => { - // Process remaining buffer - if (buffer.trim()) { - try { - const parsed = JSON.parse(buffer); - if (parsed.type === 'result') { - send('result', { - text: typeof parsed.result === 'string' ? parsed.result : - Array.isArray(parsed.result) ? - parsed.result.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n') : '', - usage: parsed.usage, - costUsd: parsed.total_cost_usd, - numTurns: parsed.num_turns, - subtype: parsed.subtype, - }); - } - } catch { /* skip */ } - } - - send('done', { code }); - try { unlinkSync(tmpFile); } catch { /* ignore */ } - controller.close(); - }); - - child.on('error', (err) => { - send('error', { message: err.message }); - try { unlinkSync(tmpFile); } catch { /* ignore */ } - controller.close(); - }); - }, - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - }); -}; - -/** Summarize tool input for display */ -function summarizeToolInput(tool: string, input: any): string { - if (!input) return ''; - switch (tool) { - case 'Read': return input.file_path || input.path || ''; - case 'Write': return input.file_path || input.path || ''; - case 'Edit': return input.file_path || input.path || ''; - case 'Glob': return input.pattern || ''; - case 'Grep': return `${input.pattern || ''} ${input.path || ''}`.trim(); - case 'Bash': return (input.command || '').slice(0, 80); - default: return JSON.stringify(input).slice(0, 80); - } -} diff --git a/packages/ui/src/routes/api/co/+server.ts b/packages/ui/src/routes/api/co/+server.ts deleted file mode 100644 index ea11b29..0000000 --- a/packages/ui/src/routes/api/co/+server.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * CO API — manages CO memory file and closed session processing. - * - * GET: Load CO memory - * POST: Save CO memory / process closed sessions - */ - -import { json } from '@sveltejs/kit'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import type { RequestHandler } from './$types'; - -interface CORequest { - action: 'load-memory' | 'save-memory' | 'compact'; - repoPath: string; - content?: string; -} - -export const POST: RequestHandler = async ({ request }) => { - const body: CORequest = await request.json(); - const repoPath = body.repoPath || process.cwd(); - const coDir = join(repoPath, '.klonode', 'co'); - - if (!existsSync(coDir)) mkdirSync(coDir, { recursive: true }); - - const memoryPath = join(coDir, 'memory.md'); - - switch (body.action) { - case 'load-memory': { - let content = ''; - if (existsSync(memoryPath)) { - content = readFileSync(memoryPath, 'utf-8'); - } - return json({ content }); - } - - case 'save-memory': { - writeFileSync(memoryPath, body.content || '', 'utf-8'); - return json({ saved: true }); - } - - case 'compact': { - // Return the current memory for the UI to display - // Actual compaction is done by CO itself via CLI - let content = ''; - if (existsSync(memoryPath)) { - content = readFileSync(memoryPath, 'utf-8'); - } - return json({ content }); - } - - default: - return json({ error: 'Ukjent aksjon' }, { status: 400 }); - } -}; diff --git a/packages/ui/src/routes/api/graph/current/+server.ts b/packages/ui/src/routes/api/graph/current/+server.ts index b70ee11..b865b28 100644 --- a/packages/ui/src/routes/api/graph/current/+server.ts +++ b/packages/ui/src/routes/api/graph/current/+server.ts @@ -62,8 +62,8 @@ export const GET: RequestHandler = async ({ url }) => { try { const raw = readFileSync(graphPath, 'utf-8'); const graph = JSON.parse(raw); - // Make sure repoPath is set so the ChatPanel's spawn path finds the - // right cwd even if the stored graph was generated on another machine. + // Make sure repoPath is set to the server's cwd even if the stored + // graph was generated on another machine. if (!graph.repoPath || graph.repoPath === '' || graph.repoPath === '/path/to/your/project') { graph.repoPath = repoPath; } diff --git a/packages/ui/src/routes/api/learning/+server.ts b/packages/ui/src/routes/api/learning/+server.ts new file mode 100644 index 0000000..8993fc3 --- /dev/null +++ b/packages/ui/src/routes/api/learning/+server.ts @@ -0,0 +1,53 @@ +/** + * Learning API — read/recompute learning scores. + * + * GET — returns the current learning state (from `.klonode/learning.json`) + * POST — action: `recompute` — runs the learning model on the observation log + */ +import { json } from '@sveltejs/kit'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RequestHandler } from './$types'; +import { openObservationLog } from '$lib/server/observation-log'; + +function findProjectRoot(startPath: string): string { + let current = startPath; + for (let i = 0; i < 20; i++) { + if (existsSync(join(current, '.klonode'))) return current; + if (existsSync(join(current, '.git'))) return current; + const parent = current.replace(/[\\/][^\\/]+$/, ''); + if (!parent || parent === current) break; + current = parent; + } + return startPath; +} + +import { + computeLearningState, + saveLearningState, + loadLearningState, +} from '$lib/server/learning'; + +export const GET: RequestHandler = async () => { + const repoPath = findProjectRoot(process.cwd()); + const state = loadLearningState(repoPath); + if (!state) { + return json({ computed: false, message: 'No learning state computed yet. Run `klonode learn` or POST action=recompute.' }); + } + return json({ computed: true, ...state }); +}; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const repoPath = findProjectRoot(process.cwd()); + + if (body.action === 'recompute') { + const log = openObservationLog(repoPath); + const observations = log.readAll(); + const state = computeLearningState(observations); + saveLearningState(repoPath, state); + return json({ computed: true, ...state }); + } + + return json({ error: 'Unknown action' }, { status: 400 }); +}; diff --git a/packages/ui/src/routes/api/observations/+server.ts b/packages/ui/src/routes/api/observations/+server.ts new file mode 100644 index 0000000..c4698df --- /dev/null +++ b/packages/ui/src/routes/api/observations/+server.ts @@ -0,0 +1,44 @@ +/** + * Observations API — stats, read, and purge for the persistent observation log. + * + * GET — returns stats (counts per tool, session, top folder, file size) + * POST — actions: `purge` (drop all observations) + */ +import { json } from '@sveltejs/kit'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RequestHandler } from './$types'; +import { openObservationLog } from '$lib/server/observation-log'; + +function findProjectRoot(startPath: string): string { + let current = startPath; + for (let i = 0; i < 20; i++) { + if (existsSync(join(current, '.klonode'))) return current; + if (existsSync(join(current, '.git'))) return current; + const parent = current.replace(/[\\/][^\\/]+$/, ''); + if (!parent || parent === current) break; + current = parent; + } + return startPath; +} + +export const GET: RequestHandler = async () => { + const repoPath = findProjectRoot(process.cwd()); + const log = openObservationLog(repoPath); + const s = log.stats(); + return json(s); +}; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const repoPath = findProjectRoot(process.cwd()); + const log = openObservationLog(repoPath); + + switch (body.action) { + case 'purge': + log.purge(); + return json({ purged: true }); + default: + return json({ error: 'Unknown action' }, { status: 400 }); + } +}; diff --git a/packages/ui/src/routes/api/sessions/stream/+server.ts b/packages/ui/src/routes/api/sessions/stream/+server.ts new file mode 100644 index 0000000..2648f5c --- /dev/null +++ b/packages/ui/src/routes/api/sessions/stream/+server.ts @@ -0,0 +1,156 @@ +/** + * SSE endpoint for the live session watcher. The client opens one connection + * and receives a stream of `event: activity` messages as tool_use entries + * appear in Claude Code's JSONL session files. See + * `$lib/server/session-watcher.ts` for the tailing mechanics. + * + * Query params: + * - `scope` — `project` | `machine`. Defaults to `project`. + * - `cwd` — absolute repo path to encode into a project-scope watch dir. + * When omitted we fall back to the server process cwd, which is + * usually the same thing the UI is looking at. + * + * Each event carries the parsed tool_use (tool name, kind, path, sessionId, + * etc). The client feeds these into the `activity.ts` store which drives + * pulse rings in GraphView and TreeView. + */ +import type { RequestHandler } from './$types'; +import { + createSessionWatcher, + resolveWatchDirs, + type SessionActivityEvent, + type SessionEndedEvent, +} from '$lib/server/session-watcher'; +import { openObservationLog } from '$lib/server/observation-log'; +import { computeLearningState, saveLearningState } from '$lib/server/learning'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Walk up from a starting path to find the project root. Prefers a dir + * with `.klonode/` (already initialized), falls back to `.git` (repo + * root). Returns null if neither is found. */ +function findProjectRoot(startPath: string): string | null { + let current = startPath; + for (let i = 0; i < 20; i++) { + if (existsSync(join(current, '.klonode'))) return current; + if (existsSync(join(current, '.git'))) return current; + const parent = current.replace(/[\\/][^\\/]+$/, ''); + if (!parent || parent === current) break; + current = parent; + } + return null; +} + +export const GET: RequestHandler = async ({ url, request }) => { + const scope = (url.searchParams.get('scope') === 'machine' ? 'machine' : 'project') as 'project' | 'machine'; + const cwd = url.searchParams.get('cwd') || process.cwd(); + + const dirs = resolveWatchDirs(scope, cwd); + const watcher = createSessionWatcher(dirs); + + // Persistent observation log — every event gets appended to + // `.klonode/observations.jsonl` so the learning model (#80, #81) can + // compute cross-session statistics. The log dedupes internally so + // multiple SSE clients or server restarts don't double-record. + // + // Use the *first* resolved watch dir's parent as the repo root (it's + // the encoded project path whose parent is ~/.claude/projects, not + // the repo itself). Instead, walk up from the server cwd to find the + // nearest directory containing `.klonode/` — that's where graph.json + // lives and where observations should go too. + const repoRoot = findProjectRoot(cwd) || cwd; + const observationLog = openObservationLog(repoRoot); + + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + function send(eventName: string, data: unknown): void { + try { + // SSE format: `event: \ndata: \n\n`. Multiline data + // needs a `data:` prefix per line, but JSON.stringify gives us one + // line so we don't need to special-case it. + controller.enqueue( + encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`), + ); + } catch { + // Controller closed (client disconnected); cleanup happens in + // cancel(). + } + } + + // Initial hello so the client can render "connected" immediately + // without waiting for the first tool call. + send('hello', { + scope, + cwd, + dirs, + watchedFileCount: watcher.watchedFileCount(), + }); + + unsubscribe = watcher.onEvent((event: SessionActivityEvent) => { + observationLog.append(event); + send('activity', event); + }); + + // Session-end detection: when a JSONL file stops growing for 10 + // minutes, recompute the learning model and notify the client. + watcher.onSessionEnded((event: SessionEndedEvent) => { + // Recompute learning scores from the full observation log. + try { + const observations = observationLog.readAll(); + const state = computeLearningState(observations); + saveLearningState(repoRoot, state); + send('session-ended', { + ...event, + learningRecomputed: true, + nodeCount: Object.keys(state.nodes).length, + observationCount: state.observationCount, + }); + } catch { + send('session-ended', { ...event, learningRecomputed: false }); + } + }); + + // Heartbeat keeps proxies and fetch buffering honest — an idle SSE + // connection can be closed by intermediaries after ~30s without + // any data. 15s is a common safe interval. + heartbeat = setInterval(() => { + send('ping', { + at: new Date().toISOString(), + watchedFileCount: watcher.watchedFileCount(), + eventCount: watcher.eventCount(), + }); + }, 15_000); + + // When the client aborts (reload, navigate away, EventSource.close), + // `request.signal` fires and we release the watcher. + request.signal.addEventListener('abort', () => { + if (unsubscribe) unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + watcher.stop(); + try { + controller.close(); + } catch { /* already closed */ } + }); + }, + cancel() { + if (unsubscribe) unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + watcher.stop(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Disable Nginx/proxy buffering on self-hosted deployments. Harmless + // in local dev. + 'X-Accel-Buffering': 'no', + }, + }); +}; diff --git a/packages/ui/src/routes/api/suggestions/+server.ts b/packages/ui/src/routes/api/suggestions/+server.ts new file mode 100644 index 0000000..a287ab8 --- /dev/null +++ b/packages/ui/src/routes/api/suggestions/+server.ts @@ -0,0 +1,86 @@ +/** + * Suggestions API — CRUD for contextualizer suggestions. + * + * GET — list pending suggestions (filters out dismissed/snoozed) + * POST — actions: `generate` (compute from learning state), `approve`, + * `dismiss`, `snooze` + */ +import { json } from '@sveltejs/kit'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RequestHandler } from './$types'; +import { loadLearningState } from '$lib/server/learning'; +import { + generateSuggestions, + loadSuggestions, + saveSuggestions, + updateSuggestionStatus, +} from '$lib/server/suggestions'; + +function findProjectRoot(startPath: string): string { + let current = startPath; + for (let i = 0; i < 20; i++) { + if (existsSync(join(current, '.klonode'))) return current; + if (existsSync(join(current, '.git'))) return current; + const parent = current.replace(/[\\/][^\\/]+$/, ''); + if (!parent || parent === current) break; + current = parent; + } + return startPath; +} + +export const GET: RequestHandler = async () => { + const repoPath = findProjectRoot(process.cwd()); + const now = new Date().toISOString(); + const suggestions = loadSuggestions(repoPath) + .filter(s => { + if (s.status === 'dismissed') return false; + if (s.status === 'snoozed' && s.snoozedUntil && s.snoozedUntil > now) return false; + return true; + }); + return json({ suggestions }); +}; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const repoPath = findProjectRoot(process.cwd()); + + switch (body.action) { + case 'generate': { + const state = loadLearningState(repoPath); + if (!state) { + return json({ error: 'No learning state. Run learning model first.' }, { status: 400 }); + } + const suggestions = generateSuggestions(state); + // Merge with existing: keep approved/dismissed status for matching ids. + const existing = loadSuggestions(repoPath); + const existingMap = new Map(existing.map(s => [s.id, s])); + const merged = suggestions.map(s => { + const prev = existingMap.get(s.id); + if (prev && (prev.status === 'approved' || prev.status === 'dismissed')) { + return prev; // don't resurrect + } + return s; + }); + saveSuggestions(repoPath, merged); + return json({ suggestions: merged.filter(s => s.status === 'pending') }); + } + + case 'approve': + case 'dismiss': { + if (!body.id) return json({ error: 'Missing suggestion id' }, { status: 400 }); + updateSuggestionStatus(repoPath, body.id, body.action); + return json({ ok: true }); + } + + case 'snooze': { + if (!body.id) return json({ error: 'Missing suggestion id' }, { status: 400 }); + const until = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + updateSuggestionStatus(repoPath, body.id, 'snoozed', until); + return json({ ok: true, snoozedUntil: until }); + } + + default: + return json({ error: 'Unknown action' }, { status: 400 }); + } +};