logancyang_obsidian-copilot/src/system-prompts/systemPromptRegister.ts
Zero Liu d6255374cb
chore(deps): swap unmaintained/legacy deps per e18e module-replacements (#2447)
- npm-run-all → npm-run-all2 (dev script uses run-p)
- lint-staged → nano-staged (config block + husky hook)
- lodash.debounce → local src/utils/debounce.ts with cancel/flush/leading/trailing
- dotenv → process.loadEnvFile in integration test bootstrap
- eslint-plugin-react → @eslint-react/eslint-plugin (flat-config migration)
- js-yaml → yaml (mock + jest CJS moduleNameMapper for jsdom)
- crypto-js → pure-JS md5/sha256 in src/utils/hash.ts producing byte-identical
  digests so all on-disk cache keys (PDF, file, project, search index)
  remain valid across the upgrade — no migration or re-parse cost

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:12:52 -07:00

352 lines
12 KiB
TypeScript

import { Notice, Plugin, TAbstractFile, Vault } from "obsidian";
import {
isSystemPromptFile,
getSystemPromptsFolder,
parseSystemPromptFile,
ensurePromptFrontmatter,
updatePromptDefaultFlag,
} from "@/system-prompts/systemPromptUtils";
import {
isPendingFileWrite,
initializeSessionPromptFromDefault,
upsertCachedSystemPrompt,
deleteCachedSystemPrompt,
updateCachedSystemPrompts,
getSelectedPromptTitle,
setSelectedPromptTitle,
} from "@/system-prompts/state";
import { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model";
import { SystemPromptManager } from "@/system-prompts/systemPromptManager";
import { debounce } from "@/utils/debounce";
import { logError, logInfo } from "@/logger";
/**
* Manages vault event listeners for system prompts
* Automatically syncs file changes to the cache
*/
export class SystemPromptRegister {
private plugin: Plugin;
private vault: Vault;
private manager: SystemPromptManager;
private settingsUnsubscriber?: () => void;
/** Monotonically increasing request ID for latest-wins semantics */
private folderChangeRequestId = 0;
constructor(plugin: Plugin, vault: Vault) {
this.plugin = plugin;
this.vault = vault;
this.manager = SystemPromptManager.getInstance(vault);
this.initializeEventListeners();
}
/**
* Initialize the register by loading all prompts
*/
async initialize(): Promise<void> {
await this.manager.initialize();
// Initialize session prompt from global default
initializeSessionPromptFromDefault();
}
/**
* Clean up event listeners when plugin unloads
*/
cleanup(): void {
// Cancel pending debounced operations
this.handleFileModify.cancel();
this.debouncedFolderChange.cancel();
// Unsubscribe from settings changes
this.settingsUnsubscriber?.();
this.vault.off("create", this.handleFileCreation);
this.vault.off("delete", this.handleFileDeletion);
this.vault.off("rename", this.handleFileRename);
this.vault.off("modify", this.handleFileModify);
}
/**
* Initialize vault event listeners
*/
private initializeEventListeners(): void {
this.vault.on("create", this.handleFileCreation);
this.vault.on("delete", this.handleFileDeletion);
this.vault.on("rename", this.handleFileRename);
this.vault.on("modify", this.handleFileModify);
this.settingsUnsubscriber = subscribeToSettingsChange(this.handleSettingsChange);
}
/**
* Handle settings changes that affect system prompt caching
*/
private handleSettingsChange = (
prev: ReturnType<typeof getSettings>,
next: ReturnType<typeof getSettings>
): void => {
const folderChanged = prev.userSystemPromptsFolder !== next.userSystemPromptsFolder;
const defaultChanged = prev.defaultSystemPromptTitle !== next.defaultSystemPromptTitle;
// Handle default change first, using the old folder path if folder also changed
// This ensures we can find the old default file before the folder changes
if (defaultChanged) {
const oldFolder = folderChanged ? prev.userSystemPromptsFolder : undefined;
void this.handleDefaultPromptChange(
prev.defaultSystemPromptTitle,
next.defaultSystemPromptTitle,
oldFolder
);
}
if (folderChanged) {
this.debouncedFolderChange(next.userSystemPromptsFolder);
}
};
/**
* Debounced folder change handler to avoid rapid reloads during typing
* Uses 300ms debounce + requestId for latest-wins semantics
*/
private debouncedFolderChange = debounce(
(nextFolder: string) => {
void this.handleSystemPromptsFolderChange(nextFolder);
},
300,
{ leading: false, trailing: true }
);
/**
* Handle default system prompt change by updating frontmatter
* Removes default flag from old prompt and adds it to new prompt
* @param oldFolder - Optional folder path for the old prompt (used when folder changes simultaneously)
*/
private async handleDefaultPromptChange(
oldTitle: string,
newTitle: string,
oldFolder?: string
): Promise<void> {
try {
if (oldTitle) {
await updatePromptDefaultFlag(oldTitle, false, oldFolder);
}
if (newTitle) {
await updatePromptDefaultFlag(newTitle, true);
}
logInfo(`Default system prompt changed: "${oldTitle}" -> "${newTitle}"`);
} catch (error) {
logError(`Error updating default system prompt frontmatter`, error);
}
}
/**
* Reload prompts from the new folder using "success-then-replace" strategy
* Uses requestId to implement true "latest-wins" semantics:
* - Only the latest request's results are applied
* - Earlier requests that complete later are discarded
* - On failure, old cache is preserved (no empty state)
*/
private async handleSystemPromptsFolderChange(nextFolder: string): Promise<void> {
// Increment request ID - only the latest request will apply its results
const currentRequestId = ++this.folderChangeRequestId;
try {
logInfo(`System prompts folder changed to: ${nextFolder}`);
// Fetch prompts without updating cache (to avoid race condition)
const prompts = await this.manager.fetchPrompts();
// Check if this is still the latest request
if (currentRequestId !== this.folderChangeRequestId) {
logInfo(
`Folder change request ${currentRequestId} superseded by ${this.folderChangeRequestId}, discarding results`
);
return;
}
// Success: now replace the cache
updateCachedSystemPrompts(prompts);
// Validate selectedPromptTitle and defaultSystemPromptTitle
const titles = new Set(prompts.map((p) => p.title));
this.validatePromptReferences(titles);
} catch (error) {
// On failure, preserve old cache (no empty state)
// Only log if this is still the latest request
if (currentRequestId === this.folderChangeRequestId) {
logError(`Error reloading system prompts after folder change to: ${nextFolder}`, error);
}
}
}
/**
* Validate that selectedPromptTitle and defaultSystemPromptTitle still exist
* Clears them with a Notice if they don't
*/
private validatePromptReferences(availableTitles: Set<string>): void {
const settings = getSettings();
const selectedTitle = getSelectedPromptTitle();
// Check defaultSystemPromptTitle
if (
settings.defaultSystemPromptTitle &&
!availableTitles.has(settings.defaultSystemPromptTitle)
) {
updateSetting("defaultSystemPromptTitle", "");
logInfo(
`Cleared defaultSystemPromptTitle (not found in new folder): ${settings.defaultSystemPromptTitle}`
);
new Notice(
`Default system prompt "${settings.defaultSystemPromptTitle}" not found in new folder. Cleared default selection.`
);
}
// Check selectedPromptTitle (session-level)
if (selectedTitle && !availableTitles.has(selectedTitle)) {
setSelectedPromptTitle("");
logInfo(`Cleared selectedPromptTitle (not found in new folder): ${selectedTitle}`);
new Notice(
`Current system prompt "${selectedTitle}" not found in new folder. Cleared chat selection.`
);
}
}
/**
* Handle file modification with debounce
* Debounce ensures frontmatter is fully written before reloading
*/
private handleFileModify = debounce(
async (file: TAbstractFile) => {
if (!isSystemPromptFile(file) || isPendingFileWrite(file.path)) {
return;
}
try {
const prompt = await parseSystemPromptFile(file);
upsertCachedSystemPrompt(prompt);
} catch (error) {
logError(`Error processing system prompt modification: ${file.path}`, error);
}
},
1000,
{
leading: false,
trailing: true,
}
);
/**
* Handle file creation
*/
private handleFileCreation = async (file: TAbstractFile) => {
if (!isSystemPromptFile(file) || isPendingFileWrite(file.path)) {
return;
}
try {
const prompt = await parseSystemPromptFile(file);
// Ensure frontmatter is properly set
await ensurePromptFrontmatter(file, prompt);
// Re-parse to get updated timestamps after frontmatter is written
const updatedPrompt = await parseSystemPromptFile(file);
upsertCachedSystemPrompt(updatedPrompt);
} catch (error) {
logError(`Error processing system prompt creation: ${file.path}`, error);
}
};
/**
* Handle file deletion
* Also clears defaultSystemPromptTitle and selectedPromptTitle if they point to the deleted file
*/
private handleFileDeletion = async (file: TAbstractFile) => {
if (!isSystemPromptFile(file) || isPendingFileWrite(file.path)) {
return;
}
try {
deleteCachedSystemPrompt(file.basename);
// Clear defaultSystemPromptTitle if it was pointing to the deleted file
const settings = getSettings();
if (settings.defaultSystemPromptTitle === file.basename) {
updateSetting("defaultSystemPromptTitle", "");
}
// Sync session-level selection to avoid silent fallback to empty prompt
if (getSelectedPromptTitle() === file.basename) {
setSelectedPromptTitle("");
new Notice(`System prompt "${file.basename}" was deleted. Cleared current chat selection.`);
}
} catch (error) {
logError(`Error processing system prompt deletion: ${file.path}`, error);
}
};
/**
* Handle file rename
* Reference: Similar to systemPromptManager.updatePrompt rename logic
*/
private handleFileRename = async (file: TAbstractFile, oldPath: string) => {
// Check pending status for both old and new paths
if (isPendingFileWrite(file.path) || isPendingFileWrite(oldPath)) {
return;
}
const folder = getSystemPromptsFolder();
// Check if old path was a valid prompt file (direct child, not in subfolders like unsupported/)
const oldRelativePath = oldPath.startsWith(folder + "/")
? oldPath.slice(folder.length + 1)
: "";
const wasValidPromptFile =
oldRelativePath !== "" && !oldRelativePath.includes("/") && oldPath.endsWith(".md");
// Use type guard to check if file is a valid system prompt file
const promptFile = isSystemPromptFile(file) ? file : null;
// Early return if neither old nor new location is relevant
if (!wasValidPromptFile && !promptFile) {
return;
}
try {
// Remove the old prompt from cache if it was a valid prompt file
if (wasValidPromptFile) {
const oldFilename = oldPath.split("/").pop()?.replace(/\.md$/i, "");
if (oldFilename) {
deleteCachedSystemPrompt(oldFilename);
// Handle defaultSystemPromptTitle update
const settings = getSettings();
if (settings.defaultSystemPromptTitle === oldFilename) {
if (promptFile) {
// Rename within folder: update to new name
updateSetting("defaultSystemPromptTitle", promptFile.basename);
} else {
// Move out of folder: clear the setting
updateSetting("defaultSystemPromptTitle", "");
}
}
// Sync session-level selection to avoid silent fallback to empty prompt
if (getSelectedPromptTitle() === oldFilename) {
const nextTitle = promptFile ? promptFile.basename : "";
setSelectedPromptTitle(nextTitle);
if (promptFile) {
new Notice(
`System prompt "${oldFilename}" was renamed to "${promptFile.basename}". Updated current chat selection.`
);
} else {
new Notice(
`System prompt "${oldFilename}" was moved out of the prompts folder. Cleared current chat selection.`
);
}
}
}
}
// Add the new prompt to cache if it's still in the folder
if (promptFile) {
const prompt = await parseSystemPromptFile(promptFile);
await ensurePromptFrontmatter(promptFile, prompt);
// Re-parse to get updated timestamps after frontmatter is written
const updatedPrompt = await parseSystemPromptFile(promptFile);
upsertCachedSystemPrompt(updatedPrompt);
}
} catch (error) {
logError(`Error processing system prompt rename: ${file.path}`, error);
}
};
}