@turri-dev/cli 0.0.0-stage → 0.1.1

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 (315) hide show
  1. package/.pi/themes/turri-dark.json +80 -0
  2. package/.pi/themes/turri-light.json +82 -0
  3. package/LICENSE +21 -0
  4. package/README.md +272 -2
  5. package/assets/jellyfish/dark-00.png +0 -0
  6. package/assets/jellyfish/dark-01.png +0 -0
  7. package/assets/jellyfish/dark-02.png +0 -0
  8. package/assets/jellyfish/dark-03.png +0 -0
  9. package/assets/jellyfish/dark-04.png +0 -0
  10. package/assets/jellyfish/dark-05.png +0 -0
  11. package/assets/jellyfish/dark-06.png +0 -0
  12. package/assets/jellyfish/dark-07.png +0 -0
  13. package/assets/jellyfish/dark-08.png +0 -0
  14. package/assets/jellyfish/dark-09.png +0 -0
  15. package/assets/jellyfish/dark-10.png +0 -0
  16. package/assets/jellyfish/dark-11.png +0 -0
  17. package/assets/jellyfish/dark-12.png +0 -0
  18. package/assets/jellyfish/dark-13.png +0 -0
  19. package/assets/jellyfish/dark-14.png +0 -0
  20. package/assets/jellyfish/fallback.txt +252 -0
  21. package/assets/jellyfish/light-00.png +0 -0
  22. package/assets/jellyfish/light-01.png +0 -0
  23. package/assets/jellyfish/light-02.png +0 -0
  24. package/assets/jellyfish/light-03.png +0 -0
  25. package/assets/jellyfish/light-04.png +0 -0
  26. package/assets/jellyfish/light-05.png +0 -0
  27. package/assets/jellyfish/light-06.png +0 -0
  28. package/assets/jellyfish/light-07.png +0 -0
  29. package/assets/jellyfish/light-08.png +0 -0
  30. package/assets/jellyfish/light-09.png +0 -0
  31. package/assets/jellyfish/light-10.png +0 -0
  32. package/assets/jellyfish/light-11.png +0 -0
  33. package/assets/jellyfish/light-12.png +0 -0
  34. package/assets/jellyfish/light-13.png +0 -0
  35. package/assets/jellyfish/light-14.png +0 -0
  36. package/bin/turri.mjs +29 -0
  37. package/dist/ask.js +149 -0
  38. package/dist/brand.js +50 -0
  39. package/dist/btw.js +174 -0
  40. package/dist/chat-models.js +37 -0
  41. package/dist/chat-skills.js +18 -0
  42. package/dist/cli.js +178 -0
  43. package/dist/config-command.js +197 -0
  44. package/dist/config-edit.js +367 -0
  45. package/dist/config.js +489 -0
  46. package/dist/conform-cli.js +29 -0
  47. package/dist/cultivate-cli.js +110 -0
  48. package/dist/drift-cli.js +25 -0
  49. package/dist/environment.js +70 -0
  50. package/dist/extension.js +2372 -0
  51. package/dist/genome.js +301 -0
  52. package/dist/graph.js +246 -0
  53. package/dist/identity.js +35 -0
  54. package/dist/init-config.js +511 -0
  55. package/dist/judge.js +62 -0
  56. package/dist/lineage-cli.js +13 -0
  57. package/dist/loop-cli.js +132 -0
  58. package/dist/loop-tools.js +225 -0
  59. package/dist/mature-tools.js +193 -0
  60. package/dist/model-check.js +117 -0
  61. package/dist/module-report.js +9 -0
  62. package/dist/pi-launch.js +31 -0
  63. package/dist/pi.js +813 -0
  64. package/dist/plain/check.js +76 -0
  65. package/dist/plain/concepts.js +213 -0
  66. package/dist/plain/errors.js +43 -0
  67. package/dist/plain/files.js +57 -0
  68. package/dist/plain/highlight.js +195 -0
  69. package/dist/plain/index.js +15 -0
  70. package/dist/plain/liquid.js +129 -0
  71. package/dist/plain/markdown.js +208 -0
  72. package/dist/plain/parser.js +752 -0
  73. package/dist/plain/plain.tmLanguage.json +124 -0
  74. package/dist/plain/spec.js +291 -0
  75. package/dist/plain/stdlib.js +44 -0
  76. package/dist/question-dialog.js +125 -0
  77. package/dist/questioning.js +109 -0
  78. package/dist/quiet.js +125 -0
  79. package/dist/regenerate/activity.js +187 -0
  80. package/dist/regenerate/agent-env.js +190 -0
  81. package/dist/regenerate/agent-shell.js +114 -0
  82. package/dist/regenerate/arbiter.js +133 -0
  83. package/dist/regenerate/audit.js +203 -0
  84. package/dist/regenerate/cases.js +83 -0
  85. package/dist/regenerate/change-view.js +102 -0
  86. package/dist/regenerate/choose-conformance.js +117 -0
  87. package/dist/regenerate/chunk-plan.js +625 -0
  88. package/dist/regenerate/codebase.js +75 -0
  89. package/dist/regenerate/command-check.js +312 -0
  90. package/dist/regenerate/compaction.js +20 -0
  91. package/dist/regenerate/confine.js +310 -0
  92. package/dist/regenerate/conform.js +133 -0
  93. package/dist/regenerate/conformance-agents.js +955 -0
  94. package/dist/regenerate/conformance.js +936 -0
  95. package/dist/regenerate/conversation-writes.js +100 -0
  96. package/dist/regenerate/credentials.js +88 -0
  97. package/dist/regenerate/debug-log.js +228 -0
  98. package/dist/regenerate/debugger.js +90 -0
  99. package/dist/regenerate/delivery.js +19 -0
  100. package/dist/regenerate/diagnose-job.js +40 -0
  101. package/dist/regenerate/diagnose.js +81 -0
  102. package/dist/regenerate/diff-view.js +240 -0
  103. package/dist/regenerate/drift.js +194 -0
  104. package/dist/regenerate/engine.js +118 -0
  105. package/dist/regenerate/environment-error.js +220 -0
  106. package/dist/regenerate/job-log.js +102 -0
  107. package/dist/regenerate/job-panel.js +336 -0
  108. package/dist/regenerate/jobs-tree.js +300 -0
  109. package/dist/regenerate/jobs.js +427 -0
  110. package/dist/regenerate/lanes.js +32 -0
  111. package/dist/regenerate/lineage.js +384 -0
  112. package/dist/regenerate/loop.js +1435 -0
  113. package/dist/regenerate/masking.js +95 -0
  114. package/dist/regenerate/notes.js +397 -0
  115. package/dist/regenerate/os-sandbox.js +243 -0
  116. package/dist/regenerate/paths.js +165 -0
  117. package/dist/regenerate/plain-palette.js +83 -0
  118. package/dist/regenerate/prompt.js +704 -0
  119. package/dist/regenerate/records.js +294 -0
  120. package/dist/regenerate/regenerate.js +598 -0
  121. package/dist/regenerate/regression.js +181 -0
  122. package/dist/regenerate/release.js +262 -0
  123. package/dist/regenerate/requires.js +241 -0
  124. package/dist/regenerate/resources.js +164 -0
  125. package/dist/regenerate/self-check.js +111 -0
  126. package/dist/regenerate/services.js +155 -0
  127. package/dist/regenerate/shell.js +319 -0
  128. package/dist/regenerate/side-panel.js +278 -0
  129. package/dist/regenerate/spec-map-view.js +574 -0
  130. package/dist/regenerate/spec-map.js +561 -0
  131. package/dist/regenerate/spec-steps.js +527 -0
  132. package/dist/regenerate/state.js +10 -0
  133. package/dist/regenerate/steer.js +91 -0
  134. package/dist/regenerate/steering.js +119 -0
  135. package/dist/regenerate/stop-report.js +161 -0
  136. package/dist/regenerate/submit-work.js +71 -0
  137. package/dist/regenerate/test-kinds.js +124 -0
  138. package/dist/regenerate/test-scripts.js +695 -0
  139. package/dist/regenerate/testing.js +406 -0
  140. package/dist/regenerate/transcript-view.js +1 -0
  141. package/dist/regenerate/unit-gate.js +20 -0
  142. package/dist/regenerate/unit-review.js +347 -0
  143. package/dist/regenerate/web.js +256 -0
  144. package/dist/regenerate/workspace.js +99 -0
  145. package/dist/regenerate-cli.js +68 -0
  146. package/dist/release-agent.js +114 -0
  147. package/dist/release-cli.js +131 -0
  148. package/dist/release-tools.js +148 -0
  149. package/dist/review/live.js +165 -0
  150. package/dist/rules.js +47 -0
  151. package/dist/self-invocation.js +110 -0
  152. package/dist/setup.js +245 -0
  153. package/dist/skill-progress.js +78 -0
  154. package/dist/skill.js +149 -0
  155. package/dist/skills.js +74 -0
  156. package/dist/spec-diff-cli.js +37 -0
  157. package/dist/spec-tools.js +155 -0
  158. package/dist/stack.js +158 -0
  159. package/dist/stacks.js +97 -0
  160. package/dist/stdlib.js +100 -0
  161. package/dist/terminal-images.js +46 -0
  162. package/dist/tools.js +242 -0
  163. package/dist/validate-cli.js +52 -0
  164. package/docs/README.md +58 -0
  165. package/docs/authoring-plain.md +245 -0
  166. package/docs/config.md +353 -0
  167. package/docs/deploy.md +109 -0
  168. package/docs/development.md +75 -0
  169. package/docs/headless.md +148 -0
  170. package/docs/images/how-turri-works.svg +50 -0
  171. package/docs/images/turri-banner.png +0 -0
  172. package/docs/installation.md +118 -0
  173. package/docs/parser.md +108 -0
  174. package/docs/regenerating.md +1116 -0
  175. package/docs/sandbox.md +233 -0
  176. package/docs/skills-and-gates.md +254 -0
  177. package/docs/theming.md +76 -0
  178. package/docs/using.md +454 -0
  179. package/genome/rules/bullet-continuation.md +69 -0
  180. package/genome/rules/concise-specs-examples.md +431 -0
  181. package/genome/rules/concise-specs-integration-examples.md +270 -0
  182. package/genome/rules/concise-specs.md +80 -0
  183. package/genome/rules/definitions.md +107 -0
  184. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
  185. package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
  186. package/genome/rules/exported-concepts.md +39 -0
  187. package/genome/rules/func-specs.md +129 -0
  188. package/genome/rules/impl-reqs.md +68 -0
  189. package/genome/rules/import-modules.md +48 -0
  190. package/genome/rules/integration-embedded-testing.md +276 -0
  191. package/genome/rules/integration-embedded.md +171 -0
  192. package/genome/rules/integration-standalone.md +135 -0
  193. package/genome/rules/integrations.md +137 -0
  194. package/genome/rules/linked-resources.md +69 -0
  195. package/genome/rules/module-structure.md +105 -0
  196. package/genome/rules/project-model.md +88 -0
  197. package/genome/rules/required-concepts.md +44 -0
  198. package/genome/rules/requires-modules.md +80 -0
  199. package/genome/rules/stdlib.md +73 -0
  200. package/genome/rules/test-reqs.md +57 -0
  201. package/genome/skills/add-acceptance-test/SKILL.md +100 -0
  202. package/genome/skills/add-concept/SKILL.md +71 -0
  203. package/genome/skills/add-functional-spec/SKILL.md +84 -0
  204. package/genome/skills/add-functional-specs/SKILL.md +118 -0
  205. package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
  206. package/genome/skills/add-resource/SKILL.md +105 -0
  207. package/genome/skills/add-template/SKILL.md +64 -0
  208. package/genome/skills/add-test-requirement/SKILL.md +73 -0
  209. package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
  210. package/genome/skills/analyze-func-specs/SKILL.md +123 -0
  211. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
  212. package/genome/skills/break-down-func-spec/SKILL.md +205 -0
  213. package/genome/skills/brood/SKILL.md +229 -0
  214. package/genome/skills/brood/references/intent.md +35 -0
  215. package/genome/skills/brood/references/product.md +101 -0
  216. package/genome/skills/brood/references/stack.md +61 -0
  217. package/genome/skills/brood/references/testing.md +56 -0
  218. package/genome/skills/bud/SKILL.md +148 -0
  219. package/genome/skills/bud/references/checklist.md +44 -0
  220. package/genome/skills/bud/references/intent.md +30 -0
  221. package/genome/skills/check-plain-env/SKILL.md +285 -0
  222. package/genome/skills/consolidate-concepts/SKILL.md +192 -0
  223. package/genome/skills/create-import-module/SKILL.md +104 -0
  224. package/genome/skills/create-requires-module/SKILL.md +109 -0
  225. package/genome/skills/debug-specs/SKILL.md +188 -0
  226. package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
  227. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
  228. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
  229. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
  230. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
  231. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
  232. package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
  233. package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
  234. package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
  235. package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
  236. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
  237. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
  238. package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
  239. package/genome/skills/init-plain-project/SKILL.md +117 -0
  240. package/genome/skills/init-plain-project/references/checklist.md +30 -0
  241. package/genome/skills/mature/SKILL.md +126 -0
  242. package/genome/skills/refactor-module/SKILL.md +196 -0
  243. package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
  244. package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
  245. package/genome/skills/spawn/SKILL.md +163 -0
  246. package/package.json +68 -4
  247. package/stdlib/backend/common.plain +25 -0
  248. package/stdlib/backend/conformance/common.plain +12 -0
  249. package/stdlib/backend/conformance/go-gin.plain +9 -0
  250. package/stdlib/backend/conformance/go-nethttp.plain +9 -0
  251. package/stdlib/backend/conformance/java-springboot.plain +9 -0
  252. package/stdlib/backend/conformance/python-django.plain +9 -0
  253. package/stdlib/backend/conformance/python-fastapi.plain +9 -0
  254. package/stdlib/backend/conformance/python-flask.plain +9 -0
  255. package/stdlib/backend/conformance/rust-axum.plain +9 -0
  256. package/stdlib/backend/conformance/typescript-express.plain +9 -0
  257. package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
  258. package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
  259. package/stdlib/backend/go-gin.plain +18 -0
  260. package/stdlib/backend/go-nethttp.plain +18 -0
  261. package/stdlib/backend/java-springboot.plain +19 -0
  262. package/stdlib/backend/python-django.plain +19 -0
  263. package/stdlib/backend/python-fastapi.plain +23 -0
  264. package/stdlib/backend/python-flask.plain +23 -0
  265. package/stdlib/backend/rust-axum.plain +17 -0
  266. package/stdlib/backend/typescript-express.plain +19 -0
  267. package/stdlib/backend/typescript-fastify.plain +19 -0
  268. package/stdlib/backend/typescript-nestjs.plain +19 -0
  269. package/stdlib/cli/common.plain +17 -0
  270. package/stdlib/cli/conformance/common.plain +11 -0
  271. package/stdlib/cli/conformance/go-cobra.plain +9 -0
  272. package/stdlib/cli/conformance/go-flag.plain +9 -0
  273. package/stdlib/cli/conformance/java-picocli.plain +9 -0
  274. package/stdlib/cli/conformance/python-argparse.plain +9 -0
  275. package/stdlib/cli/conformance/python-click.plain +9 -0
  276. package/stdlib/cli/conformance/python-typer.plain +9 -0
  277. package/stdlib/cli/conformance/rust-clap.plain +9 -0
  278. package/stdlib/cli/conformance/typescript-commander.plain +9 -0
  279. package/stdlib/cli/go-cobra.plain +14 -0
  280. package/stdlib/cli/go-flag.plain +14 -0
  281. package/stdlib/cli/java-picocli.plain +15 -0
  282. package/stdlib/cli/python-argparse.plain +18 -0
  283. package/stdlib/cli/python-click.plain +16 -0
  284. package/stdlib/cli/python-typer.plain +16 -0
  285. package/stdlib/cli/rust-clap.plain +14 -0
  286. package/stdlib/cli/typescript-commander.plain +17 -0
  287. package/stdlib/frontend/angular.plain +17 -0
  288. package/stdlib/frontend/common.plain +17 -0
  289. package/stdlib/frontend/conformance/angular.plain +5 -0
  290. package/stdlib/frontend/conformance/common.plain +11 -0
  291. package/stdlib/frontend/conformance/react-vite.plain +5 -0
  292. package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
  293. package/stdlib/frontend/conformance/vue-vite.plain +5 -0
  294. package/stdlib/frontend/react-vite.plain +16 -0
  295. package/stdlib/frontend/sveltekit-spa.plain +16 -0
  296. package/stdlib/frontend/vue-vite.plain +16 -0
  297. package/stdlib/lang/go.plain +13 -0
  298. package/stdlib/lang/java.plain +12 -0
  299. package/stdlib/lang/python.plain +18 -0
  300. package/stdlib/lang/rust.plain +12 -0
  301. package/stdlib/lang/typescript.plain +13 -0
  302. package/stdlib/library/common.plain +15 -0
  303. package/stdlib/library/conformance/common.plain +9 -0
  304. package/stdlib/library/conformance/go.plain +9 -0
  305. package/stdlib/library/conformance/java-gradle.plain +9 -0
  306. package/stdlib/library/conformance/java-maven.plain +9 -0
  307. package/stdlib/library/conformance/python.plain +9 -0
  308. package/stdlib/library/conformance/rust.plain +9 -0
  309. package/stdlib/library/conformance/typescript.plain +9 -0
  310. package/stdlib/library/go.plain +12 -0
  311. package/stdlib/library/java-gradle.plain +13 -0
  312. package/stdlib/library/java-maven.plain +13 -0
  313. package/stdlib/library/python.plain +13 -0
  314. package/stdlib/library/rust.plain +12 -0
  315. package/stdlib/library/typescript.plain +15 -0
@@ -0,0 +1,347 @@
1
+ import { basename } from "node:path";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { ask, createExecutor } from "../pi.js";
5
+ import { reportActivity } from "./activity.js";
6
+ import { diffFiles, digestFiles } from "./codebase.js";
7
+ import { confinedTools } from "./confine.js";
8
+ import { REVIEWER_CALLS, reviewKind } from "./conformance-agents.js";
9
+ import { readNotes, recordNote, reflectOnReview, REVIEW_LESSON } from "./notes.js";
10
+ import { diffSinceSnapshot, workDiff, workSnapshot } from "./lineage.js";
11
+ import { REVIEWER_COMPACTION } from "./compaction.js";
12
+ import { describeAccounts, SUBMIT_AFTER_REVIEW, unanswered, unansweredPrompt } from "./submit-work.js";
13
+ import { ARBITER_AFTER, arbitrateReview, dismissedNote, problemLine, REVIEW_LIMIT } from "./arbiter.js";
14
+ import { codeReviewCasesBlock } from "./cases.js";
15
+ import { TEST_FILE_PATTERN } from "./testing.js";
16
+ import { createCanItFailTool } from "./self-check.js";
17
+ /** How many times the reviewer runs can_it_fail in a review session: each is a run of the unit testing script. */
18
+ export const REVIEWER_CAN_IT_FAIL_RUNS = 3;
19
+ /**
20
+ * How many reviews may send the unit tests back before the arbiter weighs them: from this one on, every review that
21
+ * sends them back goes to the arbiter first, and only what it upholds reaches the coding agent.
22
+ */
23
+ export const UNIT_REVIEW_ROUNDS = ARBITER_AFTER;
24
+ /**
25
+ * How many reviews at most. The review optimises the tests; it does not decide whether the work is done - the
26
+ * unit testing script does. Past this many, whatever it still finds, blocking or not, is let go, kept in the notes,
27
+ * and the work goes on.
28
+ */
29
+ export const UNIT_REVIEW_LIMIT = REVIEW_LIMIT;
30
+ /** The unit test files that differ from what they were when the work started: added or changed. */
31
+ export function changedUnitTests(buildDir, atStart) {
32
+ const { added, changed } = diffFiles(atStart, digestFiles(buildDir));
33
+ return [...added, ...changed].filter((file) => TEST_FILE_PATTERN.test(basename(file)));
34
+ }
35
+ /**
36
+ * The reviewer's task, for its instructions: what it reviews, against which specification, and how. It stays word for
37
+ * word through every review of the loop; what each review is shown comes as a message (`unitReviewRequest`).
38
+ */
39
+ export function unitReviewTask(options) {
40
+ const mechanical = options.cases?.mechanical === true;
41
+ return [
42
+ `You review the work on ${options.what}, each time its unit tests pass: passing tests prove only what they`,
43
+ "check - a test that checks next to nothing passes whatever the code does, and code no test reaches is not checked",
44
+ "at all. Review both: that the code does what the specification says, and that its unit tests test it.",
45
+ "",
46
+ "You are the exacting reviewer: opinionated, thorough, and hard to satisfy. Raise every problem you find with",
47
+ "the code and its tests, however small - a senior engineer weighs your findings after you, and lets go what is",
48
+ "not worth the time. Missing one is worse than raising one too many.",
49
+ "",
50
+ `The code and its unit tests are in ${options.buildDir}.`,
51
+ "",
52
+ "How to read it:",
53
+ "- <specification> is the specification exactly as the coding agent had it. Review the work against <new in this",
54
+ " work>; the rest is there so you know what the words mean and what was there before.",
55
+ "- The diff you are shown is exactly what changed in the code folder - code and tests - new files whole. It is",
56
+ " taken by the harness, not by the agent: start from it, and read a file whole where the diff shows too little of",
57
+ " it, or to see how the new code fits what was there. Do not guess what the code does - it is all there.",
58
+ "- <the coding agent's account> is what the agent that did the work says it did. It is a claim: check every part of",
59
+ " it against the diff and the files. A problem it says is fixed that the code does not fix is still a problem; one",
60
+ " it declined, weigh its reason - it may be right.",
61
+ "",
62
+ "<specification>",
63
+ options.spec.trim(),
64
+ "</specification>",
65
+ "",
66
+ ...(options.cases !== undefined ? [...codeReviewCasesBlock(options.cases), ""] : []),
67
+ // A job that touches no functional spec is held only to that: the checks of behaviour are not its.
68
+ ...(mechanical ? [] : ["Check that the code does what the specification states for this work:"]),
69
+ ...(mechanical
70
+ ? []
71
+ : [
72
+ "- Every behaviour, edge case, validation rule and error it states is implemented, and implemented as it says -",
73
+ " the values, the messages, the order, the limits. Nothing is left as a stub, a placeholder, a TODO or a",
74
+ " hard-coded answer that passes a test and nothing else.",
75
+ "- It keeps to the implementation reqs, and does not break what the code already implements.",
76
+ "- When the work wrote no code at all, or none for a behaviour, that behaviour is not implemented: raise it.",
77
+ "",
78
+ "Check that every behaviour the specification states for this work is tested, and tested well:",
79
+ "- Each behaviour, edge case, validation rule and error the specification states has a test, and the test",
80
+ " asserts the outcome the specification says - the exact value returned, the state stored, the error raised",
81
+ " and its message or code - not only that something came back.",
82
+ "- A test fails when the behaviour it is named for breaks. Weak: it only checks that a function, a route, a",
83
+ " file or a field exists; that a result is not null, is truthy, has a type or a length; that a status is 200",
84
+ " and nothing about the body; `assert True`, an empty or skipped test, an exception caught and swallowed, an",
85
+ " assertion so loose any answer passes, an expected value computed the way the code computes it.",
86
+ ...(options.canItFail === true
87
+ ? [
88
+ "- A test you doubt can fail, check: can_it_fail puts a bug in a copy of the code - a condition turned round, a",
89
+ " value changed - and runs the unit tests on it. A test that still passes cannot fail: raise it as bad, with the",
90
+ ` bug it missed. ${REVIEWER_CAN_IT_FAIL_RUNS} runs a session: spend them on the tests the work rests on.`,
91
+ ]
92
+ : []),
93
+ "- It tests the code, not a stand-in: what is outside the unit - a network service, a clock, a database the",
94
+ " unit does not own - may be replaced; the unit under test itself never is, and a test that asserts only",
95
+ " what a mock was told to return tests the mock.",
96
+ "- Deterministic: no real network, no sleeping, no dependence on the time of day or the order tests run in.",
97
+ ...(options.forChange === true
98
+ ? [
99
+ "This work is the <spec change> in <new in this work>, section by section: what it adds or changes is what needs",
100
+ "testing, and the tests it changed still have to test the rest as they did. A change that adds no behaviour - a",
101
+ "description, a renamed concept, a reworded spec - needs no new test.",
102
+ "Behaviour is what a functional spec, an acceptance test or an implementation req states. A definition says what",
103
+ "a word means: a page, a component or a record that only <definitions> name, and no functional spec or",
104
+ "implementation req of this codebase asks for, has no code yet, and a test for it is not missing.",
105
+ ]
106
+ : []),
107
+ ]),
108
+ "Only what this work's specification asks for: a test of something it does not state, or of how the code",
109
+ "works inside rather than what it does, is not a problem to raise; nor is code written another way than you",
110
+ "would have, as long as it does what the specification says.",
111
+ "",
112
+ "You review the same work more than once. The first time you are shown everything it wrote, and find every problem",
113
+ "in that one review: the work goes back once with all of them. Each time after, you are shown what it changed since",
114
+ "and what the coding agent says it did about each problem: check each one you raised first - fixed, or still there -",
115
+ "and raise a new one only when it would be blocking (below). It is not done again from the start.",
116
+ "",
117
+ "Each review ends with submit_review, once: pass when the code does what the specification says and nothing is",
118
+ 'missing or weak; otherwise every problem - the file, the test or the function, its kind - "wrong" for code that does',
119
+ 'not do what the specification says, "missing" for a test that is not there, "bad" for one that is weak or wrong -',
120
+ "and exactly what, with what the specification says instead. Be specific: the agent that fixes them gets your list",
121
+ "and nothing else. Mark a problem blocking when because of it a behaviour the specification states is not",
122
+ "implemented, implemented wrong, or not tested - no test for it, or one that cannot fail. Those are worked on first;",
123
+ "the rest - ways to make code or tests that already do it better - are let go after a few reviews.",
124
+ ].join("\n");
125
+ }
126
+ /** What one review is shown: the whole work the first time; after that, what changed since the last, and the problems it raised. */
127
+ export function unitReviewRequest(options) {
128
+ const diff = options.diff.trim();
129
+ const again = options.earlier !== undefined;
130
+ return [
131
+ again
132
+ ? "The unit tests pass again, after the coding agent worked on the problems your last review raised. Review it again."
133
+ : "The unit tests pass. Review the work.",
134
+ "",
135
+ again ? "<what changed since your last review - a diff of the code folder>" : "<the code this work wrote - a diff of the code folder from where the work started>",
136
+ diff !== "" ? diff : again ? "(nothing changed since your last review)" : "(nothing - the code folder is as the work found it)",
137
+ again ? "</what changed since your last review>" : "</the code this work wrote>",
138
+ "",
139
+ "<unit tests added or changed by the work>",
140
+ options.changed.length > 0 ? options.changed.join("\n") : "(none - no unit test was added or changed)",
141
+ "</unit tests added or changed by the work>",
142
+ "",
143
+ "<the coding agent's account>",
144
+ options.account,
145
+ "</the coding agent's account>",
146
+ // The problems it raised last are its own submit_review, in its session: named, not sent again.
147
+ ...(again && options.earlier.length > 0
148
+ ? ["", options.earlier.length === 1 ? "Check first the problem your last review raised." : `Check first each of the ${options.earlier.length} problems your last review raised.`]
149
+ : []),
150
+ ...(options.letGo !== undefined && options.letGo.length > 0
151
+ ? ["", "The arbiter weighed your last review and let these go - they are settled, do not raise them again:", ...options.letGo.map((problem) => `- ${problemLine(problem)}`)]
152
+ : []),
153
+ "",
154
+ "Then call submit_review.",
155
+ ].join("\n");
156
+ }
157
+ /**
158
+ * The reviewer: a read-only agent, on the judge model, that says pass or every problem - one session for the whole
159
+ * loop, so a review after the first starts from what it already read and raised, not from nothing. Its task is its
160
+ * instructions; its work is compacted as it gets longer.
161
+ */
162
+ export async function createUnitReviewer(options) {
163
+ let verdict;
164
+ const submit = defineTool({
165
+ name: "submit_review",
166
+ label: "Submit the review",
167
+ description: "Say whether the code and its unit tests pass the review, and if not, every problem with them. Call this once, when you are done.",
168
+ parameters: Type.Object({
169
+ pass: Type.Boolean({ description: "True only when the code does what the specification says and no test is missing or weak" }),
170
+ problems: Type.Array(Type.Object({
171
+ file: Type.String({ description: "The file, relative to the code folder: the code that is wrong, the test file, or the file a missing test belongs in" }),
172
+ test: Type.Optional(Type.String({ description: "The test or the function in it, when it is one" })),
173
+ kind: Type.String({
174
+ description: '"wrong" when the code does not do what the specification says - or does not do it at all; "missing" when a test the specification' +
175
+ ' calls for is not there; "bad" when a test is there but weak or wrong. Any other word is read as one of these - one starting with' +
176
+ ' "wrong" as wrong, with "missing" as missing, the rest as bad',
177
+ }),
178
+ what: Type.String({ description: "Exactly what is wrong, missing or weak, and what the specification says instead" }),
179
+ blocking: Type.Boolean({
180
+ description: "True when because of it a behaviour the specification states is not implemented, implemented wrong, or not tested - no test, or one that cannot fail." +
181
+ " False for a way to make code or a test that already does it better",
182
+ }),
183
+ })),
184
+ }),
185
+ async execute(_id, params) {
186
+ // "missing_test", "weak": the verdict is what counts, not the exact word - one bad word never throws a review away.
187
+ const given = { ...params };
188
+ given.problems = given.problems.map((problem) => ({ ...problem, kind: /^\s*wrong/i.test(problem.kind) ? "wrong" : reviewKind(problem.kind) }));
189
+ // A pass with problems, or a fail with none, is not a verdict.
190
+ if (given.pass === given.problems.length > 0) {
191
+ return {
192
+ content: [{ type: "text", text: given.pass ? "A pass lists no problems - either drop them or fail it." : "A fail has to say what is wrong: list every problem." }],
193
+ details: {},
194
+ };
195
+ }
196
+ verdict = given;
197
+ return { content: [{ type: "text", text: "Recorded." }], details: {}, terminate: true };
198
+ },
199
+ });
200
+ const fence = confinedTools({ workdir: options.buildDir, read: [options.buildDir], write: [], noBash: true });
201
+ // A test it doubts, it checks: whether the tests catch a bug put in a copy of the code - the code itself untouched.
202
+ const canItFail = options.unitScript !== undefined ? [createCanItFailTool({ buildDir: options.buildDir, script: options.unitScript, runs: REVIEWER_CAN_IT_FAIL_RUNS })] : [];
203
+ const session = await createExecutor({
204
+ cwd: options.buildDir,
205
+ customTools: [submit, ...canItFail, ...fence.tools],
206
+ tools: [...fence.names, ...canItFail.map((tool) => tool.name), "submit_review"],
207
+ model: options.model,
208
+ label: `${options.label} reviewer`, role: "reviewer",
209
+ system: fence.note,
210
+ task: unitReviewTask({ ...options, canItFail: options.unitScript !== undefined }),
211
+ compaction: { instructions: REVIEWER_COMPACTION },
212
+ budget: { calls: REVIEWER_CALLS, finish: ["submit_review"] },
213
+ });
214
+ return {
215
+ async review(request) {
216
+ verdict = undefined;
217
+ await ask(session, request, options.onDelta);
218
+ if (verdict === undefined)
219
+ await ask(session, "You have not called submit_review. Call it now: pass, or every problem.", options.onDelta);
220
+ return (verdict ?? {
221
+ pass: false,
222
+ problems: [{ file: "(all)", kind: "bad", what: "The review gave no verdict. Check the code and every unit test you added or changed against the specification yourself." }],
223
+ });
224
+ },
225
+ dispose: () => session.dispose(),
226
+ };
227
+ }
228
+ /** The review's problems, for the coding agent. */
229
+ export function unitReviewFeedbackPrompt(problems) {
230
+ return [
231
+ "The unit tests pass. A review of the work found where the code does not yet do what the specification says, or its",
232
+ "tests do not yet test it - each, exactly:",
233
+ "",
234
+ ...problems.map((problem, index) => `${index + 1}. ${problemLine(problem)}`),
235
+ "",
236
+ "Fix every one: make the code WRONG names do what the specification says, write the tests that are missing, and",
237
+ "make the weak ones assert what the specification says - the exact outcome, so they fail when it breaks. Then run",
238
+ "the unit tests. One you are sure the review is wrong about, decline, and say why.",
239
+ ...SUBMIT_AFTER_REVIEW,
240
+ ...REVIEW_LESSON,
241
+ ].join("\n");
242
+ }
243
+ /**
244
+ * Review the code and its unit tests until they pass it: each time it finds problems, they go to the coding agent
245
+ * and the unit tests must pass again before the next review. Past UNIT_REVIEW_ROUNDS reviews with
246
+ * nothing blocking, what it still suggests is let go and kept in the notes; past UNIT_REVIEW_LIMIT,
247
+ * whatever is left is let go the same way - the review optimises the tests, it never stops the work.
248
+ * Only unit tests that no longer pass after a round of fixes do.
249
+ */
250
+ export async function untilUnitTestsReviewed(options) {
251
+ const reviewer = await createUnitReviewer(options);
252
+ try {
253
+ return await reviewLoop(options, reviewer);
254
+ }
255
+ finally {
256
+ reviewer.dispose();
257
+ }
258
+ }
259
+ async function reviewLoop(options, reviewer) {
260
+ let earlier;
261
+ const history = [];
262
+ const waived = [];
263
+ /** What the reviewer saw last: the next review is shown only what changed since. */
264
+ let seen;
265
+ /** What the arbiter dismissed of the last review: the reviewer is told, so it does not raise them again. */
266
+ let letGo = [];
267
+ const said = (problems) => problems.map((problem) => `${problem.file}${problem.test ? ` ${problem.test}` : ""}: ${problem.what.trim()}`).join("; ").slice(0, 600);
268
+ for (let reviews = 0; reviews < UNIT_REVIEW_LIMIT; reviews += 1) {
269
+ const changed = changedUnitTests(options.buildDir, options.atStart);
270
+ // What the coding agent has written by now: it changes between reviews. The arbiter, fresh each time, reads it whole.
271
+ const code = workDiff(options.buildDir);
272
+ const account = describeAccounts(options.work?.take() ?? [], earlier);
273
+ const request = seen === undefined || earlier === undefined
274
+ ? unitReviewRequest({ diff: code, changed, account })
275
+ : unitReviewRequest({ diff: diffSinceSnapshot(options.buildDir, seen), changed, account, earlier, letGo });
276
+ seen = workSnapshot(options.buildDir);
277
+ letGo = [];
278
+ options.stage(`reviewing the code and its unit tests (review ${reviews + 1}): does the code do what the spec says, and does each test fail when that breaks`);
279
+ const review = await reviewer.review(request);
280
+ if (review.pass) {
281
+ options.stage("the review has nothing left to improve in the code or its unit tests");
282
+ return waived.length > 0 ? { passed: true, waived, reviews: reviews + 1 } : { passed: true, reviews: reviews + 1 };
283
+ }
284
+ let problems = review.problems;
285
+ // After the first UNIT_REVIEW_ROUNDS reviews that send them back, the arbiter weighs each first: only what it upholds goes to the coding agent.
286
+ if (reviews >= UNIT_REVIEW_ROUNDS) {
287
+ options.stage(`the review sent the work back ${reviews + 1 === 1 ? "once" : `${reviews + 1} times`} - the arbiter weighs what it found`);
288
+ const ruling = await arbitrateReview({
289
+ subject: `the code and unit tests of ${options.what}`,
290
+ workdir: options.buildDir,
291
+ read: [options.buildDir],
292
+ uphold: [
293
+ "- uphold it when because of it a behaviour this work's specification states - in a functional spec, an",
294
+ " acceptance test or an implementation req - is not implemented, is implemented other than it says, is not tested, or a",
295
+ " test cannot fail when that behaviour breaks. Then the reviewer is right, and the coding agent fixes it.",
296
+ ],
297
+ changed,
298
+ spec: `<specification>\n${options.spec.trim()}\n</specification>`,
299
+ code,
300
+ problems: review.problems,
301
+ history,
302
+ label: `${options.label} unit test`,
303
+ ...(options.model !== undefined ? { model: options.model } : {}),
304
+ ...(options.onDelta !== undefined ? { onDelta: options.onDelta } : {}),
305
+ });
306
+ // No ruling: the review stands, as it did before there was an arbiter.
307
+ if (ruling !== undefined) {
308
+ waived.push(...ruling.dismissed.map((entry) => entry.problem));
309
+ if (ruling.dismissed.length > 0)
310
+ recordNote(options.buildDir, "pitfalls", dismissedNote(ruling, "unit test"), `${options.label} unit tests`);
311
+ if (ruling.upheld.length === 0) {
312
+ options.stage("the arbiter approved the code and its unit tests as they are - what the review still raised is let go, and in the notes");
313
+ return { passed: true, waived, reviews: reviews + 1 };
314
+ }
315
+ options.stage(`the arbiter upheld ${ruling.upheld.length} of the review's ${review.problems.length} problem${review.problems.length === 1 ? "" : "s"} - the coding agent fixes ${ruling.upheld.length === 1 ? "it" : "those"}`);
316
+ problems = ruling.upheld.map((entry) => entry.problem);
317
+ letGo = ruling.dismissed.map((entry) => entry.problem);
318
+ }
319
+ }
320
+ history.push(review.problems);
321
+ const blocking = problems.filter((problem) => problem.blocking === true);
322
+ const wrong = problems.filter((problem) => problem.kind === "wrong");
323
+ reportActivity(` the review found ${problems.length} problem${problems.length === 1 ? "" : "s"}: ${problems[0].what.slice(0, 140)}`);
324
+ options.stage(wrong.length > 0
325
+ ? `the review found ${wrong.length} place${wrong.length === 1 ? "" : "s"} where the code does not do what the spec says - the coding agent fixes the code${problems.length > wrong.length ? " and its tests" : ""}`
326
+ : blocking.length > 0
327
+ ? `the review found ${blocking.length} behaviour${blocking.length === 1 ? "" : "s"} of the spec not yet tested - the coding agent completes the unit tests`
328
+ : `the review found ${problems.length} way${problems.length === 1 ? "" : "s"} to make the unit tests test more - the coding agent improves them`);
329
+ earlier = problems;
330
+ const pitfalls = readNotes(options.buildDir).pitfalls;
331
+ await options.send(unitReviewFeedbackPrompt(problems));
332
+ // Every problem answered - fixed, or declined and why - so the reviewer knows what became of each: sent back once.
333
+ if (options.work !== undefined && options.askForAnswers !== false) {
334
+ const missing = unanswered(options.work.peek(), problems.length);
335
+ if (missing.length > 0)
336
+ await options.send(unansweredPrompt(missing));
337
+ }
338
+ // The lesson, left for the agents after it: the next functionality's tests start from it.
339
+ await reflectOnReview(options.buildDir, pitfalls, options.send);
340
+ if (!(await options.untilPassing()))
341
+ return { passed: false, problems, reviews: reviews + 1 };
342
+ }
343
+ // An optimiser, not a gate: what the last review sent back was worked on and the unit tests pass - it is not reviewed again.
344
+ options.stage(`the last of ${UNIT_REVIEW_LIMIT} reviews sent the work back once more; its unit tests pass - going on without another review`);
345
+ recordNote(options.buildDir, "pitfalls", `the unit tests went on after ${UNIT_REVIEW_LIMIT} reviews, the last one's problems worked on but not reviewed again: ${said(earlier ?? [])}`, `${options.label} unit tests`);
346
+ return { passed: true, waived: [...waived, ...(earlier ?? [])], reviews: UNIT_REVIEW_LIMIT };
347
+ }
@@ -0,0 +1,256 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { defineTool } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ /**
6
+ * The web, for the agents that build: search it, and read a page.
7
+ *
8
+ * An agent building on a library it half-remembers, or stuck on an error it has not seen, does
9
+ * better reading the documentation or the answer than guessing again. So every rendering agent
10
+ * has two tools: `web_search`, which asks DuckDuckGo and returns the results, and `web_fetch`,
11
+ * which reads one page and returns it as text.
12
+ *
13
+ * Both run here, in turri itself. What reaches the web through them is a request the agent made
14
+ * through a tool, and what comes back is text it reads.
15
+ *
16
+ * Only the public web: `web_fetch` refuses an address on this machine or its network - localhost,
17
+ * the private ranges, the cloud metadata address - so it cannot be used to reach the cloud's
18
+ * credentials or a service inside the person's network by way of turri.
19
+ */
20
+ export const WEB_TOOLS = ["web_search", "web_fetch"];
21
+ const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
22
+ const TIMEOUT_MS = 20_000;
23
+ /** How much of a page one call returns; the rest is had with `offset`. */
24
+ export const PAGE_CHARS = 20_000;
25
+ /** How much of a response is read at all. */
26
+ const MAX_BYTES = 5_000_000;
27
+ const MAX_REDIRECTS = 5;
28
+ const ENTITIES = {
29
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", times: "×", hellip: "…", mdash: "—", ndash: "–",
30
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", laquo: "«", raquo: "»", copy: "©", reg: "®", trade: "™",
31
+ bull: "•", middot: "·", rarr: "→", larr: "←", deg: "°", euro: "€",
32
+ };
33
+ export function decodeEntities(text) {
34
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (whole, name) => {
35
+ if (name[0] === "#") {
36
+ const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
37
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
38
+ }
39
+ return ENTITIES[name.toLowerCase()] ?? whole;
40
+ });
41
+ }
42
+ function stripTags(html) {
43
+ return decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
44
+ }
45
+ /** A DuckDuckGo link, which may point at its own redirect, as the address it leads to. */
46
+ function resultUrl(href) {
47
+ const raw = decodeEntities(href);
48
+ try {
49
+ const url = new URL(raw, "https://duckduckgo.com");
50
+ if (url.hostname.endsWith("duckduckgo.com")) {
51
+ const target = url.searchParams.get("uddg");
52
+ return target !== null && /^https?:\/\//.test(target) ? target : undefined;
53
+ }
54
+ return /^https?:$/.test(url.protocol) ? url.href : undefined;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ /** The results on a page of DuckDuckGo Lite: each a link, then its snippet. Ads are left out. */
61
+ export function parseSearchResults(html) {
62
+ const results = [];
63
+ const link = /<a\b[^>]*\bhref=(["'])(.*?)\1[^>]*\bclass=(["'])result-link\3[^>]*>([\s\S]*?)<\/a>|<a\b[^>]*\bclass=(["'])result-link\5[^>]*\bhref=(["'])(.*?)\6[^>]*>([\s\S]*?)<\/a>/gi;
64
+ const matches = [...html.matchAll(link)];
65
+ matches.forEach((match, i) => {
66
+ const url = resultUrl(match[2] ?? match[7] ?? "");
67
+ if (url === undefined)
68
+ return;
69
+ const end = matches[i + 1]?.index ?? html.length;
70
+ const between = html.slice(match.index + match[0].length, end);
71
+ const snippet = /<td[^>]*class=(["'])result-snippet\1[^>]*>([\s\S]*?)<\/td>/i.exec(between);
72
+ results.push({ title: stripTags(match[4] ?? match[8] ?? ""), url, snippet: snippet ? stripTags(snippet[2]) : "" });
73
+ });
74
+ return results;
75
+ }
76
+ export async function webSearch(query, limit = 8) {
77
+ const response = await fetch("https://lite.duckduckgo.com/lite/", {
78
+ method: "POST",
79
+ headers: { "user-agent": USER_AGENT, "content-type": "application/x-www-form-urlencoded" },
80
+ body: new URLSearchParams({ q: query }).toString(),
81
+ signal: AbortSignal.timeout(TIMEOUT_MS),
82
+ });
83
+ const html = await response.text();
84
+ if (response.status !== 200 || (/anomaly|challenge-form/i.test(html) && !/result-link/.test(html))) {
85
+ throw new Error(`The search did not answer (DuckDuckGo said ${response.status}${response.status === 202 ? ", which is its rate limit" : ""}). ` +
86
+ "Wait a little and search again, or fetch a documentation page you know with web_fetch.");
87
+ }
88
+ return parseSearchResults(html).slice(0, limit);
89
+ }
90
+ /** Whether an IP address is on this machine or its network, rather than the public internet. */
91
+ export function isPrivateAddress(address) {
92
+ let ip = address.toLowerCase().replace(/^\[|\]$/g, "");
93
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(ip);
94
+ if (mapped)
95
+ ip = mapped[1];
96
+ if (isIP(ip) === 4) {
97
+ const [a, b] = ip.split(".").map(Number);
98
+ return (a === 0 ||
99
+ a === 10 ||
100
+ a === 127 ||
101
+ (a === 100 && b >= 64 && b <= 127) ||
102
+ (a === 169 && b === 254) ||
103
+ (a === 172 && b >= 16 && b <= 31) ||
104
+ (a === 192 && b === 168) ||
105
+ (a === 198 && (b === 18 || b === 19)) ||
106
+ a >= 224);
107
+ }
108
+ if (isIP(ip) === 6) {
109
+ return ip === "::" || ip === "::1" || /^f[cd]/.test(ip) || /^fe[89ab]/.test(ip) || /^ff/.test(ip) || /^::ffff:/.test(ip);
110
+ }
111
+ return true;
112
+ }
113
+ const lookupAll = async (host) => (await lookup(host, { all: true })).map((entry) => entry.address);
114
+ /** Refuse a URL that is not http(s) on the public internet; its parsed form when it is. */
115
+ export async function checkPublicUrl(raw, resolve = lookupAll) {
116
+ let url;
117
+ try {
118
+ url = new URL(raw);
119
+ }
120
+ catch {
121
+ throw new Error(`"${raw}" is not a URL. Give the whole address, starting with https://.`);
122
+ }
123
+ if (url.protocol !== "http:" && url.protocol !== "https:")
124
+ throw new Error(`Only http and https pages can be fetched, not ${url.protocol}`);
125
+ const host = url.hostname.replace(/^\[|\]$/g, "");
126
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
127
+ throw new Error(`${url.hostname} is not on the public web, and only the public web can be fetched.`);
128
+ }
129
+ const addresses = isIP(host) ? [host] : await resolve(host).catch(() => {
130
+ throw new Error(`${url.hostname} could not be found.`);
131
+ });
132
+ if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
133
+ throw new Error(`${url.hostname} is not on the public web, and only the public web can be fetched.`);
134
+ }
135
+ return url;
136
+ }
137
+ /** A page as readable text: what a person would read, without the markup, scripts and styles. */
138
+ export function htmlToText(html) {
139
+ const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
140
+ let body = html
141
+ .replace(/<!--[\s\S]*?-->/g, "")
142
+ .replace(/<(script|style|noscript|svg|head|template|iframe)\b[\s\S]*?<\/\1>/gi, "")
143
+ .replace(/<(nav|footer)\b[\s\S]*?<\/\1>/gi, "");
144
+ // Code keeps its lines; everything else is reflowed.
145
+ const blocks = [];
146
+ body = body.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_whole, code) => {
147
+ blocks.push(decodeEntities(code.replace(/<[^>]*>/g, "")).replace(/\n+$/, ""));
148
+ return `\n\u0000${blocks.length - 1}\u0000\n`;
149
+ });
150
+ body = body
151
+ .replace(/<h([1-6])\b[^>]*>/gi, (_whole, level) => `\n\n${"#".repeat(Number(level))} `)
152
+ .replace(/<li\b[^>]*>/gi, "\n- ")
153
+ .replace(/<br\s*\/?>/gi, "\n")
154
+ .replace(/<\/(p|div|section|article|h[1-6]|ul|ol|table|tr|blockquote|dl|dt|dd)>/gi, "\n")
155
+ .replace(/<(td|th)\b[^>]*>/gi, " | ")
156
+ .replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, "`$1`")
157
+ .replace(/<[^>]*>/g, "");
158
+ body = decodeEntities(body)
159
+ .split("\n")
160
+ .map((line) => line.replace(/[ \t ]+/g, " ").trim())
161
+ .join("\n")
162
+ .replace(/\n{3,}/g, "\n\n")
163
+ .replace(/\u0000(\d+)\u0000/g, (_whole, i) => `\`\`\`\n${blocks[Number(i)]}\n\`\`\``)
164
+ .trim();
165
+ const heading = title ? stripTags(title[1]) : "";
166
+ return heading !== "" && !body.startsWith(`# ${heading}`) ? `# ${heading}\n\n${body}` : body;
167
+ }
168
+ const READABLE = /^(text\/|application\/(json|xml|javascript|x-yaml|yaml|toml|x-sh|[a-z.+-]*\+(json|xml)))/i;
169
+ async function readLimited(response) {
170
+ const reader = response.body?.getReader();
171
+ if (reader === undefined)
172
+ return "";
173
+ const chunks = [];
174
+ let size = 0;
175
+ while (size < MAX_BYTES) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ chunks.push(value);
180
+ size += value.byteLength;
181
+ }
182
+ await reader.cancel().catch(() => { });
183
+ return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, MAX_BYTES));
184
+ }
185
+ /** Fetch a public page, following redirects - each checked - and return it as text. */
186
+ export async function webFetch(raw, opts = {}) {
187
+ const get = opts.fetchImpl ?? fetch;
188
+ let url = await checkPublicUrl(raw, opts.resolve);
189
+ for (let hop = 0;; hop++) {
190
+ const response = await get(url.href, {
191
+ redirect: "manual",
192
+ headers: { "user-agent": USER_AGENT, accept: "text/html,text/plain,application/json;q=0.9,*/*;q=0.5" },
193
+ signal: AbortSignal.timeout(TIMEOUT_MS),
194
+ });
195
+ const location = response.headers.get("location");
196
+ if (response.status >= 300 && response.status < 400 && location !== null) {
197
+ if (hop >= MAX_REDIRECTS)
198
+ throw new Error(`${raw} redirected more than ${MAX_REDIRECTS} times.`);
199
+ url = await checkPublicUrl(new URL(location, url).href, opts.resolve);
200
+ continue;
201
+ }
202
+ const contentType = response.headers.get("content-type") ?? "";
203
+ if (contentType !== "" && !READABLE.test(contentType)) {
204
+ await response.body?.cancel().catch(() => { });
205
+ throw new Error(`${url.href} is ${contentType.split(";")[0]}, not a page that can be read as text.`);
206
+ }
207
+ const body = await readLimited(response);
208
+ const html = /html/i.test(contentType) || (contentType === "" && /^\s*<(!doctype|html)/i.test(body));
209
+ return { url: url.href, status: response.status, contentType, text: html ? htmlToText(body) : body };
210
+ }
211
+ }
212
+ /** One window of a long page, and how to read on. */
213
+ export function pageWindow(text, offset) {
214
+ const start = Math.max(0, Math.min(offset, text.length));
215
+ const window = text.slice(start, start + PAGE_CHARS);
216
+ const rest = text.length - (start + window.length);
217
+ return rest > 0 ? `${window}\n\n[${rest} more characters: call web_fetch again with offset ${start + window.length}]` : window;
218
+ }
219
+ export function createWebTools() {
220
+ const search = defineTool({
221
+ name: "web_search",
222
+ label: "Web search",
223
+ description: "Search the web (DuckDuckGo) and get back the results: title, address and a snippet each. Use it for a library's " +
224
+ "documentation, an API you are unsure of, or an error you have not seen. Read a result with web_fetch.",
225
+ promptSnippet: "search the web for documentation, APIs and errors",
226
+ parameters: Type.Object({
227
+ query: Type.String({ description: "What to search for, as you would type it into a search engine" }),
228
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "How many results, 8 by default" })),
229
+ }),
230
+ async execute(_id, params) {
231
+ const results = await webSearch(params.query, params.limit ?? 8);
232
+ const text = results.length === 0
233
+ ? `No results for "${params.query}". Try other words.`
234
+ : results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
235
+ return { content: [{ type: "text", text }], details: { results: results.length } };
236
+ },
237
+ });
238
+ const read = defineTool({
239
+ name: "web_fetch",
240
+ label: "Fetch a web page",
241
+ description: "Fetch a page from the public web and get it back as readable text - an HTML page without its markup, or a " +
242
+ "text, JSON or XML file as it is. Long pages come in parts: call again with the offset it gives to read on.",
243
+ promptSnippet: "read a web page, such as documentation, as text",
244
+ parameters: Type.Object({
245
+ url: Type.String({ description: "The page's full address, starting with https://" }),
246
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Where to start reading, for the next part of a long page" })),
247
+ }),
248
+ async execute(_id, params) {
249
+ const page = await webFetch(params.url);
250
+ const head = `${page.url} (${page.status}${page.contentType ? `, ${page.contentType.split(";")[0]}` : ""})`;
251
+ const text = page.text.trim() === "" ? `${head}\n\nThe page has no text.` : `${head}\n\n${pageWindow(page.text, params.offset ?? 0)}`;
252
+ return { content: [{ type: "text", text }], details: { characters: page.text.length, status: page.status } };
253
+ },
254
+ });
255
+ return [search, read];
256
+ }