@smthrs/std 1.0.0-rc.5 → 1.0.0-rc.7

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 (92) hide show
  1. package/README.md +39 -39
  2. package/dist/cjs/Bash.d.ts +10 -0
  3. package/dist/cjs/Bash.d.ts.map +1 -1
  4. package/dist/cjs/Bash.js +13 -12
  5. package/dist/cjs/Bash.js.map +3 -3
  6. package/dist/cjs/ExaWebSearch.js +1 -1
  7. package/dist/cjs/ExaWebSearch.js.map +1 -1
  8. package/dist/cjs/Fetch.d.ts +1 -1
  9. package/dist/cjs/Fetch.d.ts.map +1 -1
  10. package/dist/cjs/Fetch.js +1 -1
  11. package/dist/cjs/Fetch.js.map +2 -2
  12. package/dist/cjs/HttpPost.d.ts +1 -1
  13. package/dist/cjs/HttpPost.d.ts.map +1 -1
  14. package/dist/cjs/HttpPost.js +1 -1
  15. package/dist/cjs/HttpPost.js.map +2 -2
  16. package/dist/cjs/Lsp.d.ts +1 -1
  17. package/dist/cjs/Manifest.d.ts +8 -8
  18. package/dist/cjs/NodeLanguageServer.js +1 -1
  19. package/dist/cjs/NodeLanguageServer.js.map +1 -1
  20. package/dist/cjs/WebFetch.d.ts.map +1 -1
  21. package/dist/cjs/WebFetch.js +1 -1
  22. package/dist/cjs/WebFetch.js.map +2 -2
  23. package/dist/cjs/internal/Exec.d.ts +14 -7
  24. package/dist/cjs/internal/Exec.d.ts.map +1 -1
  25. package/dist/cjs/internal/Exec.js +66 -28
  26. package/dist/cjs/internal/Exec.js.map +2 -2
  27. package/dist/cjs/internal/Http.d.ts +1 -1
  28. package/dist/cjs/internal/Http.d.ts.map +1 -1
  29. package/dist/cjs/internal/Http.js +1 -1
  30. package/dist/cjs/internal/Http.js.map +2 -2
  31. package/dist/cjs/internal/HttpNetwork.d.ts +2 -2
  32. package/dist/cjs/internal/HttpNetwork.d.ts.map +1 -1
  33. package/dist/cjs/internal/HttpNetwork.js +3 -3
  34. package/dist/cjs/internal/HttpNetwork.js.map +1 -1
  35. package/dist/cjs/internal/Text.d.ts +11 -0
  36. package/dist/cjs/internal/Text.d.ts.map +1 -1
  37. package/dist/cjs/internal/Text.js +3 -0
  38. package/dist/cjs/internal/Text.js.map +2 -2
  39. package/dist/esm/Bash.d.ts +10 -0
  40. package/dist/esm/Bash.d.ts.map +1 -1
  41. package/dist/esm/Bash.js +22 -13
  42. package/dist/esm/Bash.js.map +1 -1
  43. package/dist/esm/ExaWebSearch.js +1 -1
  44. package/dist/esm/ExaWebSearch.js.map +1 -1
  45. package/dist/esm/Fetch.d.ts +1 -1
  46. package/dist/esm/Fetch.d.ts.map +1 -1
  47. package/dist/esm/Fetch.js +1 -1
  48. package/dist/esm/Fetch.js.map +1 -1
  49. package/dist/esm/HttpPost.d.ts +1 -1
  50. package/dist/esm/HttpPost.d.ts.map +1 -1
  51. package/dist/esm/HttpPost.js +1 -1
  52. package/dist/esm/HttpPost.js.map +1 -1
  53. package/dist/esm/Lsp.d.ts +1 -1
  54. package/dist/esm/Manifest.d.ts +8 -8
  55. package/dist/esm/NodeLanguageServer.js +1 -1
  56. package/dist/esm/NodeLanguageServer.js.map +1 -1
  57. package/dist/esm/WebFetch.d.ts.map +1 -1
  58. package/dist/esm/WebFetch.js +1 -1
  59. package/dist/esm/WebFetch.js.map +1 -1
  60. package/dist/esm/internal/Exec.d.ts +14 -7
  61. package/dist/esm/internal/Exec.d.ts.map +1 -1
  62. package/dist/esm/internal/Exec.js +87 -22
  63. package/dist/esm/internal/Exec.js.map +1 -1
  64. package/dist/esm/internal/Http.d.ts +1 -1
  65. package/dist/esm/internal/Http.d.ts.map +1 -1
  66. package/dist/esm/internal/Http.js +1 -1
  67. package/dist/esm/internal/Http.js.map +1 -1
  68. package/dist/esm/internal/HttpNetwork.d.ts +2 -2
  69. package/dist/esm/internal/HttpNetwork.d.ts.map +1 -1
  70. package/dist/esm/internal/HttpNetwork.js +3 -3
  71. package/dist/esm/internal/HttpNetwork.js.map +1 -1
  72. package/dist/esm/internal/Text.d.ts +11 -0
  73. package/dist/esm/internal/Text.d.ts.map +1 -1
  74. package/dist/esm/internal/Text.js +11 -0
  75. package/dist/esm/internal/Text.js.map +1 -1
  76. package/docs/api.md +1 -1
  77. package/docs/concepts/limits-and-disclosure.md +8 -5
  78. package/docs/guides/run-a-shell-command.md +16 -6
  79. package/docs/installation.md +1 -1
  80. package/docs/reference/flows.md +2 -2
  81. package/docs/troubleshooting.md +3 -3
  82. package/package.json +10 -10
  83. package/src/Bash.ts +25 -13
  84. package/src/ExaWebSearch.ts +1 -1
  85. package/src/Fetch.ts +1 -1
  86. package/src/HttpPost.ts +1 -1
  87. package/src/NodeLanguageServer.ts +1 -1
  88. package/src/WebFetch.ts +1 -1
  89. package/src/internal/Exec.ts +126 -44
  90. package/src/internal/Http.ts +1 -1
  91. package/src/internal/HttpNetwork.ts +3 -3
  92. package/src/internal/Text.ts +15 -0
@@ -241,9 +241,9 @@ envelope `bash` declares for every call.
241
241
  | Output | Type | Meaning |
242
242
  | ------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------- |
243
243
  | `exitCode` | number | The command's exit code, including non-zero. |
244
- | `stdout`, `stderr` | string | Captured streams, tail kept when truncated. |
244
+ | `stdout`, `stderr` | string | Captured streams; start and end kept when truncated. |
245
245
  | `stdoutTruncated`, `stderrTruncated` | boolean | Whether that stream is a fragment. |
246
- | `stdoutDroppedBytes`, `stderrDroppedBytes` | number | Bytes omitted from the start. |
246
+ | `stdoutDroppedBytes`, `stderrDroppedBytes` | number | Bytes omitted from the middle. |
247
247
  | `invalidProbe` | `Probe.InvalidProbe`, optional | Reserved for independently confirmed launch failures; not inferred from exit code. |
248
248
 
249
249
  `Bash.Input` and `Bash.Output` are also exported as TypeScript types alongside
@@ -260,11 +260,11 @@ runs. The server would have run workspace-written code on the host.
260
260
  absolute path, not a launcher. For typescript-language-server, also pin
261
261
  `initializationOptions: { tsserver: { path } }` to a host copy.
262
262
 
263
- ## A write of captured output replaced a file with a log tail
263
+ ## A write of captured output replaced a file with a fragment
264
264
 
265
265
  **What happened.** A truncated `stdout`, `stderr`, or `tail` was written to a
266
- file. Those fields are fragments: the capture keeps the tail of what the process
267
- printed, not the whole of it.
266
+ file. Those fields are fragments: `bash` keeps the start and the end of what the
267
+ process printed and `test` keeps the tail, never the whole of it.
268
268
 
269
269
  **What to change.** Check `stdoutTruncated`, `stderrTruncated`, or
270
270
  `tailTruncated` before writing captured output anywhere.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/std",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.5",
4
+ "version": "1.0.0-rc.7",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "agent"
@@ -401,21 +401,21 @@
401
401
  "tag": "next"
402
402
  },
403
403
  "dependencies": {
404
- "@smthrs/capability": "1.0.0-rc.5",
405
- "@smthrs/control": "1.0.0-rc.5",
406
- "@smthrs/core": "1.0.0-rc.5",
407
- "@smthrs/kernel": "1.0.0-rc.5",
408
- "@smthrs/model": "1.0.0-rc.5"
404
+ "@smthrs/capability": "1.0.0-rc.7",
405
+ "@smthrs/control": "1.0.0-rc.7",
406
+ "@smthrs/core": "1.0.0-rc.7",
407
+ "@smthrs/kernel": "1.0.0-rc.7",
408
+ "@smthrs/model": "1.0.0-rc.7"
409
409
  },
410
410
  "devDependencies": {
411
411
  "@effect/language-service": "0.87.2",
412
- "@effect/platform-node": "4.0.0-rc.115",
413
- "@effect/vitest": "4.0.0-rc.115",
412
+ "@effect/platform-node": "4.0.4",
413
+ "@effect/vitest": "4.0.4",
414
414
  "@eslint/js": "9.39.5",
415
415
  "@types/node": "26.4.1",
416
416
  "@vitest/coverage-v8": "5.0.0",
417
417
  "dprint": "0.57.1",
418
- "effect": "4.0.0-rc.115",
418
+ "effect": "4.0.4",
419
419
  "esbuild": "0.28.2",
420
420
  "eslint": "9.39.5",
421
421
  "eslint-import-resolver-typescript": "4.4.5",
@@ -428,7 +428,7 @@
428
428
  "vitest": "5.0.0"
429
429
  },
430
430
  "peerDependencies": {
431
- "effect": "4.0.0-rc.115"
431
+ "effect": "^4.0.4"
432
432
  },
433
433
  "scripts": {
434
434
  "lint": "eslint src --max-warnings=0 && dprint check",
package/src/Bash.ts CHANGED
@@ -27,7 +27,7 @@ import { capability, envelope } from "./internal/Declaration.ts"
27
27
  import * as Diagnostics from "./internal/Diagnostics.ts"
28
28
  import { outsideEnvelope } from "./internal/EnvelopePrecheck.ts"
29
29
  import * as Exec from "./internal/Exec.ts"
30
- import { MAX_SHELL_OUTPUT_BYTES, truncateBytes } from "./internal/Text.ts"
30
+ import { elideMiddle as elide, MAX_SHELL_OUTPUT_BYTES } from "./internal/Text.ts"
31
31
  import * as Probe from "./Probe.ts"
32
32
  import * as StdError from "./StdError.ts"
33
33
  import * as TreeFingerprint from "./TreeFingerprint.ts"
@@ -173,10 +173,12 @@ export type Input = typeof Input.Type
173
173
  export const Output = Schema.Struct({
174
174
  exitCode: Schema.Number.annotate({ description: "Command exit code, including non-zero codes" }),
175
175
  stdout: Schema.String.annotate({
176
- description: "Captured stdout; only the tail, and not the whole output, when stdoutTruncated is true"
176
+ description:
177
+ "Captured stdout; when stdoutTruncated is true, only its start and end, with one line naming the bytes omitted between them"
177
178
  }),
178
179
  stderr: Schema.String.annotate({
179
- description: "Captured stderr; only the tail, and not the whole output, when stderrTruncated is true"
180
+ description:
181
+ "Captured stderr; when stderrTruncated is true, only its start and end, with one line naming the bytes omitted between them"
180
182
  }),
181
183
  stdoutTruncated: Schema.Boolean.annotate({
182
184
  description:
@@ -186,8 +188,8 @@ export const Output = Schema.Struct({
186
188
  description:
187
189
  "Whether stderr exceeded the capture limit, leaving stderr a fragment that must not be written to a file"
188
190
  }),
189
- stdoutDroppedBytes: Schema.Number.annotate({ description: "UTF-8 bytes omitted from the start of stdout" }),
190
- stderrDroppedBytes: Schema.Number.annotate({ description: "UTF-8 bytes omitted from the start of stderr" }),
191
+ stdoutDroppedBytes: Schema.Number.annotate({ description: "UTF-8 bytes omitted from the middle of stdout" }),
192
+ stderrDroppedBytes: Schema.Number.annotate({ description: "UTF-8 bytes omitted from the middle of stderr" }),
191
193
  invalidProbe: Schema.optional(
192
194
  Probe.InvalidProbe.annotate({
193
195
  description: "Reserved for a confirmed launch failure; an exit code alone does not establish one"
@@ -209,6 +211,17 @@ export const Output = Schema.Struct({
209
211
  */
210
212
  export type Output = typeof Output.Type
211
213
 
214
+ /**
215
+ * The text of a stream cut to its start and end: the start, one line naming
216
+ * the bytes omitted between them, and the end. A host that captures output
217
+ * itself, such as a terminal behind {@link inTerminal}, cuts with this so every
218
+ * {@link Output} reads the same.
219
+ *
220
+ * @category rendering
221
+ * @since 1.0.0-rc.5
222
+ */
223
+ export const elideMiddle: (start: string, droppedBytes: number, end: string) => string = elide
224
+
212
225
  /**
213
226
  * Static conservative effect envelope for the bash flow.
214
227
  *
@@ -500,6 +513,7 @@ export const run = Effect.fn("Bash.run")(function*(
500
513
  ...(spawned.env === undefined ? {} : { env: spawned.env }),
501
514
  timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
502
515
  maxCaptureBytes: MAX_SHELL_OUTPUT_BYTES,
516
+ overflow: "ends",
503
517
  ...(spawned.args === undefined ? {} : { args: spawned.args }),
504
518
  ...(spawned.stdin === undefined ? {} : { stdin: spawned.stdin })
505
519
  }).pipe(Effect.mapError((error) => Exec.toStdError(spawned.quoted, error)))
@@ -507,18 +521,16 @@ export const run = Effect.fn("Bash.run")(function*(
507
521
  // A command on this host may have changed files a language server holds
508
522
  // open; a container's files are not the ones it was sent.
509
523
  if (!contained) yield* Diagnostics.refresh
510
- const stdout = truncateBytes(result.stdout, MAX_SHELL_OUTPUT_BYTES, { keep: "tail" })
511
- const stderr = truncateBytes(result.stderr, MAX_SHELL_OUTPUT_BYTES, { keep: "tail" })
512
524
  // Exit codes alone cannot establish whether the shell refused to launch a
513
525
  // command or a launched runner chose that status after executing tests.
514
526
  return {
515
527
  exitCode: result.exitCode,
516
- stdout: stdout.text,
517
- stderr: stderr.text,
518
- stdoutTruncated: result.stdoutDroppedBytes > 0 || stdout.truncated,
519
- stderrTruncated: result.stderrDroppedBytes > 0 || stderr.truncated,
520
- stdoutDroppedBytes: result.stdoutDroppedBytes + stdout.droppedBytes,
521
- stderrDroppedBytes: result.stderrDroppedBytes + stderr.droppedBytes,
528
+ stdout: result.stdout,
529
+ stderr: result.stderr,
530
+ stdoutTruncated: result.stdoutDroppedBytes > 0,
531
+ stderrTruncated: result.stderrDroppedBytes > 0,
532
+ stdoutDroppedBytes: result.stdoutDroppedBytes,
533
+ stderrDroppedBytes: result.stderrDroppedBytes,
522
534
  ...(mutated === undefined ? {} : { mutated })
523
535
  }
524
536
  })
@@ -11,7 +11,7 @@
11
11
  import * as Credential from "@smthrs/control/Credential"
12
12
  import * as HttpClient from "@smthrs/kernel/HttpClient"
13
13
  import { Clock, Effect, Layer, Redacted, Schema } from "effect"
14
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
14
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
15
15
  import { header, readBounded } from "./internal/Http.ts"
16
16
  import * as StdError from "./StdError.ts"
17
17
  import * as WebSearch from "./WebSearch.ts"
package/src/Fetch.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import * as Effect from "effect/Effect"
8
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
8
9
  import * as Schema from "effect/Schema"
9
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
10
10
  import { capability, envelope } from "./internal/Declaration.ts"
11
11
  import { execute, Response, Timeout } from "./internal/Http.ts"
12
12
 
package/src/HttpPost.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import * as Effect from "effect/Effect"
8
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
8
9
  import * as Schema from "effect/Schema"
9
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
10
10
  import { capability, envelope } from "./internal/Declaration.ts"
11
11
  import { execute, Response, Timeout } from "./internal/Http.ts"
12
12
 
@@ -12,7 +12,7 @@
12
12
  import * as ChildProcessEnvironment from "@smthrs/kernel/ChildProcessEnvironment"
13
13
  import * as ChildProcessSpawner from "@smthrs/kernel/ChildProcessSpawner"
14
14
  import { Clock, Deferred, Effect, Fiber, Layer, Option, Queue, type Scope, Semaphore, Stream } from "effect"
15
- import * as ChildProcess from "effect/unstable/process/ChildProcess"
15
+ import * as ChildProcess from "effect/process/ChildProcess"
16
16
  import { createHash } from "node:crypto"
17
17
  import * as NodeFs from "node:fs"
18
18
  import * as NodePath from "node:path"
package/src/WebFetch.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  import type { GrantStore } from "@smthrs/kernel/GrantStore"
13
13
  import * as HttpClient from "@smthrs/kernel/HttpClient"
14
14
  import * as Effect from "effect/Effect"
15
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
15
16
  import * as Schema from "effect/Schema"
16
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
17
17
  import { capability, envelope } from "./internal/Declaration.ts"
18
18
  import { toMarkdown, toText } from "./internal/Html.ts"
19
19
  import { capOutput, header, MAX_RESPONSE_BYTES, readBounded, Timeout, withDeadline } from "./internal/Http.ts"
@@ -17,10 +17,11 @@
17
17
  import * as ChildProcessEnvironment from "@smthrs/kernel/ChildProcessEnvironment"
18
18
  import * as ChildProcessSpawner from "@smthrs/kernel/ChildProcessSpawner"
19
19
  import * as Effect from "effect/Effect"
20
+ import * as ChildProcess from "effect/process/ChildProcess"
20
21
  import * as Schema from "effect/Schema"
21
22
  import * as Stream from "effect/Stream"
22
- import * as ChildProcess from "effect/unstable/process/ChildProcess"
23
23
  import * as StdError from "../StdError.ts"
24
+ import { elideMiddle } from "./Text.ts"
24
25
 
25
26
  /**
26
27
  * Failure codes a buffered execution can report, matching the codes the
@@ -66,9 +67,9 @@ export interface ExecResult {
66
67
  readonly stdout: string
67
68
  readonly stderr: string
68
69
  readonly exitCode: number
69
- /** UTF-8 bytes dropped from the head of stdout to stay inside the capture bound. */
70
+ /** UTF-8 bytes of stdout dropped to stay inside the capture bound; see `overflow`. */
70
71
  readonly stdoutDroppedBytes: number
71
- /** UTF-8 bytes dropped from the head of stderr to stay inside the capture bound. */
72
+ /** UTF-8 bytes of stderr dropped to stay inside the capture bound; see `overflow`. */
72
73
  readonly stderrDroppedBytes: number
73
74
  }
74
75
 
@@ -97,18 +98,26 @@ export interface ExecOptions {
97
98
  */
98
99
  readonly stdin?: string | undefined
99
100
  /**
100
- * Bytes retained per captured stream. A stream that prints more keeps its
101
- * tail and reports what it dropped, so a command printing gigabytes costs a
102
- * bounded amount of memory rather than the whole of what it printed.
101
+ * Bytes retained per captured stream. A stream that prints more keeps what
102
+ * `overflow` names and reports what it dropped, so a command printing
103
+ * gigabytes costs a bounded amount of memory rather than the whole of what it
104
+ * printed.
103
105
  *
104
106
  * Absent means unbounded, which is what a caller wants when it needs every
105
107
  * byte a tool produced (`rg --files`, `git worktree list`).
106
108
  */
107
109
  readonly maxCaptureBytes?: number | undefined
108
- /** Defaults to `tail`; `refuse` fails if either stream exceeds the bound. */
109
- readonly overflow?: "tail" | "refuse" | undefined
110
+ /**
111
+ * What a stream past the bound keeps. `tail`, the default, keeps its last
112
+ * bytes. `ends` keeps its first and last halves and puts one line naming the
113
+ * dropped bytes where the middle was. `refuse` fails if either stream exceeds
114
+ * the bound.
115
+ */
116
+ readonly overflow?: Overflow | undefined
110
117
  }
111
118
 
119
+ type Overflow = "tail" | "ends" | "refuse"
120
+
112
121
  /** One captured stream: the text kept, and the bytes dropped to keep it bounded. */
113
122
  interface Capture {
114
123
  readonly text: string
@@ -121,70 +130,143 @@ interface Tail {
121
130
  dropped: number
122
131
  }
123
132
 
133
+ /** A stream's first bytes and its last ones: what `overflow: "ends"` keeps. */
134
+ interface Ends {
135
+ readonly head: Array<Uint8Array>
136
+ headBytes: number
137
+ readonly tail: Tail
138
+ }
139
+
124
140
  const decoder = new TextDecoder("utf-8")
125
141
 
142
+ const emptyTail = (): Tail => ({ chunks: [], bytes: 0, dropped: 0 })
143
+
144
+ /** A UTF-8 continuation byte is `10xxxxxx`; every other byte starts a character. */
145
+ const isContinuation = (byte: number): boolean => (byte & 0b1100_0000) === 0b1000_0000
146
+
147
+ const join = (chunks: ReadonlyArray<Uint8Array>, size: number): Uint8Array => {
148
+ const bytes = new Uint8Array(size)
149
+ let offset = 0
150
+ for (const chunk of chunks) {
151
+ bytes.set(chunk, offset)
152
+ offset += chunk.byteLength
153
+ }
154
+ return bytes
155
+ }
156
+
157
+ /** Appends one chunk and drops from the front whatever no longer fits. */
158
+ const keepTail = (state: Tail, chunk: Uint8Array, maxBytes: number): void => {
159
+ if (chunk.byteLength === 0) return
160
+ state.chunks.push(chunk)
161
+ state.bytes += chunk.byteLength
162
+ while (state.bytes > maxBytes && state.chunks.length > 0) {
163
+ const excess = state.bytes - maxBytes
164
+ const first = state.chunks[0]!
165
+ if (first.byteLength <= excess) {
166
+ state.chunks.shift()
167
+ state.bytes -= first.byteLength
168
+ state.dropped += first.byteLength
169
+ } else {
170
+ state.chunks[0] = first.subarray(excess)
171
+ state.bytes -= excess
172
+ state.dropped += excess
173
+ }
174
+ }
175
+ }
176
+
126
177
  /**
127
178
  * Joins the retained chunks and decodes them, skipping the partial code point a
128
179
  * dropped head can leave behind.
129
180
  *
130
- * A UTF-8 continuation byte is `10xxxxxx`, and a code point is at most four
131
- * bytes, so at most three leading continuation bytes can precede the first
132
- * whole character. Skipping them is counted as dropped rather than decoded to a
133
- * replacement character the process never printed.
181
+ * A code point is at most four bytes, so at most three leading continuation
182
+ * bytes can precede the first whole character. Skipping them is counted as
183
+ * dropped rather than decoded to a replacement character the process never
184
+ * printed.
134
185
  */
135
186
  const decodeTail = (state: Tail): Capture => {
136
- const bytes = new Uint8Array(state.bytes)
137
- let offset = 0
138
- for (const chunk of state.chunks) {
139
- bytes.set(chunk, offset)
140
- offset += chunk.byteLength
141
- }
187
+ const bytes = join(state.chunks, state.bytes)
142
188
  if (state.dropped === 0) return { text: decoder.decode(bytes), droppedBytes: 0 }
143
189
  let start = 0
144
- while (start < bytes.byteLength && start < 3 && (bytes[start]! & 0b1100_0000) === 0b1000_0000) start++
190
+ while (start < bytes.byteLength && start < 3 && isContinuation(bytes[start]!)) start++
145
191
  return { text: decoder.decode(bytes.subarray(start)), droppedBytes: state.dropped + start }
146
192
  }
147
193
 
194
+ /**
195
+ * The length of a cut head without the character the cut left unfinished.
196
+ *
197
+ * Its last character starts at the last byte that is not a continuation byte,
198
+ * and that byte says how many bytes the character needs. One that needs more
199
+ * than the head holds is dropped, for the same reason {@link decodeTail} skips
200
+ * a partial code point.
201
+ */
202
+ const wholeLength = (bytes: Uint8Array): number => {
203
+ for (let back = 1; back <= Math.min(3, bytes.byteLength); back++) {
204
+ const byte = bytes[bytes.byteLength - back]!
205
+ if (isContinuation(byte)) continue
206
+ const width = byte >= 0b1111_0000 ? 4 : byte >= 0b1110_0000 ? 3 : byte >= 0b1100_0000 ? 2 : 1
207
+ return width > back ? bytes.byteLength - back : bytes.byteLength
208
+ }
209
+ return bytes.byteLength
210
+ }
211
+
212
+ /**
213
+ * Decodes a stream kept by its ends. A stream that fit is decoded whole;
214
+ * otherwise each end is cut on a character boundary and one line naming every
215
+ * byte between them stands where the middle was.
216
+ */
217
+ const decodeEnds = (state: Ends): Capture => {
218
+ if (state.tail.dropped === 0) {
219
+ const whole = join([...state.head, ...state.tail.chunks], state.headBytes + state.tail.bytes)
220
+ return { text: decoder.decode(whole), droppedBytes: 0 }
221
+ }
222
+ const head = join(state.head, state.headBytes)
223
+ const kept = wholeLength(head)
224
+ const tail = decodeTail(state.tail)
225
+ const droppedBytes = head.byteLength - kept + tail.droppedBytes
226
+ return { text: elideMiddle(decoder.decode(head.subarray(0, kept)), droppedBytes, tail.text), droppedBytes }
227
+ }
228
+
148
229
  const boundedTail = <E>(
149
230
  stream: Stream.Stream<Uint8Array, E>,
150
231
  maxBytes: number,
151
- overflow: "tail" | "refuse" | undefined
232
+ overflow: Overflow | undefined
152
233
  ): Effect.Effect<Capture, E> =>
153
- Stream.runFold(
154
- stream,
155
- (): Tail => ({ chunks: [], bytes: 0, dropped: 0 }),
156
- (state, chunk) => {
157
- if (chunk.byteLength === 0) return state
158
- state.chunks.push(chunk)
159
- state.bytes += chunk.byteLength
160
- while (state.bytes > maxBytes && state.chunks.length > 0) {
161
- const excess = state.bytes - maxBytes
162
- const first = state.chunks[0]!
163
- if (first.byteLength <= excess) {
164
- state.chunks.shift()
165
- state.bytes -= first.byteLength
166
- state.dropped += first.byteLength
167
- } else {
168
- state.chunks[0] = first.subarray(excess)
169
- state.bytes -= excess
170
- state.dropped += excess
171
- }
172
- }
173
- return state
174
- }
175
- ).pipe(Effect.map((state) =>
234
+ Stream.runFold(stream, emptyTail, (state, chunk) => {
235
+ keepTail(state, chunk, maxBytes)
236
+ return state
237
+ }).pipe(Effect.map((state) =>
176
238
  overflow === "refuse" && state.dropped > 0
177
239
  ? { text: "", droppedBytes: state.dropped }
178
240
  : decodeTail(state)
179
241
  ))
180
242
 
243
+ /**
244
+ * Keeps the first half of the bound and the last half. The head fills first,
245
+ * and every byte after it passes through the tail, which drops from its front:
246
+ * what falls out of the tail is the middle.
247
+ */
248
+ const boundedEnds = <E>(stream: Stream.Stream<Uint8Array, E>, maxBytes: number): Effect.Effect<Capture, E> => {
249
+ const headBytes = Math.floor(maxBytes / 2)
250
+ return Stream.runFold(stream, (): Ends => ({ head: [], headBytes: 0, tail: emptyTail() }), (state, chunk) => {
251
+ const taken = Math.min(headBytes - state.headBytes, chunk.byteLength)
252
+ if (taken > 0) {
253
+ state.head.push(chunk.subarray(0, taken))
254
+ state.headBytes += taken
255
+ }
256
+ keepTail(state.tail, chunk.subarray(taken), maxBytes - headBytes)
257
+ return state
258
+ }).pipe(Effect.map(decodeEnds))
259
+ }
260
+
181
261
  const capture = <E>(
182
262
  stream: Stream.Stream<Uint8Array, E>,
183
263
  maxBytes: number | undefined,
184
- overflow: "tail" | "refuse" | undefined
264
+ overflow: Overflow | undefined
185
265
  ): Effect.Effect<Capture, E> =>
186
266
  maxBytes === undefined
187
267
  ? Effect.map(Stream.mkString(Stream.decodeText(stream)), (text) => ({ text, droppedBytes: 0 }))
268
+ : overflow === "ends"
269
+ ? boundedEnds(stream, maxBytes)
188
270
  : boundedTail(stream, maxBytes, overflow)
189
271
 
190
272
  const unbounded = (
@@ -7,9 +7,9 @@
7
7
  import type { GrantStore } from "@smthrs/kernel/GrantStore"
8
8
  import * as HttpClient from "@smthrs/kernel/HttpClient"
9
9
  import * as Effect from "effect/Effect"
10
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
10
11
  import * as Schema from "effect/Schema"
11
12
  import * as Stream from "effect/Stream"
12
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
13
13
  import * as StdError from "../StdError.ts"
14
14
  import { guarded, refusal } from "./HttpNetwork.ts"
15
15
  import { MAX_OUTPUT_BYTES, notice, truncateBytes } from "./Text.ts"
@@ -13,9 +13,9 @@ import {
13
13
  supportsDestinationPinning
14
14
  } from "@smthrs/kernel/HttpClient"
15
15
  import { Context, Effect, Option } from "effect"
16
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
17
- import * as HttpClient from "effect/unstable/http/HttpClient"
18
- import * as HttpClientError from "effect/unstable/http/HttpClientError"
16
+ import * as FetchHttpClient from "effect/http/FetchHttpClient"
17
+ import * as HttpClient from "effect/http/HttpClient"
18
+ import * as HttpClientError from "effect/http/HttpClientError"
19
19
  import { lookup } from "node:dns/promises"
20
20
  import { BlockList, isIP } from "node:net"
21
21
  import * as StdError from "../StdError.ts"
@@ -124,6 +124,21 @@ export const truncateBytes = (
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Joins the start and the end of a stream cut in the middle around one line
129
+ * naming the bytes cut between them.
130
+ *
131
+ * The line stands alone, so a start that stops mid-line is ended before it.
132
+ * Only the line's own newlines are added; `droppedBytes` counts printed bytes.
133
+ *
134
+ * @category rendering
135
+ * @since 1.0.0-rc.5
136
+ */
137
+ export const elideMiddle = (start: string, droppedBytes: number, end: string): string =>
138
+ `${start}${start === "" || start.endsWith("\n") ? "" : "\n"}[${droppedBytes} ${
139
+ droppedBytes === 1 ? "byte" : "bytes"
140
+ } omitted]\n${end}`
141
+
127
142
  /**
128
143
  * Splits on LF without counting a terminal LF as a line.
129
144
  * Carriage returns remain part of each line, including the CR in CRLF, so