@agjs/tsforge 0.1.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 (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,370 @@
1
+ import type {
2
+ IModelResponse,
3
+ IToolCall,
4
+ ITokenUsage,
5
+ ITtsrWatcher,
6
+ TokenChannel,
7
+ } from "./inference.types";
8
+ import { isArray, isRecord } from "../lib/guards";
9
+ import { parseArgs, parseUsage, salvageToolCalls } from "./wire";
10
+ import { StreamGuard } from "./stream-guard";
11
+
12
+ interface IStreamDelta {
13
+ content?: string;
14
+ reasoning?: string;
15
+ toolCalls?: unknown;
16
+ usage?: ITokenUsage;
17
+ }
18
+
19
+ /** Streaming: parse SSE chunks, forward tokens to `onToken`, assemble the response.
20
+ * When ttsrManager is provided, feeds deltas to it and aborts on rule match. */
21
+ export async function streamResponse(
22
+ res: Response,
23
+ onToken: (text: string, channel: TokenChannel) => void,
24
+ ttsrManager?: ITtsrWatcher
25
+ ): Promise<IModelResponse> {
26
+ const body = res.body;
27
+
28
+ if (body === null) {
29
+ return { content: "", toolCalls: [] };
30
+ }
31
+
32
+ const reader = body.getReader();
33
+ const decoder = new TextDecoder();
34
+ const acc: IStreamAcc = {
35
+ calls: new Map(),
36
+ guard: new StreamGuard(),
37
+ content: "",
38
+ ttsr: ttsrManager,
39
+ ttsrFired: null,
40
+ };
41
+ // `usage` arrives in a trailing chunk (choices: []), captured in consumeLines.
42
+ let buffer = "";
43
+ let degenerated = false;
44
+ let result = await reader.read();
45
+
46
+ while (!result.done) {
47
+ buffer += decoder.decode(result.value, { stream: true });
48
+
49
+ const lines = buffer.split("\n");
50
+
51
+ buffer = lines.pop() ?? "";
52
+
53
+ degenerated = consumeLines(lines, acc, onToken);
54
+
55
+ if (degenerated || acc.ttsrFired !== null) {
56
+ // Stop the runaway generation instead of letting it spew to max_tokens,
57
+ // or abort when TTSR fires to inject corrective guidance.
58
+ await reader.cancel();
59
+
60
+ break;
61
+ }
62
+
63
+ result = await reader.read();
64
+ }
65
+
66
+ buffer += decoder.decode();
67
+
68
+ if (!degenerated && buffer.trim().length > 0) {
69
+ degenerated = consumeLines([buffer], acc, onToken);
70
+ }
71
+
72
+ return assemble(acc, degenerated);
73
+ }
74
+
75
+ /** One in-flight tool call being assembled from streamed deltas. The `path`/
76
+ * `lastProgress` fields drive the live progress heartbeat (responsiveness). */
77
+ interface IStreamingCall {
78
+ id?: string;
79
+ name: string;
80
+ args: string;
81
+ /** True once we've surfaced the file path parsed from the partial args. */
82
+ pathShown?: boolean;
83
+ /** args length at the last progress heartbeat (throttle). */
84
+ lastProgress?: number;
85
+ }
86
+
87
+ interface IStreamAcc {
88
+ calls: Map<number, IStreamingCall>;
89
+ guard: StreamGuard;
90
+ content: string;
91
+ usage?: ITokenUsage;
92
+ ttsr?: ITtsrWatcher;
93
+ ttsrFired: { readonly name: string; readonly guidance: string } | null;
94
+ }
95
+
96
+ /** Forward a content delta, watching for degeneration and TTSR matches.
97
+ * Returns true when the stream should stop. */
98
+ function consumeContentDelta(
99
+ text: string,
100
+ acc: IStreamAcc,
101
+ onToken: (text: string, channel: TokenChannel) => void
102
+ ): boolean {
103
+ acc.content += text;
104
+ onToken(text, "content");
105
+
106
+ if (acc.guard.observe(text, "content")) {
107
+ return true;
108
+ }
109
+
110
+ if (acc.ttsr !== undefined && acc.ttsrFired === null) {
111
+ acc.ttsrFired = acc.ttsr.checkDelta(text, { source: "content" });
112
+
113
+ if (acc.ttsrFired !== null) {
114
+ return true;
115
+ }
116
+ }
117
+
118
+ return false;
119
+ }
120
+
121
+ /** Parse a batch of SSE lines, forward tokens, accumulate state; returns true
122
+ * the moment the model's output degenerates into a repetition loop. */
123
+ function consumeLines(
124
+ lines: string[],
125
+ acc: IStreamAcc,
126
+ onToken: (text: string, channel: TokenChannel) => void
127
+ ): boolean {
128
+ for (const line of lines) {
129
+ const delta = parseSseLine(line);
130
+
131
+ if (delta === null) {
132
+ continue;
133
+ }
134
+
135
+ // Forward reasoning too — the log is the full record of what happened.
136
+ // (The "too much output" problem is solved by making the model think
137
+ // less, not by hiding it from the log.)
138
+ if (delta.reasoning !== undefined && delta.reasoning.length > 0) {
139
+ onToken(delta.reasoning, "reasoning");
140
+
141
+ if (acc.guard.observe(delta.reasoning, "reasoning")) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ if (
147
+ delta.content !== undefined &&
148
+ delta.content.length > 0 &&
149
+ consumeContentDelta(delta.content, acc, onToken)
150
+ ) {
151
+ return true;
152
+ }
153
+
154
+ if (delta.usage !== undefined) {
155
+ acc.usage = delta.usage;
156
+ }
157
+
158
+ accumulateToolCalls(delta.toolCalls, acc.calls, onToken, acc);
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ function assemble(acc: IStreamAcc, degenerated: boolean): IModelResponse {
165
+ const usage = acc.usage === undefined ? {} : { usage: acc.usage };
166
+ const toolCalls: IToolCall[] = [...acc.calls.values()].map((c) => ({
167
+ id: c.id,
168
+ name: c.name,
169
+ arguments: parseArgs(c.args),
170
+ }));
171
+
172
+ const ttsrFired =
173
+ acc.ttsrFired !== null
174
+ ? {
175
+ ttsrFired: {
176
+ ruleName: acc.ttsrFired.name,
177
+ guidance: acc.ttsrFired.guidance,
178
+ },
179
+ }
180
+ : {};
181
+
182
+ if (toolCalls.length > 0) {
183
+ return degenerated
184
+ ? { content: acc.content, toolCalls, degenerated, ...ttsrFired, ...usage }
185
+ : { content: acc.content, toolCalls, ...ttsrFired, ...usage };
186
+ }
187
+
188
+ const salvaged = salvageToolCalls(acc.content);
189
+
190
+ return {
191
+ content: acc.content,
192
+ toolCalls: salvaged,
193
+ salvaged: salvaged.length,
194
+ ...(degenerated ? { degenerated } : {}),
195
+ ...ttsrFired,
196
+ ...usage,
197
+ };
198
+ }
199
+
200
+ function parseSseLine(line: string): IStreamDelta | null {
201
+ const trimmed = line.trim();
202
+
203
+ if (!trimmed.startsWith("data:")) {
204
+ return null;
205
+ }
206
+
207
+ const payload = trimmed.slice(5).trim();
208
+
209
+ if (payload === "[DONE]" || payload.length === 0) {
210
+ return null;
211
+ }
212
+
213
+ let parsed: unknown;
214
+
215
+ try {
216
+ parsed = JSON.parse(payload);
217
+ } catch {
218
+ return null;
219
+ }
220
+
221
+ if (!isRecord(parsed)) {
222
+ return null;
223
+ }
224
+
225
+ // The trailing usage chunk has an empty `choices` array — capture its usage
226
+ // even though there's no delta to forward.
227
+ const usage = parseUsage(parsed.usage);
228
+ const choices = parsed.choices;
229
+ const first = isArray(choices) ? choices[0] : undefined;
230
+
231
+ if (!isRecord(first) || !isRecord(first.delta)) {
232
+ return usage === undefined ? null : { usage };
233
+ }
234
+
235
+ const delta = first.delta;
236
+
237
+ return {
238
+ content: typeof delta.content === "string" ? delta.content : undefined,
239
+ reasoning: firstString(delta.reasoning, delta.reasoning_content),
240
+ toolCalls: delta.tool_calls,
241
+ ...(usage === undefined ? {} : { usage }),
242
+ };
243
+ }
244
+
245
+ /** Tools whose argument IS a large file body — worth a live progress heartbeat
246
+ * (for a 20 tok/s model, minutes of silent arg streaming is the worst UX). */
247
+ const BIG_CONTENT_TOOLS = new Set(["create", "edit", "scaffold_ui"]);
248
+ /** Chars of args between progress heartbeats. */
249
+ const PROGRESS_EVERY = 1500;
250
+
251
+ /**
252
+ * Surface live progress as a big-content tool's arguments stream: the file path
253
+ * (parsed from the partial args JSON) the moment it's known, then a throttled size
254
+ * heartbeat. Turns minutes of silent generation into "writing X.tsx … 2.9KB …".
255
+ */
256
+ function emitToolProgress(
257
+ call: IStreamingCall,
258
+ onToken: (text: string, channel: TokenChannel) => void
259
+ ): void {
260
+ if (!BIG_CONTENT_TOOLS.has(call.name)) {
261
+ return;
262
+ }
263
+
264
+ if (call.pathShown !== true) {
265
+ const path = /"(?:file|filename|path)"\s*:\s*"([^"]+)"/.exec(
266
+ call.args
267
+ )?.[1];
268
+
269
+ if (path !== undefined) {
270
+ call.pathShown = true;
271
+ onToken(`\n ✎ → ${path}`, "tool");
272
+ }
273
+ }
274
+
275
+ if (call.args.length - (call.lastProgress ?? 0) >= PROGRESS_EVERY) {
276
+ call.lastProgress = call.args.length;
277
+ onToken(
278
+ `\n ⋯ ${(call.args.length / 1024).toFixed(1)}KB streamed…`,
279
+ "tool"
280
+ );
281
+ }
282
+ }
283
+
284
+ const TTSR_WATCHED_TOOLS = new Set(["edit", "edit_lines", "create"]);
285
+
286
+ function accumulateToolCalls(
287
+ raw: unknown,
288
+ calls: Map<number, IStreamingCall>,
289
+ onToken: (text: string, channel: TokenChannel) => void,
290
+ acc?: IStreamAcc
291
+ ): void {
292
+ if (!isArray(raw)) {
293
+ return;
294
+ }
295
+
296
+ for (const tc of raw) {
297
+ if (!isRecord(tc) || !isRecord(tc.function)) {
298
+ continue;
299
+ }
300
+
301
+ const index = typeof tc.index === "number" ? tc.index : 0;
302
+ const existing: IStreamingCall = calls.get(index) ?? { name: "", args: "" };
303
+
304
+ if (typeof tc.id === "string" && tc.id.length > 0) {
305
+ existing.id = tc.id;
306
+ }
307
+
308
+ processToolCallDelta(tc.function, existing, onToken, acc);
309
+ calls.set(index, existing);
310
+ }
311
+ }
312
+
313
+ function processToolCallDelta(
314
+ fn: Record<string, unknown>,
315
+ existing: IStreamingCall,
316
+ onToken: (text: string, channel: TokenChannel) => void,
317
+ acc?: IStreamAcc
318
+ ): void {
319
+ // Surface the tool name the moment it first appears — so a long tool-call
320
+ // generation shows "it's writing X now" instead of a frozen cursor. As the
321
+ // (often large) file body then streams, emitToolProgress adds the path + a
322
+ // throttled size heartbeat; the file lands as a clean create/edit event on run.
323
+ if (typeof fn.name === "string" && fn.name.length > 0) {
324
+ if (existing.name.length === 0) {
325
+ onToken(`\n ✎ ${fn.name}…`, "tool");
326
+ }
327
+
328
+ existing.name = fn.name;
329
+ }
330
+
331
+ if (typeof fn.arguments !== "string" || fn.arguments.length === 0) {
332
+ return;
333
+ }
334
+
335
+ existing.args += fn.arguments;
336
+ emitToolProgress(existing, onToken);
337
+
338
+ // TTSR on the tool-args channel. Gate by the ACCUMULATED tool name — the
339
+ // name only arrives on a call's first delta, but every fragment must be fed.
340
+ if (
341
+ acc?.ttsr !== undefined &&
342
+ acc.ttsrFired === null &&
343
+ TTSR_WATCHED_TOOLS.has(existing.name)
344
+ ) {
345
+ const currentFile = extractFilePath(existing.args);
346
+
347
+ acc.ttsrFired = acc.ttsr.checkDelta(fn.arguments, {
348
+ source: "tool-args",
349
+ ...(currentFile !== undefined ? { currentFile } : {}),
350
+ });
351
+ }
352
+ }
353
+
354
+ /** Extract file path from partial JSON args (e.g., "{"file":"src/app.ts",..."). */
355
+ function extractFilePath(args: string): string | undefined {
356
+ const match = /"(?:file|path)"\s*:\s*"([^"]+)"/.exec(args);
357
+
358
+ return match?.[1];
359
+ }
360
+
361
+ /** First of the candidates that is a string (vLLM uses `reasoning`; others `reasoning_content`). */
362
+ function firstString(...values: unknown[]): string | undefined {
363
+ for (const v of values) {
364
+ if (typeof v === "string") {
365
+ return v;
366
+ }
367
+ }
368
+
369
+ return undefined;
370
+ }
@@ -0,0 +1,78 @@
1
+ import { PROVIDER_LIMITS } from "./inference.constants";
2
+
3
+ // Transient CONNECTION failures (the box blipped / socket dropped) — retry these.
4
+ // Deliberately does NOT match timeouts: a request that ran past timeoutMs is a
5
+ // reasoning spiral, not a blip, and retrying would just waste another timeout.
6
+ const TRANSIENT_NETWORK =
7
+ /unable to connect|socket connection|connection (was )?(closed|refused|reset)|econnrefused|econnreset/i;
8
+
9
+ function isTransientNetworkError(err: unknown): boolean {
10
+ return err instanceof Error && TRANSIENT_NETWORK.test(err.message);
11
+ }
12
+
13
+ /**
14
+ * Retry a request on transient connection failures only (HTTP errors surface via
15
+ * `res.ok`, not a throw). Fresh AbortSignal per attempt; small linear backoff.
16
+ * The connect completes before any stream begins, so this is safe for streaming.
17
+ */
18
+ export async function fetchWithRetry(
19
+ doFetch: typeof fetch,
20
+ url: string,
21
+ headers: Record<string, string>,
22
+ body: string,
23
+ timeoutMs: number,
24
+ signal?: AbortSignal
25
+ ): Promise<Response> {
26
+ const maxAttempts = 4;
27
+ let lastErr: unknown;
28
+
29
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
30
+ // A caller abort (Ctrl-C) is terminal — don't start another attempt.
31
+ if (isAborted(signal)) {
32
+ throw signalReason(signal);
33
+ }
34
+
35
+ // Per-attempt timeout, combined with the caller's cancellation — the latter
36
+ // also aborts the streaming body (the connect finishes before the stream).
37
+ const timeout = AbortSignal.timeout(timeoutMs);
38
+ const attemptSignal =
39
+ signal === undefined ? timeout : AbortSignal.any([timeout, signal]);
40
+
41
+ try {
42
+ return await doFetch(url, {
43
+ method: "POST",
44
+ headers,
45
+ body,
46
+ signal: attemptSignal,
47
+ });
48
+ } catch (err) {
49
+ lastErr = err;
50
+
51
+ // A caller abort never retries (it isn't a transient network blip).
52
+ if (
53
+ isAborted(signal) ||
54
+ !isTransientNetworkError(err) ||
55
+ attempt === maxAttempts
56
+ ) {
57
+ throw err;
58
+ }
59
+
60
+ await new Promise<void>((resolve) => {
61
+ setTimeout(resolve, PROVIDER_LIMITS.retryBackoffMs * attempt);
62
+ });
63
+ }
64
+ }
65
+
66
+ throw lastErr instanceof Error ? lastErr : new Error("fetch failed");
67
+ }
68
+
69
+ /** Read `aborted` through a function boundary so the loop-level narrowing (which
70
+ * can't see the async abort mutate it mid-await) doesn't collapse the type. */
71
+ function isAborted(signal?: AbortSignal): boolean {
72
+ return signal?.aborted === true;
73
+ }
74
+
75
+ /** The Error an aborted signal carries (or a generic one). */
76
+ function signalReason(signal?: AbortSignal): Error {
77
+ return signal?.reason instanceof Error ? signal.reason : new Error("aborted");
78
+ }
Binary file
@@ -0,0 +1,126 @@
1
+ import { join } from "node:path";
2
+ import { Glob } from "bun";
3
+ import type { IFileView } from "./fs.types";
4
+
5
+ /** True when the file exists on disk (the one place this check lives). */
6
+ export function fileExists(cwd: string, path: string): Promise<boolean> {
7
+ return Bun.file(join(cwd, path)).exists();
8
+ }
9
+
10
+ /** Directory segments never worth reading into context (deps, build output, vcs). */
11
+ const IGNORE_SEGMENTS = new Set([
12
+ "node_modules",
13
+ "dist",
14
+ "build",
15
+ ".git",
16
+ "coverage",
17
+ ".next",
18
+ ".turbo",
19
+ ".cache",
20
+ ".vite",
21
+ ]);
22
+ /** Skip files bigger than this — generated bundles, fixtures, data blobs. */
23
+ const MAX_FILE_BYTES = 131_072;
24
+ /** Bound a glob scan so a huge tree can't blow up context (the MAP handles the
25
+ * rest — see renderFileSection). */
26
+ const MAX_GLOB_FILES = 400;
27
+ /** Binary-ish extensions: reading these as text is meaningless. */
28
+ const BINARY_EXT =
29
+ /\.(png|jpe?g|gif|webp|ico|svg|pdf|woff2?|ttf|eot|mp[34]|mov|zip|gz|tar|wasm|lockb|node|bin)$/i;
30
+
31
+ function isGlobPattern(path: string): boolean {
32
+ return /[*?[\]{}]/.test(path);
33
+ }
34
+
35
+ function ignored(rel: string): boolean {
36
+ return (
37
+ rel.split("/").some((seg) => IGNORE_SEGMENTS.has(seg)) ||
38
+ BINARY_EXT.test(rel)
39
+ );
40
+ }
41
+
42
+ /** Expand a glob scope to the concrete, readable files under `cwd` (sorted),
43
+ * skipping ignored dirs and binary files, capped at MAX_GLOB_FILES. */
44
+ async function expandGlob(cwd: string, pattern: string): Promise<string[]> {
45
+ const found: string[] = [];
46
+
47
+ for await (const rel of new Glob(pattern).scan({ cwd, onlyFiles: true })) {
48
+ if (!ignored(rel)) {
49
+ found.push(rel);
50
+ }
51
+
52
+ if (found.length >= MAX_GLOB_FILES) {
53
+ break;
54
+ }
55
+ }
56
+
57
+ return found.sort();
58
+ }
59
+
60
+ /**
61
+ * Read the given scope entries as {path, content} views. A literal path is read
62
+ * directly; a GLOB (any entry with `*`/`?`/brackets, e.g. a recursive `src` star
63
+ * pattern) is expanded to the matching files under `cwd` — without this, a glob
64
+ * scope reads as zero files and a real project gets misclassified as an empty
65
+ * scratch dir. Skips files over MAX_FILE_BYTES and de-dupes when patterns overlap.
66
+ */
67
+ export async function readFiles(
68
+ cwd: string,
69
+ paths: readonly string[]
70
+ ): Promise<IFileView[]> {
71
+ const views: IFileView[] = [];
72
+ const seen = new Set<string>();
73
+
74
+ const take = async (path: string): Promise<void> => {
75
+ if (seen.has(path)) {
76
+ return;
77
+ }
78
+
79
+ seen.add(path);
80
+
81
+ const file = Bun.file(join(cwd, path));
82
+
83
+ if ((await file.exists()) && file.size <= MAX_FILE_BYTES) {
84
+ views.push({ path, content: await file.text() });
85
+ }
86
+ };
87
+
88
+ for (const path of paths) {
89
+ if (isGlobPattern(path)) {
90
+ for (const match of await expandGlob(cwd, path)) {
91
+ await take(match);
92
+ }
93
+ } else {
94
+ await take(path);
95
+ }
96
+ }
97
+
98
+ return views;
99
+ }
100
+
101
+ /**
102
+ * Resolve a scope (literal paths + globs) to the concrete file paths it covers,
103
+ * de-duped. Literal entries pass through as-is; a glob entry (any `*`/`?`/bracket
104
+ * pattern, e.g. a recursive `src` tree) expands to matching files under `cwd`.
105
+ * Use this anywhere code iterates an editable scope by path — e.g. the
106
+ * deterministic fixers — so a glob scope isn't silently skipped (a literal
107
+ * `fileExists` on a glob pattern is always false).
108
+ */
109
+ export async function resolveScopeFiles(
110
+ cwd: string,
111
+ paths: readonly string[]
112
+ ): Promise<string[]> {
113
+ const out = new Set<string>();
114
+
115
+ for (const path of paths) {
116
+ if (isGlobPattern(path)) {
117
+ for (const match of await expandGlob(cwd, path)) {
118
+ out.add(match);
119
+ }
120
+ } else {
121
+ out.add(path);
122
+ }
123
+ }
124
+
125
+ return [...out];
126
+ }
@@ -0,0 +1,5 @@
1
+ /** A file's path (relative to the run dir) and its current contents. */
2
+ export interface IFileView {
3
+ path: string;
4
+ content: string;
5
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./fs.types";
2
+ export * from "./fs";
3
+ export * from "./process";