mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
refactor(router): extract vault operation into operations/ module (ADR-202, #199 stage 1)
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.
This commit is contained in:
parent
759ad09a9d
commit
c2d6f8b325
6 changed files with 1150 additions and 1253 deletions
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
23
src/semantic/operations/router-context.ts
Normal file
23
src/semantic/operations/router-context.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
34
src/semantic/operations/shared.ts
Normal file
34
src/semantic/operations/shared.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
948
src/semantic/operations/vault.ts
Normal file
948
src/semantic/operations/vault.ts
Normal file
|
|
@ -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<unknown> {
|
||||
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<unknown> {
|
||||
// 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<unknown> {
|
||||
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 ? '...' : ''}`
|
||||
}] : [])
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue