talos-code 0.0.1 → 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 (401) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +111 -3
  3. package/THIRD_PARTY_NOTICES.md +40 -0
  4. package/dist/archive/zip.js +73 -0
  5. package/dist/args.js +97 -0
  6. package/dist/automations/history-store.js +34 -0
  7. package/dist/automations/policy-store.js +43 -0
  8. package/dist/automations/runner.js +107 -0
  9. package/dist/automations/schedule.js +85 -0
  10. package/dist/automations/windows-task.js +53 -0
  11. package/dist/commands/advanced-cli.js +233 -0
  12. package/dist/commands/checkpoint-cli.js +45 -0
  13. package/dist/commands/config-cli.js +107 -0
  14. package/dist/commands/context.js +17 -0
  15. package/dist/commands/extensions-cli.js +207 -0
  16. package/dist/commands/project-cli.js +74 -0
  17. package/dist/commands/project-commands.js +143 -0
  18. package/dist/commands/provider-cli.js +185 -0
  19. package/dist/commands/services-cli.js +248 -0
  20. package/dist/commands/session-cli.js +171 -0
  21. package/dist/commands/system-cli.js +405 -0
  22. package/dist/config/commands.js +60 -0
  23. package/dist/config/load.js +382 -0
  24. package/dist/config/migrations.js +48 -0
  25. package/dist/config/types.js +11 -0
  26. package/dist/diagnostics/development-log.js +154 -0
  27. package/dist/diagnostics/doctor.js +104 -0
  28. package/dist/diagnostics/redact.js +80 -0
  29. package/dist/diagnostics/zip.js +52 -0
  30. package/dist/errors.js +156 -0
  31. package/dist/events/bridge.js +130 -0
  32. package/dist/extensions/installer.js +178 -0
  33. package/dist/extensions/package-schema.js +24 -0
  34. package/dist/headless/result.js +84 -0
  35. package/dist/headless/run.js +230 -0
  36. package/dist/i18n/en/approval.js +43 -0
  37. package/dist/i18n/en/common.js +9 -0
  38. package/dist/i18n/en/credentials.js +82 -0
  39. package/dist/i18n/en/errors.js +246 -0
  40. package/dist/i18n/en/firstrun.js +89 -0
  41. package/dist/i18n/en/providers.js +37 -0
  42. package/dist/i18n/en/screen.js +736 -0
  43. package/dist/i18n/en/tools.js +127 -0
  44. package/dist/i18n/en.js +14 -0
  45. package/dist/i18n/error-view.js +307 -0
  46. package/dist/i18n/index.js +19 -0
  47. package/dist/i18n/kernel-map.js +139 -0
  48. package/dist/io.js +45 -0
  49. package/dist/main.js +330 -0
  50. package/dist/paths.js +41 -0
  51. package/dist/protocol/v2/codec.js +9 -0
  52. package/dist/protocol/v2/events.js +555 -0
  53. package/dist/protocol/v2/index.js +4 -0
  54. package/dist/protocol/v2/replay.js +28 -0
  55. package/dist/protocol/v2/types.js +1 -0
  56. package/dist/provider/control-plane.js +135 -0
  57. package/dist/provider/environment-keys.js +275 -0
  58. package/dist/provider/health.js +110 -0
  59. package/dist/provider/missing-key.js +48 -0
  60. package/dist/provider/model-catalog.js +168 -0
  61. package/dist/provider/provider-text.js +13 -0
  62. package/dist/provider/store.js +95 -0
  63. package/dist/provider/system-keyring.js +214 -0
  64. package/dist/runtime/active-run.js +63 -0
  65. package/dist/runtime/agent-tree.js +74 -0
  66. package/dist/runtime/attachments.js +84 -0
  67. package/dist/runtime/brokered-executor.js +395 -0
  68. package/dist/runtime/context-status.js +76 -0
  69. package/dist/runtime/create-runtime.js +111 -0
  70. package/dist/runtime/model-profile.js +361 -0
  71. package/dist/runtime/output-store.js +137 -0
  72. package/dist/runtime/provider-attempts.js +185 -0
  73. package/dist/runtime/replay-buffer.js +153 -0
  74. package/dist/runtime/repo.js +95 -0
  75. package/dist/runtime/session-facade.js +135 -0
  76. package/dist/runtime/session-summary.js +125 -0
  77. package/dist/runtime/supervisor.js +262 -0
  78. package/dist/runtime/talos-composition.js +1006 -0
  79. package/dist/runtime/types.js +5 -0
  80. package/dist/runtime/usage-snapshot.js +82 -0
  81. package/dist/security/auto-classifier.js +38 -0
  82. package/dist/security/credential-free-environment.js +54 -0
  83. package/dist/security/evaluate.js +243 -0
  84. package/dist/security/execution-backends.js +236 -0
  85. package/dist/security/execution-broker.js +102 -0
  86. package/dist/security/forge-scan.js +74 -0
  87. package/dist/security/from-approval.js +39 -0
  88. package/dist/security/mxc-execution-backend.js +281 -0
  89. package/dist/security/permission-engine.js +138 -0
  90. package/dist/security/permission-explanation.js +54 -0
  91. package/dist/security/persist.js +179 -0
  92. package/dist/security/plugin-guard.js +230 -0
  93. package/dist/security/process-tree-evidence-store.js +74 -0
  94. package/dist/security/process-tree-probe.js +554 -0
  95. package/dist/security/project-resource-inventory.js +186 -0
  96. package/dist/security/project-trust-gate.js +38 -0
  97. package/dist/security/project-trust.js +367 -0
  98. package/dist/security/rule-parser.js +201 -0
  99. package/dist/security/shell-segmentation.js +68 -0
  100. package/dist/security/trust-authority.js +324 -0
  101. package/dist/security/types.js +1 -0
  102. package/dist/security/workspace-identity.js +102 -0
  103. package/dist/services/automation-facade.js +59 -0
  104. package/dist/services/forge-facade.js +77 -0
  105. package/dist/services/hook-facade.js +240 -0
  106. package/dist/services/index.js +16 -0
  107. package/dist/services/library-facade.js +174 -0
  108. package/dist/services/mcp-facade.js +348 -0
  109. package/dist/services/memory-facade.js +126 -0
  110. package/dist/services/notes-facade.js +157 -0
  111. package/dist/services/plugin-facade.js +308 -0
  112. package/dist/services/research-facade.js +110 -0
  113. package/dist/services/task-board-facade.js +236 -0
  114. package/dist/services/workflow-catalog.js +246 -0
  115. package/dist/sessions/export-format.js +98 -0
  116. package/dist/sessions/metadata-store.js +160 -0
  117. package/dist/sessions/share.js +122 -0
  118. package/dist/sessions/transfer.js +16 -0
  119. package/dist/subcommands.js +62 -0
  120. package/dist/tui/agent-roster.js +36 -0
  121. package/dist/tui/app.js +3761 -0
  122. package/dist/tui/approval.js +36 -0
  123. package/dist/tui/boot/boot-sequence.js +76 -0
  124. package/dist/tui/boot/cinematic.js +243 -0
  125. package/dist/tui/boot/desktop-logo.js +82 -0
  126. package/dist/tui/boot.js +3 -0
  127. package/dist/tui/busy-input.js +58 -0
  128. package/dist/tui/catalog-service.js +559 -0
  129. package/dist/tui/components/assistant-stream.js +1 -0
  130. package/dist/tui/components/command-menu.js +68 -0
  131. package/dist/tui/components/composer.js +219 -0
  132. package/dist/tui/components/diff.js +35 -0
  133. package/dist/tui/components/footer.js +48 -0
  134. package/dist/tui/components/header.js +6 -0
  135. package/dist/tui/components/markdown.js +305 -0
  136. package/dist/tui/components/status-indicator.js +32 -0
  137. package/dist/tui/components/terminal-shell.js +205 -0
  138. package/dist/tui/components/thinking-row.js +19 -0
  139. package/dist/tui/components/tool-row.js +97 -0
  140. package/dist/tui/components/transcript-virtualizer.js +165 -0
  141. package/dist/tui/components/transcript.js +43 -0
  142. package/dist/tui/diff-model.js +77 -0
  143. package/dist/tui/editor-history.js +21 -0
  144. package/dist/tui/editor.js +210 -0
  145. package/dist/tui/event-adapter.js +436 -0
  146. package/dist/tui/exit-output.js +58 -0
  147. package/dist/tui/external-editor.js +53 -0
  148. package/dist/tui/file-completion.js +89 -0
  149. package/dist/tui/focus-manager.js +5 -0
  150. package/dist/tui/highlight.js +30 -0
  151. package/dist/tui/input-router.js +18 -0
  152. package/dist/tui/interrupt.js +99 -0
  153. package/dist/tui/keybindings.js +194 -0
  154. package/dist/tui/keymap-resolver.js +91 -0
  155. package/dist/tui/launch-state.js +139 -0
  156. package/dist/tui/line-diff.js +57 -0
  157. package/dist/tui/live-activity.js +45 -0
  158. package/dist/tui/metrics.js +123 -0
  159. package/dist/tui/onboarding.js +36 -0
  160. package/dist/tui/overlays/agent-tree.js +39 -0
  161. package/dist/tui/overlays/approval-dialog.js +640 -0
  162. package/dist/tui/overlays/automation-center.js +51 -0
  163. package/dist/tui/overlays/checkpoint-picker.js +47 -0
  164. package/dist/tui/overlays/context-inspector.js +36 -0
  165. package/dist/tui/overlays/effort-line.js +110 -0
  166. package/dist/tui/overlays/forge-center.js +46 -0
  167. package/dist/tui/overlays/help-dialog.js +16 -0
  168. package/dist/tui/overlays/history-picker.js +37 -0
  169. package/dist/tui/overlays/hook-center.js +70 -0
  170. package/dist/tui/overlays/library-center.js +55 -0
  171. package/dist/tui/overlays/mcp-center.js +60 -0
  172. package/dist/tui/overlays/memory-center.js +63 -0
  173. package/dist/tui/overlays/model-picker.js +72 -0
  174. package/dist/tui/overlays/notes-center.js +53 -0
  175. package/dist/tui/overlays/overlay-host.js +8 -0
  176. package/dist/tui/overlays/plugin-center.js +76 -0
  177. package/dist/tui/overlays/provider-picker.js +145 -0
  178. package/dist/tui/overlays/queue-editor.js +32 -0
  179. package/dist/tui/overlays/research-center.js +59 -0
  180. package/dist/tui/overlays/scroll-window.js +234 -0
  181. package/dist/tui/overlays/session-picker.js +115 -0
  182. package/dist/tui/overlays/tasks-center.js +83 -0
  183. package/dist/tui/overlays/theme-picker.js +21 -0
  184. package/dist/tui/overlays/transcript-search.js +58 -0
  185. package/dist/tui/overlays/trust-center.js +29 -0
  186. package/dist/tui/overlays/workflow-center.js +46 -0
  187. package/dist/tui/project-file-index.js +214 -0
  188. package/dist/tui/project-references.js +85 -0
  189. package/dist/tui/project-trust-prompt.js +287 -0
  190. package/dist/tui/prompt-history-store.js +116 -0
  191. package/dist/tui/queue-store.js +110 -0
  192. package/dist/tui/regions/budget.js +59 -0
  193. package/dist/tui/regions/views.js +96 -0
  194. package/dist/tui/render-coordinator.js +148 -0
  195. package/dist/tui/render-scheduler.js +4 -0
  196. package/dist/tui/run.js +69 -0
  197. package/dist/tui/selection-list.js +29 -0
  198. package/dist/tui/session-controller.js +778 -0
  199. package/dist/tui/session-export.js +54 -0
  200. package/dist/tui/shell-input.js +35 -0
  201. package/dist/tui/shell-layout.js +48 -0
  202. package/dist/tui/shell-model.js +98 -0
  203. package/dist/tui/slash-commands.js +80 -0
  204. package/dist/tui/state.js +151 -0
  205. package/dist/tui/status-bar.js +125 -0
  206. package/dist/tui/status-view.js +40 -0
  207. package/dist/tui/terminal-capabilities.js +9 -0
  208. package/dist/tui/terminal-session.js +11 -0
  209. package/dist/tui/text-width.js +66 -0
  210. package/dist/tui/theme-catalog.js +25 -0
  211. package/dist/tui/theme-store.js +23 -0
  212. package/dist/tui/theme.js +23 -0
  213. package/dist/tui/tool-display.js +135 -0
  214. package/dist/tui/tool-facts.js +1 -0
  215. package/dist/tui/tools/bash-renderer.js +32 -0
  216. package/dist/tui/tools/change.js +61 -0
  217. package/dist/tui/tools/edit-renderer.js +17 -0
  218. package/dist/tui/tools/generic-renderer.js +13 -0
  219. package/dist/tui/tools/list-renderer.js +14 -0
  220. package/dist/tui/tools/read-renderer.js +13 -0
  221. package/dist/tui/tools/registry.js +20 -0
  222. package/dist/tui/tools/row-format.js +187 -0
  223. package/dist/tui/tools/search-renderer.js +38 -0
  224. package/dist/tui/tools/shared.js +97 -0
  225. package/dist/tui/tools/write-renderer.js +15 -0
  226. package/dist/tui/transcript-model.js +441 -0
  227. package/dist/tui/ui-preferences.js +79 -0
  228. package/dist/tui/usage-view.js +77 -0
  229. package/dist/tui/vim-mode.js +99 -0
  230. package/dist/update/check.js +15 -0
  231. package/dist/update/npm.js +51 -0
  232. package/dist/update/run.js +129 -0
  233. package/dist/version.js +2 -0
  234. package/dist/workspace/checkpoint-store.js +210 -0
  235. package/dist/workspace/checkpoint.js +413 -0
  236. package/dist/workspace/restore.js +232 -0
  237. package/package.json +63 -5
  238. package/vendor/context-engine/package.json +11 -0
  239. package/vendor/context-engine/src/compaction-planner.mjs +57 -0
  240. package/vendor/context-engine/src/contracts.mjs +48 -0
  241. package/vendor/context-engine/src/engine.mjs +445 -0
  242. package/vendor/context-engine/src/node/context-export.mjs +164 -0
  243. package/vendor/context-engine/src/node/legacy-import.mjs +92 -0
  244. package/vendor/context-engine/src/node/migrations/001-context.sql +126 -0
  245. package/vendor/context-engine/src/node/sqlite-store.mjs +85 -0
  246. package/vendor/context-engine/src/node/sqlite-worker.mjs +559 -0
  247. package/vendor/context-engine/src/profiles.mjs +10 -0
  248. package/vendor/context-engine/src/retrieval.mjs +104 -0
  249. package/vendor/context-engine/src/summary.mjs +97 -0
  250. package/vendor/context-engine/src/usage.mjs +34 -0
  251. package/vendor/harness-ui/package.json +38 -0
  252. package/vendor/harness-ui/src/acp-agent.mjs +350 -0
  253. package/vendor/harness-ui/src/agent-service.mjs +2188 -0
  254. package/vendor/harness-ui/src/agui-events.mjs +452 -0
  255. package/vendor/harness-ui/src/ambiente-solo-server.mjs +121 -0
  256. package/vendor/harness-ui/src/artifact-store.mjs +39 -0
  257. package/vendor/harness-ui/src/assistenza.mjs +123 -0
  258. package/vendor/harness-ui/src/automation-scheduler.mjs +69 -0
  259. package/vendor/harness-ui/src/automation-store.mjs +145 -0
  260. package/vendor/harness-ui/src/browser-annota.mjs +639 -0
  261. package/vendor/harness-ui/src/browser-frame.mjs +211 -0
  262. package/vendor/harness-ui/src/browser-proxy-universale.mjs +519 -0
  263. package/vendor/harness-ui/src/browser-proxy.mjs +87 -0
  264. package/vendor/harness-ui/src/browser-sessione-viva.mjs +329 -0
  265. package/vendor/harness-ui/src/browser-stream.mjs +445 -0
  266. package/vendor/harness-ui/src/browser-vivo.mjs +694 -0
  267. package/vendor/harness-ui/src/chat-image-attachments.mjs +72 -0
  268. package/vendor/harness-ui/src/config.mjs +697 -0
  269. package/vendor/harness-ui/src/contesto-del-progetto.mjs +385 -0
  270. package/vendor/harness-ui/src/context-asset-adapter.mjs +72 -0
  271. package/vendor/harness-ui/src/context-desktop-service.mjs +253 -0
  272. package/vendor/harness-ui/src/context-embedding-runtime.mjs +252 -0
  273. package/vendor/harness-ui/src/context-inference-scheduler.mjs +81 -0
  274. package/vendor/harness-ui/src/context-native-compaction.mjs +75 -0
  275. package/vendor/harness-ui/src/context-provider-adapter.mjs +141 -0
  276. package/vendor/harness-ui/src/context-runtime.mjs +118 -0
  277. package/vendor/harness-ui/src/context-token-counters.mjs +184 -0
  278. package/vendor/harness-ui/src/context-tool-catalog.mjs +86 -0
  279. package/vendor/harness-ui/src/context-tool-output.mjs +72 -0
  280. package/vendor/harness-ui/src/costo-elenco.mjs +252 -0
  281. package/vendor/harness-ui/src/custom-task.mjs +171 -0
  282. package/vendor/harness-ui/src/doctor.mjs +142 -0
  283. package/vendor/harness-ui/src/document-filename.mjs +97 -0
  284. package/vendor/harness-ui/src/document-generator.mjs +493 -0
  285. package/vendor/harness-ui/src/document-report.mjs +331 -0
  286. package/vendor/harness-ui/src/duckduckgo-search.mjs +155 -0
  287. package/vendor/harness-ui/src/elenco-profondo.mjs +337 -0
  288. package/vendor/harness-ui/src/favicon-proxy.mjs +113 -0
  289. package/vendor/harness-ui/src/forge-contract.mjs +221 -0
  290. package/vendor/harness-ui/src/frequent-dirs.mjs +134 -0
  291. package/vendor/harness-ui/src/generated-image-store.mjs +147 -0
  292. package/vendor/harness-ui/src/generation-idle.mjs +332 -0
  293. package/vendor/harness-ui/src/gguf-header.mjs +207 -0
  294. package/vendor/harness-ui/src/git-service.mjs +626 -0
  295. package/vendor/harness-ui/src/gitignore-elenco.mjs +604 -0
  296. package/vendor/harness-ui/src/harness-receipt-keypair.mjs +207 -0
  297. package/vendor/harness-ui/src/hf-direct-transfer.mjs +170 -0
  298. package/vendor/harness-ui/src/hf-hub-client.mjs +106 -0
  299. package/vendor/harness-ui/src/hf-image-proxy.mjs +105 -0
  300. package/vendor/harness-ui/src/hf-model-transfer.mjs +245 -0
  301. package/vendor/harness-ui/src/hook-registry.mjs +186 -0
  302. package/vendor/harness-ui/src/http-app.mjs +6259 -0
  303. package/vendor/harness-ui/src/http-lifecycle.mjs +132 -0
  304. package/vendor/harness-ui/src/id-archivio.mjs +27 -0
  305. package/vendor/harness-ui/src/image-generator.mjs +143 -0
  306. package/vendor/harness-ui/src/istruzioni-di-progetto.mjs +234 -0
  307. package/vendor/harness-ui/src/kernel/dist/kernelPerIlBanco.js +518 -0
  308. package/vendor/harness-ui/src/kernel/talosHarness.mjs +10437 -0
  309. package/vendor/harness-ui/src/library-policy-store.mjs +175 -0
  310. package/vendor/harness-ui/src/library-store.mjs +652 -0
  311. package/vendor/harness-ui/src/llama-server-supervisor.mjs +629 -0
  312. package/vendor/harness-ui/src/local-model-store.mjs +339 -0
  313. package/vendor/harness-ui/src/local-runtime-contract.mjs +66 -0
  314. package/vendor/harness-ui/src/local-runtime-events.mjs +44 -0
  315. package/vendor/harness-ui/src/local-runtime-llama-server.mjs +244 -0
  316. package/vendor/harness-ui/src/local-runtime-probe.mjs +401 -0
  317. package/vendor/harness-ui/src/machine-capacity.mjs +66 -0
  318. package/vendor/harness-ui/src/mappa-cartelle.mjs +491 -0
  319. package/vendor/harness-ui/src/mcp-client.mjs +98 -0
  320. package/vendor/harness-ui/src/mcp-registry.mjs +157 -0
  321. package/vendor/harness-ui/src/mcp-session.mjs +177 -0
  322. package/vendor/harness-ui/src/memory-store.mjs +227 -0
  323. package/vendor/harness-ui/src/model-catalog-models-dev.mjs +276 -0
  324. package/vendor/harness-ui/src/model-catalog.mjs +129 -0
  325. package/vendor/harness-ui/src/model-destination.mjs +189 -0
  326. package/vendor/harness-ui/src/modifica-ancorata.mjs +177 -0
  327. package/vendor/harness-ui/src/native-provider-adapter.mjs +205 -0
  328. package/vendor/harness-ui/src/notes-store.mjs +250 -0
  329. package/vendor/harness-ui/src/openai-compatible-runtime.mjs +428 -0
  330. package/vendor/harness-ui/src/openrouter-oauth.mjs +339 -0
  331. package/vendor/harness-ui/src/path-policy.mjs +442 -0
  332. package/vendor/harness-ui/src/plugin-registry.mjs +780 -0
  333. package/vendor/harness-ui/src/plugin-session.mjs +180 -0
  334. package/vendor/harness-ui/src/process-policy.mjs +345 -0
  335. package/vendor/harness-ui/src/prompt-enhancer-provider.mjs +94 -0
  336. package/vendor/harness-ui/src/provider-auth-cloud.mjs +95 -0
  337. package/vendor/harness-ui/src/provider-credential-store.mjs +541 -0
  338. package/vendor/harness-ui/src/provider-probe.mjs +582 -0
  339. package/vendor/harness-ui/src/provider-registry.mjs +1633 -0
  340. package/vendor/harness-ui/src/pty-terminal.mjs +312 -0
  341. package/vendor/harness-ui/src/public-problem.mjs +109 -0
  342. package/vendor/harness-ui/src/research/card.mjs +235 -0
  343. package/vendor/harness-ui/src/research/citations.mjs +142 -0
  344. package/vendor/harness-ui/src/research/collector.mjs +275 -0
  345. package/vendor/harness-ui/src/research/deposito-a-pezzi.mjs +139 -0
  346. package/vendor/harness-ui/src/research/dossier.mjs +114 -0
  347. package/vendor/harness-ui/src/research/esportazioni.mjs +560 -0
  348. package/vendor/harness-ui/src/research/fetch-cache.mjs +465 -0
  349. package/vendor/harness-ui/src/research/fidelity.mjs +122 -0
  350. package/vendor/harness-ui/src/research/independence.mjs +159 -0
  351. package/vendor/harness-ui/src/research/ledger.mjs +166 -0
  352. package/vendor/harness-ui/src/research/markdown-server.mjs +565 -0
  353. package/vendor/harness-ui/src/research/narration.mjs +181 -0
  354. package/vendor/harness-ui/src/research/open-cards.mjs +131 -0
  355. package/vendor/harness-ui/src/research/opposing.mjs +305 -0
  356. package/vendor/harness-ui/src/research/outline.mjs +111 -0
  357. package/vendor/harness-ui/src/research/page-budget.mjs +209 -0
  358. package/vendor/harness-ui/src/research/pdf.mjs +291 -0
  359. package/vendor/harness-ui/src/research/plan.mjs +301 -0
  360. package/vendor/harness-ui/src/research/raccolta-viva.mjs +452 -0
  361. package/vendor/harness-ui/src/research/recheck-document.mjs +69 -0
  362. package/vendor/harness-ui/src/research/recheck-history.mjs +192 -0
  363. package/vendor/harness-ui/src/research/recheck.mjs +194 -0
  364. package/vendor/harness-ui/src/research/report.mjs +203 -0
  365. package/vendor/harness-ui/src/research/run.mjs +527 -0
  366. package/vendor/harness-ui/src/research/synthesis.mjs +318 -0
  367. package/vendor/harness-ui/src/research/verification.mjs +572 -0
  368. package/vendor/harness-ui/src/research-orchestrator.mjs +2679 -0
  369. package/vendor/harness-ui/src/research-store.mjs +1133 -0
  370. package/vendor/harness-ui/src/runtime-build-manifest.mjs +26 -0
  371. package/vendor/harness-ui/src/runtime-contract.mjs +59 -0
  372. package/vendor/harness-ui/src/runtime-owner-adapter.mjs +1348 -0
  373. package/vendor/harness-ui/src/runtime-owner-contract.mjs +32 -0
  374. package/vendor/harness-ui/src/scheda-di-lavoro.mjs +249 -0
  375. package/vendor/harness-ui/src/search-source-store.mjs +172 -0
  376. package/vendor/harness-ui/src/session-registry.mjs +6095 -0
  377. package/vendor/harness-ui/src/session-store.mjs +220 -0
  378. package/vendor/harness-ui/src/sessione-pronta.mjs +73 -0
  379. package/vendor/harness-ui/src/setup-stato.mjs +31 -0
  380. package/vendor/harness-ui/src/sezioni-istruzioni.mjs +204 -0
  381. package/vendor/harness-ui/src/skill-registry.mjs +120 -0
  382. package/vendor/harness-ui/src/sse-replay-coalescente.mjs +0 -0
  383. package/vendor/harness-ui/src/static-files.mjs +96 -0
  384. package/vendor/harness-ui/src/stream-partition.mjs +123 -0
  385. package/vendor/harness-ui/src/subagent-orchestrator.mjs +453 -0
  386. package/vendor/harness-ui/src/task-catalog.mjs +65 -0
  387. package/vendor/harness-ui/src/tasks-store.mjs +220 -0
  388. package/vendor/harness-ui/src/terminal-registry.mjs +312 -0
  389. package/vendor/harness-ui/src/terminal-ws.mjs +170 -0
  390. package/vendor/harness-ui/src/tool-forge-store.mjs +299 -0
  391. package/vendor/harness-ui/src/tool-schema-normalize.mjs +100 -0
  392. package/vendor/harness-ui/src/usage-cache.mjs +315 -0
  393. package/vendor/harness-ui/src/workspace-browser.mjs +213 -0
  394. package/vendor/harness-ui/src/workspace-context.mjs +124 -0
  395. package/vendor/harness-ui/src/workspace-disk.mjs +62 -0
  396. package/vendor/harness-ui/src/workspace-files.mjs +589 -0
  397. package/vendor/harness-ui/src/workspace-info.mjs +189 -0
  398. package/vendor/harness-ui/src/workspace-launch-store.mjs +150 -0
  399. package/vendor/harness-ui/src/workspace-tree.mjs +67 -0
  400. package/vendor/harness-ui/src/workspace-watcher.mjs +161 -0
  401. package/vendor/manifest.json +170 -0
@@ -0,0 +1,2679 @@
1
+ /**
2
+ * research-orchestrator.mjs — FASE N, ottavo sistema (30/8): Deep
3
+ * Research, "fetta onesta" (owner, AskUserQuestion: "Fetta onesta
4
+ * (consigliato)"). Mirror ARCHITETTURALE di `subagent-orchestrator.mjs`
5
+ * (FASE C): istanziato UNA volta dentro `session-registry.mjs`, dove
6
+ * `avviaESegui`/`sessioni` sono in scope — gli 8 callback `onRicerca*`
7
+ * che `avviaESegui` costruisce sono thin delegate verso i metodi qui,
8
+ * stesso principio di `onDelega` verso `delegaSottoTask`.
9
+ *
10
+ * ⭐⭐⭐ La FORMA che serve è DIVERSA da `delegaSottoTask`: quella BLOCCA
11
+ * (una Promise risolta solo alla conclusione del figlio, per il
12
+ * dispatcher del kernel del PADRE che aspetta un riassunto SINCRONO
13
+ * dentro lo stesso giro). `research_start` deve tornare SUBITO con un
14
+ * id (mobile, `researchTools.ts`: "avvia e torna SUBITO... una
15
+ * ricerca dura minuti, e un tool che aspetta terrebbe occupato il giro
16
+ * di conversazione per tutto quel tempo") — qui `onConclusioneFn` si
17
+ * PASSA e basta, mai atteso: esattamente come `avviaESegui` stesso fa
18
+ * per OGNI sessione (avvia, ritorna `{sessionId}`, il `.then()` gira
19
+ * in background).
20
+ *
21
+ * L'id di una ricerca È il sessionId della sessione che la esegue — un
22
+ * solo spazio di identità, mai una mappatura a parte, mai
23
+ * disallineabile per costruzione.
24
+ *
25
+ * ⭐⭐⭐ Deep Research è PER-PROGETTO (vedi la doc di testa di
26
+ * `research-store.mjs`): la ricerca gira nella STESSA cartella della
27
+ * sessione che l'ha avviata (mai un workspace dedicato) — il suo
28
+ * rapporto finale, salvato in Libreria via `salvaVoceLibreriaFn`,
29
+ * finisce quindi nella Libreria di QUEL progetto, raggiungibile dalle
30
+ * sessioni future sullo stesso progetto (mobile: "i rapporti di
31
+ * ricerca SONO file di Libreria"). `permessi:'Read only'` — la ricerca
32
+ * non deve MAI scrivere/eseguire nel progetto ospite, solo cercare/
33
+ * leggere/sintetizzare: difesa in profondità, il prompt lo dice E il
34
+ * permesso lo garantisce, mai uno solo dei due.
35
+ *
36
+ * ⛔⛔⛔ Pausa/ripresa RIUSANO la macchina già costruita per il bottone
37
+ * Stop (`voce.controller.abort()`) e per `resume()` — non un secondo
38
+ * meccanismo. L'AMBIGUITÀ che questo risolve, trovata leggendo
39
+ * `talosHarness.mjs` (comeSonoFinitiIGiri) prima di scrivere una riga:
40
+ * `comeFinita.esito==='fermato'` copre SIA "ho chiamato io l'abort per
41
+ * una pausa" SIA "il modello ha semplicemente smesso di generare senza
42
+ * rispondere senza che nessuno l'abbia fermato" — indistinguibili dal
43
+ * SOLO esito del kernel. Cura: un flag sulla VOCE stessa (proprietà
44
+ * dinamica, mai nello schema condiviso di `avviaESegui`), scritto
45
+ * PRIMA di abortire, letto e SEMPRE azzerato dentro
46
+ * `onConclusioneRicerca` — mai lasciato sporco per il giro successivo
47
+ * (altrimenti una ripresa che poi conclude DAVVERO verrebbe scambiata
48
+ * per un'altra pausa, silenziosamente: il bug esatto che un test
49
+ * dedicato sotto verifica AL CONTRARIO).
50
+ *
51
+ * ⛔ Nessuno stato "sta girando ORA" è mai scritto su disco (vedi
52
+ * `research-store.mjs`): `statoVivo()` lo deriva SEMPRE dal vivo,
53
+ * confrontando `terminata` (research-store) con lo stato REALE della
54
+ * sessione in `sessioni` — per costruzione non può disallinearsi.
55
+ */
56
+
57
+ /*
58
+ * ⛔ L2 (11/09) — i DUE default del lettore di rapporti. Importati e non ri-scritti: il posto
59
+ * del rapporto e la sua forma minima vivono in `research-store.mjs`, insieme a chi lo scrive.
60
+ * Restano entrambi iniettabili (vedi `creaResearchOrchestrator`), quindi i test non toccano
61
+ * mai un filesystem — stessa disciplina di tutte le altre `*Fn` di questo modulo.
62
+ */
63
+ import {
64
+ accodaEvento, elencaFonti, leggiFonte, leggiGiornale, leggiIndiceFonti, leggiIstantaneaCache,
65
+ leggiPiano, leggiRapporto, rileggiRapportoMinimo, scriviFonte, scriviIndiceFonti,
66
+ scriviIstantaneaCache, scriviPiano, statRapporto,
67
+ } from './research-store.mjs';
68
+ import { talosResearchFetchCache } from './research/fetch-cache.mjs';
69
+ import { consegnaPartiRapporto, depositaParteRapporto } from './research/deposito-a-pezzi.mjs';
70
+ /*
71
+ * ⭐⭐⭐⭐ L9 (12/09/2026) — IL MOTORE PORTATO COMINCIA A LAVORARE DENTRO LA CORSA.
72
+ *
73
+ * L4 aveva agganciato tre file su venti (`report`, `run`, `verification` per il solo bilancio),
74
+ * L5 un quarto (`recheck`). Il resto — il piano, il collettore, il giudizio vero, la contraria,
75
+ * l'indipendenza, la fedeltà — restava «portato e provato, mai chiamato»: giornale con tre
76
+ * eventi, `Piano` e `Speso` vuoti, e 38 affermazioni su 38 marcate «non verificate» sul giro
77
+ * vero del 12/09 (`CODA-UNICA-DEBITI`, voce «L8 #2 ✅ CHIUDE»).
78
+ *
79
+ * `plan.mjs` i rami per profondità (2/4/6) e il COSTO ATTESO, detto PRIMA di partire;
80
+ * `raccolta-viva.mjs` il ponte per cui `web_search`/`naviga` della figlia diventano passi del
81
+ * giornale, spesa contata, cache che prende e fonti tenute su disco;
82
+ * `verification.mjs` i tre livelli veri, col giudice che NON è l'autore;
83
+ * `opposing.mjs` la contraria cercata apposta;
84
+ * `independence.mjs` le prove distinte, contate a gruppi e non a URL;
85
+ * `fidelity.mjs` il punteggio, con la data — «un punteggio senza data è una promessa che
86
+ * scade in silenzio».
87
+ *
88
+ * ⛔ Nessuno di questi entra nel kernel, oggi come ieri: il kernel riceve UNA porta
89
+ * (`cacheWeb`, la stessa firma di `fetch-cache.around`) e una funzione (`onPaginaLetta`), e
90
+ * non importa una sola riga di `src/research/`.
91
+ */
92
+ import { talosResearchFidelity } from './research/fidelity.mjs';
93
+ import { talosResearchIndependentSources } from './research/independence.mjs';
94
+ import { talosResearchOpposingPrompt } from './research/opposing.mjs';
95
+ import {
96
+ TALOS_RESEARCH_DEPTHS, talosResearchPlanCost, talosResearchPlanFor, talosResearchPlanTotals,
97
+ } from './research/plan.mjs';
98
+ import { creaRaccoltaViva } from './research/raccolta-viva.mjs';
99
+ /*
100
+ * ⭐⭐⭐ L4 (11/09/2026) — IL MOTORE PORTATO DAL MOBILE ENTRA IN SCENA.
101
+ *
102
+ * Fino a ieri `src/research/` era un albero nuovo che **nessuno chiamava** (L3a/L3b lo dicono
103
+ * entrambi, nel loro «cosa NON ho verificato»): venti file, 275 test verdi, zero chiamanti.
104
+ * Queste tre righe sono il primo aggancio, e agganciano esattamente due cose:
105
+ *
106
+ * `report.mjs` il record recintato ```talos-research-report — il cancello di consegna
107
+ * smette di giudicare la PROSA e comincia a giudicare il RECORD;
108
+ * `run.mjs` il replay del giornale — la ripresa smette di dipendere da
109
+ * `voce.messaggiFinali` (che un riavvio cancella) e riparte dal disco;
110
+ * `verification.mjs` il bilancio delle affermazioni, che è ciò con cui la riga in elenco
111
+ * guida (§6.7: mai col conteggio delle fonti).
112
+ *
113
+ * ⛔ Nessuno di questi import entra nel kernel: `talosHarness.mjs` importa TRE costanti da
114
+ * `research-store.mjs` e niente altro, e `research-store.mjs` continua a non importare nulla
115
+ * da `src/research/`. Il motore vive qui, nel direttore — non nel cancello di sicurezza.
116
+ */
117
+ import { talosResearchParseReport, talosResearchReportDocument, talosResearchSupportLabel } from './research/report.mjs';
118
+ /*
119
+ * ⭐⭐⭐ L5 (12/09/2026) — IL MOTORE DELLA RI-VERIFICA NEL TEMPO, agganciato per la prima volta.
120
+ *
121
+ * `recheck.mjs` era, come tutto `src/research/`, un albero senza chiamanti. Lo chiama la rotta
122
+ * `POST …/research/:id/riverifica`, ed è il «+1.1» del disegno (§6.8): la riga della tabella
123
+ * dove OGNI concorrente ispezionato ha ❌, perché per farla serve aver tenuto il TESTO, non
124
+ * l'URL.
125
+ *
126
+ * ⛔⛔ E QUI VA DETTO SUBITO COSA OGGI NON SI PUÒ MISURARE, perché il modulo, se lo si chiama
127
+ * senza saperlo, risponde una bugia educata. `talosResearchRecheckReport` vuole
128
+ * `keptByUrl`: il testo tenuto, **per url**. Sul disco di oggi quel testo non è
129
+ * ricostruibile — `fonti/<sha256>.txt` è indirizzato dal CONTENUTO (nessun url nel nome), e
130
+ * il giornale porta `resultRef` ma non l'indirizzo da cui quel testo viene. ⇒ la mappa è
131
+ * vuota, e con una mappa vuota `talosResearchSurvival` torna **1** («niente di ciò su cui ci
132
+ * appoggiavamo è sparito») e la fonte esce **`intact`**. Sarebbe un timbro «intatta» su una
133
+ * pagina che nessuno ha mai confrontato: esattamente il segno di verifica falso che tutto
134
+ * questo disegno esiste per togliere.
135
+ * ⇒ Il lettore qui sotto NON pubblica `intact` quando il testo tenuto manca: pubblica
136
+ * `non-misurabile`, e dice perché. La metà che invece è vera **senza** testo tenuto — «il
137
+ * passaggio citato è ancora ritrovabile in quella pagina?» — si misura eccome, perché il
138
+ * passaggio sta nel record del rapporto, non nel testo tenuto: ed è la metà che `recheck.mjs`
139
+ * stesso dichiara essere «nessuna euristica, nessuna soglia, nessuna opinione».
140
+ */
141
+ import { talosResearchRecheckReport } from './research/recheck.mjs';
142
+ import {
143
+ talosResearchIsTerminal, talosResearchNextStep, talosResearchRecover,
144
+ talosResearchReplay, talosResearchSpent, talosResearchWorkLeft,
145
+ } from './research/run.mjs';
146
+ import {
147
+ talosResearchJudgePrompt, talosResearchPickJudge, talosResearchVerifiedStanding,
148
+ talosResearchVerify,
149
+ } from './research/verification.mjs';
150
+
151
+ /**
152
+ * ⭐⭐⭐ L4 §6.5 — IL CANCELLO DI CONSEGNA, SUL RECORD VERO.
153
+ *
154
+ * L2 controllava la PROSA: un titolo, una riga di testo, un URL sotto «## Fonti». Bastava a
155
+ * respingere la scusa da 290 byte dell'11/09 — ed era dichiaratamente un ripiego, perché il
156
+ * record recintato non esisteva ancora nel repo. Adesso esiste (`src/research/report.mjs`,
157
+ * portato dal mobile), e il cancello guarda quello.
158
+ *
159
+ * ⛔ La differenza non è di severità, è di NATURA. La prosa dice «qui c'è un URL»; il record
160
+ * dice **quale affermazione** poggia su **quale fonte**, con quale passaggio e — quando un
161
+ * giudice c'è stato — con quale verdetto. Solo la seconda si può rileggere un mese dopo e
162
+ * ricontrollare. `report.mjs` lo scrive nella sua testa: «un prodotto che conserva gli URL
163
+ * non può farlo a nessun prezzo, perché la prova che ha controllato non c'è più».
164
+ *
165
+ * ⛔ `talosResearchParseReport` torna `null` e non un recupero parziale — è il contratto del
166
+ * mobile, conservato byte per byte dal porto: «un rapporto letto a metà mostrerebbe verdetti
167
+ * accanto ad affermazioni a cui non appartengono, e un segno di verifica sbagliato è peggio
168
+ * di nessuno». Qui `null` diventa «non rilegge», mai «va bene lo stesso».
169
+ *
170
+ * ⛔⛔ IL RIPIEGO, e perché è STRETTO. Le ricerche nate prima dell'11/09 non possono avere il
171
+ * record: quando sono state fatte non esisteva. Respingerle adesso vorrebbe dire timbrare
172
+ * «senza rapporto» su lavoro vero già pagato, cioè l'errore opposto e speculare a quello che
173
+ * L2 ha tolto. ⇒ per quelle, e SOLO per quelle (`ripiegoConsentito`, che il chiamante ricava
174
+ * dal campo `formato` della voce), vale ancora la forma minima — e l'esito lo **dichiara**
175
+ * con `ripiego:true`, invece di far passare due controlli diversi sotto lo stesso `ok`.
176
+ *
177
+ * @param {string} testo
178
+ * @param {{ripiegoConsentito?: boolean}} [opzioni]
179
+ */
180
+ export function rileggiRapportoRecintato(testo, { ripiegoConsentito = false } = {}) {
181
+ const vuoto = { ok: false, intestazione: null, affermazioni: 0, fonti: [], motivo: null, bilancio: null, proveDistinte: 0, ripiego: false, record: null };
182
+ if (typeof testo !== 'string' || testo.trim().length === 0) {
183
+ return { ...vuoto, motivo: 'il rapporto è vuoto' };
184
+ }
185
+ const record = talosResearchParseReport(testo);
186
+ if (record) {
187
+ const fonti = record.sources.map((f) => f?.url).filter((u) => typeof u === 'string' && u.trim().length > 0);
188
+ const comune = {
189
+ intestazione: typeof record.question === 'string' && record.question.trim() ? record.question.trim() : null,
190
+ affermazioni: record.claims.length,
191
+ fonti,
192
+ bilancio: bilancioDelRecord(record),
193
+ proveDistinte: proveDistinteDelRecord(record),
194
+ ripiego: false,
195
+ record,
196
+ };
197
+ if (record.claims.length === 0) return { ...comune, ok: false, motivo: 'il record del rapporto non porta nessuna affermazione' };
198
+ if (fonti.length === 0) return { ...comune, ok: false, motivo: 'il record del rapporto non elenca nessuna fonte' };
199
+ return { ...comune, ok: true, motivo: null };
200
+ }
201
+ if (!ripiegoConsentito) {
202
+ return { ...vuoto, motivo: 'il rapporto non porta il record verificabile (blocco ```talos-research-report)' };
203
+ }
204
+ const minimo = rileggiRapportoMinimo(testo);
205
+ return { ...minimo, bilancio: null, proveDistinte: 0, ripiego: true, record: null };
206
+ }
207
+
208
+ /**
209
+ * ⭐ §6.7 — IL BILANCIO, che è ciò con cui la riga in elenco guida. Mai il conteggio delle fonti.
210
+ *
211
+ * ⛔ Il motivo è misurato, non estetico: «Sci-MMR» (arXiv:2609.11243, 10/09/2026) trova che
212
+ * l'accuratezza della risposta supera di oltre venti punti il recupero delle prove — cioè un
213
+ * rapporto sembra buono anche quando le prove non ci sono. Un numero di fonti conferma quella
214
+ * impressione; un bilancio la smentisce quando è il caso.
215
+ * ⛔ `nonSostenute` e `nonVerificate` restano SEPARATE, e `contese` sta fuori da tutte:
216
+ * `verification.mjs` lo dice alla riga del conteggio — «"non abbiamo potuto controllarlo" e
217
+ * "abbiamo controllato, e la fonte non lo dice" sono due ammissioni diverse, e fonderle
218
+ * lusingherebbe la seconda». I nomi qui sono in italiano perché in italiano è tutto il
219
+ * contratto verso il frontend (`stato`, `motivo`, `domanda`): due lingue nello stesso oggetto
220
+ * sono due contratti che divergono.
221
+ */
222
+ function bilancioDelRecord(record) {
223
+ const s = talosResearchVerifiedStanding(record.claims);
224
+ return {
225
+ totali: s.total,
226
+ sostenute: s.supported,
227
+ inParte: s.partial,
228
+ nonSostenute: s.unsupported,
229
+ contese: s.contested,
230
+ nonVerificate: s.unchecked,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * ⭐ Quante fonti DIVERSE portano almeno un passaggio davvero ritrovato nel loro testo.
236
+ *
237
+ * ⛔ Non è `sources.length`, ed è tutta la differenza: la bibliografia dice cosa è stato
238
+ * citato, questo dice su quante fonti distinte poggia davvero qualcosa. Un rapporto con dodici
239
+ * fonti in fondo e un solo passaggio trovato ha `proveDistinte: 1`, e quel numero è l'unico dei
240
+ * due che non si può gonfiare allungando l'elenco dei link.
241
+ * ⛔ Un `passage` vuoto è, nel contratto di `report.mjs`, esattamente «non ci è mai stato
242
+ * trovato» — quindi non conta, e il rapporto in prosa lo scrive pure: «(il passaggio citato
243
+ * non è nel testo della fonte…)».
244
+ */
245
+ function proveDistinteDelRecord(record) {
246
+ const distinte = new Set();
247
+ for (const c of record.claims) {
248
+ if (typeof c?.passage === 'string' && c.passage.trim().length > 0) distinte.add(c.sourceIndex);
249
+ }
250
+ return distinte.size;
251
+ }
252
+
253
+ /**
254
+ * ⭐⭐⭐⭐ L8 (12/09/2026) — IL RECORD LO SCRIVE IL SERVER, E IL MODELLO PORTA I PEZZI.
255
+ *
256
+ * ⛔ Il guasto, dal giro vero del 12/09 (ricerca `3029dea2`, 18 giri, 5 minuti, 265.670 token
257
+ * di ingresso): la consegna chiedeva al modello di chiudere il rapporto con un blocco
258
+ * recintato ```talos-research-report contenente una riga di JSON. `glm-4.7-flash` ha scritto
259
+ * un rapporto in prosa da 8.953 byte — buono, con numeri e sedici URL — e ha ignorato il
260
+ * recinto. Il cancello di consegna, giustamente, ha risposto `senza-rapporto` («il rapporto
261
+ * non porta il record verificabile», `meta.json:motivoDettaglio`): motivo onesto, ma il
262
+ * lavoro pagato è perso lo stesso.
263
+ *
264
+ * ⛔ Perché la cura NON è insistere nella consegna. Ricerca del 12/09/2026, fonti primarie:
265
+ * - «When Lower Privileges Suffice» (arXiv:2606.20023, 18/06/2026, già citata in L1):
266
+ * «prompt-level controls provide only **limited mitigation**». Una forma che vive solo
267
+ * nella consegna è un controllo a livello di prompt, e il 12/09 non ha retto.
268
+ * - «The Constraint Tax: Measuring Validity-Correctness Tradeoffs in Structured Outputs for
269
+ * Small Language Models» (arXiv:2605.26128v1, 20/05/2026): «hard answer-only schema
270
+ * decoding raises schema validity from 61.5% to 100.0%, **but lowers answer accuracy from
271
+ * 19.7% to 11.0%**». ⇒ ⛔ IL VINCOLO CHE NON CONOSCEVO: costringere un modello piccolo a
272
+ * produrre tutto dentro una forma rigida NON è gratis — si paga in qualità della risposta.
273
+ * Per questo `testo` resta PROSA LIBERA e non viene mai riscritto da noi: la forma rigida
274
+ * si applica allo SCHELETRO (chi afferma cosa, su quale fonte, con quale passaggio), mai
275
+ * al ragionamento.
276
+ * - «Constraint Tax in Open-Weight LLMs: An Empirical Study of Tool Calling Suppression Under
277
+ * Structured Output Constraints» (arXiv:2606.25605v1, 24/06/2026): «when Tool Calling and
278
+ * JSON Schema constraints are simultaneously enabled, multiple open-weight models cease
279
+ * invoking tools despite maintaining high schema compliance». ⇒ niente `response_format`
280
+ * imposto sopra agli attrezzi: la struttura vive negli ARGOMENTI dell'attrezzo — il canale
281
+ * che il modello usa già — e non in un secondo vincolo sopra la generazione.
282
+ * - «PHREEQC-MCQ-200» (arXiv:2607.00436v1, 01/07/2026): «the gains are not monotonic:
283
+ * tool-augmented agents also lose items they answered correctly without tools». ⇒ ogni
284
+ * tolleranza qui sotto (un elenco arrivato come stringa JSON, una fonte indicata per numero
285
+ * invece che per URL, un passaggio mancante) esiste perché la strada nuova non deve poter
286
+ * perdere un deposito che la vecchia avrebbe accettato.
287
+ *
288
+ * ⇒ La forma di oggi: il modello passa `testo` (la prosa), `affermazioni` e `fonti`; QUESTA
289
+ * funzione costruisce il documento con `talosResearchReportDocument` — lo stesso scrittore
290
+ * che il cancello rilegge, mai un secondo che possa divergere («scritti entrambi da un
291
+ * oggetto solo così che non possano divergere», testa di `report.mjs`).
292
+ *
293
+ * ⛔ `judge: null` e `claimSupported: 'unchecked'` NON sono argomenti: li mette il server, e il
294
+ * modello non ha modo di toccarli. È la stessa regola di prima («un modello non timbra sé
295
+ * stesso») resa IMPOSSIBILE da violare invece che raccomandata.
296
+ *
297
+ * ⛔ Il `summary` del record è il `testo` del modello VERBATIM. Conseguenza dichiarata: il
298
+ * documento finale può portare due elenchi di fonti, quello scritto dal modello dentro la sua
299
+ * prosa e quello generato sotto. Preferito a riscrivere la prosa: ciò che il modello ha
300
+ * scritto non si tocca, e un elenco in più si legge — una prosa riscritta no.
301
+ *
302
+ * ⭐⭐⭐⭐ L9 (12/09/2026) — DUE AGGIUNTE, ENTRAMBE ADDITIVE.
303
+ *
304
+ * 1. `testiPerUrl` — il testo TENUTO delle pagine, per indirizzo. Serve a riempire
305
+ * `source.text`, che è l'unica cosa contro cui `talosResearchLocate` può dire se un
306
+ * passaggio citato esiste davvero. Senza (ogni chiamante di ieri, il banco, i test del
307
+ * kernel) il comportamento è **identico byte per byte**: `text: ''`, nessuna verifica
308
+ * possibile, `unchecked` dichiarato — che è quello che succedeva prima di oggi.
309
+ * ⛔ Non cambia il DOCUMENTO: `report.mjs` scrive di una fonte solo url, titolo, data e
310
+ * `obtained`. Il testo serve a chi verifica, non a chi legge.
311
+ * 2. Il ritorno porta anche `intestazione`, `claims` e `sources` — i pezzi già costruiti qui.
312
+ * ⛔ Perché: la verifica ha bisogno esattamente di quelli, e ricostruirli fuori vorrebbe dire
313
+ * un SECONDO interprete degli argomenti del modello (la tolleranza sugli elenchi
314
+ * stringhificati, la fonte indicata per URL o per numero, il passaggio assente contato e
315
+ * non rifiutato). Due interpreti dello stesso argomento divergono al primo caso limite;
316
+ * questo repo l'ha già pagato («due lettori sono due verità»).
317
+ *
318
+ * @param {{domanda?: string|null, testo: unknown, affermazioni: unknown, fonti: unknown, testiPerUrl?: Map<string,string>|null}} input
319
+ * @returns {{ok: true, documento: string, affermazioni: number, fonti: number, senzaPassaggio: number,
320
+ * intestazione: string, claims: object[], sources: object[]}
321
+ * |{ok: false, motivo: string}}
322
+ */
323
+ export function componiRapportoRicerca({ domanda = null, testo, affermazioni, fonti, testiPerUrl = null }) {
324
+ if (typeof testo !== 'string' || testo.trim().length === 0) {
325
+ return { ok: false, motivo: '`testo` is missing: it must carry the full report as Markdown prose.' };
326
+ }
327
+ const elencoAffermazioni = comeElenco(affermazioni);
328
+ const elencoFonti = comeElenco(fonti);
329
+ if (elencoAffermazioni === null) return { ok: false, motivo: '`affermazioni` must be an array of {testo, fonte, passaggio} objects.' };
330
+ if (elencoFonti === null) return { ok: false, motivo: '`fonti` must be an array of {url, titolo} objects.' };
331
+ if (elencoAffermazioni.length === 0) return { ok: false, motivo: '`affermazioni` is empty: a report with no claims cannot be verified, and is rejected.' };
332
+ if (elencoFonti.length === 0) return { ok: false, motivo: '`fonti` is empty: list every http(s) URL you actually used, with its title.' };
333
+
334
+ const sources = [];
335
+ for (let i = 0; i < elencoFonti.length; i += 1) {
336
+ const f = elencoFonti[i];
337
+ const url = typeof f?.url === 'string' ? f.url.trim() : '';
338
+ if (!/^https?:\/\//i.test(url)) {
339
+ return { ok: false, motivo: `\`fonti[${i}].url\` is not a full http(s) URL${url ? ` (got "${url}")` : ' — it is missing'}. Every source needs the address you actually opened.` };
340
+ }
341
+ const titolo = typeof f?.titolo === 'string' && f.titolo.trim()
342
+ ? f.titolo.trim()
343
+ : typeof f?.title === 'string' && f.title.trim() ? f.title.trim() : url;
344
+ const data = typeof f?.dataDichiarata === 'string' && f.dataDichiarata.trim() ? f.dataDichiarata.trim() : null;
345
+ /*
346
+ * ⛔ `obtained` NON si indovina. `report.mjs` lo stampa come «pagina letta» / «solo estratto
347
+ * dal motore di ricerca», e `ledger.mjs:154` ci conta sopra le pagine davvero aperte:
348
+ * metterlo a `'page'` per default dichiarerebbe letta ogni pagina che nessuno ha aperto.
349
+ * Lo dichiara il modello con `letta`, che è l'unico che lo sa; assente ⇒ `'snippet'`, cioè
350
+ * l'ipotesi che promette MENO.
351
+ */
352
+ /*
353
+ * ⛔ L9 — il testo si cerca con l'indirizzo COM'È e senza barra finale, le stesse due forme
354
+ * con cui `indicePerUrl` qui sotto risolve le citazioni: un url che combacia per le
355
+ * affermazioni e non per il testo produrrebbe «il passaggio non è nel testo della fonte»
356
+ * su una pagina che abbiamo in mano — il motivo giusto per il fatto sbagliato.
357
+ */
358
+ const tenuto = testiPerUrl
359
+ ? (testiPerUrl.get(url) ?? testiPerUrl.get(url.replace(/\/+$/, '')) ?? '')
360
+ : '';
361
+ sources.push({ url, title: titolo, publishedAt: data, obtained: f?.letta === true ? 'page' : 'snippet', text: tenuto });
362
+ }
363
+
364
+ const indicePerUrl = new Map();
365
+ sources.forEach((s, i) => {
366
+ indicePerUrl.set(s.url, i + 1);
367
+ indicePerUrl.set(s.url.replace(/\/+$/, ''), i + 1);
368
+ });
369
+
370
+ const claims = [];
371
+ let senzaPassaggio = 0;
372
+ for (let i = 0; i < elencoAffermazioni.length; i += 1) {
373
+ const a = elencoAffermazioni[i];
374
+ const testoAffermazione = typeof a?.testo === 'string' ? a.testo.trim()
375
+ : typeof a?.text === 'string' ? a.text.trim() : '';
376
+ if (!testoAffermazione) {
377
+ return { ok: false, motivo: `\`affermazioni[${i}].testo\` is missing: every claim needs the sentence it asserts.` };
378
+ }
379
+ const fonteGrezza = a?.fonte ?? a?.source ?? a?.sourceIndex;
380
+ let indice = null;
381
+ if (typeof fonteGrezza === 'number' && Number.isInteger(fonteGrezza)) indice = fonteGrezza;
382
+ else if (typeof fonteGrezza === 'string' && fonteGrezza.trim()) {
383
+ const pulita = fonteGrezza.trim();
384
+ indice = indicePerUrl.get(pulita) ?? indicePerUrl.get(pulita.replace(/\/+$/, '')) ?? null;
385
+ if (indice === null && /^\d+$/.test(pulita)) indice = Number(pulita);
386
+ }
387
+ if (indice === null) {
388
+ return {
389
+ ok: false,
390
+ motivo: `\`affermazioni[${i}].fonte\` is missing or matches no URL in \`fonti\`${typeof fonteGrezza === 'string' ? ` (got "${fonteGrezza.trim()}")` : ''}. Use the exact URL, spelled the same way as in \`fonti\`.`,
391
+ };
392
+ }
393
+ if (indice < 1 || indice > sources.length) {
394
+ return { ok: false, motivo: `\`affermazioni[${i}].fonte\` points to source ${indice}, but \`fonti\` lists ${sources.length}. Sources are numbered from 1.` };
395
+ }
396
+ /*
397
+ * ⛔ Un passaggio mancante NON fa cadere il deposito, e si conta. `report.mjs` tratta un
398
+ * `passage` vuoto come «non ci è mai stato trovato» e lo scrive in chiaro nella prosa;
399
+ * `proveDistinte` non lo conta. Rifiutare qui butterebbe un rapporto intero per una
400
+ * citazione — l'errore che «PHREEQC-MCQ-200» chiama «perdere item che si sarebbero presi
401
+ * senza l'attrezzo». Il numero torna al modello nella risposta, così lo sa.
402
+ */
403
+ const passaggio = typeof a?.passaggio === 'string' ? a.passaggio.trim()
404
+ : typeof a?.passage === 'string' ? a.passage.trim() : '';
405
+ if (!passaggio) senzaPassaggio += 1;
406
+ claims.push({
407
+ claim: { text: testoAffermazione, sourceIndex: indice, quote: passaggio, quotePresent: 'unchecked' },
408
+ passage: passaggio,
409
+ checks: {
410
+ resolved: sources[indice - 1].obtained,
411
+ /*
412
+ * ⛔ `false`, non `true`: nessuno ha confrontato questo passaggio col testo della
413
+ * pagina. `fidelity.mjs:96` conta le affermazioni con `resolved === 'page' &&
414
+ * quotePresent` — un `true` qui sarebbe una verifica mai avvenuta.
415
+ */
416
+ quotePresent: false,
417
+ quoteSpan: null,
418
+ claimSupported: 'unchecked',
419
+ /*
420
+ * ⛔ IN ITALIANO, e non è un dettaglio: `report.mjs` stampa questa frase nella PROSA
421
+ * («Esito: non verificata — …»), cioè la legge una persona, e tutto il resto di quel
422
+ * documento è italiano. Trovato guardando l'artefatto vero prodotto dalla cura, non
423
+ * da un test: nessuna asserzione poteva vederlo.
424
+ */
425
+ supportReason: 'depositata dal modello che ha scritto il rapporto: nessun giudice indipendente l\'ha ancora controllata.',
426
+ judge: null,
427
+ judgedAt: null,
428
+ },
429
+ });
430
+ }
431
+
432
+ const intestazione = typeof domanda === 'string' && domanda.trim() ? domanda.trim() : intestazioneDalTesto(testo);
433
+ const documento = talosResearchReportDocument({
434
+ question: intestazione,
435
+ summary: testo.trim(),
436
+ judge: null,
437
+ claims,
438
+ sources,
439
+ });
440
+ return {
441
+ ok: true, documento, affermazioni: claims.length, fonti: sources.length, senzaPassaggio,
442
+ // ⭐ L9 — i pezzi, per chi deve verificarli. Vedi la doc sopra: un solo interprete.
443
+ intestazione, claims, sources,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Un elenco, anche quando arriva come stringa JSON. `null` = non è un elenco e non lo diventa.
449
+ *
450
+ * ⛔ La tolleranza è misurata, non generosa: un modello che stringhifica un array è un caso
451
+ * visto in natura, e rifiutarlo costerebbe un rapporto intero. Tutto il resto (un oggetto
452
+ * solo, un numero, `undefined`) resta un errore che si dice a parole.
453
+ */
454
+ function comeElenco(valore) {
455
+ if (Array.isArray(valore)) return valore;
456
+ if (typeof valore === 'string' && valore.trim().startsWith('[')) {
457
+ try {
458
+ const letto = JSON.parse(valore);
459
+ return Array.isArray(letto) ? letto : null;
460
+ } catch { return null; }
461
+ }
462
+ return null;
463
+ }
464
+
465
+ /** Il titolo del rapporto quando il server non conosce la domanda (ricerche vecchie, riprese). */
466
+ function intestazioneDalTesto(testo) {
467
+ const righe = String(testo).split('\n').map((r) => r.trim());
468
+ const titolo = righe.find((r) => r.startsWith('# '));
469
+ if (titolo) return titolo.slice(2).trim();
470
+ return righe.find((r) => r.length > 0) ?? '';
471
+ }
472
+
473
+ /**
474
+ * ⭐⭐⭐⭐ L9 — LA CONSEGNA PORTA IL PIANO, e questo è ciò che rende il piano una cosa vera.
475
+ *
476
+ * ⛔ Prima di oggi il piano non esisteva affatto nella corsa: `plan.mjs` era portato, provato e
477
+ * mai chiamato, e la figlia riceveva una guida generica («search from a few different
478
+ * angles»). Un piano che nessuno legge non è un piano: è una decorazione nella scheda.
479
+ *
480
+ * ⛔ Le linee si NUMERANO nella consegna, e l'ordine non è estetico: il giornale attribuisce la
481
+ * k-esima ricerca al k-esimo ramo (`raccolta-viva.mjs`, «l'attribuzione al ramo»). Dire alla
482
+ * figlia di seguirle in ordine è ciò che rende quell'attribuzione onesta invece che casuale.
483
+ * ⇒ Se un giorno qualcuno toglie l'elenco da qui, deve togliere anche quella convenzione là.
484
+ *
485
+ * ⛔ Restano DEFAULT, non gabbie: la consegna dice esplicitamente che una linea può essere
486
+ * saltata o allargata se ciò che si trova lo chiede. Il motore del mobile dice la stessa cosa
487
+ * («Default, non gabbie: il piano resta modificabile»), e un piano che il modello non può
488
+ * disobbedire trasformerebbe una ricerca in uno scraper.
489
+ */
490
+ function promptRicerca(question, depth, piano = []) {
491
+ const guida = depth === 'quick'
492
+ ? 'Keep this brief: a couple of searches are enough — do not over-investigate.'
493
+ : depth === 'exhaustive'
494
+ ? 'Be exhaustive: search from many different angles, cross-check the claims that matter, and go deep before writing.'
495
+ : 'Do a thorough pass: search from a few different angles before writing the report.';
496
+ /*
497
+ * ⛔ Un blocco con gli a-capo VERI, non righe fuse dal `join(' ')` finale: un elenco numerato
498
+ * schiacciato su una riga sola è esattamente la forma che un modello legge come prosa e non
499
+ * come lista di compiti. È l'unico pezzo multilinea di questa consegna, ed è voluto.
500
+ */
501
+ const linee = Array.isArray(piano) && piano.length > 0
502
+ ? [[
503
+ `Your plan has ${piano.length} lines of inquiry. Work through them IN THIS ORDER, one web_search each, then open the pages that matter:`,
504
+ ...piano.map((ramo, i) => ` ${i + 1}. ${ramo.question}`),
505
+ 'Skip or widen a line if what you find asks for it — this is a default, not a cage — but do not reorder it: the journal tracks your progress by that order.',
506
+ ].join('\n')]
507
+ : [];
508
+ return [
509
+ `Research this question thoroughly using web_search and naviga: "${question}"`,
510
+ guida,
511
+ ...linee,
512
+ /*
513
+ * ⛔⛔⛔ L2 (11/09/2026) — QUESTA FRASE È CAMBIATA, ed è il cuore della cura.
514
+ *
515
+ * Prima diceva: «write your findings as a complete final response (not a tool call) — this
516
+ * text becomes the permanent research report». Cioè il rapporto ERA l'ultimo messaggio. Il
517
+ * 11/09, sulla ricerca `d2a453a8`, l'ultimo messaggio con del testo erano 290 byte di scusa
518
+ * («La sessione è in sola lettura, quindi non posso creare documenti direttamente…»), e
519
+ * sono finiti in Libreria come il rapporto permanente, con `terminata:'done'`.
520
+ *
521
+ * ⇒ Adesso il rapporto è un DEPOSITO esplicito, e la consegna lo dice due volte: come si
522
+ * fa (`research_deposit`) e che l'ultimo messaggio NON è il rapporto. La forma richiesta
523
+ * è dichiarata qui e controllata da `rileggiRapportoMinimo` — mai un cancello che chiede
524
+ * una forma che nessuno ha detto.
525
+ */
526
+ 'Deposita il rapporto con research_deposit seguendo le istruzioni sulle parti qui sotto.',
527
+ /*
528
+ * ⭐⭐⭐⭐ L8 (12/09/2026) — LA CONSEGNA NON CHIEDE PIÙ UN RECINTO, CHIEDE TRE ARGOMENTI.
529
+ *
530
+ * ⛔ Cosa c'era prima, e perché è caduto: le tre righe qui sopra dettavano il blocco
531
+ * ```talos-research-report campo per campo, con un esempio copiabile. Il 12/09
532
+ * `glm-4.7-flash` ha letto quella consegna, ha scritto un rapporto in prosa da 8.953 byte
533
+ * e non ha messo il recinto: il cancello ha risposto `senza-rapporto` e cinque minuti di
534
+ * ricerca sono rimasti senza consegna. Non era una consegna poco chiara — era una forma
535
+ * affidata alla prosa, cioè un controllo a livello di prompt («limited mitigation»,
536
+ * arXiv:2606.20023).
537
+ *
538
+ * ⇒ Adesso la forma sta negli ARGOMENTI dell'attrezzo, dove uno schema la dichiara e il
539
+ * server la costruisce. Qui resta il PERCHÉ, che uno schema non può dire: ogni
540
+ * affermazione porta la sua fonte e il passaggio verbatim, e i verdetti non li dà chi
541
+ * scrive. ⛔ `judge` e `claimSupported` non sono nemmeno più argomenti: non c'è più nulla
542
+ * da raccomandare, perché non c'è più nulla che il modello possa timbrare.
543
+ */
544
+ '`testo`: prosa Markdown della parte corrente; il rapporto assemblato porta titolo, risultati e fonti, senza riscrivere la prosa.',
545
+ '`affermazioni`: one entry per factual claim that matters, each with `testo` (the claim), `fonte` (the exact http(s) URL it rests on, spelled as in `fonti`) and `passaggio` (the sentence you actually read in that source, copied VERBATIM — never reworded, never invented; "" is the honest answer if you cannot find it, and it is counted as such).',
546
+ '`fonti`: one entry per source, with `url` (full http(s)), `titolo`, `dataDichiarata` (the date the source itself declares, or omit it) and `letta`: true only if you opened the page, false if you only saw a search-result snippet.',
547
+ 'The server builds the verifiable record from those three and saves it with your text: you do not have to write any JSON, and you cannot mark your own claims as verified — an independent check happens later.',
548
+ 'That deposited document IS the permanent report. Your chat message is not the report and is never saved as one — after depositing, just tell the user in one or two lines that the report is ready.',
549
+ 'Note any real uncertainty instead of guessing, and never deposit a report without sources.',
550
+ 'You cannot write files, run shell commands or create documents in this project: `research_deposit` is the one and only thing you are allowed to write, and it is all you need.',
551
+ consegnaPartiRapporto(),
552
+ ].join(' ');
553
+ }
554
+
555
+ /**
556
+ * ⭐ §6.6 (11/09) — il nome della ricerca: la domanda, troncata a 80 caratteri.
557
+ * ⛔ 80 e non un altro numero: è il tetto che `registro.rinomina()` impone a un nome di sessione
558
+ * (`session-registry.mjs`, «Nome non valido: serve 1-80 caratteri»). Un nome più lungo sarebbe
559
+ * rifiutato da quella porta, e due limiti diversi per la stessa cosa divergono al primo caso
560
+ * limite. Il troncamento taglia sull'ultimo spazio quando può: «Come stanno evolvendo gli…» è
561
+ * leggibile, «Come stanno evolvendo gli harness agen» no.
562
+ */
563
+ function nomeDallaDomanda(domanda) {
564
+ const pulita = String(domanda ?? '').trim().replace(/\s+/g, ' ');
565
+ if (pulita.length <= 80) return pulita;
566
+ const tagliato = pulita.slice(0, 79);
567
+ const spazio = tagliato.lastIndexOf(' ');
568
+ return `${(spazio > 40 ? tagliato.slice(0, spazio) : tagliato).trimEnd()}…`;
569
+ }
570
+
571
+ /**
572
+ * ⭐⭐⭐ BC-44 — quanto si aspetta prima di riprendere da soli, UNA volta.
573
+ *
574
+ * ⛔ Non è «un backoff» con l'articolo indeterminativo: il backoff a più tentativi esiste già, e
575
+ * sta dentro `chiamaConRitenta` (500·2^n + jitter, quattro tentativi). Quello copre la singola
576
+ * chiamata; questo copre la CORSA, e di tentativi ne ha uno solo — quindi non c'è niente da
577
+ * raddoppiare, c'è da scegliere UN'attesa.
578
+ * ⛔ Venti secondi, e il numero ha una ragione misurabile: l'ultimo tentativo del backoff del
579
+ * kernel cade intorno ai 4 s dal primo (0,5+1+2 più jitter), quindi un'attesa più corta
580
+ * ripartirebbe dentro la stessa finestra che ha appena fallito quattro volte. Venti la
581
+ * scavalca con margine e resta sotto la soglia in cui una persona davanti allo schermo
582
+ * comincia a chiedersi se sia morto tutto.
583
+ * ⛔ Non è misurata su un guasto vero del fornitore: è aritmetica sul backoff che abbiamo. Se
584
+ * un giorno la si vorrà tarare, il dato da raccogliere è la durata dei guasti veri, non
585
+ * l'opinione di chi scrive questa riga.
586
+ */
587
+ export const ATTESA_RIPRESA_AUTOMATICA_MS = 20_000;
588
+
589
+ const PROMPT_RIPRESA = 'Continue the research from where you left off, using web_search and naviga as needed, then write the final report as your last message.';
590
+
591
+ /**
592
+ * ⭐⭐⭐ L4 §6.6 — LA CONSEGNA DI UNA RIPRESA DAL GIORNALE.
593
+ *
594
+ * Quando il server è stato riavviato la conversazione non c'è più: al modello non si può
595
+ * ridare il suo contesto, e fingere di averlo sarebbe la bugia. Gli si dà invece quello che il
596
+ * giornale sa davvero — la domanda, quanto è già stato speso, quali linee sono ancora aperte,
597
+ * quale passo era in volo, e dove sta il testo già tenuto.
598
+ *
599
+ * ⛔ Nessuna di queste righe è inventata: ognuna esce dal replay o dal disco. Se una lista è
600
+ * vuota non si scrive («0 rami rimasti» su un giro che non ha mai avuto un piano direbbe che
601
+ * non c'è più niente da fare, che è il contrario del vero) — la riga semplicemente non c'è.
602
+ * ⛔ E la consegna ORIGINALE viene riproposta per intera, perché contiene le istruzioni sul
603
+ * deposito e sulla forma del record: senza, una ricerca ripresa consegnerebbe qualcosa che il
604
+ * cancello respinge — un guasto introdotto dalla cura, cioè il peggiore.
605
+ */
606
+ function consegnaDiRipresa({ giro, prossimo, rimasti, speso, fonti, task, deposito }) {
607
+ const righe = [
608
+ 'This research was interrupted (the app or the server restarted). Its conversation is gone, but its journal is not — here is exactly where it stood.',
609
+ `Question: "${giro.question}"`,
610
+ `Already spent before the interruption: ${speso.tokens} tokens, ${speso.searches} searches, ${speso.pages} pages opened. Do not redo work that is listed as done below.`,
611
+ ];
612
+ const fatti = giro.steps.filter((p) => p.state === 'done');
613
+ if (fatti.length > 0) righe.push(`Steps already completed: ${fatti.map((p) => p.id).join(', ')}.`);
614
+ if (prossimo) righe.push(`Resume from this step: ${prossimo.id} (${prossimo.kind}), which was ${prossimo.state === 'interrupted' ? 'in flight when the process died' : 'never started'}.`);
615
+ if (rimasti.length > 0) righe.push(`Lines of inquiry still open: ${rimasti.map((r) => `«${r.question}»`).join(' · ')}.`);
616
+ /*
617
+ * ⛔ L9 — «source text(s)» e non «source page(s)», ed è una parola cambiata su una misura:
618
+ * da oggi in `fonti/` finiscono anche gli ESTRATTI dei risultati di ricerca (una fonte vista
619
+ * ma non aperta è comunque una prova, e senza di lei la verifica direbbe «la fonte citata non
620
+ * esiste fra quelle raccolte»). Il conteggio è quindi di testi tenuti, non di pagine aperte —
621
+ * e chiamarli «pagine» sarebbe un numero gonfiato detto a un modello che ci conta sopra.
622
+ */
623
+ if (fonti.length > 0) righe.push(`${fonti.length} source text(s) were already fetched and kept on disk; they are available without paying for them again.`);
624
+ if (!prossimo && rimasti.length === 0 && fatti.length === 0) {
625
+ /*
626
+ * ⛔ Il caso di oggi, e si dichiara invece di nasconderlo: il giornale registra il ciclo di
627
+ * vita del giro (avvio, pausa, ripresa, fine) ma non ancora i singoli passi di raccolta,
628
+ * perché il collettore non è agganciato (vedi la testa di questo file). Il modello deve
629
+ * sapere che riparte dall'indagine, non da un passo preciso — non che «non c'è niente da
630
+ * fare».
631
+ */
632
+ righe.push('The journal records the run itself but not individual collection steps, so restart the investigation for this question from the beginning of the evidence you can still see, and do not assume anything was already concluded.');
633
+ }
634
+ righe.push(typeof task?.consegna === 'string' && task.consegna.trim() ? task.consegna.trim() : PROMPT_RIPRESA);
635
+ righe.push(deposito);
636
+ return righe.join('\n');
637
+ }
638
+
639
+ /**
640
+ * ⛔⛔⛔ L2 (11/09/2026) — QUESTA FUNZIONE HA CAMBIATO NOME, E IL NOME È LA CURA.
641
+ *
642
+ * Si chiamava `estraiTestoRapporto` e la sua doc diceva «quello che diventa il rapporto». Non
643
+ * lo è, e non lo è mai stato: è l'ultima cosa che il modello ha detto. Il 11/09 quella cosa
644
+ * erano 290 byte di scusa, e il solo controllo era «esiste del testo?» — una scusa è testo.
645
+ *
646
+ * ⇒ Adesso si chiama `ultimoMessaggioDelModello` e vale quello che vale: un ALLEGATO, mostrato
647
+ * come «ciò che il modello ha detto alla fine», mai come rapporto. Il rapporto è
648
+ * `.harness-ui-research/<id>/rapporto.md`, depositato con `research_deposit`.
649
+ */
650
+ function ultimoMessaggioDelModello(messaggiFinali) {
651
+ if (!Array.isArray(messaggiFinali)) return null;
652
+ for (let i = messaggiFinali.length - 1; i >= 0; i -= 1) {
653
+ const m = messaggiFinali[i];
654
+ if (m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim().length > 0) return m.content.trim();
655
+ }
656
+ return null;
657
+ }
658
+
659
+ /**
660
+ * ⭐⭐⭐ L2 §6.5 — «fra i risultati degli attrezzi c'è un REFUSED di permesso».
661
+ *
662
+ * ⛔ Cerca la firma ESATTA che il kernel emette (`REFUSED. ` in testa al risultato di un
663
+ * attrezzo, `talosHarness.mjs`), non la parola «permission» né una frase italiana: il motivo
664
+ * del rifiuto è prosa e cambia, il prefisso è un contratto. È la lezione «un filtro che
665
+ * riconosce la MENZIONE invece della cosa» (23/8, il guardiano che accusava la sessione
666
+ * dell'owner) applicata qui: un rapporto che PARLA di un rifiuto non è un rifiuto.
667
+ *
668
+ * ⛔ E guarda i messaggi `role:'tool'`, mai quelli dell'assistente: la scusa del modello cita
669
+ * il rifiuto parola per parola, e prenderla per la prova del rifiuto sarebbe credere al
670
+ * racconto invece che al registro.
671
+ */
672
+ function trovaRifiutoDiPermesso(messaggiFinali) {
673
+ if (!Array.isArray(messaggiFinali)) return null;
674
+ for (const m of messaggiFinali) {
675
+ if (m?.role !== 'tool') continue;
676
+ const testo = typeof m.content === 'string' ? m.content.trim() : '';
677
+ if (!testo.startsWith('REFUSED. ')) continue;
678
+ /*
679
+ * ⛔ Le uniche altre due frasi che cominciano con `REFUSED. ` nel kernel sono gli input
680
+ * vuoti (`REFUSED. Empty html`, `REFUSED. Empty report`): non sono rifiuti di permesso, e
681
+ * chiamarli così manderebbe l'owner a cambiare un permesso che non c'entra.
682
+ * ⛔ Resta fuori anche la premessa negata di `scrivi` (`REFUSED. <perché> Nothing was
683
+ * written.`) — e non serve escluderla a mano: a livello `'ricerca'` `scrivi` non passa
684
+ * mai il cancello, quindi quel ramo non può essere raggiunto qui. Se un giorno una
685
+ * ricerca girasse a un livello più largo, questa riga andrà rivista: è scritto qui
686
+ * perché l'assunzione sia visibile, non nascosta.
687
+ */
688
+ if (testo.startsWith('REFUSED. Empty ')) continue;
689
+ return testo;
690
+ }
691
+ return null;
692
+ }
693
+
694
+ function nomeRapporto(domanda) {
695
+ const troncato = String(domanda ?? '').trim().slice(0, 100);
696
+ return `Research - ${troncato || 'untitled'}.md`;
697
+ }
698
+
699
+ /**
700
+ * Il bucket "vivo" — mai duplicato su disco (vedi la doc di testa).
701
+ * `voceSessione` assente O `interrotta` (il processo che la eseguiva
702
+ * non esiste più, e non aveva mai finito da sola — stesso stato che
703
+ * `resume()` rifiuta onestamente) ⇒ 'failed': mai "running" per un
704
+ * processo che non c'è, mai "paused" per un run che non era mai stato
705
+ * fermato apposta.
706
+ */
707
+ function statoVivo(voceRicerca, voceSessione) {
708
+ if (voceRicerca.terminata) return voceRicerca.terminata;
709
+ if (!voceSessione || voceSessione.interrotta) return 'failed';
710
+ return voceSessione.conclusa ? 'paused' : 'running';
711
+ }
712
+
713
+ /* ══════════════ BC-44 (12/09/2026) — TRANSITORIO NON È FALLITO ══════════════
714
+ *
715
+ * Il fatto che l'ha fatta nascere, misurato e non dedotto: la ricerca `dec896c0` del 12/09 ha
716
+ * lavorato **16 giri, 32 attrezzi, 67 testi tenuti, 134.464 token dalla cache**, ha scritto
717
+ * «Poi deposito.» — e a quel punto il fornitore ha chiuso:
718
+ *
719
+ * {"type":"RunError","message":"Upstream idle timeout exceeded","code":"internal-error"}
720
+ *
721
+ * ⇒ `terminata:'failed'`, e `POST …/research/:id/ripresa` rispondeva **409**: venti minuti
722
+ * pagati che il giornale conserva per intero e che nessuno poteva riprendere.
723
+ *
724
+ * ## Le fonti, lette il 12/09/2026 (ricerca PRIMA di scrivere, obbligo owner)
725
+ *
726
+ * ⛔ **OpenRouter, «Errors»** — il vincolo che non conoscevo, e che decide tutto:
727
+ * «If an attempt fails before any tokens reach you, OpenRouter automatically tries a backup
728
+ * provider. The `200 OK` has already been sent by then, so the status stays `200` even when
729
+ * every provider fails.»
730
+ * «a `200 OK` whose JSON body holds only an `error` object and no `choices`; check the body
731
+ * for an `error` field even on a `200`.»
732
+ * ⇒ Il ritentativo del kernel (`chiamaConRitenta`, `siRitenta(stato)`) **non può** coprire
733
+ * questa classe: decide sullo STATO HTTP, e qui lo stato è 200. Non è una dimenticanza del
734
+ * kernel — è una cura che sta a valle del punto in cui l'errore nasce. Per questo la ripresa
735
+ * va messa al livello della RICERCA, dove c'è il giornale, e non al livello della chiamata.
736
+ *
737
+ * ⛔ **Hermes Agent v0.21 (Nous Research)**, letto nel suo codice — `agent/error_classifier.py`
738
+ * e `docs/session-lifecycle.md`. Due cose, entrambe copiate qui nella forma, non nel testo:
739
+ * 1. un classificatore CENTRALE con un campo `retryable` esplicito, che «replaces scattered
740
+ * inline string-matching», e un ordine di precedenza dichiarato (billing prima di
741
+ * rate-limit, SSL prima di disconnect, disconnect prima del catch-all);
742
+ * 2. `resume_pending` (recupero MORBIDO: «preserves the existing session_id — the user
743
+ * continues on the same transcript») tenuto separato da `suspended` («hard force-wipe
744
+ * signal»), con un `resume_reason` che dice PERCHÉ la ripresa è stata segnata.
745
+ * ⇒ Qui: un `failed` transitorio è `resume_pending`, un `failed` non transitorio resta
746
+ * quello che era. E il perché si scrive: `motivoErrore.classe`.
747
+ * ⛔ Hermes annota anche il nostro caso alla lettera: un disconnect su un modello che ragiona è
748
+ * «the upstream proxy idle-killing a long thinking stream», non un contesto pieno — e la cura
749
+ * sbagliata (comprimere) «silently delete[s] conversation history on a phantom
750
+ * context-length error». Per questo `contesto` qui NON è transitorio.
751
+ *
752
+ * ⛔ **Anthropic, «How we built our multi-agent research system»**: «When errors occur, we can't
753
+ * just restart from the beginning: restarts are expensive and frustrating for users. Instead,
754
+ * we built systems that can resume from where the agent was when the errors occurred», e
755
+ * «deterministic safeguards like retry logic and regular checkpoints». Il nostro checkpoint
756
+ * esiste già ed è il giornale: mancava solo il permesso di rientrarci.
757
+ *
758
+ * ## ⛔ DOVE DIVERGO DA HERMES, e perché
759
+ *
760
+ * Hermes manda `unknown` a `retryable=True`. Qui `ignoto` è **NON transitorio**, e non è una
761
+ * svista: là si ritenta LA STESSA CHIAMATA (costo: una chiamata), qui si riapre UNA RICERCA che
762
+ * può spendere venti minuti e centinaia di migliaia di token. Costi diversi ⇒ default diversi.
763
+ * Un guasto deterministico dichiarato «riprendibile» farebbe ripagare un fallimento garantito.
764
+ * ⇒ Si riprende solo ciò che si è RICONOSCIUTO. Quello che non si riconosce si comporta
765
+ * esattamente come ieri: nessuna regressione possibile da questa riga.
766
+ *
767
+ * ⛔ E il `code` da solo NON basta: `agent-service.mjs` marca `internal-error` OGNI guasto del
768
+ * servizio — il nostro caso incluso. Il codice si guarda per primo quando dice qualcosa
769
+ * (`CTX_*`, gli esiti del task), e per il resto si legge il MESSAGGIO, che è l'unica cosa che
770
+ * il fornitore ha davvero detto.
771
+ */
772
+
773
+ /** Le classi che si riprendono. ⛔ Elenco chiuso: chi non è qui dentro NON è transitorio. */
774
+ const CLASSI_TRANSITORIE = new Set(['rete', 'timeout-fornitore', 'traffico', 'guasto-fornitore', 'flusso-interrotto']);
775
+
776
+ /** La mezza frase italiana di ogni classe — il pezzo variabile di `motivoDelloStato`. */
777
+ const CLAUSOLA_DI_CLASSE = new Map([
778
+ ['rete', 'la connessione con il fornitore del modello è caduta'],
779
+ ['timeout-fornitore', 'il fornitore del modello ha chiuso la connessione mentre lavorava'],
780
+ ['traffico', 'il fornitore del modello ha rifiutato per troppo traffico'],
781
+ ['guasto-fornitore', 'il fornitore del modello ha risposto con un guasto suo'],
782
+ ['flusso-interrotto', 'la risposta del modello si è interrotta a metà'],
783
+ ]);
784
+
785
+ /* Gli esiti del TASK: non sono guasti, e hanno già il loro stato. */
786
+ const CODICI_ESITO_DEL_TASK = new Map([['fermato', 'fermato'], ['giri-esauriti', 'giri-esauriti'], ['premesse-negate', 'premesse-negate']]);
787
+
788
+ /*
789
+ * ⛔ L'ORDINE È LA CURA, non l'elenco. Le prime due famiglie sono NON transitorie e vanno
790
+ * guardate PRIMA delle transitorie, perché le loro frasi contengono le parole delle altre:
791
+ * «you have exceeded your current quota» porta dentro «exceeded», «insufficient credits …
792
+ * rate limit» porta dentro «rate limit». Chi legge per primo vince, quindi legge per primo
793
+ * chi non si deve ritentare. (È la stessa precedenza di Hermes: billing prima di rate_limit.)
794
+ */
795
+ const SEGNI_CREDITO = ['insufficient credit', 'insufficient_quota', 'insufficient balance', 'credit balance', 'payment required', 'exceeded your current quota', 'out of funds', 'billing'];
796
+ const SEGNI_CREDENZIALE = ['unauthorized', 'invalid api key', 'no auth credentials', 'authentication', 'forbidden', 'http 401', 'http 403'];
797
+ const SEGNI_CONTESTO = ['context length', 'context_length', 'maximum context', 'too many tokens', 'prompt is too long', 'reduce the length'];
798
+ const SEGNI_RICHIESTA = ['invalid request', 'bad request', 'model not found', 'is not a valid model', 'no endpoints found', 'http 400', 'http 404'];
799
+ const SEGNI_TRAFFICO = ['rate limit', 'rate-limit', 'too many requests', 'http 429', 'temporarily rate-limited'];
800
+ const SEGNI_TIMEOUT = ['idle timeout', 'timeout', 'timed out', 'deadline exceeded', 'http 408', 'http 504', 'http 524'];
801
+ const SEGNI_GUASTO = ['bad gateway', 'service unavailable', 'gateway timeout', 'overloaded', 'at capacity', 'over capacity', 'internal server error', 'upstream error', 'provider returned error', 'http 500', 'http 502', 'http 503'];
802
+ const SEGNI_RETE = ['econnreset', 'econnrefused', 'etimedout', 'enotfound', 'eai_again', 'epipe', 'fetch failed', 'socket hang up', 'connection reset', 'connection refused', 'network', 'terminated'];
803
+ const SEGNI_FLUSSO = ['flusso sse', 'unexpected eof', 'premature close', 'stream ended', 'incomplete chunked'];
804
+
805
+ /**
806
+ * ⭐⭐⭐ BC-44 — LA TABELLA, in una funzione pura che un test può mordere da sola.
807
+ *
808
+ * @param {{codice?: string|null, messaggio?: string|null}} errore
809
+ * @returns {{classe: string, transitorio: boolean}}
810
+ */
811
+ export function classificaErroreDiCorsa({ codice = null, messaggio = null } = {}) {
812
+ const c = typeof codice === 'string' ? codice.trim() : '';
813
+ const m = typeof messaggio === 'string' ? messaggio.toLowerCase() : '';
814
+ const dentro = (segni) => segni.some((s) => m.includes(s));
815
+ const esito = (classe) => ({ classe, transitorio: CLASSI_TRANSITORIE.has(classe) });
816
+
817
+ // 1. Il codice, quando dice davvero qualcosa. `internal-error` NON dice niente: è il default.
818
+ if (CODICI_ESITO_DEL_TASK.has(c)) return esito(CODICI_ESITO_DEL_TASK.get(c));
819
+ if (c.startsWith('CTX_')) return esito('contesto');
820
+ // P-L (12/09): i guasti dell'agente esterno ACP hanno la loro classe, così una ricerca ricostruita dal codice salvato non torna «ignoto».
821
+ if (c === 'ACP_PROCESS_EXITED') return esito('flusso-interrotto');
822
+ if (c === 'ACP_TIMEOUT') return esito('timeout-fornitore');
823
+ if (c === 'ACP_CANCELLED') return esito('fermato');
824
+ /*
825
+ * ⛔ `fermatoSuRichiesta` arriva come messaggio, non come codice: il kernel lancia «⛔ fermato
826
+ * su richiesta …» e `agent-service` lo marca `internal-error` come tutto il resto. Uno stop
827
+ * voluto non si riprende da solo, mai — sarebbe ripartire contro chi ha premuto Ferma.
828
+ */
829
+ if (m.includes('fermato su richiesta')) return esito('fermato');
830
+
831
+ // 2. Le NON transitorie che contengono le parole delle transitorie: prima loro (vedi sopra).
832
+ if (dentro(SEGNI_CREDITO)) return esito('credito');
833
+ if (dentro(SEGNI_CREDENZIALE)) return esito('credenziale');
834
+ if (dentro(SEGNI_CONTESTO)) return esito('contesto');
835
+ if (dentro(SEGNI_RICHIESTA)) return esito('richiesta-non-valida');
836
+
837
+ // 3. Le transitorie.
838
+ if (dentro(SEGNI_TRAFFICO)) return esito('traffico');
839
+ if (dentro(SEGNI_TIMEOUT)) return esito('timeout-fornitore');
840
+ if (dentro(SEGNI_GUASTO)) return esito('guasto-fornitore');
841
+ if (dentro(SEGNI_RETE)) return esito('rete');
842
+ if (dentro(SEGNI_FLUSSO)) return esito('flusso-interrotto');
843
+
844
+ // 4. Non riconosciuto ⇒ si comporta come ieri. Vedi «DOVE DIVERGO DA HERMES».
845
+ return esito('ignoto');
846
+ }
847
+
848
+ /**
849
+ * ⭐⭐⭐⭐ BC-44 — LA PROVA C'ERA GIÀ, E NESSUNO LA LEGGEVA.
850
+ *
851
+ * ⛔ Il difetto che questa funzione chiude è più grande di quello che sembra. La cura scritta in
852
+ * `onConclusioneRicerca` vale **da oggi in avanti**: ogni ricerca caduta PRIMA — compresa
853
+ * `dec896c0`, quella che ha fatto nascere BC-44, coi suoi venti minuti pagati — avrebbe avuto
854
+ * `motivoErrore: null` per sempre, e sarebbe rimasta non riprendibile. Una cura che non cura
855
+ * il caso che l'ha fatta scrivere.
856
+ *
857
+ * ⇒ Ma il fatto è registrato lo stesso, e lo era da sempre: nel `.jsonl` della SESSIONE c'è
858
+ * {"type":"RunError","message":"Upstream idle timeout exceeded","code":"internal-error"}
859
+ * e `ripristina()` rimette quegli eventi in `voce.eventi` a ogni avvio del server. Non serve
860
+ * leggere un altro file: basta guardare quello che il registro ha già in mano.
861
+ *
862
+ * ⛔ Si scandisce ALL'INDIETRO e ci si ferma al primo `RunStarted`: gli eventi di una sessione
863
+ * sono la storia di TUTTI i suoi giri, e un `RunError` di tre giri fa non dice niente sul giro
864
+ * che è appena caduto. Senza questa fermata, una ricerca ripresa e poi conclusa bene si
865
+ * porterebbe dietro il motivo della sua prima caduta.
866
+ * ⛔ E non si scrive niente sul disco: la deduzione vive nella LETTURA, come la correzione degli
867
+ * stati di `elenca()`. Ciò che è costato denaro non si riscrive per far quadrare un campo.
868
+ *
869
+ * @returns {{codice: string|null, messaggio: string|null}|null}
870
+ */
871
+ function ultimaCadutaDegliEventi(eventi) {
872
+ if (!Array.isArray(eventi)) return null;
873
+ for (let i = eventi.length - 1; i >= 0; i -= 1) {
874
+ const e = eventi[i];
875
+ if (e?.type === 'RunStarted') return null;
876
+ if (e?.type === 'RunFinished') return null;
877
+ if (e?.type === 'RunError') {
878
+ return {
879
+ codice: typeof e.code === 'string' ? e.code : null,
880
+ messaggio: typeof e.message === 'string' ? e.message : null,
881
+ };
882
+ }
883
+ }
884
+ return null;
885
+ }
886
+
887
+ /**
888
+ * La causa della caduta di una ricerca `failed`: quella REGISTRATA se c'è, altrimenti quella
889
+ * DEDOTTA dagli eventi della sessione. `null` quando non c'è né l'una né l'altra.
890
+ *
891
+ * ⛔ `registrata` prima di `dedotta`, sempre: la prima l'ha scritta chi era presente alla
892
+ * conclusione, la seconda è una rilettura. Quando ci sono entrambe non possono che coincidere
893
+ * — ma l'ordine va comunque dichiarato, perché il giorno in cui divergessero vince il testimone.
894
+ */
895
+ function causaDellaCaduta(record, voceSessione) {
896
+ if (record?.motivoErrore) return record.motivoErrore;
897
+ const grezza = ultimaCadutaDegliEventi(voceSessione?.eventi);
898
+ if (!grezza) return null;
899
+ return { ...classificaErroreDiCorsa(grezza), codice: grezza.codice, messaggio: grezza.messaggio, dedotta: true };
900
+ }
901
+
902
+ /**
903
+ * ⭐⭐⭐ L2 §6.5 — LA FRASE UMANA. Non un codice tradotto dal frontend: il motivo lo dice il
904
+ * server, in una frase sola, e in italiano.
905
+ *
906
+ * ⛔ `null` quando lo stato è `done` o quando la ricerca sta ancora girando: un «motivo» su
907
+ * una cosa riuscita è rumore, e su una in corso è una previsione.
908
+ * ⛔ Le frasi NOMINANO IL COLPEVOLE quando il colpevole siamo noi. «La sessione era in sola
909
+ * lettura e non ha potuto depositare il rapporto» è il prodotto che ammette il proprio
910
+ * errore; «ricerca fallita» sarebbe farlo pagare all'owner.
911
+ */
912
+ function motivoDelloStato(stato, dettaglio = null, motivoErrore = null) {
913
+ /*
914
+ * ⭐⭐⭐ BC-44 — quando la caduta è transitoria la frase cambia, e cambia in due punti: dice
915
+ * CHI è caduto (mai «la ricerca non ce l'ha fatta»: non è stata lei) e dice che si riprende.
916
+ * ⛔ La frase NON nomina il codice né il messaggio del fornitore: «Upstream idle timeout
917
+ * exceeded» a schermo sarebbe un nome tecnico, e quelli non entrano nella UI.
918
+ */
919
+ if (stato === 'failed' && motivoErrore?.transitorio === true && CLAUSOLA_DI_CLASSE.has(motivoErrore.classe)) {
920
+ return `La ricerca si è interrotta a metà: ${CLAUSOLA_DI_CLASSE.get(motivoErrore.classe)}. Il lavoro già fatto è conservato e può riprendere da lì.`;
921
+ }
922
+ switch (stato) {
923
+ case 'bloccata-dal-permesso':
924
+ return 'La sessione era in sola lettura e non ha potuto depositare il rapporto: il lavoro è stato fatto, la consegna no.';
925
+ case 'senza-rapporto':
926
+ return dettaglio
927
+ ? `La ricerca è finita ma il rapporto non è leggibile: ${dettaglio}.`
928
+ : 'La ricerca è finita senza depositare un rapporto leggibile.';
929
+ case 'giri-esauriti':
930
+ return 'La ricerca ha esaurito i giri a disposizione prima di depositare il rapporto.';
931
+ case 'cancelled':
932
+ return 'La ricerca è stata fermata per sempre: quello che aveva raccolto resta leggibile.';
933
+ case 'paused':
934
+ return 'La ricerca è in pausa: può riprendere da dove si era fermata.';
935
+ case 'failed':
936
+ return dettaglio ? `La ricerca non è arrivata in fondo: ${dettaglio}.` : 'La ricerca non è arrivata in fondo.';
937
+ default:
938
+ return null;
939
+ }
940
+ }
941
+
942
+ function clampNumero(valore, min, max, difetto) {
943
+ const numero = Number(valore);
944
+ return Number.isFinite(numero) ? Math.min(Math.max(numero, min), max) : difetto;
945
+ }
946
+
947
+ export function creaResearchOrchestrator({
948
+ sessioni, avviaESeguiFn,
949
+ creaRicercaFn, leggiRicercaFn, aggiornaRicercaFn, eliminaRicercaFn, elencaRicercheFn,
950
+ salvaVoceLibreriaFn, leggiVoceLibreriaFn, eliminaVoceLibreriaFn,
951
+ randomUUIDFn,
952
+ /*
953
+ * ⭐⭐⭐ L2 §6.5 (11/09) — IL PUNTO DI INNESTO DEL LETTORE DI RAPPORTI.
954
+ *
955
+ * `leggiRapportoFn({cartella, id}) => Promise<string|null>` legge il file depositato;
956
+ * `rileggiRapportoFn(testo) => {ok, intestazione, affermazioni, fonti, motivo}` lo giudica.
957
+ *
958
+ * ⛔ Sono DUE e non uno per la stessa ragione per cui `salvaVoceLibreriaFn` è iniettata: il
959
+ * default legge il disco vero (`research-store.mjs`), i test non toccano un filesystem.
960
+ * ⛔ E `rileggiRapportoFn` è iniettabile perché il record recintato vero
961
+ * (`src/research/report.mjs`, portato dal mobile da un altro agente in parallelo) non
962
+ * esiste ancora in questo repo: il giorno in cui esiste si cambia QUESTA riga, non la
963
+ * macchina degli stati qui sotto. La forma minima di oggi è dichiarata, non implicita —
964
+ * vedi `rileggiRapportoMinimo`.
965
+ */
966
+ leggiRapportoFn = leggiRapporto,
967
+ /*
968
+ * ⭐⭐⭐ L4 — IL PUNTO DI INNESTO È STATO USATO. L2 aveva lasciato qui `rileggiRapportoMinimo`
969
+ * scrivendo «il giorno in cui il record recintato esiste si cambia QUESTA riga, non la
970
+ * macchina degli stati». Quel giorno è oggi: il default è il lettore del record vero, e la
971
+ * forma minima resta dietro, come ripiego dichiarato per le ricerche vecchie.
972
+ * ⛔ La firma è cresciuta di un argomento (`{ripiegoConsentito}`): un lettore iniettato che lo
973
+ * ignora continua a funzionare — è il motivo per cui è un oggetto di opzioni e non un
974
+ * secondo parametro posizionale obbligatorio.
975
+ */
976
+ rileggiRapportoFn = rileggiRapportoRecintato,
977
+ /*
978
+ * ⭐⭐⭐ L4 — LE QUATTRO PORTE NUOVE SUL DISCO DELLA RICERCA, tutte iniettabili come le altre.
979
+ *
980
+ * `accodaEventoFn` scrive UNA riga nel giornale (solo append);
981
+ * `leggiGiornaleFn` lo rilegge tollerando le righe mozzate — è ciò da cui `riprendi()` rigioca;
982
+ * `leggiPianoFn` il piano approvato, quando c'è;
983
+ * `statRapportoFn` `mtimeMs`+`size` del rapporto: la chiave della cache di `elenca()`.
984
+ *
985
+ * ⛔ Iniettabili per la ragione di sempre, e stavolta con un caso già visto: senza queste
986
+ * porte un test dell'orchestratore andrebbe a scrivere sul filesystem VERO della macchina
987
+ * che lo esegue — cioè misurerebbe l'ambiente invece dell'oggetto (lezione 10/09).
988
+ */
989
+ accodaEventoFn = accodaEvento,
990
+ leggiGiornaleFn = leggiGiornale,
991
+ leggiPianoFn = leggiPiano,
992
+ statRapportoFn = statRapporto,
993
+ elencaFontiFn = elencaFonti,
994
+ /*
995
+ * ⭐ L4+L6 — la cache del fetch, persistita accanto al giornale (`<id>/cache.json`).
996
+ * `creaCacheFetchFn` è la fabbrica, iniettabile come tutto il resto: i test non devono
997
+ * dipendere dall'orologio vero né dai tetti di default.
998
+ */
999
+ leggiIstantaneaCacheFn = leggiIstantaneaCache,
1000
+ scriviIstantaneaCacheFn = scriviIstantaneaCache,
1001
+ creaCacheFetchFn = talosResearchFetchCache,
1002
+ /*
1003
+ * ⭐⭐⭐⭐ L9 (12/09/2026) — LE PORTE NUOVE, e perché sono tutte iniettabili come le altre.
1004
+ *
1005
+ * Le quattro del DISCO (`scriviPianoFn`, `scriviFonteFn`, `leggiFonteFn`, e le due
1006
+ * dell'indice) per la ragione di sempre, già pagata: senza, un test di questo modulo
1007
+ * scriverebbe nel filesystem VERO della macchina che lo esegue — «misuravo l'ambiente invece
1008
+ * dell'oggetto», lezione del 10/09, trovata dal vivo proprio in L4.
1009
+ *
1010
+ * ⛔⛔ Le due del MODELLO (`pianificaFn`, `chiediAlModelloFn`) sono `null` per default, e il
1011
+ * `null` è un comportamento dichiarato, non un buco:
1012
+ * - senza `pianificaFn` il piano è quello DETERMINISTICO di `plan.mjs` (rami per
1013
+ * profondità, facce ordinate). È già un piano vero e non costa un token;
1014
+ * - senza `chiediAlModelloFn` NON C'È GIUDICE: le affermazioni escono `unchecked` col
1015
+ * motivo scritto («nessun giudice indipendente disponibile: l'autore non può verificare
1016
+ * sé stesso») e il rapporto porta `judge: null`. ⛔ Mai il ripiego opposto — far
1017
+ * timbrare al modello le proprie affermazioni — che è il guasto misurato da
1018
+ * Panickssery/Bowman/Feng (arXiv:2404.13076): «a linear correlation between
1019
+ * self-recognition capability and the strength of self-preference bias».
1020
+ * ⇒ i test di questo file girano con modello e rete FINTI, sempre, e il giro vero lo lancia
1021
+ * l'owner sul 4174.
1022
+ */
1023
+ scriviPianoFn = scriviPiano,
1024
+ scriviFonteFn = scriviFonte,
1025
+ leggiFonteFn = leggiFonte,
1026
+ scriviIndiceFontiFn = scriviIndiceFonti,
1027
+ leggiIndiceFontiFn = leggiIndiceFonti,
1028
+ pianificaFn = null,
1029
+ chiediAlModelloFn = null,
1030
+ /*
1031
+ * I modelli che potrebbero fare da giudice, nell'ordine in cui vale la pena interpellarli.
1032
+ * ⛔ La scelta NON si fa qui: la fa `talosResearchPickJudge`, che è «chiunque tranne
1033
+ * l'autore» e ha i suoi test. Questa funzione dice solo CHI c'è.
1034
+ */
1035
+ modelliGiudiceFn = () => [],
1036
+ /*
1037
+ * Il prezzo pubblicato del modello, quando lo si è ottenuto. ⛔ `null` = non lo sappiamo, e
1038
+ * allora il costo si dice in LAVORO (ricerche, pagine, token) e mai in denaro: «denaro solo se
1039
+ * un prezzo pubblicato è stato ottenuto» (§6.8, +1.5).
1040
+ */
1041
+ prezzoFn = () => null,
1042
+ /*
1043
+ * ⭐⭐⭐ L5 (12/09/2026) — LA LETTURA DI UNA PAGINA, per la ri-verifica nel tempo.
1044
+ *
1045
+ * `leggiPaginaFn(url) => Promise<{url, stato, corpo}>`. Il default è **null**, e non è
1046
+ * pigrizia: qui dentro non si costruisce un secondo lettore del web. Quello vero esiste già
1047
+ * (`agent-service.leggiPaginaPerLaVista` → `leggiPaginaSicura` del kernel) e porta con sé la
1048
+ * validazione contro gli indirizzi interni che l'attrezzo `naviga` usa da mesi — allowlist di
1049
+ * schema, nessun indirizzo privato, catena di redirect limitata (OWASP «Server Side Request
1050
+ * Forgery Prevention Cheat Sheet», letta il 12/09/2026: «verify the value against an allowed
1051
+ * list of protocols (HTTP or HTTPS)», «Disable the support for the following of the
1052
+ * redirection … to prevent the bypass of the input validation»). Scriverne un secondo qui
1053
+ * vorrebbe dire due validazioni che divergono.
1054
+ * ⛔ Lo inietta `session-registry.mjs`, che è dove vive il cablaggio. Se resta `null` la
1055
+ * ri-verifica **lo dice** invece di provarci: una rotta che finge di aver guardato è peggio
1056
+ * di una che ammette di non poter guardare.
1057
+ */
1058
+ leggiPaginaFn = null,
1059
+ /*
1060
+ * ⭐⭐⭐⭐ BC-44 (12/09/2026) — le due porte della ripresa automatica, iniettabili come tutto
1061
+ * il resto e per una ragione precisa, non per abitudine:
1062
+ *
1063
+ * `dormiFn` l'attesa fra la caduta e il secondo tentativo. ⛔ Iniettabile perché un
1064
+ * test che aspettasse davvero venti secondi non è un test: misurerebbe
1065
+ * l'orologio. Il default non trattiene il processo (`unref`): una ricerca
1066
+ * caduta non deve tenere in piedi un server che sta chiudendo.
1067
+ * `ripresaAutomatica` l'interruttore. `true` di serie; a `false` il comportamento è quello di
1068
+ * ieri byte per byte — la classificazione e la ripresa A MANO restano,
1069
+ * perché quelle non spendono niente da sole.
1070
+ */
1071
+ dormiFn = (ms) => new Promise((risolvi) => { const t = setTimeout(risolvi, ms); t.unref?.(); }),
1072
+ ripresaAutomatica = true,
1073
+ clock = () => new Date(),
1074
+ }) {
1075
+ /*
1076
+ * ⭐⭐⭐ L4 — LA CACHE DEL GIUDIZIO SUL RAPPORTO, e perché `elenca()` adesso rilegge.
1077
+ *
1078
+ * ⛔ Il difetto che chiude, visto dall'owner: `elenca()` NON rileggeva i rapporti e `leggi()`
1079
+ * sì, quindi la ricerca `d2a453a8` compariva **«Conclusa» in lista** e
1080
+ * **«bloccata dal permesso» quando la si apriva**. Due viste sullo stesso fatto che si
1081
+ * contraddicono: la lista mentiva e il dettaglio la smentiva. L2 aveva chiamato quella
1082
+ * divergenza «voluta» per non pagare venti letture a ogni apertura della sezione — il costo
1083
+ * era vero, la conclusione no: si paga una volta e si mette in cache.
1084
+ *
1085
+ * ⛔ La chiave NON è l'id: è l'id PIÙ l'impronta del file (`mtimeMs`+`size`). Una cache a
1086
+ * chiave-id sola servirebbe un giudizio vecchio su un rapporto ri-depositato — cioè
1087
+ * riprodurrebbe in memoria la stessa bugia che sta togliendo dal disco. `null` (nessun
1088
+ * rapporto) è anch'esso un'impronta valida e si mette in cache come le altre: appena il file
1089
+ * nasce, l'impronta cambia e il giudizio si rifà.
1090
+ *
1091
+ * ⛔ E la mappa non cresce per sempre: oltre 200 voci si svuota tutta. Una LRU vera qui
1092
+ * sarebbe codice in più per un limite che nessun progetto reale tocca (20 ricerche per
1093
+ * cartella è già il tetto della pagina); quello che NON si può fare è lasciarla illimitata
1094
+ * dentro un processo che vive per giorni.
1095
+ */
1096
+ const giudiziRapporto = new Map();
1097
+ const TETTO_CACHE_GIUDIZI = 200;
1098
+ /*
1099
+ * ⛔ L5 — QUANTE PAGINE una sola ri-verifica ha il diritto di andare a riaprire. Non è
1100
+ * burocrazia: `talosResearchRecheckReport` gira **in sequenza** (lo dichiara: «una dozzina di
1101
+ * richieste simultanee è il modo in cui una connessione domestica e un sito di notizie
1102
+ * decidono entrambi che sei uno scraper»), quindi un rapporto con cinquanta fonti terrebbe
1103
+ * aperta una richiesta HTTP per minuti. Si guardano le prime venti e la risposta dice
1104
+ * `troncata: true` con quante erano in tutto — mai un silenzio che somigli a «erano venti».
1105
+ */
1106
+ const TETTO_FONTI_RIVERIFICA = 20;
1107
+ /**
1108
+ * ⭐ L4 — UN EVENTO NEL GIORNALE, e un guasto del giornale NON ferma la ricerca.
1109
+ *
1110
+ * ⛔ La scelta è deliberata e va detta: il giornale è la prova di ciò che è stato speso, non
1111
+ * la condizione perché si possa spendere. Se il disco è pieno, la ricerca deve continuare a
1112
+ * girare e a consegnare — perderemmo la ripresa, non il lavoro. L'errore si inghiotte **qui
1113
+ * e solo qui**, e questo è l'unico `catch` muto di questo file.
1114
+ */
1115
+ /*
1116
+ * ⭐⭐⭐ L4+L6 — LA CACHE DEL FETCH DI UNA CORSA, che sopravvive al riavvio.
1117
+ *
1118
+ * Una ricerca ripresa non deve ripagare le pagine che aveva già aperto: `fetch-cache.mjs`
1119
+ * (L6) sa tenerle e sa serializzarsi (`snapshot()`/`restore()`), ma dichiara di non scrivere
1120
+ * niente su disco — la persistenza è di chi orchestra, cioè di qui. La mappa tiene
1121
+ * l'istanza VIVA di una corsa; `cache.json` la tiene fra una vita e l'altra.
1122
+ *
1123
+ * ⛔⛔ DICHIARATO, non lasciato credere: **oggi nessuno riempie questa cache**. Il collettore
1124
+ * (`collector.mjs`) non è agganciato, e le pagine le apre il kernel con `naviga`, che non
1125
+ * passa di qui. ⇒ il giro completo salva-e-ripristina è provato nei due versi, ma sui dati
1126
+ * VERI l'istantanea di oggi è vuota. È il punto di innesto pronto, non un risparmio già
1127
+ * misurato: dire il contrario sarebbe vendere un numero che non esiste.
1128
+ */
1129
+ const cacheDelleRicerche = new Map();
1130
+
1131
+ /** @returns {number} quante voci sono rientrate dall'istantanea su disco (0 se non ce n'era una valida). */
1132
+ async function ripristinaCacheFetch({ cartella, id }) {
1133
+ const cache = creaCacheFetchFn();
1134
+ cacheDelleRicerche.set(id, cache);
1135
+ let rientrate = 0;
1136
+ try {
1137
+ /*
1138
+ * ⛔ Il VERSO CONTRARIO è già garantito dal modulo che possiede il formato: `restore()`
1139
+ * torna 0 su un'istantanea assente, di versione sconosciuta o malformata — «un formato
1140
+ * più nuovo non deve impedire a una ricerca di RIPARTIRE, al massimo la fa ripagare».
1141
+ * Qui NON si ricontrolla la versione: duplicarla creerebbe due numeri che divergono.
1142
+ */
1143
+ rientrate = cache.restore(await leggiIstantaneaCacheFn({ cartella, id })) ?? 0;
1144
+ } catch {
1145
+ rientrate = 0; // un'istantanea illeggibile costa una ri-lettura delle pagine, mai la ripresa.
1146
+ }
1147
+ /*
1148
+ * ⭐⭐⭐⭐ L9 — LA RACCOLTA SI RIMONTA QUI, e con lei il piano e l'indice delle fonti.
1149
+ *
1150
+ * ⛔ Senza queste tre righe una ricerca RIPRESA tornerebbe esattamente allo stato di ieri:
1151
+ * nessun passo nel giornale, nessuna spesa contata, nessun testo ritrovabile per la
1152
+ * verifica. Cioè la cura funzionerebbe solo finché il server non si riavvia — e una
1153
+ * ricerca lunga è proprio quella che il server riavviato interrompe.
1154
+ * ⛔ Il piano si rilegge dal DISCO (`piano.json`), non si ricalcola: ricalcolarlo darebbe
1155
+ * rami con lo stesso id ma, se un giorno il pianificatore col modello sarà acceso, con
1156
+ * domande diverse — e i passi già a registro punterebbero a linee che non esistono più.
1157
+ */
1158
+ const pianoSuDisco = await leggiPianoFn({ cartella, id });
1159
+ if (Array.isArray(pianoSuDisco) && pianoSuDisco.length > 0) pianiDelleRicerche.set(id, pianoSuDisco);
1160
+ const indice = await leggiIndiceFontiFn({ cartella, id });
1161
+ const mappa = new Map();
1162
+ for (const voce of Array.isArray(indice) ? indice : []) {
1163
+ if (typeof voce?.url === 'string') mappa.set(voce.url, voce);
1164
+ }
1165
+ indiciDelleFonti.set(id, mappa);
1166
+ montaRaccolta({ cartella, id, cache });
1167
+ return rientrate;
1168
+ }
1169
+
1170
+ /** Salva l'istantanea a un punto sicuro (pausa, conclusione). Un guasto qui non ferma niente. */
1171
+ async function salvaIstantaneaCache({ cartella, id }) {
1172
+ const cache = cacheDelleRicerche.get(id);
1173
+ if (!cache) return;
1174
+ try {
1175
+ await scriviIstantaneaCacheFn({ cartella, id, istantanea: cache.snapshot() });
1176
+ } catch { /* come il giornale: la cache è un risparmio, non una condizione per lavorare. */ }
1177
+ }
1178
+
1179
+ async function registra(cartella, id, evento) {
1180
+ try {
1181
+ await accodaEventoFn({ cartella, id, evento: { at: clock().toISOString(), ...evento }, ...(evento.kind === 'run_resumed' ? { separaRiga: true } : {}) });
1182
+ } catch { /* vedi sopra: un giornale che non si scrive non deve fermare una corsa già pagata. */ }
1183
+ }
1184
+
1185
+ /*
1186
+ * ⭐⭐⭐⭐ L9 — LA RACCOLTA DI UNA CORSA, e le due mappe che la tengono in piedi.
1187
+ *
1188
+ * `raccolteDelleRicerche` è l'oggetto che il kernel riceve come `cacheWeb`: tutto ciò che la
1189
+ * figlia cerca e apre passa di lì, e diventa un passo del giornale (vedi
1190
+ * `src/research/raccolta-viva.mjs`). Vive quanto la corsa, esattamente come
1191
+ * `cacheDelleRicerche`.
1192
+ *
1193
+ * `pianiDelleRicerche` tiene il piano APPROVATO in memoria, per l'attribuzione dei passi ai
1194
+ * rami. ⛔ Non è la fonte della verità: quella è `piano.json` su disco, ed è da lì che
1195
+ * `leggi()` e una ripresa lo rileggono. La copia in memoria esiste perché la raccolta deve
1196
+ * poterlo consultare a ogni ricerca senza una lettura di file per chiamata.
1197
+ *
1198
+ * `indiciDelleFonti` accumula l'indice url → `fonti/<sha256>.txt` e lo riscrive intero a ogni
1199
+ * fonte nuova. ⛔ Riscrittura atomica di tutta la mappa e non append: è una mappa, non un
1200
+ * registro, e due voci contraddittorie per lo stesso indirizzo sarebbero peggio di nessuna.
1201
+ */
1202
+ const raccolteDelleRicerche = new Map();
1203
+ const pianiDelleRicerche = new Map();
1204
+ const indiciDelleFonti = new Map();
1205
+
1206
+ /**
1207
+ * @param {{cartella: string, id: string, cache: object}} input
1208
+ * @returns {object} la raccolta viva di quella corsa
1209
+ */
1210
+ function montaRaccolta({ cartella, id, cache }) {
1211
+ const raccolta = creaRaccoltaViva({
1212
+ cache,
1213
+ registra: (evento) => registra(cartella, id, evento),
1214
+ tieniFonte: (testo) => scriviFonteFn({ cartella, id, testo }),
1215
+ annotaFonte: async (voce) => {
1216
+ const indice = indiciDelleFonti.get(id) ?? new Map();
1217
+ const gia = indice.get(voce.url);
1218
+ // ⛔ Una pagina APERTA non si lascia sostituire dal suo estratto: una prova più debole
1219
+ // non deve poter cancellare una più forte (stessa regola, in memoria, in raccolta-viva).
1220
+ if (gia?.ottenuta === 'page' && voce.ottenuta !== 'page') return;
1221
+ indice.set(voce.url, voce);
1222
+ indiciDelleFonti.set(id, indice);
1223
+ await scriviIndiceFontiFn({ cartella, id, voci: [...indice.values()] });
1224
+ },
1225
+ piano: () => pianiDelleRicerche.get(id) ?? [],
1226
+ });
1227
+ raccolteDelleRicerche.set(id, raccolta);
1228
+ return raccolta;
1229
+ }
1230
+
1231
+ /**
1232
+ * ⭐ La porta che `session-registry.mjs` passa al kernel. `null` per ogni sessione che NON è
1233
+ * una ricerca — e allora il kernel si comporta bit-per-bit come ieri: nessuna cache, nessun
1234
+ * budget, nessun passo nel giornale. È la garanzia che TALOS-BANCO non veda cambiare un byte.
1235
+ */
1236
+ function raccoltaDellaRicerca(id) {
1237
+ return raccolteDelleRicerche.get(id) ?? null;
1238
+ }
1239
+
1240
+ /**
1241
+ * ⭐⭐⭐⭐ L9 §6.8 (+1.5) — IL PIANO, E IL COSTO DETTO PRIMA.
1242
+ *
1243
+ * Tre passi, in quest'ordine, e ognuno ha una ragione che non è l'ordine alfabetico:
1244
+ * 1. `talosResearchPlanFor` dà i rami per PROFONDITÀ — 2 per «rapida», 4 per «approfondita»,
1245
+ * 6 per «esaustiva» — ognuno la stessa domanda vista da una faccia diversa, con la sua
1246
+ * stima di ricerche/pagine/token. ⛔ Deterministico: non costa un token, e un piano che
1247
+ * costa prima ancora di aver cercato qualcosa è il primo posto dove una corsa si allunga.
1248
+ * 2. `pianificaFn`, SE c'è, può riformulare le domande dei rami col modello. ⛔ Può cambiare
1249
+ * solo il TESTO: il numero dei rami resta quello della profondità (la persona ha scelto
1250
+ * quello) e le stime restano quelle calcolate — un modello che si stima da solo il costo
1251
+ * è un modello che dichiara quello che gli conviene. Un guasto qui NON ferma la corsa: si
1252
+ * tiene il piano deterministico e si va avanti.
1253
+ * 3. Il giornale registra `plan_proposed` e poi `plan_approved`, e il piano approvato va su
1254
+ * disco. ⛔⛔ L'approvazione oggi è AUTOMATICA e lo dice (`auto: true` nell'evento): il
1255
+ * pulsante con cui una persona toglie, aggiunge o riformula un ramo prima che parta è un
1256
+ * lotto di UI a parte, e non è questo. Scriverlo qui come se ci fosse sarebbe la bugia
1257
+ * che §6.5 esiste per togliere — due eventi distinti, invece, lasciano il posto già
1258
+ * pronto: quando il pulsante ci sarà, `plan_approved` arriverà da lui e `auto` sparirà.
1259
+ *
1260
+ * @returns {Promise<readonly object[]>}
1261
+ */
1262
+ async function costruisciPiano({ question, depth }) {
1263
+ /*
1264
+ * ⛔ La profondità si normalizza QUI e contro la tabella vera: `talosResearchPlanFor` legge
1265
+ * `TALOS_RESEARCH_DEPTHS[depth]` e su una parola sconosciuta esploderebbe DENTRO `avvia`,
1266
+ * cioè farebbe fallire l'avvio di una ricerca per una stringa arrivata da una rotta. Il
1267
+ * ripiego è `deep`, che è anche il default del prodotto.
1268
+ */
1269
+ const profondita = Object.hasOwn(TALOS_RESEARCH_DEPTHS, depth) ? depth : 'deep';
1270
+ const base = talosResearchPlanFor(question, profondita);
1271
+ if (typeof pianificaFn !== 'function') return base;
1272
+ try {
1273
+ const proposto = await pianificaFn({ question, depth: profondita, piano: base });
1274
+ if (!Array.isArray(proposto) || proposto.length === 0) return base;
1275
+ return base.map((ramo, i) => {
1276
+ const testo = typeof proposto[i] === 'string' ? proposto[i].trim()
1277
+ : typeof proposto[i]?.question === 'string' ? proposto[i].question.trim() : '';
1278
+ return testo ? { ...ramo, question: testo } : ramo;
1279
+ });
1280
+ } catch {
1281
+ /*
1282
+ * ⛔ Il piano deterministico è un ripiego COMPLETO, non degradato: è quello che il mobile
1283
+ * usa da sempre. Un pianificatore che cade costa una riformulazione, mai una corsa.
1284
+ */
1285
+ return base;
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Il costo ATTESO, in parole, prima di partire.
1291
+ *
1292
+ * ⛔ Lavoro sempre, denaro SOLO se un prezzo pubblicato è stato ottenuto (§6.8, +1.5). Un
1293
+ * «≈ 0,02 $» stampato su un prezzo indovinato è peggio di nessuna cifra: chi lo legge
1294
+ * decide con quello.
1295
+ * ⛔ E si dice che è una STIMA, con la parola. La spesa vera si conta dai passi e si mostra
1296
+ * accanto — «il divario stimato/speso è esso stesso una misura».
1297
+ */
1298
+ function costoDetto(piano) {
1299
+ const totali = talosResearchPlanTotals(piano);
1300
+ let prezzo = null;
1301
+ try { prezzo = prezzoFn(); } catch { prezzo = null; }
1302
+ const costo = talosResearchPlanCost(totali, prezzo);
1303
+ const denaro = costo?.known
1304
+ ? ` ≈ ${costo.amount.toFixed(4)} ${costo.currency} at the published price`
1305
+ : '';
1306
+ return {
1307
+ totali,
1308
+ costo,
1309
+ frase: `Planned: ${piano.length} lines of inquiry, an estimated ${totali.searches} search(es), `
1310
+ + `${totali.pages} page(s) and ~${totali.tokens} tokens${denaro}. `
1311
+ + 'Those are estimates made before starting; what is actually spent is counted step by step and shown next to them.',
1312
+ };
1313
+ }
1314
+
1315
+ /**
1316
+ * ⭐⭐⭐ L9 — IL TESTO TENUTO, PER INDIRIZZO, anche dopo un riavvio.
1317
+ *
1318
+ * Prima la memoria della corsa (gratis, e più fresca); poi il disco, via
1319
+ * `indice-fonti.json` + `fonti/<sha256>.txt`. ⛔ L'ordine conta: una corsa viva ha in memoria
1320
+ * anche le fonti che l'indice non ha ancora ricevuto (una scrittura fallita, un disco pieno),
1321
+ * e chiedere prima al disco le perderebbe.
1322
+ * ⛔ Se non c'è niente da nessuna parte la mappa è VUOTA, e la verifica lo dirà con il motivo
1323
+ * giusto («il passaggio non è nel testo della fonte») invece di inventare un verdetto.
1324
+ */
1325
+ async function testiTenutiPerUrl({ cartella, id }) {
1326
+ const mappa = new Map();
1327
+ const indice = await leggiIndiceFontiFn({ cartella, id });
1328
+ for (const voce of Array.isArray(indice) ? indice : []) {
1329
+ if (typeof voce?.url !== 'string' || typeof voce?.ref !== 'string') continue;
1330
+ const testo = await leggiFonteFn({ cartella, id, ref: voce.ref });
1331
+ if (typeof testo === 'string' && testo.length > 0) mappa.set(voce.url, testo);
1332
+ }
1333
+ const viva = raccolteDelleRicerche.get(id);
1334
+ if (viva) for (const [url, testo] of viva.testiPerUrl()) mappa.set(url, testo);
1335
+ return mappa;
1336
+ }
1337
+
1338
+ /**
1339
+ * ⭐⭐⭐ L4 — IL GIUDIZIO SUL RAPPORTO, **UNA SOLA VOLTA E IN UN SOLO POSTO**.
1340
+ *
1341
+ * La chiamano `elenca()` e `leggi()`, e questo è il punto: prima ce n'erano due di fatto —
1342
+ * l'elenco che non guardava niente e il dettaglio che guardava — e le due viste si
1343
+ * contraddicevano a schermo. Una funzione sola non può contraddirsi.
1344
+ *
1345
+ * Ordine di lettura, e il perché di ognuno:
1346
+ * 1. il file depositato (`.harness-ui-research/<id>/rapporto.md`) — la via di oggi;
1347
+ * 2. la voce di Libreria, **solo** se il file non c'è — la via di ieri, l'unica che hanno
1348
+ * le ricerche già su disco. Costa una lettura in più ed è per questo che è seconda.
1349
+ *
1350
+ * ⛔ `ripiegoConsentito` si ricava dal campo `formato` della voce, non da un'euristica: una
1351
+ * ricerca nata oggi (`formato: 2`) DEVE portare il record; una nata prima non può, e non le
1352
+ * si chiede l'impossibile.
1353
+ * ⛔ E il file su disco **non si riscrive mai**: la correzione vive nella lettura. È la stessa
1354
+ * forma di «TRE RIPETIZIONI PAGATE, UNA USATA» (22/8), dove la cura stava nel lettore.
1355
+ *
1356
+ * @returns {Promise<{stato:'done'|'senza-rapporto', motivoDettaglio:string|null, contenutoRapporto:string|null, letto:object|null}>}
1357
+ */
1358
+ /**
1359
+ * ⭐⭐⭐⭐ L9 §6.8 (+1.2 · +1.3) — LA VERIFICA VERA, PRIMA DEL DEPOSITO.
1360
+ *
1361
+ * ═══════════════════════════════════════════════════════════════════════════
1362
+ * Che cosa cambia rispetto a ieri, in una riga
1363
+ * ═══════════════════════════════════════════════════════════════════════════
1364
+ * L8 aveva tolto al modello la possibilità di timbrare sé stesso: `judge: null` e
1365
+ * `claimSupported: 'unchecked'` non erano più argomenti dell'attrezzo, li metteva il server.
1366
+ * Giusto, e a metà: il risultato era che **nessuno** timbrava, e il giro vero del 12/09 è
1367
+ * uscito con 38 affermazioni su 38 «non verificate». `unchecked` onesto è meglio di un
1368
+ * verdetto falso, ma non è il prodotto — il prodotto è il verdetto VERO.
1369
+ *
1370
+ * ⇒ Qui, fra il «il modello ha chiamato `research_deposit`» e il «il file è sul disco», i tre
1371
+ * livelli girano davvero:
1372
+ * L1 la fonte citata esiste fra quelle raccolte, e com'è stata ottenuta (pagina/estratto);
1373
+ * L2 il passaggio citato si RITROVA nel testo tenuto della pagina — `talosResearchLocate`,
1374
+ * nessuna frase simile, nessuna approssimazione gentile: «un verificatore che
1375
+ * gentilmente trova un'approssimazione è un verificatore che fabbrica attribuzioni»;
1376
+ * L3 un GIUDICE, che non è l'autore, dice se quel passaggio da solo sostiene
1377
+ * l'affermazione; e se ha detto sì o in parte, si va a CERCARE la contraria.
1378
+ *
1379
+ * ⛔⛔ IL GIUDICE NON È L'AUTORE, ed è la ragione per cui un `modelloGiudice` sta sulla
1380
+ * metadata fin dalla nascita della ricerca. Misura, non opinione: Panickssery, Bowman e
1381
+ * Feng, «LLM Evaluators Recognize and Favor Their Own Generations» (arXiv:2404.13076,
1382
+ * 15/04/2024, letta il 12/09/2026) — gli LLM riconoscono i propri testi, e «a linear
1383
+ * correlation between self-recognition capability and the strength of self-preference bias».
1384
+ * ⛔ Se non c'è nessun altro modello, `judge: null` e **lo dice**: `talosResearchVerify`
1385
+ * scrive il motivo per esteso, e il bilancio conta quelle affermazioni fra le «non
1386
+ * verificate» — mai fra le sostenute.
1387
+ *
1388
+ * ⛔ E il passaggio dichiarato NON è una prova finché non lo si ritrova: «even the best models
1389
+ * lack complete citation support 50% of the time» (Gao et al., arXiv:2305.14627, benchmark
1390
+ * ALCE, letta il 12/09/2026). È metà delle citazioni: esattamente il motivo per cui il testo
1391
+ * delle pagine si TIENE, e per cui questo controllo non è un lusso.
1392
+ *
1393
+ * ⛔ Un guasto della verifica NON butta il deposito. Se il giudice non risponde, se il disco
1394
+ * non dà il testo, se qualsiasi cosa cade: si torna al documento **senza verdetti**, che è
1395
+ * ciò che sarebbe stato depositato ieri. Perdere il rapporto pagato per far fallire il suo
1396
+ * controllo sarebbe il guasto introdotto dalla cura, cioè il peggiore.
1397
+ *
1398
+ * @returns {Promise<object>} lo stesso contratto di `componiRapportoRicerca`, più `bilancio`,
1399
+ * `giudice`, `proveDistinte` e `fedelta` quando la verifica è girata.
1400
+ */
1401
+ async function componiRapporto(argomenti) {
1402
+ if (argomenti.parte !== undefined) {
1403
+ return depositaParteRapporto(argomenti, {
1404
+ leggiGiornaleFn, accodaEventoFn, leggiRapportoFn, clock,
1405
+ valida: unito => componiRapportoRicerca({ domanda: argomenti.domanda, ...unito }),
1406
+ finalizza: unito => verificaEComponiRapporto({ ...argomenti, ...unito }),
1407
+ });
1408
+ }
1409
+ return verificaEComponiRapporto(argomenti);
1410
+ }
1411
+
1412
+ async function verificaEComponiRapporto({ cartella, id, domanda, testo, affermazioni, fonti }) {
1413
+ /*
1414
+ * ⛔ Il testo tenuto si cerca solo se abbiamo un id di ricerca: `research_deposit` fuori da
1415
+ * una ricerca non esiste (il kernel lo rifiuta a monte), ma questa funzione è anche il
1416
+ * `componiRapportoRicercaFn` di una sessione qualunque — e un id assente deve dare
1417
+ * esattamente il comportamento di ieri, non un errore.
1418
+ */
1419
+ let testiPerUrl = null;
1420
+ if (typeof id === 'string' && id.length > 0 && typeof cartella === 'string' && cartella.length > 0) {
1421
+ try { testiPerUrl = await testiTenutiPerUrl({ cartella, id }); } catch { testiPerUrl = null; }
1422
+ }
1423
+ const composto = componiRapportoRicerca({ domanda, testo, affermazioni, fonti, testiPerUrl });
1424
+ if (!composto.ok) return composto;
1425
+ if (!testiPerUrl) return composto;
1426
+
1427
+ let record = null;
1428
+ try { record = await leggiRicercaFn({ cartella, id }); } catch { record = null; }
1429
+ const modelloGiudice = typeof record?.modelloGiudice === 'string' && record.modelloGiudice.trim()
1430
+ ? record.modelloGiudice.trim() : null;
1431
+ /*
1432
+ * ⛔ La DOPPIA condizione, e nessuna delle due è ridondante: serve un modello giudice
1433
+ * scelto alla nascita (che è già «diverso dall'autore» per costruzione, via
1434
+ * `talosResearchPickJudge`) E una porta per parlargli. Senza la seconda il giudice
1435
+ * esisterebbe sulla carta e ogni chiamata cadrebbe, cioè `unchecked` con un motivo
1436
+ * sbagliato («il giudice non ha risposto» invece di «non ce n'era uno»).
1437
+ */
1438
+ const giudice = modelloGiudice && typeof chiediAlModelloFn === 'function'
1439
+ ? { id: modelloGiudice, provider: 'openrouter', model: modelloGiudice }
1440
+ : null;
1441
+
1442
+ const passo = 'verifica:verify';
1443
+ await registra(cartella, id, { kind: 'step_started', stepId: passo, branchId: 'verifica', stepKind: 'verify' });
1444
+ let caratteri = 0;
1445
+ /** @param {string} prompt */
1446
+ const chiedi = async (prompt) => {
1447
+ caratteri += prompt.length;
1448
+ const risposta = await chiediAlModelloFn({ modello: modelloGiudice, prompt, scopo: 'giudice-ricerca' });
1449
+ const detto = typeof risposta === 'string' ? risposta : String(risposta ?? '');
1450
+ caratteri += detto.length;
1451
+ return detto;
1452
+ };
1453
+
1454
+ let verificati = null;
1455
+ try {
1456
+ verificati = await talosResearchVerify({
1457
+ judge: giudice,
1458
+ ask: (claim, passaggio) => chiedi(talosResearchJudgePrompt(claim, passaggio)),
1459
+ /*
1460
+ * ⭐ §6.8 (+1.3) — LA CONTRARIA SI CERCA APPOSTA, e solo dove ha senso cercarla:
1461
+ * `talosResearchVerify` la chiede soltanto dopo un «sì» o un «in parte», perché
1462
+ * «la contesa è disaccordo: senza un accordo prima non c'è niente con cui essere in
1463
+ * disaccordo». ⛔ Senza giudice non si chiede: `askOpposing` resta `undefined`, e il
1464
+ * modulo salta il giro invece di pagarlo per niente.
1465
+ */
1466
+ ...(giudice ? { askOpposing: (claim, passaggio) => chiedi(talosResearchOpposingPrompt(claim, passaggio)) } : {}),
1467
+ at: () => clock().toISOString(),
1468
+ }, composto.claims.map((c) => c.claim), composto.sources);
1469
+ } catch {
1470
+ verificati = null;
1471
+ }
1472
+
1473
+ if (!verificati) {
1474
+ await registra(cartella, id, {
1475
+ kind: 'step_failed', stepId: passo, error: 'la verifica non è girata: il rapporto è stato depositato senza verdetti',
1476
+ });
1477
+ return composto;
1478
+ }
1479
+
1480
+ /*
1481
+ * ⭐ §6.8 (+1.2) — LE PROVE SI CONTANO A GRUPPI, NON A URL. «7 prove distinte su 14
1482
+ * indirizzi» dice una cosa che «14 fonti» non dice, e la regola sta in un posto solo
1483
+ * (`independence.mjs`), pubblicata e verificabile.
1484
+ */
1485
+ const origini = composto.sources.map((s) => ({ url: s.url }));
1486
+ const indipendenza = talosResearchIndependentSources(origini);
1487
+ const fedelta = talosResearchFidelity({ claims: verificati, sources: origini });
1488
+ const bilancio = talosResearchVerifiedStanding(verificati);
1489
+
1490
+ const documento = talosResearchReportDocument({
1491
+ question: composto.intestazione,
1492
+ summary: String(testo).trim(),
1493
+ // ⛔ Il nome del giudice nel rapporto, così il lettore lo possa PESARE: un giudice su un
1494
+ // fornitore diverso e uno sullo stesso fornitore non valgono uguale, e il rapporto deve
1495
+ // dire quale dei due è stato.
1496
+ judge: giudice?.id ?? null,
1497
+ claims: verificati,
1498
+ sources: composto.sources,
1499
+ });
1500
+
1501
+ await registra(cartella, id, {
1502
+ kind: 'step_finished', stepId: passo,
1503
+ // ⛔ La spesa della verifica è VERA e va contata: è la parte del costo che nessun
1504
+ // concorrente ha, e nasconderla farebbe sembrare gratis la cosa che ci distingue.
1505
+ spend: { searches: 0, pages: 0, tokens: Math.ceil(caratteri / 4) },
1506
+ resultRef: null,
1507
+ });
1508
+
1509
+ return {
1510
+ ...composto,
1511
+ documento,
1512
+ bilancio,
1513
+ giudice: giudice?.id ?? null,
1514
+ proveDistinte: indipendenza.independent,
1515
+ fedelta,
1516
+ verificate: verificati.filter((v) => v.checks.judge !== null).length,
1517
+ };
1518
+ }
1519
+
1520
+ async function giudicaRapporto({ cartella, record }) {
1521
+ const id = record.id;
1522
+ const chiave = `${cartella}::${id}`;
1523
+ const impronta = await statRapportoFn({ cartella, id });
1524
+ const inCache = giudiziRapporto.get(chiave);
1525
+ const stessaImpronta = inCache
1526
+ && ((inCache.impronta === null && impronta === null)
1527
+ || (inCache.impronta && impronta && inCache.impronta.mtimeMs === impronta.mtimeMs && inCache.impronta.size === impronta.size));
1528
+ if (stessaImpronta) return inCache.esito;
1529
+
1530
+ const ripiegoConsentito = !(Number(record.formato) >= 2);
1531
+ let testo = await leggiRapportoFn({ cartella, id });
1532
+ /*
1533
+ * ⛔ `daDeposito` distingue «il modello ha consegnato» da «esiste una copia in Libreria da
1534
+ * una vita precedente», e serve a una cosa sola ma importante: al momento della
1535
+ * conclusione, «non ha depositato» ha tre diagnosi diverse (permesso, giri, altro) e non
1536
+ * deve essere confuso con «ha depositato una cosa che non passa». Senza questo campo una
1537
+ * ricerca vecchia ripresa avrebbe perso la diagnosi `bloccata-dal-permesso`.
1538
+ */
1539
+ const daDeposito = testo !== null && testo !== undefined;
1540
+ if (!daDeposito && record.reportLibraryId && leggiVoceLibreriaFn) {
1541
+ try {
1542
+ const voce = await leggiVoceLibreriaFn({ cartella, id: record.reportLibraryId });
1543
+ testo = voce?.testo ?? null;
1544
+ } catch {
1545
+ testo = null; // il rapporto è dichiarato pronto ma illeggibile ORA — onesto, mai un crash.
1546
+ }
1547
+ }
1548
+ const letto = (testo === null || testo === undefined) ? null : rileggiRapportoFn(testo, { ripiegoConsentito });
1549
+ const esito = letto?.ok
1550
+ ? { stato: 'done', motivoDettaglio: null, contenutoRapporto: testo, contenutoRespinto: null, letto, daDeposito }
1551
+ : {
1552
+ stato: 'senza-rapporto',
1553
+ daDeposito,
1554
+ motivoDettaglio: letto?.motivo ?? 'non c\'è nessun file di rapporto',
1555
+ /*
1556
+ * ⛔⛔ `contenutoRapporto` resta NULL quando il cancello dice di no — e il testo respinto
1557
+ * esce da un'altra porta, `contenutoRespinto`. Due nomi perché sono due cose: se la
1558
+ * scusa da 290 byte uscisse dal campo che si chiama «il rapporto», il frontend la
1559
+ * disegnerebbe come tale e avremmo rifatto il guasto dell'11/09 dentro la sua cura.
1560
+ * ⛔ Ma non si butta: un rapporto che non porta il record è comunque il prodotto di una
1561
+ * corsa pagata, e nasconderlo perderebbe 484.171 token di lavoro per una forma
1562
+ * mancante. Il cancello decide lo STATO; non decide cosa si può leggere.
1563
+ */
1564
+ contenutoRapporto: null,
1565
+ contenutoRespinto: (testo === null || testo === undefined) ? null : testo,
1566
+ letto,
1567
+ };
1568
+ if (giudiziRapporto.size >= TETTO_CACHE_GIUDIZI) giudiziRapporto.clear();
1569
+ giudiziRapporto.set(chiave, { impronta, esito });
1570
+ return esito;
1571
+ }
1572
+
1573
+ /**
1574
+ * ⭐⭐⭐ L2 §6.5 — IL CANCELLO DI CONSEGNA.
1575
+ *
1576
+ * Prima d'oggi questa funzione faceva due cose: prendeva l'ultimo messaggio con del testo e,
1577
+ * se `comeFinita === 'concluso'`, scriveva `terminata:'done'`. Cioè decideva sul PROCESSO
1578
+ * («la corsa è finita da sola») e raccontava il PRODOTTO («c'è un rapporto»). L'11/09 le due
1579
+ * cose divergevano e la sezione mostrava «Conclusa» su 290 byte di scusa.
1580
+ *
1581
+ * Adesso l'ordine è questo, e ogni ramo ha un nome suo:
1582
+ * pausa/annullamento → invariato (una pausa non finalizza niente)
1583
+ * rapporto depositato e RILEGGIBILE → 'done'
1584
+ * rapporto depositato ma non rileggibile/vuoto → 'senza-rapporto'
1585
+ * niente rapporto + un REFUSED di permesso → 'bloccata-dal-permesso'
1586
+ * niente rapporto + giri finiti → 'giri-esauriti'
1587
+ * niente rapporto, nient'altro → 'failed'
1588
+ *
1589
+ * ⛔ `'done'` non si può più ottenere con una scusa: serve un artefatto che si rilegga.
1590
+ * ⛔ L'ordine NON è arbitrario: il rapporto si guarda PER PRIMO, prima di `comeFinita`. Una
1591
+ * ricerca che deposita un rapporto valido e poi esaurisce i giri ha consegnato — e dirle
1592
+ * «giri esauriti» butterebbe via una consegna vera. È l'errore opposto di quello di
1593
+ * stasera, e va evitato con la stessa cura.
1594
+ */
1595
+ async function onConclusioneRicerca({ cartella, id, risultato }) {
1596
+ const voceSessione = sessioni.get(id);
1597
+ const richiesta = voceSessione?._ricercaTerminataRichiesta ?? null;
1598
+ // ⛔ SEMPRE azzerato qui, su OGNI conclusione — mai lasciato sporco per il giro successivo (vedi la doc di testa: pausa→ripresa→conclusione naturale non deve essere scambiata per una seconda pausa).
1599
+ if (voceSessione) voceSessione._ricercaTerminataRichiesta = null;
1600
+ if (richiesta === 'paused') {
1601
+ /*
1602
+ * ⭐ L4 — «fermo» si registra, e non è la stessa riga di «chiesto di fermarsi» (quella
1603
+ * l'ha scritta `mettiInPausa`). `run.mjs` tiene separati `pause_requested` e `paused`
1604
+ * «perché in mezzo c'è del denaro»: il passo in volo viene drenato prima del punto sicuro.
1605
+ * Senza questa riga, un giornale rigiocato direbbe che la ricerca stava ancora fermandosi.
1606
+ */
1607
+ await registra(cartella, id, { kind: 'run_paused' });
1608
+ // ⭐ L6 — il punto sicuro è ANCHE il punto in cui si salva ciò che è già stato scaricato.
1609
+ await salvaIstantaneaCache({ cartella, id });
1610
+ return; // terminata resta null: resumable, il bucket "paused" lo deriva statoVivo() dal vivo.
1611
+ }
1612
+ if (richiesta === 'cancelled') {
1613
+ await aggiornaRicercaFn({ cartella, id, terminata: 'cancelled', conclusaAlle: clock().toISOString() });
1614
+ await registra(cartella, id, { kind: 'run_cancelled' });
1615
+ // ⛔ Nessuna istantanea su un annullamento: `cancelled` è terminale e non si riprende mai
1616
+ // (`run.mjs`), quindi scrivere un file che nessuno rileggerà sarebbe solo disco sporcato.
1617
+ cacheDelleRicerche.delete(id);
1618
+ return;
1619
+ }
1620
+ const record = await leggiRicercaFn({ cartella, id });
1621
+ const domanda = record?.domanda ?? 'research';
1622
+ const messaggi = risultato?.esito?.messaggiFinali;
1623
+ /*
1624
+ * ⛔ L'ultimo messaggio si conserva SEMPRE, anche quando è una scusa — ma come ALLEGATO,
1625
+ * in un campo che si chiama `ultimoMessaggio`. Buttarlo sarebbe perdere la diagnosi (la
1626
+ * scusa del 11/09 dice esattamente cosa è andato storto); chiamarlo «rapporto» era la
1627
+ * bugia. Troncato: è una frase da mostrare, non un documento da custodire.
1628
+ */
1629
+ const ultimoMessaggio = (ultimoMessaggioDelModello(messaggi) ?? '').slice(0, 2_000) || null;
1630
+ /*
1631
+ * ⭐⭐⭐ BC-44 — `motivoErrore: null` sta in `comune`, cioè si AZZERA su ogni conclusione che
1632
+ * non sia un `failed`. Senza, una ricerca caduta per un timeout e poi ripresa fino a
1633
+ * `done` si porterebbe dietro per sempre il motivo di una caduta che è già stata curata —
1634
+ * e la sezione mostrerebbe «riprendibile» su un rapporto consegnato.
1635
+ */
1636
+ const comune = { conclusaAlle: clock().toISOString(), ultimoMessaggio, motivoErrore: null };
1637
+ /*
1638
+ * ⭐ L6 — una conclusione è un punto sicuro come la pausa, e vale ANCHE per i guasti:
1639
+ * `bloccata-dal-permesso` e `giri-esauriti` sono proprio i casi che si riprendono, e chi
1640
+ * riprende non deve ripagare le pagine. L'istantanea si salva PRIMA di decidere lo stato,
1641
+ * così nessun ramo di ritorno anticipato può saltarla.
1642
+ */
1643
+ await salvaIstantaneaCache({ cartella, id });
1644
+ cacheDelleRicerche.delete(id);
1645
+
1646
+ /*
1647
+ * ⭐⭐⭐ L4 — LO STESSO CANCELLO DI `elenca()` E `leggi()`, non una terza copia.
1648
+ * ⛔ La cache si invalida da sola: `giudicaRapporto` chiave sull'impronta del file, e qui il
1649
+ * file è appena stato depositato ⇒ impronta nuova ⇒ giudizio rifatto. Nessuna riga di
1650
+ * invalidazione a mano, cioè nessuna riga da ricordarsi di scrivere la prossima volta.
1651
+ */
1652
+ const giudizio = record ? await giudicaRapporto({ cartella, record }) : null;
1653
+ /*
1654
+ * ⛔ Alla CONCLUSIONE conta il deposito, non una copia di Libreria ereditata: se il modello
1655
+ * non ha depositato, la domanda giusta è «perché» (permesso? giri?) e le tre diagnosi
1656
+ * sotto sono l'unica risposta utile. Un rapporto che passa il cancello vale comunque —
1657
+ * anche se arriva dalla Libreria di una vita precedente — perché consegnare è consegnare.
1658
+ */
1659
+ const testoRapporto = (giudizio?.daDeposito || giudizio?.letto?.ok) ? (giudizio.contenutoRapporto ?? giudizio.contenutoRespinto ?? null) : null;
1660
+ if (testoRapporto !== null && testoRapporto !== undefined) {
1661
+ const letto = giudizio.letto;
1662
+ if (letto?.ok) {
1663
+ /*
1664
+ * ⛔ La voce di Libreria si scrive DAL RAPPORTO VERO, non dall'ultimo messaggio: è la
1665
+ * stessa Libreria di prima (i rapporti di ricerca SONO file di Libreria, vedi la doc
1666
+ * di testa di research-store.mjs), ma adesso il contenuto è l'artefatto depositato.
1667
+ */
1668
+ let reportLibraryId;
1669
+ try {
1670
+ /*
1671
+ * ⭐ BC-38 (12/09/2026) — da QUALE sessione nasce questo rapporto. `id` È il sessionId
1672
+ * della sessione che esegue la ricerca (doc di testa di questo file, riga 21): non c'è
1673
+ * niente da dedurre, si passa quello che già si ha. Senza, il dettaglio della Libreria
1674
+ * direbbe «sessione non registrata» sul file che più di tutti ha una sessione sua.
1675
+ * ⛔ Solo l'id: il NOME leggibile della sessione lo risolve chi disegna, con l'elenco
1676
+ * vivo delle sessioni — una ricerca rinominata domani deve leggersi col nome di domani.
1677
+ */
1678
+ reportLibraryId = await salvaVoceLibreriaFn({ cartella, sessionId: id, nome: nomeRapporto(letto.intestazione || domanda), mediaType: 'text/markdown', origine: 'generated', testo: testoRapporto });
1679
+ } catch {
1680
+ /*
1681
+ * ⛔ Il rapporto ESISTE su disco: non è «senza rapporto». È solo la copia di Libreria
1682
+ * che non si è potuta scrivere — e il posto vero del rapporto è la sua cartella, non
1683
+ * la Libreria. Quindi `'done'` con `reportLibraryId: null`, non un falso fallimento:
1684
+ * ciò che è costato denaro non si dichiara perso perché una copia non è riuscita.
1685
+ */
1686
+ await aggiornaRicercaFn({ cartella, id, terminata: 'done', reportLibraryId: null, ...comune });
1687
+ await registra(cartella, id, { kind: 'run_finished' });
1688
+ return;
1689
+ }
1690
+ await aggiornaRicercaFn({ cartella, id, terminata: 'done', reportLibraryId, ...comune });
1691
+ await registra(cartella, id, { kind: 'run_finished' });
1692
+ return;
1693
+ }
1694
+ await aggiornaRicercaFn({ cartella, id, terminata: 'senza-rapporto', motivoDettaglio: letto?.motivo ?? null, ...comune });
1695
+ return;
1696
+ }
1697
+
1698
+ // Nessun rapporto depositato: si dice PERCHÉ, e i tre perché si curano in modo diverso.
1699
+ if (trovaRifiutoDiPermesso(messaggi)) {
1700
+ await aggiornaRicercaFn({ cartella, id, terminata: 'bloccata-dal-permesso', ...comune });
1701
+ return;
1702
+ }
1703
+ if (risultato?.esito?.comeFinita === 'giri-esauriti') {
1704
+ await aggiornaRicercaFn({ cartella, id, terminata: 'giri-esauriti', ...comune });
1705
+ return;
1706
+ }
1707
+ /*
1708
+ * ⭐⭐⭐⭐ BC-44 (12/09/2026) — L'ULTIMO RAMO SMETTE DI ESSERE MUTO.
1709
+ *
1710
+ * Fino a ieri qui si scriveva `failed` e basta, e da lì in poi nessuno poteva più sapere se
1711
+ * quella corsa fosse caduta per un guasto di rete di dieci secondi o per un guasto vero. La
1712
+ * riga `{"type":"RunError","message":"Upstream idle timeout exceeded","code":"internal-error"}`
1713
+ * era già sul disco della SESSIONE, e la ricerca non la leggeva: due file, nessun ponte.
1714
+ *
1715
+ * ⛔ Il codice si legge PRIMA del messaggio solo quando dice davvero qualcosa: `internal-error`
1716
+ * è il default di `agent-service.mjs` per ogni guasto del servizio, quindi lì decide la
1717
+ * frase del fornitore. `comeFinita` entra al suo posto quando la corsa è tornata con un
1718
+ * esito invece che con un'eccezione (`fermato`), perché quello È il codice di quel caso.
1719
+ */
1720
+ const caduta = classificaErroreDiCorsa({
1721
+ codice: typeof risultato?.codiceErrore === 'string' && risultato.codiceErrore
1722
+ ? risultato.codiceErrore
1723
+ : (risultato?.esito?.comeFinita ?? null),
1724
+ messaggio: typeof risultato?.erroreInterno === 'string' ? risultato.erroreInterno : null,
1725
+ });
1726
+ const motivoErrore = {
1727
+ classe: caduta.classe,
1728
+ transitorio: caduta.transitorio,
1729
+ codice: typeof risultato?.codiceErrore === 'string' ? risultato.codiceErrore : null,
1730
+ // ⛔ Troncato: è una diagnosi da rileggere, non un documento da custodire — e un fornitore può rispondere con una pagina HTML intera.
1731
+ messaggio: typeof risultato?.erroreInterno === 'string' ? risultato.erroreInterno.slice(0, 500) : null,
1732
+ };
1733
+ await aggiornaRicercaFn({ cartella, id, terminata: 'failed', ...comune, motivoErrore });
1734
+ /*
1735
+ * ⛔⛔ L'ORDINE NON È DI COMODO: lo stato onesto si scrive PRIMA di aspettare. Se il processo
1736
+ * muore durante l'attesa, sul disco resta un `failed` con la sua causa — cioè una ricerca
1737
+ * che una persona può riprendere a mano. Se aspettassimo prima di scrivere, un crash nel
1738
+ * mezzo lascerebbe una ricerca senza stato e senza motivo.
1739
+ */
1740
+ if (caduta.transitorio) await riprendiDaSolaUnaVolta({ cartella, id, caduta });
1741
+ }
1742
+
1743
+ /*
1744
+ * ⭐⭐⭐⭐ BC-44 — LA RIPRESA AUTOMATICA, e i quattro cancelli che la tengono onesta.
1745
+ *
1746
+ * Perché esiste: Anthropic, «How we built our multi-agent research system» (letta 12/09/2026)
1747
+ * — «we can't just restart from the beginning: restarts are expensive and frustrating for
1748
+ * users … we built systems that can resume from where the agent was when the errors occurred»,
1749
+ * con «deterministic safeguards like retry logic and regular checkpoints». Il checkpoint qui è
1750
+ * il giornale; questa funzione è il «retry logic» che finora mancava del tutto.
1751
+ *
1752
+ * ⛔ E perché è così stretta: riprendere COSTA. Non è ritentare una chiamata — è riaprire una
1753
+ * corsa che può spendere venti minuti. Quindi quattro cancelli, e ognuno toglie un caso in
1754
+ * cui la ripresa sarebbe uno spreco o una prepotenza:
1755
+ * 1. **transitoria** (deciso da chi chiama): un guasto deterministico si ripeterebbe uguale;
1756
+ * 2. **c'è lavoro da salvare** — almeno un `step_finished` nel giornale. Una corsa caduta
1757
+ * al primo respiro non ha niente da riprendere: ripartire non salverebbe nulla e
1758
+ * spenderebbe il doppio;
1759
+ * 3. **UNA SOLA VOLTA** — e il tetto è per RICERCA, non per giro: si guarda tutto il
1760
+ * giornale, non solo dopo l'ultimo `run_started`. È il più stretto dei due letture
1761
+ * possibili, scelto apposta: se due riprese automatiche non bastano, la terza è una
1762
+ * decisione di una persona, non di un timer;
1763
+ * 4. **lo stato si rilegge DOPO l'attesa**: in quei secondi qualcuno può aver annullato,
1764
+ * eliminato o ripreso a mano quella ricerca, e ripartirci sopra sarebbe scrivere
1765
+ * addosso a una scelta appena presa.
1766
+ *
1767
+ * ⛔ La riga nel giornale la scrive `riprendi()` e porta `auto:true` e la `causa`: senza, un
1768
+ * giornale rigiocato direbbe che a riprendere è stata una persona — e il cancello (3), che
1769
+ * quella riga la legge, non avrebbe più nessun tetto da far rispettare.
1770
+ */
1771
+ async function riprendiDaSolaUnaVolta({ cartella, id, caduta }) {
1772
+ if (!ripresaAutomatica) return false;
1773
+ let eventi = [];
1774
+ try { ({ eventi } = await leggiGiornaleFn({ cartella, id })); } catch { return false; }
1775
+ if (!eventi.some((e) => e?.kind === 'step_finished' || e?.kind === 'deposit_part')) return false;
1776
+ if (eventi.some((e) => e?.kind === 'run_resumed' && e.auto === true)) return false;
1777
+ try { await dormiFn(ATTESA_RIPRESA_AUTOMATICA_MS); } catch { return false; }
1778
+ const adesso = await leggiRicercaFn({ cartella, id });
1779
+ if (!adesso || adesso.terminata !== 'failed') return false;
1780
+ const esito = await riprendi({ id, automatica: caduta.classe });
1781
+ return Boolean(esito?.ok);
1782
+ }
1783
+
1784
+ /*
1785
+ * ⛔⛔ NESSUN `run_finished` sui tre rami di guasto, ed è una scelta, non una dimenticanza.
1786
+ * `talosResearchApply` porta `run_finished` a `status:'done'`, cioè a uno stato TERMINALE
1787
+ * da cui `talosResearchNextStep` non restituisce più niente: scriverlo su una ricerca
1788
+ * bloccata dal permesso o rimasta senza giri la renderebbe **non riprendibile** nel giornale,
1789
+ * pur essendo esattamente il caso che deve potersi riprendere. La metadata dice com'è finita;
1790
+ * il giornale dice che il lavoro è ancora dovuto. Le due cose non si contraddicono: rispondono
1791
+ * a due domande diverse (`run.mjs`, `talosResearchWorkLeft`: «i due rispondono a domande
1792
+ * diverse e servono entrambi»).
1793
+ */
1794
+
1795
+ /**
1796
+ * Avvia — torna `{ok, esito, id}` SUBITO, mai atteso il .then() (vedi
1797
+ * la doc di testa). L'id è generato QUI, PRIMA di chiamare
1798
+ * avviaESeguiFn, e la riga di metadata è scritta PRIMA che la sessione
1799
+ * parta: elimina per costruzione la race in cui una conclusione
1800
+ * fulminea (un mock nei test, o un modello istantaneo) potrebbe far
1801
+ * scattare onConclusioneRicerca prima che il file esista.
1802
+ *
1803
+ * ⛔⛔⛔ Trovato dal vivo (30/8), non da lettura: le prime versioni di
1804
+ * questa funzione tornavano solo `{id}` — il DISPATCH del kernel per
1805
+ * `research_start` (talosHarness.mjs) si aspetta lo STESSO contratto
1806
+ * `{ok,esito}` degli altri 5 mutanti (`String(risultato?.esito ??
1807
+ * (risultato?.ok ? 'started' : 'failed'))`): senza `ok`/`esito`,
1808
+ * `risultato?.ok` era `undefined` (falsy) e il messaggio mostrato al
1809
+ * modello era SEMPRE "failed" — anche quando la ricerca era
1810
+ * DAVVERO partita (confermato dal `research_list` immediatamente
1811
+ * successivo, nello stesso giro, che la mostrava "running"). Un
1812
+ * bug che NESSUN test a unità poteva vedere: i test del kernel
1813
+ * mockano `onRicercaAvvia` con la forma già corretta, i test di
1814
+ * `session-registry.mjs` verificano il wiring ma non il messaggio
1815
+ * finale — solo una sessione VERA, con un modello VERO, l'ha
1816
+ * mostrato.
1817
+ */
1818
+ /*
1819
+ * ⭐⭐⭐⭐ L8 (12/09/2026) — LA FIGLIA EREDITA IL MODELLO DELLA MADRE.
1820
+ *
1821
+ * Il guasto, misurato sul giro vero e non dedotto: la chat `c8e9b07b` girava con
1822
+ * `z-ai/glm-5.3-flash`, ha chiamato `research_start`, e la figlia `3029dea2` è partita con
1823
+ * `z-ai/glm-4.7-flash` — il modello di serie del server (`config.mjs:25`,
1824
+ * `MODELLI_AMMESSI[0]`). Le due intestazioni nello store lo dicono alla lettera
1825
+ * (`.sessions-store/<id>.jsonl`, riga 1, campo `modello`). `avvia()` non passava nessun
1826
+ * modello, quindi `avviaESegui` ricadeva sul default: `modelloEffettivo = modelIdEffettivo ||
1827
+ * modelloRichiesta || voceEsistente?.modello || modello` (`session-registry.mjs`), e i primi
1828
+ * tre erano tutti assenti.
1829
+ *
1830
+ * ⛔ Tre conseguenze, tutte e tre vere insieme:
1831
+ * 1. la regola dell'owner «giri reali SOLO con glm-5.3-flash» era violata DAL PRODOTTO, non
1832
+ * da chi lo usa: nessuna schermata permetteva di scegliere il modello della ricerca;
1833
+ * 2. la cache non poteva prendere. OpenRouter, «Prompt Caching» (letto 12/09/2026):
1834
+ * «Sticky routing is tracked at the account level, **per model**, and per conversation» —
1835
+ * un modello diverso è un'altra chiave di cache, e infatti il giro ha misurato
1836
+ * **265.670 token dentro con `cached_tokens: 0`**;
1837
+ * 3. il rapporto lo scriveva un modello che la persona non ha scelto — cioè la ricerca
1838
+ * approfondita, la parte più cara del prodotto, girava sul modello più economico
1839
+ * proprio dove la qualità conta di più.
1840
+ *
1841
+ * ⛔ La cura è UN PASSAGGIO DI PARAMETRO, ed è per questo che nessun test la vedeva: non
1842
+ * c'era nessun ramo sbagliato da far scattare, c'era un argomento assente. Un test che
1843
+ * monta l'orchestratore e guarda cosa arriva ad `avviaESeguiFn` è l'unico che morde.
1844
+ *
1845
+ * ⛔⛔ E il modello NON si eredita quando la madre gira su un runtime LOCALE: lì
1846
+ * `voce.modello` è l'id di un GGUF sul disco (`modelId`), non un modello di OpenRouter, e
1847
+ * la figlia parte comunque `provider:'cloud'` (questa funzione non passa né `provider` né
1848
+ * `runtimeId`). Passarglielo trasformerebbe l'eredità in un guasto garantito alla prima
1849
+ * chiamata. Il filtro sta in `session-registry.mjs`, dove il provider si conosce.
1850
+ */
1851
+ async function avvia({ cartella, question, depth, padreId = null, modello = null, reasoning = null }) {
1852
+ const id = randomUUIDFn();
1853
+ const nome = nomeDallaDomanda(question);
1854
+ /*
1855
+ * ⭐⭐⭐⭐ L9 — IL GIUDICE SI SCEGLIE ADESSO, prima ancora che la corsa parta.
1856
+ *
1857
+ * ⛔ «Chiunque tranne l'autore», e l'autore è il modello di QUESTA corsa: la scelta è di
1858
+ * `talosResearchPickJudge`, qui si dice solo chi era disponibile. Il perché è misurato e
1859
+ * vecchio: Panickssery, Bowman e Feng, «LLM Evaluators Recognize and Favor Their Own
1860
+ * Generations» (arXiv:2404.13076, 15/04/2024, letta il 12/09/2026) — gli LLM riconoscono
1861
+ * i propri testi e li premiano, con «a linear correlation between self-recognition
1862
+ * capability and the strength of self-preference bias».
1863
+ * ⛔ `null` è una risposta vera e va scritta come tale: nessun altro modello ammesso ⇒
1864
+ * nessun giudizio, e il rapporto lo dichiara. Mai l'autore che timbra sé stesso.
1865
+ */
1866
+ const autore = { id: modello ?? 'autore', provider: 'openrouter', model: modello ?? '' };
1867
+ let candidati = [];
1868
+ try { candidati = modelliGiudiceFn({ autore }) ?? []; } catch { candidati = []; }
1869
+ const giudiceScelto = talosResearchPickJudge(autore, candidati);
1870
+ await creaRicercaFn({
1871
+ cartella, id, domanda: question, profondita: depth || 'deep', padreId, nome, modello,
1872
+ modelloGiudice: giudiceScelto?.model ?? giudiceScelto?.id ?? null,
1873
+ });
1874
+ /*
1875
+ * ⭐⭐⭐ L4 — LA PRIMA RIGA DEL GIORNALE, e l'ordine conta.
1876
+ *
1877
+ * Scritta PRIMA di `avviaESeguiFn`, per la stessa ragione per cui la metadata lo è: una
1878
+ * conclusione fulminea (un mock, o un modello istantaneo) non deve poter scrivere
1879
+ * `run_finished` su un giornale che non ha ancora il suo `run_started`. `talosResearchApply`
1880
+ * su un giornale che comincia senza `run_started` torna `null` a ogni evento — cioè un
1881
+ * giro che non si può rigiocare, che è esattamente il guasto da cui tutto questo nasce.
1882
+ *
1883
+ * ⛔ `engine: 'device'` non è una bugia sul desktop: è il valore che `run.mjs` usa per «gira
1884
+ * qui, in locale» contro `'cloud'` (R1b, la migrazione su server). Qui gira in locale.
1885
+ */
1886
+ await registra(cartella, id, {
1887
+ kind: 'run_started', id, sessionId: id, question, depth: depth || 'deep', engine: 'device',
1888
+ });
1889
+ // ⭐ L6 — la cache di QUESTA corsa nasce qui e vive finché la corsa vive (vedi `cacheDelleRicerche`).
1890
+ const cache = creaCacheFetchFn();
1891
+ cacheDelleRicerche.set(id, cache);
1892
+ /*
1893
+ * ⭐⭐⭐⭐ L9 — IL PIANO, PRIMA CHE LA FIGLIA PARLI.
1894
+ *
1895
+ * ⛔ L'ordine è quello e non un altro: piano → `plan_proposed` → `piano.json` →
1896
+ * `plan_approved` → consegna → `avviaESegui`. La consegna PORTA le linee d'indagine
1897
+ * (vedi `promptRicerca`), quindi il piano dev'essere pronto prima che la sessione
1898
+ * esista; e il giornale deve avere i due eventi prima che un passo possa arrivarci,
1899
+ * altrimenti il replay vedrebbe un `step_started` su un giro ancora in `planning`.
1900
+ * ⛔ `plan_approved` porta `auto: true` — il campo non esiste in `run.mjs` e non gli serve
1901
+ * (l'`apply` ignora ciò che non conosce, per costruzione), ma chi rilegge il giornale
1902
+ * deve poter distinguere «approvato da una persona» da «approvato perché il pulsante non
1903
+ * c'è ancora». È un DEBITO DICHIARATO, non un silenzio.
1904
+ */
1905
+ const piano = await costruisciPiano({ question, depth });
1906
+ pianiDelleRicerche.set(id, piano);
1907
+ montaRaccolta({ cartella, id, cache });
1908
+ await registra(cartella, id, { kind: 'plan_proposed', branches: piano });
1909
+ try { await scriviPianoFn({ cartella, id, piano }); } catch { /* come il giornale: il piano su disco è una prova, non una condizione per lavorare. */ }
1910
+ await registra(cartella, id, { kind: 'plan_approved', branches: piano, auto: true });
1911
+ const costo = costoDetto(piano);
1912
+ avviaESeguiFn({
1913
+ sessionId: id, cartella, taskId: 'ricerca',
1914
+ /*
1915
+ * ⛔⛔⛔ L1 — `ricercaId` VIAGGIA DENTRO IL TASK, e questa è la riga che rende sicuro
1916
+ * `research_deposit`. Il kernel costruisce il percorso del rapporto da qui
1917
+ * (`.harness-ui-research/<ricercaId>/rapporto.md`), non da un argomento del modello: il
1918
+ * modello non vede mai questo valore e non può quindi scegliere DOVE depositare.
1919
+ * ⛔ Dentro `task` e non in un parametro nuovo di `avviaESegui` per una ragione precisa:
1920
+ * `task` è persistito nell'intestazione della sessione (`session-registry.mjs`), quindi
1921
+ * sopravvive a un riavvio del server e a un resume. Un parametro in più si sarebbe
1922
+ * perso alla prima ripresa, e il deposito avrebbe smesso di funzionare proprio nel caso
1923
+ * in cui la ricerca è più lunga.
1924
+ */
1925
+ /*
1926
+ * ⭐ L8 — `ricercaDomanda` viaggia accanto a `ricercaId`, e per la stessa ragione: il
1927
+ * kernel deve poter mettere la DOMANDA dentro il record del rapporto
1928
+ * (`record.question`, il campo che il cancello legge come `intestazione`) senza
1929
+ * chiederla al modello, che potrebbe riscriverla. Dentro `task` perché `task` è
1930
+ * persistito nell'intestazione della sessione e sopravvive a un riavvio e a un resume.
1931
+ */
1932
+ task: { consegna: promptRicerca(question, depth, piano), ricercaId: id, ricercaDomanda: question },
1933
+ /*
1934
+ * ⭐⭐⭐ L8 — il modello e il reasoning della MADRE. `null` = «non passato», e
1935
+ * `avviaESegui` ricade sul default esattamente come prima: l'eredità è additiva, non
1936
+ * cambia il comportamento di chi non la usa (TALOS-BANCO, i test, le riprese).
1937
+ */
1938
+ modelloRichiesta: modello ?? null,
1939
+ reasoningRichiesto: reasoning ?? null,
1940
+ /*
1941
+ * ⛔⛔⛔ L1 §6.3 — `'Research'`, non più `'Read only'` scritto a mano.
1942
+ *
1943
+ * La riga di prima era giusta nell'intenzione («la ricerca non deve MAI scrivere nel
1944
+ * progetto ospite») e sbagliata nella conseguenza, che nessuno aveva visto: una sessione
1945
+ * che non può scrivere NON PUÒ CONSEGNARE. L'11/09 la ricerca `d2a453a8` ha speso
1946
+ * 484.171 token, aperto 14 pagine, e ha salvato come rapporto la frase con cui si
1947
+ * scusava di non poterlo scrivere.
1948
+ *
1949
+ * `'Research'` nega tutto esattamente come `'Read only'` — tranne il deposito del
1950
+ * proprio rapporto, dentro la propria cartella. L'intenzione originale resta intatta;
1951
+ * quello che cambia è che adesso esiste una via per consegnare.
1952
+ */
1953
+ permessiRichiesti: 'Research',
1954
+ /*
1955
+ * ⭐ §6.6 — la ricerca è FIGLIA della chat che l'ha ordinata. Prima `padreId` era `null`
1956
+ * e nell'albero sessione la ricerca non compariva sotto nessuno: all'owner è sembrata
1957
+ * «una sessione nuova», e lo era davvero anche nel registro.
1958
+ * ⛔ `profonditaDelega` resta 0 (il default): una ricerca non è una delega, e contarla
1959
+ * come tale consumerebbe il tetto di profondità dei sotto-agenti.
1960
+ */
1961
+ padreId,
1962
+ onConclusioneFn: (risultato) => onConclusioneRicerca({ cartella, id, risultato }),
1963
+ });
1964
+ /*
1965
+ * ⭐ §6.6 — il nome, sulla voce di sessione appena creata. `avviaESegui` non ha un
1966
+ * parametro `nome` e non glielo aggiungo da qui: la voce esiste già al ritorno (è creata
1967
+ * in modo sincrono, prima del primo `await`), quindi si scrive direttamente.
1968
+ * ⛔ DEBITO DICHIARATO, non nascosto: questo nome vive in memoria e non passa da
1969
+ * `registro.rinomina()`, quindi NON sopravvive a un riavvio del server. Il nome che
1970
+ * sopravvive è quello sulla metadata della ricerca (`creaRicercaFn`, campo `nome`), che
1971
+ * è quello che la sezione legge. Chiuderlo del tutto vuole una riga `nome-sessione` nel
1972
+ * registro, cioè toccare `session-registry.mjs` fuori dal perimetro di questo lotto.
1973
+ */
1974
+ const voceSessione = sessioni.get(id);
1975
+ if (voceSessione && !voceSessione.nome) voceSessione.nome = nome;
1976
+ return {
1977
+ ok: true,
1978
+ /*
1979
+ * ⭐⭐⭐⭐ L9 §6.8 (+1.5) — IL COSTO SI DICE PRIMA, a chi ha chiesto la ricerca.
1980
+ *
1981
+ * ⛔ Non è cortesia: una corsa multi-agente costa «about 15× more tokens than chats»
1982
+ * (Anthropic, «How we built our multi-agent research system», letta il 12/09/2026), e
1983
+ * un ordine di grandezza scoperto dopo non è una misura — è un conto. La frase dice
1984
+ * LAVORO (ricerche, pagine, token) e dice denaro **solo** se un prezzo pubblicato è
1985
+ * stato ottenuto; e dice, con la parola, che sono stime.
1986
+ */
1987
+ esito: `Started the research «${question}» (id ${id}). It runs in the background and keeps going even if the app is closed. ${costo.frase}`,
1988
+ id,
1989
+ piano,
1990
+ costoAtteso: costo.totali,
1991
+ };
1992
+ }
1993
+
1994
+ function mettiInPausa({ id }) {
1995
+ const voce = sessioni.get(id);
1996
+ if (!voce) return { ok: false, esito: 'There is no research with that id. Call research_list to see the current ones.' };
1997
+ if (voce.conclusa) {
1998
+ return { ok: false, esito: 'That research is not running: it may already be paused, cancelled or done. Call research_list to see how it stands.' };
1999
+ }
2000
+ voce._ricercaTerminataRichiesta = 'paused';
2001
+ voce.controller.abort();
2002
+ /*
2003
+ * ⭐ L4 — `run_pause_requested`: l'INTENZIONE, registrata adesso. Il `run_paused` lo scrive
2004
+ * `onConclusioneRicerca` quando il punto sicuro è raggiunto. ⛔ Non atteso (`mettiInPausa` è
2005
+ * sincrona per contratto col kernel): se la riga non arriva, la pausa resta comunque vera
2006
+ * nella metadata — il giornale è la prova, non la condizione.
2007
+ */
2008
+ registra(voce.cartella, id, { kind: 'run_pause_requested' });
2009
+ return { ok: true, esito: 'That research is paused. Everything it collected is kept, and it can be resumed.' };
2010
+ }
2011
+
2012
+ function annulla({ id }) {
2013
+ const voce = sessioni.get(id);
2014
+ if (!voce) return { ok: false, esito: 'There is no research with that id. Call research_list to see the current ones.' };
2015
+ if (voce.conclusa) {
2016
+ // ⭐ una ricerca già ferma (in pausa, o già conclusa) si annulla lo stesso: cambia solo la metadata (terminata:'cancelled'), nessun abort da fare — mai un rifiuto per un caso che mobile stesso permette (research_cancel su una "paused"/"unfinished").
2017
+ return aggiornaRicercaFn({ cartella: voce.cartella, id, terminata: 'cancelled' })
2018
+ .then(() => registra(voce.cartella, id, { kind: 'run_cancelled' }))
2019
+ .then(() => ({ ok: true, esito: 'That research is stopped for good. What it collected is still readable.' }));
2020
+ }
2021
+ voce._ricercaTerminataRichiesta = 'cancelled';
2022
+ voce.controller.abort();
2023
+ return { ok: true, esito: 'That research is stopped for good. What it collected is still readable.' };
2024
+ }
2025
+
2026
+ /**
2027
+ * ⭐⭐⭐ L4 §6.6 — LA RIPRESA VERA, DAL GIORNALE E NON DALLA MEMORIA.
2028
+ *
2029
+ * ⛔ Cosa faceva prima, e perché era poco: riprendeva la CONVERSAZIONE (`voce.messaggiFinali`)
2030
+ * e rifiutava quando quella mancava — cioè **dopo ogni riavvio del server**, con un messaggio
2031
+ * che diceva «start a new one». Rifarla da capo costa di nuovo tutto: sulla ricerca
2032
+ * dell'11/09 sarebbero stati 484.171 token di ingresso, 9 ricerche e 14 pagine, ripagati per
2033
+ * un processo morto.
2034
+ *
2035
+ * Adesso ci sono DUE vie, in quest'ordine, e la prima è la migliore quando c'è:
2036
+ *
2037
+ * A. **la conversazione è ancora in memoria** ⇒ si riprende quella, com'è sempre stato.
2038
+ * È superiore perché il modello ritrova il proprio contesto esatto, non un riassunto.
2039
+ * B. **la conversazione non c'è più (riavvio), ma il giornale sì** ⇒ si rigioca
2040
+ * `giornale.jsonl` con `talosResearchReplay`, si deducono i passi rimasti in volo con
2041
+ * `talosResearchRecover` («un evento che nessuno è vivo per aggiungere è una bugia nel
2042
+ * giornale»), e si riparte **dal passo dopo l'ultimo committato** con una consegna che
2043
+ * dice al modello dove eravamo e cosa manca.
2044
+ *
2045
+ * ⛔ Una ricerca il cui giornale è già TERMINALE non si riprende, e non è un dettaglio:
2046
+ * `run.mjs` rifiuta `run_resumed` da uno stato terminale «perché cancellato vuol dire
2047
+ * cancellato, e una ripresa che lo riaprisse spenderebbe denaro su un giro che la persona ha
2048
+ * chiuso». Qui la stessa regola si fa rispettare **prima** di spendere, non dentro il replay.
2049
+ *
2050
+ * ⛔⛔ E la GUARDIA fra le due vie legge il GIORNALE, non il registro vivo (cura del 12/09,
2051
+ * dettaglio per esteso accanto alla riga): una ricerca in pausa sopravvissuta a un riavvio
2052
+ * torna `conclusa: true` ⇒ `interrotta: false`, e la vecchia guardia la rifiutava con
2053
+ * «still running» — cioè negava la ripresa proprio a chi la pausa l'aveva chiesta.
2054
+ *
2055
+ * ⛔ Niente `cartella` fra gli argomenti: arriva da `voce.cartella`, che dopo un riavvio
2056
+ * `ripristina()` rimette a posto dall'intestazione della sessione. Un parametro nuovo
2057
+ * avrebbe voluto una riga in `session-registry.mjs` fuori dal perimetro di questo lotto.
2058
+ */
2059
+ /*
2060
+ * ⭐⭐⭐⭐ BC-44 — le due righe che riaprono una corsa, e perché sono DUE.
2061
+ *
2062
+ * `terminata: null` rimette la ricerca fra le vive: `statoVivo()` legge PRIMA `terminata`, e
2063
+ * senza questa riga una ricerca ripresa continuerebbe a mostrarsi `failed` mentre gira davvero
2064
+ * — lo schermo direbbe il contrario del disco.
2065
+ * ⛔ `motivoErrore: null` insieme, e non dopo: il motivo di una caduta superata è una frase che
2066
+ * non descrive più niente. Se la corsa ricadrà, `onConclusioneRicerca` ne scriverà una nuova.
2067
+ * ⛔ Vale per ENTRAMBE le vie (conversazione in memoria e giornale): la via A non ci passava, e
2068
+ * una ripresa dalla memoria dopo un errore transitorio avrebbe lasciato `failed` per sempre.
2069
+ */
2070
+ async function riapriLaMetadata(cartella, id) {
2071
+ try { await aggiornaRicercaFn({ cartella, id, terminata: null, motivoErrore: null }); }
2072
+ catch { /* la metadata potrebbe essere stata eliminata mentre riprendevamo: la corsa riparte comunque, ed è il giornale la prova di ciò che è stato fatto. */ }
2073
+ }
2074
+
2075
+ async function riprendi({ id, automatica = null }) {
2076
+ const voce = sessioni.get(id);
2077
+ if (!voce) return { ok: false, esito: 'There is no research with that id. Call research_list to see the current ones.' };
2078
+ const cartella = voce.cartella;
2079
+ /* ⛔ `auto` e `causa` solo quando la ripresa è davvero automatica: una riga che dicesse `auto:false` su ogni ripresa a mano sarebbe rumore, e il cancello della ripresa automatica legge proprio `auto === true`. */
2080
+ const rigaDiRipresa = automatica ? { kind: 'run_resumed', auto: true, causa: automatica } : { kind: 'run_resumed' };
2081
+
2082
+ /*
2083
+ * ⭐⭐⭐⭐ BC-44 — IL CANCELLO NUOVO, e sta PRIMA dei due vecchi perché risponde a una domanda
2084
+ * che quelli non sanno nemmeno porsi: «questa corsa è finita, e come?».
2085
+ *
2086
+ * Il caso vero (ricerca `dec896c0`, 12/09): `terminata:'failed'`, la sessione in memoria c'è
2087
+ * ancora ed è `conclusa` ma NON `interrotta` (quel campo lo scrive solo `ripristina()`, cioè
2088
+ * solo dopo un riavvio) ⇒ la guardia «né in pausa né interrotta» concludeva **«That research
2089
+ * is still running: nothing to resume»**. Falsa due volte: non stava girando, ed era caduta
2090
+ * un minuto prima. Da lì il 409 della rotta.
2091
+ *
2092
+ * ⛔ Si legge il DISCO e non il registro vivo, per la stessa ragione già imparata sulla
2093
+ * pausa: `conclusa` dice «quel GIRO è finito», non «quella RICERCA è finita». La seconda ha
2094
+ * una risposta sola, e sta in `meta.json`.
2095
+ * ⛔ Questo cancello AGGIUNGE un permesso, non ne toglie nessuno, ed è scritto in due pezzi
2096
+ * apposta per garantirlo:
2097
+ * · `done`/`cancelled` diventano un NO esplicito. Non è una restrizione nuova: il
2098
+ * giornale li rifiutava già da terminale (`run_finished`/`run_cancelled`) sulla via B.
2099
+ * Quello che cambia è che adesso il no vale anche sulla **via A** — dove non c'era
2100
+ * nessun controllo e una ricerca già consegnata poteva ripartire. Buco preesistente,
2101
+ * chiuso qui perché è la stessa domanda.
2102
+ * · gli altri (`senza-rapporto`, `bloccata-dal-permesso`, `giri-esauriti`) NON si
2103
+ * toccano: restano esattamente com'erano, e la guardia più sotto decide per loro.
2104
+ * ⛔ Un `failed` SENZA causa registrata — cioè ogni ricerca caduta prima di oggi — si
2105
+ * comporta come ieri: `motivoErrore` assente ⇒ nessun permesso nuovo.
2106
+ */
2107
+ const record = await leggiRicercaFn({ cartella, id });
2108
+ /* ⛔ La causa REGISTRATA se c'è, altrimenti quella DEDOTTA dal `RunError` che la sessione ha già in `voce.eventi`: senza la seconda, la cura non curerebbe nessuna delle ricerche già cadute — compresa quella che l'ha fatta scrivere. */
2109
+ const cadutaRiprendibile = record?.terminata === 'failed' && causaDellaCaduta(record, voce)?.transitorio === true;
2110
+ if (record?.terminata === 'done' || record?.terminata === 'cancelled') {
2111
+ return { ok: false, esito: `That research is ${record.terminata} and will not be resumed: start a new one if you need more.` };
2112
+ }
2113
+
2114
+ // BC-49: anche la ripresa con conversazione deve riconciliare le conferme dal disco.
2115
+ const { eventi, righeSaltate } = await leggiGiornaleFn({ cartella, id, rigoroso: true });
2116
+ let deposito;
2117
+ try { deposito = consegnaPartiRapporto(eventi); }
2118
+ catch { return { ok: false, esito: 'Le parti conservate non superano il controllo di integrità. Ripristina il giornale prima di riprendere.' }; }
2119
+ if (voce.messaggiFinali) {
2120
+ await riapriLaMetadata(cartella, id);
2121
+ await registra(cartella, id, rigaDiRipresa);
2122
+ avviaESeguiFn({
2123
+ sessionId: id, taskId: voce.taskId, cartella, task: voce.task, comandoProva: voce.comandoProva,
2124
+ messaggiIniziali: [...voce.messaggiFinali, { role: 'user', content: `${PROMPT_RIPRESA}\n${deposito}` }],
2125
+ forkDa: voce.forkDa, voceEsistente: voce,
2126
+ onConclusioneFn: (risultato) => onConclusioneRicerca({ cartella, id, risultato }),
2127
+ });
2128
+ return { ok: true, esito: 'That research is running again, from where it had stopped.' };
2129
+ }
2130
+
2131
+ /*
2132
+ * ⛔⛔⛔⭐⭐⭐ 12/09/2026 — IL GIORNALE SI LEGGE **PRIMA** DELLA GUARDIA, e non è un riordino
2133
+ * di comodo: è la cura di un difetto che rifiutava esattamente il caso per cui la pausa
2134
+ * esiste.
2135
+ *
2136
+ * Com'era, e cosa faceva. La guardia chiedeva `voce.interrotta`, cioè un campo del registro
2137
+ * VIVO, e `ripristina()` lo calcola così: `interrotta: !conclusa`. Una ricerca messa in
2138
+ * PAUSA conclude il suo giro (il punto sicuro emette `RunFinished`), quindi dopo un riavvio
2139
+ * torna `conclusa: true` ⇒ `interrotta: false` ⇒ questa riga rispondeva
2140
+ * **«That research is still running: nothing to resume»**. Falsa due volte: non stava
2141
+ * girando — era ferma perché qualcuno l'aveva fermata — e il rifiuto colpiva **l'unico caso
2142
+ * che la pausa serve a creare**.
2143
+ *
2144
+ * ⛔ Perché il registro vivo non può saperlo, per costruzione: `conclusa` dice «quel GIRO è
2145
+ * finito», non «quella RICERCA è finita». Sono due domande diverse, e la seconda ha una
2146
+ * risposta sola sul disco — `run_paused` nel giornale, che un riavvio non cancella. In
2147
+ * produzione il difetto era **mascherato**: dopo un riavvio `messaggiFinali` viene
2148
+ * ripristinato dal JSONL e la via A prende il comando; si vede solo quando quella manca,
2149
+ * cioè quando il processo è morto prima di persistere la conversazione — che è, di nuovo,
2150
+ * proprio il caso disperato.
2151
+ *
2152
+ * ⇒ Adesso lo stato lo dice il GIORNALE. Tre risposte, in quest'ordine, e ognuna per una
2153
+ * ragione sua:
2154
+ * 1. giornale TERMINALE (`done`/`cancelled`/`failed`) → mai: «cancellato vuol dire
2155
+ * cancellato» (`run.mjs`), e riaprirlo spenderebbe denaro su un giro chiuso;
2156
+ * 2. né in pausa secondo il giornale, né interrotta secondo il registro → sta davvero
2157
+ * girando, e non c'è niente da riprendere. ⛔ La seconda metà della condizione RESTA,
2158
+ * e deve: un processo morto a metà giro non lascia nessun evento («un evento che
2159
+ * nessuno è vivo per aggiungere è una bugia nel giornale»), quindi lì l'unico a
2160
+ * saperlo è il registro. Le due fonti non si sostituiscono, si sommano;
2161
+ * 3. nessun giornale → la verità di prima, e nessun `run_started` inventato adesso.
2162
+ *
2163
+ * ⛔ `pause_requested` conta come «in pausa» quanto `paused`: se il processo è morto fra la
2164
+ * richiesta e il punto sicuro, la persona aveva comunque premuto Pausa — e rifiutarle la
2165
+ * ripresa perché il giro non ha fatto in tempo a scrivere la seconda riga sarebbe punirla
2166
+ * per un crash.
2167
+ */
2168
+ const giro = talosResearchReplay(eventi);
2169
+ const inPausa = Boolean(giro) && (giro.status === 'paused' || giro.status === 'pause_requested');
2170
+ if (giro && talosResearchIsTerminal(giro.status)) {
2171
+ return { ok: false, esito: `That research is ${giro.status} and will not be resumed: start a new one if you need more.` };
2172
+ }
2173
+ /*
2174
+ * ⭐⭐⭐⭐ BC-44 — la TERZA fonte, e si SOMMA alle due, non le sostituisce.
2175
+ * · il giornale sa che era in pausa;
2176
+ * · il registro vivo sa che il processo è morto a metà giro (nessun evento da scrivere);
2177
+ * · la METADATA sa che la corsa è finita con un guasto, e con quale — è l'unica delle tre
2178
+ * che poteva rispondere per la ricerca `dec896c0`, dove il giro era finito in modo
2179
+ * ordinato (RunError ⇒ `conclusa:true`) e nessuna delle altre due vedeva niente.
2180
+ */
2181
+ if (!inPausa && !voce.interrotta && !cadutaRiprendibile) return { ok: false, esito: 'That research is still running: nothing to resume.' };
2182
+
2183
+ // Via B — dal giornale. Da qui in poi la conversazione non esiste più: esiste il registro.
2184
+ if (!giro) {
2185
+ /*
2186
+ * ⛔ Nessun giornale (una ricerca nata prima dell'11/09) o un giornale che non comincia con
2187
+ * `run_started`: non c'è niente da cui ripartire, e si dice la verità di prima. ⛔ Non si
2188
+ * inventa un `run_started` adesso per «sistemare» il file: sarebbe scrivere nel registro
2189
+ * un fatto che nessuno ha osservato.
2190
+ */
2191
+ return { ok: false, esito: 'That research was interrupted by a server restart and has no journal to resume from: start a new one.' };
2192
+ }
2193
+ const recuperato = talosResearchRecover(giro, clock().toISOString());
2194
+ const prossimo = talosResearchNextStep(recuperato);
2195
+ const rimasti = talosResearchWorkLeft(recuperato);
2196
+ const speso = talosResearchSpent(recuperato);
2197
+ const fonti = await elencaFontiFn({ cartella, id });
2198
+ const ripristinateDallaCache = await ripristinaCacheFetch({ cartella, id });
2199
+
2200
+ await riapriLaMetadata(cartella, id);
2201
+ await registra(cartella, id, rigaDiRipresa);
2202
+ if (prossimo) {
2203
+ /*
2204
+ * ⛔ Il passo che era IN VOLO quando il processo è morto viene ri-annunciato come iniziato:
2205
+ * `talosResearchApply` su `step_started` di un passo già `done` non fa niente («già
2206
+ * pagato: ricominciarlo è l'errore che tutto questo file esiste per rendere
2207
+ * impossibile»), quindi questa riga non può far ripagare un passo concluso.
2208
+ */
2209
+ await registra(cartella, id, { kind: 'step_started', stepId: prossimo.id, branchId: prossimo.branchId, stepKind: prossimo.kind });
2210
+ }
2211
+ avviaESeguiFn({
2212
+ sessionId: id, taskId: voce.taskId, cartella, task: voce.task, comandoProva: voce.comandoProva,
2213
+ messaggiIniziali: [{ role: 'user', content: consegnaDiRipresa({ giro: recuperato, prossimo, rimasti, speso, fonti, task: voce.task, deposito }) }],
2214
+ forkDa: voce.forkDa, voceEsistente: voce,
2215
+ onConclusioneFn: (risultato) => onConclusioneRicerca({ cartella, id, risultato }),
2216
+ });
2217
+ return {
2218
+ ok: true,
2219
+ esito: `That research is running again from its journal (${eventi.length} recorded events${righeSaltate ? `, ${righeSaltate} unreadable lines skipped` : ''}${ripristinateDallaCache ? `, ${ripristinateDallaCache} cached pages restored` : ''}). It restarts from the step after the last committed one.`,
2220
+ };
2221
+ }
2222
+
2223
+ async function rinomina({ cartella, id, title }) {
2224
+ const aggiornata = await aggiornaRicercaFn({ cartella, id, titolo: title });
2225
+ if (!aggiornata) return { ok: false, esito: 'There is no research with that id. Call research_list to see the current ones.' };
2226
+ // ⭐ L4 — `run_renamed` è uno degli undici eventi: un giro rigiocato deve riprendere anche il suo nome, non solo il suo stato.
2227
+ await registra(cartella, id, { kind: 'run_renamed', title: title ?? null });
2228
+ return {
2229
+ ok: true,
2230
+ esito: title === null ? 'That research shows its question again.' : `Renamed that research to «${title}».`,
2231
+ };
2232
+ }
2233
+
2234
+ async function elimina({ cartella, id }) {
2235
+ const record = await leggiRicercaFn({ cartella, id });
2236
+ if (!record) return { ok: true, esito: 'There was no research with that id — nothing to delete.' };
2237
+ if (record.reportLibraryId && eliminaVoceLibreriaFn) {
2238
+ try { await eliminaVoceLibreriaFn({ cartella, id: record.reportLibraryId }); } catch { /* il rapporto potrebbe già essere sparito dalla Libreria per un'altra via (library_delete diretto) — non blocca l'eliminazione della ricerca. */ }
2239
+ }
2240
+ await eliminaRicercaFn({ cartella, id });
2241
+ return { ok: true, esito: 'That research and its report have been deleted.' };
2242
+ }
2243
+
2244
+ /**
2245
+ * ⭐⭐⭐⭐ L5 §6.8 «+1.1» — «DICE ANCORA QUESTO?»
2246
+ *
2247
+ * La domanda non è «il link risponde»: un soft 404 e una pagina riscritta in silenzio
2248
+ * rispondono **200**. La domanda è *quello che abbiamo letto è ancora lì*, e si può porre solo
2249
+ * perché il record del rapporto tiene il **passaggio** citato, non solo l'URL.
2250
+ *
2251
+ * Tre cancelli prima di spendere una sola richiesta HTTP, e ognuno risponde una cosa diversa:
2252
+ * 1. la ricerca non esiste → `{trovata:false}` (la rotta fa 404)
2253
+ * 2. non c'è un record verificabile → `{ok:false, motivo}` (409, e il motivo lo dice)
2254
+ * 3. non c'è niente di misurabile → `{ok:false, motivo}` (409)
2255
+ *
2256
+ * ⛔ Il caso (2) comprende le ricerche VECCHIE, quelle passate col ripiego in prosa: hanno un
2257
+ * rapporto vero e pagato, ma non hanno i passaggi, quindi non c'è niente da ri-trovare. Dire
2258
+ * «ricontrollate, tutto a posto» su quelle sarebbe la bugia più facile di tutta la funzione.
2259
+ * ⛔ Nessun evento nel giornale. Il giornale è la prova di ciò che la CORSA ha speso
2260
+ * (`run.mjs` conosce undici `kind` e li elenca nel suo typedef): una ri-verifica fatta
2261
+ * settimane dopo non è un passo di quella corsa, e infilarcela dentro cambierebbe il
2262
+ * significato del file — oltre a scrivere un dodicesimo `kind` in un modulo che non è mio.
2263
+ * ⇒ l'esito NON è persistito, e §6.7 («l'esito dell'ultima ri-verifica, con la data») resta
2264
+ * aperto: va un magazzino suo, dichiarato nel rapporto di questo lotto.
2265
+ *
2266
+ * @returns {Promise<{trovata:false}|{trovata:true, ok:false, motivo:string}|{trovata:true, ok:true, riverifica:object}>}
2267
+ */
2268
+ async function riverifica({ cartella, id }) {
2269
+ const record = await leggiRicercaFn({ cartella, id });
2270
+ if (!record) return { trovata: false };
2271
+
2272
+ const giudizio = await giudicaRapporto({ cartella, record });
2273
+ const recintato = giudizio.letto?.record ?? null;
2274
+ if (!recintato) {
2275
+ const coda = giudizio.letto?.ripiego
2276
+ ? 'il suo rapporto è in forma vecchia, senza il record verificabile: non porta i passaggi citati, e senza quelli non c\'è niente da ri-trovare'
2277
+ : (giudizio.motivoDettaglio ?? 'non c\'è nessun file di rapporto');
2278
+ return { trovata: true, ok: false, motivo: `questa ricerca non si può ricontrollare: ${coda}` };
2279
+ }
2280
+ if (typeof leggiPaginaFn !== 'function') {
2281
+ return { trovata: true, ok: false, motivo: 'la lettura delle pagine non è disponibile su questo TALOS: senza di quella non si può andare a vedere se le fonti dicono ancora questo' };
2282
+ }
2283
+
2284
+ /*
2285
+ * ⛔⛔ IL TESTO TENUTO, e perché oggi la mappa esce VUOTA (vedi la testa del file). Le fonti
2286
+ * su disco si contano — `testiTenuti` è un numero vero e va nella risposta — ma non si
2287
+ * possono attribuire a un url: `fonti/<sha256>.txt` prende il nome dal proprio contenuto.
2288
+ * Il giorno in cui il collettore scriverà un indice url→ref, questa è l'unica riga da
2289
+ * cambiare, e il resto della funzione comincerà a dire `intatta`/`cambiata` da solo.
2290
+ */
2291
+ const refs = await elencaFontiFn({ cartella, id });
2292
+ const testoTenutoPerUrl = new Map();
2293
+
2294
+ const passaggiCitati = recintato.claims.filter((c) => typeof c?.passage === 'string' && c.passage.trim().length > 0).length;
2295
+ if (passaggiCitati === 0 && testoTenutoPerUrl.size === 0) {
2296
+ return {
2297
+ trovata: true,
2298
+ ok: false,
2299
+ motivo: 'non c\'è ancora niente da ricontrollare: il rapporto non porta nessun passaggio citato e il testo delle fonti non è stato tenuto',
2300
+ };
2301
+ }
2302
+
2303
+ const fontiDaGuardare = recintato.sources.slice(0, TETTO_FONTI_RIVERIFICA);
2304
+ const deps = {
2305
+ /*
2306
+ * ⛔ `read` torna `null` quando la pagina non si è potuta LEGGERE, ed è una risposta
2307
+ * diversa da un lancio: `recheck.mjs` le tratta uguali («unreachable») ma il motivo che
2308
+ * riportiamo cambia. Uno stato ≥ 400 e un corpo vuoto sono entrambi «non l'ho vista»:
2309
+ * confrontare il nulla con il testo tenuto direbbe «cambiata» su una pagina che magari
2310
+ * è intatta e ha solo rifiutato questa richiesta.
2311
+ */
2312
+ read: async (indirizzo) => {
2313
+ const pagina = await leggiPaginaFn(indirizzo);
2314
+ const corpo = typeof pagina?.corpo === 'string' ? pagina.corpo : '';
2315
+ if (!pagina || Number(pagina.stato) >= 400 || corpo.trim().length === 0) return null;
2316
+ return { text: corpo };
2317
+ },
2318
+ at: () => clock().toISOString(),
2319
+ };
2320
+ const esito = await talosResearchRecheckReport(deps, { ...recintato, sources: fontiDaGuardare }, testoTenutoPerUrl);
2321
+
2322
+ const fonti = esito.sources.map((f) => {
2323
+ const tenuto = testoTenutoPerUrl.has(f.url);
2324
+ const stato = f.state === 'unreachable'
2325
+ ? 'irraggiungibile'
2326
+ : (tenuto ? (f.state === 'intact' ? 'intatta' : 'cambiata') : 'non-misurabile');
2327
+ return {
2328
+ url: f.url,
2329
+ titolo: f.title,
2330
+ stato,
2331
+ // ⛔ `null`, mai `1`, quando non c'era testo da confrontare: uno e «non misurato» non sono lo stesso numero.
2332
+ sopravvissuto: tenuto && f.state !== 'unreachable' ? f.survived : null,
2333
+ motivoLettura: f.reason ? String(f.reason).slice(0, 200) : null,
2334
+ passaggiRitrovati: f.passagesStanding,
2335
+ passaggiPersi: f.passagesLost,
2336
+ };
2337
+ });
2338
+ /*
2339
+ * ⛔ `talosResearchRecheckStanding` NON è il lettore giusto oggi, e va detto invece di
2340
+ * lasciarlo credere: conta gli stati del modulo, dove ogni fonte senza testo tenuto è
2341
+ * `intact` — cioè conterebbe come «intatte» proprio quelle che non abbiamo potuto
2342
+ * misurare. Il bilancio qui si fa sugli stati NORMALIZZATI, che sono quelli pubblicati.
2343
+ */
2344
+ const conta = (valore) => fonti.filter((f) => f.stato === valore).length;
2345
+ return {
2346
+ trovata: true,
2347
+ ok: true,
2348
+ riverifica: {
2349
+ id,
2350
+ fattaAlle: esito.at,
2351
+ misurabile: testoTenutoPerUrl.size > 0,
2352
+ avvertenza: testoTenutoPerUrl.size > 0
2353
+ ? null
2354
+ : 'Il testo delle pagine non era stato tenuto per questa ricerca: «intatta» o «cambiata» non si possono dire. Ciò che si misura è se i passaggi citati sono ancora nella pagina di oggi.',
2355
+ fonti,
2356
+ bilancio: {
2357
+ fonti: fonti.length,
2358
+ intatte: conta('intatta'),
2359
+ cambiate: conta('cambiata'),
2360
+ irraggiungibili: conta('irraggiungibile'),
2361
+ nonMisurabili: conta('non-misurabile'),
2362
+ passaggiCitati,
2363
+ passaggiRitrovati: fonti.reduce((t, f) => t + f.passaggiRitrovati, 0),
2364
+ passaggiPersi: fonti.reduce((t, f) => t + f.passaggiPersi, 0),
2365
+ },
2366
+ troncata: recintato.sources.length > fontiDaGuardare.length,
2367
+ fontiTotali: recintato.sources.length,
2368
+ testiTenuti: refs.length,
2369
+ },
2370
+ };
2371
+ }
2372
+
2373
+ /**
2374
+ * ⭐⭐⭐ L2 §6.4 (contratto) — LA VOCE CHE LA SEZIONE LEGGE, campo per campo.
2375
+ *
2376
+ * Prima d'oggi erano QUATTRO campi (`id`, `titolo`, `stato`, `avviataAlle`) e la sezione non
2377
+ * poteva fare altro che scrivere a mano, sotto l'elenco: «La consultazione del rapporto e
2378
+ * delle fonti non è ancora disponibile qui» (`public/index.html:1011`). Non era pigrizia del
2379
+ * frontend: `reportLibraryId` non usciva da questa funzione, quindi la sezione non aveva
2380
+ * letteralmente il modo di sapere dove fosse il rapporto.
2381
+ *
2382
+ * id — l'id della ricerca (= il sessionId che la esegue).
2383
+ * domanda / question — la domanda originale. Due nomi lo STESSO valore: `question` è il
2384
+ * nome del contratto verso il frontend, `domanda` quello interno già
2385
+ * in uso. Meglio un alias esplicito che una traduzione muta a metà
2386
+ * strada, dove si perde.
2387
+ * titolo — l'etichetta scelta con `research_rename`, o la domanda. Resta:
2388
+ * `formattaListaRicerche` (kernel) la legge per nome.
2389
+ * nome — la domanda troncata a 80 caratteri, l'etichetta umana della riga.
2390
+ * stato — running|paused|done|cancelled|failed|senza-rapporto|
2391
+ * bloccata-dal-permesso|giri-esauriti.
2392
+ * avviataAlle — ISO.
2393
+ * conclusaAlle — ISO, `null` se sta ancora girando (o se è una voce nata prima che
2394
+ * questo campo esistesse: `null` onesto, mai una data inventata).
2395
+ * reportLibraryId — `null` se non c'è un rapporto in Libreria.
2396
+ * motivo — la frase umana, SOLO quando lo stato non è `done`.
2397
+ * padreId — la sessione che l'ha ordinata, `null` per le vecchie.
2398
+ * ultimoMessaggio — l'ultima frase del modello. ALLEGATO, mai il rapporto.
2399
+ *
2400
+ * ⛔⛔ LA COMPATIBILITÀ ALL'INDIETRO, §6.5 — una ricerca già su disco può avere
2401
+ * `terminata:'done'` e un `reportLibraryId` che punta a una scusa: quelle di prima di oggi
2402
+ * sono TUTTE così. Il loro stato si corregge al volo in `leggi()`, rileggendo il rapporto,
2403
+ * e il file su disco NON si riscrive mai. Ciò che è costato denaro non si sovrascrive —
2404
+ * lezione già pagata in questo repo (il rilancio che cancellava $2,64 di lavoro).
2405
+ * ⇒ `elenca()` non rilegge (sono fino a 20 righe: 20 letture di file per disegnare una
2406
+ * lista sarebbero un costo per ogni apertura della sezione); `leggi()` sì, sulla singola.
2407
+ * È una divergenza VOLUTA fra le due viste, e sta scritta qui perché si veda.
2408
+ */
2409
+ function voceEsposta(r, stato, letto = null) {
2410
+ const domanda = r.domanda ?? null;
2411
+ /* ⭐ BC-44 — la causa registrata, o quella dedotta dagli eventi della sessione (vedi `causaDellaCaduta`). */
2412
+ const caduta = stato === 'failed' ? causaDellaCaduta(r, sessioni.get(r.id)) : null;
2413
+ return {
2414
+ id: r.id,
2415
+ domanda,
2416
+ question: domanda,
2417
+ titolo: r.titolo || domanda,
2418
+ nome: r.nome || nomeDallaDomanda(domanda),
2419
+ stato,
2420
+ avviataAlle: r.avviataAlle ?? null,
2421
+ conclusaAlle: r.conclusaAlle ?? null,
2422
+ reportLibraryId: r.reportLibraryId ?? null,
2423
+ motivo: stato === 'done' ? null : motivoDelloStato(stato, r.motivoDettaglio ?? null, caduta),
2424
+ padreId: r.padreId ?? null,
2425
+ ultimoMessaggio: r.ultimoMessaggio ?? null,
2426
+ /*
2427
+ * ⭐⭐⭐ L4 — I DUE CAMPI NUOVI, e sono ADDITIVI: nessuno dei dodici di L2 cambia nome,
2428
+ * tipo o significato. Il test `CONTRATTO §6.4` asserisce le chiavi con `deepEqual`
2429
+ * proprio perché una crescita si veda invece di scivolare dentro in silenzio.
2430
+ *
2431
+ * `bilancio` — sostenute / in parte / non sostenute / contese / non verificate.
2432
+ * `null` quando il record recintato non c'è: ⛔ `null` e «tutto a zero»
2433
+ * NON sono la stessa cosa, e mostrare zeri su una ricerca che non è mai
2434
+ * stata misurata sarebbe un verdetto inventato. È la riga con cui §6.7
2435
+ * vuole che la scheda guidi — mai col conteggio delle fonti.
2436
+ * `proveDistinte` — su quante fonti diverse poggia almeno un passaggio davvero ritrovato.
2437
+ */
2438
+ bilancio: letto?.bilancio ?? null,
2439
+ proveDistinte: letto?.proveDistinte ?? 0,
2440
+ // BC-51: stesso record già letto per il bilancio, in elenco e dettaglio.
2441
+ // Il modello designato non prova che abbia effettivamente giudicato.
2442
+ giudice: letto?.record?.judge ?? null,
2443
+ /*
2444
+ * ⭐⭐⭐ L8 (12/09/2026) — CON CHE COSA È STATA FATTA. Tredicesimo campo, additivo.
2445
+ *
2446
+ * `null` per ogni ricerca nata prima di oggi — e `null` è la risposta giusta: quelle
2447
+ * corse un modello ce l'hanno avuto, ma nessuno l'ha registrato, e scrivere qui quello
2448
+ * di oggi sarebbe attribuire a ieri una scelta di adesso. Il campo esiste perché il
2449
+ * 12/09 una ricerca è girata su un modello diverso da quello della chat che l'aveva
2450
+ * ordinata e dalla sezione non si poteva vedere: due ricerche fatte con due modelli
2451
+ * diversi non sono confrontabili, e la riga deve dirlo.
2452
+ */
2453
+ modello: r.modello ?? null,
2454
+ /*
2455
+ * ⭐⭐⭐⭐ L9 (12/09/2026) — SEDICESIMO CAMPO: `modelloGiudice`. Additivo come gli altri tre.
2456
+ *
2457
+ * ⛔ Perché una riga in elenco deve dirlo, e non basta il `giudice` che esce da `leggi()`:
2458
+ * sono due fatti DIVERSI. `giudice` (nel record del rapporto) è chi ha giudicato DAVVERO
2459
+ * quella corsa; questo è chi era stato SCELTO alla partenza. Coincidono quando tutto va
2460
+ * bene, e quando divergono è esattamente il caso che si vuole vedere — un giudice
2461
+ * designato che non ha mai risposto lascia `giudice: null` e affermazioni non
2462
+ * verificate, e senza questo campo la sezione non potrebbe distinguerlo da «non c'era
2463
+ * nessun altro modello».
2464
+ * ⛔ `null` per ogni ricerca nata prima di oggi, e `null` è la risposta giusta: quelle
2465
+ * corse non hanno mai avuto un giudice designato, e attribuirgliene uno adesso sarebbe
2466
+ * raccontare una scelta che nessuno ha fatto.
2467
+ */
2468
+ modelloGiudice: r.modelloGiudice ?? null,
2469
+ /*
2470
+ * ⭐⭐⭐⭐ BC-44 (12/09/2026) — DICIASSETTESIMO e DICIOTTESIMO campo, additivi come tutti gli
2471
+ * altri: nessuno dei sedici cambia nome, tipo o significato.
2472
+ *
2473
+ * `riprendibile` — «se premo Riprendi adesso, il server accetta?». È una domanda sola, e
2474
+ * la risposta la dà QUI il server, non il frontend indovinandola dallo
2475
+ * stato: fino a ieri la sezione offriva «Riprendi» su OGNI `failed` e il
2476
+ * server rispondeva 409 con «non è nello stato giusto» — un pulsante che
2477
+ * promette ciò che nessuna rotta può mantenere.
2478
+ * `motivoErrore` — `{classe, transitorio}`, o `null`. ⛔ Il `messaggio` grezzo del
2479
+ * fornitore e il suo `codice` restano sul DISCO (`meta.json`) e NON
2480
+ * escono di qui: sono diagnosi, e a schermo sarebbero nomi tecnici. La
2481
+ * frase per una persona è già in `motivo`, composta in un posto solo.
2482
+ *
2483
+ * ⛔ I DUE `failed` NON SONO LO STESSO STATO, e la distinzione è tutta in `r.terminata`:
2484
+ * · `terminata` assente e stato `failed` ⇒ è `statoVivo` che l'ha DEDOTTO da una
2485
+ * sessione che non c'è più (riavvio del server). Quella si riprende dal giornale, e
2486
+ * si riprendeva già prima di BC-44 (L4 §6.6, via B);
2487
+ * · `terminata === 'failed'` ⇒ la corsa è finita davvero, e allora decide la causa.
2488
+ * Confondere i due avrebbe tolto la ripresa proprio al caso per cui il giornale esiste.
2489
+ * ⛔ `bloccata-dal-permesso` e `giri-esauriti` NON sono qui dentro: il giornale li tiene
2490
+ * riprendibili apposta (vedi la nota sui tre rami di guasto in `onConclusioneRicerca`),
2491
+ * ma `riprendi()` oggi non li accetta e aprirli è una riga a parte, con la sua verifica.
2492
+ * Dichiarato, non dimenticato.
2493
+ */
2494
+ riprendibile: stato === 'paused'
2495
+ || (stato === 'failed' && (r.terminata !== 'failed' || caduta?.transitorio === true)),
2496
+ motivoErrore: caduta
2497
+ ? { classe: caduta.classe ?? 'ignoto', transitorio: caduta.transitorio === true }
2498
+ : null,
2499
+ };
2500
+ }
2501
+
2502
+ /**
2503
+ * ⛔⛔⛔ L4 — L'ELENCO ADESSO RILEGGE, e questa è una correzione a una scelta di L2.
2504
+ *
2505
+ * Il difetto, visto dall'owner: la ricerca `d2a453a8` compariva **«Conclusa» in lista** e
2506
+ * **«bloccata dal permesso» quando la si apriva**. L2 aveva chiamato quella divergenza
2507
+ * «voluta» — venti letture di file per disegnare una lista sembravano un costo per niente. Il
2508
+ * costo era vero; la conclusione no: **una lista che mente costa di più**, e il costo si paga
2509
+ * una volta sola grazie alla cache su `mtime`+`size` (vedi `giudiziRapporto`).
2510
+ *
2511
+ * ⛔ Rilegge SOLO le voci che si dichiarano `done`: sono le uniche che possono mentire. Una
2512
+ * `running`, una `failed` o una `cancelled` non hanno un rapporto da smentire, e leggerle
2513
+ * sarebbe costo puro.
2514
+ * ⛔ E il file su disco **non si riscrive**, nemmeno adesso che la bugia si vede in due posti:
2515
+ * la correzione vive nella lettura.
2516
+ */
2517
+ async function elenca({ cartella, status, page_size: pageSize, offset }) {
2518
+ const record = await elencaRicercheFn({ cartella });
2519
+ const conStato = [];
2520
+ for (const r of record) {
2521
+ const stato = statoVivo(r, sessioni.get(r.id));
2522
+ // ⛔ Gli stessi DUE stati di `leggi()`: sono i soli che parlano del rapporto, e sono i soli
2523
+ // che possono mentire. Una `running`/`failed`/`cancelled` non ha un rapporto da smentire.
2524
+ if (stato !== 'done' && stato !== 'senza-rapporto') { conStato.push(voceEsposta(r, stato)); continue; }
2525
+ const giudizio = await giudicaRapporto({ cartella, record: r });
2526
+ conStato.push(voceEsposta({ ...r, motivoDettaglio: giudizio.motivoDettaglio ?? r.motivoDettaglio ?? null }, giudizio.stato, giudizio.letto));
2527
+ }
2528
+ const filtrate = !status || status === 'all' ? conStato : conStato.filter((r) => r.stato === status);
2529
+ const dimensionePagina = clampNumero(pageSize, 1, 20, 10);
2530
+ const salto = clampNumero(offset, 0, Number.MAX_SAFE_INTEGER, 0);
2531
+ const pagina = filtrate.slice(salto, salto + dimensionePagina);
2532
+ return { ricerche: pagina, totale: filtrate.length };
2533
+ }
2534
+
2535
+ async function leggi({ cartella, id }) {
2536
+ const record = await leggiRicercaFn({ cartella, id });
2537
+ if (!record) return { trovata: false };
2538
+ let stato = statoVivo(record, sessioni.get(id));
2539
+ let contenutoRapporto = null;
2540
+ let contenutoRespinto = null;
2541
+ let motivoDettaglio = record.motivoDettaglio ?? null;
2542
+ let letto = null;
2543
+ /*
2544
+ * ⛔ DUE stati, non uno: `done` e `senza-rapporto` sono i due esiti che PARLANO del rapporto,
2545
+ * e vanno riletti entrambi. Guardare solo `done` (come faceva L2) lasciava fuori proprio il
2546
+ * caso in cui l'owner ha più bisogno di vedere: una ricerca che ha depositato qualcosa che
2547
+ * il cancello respinge. La rilettura può anche PROMUOVERE: se nel frattempo è stato
2548
+ * depositato un rapporto valido, `senza-rapporto` torna `done` — sempre nel lettore, mai
2549
+ * riscrivendo il file.
2550
+ */
2551
+ if (stato === 'done' || stato === 'senza-rapporto') {
2552
+ /*
2553
+ * ⛔⛔⛔ §6.5, LA COMPATIBILITÀ ALL'INDIETRO — CALCOLATA AL VOLO, SENZA RISCRIVERE.
2554
+ *
2555
+ * Ogni ricerca fatta prima dell'11/09 ha `terminata:'done'` e un `reportLibraryId` che
2556
+ * può puntare a qualunque cosa: sulla `d2a453a8` punta a 290 byte di scusa. Il giudizio
2557
+ * lo dà `giudicaRapporto`, la STESSA funzione che usa `elenca()` — mai due lettori dello
2558
+ * stesso file, perché due lettori sono due verità e a schermo si contraddicono.
2559
+ *
2560
+ * ⛔ Il file su disco non si tocca. Mai riscrivere in silenzio un record già pagato: la
2561
+ * correzione vive nella LETTURA, come per «TRE RIPETIZIONI PAGATE, UNA USATA» (22/8),
2562
+ * dove la cura stava nel lettore e non nelle righe.
2563
+ */
2564
+ const giudizio = await giudicaRapporto({ cartella, record });
2565
+ stato = giudizio.stato;
2566
+ motivoDettaglio = giudizio.motivoDettaglio ?? motivoDettaglio;
2567
+ contenutoRapporto = giudizio.contenutoRapporto;
2568
+ contenutoRespinto = giudizio.contenutoRespinto ?? null;
2569
+ letto = giudizio.letto;
2570
+ }
2571
+ /*
2572
+ * ⭐⭐⭐ L4 — IL GIORNALE ESPOSTO: `piano`, `passi`, `spesa`, `giornale`.
2573
+ *
2574
+ * ⛔ Tutti e quattro ADDITIVI e tutti e quattro DERIVATI: `piano` e `passi` escono dal
2575
+ * replay degli eventi veri, non da un campo salvato che potrebbe divergere. Un giro senza
2576
+ * giornale (una ricerca vecchia) dà `piano: []`, `passi: []` e `giornale: null` — e
2577
+ * `null` sul giornale è la differenza fra «non ne ha uno» e «ne ha uno vuoto».
2578
+ * ⛔ `righeSaltate` esce allo scoperto: se una riga del registro è illeggibile, la sezione
2579
+ * deve poterlo dire. «Si è caricato» e «si è caricato per intero» non sono la stessa frase.
2580
+ */
2581
+ const { eventi, righeSaltate } = await leggiGiornaleFn({ cartella, id });
2582
+ const giro = talosResearchReplay(eventi);
2583
+ const pianoSuDisco = await leggiPianoFn({ cartella, id });
2584
+ return {
2585
+ trovata: true,
2586
+ ...voceEsposta({ ...record, motivoDettaglio }, stato, letto),
2587
+ contenutoRapporto,
2588
+ /*
2589
+ * ⛔ Il testo che il cancello ha RESPINTO, in un campo che dice di esserlo. La sezione può
2590
+ * mostrarlo come «ciò che la ricerca ha depositato, e che non passa il controllo»: si
2591
+ * vede tutto il lavoro pagato, e nessuno lo scambia per un rapporto.
2592
+ */
2593
+ contenutoRespinto,
2594
+ /*
2595
+ * ⭐⭐⭐⭐ L5 §6.7 — LE AFFERMAZIONI E LE FONTI, STRUTTURATE.
2596
+ *
2597
+ * Fino a ieri di qui usciva `contenutoRapporto`: il markdown intero, col record recintato
2598
+ * dentro un blocco ```talos-research-report. La sezione avrebbe dovuto **ri-parsare** quel
2599
+ * blocco nel browser per disegnare le due viste che §6.7 chiede (Affermazioni e Fonti) —
2600
+ * cioè scrivere un secondo lettore del record, in un altro linguaggio, che diverge dal
2601
+ * primo alla prima modifica del formato. Il lettore è UNO, sta in `report.mjs`, e gira
2602
+ * qui: alla sezione arriva il risultato.
2603
+ *
2604
+ * ⛔ `null` — MAI `[]` — quando il record non c'è (ricerca vecchia, o rapporto respinto):
2605
+ * una lista vuota si disegna come «nessuna affermazione», che è un fatto; `null` è «non
2606
+ * lo sappiamo», che è la verità. Sono le stesse due parole che `bilancio` distingue.
2607
+ * ⛔ `verdettoUmano` esce da `talosResearchSupportLabel`, cioè dalla STESSA funzione che
2608
+ * scrive la prosa del rapporto: due frasari per lo stesso verdetto sono due verdetti.
2609
+ * ⛔ `contrarie` (CONTESA-01) è `null` quando `opposing` è assente, e `[]` quando è stato
2610
+ * guardato e non si è trovato niente: «non guardato» e «guardato, nessuna» si leggono
2611
+ * uguali solo se non importa sbagliare.
2612
+ * ⛔ Costo per il MODELLO: zero. `formattaLetturaRicerca` (kernel) legge solo `trovata`,
2613
+ * `contenutoRapporto` e `stato` — questi campi non entrano mai in un prompt.
2614
+ */
2615
+ affermazioni: letto?.record
2616
+ ? letto.record.claims.map((c, i) => ({
2617
+ numero: i + 1,
2618
+ testo: c?.text ?? '',
2619
+ fonte: c?.sourceIndex ?? null,
2620
+ passaggio: typeof c?.passage === 'string' ? c.passage : '',
2621
+ ritrovato: c?.checks?.quotePresent === true,
2622
+ tratto: c?.checks?.quoteSpan ?? null,
2623
+ verdetto: c?.checks?.claimSupported ?? 'unchecked',
2624
+ verdettoUmano: talosResearchSupportLabel(c?.checks ?? {}),
2625
+ motivoVerdetto: c?.checks?.supportReason ?? null,
2626
+ giudice: c?.checks?.judge ?? null,
2627
+ giudicataAlle: c?.checks?.judgedAt ?? null,
2628
+ contrarie: Array.isArray(c?.checks?.opposing) ? c.checks.opposing : null,
2629
+ }))
2630
+ : null,
2631
+ fonti: letto?.record
2632
+ ? letto.record.sources.map((f, i) => ({
2633
+ numero: i + 1,
2634
+ url: f?.url ?? '',
2635
+ titolo: f?.title ?? '',
2636
+ pubblicataAlle: f?.publishedAt ?? null,
2637
+ // 'page' = la pagina è stata aperta e letta; 'snippet' = se n'è visto solo l'estratto della ricerca.
2638
+ ottenuta: f?.obtained ?? null,
2639
+ }))
2640
+ : null,
2641
+ sintesi: letto?.record?.summary ?? null,
2642
+ piano: Array.isArray(pianoSuDisco) ? pianoSuDisco : (giro?.plan ?? []),
2643
+ passi: giro?.steps ?? [],
2644
+ spesa: giro ? talosResearchSpent(giro) : null,
2645
+ /*
2646
+ * ⭐⭐⭐⭐ L9 §6.8 (+1.5) — LO STIMATO, ACCANTO ALLO SPESO.
2647
+ *
2648
+ * ⛔ `null` — mai zeri — quando non c'è un piano: una ricerca vecchia non ha mai avuto
2649
+ * una stima, e «0 ricerche attese» direbbe che era gratis. Il divario fra questi due
2650
+ * numeri È una misura («il divario stimato/speso è esso stesso una misura», §6.8), e
2651
+ * una misura fatta contro uno zero inventato non misura niente.
2652
+ * ⛔ Si ricalcola dal piano invece di salvarlo: il piano su disco è il fatto, la somma è
2653
+ * aritmetica su di lui — un totale salvato a parte è un secondo numero che può
2654
+ * divergere dal primo.
2655
+ */
2656
+ costoAtteso: Array.isArray(pianoSuDisco) && pianoSuDisco.length > 0
2657
+ ? talosResearchPlanTotals(pianoSuDisco)
2658
+ : (giro?.plan?.length ? talosResearchPlanTotals(giro.plan) : null),
2659
+ giornale: giro ? { eventi: eventi.length, righeSaltate, stato: giro.status } : null,
2660
+ };
2661
+ }
2662
+
2663
+ /*
2664
+ * ⭐⭐⭐⭐ L9 — DUE METODI NUOVI, e nessuno dei nove di prima cambia.
2665
+ *
2666
+ * `componiRapporto` — quello che il kernel chiama da `research_deposit`: compone il
2667
+ * record E lo verifica, prima che il file esista. Sostituisce
2668
+ * l'uso diretto della funzione pura `componiRapportoRicerca`, che
2669
+ * resta esportata e invariata per chi non ha una ricerca intorno
2670
+ * (il banco, i test del kernel).
2671
+ * `raccoltaDellaRicerca` — la porta `cacheWeb` di quella corsa, o `null`. `session-registry`
2672
+ * la chiede per la sessione che sta per partire e la passa al
2673
+ * kernel; per ogni altra sessione è `null`, cioè il kernel di ieri.
2674
+ */
2675
+ return Object.freeze({
2676
+ avvia, mettiInPausa, annulla, riprendi, rinomina, elimina, elenca, leggi, riverifica,
2677
+ componiRapporto, raccoltaDellaRicerca,
2678
+ });
2679
+ }