@namzu/sdk 62.7.0 → 62.8.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 (73) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  3. package/dist/manager/session/turn-recorder.js +50 -26
  4. package/dist/manager/session/turn-recorder.js.map +1 -1
  5. package/dist/runtime/query/context.d.ts.map +1 -1
  6. package/dist/runtime/query/context.js +28 -2
  7. package/dist/runtime/query/context.js.map +1 -1
  8. package/dist/runtime/query/executor.d.ts.map +1 -1
  9. package/dist/runtime/query/executor.js +24 -2
  10. package/dist/runtime/query/executor.js.map +1 -1
  11. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  12. package/dist/runtime/query/prepare-turn.js +17 -8
  13. package/dist/runtime/query/prepare-turn.js.map +1 -1
  14. package/dist/runtime/query/session-storage.d.ts.map +1 -1
  15. package/dist/runtime/query/session-storage.js +44 -3
  16. package/dist/runtime/query/session-storage.js.map +1 -1
  17. package/dist/sandbox/file-walk.d.ts +7 -1
  18. package/dist/sandbox/file-walk.d.ts.map +1 -1
  19. package/dist/sandbox/file-walk.js +30 -4
  20. package/dist/sandbox/file-walk.js.map +1 -1
  21. package/dist/store/checkpoint/contract.d.ts +12 -0
  22. package/dist/store/checkpoint/contract.d.ts.map +1 -1
  23. package/dist/store/checkpoint/contract.js.map +1 -1
  24. package/dist/store/checkpoint/disk.d.ts.map +1 -1
  25. package/dist/store/checkpoint/disk.js +34 -2
  26. package/dist/store/checkpoint/disk.js.map +1 -1
  27. package/dist/store/checkpoint/prune.d.ts.map +1 -1
  28. package/dist/store/checkpoint/prune.js +6 -0
  29. package/dist/store/checkpoint/prune.js.map +1 -1
  30. package/dist/store/evidence/disk.js +40 -3
  31. package/dist/store/evidence/disk.js.map +1 -1
  32. package/dist/store/session-log/incremental.d.ts +23 -0
  33. package/dist/store/session-log/incremental.d.ts.map +1 -0
  34. package/dist/store/session-log/incremental.js +117 -0
  35. package/dist/store/session-log/incremental.js.map +1 -0
  36. package/dist/tools/admit-facts.d.ts.map +1 -1
  37. package/dist/tools/admit-facts.js +1 -0
  38. package/dist/tools/admit-facts.js.map +1 -1
  39. package/dist/tools/builtins/bash.d.ts.map +1 -1
  40. package/dist/tools/builtins/bash.js +10 -1
  41. package/dist/tools/builtins/bash.js.map +1 -1
  42. package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
  43. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
  44. package/dist/tools/builtins/bounded-file-read.js +56 -3
  45. package/dist/tools/builtins/bounded-file-read.js.map +1 -1
  46. package/dist/tools/builtins/grep.d.ts +8 -0
  47. package/dist/tools/builtins/grep.d.ts.map +1 -1
  48. package/dist/tools/builtins/grep.js +147 -20
  49. package/dist/tools/builtins/grep.js.map +1 -1
  50. package/dist/tools/builtins/search-support.d.ts +8 -0
  51. package/dist/tools/builtins/search-support.d.ts.map +1 -1
  52. package/dist/tools/builtins/search-support.js +66 -0
  53. package/dist/tools/builtins/search-support.js.map +1 -1
  54. package/dist/types/tool/presentation.d.ts +6 -0
  55. package/dist/types/tool/presentation.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/manager/session/turn-recorder.ts +53 -27
  58. package/src/runtime/query/context.ts +33 -6
  59. package/src/runtime/query/executor.ts +24 -2
  60. package/src/runtime/query/prepare-turn.ts +16 -8
  61. package/src/runtime/query/session-storage.ts +42 -3
  62. package/src/sandbox/file-walk.ts +41 -4
  63. package/src/store/checkpoint/contract.ts +15 -0
  64. package/src/store/checkpoint/disk.ts +34 -2
  65. package/src/store/checkpoint/prune.ts +13 -0
  66. package/src/store/evidence/disk.ts +57 -2
  67. package/src/store/session-log/incremental.ts +147 -0
  68. package/src/tools/admit-facts.ts +1 -0
  69. package/src/tools/builtins/bash.ts +11 -1
  70. package/src/tools/builtins/bounded-file-read.ts +59 -2
  71. package/src/tools/builtins/grep.ts +172 -26
  72. package/src/tools/builtins/search-support.ts +62 -0
  73. package/src/types/tool/presentation.ts +6 -0
@@ -0,0 +1,147 @@
1
+ import type { RecordPointer } from '../../types/session/records.js'
2
+ import { type SessionLogEntry, SessionLogIntegrityError } from './chain.js'
3
+ import type { SessionLog } from './core.js'
4
+
5
+ /**
6
+ * Reductions over a whole session log, advanced rather than recomputed.
7
+ *
8
+ * A session log only grows, and every turn asks it the same questions (which
9
+ * checkpoints were committed, which turn ids exist, what the folded context
10
+ * is). Answering each from byte 0 parses and verifies every record the
11
+ * session ever wrote, so a turn cost more the longer the session. A reduction
12
+ * held here remembers the last record it folded and, on the next ask, walks
13
+ * only what was appended after it. `SessionLog.read({ after })` checks that
14
+ * record against the log byte for byte before continuing, so a log that was
15
+ * replaced, truncated or rewritten at that record is noticed and the
16
+ * reduction starts again from byte 0 with the original behaviour (strict reads
17
+ * refuse a broken chain, tolerant reads stop at the break).
18
+ *
19
+ * What a resumed read does not do is re-verify the records before the anchor:
20
+ * they were verified when they were first folded, by this process. A byte
21
+ * flipped in them afterwards, with every later record left as it was, is not
22
+ * noticed by this path; a fresh process (a resume) always starts from byte 0.
23
+ *
24
+ * The cache is keyed by session id and the reduction's own name and mode,
25
+ * holds a few sessions, and is anchored by content, so two logs that share a
26
+ * session id never share a result.
27
+ *
28
+ * Cost that remains: the state of the "every message ever recorded" reduction
29
+ * holds each message the log ever recorded, superseded and compacted ones
30
+ * included, so it grows with the log and not with the live context, and it
31
+ * lives for the life of the process (for the sessions kept). Reading it
32
+ * returns a copy of all of them, which is O(history) per turn. Both are small
33
+ * next to the whole-log walks this file removes, and are left as they are.
34
+ * More than SESSIONS_KEPT sessions in use at once evict one another, and each
35
+ * eviction restarts from byte 0, the cost before this file existed.
36
+ */
37
+
38
+ const SESSIONS_KEPT = 4
39
+
40
+ interface Kept {
41
+ state: unknown
42
+ /** The last record folded; the next read resumes after it. */
43
+ anchor: RecordPointer | undefined
44
+ /** Calls on one reduction take turns: the state is advanced in place. */
45
+ turn: Promise<unknown>
46
+ }
47
+
48
+ const sessions = new Map<string, Map<string, Kept>>()
49
+
50
+ function keptFor(sessionId: string, key: string): Kept {
51
+ let reductions = sessions.get(sessionId)
52
+ if (reductions === undefined) {
53
+ reductions = new Map()
54
+ if (sessions.size >= SESSIONS_KEPT) {
55
+ const oldest = sessions.keys().next().value
56
+ if (oldest !== undefined) sessions.delete(oldest)
57
+ }
58
+ } else {
59
+ sessions.delete(sessionId)
60
+ }
61
+ sessions.set(sessionId, reductions)
62
+ let kept = reductions.get(key)
63
+ if (kept === undefined) {
64
+ kept = { state: undefined, anchor: undefined, turn: Promise.resolve() }
65
+ reductions.set(key, kept)
66
+ }
67
+ return kept
68
+ }
69
+
70
+ export interface IncrementalFold<S, R> {
71
+ /** The reduction's name; one cache slot per name and read mode. */
72
+ readonly name: string
73
+ readonly mode: 'strict' | 'tolerant'
74
+ /** A fresh, empty state. */
75
+ create(): S
76
+ /** Fold one record into the state, in place. */
77
+ apply(state: S, entry: SessionLogEntry): void
78
+ /**
79
+ * The answer, from a state that is current for as long as this call runs.
80
+ * It must not keep or hand out the state itself, which later calls advance.
81
+ */
82
+ result(state: S): Promise<R> | R
83
+ }
84
+
85
+ /**
86
+ * Fold `log` into `fold.create()` through `fold.apply`, resuming from the
87
+ * record the previous call for this session stopped at when the log still
88
+ * holds it, and answer with `fold.result`.
89
+ */
90
+ export async function foldSessionLog<S, R>(
91
+ log: SessionLog,
92
+ fold: IncrementalFold<S, R>,
93
+ ): Promise<R> {
94
+ const kept = keptFor(log.sessionId, `${fold.name}:${fold.mode}`)
95
+ const run = async (): Promise<R> => {
96
+ let state = kept.state as S | undefined
97
+ let anchor = kept.anchor
98
+ for (let attempt = 0; attempt < 2; attempt++) {
99
+ const resuming = state !== undefined && anchor !== undefined
100
+ if (!resuming) {
101
+ state = fold.create()
102
+ anchor = undefined
103
+ }
104
+ let moved = false
105
+ try {
106
+ const walk = log.read({ mode: fold.mode, ...(anchor ? { after: anchor } : {}) })
107
+ let summary: Awaited<ReturnType<typeof walk.next>>
108
+ for (;;) {
109
+ summary = await walk.next()
110
+ if (summary.done) break
111
+ moved = true
112
+ fold.apply(state as S, summary.value)
113
+ }
114
+ const ended = summary.value
115
+ if (resuming && !moved && ended.break?.reason === 'anchor-mismatch') {
116
+ kept.state = undefined
117
+ kept.anchor = undefined
118
+ state = undefined
119
+ anchor = undefined
120
+ continue
121
+ }
122
+ kept.state = state
123
+ kept.anchor = ended.head?.pointer ?? anchor
124
+ return await fold.result(state as S)
125
+ } catch (error) {
126
+ // The state may be half-advanced: never reuse it.
127
+ kept.state = undefined
128
+ kept.anchor = undefined
129
+ if (
130
+ resuming &&
131
+ !moved &&
132
+ error instanceof SessionLogIntegrityError &&
133
+ error.reason === 'anchor-mismatch'
134
+ ) {
135
+ state = undefined
136
+ anchor = undefined
137
+ continue
138
+ }
139
+ throw error
140
+ }
141
+ }
142
+ throw new Error('unreachable')
143
+ }
144
+ const result = kept.turn.then(run, run)
145
+ kept.turn = result.catch(() => undefined)
146
+ return result
147
+ }
@@ -189,6 +189,7 @@ export function admitToolFacts(value: unknown): ToolFacts | undefined {
189
189
  kind: 'failure',
190
190
  reason,
191
191
  subject: text(value.subject),
192
+ note: text(value.note),
192
193
  })
193
194
  }
194
195
  default:
@@ -520,6 +520,11 @@ export function execHostShell(
520
520
 
521
521
  export { dangerousCommandReason }
522
522
 
523
+ /** A reason as a sentence a person reads under a row: first letter up. */
524
+ function sentence(reason: string): string {
525
+ return reason ? `${reason.charAt(0).toUpperCase()}${reason.slice(1)}` : reason
526
+ }
527
+
523
528
  /** The dangerous-pattern check `bash` applies, shared so the `monitor` tool refuses the same lines. */
524
529
  export function isDangerousCommand(command: string): boolean {
525
530
  return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command))
@@ -596,7 +601,12 @@ const BashToolBase = defineTool({
596
601
  success: false,
597
602
  output: '',
598
603
  error: `Dangerous command blocked: "${input.command}". Why: ${dangerousCommandReason(input.command)}. Nothing ran. Use a narrower command that avoids it, or ask the user to run this one.`,
599
- facts: { kind: 'failure', reason: 'blocked', subject: 'bash' },
604
+ facts: {
605
+ kind: 'failure',
606
+ reason: 'blocked',
607
+ subject: 'bash',
608
+ note: sentence(dangerousCommandReason(input.command)),
609
+ },
600
610
  }
601
611
  }
602
612
 
@@ -1,4 +1,4 @@
1
- import { constants } from 'node:fs'
1
+ import { constants, closeSync, fstatSync, openSync, readSync } from 'node:fs'
2
2
  import { open } from 'node:fs/promises'
3
3
 
4
4
  const CHUNK_BYTES = 64 * 1024
@@ -40,10 +40,13 @@ export async function readBoundedFile(
40
40
  maximum: number,
41
41
  signal?: AbortSignal,
42
42
  selectLimit?: (prefix: Buffer) => number,
43
+ extraFlags = 0,
43
44
  ): Promise<Buffer> {
44
45
  // O_NONBLOCK prevents a substituted FIFO from blocking open before fstat.
46
+ // `extraFlags` adds to it, for a caller that also needs the last component
47
+ // refused when it is a link.
45
48
  signal?.throwIfAborted()
46
- const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK)
49
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | extraFlags)
47
50
  try {
48
51
  const info = await file.stat()
49
52
  if (!info.isFile()) throw new Error('Read requires a regular file.')
@@ -78,3 +81,57 @@ function inputTooLarge(limit: number): Error {
78
81
  `File exceeds the bounded read limit (${limit} bytes). Extract a smaller file with a shell command.`,
79
82
  )
80
83
  }
84
+
85
+ /** True where the platform can refuse a final-component link at open time. */
86
+ export const CAN_REFUSE_LINKS = typeof constants.O_NOFOLLOW === 'number' && constants.O_NOFOLLOW > 0
87
+
88
+ /** The flag a caller passes to {@link readBoundedFile} to refuse a final-component link. */
89
+ export const REFUSE_LINK_FLAG: number = CAN_REFUSE_LINKS ? constants.O_NOFOLLOW : 0
90
+
91
+ /**
92
+ * The same bounded read as {@link readBoundedFile} through one synchronous
93
+ * handle: open, fstat, read, close. A link in the last path component is
94
+ * refused at open (`O_NOFOLLOW`), so a link swapped in after a walk listed the
95
+ * file cannot be followed. Callers yield between files; one file is at most
96
+ * `maximum` bytes; a file whose size is over it answers `'too-large'`. With a
97
+ * `scratch` buffer, a file that fits one chunk comes back as a view of it, valid
98
+ * until the next call. Requires {@link CAN_REFUSE_LINKS}.
99
+ *
100
+ * With `syncMaximum`, a file whose size is over it (and within `maximum`) is not
101
+ * read here: the answer is `'large'`, so the caller reads it through
102
+ * {@link readBoundedFile}, which does not hold the event loop for the whole file.
103
+ */
104
+ export function readBoundedFileSync(
105
+ path: string,
106
+ maximum: number,
107
+ scratch?: Buffer,
108
+ syncMaximum = maximum,
109
+ ): Buffer | 'too-large' | 'large' {
110
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
111
+ try {
112
+ const info = fstatSync(fd)
113
+ if (!info.isFile()) throw new Error('Read requires a regular file.')
114
+ if (info.size > maximum) return 'too-large'
115
+ if (info.size > syncMaximum) return 'large'
116
+ const chunks: Buffer[] = []
117
+ let offset = 0
118
+ for (;;) {
119
+ const length = Math.min(CHUNK_BYTES, maximum + 1 - offset)
120
+ const buffer =
121
+ offset === 0 && scratch && scratch.length >= length ? scratch : Buffer.allocUnsafe(length)
122
+ let used = 0
123
+ while (used < length) {
124
+ const bytesRead = readSync(fd, buffer, used, length - used, offset + used)
125
+ if (bytesRead === 0) break
126
+ used += bytesRead
127
+ }
128
+ offset += used
129
+ if (offset > maximum) throw inputTooLarge(maximum)
130
+ chunks.push(buffer.subarray(0, used))
131
+ if (used < length)
132
+ return chunks.length === 1 ? (chunks[0] as Buffer) : Buffer.concat(chunks, offset)
133
+ }
134
+ } finally {
135
+ closeSync(fd)
136
+ }
137
+ }
@@ -1,5 +1,5 @@
1
1
  import { stat } from 'node:fs/promises'
2
- import { isAbsolute, join, relative, sep } from 'node:path'
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { walkFilesLocally } from '../../sandbox/file-walk.js'
5
5
  import type { SandboxWalkFilesOptions, SandboxWalkSummary } from '../../types/sandbox/index.js'
@@ -7,6 +7,7 @@ import type { ToolContext } from '../../types/tool/index.js'
7
7
  import { subscribeToAbort } from '../../utils/abort.js'
8
8
  import { defineTool } from '../defineTool.js'
9
9
  import {
10
+ canonicalize,
10
11
  resolveWithin,
11
12
  resolveWithinAny,
12
13
  resolveWithinAnyReal,
@@ -14,7 +15,13 @@ import {
14
15
  toolRoots,
15
16
  } from '../paths.js'
16
17
  import { relativePosix, resolveWithinPosix } from '../posix-path.js'
17
- import { readBoundedChunks, readBoundedFile } from './bounded-file-read.js'
18
+ import {
19
+ CAN_REFUSE_LINKS,
20
+ REFUSE_LINK_FLAG,
21
+ readBoundedChunks,
22
+ readBoundedFile,
23
+ readBoundedFileSync,
24
+ } from './bounded-file-read.js'
18
25
  import {
19
26
  MAX_FILES_SEARCHED,
20
27
  SEARCH_BUDGET_MS,
@@ -24,6 +31,7 @@ import {
24
31
  isReadDenied,
25
32
  openSearchBudget,
26
33
  plural,
34
+ readAhead,
27
35
  stoppedNotice,
28
36
  } from './search-support.js'
29
37
 
@@ -49,6 +57,125 @@ async function readWithSignal(read: () => Promise<Buffer>, signal?: AbortSignal)
49
57
  }
50
58
  }
51
59
 
60
+ /** Longest the local read loop holds the event loop before it yields. */
61
+ const READ_SLICE_MS = 8
62
+
63
+ /**
64
+ * Largest file the local reader reads synchronously, by the size on the open
65
+ * handle. One synchronous read of a file is one uninterrupted block, which on a
66
+ * slow mount can last tens of milliseconds; a larger file is read through the
67
+ * asynchronous bounded read instead, which lets the event loop run between
68
+ * chunks.
69
+ */
70
+ export const LOCAL_SYNC_READ_MAX_BYTES = 64 * 1024
71
+
72
+ /**
73
+ * Reads files of a local search root. Each directory is resolved to its real
74
+ * path once and checked against the real root once; a file is then one open
75
+ * (links refused at the last component), fstat, read and close, all
76
+ * synchronous, with a yield to the event loop every few milliseconds. That is
77
+ * the same containment decision as resolving every file, because the walk
78
+ * lists only regular files in real directories and the open refuses a file
79
+ * that has become a link. Not used for a sandbox, nor where the platform cannot
80
+ * refuse links.
81
+ */
82
+ function createLocalReader(root: string, signal: AbortSignal | undefined) {
83
+ const scratch = Buffer.allocUnsafe(64 * 1024)
84
+ const rootRealPromise = canonicalize(resolve(root))
85
+ const directories = new Map<string, Promise<string>>()
86
+ let sliceStart = performance.now()
87
+ const realDirectory = (directory: string): Promise<string> => {
88
+ let known = directories.get(directory)
89
+ if (!known) {
90
+ known = canonicalize(directory)
91
+ directories.set(directory, known)
92
+ }
93
+ return known
94
+ }
95
+ let rootReal: string | undefined
96
+ return {
97
+ async read(
98
+ filePath: string,
99
+ ): Promise<{ buffer: Buffer | 'too-large' | 'gone'; contained: string }> {
100
+ if (performance.now() - sliceStart > READ_SLICE_MS) {
101
+ await new Promise<void>((done) => setImmediate(done))
102
+ sliceStart = performance.now()
103
+ }
104
+ signal?.throwIfAborted()
105
+ const directory = dirname(filePath)
106
+ const real = await realDirectory(directory)
107
+ rootReal ??= await rootRealPromise
108
+ const contained =
109
+ real === directory ? filePath : join(real, filePath.slice(directory.length + 1))
110
+ if (!(contained.startsWith(rootReal + sep) || contained === rootReal)) {
111
+ const rel = relative(rootReal, contained)
112
+ if (rel.startsWith('..') || isAbsolute(rel)) {
113
+ throw new Error('Path escapes the working directory through a link.')
114
+ }
115
+ }
116
+ signal?.throwIfAborted()
117
+ try {
118
+ const read = readBoundedFileSync(
119
+ contained,
120
+ MAX_FILE_SIZE,
121
+ scratch,
122
+ LOCAL_SYNC_READ_MAX_BYTES,
123
+ )
124
+ if (read !== 'large') return { buffer: read, contained }
125
+ // The handle that measured the file is closed; the asynchronous read opens it
126
+ // again with the same flags (links refused) and repeats every check.
127
+ const large = await readBoundedFile(
128
+ contained,
129
+ MAX_FILE_SIZE,
130
+ signal,
131
+ undefined,
132
+ REFUSE_LINK_FLAG,
133
+ )
134
+ sliceStart = performance.now()
135
+ return { buffer: large, contained }
136
+ } catch (error) {
137
+ // Gone since the walk listed it: absent, as a walk that ran later would find it.
138
+ const code = (error as NodeJS.ErrnoException).code
139
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { buffer: 'gone', contained }
140
+ throw error
141
+ }
142
+ },
143
+ }
144
+ }
145
+
146
+ /** A walked path that is already inside `root` in normal form, or null when it needs the full check. */
147
+ const PLAIN_TAIL = /(^|\/)\.{1,2}(\/|$)|\/\/|\/$/
148
+
149
+ /**
150
+ * The fast form of what `resolveWithin` and the display path compute for the
151
+ * walk's own entries on a POSIX host: a path the walk built under `root` is
152
+ * already normal, so it is returned as it is and its display name is a
153
+ * prefix plus the tail. Anything else, and every other platform, takes the
154
+ * full computation.
155
+ */
156
+ function createWalkedPaths(root: string, lexicalRoot: string, workingDirectory: string) {
157
+ if (sep !== '/') return undefined
158
+ const prefix = root === '/' ? '/' : `${root}/`
159
+ const rootRel = relative(workingDirectory, lexicalRoot)
160
+ const display = rootRel.startsWith('..') || isAbsolute(rootRel) ? undefined : rootRel
161
+ return {
162
+ within(path: string): string {
163
+ if (path.startsWith(prefix) && path.length > prefix.length) {
164
+ if (!PLAIN_TAIL.test(path.slice(prefix.length))) return path
165
+ }
166
+ return resolveWithin(root, path)
167
+ },
168
+ displayName(path: string): string | undefined {
169
+ if (display === undefined || !path.startsWith(prefix) || path.length === prefix.length) {
170
+ return undefined
171
+ }
172
+ const tail = path.slice(prefix.length)
173
+ if (PLAIN_TAIL.test(tail)) return undefined
174
+ return display === '' ? tail : `${display}/${tail}`
175
+ },
176
+ }
177
+ }
178
+
52
179
  function relativeInclude(include: string, root: string, sandboxed: boolean): string {
53
180
  let pattern = sandboxed ? include : include.split(sep).join('/')
54
181
  if (pattern.split('/').includes('..'))
@@ -201,11 +328,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB — skip binaries/large files
201
328
  const BINARY_CHECK_BYTES = 512
202
329
 
203
330
  function isBinaryContent(buffer: Buffer): boolean {
204
- const check = buffer.subarray(0, BINARY_CHECK_BYTES)
205
- for (const byte of check) {
206
- if (byte === 0) return true
207
- }
208
- return false
331
+ return buffer.subarray(0, BINARY_CHECK_BYTES).includes(0)
209
332
  }
210
333
 
211
334
  export const GrepTool = defineTool({
@@ -291,7 +414,11 @@ export const GrepTool = defineTool({
291
414
  walkEnd = value
292
415
  },
293
416
  }
294
- const entries = walkSandbox ? walkSandbox(root, options) : walkFilesLocally(root, options)
417
+ // A local walk is read ahead so its directory reads overlap with matching; a
418
+ // sandbox's walk is left as the adapter produces it.
419
+ const entries = walkSandbox
420
+ ? walkSandbox(root, options)
421
+ : readAhead(walkFilesLocally(root, { ...options, withoutSizes: CAN_REFUSE_LINKS }))
295
422
 
296
423
  // `execute` is reachable without the schema's defaults.
297
424
  const mode = input.output_mode ?? 'files_with_matches'
@@ -321,12 +448,16 @@ export const GrepTool = defineTool({
321
448
  const filesToCollect =
322
449
  headLimit > 0 ? offset + Math.max(headLimit * 4, 1000) : Number.POSITIVE_INFINITY
323
450
  let enough = false
451
+ const walked = sandbox
452
+ ? undefined
453
+ : createWalkedPaths(root, lexicalRoot, context.workingDirectory)
454
+ const localReader = !sandbox && CAN_REFUSE_LINKS ? createLocalReader(root, signal) : undefined
324
455
  try {
325
456
  for await (const entry of entries) {
326
457
  signal?.throwIfAborted()
327
458
  const filePath = sandbox
328
459
  ? resolveWithinPosix(root, entry.path)
329
- : resolveWithin(root, entry.path)
460
+ : (walked?.within(entry.path) ?? resolveWithin(root, entry.path))
330
461
  if (filesSearched >= MAX_FILES_SEARCHED) {
331
462
  fileLimit = true
332
463
  break
@@ -339,21 +470,34 @@ export const GrepTool = defineTool({
339
470
  let content: string
340
471
  let contained: string | undefined
341
472
  try {
342
- const buffer = await readWithSignal(
343
- () =>
344
- sandbox
345
- ? readBoundedChunks(
346
- (offset, length) =>
347
- sandbox.readFile(filePath, { offset, length, signal: signal }),
348
- MAX_FILE_SIZE,
349
- signal,
350
- )
351
- : resolveWithinReal(root, filePath).then((path) => {
352
- contained = path
353
- return readBoundedFile(path, MAX_FILE_SIZE, signal)
354
- }),
355
- signal,
356
- )
473
+ let buffer: Buffer
474
+ if (localReader) {
475
+ const read = await localReader.read(filePath)
476
+ contained = read.contained
477
+ // Over the cap by the size on the open handle: skipped, not unreadable.
478
+ if (read.buffer === 'too-large') continue
479
+ // Gone since the walk listed it: as if the walk had not listed it.
480
+ if (read.buffer === 'gone') {
481
+ filesSearched--
482
+ continue
483
+ }
484
+ buffer = read.buffer
485
+ } else
486
+ buffer = await readWithSignal(
487
+ () =>
488
+ sandbox
489
+ ? readBoundedChunks(
490
+ (offset, length) =>
491
+ sandbox.readFile(filePath, { offset, length, signal: signal }),
492
+ MAX_FILE_SIZE,
493
+ signal,
494
+ )
495
+ : resolveWithinReal(root, filePath).then((path) => {
496
+ contained = path
497
+ return readBoundedFile(path, MAX_FILE_SIZE, signal)
498
+ }),
499
+ signal,
500
+ )
357
501
  signal?.throwIfAborted()
358
502
  if (buffer.length > MAX_FILE_SIZE || isBinaryContent(buffer)) continue
359
503
  content = buffer.toString('utf-8')
@@ -367,7 +511,8 @@ export const GrepTool = defineTool({
367
511
  let fileMatches = 0
368
512
  const relPath = sandbox
369
513
  ? relativePosix(sandbox.rootDir, filePath)
370
- : hostDisplayPath(context.workingDirectory, join(lexicalRoot, relative(root, filePath)))
514
+ : (walked?.displayName(filePath) ??
515
+ hostDisplayPath(context.workingDirectory, join(lexicalRoot, relative(root, filePath))))
371
516
 
372
517
  if (mode === 'content' && input.only_matching) {
373
518
  for (const fragment of matchingFragments(content, regex, multiline)) {
@@ -580,7 +725,8 @@ function lineHits(
580
725
  ): [number, number][] {
581
726
  const hits: [number, number][] = []
582
727
  for (let i = 0; i < lines.length; i++) {
583
- context.abortSignal?.throwIfAborted()
728
+ // A cancellation is seen within a few hundred lines; per line it cost more than the match.
729
+ if ((i & 255) === 0) context.abortSignal?.throwIfAborted()
584
730
  regex.lastIndex = 0
585
731
  if (regex.test(lines[i] ?? '')) hits.push([i, i])
586
732
  }
@@ -105,3 +105,65 @@ export function isReadDenied(
105
105
  if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) return true
106
106
  return context.readDenied(joinPath(context.workingDirectory, relative))
107
107
  }
108
+
109
+ /**
110
+ * Pulls from `source` ahead of the consumer, up to `ahead` items, so a walk's
111
+ * directory reads overlap with the matching of files already listed. Order is
112
+ * unchanged. An error from the source is thrown to the consumer after the
113
+ * items listed before it; when the consumer stops, the source is closed (its
114
+ * own cleanup runs) before this returns.
115
+ */
116
+ export async function* readAhead<T>(source: AsyncIterable<T>, ahead = 256): AsyncGenerator<T> {
117
+ const iterator = source[Symbol.asyncIterator]()
118
+ const buffer: T[] = []
119
+ let finished = false
120
+ let failure: { error: unknown } | undefined
121
+ let stop = false
122
+ let wakeConsumer: (() => void) | undefined
123
+ let wakeProducer: (() => void) | undefined
124
+ const notifyConsumer = () => {
125
+ const wake = wakeConsumer
126
+ wakeConsumer = undefined
127
+ wake?.()
128
+ }
129
+ const producer = (async () => {
130
+ try {
131
+ while (!stop) {
132
+ if (buffer.length >= ahead) {
133
+ await new Promise<void>((resolve) => {
134
+ wakeProducer = resolve
135
+ })
136
+ continue
137
+ }
138
+ const next = await iterator.next()
139
+ if (next.done) break
140
+ buffer.push(next.value)
141
+ notifyConsumer()
142
+ }
143
+ } catch (error) {
144
+ failure = { error }
145
+ }
146
+ finished = true
147
+ notifyConsumer()
148
+ })()
149
+ try {
150
+ for (;;) {
151
+ if (buffer.length > 0) {
152
+ const item = buffer.shift() as T
153
+ wakeProducer?.()
154
+ yield item
155
+ continue
156
+ }
157
+ if (failure) throw failure.error
158
+ if (finished) return
159
+ await new Promise<void>((resolve) => {
160
+ wakeConsumer = resolve
161
+ })
162
+ }
163
+ } finally {
164
+ stop = true
165
+ wakeProducer?.()
166
+ await producer
167
+ await iterator.return?.()
168
+ }
169
+ }
@@ -220,6 +220,12 @@ export type ToolFacts =
220
220
  readonly reason: ToolFailureReason
221
221
  /** What it was about: a path, or the tool's name for `unknown_tool`. */
222
222
  readonly subject?: string
223
+ /**
224
+ * Why a refused call did not run, as one plain line the kernel wrote
225
+ * (a policy rule, the command guard). Absent when no reason was
226
+ * recorded; a host shows nothing for it rather than a guess.
227
+ */
228
+ readonly note?: string
223
229
  }
224
230
 
225
231
  /** The same shapes, for what a call produced. */