From c2d6f8b325d445a318962d433838fd21fbf5d22b Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Mon, 18 May 2026 23:07:40 -0500 Subject: [PATCH 1/2] refactor(router): extract vault operation into operations/ module (ADR-202, #199 stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router.ts was a 2,228-line monolith (~2.8x the 800-line threshold). Extract the largest unit — executeVaultOperation + its helpers — into src/semantic/operations/vault.ts behind a RouterContext interface. - operations/router-context.ts: RouterContext (api/app/fragmentRetriever/ validator); SemanticRouter implements it and passes itself, so shared state propagates with no indirection - operations/shared.ts: Params, SearchResultItem, paramStr/Num/Bool (router-private → shared by all handlers) - operations/vault.ts: executeVaultOperation + splitContent/sortFiles/ copyFile/copyDirectoryRecursive; mechanical this.→ctx. (tsc is the safety net — a missed rewrite fails compile) - router fields api/app/fragmentRetriever/validator → public readonly - removed a fully unreachable pre-existing dead subtree (combineSearchResults, isDirectory, performFileBasedSearch, indexVaultFiles, getSearchWorkflowHints, extractContext, getFileType — zero call sites anywhere; eslint doesn't flag unused class methods, which hid them). ~290 dead lines gone. router.ts 2228→988; vault.ts 948. Behaviour-preserving: tsc clean, lint at the exact pre-existing baseline (0 errors, 5 unrelated warnings), 235/235 tests unchanged. Staged per the issue's "extract vault first in isolation" guidance: #199 stays open for the remaining handlers (edit/view/graph/system/bases → router thin dispatcher <300 lines). ADR-202. --- docs/architecture/INDEX.md | 1 + ...ter-monolith-into-per-operation-modules.md | 131 ++ src/semantic/operations/router-context.ts | 23 + src/semantic/operations/shared.ts | 34 + src/semantic/operations/vault.ts | 948 ++++++++++++ src/semantic/router.ts | 1266 +---------------- 6 files changed, 1150 insertions(+), 1253 deletions(-) create mode 100644 docs/architecture/tools/ADR-202-split-semantic-router-monolith-into-per-operation-modules.md create mode 100644 src/semantic/operations/router-context.ts create mode 100644 src/semantic/operations/shared.ts create mode 100644 src/semantic/operations/vault.ts diff --git a/docs/architecture/INDEX.md b/docs/architecture/INDEX.md index 1882cbf..f3da71a 100644 --- a/docs/architecture/INDEX.md +++ b/docs/architecture/INDEX.md @@ -35,6 +35,7 @@ _MCP tool design, semantic operations, graph operations, formatters_ |-----|-------|--------| | [ADR-200](./tools/ADR-200-close-cli-parity-gaps-add-daily-notes-tasks-templates-and-properties-operations.md) | Close CLI parity gaps — add daily notes, tasks, templates, and properties operations | Draft | | [ADR-201](./tools/ADR-201-replace-new-function-with-a-sandboxed-expression-evaluator-for-bases.md) | Replace new Function with a sandboxed expression evaluator for Bases | Accepted | +| [ADR-202](./tools/ADR-202-split-semantic-router-monolith-into-per-operation-modules.md) | Split semantic router monolith into per-operation modules | Accepted | ## Delivery _Build, release, versioning, community plugin submission_ diff --git a/docs/architecture/tools/ADR-202-split-semantic-router-monolith-into-per-operation-modules.md b/docs/architecture/tools/ADR-202-split-semantic-router-monolith-into-per-operation-modules.md new file mode 100644 index 0000000..e87e658 --- /dev/null +++ b/docs/architecture/tools/ADR-202-split-semantic-router-monolith-into-per-operation-modules.md @@ -0,0 +1,131 @@ +--- +status: Accepted +date: 2026-05-18 +deciders: + - aaronsb + - claude +related: [] +--- + +# ADR-202: Split semantic router monolith into per-operation modules + +## Context + +`src/semantic/router.ts` had grown to **2,228 lines** — ~2.8× the project's +800-line "split before adding more" quality threshold (#199). It was already +oversized before the May 2026 contributor-PR work; every new operation or +action grew it further (the #139 per-file-lock change had to be threaded into +an already-2,200-line file because there was no smaller home). + +`SemanticRouter.executeOperation()` is a clean dispatcher to per-operation +handlers (`executeVaultOperation`, `executeEditOperation`, …). These are +natural module seams and already match the `src/semantic/operations/` layout +CLAUDE.md prescribes. `executeVaultOperation` plus its vault-private helpers +is by far the largest single unit (~the bulk of the file). + +CLAUDE.md calls the `IObsidianAPI`/router abstraction the architectural +cornerstone, so this is a deliberate, mechanical-but-large refactor that must +be staged and behaviour-preserving, not bundled into a feature PR. + +## Decision + +Introduce a **`RouterContext` interface** (the dependency surface a router +exposes to extracted handlers) and extract per-operation handlers into +`src/semantic/operations/*.ts` as free functions +`executeXOperation(ctx: RouterContext, action, params)`. `SemanticRouter +implements RouterContext` and passes **itself** as the context, so shared +state and mutations propagate without getter/setter indirection. +`executeOperation` becomes a thin dispatch (`return +executeVaultOperation(this, action, params)`). + +**This PR is the first, focused stage** (the issue's own recommendation — +"`executeVaultOperation` is the biggest single win and could be extracted +first in isolation"): + +- New `operations/router-context.ts` — minimal `RouterContext` (the four + members the vault path touches: `api`, `app?`, `fragmentRetriever`, + `validator`); grows as further handlers are extracted. +- New `operations/shared.ts` — `Params`, `SearchResultItem`, and the + `paramStr/paramNum/paramBool` helpers, previously router-private and needed + by every handler. +- New `operations/vault.ts` — `executeVaultOperation` + its live helpers + (`splitContent`, `sortFiles`, `copyFile`, `copyDirectoryRecursive`), + mechanically transformed (`this.` → `ctx.`; the TypeScript compiler is the + safety net — a missed rewrite is a compile error in a free function). +- The router's relevant fields (`api`, `app`, `fragmentRetriever`, + `validator`) are made `public readonly` to satisfy `RouterContext`. This + widens visibility on the cornerstone class; the interface is the explicit + boundary and the cost is accepted. +- **Pre-existing dead code removed.** Extraction surfaced a fully + unreachable file-search subtree — `combineSearchResults`, `isDirectory`, + `performFileBasedSearch`, `indexVaultFiles`, and transitively + `getSearchWorkflowHints`, `extractContext`, `getFileType` — private methods + with **zero call sites** in the original `router.ts` or anywhere in + `src/`/`tests/` (eslint's `no-unused-vars` does not flag unused class + methods, which is why they survived). Removing unreachable code is + behaviour-preserving by definition; ~290 lines eliminated outright. + +Result: `router.ts` **2,228 → 988 lines**; `vault.ts` 948; `make check` +green with the **exact** pre-existing warning baseline (0 errors, 5 +unrelated `prefer-active-doc` warnings) and **235/235** tests unchanged. + +### Scope and staging + +`router.ts` at 988 lines is **not yet under the 800-line acceptance**; +`vault.ts` at 948 is itself over 800. Both are accepted for this stage: + +- Faithful to the issue's "extract vault first in isolation" guidance and + to keeping one PR mechanically reviewable. +- `vault.ts` maps exactly to the `operations/vault.ts` seam CLAUDE.md + prescribes; `executeVaultOperation` is inherently the largest operation. + Sub-splitting vault *by action* is a possible later refinement, not this + ADR's seam. +- #199 stays open with a follow-up: extract `edit`/`view`/`graph`/ + `system`/`bases` the same way (router → thin dispatcher, < ~300 lines; + each `operations/*.ts` independently testable). The acceptance closes when + that lands. + +## Consequences + +### Positive + +- Removes the single worst quality-threshold violation in the codebase and + ~290 lines of provably dead code. +- Establishes the reusable `RouterContext` + free-function pattern; each + subsequent handler extraction is now mechanical and low-risk. +- `vault.ts` is independently testable without constructing the whole + router. +- No behaviour change: `tsc` proves the mechanical rewrite; the full suite + is unchanged at 235/235. + +### Negative + +- Widens visibility of four router fields from `private` to `public + readonly` (mitigated: `RouterContext` is the declared, narrow boundary; + `readonly` prevents external mutation). +- Two-stage (or more) resolution of #199 — the acceptance is not met by + this PR alone. Accepted in exchange for reviewability and the issue's + explicit "not bundled / extract vault first" guidance. + +### Neutral + +- `operations/shared.ts` now owns param helpers; `router.ts` and future + operation modules import them rather than each redefining. +- Follow-up PRs extend `RouterContext` with the members the remaining + handlers touch (e.g. `context`, `tokenManager`, graph tools). + +## Alternatives Considered + +- **One PR extracting all seven handlers.** Rejected: same volume of change + but far worse reviewability ("trust me" vs. a readable mechanical diff); + contradicts the issue's "not bundled / first in isolation" guidance. +- **Mixin / prototype assignment to split the class across files.** + Rejected: fights TypeScript's type system, loses the compile-time safety + net that makes the `this.`→`ctx.` rewrite verifiable. +- **Thin delegating wrapper methods that call module functions.** Rejected: + adds lines back to `router.ts`, working against the threshold goal for no + structural benefit. +- **Carry the dead code into `vault.ts` unchanged.** Rejected: it would add + ~290 lines and 13 lint warnings to a fresh module; the code is provably + unreachable so deletion is behaviour-preserving and the honest result of + the move. diff --git a/src/semantic/operations/router-context.ts b/src/semantic/operations/router-context.ts new file mode 100644 index 0000000..a577401 --- /dev/null +++ b/src/semantic/operations/router-context.ts @@ -0,0 +1,23 @@ +/** + * RouterContext — the dependency surface a SemanticRouter exposes to the + * extracted per-operation modules (ADR-202). + * + * `SemanticRouter implements RouterContext`, and the router instance itself + * is passed as the context, so mutations (e.g. of shared state) propagate + * back to the router without getter/setter indirection. + * + * This interface intentionally starts minimal (the members the extracted + * vault operation touches) and grows as further handlers are extracted in + * follow-up PRs. + */ +import { App } from 'obsidian'; +import { ObsidianAPI } from '../../utils/obsidian-api'; +import { UniversalFragmentRetriever } from '../../indexing/fragment-retriever'; +import { InputValidator } from '../../validation/input-validator'; + +export interface RouterContext { + readonly api: ObsidianAPI; + readonly app?: App; + readonly fragmentRetriever: UniversalFragmentRetriever; + readonly validator: InputValidator; +} diff --git a/src/semantic/operations/shared.ts b/src/semantic/operations/shared.ts new file mode 100644 index 0000000..0b2979b --- /dev/null +++ b/src/semantic/operations/shared.ts @@ -0,0 +1,34 @@ +/** + * Shared types and param helpers for the semantic operation modules + * (extracted from router.ts — ADR-202). + */ + +/** Type alias for operation parameters passed through the semantic router */ +export type Params = Record; + +/** Search result item from vault search */ +export interface SearchResultItem { + path: string; + title?: string; + score?: number; + type?: string; + context?: string; +} + +/** Helper to safely extract a string from params */ +export function paramStr(params: Params, key: string): string | undefined { + const val = params[key]; + return typeof val === 'string' ? val : undefined; +} + +/** Helper to safely extract a number from params */ +export function paramNum(params: Params, key: string): number | undefined { + const val = params[key]; + return typeof val === 'number' ? val : undefined; +} + +/** Helper to safely extract a boolean from params */ +export function paramBool(params: Params, key: string): boolean | undefined { + const val = params[key]; + return typeof val === 'boolean' ? val : undefined; +} diff --git a/src/semantic/operations/vault.ts b/src/semantic/operations/vault.ts new file mode 100644 index 0000000..e04b60c --- /dev/null +++ b/src/semantic/operations/vault.ts @@ -0,0 +1,948 @@ +/** + * Vault operation handler — extracted from router.ts (ADR-202, #199). + * + * Behaviour-preserving move of SemanticRouter.executeVaultOperation and its + * vault-private helpers. The router passes itself as the RouterContext, so + * `ctx.api`/`ctx.app`/etc. are the same instances as before. + */ +import { Debug } from '../../utils/debug'; +import { isImageFile, ObsidianFileResponse } from '../../types/obsidian'; +import { readFileWithFragments } from '../../utils/file-reader'; +import { ValidationException } from '../../validation/input-validator'; +import { RouterContext } from './router-context'; +import { Params, paramStr, paramNum, paramBool } from './shared'; + +export async function executeVaultOperation(ctx: RouterContext, action: string, params: Params): Promise { + switch (action) { + case 'list': { + // Translate "/" to undefined for root directory + const dirParam = paramStr(params, 'directory'); + const directory = dirParam === '/' ? undefined : dirParam; + + // Use paginated list if page parameters are provided. + // When paginating inside a specific directory, recurse so the + // agent sees the same universe of files as the non-paginated + // call — page N gives the Nth slice of the recursive listing, + // not a level-only folder enumeration. The root case is + // already recursive (getAllLoadedFiles), so we leave + // recursive=false there; listFilesPaginated routes through + // the same path regardless. + if (params.page || params.pageSize) { + // MCP clients send these as JSON numbers; paramStr returns undefined + // for non-strings, so parseInt(paramStr(...) ?? '1') silently + // collapsed to defaults and made pagination a no-op. + const page = paramNum(params, 'page') ?? 1; + const pageSize = paramNum(params, 'pageSize') ?? 20; + const recursive = directory !== undefined; + return await ctx.api.listFilesPaginated(directory, page, pageSize, recursive); + } + + // Fallback to simple list for backwards compatibility + return await ctx.api.listFiles(directory); + } + case 'read': { + const path = paramStr(params, 'path') ?? ''; + const strategy = paramStr(params, 'strategy') as 'auto' | 'adaptive' | 'proximity' | 'semantic' | undefined; + return await readFileWithFragments(ctx.api, ctx.fragmentRetriever, { + path, + returnFullFile: paramBool(params, 'returnFullFile'), + query: paramStr(params, 'query'), + strategy, + maxFragments: paramNum(params, 'maxFragments') + }); + } + case 'fragments': { + // Dedicated fragment search across multiple files + const fragmentQuery = paramStr(params, 'query') ?? paramStr(params, 'path') ?? ''; + + // Skip indexing if no query provided + if (!fragmentQuery || fragmentQuery.trim().length === 0) { + return { + result: [], + context: { + operation: 'vault', + action: 'fragments', + error: 'No query provided for fragment search' + } + }; + } + + try { + // Only index files that match the query to avoid indexing entire vault + // This is a lazy indexing approach - index on demand + const searchResults = await ctx.api.searchPaginated(fragmentQuery, 1, 20, 'combined', false); + + // Index only the files that match the search + if (searchResults && searchResults.results && searchResults.results.length > 0) { + for (const result of searchResults.results.slice(0, 20)) { // Limit to first 20 files + try { + const filePath = result.path; + if (filePath && filePath.endsWith('.md')) { + const fileResponse = await ctx.api.getFile(filePath); + let content: string; + + if (typeof fileResponse === 'string') { + content = fileResponse; + } else if (fileResponse && typeof fileResponse === 'object' && 'content' in fileResponse) { + content = fileResponse.content; + } else { + continue; + } + + const docId = `file:${filePath}`; + ctx.fragmentRetriever.indexDocument(docId, filePath, content); + } + } catch (e) { + // Skip files that can't be indexed + Debug.log(`Skipping file during fragment indexing:`, e); + } + } + } + + // Search for fragments in indexed documents + const fragmentResponse = ctx.fragmentRetriever.retrieveFragments(fragmentQuery, { + strategy: (paramStr(params, 'strategy') as 'auto' | 'adaptive' | 'proximity' | 'semantic') || 'auto', + maxFragments: paramNum(params, 'maxFragments') || 5 + }); + + return fragmentResponse; + } catch (error) { + Debug.error('Fragment search failed:', error); + return { + result: [], + context: { + operation: 'vault', + action: 'fragments', + error: error instanceof Error ? error.message : String(error) + } + }; + } + } + case 'create': + return await ctx.api.createFile(paramStr(params, 'path') ?? '', paramStr(params, 'content') ?? ''); + case 'update': + return await ctx.api.updateFile(String(params.path), String(params.content)); + case 'delete': + return await ctx.api.deleteFile(String(params.path)); + case 'search': { + // Validate query + const queryStr = paramStr(params, 'query'); + if (!queryStr || queryStr.trim().length === 0) { + return { + query: queryStr || '', + page: 1, + pageSize: 10, + totalResults: 0, + totalPages: 0, + results: [], + method: 'error', + error: 'Search query is required', + hint: 'Please provide a search query. Examples: "keyword", "tag:#example", "file:name.md"' + }; + } + + // Use advanced search with ranking and snippets + try { + // MCP clients send these as JSON numbers — use paramNum, not paramStr. + const page = paramNum(params, 'page') ?? 1; + const pageSize = paramNum(params, 'pageSize') ?? 10; + // Use searchStrategy for search, fall back to strategy for backward compatibility + const strategy = (paramStr(params, 'searchStrategy') || paramStr(params, 'strategy') || 'combined') as 'filename' | 'content' | 'combined'; + const includeContent = params.includeContent !== false; // Default to true + + // Build search options from new parameters + const searchOptions: { + ranked?: boolean; + includeSnippets?: boolean; + snippetLength?: number; + } = {}; + + if (params.ranked !== undefined) { + searchOptions.ranked = Boolean(params.ranked); + } + if (params.includeSnippets !== undefined) { + searchOptions.includeSnippets = Boolean(params.includeSnippets); + } + if (params.snippetLength !== undefined) { + searchOptions.snippetLength = paramNum(params, 'snippetLength') ?? 0; + } + + const searchResults = await ctx.api.searchPaginated( + queryStr, + page, + pageSize, + strategy, + includeContent, + searchOptions + ); + + // Check if results are valid + if (!searchResults || typeof searchResults !== 'object') { + throw new Error('Invalid search response from API'); + } + + return searchResults; + } catch (searchError) { + Debug.error('Search failed:', searchError); + + // Try fallback with basic search strategy + try { + const fallbackResults = await ctx.api.searchPaginated( + queryStr, + 1, + 10, + 'filename', // Use simple filename search as fallback + false // Don't include content to avoid errors + ); + + if (fallbackResults && fallbackResults.results && fallbackResults.results.length > 0) { + return { + ...fallbackResults, + method: 'filename_fallback', + warning: 'Using filename-only search due to advanced search failure' + }; + } + } catch (fallbackError) { + Debug.error('Fallback search also failed:', fallbackError); + } + + // Return error with helpful information + return { + query: queryStr, + page: 1, + pageSize: 10, + totalResults: 0, + totalPages: 0, + results: [], + method: 'error', + error: searchError instanceof Error ? searchError.message : String(searchError), + hint: 'Try simplifying your query or check if the vault is accessible' + }; + } + } + case 'move': { + const path = paramStr(params, 'path'); + const destination = paramStr(params, 'destination'); + const overwrite = paramBool(params, 'overwrite') ?? false; + + if (!path || !destination) { + throw new Error('Both path and destination are required for move operation'); + } + + // Check if source file exists + const sourceFile = await ctx.api.getFile(path); + if (!sourceFile) { + throw new Error(`Source file not found: ${path}`); + } + + // Check if destination already exists + try { + const destFile = await ctx.api.getFile(destination); + if (destFile && !overwrite) { + throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); + } + } catch { + // File doesn't exist, which is what we want + } + + // Directory creation is handled automatically by createFile + + // Use Obsidian's rename method (which handles moves) + if (ctx.app) { + const file = ctx.app.vault.getAbstractFileByPath(path); + if (file && 'extension' in file) { + await ctx.app.fileManager.renameFile(file, destination); + return { + success: true, + oldPath: path, + newPath: destination, + workflow: { + message: `File moved successfully from ${path} to ${destination}`, + suggested_next: [ + { + description: 'View the moved file', + command: `view(action='file', path='${destination}')` + }, + { + description: 'Edit the moved file', + command: `edit(action='window', path='${destination}', oldText='...', newText='...')` + } + ] + } + }; + } + } + + // Fallback: copy and delete + const sourceFileData = await ctx.api.getFile(path); + if (isImageFile(sourceFileData)) { + throw new Error('Cannot move image files using fallback method'); + } + const content = sourceFileData.content; + await ctx.api.createFile(destination, content); + await ctx.api.deleteFile(path); + + return { + success: true, + oldPath: path, + newPath: destination, + workflow: { + message: `File moved successfully from ${path} to ${destination}`, + suggested_next: [ + { + description: 'View the moved file', + command: `view(action='file', path='${destination}')` + }, + { + description: 'Edit the moved file', + command: `edit(action='window', path='${destination}', oldText='...', newText='...')` + } + ] + } + }; + } + + case 'rename': { + const path = paramStr(params, 'path'); + const newName = paramStr(params, 'newName'); + const overwrite = paramBool(params, 'overwrite') ?? false; + + if (!path || !newName) { + throw new Error('Both path and newName are required for rename operation'); + } + + // Check if source file exists + const sourceFile = await ctx.api.getFile(path); + if (!sourceFile) { + throw new Error(`File not found: ${path}`); + } + + // Extract directory from current path + const lastSlash = path.lastIndexOf('/'); + const dir = lastSlash >= 0 ? path.substring(0, lastSlash) : ''; + const newPath = dir ? `${dir}/${newName}` : newName; + + // Check if destination already exists + try { + const destFile = await ctx.api.getFile(newPath); + if (destFile && !overwrite) { + throw new Error(`File already exists: ${newPath}. Set overwrite=true to replace.`); + } + } catch { + // File doesn't exist, which is what we want + } + + // Use Obsidian's rename method + if (ctx.app) { + const file = ctx.app.vault.getAbstractFileByPath(path); + if (file && 'extension' in file) { + await ctx.app.fileManager.renameFile(file, newPath); + return { + success: true, + oldPath: path, + newPath: newPath, + workflow: { + message: `File renamed successfully from ${path} to ${newPath}`, + suggested_next: [ + { + description: 'View the renamed file', + command: `view(action='file', path='${newPath}')` + }, + { + description: 'Edit the renamed file', + command: `edit(action='window', path='${newPath}', oldText='...', newText='...')` + } + ] + } + }; + } + } + + // Fallback: copy and delete + const sourceFileData = await ctx.api.getFile(path); + if (isImageFile(sourceFileData)) { + throw new Error('Cannot rename image files using fallback method'); + } + const content = sourceFileData.content; + await ctx.api.createFile(newPath, content); + await ctx.api.deleteFile(path); + + return { + success: true, + oldPath: path, + newPath: newPath, + workflow: { + message: `File renamed successfully from ${path} to ${newPath}`, + suggested_next: [ + { + description: 'View the renamed file', + command: `view(action='file', path='${newPath}')` + }, + { + description: 'Edit the renamed file', + command: `edit(action='window', path='${newPath}', oldText='...', newText='...')` + } + ] + } + }; + } + + case 'copy': { + const path = paramStr(params, 'path'); + const destination = paramStr(params, 'destination'); + const overwrite = paramBool(params, 'overwrite') ?? false; + + if (!path || !destination) { + throw new Error('Both path and destination are required for copy operation'); + } + + // First try as a file (this will go through security validation) + try { + const sourceFile = await ctx.api.getFile(path); + return await copyFile(ctx, path, destination, overwrite, sourceFile); + } catch { + // If file operation failed, try as directory (this will also go through security validation) + try { + // Test if it's a directory by trying to list its contents + await ctx.api.listFiles(path); + // If listing succeeds, it's a directory + return await copyDirectoryRecursive(ctx, path, destination, overwrite); + } catch { + // Neither file nor directory worked + throw new Error(`Source not found or inaccessible: ${path}`); + } + } + } + + case 'split': { + const path = paramStr(params, 'path'); + const splitBy = paramStr(params, 'splitBy'); + const outputPattern = paramStr(params, 'outputPattern'); + const outputDirectory = paramStr(params, 'outputDirectory'); + + if (!path || !splitBy) { + throw new Error('Both path and splitBy are required for split operation'); + } + + // Get the source file + const sourceFile = await ctx.api.getFile(path); + if (!sourceFile) { + throw new Error(`File not found: ${path}`); + } + + if (isImageFile(sourceFile)) { + throw new Error('Cannot split image files'); + } + + // Split the content + const splitFiles = splitContent(ctx, sourceFile.content, params); + + // Create output files + const createdFiles = []; + const pathParts = path.split('/'); + const filename = pathParts.pop() || ''; + const dir = outputDirectory || pathParts.join('/'); + const [basename, ext] = filename.includes('.') + ? [filename.substring(0, filename.lastIndexOf('.')), filename.substring(filename.lastIndexOf('.'))] + : [filename, '']; + + for (let i = 0; i < splitFiles.length; i++) { + const pattern = outputPattern || '{filename}-{index}{ext}'; + const outputFilename = pattern + .replace('{filename}', basename) + .replace('{index}', String(i + 1).padStart(3, '0')) + .replace('{ext}', ext); + + const outputPath = dir ? `${dir}/${outputFilename}` : outputFilename; + await ctx.api.createFile(outputPath, splitFiles[i].content); + + createdFiles.push({ + path: outputPath, + lines: splitFiles[i].content.split('\n').length, + size: splitFiles[i].content.length + }); + } + + return { + success: true, + sourceFile: path, + createdFiles, + totalFiles: createdFiles.length, + workflow: { + message: `Successfully split ${path} into ${createdFiles.length} files`, + suggested_next: [ + { + description: 'View one of the split files', + command: `view(action='file', path='${createdFiles[0]?.path}')` + }, + { + description: 'List all created files', + command: `vault(action='list', directory='${dir || '.'}')` + }, + { + description: 'Combine files back together', + command: `vault(action='combine', paths=${JSON.stringify(createdFiles.map(f => f.path))}, destination='${path}-combined${ext}')` + } + ] + } + }; + } + + case 'combine': { + const paths = params.paths as string[] | undefined; + const destination = paramStr(params, 'destination'); + const separator = paramStr(params, 'separator') ?? '\n\n---\n\n'; + const includeFilenames = paramBool(params, 'includeFilenames') ?? false; + const overwrite = paramBool(params, 'overwrite') ?? false; + const sortBy = paramStr(params, 'sortBy'); + const sortOrder = paramStr(params, 'sortOrder') ?? 'asc'; + + // Validate batch operation + const validationResult = ctx.validator.validate('batch.combine', { paths, path: destination }); + if (!validationResult.valid) { + throw new ValidationException( + validationResult.errors || [], + `Validation failed for combine: ${validationResult.errors?.map(e => e.message).join(', ')}` + ); + } + + if (!paths || !Array.isArray(paths) || paths.length === 0) { + throw new Error('paths array is required for combine operation'); + } + + // When a destination is given, refuse to clobber it unless overwrite. + // When omitted, the combined content is returned inline (no write) — + // see the inline branch below. + if (destination) { + try { + const destFile = await ctx.api.getFile(destination); + if (destFile && !overwrite) { + throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); + } + } catch { + // File doesn't exist, which is what we want + } + } + + // Validate and get all source files + const sourceFiles = []; + for (const path of paths) { + const file = await ctx.api.getFile(path); + if (!file) { + throw new Error(`File not found: ${path}`); + } + if (isImageFile(file)) { + throw new Error(`Cannot combine image files: ${path}`); + } + sourceFiles.push({ path, content: file.content }); + } + + // Sort files if requested + if (sortBy) { + sortFiles(ctx, sourceFiles, sortBy, sortOrder); + } + + // Combine content + const combinedContent = []; + for (const file of sourceFiles) { + if (includeFilenames) { + const filename = file.path.split('/').pop() || file.path; + combinedContent.push(`# ${filename}`); + combinedContent.push(''); + } + combinedContent.push(file.content); + } + + const finalContent = combinedContent.join(separator); + + // No destination → return the combined content inline without writing + // to the vault. Lets read-only consumers use combine for multi-file + // retrieval with no side effects. + if (!destination) { + return { + success: true, + inline: true, + content: finalContent, + filesCombined: paths.length, + totalSize: finalContent.length, + // Reflect the order the content was actually combined in + // (sortFiles mutates sourceFiles in place when sortBy is set), + // so consumers can map sections back to files correctly. + sourceFiles: sourceFiles.map(f => f.path), + workflow: { + message: `Combined ${paths.length} files inline (no file written)`, + suggested_next: [ + { + description: 'Save the combined content to a file', + command: `vault(action='combine', paths=${JSON.stringify(paths)}, destination='combined.md')` + } + ] + } + }; + } + + // Create or update destination file + if (overwrite) { + await ctx.api.updateFile(destination, finalContent); + } else { + await ctx.api.createFile(destination, finalContent); + } + + return { + success: true, + destination, + filesCombined: paths.length, + totalSize: finalContent.length, + workflow: { + message: `Successfully combined ${paths.length} files into ${destination}`, + suggested_next: [ + { + description: 'View the combined file', + command: `view(action='file', path='${destination}')` + }, + { + description: 'Edit the combined file', + command: `edit(action='window', path='${destination}', oldText='...', newText='...')` + }, + { + description: 'Split the file back into parts', + command: `vault(action='split', path='${destination}', splitBy='delimiter', delimiter='${separator}')` + } + ] + } + }; + } + + case 'concatenate': { + const path1 = paramStr(params, 'path1'); + const path2 = paramStr(params, 'path2'); + const concatDest = paramStr(params, 'destination'); + const mode = paramStr(params, 'mode') ?? 'append'; + + if (!path1 || !path2) { + throw new Error('Both path1 and path2 are required for concatenate operation'); + } + + // Determine paths and destination based on mode + const concatPaths = mode === 'prepend' ? [path2, path1] : [path1, path2]; + const dest = concatDest || (mode === 'new' ? `${path1}-concatenated` : path1); + + // Use combine operation internally + return executeVaultOperation(ctx, 'combine', { + paths: concatPaths, + destination: dest, + separator: '\n\n', + overwrite: mode !== 'new', + includeFilenames: false + }); + } + + default: + throw new Error(`Unknown vault action: ${action}`); + } + } + +function splitContent(ctx: RouterContext, content: string, params: Params): Array<{ content: string }> { + const splitBy = paramStr(params, 'splitBy'); + const delimiter = paramStr(params, 'delimiter'); + const level = paramNum(params, 'level'); + const linesPerFile = paramNum(params, 'linesPerFile'); + const maxSize = paramNum(params, 'maxSize'); + const splitFiles: Array<{ content: string }> = []; + + switch (splitBy) { + case 'heading': { + // Split by markdown headings + const headingLevel = level || 1; + const headingRegex = new RegExp(`^${'#'.repeat(headingLevel)}\\s+.+$`, 'gm'); + const matches = Array.from(content.matchAll(headingRegex)); + + if (matches.length === 0) { + // No headings found, return original content + return [{ content }]; + } + + // Split content at each heading + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const nextMatch = matches[i + 1]; + const startIndex = match.index || 0; + const endIndex = nextMatch ? nextMatch.index : content.length; + + if (i === 0 && startIndex > 0) { + // Content before first heading + splitFiles.push({ content: content.substring(0, startIndex).trim() }); + } + + const section = content.substring(startIndex, endIndex).trim(); + if (section) { + splitFiles.push({ content: section }); + } + } + break; + } + + case 'delimiter': { + // Split by custom delimiter + const delim = delimiter || '---'; + const parts = content.split(delim); + + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed) { + splitFiles.push({ content: trimmed }); + } + } + break; + } + + case 'lines': { + // Split by line count + const lines = content.split('\n'); + const chunkSize = linesPerFile || 100; + + for (let i = 0; i < lines.length; i += chunkSize) { + const chunk = lines.slice(i, i + chunkSize).join('\n'); + if (chunk.trim()) { + splitFiles.push({ content: chunk }); + } + } + break; + } + + case 'size': { + // Split by character count, preserving word boundaries + const max = maxSize || 10000; + let currentPos = 0; + + while (currentPos < content.length) { + let endPos = Math.min(currentPos + max, content.length); + + // If we're not at the end, try to find a good break point + if (endPos < content.length) { + // Look for paragraph break first + const paragraphBreak = content.lastIndexOf('\n\n', endPos); + if (paragraphBreak > currentPos && paragraphBreak > endPos - 1000) { + endPos = paragraphBreak; + } else { + // Look for line break + const lineBreak = content.lastIndexOf('\n', endPos); + if (lineBreak > currentPos && lineBreak > endPos - 200) { + endPos = lineBreak; + } else { + // Look for sentence end + const sentenceEnd = content.lastIndexOf('. ', endPos); + if (sentenceEnd > currentPos && sentenceEnd > endPos - 100) { + endPos = sentenceEnd + 1; + } else { + // Look for word boundary + const wordBoundary = content.lastIndexOf(' ', endPos); + if (wordBoundary > currentPos) { + endPos = wordBoundary; + } + } + } + } + } + + const chunk = content.substring(currentPos, endPos).trim(); + if (chunk) { + splitFiles.push({ content: chunk }); + } + currentPos = endPos; + + // Skip whitespace at the beginning of next chunk + while (currentPos < content.length && /\s/.test(content[currentPos])) { + currentPos++; + } + } + break; + } + + default: + throw new Error(`Unknown split strategy: ${splitBy}`); + } + + return splitFiles.length > 0 ? splitFiles : [{ content }]; + } + +function sortFiles(ctx: RouterContext, files: Array<{ path: string; content: string }>, sortBy: string, sortOrder: string): void { + // For file metadata, we'd need to use Obsidian's API + // For now, we'll sort by name and size (which we can calculate) + + files.sort((a, b) => { + let compareValue = 0; + + switch (sortBy) { + case 'name': { + const nameA = a.path.split('/').pop() || a.path; + const nameB = b.path.split('/').pop() || b.path; + compareValue = nameA.localeCompare(nameB); + break; + } + + case 'size': + compareValue = a.content.length - b.content.length; + break; + + case 'modified': + case 'created': { + // Would need file stats from Obsidian API + // For now, fall back to name sort + const fallbackA = a.path.split('/').pop() || a.path; + const fallbackB = b.path.split('/').pop() || b.path; + compareValue = fallbackA.localeCompare(fallbackB); + break; + } + + default: + compareValue = 0; + } + + return sortOrder === 'desc' ? -compareValue : compareValue; + }); + } + + /** + * Copy a single file + */ +async function copyFile(ctx: RouterContext, path: string, destination: string, overwrite: boolean, sourceFile: ObsidianFileResponse): Promise { + // Check if destination already exists + try { + const destFile = await ctx.api.getFile(destination); + if (destFile && !overwrite) { + throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); + } + } catch { + // File doesn't exist, which is what we want + } + + // Check for image files + if (isImageFile(sourceFile)) { + throw new Error('Cannot copy image files - use Obsidian file explorer'); + } + + const content = sourceFile.content; + + // Create the copy + if (overwrite) { + await ctx.api.updateFile(destination, content); + } else { + await ctx.api.createFile(destination, content); + } + + return { + success: true, + sourcePath: path, + copiedTo: destination, + workflow: { + message: `File copied successfully from ${path} to ${destination}`, + suggested_next: [ + { + description: 'View the copied file', + command: `view(action='file', path='${destination}')` + }, + { + description: 'Edit the copied file', + command: `edit(action='window', path='${destination}', oldText='...', newText='...')` + }, + { + description: 'Compare original and copy', + command: `view(action='file', path='${path}') then view(action='file', path='${destination}')` + } + ] + } + }; + } + + /** + * Recursively copy a directory and all its contents + */ +async function copyDirectoryRecursive(ctx: RouterContext, sourcePath: string, destPath: string, overwrite: boolean): Promise { + const copiedFiles: string[] = []; + const skippedFiles: string[] = []; + + const copyDir = async (srcDir: string, destDir: string) => { + // Use listFilesPaginated to get both files and directories + const response = await ctx.api.listFilesPaginated(srcDir, 1, 1000); // Get large page to avoid pagination + const items = response.files; + + for (const item of items) { + const srcPath = item.path; + const relativePath = srcPath.startsWith(srcDir + '/') ? srcPath.substring(srcDir.length + 1) : item.name; + const destFilePath = `${destDir}/${relativePath}`; + + if (item.type === 'folder') { + // Subdirectory - recurse + await copyDir(srcPath, destFilePath); + } else { + try { + // File - copy + const sourceFile = await ctx.api.getFile(srcPath); + if (isImageFile(sourceFile)) { + Debug.warn(`Skipping image file: ${srcPath}`); + skippedFiles.push(srcPath); + continue; + } + + // Check destination exists if not overwriting + if (!overwrite) { + try { + await ctx.api.getFile(destFilePath); + throw new Error(`Destination exists: ${destFilePath}. Set overwrite=true to replace.`); + } catch (e: unknown) { + // File doesn't exist - good to proceed + if (e instanceof Error && e.message?.includes('Destination exists')) { + throw e; + } + } + } + + const content = sourceFile.content; + if (overwrite) { + await ctx.api.updateFile(destFilePath, content); + } else { + await ctx.api.createFile(destFilePath, content); + } + copiedFiles.push(destFilePath); + } catch (error: unknown) { + if (error instanceof Error && error.message?.includes('Destination exists')) { + throw error; // Re-throw destination exists errors + } + // Log other errors but continue + const errMsg = error instanceof Error ? error.message : String(error); + Debug.warn(`Failed to copy ${srcPath}: ${errMsg}`); + skippedFiles.push(srcPath); + } + } + } + }; + + await copyDir(sourcePath, destPath); + + return { + success: true, + sourcePath, + destinationPath: destPath, + filesCount: copiedFiles.length, + copiedFiles, + skippedFiles, + workflow: { + message: `Directory copied successfully: ${copiedFiles.length} files from ${sourcePath} to ${destPath}${skippedFiles.length > 0 ? ` (${skippedFiles.length} files skipped)` : ''}`, + suggested_next: [ + { + description: 'List copied directory contents', + command: `vault(action='list', directory='${destPath}')` + }, + { + description: 'View a copied file', + command: `view(action='file', path='${copiedFiles[0] || destPath + '/README.md'}')` + }, + ...(skippedFiles.length > 0 ? [{ + description: 'Review skipped files', + command: `Review skipped files: ${skippedFiles.slice(0, 3).join(', ')}${skippedFiles.length > 3 ? '...' : ''}` + }] : []) + ] + } + }; + } diff --git a/src/semantic/router.ts b/src/semantic/router.ts index 22e9174..6b4f770 100644 --- a/src/semantic/router.ts +++ b/src/semantic/router.ts @@ -1,4 +1,3 @@ -import { Debug } from '../utils/debug'; import { ObsidianAPI } from '../utils/obsidian-api'; import { SemanticResponse, @@ -13,57 +12,31 @@ import { ContentBufferManager } from '../utils/content-buffer'; import { FileLockManager } from '../utils/file-lock'; import { StateTokenManager } from './state-tokens'; import { limitResponse } from '../utils/response-limiter'; -import { isImageFile, ObsidianFileResponse } from '../types/obsidian'; +import { isImageFile } from '../types/obsidian'; import { UniversalFragmentRetriever } from '../indexing/fragment-retriever'; -import { readFileWithFragments } from '../utils/file-reader'; import { GraphSearchTool, GraphSearchParams } from '../tools/graph-search'; import { GraphSearchTool as GraphSearchTraversalTool } from '../tools/graph-search-tool'; import { GraphTagTool } from '../tools/graph-tag-tool'; import { App } from 'obsidian'; -import { InputValidator, ValidationException } from '../validation/input-validator'; +import { InputValidator } from '../validation/input-validator'; import { BaseYAML } from '../types/bases-yaml'; +import { RouterContext } from './operations/router-context'; +import { executeVaultOperation } from './operations/vault'; +import { Params, SearchResultItem, paramStr, paramNum, paramBool } from './operations/shared'; -/** Type alias for operation parameters passed through the semantic router */ -type Params = Record; - -/** Search result item from vault search */ -interface SearchResultItem { - path: string; - title?: string; - score?: number; - type?: string; - context?: string; -} - -/** Helper to safely extract a string from params */ -function paramStr(params: Params, key: string): string | undefined { - const val = params[key]; - return typeof val === 'string' ? val : undefined; -} - -/** Helper to safely extract a number from params */ -function paramNum(params: Params, key: string): number | undefined { - const val = params[key]; - return typeof val === 'number' ? val : undefined; -} - -/** Helper to safely extract a boolean from params */ -function paramBool(params: Params, key: string): boolean | undefined { - const val = params[key]; - return typeof val === 'boolean' ? val : undefined; -} - -export class SemanticRouter { +export class SemanticRouter implements RouterContext { private config!: WorkflowConfig; private context: SemanticContext = {}; - private api: ObsidianAPI; + // Public to satisfy RouterContext — the router passes itself as the + // dependency context to extracted operation modules (ADR-202, #199). + readonly api: ObsidianAPI; private tokenManager: StateTokenManager; - private fragmentRetriever: UniversalFragmentRetriever; + readonly fragmentRetriever: UniversalFragmentRetriever; private graphSearchTool?: GraphSearchTool; private graphSearchTraversalTool?: GraphSearchTraversalTool; private graphTagTool?: GraphTagTool; - private app?: App; - private validator: InputValidator; + readonly app?: App; + readonly validator: InputValidator; constructor(api: ObsidianAPI, app?: App) { this.api = api; @@ -138,7 +111,7 @@ export class SemanticRouter { // Map semantic operations to actual tool calls switch (operation) { case 'vault': - return this.executeVaultOperation(action, params); + return executeVaultOperation(this, action, params); case 'edit': return this.executeEditOperation(action, params); case 'view': @@ -156,1219 +129,6 @@ export class SemanticRouter { } } - private async executeVaultOperation(action: string, params: Params): Promise { - switch (action) { - case 'list': { - // Translate "/" to undefined for root directory - const dirParam = paramStr(params, 'directory'); - const directory = dirParam === '/' ? undefined : dirParam; - - // Use paginated list if page parameters are provided. - // When paginating inside a specific directory, recurse so the - // agent sees the same universe of files as the non-paginated - // call — page N gives the Nth slice of the recursive listing, - // not a level-only folder enumeration. The root case is - // already recursive (getAllLoadedFiles), so we leave - // recursive=false there; listFilesPaginated routes through - // the same path regardless. - if (params.page || params.pageSize) { - // MCP clients send these as JSON numbers; paramStr returns undefined - // for non-strings, so parseInt(paramStr(...) ?? '1') silently - // collapsed to defaults and made pagination a no-op. - const page = paramNum(params, 'page') ?? 1; - const pageSize = paramNum(params, 'pageSize') ?? 20; - const recursive = directory !== undefined; - return await this.api.listFilesPaginated(directory, page, pageSize, recursive); - } - - // Fallback to simple list for backwards compatibility - return await this.api.listFiles(directory); - } - case 'read': { - const path = paramStr(params, 'path') ?? ''; - const strategy = paramStr(params, 'strategy') as 'auto' | 'adaptive' | 'proximity' | 'semantic' | undefined; - return await readFileWithFragments(this.api, this.fragmentRetriever, { - path, - returnFullFile: paramBool(params, 'returnFullFile'), - query: paramStr(params, 'query'), - strategy, - maxFragments: paramNum(params, 'maxFragments') - }); - } - case 'fragments': { - // Dedicated fragment search across multiple files - const fragmentQuery = paramStr(params, 'query') ?? paramStr(params, 'path') ?? ''; - - // Skip indexing if no query provided - if (!fragmentQuery || fragmentQuery.trim().length === 0) { - return { - result: [], - context: { - operation: 'vault', - action: 'fragments', - error: 'No query provided for fragment search' - } - }; - } - - try { - // Only index files that match the query to avoid indexing entire vault - // This is a lazy indexing approach - index on demand - const searchResults = await this.api.searchPaginated(fragmentQuery, 1, 20, 'combined', false); - - // Index only the files that match the search - if (searchResults && searchResults.results && searchResults.results.length > 0) { - for (const result of searchResults.results.slice(0, 20)) { // Limit to first 20 files - try { - const filePath = result.path; - if (filePath && filePath.endsWith('.md')) { - const fileResponse = await this.api.getFile(filePath); - let content: string; - - if (typeof fileResponse === 'string') { - content = fileResponse; - } else if (fileResponse && typeof fileResponse === 'object' && 'content' in fileResponse) { - content = fileResponse.content; - } else { - continue; - } - - const docId = `file:${filePath}`; - this.fragmentRetriever.indexDocument(docId, filePath, content); - } - } catch (e) { - // Skip files that can't be indexed - Debug.log(`Skipping file during fragment indexing:`, e); - } - } - } - - // Search for fragments in indexed documents - const fragmentResponse = this.fragmentRetriever.retrieveFragments(fragmentQuery, { - strategy: (paramStr(params, 'strategy') as 'auto' | 'adaptive' | 'proximity' | 'semantic') || 'auto', - maxFragments: paramNum(params, 'maxFragments') || 5 - }); - - return fragmentResponse; - } catch (error) { - Debug.error('Fragment search failed:', error); - return { - result: [], - context: { - operation: 'vault', - action: 'fragments', - error: error instanceof Error ? error.message : String(error) - } - }; - } - } - case 'create': - return await this.api.createFile(paramStr(params, 'path') ?? '', paramStr(params, 'content') ?? ''); - case 'update': - return await this.api.updateFile(String(params.path), String(params.content)); - case 'delete': - return await this.api.deleteFile(String(params.path)); - case 'search': { - // Validate query - const queryStr = paramStr(params, 'query'); - if (!queryStr || queryStr.trim().length === 0) { - return { - query: queryStr || '', - page: 1, - pageSize: 10, - totalResults: 0, - totalPages: 0, - results: [], - method: 'error', - error: 'Search query is required', - hint: 'Please provide a search query. Examples: "keyword", "tag:#example", "file:name.md"' - }; - } - - // Use advanced search with ranking and snippets - try { - // MCP clients send these as JSON numbers — use paramNum, not paramStr. - const page = paramNum(params, 'page') ?? 1; - const pageSize = paramNum(params, 'pageSize') ?? 10; - // Use searchStrategy for search, fall back to strategy for backward compatibility - const strategy = (paramStr(params, 'searchStrategy') || paramStr(params, 'strategy') || 'combined') as 'filename' | 'content' | 'combined'; - const includeContent = params.includeContent !== false; // Default to true - - // Build search options from new parameters - const searchOptions: { - ranked?: boolean; - includeSnippets?: boolean; - snippetLength?: number; - } = {}; - - if (params.ranked !== undefined) { - searchOptions.ranked = Boolean(params.ranked); - } - if (params.includeSnippets !== undefined) { - searchOptions.includeSnippets = Boolean(params.includeSnippets); - } - if (params.snippetLength !== undefined) { - searchOptions.snippetLength = paramNum(params, 'snippetLength') ?? 0; - } - - const searchResults = await this.api.searchPaginated( - queryStr, - page, - pageSize, - strategy, - includeContent, - searchOptions - ); - - // Check if results are valid - if (!searchResults || typeof searchResults !== 'object') { - throw new Error('Invalid search response from API'); - } - - return searchResults; - } catch (searchError) { - Debug.error('Search failed:', searchError); - - // Try fallback with basic search strategy - try { - const fallbackResults = await this.api.searchPaginated( - queryStr, - 1, - 10, - 'filename', // Use simple filename search as fallback - false // Don't include content to avoid errors - ); - - if (fallbackResults && fallbackResults.results && fallbackResults.results.length > 0) { - return { - ...fallbackResults, - method: 'filename_fallback', - warning: 'Using filename-only search due to advanced search failure' - }; - } - } catch (fallbackError) { - Debug.error('Fallback search also failed:', fallbackError); - } - - // Return error with helpful information - return { - query: queryStr, - page: 1, - pageSize: 10, - totalResults: 0, - totalPages: 0, - results: [], - method: 'error', - error: searchError instanceof Error ? searchError.message : String(searchError), - hint: 'Try simplifying your query or check if the vault is accessible' - }; - } - } - case 'move': { - const path = paramStr(params, 'path'); - const destination = paramStr(params, 'destination'); - const overwrite = paramBool(params, 'overwrite') ?? false; - - if (!path || !destination) { - throw new Error('Both path and destination are required for move operation'); - } - - // Check if source file exists - const sourceFile = await this.api.getFile(path); - if (!sourceFile) { - throw new Error(`Source file not found: ${path}`); - } - - // Check if destination already exists - try { - const destFile = await this.api.getFile(destination); - if (destFile && !overwrite) { - throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); - } - } catch { - // File doesn't exist, which is what we want - } - - // Directory creation is handled automatically by createFile - - // Use Obsidian's rename method (which handles moves) - if (this.app) { - const file = this.app.vault.getAbstractFileByPath(path); - if (file && 'extension' in file) { - await this.app.fileManager.renameFile(file, destination); - return { - success: true, - oldPath: path, - newPath: destination, - workflow: { - message: `File moved successfully from ${path} to ${destination}`, - suggested_next: [ - { - description: 'View the moved file', - command: `view(action='file', path='${destination}')` - }, - { - description: 'Edit the moved file', - command: `edit(action='window', path='${destination}', oldText='...', newText='...')` - } - ] - } - }; - } - } - - // Fallback: copy and delete - const sourceFileData = await this.api.getFile(path); - if (isImageFile(sourceFileData)) { - throw new Error('Cannot move image files using fallback method'); - } - const content = sourceFileData.content; - await this.api.createFile(destination, content); - await this.api.deleteFile(path); - - return { - success: true, - oldPath: path, - newPath: destination, - workflow: { - message: `File moved successfully from ${path} to ${destination}`, - suggested_next: [ - { - description: 'View the moved file', - command: `view(action='file', path='${destination}')` - }, - { - description: 'Edit the moved file', - command: `edit(action='window', path='${destination}', oldText='...', newText='...')` - } - ] - } - }; - } - - case 'rename': { - const path = paramStr(params, 'path'); - const newName = paramStr(params, 'newName'); - const overwrite = paramBool(params, 'overwrite') ?? false; - - if (!path || !newName) { - throw new Error('Both path and newName are required for rename operation'); - } - - // Check if source file exists - const sourceFile = await this.api.getFile(path); - if (!sourceFile) { - throw new Error(`File not found: ${path}`); - } - - // Extract directory from current path - const lastSlash = path.lastIndexOf('/'); - const dir = lastSlash >= 0 ? path.substring(0, lastSlash) : ''; - const newPath = dir ? `${dir}/${newName}` : newName; - - // Check if destination already exists - try { - const destFile = await this.api.getFile(newPath); - if (destFile && !overwrite) { - throw new Error(`File already exists: ${newPath}. Set overwrite=true to replace.`); - } - } catch { - // File doesn't exist, which is what we want - } - - // Use Obsidian's rename method - if (this.app) { - const file = this.app.vault.getAbstractFileByPath(path); - if (file && 'extension' in file) { - await this.app.fileManager.renameFile(file, newPath); - return { - success: true, - oldPath: path, - newPath: newPath, - workflow: { - message: `File renamed successfully from ${path} to ${newPath}`, - suggested_next: [ - { - description: 'View the renamed file', - command: `view(action='file', path='${newPath}')` - }, - { - description: 'Edit the renamed file', - command: `edit(action='window', path='${newPath}', oldText='...', newText='...')` - } - ] - } - }; - } - } - - // Fallback: copy and delete - const sourceFileData = await this.api.getFile(path); - if (isImageFile(sourceFileData)) { - throw new Error('Cannot rename image files using fallback method'); - } - const content = sourceFileData.content; - await this.api.createFile(newPath, content); - await this.api.deleteFile(path); - - return { - success: true, - oldPath: path, - newPath: newPath, - workflow: { - message: `File renamed successfully from ${path} to ${newPath}`, - suggested_next: [ - { - description: 'View the renamed file', - command: `view(action='file', path='${newPath}')` - }, - { - description: 'Edit the renamed file', - command: `edit(action='window', path='${newPath}', oldText='...', newText='...')` - } - ] - } - }; - } - - case 'copy': { - const path = paramStr(params, 'path'); - const destination = paramStr(params, 'destination'); - const overwrite = paramBool(params, 'overwrite') ?? false; - - if (!path || !destination) { - throw new Error('Both path and destination are required for copy operation'); - } - - // First try as a file (this will go through security validation) - try { - const sourceFile = await this.api.getFile(path); - return await this.copyFile(path, destination, overwrite, sourceFile); - } catch { - // If file operation failed, try as directory (this will also go through security validation) - try { - // Test if it's a directory by trying to list its contents - await this.api.listFiles(path); - // If listing succeeds, it's a directory - return await this.copyDirectoryRecursive(path, destination, overwrite); - } catch { - // Neither file nor directory worked - throw new Error(`Source not found or inaccessible: ${path}`); - } - } - } - - case 'split': { - const path = paramStr(params, 'path'); - const splitBy = paramStr(params, 'splitBy'); - const outputPattern = paramStr(params, 'outputPattern'); - const outputDirectory = paramStr(params, 'outputDirectory'); - - if (!path || !splitBy) { - throw new Error('Both path and splitBy are required for split operation'); - } - - // Get the source file - const sourceFile = await this.api.getFile(path); - if (!sourceFile) { - throw new Error(`File not found: ${path}`); - } - - if (isImageFile(sourceFile)) { - throw new Error('Cannot split image files'); - } - - // Split the content - const splitFiles = this.splitContent(sourceFile.content, params); - - // Create output files - const createdFiles = []; - const pathParts = path.split('/'); - const filename = pathParts.pop() || ''; - const dir = outputDirectory || pathParts.join('/'); - const [basename, ext] = filename.includes('.') - ? [filename.substring(0, filename.lastIndexOf('.')), filename.substring(filename.lastIndexOf('.'))] - : [filename, '']; - - for (let i = 0; i < splitFiles.length; i++) { - const pattern = outputPattern || '{filename}-{index}{ext}'; - const outputFilename = pattern - .replace('{filename}', basename) - .replace('{index}', String(i + 1).padStart(3, '0')) - .replace('{ext}', ext); - - const outputPath = dir ? `${dir}/${outputFilename}` : outputFilename; - await this.api.createFile(outputPath, splitFiles[i].content); - - createdFiles.push({ - path: outputPath, - lines: splitFiles[i].content.split('\n').length, - size: splitFiles[i].content.length - }); - } - - return { - success: true, - sourceFile: path, - createdFiles, - totalFiles: createdFiles.length, - workflow: { - message: `Successfully split ${path} into ${createdFiles.length} files`, - suggested_next: [ - { - description: 'View one of the split files', - command: `view(action='file', path='${createdFiles[0]?.path}')` - }, - { - description: 'List all created files', - command: `vault(action='list', directory='${dir || '.'}')` - }, - { - description: 'Combine files back together', - command: `vault(action='combine', paths=${JSON.stringify(createdFiles.map(f => f.path))}, destination='${path}-combined${ext}')` - } - ] - } - }; - } - - case 'combine': { - const paths = params.paths as string[] | undefined; - const destination = paramStr(params, 'destination'); - const separator = paramStr(params, 'separator') ?? '\n\n---\n\n'; - const includeFilenames = paramBool(params, 'includeFilenames') ?? false; - const overwrite = paramBool(params, 'overwrite') ?? false; - const sortBy = paramStr(params, 'sortBy'); - const sortOrder = paramStr(params, 'sortOrder') ?? 'asc'; - - // Validate batch operation - const validationResult = this.validator.validate('batch.combine', { paths, path: destination }); - if (!validationResult.valid) { - throw new ValidationException( - validationResult.errors || [], - `Validation failed for combine: ${validationResult.errors?.map(e => e.message).join(', ')}` - ); - } - - if (!paths || !Array.isArray(paths) || paths.length === 0) { - throw new Error('paths array is required for combine operation'); - } - - // When a destination is given, refuse to clobber it unless overwrite. - // When omitted, the combined content is returned inline (no write) — - // see the inline branch below. - if (destination) { - try { - const destFile = await this.api.getFile(destination); - if (destFile && !overwrite) { - throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); - } - } catch { - // File doesn't exist, which is what we want - } - } - - // Validate and get all source files - const sourceFiles = []; - for (const path of paths) { - const file = await this.api.getFile(path); - if (!file) { - throw new Error(`File not found: ${path}`); - } - if (isImageFile(file)) { - throw new Error(`Cannot combine image files: ${path}`); - } - sourceFiles.push({ path, content: file.content }); - } - - // Sort files if requested - if (sortBy) { - this.sortFiles(sourceFiles, sortBy, sortOrder); - } - - // Combine content - const combinedContent = []; - for (const file of sourceFiles) { - if (includeFilenames) { - const filename = file.path.split('/').pop() || file.path; - combinedContent.push(`# ${filename}`); - combinedContent.push(''); - } - combinedContent.push(file.content); - } - - const finalContent = combinedContent.join(separator); - - // No destination → return the combined content inline without writing - // to the vault. Lets read-only consumers use combine for multi-file - // retrieval with no side effects. - if (!destination) { - return { - success: true, - inline: true, - content: finalContent, - filesCombined: paths.length, - totalSize: finalContent.length, - // Reflect the order the content was actually combined in - // (sortFiles mutates sourceFiles in place when sortBy is set), - // so consumers can map sections back to files correctly. - sourceFiles: sourceFiles.map(f => f.path), - workflow: { - message: `Combined ${paths.length} files inline (no file written)`, - suggested_next: [ - { - description: 'Save the combined content to a file', - command: `vault(action='combine', paths=${JSON.stringify(paths)}, destination='combined.md')` - } - ] - } - }; - } - - // Create or update destination file - if (overwrite) { - await this.api.updateFile(destination, finalContent); - } else { - await this.api.createFile(destination, finalContent); - } - - return { - success: true, - destination, - filesCombined: paths.length, - totalSize: finalContent.length, - workflow: { - message: `Successfully combined ${paths.length} files into ${destination}`, - suggested_next: [ - { - description: 'View the combined file', - command: `view(action='file', path='${destination}')` - }, - { - description: 'Edit the combined file', - command: `edit(action='window', path='${destination}', oldText='...', newText='...')` - }, - { - description: 'Split the file back into parts', - command: `vault(action='split', path='${destination}', splitBy='delimiter', delimiter='${separator}')` - } - ] - } - }; - } - - case 'concatenate': { - const path1 = paramStr(params, 'path1'); - const path2 = paramStr(params, 'path2'); - const concatDest = paramStr(params, 'destination'); - const mode = paramStr(params, 'mode') ?? 'append'; - - if (!path1 || !path2) { - throw new Error('Both path1 and path2 are required for concatenate operation'); - } - - // Determine paths and destination based on mode - const concatPaths = mode === 'prepend' ? [path2, path1] : [path1, path2]; - const dest = concatDest || (mode === 'new' ? `${path1}-concatenated` : path1); - - // Use combine operation internally - return this.executeVaultOperation('combine', { - paths: concatPaths, - destination: dest, - separator: '\n\n', - overwrite: mode !== 'new', - includeFilenames: false - }); - } - - default: - throw new Error(`Unknown vault action: ${action}`); - } - } - - private combineSearchResults(apiResults: SearchResultItem[], fallbackResults: SearchResultItem[]): SearchResultItem[] { - const combined = [...apiResults]; - const existingPaths = new Set(apiResults.map(r => r.path)); - - // Add fallback results that aren't already in API results - for (const fallbackResult of fallbackResults) { - if (!existingPaths.has(fallbackResult.path)) { - combined.push(fallbackResult); - } - } - - // Sort by score (API results have negative scores, higher is better) - // Fallback results have positive scores, higher is better - return combined.sort((a, b) => { - const scoreA = a.score || 0; - const scoreB = b.score || 0; - - // If both are negative (API results), more negative is better - if (scoreA < 0 && scoreB < 0) { - return scoreA - scoreB; // More negative first - } - - // If both are positive (fallback results), higher is better - if (scoreA > 0 && scoreB > 0) { - return scoreB - scoreA; // Higher first - } - - // Mixed: prioritize API results (negative scores) over fallback (positive scores) - if (scoreA < 0 && scoreB > 0) { - return -1; // API result first - } - if (scoreA > 0 && scoreB < 0) { - return 1; // API result first - } - - return 0; - }); - } - - private splitContent(content: string, params: Params): Array<{ content: string }> { - const splitBy = paramStr(params, 'splitBy'); - const delimiter = paramStr(params, 'delimiter'); - const level = paramNum(params, 'level'); - const linesPerFile = paramNum(params, 'linesPerFile'); - const maxSize = paramNum(params, 'maxSize'); - const splitFiles: Array<{ content: string }> = []; - - switch (splitBy) { - case 'heading': { - // Split by markdown headings - const headingLevel = level || 1; - const headingRegex = new RegExp(`^${'#'.repeat(headingLevel)}\\s+.+$`, 'gm'); - const matches = Array.from(content.matchAll(headingRegex)); - - if (matches.length === 0) { - // No headings found, return original content - return [{ content }]; - } - - // Split content at each heading - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; - const nextMatch = matches[i + 1]; - const startIndex = match.index || 0; - const endIndex = nextMatch ? nextMatch.index : content.length; - - if (i === 0 && startIndex > 0) { - // Content before first heading - splitFiles.push({ content: content.substring(0, startIndex).trim() }); - } - - const section = content.substring(startIndex, endIndex).trim(); - if (section) { - splitFiles.push({ content: section }); - } - } - break; - } - - case 'delimiter': { - // Split by custom delimiter - const delim = delimiter || '---'; - const parts = content.split(delim); - - for (const part of parts) { - const trimmed = part.trim(); - if (trimmed) { - splitFiles.push({ content: trimmed }); - } - } - break; - } - - case 'lines': { - // Split by line count - const lines = content.split('\n'); - const chunkSize = linesPerFile || 100; - - for (let i = 0; i < lines.length; i += chunkSize) { - const chunk = lines.slice(i, i + chunkSize).join('\n'); - if (chunk.trim()) { - splitFiles.push({ content: chunk }); - } - } - break; - } - - case 'size': { - // Split by character count, preserving word boundaries - const max = maxSize || 10000; - let currentPos = 0; - - while (currentPos < content.length) { - let endPos = Math.min(currentPos + max, content.length); - - // If we're not at the end, try to find a good break point - if (endPos < content.length) { - // Look for paragraph break first - const paragraphBreak = content.lastIndexOf('\n\n', endPos); - if (paragraphBreak > currentPos && paragraphBreak > endPos - 1000) { - endPos = paragraphBreak; - } else { - // Look for line break - const lineBreak = content.lastIndexOf('\n', endPos); - if (lineBreak > currentPos && lineBreak > endPos - 200) { - endPos = lineBreak; - } else { - // Look for sentence end - const sentenceEnd = content.lastIndexOf('. ', endPos); - if (sentenceEnd > currentPos && sentenceEnd > endPos - 100) { - endPos = sentenceEnd + 1; - } else { - // Look for word boundary - const wordBoundary = content.lastIndexOf(' ', endPos); - if (wordBoundary > currentPos) { - endPos = wordBoundary; - } - } - } - } - } - - const chunk = content.substring(currentPos, endPos).trim(); - if (chunk) { - splitFiles.push({ content: chunk }); - } - currentPos = endPos; - - // Skip whitespace at the beginning of next chunk - while (currentPos < content.length && /\s/.test(content[currentPos])) { - currentPos++; - } - } - break; - } - - default: - throw new Error(`Unknown split strategy: ${splitBy}`); - } - - return splitFiles.length > 0 ? splitFiles : [{ content }]; - } - - private sortFiles(files: Array<{ path: string; content: string }>, sortBy: string, sortOrder: string): void { - // For file metadata, we'd need to use Obsidian's API - // For now, we'll sort by name and size (which we can calculate) - - files.sort((a, b) => { - let compareValue = 0; - - switch (sortBy) { - case 'name': { - const nameA = a.path.split('/').pop() || a.path; - const nameB = b.path.split('/').pop() || b.path; - compareValue = nameA.localeCompare(nameB); - break; - } - - case 'size': - compareValue = a.content.length - b.content.length; - break; - - case 'modified': - case 'created': { - // Would need file stats from Obsidian API - // For now, fall back to name sort - const fallbackA = a.path.split('/').pop() || a.path; - const fallbackB = b.path.split('/').pop() || b.path; - compareValue = fallbackA.localeCompare(fallbackB); - break; - } - - default: - compareValue = 0; - } - - return sortOrder === 'desc' ? -compareValue : compareValue; - }); - } - - /** - * Copy a single file - */ - private async copyFile(path: string, destination: string, overwrite: boolean, sourceFile: ObsidianFileResponse): Promise { - // Check if destination already exists - try { - const destFile = await this.api.getFile(destination); - if (destFile && !overwrite) { - throw new Error(`Destination already exists: ${destination}. Set overwrite=true to replace.`); - } - } catch { - // File doesn't exist, which is what we want - } - - // Check for image files - if (isImageFile(sourceFile)) { - throw new Error('Cannot copy image files - use Obsidian file explorer'); - } - - const content = sourceFile.content; - - // Create the copy - if (overwrite) { - await this.api.updateFile(destination, content); - } else { - await this.api.createFile(destination, content); - } - - return { - success: true, - sourcePath: path, - copiedTo: destination, - workflow: { - message: `File copied successfully from ${path} to ${destination}`, - suggested_next: [ - { - description: 'View the copied file', - command: `view(action='file', path='${destination}')` - }, - { - description: 'Edit the copied file', - command: `edit(action='window', path='${destination}', oldText='...', newText='...')` - }, - { - description: 'Compare original and copy', - command: `view(action='file', path='${path}') then view(action='file', path='${destination}')` - } - ] - } - }; - } - - /** - * Check if a path is a directory using the paginated listing API that properly identifies folders - */ - private async isDirectory(path: string): Promise { - try { - // Method 1: Use Obsidian's vault API to check if path is a folder - if (this.app) { - const abstractFile = this.app.vault.getAbstractFileByPath(path); - if (abstractFile && 'children' in abstractFile) { - return true; // TFolder has children property - } - } - - // Method 2: Use paginated listing to check if this path exists as a folder - try { - const parentPath = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '.'; - const dirName = path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path; - - // Use paginated listing to get detailed file information including type - const result = await this.api.listFilesPaginated(parentPath === '.' ? undefined : parentPath, 1, 100); - - // Check if any item matches our directory name and has type 'folder' - return result.files.some(file => - file.name === dirName && file.type === 'folder' - ); - } catch { - // Fallback method: try to list the path directly as a directory - try { - await this.api.listFiles(path); - return true; - } catch { - return false; - } - } - } catch { - return false; - } - } - - /** - * Recursively copy a directory and all its contents - */ - private async copyDirectoryRecursive(sourcePath: string, destPath: string, overwrite: boolean): Promise { - const copiedFiles: string[] = []; - const skippedFiles: string[] = []; - - const copyDir = async (srcDir: string, destDir: string) => { - // Use listFilesPaginated to get both files and directories - const response = await this.api.listFilesPaginated(srcDir, 1, 1000); // Get large page to avoid pagination - const items = response.files; - - for (const item of items) { - const srcPath = item.path; - const relativePath = srcPath.startsWith(srcDir + '/') ? srcPath.substring(srcDir.length + 1) : item.name; - const destFilePath = `${destDir}/${relativePath}`; - - if (item.type === 'folder') { - // Subdirectory - recurse - await copyDir(srcPath, destFilePath); - } else { - try { - // File - copy - const sourceFile = await this.api.getFile(srcPath); - if (isImageFile(sourceFile)) { - Debug.warn(`Skipping image file: ${srcPath}`); - skippedFiles.push(srcPath); - continue; - } - - // Check destination exists if not overwriting - if (!overwrite) { - try { - await this.api.getFile(destFilePath); - throw new Error(`Destination exists: ${destFilePath}. Set overwrite=true to replace.`); - } catch (e: unknown) { - // File doesn't exist - good to proceed - if (e instanceof Error && e.message?.includes('Destination exists')) { - throw e; - } - } - } - - const content = sourceFile.content; - if (overwrite) { - await this.api.updateFile(destFilePath, content); - } else { - await this.api.createFile(destFilePath, content); - } - copiedFiles.push(destFilePath); - } catch (error: unknown) { - if (error instanceof Error && error.message?.includes('Destination exists')) { - throw error; // Re-throw destination exists errors - } - // Log other errors but continue - const errMsg = error instanceof Error ? error.message : String(error); - Debug.warn(`Failed to copy ${srcPath}: ${errMsg}`); - skippedFiles.push(srcPath); - } - } - } - }; - - await copyDir(sourcePath, destPath); - - return { - success: true, - sourcePath, - destinationPath: destPath, - filesCount: copiedFiles.length, - copiedFiles, - skippedFiles, - workflow: { - message: `Directory copied successfully: ${copiedFiles.length} files from ${sourcePath} to ${destPath}${skippedFiles.length > 0 ? ` (${skippedFiles.length} files skipped)` : ''}`, - suggested_next: [ - { - description: 'List copied directory contents', - command: `vault(action='list', directory='${destPath}')` - }, - { - description: 'View a copied file', - command: `view(action='file', path='${copiedFiles[0] || destPath + '/README.md'}')` - }, - ...(skippedFiles.length > 0 ? [{ - description: 'Review skipped files', - command: `Review skipped files: ${skippedFiles.slice(0, 3).join(', ')}${skippedFiles.length > 3 ? '...' : ''}` - }] : []) - ] - } - }; - } - - private getFileType(filename: string): string { - const ext = filename.toLowerCase().split('.').pop() || ''; - - // Image formats - if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) { - return 'image'; - } - - // Video formats - if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(ext)) { - return 'video'; - } - - // Audio formats - if (['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'wma'].includes(ext)) { - return 'audio'; - } - - // Document formats - if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) { - return 'document'; - } - - // Text/code formats - if (['md', 'txt', 'json', 'yaml', 'yml', 'js', 'ts', 'py', 'java', 'cpp', 'c', 'h', 'css', 'html', 'xml'].includes(ext)) { - return 'text'; - } - - return 'binary'; - } - - private getSearchWorkflowHints(results: SearchResultItem[]): { available_actions: string[]; note: string } { - const hasEditableFiles = results.some(r => { - const type = r.type || this.getFileType(r.path); - return type === 'text'; - }); - - const availableActions = [ - "view:file", - "view:window", - "view:open_in_obsidian" - ]; - - if (hasEditableFiles) { - availableActions.push("edit:window"); - } - - return { - available_actions: availableActions, - note: hasEditableFiles ? - "Use with paths from results. Edit only for text files." : - "Use with paths from results." - }; - } - - private async performFileBasedSearch(query: string, page: number, pageSize: number, includeContent: boolean = false): Promise { - const lowerQuery = query.toLowerCase(); - const allResults: SearchResultItem[] = []; - - const searchDirectory = async (directory?: string) => { - try { - const files = await this.api.listFiles(directory); - - for (const file of files) { - const filePath = directory ? `${directory}/${file}` : file; - - if (file.endsWith('/')) { - // Recursively search subdirectories - await searchDirectory(filePath.slice(0, -1)); - } else { - try { - // Check filename first (faster) for all files - if (file.toLowerCase().includes(lowerQuery)) { - const isMarkdown = file.endsWith('.md'); - allResults.push({ - path: filePath, - title: isMarkdown ? file.replace('.md', '') : file, - score: 2, // Higher score for filename matches - type: this.getFileType(file) - }); - } else if (includeContent && file.endsWith('.md')) { - // Only read file content if specifically requested - const fileResponse = await this.api.getFile(filePath); - let content: string; - - if (typeof fileResponse === 'string') { - content = fileResponse; - } else if (fileResponse && typeof fileResponse === 'object' && 'content' in fileResponse) { - content = fileResponse.content; - } else { - continue; - } - - if (content.toLowerCase().includes(lowerQuery)) { - const matches = (content.toLowerCase().split(lowerQuery).length - 1); - allResults.push({ - path: filePath, - title: file.replace('.md', ''), - context: this.extractContext(content, query, 150), - score: matches, - type: 'text' - }); - } - } - } catch (e) { - // Skip unreadable files - Debug.warn(`Failed to search file ${filePath}:`, e); - } - } - } - } catch (e) { - // Skip unreadable directories - Debug.warn(`Failed to search directory ${directory}:`, e); - } - }; - - await searchDirectory(); - - // Sort by score - allResults.sort((a, b) => (b.score || 0) - (a.score || 0)); - - // Apply pagination - const totalResults = allResults.length; - const totalPages = Math.ceil(totalResults / pageSize); - const startIndex = (page - 1) * pageSize; - const endIndex = startIndex + pageSize; - - const paginatedResults = allResults.slice(startIndex, endIndex); - - return { - query, - page, - pageSize, - totalResults, - totalPages, - results: paginatedResults, - method: 'fallback', - workflow: this.getSearchWorkflowHints(paginatedResults) - }; - } - - private extractContext(content: string, query: string, maxLength: number = 150): string { - const lowerContent = content.toLowerCase(); - const index = lowerContent.indexOf(query.toLowerCase()); - - if (index === -1) return ''; - - const start = Math.max(0, index - maxLength / 2); - const end = Math.min(content.length, index + query.length + maxLength / 2); - - let context = content.substring(start, end); - if (start > 0) context = '...' + context; - if (end < content.length) context = context + '...'; - - return context.trim(); - } - - private async indexVaultFiles(): Promise { - // Index all markdown files in the vault - const indexDirectory = async (directory?: string) => { - try { - const files = await this.api.listFiles(directory); - - for (const file of files) { - const filePath = directory ? `${directory}/${file}` : file; - - if (file.endsWith('/')) { - // Recursively index subdirectories - await indexDirectory(filePath.slice(0, -1)); - } else if (file.endsWith('.md')) { - try { - const fileResponse = await this.api.getFile(filePath); - let content: string; - - // Handle both string and structured responses - if (typeof fileResponse === 'string') { - content = fileResponse; - } else if (fileResponse && typeof fileResponse === 'object' && 'content' in fileResponse) { - content = fileResponse.content; - } else { - continue; // Skip if we can't extract content - } - - const docId = `file:${filePath}`; - this.fragmentRetriever.indexDocument(docId, filePath, content); - } catch (e) { - // Skip unreadable files - Debug.warn(`Failed to index ${filePath}:`, e); - } - } - } - } catch (e) { - // Skip unreadable directories - Debug.warn(`Failed to index directory ${directory}:`, e); - } - }; - - await indexDirectory(); - } - private async executeEditOperation(action: string, params: Params): Promise { const buffer = ContentBufferManager.getInstance(); From 767bbd7a4939e1c5cfc4aab90fbe080e616001b9 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Mon, 18 May 2026 23:14:12 -0500 Subject: [PATCH 2/2] refactor(router): drop unused ctx param from pure helpers (PR #203 review nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitContent/sortFiles don't touch RouterContext — the uniform ctx-first param was a mechanical-extraction artifact. Removed from both defs and their two call sites. Indentation nit deliberately NOT addressed: the retained class-method indentation is what makes the extraction a byte-verifiable behaviour-preserving move (per the review's normalized diff); reflowing would destroy that property for zero functional gain. tsc clean, lint baseline, 235/235. --- src/semantic/operations/vault.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/semantic/operations/vault.ts b/src/semantic/operations/vault.ts index e04b60c..63b98a8 100644 --- a/src/semantic/operations/vault.ts +++ b/src/semantic/operations/vault.ts @@ -435,7 +435,7 @@ export async function executeVaultOperation(ctx: RouterContext, action: string, } // Split the content - const splitFiles = splitContent(ctx, sourceFile.content, params); + const splitFiles = splitContent(sourceFile.content, params); // Create output files const createdFiles = []; @@ -539,7 +539,7 @@ export async function executeVaultOperation(ctx: RouterContext, action: string, // Sort files if requested if (sortBy) { - sortFiles(ctx, sourceFiles, sortBy, sortOrder); + sortFiles(sourceFiles, sortBy, sortOrder); } // Combine content @@ -642,7 +642,7 @@ export async function executeVaultOperation(ctx: RouterContext, action: string, } } -function splitContent(ctx: RouterContext, content: string, params: Params): Array<{ content: string }> { +function splitContent(content: string, params: Params): Array<{ content: string }> { const splitBy = paramStr(params, 'splitBy'); const delimiter = paramStr(params, 'delimiter'); const level = paramNum(params, 'level'); @@ -766,7 +766,7 @@ function splitContent(ctx: RouterContext, content: string, params: Params): Arra return splitFiles.length > 0 ? splitFiles : [{ content }]; } -function sortFiles(ctx: RouterContext, files: Array<{ path: string; content: string }>, sortBy: string, sortOrder: string): void { +function sortFiles(files: Array<{ path: string; content: string }>, sortBy: string, sortOrder: string): void { // For file metadata, we'd need to use Obsidian's API // For now, we'll sort by name and size (which we can calculate)