Add offset and max_chars parameters to obsidian_read_note: - max_chars (default 50000, max 500000): caps characters returned per call - offset (default 0): start position for reading, enabling pagination When content is truncated a trailer message is appended telling the caller the total size and the exact offset to pass on the next call. This prevents the 26MB+ responses that caused Claude to reject output when reading large PDFs stored in an Obsidian vault. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
170 lines
4.7 KiB
TypeScript
170 lines
4.7 KiB
TypeScript
/**
|
|
* Zod Validation Schemas
|
|
* Constitutional Principle III: Local Execution Security
|
|
* Constitutional Principle IV: Defensive Programming
|
|
*/
|
|
|
|
import { z } from 'zod';
|
|
|
|
/**
|
|
* Common validation patterns
|
|
*/
|
|
|
|
// Vault name validation (alphanumeric, spaces, hyphens, underscores)
|
|
export const vaultNameSchema = z
|
|
.string()
|
|
.min(1, 'Vault name cannot be empty')
|
|
.max(255, 'Vault name too long')
|
|
.regex(/^[a-zA-Z0-9\s_-]+$/, 'Vault name contains invalid characters');
|
|
|
|
// Note name validation (allow most characters, but not path separators)
|
|
export const noteNameSchema = z
|
|
.string()
|
|
.min(1, 'Note name cannot be empty')
|
|
.max(255, 'Note name too long')
|
|
.regex(/^[^/\\]+$/, 'Note name cannot contain path separators');
|
|
|
|
// File path validation (allow subdirectories)
|
|
export const filePathSchema = z
|
|
.string()
|
|
.min(1, 'File path cannot be empty')
|
|
.max(1024, 'File path too long')
|
|
.regex(/^[^<>:"|?*]+$/, 'File path contains invalid characters');
|
|
|
|
// Content validation (allow any string, but limit size)
|
|
export const contentSchema = z
|
|
.string()
|
|
.max(1048576, 'Content too large (max 1MB)'); // 1MB limit
|
|
|
|
// Tag validation (must start with #)
|
|
export const tagSchema = z
|
|
.string()
|
|
.min(2, 'Tag too short')
|
|
.regex(/^#[a-zA-Z0-9_/-]+$/, 'Invalid tag format (must start with # and contain only alphanumeric, _, /, -)');
|
|
|
|
// Property key validation
|
|
export const propertyKeySchema = z
|
|
.string()
|
|
.min(1, 'Property key cannot be empty')
|
|
.max(100, 'Property key too long')
|
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Property key contains invalid characters');
|
|
|
|
// Boolean flag validation
|
|
export const booleanFlagSchema = z
|
|
.union([z.boolean(), z.string()])
|
|
.transform((val) => {
|
|
if (typeof val === 'boolean') return val;
|
|
return val.toLowerCase() === 'true' || val === '1';
|
|
});
|
|
|
|
// Optional string that can be undefined or empty
|
|
export const optionalStringSchema = z.string().optional();
|
|
|
|
// Date string validation (ISO 8601)
|
|
export const dateSchema = z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/, 'Invalid date format (use ISO 8601)');
|
|
|
|
/**
|
|
* Common parameter schemas
|
|
*/
|
|
|
|
// File identifier (either file name or full path)
|
|
export const fileIdentifierSchema = z.object({
|
|
file: z.string().optional(),
|
|
path: z.string().optional(),
|
|
}).refine(
|
|
(data) => data.file || data.path,
|
|
{ message: 'Either file or path must be provided' }
|
|
);
|
|
|
|
// Pagination parameters
|
|
export const paginationSchema = z.object({
|
|
limit: z.number().int().positive().max(1000).optional(),
|
|
offset: z.number().int().nonnegative().optional(),
|
|
});
|
|
|
|
// Format options
|
|
export const formatSchema = z.object({
|
|
format: z.enum(['json', 'tsv', 'csv', 'text']).optional().default('text'),
|
|
});
|
|
|
|
/**
|
|
* Tool-specific schemas
|
|
*/
|
|
|
|
// Create note parameters
|
|
export const createNoteSchema = z.object({
|
|
name: noteNameSchema,
|
|
path: filePathSchema.optional(),
|
|
content: contentSchema.optional(),
|
|
template: z.string().optional(),
|
|
overwrite: booleanFlagSchema.optional(),
|
|
open: booleanFlagSchema.optional(),
|
|
});
|
|
|
|
// Read note parameters
|
|
export const readNoteSchema = z.object({
|
|
file: noteNameSchema.optional(),
|
|
path: filePathSchema.optional(),
|
|
offset: z.number().int().nonnegative().optional().default(0),
|
|
max_chars: z.number().int().positive().max(500000).optional().default(50000),
|
|
}).refine(
|
|
(data) => data.file || data.path,
|
|
{ message: 'Either file or path must be provided' }
|
|
);
|
|
|
|
// Append/Prepend parameters
|
|
export const appendPrependSchema = z.intersection(
|
|
fileIdentifierSchema,
|
|
z.object({
|
|
content: contentSchema,
|
|
inline: booleanFlagSchema.optional(),
|
|
})
|
|
);
|
|
|
|
// Delete note parameters
|
|
export const deleteNoteSchema = z.intersection(
|
|
fileIdentifierSchema,
|
|
z.object({
|
|
permanent: booleanFlagSchema.optional(),
|
|
})
|
|
);
|
|
|
|
// Move/Rename parameters
|
|
export const moveRenameSchema = z.intersection(
|
|
fileIdentifierSchema,
|
|
z.object({
|
|
newPath: filePathSchema.optional(),
|
|
newName: noteNameSchema.optional(),
|
|
})
|
|
).refine(
|
|
(data) => data.newPath || data.newName,
|
|
{ message: 'Either newPath or newName must be provided' }
|
|
);
|
|
|
|
// Search parameters
|
|
export const searchSchema = z.object({
|
|
query: z.string().min(1, 'Search query cannot be empty'),
|
|
path: optionalStringSchema, // Folder path to limit search
|
|
limit: z.number().int().positive().max(1000).optional(),
|
|
total: booleanFlagSchema.optional(), // Return match count instead of files
|
|
case: booleanFlagSchema.optional(), // Case sensitive search
|
|
format: z.enum(['text', 'json']).optional().default('text'),
|
|
});
|
|
|
|
// Tag search parameters
|
|
export const tagSearchSchema = z.object({
|
|
tag: tagSchema,
|
|
...formatSchema.shape,
|
|
});
|
|
|
|
// Property parameters
|
|
export const propertySchema = z.intersection(
|
|
fileIdentifierSchema,
|
|
z.object({
|
|
key: propertyKeySchema,
|
|
value: z.unknown().optional(),
|
|
})
|
|
);
|