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)
|
||||
}
|
||||
|
||||
// --- 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) ---
|
||||
|
||||
#[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,
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
|
|
|
|||
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 { 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<string, unknown>): 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<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 {
|
||||
if (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