summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-29 13:25:18 +0900
committerAdam Malczewski <[email protected]>2026-03-29 13:25:18 +0900
commit90346a91a81c317b90f4ca9a64cbaaf0ade7868b (patch)
tree0be851269904a3ef76f16cff9d384914face8927
parentbffeac84cb7b3094f7a4b879b6bab6ceaec561ac (diff)
downloadai-pulse-obsidian-plugin-90346a91a81c317b90f4ca9a64cbaaf0ade7868b.tar.gz
ai-pulse-obsidian-plugin-90346a91a81c317b90f4ca9a64cbaaf0ade7868b.zip
add persistent chat history with cross-device sync
-rw-r--r--.rules/changelog/2026-03/28/10.md15
-rw-r--r--.rules/changelog/2026-03/29/01.md24
-rw-r--r--src/calendar/daily-notes.ts223
-rw-r--r--src/chat-history.ts136
-rw-r--r--src/chat-view.ts108
-rw-r--r--src/main.ts66
6 files changed, 349 insertions, 223 deletions
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<TFile> {
- 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<void> {
- 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<string, TFile> {
- const index = new Map<string, TFile>();
- 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<void> {
- 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<PersistedMessage[]> {
+ 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<void> {
+ 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<void> {
+ 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<string, unknown>;
+ 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<string, unknown>;
+ 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<typeof setTimeout> | null = null;
+ private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private bubbleContent: Map<HTMLDivElement, string> = 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<void> {
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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
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<void> {
+ 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<void> {
+ 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<void> {
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)}`;
+}