protoagent 0.1.13 → 0.1.15

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.
@@ -3,7 +3,49 @@ import { existsSync } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { parse, printParseErrorCode } from 'jsonc-parser';
6
+ import { z } from 'zod';
6
7
  import { logger } from './utils/logger.js';
8
+ // ─── Zod Schemas for Runtime Validation ───
9
+ export const RuntimeModelConfigSchema = z.object({
10
+ name: z.string().optional(),
11
+ contextWindow: z.number().optional(),
12
+ inputPricePerMillion: z.number().optional(),
13
+ outputPricePerMillion: z.number().optional(),
14
+ cachedPricePerMillion: z.number().optional(),
15
+ defaultParams: z.record(z.unknown()).optional(),
16
+ });
17
+ export const RuntimeProviderConfigSchema = z.object({
18
+ name: z.string().optional(),
19
+ baseURL: z.string().optional(),
20
+ apiKey: z.string().optional(),
21
+ apiKeyEnvVar: z.string().optional(),
22
+ headers: z.record(z.string()).optional(),
23
+ defaultParams: z.record(z.unknown()).optional(),
24
+ models: z.record(RuntimeModelConfigSchema).optional(),
25
+ });
26
+ export const StdioServerConfigSchema = z.object({
27
+ type: z.literal('stdio'),
28
+ command: z.string(),
29
+ args: z.array(z.string()).optional(),
30
+ env: z.record(z.string()).optional(),
31
+ cwd: z.string().optional(),
32
+ enabled: z.boolean().optional(),
33
+ timeoutMs: z.number().optional(),
34
+ });
35
+ export const HttpServerConfigSchema = z.object({
36
+ type: z.literal('http'),
37
+ url: z.string(),
38
+ headers: z.record(z.string()).optional(),
39
+ enabled: z.boolean().optional(),
40
+ timeoutMs: z.number().optional(),
41
+ });
42
+ export const RuntimeMcpServerConfigSchema = z.union([StdioServerConfigSchema, HttpServerConfigSchema]);
43
+ export const RuntimeConfigFileSchema = z.object({
44
+ providers: z.record(z.any()).optional(),
45
+ mcp: z.object({
46
+ servers: z.record(z.any()).optional(),
47
+ }).optional(),
48
+ });
7
49
  const RESERVED_DEFAULT_PARAM_KEYS = new Set([
8
50
  'model',
9
51
  'messages',
@@ -41,6 +83,10 @@ export function getActiveRuntimeConfigPath() {
41
83
  function isPlainObject(value) {
42
84
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
43
85
  }
86
+ /**
87
+ * Replaces ${ENV_VAR} placeholders in a string with actual environment variable values.
88
+ * Logs a warning if the environment variable is not set (replaces with empty string).
89
+ */
44
90
  function interpolateString(value, sourcePath) {
45
91
  return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_match, envVar) => {
46
92
  const resolved = process.env[envVar];
@@ -51,6 +97,10 @@ function interpolateString(value, sourcePath) {
51
97
  return resolved;
52
98
  });
53
99
  }
100
+ /**
101
+ * Recursively interpolates environment variables in all string values within a config object.
102
+ * Handles nested objects and arrays. Filters out empty header values.
103
+ */
54
104
  function interpolateValue(value, sourcePath) {
55
105
  if (typeof value === 'string') {
56
106
  return interpolateString(value, sourcePath);
@@ -63,6 +113,7 @@ function interpolateValue(value, sourcePath) {
63
113
  for (const [key, entry] of Object.entries(value)) {
64
114
  const interpolated = interpolateValue(entry, sourcePath);
65
115
  if (key === 'headers' && isPlainObject(interpolated)) {
116
+ // Filter out headers with empty values (from unset env vars)
66
117
  const filtered = Object.fromEntries(Object.entries(interpolated).filter(([, headerValue]) => typeof headerValue !== 'string' || headerValue.length > 0));
67
118
  next[key] = filtered;
68
119
  continue;
@@ -73,6 +124,11 @@ function interpolateValue(value, sourcePath) {
73
124
  }
74
125
  return value;
75
126
  }
127
+ /**
128
+ * Removes reserved API parameters from provider and model defaultParams.
129
+ * Prevents users from accidentally overriding critical parameters like
130
+ * 'model', 'messages', 'tools' that are managed by the agentic loop.
131
+ */
76
132
  function sanitizeDefaultParamsInConfig(config) {
77
133
  const nextProviders = Object.fromEntries(Object.entries(config.providers || {}).map(([providerId, provider]) => {
78
134
  const providerDefaultParams = Object.fromEntries(Object.entries(provider.defaultParams || {}).filter(([key]) => {
@@ -112,37 +168,6 @@ function sanitizeDefaultParamsInConfig(config) {
112
168
  providers: nextProviders,
113
169
  };
114
170
  }
115
- function mergeRuntimeConfig(base, overlay) {
116
- const mergedProviders = {
117
- ...(base.providers || {}),
118
- };
119
- for (const [providerId, providerConfig] of Object.entries(overlay.providers || {})) {
120
- const currentProvider = mergedProviders[providerId] || {};
121
- mergedProviders[providerId] = {
122
- ...currentProvider,
123
- ...providerConfig,
124
- models: {
125
- ...(currentProvider.models || {}),
126
- ...(providerConfig.models || {}),
127
- },
128
- };
129
- }
130
- const mergedServers = {
131
- ...(base.mcp?.servers || {}),
132
- };
133
- for (const [serverName, serverConfig] of Object.entries(overlay.mcp?.servers || {})) {
134
- const currentServer = mergedServers[serverName];
135
- mergedServers[serverName] = currentServer && isPlainObject(currentServer)
136
- ? { ...currentServer, ...serverConfig }
137
- : serverConfig;
138
- }
139
- return {
140
- providers: mergedProviders,
141
- mcp: {
142
- servers: mergedServers,
143
- },
144
- };
145
- }
146
171
  async function readRuntimeConfigFile(configPath) {
147
172
  try {
148
173
  const content = await fs.readFile(configPath, 'utf8');
@@ -157,7 +182,13 @@ async function readRuntimeConfigFile(configPath) {
157
182
  if (!isPlainObject(parsed)) {
158
183
  throw new Error(`Failed to parse ${configPath}: top-level value must be an object`);
159
184
  }
160
- return sanitizeDefaultParamsInConfig(interpolateValue(parsed, configPath));
185
+ // Validate against zod schema for better error messages
186
+ const result = RuntimeConfigFileSchema.safeParse(parsed);
187
+ if (!result.success) {
188
+ const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
189
+ throw new Error(`Invalid runtime config in ${configPath}: ${issues}`);
190
+ }
191
+ return sanitizeDefaultParamsInConfig(interpolateValue(result.data, configPath));
161
192
  }
162
193
  catch (error) {
163
194
  if (error?.code === 'ENOENT') {
@@ -176,7 +207,7 @@ export async function loadRuntimeConfig(forceReload = false) {
176
207
  const fileConfig = await readRuntimeConfigFile(configPath);
177
208
  if (fileConfig) {
178
209
  logger.debug('Loaded runtime config', { path: configPath });
179
- loaded = mergeRuntimeConfig(DEFAULT_RUNTIME_CONFIG, fileConfig);
210
+ loaded = fileConfig;
180
211
  }
181
212
  }
182
213
  runtimeConfigCache = loaded;
package/dist/skills.js CHANGED
@@ -184,7 +184,9 @@ async function listSkillResources(skillDir) {
184
184
  }
185
185
  }
186
186
  }
187
- await Promise.all(['scripts', 'references', 'assets'].map((dir) => walk(dir)));
187
+ for (const dir of ['scripts', 'references', 'assets']) {
188
+ await walk(dir);
189
+ }
188
190
  return files.sort();
189
191
  }
190
192
  export async function activateSkill(skillName, options = {}) {
package/dist/sub-agent.js CHANGED
@@ -12,6 +12,7 @@ import { handleToolCall, getAllTools } from './tools/index.js';
12
12
  import { generateSystemPrompt } from './system-prompt.js';
13
13
  import { logger } from './utils/logger.js';
14
14
  import { clearTodos } from './tools/todo.js';
15
+ import { calculateCost } from './utils/cost-tracker.js';
15
16
  export const subAgentTool = {
16
17
  type: 'function',
17
18
  function: {
@@ -62,7 +63,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
62
63
  for (let i = 0; i < maxIterations; i++) {
63
64
  // Check abort at the top of each iteration
64
65
  if (abortSignal?.aborted) {
65
- return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
66
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
66
67
  }
67
68
  let assistantMessage;
68
69
  let hasToolCalls = false;
@@ -99,7 +100,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
99
100
  if (delta?.tool_calls) {
100
101
  hasToolCalls = true;
101
102
  for (const tc of delta.tool_calls) {
102
- const idx = tc.index || 0;
103
+ const idx = tc.index ?? 0;
103
104
  if (!assistantMessage.tool_calls[idx]) {
104
105
  assistantMessage.tool_calls[idx] = {
105
106
  id: '',
@@ -121,25 +122,19 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
121
122
  // Accumulate usage for this iteration
122
123
  const iterationInputTokens = actualUsage?.prompt_tokens || 0;
123
124
  const iterationOutputTokens = actualUsage?.completion_tokens || 0;
125
+ const iterationCachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens || 0;
124
126
  totalInputTokens += iterationInputTokens;
125
127
  totalOutputTokens += iterationOutputTokens;
126
- // Calculate cost if pricing is available
128
+ // Calculate cost if pricing is available (handles cached token discount)
127
129
  if (pricing && (iterationInputTokens > 0 || iterationOutputTokens > 0)) {
128
- const cachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens;
129
- if (cachedTokens && cachedTokens > 0 && pricing.cachedPerToken != null) {
130
- const uncachedTokens = iterationInputTokens - cachedTokens;
131
- totalCost += uncachedTokens * pricing.inputPerToken + cachedTokens * pricing.cachedPerToken + iterationOutputTokens * pricing.outputPerToken;
132
- }
133
- else {
134
- totalCost += iterationInputTokens * pricing.inputPerToken + iterationOutputTokens * pricing.outputPerToken;
135
- }
130
+ totalCost += calculateCost(iterationInputTokens, iterationOutputTokens, pricing, iterationCachedTokens);
136
131
  }
137
132
  }
138
133
  catch (err) {
139
134
  // If aborted during streaming, return gracefully
140
135
  if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
141
136
  logger.debug('Sub-agent aborted during streaming');
142
- return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
137
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
143
138
  }
144
139
  throw err;
145
140
  }
@@ -177,7 +172,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
177
172
  for (const toolCall of assistantMessage.tool_calls) {
178
173
  // Check abort between tool calls
179
174
  if (abortSignal?.aborted) {
180
- return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
175
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
181
176
  }
182
177
  const { name, arguments: argsStr } = toolCall.function;
183
178
  let args;
@@ -216,15 +211,15 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
216
211
  role: 'assistant',
217
212
  content: message.content,
218
213
  });
219
- return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
214
+ return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
220
215
  }
221
216
  // The model produced an empty text response (e.g. it only called tools
222
217
  // and issued no final summary). Log it and return a sentinel so the
223
218
  // parent agent knows the sub-agent finished but had nothing to say.
224
219
  logger.debug('Sub-agent returned empty content', { iteration: i });
225
- return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
220
+ return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
226
221
  }
227
- return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
222
+ return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
228
223
  }
229
224
  finally {
230
225
  op.end();
@@ -27,7 +27,9 @@ export const bashTool = {
27
27
  },
28
28
  },
29
29
  };
30
- // Hard-blocked commands — these CANNOT be run, even with --dangerously-skip-permissions
30
+ // Security: Hard-blocked commands — these CANNOT be run, even with --dangerously-skip-permissions
31
+ // Naive approach: Allow any command with user approval
32
+ // Risk: Some commands are too destructive even with approval (rm -rf /, mkfs)
31
33
  const DANGEROUS_PATTERNS = [
32
34
  'rm -rf /',
33
35
  'sudo',
@@ -37,16 +39,27 @@ const DANGEROUS_PATTERNS = [
37
39
  'mkfs',
38
40
  'fdisk',
39
41
  'format c:',
42
+ '> /dev/sda', // Disk overwrite
43
+ 'of=/dev/sda', // dd to disk
44
+ ':(){ :|:& };:', // Fork bomb
40
45
  ];
41
- // Auto-approved safe commands read-only / informational
46
+ // Security: Allowlist approach for safe commands
47
+ // Naive approach: Blocklist of dangerous patterns - easily bypassed
48
+ // Bypass examples: 's\udo', '$(which sudo)', '/bin/rm', 'sh -c "rm -rf /"'
49
+ // Fix: Allowlist - only these specific commands run without approval
42
50
  const SAFE_COMMANDS = [
43
- 'pwd', 'whoami', 'date',
44
- 'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote',
51
+ 'pwd', 'whoami', 'date', 'uname', 'uptime',
52
+ 'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote -v',
45
53
  'npm list', 'npm ls', 'yarn list',
46
54
  'node --version', 'npm --version', 'python --version', 'python3 --version',
55
+ 'which', 'type',
47
56
  ];
48
- const SHELL_CONTROL_PATTERN = /(^|[^\\])(?:;|&&|\|\||\||>|<|`|\$\(|\*|\?)/;
49
- const UNSAFE_BASH_TOKENS = new Set(['cat', 'head', 'tail', 'grep', 'rg', 'find', 'awk', 'sed', 'sort', 'uniq', 'cut', 'wc', 'tree', 'file', 'dir', 'ls', 'echo', 'which', 'type']);
57
+ // Security: Stricter shell control pattern to catch bypass attempts
58
+ // Naive approach: Simple regex misses many bypasses
59
+ // Bypasses: Newlines, encoded chars, quoted operators, backticks
60
+ const SHELL_CONTROL_PATTERN = /[;&|<>()`$\[\]{}!]/;
61
+ // Commands that read files - require path validation
62
+ const FILE_READING_COMMANDS = new Set(['cat', 'head', 'tail', 'grep', 'rg', 'find', 'awk', 'sed', 'sort', 'uniq', 'cut', 'wc', 'tree', 'file', 'dir', 'ls', 'echo']);
50
63
  function isDangerous(command) {
51
64
  const lower = command.toLowerCase().trim();
52
65
  return DANGEROUS_PATTERNS.some((p) => lower.includes(p));
@@ -95,24 +108,37 @@ async function isSafe(command) {
95
108
  if (!trimmed || hasShellControlOperators(trimmed)) {
96
109
  return false;
97
110
  }
111
+ // Security: Reject commands with newlines (bypass technique)
112
+ if (trimmed.includes('\n') || trimmed.includes('\r')) {
113
+ return false;
114
+ }
115
+ // Security: Reject escape sequences that could hide shell operators
116
+ if (/\\[;&|<>()`$]/.test(trimmed)) {
117
+ return false;
118
+ }
98
119
  const tokens = tokenizeCommand(trimmed);
99
120
  if (!tokens) {
100
121
  return false;
101
122
  }
102
123
  const firstWord = trimmed.split(/\s+/)[0];
103
- if (UNSAFE_BASH_TOKENS.has(firstWord)) {
104
- return false;
105
- }
124
+ // Security: File-reading commands need path validation
125
+ // They can read any file in working directory but not outside
126
+ const needsPathValidation = FILE_READING_COMMANDS.has(firstWord);
127
+ // Check against allowlist of safe commands
106
128
  const matchedSafeCommand = SAFE_COMMANDS.some((safe) => {
107
129
  if (safe.includes(' ')) {
108
130
  return trimmed === safe || trimmed.startsWith(`${safe} `);
109
131
  }
110
132
  return firstWord === safe;
111
133
  });
112
- if (!matchedSafeCommand) {
134
+ if (!matchedSafeCommand && !needsPathValidation) {
113
135
  return false;
114
136
  }
115
- return validateCommandPaths(tokens);
137
+ // Security: Always validate paths for file-reading commands
138
+ if (needsPathValidation) {
139
+ return validateCommandPaths(tokens);
140
+ }
141
+ return true;
116
142
  }
117
143
  export async function runBash(command, timeoutMs = 30_000, sessionId, abortSignal) {
118
144
  // Layer 1: hard block
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
- import { validatePath, getWorkingDirectory } from '../utils/path-validation.js';
10
+ import { validatePath } from '../utils/path-validation.js';
11
+ import { findSimilarPaths } from '../utils/path-suggestions.js';
11
12
  import { requestApproval } from '../utils/approval.js';
12
13
  import { checkReadBefore, recordRead } from '../utils/file-time.js';
13
14
  export const editFileTool = {
@@ -32,54 +33,6 @@ export const editFileTool = {
32
33
  },
33
34
  },
34
35
  };
35
- // ─── Path suggestion helper (mirrors read_file behaviour) ───
36
- async function findSimilarPaths(requestedPath) {
37
- const cwd = getWorkingDirectory();
38
- const segments = requestedPath.split('/').filter(Boolean);
39
- const MAX_DEPTH = 6;
40
- const MAX_ENTRIES = 200;
41
- const MAX_SUGGESTIONS = 3;
42
- const candidates = [];
43
- async function walkSegments(dir, segIndex, currentPath) {
44
- if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
45
- return;
46
- const targetSegment = segments[segIndex].toLowerCase();
47
- let entries;
48
- try {
49
- entries = (await fs.readdir(dir, { withFileTypes: true })).slice(0, MAX_ENTRIES).map(e => e.name);
50
- }
51
- catch {
52
- return;
53
- }
54
- const isLastSegment = segIndex === segments.length - 1;
55
- for (const entry of entries) {
56
- if (candidates.length >= MAX_SUGGESTIONS)
57
- break;
58
- const entryLower = entry.toLowerCase();
59
- if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
60
- continue;
61
- const entryPath = path.join(currentPath, entry);
62
- const fullPath = path.join(dir, entry);
63
- if (isLastSegment) {
64
- try {
65
- await fs.stat(fullPath);
66
- candidates.push(entryPath);
67
- }
68
- catch { /* skip */ }
69
- }
70
- else {
71
- try {
72
- const stat = await fs.stat(fullPath);
73
- if (stat.isDirectory())
74
- await walkSegments(fullPath, segIndex + 1, entryPath);
75
- }
76
- catch { /* skip */ }
77
- }
78
- }
79
- }
80
- await walkSegments(cwd, 0, '');
81
- return candidates;
82
- }
83
36
  /** Strategy 1: Exact verbatim match (current behavior). */
84
37
  const exactReplacer = {
85
38
  name: 'exact',
@@ -314,6 +267,12 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
314
267
  if (staleError)
315
268
  return staleError;
316
269
  }
270
+ // Check file size before reading to avoid OOM on huge files
271
+ const stat = await fs.stat(validated);
272
+ const MAX_EDIT_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
273
+ if (stat.size > MAX_EDIT_FILE_SIZE) {
274
+ return `Error: file is too large to edit (${(stat.size / 1024 / 1024).toFixed(1)} MB, limit is ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MB).`;
275
+ }
317
276
  const content = await fs.readFile(validated, 'utf8');
318
277
  // Use fuzzy match cascade
319
278
  const match = findWithCascade(content, oldString, expectedReplacements);
@@ -4,17 +4,16 @@
4
4
  * When a file is not found, suggests similar paths to help the model
5
5
  * recover from typos without repeated failed attempts.
6
6
  */
7
- import fs from 'node:fs/promises';
8
7
  import { createReadStream } from 'node:fs';
9
8
  import readline from 'node:readline';
10
- import path from 'node:path';
11
- import { validatePath, getWorkingDirectory } from '../utils/path-validation.js';
9
+ import { validatePath } from '../utils/path-validation.js';
10
+ import { findSimilarPaths } from '../utils/path-suggestions.js';
12
11
  import { recordRead } from '../utils/file-time.js';
13
12
  export const readFileTool = {
14
13
  type: 'function',
15
14
  function: {
16
15
  name: 'read_file',
17
- description: 'Read the contents of a file. Returns the file content with line numbers. Use offset and limit to read specific sections of large files.',
16
+ description: 'Read the contents of a file. Use offset and limit to read specific sections of large files.',
18
17
  parameters: {
19
18
  type: 'object',
20
19
  properties: {
@@ -26,68 +25,6 @@ export const readFileTool = {
26
25
  },
27
26
  },
28
27
  };
29
- /**
30
- * Find similar paths when a requested file doesn't exist.
31
- * Walks from the repo root, matching segments case-insensitively.
32
- */
33
- async function findSimilarPaths(requestedPath) {
34
- const cwd = getWorkingDirectory();
35
- const segments = requestedPath.split('/').filter(Boolean);
36
- const MAX_DEPTH = 6;
37
- const MAX_ENTRIES = 200;
38
- const MAX_SUGGESTIONS = 3;
39
- const candidates = [];
40
- async function walkSegments(dir, segIndex, currentPath) {
41
- if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
42
- return;
43
- const targetSegment = segments[segIndex].toLowerCase();
44
- let entries;
45
- try {
46
- const dirEntries = await fs.readdir(dir, { withFileTypes: true });
47
- entries = dirEntries
48
- .slice(0, MAX_ENTRIES)
49
- .map(e => e.name);
50
- }
51
- catch {
52
- return;
53
- }
54
- const isLastSegment = segIndex === segments.length - 1;
55
- for (const entry of entries) {
56
- if (candidates.length >= MAX_SUGGESTIONS)
57
- break;
58
- const entryLower = entry.toLowerCase();
59
- // Match if entry contains the target segment as a substring (case-insensitive)
60
- if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
61
- continue;
62
- const entryPath = path.join(currentPath, entry);
63
- const fullPath = path.join(dir, entry);
64
- if (isLastSegment) {
65
- // Check if this file/dir actually exists
66
- try {
67
- await fs.stat(fullPath);
68
- candidates.push(entryPath);
69
- }
70
- catch {
71
- // skip
72
- }
73
- }
74
- else {
75
- // Continue walking deeper
76
- try {
77
- const stat = await fs.stat(fullPath);
78
- if (stat.isDirectory()) {
79
- await walkSegments(fullPath, segIndex + 1, entryPath);
80
- }
81
- }
82
- catch {
83
- // skip
84
- }
85
- }
86
- }
87
- }
88
- await walkSegments(cwd, 0, '');
89
- return candidates;
90
- }
91
28
  export async function readFile(filePath, offset = 0, limit = 2000, sessionId) {
92
29
  let validated;
93
30
  try {