@namzu/sdk 35.0.0 → 36.0.0

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/provider/token-budget.d.ts.map +1 -1
  3. package/dist/provider/token-budget.js +56 -8
  4. package/dist/provider/token-budget.js.map +1 -1
  5. package/dist/public-runtime.d.ts +2 -2
  6. package/dist/public-runtime.d.ts.map +1 -1
  7. package/dist/public-runtime.js +1 -1
  8. package/dist/public-runtime.js.map +1 -1
  9. package/dist/runtime/query/executor.d.ts +1 -0
  10. package/dist/runtime/query/executor.d.ts.map +1 -1
  11. package/dist/runtime/query/executor.js +50 -10
  12. package/dist/runtime/query/executor.js.map +1 -1
  13. package/dist/runtime/query/index.d.ts +7 -0
  14. package/dist/runtime/query/index.d.ts.map +1 -1
  15. package/dist/runtime/query/index.js +1 -0
  16. package/dist/runtime/query/index.js.map +1 -1
  17. package/dist/runtime/query/iteration/index.d.ts +3 -3
  18. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  19. package/dist/runtime/query/iteration/index.js +31 -6
  20. package/dist/runtime/query/iteration/index.js.map +1 -1
  21. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  22. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  23. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  24. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  25. package/dist/runtime/query/iteration/stream-turn.js +10 -5
  26. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  27. package/dist/runtime/query/prompt.d.ts.map +1 -1
  28. package/dist/runtime/query/prompt.js +14 -4
  29. package/dist/runtime/query/prompt.js.map +1 -1
  30. package/dist/sandbox/file-walk-program.d.ts +3 -0
  31. package/dist/sandbox/file-walk-program.d.ts.map +1 -0
  32. package/dist/sandbox/file-walk-program.js +94 -0
  33. package/dist/sandbox/file-walk-program.js.map +1 -0
  34. package/dist/sandbox/file-walk.d.ts +12 -0
  35. package/dist/sandbox/file-walk.d.ts.map +1 -0
  36. package/dist/sandbox/file-walk.js +429 -0
  37. package/dist/sandbox/file-walk.js.map +1 -0
  38. package/dist/sandbox/index.d.ts +2 -0
  39. package/dist/sandbox/index.d.ts.map +1 -1
  40. package/dist/sandbox/index.js +1 -0
  41. package/dist/sandbox/index.js.map +1 -1
  42. package/dist/sandbox/provider/local.d.ts.map +1 -1
  43. package/dist/sandbox/provider/local.js +9 -0
  44. package/dist/sandbox/provider/local.js.map +1 -1
  45. package/dist/scheduler/completion-inbox.d.ts +3 -2
  46. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  47. package/dist/scheduler/completion-inbox.js +29 -15
  48. package/dist/scheduler/completion-inbox.js.map +1 -1
  49. package/dist/tools/builtins/bash.d.ts.map +1 -1
  50. package/dist/tools/builtins/bash.js +215 -59
  51. package/dist/tools/builtins/bash.js.map +1 -1
  52. package/dist/tools/builtins/glob.d.ts +2 -1
  53. package/dist/tools/builtins/glob.d.ts.map +1 -1
  54. package/dist/tools/builtins/glob.js +95 -103
  55. package/dist/tools/builtins/glob.js.map +1 -1
  56. package/dist/tools/builtins/grep.d.ts.map +1 -1
  57. package/dist/tools/builtins/grep.js +155 -111
  58. package/dist/tools/builtins/grep.js.map +1 -1
  59. package/dist/tools/builtins/ls.js +2 -2
  60. package/dist/tools/builtins/ls.js.map +1 -1
  61. package/dist/types/sandbox/index.d.ts +20 -0
  62. package/dist/types/sandbox/index.d.ts.map +1 -1
  63. package/dist/types/sandbox/index.js.map +1 -1
  64. package/package.json +6 -1
  65. package/src/provider/token-budget.ts +52 -8
  66. package/src/public-runtime.ts +2 -1
  67. package/src/runtime/query/executor.ts +54 -14
  68. package/src/runtime/query/index.ts +8 -0
  69. package/src/runtime/query/iteration/index.ts +29 -9
  70. package/src/runtime/query/iteration/phases/context.ts +2 -0
  71. package/src/runtime/query/iteration/stream-turn.ts +9 -5
  72. package/src/runtime/query/prompt.ts +21 -4
  73. package/src/sandbox/file-walk-program.ts +93 -0
  74. package/src/sandbox/file-walk.ts +463 -0
  75. package/src/sandbox/index.ts +2 -0
  76. package/src/sandbox/provider/local.ts +13 -0
  77. package/src/scheduler/completion-inbox.ts +29 -19
  78. package/src/tools/builtins/bash.ts +230 -58
  79. package/src/tools/builtins/glob.ts +104 -122
  80. package/src/tools/builtins/grep.ts +166 -132
  81. package/src/tools/builtins/ls.ts +2 -2
  82. package/src/types/sandbox/index.ts +21 -0
@@ -1,60 +1,54 @@
1
- import { glob, readFile } from 'node:fs/promises'
2
- import { relative, resolve, sep } from 'node:path'
1
+ import { readFile } from 'node:fs/promises'
2
+ import { isAbsolute, join, relative, sep } from 'node:path'
3
3
  import { z } from 'zod'
4
- import type { Sandbox } from '../../types/sandbox/index.js'
4
+ import { walkFilesLocally } from '../../sandbox/file-walk.js'
5
+ import type { SandboxWalkFilesOptions } from '../../types/sandbox/index.js'
6
+ import { subscribeToAbort } from '../../utils/abort.js'
5
7
  import { defineTool } from '../defineTool.js'
6
- import { matchesGlob } from '../glob-match.js'
7
- import { resolveWithinAny } from '../paths.js'
8
- import { joinPosix, relativePosix, resolveWithinPosix } from '../posix-path.js'
9
-
10
- /**
11
- * Where the files come from.
12
- *
13
- * The host and the sandbox differ in exactly two operations — enumerate
14
- * and read — so that is all this abstracts. Matching, context lines and
15
- * the caps stay in one implementation; duplicating them per source is how
16
- * the two would drift apart, and the sandbox path is the one nobody runs
17
- * by accident.
18
- */
19
- interface FileSource {
20
- /** The root every reported path is relative to. */
21
- readonly root: string
22
- /** True when paths are the sandbox's, not the host's. */
23
- readonly posix?: boolean
24
- list(searchRoot: string, pattern: string): AsyncIterable<string>
25
- read(path: string): Promise<Buffer>
26
- }
8
+ import {
9
+ resolveWithin,
10
+ resolveWithinAny,
11
+ resolveWithinAnyReal,
12
+ resolveWithinReal,
13
+ toolRoots,
14
+ } from '../paths.js'
15
+ import { relativePosix, resolveWithinPosix } from '../posix-path.js'
27
16
 
28
- function hostSource(workingDirectory: string): FileSource {
29
- return {
30
- root: workingDirectory,
31
- async *list(searchRoot, pattern) {
32
- for await (const entry of glob(pattern, { cwd: searchRoot })) {
33
- yield resolve(searchRoot, entry)
34
- }
35
- },
36
- read: (path) => readFile(path),
17
+ /** A remote read has no signal parameter; stop consuming it when the turn ends. */
18
+ async function readWithSignal(read: () => Promise<Buffer>, signal?: AbortSignal): Promise<Buffer> {
19
+ signal?.throwIfAborted()
20
+ if (!signal) return await read()
21
+ let dispose: (() => void) | undefined
22
+ try {
23
+ const cancelled = new Promise<never>((_, reject) => {
24
+ dispose = subscribeToAbort(signal, () => reject(signal.reason))
25
+ })
26
+ // Promise.race observes any late rejection. A late buffer is never searched.
27
+ return await Promise.race([
28
+ Promise.resolve().then(() => {
29
+ signal.throwIfAborted()
30
+ return read()
31
+ }),
32
+ cancelled,
33
+ ])
34
+ } finally {
35
+ dispose?.()
37
36
  }
38
37
  }
39
38
 
40
- function sandboxSource(sandbox: Sandbox): FileSource {
41
- return {
42
- root: sandbox.rootDir,
43
- posix: true,
44
- async *list(searchRoot, pattern) {
45
- for (const entry of await sandbox.listFiles(searchRoot)) {
46
- // Sandbox paths stay in the sandbox's own coordinate system.
47
- // Running them through the host's path module would rewrite a
48
- // POSIX container path into a host-shaped one whenever the
49
- // two disagree, and then hand the model a path its own
50
- // sandbox cannot open.
51
- const absolute = joinPosix(searchRoot, entry.path)
52
- const relPath = relativePosix(sandbox.rootDir, absolute)
53
- if (matchesGlob(relPath, pattern)) yield absolute
54
- }
55
- },
56
- read: (path) => sandbox.readFile(path),
39
+ function relativeInclude(include: string, root: string, sandboxed: boolean): string {
40
+ let pattern = sandboxed ? include : include.split(sep).join('/')
41
+ if (pattern.split('/').includes('..'))
42
+ throw new Error('Include pattern escapes the search directory')
43
+ if (sandboxed ? pattern.startsWith('/') : isAbsolute(include)) {
44
+ const absolute = sandboxed ? resolveWithinPosix(root, pattern) : resolveWithin(root, include)
45
+ pattern = sandboxed
46
+ ? relativePosix(root, absolute)
47
+ : relative(root, absolute).split(sep).join('/')
57
48
  }
49
+ while (pattern.startsWith('./')) pattern = pattern.slice(2)
50
+ // Grep's existing unqualified include is recursive: *.ts means **/*.ts.
51
+ return pattern.includes('/') ? pattern : `**/${pattern}`
58
52
  }
59
53
 
60
54
  const inputSchema = z.object({
@@ -84,6 +78,7 @@ const inputSchema = z.object({
84
78
 
85
79
  const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB — skip binaries/large files
86
80
  const BINARY_CHECK_BYTES = 512
81
+ const MAX_VISITED_ENTRIES = 20_000
87
82
 
88
83
  function isBinaryContent(buffer: Buffer): boolean {
89
84
  const check = buffer.subarray(0, BINARY_CHECK_BYTES)
@@ -96,15 +91,17 @@ function isBinaryContent(buffer: Buffer): boolean {
96
91
  export const GrepTool = defineTool({
97
92
  name: 'grep',
98
93
  description:
99
- 'Searches file contents using a regular expression. Returns matching lines with file paths, line numbers, and optional context lines. Skips binary files.',
94
+ 'Searches file contents using a regular expression. Returns matching lines with file paths, line numbers, and optional context lines. Searches incrementally with a bounded traversal; identifies incomplete searches. Skips binary files, files over 5 MB, and symlinks. Choose the narrowest relevant directory.',
100
95
  inputSchema,
101
96
  category: 'analysis',
102
97
  permissions: ['file_read'],
103
98
  readOnly: true,
104
99
  destructive: false,
105
100
  concurrencySafe: true,
101
+ timeoutMs: 15_000,
106
102
 
107
103
  async execute(input, context) {
104
+ context.abortSignal?.throwIfAborted()
108
105
  const flags = input.case_sensitive ? 'g' : 'gi'
109
106
  let regex: RegExp
110
107
  try {
@@ -117,108 +114,145 @@ export const GrepTool = defineTool({
117
114
  }
118
115
  }
119
116
 
120
- // Inside the sandbox when there is one, on the host otherwise. Only
121
- // the file SOURCE differs — matching, context lines and the caps are
122
- // one implementation, because duplicating the substantive half is
123
- // how the two paths would drift.
124
- //
125
- // This tool read the host filesystem through `node:fs` and
126
- // referenced `context.sandbox` nowhere, so with a container backend
127
- // wired in the sandbox was not a read boundary at all — and grep
128
- // returns file CONTENT, so what leaked was not a listing.
129
- const source: FileSource = context.sandbox
130
- ? sandboxSource(context.sandbox)
131
- : hostSource(context.workingDirectory)
132
-
133
- // Contained, not merely resolved — see `resolveWithin`. Bare
134
- // resolution let `path: "../../.."` read whatever sits above the
135
- // working directory, with no sandbox needed to make it work.
136
- const searchRoot = source.posix
137
- ? resolveWithinPosix(source.root, input.path)
138
- : resolveWithinAny([source.root, ...(context.additionalDirectories ?? [])], input.path)
139
-
140
- // Auto-prepend **/ for simple patterns (e.g. "*.ts" → "**/*.ts")
141
- let filePattern = input.include ?? '**/*'
142
- if (filePattern !== '**/*' && !filePattern.includes('/') && !filePattern.startsWith('**/')) {
143
- filePattern = `**/${filePattern}`
117
+ const sandbox = context.sandbox
118
+ const lexicalRoot = sandbox
119
+ ? resolveWithinPosix(sandbox.rootDir, input.path)
120
+ : resolveWithinAny(toolRoots(context), input.path)
121
+ const root = sandbox ? lexicalRoot : await resolveWithinAnyReal(toolRoots(context), input.path)
122
+ const filePattern = relativeInclude(input.include ?? '**/*', lexicalRoot, sandbox !== undefined)
123
+ const walkSandbox = sandbox?.walkFiles?.bind(sandbox)
124
+ if (sandbox && !walkSandbox) {
125
+ return {
126
+ success: false,
127
+ output: '',
128
+ error:
129
+ 'This sandbox does not support bounded file discovery (Sandbox.walkFiles). Update its adapter before searching file contents.',
130
+ }
131
+ }
132
+ const options: SandboxWalkFilesOptions = {
133
+ pattern: filePattern,
134
+ signal: context.abortSignal,
135
+ maxEntries: MAX_VISITED_ENTRIES + 1,
136
+ maxVisitedEntries: MAX_VISITED_ENTRIES,
137
+ // Preserve existing source behavior: sandbox grep included dotfiles;
138
+ // node's host glob excluded wildcard dotfiles.
139
+ includeHidden: sandbox !== undefined,
144
140
  }
141
+ const entries = walkSandbox ? walkSandbox(root, options) : walkFilesLocally(root, options)
145
142
 
146
143
  const results: string[] = []
147
144
  let totalMatches = 0
148
145
  let filesSearched = 0
149
146
  let filesMatched = 0
150
147
 
151
- for await (const filePath of source.list(searchRoot, filePattern)) {
152
- filesSearched++
153
-
154
- let content: string
155
- try {
156
- const buffer = await source.read(filePath)
157
- if (buffer.length > MAX_FILE_SIZE) continue
158
- if (isBinaryContent(buffer)) continue
159
- content = buffer.toString('utf-8')
160
- } catch {
161
- continue // Skip unreadable files (directories, permissions, etc.)
162
- }
163
-
164
- const lines = content.split('\n')
165
- let fileHasMatch = false
166
-
167
- for (let i = 0; i < lines.length; i++) {
168
- const line = lines[i] ?? ''
169
- regex.lastIndex = 0
170
- if (!regex.test(line)) continue
148
+ let truncated = false
149
+ let failure: string | undefined
150
+ let unreadableFiles = 0
151
+ try {
152
+ for await (const entry of entries) {
153
+ context.abortSignal?.throwIfAborted()
154
+ const filePath = sandbox
155
+ ? resolveWithinPosix(root, entry.path)
156
+ : resolveWithin(root, entry.path)
157
+ if (filesSearched >= MAX_VISITED_ENTRIES) {
158
+ throw new Error(
159
+ `File search stopped after examining ${MAX_VISITED_ENTRIES} files; narrow its root or include pattern.`,
160
+ )
161
+ }
162
+ filesSearched++
163
+ if (entry.size > MAX_FILE_SIZE) continue
171
164
 
172
- if (!fileHasMatch) {
173
- fileHasMatch = true
174
- filesMatched++
165
+ let content: string
166
+ try {
167
+ const buffer = await readWithSignal(
168
+ () =>
169
+ sandbox
170
+ ? sandbox.readFile(filePath)
171
+ : resolveWithinReal(root, filePath).then((contained) =>
172
+ readFile(contained, { signal: context.abortSignal }),
173
+ ),
174
+ context.abortSignal,
175
+ )
176
+ context.abortSignal?.throwIfAborted()
177
+ if (buffer.length > MAX_FILE_SIZE || isBinaryContent(buffer)) continue
178
+ content = buffer.toString('utf-8')
179
+ } catch (error) {
180
+ if (context.abortSignal?.aborted) throw error
181
+ unreadableFiles++
182
+ continue
175
183
  }
176
184
 
177
- // Relative to the root the file came FROM. Reporting a
178
- // host-relative path for a file read inside the sandbox would
179
- // hand the model a string `read` then resolves somewhere else.
180
- const relPath = source.posix
181
- ? `./${relativePosix(source.root, filePath)}`
182
- : `./${relative(source.root, filePath).split(sep).join('/')}`
185
+ const lines = content.split('\n')
186
+ let fileHasMatch = false
183
187
 
184
- if (input.context_lines > 0) {
185
- const start = Math.max(0, i - input.context_lines)
186
- const end = Math.min(lines.length - 1, i + input.context_lines)
188
+ for (let i = 0; i < lines.length; i++) {
189
+ context.abortSignal?.throwIfAborted()
190
+ const line = lines[i] ?? ''
191
+ regex.lastIndex = 0
192
+ if (!regex.test(line)) continue
187
193
 
188
- if (results.length > 0) {
189
- results.push('--')
194
+ if (!fileHasMatch) {
195
+ fileHasMatch = true
196
+ filesMatched++
190
197
  }
191
198
 
192
- for (let j = start; j <= end; j++) {
193
- const prefix = j === i ? ':' : '-'
194
- results.push(`${relPath}${prefix}${j + 1}${prefix}${lines[j]}`)
199
+ const relPath = sandbox
200
+ ? `./${relativePosix(sandbox.rootDir, filePath)}`
201
+ : `./${relative(context.workingDirectory, join(lexicalRoot, relative(root, filePath)))
202
+ .split(sep)
203
+ .join('/')}`
204
+
205
+ if (input.context_lines > 0) {
206
+ const start = Math.max(0, i - input.context_lines)
207
+ const end = Math.min(lines.length - 1, i + input.context_lines)
208
+
209
+ if (results.length > 0) {
210
+ results.push('--')
211
+ }
212
+
213
+ for (let j = start; j <= end; j++) {
214
+ const prefix = j === i ? ':' : '-'
215
+ results.push(`${relPath}${prefix}${j + 1}${prefix}${lines[j]}`)
216
+ }
217
+ } else {
218
+ results.push(`${relPath}:${i + 1}:${line}`)
195
219
  }
196
- } else {
197
- results.push(`${relPath}:${i + 1}:${line}`)
220
+
221
+ totalMatches++
222
+ if (totalMatches >= input.max_results) break
198
223
  }
199
224
 
200
- totalMatches++
201
- if (totalMatches >= input.max_results) break
225
+ if (totalMatches >= input.max_results) {
226
+ // Do not read another file just to prove there are more hits. Reaching
227
+ // the requested cap means completeness was not established.
228
+ truncated = true
229
+ break
230
+ }
202
231
  }
203
-
204
- if (totalMatches >= input.max_results) break
232
+ } catch (error) {
233
+ truncated = true
234
+ failure = (error instanceof Error ? error.message : String(error)) || 'File search failed.'
205
235
  }
206
-
207
- if (totalMatches === 0) {
208
- return {
209
- success: true,
210
- output: `No matches found for pattern "${input.pattern}"`,
211
- data: { totalMatches: 0, filesSearched, filesMatched: 0 },
212
- }
236
+ if (unreadableFiles > 0) {
237
+ truncated = true
238
+ failure ??= `${unreadableFiles} file(s) could not be read.`
213
239
  }
214
-
215
- const summary = `Found ${totalMatches} match(es) in ${filesMatched} file(s) (${filesSearched} files searched)`
216
- const output = `${results.join('\n')}\n\n${summary}`
217
-
240
+ const summary =
241
+ totalMatches > 0
242
+ ? `Found ${totalMatches} match(es) in ${filesMatched} file(s) (${filesSearched} files searched)`
243
+ : truncated
244
+ ? `No matches found in the files searched for pattern "${input.pattern}"`
245
+ : `No matches found for pattern "${input.pattern}"`
246
+ const notice = failure
247
+ ? `[Search incomplete: ${failure}]`
248
+ : truncated
249
+ ? `[Search incomplete: reached max_results (${input.max_results}). Narrow the directory or include pattern for a complete search.]`
250
+ : undefined
218
251
  return {
219
- success: true,
220
- output,
221
- data: { totalMatches, filesSearched, filesMatched },
252
+ success: failure === undefined,
253
+ output: [results.join('\n'), summary, notice].filter(Boolean).join('\n\n'),
254
+ data: { totalMatches, filesSearched, filesMatched, truncated },
255
+ ...(failure ? { error: failure } : {}),
222
256
  }
223
257
  },
224
258
  })
@@ -5,7 +5,7 @@ import type { Sandbox } from '../../types/sandbox/index.js'
5
5
  import type { ToolResult } from '../../types/tool/index.js'
6
6
  import { defineTool } from '../defineTool.js'
7
7
  import { resolveWithinAny, toolRoots } from '../paths.js'
8
- import { joinPosix, relativePosix, resolveWithinPosix } from '../posix-path.js'
8
+ import { relativePosix, resolveWithinPosix } from '../posix-path.js'
9
9
 
10
10
  const inputSchema = z.object({
11
11
  path: z.string().default('.').describe('Directory path to list. Defaults to working directory.'),
@@ -140,7 +140,7 @@ async function listInSandbox(
140
140
  // Relative to the LISTED directory, in the sandbox's own coordinates.
141
141
  const relativePaths: { segments: string[]; size: number }[] = []
142
142
  for (const entry of entries) {
143
- const rel = relativePosix(root, joinPosix(root, entry.path))
143
+ const rel = relativePosix(root, resolveWithinPosix(root, entry.path))
144
144
  if (!rel || rel.startsWith('..')) continue
145
145
  const segments = rel.split('/').filter(Boolean)
146
146
  if (segments.length === 0) continue
@@ -181,6 +181,20 @@ export interface SandboxFileEntry {
181
181
  readonly size: number
182
182
  }
183
183
 
184
+ /** A bounded search; patterns and depth are relative to the requested root. */
185
+ export interface SandboxWalkFilesOptions {
186
+ readonly pattern?: string
187
+ readonly signal?: AbortSignal
188
+ /** Direct children have depth 1. Omitted means no additional depth bound. */
189
+ readonly maxDepth?: number
190
+ /** Positive safe integer bounding emitted matching files. */
191
+ readonly maxEntries: number
192
+ /** Positive safe integer bounding examined directory entries; defaults to 20,000. */
193
+ readonly maxVisitedEntries?: number
194
+ /** Match hidden names through wildcards; explicit dotfile patterns always work. Defaults to false. */
195
+ readonly includeHidden?: boolean
196
+ }
197
+
184
198
  // ---------------------------------------------------------------------------
185
199
  // Sandbox interface — the core abstraction
186
200
  // ---------------------------------------------------------------------------
@@ -294,6 +308,13 @@ export interface Sandbox {
294
308
  * MAY throw for other I/O failures.
295
309
  */
296
310
  listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]>
311
+ /**
312
+ * Lazily enumerate regular files as absolute paths, without following symlinks.
313
+ * Cancellation and iterator return stop traversal. Exceeding the examined-entry
314
+ * budget throws an error with code `ERR_FILE_WALK_LIMIT`; it is not an empty or
315
+ * complete listing. Hosts requiring bounded search must refuse an absent method.
316
+ */
317
+ walkFiles?(rootPath: string, options: SandboxWalkFilesOptions): AsyncIterable<SandboxFileEntry>
297
318
  /**
298
319
  * Release every resource owned by this sandbox.
299
320
  *