@geminilight/mindos 0.5.20 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,11 @@
1
- import { tool } from 'ai';
2
- import { z } from 'zod';
1
+ import { Type, type Static } from '@sinclair/typebox';
2
+ import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core';
3
3
  import {
4
4
  searchFiles, getFileContent, getFileTree, getRecentlyModified,
5
5
  saveFileContent, createFile, appendToFile, insertAfterHeading, updateSection,
6
6
  deleteFile, renameFile, moveFile, findBacklinks, gitLog, gitShowFile, appendCsvRow,
7
7
  getMindRoot,
8
8
  } from '@/lib/fs';
9
- import { assertNotProtected } from '@/lib/core';
10
- import { logAgentOp } from './log';
11
9
 
12
10
  // Max chars per file to avoid token overflow (~100k chars ≈ ~25k tokens)
13
11
  const MAX_FILE_CHARS = 20_000;
@@ -17,329 +15,325 @@ export function truncate(content: string): string {
17
15
  return content.slice(0, MAX_FILE_CHARS) + `\n\n[...truncated — file is ${content.length} chars, showing first ${MAX_FILE_CHARS}]`;
18
16
  }
19
17
 
20
- /** Checks write-protection using core's assertNotProtected */
21
- export function assertWritable(filePath: string): void {
22
- assertNotProtected(filePath, 'modified by AI agent');
18
+ // ─── Helper: format tool error consistently ────────────────────────────────
19
+
20
+ function formatToolError(error: unknown): string {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
23
+
24
+ // ─── Helper: build a text-only AgentToolResult ──────────────────────────────
25
+
26
+ function textResult(text: string): AgentToolResult<Record<string, never>> {
27
+ return { content: [{ type: 'text', text }], details: {} as Record<string, never> };
23
28
  }
24
29
 
25
- /**
26
- * Wrap a tool execute fn with agent-op logging.
27
- * Catches ALL exceptions and returns an error string — never throws.
28
- * This is critical: an unhandled throw from a tool execute function kills
29
- * the AI SDK stream and corrupts the session message state, causing
30
- * "Cannot read properties of undefined" on every subsequent request.
31
- */
32
- function logged<P extends Record<string, unknown>>(
33
- toolName: string,
34
- fn: (params: P) => Promise<string>,
35
- ): (params: P) => Promise<string> {
36
- return async (params: P) => {
37
- const ts = new Date().toISOString();
30
+ /** Safe execute wrapper — catches all errors, returns error text (never throws) */
31
+ function safeExecute<T>(
32
+ fn: (toolCallId: string, params: T, signal?: AbortSignal) => Promise<AgentToolResult<any>>,
33
+ ): (toolCallId: string, params: T, signal?: AbortSignal) => Promise<AgentToolResult<any>> {
34
+ return async (toolCallId, params, signal) => {
38
35
  try {
39
- const result = await fn(params);
40
- const isError = result.startsWith('Error:');
41
- try { logAgentOp({ ts, tool: toolName, params, result: isError ? 'error' : 'ok', message: result.slice(0, 200) }); } catch { /* logging must never kill the stream */ }
42
- return result;
36
+ return await fn(toolCallId, params, signal);
43
37
  } catch (e) {
44
- const msg = e instanceof Error ? e.message : String(e);
45
- try { logAgentOp({ ts, tool: toolName, params, result: 'error', message: msg.slice(0, 200) }); } catch { /* swallow — logging must never kill the stream */ }
46
- return `Error: ${msg}`;
38
+ return textResult(`Error: ${formatToolError(e)}`);
47
39
  }
48
40
  };
49
41
  }
50
42
 
51
- // ─── Knowledge base tools ─────────────────────────────────────────────────────
43
+ // ─── TypeBox Schemas ────────────────────────────────────────────────────────
44
+
45
+ const ListFilesParams = Type.Object({
46
+ path: Type.Optional(Type.String({ description: 'Optional subdirectory to list (e.g. "Projects/Products"). Omit to list everything.' })),
47
+ depth: Type.Optional(Type.Number({ description: 'Max tree depth to expand (default 3). Directories deeper than this show item count only.', minimum: 1, maximum: 10 })),
48
+ });
49
+
50
+ const PathParam = Type.Object({
51
+ path: Type.String({ description: 'Relative file path' }),
52
+ });
53
+
54
+ const QueryParam = Type.Object({
55
+ query: Type.String({ description: 'Search query (case-insensitive)' }),
56
+ });
57
+
58
+ const LimitParam = Type.Object({
59
+ limit: Type.Optional(Type.Number({ description: 'Number of files to return (default 10)', minimum: 1, maximum: 50 })),
60
+ });
61
+
62
+ const WriteFileParams = Type.Object({
63
+ path: Type.String({ description: 'Relative file path' }),
64
+ content: Type.String({ description: 'New full content' }),
65
+ });
66
+
67
+ const CreateFileParams = Type.Object({
68
+ path: Type.String({ description: 'Relative file path (must end in .md or .csv)' }),
69
+ content: Type.Optional(Type.String({ description: 'Initial file content' })),
70
+ });
71
+
72
+ const AppendParams = Type.Object({
73
+ path: Type.String({ description: 'Relative file path' }),
74
+ content: Type.String({ description: 'Content to append' }),
75
+ });
76
+
77
+ const InsertHeadingParams = Type.Object({
78
+ path: Type.String({ description: 'Relative file path' }),
79
+ heading: Type.String({ description: 'Heading text to find (e.g. "## Tasks" or just "Tasks")' }),
80
+ content: Type.String({ description: 'Content to insert after the heading' }),
81
+ });
82
+
83
+ const UpdateSectionParams = Type.Object({
84
+ path: Type.String({ description: 'Relative file path' }),
85
+ heading: Type.String({ description: 'Heading text to find (e.g. "## Status")' }),
86
+ content: Type.String({ description: 'New content for the section' }),
87
+ });
88
+
89
+ const RenameParams = Type.Object({
90
+ path: Type.String({ description: 'Current relative file path' }),
91
+ new_name: Type.String({ description: 'New filename (no path separators, e.g. "new-name.md")' }),
92
+ });
52
93
 
53
- export const knowledgeBaseTools = {
54
- list_files: tool({
94
+ const MoveParams = Type.Object({
95
+ from_path: Type.String({ description: 'Current relative file path' }),
96
+ to_path: Type.String({ description: 'New relative file path' }),
97
+ });
98
+
99
+ const HistoryParams = Type.Object({
100
+ path: Type.String({ description: 'Relative file path' }),
101
+ limit: Type.Optional(Type.Number({ description: 'Number of commits to return (default 10)', minimum: 1, maximum: 50 })),
102
+ });
103
+
104
+ const FileAtVersionParams = Type.Object({
105
+ path: Type.String({ description: 'Relative file path' }),
106
+ commit: Type.String({ description: 'Git commit hash (full or abbreviated)' }),
107
+ });
108
+
109
+ const CsvAppendParams = Type.Object({
110
+ path: Type.String({ description: 'Relative path to .csv file' }),
111
+ row: Type.Array(Type.String(), { description: 'Array of cell values for the new row' }),
112
+ });
113
+
114
+ // ─── Tool Definitions (AgentTool interface) ─────────────────────────────────
115
+
116
+ // Write-operation tool names — used by beforeToolCall for write-protection
117
+ export const WRITE_TOOLS = new Set([
118
+ 'write_file', 'create_file', 'append_to_file', 'insert_after_heading',
119
+ 'update_section', 'delete_file', 'rename_file', 'move_file', 'append_csv',
120
+ ]);
121
+
122
+ export const knowledgeBaseTools: AgentTool<any>[] = [
123
+ {
124
+ name: 'list_files',
125
+ label: 'List Files',
55
126
  description: 'List files in the knowledge base as an indented tree. Directories beyond `depth` show "... (N items)". Pass `path` to list only a subdirectory, or `depth` to control how deep to expand (default 3).',
56
- inputSchema: z.object({
57
- path: z.string().nullish().describe('Optional subdirectory to list (e.g. "Projects/Products"). Omit to list everything.'),
58
- depth: z.number().min(1).max(10).nullish().describe('Max tree depth to expand (default 3). Directories deeper than this show item count only.'),
59
- }),
60
- execute: logged('list_files', async ({ path: subdir, depth: maxDepth }) => {
61
- try {
62
- const tree = getFileTree();
63
-
64
- // Empty tree at root level → likely a misconfigured mindRoot
65
- if (tree.length === 0 && !subdir) {
66
- const root = getMindRoot();
67
- return `(empty — no .md or .csv files found under mind_root: ${root})`;
68
- }
127
+ parameters: ListFilesParams,
128
+ execute: safeExecute(async (_id, params: Static<typeof ListFilesParams>) => {
129
+ const { path: subdir, depth: maxDepth } = params;
130
+ const tree = getFileTree();
131
+
132
+ if (tree.length === 0 && !subdir) {
133
+ const root = getMindRoot();
134
+ return textResult(`(empty — no .md or .csv files found under mind_root: ${root})`);
135
+ }
69
136
 
70
- const limit = maxDepth ?? 3;
71
- const lines: string[] = [];
72
- function walk(nodes: Array<{ name: string; type: string; children?: unknown[] }>, depth: number) {
73
- for (const n of nodes) {
74
- lines.push(' '.repeat(depth) + (n.type === 'directory' ? `${n.name}/` : n.name));
75
- if (n.type === 'directory' && Array.isArray(n.children)) {
76
- if (depth + 1 < limit) {
77
- walk(n.children as typeof nodes, depth + 1);
78
- } else {
79
- lines.push(' '.repeat(depth + 1) + `... (${n.children.length} items)`);
80
- }
137
+ const limit = maxDepth ?? 3;
138
+ const lines: string[] = [];
139
+ function walk(nodes: Array<{ name: string; type: string; children?: unknown[] }>, depth: number) {
140
+ for (const n of nodes) {
141
+ lines.push(' '.repeat(depth) + (n.type === 'directory' ? `${n.name}/` : n.name));
142
+ if (n.type === 'directory' && Array.isArray(n.children)) {
143
+ if (depth + 1 < limit) {
144
+ walk(n.children as typeof nodes, depth + 1);
145
+ } else {
146
+ lines.push(' '.repeat(depth + 1) + `... (${n.children.length} items)`);
81
147
  }
82
148
  }
83
149
  }
150
+ }
84
151
 
85
- if (subdir) {
86
- const segments = subdir.replace(/\/$/, '').split('/').filter(Boolean);
87
- let current: Array<{ name: string; type: string; path?: string; children?: unknown[] }> = tree as any;
88
- for (const seg of segments) {
89
- const found = current.find(n => n.name === seg && n.type === 'directory');
90
- if (!found || !Array.isArray(found.children)) {
91
- return `Directory not found: ${subdir}`;
92
- }
93
- current = found.children as typeof current;
152
+ if (subdir) {
153
+ const segments = subdir.replace(/\/$/, '').split('/').filter(Boolean);
154
+ let current: Array<{ name: string; type: string; path?: string; children?: unknown[] }> = tree as any;
155
+ for (const seg of segments) {
156
+ const found = current.find(n => n.name === seg && n.type === 'directory');
157
+ if (!found || !Array.isArray(found.children)) {
158
+ return textResult(`Directory not found: ${subdir}`);
94
159
  }
95
- walk(current as any, 0);
96
- } else {
97
- walk(tree as any, 0);
160
+ current = found.children as typeof current;
98
161
  }
99
-
100
- return lines.length > 0 ? lines.join('\n') : '(empty directory)';
101
- } catch (e: unknown) {
102
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
162
+ walk(current as any, 0);
163
+ } else {
164
+ walk(tree as any, 0);
103
165
  }
166
+
167
+ return textResult(lines.length > 0 ? lines.join('\n') : '(empty directory)');
104
168
  }),
105
- }),
169
+ },
106
170
 
107
- read_file: tool({
171
+ {
172
+ name: 'read_file',
173
+ label: 'Read File',
108
174
  description: 'Read the content of a file by its relative path. Always read a file before modifying it.',
109
- inputSchema: z.object({ path: z.string().describe('Relative file path, e.g. "Profile/👤 Identity.md"') }),
110
- execute: logged('read_file', async ({ path }) => {
111
- try {
112
- return truncate(getFileContent(path));
113
- } catch (e: unknown) {
114
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
115
- }
175
+ parameters: PathParam,
176
+ execute: safeExecute(async (_id, params: Static<typeof PathParam>) => {
177
+ return textResult(truncate(getFileContent(params.path)));
116
178
  }),
117
- }),
179
+ },
118
180
 
119
- search: tool({
181
+ {
182
+ name: 'search',
183
+ label: 'Search',
120
184
  description: 'Full-text search across all files in the knowledge base. Returns matching files with context snippets.',
121
- inputSchema: z.object({ query: z.string().describe('Search query (case-insensitive)') }),
122
- execute: logged('search', async ({ query }) => {
123
- const results = searchFiles(query);
124
- if (results.length === 0) return 'No results found.';
125
- return results.map(r => `- **${r.path}**: ${r.snippet}`).join('\n');
185
+ parameters: QueryParam,
186
+ execute: safeExecute(async (_id, params: Static<typeof QueryParam>) => {
187
+ const results = searchFiles(params.query);
188
+ if (results.length === 0) return textResult('No results found.');
189
+ return textResult(results.map(r => `- **${r.path}**: ${r.snippet}`).join('\n'));
126
190
  }),
127
- }),
191
+ },
128
192
 
129
- get_recent: tool({
193
+ {
194
+ name: 'get_recent',
195
+ label: 'Recent Files',
130
196
  description: 'Get the most recently modified files in the knowledge base.',
131
- inputSchema: z.object({ limit: z.number().min(1).max(50).nullish().describe('Number of files to return (default 10)') }),
132
- execute: logged('get_recent', async ({ limit }) => {
133
- const files = getRecentlyModified(limit ?? 10);
134
- return files.map(f => `- ${f.path} (${new Date(f.mtime).toISOString()})`).join('\n');
197
+ parameters: LimitParam,
198
+ execute: safeExecute(async (_id, params: Static<typeof LimitParam>) => {
199
+ const files = getRecentlyModified(params.limit ?? 10);
200
+ return textResult(files.map(f => `- ${f.path} (${new Date(f.mtime).toISOString()})`).join('\n'));
135
201
  }),
136
- }),
202
+ },
137
203
 
138
- write_file: tool({
204
+ {
205
+ name: 'write_file',
206
+ label: 'Write File',
139
207
  description: 'Overwrite the entire content of an existing file. Use read_file first to see current content. Prefer update_section or insert_after_heading for partial edits.',
140
- inputSchema: z.object({
141
- path: z.string().describe('Relative file path'),
142
- content: z.string().describe('New full content'),
143
- }),
144
- execute: logged('write_file', async ({ path, content }) => {
145
- try {
146
- assertWritable(path);
147
- saveFileContent(path, content);
148
- return `File written: ${path}`;
149
- } catch (e: unknown) {
150
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
151
- }
208
+ parameters: WriteFileParams,
209
+ execute: safeExecute(async (_id, params: Static<typeof WriteFileParams>) => {
210
+ saveFileContent(params.path, params.content);
211
+ return textResult(`File written: ${params.path}`);
152
212
  }),
153
- }),
213
+ },
154
214
 
155
- create_file: tool({
215
+ {
216
+ name: 'create_file',
217
+ label: 'Create File',
156
218
  description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically.',
157
- inputSchema: z.object({
158
- path: z.string().describe('Relative file path (must end in .md or .csv)'),
159
- content: z.string().nullish().describe('Initial file content'),
219
+ parameters: CreateFileParams,
220
+ execute: safeExecute(async (_id, params: Static<typeof CreateFileParams>) => {
221
+ createFile(params.path, params.content ?? '');
222
+ return textResult(`File created: ${params.path}`);
160
223
  }),
161
- execute: logged('create_file', async ({ path, content }) => {
162
- try {
163
- assertWritable(path);
164
- createFile(path, content ?? '');
165
- return `File created: ${path}`;
166
- } catch (e: unknown) {
167
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
168
- }
169
- }),
170
- }),
224
+ },
171
225
 
172
- append_to_file: tool({
226
+ {
227
+ name: 'append_to_file',
228
+ label: 'Append to File',
173
229
  description: 'Append text to the end of an existing file. A blank line separator is added automatically.',
174
- inputSchema: z.object({
175
- path: z.string().describe('Relative file path'),
176
- content: z.string().describe('Content to append'),
177
- }),
178
- execute: logged('append_to_file', async ({ path, content }) => {
179
- try {
180
- assertWritable(path);
181
- appendToFile(path, content);
182
- return `Content appended to: ${path}`;
183
- } catch (e: unknown) {
184
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
185
- }
230
+ parameters: AppendParams,
231
+ execute: safeExecute(async (_id, params: Static<typeof AppendParams>) => {
232
+ appendToFile(params.path, params.content);
233
+ return textResult(`Content appended to: ${params.path}`);
186
234
  }),
187
- }),
235
+ },
188
236
 
189
- insert_after_heading: tool({
237
+ {
238
+ name: 'insert_after_heading',
239
+ label: 'Insert After Heading',
190
240
  description: 'Insert content right after a Markdown heading. Useful for adding items under a specific section.',
191
- inputSchema: z.object({
192
- path: z.string().describe('Relative file path'),
193
- heading: z.string().describe('Heading text to find (e.g. "## Tasks" or just "Tasks")'),
194
- content: z.string().describe('Content to insert after the heading'),
195
- }),
196
- execute: logged('insert_after_heading', async ({ path, heading, content }) => {
197
- try {
198
- assertWritable(path);
199
- insertAfterHeading(path, heading, content);
200
- return `Content inserted after heading "${heading}" in ${path}`;
201
- } catch (e: unknown) {
202
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
203
- }
241
+ parameters: InsertHeadingParams,
242
+ execute: safeExecute(async (_id, params: Static<typeof InsertHeadingParams>) => {
243
+ insertAfterHeading(params.path, params.heading, params.content);
244
+ return textResult(`Content inserted after heading "${params.heading}" in ${params.path}`);
204
245
  }),
205
- }),
246
+ },
206
247
 
207
- update_section: tool({
248
+ {
249
+ name: 'update_section',
250
+ label: 'Update Section',
208
251
  description: 'Replace the content of a Markdown section identified by its heading. The section spans from the heading to the next heading of equal or higher level.',
209
- inputSchema: z.object({
210
- path: z.string().describe('Relative file path'),
211
- heading: z.string().describe('Heading text to find (e.g. "## Status")'),
212
- content: z.string().describe('New content for the section'),
252
+ parameters: UpdateSectionParams,
253
+ execute: safeExecute(async (_id, params: Static<typeof UpdateSectionParams>) => {
254
+ updateSection(params.path, params.heading, params.content);
255
+ return textResult(`Section "${params.heading}" updated in ${params.path}`);
213
256
  }),
214
- execute: logged('update_section', async ({ path, heading, content }) => {
215
- try {
216
- assertWritable(path);
217
- updateSection(path, heading, content);
218
- return `Section "${heading}" updated in ${path}`;
219
- } catch (e: unknown) {
220
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
221
- }
222
- }),
223
- }),
224
-
225
- // ─── New tools (Phase 1a) ──────────────────────────────────────────────────
257
+ },
226
258
 
227
- delete_file: tool({
259
+ {
260
+ name: 'delete_file',
261
+ label: 'Delete File',
228
262
  description: 'Permanently delete a file from the knowledge base. This is destructive and cannot be undone.',
229
- inputSchema: z.object({
230
- path: z.string().describe('Relative file path to delete'),
263
+ parameters: PathParam,
264
+ execute: safeExecute(async (_id, params: Static<typeof PathParam>) => {
265
+ deleteFile(params.path);
266
+ return textResult(`File deleted: ${params.path}`);
231
267
  }),
232
- execute: logged('delete_file', async ({ path }) => {
233
- try {
234
- assertWritable(path);
235
- deleteFile(path);
236
- return `File deleted: ${path}`;
237
- } catch (e: unknown) {
238
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
239
- }
240
- }),
241
- }),
268
+ },
242
269
 
243
- rename_file: tool({
270
+ {
271
+ name: 'rename_file',
272
+ label: 'Rename File',
244
273
  description: 'Rename a file within its current directory. Only the filename changes, not the directory.',
245
- inputSchema: z.object({
246
- path: z.string().describe('Current relative file path'),
247
- new_name: z.string().describe('New filename (no path separators, e.g. "new-name.md")'),
248
- }),
249
- execute: logged('rename_file', async ({ path, new_name }) => {
250
- try {
251
- assertWritable(path);
252
- const newPath = renameFile(path, new_name);
253
- return `File renamed: ${path} → ${newPath}`;
254
- } catch (e: unknown) {
255
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
256
- }
274
+ parameters: RenameParams,
275
+ execute: safeExecute(async (_id, params: Static<typeof RenameParams>) => {
276
+ const newPath = renameFile(params.path, params.new_name);
277
+ return textResult(`File renamed: ${params.path} → ${newPath}`);
257
278
  }),
258
- }),
279
+ },
259
280
 
260
- move_file: tool({
281
+ {
282
+ name: 'move_file',
283
+ label: 'Move File',
261
284
  description: 'Move a file to a new location. Also returns any files that had backlinks affected by the move.',
262
- inputSchema: z.object({
263
- from_path: z.string().describe('Current relative file path'),
264
- to_path: z.string().describe('New relative file path'),
285
+ parameters: MoveParams,
286
+ execute: safeExecute(async (_id, params: Static<typeof MoveParams>) => {
287
+ const result = moveFile(params.from_path, params.to_path);
288
+ const affected = result.affectedFiles.length > 0
289
+ ? `\nAffected backlinks in: ${result.affectedFiles.join(', ')}`
290
+ : '';
291
+ return textResult(`File moved: ${params.from_path} → ${result.newPath}${affected}`);
265
292
  }),
266
- execute: logged('move_file', async ({ from_path, to_path }) => {
267
- try {
268
- assertWritable(from_path);
269
- const result = moveFile(from_path, to_path);
270
- const affected = result.affectedFiles.length > 0
271
- ? `\nAffected backlinks in: ${result.affectedFiles.join(', ')}`
272
- : '';
273
- return `File moved: ${from_path} → ${result.newPath}${affected}`;
274
- } catch (e: unknown) {
275
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
276
- }
277
- }),
278
- }),
293
+ },
279
294
 
280
- get_backlinks: tool({
295
+ {
296
+ name: 'get_backlinks',
297
+ label: 'Backlinks',
281
298
  description: 'Find all files that reference a given file path. Useful for understanding connections between notes.',
282
- inputSchema: z.object({
283
- path: z.string().describe('Relative file path to find backlinks for'),
284
- }),
285
- execute: logged('get_backlinks', async ({ path }) => {
286
- try {
287
- const backlinks = findBacklinks(path);
288
- if (backlinks.length === 0) return `No backlinks found for: ${path}`;
289
- return backlinks.map(b => `- **${b.source}** (L${b.line}): ${b.context}`).join('\n');
290
- } catch (e: unknown) {
291
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
292
- }
299
+ parameters: PathParam,
300
+ execute: safeExecute(async (_id, params: Static<typeof PathParam>) => {
301
+ const backlinks = findBacklinks(params.path);
302
+ if (backlinks.length === 0) return textResult(`No backlinks found for: ${params.path}`);
303
+ return textResult(backlinks.map(b => `- **${b.source}** (L${b.line}): ${b.context}`).join('\n'));
293
304
  }),
294
- }),
305
+ },
295
306
 
296
- get_history: tool({
307
+ {
308
+ name: 'get_history',
309
+ label: 'History',
297
310
  description: 'Get git commit history for a file. Shows recent commits that modified this file.',
298
- inputSchema: z.object({
299
- path: z.string().describe('Relative file path'),
300
- limit: z.number().min(1).max(50).nullish().describe('Number of commits to return (default 10)'),
311
+ parameters: HistoryParams,
312
+ execute: safeExecute(async (_id, params: Static<typeof HistoryParams>) => {
313
+ const commits = gitLog(params.path, params.limit ?? 10);
314
+ if (commits.length === 0) return textResult(`No git history found for: ${params.path}`);
315
+ return textResult(commits.map(c => `- \`${c.hash.slice(0, 7)}\` ${c.date} — ${c.message} (${c.author})`).join('\n'));
301
316
  }),
302
- execute: logged('get_history', async ({ path, limit }) => {
303
- try {
304
- const commits = gitLog(path, limit ?? 10);
305
- if (commits.length === 0) return `No git history found for: ${path}`;
306
- return commits.map(c => `- \`${c.hash.slice(0, 7)}\` ${c.date} — ${c.message} (${c.author})`).join('\n');
307
- } catch (e: unknown) {
308
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
309
- }
310
- }),
311
- }),
317
+ },
312
318
 
313
- get_file_at_version: tool({
319
+ {
320
+ name: 'get_file_at_version',
321
+ label: 'File at Version',
314
322
  description: 'Read the content of a file at a specific git commit. Use get_history first to find commit hashes.',
315
- inputSchema: z.object({
316
- path: z.string().describe('Relative file path'),
317
- commit: z.string().describe('Git commit hash (full or abbreviated)'),
318
- }),
319
- execute: logged('get_file_at_version', async ({ path, commit }) => {
320
- try {
321
- const content = gitShowFile(path, commit);
322
- return truncate(content);
323
- } catch (e: unknown) {
324
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
325
- }
323
+ parameters: FileAtVersionParams,
324
+ execute: safeExecute(async (_id, params: Static<typeof FileAtVersionParams>) => {
325
+ return textResult(truncate(gitShowFile(params.path, params.commit)));
326
326
  }),
327
- }),
327
+ },
328
328
 
329
- append_csv: tool({
329
+ {
330
+ name: 'append_csv',
331
+ label: 'Append CSV Row',
330
332
  description: 'Append a row to a CSV file. Values are automatically escaped per RFC 4180.',
331
- inputSchema: z.object({
332
- path: z.string().describe('Relative path to .csv file'),
333
- row: z.array(z.string()).describe('Array of cell values for the new row'),
334
- }),
335
- execute: logged('append_csv', async ({ path, row }) => {
336
- try {
337
- assertWritable(path);
338
- const result = appendCsvRow(path, row);
339
- return `Row appended to ${path} (now ${result.newRowCount} rows)`;
340
- } catch (e: unknown) {
341
- return `Error: ${e instanceof Error ? e.message : String(e)}`;
342
- }
333
+ parameters: CsvAppendParams,
334
+ execute: safeExecute(async (_id, params: Static<typeof CsvAppendParams>) => {
335
+ const result = appendCsvRow(params.path, params.row);
336
+ return textResult(`Row appended to ${params.path} (now ${result.newRowCount} rows)`);
343
337
  }),
344
- }),
345
- };
338
+ },
339
+ ];