mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
Refactor SessionManager to use static factory methods
- Add factory methods: create(cwd), open(path), continueRecent(cwd), inMemory() - Add static list(cwd) for session listing - Make constructor private, pass cwd explicitly - Update SDK to take sessionManager instead of sessionFile options - Update main.ts to create SessionManager based on CLI flags - Update SessionSelectorComponent to take sessions[] instead of SessionManager - Update tests to use factory methods
This commit is contained in:
parent
7bf4c8ff24
commit
ace8ea3d5b
9 changed files with 346 additions and 473 deletions
|
|
@ -3,17 +3,17 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||||
import type { SessionManager } from "../core/session-manager.js";
|
import type { SessionInfo } from "../core/session-manager.js";
|
||||||
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
|
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
|
||||||
|
|
||||||
/** Show TUI session selector and return selected session path or null if cancelled */
|
/** Show TUI session selector and return selected session path or null if cancelled */
|
||||||
export async function selectSession(sessionManager: SessionManager): Promise<string | null> {
|
export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = new TUI(new ProcessTerminal());
|
const ui = new TUI(new ProcessTerminal());
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const selector = new SessionSelectorComponent(
|
const selector = new SessionSelectorComponent(
|
||||||
sessionManager,
|
sessions,
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,8 @@ export interface CreateAgentSessionOptions {
|
||||||
/** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */
|
/** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */
|
||||||
slashCommands?: FileSlashCommand[];
|
slashCommands?: FileSlashCommand[];
|
||||||
|
|
||||||
/** Session file path, or false to disable persistence. Default: auto in agentDir/sessions/ */
|
/** Session manager. Default: SessionManager.create(cwd) */
|
||||||
sessionFile?: string | false;
|
sessionManager?: SessionManager;
|
||||||
/** Continue most recent session for cwd. */
|
|
||||||
continueSession?: boolean;
|
|
||||||
/** Restore model/thinking from session (default: true when continuing). */
|
|
||||||
restoreFromSession?: boolean;
|
|
||||||
|
|
||||||
/** Settings overrides (merged with agentDir/settings.json) */
|
/** Settings overrides (merged with agentDir/settings.json) */
|
||||||
settings?: Partial<Settings>;
|
settings?: Partial<Settings>;
|
||||||
|
|
@ -411,7 +407,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
|
||||||
* tools: [readTool, bashTool],
|
* tools: [readTool, bashTool],
|
||||||
* hooks: [],
|
* hooks: [],
|
||||||
* skills: [],
|
* skills: [],
|
||||||
* sessionFile: false,
|
* sessionManager: SessionManager.inMemory(),
|
||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
@ -420,34 +416,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
const agentDir = options.agentDir ?? getDefaultAgentDir();
|
||||||
|
|
||||||
const settingsManager = new SettingsManager(agentDir);
|
const settingsManager = new SettingsManager(agentDir);
|
||||||
|
const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
|
||||||
|
|
||||||
const sessionManager = new SessionManager(options.continueSession ?? false, undefined);
|
// Check if session has existing data to restore
|
||||||
if (options.sessionFile === false) {
|
const existingSession = sessionManager.loadSession();
|
||||||
sessionManager.disable();
|
const hasExistingSession = existingSession.messages.length > 0;
|
||||||
} else if (typeof options.sessionFile === "string") {
|
|
||||||
sessionManager.setSessionFile(options.sessionFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
let model = options.model;
|
let model = options.model;
|
||||||
let modelFallbackMessage: string | undefined;
|
let modelFallbackMessage: string | undefined;
|
||||||
const shouldRestoreFromSession = options.restoreFromSession ?? (options.continueSession || options.sessionFile);
|
|
||||||
|
|
||||||
// If continuing/restoring, try to get model from session first
|
// If session has data, try to restore model from it
|
||||||
if (!model && shouldRestoreFromSession) {
|
if (!model && hasExistingSession && existingSession.model) {
|
||||||
const savedModel = sessionManager.loadModel();
|
const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
|
||||||
if (savedModel) {
|
if (restoredModel) {
|
||||||
const restoredModel = findModel(savedModel.provider, savedModel.modelId);
|
const key = await getApiKeyForModel(restoredModel);
|
||||||
if (restoredModel) {
|
if (key) {
|
||||||
const key = await getApiKeyForModel(restoredModel);
|
model = restoredModel;
|
||||||
if (key) {
|
|
||||||
model = restoredModel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If we couldn't restore, we'll fall back below and set fallback message
|
|
||||||
if (!model) {
|
|
||||||
modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!model) {
|
||||||
|
modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still no model, try settings default
|
// If still no model, try settings default
|
||||||
|
|
@ -482,12 +471,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
|
|
||||||
let thinkingLevel = options.thinkingLevel;
|
let thinkingLevel = options.thinkingLevel;
|
||||||
|
|
||||||
// If continuing/restoring, try to get thinking level from session
|
// If session has data, restore thinking level from it
|
||||||
if (thinkingLevel === undefined && shouldRestoreFromSession) {
|
if (thinkingLevel === undefined && hasExistingSession) {
|
||||||
const savedThinking = sessionManager.loadThinkingLevel();
|
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
|
||||||
if (savedThinking) {
|
|
||||||
thinkingLevel = savedThinking as ThinkingLevel;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to settings default
|
// Fall back to settings default
|
||||||
|
|
@ -595,11 +581,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldRestoreFromSession) {
|
// Restore messages if session has existing data
|
||||||
const messages = sessionManager.loadMessages();
|
if (hasExistingSession) {
|
||||||
if (messages.length > 0) {
|
agent.replaceMessages(existingSession.messages);
|
||||||
agent.replaceMessages(messages);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new AgentSession({
|
const session = new AgentSession({
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@ function uuidv4(): string {
|
||||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Session entry types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface SessionHeader {
|
export interface SessionHeader {
|
||||||
type: "session";
|
type: "session";
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,11 +46,10 @@ export interface CompactionEntry {
|
||||||
type: "compaction";
|
type: "compaction";
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
firstKeptEntryIndex: number; // Index into session entries where we start keeping
|
firstKeptEntryIndex: number;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union of all session entry types */
|
|
||||||
export type SessionEntry =
|
export type SessionEntry =
|
||||||
| SessionHeader
|
| SessionHeader
|
||||||
| SessionMessageEntry
|
| SessionMessageEntry
|
||||||
|
|
@ -62,16 +57,22 @@ export type SessionEntry =
|
||||||
| ModelChangeEntry
|
| ModelChangeEntry
|
||||||
| CompactionEntry;
|
| CompactionEntry;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Session loading with compaction support
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface LoadedSession {
|
export interface LoadedSession {
|
||||||
messages: AppMessage[];
|
messages: AppMessage[];
|
||||||
thinkingLevel: string;
|
thinkingLevel: string;
|
||||||
model: { provider: string; modelId: string } | null;
|
model: { provider: string; modelId: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
path: string;
|
||||||
|
id: string;
|
||||||
|
created: Date;
|
||||||
|
modified: Date;
|
||||||
|
messageCount: number;
|
||||||
|
firstMessage: string;
|
||||||
|
allMessagesText: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||||
|
|
||||||
<summary>
|
<summary>
|
||||||
|
|
@ -80,9 +81,6 @@ export const SUMMARY_PREFIX = `The conversation history before this point was co
|
||||||
export const SUMMARY_SUFFIX = `
|
export const SUMMARY_SUFFIX = `
|
||||||
</summary>`;
|
</summary>`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a user message containing the summary with the standard prefix.
|
|
||||||
*/
|
|
||||||
export function createSummaryMessage(summary: string): AppMessage {
|
export function createSummaryMessage(summary: string): AppMessage {
|
||||||
return {
|
return {
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -91,9 +89,6 @@ export function createSummaryMessage(summary: string): AppMessage {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse session file content into entries.
|
|
||||||
*/
|
|
||||||
export function parseSessionEntries(content: string): SessionEntry[] {
|
export function parseSessionEntries(content: string): SessionEntry[] {
|
||||||
const entries: SessionEntry[] = [];
|
const entries: SessionEntry[] = [];
|
||||||
const lines = content.trim().split("\n");
|
const lines = content.trim().split("\n");
|
||||||
|
|
@ -111,17 +106,6 @@ export function parseSessionEntries(content: string): SessionEntry[] {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load session from entries, handling compaction events.
|
|
||||||
*
|
|
||||||
* Algorithm:
|
|
||||||
* 1. Find latest compaction event (if any)
|
|
||||||
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
|
|
||||||
* 3. Prepend summary as user message
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Get the latest compaction entry from session entries, if any.
|
|
||||||
*/
|
|
||||||
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
if (entries[i].type === "compaction") {
|
if (entries[i].type === "compaction") {
|
||||||
|
|
@ -132,7 +116,6 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
// Find model and thinking level (always scan all entries)
|
|
||||||
let thinkingLevel = "off";
|
let thinkingLevel = "off";
|
||||||
let model: { provider: string; modelId: string } | null = null;
|
let model: { provider: string; modelId: string } | null = null;
|
||||||
|
|
||||||
|
|
@ -147,7 +130,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find latest compaction event
|
|
||||||
let latestCompactionIndex = -1;
|
let latestCompactionIndex = -1;
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
if (entries[i].type === "compaction") {
|
if (entries[i].type === "compaction") {
|
||||||
|
|
@ -156,7 +138,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No compaction: return all messages
|
|
||||||
if (latestCompactionIndex === -1) {
|
if (latestCompactionIndex === -1) {
|
||||||
const messages: AppMessage[] = [];
|
const messages: AppMessage[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -169,7 +150,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
|
|
||||||
const compactionEvent = entries[latestCompactionIndex] as CompactionEntry;
|
const compactionEvent = entries[latestCompactionIndex] as CompactionEntry;
|
||||||
|
|
||||||
// Extract messages from firstKeptEntryIndex to end (skipping compaction entries)
|
|
||||||
const keptMessages: AppMessage[] = [];
|
const keptMessages: AppMessage[] = [];
|
||||||
for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
|
for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
|
|
@ -178,7 +158,6 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build final messages: summary + kept messages
|
|
||||||
const messages: AppMessage[] = [];
|
const messages: AppMessage[] = [];
|
||||||
messages.push(createSummaryMessage(compactionEvent.summary));
|
messages.push(createSummaryMessage(compactionEvent.summary));
|
||||||
messages.push(...keptMessages);
|
messages.push(...keptMessages);
|
||||||
|
|
@ -186,320 +165,137 @@ export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
return { messages, thinkingLevel, model };
|
return { messages, thinkingLevel, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionDirectory(cwd: string): string {
|
||||||
|
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||||
|
const configDir = getAgentDir();
|
||||||
|
const sessionDir = join(configDir, "sessions", safePath);
|
||||||
|
if (!existsSync(sessionDir)) {
|
||||||
|
mkdirSync(sessionDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return sessionDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEntriesFromFile(filePath: string): SessionEntry[] {
|
||||||
|
if (!existsSync(filePath)) return [];
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, "utf8");
|
||||||
|
const entries: SessionEntry[] = [];
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as SessionEntry;
|
||||||
|
entries.push(entry);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSessionIdFromFile(filePath: string): string | null {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
|
||||||
|
const lines = readFileSync(filePath, "utf8").trim().split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === "session") {
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMostRecentSession(sessionDir: string): string | null {
|
||||||
|
try {
|
||||||
|
const files = readdirSync(sessionDir)
|
||||||
|
.filter((f) => f.endsWith(".jsonl"))
|
||||||
|
.map((f) => ({
|
||||||
|
path: join(sessionDir, f),
|
||||||
|
mtime: statSync(join(sessionDir, f)).mtime,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||||
|
|
||||||
|
return files[0]?.path || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private sessionId!: string;
|
private sessionId: string;
|
||||||
private sessionFile!: string;
|
private sessionFile: string;
|
||||||
private sessionDir: string;
|
private sessionDir: string;
|
||||||
private enabled: boolean = true;
|
private cwd: string;
|
||||||
private sessionInitialized: boolean = false;
|
private enabled: boolean;
|
||||||
|
private sessionInitialized: boolean;
|
||||||
private pendingEntries: SessionEntry[] = [];
|
private pendingEntries: SessionEntry[] = [];
|
||||||
// In-memory entries for --no-session mode (when enabled=false)
|
|
||||||
private inMemoryEntries: SessionEntry[] = [];
|
private inMemoryEntries: SessionEntry[] = [];
|
||||||
|
|
||||||
constructor(continueSession: boolean = false, customSessionPath?: string) {
|
private constructor(cwd: string, sessionFile: string | null, enabled: boolean) {
|
||||||
this.sessionDir = this.getSessionDirectory();
|
this.cwd = cwd;
|
||||||
|
this.sessionDir = getSessionDirectory(cwd);
|
||||||
|
this.enabled = enabled;
|
||||||
|
|
||||||
if (customSessionPath) {
|
if (sessionFile) {
|
||||||
// Use custom session file path
|
this.sessionFile = resolve(sessionFile);
|
||||||
this.sessionFile = resolve(customSessionPath);
|
this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4();
|
||||||
this.loadSessionId();
|
|
||||||
// If file doesn't exist, loadSessionId() won't set sessionId, so generate one
|
|
||||||
if (!this.sessionId) {
|
|
||||||
this.sessionId = uuidv4();
|
|
||||||
}
|
|
||||||
// Mark as initialized since we're loading an existing session
|
|
||||||
this.sessionInitialized = existsSync(this.sessionFile);
|
this.sessionInitialized = existsSync(this.sessionFile);
|
||||||
// Load entries into memory
|
|
||||||
if (this.sessionInitialized) {
|
if (this.sessionInitialized) {
|
||||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
this.inMemoryEntries = loadEntriesFromFile(this.sessionFile);
|
||||||
}
|
|
||||||
} else if (continueSession) {
|
|
||||||
const mostRecent = this.findMostRecentlyModifiedSession();
|
|
||||||
if (mostRecent) {
|
|
||||||
this.sessionFile = mostRecent;
|
|
||||||
this.loadSessionId();
|
|
||||||
// Mark as initialized since we're loading an existing session
|
|
||||||
this.sessionInitialized = true;
|
|
||||||
// Load entries into memory
|
|
||||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
|
||||||
} else {
|
|
||||||
this.initNewSession();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.initNewSession();
|
this.sessionId = uuidv4();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||||
|
this.sessionInitialized = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disable session saving (for --no-session mode) */
|
/** Create a new session for the given directory */
|
||||||
disable() {
|
static create(cwd: string): SessionManager {
|
||||||
this.enabled = false;
|
return new SessionManager(cwd, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if session persistence is enabled */
|
/** Open a specific session file */
|
||||||
isEnabled(): boolean {
|
static open(path: string): SessionManager {
|
||||||
return this.enabled;
|
// Extract cwd from session header if possible, otherwise use process.cwd()
|
||||||
|
const entries = loadEntriesFromFile(path);
|
||||||
|
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
|
||||||
|
const cwd = header?.cwd ?? process.cwd();
|
||||||
|
return new SessionManager(cwd, path, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSessionDirectory(): string {
|
/** Continue the most recent session for the given directory, or create new if none */
|
||||||
const cwd = process.cwd();
|
static continueRecent(cwd: string): SessionManager {
|
||||||
// Replace all path separators and colons (for Windows drive letters) with dashes
|
const sessionDir = getSessionDirectory(cwd);
|
||||||
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
const mostRecent = findMostRecentSession(sessionDir);
|
||||||
|
if (mostRecent) {
|
||||||
const configDir = getAgentDir();
|
return new SessionManager(cwd, mostRecent, true);
|
||||||
const sessionDir = join(configDir, "sessions", safePath);
|
|
||||||
if (!existsSync(sessionDir)) {
|
|
||||||
mkdirSync(sessionDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
return sessionDir;
|
return new SessionManager(cwd, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initNewSession(): void {
|
/** Create an in-memory session (no file persistence) */
|
||||||
this.sessionId = uuidv4();
|
static inMemory(): SessionManager {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
return new SessionManager(process.cwd(), null, false);
|
||||||
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset to a fresh session. Clears pending entries and starts a new session file. */
|
/** List all sessions for a directory */
|
||||||
reset(): void {
|
static list(cwd: string): SessionInfo[] {
|
||||||
this.pendingEntries = [];
|
const sessionDir = getSessionDirectory(cwd);
|
||||||
this.inMemoryEntries = [];
|
const sessions: SessionInfo[] = [];
|
||||||
this.sessionInitialized = false;
|
|
||||||
this.initNewSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
private findMostRecentlyModifiedSession(): string | null {
|
|
||||||
try {
|
|
||||||
const files = readdirSync(this.sessionDir)
|
|
||||||
.filter((f) => f.endsWith(".jsonl"))
|
|
||||||
.map((f) => ({
|
|
||||||
name: f,
|
|
||||||
path: join(this.sessionDir, f),
|
|
||||||
mtime: statSync(join(this.sessionDir, f)).mtime,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
||||||
|
|
||||||
return files[0]?.path || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadSessionId(): void {
|
|
||||||
if (!existsSync(this.sessionFile)) return;
|
|
||||||
|
|
||||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(line);
|
|
||||||
if (entry.type === "session") {
|
|
||||||
this.sessionId = entry.id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.sessionId = uuidv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
startSession(state: AgentState): void {
|
|
||||||
if (this.sessionInitialized) return;
|
|
||||||
this.sessionInitialized = true;
|
|
||||||
|
|
||||||
const entry: SessionHeader = {
|
|
||||||
type: "session",
|
|
||||||
id: this.sessionId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
cwd: process.cwd(),
|
|
||||||
provider: state.model.provider,
|
|
||||||
modelId: state.model.id,
|
|
||||||
thinkingLevel: state.thinkingLevel,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always track in memory
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
for (const pending of this.pendingEntries) {
|
|
||||||
this.inMemoryEntries.push(pending);
|
|
||||||
}
|
|
||||||
this.pendingEntries = [];
|
|
||||||
|
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
for (const memEntry of this.inMemoryEntries.slice(1)) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMessage(message: any): void {
|
|
||||||
const entry: SessionMessageEntry = {
|
|
||||||
type: "message",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.sessionInitialized) {
|
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
// Always track in memory
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveThinkingLevelChange(thinkingLevel: string): void {
|
|
||||||
const entry: ThinkingLevelChangeEntry = {
|
|
||||||
type: "thinking_level_change",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
thinkingLevel,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.sessionInitialized) {
|
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
// Always track in memory
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveModelChange(provider: string, modelId: string): void {
|
|
||||||
const entry: ModelChangeEntry = {
|
|
||||||
type: "model_change",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
provider,
|
|
||||||
modelId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.sessionInitialized) {
|
|
||||||
this.pendingEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
// Always track in memory
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCompaction(entry: CompactionEntry): void {
|
|
||||||
// Always track in memory
|
|
||||||
this.inMemoryEntries.push(entry);
|
|
||||||
// Write to file only if enabled
|
|
||||||
if (this.enabled) {
|
|
||||||
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load session data (messages, model, thinking level) with compaction support.
|
|
||||||
*/
|
|
||||||
loadSession(): LoadedSession {
|
|
||||||
const entries = this.loadEntries();
|
|
||||||
return loadSessionFromEntries(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use loadSession().messages instead
|
|
||||||
*/
|
|
||||||
loadMessages(): AppMessage[] {
|
|
||||||
return this.loadSession().messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use loadSession().thinkingLevel instead
|
|
||||||
*/
|
|
||||||
loadThinkingLevel(): string {
|
|
||||||
return this.loadSession().thinkingLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use loadSession().model instead
|
|
||||||
*/
|
|
||||||
loadModel(): { provider: string; modelId: string } | null {
|
|
||||||
return this.loadSession().model;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessionId(): string {
|
|
||||||
return this.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessionFile(): string {
|
|
||||||
return this.sessionFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load entries directly from the session file (internal helper).
|
|
||||||
*/
|
|
||||||
private loadEntriesFromFile(): SessionEntry[] {
|
|
||||||
if (!existsSync(this.sessionFile)) return [];
|
|
||||||
|
|
||||||
const content = readFileSync(this.sessionFile, "utf8");
|
|
||||||
const entries: SessionEntry[] = [];
|
|
||||||
const lines = content.trim().split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(line) as SessionEntry;
|
|
||||||
entries.push(entry);
|
|
||||||
} catch {
|
|
||||||
// Skip malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all entries from the session file or in-memory store.
|
|
||||||
* When file persistence is enabled, reads from file (source of truth for resumed sessions).
|
|
||||||
* When disabled (--no-session), returns in-memory entries.
|
|
||||||
*/
|
|
||||||
loadEntries(): SessionEntry[] {
|
|
||||||
// If file persistence is enabled and file exists, read from file
|
|
||||||
if (this.enabled && existsSync(this.sessionFile)) {
|
|
||||||
return this.loadEntriesFromFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise return in-memory entries (for --no-session mode)
|
|
||||||
return [...this.inMemoryEntries];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all sessions for the current directory with metadata
|
|
||||||
*/
|
|
||||||
loadAllSessions(): Array<{
|
|
||||||
path: string;
|
|
||||||
id: string;
|
|
||||||
created: Date;
|
|
||||||
modified: Date;
|
|
||||||
messageCount: number;
|
|
||||||
firstMessage: string;
|
|
||||||
allMessagesText: string;
|
|
||||||
}> {
|
|
||||||
const sessions: Array<{
|
|
||||||
path: string;
|
|
||||||
id: string;
|
|
||||||
created: Date;
|
|
||||||
modified: Date;
|
|
||||||
messageCount: number;
|
|
||||||
firstMessage: string;
|
|
||||||
allMessagesText: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = readdirSync(this.sessionDir)
|
const files = readdirSync(sessionDir)
|
||||||
.filter((f) => f.endsWith(".jsonl"))
|
.filter((f) => f.endsWith(".jsonl"))
|
||||||
.map((f) => join(this.sessionDir, f));
|
.map((f) => join(sessionDir, f));
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -517,17 +313,14 @@ export class SessionManager {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
|
|
||||||
// Extract session ID from first session entry
|
|
||||||
if (entry.type === "session" && !sessionId) {
|
if (entry.type === "session" && !sessionId) {
|
||||||
sessionId = entry.id;
|
sessionId = entry.id;
|
||||||
created = new Date(entry.timestamp);
|
created = new Date(entry.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count messages and collect all text
|
|
||||||
if (entry.type === "message") {
|
if (entry.type === "message") {
|
||||||
messageCount++;
|
messageCount++;
|
||||||
|
|
||||||
// Extract text from user and assistant messages
|
|
||||||
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
||||||
const textContent = entry.message.content
|
const textContent = entry.message.content
|
||||||
.filter((c: any) => c.type === "text")
|
.filter((c: any) => c.type === "text")
|
||||||
|
|
@ -537,7 +330,6 @@ export class SessionManager {
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
allMessages.push(textContent);
|
allMessages.push(textContent);
|
||||||
|
|
||||||
// Get first user message for display
|
|
||||||
if (!firstMessage && entry.message.role === "user") {
|
if (!firstMessage && entry.message.role === "user") {
|
||||||
firstMessage = textContent;
|
firstMessage = textContent;
|
||||||
}
|
}
|
||||||
|
|
@ -558,42 +350,168 @@ export class SessionManager {
|
||||||
firstMessage: firstMessage || "(no messages)",
|
firstMessage: firstMessage || "(no messages)",
|
||||||
allMessagesText: allMessages.join(" "),
|
allMessagesText: allMessages.join(" "),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Skip files that can't be read
|
// Skip files that can't be read
|
||||||
console.error(`Failed to read session file ${file}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by modified date (most recent first)
|
|
||||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to load sessions:", error);
|
// Return empty list on error
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
isEnabled(): boolean {
|
||||||
* Set the session file to an existing session
|
return this.enabled;
|
||||||
*/
|
}
|
||||||
|
|
||||||
|
getCwd(): string {
|
||||||
|
return this.cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionId(): string {
|
||||||
|
return this.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFile(): string {
|
||||||
|
return this.sessionFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to a different session file (used for resume and branching) */
|
||||||
setSessionFile(path: string): void {
|
setSessionFile(path: string): void {
|
||||||
this.sessionFile = path;
|
this.sessionFile = resolve(path);
|
||||||
this.loadSessionId();
|
this.sessionId = extractSessionIdFromFile(this.sessionFile) ?? uuidv4();
|
||||||
// Mark as initialized since we're loading an existing session
|
this.sessionInitialized = existsSync(this.sessionFile);
|
||||||
this.sessionInitialized = existsSync(path);
|
|
||||||
// Load entries into memory for consistency
|
|
||||||
if (this.sessionInitialized) {
|
if (this.sessionInitialized) {
|
||||||
this.inMemoryEntries = this.loadEntriesFromFile();
|
this.inMemoryEntries = loadEntriesFromFile(this.sessionFile);
|
||||||
} else {
|
} else {
|
||||||
this.inMemoryEntries = [];
|
this.inMemoryEntries = [];
|
||||||
}
|
}
|
||||||
this.pendingEntries = [];
|
this.pendingEntries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
reset(): void {
|
||||||
* Check if we should initialize the session based on message history.
|
this.pendingEntries = [];
|
||||||
* Session is initialized when we have at least 1 user message and 1 assistant message.
|
this.inMemoryEntries = [];
|
||||||
*/
|
this.sessionInitialized = false;
|
||||||
|
this.sessionId = uuidv4();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
startSession(state: AgentState): void {
|
||||||
|
if (this.sessionInitialized) return;
|
||||||
|
this.sessionInitialized = true;
|
||||||
|
|
||||||
|
const entry: SessionHeader = {
|
||||||
|
type: "session",
|
||||||
|
id: this.sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: this.cwd,
|
||||||
|
provider: state.model.provider,
|
||||||
|
modelId: state.model.id,
|
||||||
|
thinkingLevel: state.thinkingLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
|
for (const pending of this.pendingEntries) {
|
||||||
|
this.inMemoryEntries.push(pending);
|
||||||
|
}
|
||||||
|
this.pendingEntries = [];
|
||||||
|
|
||||||
|
if (this.enabled) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
for (const memEntry of this.inMemoryEntries.slice(1)) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(memEntry)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage(message: any): void {
|
||||||
|
const entry: SessionMessageEntry = {
|
||||||
|
type: "message",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.sessionInitialized) {
|
||||||
|
this.pendingEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
|
if (this.enabled) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveThinkingLevelChange(thinkingLevel: string): void {
|
||||||
|
const entry: ThinkingLevelChangeEntry = {
|
||||||
|
type: "thinking_level_change",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
thinkingLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.sessionInitialized) {
|
||||||
|
this.pendingEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
|
if (this.enabled) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveModelChange(provider: string, modelId: string): void {
|
||||||
|
const entry: ModelChangeEntry = {
|
||||||
|
type: "model_change",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.sessionInitialized) {
|
||||||
|
this.pendingEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
|
if (this.enabled) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCompaction(entry: CompactionEntry): void {
|
||||||
|
this.inMemoryEntries.push(entry);
|
||||||
|
if (this.enabled) {
|
||||||
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession(): LoadedSession {
|
||||||
|
const entries = this.loadEntries();
|
||||||
|
return loadSessionFromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMessages(): AppMessage[] {
|
||||||
|
return this.loadSession().messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThinkingLevel(): string {
|
||||||
|
return this.loadSession().thinkingLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadModel(): { provider: string; modelId: string } | null {
|
||||||
|
return this.loadSession().model;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEntries(): SessionEntry[] {
|
||||||
|
if (this.enabled && existsSync(this.sessionFile)) {
|
||||||
|
return loadEntriesFromFile(this.sessionFile);
|
||||||
|
}
|
||||||
|
return [...this.inMemoryEntries];
|
||||||
|
}
|
||||||
|
|
||||||
shouldInitializeSession(messages: any[]): boolean {
|
shouldInitializeSession(messages: any[]): boolean {
|
||||||
if (this.sessionInitialized) return false;
|
if (this.sessionInitialized) return false;
|
||||||
|
|
||||||
|
|
@ -603,23 +521,16 @@ export class SessionManager {
|
||||||
return userMessages.length >= 1 && assistantMessages.length >= 1;
|
return userMessages.length >= 1 && assistantMessages.length >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a branched session from a specific message index.
|
|
||||||
* If branchFromIndex is -1, creates an empty session.
|
|
||||||
* Returns the new session file path.
|
|
||||||
*/
|
|
||||||
createBranchedSession(state: any, branchFromIndex: number): string {
|
createBranchedSession(state: any, branchFromIndex: number): string {
|
||||||
// Create a new session ID for the branch
|
|
||||||
const newSessionId = uuidv4();
|
const newSessionId = uuidv4();
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||||
|
|
||||||
// Write session header
|
|
||||||
const entry: SessionHeader = {
|
const entry: SessionHeader = {
|
||||||
type: "session",
|
type: "session",
|
||||||
id: newSessionId,
|
id: newSessionId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
cwd: process.cwd(),
|
cwd: this.cwd,
|
||||||
provider: state.model.provider,
|
provider: state.model.provider,
|
||||||
modelId: state.model.id,
|
modelId: state.model.id,
|
||||||
thinkingLevel: state.thinkingLevel,
|
thinkingLevel: state.thinkingLevel,
|
||||||
|
|
@ -627,7 +538,6 @@ export class SessionManager {
|
||||||
};
|
};
|
||||||
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
|
|
||||||
// Write messages up to and including the branch point (if >= 0)
|
|
||||||
if (branchFromIndex >= 0) {
|
if (branchFromIndex >= 0) {
|
||||||
const messagesToWrite = state.messages.slice(0, branchFromIndex + 1);
|
const messagesToWrite = state.messages.slice(0, branchFromIndex + 1);
|
||||||
for (const message of messagesToWrite) {
|
for (const message of messagesToWrite) {
|
||||||
|
|
@ -643,23 +553,16 @@ export class SessionManager {
|
||||||
return newSessionFile;
|
return newSessionFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a branched session from session entries up to (but not including) a specific entry index.
|
|
||||||
* This preserves compaction events and all entry types.
|
|
||||||
* Returns the new session file path, or null if in --no-session mode (in-memory only).
|
|
||||||
*/
|
|
||||||
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null {
|
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null {
|
||||||
const newSessionId = uuidv4();
|
const newSessionId = uuidv4();
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||||
|
|
||||||
// Build new entries list (up to but not including branch point)
|
|
||||||
const newEntries: SessionEntry[] = [];
|
const newEntries: SessionEntry[] = [];
|
||||||
for (let i = 0; i < branchBeforeIndex; i++) {
|
for (let i = 0; i < branchBeforeIndex; i++) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
|
|
||||||
if (entry.type === "session") {
|
if (entry.type === "session") {
|
||||||
// Rewrite session header with new ID and branchedFrom
|
|
||||||
newEntries.push({
|
newEntries.push({
|
||||||
...entry,
|
...entry,
|
||||||
id: newSessionId,
|
id: newSessionId,
|
||||||
|
|
@ -667,22 +570,18 @@ export class SessionManager {
|
||||||
branchedFrom: this.enabled ? this.sessionFile : undefined,
|
branchedFrom: this.enabled ? this.sessionFile : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Copy other entries as-is
|
|
||||||
newEntries.push(entry);
|
newEntries.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.enabled) {
|
if (this.enabled) {
|
||||||
// Write to file
|
|
||||||
for (const entry of newEntries) {
|
for (const entry of newEntries) {
|
||||||
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
||||||
}
|
}
|
||||||
return newSessionFile;
|
return newSessionFile;
|
||||||
} else {
|
|
||||||
// In-memory mode: replace inMemoryEntries, no file created
|
|
||||||
this.inMemoryEntries = newEntries;
|
|
||||||
this.sessionId = newSessionId;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
this.inMemoryEntries = newEntries;
|
||||||
|
this.sessionId = newSessionId;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ export {
|
||||||
parseSessionEntries,
|
parseSessionEntries,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionHeader,
|
type SessionHeader,
|
||||||
|
type SessionInfo,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type SessionMessageEntry,
|
type SessionMessageEntry,
|
||||||
SUMMARY_PREFIX,
|
SUMMARY_PREFIX,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
|
||||||
import { ensureTool } from "./utils/tools-manager.js";
|
import { ensureTool } from "./utils/tools-manager.js";
|
||||||
|
|
||||||
/** Configure OAuth storage to use the coding-agent's configurable path */
|
|
||||||
function configureOAuthStorage(): void {
|
function configureOAuthStorage(): void {
|
||||||
const oauthPath = getOAuthPath();
|
const oauthPath = getOAuthPath();
|
||||||
|
|
||||||
|
|
@ -55,7 +54,6 @@ function configureOAuthStorage(): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check npm registry for new version (non-blocking) */
|
|
||||||
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
|
||||||
|
|
@ -74,7 +72,6 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run interactive mode with TUI */
|
|
||||||
async function runInteractiveMode(
|
async function runInteractiveMode(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
version: string,
|
version: string,
|
||||||
|
|
@ -133,7 +130,6 @@ async function runInteractiveMode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prepare initial message from @file arguments */
|
|
||||||
async function prepareInitialMessage(parsed: Args): Promise<{
|
async function prepareInitialMessage(parsed: Args): Promise<{
|
||||||
initialMessage?: string;
|
initialMessage?: string;
|
||||||
initialAttachments?: Attachment[];
|
initialAttachments?: Attachment[];
|
||||||
|
|
@ -158,7 +154,6 @@ async function prepareInitialMessage(parsed: Args): Promise<{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get changelog markdown to display (only for new sessions with updates) */
|
|
||||||
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
|
||||||
if (parsed.continue || parsed.resume) {
|
if (parsed.continue || parsed.resume) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -184,10 +179,32 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build CreateAgentSessionOptions from CLI args */
|
function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
|
||||||
async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): Promise<CreateAgentSessionOptions> {
|
if (parsed.noSession) {
|
||||||
|
return SessionManager.inMemory();
|
||||||
|
}
|
||||||
|
if (parsed.session) {
|
||||||
|
return SessionManager.open(parsed.session);
|
||||||
|
}
|
||||||
|
if (parsed.continue) {
|
||||||
|
return SessionManager.continueRecent(cwd);
|
||||||
|
}
|
||||||
|
// --resume is handled separately (needs picker UI)
|
||||||
|
// Default case (new session) returns null, SDK will create one
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionOptions(
|
||||||
|
parsed: Args,
|
||||||
|
scopedModels: ScopedModel[],
|
||||||
|
sessionManager: SessionManager | null,
|
||||||
|
): CreateAgentSessionOptions {
|
||||||
const options: CreateAgentSessionOptions = {};
|
const options: CreateAgentSessionOptions = {};
|
||||||
|
|
||||||
|
if (sessionManager) {
|
||||||
|
options.sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
// Model from CLI
|
// Model from CLI
|
||||||
if (parsed.provider && parsed.model) {
|
if (parsed.provider && parsed.model) {
|
||||||
const { model, error } = findModel(parsed.provider, parsed.model);
|
const { model, error } = findModel(parsed.provider, parsed.model);
|
||||||
|
|
@ -201,7 +218,6 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P
|
||||||
}
|
}
|
||||||
options.model = model;
|
options.model = model;
|
||||||
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||||
// Use first scoped model
|
|
||||||
options.model = scopedModels[0].model;
|
options.model = scopedModels[0].model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,23 +267,10 @@ async function buildSessionOptions(parsed: Args, scopedModels: ScopedModel[]): P
|
||||||
options.additionalCustomToolPaths = parsed.customTools;
|
options.additionalCustomToolPaths = parsed.customTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session handling
|
|
||||||
if (parsed.noSession) {
|
|
||||||
options.sessionFile = false;
|
|
||||||
} else if (parsed.session) {
|
|
||||||
options.sessionFile = parsed.session;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue session
|
|
||||||
if (parsed.continue && !parsed.resume) {
|
|
||||||
options.continueSession = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(args: string[]) {
|
export async function main(args: string[]) {
|
||||||
// Configure OAuth storage first
|
|
||||||
configureOAuthStorage();
|
configureOAuthStorage();
|
||||||
|
|
||||||
const parsed = parseArgs(args);
|
const parsed = parseArgs(args);
|
||||||
|
|
@ -306,40 +309,38 @@ export async function main(args: string[]) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
|
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
|
||||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||||
const mode = parsed.mode || "text";
|
const mode = parsed.mode || "text";
|
||||||
|
|
||||||
// Initialize theme early
|
|
||||||
const settingsManager = new SettingsManager();
|
const settingsManager = new SettingsManager();
|
||||||
initTheme(settingsManager.getTheme(), isInteractive);
|
initTheme(settingsManager.getTheme(), isInteractive);
|
||||||
|
|
||||||
// Resolve scoped models from --models flag
|
|
||||||
let scopedModels: ScopedModel[] = [];
|
let scopedModels: ScopedModel[] = [];
|
||||||
if (parsed.models && parsed.models.length > 0) {
|
if (parsed.models && parsed.models.length > 0) {
|
||||||
scopedModels = await resolveModelScope(parsed.models);
|
scopedModels = await resolveModelScope(parsed.models);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create session manager based on CLI flags
|
||||||
|
let sessionManager = createSessionManager(parsed, cwd);
|
||||||
|
|
||||||
// Handle --resume: show session picker
|
// Handle --resume: show session picker
|
||||||
let sessionFileFromResume: string | undefined;
|
|
||||||
if (parsed.resume) {
|
if (parsed.resume) {
|
||||||
const tempSessionManager = new SessionManager(false, undefined);
|
const sessions = SessionManager.list(cwd);
|
||||||
const selectedSession = await selectSession(tempSessionManager);
|
if (sessions.length === 0) {
|
||||||
if (!selectedSession) {
|
console.log(chalk.dim("No sessions found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedPath = await selectSession(sessions);
|
||||||
|
if (!selectedPath) {
|
||||||
console.log(chalk.dim("No session selected"));
|
console.log(chalk.dim("No session selected"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionFileFromResume = selectedSession;
|
sessionManager = SessionManager.open(selectedPath);
|
||||||
}
|
|
||||||
|
|
||||||
const sessionOptions = await buildSessionOptions(parsed, scopedModels);
|
|
||||||
|
|
||||||
// Apply resume session file
|
|
||||||
if (sessionFileFromResume) {
|
|
||||||
sessionOptions.sessionFile = sessionFileFromResume;
|
|
||||||
sessionOptions.restoreFromSession = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager);
|
||||||
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
const { session, customToolsResult, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||||
|
|
||||||
if (!isInteractive && !session.model) {
|
if (!isInteractive && !session.model) {
|
||||||
|
|
@ -369,7 +370,6 @@ export async function main(args: string[]) {
|
||||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
|
||||||
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
|
||||||
|
|
||||||
// Show model scope if provided
|
|
||||||
if (scopedModels.length > 0) {
|
if (scopedModels.length > 0) {
|
||||||
const modelList = scopedModels
|
const modelList = scopedModels
|
||||||
.map((sm) => {
|
.map((sm) => {
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,17 @@ import {
|
||||||
Text,
|
Text,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import type { SessionManager } from "../../../core/session-manager.js";
|
import type { SessionInfo } from "../../../core/session-manager.js";
|
||||||
import { fuzzyFilter } from "../../../utils/fuzzy.js";
|
import { fuzzyFilter } from "../../../utils/fuzzy.js";
|
||||||
import { theme } from "../theme/theme.js";
|
import { theme } from "../theme/theme.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
|
|
||||||
interface SessionItem {
|
|
||||||
path: string;
|
|
||||||
id: string;
|
|
||||||
created: Date;
|
|
||||||
modified: Date;
|
|
||||||
messageCount: number;
|
|
||||||
firstMessage: string;
|
|
||||||
allMessagesText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom session list component with multi-line items and search
|
* Custom session list component with multi-line items and search
|
||||||
*/
|
*/
|
||||||
class SessionList implements Component {
|
class SessionList implements Component {
|
||||||
private allSessions: SessionItem[] = [];
|
private allSessions: SessionInfo[] = [];
|
||||||
private filteredSessions: SessionItem[] = [];
|
private filteredSessions: SessionInfo[] = [];
|
||||||
private selectedIndex: number = 0;
|
private selectedIndex: number = 0;
|
||||||
private searchInput: Input;
|
private searchInput: Input;
|
||||||
public onSelect?: (sessionPath: string) => void;
|
public onSelect?: (sessionPath: string) => void;
|
||||||
|
|
@ -39,7 +29,7 @@ class SessionList implements Component {
|
||||||
public onExit: () => void = () => {};
|
public onExit: () => void = () => {};
|
||||||
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
|
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
|
||||||
|
|
||||||
constructor(sessions: SessionItem[]) {
|
constructor(sessions: SessionInfo[]) {
|
||||||
this.allSessions = sessions;
|
this.allSessions = sessions;
|
||||||
this.filteredSessions = sessions;
|
this.filteredSessions = sessions;
|
||||||
this.searchInput = new Input();
|
this.searchInput = new Input();
|
||||||
|
|
@ -176,16 +166,13 @@ export class SessionSelectorComponent extends Container {
|
||||||
private sessionList: SessionList;
|
private sessionList: SessionList;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
sessionManager: SessionManager,
|
sessions: SessionInfo[],
|
||||||
onSelect: (sessionPath: string) => void,
|
onSelect: (sessionPath: string) => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Load all sessions
|
|
||||||
const sessions = sessionManager.loadAllSessions();
|
|
||||||
|
|
||||||
// Add header
|
// Add header
|
||||||
this.addChild(new Spacer(1));
|
this.addChild(new Spacer(1));
|
||||||
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
|
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,12 @@ import type { HookUIContext } from "../../core/hooks/index.js";
|
||||||
import { isBashExecutionMessage } from "../../core/messages.js";
|
import { isBashExecutionMessage } from "../../core/messages.js";
|
||||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||||
import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js";
|
import { listOAuthProviders, login, logout, type OAuthProvider } from "../../core/oauth/index.js";
|
||||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
import {
|
||||||
|
getLatestCompactionEntry,
|
||||||
|
SessionManager,
|
||||||
|
SUMMARY_PREFIX,
|
||||||
|
SUMMARY_SUFFIX,
|
||||||
|
} from "../../core/session-manager.js";
|
||||||
import { loadSkills } from "../../core/skills.js";
|
import { loadSkills } from "../../core/skills.js";
|
||||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||||
|
|
@ -1513,8 +1518,9 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private showSessionSelector(): void {
|
private showSessionSelector(): void {
|
||||||
this.showSelector((done) => {
|
this.showSelector((done) => {
|
||||||
|
const sessions = SessionManager.list(this.sessionManager.getCwd());
|
||||||
const selector = new SessionSelectorComponent(
|
const selector = new SessionSelectorComponent(
|
||||||
this.sessionManager,
|
sessions,
|
||||||
async (sessionPath) => {
|
async (sessionPath) => {
|
||||||
done();
|
done();
|
||||||
await this.handleResumeSession(sessionPath);
|
await this.handleResumeSession(sessionPath);
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionManager = new SessionManager(false);
|
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||||
if (noSession) {
|
|
||||||
sessionManager.disable();
|
|
||||||
}
|
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
const settingsManager = new SettingsManager(tempDir);
|
||||||
|
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionManager = new SessionManager(false);
|
sessionManager = SessionManager.create(tempDir);
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
const settingsManager = new SettingsManager(tempDir);
|
||||||
|
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
|
|
@ -174,9 +174,8 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create session manager and disable file persistence
|
// Create in-memory session manager
|
||||||
const noSessionManager = new SessionManager(false);
|
const noSessionManager = SessionManager.inMemory();
|
||||||
noSessionManager.disable();
|
|
||||||
|
|
||||||
const settingsManager = new SettingsManager(tempDir);
|
const settingsManager = new SettingsManager(tempDir);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue