world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,144 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import type { AgentMessage } from "../../types.ts";
3
+
4
+ /** File paths touched by a session branch or compaction range. */
5
+ export interface FileOperations {
6
+ /** Files read but not necessarily modified. */
7
+ read: Set<string>;
8
+ /** Files written by full-file write operations. */
9
+ written: Set<string>;
10
+ /** Files modified by edit operations. */
11
+ edited: Set<string>;
12
+ }
13
+
14
+ /** Create an empty file-operation accumulator. */
15
+ export function createFileOps(): FileOperations {
16
+ return {
17
+ read: new Set(),
18
+ written: new Set(),
19
+ edited: new Set(),
20
+ };
21
+ }
22
+
23
+ /** Add file operations from assistant tool calls to an accumulator. */
24
+ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
25
+ if (message.role !== "assistant") return;
26
+ if (!("content" in message) || !Array.isArray(message.content)) return;
27
+
28
+ for (const block of message.content) {
29
+ if (typeof block !== "object" || block === null) continue;
30
+ if (!("type" in block) || block.type !== "toolCall") continue;
31
+ if (!("arguments" in block) || !("name" in block)) continue;
32
+
33
+ const args = block.arguments as Record<string, unknown> | undefined;
34
+ if (!args) continue;
35
+
36
+ const path = typeof args.path === "string" ? args.path : undefined;
37
+ if (!path) continue;
38
+
39
+ switch (block.name) {
40
+ case "read":
41
+ fileOps.read.add(path);
42
+ break;
43
+ case "write":
44
+ fileOps.written.add(path);
45
+ break;
46
+ case "edit":
47
+ fileOps.edited.add(path);
48
+ break;
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Compute sorted read-only and modified file lists from accumulated operations. */
54
+ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
55
+ const modified = new Set([...fileOps.edited, ...fileOps.written]);
56
+ const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
57
+ const modifiedFiles = [...modified].sort();
58
+ return { readFiles: readOnly, modifiedFiles };
59
+ }
60
+
61
+ /** Format file lists as summary metadata tags. */
62
+ export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
63
+ const sections: string[] = [];
64
+ if (readFiles.length > 0) {
65
+ sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
66
+ }
67
+ if (modifiedFiles.length > 0) {
68
+ sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
69
+ }
70
+ if (sections.length === 0) return "";
71
+ return `\n\n${sections.join("\n\n")}`;
72
+ }
73
+
74
+ const TOOL_RESULT_MAX_CHARS = 2000;
75
+
76
+ function safeJsonStringify(value: unknown): string {
77
+ try {
78
+ return JSON.stringify(value) ?? "undefined";
79
+ } catch {
80
+ return "[unserializable]";
81
+ }
82
+ }
83
+
84
+ function truncateForSummary(text: string, maxChars: number): string {
85
+ if (text.length <= maxChars) return text;
86
+ const truncatedChars = text.length - maxChars;
87
+ return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
88
+ }
89
+
90
+ /** Serialize LLM messages to plain text for summarization prompts. */
91
+ export function serializeConversation(messages: Message[]): string {
92
+ const parts: string[] = [];
93
+
94
+ for (const msg of messages) {
95
+ if (msg.role === "user") {
96
+ const content =
97
+ typeof msg.content === "string"
98
+ ? msg.content
99
+ : msg.content
100
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
101
+ .map((c) => c.text)
102
+ .join("");
103
+ if (content) parts.push(`[User]: ${content}`);
104
+ } else if (msg.role === "assistant") {
105
+ const textParts: string[] = [];
106
+ const thinkingParts: string[] = [];
107
+ const toolCalls: string[] = [];
108
+
109
+ for (const block of msg.content) {
110
+ if (block.type === "text") {
111
+ textParts.push(block.text);
112
+ } else if (block.type === "thinking") {
113
+ thinkingParts.push(block.thinking);
114
+ } else if (block.type === "toolCall") {
115
+ const args = block.arguments as Record<string, unknown>;
116
+ const argsStr = Object.entries(args)
117
+ .map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
118
+ .join(", ");
119
+ toolCalls.push(`${block.name}(${argsStr})`);
120
+ }
121
+ }
122
+
123
+ if (thinkingParts.length > 0) {
124
+ parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
125
+ }
126
+ if (textParts.length > 0) {
127
+ parts.push(`[Assistant]: ${textParts.join("\n")}`);
128
+ }
129
+ if (toolCalls.length > 0) {
130
+ parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
131
+ }
132
+ } else if (msg.role === "toolResult") {
133
+ const content = msg.content
134
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
135
+ .map((c) => c.text)
136
+ .join("");
137
+ if (content) {
138
+ parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ return parts.join("\n\n");
144
+ }
@@ -0,0 +1,550 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants, createReadStream } from "node:fs";
4
+ import {
5
+ access,
6
+ appendFile,
7
+ lstat,
8
+ mkdir,
9
+ mkdtemp,
10
+ readdir,
11
+ readFile,
12
+ realpath,
13
+ rm,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { isAbsolute, join, resolve } from "node:path";
18
+ import { createInterface } from "node:readline";
19
+ import {
20
+ type ExecutionEnv,
21
+ ExecutionError,
22
+ err,
23
+ FileError,
24
+ type FileInfo,
25
+ type FileKind,
26
+ ok,
27
+ type Result,
28
+ toError,
29
+ } from "../types.ts";
30
+
31
+ function resolvePath(cwd: string, path: string): string {
32
+ return isAbsolute(path) ? path : resolve(cwd, path);
33
+ }
34
+
35
+ function fileKindFromStats(stats: {
36
+ isFile(): boolean;
37
+ isDirectory(): boolean;
38
+ isSymbolicLink(): boolean;
39
+ }): FileKind | undefined {
40
+ if (stats.isFile()) return "file";
41
+ if (stats.isDirectory()) return "directory";
42
+ if (stats.isSymbolicLink()) return "symlink";
43
+ return undefined;
44
+ }
45
+
46
+ function fileInfoFromStats(
47
+ path: string,
48
+ stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number },
49
+ ): Result<FileInfo, FileError> {
50
+ const kind = fileKindFromStats(stats);
51
+ if (!kind) return err(new FileError("invalid", "Unsupported file type", path));
52
+ return ok({
53
+ name: path.replace(/\/+$/, "").split("/").pop() ?? path,
54
+ path,
55
+ kind,
56
+ size: stats.size,
57
+ mtimeMs: stats.mtimeMs,
58
+ });
59
+ }
60
+
61
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
62
+ return error instanceof Error && "code" in error;
63
+ }
64
+
65
+ function toFileError(error: unknown, path?: string): FileError {
66
+ if (error instanceof FileError) return error;
67
+ const cause = toError(error);
68
+ if (isNodeError(error)) {
69
+ const message = error.message;
70
+ switch (error.code) {
71
+ case "ABORT_ERR":
72
+ return new FileError("aborted", message, path, cause);
73
+ case "ENOENT":
74
+ return new FileError("not_found", message, path, cause);
75
+ case "EACCES":
76
+ case "EPERM":
77
+ return new FileError("permission_denied", message, path, cause);
78
+ case "ENOTDIR":
79
+ return new FileError("not_directory", message, path, cause);
80
+ case "EISDIR":
81
+ return new FileError("is_directory", message, path, cause);
82
+ case "EINVAL":
83
+ return new FileError("invalid", message, path, cause);
84
+ }
85
+ }
86
+ return new FileError("unknown", cause.message, path, cause);
87
+ }
88
+
89
+ function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined {
90
+ return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
91
+ }
92
+
93
+ async function pathExists(path: string): Promise<boolean> {
94
+ try {
95
+ await access(path, constants.F_OK);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ async function runCommand(
103
+ command: string,
104
+ args: string[],
105
+ timeoutMs: number,
106
+ ): Promise<{ stdout: string; status: number | null }> {
107
+ return await new Promise((resolve) => {
108
+ let stdout = "";
109
+ let child: ReturnType<typeof spawn>;
110
+ try {
111
+ child = spawn(command, args, {
112
+ stdio: ["ignore", "pipe", "ignore"],
113
+ windowsHide: true,
114
+ });
115
+ } catch {
116
+ resolve({ stdout: "", status: null });
117
+ return;
118
+ }
119
+ const timeout = setTimeout(() => {
120
+ if (child.pid) killProcessTree(child.pid);
121
+ }, timeoutMs);
122
+ child.stdout?.setEncoding("utf8");
123
+ child.stdout?.on("data", (chunk: string) => {
124
+ stdout += chunk;
125
+ });
126
+ child.on("error", () => {
127
+ clearTimeout(timeout);
128
+ resolve({ stdout: "", status: null });
129
+ });
130
+ child.on("close", (status) => {
131
+ clearTimeout(timeout);
132
+ resolve({ stdout, status });
133
+ });
134
+ });
135
+ }
136
+
137
+ async function findBashOnPath(): Promise<string | null> {
138
+ const result =
139
+ process.platform === "win32"
140
+ ? await runCommand("where", ["bash.exe"], 5000)
141
+ : await runCommand("which", ["bash"], 5000);
142
+ if (result.status !== 0 || !result.stdout) return null;
143
+ const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
144
+ return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
145
+ }
146
+
147
+ interface ShellConfig {
148
+ shell: string;
149
+ args: string[];
150
+ commandTransport?: "argv" | "stdin";
151
+ }
152
+
153
+ function isLegacyWslBashPath(path: string): boolean {
154
+ const normalized = path.replace(/\//g, "\\").toLowerCase();
155
+ return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
156
+ }
157
+
158
+ function getBashShellConfig(shell: string): ShellConfig {
159
+ return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
160
+ }
161
+
162
+ async function getShellConfig(customShellPath?: string): Promise<Result<ShellConfig, ExecutionError>> {
163
+ if (customShellPath) {
164
+ if (await pathExists(customShellPath)) {
165
+ return ok(getBashShellConfig(customShellPath));
166
+ }
167
+ return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
168
+ }
169
+ if (process.platform === "win32") {
170
+ const candidates: string[] = [];
171
+ const programFiles = process.env.ProgramFiles;
172
+ if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`);
173
+ const programFilesX86 = process.env["ProgramFiles(x86)"];
174
+ if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
175
+ for (const candidate of candidates) {
176
+ if (await pathExists(candidate)) {
177
+ return ok(getBashShellConfig(candidate));
178
+ }
179
+ }
180
+ const bashOnPath = await findBashOnPath();
181
+ if (bashOnPath) {
182
+ return ok(getBashShellConfig(bashOnPath));
183
+ }
184
+ return err(new ExecutionError("shell_unavailable", "No bash shell found"));
185
+ }
186
+
187
+ if (await pathExists("/bin/bash")) {
188
+ return ok(getBashShellConfig("/bin/bash"));
189
+ }
190
+ const bashOnPath = await findBashOnPath();
191
+ if (bashOnPath) {
192
+ return ok(getBashShellConfig(bashOnPath));
193
+ }
194
+ return ok({ shell: "sh", args: ["-c"] });
195
+ }
196
+
197
+ function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
198
+ return {
199
+ ...process.env,
200
+ ...baseEnv,
201
+ ...extraEnv,
202
+ };
203
+ }
204
+
205
+ function killProcessTree(pid: number): void {
206
+ if (process.platform === "win32") {
207
+ try {
208
+ spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
209
+ stdio: "ignore",
210
+ detached: true,
211
+ windowsHide: true,
212
+ });
213
+ } catch {
214
+ // Ignore errors.
215
+ }
216
+ return;
217
+ }
218
+
219
+ try {
220
+ process.kill(-pid, "SIGKILL");
221
+ } catch {
222
+ try {
223
+ process.kill(pid, "SIGKILL");
224
+ } catch {
225
+ // Process already dead.
226
+ }
227
+ }
228
+ }
229
+
230
+ export class NodeExecutionEnv implements ExecutionEnv {
231
+ cwd: string;
232
+ private shellPath?: string;
233
+ private shellEnv?: NodeJS.ProcessEnv;
234
+
235
+ constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
236
+ this.cwd = options.cwd;
237
+ this.shellPath = options.shellPath;
238
+ this.shellEnv = options.shellEnv;
239
+ }
240
+
241
+ async absolutePath(path: string): Promise<Result<string, FileError>> {
242
+ return ok(resolvePath(this.cwd, path));
243
+ }
244
+
245
+ async joinPath(parts: string[]): Promise<Result<string, FileError>> {
246
+ return ok(join(...parts));
247
+ }
248
+
249
+ async exec(
250
+ command: string,
251
+ options?: {
252
+ cwd?: string;
253
+ env?: Record<string, string>;
254
+ timeout?: number;
255
+ abortSignal?: AbortSignal;
256
+ onStdout?: (chunk: string) => void;
257
+ onStderr?: (chunk: string) => void;
258
+ },
259
+ ): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
260
+ if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
261
+
262
+ const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
263
+ const shellConfig = await getShellConfig(this.shellPath);
264
+ if (!shellConfig.ok) return shellConfig;
265
+
266
+ return await new Promise((resolvePromise) => {
267
+ let stdout = "";
268
+ let stderr = "";
269
+ let settled = false;
270
+ let timedOut = false;
271
+ let callbackError: ExecutionError | undefined;
272
+ let child: ReturnType<typeof spawn> | undefined;
273
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
274
+
275
+ const onAbort = () => {
276
+ if (child?.pid) {
277
+ killProcessTree(child.pid);
278
+ }
279
+ };
280
+
281
+ const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
282
+ if (timeoutId) clearTimeout(timeoutId);
283
+ if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
284
+ if (settled) return;
285
+ settled = true;
286
+ resolvePromise(result);
287
+ };
288
+
289
+ try {
290
+ const commandFromStdin = shellConfig.value.commandTransport === "stdin";
291
+ child = spawn(
292
+ shellConfig.value.shell,
293
+ commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command],
294
+ {
295
+ cwd,
296
+ detached: process.platform !== "win32",
297
+ env: getShellEnv(this.shellEnv, options?.env),
298
+ stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
299
+ windowsHide: true,
300
+ },
301
+ );
302
+ if (commandFromStdin) {
303
+ child.stdin?.on("error", () => {});
304
+ child.stdin?.end(command);
305
+ }
306
+ } catch (error) {
307
+ const cause = toError(error);
308
+ settle(err(new ExecutionError("spawn_error", cause.message, cause)));
309
+ return;
310
+ }
311
+
312
+ timeoutId =
313
+ typeof options?.timeout === "number"
314
+ ? setTimeout(() => {
315
+ timedOut = true;
316
+ if (child?.pid) {
317
+ killProcessTree(child.pid);
318
+ }
319
+ }, options.timeout * 1000)
320
+ : undefined;
321
+
322
+ if (options?.abortSignal) {
323
+ if (options.abortSignal.aborted) {
324
+ onAbort();
325
+ } else {
326
+ options.abortSignal.addEventListener("abort", onAbort, { once: true });
327
+ }
328
+ }
329
+
330
+ child.stdout?.setEncoding("utf8");
331
+ child.stderr?.setEncoding("utf8");
332
+ child.stdout?.on("data", (chunk: string) => {
333
+ stdout += chunk;
334
+ try {
335
+ options?.onStdout?.(chunk);
336
+ } catch (error) {
337
+ const cause = toError(error);
338
+ callbackError = new ExecutionError("callback_error", cause.message, cause);
339
+ onAbort();
340
+ }
341
+ });
342
+ child.stderr?.on("data", (chunk: string) => {
343
+ stderr += chunk;
344
+ try {
345
+ options?.onStderr?.(chunk);
346
+ } catch (error) {
347
+ const cause = toError(error);
348
+ callbackError = new ExecutionError("callback_error", cause.message, cause);
349
+ onAbort();
350
+ }
351
+ });
352
+
353
+ child.on("error", (error) => {
354
+ settle(err(new ExecutionError("spawn_error", error.message, error)));
355
+ });
356
+
357
+ child.on("close", (code) => {
358
+ if (callbackError) {
359
+ settle(err(callbackError));
360
+ return;
361
+ }
362
+ if (timedOut) {
363
+ settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
364
+ return;
365
+ }
366
+ if (options?.abortSignal?.aborted) {
367
+ settle(err(new ExecutionError("aborted", "aborted")));
368
+ return;
369
+ }
370
+ settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
371
+ });
372
+ });
373
+ }
374
+
375
+ async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
376
+ const resolved = resolvePath(this.cwd, path);
377
+ const aborted = abortResult<string>(abortSignal, resolved);
378
+ if (aborted) return aborted;
379
+ try {
380
+ return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
381
+ } catch (error) {
382
+ return err(toFileError(error, resolved));
383
+ }
384
+ }
385
+
386
+ async readTextLines(
387
+ path: string,
388
+ options?: { maxLines?: number; abortSignal?: AbortSignal },
389
+ ): Promise<Result<string[], FileError>> {
390
+ const resolved = resolvePath(this.cwd, path);
391
+ const aborted = abortResult<string[]>(options?.abortSignal, resolved);
392
+ if (aborted) return aborted;
393
+ if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
394
+ let stream: ReturnType<typeof createReadStream> | undefined;
395
+ let lineReader: ReturnType<typeof createInterface> | undefined;
396
+ try {
397
+ stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
398
+ lineReader = createInterface({ input: stream, crlfDelay: Infinity });
399
+ const lines: string[] = [];
400
+ for await (const line of lineReader) {
401
+ const loopAbort = abortResult<string[]>(options?.abortSignal, resolved);
402
+ if (loopAbort) return loopAbort;
403
+ lines.push(line);
404
+ if (options?.maxLines !== undefined && lines.length >= options.maxLines) break;
405
+ }
406
+ const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved);
407
+ if (afterReadAbort) return afterReadAbort;
408
+ return ok(lines);
409
+ } catch (error) {
410
+ return err(toFileError(error, resolved));
411
+ } finally {
412
+ lineReader?.close();
413
+ stream?.destroy();
414
+ }
415
+ }
416
+
417
+ async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
418
+ const resolved = resolvePath(this.cwd, path);
419
+ const aborted = abortResult<Uint8Array>(abortSignal, resolved);
420
+ if (aborted) return aborted;
421
+ try {
422
+ return ok(await readFile(resolved, { signal: abortSignal }));
423
+ } catch (error) {
424
+ return err(toFileError(error, resolved));
425
+ }
426
+ }
427
+
428
+ async writeFile(
429
+ path: string,
430
+ content: string | Uint8Array,
431
+ abortSignal?: AbortSignal,
432
+ ): Promise<Result<void, FileError>> {
433
+ const resolved = resolvePath(this.cwd, path);
434
+ const aborted = abortResult<void>(abortSignal, resolved);
435
+ if (aborted) return aborted;
436
+ try {
437
+ await mkdir(resolve(resolved, ".."), { recursive: true });
438
+ const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
439
+ if (afterMkdirAbort) return afterMkdirAbort;
440
+ await writeFile(resolved, content, { signal: abortSignal });
441
+ return ok(undefined);
442
+ } catch (error) {
443
+ return err(toFileError(error, resolved));
444
+ }
445
+ }
446
+
447
+ async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
448
+ const resolved = resolvePath(this.cwd, path);
449
+ try {
450
+ await mkdir(resolve(resolved, ".."), { recursive: true });
451
+ await appendFile(resolved, content);
452
+ return ok(undefined);
453
+ } catch (error) {
454
+ return err(toFileError(error, resolved));
455
+ }
456
+ }
457
+
458
+ async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
459
+ const resolved = resolvePath(this.cwd, path);
460
+ try {
461
+ return fileInfoFromStats(resolved, await lstat(resolved));
462
+ } catch (error) {
463
+ return err(toFileError(error, resolved));
464
+ }
465
+ }
466
+
467
+ async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
468
+ const resolved = resolvePath(this.cwd, path);
469
+ const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
470
+ if (aborted) return aborted;
471
+ try {
472
+ const entries = await readdir(resolved, { withFileTypes: true });
473
+ const infos: FileInfo[] = [];
474
+ for (const entry of entries) {
475
+ const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
476
+ if (loopAbort) return loopAbort;
477
+ const entryPath = resolve(resolved, entry.name);
478
+ try {
479
+ const info = fileInfoFromStats(entryPath, await lstat(entryPath));
480
+ if (info.ok) infos.push(info.value);
481
+ } catch (error) {
482
+ return err(toFileError(error, entryPath));
483
+ }
484
+ }
485
+ return ok(infos);
486
+ } catch (error) {
487
+ return err(toFileError(error, resolved));
488
+ }
489
+ }
490
+
491
+ async canonicalPath(path: string): Promise<Result<string, FileError>> {
492
+ const resolved = resolvePath(this.cwd, path);
493
+ try {
494
+ return ok(await realpath(resolved));
495
+ } catch (error) {
496
+ return err(toFileError(error, resolved));
497
+ }
498
+ }
499
+
500
+ async exists(path: string): Promise<Result<boolean, FileError>> {
501
+ const result = await this.fileInfo(path);
502
+ if (result.ok) return ok(true);
503
+ if (result.error.code === "not_found") return ok(false);
504
+ return err(result.error);
505
+ }
506
+
507
+ async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> {
508
+ const resolved = resolvePath(this.cwd, path);
509
+ try {
510
+ await mkdir(resolved, { recursive: options?.recursive ?? true });
511
+ return ok(undefined);
512
+ } catch (error) {
513
+ return err(toFileError(error, resolved));
514
+ }
515
+ }
516
+
517
+ async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> {
518
+ const resolved = resolvePath(this.cwd, path);
519
+ try {
520
+ await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
521
+ return ok(undefined);
522
+ } catch (error) {
523
+ return err(toFileError(error, resolved));
524
+ }
525
+ }
526
+
527
+ async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> {
528
+ try {
529
+ return ok(await mkdtemp(join(tmpdir(), prefix)));
530
+ } catch (error) {
531
+ return err(toFileError(error));
532
+ }
533
+ }
534
+
535
+ async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> {
536
+ const dir = await this.createTempDir("tmp-");
537
+ if (!dir.ok) return dir;
538
+ const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
539
+ try {
540
+ await writeFile(filePath, "");
541
+ return ok(filePath);
542
+ } catch (error) {
543
+ return err(toFileError(error, filePath));
544
+ }
545
+ }
546
+
547
+ async cleanup(): Promise<void> {
548
+ // nothing to clean up for the local node implementation
549
+ }
550
+ }