From f3437f0dbfe743d3a6d884af1b92fbb5fc36c44a Mon Sep 17 00:00:00 2001 From: smorchj Date: Wed, 15 Apr 2026 22:45:29 +0200 Subject: [PATCH 1/7] feat: pivot to contextualizer-only (#77) Klonode is not a Claude wrapper. It is a contextualizer that rides alongside Claude Code. Claude Code handles the conversation. Klonode watches, learns, and improves the routing. Delete everything in the chat/CO/agent surface. Keep the analyzer, graph, tree, editor, and GitHub view. Follow-up PRs add the JSONL session watcher, live node graph, learning model, and suggestions panel. Deleted: - ChatPanel component - stores/chat, stores/agents, stores/settings - api/chat (+ /stream, /classify), api/co, api/agents - core/agents/{chief-organizer,message-bus,agent-registry} - Chat panel wiring in +page.svelte and +layout.svelte - Agents re-exports from core/index.ts Net: -4377 lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/agents/agent-registry.ts | 267 ---- packages/core/src/agents/chief-organizer.ts | 217 --- packages/core/src/agents/message-bus.ts | 168 -- packages/core/src/index.ts | 10 - .../lib/components/ChatPanel/ChatPanel.svelte | 1375 ----------------- packages/ui/src/lib/stores/agents.ts | 502 ------ packages/ui/src/lib/stores/chat.ts | 598 ------- packages/ui/src/lib/stores/settings.ts | 69 - packages/ui/src/routes/+layout.svelte | 4 - packages/ui/src/routes/+page.svelte | 58 - packages/ui/src/routes/api/agents/+server.ts | 204 --- packages/ui/src/routes/api/chat/+server.ts | 469 ------ .../src/routes/api/chat/classify/+server.ts | 127 -- .../ui/src/routes/api/chat/stream/+server.ts | 252 --- packages/ui/src/routes/api/co/+server.ts | 55 - .../src/routes/api/graph/current/+server.ts | 4 +- 16 files changed, 2 insertions(+), 4377 deletions(-) delete mode 100644 packages/core/src/agents/agent-registry.ts delete mode 100644 packages/core/src/agents/chief-organizer.ts delete mode 100644 packages/core/src/agents/message-bus.ts delete mode 100644 packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte delete mode 100644 packages/ui/src/lib/stores/agents.ts delete mode 100644 packages/ui/src/lib/stores/chat.ts delete mode 100644 packages/ui/src/lib/stores/settings.ts delete mode 100644 packages/ui/src/routes/api/agents/+server.ts delete mode 100644 packages/ui/src/routes/api/chat/+server.ts delete mode 100644 packages/ui/src/routes/api/chat/classify/+server.ts delete mode 100644 packages/ui/src/routes/api/chat/stream/+server.ts delete mode 100644 packages/ui/src/routes/api/co/+server.ts 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/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/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/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 66c8b7d..f5f00e3 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -5,8 +5,6 @@ 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 { defineComponent, @@ -59,8 +57,6 @@ graphStore, selectedNodeId, githubStore, - chatStore, - sessionsStore, simulatorStore, ]); return stop; diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index 4f8d542..365ccd1 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -5,12 +5,10 @@ import TreeView from '$lib/components/TreeView/TreeView.svelte'; import GraphView from '$lib/components/GraphView/GraphView.svelte'; import ContextEditor from '$lib/components/Editor/ContextEditor.svelte'; - import ChatPanel from '$lib/components/ChatPanel/ChatPanel.svelte'; import GitHubView from '$lib/components/GitHubView/GitHubView.svelte'; let loaded = false; let error = ''; - let showChat = true; let graphSource: 'real' | 'demo' | null = null; onMount(async () => { @@ -41,7 +39,6 @@ class:tree-only={$viewMode === 'tree'} class:graph-only={$viewMode === 'graph'} class:github-only={$viewMode === 'github'} - class:chat-open={showChat} > {#if $viewMode === 'tree' || $viewMode === 'split'} @@ -70,24 +67,7 @@
{/if} - - - {#if showChat} -
- -
- {/if} - - - {/if} 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..1f2b2c2 --- /dev/null +++ b/packages/ui/src/routes/api/sessions/stream/+server.ts @@ -0,0 +1,103 @@ +/** + * 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, +} from '$lib/server/session-watcher'; + +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); + + 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) => { + send('activity', event); + }); + + // 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', + }, + }); +}; From cdc8153c0267ec97691df7b34a20c2a55b2b2f02 Mon Sep 17 00:00:00 2001 From: smorchj Date: Thu, 16 Apr 2026 06:39:30 +0200 Subject: [PATCH 3/7] feat: persistent observation log for contextualizer learning (#79) Every tool_use event the session watcher extracts from Claude Code's JSONL files now also gets appended to `.klonode/observations.jsonl`. This is the foundation the learning model (#80, #81) needs to compute cross-session statistics. New: - `packages/ui/src/lib/server/observation-log.ts`: append-only JSONL store with dedup (bounded in-memory set seeded from file tail on restart), file rotation at 50 MB, normalized paths. - `packages/ui/src/routes/api/observations/+server.ts`: GET for stats (counts per tool/session/folder, file size), POST for purge. - `klonode observations` CLI command: displays stats or purges with `--purge`. Wiring: - The SSE endpoint (`api/sessions/stream`) now calls `observationLog.append(event)` alongside the SSE push. The log dedupes internally so multiple SSE clients don't double-record. - Project root resolution: walks up from the server's process.cwd() looking for `.klonode/` or `.git` so the log lands at the repo root, not in whatever subdirectory the dev server happens to run from. Verified: 7 events recorded in `.klonode/observations.jsonl` at the worktree root, `/api/observations` returns correct per-tool and per-session stats. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 45 ++++ packages/ui/src/lib/server/observation-log.ts | 238 ++++++++++++++++++ .../ui/src/routes/api/observations/+server.ts | 44 ++++ .../src/routes/api/sessions/stream/+server.ts | 32 +++ 4 files changed, 359 insertions(+) create mode 100644 packages/ui/src/lib/server/observation-log.ts create mode 100644 packages/ui/src/routes/api/observations/+server.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 371fb92..03a3472 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -39,6 +39,51 @@ 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('update') .description('Regenerate routing for changed directories') 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/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 index 1f2b2c2..9ca6782 100644 --- a/packages/ui/src/routes/api/sessions/stream/+server.ts +++ b/packages/ui/src/routes/api/sessions/stream/+server.ts @@ -20,6 +20,24 @@ import { resolveWatchDirs, type SessionActivityEvent, } from '$lib/server/session-watcher'; +import { openObservationLog } from '$lib/server/observation-log'; +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'; @@ -28,6 +46,19 @@ export const GET: RequestHandler = async ({ url, request }) => { 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; @@ -58,6 +89,7 @@ export const GET: RequestHandler = async ({ url, request }) => { }); unsubscribe = watcher.onEvent((event: SessionActivityEvent) => { + observationLog.append(event); send('activity', event); }); From 7a2afd8c73f4207c65b27870d755adf4856a3d8a Mon Sep 17 00:00:00 2001 From: smorchj Date: Thu, 16 Apr 2026 06:44:24 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20learning=20model=20=E2=80=94=20repe?= =?UTF-8?q?tition=20confidence=20+=20emotion=20urgency=20(#80,=20#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brain of the contextualizer. Two signal families scored per folder: Repetition → confidence in [0, 1]: confidence = min(1, sessionFraction*0.5 + editBoost*0.3 + diversityBoost*0.2) - sessionFraction: how many sessions touched this folder vs total - editBoost: min(1, editCount/10) — edits weight more than reads - diversityBoost: min(1, distinctTools/4) — variety signals importance Emotion → urgency in [0, ∞) with exponential time decay: urgency = Σ weight(event) * exp(-λ * ageDays) (half-life = 7 days) - correction (no/stop/wrong/nei/ikke): weight 5 - max_turns hit: weight 3 - hedge (couldn't find/can't locate): weight 1 - praise (perfect/thanks/great): weight -2 (reduces urgency) Emotion events are extracted from Claude Code session JSONL by parsing user/assistant message text for keywords. Repetition is computed purely from the observation log. New: - `packages/ui/src/lib/server/learning.ts`: pure functions `computeRepetition`, `computeUrgency`, `computeLearningState`, `extractEmotionEvents`. Plus `saveLearningState`/`loadLearningState` for `.klonode/learning.json` persistence. - `packages/ui/src/routes/api/learning/+server.ts`: GET returns current learning state, POST action=recompute runs the model on the observation log and saves. - `klonode learn` CLI command: computes and saves learning state, prints top folders by confidence with a bar chart. Verified: 26 observations from the current session produce per-folder confidence scores. Urgency computation is wired but requires emotion event extraction from raw JSONL (session-end trigger, #82) to produce non-zero scores. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/cli.ts | 50 ++ packages/ui/src/lib/server/learning.ts | 435 ++++++++++++++++++ .../ui/src/routes/api/learning/+server.ts | 53 +++ 3 files changed, 538 insertions(+) create mode 100644 packages/ui/src/lib/server/learning.ts create mode 100644 packages/ui/src/routes/api/learning/+server.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 03a3472..a4204a5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -84,6 +84,56 @@ program } }); +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/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/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 }); +}; From c7bf0b6d32df9a8d0603ee9c2b1e74ffbc335fea Mon Sep 17 00:00:00 2001 From: smorchj Date: Thu, 16 Apr 2026 06:47:22 +0200 Subject: [PATCH 5/7] feat: session-end detection + auto learning recompute (#82) When a Claude Code session's JSONL file stops growing for 10 minutes, the watcher declares it ended, recomputes the learning model from the full observation log, saves `.klonode/learning.json`, and pushes a `session-ended` SSE event to the client. Changes: - `session-watcher.ts`: WatchedFile gains `lastGrowthAt` and `endedFired` fields. Poll loop checks idle timeout, emits `session-ended` via EventEmitter. Re-arms on new growth so a session that resumes after a pause gets a fresh detection cycle. - `api/sessions/stream`: subscribes to `onSessionEnded`, runs `computeLearningState` + `saveLearningState` inline, sends the event to the client with node count and observation count. - `sessionWatcher.ts` (client): listens for `session-ended` event, updates status with node count message and `pendingSuggestions`. The 10-minute timeout is conservative. A shorter timeout would fire false positives when Claude is thinking on a long tool call. Longer timeouts delay the feedback loop. 10 minutes is the sweet spot. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/src/lib/server/session-watcher.ts | 39 ++++++++++++++++++- packages/ui/src/lib/stores/sessionWatcher.ts | 17 ++++++++ .../src/routes/api/sessions/stream/+server.ts | 21 ++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/lib/server/session-watcher.ts b/packages/ui/src/lib/server/session-watcher.ts index e48d293..2c73e3c 100644 --- a/packages/ui/src/lib/server/session-watcher.ts +++ b/packages/ui/src/lib/server/session-watcher.ts @@ -59,13 +59,29 @@ interface WatchedFile { 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 event as it's parsed. */ + /** 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. */ @@ -265,6 +281,8 @@ export function createSessionWatcher(dirs: string[]): WatcherHandle { offset: size, buffer: '', sessionId: entry.replace(/\.jsonl$/, ''), + lastGrowthAt: Date.now(), + endedFired: false, }); } } @@ -280,7 +298,20 @@ export function createSessionWatcher(dirs: string[]): WatcherHandle { watched.delete(state.path); return; } - if (size === state.offset) 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; @@ -341,6 +372,10 @@ export function createSessionWatcher(dirs: string[]): WatcherHandle { 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; }, diff --git a/packages/ui/src/lib/stores/sessionWatcher.ts b/packages/ui/src/lib/stores/sessionWatcher.ts index 13d6434..db1e04c 100644 --- a/packages/ui/src/lib/stores/sessionWatcher.ts +++ b/packages/ui/src/lib/stores/sessionWatcher.ts @@ -29,6 +29,8 @@ export interface SessionWatcherStatus { 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 = { @@ -37,6 +39,7 @@ const initial: SessionWatcherStatus = { watchedFileCount: 0, eventCount: 0, message: 'idle', + pendingSuggestions: 0, }; export const sessionWatcherStatus = writable(initial); @@ -147,6 +150,20 @@ function openStream(scope: WatchScope): void { })); }); + 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 diff --git a/packages/ui/src/routes/api/sessions/stream/+server.ts b/packages/ui/src/routes/api/sessions/stream/+server.ts index 9ca6782..2648f5c 100644 --- a/packages/ui/src/routes/api/sessions/stream/+server.ts +++ b/packages/ui/src/routes/api/sessions/stream/+server.ts @@ -19,8 +19,10 @@ 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'; @@ -93,6 +95,25 @@ export const GET: RequestHandler = async ({ url, request }) => { 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. From 4de7ffb52b54a72e16e04533733d8150bb7bdba9 Mon Sep 17 00:00:00 2001 From: smorchj Date: Thu, 16 Apr 2026 06:49:48 +0200 Subject: [PATCH 6/7] feat: visualize confidence and urgency on graph nodes (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph nodes now reflect the learning model's per-folder scores: - **Confidence → opacity**: high-confidence folders (frequently accessed across sessions) render at full opacity. Low-confidence folders fade to 50% opacity. Makes load-bearing parts of the codebase visually prominent at a glance. - **Urgency → border color**: folders with urgency > 2 get a red border (#ef4444), > 0.5 gets orange (#f97316), otherwise default layer color. Urgency decays over 7 days so the graph drifts back to calm naturally. - Activity pulse (live watcher) takes priority over urgency color when both are present — you see what Claude is doing RIGHT NOW, not what the learning model thinks about the node. New: - `packages/ui/src/lib/stores/learning.ts`: client store that fetches `/api/learning` on mount, exposes a derived `learningScores` map keyed by folder path. - GraphView.svelte: imports `learningScores`, computes `confidenceOpacity` and `urgencyColor` per node, applies to group opacity and rect stroke. - Layout loads learning state on mount via `loadLearning()`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/GraphView/GraphView.svelte | 12 ++- packages/ui/src/lib/stores/learning.ts | 76 +++++++++++++++++++ packages/ui/src/routes/+layout.svelte | 3 + 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/stores/learning.ts 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/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/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 8092619..0ad8b32 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -8,6 +8,7 @@ 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, @@ -65,6 +66,8 @@ // 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(); From 689fc33cc2a872a71050984a1e0eadf02b65796d Mon Sep 17 00:00:00 2001 From: smorchj Date: Thu, 16 Apr 2026 06:53:06 +0200 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20suggestions=20panel=20=E2=80=94=20c?= =?UTF-8?q?ontextualizer=20output=20with=20approve/dismiss=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing output of the learning model. Right-side panel shows urgency-sorted CONTEXT.md improvement proposals with one-click actions. New: - `packages/ui/src/lib/server/suggestions.ts`: suggestion engine with strategy functions (`addRoutingSuggestions`, `urgentFixSuggestions`). Pure functions of `(learningState) → Suggestion[]`. Persistence to `.klonode/suggestions.json` with status tracking (pending, approved, dismissed, snoozed). - `packages/ui/src/routes/api/suggestions/+server.ts`: GET returns pending suggestions (filters dismissed/snoozed), POST supports generate/approve/dismiss/snooze actions. Merges with existing suggestions so approved/dismissed status persists across regeneration. - `SuggestionsPanel.svelte`: right-side panel with urgency-colored cards (red >2, orange >1, yellow >0.5). Each card shows type badge, urgency score, title, reason, folder path, and three action buttons. Analyze button triggers suggestion generation from current learning state. - `+page.svelte`: three-panel layout (tree | graph | suggestions) with a toggle button. Grid adds 300px right column when open. Verified: clicked Analyze, two real suggestions appeared from the observation data — "Add routing for packages/core/src/contextualizer" (0.7 confidence, 3 ops) and "Add routing for packages/ui/src/lib/server" (0.6 confidence, 4 reads). Approve/Dismiss/Snooze buttons work. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SuggestionsPanel/SuggestionsPanel.svelte | 272 ++++++++++++++++++ packages/ui/src/lib/server/suggestions.ts | 169 +++++++++++ packages/ui/src/routes/+page.svelte | 53 ++++ .../ui/src/routes/api/suggestions/+server.ts | 86 ++++++ 4 files changed, 580 insertions(+) create mode 100644 packages/ui/src/lib/components/SuggestionsPanel/SuggestionsPanel.svelte create mode 100644 packages/ui/src/lib/server/suggestions.ts create mode 100644 packages/ui/src/routes/api/suggestions/+server.ts 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/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/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index 365ccd1..974b88e 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -6,9 +6,11 @@ import GraphView from '$lib/components/GraphView/GraphView.svelte'; import ContextEditor from '$lib/components/Editor/ContextEditor.svelte'; import GitHubView from '$lib/components/GitHubView/GitHubView.svelte'; + import SuggestionsPanel from '$lib/components/SuggestionsPanel/SuggestionsPanel.svelte'; let loaded = false; let error = ''; + let showSuggestions = true; let graphSource: 'real' | 'demo' | null = null; onMount(async () => { @@ -39,6 +41,7 @@ class:tree-only={$viewMode === 'tree'} class:graph-only={$viewMode === 'graph'} class:github-only={$viewMode === 'github'} + class:suggestions-open={showSuggestions} > {#if $viewMode === 'tree' || $viewMode === 'split'} @@ -67,7 +70,24 @@ {/if} + + + {#if showSuggestions} +
+ +
+ {/if} + + + {/if}