feat(session-anchors): implement S-2 session anchor persistence and auto-anchoring
This commit is contained in:
parent
8f4faaafa3
commit
a9e94fc154
7 changed files with 616 additions and 1 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
25
v2/src/lib/adapters/anchors-bridge.ts
Normal file
25
v2/src/lib/adapters/anchors-bridge.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
125
v2/src/lib/stores/anchors.svelte.ts
Normal file
125
v2/src/lib/stores/anchors.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
50
v2/src/lib/types/anchors.ts
Normal file
50
v2/src/lib/types/anchors.ts
Normal 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;
|
||||||
|
}
|
||||||
212
v2/src/lib/utils/anchor-serializer.ts
Normal file
212
v2/src/lib/utils/anchor-serializer.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue