ummaya 0.2.4 → 0.2.5

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 (477) hide show
  1. package/README.md +15 -2
  2. package/bin/ummaya +10 -1
  3. package/npm-shrinkwrap.json +253 -2
  4. package/package.json +5 -1
  5. package/prompts/manifest.yaml +1 -1
  6. package/prompts/system_v1.md +1 -0
  7. package/pyproject.toml +26 -2
  8. package/specs/2803-document-production-hardening/contracts/document-tools.schema.json +1043 -0
  9. package/src/ummaya/_canonical/__init__.py +2 -0
  10. package/src/ummaya/engine/engine.py +29 -132
  11. package/src/ummaya/evidence/__init__.py +21 -2
  12. package/src/ummaya/evidence/dataset_contract.py +193 -0
  13. package/src/ummaya/evidence/document_authoring_cases.py +33 -0
  14. package/src/ummaya/evidence/document_harness.py +313 -0
  15. package/src/ummaya/evidence/document_viewer_ux.py +391 -0
  16. package/src/ummaya/evidence/gates.py +70 -0
  17. package/src/ummaya/evidence/json_types.py +20 -0
  18. package/src/ummaya/evidence/models.py +88 -1
  19. package/src/ummaya/evidence/output_payload.py +89 -0
  20. package/src/ummaya/evidence/payload_documents.py +233 -0
  21. package/src/ummaya/evidence/route_contracts.py +224 -0
  22. package/src/ummaya/evidence/route_helpers.py +150 -0
  23. package/src/ummaya/evidence/runner.py +81 -212
  24. package/src/ummaya/evidence/source_provenance.py +246 -0
  25. package/src/ummaya/evidence/source_provenance_redaction.py +176 -0
  26. package/src/ummaya/evidence/tool_layer.py +39 -0
  27. package/src/ummaya/evidence/tool_layer_models.py +151 -0
  28. package/src/ummaya/ipc/adapter_manifest_emitter.py +26 -10
  29. package/src/ummaya/ipc/document_intent_normalization.py +185 -0
  30. package/src/ummaya/ipc/frame_schema.py +5 -5
  31. package/src/ummaya/ipc/route_diagnostics.py +73 -0
  32. package/src/ummaya/ipc/stdio.py +1109 -477
  33. package/src/ummaya/llm/client.py +102 -3
  34. package/src/ummaya/llm/config.py +8 -3
  35. package/src/ummaya/primitives/__init__.py +6 -2
  36. package/src/ummaya/primitives/delegation.py +1 -1
  37. package/src/ummaya/primitives/document.py +28 -0
  38. package/src/ummaya/settings.py +0 -3
  39. package/src/ummaya/tools/discovery_bridge.py +17 -1
  40. package/src/ummaya/tools/documents/__init__.py +297 -0
  41. package/src/ummaya/tools/documents/adapter_registry.py +487 -0
  42. package/src/ummaya/tools/documents/archive_container_probe.py +167 -0
  43. package/src/ummaya/tools/documents/artifact_store.py +454 -0
  44. package/src/ummaya/tools/documents/authoring.py +283 -0
  45. package/src/ummaya/tools/documents/baselines.py +114 -0
  46. package/src/ummaya/tools/documents/capability.py +331 -0
  47. package/src/ummaya/tools/documents/contracts.py +112 -0
  48. package/src/ummaya/tools/documents/conversion.py +521 -0
  49. package/src/ummaya/tools/documents/diff.py +275 -0
  50. package/src/ummaya/tools/documents/engines.py +163 -0
  51. package/src/ummaya/tools/documents/evaluation.py +291 -0
  52. package/src/ummaya/tools/documents/explicit_values.py +108 -0
  53. package/src/ummaya/tools/documents/fixtures.py +174 -0
  54. package/src/ummaya/tools/documents/format_completion_audit.py +471 -0
  55. package/src/ummaya/tools/documents/formats/__init__.py +2 -0
  56. package/src/ummaya/tools/documents/formats/archive.py +528 -0
  57. package/src/ummaya/tools/documents/formats/base.py +41 -0
  58. package/src/ummaya/tools/documents/formats/code_file.py +211 -0
  59. package/src/ummaya/tools/documents/formats/data_file.py +272 -0
  60. package/src/ummaya/tools/documents/formats/hwp.py +284 -0
  61. package/src/ummaya/tools/documents/formats/hwpx.py +1837 -0
  62. package/src/ummaya/tools/documents/formats/odf.py +435 -0
  63. package/src/ummaya/tools/documents/formats/ooxml.py +1030 -0
  64. package/src/ummaya/tools/documents/formats/passive.py +766 -0
  65. package/src/ummaya/tools/documents/formats/pdf.py +702 -0
  66. package/src/ummaya/tools/documents/formats/text_web.py +268 -0
  67. package/src/ummaya/tools/documents/hwp_conversion_probe.py +178 -0
  68. package/src/ummaya/tools/documents/hwp_direct_candidate.py +141 -0
  69. package/src/ummaya/tools/documents/inspection.py +289 -0
  70. package/src/ummaya/tools/documents/intake.py +1079 -0
  71. package/src/ummaya/tools/documents/legacy_office_promotion_probe.py +366 -0
  72. package/src/ummaya/tools/documents/models.py +1598 -0
  73. package/src/ummaya/tools/documents/odf_promotion_probe.py +167 -0
  74. package/src/ummaya/tools/documents/orchestrator.py +96 -0
  75. package/src/ummaya/tools/documents/passive_capability_probe.py +251 -0
  76. package/src/ummaya/tools/documents/patch.py +170 -0
  77. package/src/ummaya/tools/documents/pdfa_conformance.py +284 -0
  78. package/src/ummaya/tools/documents/pdfa_promotion_probe.py +198 -0
  79. package/src/ummaya/tools/documents/permissions.py +110 -0
  80. package/src/ummaya/tools/documents/planner.py +616 -0
  81. package/src/ummaya/tools/documents/registry.py +2733 -0
  82. package/src/ummaya/tools/documents/render.py +978 -0
  83. package/src/ummaya/tools/documents/render_comparison.py +113 -0
  84. package/src/ummaya/tools/documents/render_comparison_models.py +74 -0
  85. package/src/ummaya/tools/documents/render_comparison_regions.py +73 -0
  86. package/src/ummaya/tools/documents/render_comparison_style.py +161 -0
  87. package/src/ummaya/tools/documents/reread.py +157 -0
  88. package/src/ummaya/tools/documents/runtime_authoring.py +244 -0
  89. package/src/ummaya/tools/documents/runtime_authoring_bundle.py +76 -0
  90. package/src/ummaya/tools/documents/scorecard.py +184 -0
  91. package/src/ummaya/tools/documents/socratic_planner.py +193 -0
  92. package/src/ummaya/tools/documents/style.py +48 -0
  93. package/src/ummaya/tools/documents/tool_defs.py +523 -0
  94. package/src/ummaya/tools/documents/validate.py +347 -0
  95. package/src/ummaya/tools/executor.py +29 -0
  96. package/src/ummaya/tools/live_proxy.py +0 -3
  97. package/src/ummaya/tools/models.py +5 -1
  98. package/src/ummaya/tools/register_all.py +8 -0
  99. package/src/ummaya/tools/registry.py +10 -1
  100. package/src/ummaya/tools/routing/__init__.py +59 -0
  101. package/src/ummaya/tools/routing/builder.py +105 -0
  102. package/src/ummaya/tools/routing/cards.py +29 -0
  103. package/src/ummaya/tools/routing/decision_service.py +534 -0
  104. package/src/ummaya/tools/routing/decision_types.py +74 -0
  105. package/src/ummaya/tools/routing/feasibility.py +122 -0
  106. package/src/ummaya/tools/routing/intent.py +17 -0
  107. package/src/ummaya/tools/routing/intent_extractor.py +207 -0
  108. package/src/ummaya/tools/routing/intent_patterns.py +160 -0
  109. package/src/ummaya/tools/routing/intent_public_data.py +150 -0
  110. package/src/ummaya/tools/routing/intent_types.py +48 -0
  111. package/src/ummaya/tools/routing/lint.py +78 -0
  112. package/src/ummaya/tools/routing/metadata.py +174 -0
  113. package/src/ummaya/tools/routing/projection.py +340 -0
  114. package/src/ummaya/tools/routing/retrieval_policy.py +629 -0
  115. package/src/ummaya/tools/routing/schema.py +81 -0
  116. package/src/ummaya/tools/routing/types.py +96 -0
  117. package/src/ummaya/tools/routing_index.py +2 -2
  118. package/src/ummaya/tools/search.py +34 -746
  119. package/tests/fixtures/documents/public_forms/baselines.yaml +113 -0
  120. package/tui/package.json +1 -1
  121. package/tui/src/.cc-byte-identical-whitelist.yaml +266 -0
  122. package/tui/src/QueryEngine.ts +12 -8
  123. package/tui/src/bridge/inboundAttachments.ts +3 -3
  124. package/tui/src/cli/handlers/auth.ts +3 -12
  125. package/tui/src/cli/print.ts +7 -7
  126. package/tui/src/commands/insights.ts +1 -1
  127. package/tui/src/commands/install-github-app/types.ts +8 -30
  128. package/tui/src/commands/plugin/types.ts +6 -28
  129. package/tui/src/commands/plugin/unifiedTypes.ts +4 -26
  130. package/tui/src/commands/rename/generateSessionName.ts +1 -1
  131. package/tui/src/components/Feedback.tsx +1 -1
  132. package/tui/src/components/LogoV2/EmergencyTip.tsx +11 -2
  133. package/tui/src/components/LogoV2/WelcomeV2.tsx +1 -3
  134. package/tui/src/components/ScrollKeybindingHandler.tsx +6 -6
  135. package/tui/src/components/Spinner/types.ts +6 -28
  136. package/tui/src/components/agents/generateAgent.ts +1 -1
  137. package/tui/src/components/agents/new-agent-creation/types.ts +4 -26
  138. package/tui/src/components/config/EnvSecretIsolatedEditor.tsx +1 -1
  139. package/tui/src/components/mcp/types.ts +16 -38
  140. package/tui/src/components/messages/AssistantToolUseMessage.tsx +3 -2
  141. package/tui/src/components/messages/UserCrossSessionMessage.ts +16 -4
  142. package/tui/src/components/messages/UserForkBoilerplateMessage.ts +16 -4
  143. package/tui/src/components/messages/UserGitHubWebhookMessage.ts +16 -4
  144. package/tui/src/components/messages/UserToolResultMessage/utils.tsx +3 -2
  145. package/tui/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts +9 -4
  146. package/tui/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +9 -4
  147. package/tui/src/components/primitive/DocumentSocraticReviewBlock.tsx +129 -0
  148. package/tui/src/components/primitive/DocumentToolResultCard.tsx +224 -0
  149. package/tui/src/components/primitive/documentSocraticReview.ts +215 -0
  150. package/tui/src/components/primitive/index.tsx +43 -1
  151. package/tui/src/components/primitive/types.ts +137 -0
  152. package/tui/src/components/ui/option.ts +4 -26
  153. package/tui/src/constants/common.ts +0 -2
  154. package/tui/src/constants/prompts.ts +4 -3
  155. package/tui/src/constants/querySource.ts +4 -26
  156. package/tui/src/entrypoints/sdk/controlTypes.ts +26 -48
  157. package/tui/src/entrypoints/sdk/coreTypes.generated.ts +3 -25
  158. package/tui/src/entrypoints/sdk/runtimeTypes.ts +38 -60
  159. package/tui/src/entrypoints/sdk/sdkUtilityTypes.ts +4 -26
  160. package/tui/src/entrypoints/sdk/settingsTypes.generated.ts +3 -25
  161. package/tui/src/entrypoints/sdk/toolTypes.ts +3 -25
  162. package/tui/src/hooks/toolPermission/handlers/interactiveHandler.ts +10 -0
  163. package/tui/src/hooks/useApiKeyVerification.ts +1 -1
  164. package/tui/src/hooks/useVirtualScroll.ts +1 -1
  165. package/tui/src/ink/ink.tsx +33 -14
  166. package/tui/src/ink/reconciler.ts +2 -3
  167. package/tui/src/ink/render-to-screen.ts +30 -10
  168. package/tui/src/ipc/bridge.ts +62 -15
  169. package/tui/src/ipc/bridgeSingleton.ts +5 -1
  170. package/tui/src/ipc/codec.ts +3 -3
  171. package/tui/src/ipc/frames.generated.ts +12 -12
  172. package/tui/src/ipc/llmClient.ts +151 -27
  173. package/tui/src/ipc/schema/frame.schema.json +1 -1
  174. package/tui/src/keybindings/defaultBindings.ts +4 -0
  175. package/tui/src/main.tsx +29 -11
  176. package/tui/src/native-ts/file-index/index.ts +33 -3
  177. package/tui/src/observability/surface.ts +2 -2
  178. package/tui/src/probes/toolRegistryProbe.tsx +3 -1
  179. package/tui/src/projectOnboardingState.ts +7 -6
  180. package/tui/src/query/chatMessageTypes.ts +18 -0
  181. package/tui/src/query/chatMessagesBuilder.ts +1 -1
  182. package/tui/src/query/deps.ts +1 -1
  183. package/tui/src/query/messageGuards.ts +106 -0
  184. package/tui/src/query/publicDataTerminalRepair.ts +384 -0
  185. package/tui/src/query/run.ts +1075 -0
  186. package/tui/src/query/supportBoundary.ts +168 -0
  187. package/tui/src/query/toolResultErrors.ts +103 -0
  188. package/tui/src/query/toolRunner.ts +687 -0
  189. package/tui/src/query/unavailableToolRepair.ts +118 -0
  190. package/tui/src/query.ts +9 -2186
  191. package/tui/src/screens/REPL.tsx +40 -29
  192. package/tui/src/services/api/adapterManifest.ts +4 -0
  193. package/tui/src/services/api/backendChat/events.ts +117 -0
  194. package/tui/src/services/api/backendChat/finalMessage.ts +40 -0
  195. package/tui/src/services/api/backendChat/frame.ts +9 -0
  196. package/tui/src/services/api/backendChat/streaming.ts +430 -0
  197. package/tui/src/services/api/backendChat/types.ts +62 -0
  198. package/tui/src/services/api/backendChat.ts +1 -0
  199. package/tui/src/services/api/client.ts +65 -2
  200. package/tui/src/services/api/errorUtils.ts +5 -5
  201. package/tui/src/services/api/errors.ts +1 -1
  202. package/tui/src/services/api/logging.ts +1 -1
  203. package/tui/src/services/api/ummaya/evidence.ts +194 -0
  204. package/tui/src/services/api/ummaya/messages.ts +255 -0
  205. package/tui/src/services/api/ummaya/nonStreaming.ts +66 -0
  206. package/tui/src/services/api/ummaya/provider.ts +200 -0
  207. package/tui/src/services/api/ummaya/reasoning.ts +24 -0
  208. package/tui/src/services/api/ummaya/request.ts +200 -0
  209. package/tui/src/services/api/ummaya/selectionContext.ts +240 -0
  210. package/tui/src/services/api/ummaya/streaming.ts +365 -0
  211. package/tui/src/services/api/ummaya/streamingPayload.ts +129 -0
  212. package/tui/src/services/api/ummaya/streamingReader.ts +40 -0
  213. package/tui/src/services/api/ummaya/toolSelection.ts +217 -0
  214. package/tui/src/services/api/ummaya/types.ts +110 -0
  215. package/tui/src/services/api/ummaya/usage.ts +30 -0
  216. package/tui/src/services/api/ummaya.ts +26 -418
  217. package/tui/src/services/api/withRetry.ts +1 -1
  218. package/tui/src/services/awaySummary.ts +2 -2
  219. package/tui/src/services/claudeAiLimits.ts +1 -1
  220. package/tui/src/services/compact/autoCompact.ts +1 -1
  221. package/tui/src/services/compact/compact.ts +1 -1
  222. package/tui/src/services/lsp/types.ts +8 -30
  223. package/tui/src/services/tips/types.ts +6 -28
  224. package/tui/src/services/tokenEstimation.ts +1 -1
  225. package/tui/src/services/toolRegistry/bootGuard.ts +5 -5
  226. package/tui/src/services/toolUseSummary/toolUseSummaryGenerator.ts +1 -1
  227. package/tui/src/services/tools/toolExecution.ts +94 -1
  228. package/tui/src/store/pendingPermissionSlot.ts +1 -1
  229. package/tui/src/store/session-store.ts +10 -36
  230. package/tui/src/stubs/any-stub.ts +15 -10
  231. package/tui/src/stubs/color-diff-napi.ts +37 -23
  232. package/tui/src/stubs/globals.d.ts +3 -3
  233. package/tui/src/stubs/macro-preload.ts +23 -12
  234. package/tui/src/tools/AdapterTool/AdapterTool.ts +1207 -714
  235. package/tui/src/tools/AdapterTool/routeDiagnostics.ts +75 -0
  236. package/tui/src/tools/AgentTool/AgentTool.tsx +84 -1371
  237. package/tui/src/tools/AgentTool/agentToolHandoff.ts +114 -0
  238. package/tui/src/tools/AgentTool/agentToolPartialResult.ts +16 -0
  239. package/tui/src/tools/AgentTool/agentToolProgress.ts +32 -0
  240. package/tui/src/tools/AgentTool/agentToolResolver.ts +161 -0
  241. package/tui/src/tools/AgentTool/agentToolResult.ts +163 -0
  242. package/tui/src/tools/AgentTool/agentToolUtils.ts +14 -686
  243. package/tui/src/tools/AgentTool/asyncAgentLifecycle.ts +208 -0
  244. package/tui/src/tools/AgentTool/asyncLifecycle.ts +153 -0
  245. package/tui/src/tools/AgentTool/backgroundedCompletion.ts +126 -0
  246. package/tui/src/tools/AgentTool/backgroundedLifecycle.ts +174 -0
  247. package/tui/src/tools/AgentTool/foregroundBackground.ts +83 -0
  248. package/tui/src/tools/AgentTool/foregroundDrain.tsx +133 -0
  249. package/tui/src/tools/AgentTool/foregroundFinalize.ts +98 -0
  250. package/tui/src/tools/AgentTool/foregroundLifecycle.tsx +237 -0
  251. package/tui/src/tools/AgentTool/foregroundProgress.tsx +169 -0
  252. package/tui/src/tools/AgentTool/foregroundTask.ts +89 -0
  253. package/tui/src/tools/AgentTool/forkSubagent.ts +1 -12
  254. package/tui/src/tools/AgentTool/forkSubagentGate.ts +34 -0
  255. package/tui/src/tools/AgentTool/launchRouting.ts +203 -0
  256. package/tui/src/tools/AgentTool/lifecycle.ts +244 -0
  257. package/tui/src/tools/AgentTool/mcpRouting.ts +73 -0
  258. package/tui/src/tools/AgentTool/orchestrationSupport.ts +70 -0
  259. package/tui/src/tools/AgentTool/permissions.ts +39 -0
  260. package/tui/src/tools/AgentTool/promptSetup.ts +181 -0
  261. package/tui/src/tools/AgentTool/remoteRouting.ts +62 -0
  262. package/tui/src/tools/AgentTool/resultMapping.ts +116 -0
  263. package/tui/src/tools/AgentTool/resumeAgent.ts +39 -107
  264. package/tui/src/tools/AgentTool/resumeAgentHelpers.ts +140 -0
  265. package/tui/src/tools/AgentTool/runAgent.ts +1 -1
  266. package/tui/src/tools/AgentTool/runtimeConfig.ts +57 -0
  267. package/tui/src/tools/AgentTool/schemas.ts +196 -0
  268. package/tui/src/tools/AgentTool/sourceVerificationPropagation.ts +263 -0
  269. package/tui/src/tools/AgentTool/worktreeLifecycle.ts +105 -0
  270. package/tui/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +174 -202
  271. package/tui/src/tools/BashTool/BashTool.tsx +71 -1072
  272. package/tui/src/tools/BashTool/bashCommandHelpers.ts +12 -12
  273. package/tui/src/tools/BashTool/bashPermissions/astPreflight.ts +173 -0
  274. package/tui/src/tools/BashTool/bashPermissions/classifierChecks.ts +199 -0
  275. package/tui/src/tools/BashTool/bashPermissions/compoundGuards.ts +53 -0
  276. package/tui/src/tools/BashTool/bashPermissions/constants.ts +99 -0
  277. package/tui/src/tools/BashTool/bashPermissions/index.ts +38 -0
  278. package/tui/src/tools/BashTool/bashPermissions/legacyMisparsing.ts +62 -0
  279. package/tui/src/tools/BashTool/bashPermissions/main.ts +135 -0
  280. package/tui/src/tools/BashTool/bashPermissions/normalizedCommands.ts +33 -0
  281. package/tui/src/tools/BashTool/bashPermissions/operatorFlow.ts +98 -0
  282. package/tui/src/tools/BashTool/bashPermissions/permissionChecks.ts +200 -0
  283. package/tui/src/tools/BashTool/bashPermissions/prefixSuggestions.ts +88 -0
  284. package/tui/src/tools/BashTool/bashPermissions/promptClassifierRules.ts +125 -0
  285. package/tui/src/tools/BashTool/bashPermissions/ruleDelegates.ts +19 -0
  286. package/tui/src/tools/BashTool/bashPermissions/ruleMatching.ts +145 -0
  287. package/tui/src/tools/BashTool/bashPermissions/sandboxAutoAllow.ts +75 -0
  288. package/tui/src/tools/BashTool/bashPermissions/subcommandFlow.ts +205 -0
  289. package/tui/src/tools/BashTool/bashPermissions/subcommandGuards.ts +73 -0
  290. package/tui/src/tools/BashTool/bashPermissions/subcommandResultHelpers.ts +116 -0
  291. package/tui/src/tools/BashTool/bashPermissions/types.ts +26 -0
  292. package/tui/src/tools/BashTool/bashPermissions/wrapperStripping.ts +139 -0
  293. package/tui/src/tools/BashTool/bashPermissions.ts +26 -2621
  294. package/tui/src/tools/BashTool/call.ts +202 -0
  295. package/tui/src/tools/BashTool/callLoader.ts +35 -0
  296. package/tui/src/tools/BashTool/commandClassification.ts +151 -0
  297. package/tui/src/tools/BashTool/commandClassificationLoader.ts +40 -0
  298. package/tui/src/tools/BashTool/cwdReset.ts +33 -0
  299. package/tui/src/tools/BashTool/lineTruncation.ts +11 -0
  300. package/tui/src/tools/BashTool/modeValidation.ts +13 -1
  301. package/tui/src/tools/BashTool/outputPersistence.ts +42 -0
  302. package/tui/src/tools/BashTool/permissionClassification.ts +66 -0
  303. package/tui/src/tools/BashTool/permissionLoader.ts +44 -0
  304. package/tui/src/tools/BashTool/resultLoader.ts +29 -0
  305. package/tui/src/tools/BashTool/resultMapping.ts +83 -0
  306. package/tui/src/tools/BashTool/sandboxPolicy.ts +79 -0
  307. package/tui/src/tools/BashTool/schemas.ts +65 -0
  308. package/tui/src/tools/BashTool/sedEditExecution.ts +59 -0
  309. package/tui/src/tools/BashTool/shellExecution.tsx +245 -0
  310. package/tui/src/tools/BashTool/shellOutputUtils.ts +85 -0
  311. package/tui/src/tools/BashTool/shellPermissionGauntlet.ts +97 -0
  312. package/tui/src/tools/BashTool/uiLoader.ts +37 -0
  313. package/tui/src/tools/BriefTool/upload.ts +1 -1
  314. package/tui/src/tools/CalculatorTool/parser.ts +2 -2
  315. package/tui/src/tools/DocumentPrimitive/DocumentPrimitive.ts +262 -0
  316. package/tui/src/tools/DocumentPrimitive/dispatchNormalization.ts +270 -0
  317. package/tui/src/tools/DocumentPrimitive/documentDestinationPath.ts +18 -0
  318. package/tui/src/tools/DocumentPrimitive/documentMutationGuard.ts +22 -0
  319. package/tui/src/tools/DocumentPrimitive/documentPatchNormalization.ts +248 -0
  320. package/tui/src/tools/DocumentPrimitive/documentSourceVerification.ts +245 -0
  321. package/tui/src/tools/DocumentPrimitive/documentSourceVerificationFields.ts +103 -0
  322. package/tui/src/tools/DocumentPrimitive/modelVisibleOutput.ts +40 -0
  323. package/tui/src/tools/DocumentPrimitive/prompt.ts +35 -0
  324. package/tui/src/tools/FileEditTool/FileEditTool.ts +9 -507
  325. package/tui/src/tools/FileEditTool/call.ts +228 -0
  326. package/tui/src/tools/FileEditTool/validateInput.ts +196 -0
  327. package/tui/src/tools/FileReadTool/imageProcessor.ts +13 -0
  328. package/tui/src/tools/FileWriteTool/FileWriteTool.ts +7 -300
  329. package/tui/src/tools/FileWriteTool/call.ts +223 -0
  330. package/tui/src/tools/FileWriteTool/validateInput.ts +80 -0
  331. package/tui/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +19 -3
  332. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +25 -32
  333. package/tui/src/tools/LookupPrimitive/prompt.ts +0 -2
  334. package/tui/src/tools/MCPTool/trustPolicy.ts +118 -0
  335. package/tui/src/tools/McpAuthTool/McpAuthTool.ts +21 -3
  336. package/tui/src/tools/NotebookEditTool/NotebookEditTool.ts +7 -326
  337. package/tui/src/tools/NotebookEditTool/call.ts +254 -0
  338. package/tui/src/tools/NotebookEditTool/notebookModel.ts +51 -0
  339. package/tui/src/tools/NotebookEditTool/validateInput.ts +142 -0
  340. package/tui/src/tools/PowerShellTool/PowerShellTool.tsx +46 -937
  341. package/tui/src/tools/PowerShellTool/acceptEditsCommandValidation.ts +162 -0
  342. package/tui/src/tools/PowerShellTool/call.ts +179 -0
  343. package/tui/src/tools/PowerShellTool/callLoader.ts +37 -0
  344. package/tui/src/tools/PowerShellTool/commandClassification.ts +86 -0
  345. package/tui/src/tools/PowerShellTool/modeValidation.ts +25 -332
  346. package/tui/src/tools/PowerShellTool/outputPersistence.ts +42 -0
  347. package/tui/src/tools/PowerShellTool/permissionClassification.ts +28 -0
  348. package/tui/src/tools/PowerShellTool/resultLoader.ts +31 -0
  349. package/tui/src/tools/PowerShellTool/resultMapping.ts +75 -0
  350. package/tui/src/tools/PowerShellTool/schemas.ts +40 -0
  351. package/tui/src/tools/PowerShellTool/shellExecution.tsx +258 -0
  352. package/tui/src/tools/PowerShellTool/symlinkModeValidation.ts +44 -0
  353. package/tui/src/tools/PowerShellTool/uiLoader.ts +37 -0
  354. package/tui/src/tools/PowerShellTool/validation.ts +39 -0
  355. package/tui/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +19 -3
  356. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +1 -11
  357. package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +2 -6
  358. package/tui/src/tools/SkillTool/SkillTool.ts +2 -2
  359. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +27 -10
  360. package/tui/src/tools/TaskCreateTool/TaskCreateTool.ts +16 -2
  361. package/tui/src/tools/TaskGetTool/TaskGetTool.ts +23 -3
  362. package/tui/src/tools/TaskListTool/TaskListTool.ts +22 -4
  363. package/tui/src/tools/TaskOutputTool/TaskOutputTool.tsx +46 -547
  364. package/tui/src/tools/TaskOutputTool/lookup.ts +216 -0
  365. package/tui/src/tools/TaskOutputTool/render.tsx +257 -0
  366. package/tui/src/tools/TaskOutputTool/schemas.ts +55 -0
  367. package/tui/src/tools/TaskOutputTool/serialization.ts +36 -0
  368. package/tui/src/tools/TaskStopTool/TaskStopTool.ts +10 -0
  369. package/tui/src/tools/TaskUpdateTool/TaskUpdateTool.ts +14 -364
  370. package/tui/src/tools/TaskUpdateTool/completion.ts +62 -0
  371. package/tui/src/tools/TaskUpdateTool/schemas.ts +62 -0
  372. package/tui/src/tools/TaskUpdateTool/serialization.ts +46 -0
  373. package/tui/src/tools/TaskUpdateTool/statusUpdate.ts +247 -0
  374. package/tui/src/tools/TodoWriteTool/TodoWriteTool.ts +21 -2
  375. package/tui/src/tools/ToolSearchTool/ToolSearchTool.ts +21 -302
  376. package/tui/src/tools/ToolSearchTool/ccSupportTools.ts +223 -0
  377. package/tui/src/tools/ToolSearchTool/descriptionCache.ts +50 -0
  378. package/tui/src/tools/ToolSearchTool/keywordSearch.ts +216 -0
  379. package/tui/src/tools/ToolSearchTool/prompt.ts +10 -4
  380. package/tui/src/tools/ToolSearchTool/resultMapping.ts +30 -0
  381. package/tui/src/tools/ToolSearchTool/schemas.ts +30 -0
  382. package/tui/src/tools/ToolSearchTool/searchPool.ts +47 -0
  383. package/tui/src/tools/ToolSearchTool/supportIntentHints.ts +140 -0
  384. package/tui/src/tools/TranslateTool/TranslateTool.ts +1 -1
  385. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +2 -1
  386. package/tui/src/tools/WebFetchTool/WebFetchTool.ts +43 -138
  387. package/tui/src/tools/WebFetchTool/call.ts +227 -0
  388. package/tui/src/tools/WebFetchTool/resolvedAddressSafety.ts +78 -0
  389. package/tui/src/tools/WebFetchTool/sourceVerification.ts +204 -0
  390. package/tui/src/tools/WebFetchTool/types.ts +23 -0
  391. package/tui/src/tools/WebFetchTool/urlSafety.ts +181 -0
  392. package/tui/src/tools/WebFetchTool/utils.ts +1 -1
  393. package/tui/src/tools/WebSearchTool/UI.tsx +0 -1
  394. package/tui/src/tools/WebSearchTool/WebSearchTool.ts +9 -313
  395. package/tui/src/tools/WebSearchTool/call.ts +33 -0
  396. package/tui/src/tools/WebSearchTool/responseMapping.ts +190 -0
  397. package/tui/src/tools/WebSearchTool/resultBlock.ts +47 -0
  398. package/tui/src/tools/WebSearchTool/schemas.ts +47 -0
  399. package/tui/src/tools/WebSearchTool/toolSchema.ts +12 -0
  400. package/tui/src/tools/WorkspaceToolAdapter/WorkspaceToolAdapter.ts +79 -0
  401. package/tui/src/tools/WorkspaceToolAdapter/allowedRootPolicy.ts +85 -0
  402. package/tui/src/tools/WorkspaceToolAdapter/documentFormatGuards.ts +73 -0
  403. package/tui/src/tools/WorkspaceToolAdapter/inputNormalization.ts +105 -0
  404. package/tui/src/tools/WorkspaceToolAdapter/mcpExposurePolicy.ts +64 -0
  405. package/tui/src/tools/WorkspaceToolAdapter/toolDefFactory.ts +215 -0
  406. package/tui/src/tools/WorkspaceToolAdapter/toolNames.ts +6 -0
  407. package/tui/src/tools/WorkspaceToolAdapter/workspacePolicy.ts +15 -0
  408. package/tui/src/tools/_shared/dispatchPrimitive.ts +6 -6
  409. package/tui/src/tools/_shared/documentChangeToPatch.ts +125 -0
  410. package/tui/src/tools/_shared/documentDispatchArguments.ts +87 -0
  411. package/tui/src/tools/_shared/documentPrimitiveTimeout.ts +13 -0
  412. package/tui/src/tools/_shared/documentToolResultRender.ts +98 -0
  413. package/tui/src/tools/_shared/pendingCallRegistry.ts +1 -6
  414. package/tui/src/tools/_shared/rootPrimitiveInput.ts +1 -0
  415. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPatterns.ts +58 -0
  416. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPrompt.ts +271 -0
  417. package/tui/src/tools/_shared/toolChoiceRepair/documentRepair.ts +452 -0
  418. package/tui/src/tools/_shared/toolChoiceRepair/messageAccess.ts +80 -0
  419. package/tui/src/tools/_shared/toolChoiceRepair/publicDataRepair.ts +92 -0
  420. package/tui/src/tools/_shared/toolChoiceRepair/supportRepair.ts +135 -0
  421. package/tui/src/tools/_shared/toolChoiceRepair.ts +55 -860
  422. package/tui/src/tools/shared/mockDisclaimer.ts +1 -1
  423. package/tui/src/tools.ts +39 -190
  424. package/tui/src/types/fileSuggestion.ts +4 -26
  425. package/tui/src/types/generated/events_mono/claude_code/v1/claude_code_internal_event.ts +186 -148
  426. package/tui/src/types/generated/events_mono/common/v1/auth.ts +25 -11
  427. package/tui/src/types/generated/events_mono/growthbook/v1/growthbook_experiment_event.ts +47 -30
  428. package/tui/src/types/generated/google/protobuf/timestamp.ts +21 -7
  429. package/tui/src/types/message.ts +80 -102
  430. package/tui/src/types/messageQueueTypes.ts +6 -28
  431. package/tui/src/types/notebook.ts +16 -38
  432. package/tui/src/types/statusLine.ts +4 -26
  433. package/tui/src/types/tools.ts +24 -46
  434. package/tui/src/types/utils.ts +6 -28
  435. package/tui/src/upstreamproxy/relay.ts +7 -3
  436. package/tui/src/upstreamproxy/upstreamproxy.ts +1 -1
  437. package/tui/src/utils/assistantMessageFactories.ts +9 -3
  438. package/tui/src/utils/auth.ts +129 -139
  439. package/tui/src/utils/bash/ast.ts +23 -23
  440. package/tui/src/utils/bash/bashParser.ts +5 -5
  441. package/tui/src/utils/billing.ts +1 -1
  442. package/tui/src/utils/collapseReadSearch.ts +3 -3
  443. package/tui/src/utils/cronTasks.ts +1 -1
  444. package/tui/src/utils/execFileNoThrow.ts +1 -1
  445. package/tui/src/utils/filePersistence/types.ts +16 -38
  446. package/tui/src/utils/forkedAgent.ts +1 -1
  447. package/tui/src/utils/gracefulShutdown.ts +4 -4
  448. package/tui/src/utils/heapDumpService.ts +12 -8
  449. package/tui/src/utils/hooks/apiQueryHookHelper.ts +1 -1
  450. package/tui/src/utils/hooks/execPromptHook.ts +1 -1
  451. package/tui/src/utils/hooks/skillImprovement.ts +1 -1
  452. package/tui/src/utils/mcp/dateTimeParser.ts +1 -1
  453. package/tui/src/utils/messages.ts +18 -0
  454. package/tui/src/utils/migrateSessions.ts +3 -3
  455. package/tui/src/utils/model/model.ts +6 -6
  456. package/tui/src/utils/permissions/yoloClassifier.ts +1 -1
  457. package/tui/src/utils/plugins/headlessPluginInstall.ts +1 -1
  458. package/tui/src/utils/plugins/mcpPluginIntegration.ts +1 -1
  459. package/tui/src/utils/plugins/mcpbHandler.ts +1 -1
  460. package/tui/src/utils/plugins/pluginLoader.ts +8 -8
  461. package/tui/src/utils/protectedNamespace.ts +5 -3
  462. package/tui/src/utils/rawJsonToolCall.ts +242 -0
  463. package/tui/src/utils/ripgrep.ts +16 -7
  464. package/tui/src/utils/sessionTitle.ts +1 -1
  465. package/tui/src/utils/settings/permissionValidation.ts +14 -2
  466. package/tui/src/utils/shell/prefix.ts +1 -1
  467. package/tui/src/utils/sideQuery.ts +1 -1
  468. package/tui/src/utils/systemThemeWatcher.ts +13 -3
  469. package/tui/src/utils/teleport.tsx +1 -1
  470. package/uv.lock +400 -14
  471. package/tui/src/services/api/claude.ts +0 -3540
  472. package/tui/src/tools/_shared/directPublicDataGuard.ts +0 -362
  473. package/tui/src/tools/_shared/kmaAnalysisGuard.ts +0 -197
  474. package/tui/src/tools/_shared/kmaAviationGuard.ts +0 -70
  475. package/tui/src/tools/_shared/nmcAedGuard.ts +0 -234
  476. package/tui/src/tools/_shared/protectedCheckGuard.ts +0 -207
  477. package/tui/src/tools/_shared/textToolCallGuard.ts +0 -91
@@ -0,0 +1,978 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Renderer facade for local document evidence artifacts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import html
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+ from typing import Protocol, cast, runtime_checkable
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from ummaya.tools.documents.artifact_store import (
18
+ ArtifactStoreConflictError,
19
+ DocumentArtifactStore,
20
+ )
21
+ from ummaya.tools.documents.diff import RenderArtifactRecord
22
+ from ummaya.tools.documents.engines import (
23
+ DocumentEngineRegistry,
24
+ DocumentInspectionEngine,
25
+ UnsupportedDocumentEngineError,
26
+ )
27
+ from ummaya.tools.documents.models import (
28
+ ArtifactLineage,
29
+ BlockedReason,
30
+ DocumentArtifact,
31
+ DocumentChange,
32
+ DocumentChangedViewport,
33
+ DocumentClipRect,
34
+ DocumentDiff,
35
+ DocumentFormat,
36
+ DocumentViewportCamera,
37
+ PromotionCapability,
38
+ PromotionChecklistItem,
39
+ PromotionChecklistStatus,
40
+ PromotionGateResult,
41
+ PromotionState,
42
+ ToolResultStatus,
43
+ )
44
+
45
+
46
+ @runtime_checkable
47
+ class DocumentRenderEngine(DocumentInspectionEngine, Protocol):
48
+ """Promoted engine that can render reviewer-readable evidence."""
49
+
50
+ def render(self, path: Path, *, artifact_id: str, output_dir: Path) -> tuple[bytes, ...]:
51
+ """Return one or more page, sheet, or slide render payloads."""
52
+
53
+
54
+ class DocumentRenderResult(BaseModel):
55
+ """Result of rendering one derivative artifact for review."""
56
+
57
+ model_config = ConfigDict(frozen=True, extra="forbid")
58
+
59
+ status: ToolResultStatus
60
+ correlation_id: str
61
+ source_artifact_id: str
62
+ source_sha256: str
63
+ records: tuple[RenderArtifactRecord, ...] = ()
64
+ baseline_records: tuple[RenderArtifactRecord, ...] = ()
65
+ changed_viewports: tuple[DocumentChangedViewport, ...] = ()
66
+ viewport_cameras: tuple[DocumentViewportCamera, ...] = ()
67
+ artifact_refs: list[str] = Field(default_factory=list)
68
+ render_passed: bool
69
+ blocked_reason: BlockedReason | None = None
70
+ promotion_gate_result: PromotionGateResult | None = None
71
+ text_summary: str
72
+
73
+
74
+ def _write_or_reuse_derivative(
75
+ parent: DocumentArtifact,
76
+ store: DocumentArtifactStore,
77
+ *,
78
+ artifact_id: str,
79
+ lineage: ArtifactLineage,
80
+ destination_name: str,
81
+ payload: bytes,
82
+ document_format: DocumentFormat | None = None,
83
+ mime_type: str | None = None,
84
+ expanded_byte_size: int | None = None,
85
+ ) -> DocumentArtifact:
86
+ """Write a deterministic render artifact, or reuse an identical existing one."""
87
+
88
+ try:
89
+ return store.write_derivative(
90
+ parent,
91
+ artifact_id=artifact_id,
92
+ lineage=lineage,
93
+ destination_name=destination_name,
94
+ payload=payload,
95
+ document_format=document_format,
96
+ mime_type=mime_type,
97
+ expanded_byte_size=expanded_byte_size,
98
+ )
99
+ except ArtifactStoreConflictError as err:
100
+ existing = store.load_artifact(artifact_id)
101
+ if existing is None:
102
+ raise
103
+ mismatches = _derivative_metadata_mismatches(
104
+ existing,
105
+ parent,
106
+ payload=payload,
107
+ lineage=lineage,
108
+ document_format=document_format,
109
+ mime_type=mime_type,
110
+ expanded_byte_size=expanded_byte_size,
111
+ )
112
+ if mismatches:
113
+ mismatch_list = ", ".join(mismatches)
114
+ raise ArtifactStoreConflictError(
115
+ f"artifact already exists with divergent render metadata: "
116
+ f"{artifact_id} ({mismatch_list})"
117
+ ) from err
118
+ return existing
119
+
120
+
121
+ def _derivative_metadata_mismatches(
122
+ existing: DocumentArtifact,
123
+ parent: DocumentArtifact,
124
+ *,
125
+ payload: bytes,
126
+ lineage: ArtifactLineage,
127
+ document_format: DocumentFormat | None,
128
+ mime_type: str | None,
129
+ expanded_byte_size: int | None,
130
+ ) -> list[str]:
131
+ expected_expanded_byte_size = (
132
+ expanded_byte_size if expanded_byte_size is not None else len(payload)
133
+ )
134
+ expected = {
135
+ "sha256": hashlib.sha256(payload).hexdigest(),
136
+ "byte_size": len(payload),
137
+ "expanded_byte_size": expected_expanded_byte_size,
138
+ "format": document_format or parent.format,
139
+ "mime_type": mime_type or parent.mime_type,
140
+ "lineage": lineage,
141
+ "parent_artifact_id": parent.artifact_id,
142
+ }
143
+ actual = {
144
+ "sha256": existing.sha256,
145
+ "byte_size": existing.byte_size,
146
+ "expanded_byte_size": existing.expanded_byte_size,
147
+ "format": existing.format,
148
+ "mime_type": existing.mime_type,
149
+ "lineage": existing.lineage,
150
+ "parent_artifact_id": existing.parent_artifact_id,
151
+ }
152
+ return [field for field, expected_value in expected.items() if actual[field] != expected_value]
153
+
154
+
155
+ def render_document_evidence(
156
+ store: DocumentArtifactStore,
157
+ artifact: DocumentArtifact,
158
+ *,
159
+ engine_registry: DocumentEngineRegistry,
160
+ correlation_id: str,
161
+ artifact_id_prefix: str,
162
+ diff: DocumentDiff | None = None,
163
+ baseline_artifact: DocumentArtifact | None = None,
164
+ ) -> DocumentRenderResult:
165
+ """Render a derivative through the promoted format engine and store artifacts."""
166
+ try:
167
+ engine = _require_render_engine(engine_registry, artifact)
168
+ except UnsupportedDocumentEngineError:
169
+ promotion_gate_result = _blocked_render_promotion_gate(artifact)
170
+ return DocumentRenderResult(
171
+ status=ToolResultStatus.blocked,
172
+ correlation_id=correlation_id,
173
+ source_artifact_id=artifact.artifact_id,
174
+ source_sha256=artifact.sha256,
175
+ render_passed=False,
176
+ blocked_reason=BlockedReason.unsupported_operation,
177
+ promotion_gate_result=promotion_gate_result,
178
+ text_summary=_unsupported_render_summary(artifact),
179
+ )
180
+
181
+ output_dir = store.session_root / "renders" / artifact_id_prefix
182
+ try:
183
+ payloads = engine.render(
184
+ Path(artifact.source_path),
185
+ artifact_id=artifact.artifact_id,
186
+ output_dir=output_dir,
187
+ )
188
+ baseline_payloads: tuple[bytes, ...] = ()
189
+ if diff is not None and baseline_artifact is not None:
190
+ baseline_payloads = engine.render(
191
+ Path(baseline_artifact.source_path),
192
+ artifact_id=baseline_artifact.artifact_id,
193
+ output_dir=output_dir / "baseline",
194
+ )
195
+ except Exception as exc: # noqa: BLE001 - native render bridges fail as runtime exceptions.
196
+ return _render_engine_failure_result(
197
+ artifact,
198
+ correlation_id=correlation_id,
199
+ engine=engine,
200
+ exc=exc,
201
+ )
202
+ artifact_path = Path(artifact.source_path)
203
+ render_extension = _render_artifact_extension(engine, artifact_path=artifact_path)
204
+ render_mime_type = _render_mime_type(
205
+ engine,
206
+ extension=render_extension,
207
+ artifact_path=artifact_path,
208
+ )
209
+ records: list[RenderArtifactRecord] = []
210
+ baseline_records: list[RenderArtifactRecord] = []
211
+ changed_viewports: list[DocumentChangedViewport] = []
212
+ viewport_cameras: list[DocumentViewportCamera] = []
213
+ changed_viewport_anchored = False
214
+ for index, payload in enumerate(payloads, start=1):
215
+ render_artifact_id = f"{artifact_id_prefix}-{index:03d}"
216
+ render_payload, viewport_anchored, page_viewports = _detect_changed_viewports(
217
+ payload,
218
+ diff=diff,
219
+ mime_type=render_mime_type,
220
+ page_number=index,
221
+ render_artifact_id=render_artifact_id,
222
+ )
223
+ changed_viewport_anchored = changed_viewport_anchored or viewport_anchored
224
+ render_artifact = _write_or_reuse_derivative(
225
+ artifact,
226
+ store,
227
+ artifact_id=render_artifact_id,
228
+ lineage=ArtifactLineage.render,
229
+ destination_name=f"{render_artifact_id}.{render_extension}",
230
+ payload=render_payload,
231
+ document_format=artifact.format,
232
+ mime_type=render_mime_type,
233
+ )
234
+ raster_update = (
235
+ _optional_png_render_update(
236
+ store,
237
+ artifact,
238
+ render_artifact_id=render_artifact.artifact_id,
239
+ render_path=Path(render_artifact.source_path),
240
+ )
241
+ if render_mime_type == "image/svg+xml"
242
+ else {}
243
+ )
244
+ raster_artifact_ref = cast(str | None, raster_update.get("raster_artifact_ref"))
245
+ raster_artifact_path = cast(Path | None, raster_update.get("raster_artifact_path"))
246
+ raster_mime_type = cast(str | None, raster_update.get("raster_mime_type"))
247
+ records.append(
248
+ _render_artifact_record(
249
+ artifact=artifact,
250
+ render_artifact=render_artifact,
251
+ render_mime_type=render_mime_type,
252
+ raster_artifact_ref=raster_artifact_ref,
253
+ raster_artifact_path=raster_artifact_path,
254
+ raster_mime_type=raster_mime_type,
255
+ page_number=index,
256
+ correlation_id=correlation_id,
257
+ engine=engine,
258
+ artifact_path=artifact_path,
259
+ )
260
+ )
261
+ baseline_record: RenderArtifactRecord | None = None
262
+ baseline_payload = baseline_payloads[index - 1] if index <= len(baseline_payloads) else None
263
+ if baseline_artifact is not None and baseline_payload is not None:
264
+ baseline_record = _write_full_page_render_record(
265
+ store,
266
+ baseline_artifact,
267
+ render_artifact_id=f"{artifact_id_prefix}-baseline-{index:03d}",
268
+ render_payload=baseline_payload,
269
+ render_extension=render_extension,
270
+ render_mime_type=render_mime_type,
271
+ page_number=index,
272
+ correlation_id=correlation_id,
273
+ engine=engine,
274
+ artifact_path=Path(baseline_artifact.source_path),
275
+ )
276
+ baseline_records.append(baseline_record)
277
+ if render_mime_type == "image/svg+xml":
278
+ page_changed_viewports = _write_changed_viewport_artifacts(
279
+ store,
280
+ artifact,
281
+ after_render_payload=render_payload,
282
+ before_artifact=baseline_artifact,
283
+ before_render_payload=baseline_payload,
284
+ viewports=page_viewports,
285
+ )
286
+ changed_viewports.extend(page_changed_viewports)
287
+ if baseline_record is not None:
288
+ viewport_cameras.extend(
289
+ _viewport_cameras_for_page(
290
+ page_changed_viewports,
291
+ source_render_artifact_id=render_artifact.artifact_id,
292
+ baseline_render_artifact_id=baseline_record.render_artifact_id,
293
+ page_index=index - 1,
294
+ )
295
+ )
296
+ else:
297
+ changed_viewports.extend(page_viewports)
298
+
299
+ render_passed = len(records) > 0
300
+ return DocumentRenderResult(
301
+ status=ToolResultStatus.ok if render_passed else ToolResultStatus.blocked,
302
+ correlation_id=correlation_id,
303
+ source_artifact_id=artifact.artifact_id,
304
+ source_sha256=artifact.sha256,
305
+ records=tuple(records),
306
+ baseline_records=tuple(baseline_records),
307
+ changed_viewports=tuple(changed_viewports),
308
+ viewport_cameras=tuple(viewport_cameras),
309
+ artifact_refs=[record.render_artifact_id for record in records],
310
+ render_passed=render_passed,
311
+ blocked_reason=None if render_passed else BlockedReason.validation_failed,
312
+ text_summary=_render_text_summary(
313
+ record_count=len(records),
314
+ engine=engine,
315
+ render_passed=render_passed,
316
+ visual_diff_requested=diff is not None,
317
+ changed_viewport_anchored=changed_viewport_anchored,
318
+ artifact_path=artifact_path,
319
+ ),
320
+ )
321
+
322
+
323
+ def _write_full_page_render_record(
324
+ store: DocumentArtifactStore,
325
+ artifact: DocumentArtifact,
326
+ *,
327
+ render_artifact_id: str,
328
+ render_payload: bytes,
329
+ render_extension: str,
330
+ render_mime_type: str,
331
+ page_number: int,
332
+ correlation_id: str,
333
+ engine: DocumentRenderEngine,
334
+ artifact_path: Path,
335
+ ) -> RenderArtifactRecord:
336
+ render_artifact = _write_or_reuse_derivative(
337
+ artifact,
338
+ store,
339
+ artifact_id=render_artifact_id,
340
+ lineage=ArtifactLineage.render,
341
+ destination_name=f"{render_artifact_id}.{render_extension}",
342
+ payload=render_payload,
343
+ document_format=artifact.format,
344
+ mime_type=render_mime_type,
345
+ )
346
+ raster_update = (
347
+ _optional_png_render_update(
348
+ store,
349
+ artifact,
350
+ render_artifact_id=render_artifact.artifact_id,
351
+ render_path=Path(render_artifact.source_path),
352
+ )
353
+ if render_mime_type == "image/svg+xml"
354
+ else {}
355
+ )
356
+ return _render_artifact_record(
357
+ artifact=artifact,
358
+ render_artifact=render_artifact,
359
+ render_mime_type=render_mime_type,
360
+ raster_artifact_ref=cast(str | None, raster_update.get("raster_artifact_ref")),
361
+ raster_artifact_path=cast(Path | None, raster_update.get("raster_artifact_path")),
362
+ raster_mime_type=cast(str | None, raster_update.get("raster_mime_type")),
363
+ page_number=page_number,
364
+ correlation_id=correlation_id,
365
+ engine=engine,
366
+ artifact_path=artifact_path,
367
+ )
368
+
369
+
370
+ def _render_artifact_record(
371
+ *,
372
+ artifact: DocumentArtifact,
373
+ render_artifact: DocumentArtifact,
374
+ render_mime_type: str,
375
+ raster_artifact_ref: str | None,
376
+ raster_artifact_path: Path | None,
377
+ raster_mime_type: str | None,
378
+ page_number: int,
379
+ correlation_id: str,
380
+ engine: DocumentRenderEngine,
381
+ artifact_path: Path,
382
+ ) -> RenderArtifactRecord:
383
+ return RenderArtifactRecord(
384
+ render_artifact_id=render_artifact.artifact_id,
385
+ source_artifact_id=artifact.artifact_id,
386
+ source_sha256=artifact.sha256,
387
+ render_sha256=render_artifact.sha256,
388
+ render_path=render_artifact.source_path,
389
+ render_mime_type=render_mime_type,
390
+ raster_artifact_ref=raster_artifact_ref,
391
+ raster_artifact_path=raster_artifact_path,
392
+ raster_mime_type=raster_mime_type,
393
+ page_number=page_number,
394
+ correlation_id=correlation_id,
395
+ engine_id=_render_engine_id(engine, artifact_path=artifact_path),
396
+ )
397
+
398
+
399
+ def _viewport_cameras_for_page(
400
+ viewports: tuple[DocumentChangedViewport, ...],
401
+ *,
402
+ source_render_artifact_id: str,
403
+ baseline_render_artifact_id: str,
404
+ page_index: int,
405
+ ) -> tuple[DocumentViewportCamera, ...]:
406
+ return tuple(
407
+ DocumentViewportCamera(
408
+ source_render_artifact_id=source_render_artifact_id,
409
+ baseline_render_artifact_id=baseline_render_artifact_id,
410
+ page_index=page_index,
411
+ viewport_rect=viewport.clip_rect,
412
+ zoom=Decimal("1"),
413
+ change_ids=viewport.change_ids,
414
+ )
415
+ for viewport in viewports
416
+ )
417
+
418
+
419
+ def _write_changed_viewport_artifacts(
420
+ store: DocumentArtifactStore,
421
+ artifact: DocumentArtifact,
422
+ *,
423
+ after_render_payload: bytes,
424
+ before_artifact: DocumentArtifact | None = None,
425
+ before_render_payload: bytes | None = None,
426
+ viewports: tuple[DocumentChangedViewport, ...],
427
+ ) -> tuple[DocumentChangedViewport, ...]:
428
+ updated: list[DocumentChangedViewport] = []
429
+ for viewport in viewports:
430
+ after_viewport_payload = _viewport_svg_payload(
431
+ after_render_payload,
432
+ viewport_id=viewport.viewport_id,
433
+ clip_rect=viewport.clip_rect,
434
+ )
435
+ after_viewport_artifact = _write_or_reuse_derivative(
436
+ artifact,
437
+ store,
438
+ artifact_id=viewport.viewport_id,
439
+ lineage=ArtifactLineage.render,
440
+ destination_name=f"{viewport.viewport_id}.svg",
441
+ payload=after_viewport_payload,
442
+ document_format=artifact.format,
443
+ mime_type="image/svg+xml",
444
+ )
445
+ after_png_update = _optional_png_viewport_update(
446
+ store,
447
+ artifact,
448
+ viewport_id=viewport.viewport_id,
449
+ viewport_svg_path=Path(after_viewport_artifact.source_path),
450
+ )
451
+ update: dict[str, str | Path | None] = {
452
+ "svg_artifact_ref": after_viewport_artifact.artifact_id,
453
+ "svg_artifact_path": after_viewport_artifact.source_path,
454
+ "after_svg_artifact_ref": after_viewport_artifact.artifact_id,
455
+ "after_svg_artifact_path": after_viewport_artifact.source_path,
456
+ **after_png_update,
457
+ **_prefixed_png_update(after_png_update, prefix="after"),
458
+ }
459
+ if before_artifact is not None and before_render_payload is not None:
460
+ before_viewport_id = f"{viewport.viewport_id}-before"
461
+ before_viewport_payload = _viewport_svg_payload(
462
+ before_render_payload,
463
+ viewport_id=before_viewport_id,
464
+ clip_rect=viewport.clip_rect,
465
+ )
466
+ before_viewport_artifact = _write_or_reuse_derivative(
467
+ before_artifact,
468
+ store,
469
+ artifact_id=before_viewport_id,
470
+ lineage=ArtifactLineage.render,
471
+ destination_name=f"{before_viewport_id}.svg",
472
+ payload=before_viewport_payload,
473
+ document_format=before_artifact.format,
474
+ mime_type="image/svg+xml",
475
+ )
476
+ before_png_update = _optional_png_viewport_update(
477
+ store,
478
+ before_artifact,
479
+ viewport_id=before_viewport_id,
480
+ viewport_svg_path=Path(before_viewport_artifact.source_path),
481
+ )
482
+ update.update(
483
+ {
484
+ "before_svg_artifact_ref": before_viewport_artifact.artifact_id,
485
+ "before_svg_artifact_path": before_viewport_artifact.source_path,
486
+ **_prefixed_png_update(before_png_update, prefix="before"),
487
+ }
488
+ )
489
+ updated.append(
490
+ viewport.model_copy(
491
+ update=update,
492
+ )
493
+ )
494
+ return tuple(updated)
495
+
496
+
497
+ def _prefixed_png_update(
498
+ png_update: dict[str, str | Path],
499
+ *,
500
+ prefix: str,
501
+ ) -> dict[str, str | Path]:
502
+ prefixed: dict[str, str | Path] = {}
503
+ if "png_artifact_ref" in png_update:
504
+ prefixed[f"{prefix}_png_artifact_ref"] = png_update["png_artifact_ref"]
505
+ if "png_artifact_path" in png_update:
506
+ prefixed[f"{prefix}_png_artifact_path"] = png_update["png_artifact_path"]
507
+ return prefixed
508
+
509
+
510
+ def _optional_png_viewport_update(
511
+ store: DocumentArtifactStore,
512
+ artifact: DocumentArtifact,
513
+ *,
514
+ viewport_id: str,
515
+ viewport_svg_path: Path,
516
+ ) -> dict[str, str | Path]:
517
+ return _optional_svg_to_png_update(
518
+ store,
519
+ artifact,
520
+ png_artifact_id=f"{viewport_id}-png",
521
+ svg_path=viewport_svg_path,
522
+ )
523
+
524
+
525
+ def _optional_svg_to_png_update(
526
+ store: DocumentArtifactStore,
527
+ artifact: DocumentArtifact,
528
+ *,
529
+ png_artifact_id: str,
530
+ svg_path: Path,
531
+ ) -> dict[str, str | Path]:
532
+ rasterizer = shutil.which("rsvg-convert")
533
+ if rasterizer is None:
534
+ return {}
535
+
536
+ png_path = (
537
+ store.session_root / "render" / f"{png_artifact_id}-raster-tmp" / f"{png_artifact_id}.png"
538
+ )
539
+ png_path.parent.mkdir(parents=True, exist_ok=True)
540
+ # Local rasterizer only, no shell: fixed executable plus local artifact paths.
541
+ completed = subprocess.run( # noqa: S603
542
+ [rasterizer, str(svg_path), "-o", str(png_path)],
543
+ check=False,
544
+ stdout=subprocess.DEVNULL,
545
+ stderr=subprocess.DEVNULL,
546
+ )
547
+ if completed.returncode != 0 or not png_path.is_file():
548
+ return {}
549
+
550
+ png_payload = png_path.read_bytes()
551
+ png_artifact = _write_or_reuse_derivative(
552
+ artifact,
553
+ store,
554
+ artifact_id=png_artifact_id,
555
+ lineage=ArtifactLineage.render,
556
+ destination_name=f"{png_artifact_id}.png",
557
+ payload=png_payload,
558
+ document_format=artifact.format,
559
+ mime_type="image/png",
560
+ )
561
+ return {
562
+ "png_artifact_ref": png_artifact.artifact_id,
563
+ "png_artifact_path": png_artifact.source_path,
564
+ }
565
+
566
+
567
+ def _optional_png_render_update(
568
+ store: DocumentArtifactStore,
569
+ artifact: DocumentArtifact,
570
+ *,
571
+ render_artifact_id: str,
572
+ render_path: Path,
573
+ ) -> dict[str, str | Path]:
574
+ png_update = _optional_svg_to_png_update(
575
+ store,
576
+ artifact,
577
+ png_artifact_id=f"{render_artifact_id}-png",
578
+ svg_path=render_path,
579
+ )
580
+ if not png_update:
581
+ return {}
582
+ return {
583
+ "raster_artifact_ref": png_update["png_artifact_ref"],
584
+ "raster_artifact_path": png_update["png_artifact_path"],
585
+ "raster_mime_type": "image/png",
586
+ }
587
+
588
+
589
+ def _viewport_svg_payload(
590
+ render_payload: bytes,
591
+ *,
592
+ viewport_id: str,
593
+ clip_rect: DocumentClipRect,
594
+ ) -> bytes:
595
+ svg = render_payload.decode("utf-8")
596
+ clip_x = float(clip_rect.x)
597
+ clip_y = float(clip_rect.y)
598
+ clip_width = max(float(clip_rect.width), 1.0)
599
+ clip_height = max(float(clip_rect.height), 1.0)
600
+ escaped_viewport_id = html.escape(viewport_id, quote=True)
601
+ return (
602
+ '<svg xmlns="http://www.w3.org/2000/svg" '
603
+ f'width="{clip_width:.2f}" height="{clip_height:.2f}" '
604
+ f'viewBox="{clip_x:.2f} {clip_y:.2f} {clip_width:.2f} {clip_height:.2f}" '
605
+ 'preserveAspectRatio="xMinYMin meet" overflow="hidden" '
606
+ f'data-ummaya-viewport-id="{escaped_viewport_id}">'
607
+ "<title>UMMAYA changed document viewport</title>"
608
+ f"{_svg_inner_markup(svg)}</svg>"
609
+ ).encode()
610
+
611
+
612
+ def _svg_inner_markup(svg: str) -> str:
613
+ match = re.search(r"<svg\b[^>]*>(?P<body>.*)</svg>", svg, re.DOTALL)
614
+ if match is None:
615
+ return html.escape(svg)
616
+ return match.group("body")
617
+
618
+
619
+ def _render_artifact_extension(
620
+ engine: DocumentRenderEngine,
621
+ *,
622
+ artifact_path: Path | None = None,
623
+ ) -> str:
624
+ dynamic_extension = getattr(engine, "render_artifact_extension_for", None)
625
+ if dynamic_extension is not None and artifact_path is not None:
626
+ extension = str(dynamic_extension(artifact_path)).lstrip(".").lower()
627
+ else:
628
+ extension = str(getattr(engine, "render_artifact_extension", "txt")).lstrip(".").lower()
629
+ supported_extensions = {"txt", "svg", "png", "pdf", "html"}
630
+ if extension not in supported_extensions:
631
+ raise ValueError(f"Unsupported render artifact extension: {extension}")
632
+ return extension
633
+
634
+
635
+ def _render_mime_type(
636
+ engine: DocumentRenderEngine,
637
+ *,
638
+ extension: str,
639
+ artifact_path: Path | None = None,
640
+ ) -> str:
641
+ dynamic_mime_type = getattr(engine, "render_mime_type_for", None)
642
+ if dynamic_mime_type is not None and artifact_path is not None:
643
+ return str(dynamic_mime_type(artifact_path))
644
+ explicit_mime_type = getattr(engine, "render_mime_type", None)
645
+ if explicit_mime_type is not None:
646
+ return str(explicit_mime_type)
647
+ return {
648
+ "html": "text/html",
649
+ "pdf": "application/pdf",
650
+ "png": "image/png",
651
+ "svg": "image/svg+xml",
652
+ "txt": "text/plain",
653
+ }[extension]
654
+
655
+
656
+ def _render_engine_id(
657
+ engine: DocumentRenderEngine,
658
+ *,
659
+ artifact_path: Path | None = None,
660
+ ) -> str:
661
+ dynamic_engine_id = getattr(engine, "render_engine_id_for", None)
662
+ if dynamic_engine_id is not None and artifact_path is not None:
663
+ return str(dynamic_engine_id(artifact_path))
664
+ return str(getattr(engine, "render_engine_id", engine.engine_id))
665
+
666
+
667
+ def _render_text_summary(
668
+ *,
669
+ record_count: int,
670
+ engine: DocumentRenderEngine,
671
+ render_passed: bool,
672
+ visual_diff_requested: bool,
673
+ changed_viewport_anchored: bool,
674
+ artifact_path: Path | None = None,
675
+ ) -> str:
676
+ if not render_passed:
677
+ return "Renderer produced no reviewer evidence artifacts."
678
+ engine_id = _render_engine_id(engine, artifact_path=artifact_path)
679
+ if changed_viewport_anchored:
680
+ return (
681
+ f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id} "
682
+ "with changed viewport evidence."
683
+ )
684
+ if visual_diff_requested:
685
+ return (
686
+ f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id}; "
687
+ "no changed viewport anchors matched the rendered page."
688
+ )
689
+ return f"Rendered {record_count} reviewer evidence artifact(s) through {engine_id}."
690
+
691
+
692
+ def _render_engine_failure_result(
693
+ artifact: DocumentArtifact,
694
+ *,
695
+ correlation_id: str,
696
+ engine: DocumentRenderEngine,
697
+ exc: Exception,
698
+ ) -> DocumentRenderResult:
699
+ return DocumentRenderResult(
700
+ status=ToolResultStatus.blocked,
701
+ correlation_id=correlation_id,
702
+ source_artifact_id=artifact.artifact_id,
703
+ source_sha256=artifact.sha256,
704
+ render_passed=False,
705
+ blocked_reason=BlockedReason.validation_failed,
706
+ text_summary=_render_engine_failure_summary(engine=engine, exc=exc),
707
+ )
708
+
709
+
710
+ def _render_engine_failure_summary(
711
+ *,
712
+ engine: DocumentRenderEngine,
713
+ exc: Exception,
714
+ ) -> str:
715
+ message = str(exc).strip() or exc.__class__.__name__
716
+ if len(message) > 300:
717
+ message = f"{message[:300]}..."
718
+ return f"Document render failed through {_render_engine_id(engine)}: {message}"
719
+
720
+
721
+ def _require_render_engine(
722
+ engine_registry: DocumentEngineRegistry,
723
+ artifact: DocumentArtifact,
724
+ ) -> DocumentRenderEngine:
725
+ engine = engine_registry.require(artifact.format)
726
+ if not isinstance(engine, DocumentRenderEngine):
727
+ raise UnsupportedDocumentEngineError(artifact.format)
728
+ return engine
729
+
730
+
731
+ def _blocked_render_promotion_gate(artifact: DocumentArtifact) -> PromotionGateResult:
732
+ failure_id = (
733
+ "hwpx_render_engine_unpromoted"
734
+ if artifact.format is DocumentFormat.hwpx
735
+ else f"{artifact.format.value}_render_engine_unavailable"
736
+ )
737
+ return PromotionGateResult(
738
+ gate_id=f"gate-{artifact.artifact_id}-render",
739
+ profile_id=f"profile-{artifact.format.value}",
740
+ capability=PromotionCapability.render,
741
+ score_total=0,
742
+ extraction_fidelity=0,
743
+ write_fidelity=0,
744
+ style_layout_control=0,
745
+ deterministic_round_trip=0,
746
+ public_form_validation=0,
747
+ security_privacy=0,
748
+ license_maintenance_tool_usability=0,
749
+ hard_gates_passed=False,
750
+ hard_gate_failures=[failure_id],
751
+ promotion_state=PromotionState.blocked,
752
+ promotion_checklist=_promotion_checklist_for_render(artifact),
753
+ evidence_record_ids=[],
754
+ )
755
+
756
+
757
+ def _promotion_checklist_for_render(artifact: DocumentArtifact) -> list[PromotionChecklistItem]:
758
+ if artifact.format is not DocumentFormat.hwpx:
759
+ return []
760
+ evidence_items = {
761
+ "page_geometry": "Rendered pages preserve paper size, margins, and page breaks.",
762
+ "table_spans": "Rendered tables preserve row spans, column spans, borders, and cell text.",
763
+ "font_fallback": (
764
+ "Korean fonts resolve deterministically without missing-glyph fallback drift."
765
+ ),
766
+ "korean_line_breaks": "Korean line breaks and paragraph spacing match the source fixture.",
767
+ "visible_field_values": (
768
+ "Filled values are visible at the expected anchors in the rendered output."
769
+ ),
770
+ "package_integrity": "The HWPX package remains valid after render preparation.",
771
+ "no_external_egress": (
772
+ "Rendering uses only local files and performs no external network egress."
773
+ ),
774
+ }
775
+ return [
776
+ PromotionChecklistItem(
777
+ check_id=check_id,
778
+ capability=PromotionCapability.render,
779
+ status=PromotionChecklistStatus.required,
780
+ evidence_required=evidence_required,
781
+ )
782
+ for check_id, evidence_required in evidence_items.items()
783
+ ]
784
+
785
+
786
+ def _unsupported_render_summary(artifact: DocumentArtifact) -> str:
787
+ if artifact.format is DocumentFormat.hwpx:
788
+ return (
789
+ "HWPX visual render is not promoted for the hwpx-package-text engine. "
790
+ "Use extraction, structured diff, and public-form validation evidence "
791
+ "until an HWPX renderer passes the visual fixture gate."
792
+ )
793
+ return f"No render-capable engine is registered for {artifact.format.value}."
794
+
795
+
796
+ _TEXT_ELEMENT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<text>.*?)</text>", re.DOTALL)
797
+ _NUMBER_ATTR_RE = re.compile(r'\b(?P<name>x|y|font-size|textLength)="(?P<value>-?\d+(?:\.\d+)?)"')
798
+ _MIN_CHANGED_VIEWPORT_WIDTH = 240.0
799
+ _MIN_CHANGED_VIEWPORT_HEIGHT = 140.0
800
+
801
+
802
+ class _SvgTextRun(BaseModel):
803
+ """One positioned SVG text run extracted from renderer output."""
804
+
805
+ model_config = ConfigDict(frozen=True)
806
+
807
+ index: int
808
+ x: float
809
+ y: float
810
+ width: float
811
+ height: float
812
+ text: str
813
+
814
+
815
+ class _SvgViewportMatch(BaseModel):
816
+ """Matched SVG visual change location plus its page crop rectangle."""
817
+
818
+ model_config = ConfigDict(frozen=True)
819
+
820
+ clip_x: float
821
+ clip_y: float
822
+ clip_width: float
823
+ clip_height: float
824
+ text_fallback: tuple[str, ...]
825
+
826
+
827
+ def _detect_changed_viewports(
828
+ payload: bytes,
829
+ *,
830
+ diff: DocumentDiff | None,
831
+ mime_type: str,
832
+ page_number: int,
833
+ render_artifact_id: str,
834
+ ) -> tuple[bytes, bool, tuple[DocumentChangedViewport, ...]]:
835
+ if diff is None or mime_type != "image/svg+xml":
836
+ return payload, False, ()
837
+
838
+ svg = payload.decode("utf-8")
839
+ runs = _svg_text_runs(svg)
840
+ changed_viewports: list[DocumentChangedViewport] = []
841
+ for change in diff.changes:
842
+ viewport = _svg_viewport_for_change(change, runs=runs, page_number=page_number)
843
+ if viewport is None:
844
+ continue
845
+ changed_viewports.append(
846
+ DocumentChangedViewport(
847
+ viewport_id=f"viewport-{render_artifact_id}-{change.change_id}",
848
+ change_ids=(change.change_id,),
849
+ page_number=page_number,
850
+ source_render_artifact_id=render_artifact_id,
851
+ clip_rect=DocumentClipRect(
852
+ x=_decimal(viewport.clip_x),
853
+ y=_decimal(viewport.clip_y),
854
+ width=_decimal(viewport.clip_width),
855
+ height=_decimal(viewport.clip_height),
856
+ ),
857
+ svg_artifact_ref=render_artifact_id,
858
+ text_fallback=viewport.text_fallback,
859
+ anchor_strategy="exact_text_run",
860
+ confidence=Decimal("0.90"),
861
+ )
862
+ )
863
+ if not changed_viewports:
864
+ return payload, False, ()
865
+
866
+ return payload, True, tuple(changed_viewports)
867
+
868
+
869
+ def _svg_text_runs(svg: str) -> list[_SvgTextRun]:
870
+ runs: list[_SvgTextRun] = []
871
+ for index, match in enumerate(_TEXT_ELEMENT_RE.finditer(svg)):
872
+ attrs = _number_attrs(match.group("attrs"))
873
+ text = html.unescape(re.sub(r"<[^>]+>", "", match.group("text")))
874
+ if not text:
875
+ continue
876
+ x = attrs.get("x")
877
+ y = attrs.get("y")
878
+ font_size = attrs.get("font-size", 12.0)
879
+ if x is None or y is None:
880
+ continue
881
+ width = attrs.get("textLength", _estimated_text_width(text, font_size))
882
+ runs.append(
883
+ _SvgTextRun(
884
+ index=index,
885
+ x=x,
886
+ y=y,
887
+ width=width,
888
+ height=max(font_size, 8.0),
889
+ text=text,
890
+ )
891
+ )
892
+ return runs
893
+
894
+
895
+ def _number_attrs(attrs: str) -> dict[str, float]:
896
+ return {
897
+ match.group("name"): float(match.group("value"))
898
+ for match in _NUMBER_ATTR_RE.finditer(attrs)
899
+ }
900
+
901
+
902
+ def _estimated_text_width(text: str, font_size: float) -> float:
903
+ width = 0.0
904
+ for character in text:
905
+ width += font_size if ord(character) > 0x7F else font_size * 0.55
906
+ return max(width, font_size * 0.55)
907
+
908
+
909
+ def _svg_viewport_for_change(
910
+ change: DocumentChange,
911
+ *,
912
+ runs: list[_SvgTextRun],
913
+ page_number: int,
914
+ ) -> _SvgViewportMatch | None:
915
+ if change.after_value is None:
916
+ return None
917
+ matched_runs = _match_text_runs(runs, change.after_value)
918
+ if not matched_runs:
919
+ return None
920
+
921
+ x1 = min(run.x for run in matched_runs)
922
+ y1 = min(run.y - run.height for run in matched_runs) - 3
923
+ x2 = max(run.x + run.width for run in matched_runs)
924
+ y2 = max(run.y + 3 for run in matched_runs)
925
+ width = max(x2 - x1 + 6, 10)
926
+ height = max(y2 - y1 + 6, 10)
927
+ center_x = (x1 + x2) / 2
928
+ center_y = (y1 + y2) / 2
929
+ clip_width = max(width + 96.0, _MIN_CHANGED_VIEWPORT_WIDTH)
930
+ clip_height = max(height + 96.0, _MIN_CHANGED_VIEWPORT_HEIGHT)
931
+ clip_x = max(0.0, center_x - (clip_width / 2))
932
+ clip_y = max(0.0, center_y - (clip_height / 2))
933
+ return _SvgViewportMatch(
934
+ clip_x=clip_x,
935
+ clip_y=clip_y,
936
+ clip_width=clip_width,
937
+ clip_height=clip_height,
938
+ text_fallback=_change_text_fallback(change, page_number),
939
+ )
940
+
941
+
942
+ def _match_text_runs(runs: list[_SvgTextRun], value: str) -> list[_SvgTextRun]:
943
+ target = _normalize_match_text(value)
944
+ if not target:
945
+ return []
946
+
947
+ for start_index in range(len(runs)):
948
+ matched: list[_SvgTextRun] = []
949
+ observed = ""
950
+ for run in runs[start_index:]:
951
+ normalized = _normalize_match_text(run.text)
952
+ if not normalized:
953
+ continue
954
+ observed += normalized
955
+ matched.append(run)
956
+ if target.startswith(observed):
957
+ if observed == target:
958
+ return matched
959
+ continue
960
+ break
961
+ return []
962
+
963
+
964
+ def _normalize_match_text(value: str) -> str:
965
+ return "".join(value.split())
966
+
967
+
968
+ def _change_text_fallback(change: DocumentChange, page_number: int) -> tuple[str, ...]:
969
+ lines = [f"Page {page_number} · {change.change_id} · {change.target_path}"]
970
+ if change.before_value is not None:
971
+ lines.append(f"- {change.before_value}")
972
+ if change.after_value is not None:
973
+ lines.append(f"+ {change.after_value}")
974
+ return tuple(lines)
975
+
976
+
977
+ def _decimal(value: float) -> Decimal:
978
+ return Decimal(f"{value:.2f}")