@namzu/sdk 62.7.0 → 62.9.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 (147) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/compaction/plan.d.ts +12 -0
  3. package/dist/compaction/plan.d.ts.map +1 -1
  4. package/dist/compaction/plan.js +39 -11
  5. package/dist/compaction/plan.js.map +1 -1
  6. package/dist/compaction/salience/minhash.d.ts +9 -0
  7. package/dist/compaction/salience/minhash.d.ts.map +1 -1
  8. package/dist/compaction/salience/minhash.js +20 -0
  9. package/dist/compaction/salience/minhash.js.map +1 -1
  10. package/dist/compaction/salience/score.d.ts.map +1 -1
  11. package/dist/compaction/salience/score.js +87 -45
  12. package/dist/compaction/salience/score.js.map +1 -1
  13. package/dist/compaction/salience/working-set.d.ts +14 -0
  14. package/dist/compaction/salience/working-set.d.ts.map +1 -1
  15. package/dist/compaction/salience/working-set.js +84 -33
  16. package/dist/compaction/salience/working-set.js.map +1 -1
  17. package/dist/config/runtime.d.ts +2 -0
  18. package/dist/config/runtime.d.ts.map +1 -1
  19. package/dist/config/runtime.js +14 -0
  20. package/dist/config/runtime.js.map +1 -1
  21. package/dist/manager/session/turn-recorder.d.ts +25 -0
  22. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  23. package/dist/manager/session/turn-recorder.js +231 -31
  24. package/dist/manager/session/turn-recorder.js.map +1 -1
  25. package/dist/public-runtime.d.ts +1 -1
  26. package/dist/public-runtime.d.ts.map +1 -1
  27. package/dist/public-runtime.js +1 -1
  28. package/dist/public-runtime.js.map +1 -1
  29. package/dist/runtime/query/cancelled-before-start.js +1 -1
  30. package/dist/runtime/query/cancelled-before-start.js.map +1 -1
  31. package/dist/runtime/query/checkpoint.js +2 -1
  32. package/dist/runtime/query/checkpoint.js.map +1 -1
  33. package/dist/runtime/query/context.d.ts.map +1 -1
  34. package/dist/runtime/query/context.js +28 -2
  35. package/dist/runtime/query/context.js.map +1 -1
  36. package/dist/runtime/query/executor.d.ts.map +1 -1
  37. package/dist/runtime/query/executor.js +24 -2
  38. package/dist/runtime/query/executor.js.map +1 -1
  39. package/dist/runtime/query/index.d.ts.map +1 -1
  40. package/dist/runtime/query/index.js +4 -1
  41. package/dist/runtime/query/index.js.map +1 -1
  42. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  43. package/dist/runtime/query/iteration/phases/compaction.js +15 -1
  44. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  45. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  46. package/dist/runtime/query/prepare-turn.js +27 -12
  47. package/dist/runtime/query/prepare-turn.js.map +1 -1
  48. package/dist/runtime/query/session-storage.d.ts.map +1 -1
  49. package/dist/runtime/query/session-storage.js +44 -3
  50. package/dist/runtime/query/session-storage.js.map +1 -1
  51. package/dist/sandbox/file-walk.d.ts +7 -1
  52. package/dist/sandbox/file-walk.d.ts.map +1 -1
  53. package/dist/sandbox/file-walk.js +30 -4
  54. package/dist/sandbox/file-walk.js.map +1 -1
  55. package/dist/store/checkpoint/contract.d.ts +12 -0
  56. package/dist/store/checkpoint/contract.d.ts.map +1 -1
  57. package/dist/store/checkpoint/contract.js.map +1 -1
  58. package/dist/store/checkpoint/disk.d.ts.map +1 -1
  59. package/dist/store/checkpoint/disk.js +34 -2
  60. package/dist/store/checkpoint/disk.js.map +1 -1
  61. package/dist/store/checkpoint/prune.d.ts.map +1 -1
  62. package/dist/store/checkpoint/prune.js +6 -0
  63. package/dist/store/checkpoint/prune.js.map +1 -1
  64. package/dist/store/evidence/disk.js +40 -3
  65. package/dist/store/evidence/disk.js.map +1 -1
  66. package/dist/store/session-log/core.d.ts +31 -1
  67. package/dist/store/session-log/core.d.ts.map +1 -1
  68. package/dist/store/session-log/core.js +95 -9
  69. package/dist/store/session-log/core.js.map +1 -1
  70. package/dist/store/session-log/disk.d.ts +2 -0
  71. package/dist/store/session-log/disk.d.ts.map +1 -1
  72. package/dist/store/session-log/disk.js +37 -0
  73. package/dist/store/session-log/disk.js.map +1 -1
  74. package/dist/store/session-log/fold.d.ts +9 -1
  75. package/dist/store/session-log/fold.d.ts.map +1 -1
  76. package/dist/store/session-log/fold.js +18 -2
  77. package/dist/store/session-log/fold.js.map +1 -1
  78. package/dist/store/session-log/incremental.d.ts +31 -0
  79. package/dist/store/session-log/incremental.d.ts.map +1 -0
  80. package/dist/store/session-log/incremental.js +122 -0
  81. package/dist/store/session-log/incremental.js.map +1 -0
  82. package/dist/store/session-log/index.d.ts +1 -0
  83. package/dist/store/session-log/index.d.ts.map +1 -1
  84. package/dist/store/session-log/index.js +1 -0
  85. package/dist/store/session-log/index.js.map +1 -1
  86. package/dist/store/session-log/verified-prefix.d.ts +46 -0
  87. package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
  88. package/dist/store/session-log/verified-prefix.js +233 -0
  89. package/dist/store/session-log/verified-prefix.js.map +1 -0
  90. package/dist/tools/admit-facts.d.ts.map +1 -1
  91. package/dist/tools/admit-facts.js +1 -0
  92. package/dist/tools/admit-facts.js.map +1 -1
  93. package/dist/tools/builtins/bash.d.ts.map +1 -1
  94. package/dist/tools/builtins/bash.js +10 -1
  95. package/dist/tools/builtins/bash.js.map +1 -1
  96. package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
  97. package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
  98. package/dist/tools/builtins/bounded-file-read.js +56 -3
  99. package/dist/tools/builtins/bounded-file-read.js.map +1 -1
  100. package/dist/tools/builtins/grep.d.ts +8 -0
  101. package/dist/tools/builtins/grep.d.ts.map +1 -1
  102. package/dist/tools/builtins/grep.js +147 -20
  103. package/dist/tools/builtins/grep.js.map +1 -1
  104. package/dist/tools/builtins/search-support.d.ts +8 -0
  105. package/dist/tools/builtins/search-support.d.ts.map +1 -1
  106. package/dist/tools/builtins/search-support.js +66 -0
  107. package/dist/tools/builtins/search-support.js.map +1 -1
  108. package/dist/types/session/records.d.ts +80 -1
  109. package/dist/types/session/records.d.ts.map +1 -1
  110. package/dist/types/session/records.js +22 -0
  111. package/dist/types/session/records.js.map +1 -1
  112. package/dist/types/tool/presentation.d.ts +6 -0
  113. package/dist/types/tool/presentation.d.ts.map +1 -1
  114. package/package.json +1 -1
  115. package/src/compaction/plan.ts +53 -11
  116. package/src/compaction/salience/minhash.ts +20 -0
  117. package/src/compaction/salience/score.ts +112 -43
  118. package/src/compaction/salience/working-set.ts +102 -31
  119. package/src/config/runtime.ts +14 -0
  120. package/src/manager/session/turn-recorder.ts +259 -30
  121. package/src/public-runtime.ts +1 -0
  122. package/src/runtime/query/cancelled-before-start.ts +1 -1
  123. package/src/runtime/query/checkpoint.ts +2 -1
  124. package/src/runtime/query/context.ts +33 -6
  125. package/src/runtime/query/executor.ts +24 -2
  126. package/src/runtime/query/index.ts +4 -1
  127. package/src/runtime/query/iteration/phases/compaction.ts +18 -0
  128. package/src/runtime/query/prepare-turn.ts +26 -11
  129. package/src/runtime/query/session-storage.ts +42 -3
  130. package/src/sandbox/file-walk.ts +41 -4
  131. package/src/store/checkpoint/contract.ts +15 -0
  132. package/src/store/checkpoint/disk.ts +34 -2
  133. package/src/store/checkpoint/prune.ts +13 -0
  134. package/src/store/evidence/disk.ts +57 -2
  135. package/src/store/session-log/core.ts +129 -10
  136. package/src/store/session-log/disk.ts +32 -0
  137. package/src/store/session-log/fold.ts +19 -2
  138. package/src/store/session-log/incremental.ts +160 -0
  139. package/src/store/session-log/index.ts +1 -0
  140. package/src/store/session-log/verified-prefix.ts +243 -0
  141. package/src/tools/admit-facts.ts +1 -0
  142. package/src/tools/builtins/bash.ts +11 -1
  143. package/src/tools/builtins/bounded-file-read.ts +59 -2
  144. package/src/tools/builtins/grep.ts +172 -26
  145. package/src/tools/builtins/search-support.ts +62 -0
  146. package/src/types/session/records.ts +25 -0
  147. package/src/types/tool/presentation.ts +6 -0
@@ -0,0 +1,243 @@
1
+ import * as crypto from 'node:crypto'
2
+ import type { SessionId } from '../../types/ids/index.js'
3
+ import type { RecordPointer } from '../../types/session/records.js'
4
+ import { LineSplitter, type LogBytes, type SessionLogEntry } from './chain.js'
5
+ import { SessionTurnState } from './fold.js'
6
+
7
+ /**
8
+ * What this process has already verified of a session log, so that checking
9
+ * it again costs the bytes and not the parsing.
10
+ *
11
+ * A ledger remembers, for every record this process verified IN FULL (schema,
12
+ * parse, hash chain, seq, gen, links: everything `SessionLogChain.accept`
13
+ * checks), the byte length of its line and the SHA-256 of those bytes, plus
14
+ * the head pointer, the `gen`, the session id and the turn state folded to
15
+ * the head. A later claim of the same log reads every byte again and hashes
16
+ * each line; a line whose length and digest equal the remembered ones is
17
+ * byte-identical to a line that already passed every rule, so it passes them
18
+ * again without being parsed. Only what lies after the remembered head is
19
+ * verified in full. Nothing about the file's identity is trusted for
20
+ * soundness: the content is compared. Any difference (a changed byte, a
21
+ * shorter file, a replaced file, a line that no longer splits the same way)
22
+ * makes the caller discard the ledger and verify from byte 0 as before.
23
+ *
24
+ * A ledger is fed only from entries that came out of a full verification and
25
+ * that continue it (`seq` = count + 1 and `prev` equal to the head), so what
26
+ * it holds is a prefix of one valid chain and nothing else. It costs 36 bytes
27
+ * per record.
28
+ */
29
+
30
+ const DIGEST_BYTES = 32
31
+ /** Logs kept; the least recently used is dropped. */
32
+ const LEDGERS_KEPT = 16
33
+ /** A log with more records than this is not remembered (about 72 MB of digests). */
34
+ const MAX_RECORDS = 1 << 21
35
+
36
+ /**
37
+ * The digest of one line as lowercase hex. A string, not a Buffer: a Buffer per
38
+ * line (82,000 of them in a long log) is native memory the collector has to
39
+ * chase, and measured about twice the CPU. The one-shot form where Node has it.
40
+ */
41
+ const hexDigestOf: (line: Uint8Array) => string =
42
+ typeof crypto.hash === 'function'
43
+ ? (line) => crypto.hash('sha256', line, 'hex')
44
+ : (line) => crypto.createHash('sha256').update(line).digest('hex')
45
+
46
+ /** Whether `hex` (64 lowercase hex characters) spells the 32 bytes at `at` in `bytes`. */
47
+ function hexEqualsBytes(hex: string, bytes: Uint8Array, at: number): boolean {
48
+ if (hex.length !== DIGEST_BYTES * 2) return false
49
+ for (let i = 0; i < DIGEST_BYTES; i++) {
50
+ const high = hex.charCodeAt(i * 2)
51
+ const low = hex.charCodeAt(i * 2 + 1)
52
+ const value =
53
+ ((high <= 0x39 ? high - 0x30 : high - 0x57) << 4) | (low <= 0x39 ? low - 0x30 : low - 0x57)
54
+ if (value !== bytes[at + i]) return false
55
+ }
56
+ return true
57
+ }
58
+
59
+ export class VerifiedPrefix {
60
+ #count = 0
61
+ #lengths = new Uint32Array(1024)
62
+ #digests = Buffer.allocUnsafe(1024 * DIGEST_BYTES)
63
+ #head: RecordPointer | null = null
64
+ #gen = 0
65
+ #sessionId: SessionId | undefined
66
+ #end = 0
67
+ #dead = false
68
+ readonly #turns = new SessionTurnState()
69
+
70
+ /** Records remembered. */
71
+ get count(): number {
72
+ return this.#count
73
+ }
74
+ get head(): RecordPointer | null {
75
+ return this.#head
76
+ }
77
+ get gen(): number {
78
+ return this.#gen
79
+ }
80
+ get sessionId(): SessionId | undefined {
81
+ return this.#sessionId
82
+ }
83
+ /** Byte offset just after the last remembered record. */
84
+ get end(): number {
85
+ return this.#end
86
+ }
87
+ /** The turn state at the head, as its own copy. */
88
+ turns(): SessionTurnState {
89
+ return this.#turns.clone()
90
+ }
91
+ /** Lengths and digests as they stand now; entries below `count` never change. */
92
+ get lengths(): Uint32Array {
93
+ return this.#lengths
94
+ }
95
+ get digests(): Buffer {
96
+ return this.#digests
97
+ }
98
+
99
+ /** Whether the remembered record `seq` is exactly `pointer` (length and digest). */
100
+ holds(pointer: RecordPointer): boolean {
101
+ const at = pointer.seq - 1
102
+ if (at < 0 || at >= this.#count) return false
103
+ if (this.#lengths[at] !== pointer.length) return false
104
+ return (
105
+ this.#digests.toString('hex', at * DIGEST_BYTES, (at + 1) * DIGEST_BYTES) === pointer.sha256
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Remember a record that was verified in full. Ignored unless it continues
111
+ * what is held: the next seq, naming the head by its pointer.
112
+ */
113
+ append(entry: SessionLogEntry): void {
114
+ if (this.#dead) return
115
+ const { record, pointer } = entry
116
+ if (pointer.seq !== this.#count + 1 || pointer.offset !== this.#end) return
117
+ const prev = record.prev
118
+ if (this.#head === null ? prev !== null : !samePointer(prev, this.#head)) return
119
+ if (this.#sessionId !== undefined && record.sessionId !== this.#sessionId) return
120
+ if (this.#count >= MAX_RECORDS) {
121
+ this.#dead = true
122
+ return
123
+ }
124
+ if (this.#count === this.#lengths.length) {
125
+ const lengths = new Uint32Array(this.#lengths.length * 2)
126
+ lengths.set(this.#lengths)
127
+ this.#lengths = lengths
128
+ const digests = Buffer.allocUnsafe(this.#digests.length * 2)
129
+ this.#digests.copy(digests)
130
+ this.#digests = digests
131
+ }
132
+ this.#lengths[this.#count] = pointer.length
133
+ this.#digests.write(pointer.sha256, this.#count * DIGEST_BYTES, DIGEST_BYTES, 'hex')
134
+ this.#count += 1
135
+ this.#head = pointer
136
+ this.#gen = record.gen
137
+ this.#end = pointer.offset + pointer.length
138
+ this.#sessionId ??= record.sessionId
139
+ this.#turns.apply(record)
140
+ }
141
+ }
142
+
143
+ function samePointer(a: RecordPointer | null | undefined, b: RecordPointer): boolean {
144
+ return (
145
+ a !== null &&
146
+ a !== undefined &&
147
+ a.seq === b.seq &&
148
+ a.offset === b.offset &&
149
+ a.length === b.length &&
150
+ a.sha256 === b.sha256
151
+ )
152
+ }
153
+
154
+ const ledgers = new Map<string, VerifiedPrefix>()
155
+
156
+ /** The ledger of a log, if this process has one; marks it recently used. */
157
+ export function verifiedPrefixOf(key: string): VerifiedPrefix | undefined {
158
+ const found = ledgers.get(key)
159
+ if (found !== undefined) {
160
+ ledgers.delete(key)
161
+ ledgers.set(key, found)
162
+ }
163
+ return found
164
+ }
165
+
166
+ /** Discard what is remembered of a log and start an empty ledger for it. */
167
+ export function startVerifiedPrefix(key: string): VerifiedPrefix {
168
+ ledgers.delete(key)
169
+ if (ledgers.size >= LEDGERS_KEPT) {
170
+ const oldest = ledgers.keys().next().value
171
+ if (oldest !== undefined) ledgers.delete(oldest)
172
+ }
173
+ const fresh = new VerifiedPrefix()
174
+ ledgers.set(key, fresh)
175
+ return fresh
176
+ }
177
+
178
+ /** Forget every remembered log: what a process that has not read them yet looks like. For tests. */
179
+ export function clearVerifiedPrefixes(): void {
180
+ ledgers.clear()
181
+ }
182
+
183
+ /** How many logs are remembered. For tests. */
184
+ export function verifiedPrefixCount(): number {
185
+ return ledgers.size
186
+ }
187
+
188
+ /**
189
+ * Read the first `count` lines of `bytes` and compare each one's length and
190
+ * SHA-256 with the ledger's. Resolves `true` only when every one is equal.
191
+ * Parses nothing and keeps nothing. Any difference, a log that ends sooner,
192
+ * or a fragment no record could be, resolves `false`: the caller then verifies
193
+ * in full, which says what is wrong.
194
+ */
195
+ export async function rehashPrefix(
196
+ bytes: Pick<LogBytes, 'size'> & {
197
+ stream(offset: number): AsyncIterable<Uint8Array>
198
+ scan?(offset: number, visit: (chunk: Uint8Array) => boolean): Promise<void>
199
+ },
200
+ ledger: VerifiedPrefix,
201
+ count: number,
202
+ ): Promise<boolean> {
203
+ if (count === 0) return true
204
+ if (count > ledger.count) return false
205
+ const lengths = ledger.lengths
206
+ const digests = ledger.digests
207
+ if ((await bytes.size()) < endOfLine(lengths, count)) return false
208
+ const splitter = new LineSplitter(0)
209
+ let index = 0
210
+ let matched = false
211
+ // True once the answer is known. A chunk is read from a buffer the caller of `visit`
212
+ // may reuse, so nothing here keeps a slice of it: a line is hashed where it stands.
213
+ const visit = (chunk: Uint8Array): boolean => {
214
+ for (const { line } of splitter.push(chunk)) {
215
+ if (line.byteLength !== lengths[index]) return true
216
+ if (!hexEqualsBytes(hexDigestOf(line), digests, index * DIGEST_BYTES)) return true
217
+ index += 1
218
+ if (index === count) {
219
+ matched = true
220
+ return true
221
+ }
222
+ }
223
+ return false
224
+ }
225
+ try {
226
+ if (bytes.scan !== undefined) {
227
+ await bytes.scan(0, visit)
228
+ } else {
229
+ for await (const chunk of bytes.stream(0)) if (visit(chunk)) break
230
+ }
231
+ } catch (error) {
232
+ // A fragment longer than any record can be: not the log that was verified.
233
+ if ((error as { name?: string }).name === 'SessionLogIntegrityError') return false
234
+ throw error
235
+ }
236
+ return matched
237
+ }
238
+
239
+ function endOfLine(lengths: Uint32Array, count: number): number {
240
+ let total = 0
241
+ for (let i = 0; i < count; i++) total += lengths[i] as number
242
+ return total
243
+ }
@@ -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
+ }