diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 06bf735..99cace6 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -492,6 +492,49 @@ fn session_metrics_load( state.session_db.load_session_metrics(&project_id, limit) } +// --- Session anchor commands --- + +#[tauri::command] +fn session_anchors_save( + state: State<'_, AppState>, + anchors: Vec, +) -> Result<(), String> { + state.session_db.save_session_anchors(&anchors) +} + +#[tauri::command] +fn session_anchors_load( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + state.session_db.load_session_anchors(&project_id) +} + +#[tauri::command] +fn session_anchor_delete( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + state.session_db.delete_session_anchor(&id) +} + +#[tauri::command] +fn session_anchors_clear( + state: State<'_, AppState>, + project_id: String, +) -> Result<(), String> { + state.session_db.delete_project_anchors(&project_id) +} + +#[tauri::command] +fn session_anchor_update_type( + state: State<'_, AppState>, + id: String, + anchor_type: String, +) -> Result<(), String> { + state.session_db.update_anchor_type(&id, &anchor_type) +} + // --- File browser commands (Files tab) --- #[derive(serde::Serialize)] @@ -810,6 +853,11 @@ pub fn run() { project_agent_state_load, session_metric_save, session_metrics_load, + session_anchors_save, + session_anchors_load, + session_anchor_delete, + session_anchors_clear, + session_anchor_update_type, cli_get_group, pick_directory, open_url, diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index a87a9fd..c3d9dce 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -171,7 +171,20 @@ impl SessionDb { error_message TEXT ); CREATE INDEX IF NOT EXISTS idx_session_metrics_project - ON session_metrics(project_id);" + ON session_metrics(project_id); + + CREATE TABLE IF NOT EXISTS session_anchors ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + message_id TEXT NOT NULL, + anchor_type TEXT NOT NULL, + content TEXT NOT NULL, + estimated_tokens INTEGER NOT NULL, + turn_index INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_session_anchors_project + ON session_anchors(project_id);" ).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?; Ok(()) @@ -597,6 +610,91 @@ impl SessionDb { Ok(metrics) } + + // --- Session anchors --- + + pub fn save_session_anchors(&self, anchors: &[SessionAnchorRecord]) -> Result<(), String> { + if anchors.is_empty() { + return Ok(()); + } + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "INSERT OR REPLACE INTO session_anchors (id, project_id, message_id, anchor_type, content, estimated_tokens, turn_index, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)" + ).map_err(|e| format!("Prepare anchor insert failed: {e}"))?; + + for anchor in anchors { + stmt.execute(params![ + anchor.id, + anchor.project_id, + anchor.message_id, + anchor.anchor_type, + anchor.content, + anchor.estimated_tokens, + anchor.turn_index, + anchor.created_at, + ]).map_err(|e| format!("Insert anchor failed: {e}"))?; + } + Ok(()) + } + + pub fn load_session_anchors(&self, project_id: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, message_id, anchor_type, content, estimated_tokens, turn_index, created_at FROM session_anchors WHERE project_id = ?1 ORDER BY turn_index ASC" + ).map_err(|e| format!("Query anchors failed: {e}"))?; + + let anchors = stmt.query_map(params![project_id], |row| { + Ok(SessionAnchorRecord { + id: row.get(0)?, + project_id: row.get(1)?, + message_id: row.get(2)?, + anchor_type: row.get(3)?, + content: row.get(4)?, + estimated_tokens: row.get(5)?, + turn_index: row.get(6)?, + created_at: row.get(7)?, + }) + }).map_err(|e| format!("Query anchors failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Read anchor row failed: {e}"))?; + + Ok(anchors) + } + + pub fn delete_session_anchor(&self, id: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM session_anchors WHERE id = ?1", params![id]) + .map_err(|e| format!("Delete anchor failed: {e}"))?; + Ok(()) + } + + pub fn delete_project_anchors(&self, project_id: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM session_anchors WHERE project_id = ?1", params![project_id]) + .map_err(|e| format!("Delete project anchors failed: {e}"))?; + Ok(()) + } + + pub fn update_anchor_type(&self, id: &str, anchor_type: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE session_anchors SET anchor_type = ?2 WHERE id = ?1", + params![id, anchor_type], + ).map_err(|e| format!("Update anchor type failed: {e}"))?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionAnchorRecord { + pub id: String, + pub project_id: String, + pub message_id: String, + pub anchor_type: String, + pub content: String, + pub estimated_tokens: i64, + pub turn_index: i64, + pub created_at: i64, } #[cfg(test)] diff --git a/v2/src/lib/adapters/anchors-bridge.ts b/v2/src/lib/adapters/anchors-bridge.ts new file mode 100644 index 0000000..abc51ca --- /dev/null +++ b/v2/src/lib/adapters/anchors-bridge.ts @@ -0,0 +1,25 @@ +// Anchors Bridge — Tauri IPC adapter for session anchor CRUD +// Mirrors groups-bridge.ts pattern + +import { invoke } from '@tauri-apps/api/core'; +import type { SessionAnchorRecord } from '../types/anchors'; + +export async function saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise { + return invoke('session_anchors_save', { anchors }); +} + +export async function loadSessionAnchors(projectId: string): Promise { + return invoke('session_anchors_load', { projectId }); +} + +export async function deleteSessionAnchor(id: string): Promise { + return invoke('session_anchor_delete', { id }); +} + +export async function clearProjectAnchors(projectId: string): Promise { + return invoke('session_anchors_clear', { projectId }); +} + +export async function updateAnchorType(id: string, anchorType: string): Promise { + return invoke('session_anchor_update_type', { id, anchorType }); +} diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 44b6b2e..c15b67d 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -28,6 +28,9 @@ import { tel } from './adapters/telemetry-bridge'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; +import { hasAutoAnchored, markAutoAnchored, addAnchors, getAnchorSettings } from './stores/anchors.svelte'; +import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer'; +import type { SessionAnchor } from './types/anchors'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; @@ -209,6 +212,18 @@ function handleAgentEvent(sessionId: string, event: Record): vo break; } + case 'compaction': { + // Auto-anchor on first compaction for this project + const compactProjId = sessionProjectMap.get(sessionId); + if (compactProjId && !hasAutoAnchored(compactProjId)) { + const session = getAgentSession(sessionId); + if (session) { + triggerAutoAnchor(compactProjId, session.messages, session.prompt); + } + } + break; + } + case 'cost': { const cost = msg.content as CostContent; updateAgentCost(sessionId, { @@ -395,6 +410,48 @@ async function persistSessionForProject(sessionId: string): Promise { } } +/** Auto-anchor first N turns on first compaction event for a project */ +function triggerAutoAnchor( + projectId: string, + messages: import('./adapters/claude-messages').AgentMessage[], + sessionPrompt: string, +): void { + markAutoAnchored(projectId); + + const settings = getAnchorSettings(projectId); + const { turns, totalTokens } = selectAutoAnchors( + messages, + sessionPrompt, + settings.anchorTurns, + settings.anchorTokenBudget, + ); + + if (turns.length === 0) return; + + const nowSecs = Math.floor(Date.now() / 1000); + const anchors: SessionAnchor[] = turns.map((turn, i) => { + const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget); + return { + id: crypto.randomUUID(), + projectId, + messageId: `turn-${turn.index}`, + anchorType: 'auto' as const, + content: content, + estimatedTokens: turn.estimatedTokens, + turnIndex: turn.index, + createdAt: nowSecs, + }; + }); + + addAnchors(projectId, anchors); + tel.info('auto_anchor_created', { + projectId, + anchorCount: anchors.length, + totalTokens, + }); + notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); +} + export function stopAgentDispatcher(): void { if (unlistenMsg) { unlistenMsg(); diff --git a/v2/src/lib/stores/anchors.svelte.ts b/v2/src/lib/stores/anchors.svelte.ts new file mode 100644 index 0000000..f6479b3 --- /dev/null +++ b/v2/src/lib/stores/anchors.svelte.ts @@ -0,0 +1,125 @@ +// Session Anchors store — Svelte 5 runes +// Per-project anchor management with re-injection support + +import type { SessionAnchor, AnchorType, SessionAnchorRecord } from '../types/anchors'; +import { DEFAULT_ANCHOR_SETTINGS } from '../types/anchors'; +import { + saveSessionAnchors, + loadSessionAnchors, + deleteSessionAnchor, + updateAnchorType as updateAnchorTypeBridge, +} from '../adapters/anchors-bridge'; + +// Per-project anchor state +const projectAnchors = $state>(new Map()); + +// Track which projects have had auto-anchoring triggered (prevents re-anchoring on subsequent compactions) +const autoAnchoredProjects = $state>(new Set()); + +export function getProjectAnchors(projectId: string): SessionAnchor[] { + return projectAnchors.get(projectId) ?? []; +} + +/** Get only re-injectable anchors (auto + promoted, not pinned-only) */ +export function getInjectableAnchors(projectId: string): SessionAnchor[] { + const anchors = projectAnchors.get(projectId) ?? []; + return anchors.filter(a => a.anchorType === 'auto' || a.anchorType === 'promoted'); +} + +/** Total estimated tokens for re-injectable anchors */ +export function getInjectableTokenCount(projectId: string): number { + return getInjectableAnchors(projectId).reduce((sum, a) => sum + a.estimatedTokens, 0); +} + +/** Check if auto-anchoring has already run for this project */ +export function hasAutoAnchored(projectId: string): boolean { + return autoAnchoredProjects.has(projectId); +} + +/** Mark project as having been auto-anchored */ +export function markAutoAnchored(projectId: string): void { + autoAnchoredProjects.add(projectId); +} + +/** Add anchors to a project (in-memory + persist) */ +export async function addAnchors(projectId: string, anchors: SessionAnchor[]): Promise { + const existing = projectAnchors.get(projectId) ?? []; + const updated = [...existing, ...anchors]; + projectAnchors.set(projectId, updated); + + // Persist to SQLite + const records: SessionAnchorRecord[] = anchors.map(a => ({ + id: a.id, + project_id: a.projectId, + message_id: a.messageId, + anchor_type: a.anchorType, + content: a.content, + estimated_tokens: a.estimatedTokens, + turn_index: a.turnIndex, + created_at: a.createdAt, + })); + + try { + await saveSessionAnchors(records); + } catch (e) { + console.warn('Failed to persist anchors:', e); + } +} + +/** Remove a single anchor */ +export async function removeAnchor(projectId: string, anchorId: string): Promise { + const existing = projectAnchors.get(projectId) ?? []; + projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId)); + + try { + await deleteSessionAnchor(anchorId); + } catch (e) { + console.warn('Failed to delete anchor:', e); + } +} + +/** Change anchor type (pinned <-> promoted) */ +export async function changeAnchorType(projectId: string, anchorId: string, newType: AnchorType): Promise { + const existing = projectAnchors.get(projectId) ?? []; + const anchor = existing.find(a => a.id === anchorId); + if (!anchor) return; + + anchor.anchorType = newType; + // Trigger reactivity + projectAnchors.set(projectId, [...existing]); + + try { + await updateAnchorTypeBridge(anchorId, newType); + } catch (e) { + console.warn('Failed to update anchor type:', e); + } +} + +/** Load anchors from SQLite for a project */ +export async function loadAnchorsForProject(projectId: string): Promise { + try { + const records = await loadSessionAnchors(projectId); + const anchors: SessionAnchor[] = records.map(r => ({ + id: r.id, + projectId: r.project_id, + messageId: r.message_id, + anchorType: r.anchor_type as AnchorType, + content: r.content, + estimatedTokens: r.estimated_tokens, + turnIndex: r.turn_index, + createdAt: r.created_at, + })); + projectAnchors.set(projectId, anchors); + // If anchors exist, mark as already auto-anchored + if (anchors.some(a => a.anchorType === 'auto')) { + autoAnchoredProjects.add(projectId); + } + } catch (e) { + console.warn('Failed to load anchors for project:', e); + } +} + +/** Get anchor settings (uses defaults for now — per-project config can be added later) */ +export function getAnchorSettings(_projectId: string) { + return DEFAULT_ANCHOR_SETTINGS; +} diff --git a/v2/src/lib/types/anchors.ts b/v2/src/lib/types/anchors.ts new file mode 100644 index 0000000..1d4cb8e --- /dev/null +++ b/v2/src/lib/types/anchors.ts @@ -0,0 +1,50 @@ +// Session Anchor types — preserves important conversation turns through compaction chains +// Anchored turns are re-injected into system prompt on subsequent queries + +/** Anchor classification */ +export type AnchorType = 'auto' | 'pinned' | 'promoted'; + +/** A single anchored turn, stored per-project */ +export interface SessionAnchor { + id: string; + projectId: string; + messageId: string; + anchorType: AnchorType; + /** Serialized turn text for re-injection (observation-masked) */ + content: string; + /** Estimated token count (~chars/4) */ + estimatedTokens: number; + /** Turn index in original session */ + turnIndex: number; + createdAt: number; +} + +/** Settings for anchor behavior, stored per-project */ +export interface AnchorSettings { + /** Number of turns to auto-anchor on first compaction (default: 3) */ + anchorTurns: number; + /** Hard cap on re-injectable anchor tokens (default: 6144) */ + anchorTokenBudget: number; +} + +export const DEFAULT_ANCHOR_SETTINGS: AnchorSettings = { + anchorTurns: 3, + anchorTokenBudget: 6144, +}; + +/** Maximum token budget for re-injected anchors */ +export const MAX_ANCHOR_TOKEN_BUDGET = 20_000; +/** Minimum token budget */ +export const MIN_ANCHOR_TOKEN_BUDGET = 2_000; + +/** Rust-side record shape (matches SessionAnchorRecord in session.rs) */ +export interface SessionAnchorRecord { + id: string; + project_id: string; + message_id: string; + anchor_type: string; + content: string; + estimated_tokens: number; + turn_index: number; + created_at: number; +} diff --git a/v2/src/lib/utils/anchor-serializer.ts b/v2/src/lib/utils/anchor-serializer.ts new file mode 100644 index 0000000..dbfc8a7 --- /dev/null +++ b/v2/src/lib/utils/anchor-serializer.ts @@ -0,0 +1,212 @@ +// Anchor Serializer — converts agent messages into observation-masked anchor text +// Observation masking: preserve user prompts + assistant reasoning, compact tool results + +import type { AgentMessage, TextContent, ToolCallContent, ToolResultContent } from '../adapters/claude-messages'; + +/** Estimate token count from text (~4 chars per token) */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** A turn group: one user prompt + assistant response + tool interactions */ +export interface TurnGroup { + index: number; + userPrompt: string; + assistantText: string; + toolSummaries: string[]; + estimatedTokens: number; +} + +/** + * Group messages into turns. A new turn starts at each 'cost' event boundary + * or at session start. The first turn includes messages from init to the first cost event. + */ +export function groupMessagesIntoTurns(messages: AgentMessage[]): TurnGroup[] { + const turns: TurnGroup[] = []; + let currentTurn: { userPrompt: string; assistantText: string; toolSummaries: string[]; messages: AgentMessage[] } = { + userPrompt: '', + assistantText: '', + toolSummaries: [], + messages: [], + }; + let turnIndex = 0; + + // Build a map of toolUseId -> tool_result for compact summaries + const toolResults = new Map(); + for (const msg of messages) { + if (msg.type === 'tool_result') { + const tr = msg.content as ToolResultContent; + toolResults.set(tr.toolUseId, msg); + } + } + + for (const msg of messages) { + switch (msg.type) { + case 'text': { + const text = (msg.content as TextContent).text; + currentTurn.assistantText += (currentTurn.assistantText ? '\n' : '') + text; + break; + } + case 'tool_call': { + const tc = msg.content as ToolCallContent; + const result = toolResults.get(tc.toolUseId); + const summary = compactToolSummary(tc, result); + currentTurn.toolSummaries.push(summary); + break; + } + case 'cost': { + // End of turn — finalize and start new one + if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { + const serialized = serializeTurn(turnIndex, currentTurn); + turns.push({ + index: turnIndex, + userPrompt: currentTurn.userPrompt, + assistantText: currentTurn.assistantText, + toolSummaries: currentTurn.toolSummaries, + estimatedTokens: estimateTokens(serialized), + }); + turnIndex++; + } + currentTurn = { userPrompt: '', assistantText: '', toolSummaries: [], messages: [] }; + break; + } + // Skip init, thinking, compaction, status, etc. + } + } + + // Finalize last turn if it has content (session may not have ended with cost) + if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { + const serialized = serializeTurn(turnIndex, currentTurn); + turns.push({ + index: turnIndex, + userPrompt: currentTurn.userPrompt, + assistantText: currentTurn.assistantText, + toolSummaries: currentTurn.toolSummaries, + estimatedTokens: estimateTokens(serialized), + }); + } + + return turns; +} + +/** Compact a tool_call + optional tool_result into a short summary */ +function compactToolSummary(tc: ToolCallContent, result?: AgentMessage): string { + const name = tc.name; + const input = tc.input as Record | undefined; + + // Extract key info based on tool type + let detail = ''; + if (input) { + if (name === 'Read' || name === 'read_file') { + detail = ` ${input.file_path ?? input.path ?? ''}`; + } else if (name === 'Write' || name === 'write_file') { + const path = input.file_path ?? input.path ?? ''; + const content = typeof input.content === 'string' ? input.content : ''; + detail = ` ${path} → ${content.split('\n').length} lines`; + } else if (name === 'Edit' || name === 'edit_file') { + detail = ` ${input.file_path ?? input.path ?? ''}`; + } else if (name === 'Bash' || name === 'execute_bash') { + const cmd = typeof input.command === 'string' ? input.command.slice(0, 80) : ''; + detail = ` \`${cmd}\``; + } else if (name === 'Glob' || name === 'Grep') { + const pattern = input.pattern ?? ''; + detail = ` ${pattern}`; + } + } + + // Add compact result indicator + let resultNote = ''; + if (result) { + const output = result.content as ToolResultContent; + const outStr = typeof output.output === 'string' ? output.output : JSON.stringify(output.output ?? ''); + const lineCount = outStr.split('\n').length; + resultNote = ` → ${lineCount} lines`; + } + + return `[${name}${detail}${resultNote}]`; +} + +/** Serialize a single turn to observation-masked text */ +function serializeTurn( + index: number, + turn: { userPrompt: string; assistantText: string; toolSummaries: string[] }, +): string { + const parts: string[] = []; + + if (turn.userPrompt) { + parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`); + } + if (turn.assistantText) { + // Truncate very long responses to ~500 chars + const text = turn.assistantText.length > 500 + ? turn.assistantText.slice(0, 497) + '...' + : turn.assistantText; + parts.push(`[Turn ${index + 1}] Assistant: "${text}"`); + } + if (turn.toolSummaries.length > 0) { + parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`); + } + + return parts.join('\n'); +} + +/** + * Serialize turns into anchor text for system prompt re-injection. + * Respects token budget — stops adding turns when budget would be exceeded. + */ +export function serializeAnchorsForInjection( + turns: TurnGroup[], + tokenBudget: number, + projectName?: string, +): string { + const header = ``; + const footer = ''; + const headerTokens = estimateTokens(header + '\n' + footer); + + let remaining = tokenBudget - headerTokens; + const lines: string[] = [header]; + lines.push('Key decisions and context from earlier in this project:'); + lines.push(''); + + for (const turn of turns) { + const text = serializeTurn(turn.index, turn); + const tokens = estimateTokens(text); + if (tokens > remaining) break; + lines.push(text); + lines.push(''); + remaining -= tokens; + } + + lines.push(footer); + return lines.join('\n'); +} + +/** + * Select turns for auto-anchoring on first compaction. + * Takes first N turns up to token budget, using the session's original prompt as turn 0 user prompt. + */ +export function selectAutoAnchors( + messages: AgentMessage[], + sessionPrompt: string, + maxTurns: number, + tokenBudget: number, +): { turns: TurnGroup[]; totalTokens: number } { + const allTurns = groupMessagesIntoTurns(messages); + + // Inject session prompt as user prompt for turn 0 + if (allTurns.length > 0 && !allTurns[0].userPrompt) { + allTurns[0].userPrompt = sessionPrompt; + } + + const selected: TurnGroup[] = []; + let totalTokens = 0; + + for (const turn of allTurns) { + if (selected.length >= maxTurns) break; + if (totalTokens + turn.estimatedTokens > tokenBudget) break; + selected.push(turn); + totalTokens += turn.estimatedTokens; + } + + return { turns: selected, totalTokens }; +}