From 0b6bb5a7bfa3f272cd1f8d00a7ce3f4ec0a87f74 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Sun, 22 Feb 2026 23:01:59 -0800 Subject: [PATCH] fix: support hidden directory chat save and history (#2188) (#2204) When defaultSaveFolder is a hidden directory (e.g. .copilot/conversations), Obsidian's metadata cache doesn't index it, causing saves to fail and chat history to show empty. Add adapter-level fallback utilities in src/utils/vaultAdapterUtils.ts: - resolveFileByPath: vault lookup with synthetic TFile fallback - listMarkdownFiles: folder listing with adapter fallback - patchFrontmatter: processFrontMatter with adapter YAML patching fallback - readFrontmatterViaAdapter: parse frontmatter from raw file content Apply fallbacks across ChatPersistenceManager (save, load, list, find) and main.ts (history picker, load, delete, title update, lastAccessedAt). Also removes obsolete design docs from docs/. Co-authored-by: Claude Opus 4.6 --- docs/MIYO_SEARCH_PLAN.md | 139 ---------- docs/NATIVE_TOOL_CALLING_MIGRATION.md | 353 ------------------------ docs/SELF_HOSTED_SEARCH.md | 211 -------------- docs/deprecating-intent-analyzer.md | 91 ------ src/core/ChatPersistenceManager.test.ts | 48 ++++ src/core/ChatPersistenceManager.ts | 115 +++++--- src/main.ts | 58 ++-- src/utils/vaultAdapterUtils.ts | 134 +++++++++ 8 files changed, 296 insertions(+), 853 deletions(-) delete mode 100644 docs/MIYO_SEARCH_PLAN.md delete mode 100644 docs/NATIVE_TOOL_CALLING_MIGRATION.md delete mode 100644 docs/SELF_HOSTED_SEARCH.md delete mode 100644 docs/deprecating-intent-analyzer.md create mode 100644 src/utils/vaultAdapterUtils.ts diff --git a/docs/MIYO_SEARCH_PLAN.md b/docs/MIYO_SEARCH_PLAN.md deleted file mode 100644 index 70ef143f..00000000 --- a/docs/MIYO_SEARCH_PLAN.md +++ /dev/null @@ -1,139 +0,0 @@ -# Miyo Search Integration Plan (Revised) - -## Summary - -Integrate Miyo as the semantic index backend while **preserving Copilot’s existing chunking, indexing, and Search v3 UX**. Copilot continues to generate chunks and embeddings locally, but stores vectors and metadata in Miyo via new index-management endpoints. Search v3 keeps its lexical + semantic merge and query expansion, with the semantic leg served by Miyo. Implementation will proceed in two phases: Phase 1 refactors Copilot to abstract Orama; Phase 2 adds Miyo as an additional backend. - -## Inputs Read - -- `discover_miyo.md`: Service discovery via `~/Library/Application Support/Miyo/service.json` with `host`, `port`, and `pid`. -- `miyo_openapi.json`: Existing `/v0/search` and `/v0/health` endpoints. - -## Goals - -- Preserve current Copilot chunking and indexing flow (Search v3 chunk IDs, metadata injection). -- Preserve Search v3 experience (lexical + semantic merge, query expansion, dedupe behavior). -- Store vectors and semantic index data in Miyo (not in Orama) when enabled. -- Reuse existing indexing hooks (refresh, force reindex, clear, delete, list) without UI regressions. -- Allow easy switching between Orama and Miyo via a backend abstraction. -- Keep Miyo usage behind the self-host toggle and Miyo toggle. - -## Non-Goals - -- Changing AI prompts or chat behavior. -- Rewriting lexical search or QueryExpander. -- Forcing a single embedding model for all users (the system remains configurable). - -## Proposed Design - -### 1. Embedding Provider: Miyo (Toggle-Controlled) - -- Add `EmbeddingModelProviders.MIYO` and an internal Miyo embedding model entry. -- **Users do not select Miyo embeddings directly**. The Miyo toggle under self-host mode controls this automatically. -- Implement using `CustomOpenAIEmbeddings` so Miyo’s embeddings are **OpenAI-compatible** (same request/response as Brevilabs embeddings). -- Base URL should resolve to Miyo (self-host URL or discovery result). The embedding endpoint should be `POST /v1/embeddings` on that base URL. -- When Miyo indexing is enabled, automatically switch the embedding model to the Miyo provider and restore the previous embedding model when disabled. - -### 2. Index Backend Abstraction (Phase 1) - -Introduce a backend interface (example name `SemanticIndexBackend`) to decouple indexing from Orama: - -- `initialize(embeddingInstance)` -- `clearIndex()` -- `upsert(doc | docs[])` -- `removeByPath(path)` -- `getIndexedFiles()` -- `getLatestFileMtime()` -- `isIndexEmpty()` / `hasIndex(path)` -- `checkAndHandleEmbeddingModelChange(embeddingInstance)` -- `save()` (no-op for Miyo) -- `checkIndexIntegrity()` (optional for Miyo) - -Implement two backends: - -- **OramaIndexBackend**: wraps existing `DBOperations` unchanged. -- **MiyoIndexBackend**: calls new Miyo index-management endpoints (Phase 2). - -VectorStoreManager should select the backend based on settings: - -- Orama when `enableSemanticSearchV3` and Miyo disabled. -- Miyo when self-host + Miyo indexing enabled. - -### 3. Reuse Existing Chunking + Indexing Flow - -- Keep `IndexOperations.prepareAllChunks()` and its metadata injection. -- Keep chunk IDs (`note_path#chunk_index`) and metadata fields (heading, frontmatter, created/modified). These must be stored in Miyo. -- Replace direct `DBOperations` calls with backend interface calls. - -### 4. Search v3: Merge + Expansion Preserved - -- Keep `TieredLexicalRetriever` for lexical + query expansion. -- Modify `MergedSemanticRetriever` to accept an injected semantic retriever. -- Implement `MiyoSemanticRetriever` that queries Miyo for semantic results (hybrid search), mapping results into LangChain `Document` objects. -- When Miyo indexing is enabled, semantic leg uses Miyo; otherwise fallback to `HybridRetriever` (Orama). -- **Search response should return only required fields** (no snippet requirement): - - `path`, `title`, `ctime`, `mtime`, `tags` - - `chunk_text` (full chunk content) - - `chunk_index` - - `metadata.chunkId` (stable `note_path#chunk_index`) - - `score` - -### 5. Indexing Hooks (No UX Change) - -Existing commands and UI should continue to work: - -- Refresh Vault Index -- Force Reindex Vault -- Clear Index -- List Indexed Files -- Remove From Index - -These hooks should call VectorStoreManager which delegates to the chosen backend. - -### 6. Service Discovery - -- Keep `MiyoServiceDiscovery` to resolve base URL from `service.json`. -- Use discovery for **search**, **embeddings**, and **index management** endpoints. - -### 7. Migration + Switching - -- Switching from Orama to Miyo should trigger a full reindex (force rebuild) because vectors move stores. -- Switching back from Miyo to Orama should also trigger a full reindex to rebuild local Orama state. -- Each vault should map to its own Miyo collection; Copilot passes `collection_name` on every request. -- Collection name format: `{vault_name}_{md5(vault_path).slice(0, 3)}` (example: `my-vault_1bc`). - -## Two-Phase Delivery Plan - -### Phase 1: Orama Abstraction (No Behavior Change) - -- Introduce the index backend interface and refactor Orama usage behind it. -- Keep all existing commands, UI, and indexing flows unchanged. -- Validate that Search v3 behavior is unchanged. - -### Phase 2: Add Miyo Backend - -- Implement Miyo index backend and retriever. -- Enable Miyo embedding provider toggled by self-host + Miyo switch. -- Wire collection naming per vault and hybrid search endpoint usage. - -## Integration Points - -- `src/LLMProviders/embeddingManager.ts` (new provider `MIYO`) -- `src/constants.ts` (provider enum + built-in model entry) -- `src/search/indexBackend/*` (new backend abstraction) -- `src/search/vectorStoreManager.ts` (backend selection) -- `src/search/indexOperations.ts` (backend interface usage) -- `src/search/v3/MergedSemanticRetriever.ts` (semantic retriever injection) -- `src/search/miyo/*` (discovery + Miyo client) - -## Testing Plan - -- Unit tests for backend abstraction (Orama adapter, Miyo adapter) -- Integration test for “force reindex” using Miyo backend (verify calls) -- Manual test: enable Miyo + reindex; confirm search results still include query expansion + lexical merge - -## Open Questions (Resolved) - -- Miyo embeddings are **not user-selectable**; the Miyo toggle controls them. -- Miyo search should be **hybrid** (BM25 + vector). -- Each vault is a **separate Miyo collection**. diff --git a/docs/NATIVE_TOOL_CALLING_MIGRATION.md b/docs/NATIVE_TOOL_CALLING_MIGRATION.md deleted file mode 100644 index 13eb03a7..00000000 --- a/docs/NATIVE_TOOL_CALLING_MIGRATION.md +++ /dev/null @@ -1,353 +0,0 @@ -# Migration Plan: XML Tool Calling → LangChain Native Tool Calling - -## Goal - -1. Replace XML-based tool calling with LangChain native tool calling -2. **Aggressively simplify** the agent loop to follow the standard ReAct pattern -3. Stay model-switchable while removing XML complexity - ---- - -## Migration Status - -### ✅ COMPLETED - -| Phase | Description | Status | -| ------------ | ---------------------------------------------------------------- | ------- | -| Phase 1 | Tool definitions, registry, metadata, nativeToolCalling.ts | ✅ Done | -| Phase 2 | Simplified AutonomousAgentChainRunner with ReAct loop | ✅ Done | -| Phase 3 | CopilotPlusChainRunner migrated to native tool calling | ✅ Done | -| Phase 4 | XML tool parsing functions removed (kept escape/unescape only) | ✅ Done | -| Phase 5 | Model adapters cleaned up - XML templates removed | ✅ Done | -| Phase 6 | Agent Reasoning Block UI replaces tool call banner | ✅ Done | -| Bedrock | BedrockChatModel native tool calling (streaming + non-streaming) | ✅ Done | -| Copilot Plus | copilot-plus-flash native tool calling (via ChatOpenRouter) | ✅ Done | - -**Notes:** - -- `AutonomousAgentChainRunner` now uses `bindTools()` - no XML format instructions -- `CopilotPlusChainRunner` now uses `bindTools()` for tool planning -- `xmlParsing.ts` simplified to only `escapeXml`, `unescapeXml`, `escapeXmlAttribute` for context envelope processing -- Model adapters cleaned up - XML `` templates removed, kept behavioral guidance only -- COPILOT_PLUS provider uses `ChatOpenRouter` for proper SSE tool_call parsing -- `ToolCall` interface moved to `toolExecution.ts` -- Agent Reasoning Block shows reasoning process with timer and step summaries - -### 🔲 REMAINING - -See **Testing** and **What's Left** sections below. - ---- - -## ReAct Pattern (Reference) - -1. **Reasoning**: Model analyzes the task -2. **Acting**: Model returns `AIMessage` with `tool_calls` -3. **Observation**: Tool results fed back as `ToolMessage` -4. **Iteration**: Repeat until no more `tool_calls` - ---- - -## Simplification Summary - -### Removed - -- `ModelAdapter` XML format instructions -- `xmlParsing.ts` (for agent mode) -- XML markers in `toolCallParser.ts` -- `ConversationMessage` type → use `BaseMessage[]` -- `iterationHistory` tracking → just messages array -- Complex streaming display → simple content accumulation - -### Kept - -- `StructuredTool` from registry -- `bindTools()` for native tool binding -- `ThinkBlockStreamer` for thinking content -- `executeSequentialToolCall()` with timeout -- Source collection for UI - -### Code Reduction - -| Before | After | -| ----------------- | ---------- | -| ~2500 lines | ~200 lines | -| **92% reduction** | | - ---- - -## Gemini Compatibility - -Gemini only supports subset of JSON Schema. Avoid: - -- `.positive()` → use `.min(1)` instead -- `.gt(n)`, `.lt(n)` → use `.min(n)`, `.max(n)` instead - ---- - -## Testing (🔲 REMAINING) - -### Manual Tests - -- [ ] "Search my notes about X" → localSearch -- [x] "What did I do this month" → getTimeRangeMs + localSearch -- [ ] Multi-tool conversation -- [ ] Streaming display -- [ ] Abort mid-execution - -### Provider Validation - -- [ ] OpenAI GPT-4 -- [ ] Anthropic Claude -- [ ] Google Gemini -- [ ] OpenRouter models -- [x] Amazon Bedrock -- [x] Copilot Plus (copilot-plus-flash) -- [x] LM Studio (gpt-oss-20b) -- [ ] Ollama (tool-capable models) - -### Edge Cases - -- [ ] Model without tool support falls back -- [ ] Tool timeout handled -- [ ] Invalid args handled -- [ ] Max iterations respected - ---- - -## 🔲 What's Left to Remove XML Completely - -### ✅ Phase 3: Migrate CopilotPlusChainRunner - -**COMPLETED** - CopilotPlusChainRunner now uses native tool calling. - -| Task | Description | Status | -| ---------------------------- | --------------------------------------------------------------------- | ------- | -| [x] Replace XML tool format | Use `bindTools()` instead of XML instructions | ✅ Done | -| [x] Update response parsing | Parse `tool_calls` from AIMessage instead of XML regex | ✅ Done | -| [x] Remove XML system prompt | Remove tool format instructions | ✅ Done | -| [x] Tool result handling | CopilotPlus doesn't use ReAct loop - results passed to final LLM call | ✅ N/A | - -**Implementation notes:** - -- `planToolCalls()` now uses `chatModel.bindTools(availableTools)` instead of XML descriptions -- Tool calls extracted from `response.tool_calls` instead of `parseXMLToolCalls()` -- Salient terms extracted via simple text pattern `[SALIENT_TERMS: ...]` with fallback -- `unescapeXml` still used for context envelope image extraction (separate from tool calling) - -### ✅ Phase 4: Delete XML Tool Parsing Utilities - -**COMPLETED** - XML tool parsing functions removed, kept escape/unescape for context envelope. - -| Task | Description | Status | -| ---------------------------------------------- | ----------------------------------------------------- | ------- | -| [x] Remove XML tool parsing from xmlParsing.ts | Removed `parseXMLToolCalls`, `stripToolCallXML`, etc. | ✅ Done | -| [x] Move ToolCall interface | Moved to `toolExecution.ts` where it's actually used | ✅ Done | -| [x] Update xmlParsing.test.ts | Simplified to only test escape/unescape functions | ✅ Done | -| [x] Keep escape/unescape | Retained for context envelope image URL processing | ✅ Done | - -**Implementation notes:** - -- `xmlParsing.ts` now only exports: `escapeXml`, `unescapeXml`, `escapeXmlAttribute` -- `ToolCall` interface moved to `toolExecution.ts` with execution utilities -- Legacy integration test `AgentPrompt.test.ts` skipped (tests old XML flow) - -### ✅ Phase 5: Clean Up Model Adapters - -**COMPLETED** - XML `` templates removed from all adapters. - -| Task | Description | Status | -| ----------------------------------------- | ------------------------------------------------------ | ------- | -| [x] Remove XML templates | Removed `` format instructions | ✅ Done | -| [x] Remove model-specific XML workarounds | Simplified GPT, Claude, Gemini guidance | ✅ Done | -| [x] Remove premature response handling | Removed detectPrematureResponse, sanitizeResponse, etc | ✅ Done | -| [x] Update adapter tests | Fixed assertions for new simplified prompts | ✅ Done | - -**Implementation notes:** - -- `BaseModelAdapter.buildSystemPromptSections` now says "Tools are provided via native function calling" -- `GPTModelAdapter` no longer includes verbose XML examples, just behavioral guidance -- `ClaudeModelAdapter` simplified - removed XML patterns, kept thinking model guidance -- `GeminiModelAdapter` removed XML examples, kept sequential tool call guidance -- Removed `detectPrematureResponse`, `sanitizeResponse`, `shouldTruncateStreaming` - not needed with native tool calling (tool calls are in structured `response.tool_calls`, not embedded XML) - -### ✅ Phase 6: Replace Tool Call Banner UI - -**COMPLETED** - Agent Reasoning Block implemented, tool call markers deprecated. - -| Task | Description | Status | -| ----------------------------------- | ------------------------------------------------------- | ------- | -| [x] Implement Agent Reasoning Block | See `docs/AGENT_REASONING_BLOCK.md` | ✅ Done | -| [x] Deprecate marker creation | Added deprecation warnings to marker creation functions | ✅ Done | -| [x] Keep marker parsing | Retained for old saved messages | ✅ Done | -| [x] Keep ToolCallBanner | Keep for backward compat with old messages | ✅ Done | - -**Implementation notes:** - -- Created `AgentReasoningState.ts` with state management and serialization -- Created `AgentReasoningBlock.tsx` React component with collapsible UI -- Added CSS styles in `tailwind.css` for reasoning block styling -- Updated `AutonomousAgentChainRunner.ts` with reasoning timer and step tracking -- Updated `ChatSingleMessage.tsx` to parse and render reasoning blocks -- Agent reasoning block replaces tool call banners for new agent sessions -- `createToolCallMarker()` and `updateToolCallMarker()` marked as @deprecated -- `parseToolCallMarkers()` retained for backward compatibility with saved chats -- **Timer architecture:** Timer runs independently (100ms interval) and tracks `accumulatedContent`; early detection of final response (text without tool calls) stops timer and switches to direct UI updates for smooth streaming - -### Phase 7: Simplify Chat Persistence - -| Task | Description | -| ------------------------------------------------ | --------------------------------------------------------- | -| [ ] Remove tool call markers from saved messages | Only persist user messages and AI final responses | -| [ ] Update ChatPersistenceManager | Skip intermediate tool results during save | -| [ ] Update chat load logic | No need to parse tool markers from saved chats | -| [ ] Keep reasoning metadata | Persist elapsed time and step summaries in final response | - -**Note:** Intermediate tool call results (ToolMessages, AIMessages with tool_calls) will NOT be persisted. Only: - -- User messages (HumanMessage) -- AI final responses (with optional reasoning block metadata) - -This simplifies persistence and reduces chat file size significantly. - -### Phase 8: Final Cleanup - -| Task | Description | -| ------------------------------------------ | -------------------------------- | -| [ ] Remove ThinkBlockStreamer XML handling | Remove tool call marker emission | -| [ ] Audit for remaining XML refs | Search for `; - searchByVector(embedding: number[], options: {...}): Promise; - isAvailable(): Promise; - getEmbeddingDimension(): number; -} - -// Factory usage -const result = await RetrieverFactory.createRetriever(app, options); -const docs = await result.retriever.getRelevantDocuments(query); -``` - -### Settings - -| Setting | Type | Default | Description | -| -------------------- | ------- | ------- | --------------------------------- | -| `enableSelfHostMode` | boolean | false | Enable self-host mode | -| `selfHostUrl` | string | "" | URL endpoint for the Miyo backend | -| `selfHostApiKey` | string | "" | API key (if required) | - -## What To Do Next - -### 1. Implement Miyo Backend - -Create `src/search/backends/MiyoBackend.ts`: - -```typescript -import { VectorSearchBackend, VectorSearchResult } from "../selfHostRetriever"; - -export class MiyoBackend implements VectorSearchBackend { - private url: string; - private apiKey?: string; - - constructor(config: { url: string; apiKey?: string }) { - this.url = config.url; - this.apiKey = config.apiKey; - } - - async search( - query: string, - options: { - limit: number; - minScore?: number; - filter?: Record; - } - ): Promise { - // TODO: Implement HTTP call to Miyo API - // POST /search { query, limit, minScore, filter } - throw new Error("Not implemented"); - } - - async searchByVector( - embedding: number[], - options: { - limit: number; - minScore?: number; - filter?: Record; - } - ): Promise { - // TODO: Implement vector search - // POST /search/vector { embedding, limit, minScore, filter } - throw new Error("Not implemented"); - } - - async isAvailable(): Promise { - // TODO: Health check - // GET /health - try { - const response = await fetch(`${this.url}/health`); - return response.ok; - } catch { - return false; - } - } - - getEmbeddingDimension(): number { - // TODO: Return the dimension your embeddings use - return 1536; // e.g., OpenAI ada-002 - } -} -``` - -### 2. Register Backend on Plugin Load - -In `src/main.ts` or appropriate initialization code: - -```typescript -import { RetrieverFactory } from "@/search/RetrieverFactory"; -import { MiyoBackend } from "@/search/backends/MiyoBackend"; - -// During plugin initialization -const settings = getSettings(); -if (settings.enableSelfHostMode && settings.selfHostUrl) { - const backend = new MiyoBackend({ - url: settings.selfHostUrl, - apiKey: settings.selfHostApiKey, - }); - RetrieverFactory.registerSelfHostedBackend(backend); -} -``` - -### 3. Add Settings UI - -Add toggle and input fields in `src/settings/components/QASettings.tsx`: - -```tsx -// Enable toggle - - updateSetting("enableSelfHostMode", value)} - /> -; - -// URL input -{ - settings.enableSelfHostMode && ( - <> - - updateSetting("selfHostUrl", value)} - placeholder="http://localhost:6333" - /> - - - updateSetting("selfHostApiKey", value)} - type="password" - /> - - - ); -} -``` - -### 4. Handle Settings Changes - -Re-register backend when settings change: - -```typescript -subscribeToSettingsChange((prev, next) => { - if ( - prev.enableSelfHostMode !== next.enableSelfHostMode || - prev.selfHostUrl !== next.selfHostUrl || - prev.selfHostApiKey !== next.selfHostApiKey - ) { - if (next.enableSelfHostMode && next.selfHostUrl) { - const backend = new MiyoBackend({ - url: next.selfHostUrl, - apiKey: next.selfHostApiKey, - }); - RetrieverFactory.registerSelfHostedBackend(backend); - } else { - RetrieverFactory.clearSelfHostedBackend(); - } - } -}); -``` - -## Testing Checklist - -- [ ] Implement `MiyoBackend` class -- [ ] Add settings UI for Miyo configuration -- [ ] Register backend on plugin load -- [ ] Handle settings changes (re-register backend) -- [ ] Test search with Miyo enabled -- [ ] Test fallback when Miyo unavailable -- [ ] Verify existing lexical/semantic search still works diff --git a/docs/deprecating-intent-analyzer.md b/docs/deprecating-intent-analyzer.md deleted file mode 100644 index eb19e803..00000000 --- a/docs/deprecating-intent-analyzer.md +++ /dev/null @@ -1,91 +0,0 @@ -# Deprecating `IntentAnalyzer` and Broca - -This document captures the current responsibilities of the Copilot Plus intent analysis flow (`IntentAnalyzer` + Brevilabs “broca” API) and outlines a safe migration path to remove it while keeping Copilot Plus functionality aligned with the autonomous agent experience. The end state is simple: the user-facing chat model (same family as the agent chain) owns tool planning, salient term extraction, and time-expression handling; Broca is fully removed. - -## Current Responsibilities - -- **Tool orchestration via Broca** (`src/LLMProviders/intentAnalyzer.ts:34`\ - – `BrevilabsClient.broca`): each chat turn sends the raw user message to `/broca` and receives: - - `tool_calls`: predefined tool names with argument payloads. In practice the only tools that still rely on Broca for automatic detection are the _utility tools_ (`getCurrentTime`, `convertTimeBetweenTimezones`, `getTimeRangeMs`, `getTimeInfoByEpoch`, and occasionally `getFileTree`). Feature toggles, explicit UI controls, and `@` commands already cover search, web search, composer, memory updates, and indexing. - - `salience_terms`: keywords Broca derives from the user message, passed untouched to the vault search tool. -- **Tool registry bootstrap** (`IntentAnalyzer.initTools`): wires up the same Zod-described tools used by the agent (`localSearchTool`, `webSearchTool`, `getTimeRangeMs`, etc.) so Copilot Plus can execute them without the agent loop. -- **Time-expression handling**: when Broca schedules `getTimeRangeMs`, `IntentAnalyzer` executes it first and stores the returned range so the subsequent `localSearch` call includes the `timeRange`. -- **`@` command overrides** (`IntentAnalyzer.processAtCommands`): falls back to local heuristics for inline control commands even if Broca does not schedule a tool call. - - `@vault` → forces `localSearch`. - - `@websearch` / `@web` → forces `webSearch`. - - `@memory` → forces `updateMemory`. -- **Plus-specific salient term injection**: any Broca `salience_terms` array is passed into `localSearch` unchanged, altering recall compared with the agent chain. Our discovery shows this causes divergence between Plus and agent results; we need both flows to end up using the same salient term set. -- **Implicit license enforcement**: `/broca` is the only per-turn API call guaranteed to touch Brevilabs. Although license validation also uses `/license` (`checkIsPlusUser`), Broca acts as the continuous touch point that can detect expired keys mid-session. **Today**: `checkIsPlusUser` already runs per turn in both Copilot Plus and Autonomous Agent chain runners; keep that behavior when Broca goes away (no extra moving parts). - -## Known Consumers and Side Effects - -- `CopilotPlusChainRunner.run` (`src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts:488`) is the sole caller of `IntentAnalyzer.analyzeIntent`. -- `BrevilabsClient` exposes `broca` and `/license` validation; multiple subsystems already call `validateLicenseKey` directly (e.g., `checkIsPlusUser`, `embeddingManager`, `plusUtils`). -- Tests: there is no direct unit test coverage for `IntentAnalyzer`, but tool execution tests (`src/LLMProviders/chainRunner/utils/toolExecution.test.ts`) rely on the same registry. -- Production telemetry/debug logging assumes the Broca payload; removal must not break logging expectations. - -## Constraints for Migration - -- **Feature parity**: Copilot Plus must keep working with vault search, timeline questions, web search, file tree lookup, and memory updates. -- **License verification**: every chat turn must continue to check Plus eligibility (either by retaining a per-turn Brevilabs call or by adding an explicit `/license` check with caching and backoff). -- **Minimal prompt drift**: Copilot Plus prompts currently do not include the aggressive tool-calling instructions used by the autonomous agent; the migration should not break existing prompt tuning until the replacement strategy is ready. Prefer reusing existing agent instructions instead of inventing new ones. -- **Zero regression for `@` commands**: the existing inline overrides are user-facing affordances. -- **Search recall**: any solution must surface the same or better salient term quality as the agent chain to avoid regressions highlighted in recent investigations; salient terms should come from the user chat model (agent-style extraction), not Broca. -- **Plus/agent parity**: after migration, the Plus chain must feed the vault search pipeline with the same query string, expanded variants, and salient term list that the agent chain would generate for the identical user input. -- **Scoped automatic detection**: only the utility tools without explicit UI affordances (`getCurrentTime`, `convertTimeBetweenTimezones`, `getTimeRangeMs`, `getTimeInfoByEpoch`, `getFileTree`) need automatic invocation; everything else can be triggered through toggles or `@tool` commands. -- **Self-host readiness**: future “self-host” mode must be able to (a) bypass live license checks (or use a short-lived cached verification) and (b) avoid all Brevilabs API calls while keeping the rest of the product functional, without branching code paths everywhere. - -## Migration Plan - -### Phase 0 – Discovery & Telemetry - -1. **Instrument current intent decisions**: add temporary logging (behind debug flag) to record Broca output, executed tools, and overrides. Purpose: baseline behavior before refactor. -2. **Map tool usage**: capture how frequently each Broca tool is invoked to prioritise feature parity work. -3. **Clarify license expectations**: confirm with product whether `/license` checks can replace Broca for per-turn validation, or if a lighter “heartbeat” endpoint is needed. (Current code already calls `/license` per turn via `checkIsPlusUser`; keep this as the baseline.) - -### Phase 1 – Surface-Agnostic Tool Planning - -4. **Extract shared tool planner interface**: design a single planner abstraction (e.g., `PlusToolPlanner`) that returns `{ tool, args }[]`. Default implementation uses the same chat-model planning as the agent; keep Broca only as a temporary hidden fallback. -5. **Port `@` command handling**: move `processAtCommands` into the planner; keep `chatHistory` enrichment for `@websearch/@web`. One place, one behavior. -6. **Adopt agent-style salient term generation**: reuse the agent chain’s salience extraction via the chat model. Remove Broca salience entirely; no parallel salience paths. -7. **Utility auto-detection**: rely on the chat-model planner for time/time-range/time-info/file-tree, with a minimal heuristic fallback for obvious time expressions. Preserve project-mode guardrails (skip vault-level `getFileTree` in projects). - -### Phase 2 – Replace Broca for Tool Scheduling - -8. **Reuse ModelAdapter-driven planning**: embed a single-iteration agent-style planner that emits localSearch/webSearch/time tools with the same parameters (query + salience terms + timeRange) the agent chain would produce. -9. **Fallback heuristics**: keep deterministic fallbacks for time expressions; compute timeRange once and pass it to localSearch. Avoid extra tool calls. -10. **Feature flag & dogfood**: gate the new planner behind a toggle to compare against Broca; collect salience/time parity telemetry. Keep toggles minimal. - -### Phase 3 – License Enforcement Replacement - -11. **Per-turn license check**: keep the current per-turn `checkIsPlusUser` in Copilot Plus, Agent, and Projects. If you cache, cache per turn only. Centralize this so there is one path. -12. **Graceful degradation**: if validation fails or is unreachable, show the same invalid-key flow. Add a single override hook to bypass in self-host mode (no scattered flags). - -### Phase 4 – Removal & Cleanup - -13. **Switch default planner**: flip the feature flag when parity is reached; Broca stays as a hidden one-release fallback. -14. **Delete `IntentAnalyzer`**: remove the class, tests, and references; drop `initTools` from `src/main.ts` (tool registration already handled elsewhere). -15. **Retire `BrevilabsClient.broca`**: remove the method and unused types; keep `/license` and others. -16. **Documentation & migration notes**: update `AGENTS.md`, `TODO.md`, and user-facing Plus docs. - -### Phase 5 – Prepare for Self-Host Mode - -17. **Abstract Brevilabs dependencies**: one provider interface for license, web search, rerank, youtube, url/pdf ingestion. Default = Brevilabs; self-host = offline/no-op or user-supplied. -18. **Configurable license gate**: single setting for “offline verified” (short-lived cache) or “skip verification” (self-host). Copilot Plus/Projects should read from the provider, not from BrevilabsClient directly. -19. **Feature degradation without Brevilabs**: define one behavior for missing endpoints (disable specific tools or route to user-provided endpoints) without branching everywhere. Keep telemetry resilient when Broca/Brevilabs events are absent. -20. **Testing and telemetry updates**: cover provider modes (online vs self-host) and update dashboards for the absence of Broca events. - -## Risks and Mitigations - -- **Planner hallucination / regressions**: mitigate with deterministic overrides, strict XML parsing (already in `toolExecution`), and fallback to default responses if tool planning fails. -- **License API outages**: add retry/backoff and degrade gracefully (read-only mode, user notice). -- **Search recall differences**: unit test the new salience extractor against QueryExpander fixtures to guarantee improved recall vs Broca output and to keep Plus aligned with agent expansion behaviour. -- **Timeline for removal**: schedule the cleanup after at least one release cycle of telemetry from the new planner. - -## Open Questions - -- Should Copilot Plus adopt the full autonomous agent loop (multiple tool turns) or stay single-shot with a tighter planner? -- Do we need a dedicated Brevilabs endpoint for per-turn license heartbeat instead of reusing `/license`? -- How will we migrate existing analytics dashboards that currently expect Broca telemetry? - -Document owner: _TBD_ (assign during implementation kickoff). diff --git a/src/core/ChatPersistenceManager.test.ts b/src/core/ChatPersistenceManager.test.ts index b5cb30bb..248381a2 100644 --- a/src/core/ChatPersistenceManager.test.ts +++ b/src/core/ChatPersistenceManager.test.ts @@ -97,6 +97,13 @@ describe("ChatPersistenceManager", () => { modify: jest.fn(), read: jest.fn(), getMarkdownFiles: jest.fn().mockReturnValue([]), // Default: no files + adapter: { + exists: jest.fn().mockResolvedValue(false), + read: jest.fn().mockResolvedValue(""), + write: jest.fn().mockResolvedValue(undefined), + list: jest.fn().mockResolvedValue({ files: [], folders: [] }), + stat: jest.fn().mockResolvedValue({ ctime: Date.now(), mtime: Date.now(), size: 0 }), + }, }, metadataCache: { getFileCache: jest.fn(), @@ -374,6 +381,7 @@ Nature's quiet song`); path: "test-folder/Summarize_weather_data@20240923_221800.md", } as unknown as TFile; mockApp.vault.create.mockResolvedValue(mockFile); + mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile); mockMessageRepo.getDisplayMessages.mockReturnValue(messages); const frontmatterState: Record = {}; mockApp.fileManager.processFrontMatter.mockImplementation( @@ -839,6 +847,7 @@ Nature's quiet song`); mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { epoch: "1695513480000" }, }); + mockApp.vault.getAbstractFileByPath.mockReturnValue(existingFile); await persistenceManager.saveChat("gpt-4"); @@ -956,6 +965,44 @@ Nature's quiet song`); }); }); + describe("hidden directory support", () => { + it("should save via adapter when file exists on disk but not in vault cache", async () => { + const messages: ChatMessage[] = [ + { + id: "1", + message: "Hello", + sender: USER_SENDER, + timestamp: { + epoch: 1695513480000, + display: "2024/09/23 22:18:00", + fileName: "2024_09_23_221800", + }, + isVisible: true, + }, + ]; + + const getFilesSpy = jest + .spyOn(persistenceManager, "getChatHistoryFiles") + .mockResolvedValue([]); + + mockMessageRepo.getDisplayMessages.mockReturnValue(messages); + + // findFileByEpoch returns null, but file exists on disk (hidden dir) + mockApp.vault.adapter.exists.mockResolvedValue(true); + + await persistenceManager.saveChat("gpt-4"); + + // Should write via adapter, not vault.create + expect(mockApp.vault.adapter.write).toHaveBeenCalledWith( + expect.stringContaining("test-folder/"), + expect.stringContaining("**user**: Hello") + ); + expect(mockApp.vault.create).not.toHaveBeenCalled(); + + getFilesSpy.mockRestore(); + }); + }); + describe("loadChat", () => { it("should load and parse chat from file", async () => { const fileContent = `--- @@ -1498,6 +1545,7 @@ tags: lastAccessedAt: 1700000000000, // Existing lastAccessedAt }, }); + mockApp.vault.getAbstractFileByPath.mockReturnValue(existingFile); await persistenceManager.saveChat("gpt-4"); diff --git a/src/core/ChatPersistenceManager.ts b/src/core/ChatPersistenceManager.ts index 173c6362..ba3ee402 100644 --- a/src/core/ChatPersistenceManager.ts +++ b/src/core/ChatPersistenceManager.ts @@ -12,7 +12,13 @@ import { getUtf8ByteLength, truncateToByteLimit, } from "@/utils"; -import { App, Notice, TFile, TFolder } from "obsidian"; +import { + isInVaultCache, + listMarkdownFiles, + patchFrontmatter, + readFrontmatterViaAdapter, +} from "@/utils/vaultAdapterUtils"; +import { App, Notice, TFile } from "obsidian"; import { MessageRepository } from "./MessageRepository"; const SAFE_FILENAME_BYTE_LIMIT = 100; @@ -64,8 +70,22 @@ export class ChatPersistenceManager { const existingFrontmatter = existingFile ? this.app.metadataCache.getFileCache(existingFile)?.frontmatter : undefined; - let existingTopic = existingFrontmatter?.topic; - const existingLastAccessedAt = existingFrontmatter?.lastAccessedAt; + + let existingTopic: string | undefined = existingFrontmatter?.topic; + let existingLastAccessedAt: number | undefined = existingFrontmatter?.lastAccessedAt; + + // For hidden directory files, metadataCache returns null — read frontmatter via adapter + if (existingFile && !existingFrontmatter) { + try { + const adapterFm = await readFrontmatterViaAdapter(this.app, existingFile.path); + if (adapterFm) { + if (adapterFm.topic) existingTopic = adapterFm.topic; + if (adapterFm.lastAccessedAt) existingLastAccessedAt = Number(adapterFm.lastAccessedAt); + } + } catch { + // Ignore — proceed without preserved frontmatter + } + } const preferredFileName = existingFile ? existingFile.path @@ -80,12 +100,28 @@ export class ChatPersistenceManager { ); let targetFile: TFile | null = existingFile; - if (existingFile) { - // If the file exists, update its content + // Check if existingFile is a real vault file (not a synthetic object for hidden dirs) + const existingFileIsReal = + existingFile != null && isInVaultCache(this.app, existingFile.path); + + if (existingFile && existingFileIsReal) { + // If the file exists in the vault cache, update via vault API await this.app.vault.modify(existingFile, noteContent); logInfo(`[ChatPersistenceManager] Updated existing chat file: ${existingFile.path}`); + } else if ( + !isInVaultCache(this.app, preferredFileName) && + (await this.app.vault.adapter.exists(preferredFileName)) + ) { + // File exists on disk but not in the vault cache. + // This happens when the save folder is a hidden directory (path starting with '.') + // because Obsidian's metadata cache does not index hidden paths. + await this.app.vault.adapter.write(preferredFileName, noteContent); + new Notice("Existing chat note found - updating it now."); + logInfo( + `[ChatPersistenceManager] Updated existing chat file via adapter: ${preferredFileName}` + ); } else { - // If the file doesn't exist, create a new one + // File doesn't exist, create a new one try { targetFile = await this.app.vault.create(preferredFileName, noteContent); new Notice(`Chat saved as note: ${preferredFileName}`); @@ -115,7 +151,12 @@ export class ChatPersistenceManager { `[ChatPersistenceManager] Resolved save conflict by updating existing chat file: ${conflictFile.path}` ); } else { - throw error; + // File exists on disk but not in vault cache (hidden directory) + await this.app.vault.adapter.write(preferredFileName, noteContent); + new Notice("Existing chat note found - updating it now."); + logInfo( + `[ChatPersistenceManager] Resolved save conflict via adapter: ${preferredFileName}` + ); } } else if (this.isNameTooLongError(error)) { // Single fallback: minimal guaranteed-to-work filename with project prefix @@ -154,7 +195,12 @@ export class ChatPersistenceManager { `[ChatPersistenceManager] Resolved fallback save conflict by updating existing chat file: ${conflictFile.path}` ); } else { - throw fallbackError; + // File exists on disk but not in vault cache (hidden directory) + await this.app.vault.adapter.write(fallbackName, noteContent); + new Notice("Existing chat note found - updating it now."); + logInfo( + `[ChatPersistenceManager] Resolved fallback save conflict via adapter: ${fallbackName}` + ); } } else { throw fallbackError; @@ -178,7 +224,13 @@ export class ChatPersistenceManager { */ async loadChat(file: TFile): Promise { try { - const content = await this.app.vault.read(file); + let content: string; + try { + content = await this.app.vault.read(file); + } catch { + // Fallback for hidden directory files not indexed by Obsidian + content = await this.app.vault.adapter.read(file.path); + } const messages = this.parseChatContent(content); logInfo(`[ChatPersistenceManager] Loaded ${messages.length} messages from ${file.path}`); return messages; @@ -194,13 +246,8 @@ export class ChatPersistenceManager { */ async getChatHistoryFiles(): Promise { const settings = getSettings(); - const folder = this.app.vault.getAbstractFileByPath(settings.defaultSaveFolder); - if (!(folder instanceof TFolder)) { - return []; - } - - const files = this.app.vault.getMarkdownFiles(); - const folderFiles = files.filter((file) => file.path.startsWith(folder.path)); + const folderFiles = await listMarkdownFiles(this.app, settings.defaultSaveFolder); + if (folderFiles.length === 0) return []; // Get current project ID if in a project const currentProject = getCurrentProject(); @@ -481,12 +528,24 @@ export class ChatPersistenceManager { const files = await this.getChatHistoryFiles(); for (const file of files) { - const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; + // Try metadata cache first (works for non-hidden directories) + let epochValue: unknown = this.app.metadataCache.getFileCache(file)?.frontmatter?.epoch; + + // Fallback for hidden directory files: read frontmatter via adapter + if (epochValue === undefined) { + try { + const adapterFm = await readFrontmatterViaAdapter(this.app, file.path); + if (adapterFm?.epoch) epochValue = Number(adapterFm.epoch); + } catch { + continue; + } + } + const frontmatterEpoch = - typeof frontmatter?.epoch === "number" - ? frontmatter.epoch - : typeof frontmatter?.epoch === "string" - ? Number(frontmatter.epoch) + typeof epochValue === "number" + ? epochValue + : typeof epochValue === "string" + ? Number(epochValue) : undefined; if ( typeof frontmatterEpoch === "number" && @@ -708,19 +767,7 @@ ${chatContent}`; */ private async applyTopicToFrontmatter(file: TFile, topic: string): Promise { try { - if (!this.app.fileManager?.processFrontMatter) { - return; - } - - const sanitizedTopic = topic.trim(); - - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - if (frontmatter.topic === sanitizedTopic) { - return; - } - frontmatter.topic = sanitizedTopic; - }); - + await patchFrontmatter(this.app, file.path, { topic: topic.trim() }); logInfo(`[ChatPersistenceManager] Applied AI topic to chat file: ${file.path}`); } catch (error) { logError("[ChatPersistenceManager] Error applying AI topic to file:", error); diff --git a/src/main.ts b/src/main.ts index 4085e1a2..a9be4950 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,7 +58,6 @@ import { Platform, Plugin, TFile, - TFolder, WorkspaceLeaf, } from "obsidian"; import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; @@ -68,6 +67,7 @@ import { extractChatTitle, } from "@/utils/chatHistoryUtils"; import { RecentUsageManager } from "@/utils/recentUsageManager"; +import { listMarkdownFiles, patchFrontmatter, resolveFileByPath } from "@/utils/vaultAdapterUtils"; import { v4 as uuidv4 } from "uuid"; // Removed unused FileTrackingState interface @@ -633,13 +633,8 @@ export default class CopilotPlugin extends Plugin { } async getChatHistoryFiles(): Promise { - const folder = this.app.vault.getAbstractFileByPath(getSettings().defaultSaveFolder); - if (!(folder instanceof TFolder)) { - return []; - } - - const files = this.app.vault.getMarkdownFiles(); - const folderFiles = files.filter((file) => file.path.startsWith(folder.path)); + const folderFiles = await listMarkdownFiles(this.app, getSettings().defaultSaveFolder); + if (folderFiles.length === 0) return []; // Get current project ID if in a project const currentProject = getCurrentProject(); @@ -704,26 +699,29 @@ export default class CopilotPlugin extends Plugin { return; } - if (!this.app.fileManager?.processFrontMatter) { - return; - } - let persistedAtMs = timestampToPersist; - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - // Monotonic protection: ensure we never write an older timestamp - const existingValue = Number(frontmatter.lastAccessedAt); - const existingAtMs = - Number.isFinite(existingValue) && existingValue > 0 ? existingValue : 0; + if ( + this.app.fileManager?.processFrontMatter && + this.app.vault.getAbstractFileByPath(file.path) != null + ) { + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + // Monotonic protection: ensure we never write an older timestamp + const existingValue = Number(frontmatter.lastAccessedAt); + const existingAtMs = + Number.isFinite(existingValue) && existingValue > 0 ? existingValue : 0; - persistedAtMs = Math.max(existingAtMs, timestampToPersist); + persistedAtMs = Math.max(existingAtMs, timestampToPersist); - if (existingAtMs === persistedAtMs) { - return; - } + if (existingAtMs === persistedAtMs) { + return; + } - frontmatter.lastAccessedAt = persistedAtMs; - }); + frontmatter.lastAccessedAt = persistedAtMs; + }); + } else { + await patchFrontmatter(this.app, file.path, { lastAccessedAt: persistedAtMs }); + } // Mark persistence successful for throttling purposes this.chatHistoryLastAccessedAtManager.markPersisted(file.path, persistedAtMs); @@ -766,8 +764,8 @@ export default class CopilotPlugin extends Plugin { } async loadChatById(fileId: string): Promise { - const file = this.app.vault.getAbstractFileByPath(fileId); - if (file instanceof TFile) { + const file = await resolveFileByPath(this.app, fileId); + if (file) { await this.loadChatHistory(file); } else { throw new Error("Chat file not found."); @@ -778,6 +776,10 @@ export default class CopilotPlugin extends Plugin { const file = this.app.vault.getAbstractFileByPath(fileId); if (file instanceof TFile) { await this.app.workspace.getLeaf(true).openFile(file); + } else 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." + ); } else { throw new Error("Chat file not found."); } @@ -812,6 +814,9 @@ export default class CopilotPlugin extends Plugin { }, 500); // Reduced timeout for better performance }); + new Notice("Chat title updated."); + } else if (await resolveFileByPath(this.app, fileId)) { + await patchFrontmatter(this.app, fileId, { topic: newTitle.trim() }); new Notice("Chat title updated."); } else { throw new Error("Chat file not found."); @@ -823,6 +828,9 @@ export default class CopilotPlugin extends Plugin { if (file instanceof TFile) { await this.app.vault.delete(file); new Notice("Chat deleted."); + } else if (await this.app.vault.adapter.exists(fileId)) { + await this.app.vault.adapter.remove(fileId); + new Notice("Chat deleted."); } else { throw new Error("Chat file not found."); } diff --git a/src/utils/vaultAdapterUtils.ts b/src/utils/vaultAdapterUtils.ts new file mode 100644 index 00000000..cfb746f8 --- /dev/null +++ b/src/utils/vaultAdapterUtils.ts @@ -0,0 +1,134 @@ +import { App, TFile, TFolder } from "obsidian"; + +/** + * Resolve a file path to a TFile, with adapter fallback for hidden directories. + * Returns a real TFile if in vault cache, a synthetic TFile if only on disk, or null. + */ +export async function resolveFileByPath(app: App, filePath: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (file) return file as TFile; + + if (await app.vault.adapter.exists(filePath)) { + return createSyntheticTFile(app, filePath); + } + + return null; +} + +/** + * Check if a file path points to a real vault-cached file (not a hidden directory file). + */ +export function isInVaultCache(app: App, filePath: string): boolean { + return app.vault.getAbstractFileByPath(filePath) != null; +} + +/** + * List markdown files in a folder, with adapter fallback for hidden directories. + */ +export async function listMarkdownFiles(app: App, folderPath: string): Promise { + const folder = app.vault.getAbstractFileByPath(folderPath); + if (folder instanceof TFolder) { + return app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder.path)); + } + + if (await app.vault.adapter.exists(folderPath)) { + const listing = await app.vault.adapter.list(folderPath); + const mdPaths = listing.files.filter((f) => f.endsWith(".md")); + const result: TFile[] = []; + for (const filePath of mdPaths) { + result.push(await createSyntheticTFile(app, filePath)); + } + return result; + } + + return []; +} + +/** + * Patch frontmatter fields in a file, with adapter fallback for hidden directories. + * Uses processFrontMatter for vault-cached files, regex-based YAML patching otherwise. + */ +export async function patchFrontmatter( + app: App, + filePath: string, + updates: Record +): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + + if (file && app.fileManager?.processFrontMatter) { + await app.fileManager.processFrontMatter(file as TFile, (frontmatter) => { + for (const [key, value] of Object.entries(updates)) { + frontmatter[key] = value; + } + }); + return; + } + + if (!(await app.vault.adapter.exists(filePath))) return; + + const raw = await app.vault.adapter.read(filePath); + const updated = raw.replace( + /^(---\n[\s\S]*?)(---)/, + (_match, yamlBlock: string, closing: string) => { + let patched = yamlBlock; + for (const [key, value] of Object.entries(updates)) { + const formattedValue = + typeof value === "string" + ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + : String(value); + const fieldRegex = new RegExp(`^${key}:\\s*.+`, "m"); + if (fieldRegex.test(patched)) { + patched = patched.replace(fieldRegex, `${key}: ${formattedValue}`); + } else { + patched += `${key}: ${formattedValue}\n`; + } + } + return patched + closing; + } + ); + + if (updated !== raw) { + await app.vault.adapter.write(filePath, updated); + } +} + +/** + * Read frontmatter key-value pairs from a file via adapter. + * Used when metadataCache returns null (hidden directory files). + * Returns null if file has no frontmatter block. + */ +export async function readFrontmatterViaAdapter( + app: App, + filePath: string +): Promise | null> { + const raw = await app.vault.adapter.read(filePath); + const yaml = raw.match(/^---\n([\s\S]*?)\n---/)?.[1]; + if (!yaml) return null; + + const result: Record = {}; + for (const line of yaml.split("\n")) { + const match = line.match(/^(\w+):\s*(.+)/); + if (match) { + result[match[1]] = match[2].trim().replace(/^["']|["']$/g, ""); + } + } + return result; +} + +/** + * Create a synthetic TFile object from adapter stat data. + * Used for files in hidden directories not indexed by Obsidian's vault cache. + */ +async function createSyntheticTFile(app: App, filePath: string): Promise { + const stat = await app.vault.adapter.stat(filePath); + const name = filePath.split("/").pop() ?? ""; + return { + path: filePath, + name, + basename: name.replace(/\.md$/, ""), + extension: "md", + stat: stat ?? { ctime: Date.now(), mtime: Date.now(), size: 0 }, + vault: app.vault, + parent: null, + } as unknown as TFile; +}