From 90346a91a81c317b90f4ca9a64cbaaf0ade7868b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 29 Mar 2026 13:25:18 +0900 Subject: add persistent chat history with cross-device sync --- .rules/changelog/2026-03/28/10.md | 15 +++ .rules/changelog/2026-03/29/01.md | 24 ++++ src/calendar/daily-notes.ts | 223 -------------------------------------- src/chat-history.ts | 136 +++++++++++++++++++++++ src/chat-view.ts | 108 ++++++++++++++++++ src/main.ts | 66 +++++++++++ 6 files changed, 349 insertions(+), 223 deletions(-) create mode 100644 .rules/changelog/2026-03/28/10.md create mode 100644 .rules/changelog/2026-03/29/01.md delete mode 100644 src/calendar/daily-notes.ts create mode 100644 src/chat-history.ts diff --git a/.rules/changelog/2026-03/28/10.md b/.rules/changelog/2026-03/28/10.md new file mode 100644 index 0000000..a5f6de0 --- /dev/null +++ b/.rules/changelog/2026-03/28/10.md @@ -0,0 +1,15 @@ +# Phase 2: Calendar State + +## Added +- `src/calendar/calendar-state.ts` — observable state container for the calendar view + +## Details +- `CalendarState` class with private fields: `displayedMonth`, `today`, `activeFileDate`, `noteIndex` +- Read-only getters for all state fields +- `subscribe(cb)` / unsubscribe pattern using a `Set<() => void>` +- `setDisplayedMonth(m)` — clones and normalizes to start-of-month +- `setActiveFile(file, rootFolder)` — delegates to `getDateFromDailyNote()` from Phase 1 +- `reindex(app, rootFolder)` — delegates to `indexDailyNotes()` from Phase 1 +- `tick()` — heartbeat that notifies only on day rollover +- `rootFolder` passed as parameter (not stored) for consistency across methods +- No framework dependencies; strict TypeScript, no `any` diff --git a/.rules/changelog/2026-03/29/01.md b/.rules/changelog/2026-03/29/01.md new file mode 100644 index 0000000..117efc2 --- /dev/null +++ b/.rules/changelog/2026-03/29/01.md @@ -0,0 +1,24 @@ +# Chat History Persistence & Cross-Device Sync + +## New File: `src/chat-history.ts` +- Added `PersistedMessage` and `ChatHistoryData` interfaces for typed storage +- `loadChatHistory()` — reads from `chat-history.json` in the plugin folder via `vault.adapter` +- `saveChatHistory()` — writes user/assistant messages to disk (strips system/tool messages) +- `clearChatHistory()` — writes an empty history file (not deletion) for reliable Obsidian Sync propagation +- `toPersistableMessages()` / `toRuntimeMessages()` — conversion between runtime `ChatMessage[]` and storage format +- `isValidChatHistory()` — strict type guard for safe JSON parsing with version check + +## Modified: `src/chat-view.ts` +- On **open**: restores persisted chat history, renders user messages as plain text and assistant messages as markdown (with wiki-link navigation) +- On **message send**: debounced save (500ms) after user message and after assistant response completes +- On **close**: flushes pending save and cleans up debounce timer +- On **clear chat**: writes empty history file and updates sync snapshot +- Added `reloadChatHistory()` public method for external sync triggers; skips reload if streaming is active to avoid UI disruption +- Added `saveChatHistoryDebounced()` with snapshot update to prevent false sync reloads from local writes + +## Modified: `src/main.ts` +- Added `onExternalSettingsChange()` — reloads settings and checks for chat history changes when Obsidian Sync updates `data.json` +- Added `visibilitychange` DOM event listener — checks for synced changes when the app regains focus (covers device switching) +- Added `checkChatHistorySync()` — snapshot-based change detection that reloads the chat view only when the persisted file differs from the known state +- Added `updateChatSnapshot()` — called after local saves and restores to prevent false sync triggers +- Added `buildChatSnapshot()` helper — lightweight string comparison using message count and last message content diff --git a/src/calendar/daily-notes.ts b/src/calendar/daily-notes.ts deleted file mode 100644 index 2e66834..0000000 --- a/src/calendar/daily-notes.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { App, TFile, Vault, WorkspaceLeaf } from "obsidian"; -import type { Moment } from "moment"; - -const DATE_FORMAT = "YYYY-MM-DD"; - -/** - * Computes the vault-relative path for a daily note. - * Format: {rootFolder}/{YYYY}/{MM}/{DD}/{YYYY-MM-DD}.md - */ -export function getDailyNotePath(date: Moment, rootFolder: string): string { - const year = date.format("YYYY"); - const month = date.format("MM"); - const day = date.format("DD"); - const filename = date.format(DATE_FORMAT); - return `${rootFolder}/${year}/${month}/${day}/${filename}.md`; -} - -/** - * Looks up an existing daily note in the vault for the given date. - * Returns null if the file does not exist. - */ -export function getDailyNote( - app: App, - date: Moment, - rootFolder: string, -): TFile | null { - const path = getDailyNotePath(date, rootFolder); - return app.vault.getFileByPath(path); -} - -/** - * Creates a new daily note for the given date. - * Creates parent folders ({rootFolder}/{YYYY}/{MM}/{DD}/) if they don't exist. - * If a template path is provided and the template file exists, its content is - * used with `{{date}}` placeholders replaced by the ISO date string. - * Otherwise, a minimal file with date frontmatter is created. - */ -export async function createDailyNote( - app: App, - date: Moment, - rootFolder: string, - template?: string, -): Promise { - const path = getDailyNotePath(date, rootFolder); - const dateStr = date.format(DATE_FORMAT); - - // Ensure parent folders exist - const folderPath = path.substring(0, path.lastIndexOf("/")); - await ensureFolderExists(app.vault, folderPath); - - // Resolve content - let content: string; - if (template !== undefined && template !== "") { - const templateFile = app.vault.getFileByPath(template); - if (templateFile !== null) { - const raw = await app.vault.cachedRead(templateFile); - content = raw.replace(/\{\{date\}\}/g, dateStr); - } else { - content = defaultDailyNoteContent(dateStr); - } - } else { - content = defaultDailyNoteContent(dateStr); - } - - return app.vault.create(path, content); -} - -/** - * Opens an existing daily note or creates one first, then opens it. - * When `newLeaf` is true, opens in a new tab; otherwise reuses the current leaf. - */ -export async function openDailyNote( - app: App, - date: Moment, - rootFolder: string, - opts: { newLeaf: boolean }, - template?: string, -): Promise { - let file = getDailyNote(app, date, rootFolder); - if (file === null) { - file = await createDailyNote(app, date, rootFolder, template); - } - - const leaf: WorkspaceLeaf = app.workspace.getLeaf(opts.newLeaf); - await leaf.openFile(file); -} - -/** - * Scans the calendar root folder recursively and builds an index of all - * daily notes, keyed by their ISO date string ("YYYY-MM-DD"). - * - * Only files that match the expected `{YYYY}/{MM}/{DD}/{YYYY-MM-DD}.md` - * structure within the root folder are included. - */ -export function indexDailyNotes( - app: App, - rootFolder: string, -): Map { - const index = new Map(); - const root = app.vault.getFolderByPath(rootFolder); - if (root === null) { - return index; - } - - // Pattern: rootFolder/YYYY/MM/DD/YYYY-MM-DD.md - // After stripping rootFolder prefix, remainder is: YYYY/MM/DD/YYYY-MM-DD.md - const datePathRegex = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{4}-\d{2}-\d{2})\.md$/; - - Vault.recurseChildren(root, (abstractFile) => { - if (!(abstractFile instanceof TFile)) { - return; - } - if (abstractFile.extension !== "md") { - return; - } - - const relativePath = abstractFile.path.substring(rootFolder.length + 1); - const match = datePathRegex.exec(relativePath); - if (match === null) { - return; - } - - const [, year, month, day, filename] = match; - if ( - year === undefined || - month === undefined || - day === undefined || - filename === undefined - ) { - return; - } - - // Verify the folder components match the filename - const expectedFilename = `${year}-${month}-${day}`; - if (filename !== expectedFilename) { - return; - } - - // Validate it's a real date - const m = window.moment(filename, DATE_FORMAT, true); - if (!m.isValid()) { - return; - } - - index.set(filename, abstractFile); - }); - - return index; -} - -/** - * Given a TFile, determines whether it lives in the daily note folder - * structure and extracts the date if so. - * Returns null if the file is not a recognized daily note. - */ -export function getDateFromDailyNote( - file: TFile, - rootFolder: string, -): Moment | null { - // File must be under the root folder - if (!file.path.startsWith(rootFolder + "/")) { - return null; - } - - const relativePath = file.path.substring(rootFolder.length + 1); - const datePathRegex = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{4}-\d{2}-\d{2})\.md$/; - const match = datePathRegex.exec(relativePath); - if (match === null) { - return null; - } - - const [, year, month, day, filename] = match; - if ( - year === undefined || - month === undefined || - day === undefined || - filename === undefined - ) { - return null; - } - - const expectedFilename = `${year}-${month}-${day}`; - if (filename !== expectedFilename) { - return null; - } - - const m = window.moment(filename, DATE_FORMAT, true); - if (!m.isValid()) { - return null; - } - - return m; -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * Recursively creates folders for the given path if they don't already exist. - */ -async function ensureFolderExists(vault: Vault, folderPath: string): Promise { - if (vault.getFolderByPath(folderPath) !== null) { - return; - } - - // Walk from root to deepest folder, creating each segment - const segments = folderPath.split("/"); - let current = ""; - for (const segment of segments) { - current = current === "" ? segment : `${current}/${segment}`; - if (vault.getFolderByPath(current) === null) { - await vault.createFolder(current); - } - } -} - -/** - * Returns the default content for a new daily note with date frontmatter. - */ -function defaultDailyNoteContent(dateStr: string): string { - return `---\ndate: ${dateStr}\n---\n`; -} diff --git a/src/chat-history.ts b/src/chat-history.ts new file mode 100644 index 0000000..ac29ed9 --- /dev/null +++ b/src/chat-history.ts @@ -0,0 +1,136 @@ +import type { App } from "obsidian"; +import type { ChatMessage } from "./ollama-client"; + +/** + * Stored chat history format. + * Only user and assistant messages are persisted — system and tool messages + * are transient (injected per-request by the agent loop). + */ +export interface ChatHistoryData { + version: 1; + messages: PersistedMessage[]; +} + +/** + * A message stored in the chat history file. + * This is a subset of ChatMessage — we strip tool_calls, tool_name, + * and system/tool role messages since they are not meaningful across sessions. + */ +export interface PersistedMessage { + role: "user" | "assistant"; + content: string; +} + +const CHAT_HISTORY_FILENAME = "chat-history.json"; + +/** + * Resolve the full path to the chat history file inside the plugin folder. + */ +function getHistoryPath(app: App, pluginId: string): string { + return `${app.vault.configDir}/plugins/${pluginId}/${CHAT_HISTORY_FILENAME}`; +} + +/** + * Filter ChatMessage[] down to only persistable user/assistant messages. + */ +export function toPersistableMessages(messages: readonly ChatMessage[]): PersistedMessage[] { + const result: PersistedMessage[] = []; + for (const msg of messages) { + if (msg.role === "user" || msg.role === "assistant") { + result.push({ role: msg.role, content: msg.content }); + } + } + return result; +} + +/** + * Convert persisted messages back to ChatMessage[] for the LLM context. + */ +export function toRuntimeMessages(messages: readonly PersistedMessage[]): ChatMessage[] { + return messages.map((m) => ({ role: m.role, content: m.content })); +} + +/** + * Load chat history from the plugin folder. + * Returns an empty array if the file doesn't exist or is corrupted. + */ +export async function loadChatHistory(app: App, pluginId: string): Promise { + const path = getHistoryPath(app, pluginId); + + try { + const exists = await app.vault.adapter.exists(path); + if (!exists) { + return []; + } + + const raw = await app.vault.adapter.read(path); + const parsed = JSON.parse(raw) as unknown; + + if (!isValidChatHistory(parsed)) { + return []; + } + + return parsed.messages; + } catch { + return []; + } +} + +/** + * Save chat history to the plugin folder. + */ +export async function saveChatHistory( + app: App, + pluginId: string, + messages: readonly ChatMessage[], +): Promise { + const path = getHistoryPath(app, pluginId); + const persistable = toPersistableMessages(messages); + + const data: ChatHistoryData = { + version: 1, + messages: persistable, + }; + + await app.vault.adapter.write(path, JSON.stringify(data, null, 2)); +} + +/** + * Clear the chat history by writing an empty messages array. + * Writing an empty file rather than deleting ensures Obsidian Sync + * propagates the "cleared" state to all devices. + */ +export async function clearChatHistory(app: App, pluginId: string): Promise { + const path = getHistoryPath(app, pluginId); + + const data: ChatHistoryData = { + version: 1, + messages: [], + }; + + try { + await app.vault.adapter.write(path, JSON.stringify(data, null, 2)); + } catch { + // Silently ignore — clear is best-effort + } +} + +/** + * Type guard for validating the parsed chat history JSON. + */ +function isValidChatHistory(value: unknown): value is ChatHistoryData { + if (typeof value !== "object" || value === null) return false; + + const obj = value as Record; + if (obj["version"] !== 1) return false; + if (!Array.isArray(obj["messages"])) return false; + + for (const msg of obj["messages"]) { + if (typeof msg !== "object" || msg === null) return false; + const m = msg as Record; + if (m["role"] !== "user" && m["role"] !== "assistant") return false; + if (typeof m["content"] !== "string") return false; + } + + return true; +} diff --git a/src/chat-view.ts b/src/chat-view.ts index 55b730d..72e7a32 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -7,6 +7,8 @@ import { ToolModal } from "./tool-modal"; import { TOOL_REGISTRY } from "./tools"; import type { OllamaToolDefinition } from "./tools"; import { collectVaultContext, formatVaultContext } from "./vault-context"; +import { loadChatHistory, saveChatHistory, clearChatHistory, toRuntimeMessages, toPersistableMessages } from "./chat-history"; +import type { PersistedMessage } from "./chat-history"; export const VIEW_TYPE_CHAT = "ai-pulse-chat"; @@ -19,6 +21,7 @@ export class ChatView extends ItemView { private toolsButton: HTMLButtonElement | null = null; private abortController: AbortController | null = null; private scrollDebounceTimer: ReturnType | null = null; + private saveDebounceTimer: ReturnType | null = null; private bubbleContent: Map = new Map(); private modelBadge: HTMLDivElement | null = null; @@ -113,6 +116,8 @@ export class ChatView extends ItemView { if (this.messageContainer !== null) { this.messageContainer.empty(); } + void clearChatHistory(this.plugin.app, this.plugin.manifest.id); + this.plugin.updateChatSnapshot([]); (document.activeElement as HTMLElement)?.blur(); }); @@ -142,12 +147,21 @@ export class ChatView extends ItemView { // Auto-connect on open void this.plugin.connect(); + + // Restore persisted chat history + void this.restoreChatHistory(); } async onClose(): Promise { if (this.abortController !== null) { this.abortController.abort(); } + if (this.saveDebounceTimer !== null) { + clearTimeout(this.saveDebounceTimer); + this.saveDebounceTimer = null; + } + // Save any pending history before closing + void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); this.contentEl.empty(); this.messages = []; this.bubbleContent.clear(); @@ -219,6 +233,7 @@ export class ChatView extends ItemView { // Track in message history this.messages.push({ role: "user", content: text }); + this.saveChatHistoryDebounced(); // Switch to streaming state this.abortController = new AbortController(); @@ -320,6 +335,7 @@ export class ChatView extends ItemView { await this.finalizeBubble(currentBubble); } this.messages.push({ role: "assistant", content: response }); + this.saveChatHistoryDebounced(); this.scrollToBottom(); } catch (err: unknown) { const isAbort = err instanceof DOMException && err.name === "AbortError"; @@ -737,6 +753,98 @@ export class ChatView extends ItemView { } } + /** + * Save chat history with debouncing to avoid excessive writes. + */ + private saveChatHistoryDebounced(): void { + if (this.saveDebounceTimer !== null) { + clearTimeout(this.saveDebounceTimer); + } + this.saveDebounceTimer = setTimeout(() => { + this.saveDebounceTimer = null; + void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); + // Update the plugin's snapshot so the sync checker doesn't treat + // our own save as an external change. + this.plugin.updateChatSnapshot( + toPersistableMessages(this.messages), + ); + }, 500); + } + + /** + * Restore chat history from the persisted file and render messages. + */ + private async restoreChatHistory(): Promise { + const persisted = await loadChatHistory(this.plugin.app, this.plugin.manifest.id); + if (persisted.length === 0) return; + + this.messages = toRuntimeMessages(persisted); + this.plugin.updateChatSnapshot(persisted); + await this.renderPersistedMessages(persisted); + this.scrollToBottom(); + } + + /** + * Render persisted messages into the chat container. + * User messages are shown as plain text; assistant messages are rendered as markdown. + */ + private async renderPersistedMessages(messages: PersistedMessage[]): Promise { + if (this.messageContainer === null) return; + + for (const msg of messages) { + if (msg.role === "user") { + this.messageContainer.createDiv({ + cls: "ai-pulse-message user", + text: msg.content, + }); + } else if (msg.role === "assistant") { + const bubble = this.messageContainer.createDiv({ + cls: "ai-pulse-message assistant ai-pulse-markdown", + }); + await MarkdownRenderer.render( + this.plugin.app, + msg.content, + bubble, + "", + this, + ); + + // Wire up internal [[wiki-links]] so they navigate on click + bubble.querySelectorAll("a.internal-link").forEach((link) => { + link.addEventListener("click", (evt) => { + evt.preventDefault(); + const href = link.getAttribute("href"); + if (href !== null) { + void this.plugin.app.workspace.openLinkText(href, "", false); + } + }); + }); + } + } + } + + /** + * Reload chat history from disk (e.g., after an external sync). + * Replaces the current messages and re-renders the UI. + */ + async reloadChatHistory(): Promise { + const persisted = await loadChatHistory(this.plugin.app, this.plugin.manifest.id); + + // Skip reload if we're currently streaming — avoid disrupting the UI + if (this.abortController !== null) return; + + this.messages = toRuntimeMessages(persisted); + this.bubbleContent.clear(); + if (this.messageContainer !== null) { + this.messageContainer.empty(); + } + + if (persisted.length > 0) { + await this.renderPersistedMessages(persisted); + this.scrollToBottom(); + } + } + private scrollToBottom(): void { if (this.messageContainer === null) return; const lastChild = this.messageContainer.lastElementChild; diff --git a/src/main.ts b/src/main.ts index 12dadaf..96eb207 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,8 @@ import { DEFAULT_SETTINGS } from "./settings"; import { ChatView, VIEW_TYPE_CHAT } from "./chat-view"; import { testConnection, listModels } from "./ollama-client"; import { getDefaultToolStates } from "./tools"; +import { loadChatHistory } from "./chat-history"; +import type { PersistedMessage } from "./chat-history"; export default class AIPulse extends Plugin { settings: AIPulseSettings = DEFAULT_SETTINGS; @@ -13,6 +15,9 @@ export default class AIPulse extends Plugin { connectionMessage = ""; availableModels: string[] = []; + // Snapshot of persisted chat history for sync change detection + private lastChatSnapshot = ""; + async onload(): Promise { await this.loadSettings(); @@ -29,6 +34,14 @@ export default class AIPulse extends Plugin { void this.activateView(); }, }); + + // Detect chat history changes from Obsidian Sync or other devices. + // We check when the app regains visibility (user switches back from another app/device). + this.registerDomEvent(document, "visibilitychange", () => { + if (document.visibilityState === "visible") { + void this.checkChatHistorySync(); + } + }); } onunload(): void { @@ -72,6 +85,47 @@ export default class AIPulse extends Plugin { await this.saveData(this.settings); } + /** + * Called by Obsidian when data.json is modified externally (e.g., via Sync). + * This is a strong signal that other plugin files may also have been synced. + */ + async onExternalSettingsChange(): Promise { + await this.loadSettings(); + void this.checkChatHistorySync(); + } + + /** + * Check if the persisted chat history has changed (e.g., from another device) + * and reload the chat view if needed. + */ + async checkChatHistorySync(): Promise { + try { + const persisted = await loadChatHistory(this.app, this.manifest.id); + const snapshot = buildChatSnapshot(persisted); + + if (snapshot === this.lastChatSnapshot) return; + this.lastChatSnapshot = snapshot; + + // Find the active chat view and reload it + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT); + for (const leaf of leaves) { + const view = leaf.view; + if (view instanceof ChatView) { + void view.reloadChatHistory(); + } + } + } catch { + // Silently ignore — sync check is best-effort + } + } + + /** + * Update the snapshot after a local save so we don't trigger a false reload. + */ + updateChatSnapshot(messages: PersistedMessage[]): void { + this.lastChatSnapshot = buildChatSnapshot(messages); + } + async connect(): Promise { this.connectionStatus = "connecting"; this.connectionMessage = "Connecting..."; @@ -99,3 +153,15 @@ export default class AIPulse extends Plugin { } } } + +/** + * Build a lightweight snapshot string of chat messages for change detection. + * Uses message count + last message content hash to detect changes + * without deep comparison. + */ +function buildChatSnapshot(messages: PersistedMessage[]): string { + if (messages.length === 0) return "empty"; + const last = messages[messages.length - 1]; + if (last === undefined) return "empty"; + return `${messages.length}:${last.role}:${last.content.length}:${last.content.slice(0, 100)}`; +} -- cgit v1.2.3