diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index ec054f4d..f7c55283 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -4,6 +4,7 @@ import { logError } from "@/logger"; import { getSettings } from "@/settings/model"; import { backendRegistry, listBackendDescriptors } from "./backends/registry"; import { AgentModelPreloader } from "./session/AgentModelPreloader"; +import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager"; import { AgentSessionManager } from "./session/AgentSessionManager"; import { createDefaultPermissionPrompter } from "./ui/permissionPrompter"; @@ -19,6 +20,8 @@ export { export { useAgentModelPicker } from "./ui/useAgentModelPicker"; export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker"; export type { AgentSessionManager } from "./session/AgentSessionManager"; +export { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager"; +export { AGENT_CHAT_FILE_PREFIX } from "./session/AgentChatPersistenceManager"; export type { BackendDescriptor, BackendId, InstallState } from "./session/types"; export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry"; @@ -33,9 +36,11 @@ export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/r * the existing manager and call this again. */ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager { + const persistence = new AgentChatPersistenceManager(app); const manager = new AgentSessionManager(app, plugin, { permissionPrompter: createDefaultPermissionPrompter(app), resolveDescriptor: (id) => backendRegistry[id], + persistence, }); // Non-blocking — plugin load should not wait on disk reconcile. for (const descriptor of listBackendDescriptors()) { diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts new file mode 100644 index 00000000..12138bab --- /dev/null +++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts @@ -0,0 +1,310 @@ +import { TFile, TFolder } from "obsidian"; +import { AGENT_CHAT_FILE_PREFIX, AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; +import { AI_SENDER, USER_SENDER } from "@/constants"; +import type { AgentChatMessage } from "./types"; + +const SAVE_FOLDER = "copilot-conversations"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +jest.mock("@/settings/model", () => ({ + getSettings: () => ({ + defaultSaveFolder: "copilot-conversations", + defaultConversationTag: "conversation", + defaultConversationNoteName: "{$date}_{$time}__{$topic}", + }), +})); + +jest.mock("@/utils", () => { + const actual = jest.requireActual("@/utils"); + return { + ...actual, + ensureFolderExists: jest.fn().mockResolvedValue(undefined), + }; +}); + +interface FakeFile { + path: string; + basename: string; + body: string; + frontmatter: Record; + ctime: number; +} + +/** + * Minimal in-memory `App` shim that captures vault writes and lets the test + * inspect what landed on "disk". Keeps the test free of jest mocks for every + * vault method by routing the persistence manager's calls through a small + * map. + */ +function toTFile(f: FakeFile): TFile { + // The obsidian mock's TFile is a constructable function — `instanceof TFile` + // checks downstream rely on this prototype chain. + const tfile = new (TFile as any)(f.path); + tfile.stat = { ctime: f.ctime, mtime: f.ctime, size: f.body.length }; + return tfile; +} + +function makeApp(initialFiles: FakeFile[] = []) { + const files = new Map(); + for (const f of initialFiles) files.set(f.path, f); + + const folder = new (TFolder as any)(SAVE_FOLDER); + + const vault = { + getAbstractFileByPath: (path: string) => { + if (path === SAVE_FOLDER) return folder; + const f = files.get(path); + return f ? toTFile(f) : null; + }, + create: jest.fn(async (path: string, content: string) => { + if (files.has(path)) { + const err = new Error("File already exists."); + throw err; + } + const basename = path.split("/").pop()!.replace(/\.md$/, ""); + const file: FakeFile = { path, basename, body: content, frontmatter: {}, ctime: Date.now() }; + files.set(path, file); + return toTFile(file); + }), + modify: jest.fn(async (file: TFile, content: string) => { + const f = files.get(file.path); + if (f) f.body = content; + }), + read: jest.fn(async (file: TFile) => files.get(file.path)?.body ?? ""), + delete: jest.fn(async (file: TFile) => { + files.delete(file.path); + }), + getMarkdownFiles: () => Array.from(files.values()).map(toTFile), + adapter: { + exists: jest.fn(async (path: string) => path === SAVE_FOLDER || files.has(path)), + read: jest.fn(async (path: string) => files.get(path)?.body ?? ""), + write: jest.fn(async (path: string, content: string) => { + const f = files.get(path); + if (f) f.body = content; + }), + remove: jest.fn(async (path: string) => { + files.delete(path); + }), + list: jest.fn(async (path: string) => ({ + files: + path === SAVE_FOLDER + ? Array.from(files.keys()).filter((p) => p.startsWith(`${SAVE_FOLDER}/`)) + : [], + folders: [], + })), + stat: jest.fn(), + mkdir: jest.fn(), + }, + }; + + const metadataCache = { + getFileCache: (file: TFile) => { + const f = files.get(file.path); + return f ? { frontmatter: f.frontmatter } : null; + }, + }; + + const fileManager = { + processFrontMatter: jest.fn(async (file: TFile, mutator: (fm: any) => void) => { + const f = files.get(file.path); + if (!f) return; + mutator(f.frontmatter); + }), + }; + + return { + app: { vault, metadataCache, fileManager } as any, + files, + }; +} + +function makeMessages(): AgentChatMessage[] { + return [ + { + id: "u1", + sender: USER_SENDER, + message: "Hello agent", + isVisible: true, + timestamp: { + epoch: 1700000000000, + display: "2023/11/14 22:13:20", + fileName: "20231114_221320", + }, + }, + { + id: "a1", + sender: AI_SENDER, + message: "Hi there!", + isVisible: true, + timestamp: { + epoch: 1700000005000, + display: "2023/11/14 22:13:25", + fileName: "20231114_221325", + }, + }, + ]; +} + +describe("AgentChatPersistenceManager", () => { + it("save() returns null when there are no messages", async () => { + const { app } = makeApp(); + const mgr = new AgentChatPersistenceManager(app); + const result = await mgr.save({ messages: [], backendId: "opencode" }); + expect(result).toBeNull(); + expect(app.vault.create).not.toHaveBeenCalled(); + }); + + it("save() creates a file with the agent__ prefix and mode/backendId frontmatter", async () => { + const { app, files } = makeApp(); + const mgr = new AgentChatPersistenceManager(app); + await mgr.save({ messages: makeMessages(), backendId: "opencode", label: "My session" }); + + const created = Array.from(files.values()).find((f) => + f.basename.startsWith(AGENT_CHAT_FILE_PREFIX) + ); + expect(created).toBeDefined(); + expect(created!.body).toMatch(/mode: agent/); + expect(created!.body).toMatch(/backendId: opencode/); + expect(created!.body).toMatch(/agentLabel: "My session"/); + expect(created!.body).toMatch(/\*\*user\*\*: Hello agent/); + expect(created!.body).toMatch(/\*\*ai\*\*: Hi there!/); + }); + + it("save() updates the existing file in place when given its path", async () => { + const { app, files } = makeApp(); + const mgr = new AgentChatPersistenceManager(app); + const first = await mgr.save({ messages: makeMessages(), backendId: "opencode" }); + expect(first).not.toBeNull(); + const path = first!.path; + const fm = files.get(path)!.frontmatter; + fm.epoch = 1700000000000; + + const more = [ + ...makeMessages(), + { + id: "u2", + sender: USER_SENDER, + message: "Another turn", + isVisible: true, + timestamp: { + epoch: 1700000010000, + display: "2023/11/14 22:13:30", + fileName: "20231114_221330", + }, + }, + ] as AgentChatMessage[]; + + await mgr.save({ messages: more, backendId: "opencode" }, path); + expect(app.vault.create).toHaveBeenCalledTimes(1); // no new file + expect(files.get(path)!.body).toMatch(/Another turn/); + }); + + it("listFiles() filters to agent__ prefix only", async () => { + const { app } = makeApp([ + { + path: "copilot-conversations/agent__abc.md", + basename: "agent__abc", + body: "", + frontmatter: {}, + ctime: 0, + }, + { + path: "copilot-conversations/regular_chat.md", + basename: "regular_chat", + body: "", + frontmatter: {}, + ctime: 0, + }, + { + path: "copilot-conversations/projectId__chat.md", + basename: "projectId__chat", + body: "", + frontmatter: {}, + ctime: 0, + }, + ]); + const mgr = new AgentChatPersistenceManager(app); + const result = await mgr.listFiles(); + expect(result.map((f) => f.basename)).toEqual(["agent__abc"]); + }); + + it("loadFile() round-trips a saved chat", async () => { + const { app, files } = makeApp(); + const mgr = new AgentChatPersistenceManager(app); + const file = await mgr.save({ messages: makeMessages(), backendId: "claude-code" }); + expect(file).not.toBeNull(); + + // Mirror the frontmatter the metadataCache would have picked up after save. + const stored = files.get(file!.path)!; + stored.frontmatter = parseFrontmatter(stored.body); + + const loaded = await mgr.loadFile(file!); + expect(loaded).not.toBeNull(); + expect(loaded!.backendId).toBe("claude-code"); + expect(loaded!.messages).toHaveLength(2); + expect(loaded!.messages[0].sender).toBe(USER_SENDER); + expect(loaded!.messages[0].message).toBe("Hello agent"); + expect(loaded!.messages[1].sender).toBe(AI_SENDER); + expect(loaded!.messages[1].message).toBe("Hi there!"); + }); + + it("loadFile() rejects files whose mode is not 'agent'", async () => { + const file: FakeFile = { + path: "copilot-conversations/agent__legacy.md", + basename: "agent__legacy", + body: `---\nmode: copilot\n---\n\n**user**: not for agent`, + frontmatter: { mode: "copilot" }, + ctime: 0, + }; + const { app } = makeApp([file]); + const mgr = new AgentChatPersistenceManager(app); + const tFile = app.vault.getAbstractFileByPath(file.path); + const loaded = await mgr.loadFile(tFile); + expect(loaded).toBeNull(); + }); + + it("updateTopic() patches frontmatter via fileManager", async () => { + const file: FakeFile = { + path: "copilot-conversations/agent__abc.md", + basename: "agent__abc", + body: `---\nmode: agent\nbackendId: opencode\n---\n\n**user**: hi`, + frontmatter: { mode: "agent", backendId: "opencode" }, + ctime: 0, + }; + const { app, files } = makeApp([file]); + const mgr = new AgentChatPersistenceManager(app); + await mgr.updateTopic(file.path, "Renamed"); + expect(files.get(file.path)!.frontmatter.topic).toBe("Renamed"); + }); + + it("deleteFile() removes the file from the vault", async () => { + const file: FakeFile = { + path: "copilot-conversations/agent__abc.md", + basename: "agent__abc", + body: "", + frontmatter: {}, + ctime: 0, + }; + const { app, files } = makeApp([file]); + const mgr = new AgentChatPersistenceManager(app); + await mgr.deleteFile(file.path); + expect(files.has(file.path)).toBe(false); + }); +}); + +/** Tiny YAML extractor — just enough to feed the round-trip test. */ +function parseFrontmatter(body: string): Record { + const match = body.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const result: Record = {}; + for (const line of match[1].split("\n")) { + const m = line.match(/^(\w+):\s*(.+)/); + if (m) result[m[1]] = m[2].replace(/^"|"$/g, ""); + } + return result; +} diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts new file mode 100644 index 00000000..f94ac4fc --- /dev/null +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -0,0 +1,440 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { logError, logInfo } from "@/logger"; +import { getSettings } from "@/settings/model"; +import { + ensureFolderExists, + formatDateTime, + getUtf8ByteLength, + truncateToByteLimit, +} from "@/utils"; +import { + isInVaultCache, + listMarkdownFiles, + patchFrontmatter, + readFrontmatterViaAdapter, +} from "@/utils/vaultAdapterUtils"; +import { App, Notice, TFile } from "obsidian"; +import type { AgentChatMessage, NewAgentChatMessage } from "./types"; +import type { BackendId } from "@/agentMode/acp/types"; + +const SAFE_FILENAME_BYTE_LIMIT = 100; + +/** Filename prefix that marks a markdown file as an Agent Mode chat. */ +export const AGENT_CHAT_FILE_PREFIX = "agent__"; + +/** Frontmatter discriminator value for `mode`. */ +const AGENT_MODE_FRONTMATTER_VALUE = "agent"; + +function escapeYamlString(str: string): string { + return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** Parsed payload returned by `loadFile` — matches what `AgentSessionManager` needs to seed a new session. */ +export interface LoadedAgentChat { + /** Parsed messages without ids — the consuming `AgentMessageStore` assigns ids on load. */ + messages: NewAgentChatMessage[]; + backendId: BackendId | null; + topic?: string; + label?: string; + modelKey?: string; + epoch: number; +} + +/** Snapshot the persistence manager needs to write a session to disk. */ +export interface AgentChatSaveSnapshot { + /** Visible messages in display order. Persistence is skipped when this is empty. */ + messages: AgentChatMessage[]; + backendId: BackendId; + /** Optional sticky tab label set by the user or pushed by the agent. */ + label?: string | null; + /** Optional agent-native model id, recorded for debugging — not used on load. */ + modelKey?: string | null; +} + +/** + * Persists Agent Mode chat sessions to markdown files in the configured save + * folder. Files are tagged with an `agent__` filename prefix and a `mode: agent` + * frontmatter discriminator so they can be filtered out of the legacy chat + * history list. + * + * Designed to be backend-agnostic: the `backendId` is recorded in frontmatter + * for routing on load, but listing is unified across all registered backends. + */ +export class AgentChatPersistenceManager { + constructor(private readonly app: App) {} + + /** + * Save a session snapshot to disk. Returns the resulting `TFile` (or `null` + * when the snapshot has no visible messages — empty sessions never persist). + * + * If `existingPath` points to a previously written file, that file is + * updated in place. Otherwise a fresh filename is generated based on the + * snapshot's first user message. + */ + async save(snapshot: AgentChatSaveSnapshot, existingPath?: string): Promise { + if (snapshot.messages.length === 0) return null; + + try { + const settings = getSettings(); + await ensureFolderExists(settings.defaultSaveFolder); + + const firstEpoch = this.firstMessageEpoch(snapshot.messages); + const existingFile = existingPath ? this.app.vault.getAbstractFileByPath(existingPath) : null; + const existingTopic = await this.readExistingTopic(existingPath, existingFile); + const existingLastAccessedAt = await this.readExistingLastAccessedAt( + existingPath, + existingFile + ); + + const targetPath = + existingFile instanceof TFile + ? existingFile.path + : existingPath && (await this.app.vault.adapter.exists(existingPath)) + ? existingPath + : this.generateFileName(snapshot.messages, firstEpoch, existingTopic); + + const noteContent = this.generateNoteContent(snapshot, firstEpoch, { + topic: existingTopic, + lastAccessedAt: existingLastAccessedAt, + }); + + if (existingFile instanceof TFile && isInVaultCache(this.app, existingFile.path)) { + await this.app.vault.modify(existingFile, noteContent); + return existingFile; + } + + if ( + !isInVaultCache(this.app, targetPath) && + (await this.app.vault.adapter.exists(targetPath)) + ) { + await this.app.vault.adapter.write(targetPath, noteContent); + return null; + } + + try { + const created = await this.app.vault.create(targetPath, noteContent); + return created; + } catch (error) { + if (this.isFileAlreadyExistsError(error)) { + const existing = this.app.vault.getAbstractFileByPath(targetPath); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, noteContent); + return existing; + } + await this.app.vault.adapter.write(targetPath, noteContent); + return null; + } + if (this.isNameTooLongError(error)) { + const fallback = `${settings.defaultSaveFolder}/${AGENT_CHAT_FILE_PREFIX}chat-${firstEpoch}.md`; + try { + return await this.app.vault.create(fallback, noteContent); + } catch (fallbackError) { + if (this.isFileAlreadyExistsError(fallbackError)) { + const existing = this.app.vault.getAbstractFileByPath(fallback); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, noteContent); + return existing; + } + await this.app.vault.adapter.write(fallback, noteContent); + return null; + } + throw fallbackError; + } + } + throw error; + } + } catch (error) { + logError("[AgentChatPersistenceManager] Error saving session:", error); + new Notice("Failed to save agent chat. Check console for details."); + return null; + } + } + + /** + * Read a saved agent chat file and return the parsed payload. Returns `null` + * when the file isn't an Agent Mode file (no `mode: agent` frontmatter). + */ + async loadFile(file: TFile): Promise { + try { + let content: string; + try { + content = await this.app.vault.read(file); + } catch { + content = await this.app.vault.adapter.read(file.path); + } + + const frontmatter = await this.readFrontmatter(file); + if (frontmatter?.mode && frontmatter.mode !== AGENT_MODE_FRONTMATTER_VALUE) return null; + + const messages = this.parseChatBody(content); + const epoch = Number(frontmatter?.epoch); + logInfo(`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path}`); + return { + messages, + backendId: (frontmatter?.backendId as BackendId | undefined) ?? null, + topic: frontmatter?.topic, + label: frontmatter?.agentLabel, + modelKey: frontmatter?.modelKey, + epoch: Number.isFinite(epoch) && epoch > 0 ? epoch : file.stat.ctime, + }; + } catch (error) { + logError("[AgentChatPersistenceManager] Error loading file:", error); + new Notice("Failed to load agent chat. Check console for details."); + return null; + } + } + + /** + * List all agent chat files in the configured save folder, regardless of + * which backend produced them. Filtering is filename-prefix based to avoid + * reading every file's frontmatter just to build the list. + */ + async listFiles(): Promise { + const settings = getSettings(); + const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder); + return files.filter((f) => f.basename.startsWith(AGENT_CHAT_FILE_PREFIX)); + } + + private async readFrontmatter(file: TFile): Promise | null> { + const cached = this.app.metadataCache.getFileCache(file)?.frontmatter; + if (cached) return cached; + try { + return await readFrontmatterViaAdapter(this.app, file.path); + } catch { + return null; + } + } + + private async readExistingTopic( + path: string | undefined, + file: ReturnType + ): Promise { + if (!path) return undefined; + if (file instanceof TFile) { + const fm = await this.readFrontmatter(file); + return fm?.topic; + } + try { + const fm = await readFrontmatterViaAdapter(this.app, path); + return fm?.topic; + } catch { + return undefined; + } + } + + private async readExistingLastAccessedAt( + path: string | undefined, + file: ReturnType + ): Promise { + if (!path) return undefined; + if (file instanceof TFile) { + const fm = await this.readFrontmatter(file); + const value = Number(fm?.lastAccessedAt); + return Number.isFinite(value) && value > 0 ? value : undefined; + } + try { + const fm = await readFrontmatterViaAdapter(this.app, path); + const value = Number(fm?.lastAccessedAt); + return Number.isFinite(value) && value > 0 ? value : undefined; + } catch { + return undefined; + } + } + + private firstMessageEpoch(messages: AgentChatMessage[]): number { + for (const msg of messages) { + if (msg.timestamp?.epoch) return msg.timestamp.epoch; + } + return Date.now(); + } + + private generateNoteContent( + snapshot: AgentChatSaveSnapshot, + firstEpoch: number, + preserved: { topic?: string; lastAccessedAt?: number } + ): string { + const settings = getSettings(); + const topic = preserved.topic; + const label = snapshot.label?.trim(); + const modelKey = snapshot.modelKey?.trim(); + const lastAccessedAt = preserved.lastAccessedAt; + + const frontmatterLines = [ + `epoch: ${firstEpoch}`, + `mode: ${AGENT_MODE_FRONTMATTER_VALUE}`, + `backendId: ${snapshot.backendId}`, + ]; + if (topic) frontmatterLines.push(`topic: "${escapeYamlString(topic)}"`); + if (label) frontmatterLines.push(`agentLabel: "${escapeYamlString(label)}"`); + if (modelKey) frontmatterLines.push(`modelKey: "${escapeYamlString(modelKey)}"`); + if (lastAccessedAt) frontmatterLines.push(`lastAccessedAt: ${lastAccessedAt}`); + frontmatterLines.push("tags:"); + frontmatterLines.push(` - ${settings.defaultConversationTag}`); + + const body = this.formatChatBody(snapshot.messages); + return `---\n${frontmatterLines.join("\n")}\n---\n\n${body}`; + } + + private formatChatBody(messages: AgentChatMessage[]): string { + return messages + .map((message) => { + const timestamp = message.timestamp?.display ?? "Unknown time"; + const sender = message.sender; + return `**${sender}**: ${message.message}\n[Timestamp: ${timestamp}]`; + }) + .join("\n\n"); + } + + /** + * Parse the markdown body into `AgentChatMessage` objects. Tool / plan / + * thought parts are not restored because they're display-only and don't + * survive a serialize/parse round-trip; the saved view is plain text. + */ + private parseChatBody(content: string): NewAgentChatMessage[] { + const stripped = content.replace(/^---\n[\s\S]*?\n---/, "").trim(); + const messages: NewAgentChatMessage[] = []; + const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(stripped)) !== null) { + const sender = match[1] === "user" ? USER_SENDER : AI_SENDER; + const lines = match[2].trim().split("\n"); + let timestamp: string | undefined; + let endIndex = lines.length; + const last = lines[endIndex - 1]; + if (last?.startsWith("[Timestamp: ")) { + const ts = last.match(/\[Timestamp: (.*?)\]/); + if (ts) { + timestamp = ts[1]; + endIndex--; + } + } + const messageText = lines.slice(0, endIndex).join("\n").trim(); + const epoch = timestamp ? Date.parse(timestamp) : NaN; + + messages.push({ + message: messageText, + sender, + isVisible: true, + timestamp: + timestamp && Number.isFinite(epoch) ? { epoch, display: timestamp, fileName: "" } : null, + }); + } + return messages; + } + + /** + * Build a fresh filename from the first user message. Mirrors the budget + * logic in `ChatPersistenceManager.generateFileName` — same `defaultSaveFolder`, + * same `defaultConversationNoteName` template, but with `agent__` prefix + * instead of a project id prefix. + */ + private generateFileName( + messages: AgentChatMessage[], + firstEpoch: number, + topic?: string + ): string { + const settings = getSettings(); + const formatted = formatDateTime(new Date(firstEpoch)); + const [date, time] = formatted.fileName.split("_"); + + let topicForFilename: string; + if (topic) { + topicForFilename = topic; + } else { + const firstUser = messages.find((m) => m.sender === USER_SENDER); + topicForFilename = firstUser + ? firstUser.message + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/[{}[\]]/g, "") + .split(/\s+/) + .slice(0, 10) + .join(" ") + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1F]/g, "") + .trim() || "Untitled Chat" + : "Untitled Chat"; + } + + let template = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}"; + const filePrefix = AGENT_CHAT_FILE_PREFIX; + const extensionBytes = getUtf8ByteLength(".md"); + const filePrefixBytes = getUtf8ByteLength(filePrefix); + const overhead = template + .replace("{$topic}", "") + .replace("{$date}", date) + .replace("{$time}", time); + const overheadBytes = getUtf8ByteLength(overhead); + const topicBudget = Math.max( + 20, + SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - overheadBytes + ); + const truncatedTopic = truncateToByteLimit(topicForFilename.replace(/\s+/g, "_"), topicBudget); + + template = template + .replace("{$topic}", truncatedTopic) + .replace("{$date}", date) + .replace("{$time}", time); + + const sanitized = template + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/[{}[\]]/g, "_") + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1F]/g, "_"); + + const basename = `${filePrefix}${sanitized}.md`; + if (getUtf8ByteLength(basename) > SAFE_FILENAME_BYTE_LIMIT) { + const available = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes; + const truncated = truncateToByteLimit(sanitized, available); + return `${settings.defaultSaveFolder}/${filePrefix}${truncated}.md`; + } + return `${settings.defaultSaveFolder}/${basename}`; + } + + /** + * Update the `topic` frontmatter field. Used by the inline rename in the + * history popover. Falls back to the adapter when the file lives in a + * hidden directory not indexed by Obsidian's metadata cache. + */ + async updateTopic(filePath: string, topic: string): Promise { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile && this.app.fileManager?.processFrontMatter) { + await this.app.fileManager.processFrontMatter(file, (fm) => { + fm.topic = topic; + }); + return; + } + if (await this.app.vault.adapter.exists(filePath)) { + await patchFrontmatter(this.app, filePath, { topic: topic.trim() }); + return; + } + throw new Error("Agent chat file not found."); + } + + /** Delete a saved agent chat file. */ + async deleteFile(filePath: string): Promise { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + await this.app.vault.delete(file); + return; + } + if (await this.app.vault.adapter.exists(filePath)) { + await this.app.vault.adapter.remove(filePath); + return; + } + throw new Error("Agent chat file not found."); + } + + private isFileAlreadyExistsError(error: unknown): boolean { + if (!error) return false; + const msg = error instanceof Error ? error.message : String(error); + return msg.toLowerCase().includes("already exists"); + } + + private isNameTooLongError(error: unknown): boolean { + if (!error) return false; + const msg = error instanceof Error ? error.message : String(error); + const normalized = msg.toLowerCase(); + return normalized.includes("enametoolong") || normalized.includes("name too long"); + } +} diff --git a/src/agentMode/session/AgentMessageStore.ts b/src/agentMode/session/AgentMessageStore.ts index 22c89550..56dc8002 100644 --- a/src/agentMode/session/AgentMessageStore.ts +++ b/src/agentMode/session/AgentMessageStore.ts @@ -172,20 +172,10 @@ export class AgentMessageStore { return msg ? this.toAgentChatMessage(msg) : undefined; } - loadMessages(messages: AgentChatMessage[]): void { + loadMessages(messages: NewAgentChatMessage[]): void { this.clear(); for (const msg of messages) { - this.messages.push({ - id: msg.id || this.generateId(), - displayText: msg.message, - sender: msg.sender, - timestamp: msg.timestamp || formatDateTime(new Date()), - context: msg.context, - isVisible: msg.isVisible !== false, - isErrorMessage: msg.isErrorMessage, - content: msg.content, - parts: msg.parts, - }); + this.addMessage(msg); } logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`); } diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts index 49008f6d..eec8859a 100644 --- a/src/agentMode/session/AgentSession.ts +++ b/src/agentMode/session/AgentSession.ts @@ -318,6 +318,17 @@ export class AgentSession { this.abortController?.abort(); } + /** + * Replace the session's message history with `messages` and notify + * subscribers. Used by the history-load flow: the underlying ACP session is + * fresh (no prior context), but the user-visible transcript is restored from + * the saved file. + */ + loadHistoryMessages(messages: NewAgentChatMessage[]): void { + this.store.loadMessages(messages); + this.notifyMessages(); + } + /** Detach from the backend. Does not cancel — call `cancel()` first. */ async dispose(): Promise { this.unregisterSessionHandler?.(); diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index bc8c8df0..b1c644bc 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -92,6 +92,13 @@ function buildManager(): AgentSessionManager { { permissionPrompter: jest.fn(), resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined), + persistence: { + save: jest.fn().mockResolvedValue(null), + loadFile: jest.fn().mockResolvedValue(null), + listFiles: jest.fn().mockResolvedValue([]), + updateTopic: jest.fn().mockResolvedValue(undefined), + deleteFile: jest.fn().mockResolvedValue(undefined), + } as unknown as ConstructorParameters[2]["persistence"], } ); } diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index ef51c619..2726a3f1 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -1,16 +1,20 @@ import { logError, logInfo, logWarn } from "@/logger"; import type CopilotPlugin from "@/main"; import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState"; +import type { AgentChatPersistenceManager } from "@/agentMode/session/AgentChatPersistenceManager"; import { getSettings, setSettings } from "@/settings/model"; import { err2String } from "@/utils"; import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; -import { App, FileSystemAdapter, Platform } from "obsidian"; +import { App, FileSystemAdapter, Platform, TFile } from "obsidian"; import { v4 as uuidv4 } from "uuid"; import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess"; import { AgentSession } from "./AgentSession"; import type { AgentModelPreloader } from "./AgentModelPreloader"; import type { BackendDescriptor, BackendId } from "./types"; +/** Trailing-edge debounce window for persisting message changes to disk. */ +const SAVE_DEBOUNCE_MS = 500; + export type PermissionPrompter = ( req: RequestPermissionRequest ) => Promise; @@ -22,6 +26,8 @@ export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefine export interface AgentSessionManagerOptions { permissionPrompter: PermissionPrompter; resolveDescriptor: DescriptorResolver; + /** Persistence sink for session message changes. Required so the history list reflects live sessions. */ + persistence: AgentChatPersistenceManager; } /** @@ -51,6 +57,12 @@ export class AgentSessionManager { private isStarting = false; private lastError: string | null = null; private preloader: AgentModelPreloader | null = null; + /** File path each session is currently persisted to. Unset until first save lands. */ + private sessionFilePaths = new Map(); + /** Pending debounced save handle per session. */ + private saveTimers = new Map>(); + /** Per-session unsubscribe handles for the persistence listener. */ + private persistenceUnsubs = new Map void>(); constructor( private readonly app: App, @@ -128,6 +140,7 @@ export class AgentSessionManager { this.sessions.set(session.internalId, session); this.chatUIStates.set(session.internalId, new AgentChatUIState(session)); this.activeSessionId = session.internalId; + this.attachPersistence(session); // Clear lastError on success — but not eagerly at the start of every // create. Eager clearing would let a second concurrent create wipe the // first's error before the user (or a retry handler) has seen it. @@ -181,6 +194,13 @@ export class AgentSessionManager { return this.preloader; } + /** The persistence manager handed in at construction time. Exposed so the + * plugin host can call list/delete/rename without deep-importing the + * session layer. */ + getPersistenceManager(): AgentChatPersistenceManager { + return this.opts.persistence; + } + /** * Cancel any in-flight turn, dispose the session, and remove it from the * pool. If the closed session was active, picks the right neighbor (or the @@ -204,6 +224,7 @@ export class AgentSessionManager { } catch (e) { logWarn(`[AgentMode] dispose during closeSession failed`, e); } + this.detachPersistence(id); this.sessions.delete(id); this.chatUIStates.delete(id); if (this.activeSessionId === id) { @@ -321,6 +342,12 @@ export class AgentSessionManager { `[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})` ); + // Flush pending debounced saves first so the last turn isn't lost when the + // user closes Obsidian within the debounce window. Done before dispose so + // the sessions can still report messages. + await Promise.allSettled( + Array.from(this.sessions.keys()).map((id) => this.flushPersistence(id)) + ); const allSessions = Array.from(this.sessions.values()); for (const session of allSessions) { try { @@ -333,10 +360,12 @@ export class AgentSessionManager { } catch (e) { logError("[AgentMode] dispose during shutdown failed", e); } + this.detachPersistence(session.internalId); } this.sessions.clear(); this.chatUIStates.clear(); this.activeSessionId = null; + this.sessionFilePaths.clear(); const allBackends = Array.from(this.backends.values()); for (const proc of allBackends) { @@ -379,6 +408,7 @@ export class AgentSessionManager { const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId); if (dead.length === 0) return; for (const s of dead) { + this.detachPersistence(s.internalId); this.sessions.delete(s.internalId); this.chatUIStates.delete(s.internalId); s.cancel().catch(() => {}); @@ -405,4 +435,115 @@ export class AgentSessionManager { this.starting.delete(backendId); } } + + /** + * Subscribe to a session's message and label changes and trigger debounced + * persistence on each notification. Sessions with zero visible messages are + * skipped at save time, so empty tabs never produce a file. + */ + private attachPersistence(session: AgentSession): void { + const id = session.internalId; + const unsub = session.subscribe({ + onMessagesChanged: () => this.scheduleSave(id), + onStatusChanged: () => {}, + onLabelChanged: () => this.scheduleSave(id), + }); + this.persistenceUnsubs.set(id, unsub); + } + + /** + * Cancel any pending save and unsubscribe from the session's notifications. + * Called when a session is closed, when its backend exits, and on shutdown. + * Note: this does NOT delete the persisted file — that requires an explicit + * delete via the history popover. + */ + private detachPersistence(id: string): void { + const timer = this.saveTimers.get(id); + if (timer) { + clearTimeout(timer); + this.saveTimers.delete(id); + } + const unsub = this.persistenceUnsubs.get(id); + if (unsub) { + unsub(); + this.persistenceUnsubs.delete(id); + } + } + + private scheduleSave(id: string): void { + const existing = this.saveTimers.get(id); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + this.saveTimers.delete(id); + void this.runSave(id); + }, SAVE_DEBOUNCE_MS); + this.saveTimers.set(id, timer); + } + + /** Cancel the debounced timer (if any) and run the save synchronously. */ + private async flushPersistence(id: string): Promise { + const timer = this.saveTimers.get(id); + if (timer) { + clearTimeout(timer); + this.saveTimers.delete(id); + } + await this.runSave(id); + } + + private async runSave(id: string): Promise { + const session = this.sessions.get(id); + if (!session) return; + const messages = session.store.getDisplayMessages(); + if (messages.length === 0) return; + const existingPath = this.sessionFilePaths.get(id); + try { + const file = await this.opts.persistence.save( + { + messages, + backendId: session.backendId, + label: session.getLabel(), + modelKey: session.getModelState()?.currentModelId ?? null, + }, + existingPath + ); + if (file) this.sessionFilePaths.set(id, file.path); + } catch (e) { + logWarn(`[AgentMode] persistence save failed for session ${id}`, e); + } + } + + /** + * Load a saved agent chat into a session. If a live session is already bound + * to this file (e.g. the user clicked an item that's already an open tab), + * just activate that tab. Otherwise spawn a new session on the file's + * backend, seed its message store from the file, and bind the new session + * to the same path so subsequent saves update the same file. + */ + async loadSessionFromHistory(file: TFile): Promise { + if (this.disposed) { + throw new Error("AgentSessionManager has been shut down"); + } + + // Switch if already open. + for (const [id, path] of this.sessionFilePaths) { + if (path === file.path) { + this.setActiveSession(id); + return this.sessions.get(id) ?? null; + } + } + + const loaded = await this.opts.persistence.loadFile(file); + if (!loaded) { + throw new Error(`File "${file.path}" is not a saved agent chat.`); + } + const backendId = loaded.backendId ?? getSettings().agentMode?.activeBackend ?? "opencode"; + const session = await this.createSession(backendId); + // Bind the persistence path BEFORE seeding messages so the debounced save + // triggered by `loadHistoryMessages` updates the existing file instead of + // generating a fresh filename. + this.sessionFilePaths.set(session.internalId, file.path); + if (loaded.label) session.setLabel(loaded.label); + session.loadHistoryMessages(loaded.messages); + return session; + } } diff --git a/src/agentMode/ui/AgentChat.tsx b/src/agentMode/ui/AgentChat.tsx index a2558cb8..06317e61 100644 --- a/src/agentMode/ui/AgentChat.tsx +++ b/src/agentMode/ui/AgentChat.tsx @@ -3,6 +3,7 @@ import AgentChatMessages from "@/agentMode/ui/AgentChatMessages"; import { AgentModeStatus } from "@/agentMode/ui/AgentModeStatus"; import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor"; import { useAgentModelPicker } from "@/agentMode/ui/useAgentModelPicker"; +import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; import ChatInput from "@/components/chat-components/ChatInput"; import { EVENT_NAMES } from "@/constants"; import { AppContext, EventTargetContext } from "@/context"; @@ -47,6 +48,7 @@ const AgentChatInternal: React.FC = ({ const [includeActiveNote, setIncludeActiveNote] = useState(false); const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false); const [loading, setLoading] = useState(false); + const [chatHistoryItems, setChatHistoryItems] = useState([]); const isMountedRef = useRef(false); const chatContainerRef = useRef(null); @@ -148,6 +150,68 @@ const AgentChatInternal: React.FC = ({ setContextNotes([]); }, [backend, handleStopGenerating]); + const handleLoadChatHistory = useCallback(async () => { + try { + const items = await plugin.getAgentChatHistoryItems(); + setChatHistoryItems(items); + } catch (error) { + logError("[AgentMode] failed to load chat history", error); + new Notice("Failed to load agent chat history."); + } + }, [plugin]); + + const handleLoadChat = useCallback( + async (id: string) => { + try { + await plugin.loadAgentChatById(id); + } catch (error) { + logError("[AgentMode] failed to load chat", error); + new Notice("Failed to load agent chat."); + } + }, + [plugin] + ); + + const handleUpdateChatTitle = useCallback( + async (id: string, newTitle: string) => { + try { + await plugin.updateAgentChatTitle(id, newTitle); + setChatHistoryItems((prev) => + prev.map((it) => (it.id === id ? { ...it, title: newTitle } : it)) + ); + } catch (error) { + logError("[AgentMode] failed to rename chat", error); + new Notice("Failed to rename agent chat."); + } + }, + [plugin] + ); + + const handleDeleteChat = useCallback( + async (id: string) => { + try { + await plugin.deleteAgentChatHistory(id); + setChatHistoryItems((prev) => prev.filter((it) => it.id !== id)); + } catch (error) { + logError("[AgentMode] failed to delete chat", error); + new Notice("Failed to delete agent chat."); + } + }, + [plugin] + ); + + const handleOpenSourceFile = useCallback( + async (id: string) => { + try { + await plugin.openAgentChatSourceFile(id); + } catch (error) { + logError("[AgentMode] failed to open chat source file", error); + new Notice("Failed to open chat source file."); + } + }, + [plugin] + ); + const handleDelete = useCallback( async (messageId: string) => { try { @@ -191,7 +255,15 @@ const AgentChatInternal: React.FC = ({
- +
); diff --git a/src/main.ts b/src/main.ts index 81d5c874..b91884bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,8 @@ -import { createAgentSessionManager, type AgentSessionManager } from "@/agentMode"; +import { + AGENT_CHAT_FILE_PREFIX, + createAgentSessionManager, + type AgentSessionManager, +} from "@/agentMode"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import ProjectManager from "@/LLMProviders/projectManager"; import { @@ -645,6 +649,12 @@ export default class CopilotPlugin extends Plugin { const folderFiles = await listMarkdownFiles(this.app, getSettings().defaultSaveFolder); if (folderFiles.length === 0) return []; + // Always exclude Agent Mode files — they have their own history list and + // share the same save folder via the `agent__` filename prefix. + const nonAgentFiles = folderFiles.filter( + (file) => !file.basename.startsWith(AGENT_CHAT_FILE_PREFIX) + ); + // Get current project ID if in a project const currentProject = getCurrentProject(); const currentProjectId = currentProject?.id; @@ -652,11 +662,11 @@ export default class CopilotPlugin extends Plugin { if (currentProjectId) { // In project mode: return only files with this project's ID prefix const projectPrefix = `${currentProjectId}__`; - return folderFiles.filter((file) => file.basename.startsWith(projectPrefix)); + return nonAgentFiles.filter((file) => file.basename.startsWith(projectPrefix)); } else { // In non-project mode: return only files without any project ID prefix // This assumes project IDs always use the format projectId__ as prefix - return folderFiles.filter((file) => { + return nonAgentFiles.filter((file) => { // Check if the filename has any projectId__ prefix pattern return !file.basename.match(/^[a-zA-Z0-9-]+__/); }); @@ -845,6 +855,73 @@ export default class CopilotPlugin extends Plugin { } } + /** + * List Agent Mode chat history items across all backends. Empty sessions + * never persist, so they never appear here. Backends share the same on-disk + * folder via the `agent__` filename prefix. + */ + async getAgentChatHistoryItems(): Promise { + if (!this.agentSessionManager) return []; + const persistence = this.agentSessionManager.getPersistenceManager(); + const files = await persistence.listFiles(); + return files.map((file) => { + const createdAt = extractChatDate(file); + const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file); + const effectiveLastAccessedAtMs = + this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt( + file.path, + persistedLastAccessedAtMs ?? createdAt.getTime() + ); + return { + id: file.path, + title: extractChatTitle(file), + createdAt, + lastAccessedAt: new Date(effectiveLastAccessedAtMs), + }; + }); + } + + async loadAgentChatById(fileId: string): Promise { + if (!this.agentSessionManager) { + throw new Error("Agent Mode is not initialized."); + } + const file = await resolveFileByPath(this.app, fileId); + if (!file) throw new Error("Agent chat file not found."); + await this.agentSessionManager.loadSessionFromHistory(file); + this.chatHistoryLastAccessedAtManager.touch(fileId); + } + + async openAgentChatSourceFile(fileId: string): Promise { + const file = this.app.vault.getAbstractFileByPath(fileId); + if (file instanceof TFile) { + await this.app.workspace.getLeaf(true).openFile(file); + return; + } + if (await this.app.vault.adapter.exists(fileId)) { + new Notice( + "Cannot open source files from hidden directories. To open chat notes in the editor, save them to a non-hidden folder in settings." + ); + return; + } + throw new Error("Agent chat file not found."); + } + + async updateAgentChatTitle(fileId: string, newTitle: string): Promise { + if (!this.agentSessionManager) { + throw new Error("Agent Mode is not initialized."); + } + await this.agentSessionManager.getPersistenceManager().updateTopic(fileId, newTitle); + new Notice("Chat title updated."); + } + + async deleteAgentChatHistory(fileId: string): Promise { + if (!this.agentSessionManager) { + throw new Error("Agent Mode is not initialized."); + } + await this.agentSessionManager.getPersistenceManager().deleteFile(fileId); + new Notice("Chat deleted."); + } + async handleNewChat() { clearRecordedPromptPayload(); await logFileManager.clear();