morpheus-cli 0.4.1 → 0.4.3

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.
package/README.md CHANGED
@@ -153,6 +153,13 @@ The system also supports generic environment variables that apply to all provide
153
153
  | `MORPHEUS_SATI_MEMORY_LIMIT` | Memory retrieval limit for Sati | santi.memory_limit |
154
154
  | `MORPHEUS_SATI_MEMORY_LIMIT` | Memory retrieval limit for Sati | santi.memory_limit |
155
155
  | `MORPHEUS_SATI_ENABLED_ARCHIVED_SESSIONS`| Enable/disable retrieval of archived sessions in Sati | santi.enableArchivedSessions |
156
+ | `MORPHEUS_APOC_PROVIDER` | Apoc LLM provider | apoc.provider |
157
+ | `MORPHEUS_APOC_MODEL` | Model name for Apoc | apoc.model |
158
+ | `MORPHEUS_APOC_TEMPERATURE` | Temperature for Apoc | apoc.temperature |
159
+ | `MORPHEUS_APOC_MAX_TOKENS` | Maximum tokens for Apoc | apoc.max_tokens |
160
+ | `MORPHEUS_APOC_API_KEY` | API key for Apoc (falls back to provider-specific key) | apoc.api_key |
161
+ | `MORPHEUS_APOC_WORKING_DIR` | Working directory for Apoc file/shell operations | apoc.working_dir |
162
+ | `MORPHEUS_APOC_TIMEOUT_MS` | Timeout in ms for Apoc shell operations (default: 30000) | apoc.timeout_ms |
156
163
  | `MORPHEUS_AUDIO_MODEL` | Model name for audio processing | audio.model |
157
164
  | `MORPHEUS_AUDIO_ENABLED` | Enable/disable audio processing | audio.enabled |
158
165
  | `MORPHEUS_AUDIO_API_KEY` | Generic API key for audio (lower precedence than provider-specific keys) | audio.apiKey |
@@ -206,6 +213,28 @@ When enabled:
206
213
  ### 🧩 MCP Support (Model Context Protocol)
207
214
  Full integration with [Model Context Protocol](https://modelcontextprotocol.io/), allowing Morpheus to use standardized tools from any MCP-compatible server.
208
215
 
216
+ ### 🛠️ Apoc (DevTools Subagent)
217
+
218
+ Morpheus includes **Apoc**, a specialized subagent invoked by Oracle whenever the user requests developer-level operations. Apoc runs with access to the **DevKit** tool set:
219
+
220
+ | Tool Group | Capabilities |
221
+ |---|---|
222
+ | **Filesystem** | Read, write, append, delete files and directories |
223
+ | **Shell** | Execute shell commands and scripts with timeout control |
224
+ | **Git** | status, log, diff, commit, push, pull, clone, branch |
225
+ | **Packages** | npm/yarn install, update, audit, package.json inspection |
226
+ | **Processes** | List running processes, check ports, terminate processes |
227
+ | **Network** | curl, ping, DNS lookups, HTTP requests |
228
+ | **System** | Environment variables, OS info, disk space, memory usage |
229
+
230
+ Oracle delegates to Apoc via the `apoc_delegate` tool when you ask things like:
231
+ - *"Run npm install and show me any errors"*
232
+ - *"What's the git status of this repo?"*
233
+ - *"Read the contents of config.json"*
234
+ - *"Execute the build script and tell me what happened"*
235
+
236
+ Apoc is independently configurable — use a different (e.g., faster, cheaper) model than Oracle for tool execution tasks.
237
+
209
238
  ### 🧠 Sati (Long-Term Memory)
210
239
  Morpheus features a dedicated middleware system called **Sati** (Mindfulness) that provides long-term memory capabilities.
211
240
  - **Automated Storage**: Automatically extracts and saves preferences, project details, and facts from conversations.
@@ -309,6 +338,12 @@ santi: # Optional: Sati (Long-Term Memory) specific settings
309
338
  provider: "openai" # defaults to llm.provider
310
339
  model: "gpt-4o"
311
340
  memory_limit: 1000 # Number of messages/items to retrieve
341
+ apoc: # Optional: Apoc DevTools subagent settings
342
+ provider: "openai" # defaults to llm.provider (can use a cheaper/faster model)
343
+ model: "gpt-4o-mini"
344
+ temperature: 0.2
345
+ working_dir: "/home/user/projects" # root dir for file/shell ops (defaults to process cwd)
346
+ timeout_ms: 30000 # shell command timeout in ms
312
347
  channels:
313
348
  telegram:
314
349
  enabled: true
@@ -560,6 +595,55 @@ Update the Sati (long-term memory) configuration.
560
595
  #### DELETE `/api/config/sati`
561
596
  Remove the Sati (long-term memory) configuration (falls back to Oracle config).
562
597
 
598
+ * **Authentication:** Requires `Authorization` header with the password set in `THE_ARCHITECT_PASS`.
599
+ * **Response:**
600
+ ```json
601
+ {
602
+ "success": true
603
+ }
604
+ ```
605
+
606
+
607
+ #### GET `/api/config/apoc`
608
+ Retrieve the Apoc (DevTools subagent) configuration. Falls back to Oracle (LLM) config if not explicitly set.
609
+
610
+ * **Authentication:** Requires `Authorization` header with the password set in `THE_ARCHITECT_PASS`.
611
+ * **Response:**
612
+ ```json
613
+ {
614
+ "provider": "openai",
615
+ "model": "gpt-4o-mini",
616
+ "temperature": 0.2,
617
+ "api_key": "***",
618
+ "working_dir": "/home/user/projects",
619
+ "timeout_ms": 30000
620
+ }
621
+ ```
622
+
623
+ #### POST `/api/config/apoc`
624
+ Update the Apoc (DevTools subagent) configuration.
625
+
626
+ * **Authentication:** Requires `Authorization` header with the password set in `THE_ARCHITECT_PASS`.
627
+ * **Body:**
628
+ ```json
629
+ {
630
+ "provider": "openai",
631
+ "model": "gpt-4o-mini",
632
+ "temperature": 0.2,
633
+ "working_dir": "/home/user/projects",
634
+ "timeout_ms": 30000
635
+ }
636
+ ```
637
+ * **Response:**
638
+ ```json
639
+ {
640
+ "success": true
641
+ }
642
+ ```
643
+
644
+ #### DELETE `/api/config/apoc`
645
+ Remove the Apoc configuration (falls back to Oracle config).
646
+
563
647
  * **Authentication:** Requires `Authorization` header with the password set in `THE_ARCHITECT_PASS`.
564
648
  * **Response:**
565
649
  ```json
@@ -860,6 +944,10 @@ npm run test:watch
860
944
  │ ├── cli/ # CLI commands and logic
861
945
  │ ├── config/ # Configuration management
862
946
  │ ├── runtime/ # Core agent logic, lifecycle, and providers
947
+ │ ├── apoc.ts # Apoc DevTools subagent (filesystem, shell, git, etc.)
948
+ │ ├── oracle.ts # Oracle main agent (LangChain ReactAgent)
949
+ │ └── providers/ # LLM provider factory (createBare for subagents)
950
+ ├── devkit/ # DevKit tool factories (filesystem, shell, git, network, packages, processes, system)
863
951
  │ ├── types/ # Shared TypeScript definitions
864
952
  │ └── ui/ # React Web UI Dashboard
865
953
  └── package.json
@@ -4,7 +4,7 @@ import fs from 'fs-extra';
4
4
  import { confirm } from '@inquirer/prompts';
5
5
  import { scaffold } from '../../runtime/scaffold.js';
6
6
  import { DisplayManager } from '../../runtime/display.js';
7
- import { writePid, readPid, isProcessRunning, clearPid, checkStalePid, killProcess } from '../../runtime/lifecycle.js';
7
+ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid, killProcess, waitForProcessDeath } from '../../runtime/lifecycle.js';
8
8
  import { ConfigManager } from '../../config/manager.js';
9
9
  import { renderBanner } from '../utils/render.js';
10
10
  import { TelegramAdapter } from '../../channels/telegram.js';
@@ -28,7 +28,8 @@ export const startCommand = new Command('start')
28
28
  // Cleanup stale PID first
29
29
  await checkStalePid();
30
30
  const existingPid = await readPid();
31
- if (existingPid !== null && isProcessRunning(existingPid)) {
31
+ // Guard: skip if the stored PID is our own (container restart PID reuse scenario)
32
+ if (existingPid !== null && existingPid !== process.pid && isProcessRunning(existingPid)) {
32
33
  display.log(chalk.yellow(`Morpheus is already running (PID: ${existingPid})`));
33
34
  let shouldKill = options.yes;
34
35
  if (!shouldKill) {
@@ -48,10 +49,13 @@ export const startCommand = new Command('start')
48
49
  display.log(chalk.cyan(`Stopping existing process (PID: ${existingPid})...`));
49
50
  const killed = killProcess(existingPid);
50
51
  if (killed) {
51
- display.log(chalk.green('✓ Process stopped successfully'));
52
+ display.log(chalk.green('Terminated'));
52
53
  await clearPid();
53
- // Give a moment for the process to fully terminate
54
- await new Promise(resolve => setTimeout(resolve, 1000));
54
+ // Wait up to 5 s for the process to actually die before continuing
55
+ const died = await waitForProcessDeath(existingPid, 5000);
56
+ if (!died) {
57
+ display.log(chalk.yellow('Warning: process may still be running. Proceeding anyway.'));
58
+ }
55
59
  }
56
60
  else {
57
61
  display.log(chalk.red('Failed to stop the process'));
@@ -69,6 +69,22 @@ export class ConfigManager {
69
69
  enabled_archived_sessions: resolveBoolean('MORPHEUS_SATI_ENABLED_ARCHIVED_SESSIONS', config.sati.enabled_archived_sessions, true)
70
70
  };
71
71
  }
72
+ // Apply precedence to Apoc config
73
+ let apocConfig;
74
+ if (config.apoc) {
75
+ const apocProvider = resolveProvider('MORPHEUS_APOC_PROVIDER', config.apoc.provider, llmConfig.provider);
76
+ apocConfig = {
77
+ provider: apocProvider,
78
+ model: resolveModel(apocProvider, 'MORPHEUS_APOC_MODEL', config.apoc.model || llmConfig.model),
79
+ temperature: resolveNumeric('MORPHEUS_APOC_TEMPERATURE', config.apoc.temperature, llmConfig.temperature),
80
+ max_tokens: config.apoc.max_tokens !== undefined ? resolveNumeric('MORPHEUS_APOC_MAX_TOKENS', config.apoc.max_tokens, config.apoc.max_tokens) : llmConfig.max_tokens,
81
+ api_key: resolveApiKey(apocProvider, 'MORPHEUS_APOC_API_KEY', config.apoc.api_key || llmConfig.api_key),
82
+ base_url: config.apoc.base_url || config.llm.base_url,
83
+ context_window: config.apoc.context_window !== undefined ? resolveNumeric('MORPHEUS_APOC_CONTEXT_WINDOW', config.apoc.context_window, config.apoc.context_window) : llmConfig.context_window,
84
+ working_dir: resolveString('MORPHEUS_APOC_WORKING_DIR', config.apoc.working_dir, process.cwd()),
85
+ timeout_ms: config.apoc.timeout_ms !== undefined ? resolveNumeric('MORPHEUS_APOC_TIMEOUT_MS', config.apoc.timeout_ms, 30_000) : 30_000
86
+ };
87
+ }
72
88
  // Apply precedence to audio config
73
89
  const audioProvider = resolveString('MORPHEUS_AUDIO_PROVIDER', config.audio.provider, DEFAULT_CONFIG.audio.provider);
74
90
  // AudioProvider uses 'google' but resolveApiKey expects LLMProvider which uses 'gemini'
@@ -112,6 +128,7 @@ export class ConfigManager {
112
128
  agent: agentConfig,
113
129
  llm: llmConfig,
114
130
  sati: satiConfig,
131
+ apoc: apocConfig,
115
132
  audio: audioConfig,
116
133
  channels: channelsConfig,
117
134
  ui: uiConfig,
@@ -153,4 +170,19 @@ export class ConfigManager {
153
170
  memory_limit: 10 // Default fallback
154
171
  };
155
172
  }
173
+ getApocConfig() {
174
+ if (this.config.apoc) {
175
+ return {
176
+ working_dir: process.cwd(),
177
+ timeout_ms: 30_000,
178
+ ...this.config.apoc
179
+ };
180
+ }
181
+ // Fallback to main LLM config with Apoc defaults
182
+ return {
183
+ ...this.config.llm,
184
+ working_dir: process.cwd(),
185
+ timeout_ms: 30_000
186
+ };
187
+ }
156
188
  }
@@ -22,6 +22,10 @@ export const SatiConfigSchema = LLMConfigSchema.extend({
22
22
  memory_limit: z.number().int().positive().optional(),
23
23
  enabled_archived_sessions: z.boolean().default(true),
24
24
  });
25
+ export const ApocConfigSchema = LLMConfigSchema.extend({
26
+ working_dir: z.string().optional(),
27
+ timeout_ms: z.number().int().positive().optional(),
28
+ });
25
29
  // Zod Schema matching MorpheusConfig interface
26
30
  export const ConfigSchema = z.object({
27
31
  agent: z.object({
@@ -30,6 +34,7 @@ export const ConfigSchema = z.object({
30
34
  }).default(DEFAULT_CONFIG.agent),
31
35
  llm: LLMConfigSchema.default(DEFAULT_CONFIG.llm),
32
36
  sati: SatiConfigSchema.optional(),
37
+ apoc: ApocConfigSchema.optional(),
33
38
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
34
39
  memory: z.object({
35
40
  limit: z.number().int().positive().optional(),
@@ -0,0 +1,80 @@
1
+ import { platform } from 'os';
2
+ export class ShellAdapter {
3
+ /**
4
+ * Factory: returns the appropriate adapter for the current OS.
5
+ * Uses direct imports (ESM-compatible, no require()).
6
+ */
7
+ static create() {
8
+ switch (platform()) {
9
+ case 'win32': return new WindowsAdapter();
10
+ case 'darwin': return new MacAdapter();
11
+ default: return new LinuxAdapter();
12
+ }
13
+ }
14
+ }
15
+ // ─── Inline implementations (avoids ESM dynamic import issues) ────────────────
16
+ import { spawn } from 'child_process';
17
+ class WindowsAdapter extends ShellAdapter {
18
+ getShell() { return { shell: 'cmd.exe', flag: '/c' }; }
19
+ async run(command, args, options) {
20
+ return spawnCommand(command, args, { ...options, windowsHide: true, shell: true });
21
+ }
22
+ async which(binary) {
23
+ const result = await this.run('where', [binary], { cwd: process.cwd(), timeout_ms: 5000 });
24
+ if (result.exitCode !== 0)
25
+ return null;
26
+ const first = result.stdout.trim().split(/\r?\n/)[0];
27
+ return first || null;
28
+ }
29
+ }
30
+ class LinuxAdapter extends ShellAdapter {
31
+ getShell() { return { shell: '/bin/bash', flag: '-c' }; }
32
+ async run(command, args, options) {
33
+ return spawnCommand(command, args, { ...options, shell: false });
34
+ }
35
+ async which(binary) {
36
+ const result = await this.run('which', [binary], { cwd: process.cwd(), timeout_ms: 5000 });
37
+ if (result.exitCode !== 0)
38
+ return null;
39
+ return result.stdout.trim() || null;
40
+ }
41
+ }
42
+ class MacAdapter extends ShellAdapter {
43
+ getShell() { return { shell: '/bin/zsh', flag: '-c' }; }
44
+ async run(command, args, options) {
45
+ return spawnCommand(command, args, { ...options, shell: false });
46
+ }
47
+ async which(binary) {
48
+ const result = await this.run('which', [binary], { cwd: process.cwd(), timeout_ms: 5000 });
49
+ if (result.exitCode !== 0)
50
+ return null;
51
+ return result.stdout.trim() || null;
52
+ }
53
+ }
54
+ function spawnCommand(command, args, options) {
55
+ return new Promise((resolve) => {
56
+ let stdout = '';
57
+ let stderr = '';
58
+ let timedOut = false;
59
+ const child = spawn(command, args, {
60
+ cwd: options.cwd,
61
+ env: { ...process.env, ...options.env },
62
+ shell: options.shell ?? false,
63
+ windowsHide: options.windowsHide ?? false,
64
+ });
65
+ const timer = setTimeout(() => {
66
+ timedOut = true;
67
+ child.kill('SIGKILL');
68
+ }, options.timeout_ms);
69
+ child.stdout?.on('data', (d) => { stdout += d.toString(); });
70
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
71
+ child.on('error', (err) => {
72
+ clearTimeout(timer);
73
+ resolve({ exitCode: 1, stdout, stderr: stderr + err.message, timedOut });
74
+ });
75
+ child.on('close', (code) => {
76
+ clearTimeout(timer);
77
+ resolve({ exitCode: code ?? 1, stdout, stderr, timedOut });
78
+ });
79
+ });
80
+ }
@@ -0,0 +1,10 @@
1
+ // Register all DevKit tool factories
2
+ // Import order matters: each import triggers registerToolFactory() as a side effect
3
+ import './tools/filesystem.js';
4
+ import './tools/shell.js';
5
+ import './tools/processes.js';
6
+ import './tools/network.js';
7
+ import './tools/git.js';
8
+ import './tools/packages.js';
9
+ import './tools/system.js';
10
+ export { buildDevKit } from './registry.js';
@@ -0,0 +1,12 @@
1
+ const factories = [];
2
+ export function registerToolFactory(factory) {
3
+ factories.push(factory);
4
+ }
5
+ /**
6
+ * Builds the full DevKit tool set for a given context.
7
+ * Each factory receives the context (working_dir, allowed_commands, etc.)
8
+ * and returns tools with the context captured in closure.
9
+ */
10
+ export function buildDevKit(ctx) {
11
+ return factories.flatMap(factory => factory(ctx));
12
+ }
@@ -0,0 +1,219 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { glob } from 'glob';
6
+ import { truncateOutput, isWithinDir } from '../utils.js';
7
+ import { registerToolFactory } from '../registry.js';
8
+ function resolveSafe(ctx, filePath) {
9
+ // Always resolve relative to working_dir
10
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.working_dir, filePath);
11
+ return resolved;
12
+ }
13
+ function guardPath(ctx, resolved, destructive = false) {
14
+ // If allowed_commands is empty (Merovingian), no path restriction
15
+ if (!destructive)
16
+ return;
17
+ // For Apoc (non-empty allowed_commands) or explicit working_dir set, guard destructive ops
18
+ if (ctx.allowed_commands.length > 0 && !isWithinDir(resolved, ctx.working_dir)) {
19
+ throw new Error(`Path '${resolved}' is outside the working directory '${ctx.working_dir}'. Operation denied.`);
20
+ }
21
+ }
22
+ export function createFilesystemTools(ctx) {
23
+ return [
24
+ tool(async ({ file_path, encoding, start_line, end_line }) => {
25
+ const resolved = resolveSafe(ctx, file_path);
26
+ const content = await fs.readFile(resolved, encoding ?? 'utf8');
27
+ const lines = content.split('\n');
28
+ const sliced = (start_line || end_line)
29
+ ? lines.slice((start_line ?? 1) - 1, end_line).join('\n')
30
+ : content;
31
+ return truncateOutput(sliced);
32
+ }, {
33
+ name: 'read_file',
34
+ description: 'Read the contents of a file. Optionally specify line range.',
35
+ schema: z.object({
36
+ file_path: z.string().describe('Path to the file (absolute or relative to working_dir)'),
37
+ encoding: z.string().optional().describe('File encoding, default utf8'),
38
+ start_line: z.number().int().positive().optional().describe('Start line (1-based)'),
39
+ end_line: z.number().int().positive().optional().describe('End line (inclusive)'),
40
+ }),
41
+ }),
42
+ tool(async ({ file_path, content }) => {
43
+ const resolved = resolveSafe(ctx, file_path);
44
+ guardPath(ctx, resolved, true);
45
+ await fs.ensureDir(path.dirname(resolved));
46
+ await fs.writeFile(resolved, content, 'utf8');
47
+ return JSON.stringify({ success: true, path: resolved });
48
+ }, {
49
+ name: 'write_file',
50
+ description: 'Write content to a file, creating it and parent directories if needed.',
51
+ schema: z.object({
52
+ file_path: z.string(),
53
+ content: z.string().describe('Content to write'),
54
+ }),
55
+ }),
56
+ tool(async ({ file_path, content }) => {
57
+ const resolved = resolveSafe(ctx, file_path);
58
+ guardPath(ctx, resolved, true);
59
+ await fs.ensureDir(path.dirname(resolved));
60
+ await fs.appendFile(resolved, content, 'utf8');
61
+ return JSON.stringify({ success: true, path: resolved });
62
+ }, {
63
+ name: 'append_file',
64
+ description: 'Append content to a file without overwriting existing content.',
65
+ schema: z.object({
66
+ file_path: z.string(),
67
+ content: z.string(),
68
+ }),
69
+ }),
70
+ tool(async ({ file_path }) => {
71
+ const resolved = resolveSafe(ctx, file_path);
72
+ guardPath(ctx, resolved, true);
73
+ await fs.remove(resolved);
74
+ return JSON.stringify({ success: true, deleted: resolved });
75
+ }, {
76
+ name: 'delete_file',
77
+ description: 'Delete a file or directory.',
78
+ schema: z.object({ file_path: z.string() }),
79
+ }),
80
+ tool(async ({ source, destination }) => {
81
+ const src = resolveSafe(ctx, source);
82
+ const dest = resolveSafe(ctx, destination);
83
+ guardPath(ctx, dest, true);
84
+ await fs.ensureDir(path.dirname(dest));
85
+ await fs.move(src, dest, { overwrite: true });
86
+ return JSON.stringify({ success: true, from: src, to: dest });
87
+ }, {
88
+ name: 'move_file',
89
+ description: 'Move or rename a file or directory.',
90
+ schema: z.object({
91
+ source: z.string(),
92
+ destination: z.string(),
93
+ }),
94
+ }),
95
+ tool(async ({ source, destination }) => {
96
+ const src = resolveSafe(ctx, source);
97
+ const dest = resolveSafe(ctx, destination);
98
+ await fs.ensureDir(path.dirname(dest));
99
+ await fs.copy(src, dest);
100
+ return JSON.stringify({ success: true, from: src, to: dest });
101
+ }, {
102
+ name: 'copy_file',
103
+ description: 'Copy a file or directory to a new location.',
104
+ schema: z.object({
105
+ source: z.string(),
106
+ destination: z.string(),
107
+ }),
108
+ }),
109
+ tool(async ({ dir_path, recursive, pattern }) => {
110
+ const resolved = resolveSafe(ctx, dir_path ?? '.');
111
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
112
+ let results = entries.map(e => ({
113
+ name: e.name,
114
+ type: e.isDirectory() ? 'dir' : 'file',
115
+ path: path.join(resolved, e.name),
116
+ }));
117
+ if (pattern) {
118
+ const re = new RegExp(pattern.replace('*', '.*').replace('?', '.'));
119
+ results = results.filter(r => re.test(r.name));
120
+ }
121
+ if (recursive) {
122
+ const subResults = [];
123
+ for (const entry of results.filter(r => r.type === 'dir')) {
124
+ try {
125
+ const subEntries = await fs.readdir(entry.path, { withFileTypes: true });
126
+ subResults.push(...subEntries.map(e => ({
127
+ name: e.name,
128
+ type: e.isDirectory() ? 'dir' : 'file',
129
+ path: path.join(entry.path, e.name),
130
+ })));
131
+ }
132
+ catch { /* skip inaccessible */ }
133
+ }
134
+ results.push(...subResults);
135
+ }
136
+ return truncateOutput(JSON.stringify(results, null, 2));
137
+ }, {
138
+ name: 'list_dir',
139
+ description: 'List files and directories in a path.',
140
+ schema: z.object({
141
+ dir_path: z.string().optional().describe('Directory path, defaults to working_dir'),
142
+ recursive: z.boolean().optional().describe('Include subdirectory contents'),
143
+ pattern: z.string().optional().describe('Filter by name pattern (glob-like)'),
144
+ }),
145
+ }),
146
+ tool(async ({ dir_path }) => {
147
+ const resolved = resolveSafe(ctx, dir_path);
148
+ await fs.ensureDir(resolved);
149
+ return JSON.stringify({ success: true, path: resolved });
150
+ }, {
151
+ name: 'create_dir',
152
+ description: 'Create a directory and all parent directories.',
153
+ schema: z.object({ dir_path: z.string() }),
154
+ }),
155
+ tool(async ({ file_path }) => {
156
+ const resolved = resolveSafe(ctx, file_path);
157
+ const stat = await fs.stat(resolved);
158
+ return JSON.stringify({
159
+ path: resolved,
160
+ size: stat.size,
161
+ isDirectory: stat.isDirectory(),
162
+ isFile: stat.isFile(),
163
+ created: stat.birthtime.toISOString(),
164
+ modified: stat.mtime.toISOString(),
165
+ permissions: stat.mode.toString(8),
166
+ });
167
+ }, {
168
+ name: 'file_info',
169
+ description: 'Get metadata about a file or directory (size, dates, permissions).',
170
+ schema: z.object({ file_path: z.string() }),
171
+ }),
172
+ tool(async ({ pattern, search_path, regex, case_insensitive, max_results }) => {
173
+ const base = resolveSafe(ctx, search_path ?? '.');
174
+ const files = await glob('**/*', { cwd: base, nodir: true, absolute: true });
175
+ const re = new RegExp(pattern, case_insensitive ? 'i' : undefined);
176
+ const results = [];
177
+ for (const file of files) {
178
+ if (results.length >= (max_results ?? 100))
179
+ break;
180
+ try {
181
+ const content = await fs.readFile(file, 'utf8');
182
+ const lines = content.split('\n');
183
+ for (let i = 0; i < lines.length; i++) {
184
+ if (re.test(lines[i])) {
185
+ results.push({ file: path.relative(base, file), line: i + 1, match: lines[i].trim() });
186
+ if (results.length >= (max_results ?? 100))
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ catch { /* skip binary/unreadable files */ }
192
+ }
193
+ return truncateOutput(JSON.stringify(results, null, 2));
194
+ }, {
195
+ name: 'search_in_files',
196
+ description: 'Search for a pattern (regex) inside file contents.',
197
+ schema: z.object({
198
+ pattern: z.string().describe('Regex pattern to search for'),
199
+ search_path: z.string().optional().describe('Directory to search in, defaults to working_dir'),
200
+ regex: z.boolean().optional().describe('Treat pattern as regex (default true)'),
201
+ case_insensitive: z.boolean().optional(),
202
+ max_results: z.number().int().positive().optional().describe('Max matches to return (default 100)'),
203
+ }),
204
+ }),
205
+ tool(async ({ pattern, search_path }) => {
206
+ const base = resolveSafe(ctx, search_path ?? '.');
207
+ const files = await glob(pattern, { cwd: base, absolute: true });
208
+ return truncateOutput(JSON.stringify(files.map(f => path.relative(base, f)), null, 2));
209
+ }, {
210
+ name: 'find_files',
211
+ description: 'Find files matching a glob pattern.',
212
+ schema: z.object({
213
+ pattern: z.string().describe('Glob pattern e.g. "**/*.ts", "src/**/*.json"'),
214
+ search_path: z.string().optional().describe('Base directory, defaults to working_dir'),
215
+ }),
216
+ }),
217
+ ];
218
+ }
219
+ registerToolFactory(createFilesystemTools);