feat(session-anchors): implement S-2 session anchor persistence and auto-anchoring

This commit is contained in:
Hibryda 2026-03-11 02:43:06 +01:00
parent 8f4faaafa3
commit a9e94fc154
7 changed files with 616 additions and 1 deletions

View file

@ -492,6 +492,49 @@ fn session_metrics_load(
state.session_db.load_session_metrics(&project_id, limit) state.session_db.load_session_metrics(&project_id, limit)
} }
// --- Session anchor commands ---
#[tauri::command]
fn session_anchors_save(
state: State<'_, AppState>,
anchors: Vec<session::SessionAnchorRecord>,
) -> Result<(), String> {
state.session_db.save_session_anchors(&anchors)
}
#[tauri::command]
fn session_anchors_load(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<session::SessionAnchorRecord>, 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) --- // --- File browser commands (Files tab) ---
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@ -810,6 +853,11 @@ pub fn run() {
project_agent_state_load, project_agent_state_load,
session_metric_save, session_metric_save,
session_metrics_load, session_metrics_load,
session_anchors_save,
session_anchors_load,
session_anchor_delete,
session_anchors_clear,
session_anchor_update_type,
cli_get_group, cli_get_group,
pick_directory, pick_directory,
open_url, open_url,

View file

@ -171,7 +171,20 @@ impl SessionDb {
error_message TEXT error_message TEXT
); );
CREATE INDEX IF NOT EXISTS idx_session_metrics_project 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}"))?; ).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?;
Ok(()) Ok(())
@ -597,6 +610,91 @@ impl SessionDb {
Ok(metrics) 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<Vec<SessionAnchorRecord>, 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::<Result<Vec<_>, _>>()
.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)] #[cfg(test)]

View file

@ -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<void> {
return invoke('session_anchors_save', { anchors });
}
export async function loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
return invoke('session_anchors_load', { projectId });
}
export async function deleteSessionAnchor(id: string): Promise<void> {
return invoke('session_anchor_delete', { id });
}
export async function clearProjectAnchors(projectId: string): Promise<void> {
return invoke('session_anchors_clear', { projectId });
}
export async function updateAnchorType(id: string, anchorType: string): Promise<void> {
return invoke('session_anchor_update_type', { id, anchorType });
}

View file

@ -28,6 +28,9 @@ import { tel } from './adapters/telemetry-bridge';
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; 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 unlistenMsg: (() => void) | null = null;
let unlistenExit: (() => void) | null = null; let unlistenExit: (() => void) | null = null;
@ -209,6 +212,18 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
break; 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': { case 'cost': {
const cost = msg.content as CostContent; const cost = msg.content as CostContent;
updateAgentCost(sessionId, { updateAgentCost(sessionId, {
@ -395,6 +410,48 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
} }
} }
/** 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 { export function stopAgentDispatcher(): void {
if (unlistenMsg) { if (unlistenMsg) {
unlistenMsg(); unlistenMsg();

View file

@ -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<Map<string, SessionAnchor[]>>(new Map());
// Track which projects have had auto-anchoring triggered (prevents re-anchoring on subsequent compactions)
const autoAnchoredProjects = $state<Set<string>>(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<void> {
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<void> {
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<void> {
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<void> {
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;
}

View file

@ -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;
}

View file

@ -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<string, AgentMessage>();
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<string, unknown> | 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 = `<session-anchors${projectName ? ` project="${projectName}"` : ''}>`;
const footer = '</session-anchors>';
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 };
}