@salesforce/afv-skills 1.14.0 → 1.16.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 (365) hide show
  1. package/package.json +1 -1
  2. package/skills/activating-datacloud/SKILL.md +0 -1
  3. package/skills/analyzing-omnistudio-dependencies/SKILL.md +0 -1
  4. package/skills/applying-slds/SKILL.md +322 -0
  5. package/skills/applying-slds/checklists.md +83 -0
  6. package/skills/applying-slds/examples.md +283 -0
  7. package/skills/applying-slds/guidance/README.md +83 -0
  8. package/skills/applying-slds/guidance/blueprints-index.md +213 -0
  9. package/skills/applying-slds/guidance/icons-guidance.md +186 -0
  10. package/skills/applying-slds/guidance/overviews/borders.md +236 -0
  11. package/skills/applying-slds/guidance/overviews/color.md +266 -0
  12. package/skills/applying-slds/guidance/overviews/display-density.md +366 -0
  13. package/skills/applying-slds/guidance/overviews/icons.md +240 -0
  14. package/skills/applying-slds/guidance/overviews/illustrations.md +235 -0
  15. package/skills/applying-slds/guidance/overviews/shadows.md +176 -0
  16. package/skills/applying-slds/guidance/overviews/spacing.md +216 -0
  17. package/skills/applying-slds/guidance/overviews/typography.md +323 -0
  18. package/skills/applying-slds/guidance/overviews/utilities.md +542 -0
  19. package/skills/applying-slds/guidance/slds-development-guide.md +288 -0
  20. package/skills/applying-slds/guidance/styling-hooks/borders.md +202 -0
  21. package/skills/applying-slds/guidance/styling-hooks/color/expressive-palette-hooks.md +153 -0
  22. package/skills/applying-slds/guidance/styling-hooks/color/index.md +171 -0
  23. package/skills/applying-slds/guidance/styling-hooks/color/semantic/accent-hooks.md +204 -0
  24. package/skills/applying-slds/guidance/styling-hooks/color/semantic/feedback-hooks.md +768 -0
  25. package/skills/applying-slds/guidance/styling-hooks/color/semantic/surface-hooks.md +337 -0
  26. package/skills/applying-slds/guidance/styling-hooks/color/system-hooks.md +132 -0
  27. package/skills/applying-slds/guidance/styling-hooks/index.md +327 -0
  28. package/skills/applying-slds/guidance/styling-hooks/shadows.md +238 -0
  29. package/skills/applying-slds/guidance/styling-hooks/spacing.md +254 -0
  30. package/skills/applying-slds/guidance/styling-hooks/typography.md +448 -0
  31. package/skills/applying-slds/guidance/utilities/alignment.md +119 -0
  32. package/skills/applying-slds/guidance/utilities/borders.md +131 -0
  33. package/skills/applying-slds/guidance/utilities/box.md +125 -0
  34. package/skills/applying-slds/guidance/utilities/color.md +165 -0
  35. package/skills/applying-slds/guidance/utilities/dark-mode.md +111 -0
  36. package/skills/applying-slds/guidance/utilities/description-list.md +168 -0
  37. package/skills/applying-slds/guidance/utilities/floats.md +117 -0
  38. package/skills/applying-slds/guidance/utilities/grid.md +264 -0
  39. package/skills/applying-slds/guidance/utilities/horizontal-list.md +110 -0
  40. package/skills/applying-slds/guidance/utilities/hyphenation.md +84 -0
  41. package/skills/applying-slds/guidance/utilities/index.md +205 -0
  42. package/skills/applying-slds/guidance/utilities/interactions.md +89 -0
  43. package/skills/applying-slds/guidance/utilities/layout.md +109 -0
  44. package/skills/applying-slds/guidance/utilities/line-clamp.md +131 -0
  45. package/skills/applying-slds/guidance/utilities/margin.md +155 -0
  46. package/skills/applying-slds/guidance/utilities/media-object.md +161 -0
  47. package/skills/applying-slds/guidance/utilities/name-value-list.md +152 -0
  48. package/skills/applying-slds/guidance/utilities/padding.md +155 -0
  49. package/skills/applying-slds/guidance/utilities/position.md +177 -0
  50. package/skills/applying-slds/guidance/utilities/print.md +114 -0
  51. package/skills/applying-slds/guidance/utilities/scrollable.md +126 -0
  52. package/skills/applying-slds/guidance/utilities/sizing.md +190 -0
  53. package/skills/applying-slds/guidance/utilities/themes.md +121 -0
  54. package/skills/applying-slds/guidance/utilities/truncate.md +127 -0
  55. package/skills/applying-slds/guidance/utilities/typography.md +166 -0
  56. package/skills/applying-slds/guidance/utilities/vertical-list.md +166 -0
  57. package/skills/applying-slds/guidance/utilities/visibility.md +228 -0
  58. package/skills/applying-slds/metadata/README.md +84 -0
  59. package/skills/applying-slds/metadata/blueprints/components/accordion.yaml +304 -0
  60. package/skills/applying-slds/metadata/blueprints/components/activity-timeline.yaml +92 -0
  61. package/skills/applying-slds/metadata/blueprints/components/alert.yaml +103 -0
  62. package/skills/applying-slds/metadata/blueprints/components/app-launcher.yaml +94 -0
  63. package/skills/applying-slds/metadata/blueprints/components/avatar-group.yaml +81 -0
  64. package/skills/applying-slds/metadata/blueprints/components/avatar.yaml +97 -0
  65. package/skills/applying-slds/metadata/blueprints/components/badges.yaml +102 -0
  66. package/skills/applying-slds/metadata/blueprints/components/brand-band.yaml +198 -0
  67. package/skills/applying-slds/metadata/blueprints/components/breadcrumbs.yaml +95 -0
  68. package/skills/applying-slds/metadata/blueprints/components/builder-header.yaml +192 -0
  69. package/skills/applying-slds/metadata/blueprints/components/button-groups.yaml +82 -0
  70. package/skills/applying-slds/metadata/blueprints/components/button-icons.yaml +295 -0
  71. package/skills/applying-slds/metadata/blueprints/components/buttons.yaml +230 -0
  72. package/skills/applying-slds/metadata/blueprints/components/cards.yaml +124 -0
  73. package/skills/applying-slds/metadata/blueprints/components/carousel.yaml +140 -0
  74. package/skills/applying-slds/metadata/blueprints/components/chat.yaml +179 -0
  75. package/skills/applying-slds/metadata/blueprints/components/checkbox-button-group.yaml +192 -0
  76. package/skills/applying-slds/metadata/blueprints/components/checkbox-button.yaml +204 -0
  77. package/skills/applying-slds/metadata/blueprints/components/checkbox-toggle.yaml +177 -0
  78. package/skills/applying-slds/metadata/blueprints/components/checkbox.yaml +108 -0
  79. package/skills/applying-slds/metadata/blueprints/components/color-picker.yaml +172 -0
  80. package/skills/applying-slds/metadata/blueprints/components/combobox.yaml +136 -0
  81. package/skills/applying-slds/metadata/blueprints/components/counter.yaml +147 -0
  82. package/skills/applying-slds/metadata/blueprints/components/data-tables.yaml +157 -0
  83. package/skills/applying-slds/metadata/blueprints/components/datepickers.yaml +130 -0
  84. package/skills/applying-slds/metadata/blueprints/components/datetime-picker.yaml +155 -0
  85. package/skills/applying-slds/metadata/blueprints/components/docked-composer.yaml +201 -0
  86. package/skills/applying-slds/metadata/blueprints/components/docked-form-footer.yaml +161 -0
  87. package/skills/applying-slds/metadata/blueprints/components/docked-utility-bar.yaml +175 -0
  88. package/skills/applying-slds/metadata/blueprints/components/drop-zone.yaml +115 -0
  89. package/skills/applying-slds/metadata/blueprints/components/dueling-picklist.yaml +196 -0
  90. package/skills/applying-slds/metadata/blueprints/components/dynamic-icons.yaml +128 -0
  91. package/skills/applying-slds/metadata/blueprints/components/dynamic-menu.yaml +141 -0
  92. package/skills/applying-slds/metadata/blueprints/components/expandable-section.yaml +115 -0
  93. package/skills/applying-slds/metadata/blueprints/components/expression.yaml +143 -0
  94. package/skills/applying-slds/metadata/blueprints/components/feeds.yaml +125 -0
  95. package/skills/applying-slds/metadata/blueprints/components/file-selector.yaml +154 -0
  96. package/skills/applying-slds/metadata/blueprints/components/files.yaml +119 -0
  97. package/skills/applying-slds/metadata/blueprints/components/form-element.yaml +145 -0
  98. package/skills/applying-slds/metadata/blueprints/components/global-header.yaml +120 -0
  99. package/skills/applying-slds/metadata/blueprints/components/global-navigation.yaml +100 -0
  100. package/skills/applying-slds/metadata/blueprints/components/icons.yaml +138 -0
  101. package/skills/applying-slds/metadata/blueprints/components/illustration.yaml +205 -0
  102. package/skills/applying-slds/metadata/blueprints/components/input.yaml +151 -0
  103. package/skills/applying-slds/metadata/blueprints/components/list-builder.yaml +127 -0
  104. package/skills/applying-slds/metadata/blueprints/components/lookups.yaml +132 -0
  105. package/skills/applying-slds/metadata/blueprints/components/map.yaml +118 -0
  106. package/skills/applying-slds/metadata/blueprints/components/menus.yaml +134 -0
  107. package/skills/applying-slds/metadata/blueprints/components/modals.yaml +152 -0
  108. package/skills/applying-slds/metadata/blueprints/components/notifications.yaml +88 -0
  109. package/skills/applying-slds/metadata/blueprints/components/page-headers.yaml +135 -0
  110. package/skills/applying-slds/metadata/blueprints/components/panels.yaml +149 -0
  111. package/skills/applying-slds/metadata/blueprints/components/path.yaml +154 -0
  112. package/skills/applying-slds/metadata/blueprints/components/picklist.yaml +125 -0
  113. package/skills/applying-slds/metadata/blueprints/components/pills.yaml +154 -0
  114. package/skills/applying-slds/metadata/blueprints/components/popovers.yaml +120 -0
  115. package/skills/applying-slds/metadata/blueprints/components/progress-bar.yaml +110 -0
  116. package/skills/applying-slds/metadata/blueprints/components/progress-indicator.yaml +133 -0
  117. package/skills/applying-slds/metadata/blueprints/components/progress-ring.yaml +102 -0
  118. package/skills/applying-slds/metadata/blueprints/components/prompt.yaml +126 -0
  119. package/skills/applying-slds/metadata/blueprints/components/publishers.yaml +178 -0
  120. package/skills/applying-slds/metadata/blueprints/components/radio-button-group.yaml +172 -0
  121. package/skills/applying-slds/metadata/blueprints/components/radio-group.yaml +112 -0
  122. package/skills/applying-slds/metadata/blueprints/components/rich-text-editor.yaml +135 -0
  123. package/skills/applying-slds/metadata/blueprints/components/scoped-notifications.yaml +188 -0
  124. package/skills/applying-slds/metadata/blueprints/components/scoped-tabs.yaml +97 -0
  125. package/skills/applying-slds/metadata/blueprints/components/select.yaml +127 -0
  126. package/skills/applying-slds/metadata/blueprints/components/setup-assistant.yaml +152 -0
  127. package/skills/applying-slds/metadata/blueprints/components/slider.yaml +111 -0
  128. package/skills/applying-slds/metadata/blueprints/components/spinners.yaml +135 -0
  129. package/skills/applying-slds/metadata/blueprints/components/split-view.yaml +112 -0
  130. package/skills/applying-slds/metadata/blueprints/components/summary-detail.yaml +103 -0
  131. package/skills/applying-slds/metadata/blueprints/components/tabs.yaml +138 -0
  132. package/skills/applying-slds/metadata/blueprints/components/textarea.yaml +116 -0
  133. package/skills/applying-slds/metadata/blueprints/components/tiles.yaml +108 -0
  134. package/skills/applying-slds/metadata/blueprints/components/timepicker.yaml +111 -0
  135. package/skills/applying-slds/metadata/blueprints/components/toast.yaml +154 -0
  136. package/skills/applying-slds/metadata/blueprints/components/tooltips.yaml +107 -0
  137. package/skills/applying-slds/metadata/blueprints/components/tree-grid.yaml +116 -0
  138. package/skills/applying-slds/metadata/blueprints/components/trees.yaml +116 -0
  139. package/skills/applying-slds/metadata/blueprints/components/trial-bar.yaml +112 -0
  140. package/skills/applying-slds/metadata/blueprints/components/vertical-navigation.yaml +130 -0
  141. package/skills/applying-slds/metadata/blueprints/components/vertical-tabs.yaml +140 -0
  142. package/skills/applying-slds/metadata/blueprints/components/visual-picker.yaml +150 -0
  143. package/skills/applying-slds/metadata/blueprints/components/welcome-mat.yaml +136 -0
  144. package/skills/applying-slds/metadata/hooks-index.json +6272 -0
  145. package/skills/applying-slds/metadata/icon-metadata.json +38466 -0
  146. package/skills/applying-slds/metadata/utilities-index.json +21912 -0
  147. package/skills/applying-slds/references/component-selection.md +112 -0
  148. package/skills/applying-slds/references/icons-decision-guide.md +124 -0
  149. package/skills/applying-slds/references/styling-decision-guide.md +228 -0
  150. package/skills/applying-slds/references/utilities-quick-ref.md +125 -0
  151. package/skills/applying-slds/scripts/search-blueprints.cjs +117 -0
  152. package/skills/applying-slds/scripts/search-hooks.cjs +139 -0
  153. package/skills/applying-slds/scripts/search-icons.cjs +174 -0
  154. package/skills/applying-slds/scripts/search-utilities.cjs +161 -0
  155. package/skills/building-mobile-apps/SKILL.md +0 -1
  156. package/skills/building-omnistudio-callable-apex/SKILL.md +0 -1
  157. package/skills/building-omnistudio-datamapper/SKILL.md +0 -1
  158. package/skills/building-omnistudio-flexcard/SKILL.md +0 -1
  159. package/skills/building-omnistudio-integration-procedure/SKILL.md +0 -1
  160. package/skills/building-omnistudio-omniscript/SKILL.md +0 -1
  161. package/skills/building-sf-integrations/SKILL.md +0 -1
  162. package/skills/configuring-connected-apps/SKILL.md +0 -1
  163. package/skills/connecting-datacloud/SKILL.md +0 -1
  164. package/skills/creating-b2b-commerce-store/SKILL.md +0 -1
  165. package/skills/debugging-apex-logs/SKILL.md +0 -1
  166. package/skills/deploying-metadata/SKILL.md +0 -1
  167. package/skills/deploying-omnistudio-datapacks/SKILL.md +0 -1
  168. package/skills/developing-agentforce/SKILL.md +0 -1
  169. package/skills/fetching-salesforce-docs/SKILL.md +0 -1
  170. package/skills/generating-custom-lightning-type/SKILL.md +17 -39
  171. package/skills/generating-custom-lightning-type/assets/primitive-types-and-constraints.md +41 -0
  172. package/skills/generating-custom-lightning-type/references/widget-rendition.md +124 -0
  173. package/skills/generating-lwc-components/SKILL.md +0 -1
  174. package/skills/generating-mermaid-diagrams/SKILL.md +0 -1
  175. package/skills/generating-visual-diagrams/SKILL.md +0 -1
  176. package/skills/handling-sf-data/SKILL.md +0 -1
  177. package/skills/harmonizing-datacloud/SKILL.md +0 -1
  178. package/skills/integrating-b2b-commerce-open-code-components/SKILL.md +0 -1
  179. package/skills/investigating-agentforce-architecture/README.md +156 -0
  180. package/skills/investigating-agentforce-architecture/SKILL.md +230 -0
  181. package/skills/investigating-agentforce-architecture/assets/cli/describe_sobject.yaml +16 -0
  182. package/skills/investigating-agentforce-architecture/assets/cli/describe_tooling_sobject.yaml +17 -0
  183. package/skills/investigating-agentforce-architecture/assets/cli/list_metadata_genaiprompttemplate.yaml +17 -0
  184. package/skills/investigating-agentforce-architecture/assets/cli/org_display.yaml +15 -0
  185. package/skills/investigating-agentforce-architecture/assets/cli/retrieve_genai_plugin.yaml +18 -0
  186. package/skills/investigating-agentforce-architecture/assets/cli/show_access_token.yaml +27 -0
  187. package/skills/investigating-agentforce-architecture/assets/mermaid/action_tree.mmd +20 -0
  188. package/skills/investigating-agentforce-architecture/assets/mermaid/data_flow.mmd +19 -0
  189. package/skills/investigating-agentforce-architecture/assets/mermaid/dependency_graph.mmd +19 -0
  190. package/skills/investigating-agentforce-architecture/assets/mermaid/invocation_sequence.mmd +20 -0
  191. package/skills/investigating-agentforce-architecture/assets/mermaid/planner_state.mmd +18 -0
  192. package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_ids.soql +3 -0
  193. package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_names.soql +3 -0
  194. package/skills/investigating-agentforce-architecture/assets/soql/bot_definition_details.soql +3 -0
  195. package/skills/investigating-agentforce-architecture/assets/soql/bot_version_lookup.soql +4 -0
  196. package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_by_ids.soql +3 -0
  197. package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_ids_by_names.soql +3 -0
  198. package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_view_by_durable_ids.soql +4 -0
  199. package/skills/investigating-agentforce-architecture/assets/soql/flow_metadata_by_id.soql +3 -0
  200. package/skills/investigating-agentforce-architecture/assets/soql/functions_by_plugins.soql +5 -0
  201. package/skills/investigating-agentforce-architecture/assets/soql/planner_attrs_by_parent_ids.soql +3 -0
  202. package/skills/investigating-agentforce-architecture/assets/soql/planner_bundle_functions.soql +3 -0
  203. package/skills/investigating-agentforce-architecture/assets/soql/planner_definition_by_agent_chain.soql +3 -0
  204. package/skills/investigating-agentforce-architecture/assets/soql/plugin_functions_by_plugin_ids.soql +3 -0
  205. package/skills/investigating-agentforce-architecture/assets/soql/plugin_instructions_by_plugin_ids.soql +3 -0
  206. package/skills/investigating-agentforce-architecture/assets/soql/plugins_by_planner.soql +4 -0
  207. package/skills/investigating-agentforce-architecture/references/architecture_sections.md +243 -0
  208. package/skills/investigating-agentforce-architecture/references/contract.json +244 -0
  209. package/skills/investigating-agentforce-architecture/references/soql_fields.md +512 -0
  210. package/skills/investigating-agentforce-architecture/scripts/_shared/__init__.py +1 -0
  211. package/skills/investigating-agentforce-architecture/scripts/_shared/fs_guard.py +329 -0
  212. package/skills/investigating-agentforce-architecture/scripts/_shared/paths.py +110 -0
  213. package/skills/investigating-agentforce-architecture/scripts/_shared/runtime.py +59 -0
  214. package/skills/investigating-agentforce-architecture/scripts/_shared/sql.py +10 -0
  215. package/skills/investigating-agentforce-architecture/scripts/cache_check.py +234 -0
  216. package/skills/investigating-agentforce-architecture/scripts/config.py +131 -0
  217. package/skills/investigating-agentforce-architecture/scripts/fetch_soql.py +689 -0
  218. package/skills/investigating-agentforce-architecture/scripts/finalize.py +295 -0
  219. package/skills/investigating-agentforce-architecture/scripts/main.py +2835 -0
  220. package/skills/investigating-agentforce-architecture/scripts/metadata_listing.py +265 -0
  221. package/skills/investigating-agentforce-architecture/scripts/parallel_retrieve.py +69 -0
  222. package/skills/investigating-agentforce-architecture/scripts/parse_bundle.py +215 -0
  223. package/skills/investigating-agentforce-architecture/scripts/parse_wave.py +845 -0
  224. package/skills/investigating-agentforce-architecture/scripts/probe_channels.py +302 -0
  225. package/skills/investigating-agentforce-architecture/scripts/render_architecture.py +1043 -0
  226. package/skills/investigating-agentforce-architecture/scripts/resolve_bot.py +255 -0
  227. package/skills/investigating-agentforce-architecture/scripts/resolve_invocation_target.py +130 -0
  228. package/skills/investigating-agentforce-architecture/scripts/rest_client.py +763 -0
  229. package/skills/investigating-agentforce-architecture/scripts/retrieve_planner.py +13 -0
  230. package/skills/investigating-agentforce-architecture/scripts/sf_cli.py +242 -0
  231. package/skills/investigating-agentforce-architecture/scripts/soql_loader.py +253 -0
  232. package/skills/investigating-agentforce-architecture/scripts/summarize_tree.py +143 -0
  233. package/skills/investigating-agentforce-architecture/scripts/tests/__init__.py +0 -0
  234. package/skills/investigating-agentforce-architecture/scripts/tests/_bootstrap.py +23 -0
  235. package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/__init__.py +0 -0
  236. package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/genai_payloads.py +400 -0
  237. package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check.py +307 -0
  238. package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check_main.py +283 -0
  239. package/skills/investigating-agentforce-architecture/scripts/tests/test_config.py +115 -0
  240. package/skills/investigating-agentforce-architecture/scripts/tests/test_end_to_end_fixture.py +651 -0
  241. package/skills/investigating-agentforce-architecture/scripts/tests/test_finalize.py +278 -0
  242. package/skills/investigating-agentforce-architecture/scripts/tests/test_flow_children_inflation.py +582 -0
  243. package/skills/investigating-agentforce-architecture/scripts/tests/test_fs_guard.py +113 -0
  244. package/skills/investigating-agentforce-architecture/scripts/tests/test_iterative_wave_b.py +478 -0
  245. package/skills/investigating-agentforce-architecture/scripts/tests/test_main_pipeline.py +3359 -0
  246. package/skills/investigating-agentforce-architecture/scripts/tests/test_parallel_retrieve.py +131 -0
  247. package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_bundle.py +400 -0
  248. package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave.py +644 -0
  249. package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_classifiers.py +224 -0
  250. package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_helpers.py +380 -0
  251. package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_main.py +397 -0
  252. package/skills/investigating-agentforce-architecture/scripts/tests/test_per_branch_visited.py +244 -0
  253. package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_channels.py +359 -0
  254. package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_cli_recipes.py +185 -0
  255. package/skills/investigating-agentforce-architecture/scripts/tests/test_render_architecture.py +810 -0
  256. package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_bot.py +203 -0
  257. package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_creds.py +157 -0
  258. package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_invocation_target.py +145 -0
  259. package/skills/investigating-agentforce-architecture/scripts/tests/test_rest_client.py +1253 -0
  260. package/skills/investigating-agentforce-architecture/scripts/tests/test_runtime_override.py +100 -0
  261. package/skills/investigating-agentforce-architecture/scripts/tests/test_sf_cli.py +261 -0
  262. package/skills/investigating-agentforce-architecture/scripts/tests/test_signature_stamping.py +466 -0
  263. package/skills/investigating-agentforce-architecture/scripts/tests/test_soql_loader.py +501 -0
  264. package/skills/investigating-agentforce-architecture/scripts/tests/test_summarize_tree.py +241 -0
  265. package/skills/investigating-agentforce-architecture/scripts/tests/test_write_emit_ctx.py +480 -0
  266. package/skills/investigating-agentforce-architecture/tools/emit_env.py +157 -0
  267. package/skills/investigating-agentforce-architecture/tools/emit_result.py +262 -0
  268. package/skills/investigating-agentforce-architecture/tools/sanitize.py +33 -0
  269. package/skills/investigating-agentforce-architecture/tools/write_emit_ctx.py +332 -0
  270. package/skills/investigating-agentforce-d360/README.md +123 -0
  271. package/skills/investigating-agentforce-d360/SKILL.md +163 -0
  272. package/skills/investigating-agentforce-d360/assets/dc/app_generation.sql +51 -0
  273. package/skills/investigating-agentforce-d360/assets/dc/content_category.sql +44 -0
  274. package/skills/investigating-agentforce-d360/assets/dc/content_quality.sql +41 -0
  275. package/skills/investigating-agentforce-d360/assets/dc/discover_sessions.sql +36 -0
  276. package/skills/investigating-agentforce-d360/assets/dc/feedback.sql +47 -0
  277. package/skills/investigating-agentforce-d360/assets/dc/feedback_details.sql +38 -0
  278. package/skills/investigating-agentforce-d360/assets/dc/gateway_records.sql +45 -0
  279. package/skills/investigating-agentforce-d360/assets/dc/gateway_request_llm.sql +50 -0
  280. package/skills/investigating-agentforce-d360/assets/dc/gateway_request_metadata.sql +44 -0
  281. package/skills/investigating-agentforce-d360/assets/dc/gateway_request_tags.sql +42 -0
  282. package/skills/investigating-agentforce-d360/assets/dc/gateway_requests.sql +89 -0
  283. package/skills/investigating-agentforce-d360/assets/dc/gateway_responses.sql +43 -0
  284. package/skills/investigating-agentforce-d360/assets/dc/generations.sql +52 -0
  285. package/skills/investigating-agentforce-d360/assets/dc/interactions.sql +53 -0
  286. package/skills/investigating-agentforce-d360/assets/dc/messages.sql +53 -0
  287. package/skills/investigating-agentforce-d360/assets/dc/messaging_session.sql +37 -0
  288. package/skills/investigating-agentforce-d360/assets/dc/moment_interactions.sql +34 -0
  289. package/skills/investigating-agentforce-d360/assets/dc/moments.sql +39 -0
  290. package/skills/investigating-agentforce-d360/assets/dc/participants.sql +48 -0
  291. package/skills/investigating-agentforce-d360/assets/dc/sessions.sql +78 -0
  292. package/skills/investigating-agentforce-d360/assets/dc/steps.sql +64 -0
  293. package/skills/investigating-agentforce-d360/assets/dc/tag_associations.sql +46 -0
  294. package/skills/investigating-agentforce-d360/assets/dc/tag_definition_associations.sql +37 -0
  295. package/skills/investigating-agentforce-d360/assets/dc/tag_definitions.sql +50 -0
  296. package/skills/investigating-agentforce-d360/assets/dc/tags.sql +37 -0
  297. package/skills/investigating-agentforce-d360/assets/dc/telemetry_spans.sql +55 -0
  298. package/skills/investigating-agentforce-d360/references/artifacts.md +50 -0
  299. package/skills/investigating-agentforce-d360/references/dc_dmo_fields.md +823 -0
  300. package/skills/investigating-agentforce-d360/references/dc_pipeline_contract.md +608 -0
  301. package/skills/investigating-agentforce-d360/scripts/_shared/__init__.py +2 -0
  302. package/skills/investigating-agentforce-d360/scripts/_shared/cli_override.py +98 -0
  303. package/skills/investigating-agentforce-d360/scripts/_shared/fs_guard.py +334 -0
  304. package/skills/investigating-agentforce-d360/scripts/_shared/paths.py +155 -0
  305. package/skills/investigating-agentforce-d360/scripts/_shared/runtime.py +59 -0
  306. package/skills/investigating-agentforce-d360/scripts/_shared/sql.py +14 -0
  307. package/skills/investigating-agentforce-d360/scripts/assemble_dc.py +1624 -0
  308. package/skills/investigating-agentforce-d360/scripts/config.py +45 -0
  309. package/skills/investigating-agentforce-d360/scripts/dc.py +188 -0
  310. package/skills/investigating-agentforce-d360/scripts/discover_sessions.py +556 -0
  311. package/skills/investigating-agentforce-d360/scripts/fetch_dc.py +1045 -0
  312. package/skills/investigating-agentforce-d360/scripts/render_dc.py +1750 -0
  313. package/skills/investigating-agentforce-d360/scripts/resolve_session.py +264 -0
  314. package/skills/investigating-agentforce-d360/scripts/storage.py +92 -0
  315. package/skills/investigating-agentforce-d360/scripts/tests/__init__.py +0 -0
  316. package/skills/investigating-agentforce-d360/scripts/tests/_bootstrap.py +15 -0
  317. package/skills/investigating-agentforce-d360/scripts/tests/fixtures/__init__.py +0 -0
  318. package/skills/investigating-agentforce-d360/scripts/tests/fixtures/synthetic_session.py +424 -0
  319. package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_bootstrap_and_mode.py +115 -0
  320. package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct.py +220 -0
  321. package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct_integration.py +158 -0
  322. package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_helpers.py +287 -0
  323. package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_integration.py +247 -0
  324. package/skills/investigating-agentforce-d360/scripts/tests/test_dc_and_resolve_session.py +433 -0
  325. package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions.py +458 -0
  326. package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions_grep_ci.py +193 -0
  327. package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_helpers.py +266 -0
  328. package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_identity.py +528 -0
  329. package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_main.py +251 -0
  330. package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall.py +229 -0
  331. package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall_full.py +283 -0
  332. package/skills/investigating-agentforce-d360/scripts/tests/test_identity_coherence.py +327 -0
  333. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_branches.py +256 -0
  334. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_gateway_direct.py +130 -0
  335. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_helpers.py +291 -0
  336. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_integration.py +220 -0
  337. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_planner_llm_calls.py +284 -0
  338. package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_show_prompts_gating.py +215 -0
  339. package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_from_disk.py +100 -0
  340. package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_session_main.py +149 -0
  341. package/skills/investigating-agentforce-d360/scripts/tests/test_runtime_override.py +104 -0
  342. package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape.py +95 -0
  343. package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape_dropped_by_stdm.py +85 -0
  344. package/skills/managing-managed-event-subscription/SKILL.md +152 -0
  345. package/skills/managing-managed-event-subscription/assets/managed-event-subscription-template.xml +20 -0
  346. package/skills/managing-managed-event-subscription/references/delete-guide.md +57 -0
  347. package/skills/managing-managed-event-subscription/references/topic-name-formats.md +26 -0
  348. package/skills/managing-managed-event-subscription/references/update-constraints.md +30 -0
  349. package/skills/modeling-omnistudio-epc-catalog/SKILL.md +0 -1
  350. package/skills/observing-agentforce/SKILL.md +0 -1
  351. package/skills/orchestrating-datacloud/SKILL.md +0 -1
  352. package/skills/preparing-datacloud/SKILL.md +0 -1
  353. package/skills/querying-soql/SKILL.md +0 -1
  354. package/skills/retrieving-datacloud/SKILL.md +0 -1
  355. package/skills/running-apex-tests/SKILL.md +0 -1
  356. package/skills/running-code-analyzer/SKILL.md +0 -1
  357. package/skills/segmenting-datacloud/SKILL.md +0 -1
  358. package/skills/testing-agentforce/SKILL.md +0 -1
  359. package/skills/uplifting-components-to-slds2/SKILL.md +3 -2
  360. package/skills/uplifting-components-to-slds2/references/color-hooks-decision-guide.md +30 -9
  361. package/skills/uplifting-components-to-slds2/references/examples.md +24 -6
  362. package/skills/validating-slds/SKILL.md +262 -0
  363. package/skills/validating-slds/references/quality-checks.md +308 -0
  364. package/skills/validating-slds/references/report-format.md +302 -0
  365. package/skills/validating-slds/scripts/analyze-quality.cjs +521 -0
@@ -0,0 +1,1750 @@
1
+ """Render dc._session_summary.md from dc._session_tree.json + dc._session_manifest.json.
2
+
3
+ Given `DATA_ROOT/<sid>/dc._session_tree.json` (produced by `scripts/assemble_dc.py`)
4
+ this module emits a human-readable `dc._session_summary.md`. Pure tree reader —
5
+ no raw DMO fetches, no joins. The `session.identity` sub-object on the tree
6
+ (added by the assembler) supplies all identity fields; everything else
7
+ (counts, trees, catalog) comes from the tree itself.
8
+
9
+ Output has 8 `##` sections:
10
+
11
+ 1. Session identity — org/agent/bot/planner/session-start/end/duration
12
+ 2. ID reference — full UUIDs for every ellipsized id in the tree
13
+ 3. Transcript — narrative USER/AGENT text per TURN
14
+ 4. Complete hierarchical trace — tree with `+start + dur = +end` math
15
+ 5. Per-turn summary — one row per interaction
16
+ 6. Session counts — engineer-facing totals
17
+ 7. Empties diagnostics — DMOs that returned 0 rows + reason
18
+ 8. Catalog (session-filtered)
19
+
20
+ Contract:
21
+ - Pure in-memory compute over already-produced artifacts.
22
+ - Reads DATA_ROOT/<sid>/dc._session_tree.json and dc._session_manifest.json.
23
+ - UUIDs in the tree are truncated to first 8 chars + `…`; full forms
24
+ live in the ID reference block.
25
+ - Session-not-found trees render only Session identity + a note.
26
+ - Tree schema version check: refuses incompatible versions, warns on
27
+ missing version (backward compat).
28
+
29
+ Invocation:
30
+ python3 scripts/render_dc.py --session <sid>
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import html
36
+ import json
37
+ import sys
38
+ from datetime import datetime
39
+ from pathlib import Path
40
+ from typing import Dict, List, Optional
41
+
42
+ sys.path.insert(0, str(Path(__file__).parent))
43
+
44
+ from config import DATA_ROOT, paths
45
+
46
+
47
+ # ---- schema ---------------------------------------------------------------
48
+
49
+ _SUPPORTED_SCHEMA_VERSION = 1
50
+
51
+
52
+ # ---- timestamp / string helpers -------------------------------------------
53
+
54
+ def _parse_iso(s: Optional[str]) -> Optional[datetime]:
55
+ if not s:
56
+ return None
57
+ try:
58
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
59
+ except (ValueError, AttributeError):
60
+ return None
61
+
62
+
63
+ def _fmt_offset(ts_iso: Optional[str], start_dt: Optional[datetime]) -> str:
64
+ """Return '+12.345s' or '—' if timestamp is missing."""
65
+ ts = _parse_iso(ts_iso)
66
+ if ts is None or start_dt is None:
67
+ return "—"
68
+ return f"+{(ts - start_dt).total_seconds():.3f}s"
69
+
70
+
71
+ def _fmt_duration_ms(start_iso: Optional[str], end_iso: Optional[str]) -> str:
72
+ s = _parse_iso(start_iso)
73
+ e = _parse_iso(end_iso)
74
+ if s is None or e is None:
75
+ return "—"
76
+ return f"{int((e - s).total_seconds() * 1000)}ms"
77
+
78
+
79
+ def _decode(s: Optional[str]) -> str:
80
+ if not s:
81
+ return ""
82
+ return html.unescape(s).replace("\n", " ").strip()
83
+
84
+
85
+ def _truncate(s: Optional[str], n: int = 80) -> str:
86
+ if not s:
87
+ return "—"
88
+ s = _decode(s)
89
+ return s if len(s) <= n else s[: n - 1] + "…"
90
+
91
+
92
+ def _short(uid: Optional[str], keep: int = 8) -> str:
93
+ """Truncate a UUID to `keep` chars + ellipsis. Full form lives in ID reference."""
94
+ if not uid:
95
+ return "—"
96
+ return uid[:keep] + "…" if len(uid) > keep else uid
97
+
98
+
99
+ def _fmt_total_duration(start_iso: Optional[str], end_iso: Optional[str]) -> Optional[str]:
100
+ s = _parse_iso(start_iso)
101
+ e = _parse_iso(end_iso)
102
+ if s is None or e is None:
103
+ return None
104
+ total_secs = (e - s).total_seconds()
105
+ if total_secs >= 3600:
106
+ h = int(total_secs // 3600)
107
+ m = int((total_secs % 3600) // 60)
108
+ return f"{h}h {m}m {total_secs % 60:.3f}s"
109
+ if total_secs >= 60:
110
+ m = int(total_secs // 60)
111
+ return f"{m}m {total_secs % 60:.3f}s"
112
+ return f"{total_secs:.3f}s"
113
+
114
+
115
+ # ---- session-end derivation ----------------------------------------------
116
+
117
+ def _derive_session_end(sess: dict) -> tuple[Optional[str], Optional[str]]:
118
+ """Return (effective_end_iso, source_label).
119
+
120
+ Contract §3.5 derivation:
121
+ 1. If `session.end_ts` is non-null → return it as-is (caller adds
122
+ "✓ materialized" suffix).
123
+ 2. Else prefer a SESSION_END interaction's `start_ts`
124
+ (label: "from SESSION_END interaction").
125
+ 3. Else fall back to the last TURN interaction's `end_ts`
126
+ (or `start_ts` if end is null) (label: "session still open (last TURN)").
127
+ 4. Else `(None, None)`.
128
+ """
129
+ end_iso = sess.get("end_ts")
130
+ if end_iso:
131
+ return end_iso, None # no derivation needed
132
+ interactions = sess.get("interactions") or []
133
+ # Prefer SESSION_END start_ts.
134
+ for iv in interactions:
135
+ if iv.get("type") == "SESSION_END" and iv.get("start_ts"):
136
+ return iv["start_ts"], "from SESSION_END interaction"
137
+ # Fall back to the last TURN (by interaction order; tree is already sorted).
138
+ last_turn = None
139
+ for iv in interactions:
140
+ if iv.get("type") == "TURN":
141
+ last_turn = iv
142
+ if last_turn is not None:
143
+ fallback = last_turn.get("end_ts") or last_turn.get("start_ts")
144
+ if fallback:
145
+ return fallback, "session still open (last TURN)"
146
+ return None, None
147
+
148
+
149
+ # ---- section builders -----------------------------------------------------
150
+
151
+ # Channel-mode value → human cell. Annotated so the reader can tell at
152
+ # a glance why we believe the mode is
153
+ # what it is — `ssot__AiAgentChannelType__c` is identical for MIAW
154
+ # production and Builder Previewer, which makes it useless without the
155
+ # annotation.
156
+ _MODE_HINTS: dict[str, str] = {
157
+ "production_messaging": "production_messaging ← inferred from RelatedMessagingSessionId",
158
+ "builder_previewer": "builder_previewer ← inferred from VariableText__c bootstrap keys",
159
+ "voice": "voice ← inferred from RelatedVoiceCallId",
160
+ "unknown": "unknown ← no signals (headless API, etc.)",
161
+ }
162
+
163
+
164
+ def _fmt_mode_cell(mode_value: str) -> str:
165
+ """Format the `mode` field with a short why-this-value annotation."""
166
+ return _MODE_HINTS.get(mode_value, mode_value)
167
+
168
+
169
+ # Channel values that flag a production messaging session. `VariableText__c`
170
+ # is expected to be NOT_SET on these — the bootstrap variables ride the
171
+ # messaging session record, not the AI session. Any other channel
172
+ # (E & O headless API, Builder Previewer, voice, unknown/integration)
173
+ # can also legitimately produce NOT_SET, but for a different reason
174
+ # (no bootstrap variables were attached at session start) — and we
175
+ # must not mislabel those as "production messaging path".
176
+ _MESSAGING_CHANNELS = frozenset({
177
+ "SCRT2 - EmbeddedMessaging",
178
+ "Messaging",
179
+ })
180
+
181
+
182
+ def _section_session_bootstrap(identity: dict, channel: Optional[str] = None) -> List[str]:
183
+ """Render the `bootstrap_variables` block parsed from VariableText__c.
184
+
185
+ Three states (all rendered as a small subtable so they don't clutter
186
+ the main identity table):
187
+ - None / NOT_SET → "no bootstrap variables for this session"
188
+ (with a messaging-path addendum only when the
189
+ session's `channel` is actually a messaging
190
+ channel — see `_MESSAGING_CHANNELS`).
191
+ - parse error → "_parse_error" with the raw prefix
192
+ - populated → key/value pairs sorted, plus a "Builder Previewer
193
+ indicators" tally derived from the same key set
194
+ used in `_derive_mode`.
195
+
196
+ Empty section returned when bootstrap_variables is missing entirely
197
+ (older artifacts predate the bootstrap-variables harvest).
198
+ """
199
+ if "bootstrap_variables" not in identity:
200
+ return [] # older artifact: no bootstrap_variables harvested
201
+
202
+ bootstrap = identity.get("bootstrap_variables")
203
+ lines: List[str] = ["## Session bootstrap", ""]
204
+
205
+ if bootstrap is None:
206
+ if channel in _MESSAGING_CHANNELS:
207
+ lines.append(
208
+ "`ssot__VariableText__c` is `NOT_SET` — no bootstrap variables "
209
+ "(production messaging path; messaging sessions don't carry VariableText)."
210
+ )
211
+ else:
212
+ lines.append(
213
+ "`ssot__VariableText__c` is `NOT_SET` — no bootstrap variables for this session."
214
+ )
215
+ lines.append("")
216
+ return lines
217
+
218
+ if isinstance(bootstrap, dict) and bootstrap.get("_parse_error"):
219
+ raw = bootstrap.get("_raw") or ""
220
+ lines.append("`ssot__VariableText__c` failed to parse as JSON:")
221
+ lines.append("")
222
+ lines.append("```")
223
+ lines.append(raw)
224
+ lines.append("```")
225
+ lines.append("")
226
+ return lines
227
+
228
+ # Populated case.
229
+ indicator_keys = {
230
+ "__resolved_locale__",
231
+ "__locale_instruction__",
232
+ "__supports_result_display__",
233
+ "__show_tool_results_invoked__",
234
+ }
235
+ present_indicators = sorted(set(bootstrap) & indicator_keys)
236
+ indicator_cell = (
237
+ "yes · " + ", ".join(present_indicators)
238
+ if present_indicators else "no"
239
+ )
240
+
241
+ lines.append("| Key | Value |")
242
+ lines.append("|---|---|")
243
+ for key in sorted(bootstrap):
244
+ value = bootstrap[key]
245
+ # Render lists / dicts compactly; keep strings/numbers/bools as-is.
246
+ if isinstance(value, (list, dict)):
247
+ value_repr = json.dumps(value)
248
+ else:
249
+ value_repr = str(value)
250
+ # Pipe character would break the markdown table — escape.
251
+ value_repr = value_repr.replace("|", "\\|")
252
+ lines.append(f"| {key} | {value_repr} |")
253
+ lines.append(f"| **Builder Previewer indicators** | {indicator_cell} |")
254
+ lines.append("")
255
+ return lines
256
+
257
+
258
+ def _compose_agent_cell(identity: dict) -> Optional[str]:
259
+ """Build the human Agent cell from identity fields; drop None components."""
260
+ parts: List[str] = []
261
+ api_name = identity.get("agent_api_name")
262
+ version = identity.get("agent_version")
263
+ if api_name and version:
264
+ parts.append(f"{api_name} {version}")
265
+ elif api_name:
266
+ parts.append(api_name)
267
+ elif version:
268
+ parts.append(version)
269
+ label = identity.get("agent_label")
270
+ if label:
271
+ parts.append(f"— {label}" if parts else label)
272
+ atype = identity.get("agent_type")
273
+ if atype:
274
+ parts.append(f"({atype})")
275
+ return " ".join(parts) if parts else None
276
+
277
+
278
+ def _fmt_session_end_cell(effective_end_iso: Optional[str],
279
+ end_source: Optional[str],
280
+ raw_end_iso: Optional[str]) -> Optional[str]:
281
+ """Compose the identity-table value for `Session end`.
282
+
283
+ - Materialized end (`raw_end_iso` truthy) → "<iso> ✓ materialized"
284
+ - Derived end (end_source truthy) → "<iso> (<source label>)"
285
+ - Neither → None (row is dropped)
286
+ """
287
+ if effective_end_iso is None:
288
+ return None
289
+ if raw_end_iso:
290
+ return f"{effective_end_iso} ✓ materialized"
291
+ if end_source:
292
+ return f"{effective_end_iso} ({end_source})"
293
+ return effective_end_iso
294
+
295
+
296
+ def _section_session_identity(sess: dict, effective_end_iso: Optional[str],
297
+ end_source: Optional[str]) -> List[str]:
298
+ identity = sess.get("identity") or {}
299
+ org = sess.get("org") or {}
300
+
301
+ # `mode` is "production_messaging" / "builder_previewer" / "voice" /
302
+ # "unknown" — derived in assemble_dc from RelatedMessagingSessionId +
303
+ # RelatedVoiceCallId + bootstrap_variables because
304
+ # ssot__AiAgentChannelType__c is identical for MIAW production and
305
+ # Builder Previewer.
306
+ mode_value = identity.get("mode")
307
+ mode_cell = _fmt_mode_cell(mode_value) if mode_value else None
308
+
309
+ rows_out = [
310
+ ("Session id", sess.get("id")),
311
+ ("Org id", identity.get("org_id")),
312
+ ("Org alias", org.get("alias")),
313
+ ("Instance URL", org.get("instance_url")),
314
+ ("Channel", sess.get("channel")),
315
+ ("Mode", mode_cell),
316
+ ("App type", identity.get("app_type")),
317
+ ("Agent", _compose_agent_cell(identity)),
318
+ ("Bot id", identity.get("bot_id")),
319
+ ("Bot name", identity.get("bot_name")),
320
+ ("Bot version id", identity.get("bot_version_id")),
321
+ ("Planner id", identity.get("planner_id")),
322
+ ("Planner name", identity.get("planner_name")),
323
+ ("Planner type", identity.get("planner_type")),
324
+ ("Configured model", identity.get("configured_model")),
325
+ ("Platform user id", identity.get("platform_user_id")),
326
+ ("Messaging session id", identity.get("messaging_session_id")),
327
+ ("Messaging end-user id", identity.get("messaging_end_user_id")),
328
+ ("Voice call id", identity.get("voice_call_id")),
329
+ ("Individual id", identity.get("individual_id")),
330
+ ("Session start", sess.get("start_ts")),
331
+ (
332
+ "Session end",
333
+ _fmt_session_end_cell(effective_end_iso, end_source, sess.get("end_ts")),
334
+ ),
335
+ ("End type", sess.get("end_type")),
336
+ ("Total duration", _fmt_total_duration(sess.get("start_ts"), effective_end_iso)),
337
+ ]
338
+ lines: List[str] = ["## Session identity", "", "| Field | Value |", "|---|---|"]
339
+ for label, value in rows_out:
340
+ if value is None or value == "" or value == "—":
341
+ continue
342
+ lines.append(f"| {label} | {value} |")
343
+ lines.append("")
344
+ return lines
345
+
346
+
347
+ def _section_id_reference(sess: dict) -> List[str]:
348
+ """Full-UUID lookup for every id the tree truncates."""
349
+ lines: List[str] = [
350
+ "## ID reference",
351
+ "",
352
+ "The tree truncates UUIDs to the first 8 chars + `…`. Look up the full "
353
+ "form here. Ordered by type → first occurrence.",
354
+ "",
355
+ "```",
356
+ f"session = {sess.get('id')}",
357
+ "",
358
+ ]
359
+ # Interactions
360
+ lines.append("interactions:")
361
+ for iv in sess.get("interactions", []):
362
+ lines.append(
363
+ f" {iv.get('id')} type={iv.get('type') or '?'} "
364
+ f"trace={iv.get('trace_id') or '—'}"
365
+ )
366
+ lines.append("")
367
+ # Participants
368
+ lines.append("participants:")
369
+ for p in sess.get("participants", []):
370
+ lines.append(
371
+ f" {p.get('participant_id') or '—'} role={p.get('role') or '—'} "
372
+ f"agent={p.get('agent_api_name') or '—'} version={p.get('agent_version') or '—'} "
373
+ f"type={p.get('agent_type') or '—'}"
374
+ )
375
+ lines.append("")
376
+
377
+ # Steps, generations, gateway_requests, messages harvested from the tree.
378
+ all_steps: List[tuple] = []
379
+ all_gens: List[tuple] = []
380
+ all_gws: List[tuple] = []
381
+ all_msgs: List[tuple] = []
382
+ for iv in sess.get("interactions", []):
383
+ for m in iv.get("messages", []):
384
+ if m.get("message_id"):
385
+ all_msgs.append((m["message_id"], m.get("role") or m.get("type") or "?"))
386
+ for st in iv.get("steps", []):
387
+ if st.get("id"):
388
+ all_steps.append((st["id"], st.get("type") or "?", st.get("name") or "—"))
389
+ gen = st.get("generation")
390
+ if gen and gen.get("generation_id"):
391
+ all_gens.append((
392
+ gen["generation_id"],
393
+ gen.get("response_id") or "—",
394
+ gen.get("feature") or "—",
395
+ ))
396
+ gw = st.get("gateway_request")
397
+ if gw and gw.get("gateway_request_id"):
398
+ all_gws.append((
399
+ gw["gateway_request_id"], "declared",
400
+ gw.get("feature") or "—", gw.get("model") or "—",
401
+ ))
402
+ for tb in iv.get("timestamp_bound_gateway_calls", []):
403
+ if tb.get("gateway_request_id"):
404
+ all_gws.append((
405
+ tb["gateway_request_id"], "timestamp_window",
406
+ tb.get("feature") or "—", tb.get("model") or "—",
407
+ ))
408
+ for g in sess.get("unbound_gateway_calls", []):
409
+ if g.get("gateway_request_id"):
410
+ all_gws.append((
411
+ g["gateway_request_id"], "unbound",
412
+ g.get("feature") or "—", g.get("model") or "—",
413
+ ))
414
+
415
+ if all_steps:
416
+ lines.append("steps:")
417
+ for sid_, stype, sname in all_steps:
418
+ lines.append(f" {sid_} type={stype} name={sname}")
419
+ lines.append("")
420
+ if all_gens:
421
+ lines.append("generations:")
422
+ for gid, rid, feat in all_gens:
423
+ lines.append(f" {gid} response_id={rid} feature={feat}")
424
+ lines.append("")
425
+ if all_gws:
426
+ lines.append("gateway_requests:")
427
+ for gwid, bm, feat, model in all_gws:
428
+ lines.append(f" {gwid} binding={bm} feature={feat} model={model}")
429
+ lines.append("")
430
+ if all_msgs:
431
+ lines.append("messages:")
432
+ for mid, role in all_msgs:
433
+ lines.append(f" {mid} role={role}")
434
+ lines.append("```")
435
+ lines.append("")
436
+ return lines
437
+
438
+
439
+ def _section_transcript(sess: dict, start_dt: Optional[datetime]) -> List[str]:
440
+ turns = [iv for iv in sess.get("interactions", []) if iv.get("type") == "TURN"]
441
+ if not turns:
442
+ return []
443
+ lines: List[str] = ["## Transcript", ""]
444
+ for iv in turns:
445
+ start_off = _fmt_offset(iv.get("start_ts"), start_dt)
446
+ dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
447
+ lines.append(f"**Interaction {_short(iv.get('id'))}** · {start_off} · {dur}")
448
+ for m in iv.get("messages", []):
449
+ role = m.get("role") or m.get("type") or "?"
450
+ lines.append(f"> **{role}:** {_decode(m.get('text') or '')}")
451
+ lines.append("")
452
+ return lines
453
+
454
+
455
+ def _section_hierarchical_trace(sess: dict, start_dt: Optional[datetime],
456
+ effective_end_iso: Optional[str],
457
+ end_source: Optional[str]) -> List[str]:
458
+ lines: List[str] = [
459
+ "## Complete hierarchical trace",
460
+ "",
461
+ "Notation: `+Xs` = offset in seconds from session start. "
462
+ "UUIDs are truncated to 8 chars + `…` for readability; "
463
+ "full forms are in the **ID reference** block above.",
464
+ "",
465
+ "```",
466
+ ]
467
+
468
+ # Session header
469
+ lines.append(f"SESSION {_short(sess.get('id'))}")
470
+ start_iso = sess.get("start_ts")
471
+ if start_iso:
472
+ lines.append(f"│ Start +0.000s ({start_iso})")
473
+
474
+ end_iso_raw = sess.get("end_ts")
475
+ end_type = sess.get("end_type") or None
476
+ outcome_s = end_type or "—"
477
+ if not end_type and not end_iso_raw:
478
+ outcome_s = "— (session end not yet materialized in STDM)"
479
+ display_end = end_iso_raw or effective_end_iso
480
+ if display_end:
481
+ label_suffix = f" [{end_source}]" if end_source else ""
482
+ lines.append(
483
+ f"│ End {_fmt_offset(display_end, start_dt)} ({display_end}){label_suffix} "
484
+ f"outcome={outcome_s}"
485
+ )
486
+ else:
487
+ lines.append(f"│ End — (session still open) outcome={outcome_s}")
488
+ lines.append("│")
489
+
490
+ interactions = sess.get("interactions", [])
491
+ for iv_idx, iv in enumerate(interactions):
492
+ is_last_iv = iv_idx == len(interactions) - 1
493
+ iv_branch = "└──" if is_last_iv else "├──"
494
+ iv_cont = " " if is_last_iv else "│ "
495
+
496
+ iv_start_off = _fmt_offset(iv.get("start_ts"), start_dt)
497
+ iv_end_off = _fmt_offset(iv.get("end_ts"), start_dt)
498
+ iv_dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
499
+ iv_type = iv.get("type") or "?"
500
+ lines.append(
501
+ f"{iv_branch} {iv_type} {_short(iv.get('id'))} "
502
+ f"{iv_start_off} + {iv_dur} = {iv_end_off}"
503
+ )
504
+
505
+ topic = iv.get("topic")
506
+ if topic:
507
+ lines.append(f"{iv_cont}├── TOPIC: {topic}")
508
+
509
+ for m in iv.get("messages", []):
510
+ role = m.get("role") or m.get("type") or "?"
511
+ m_off = _fmt_offset(m.get("ts"), start_dt) if m.get("ts") else "—"
512
+ lines.append(f"{iv_cont}├── {role} message {_short(m.get('message_id'))} ts={m_off}")
513
+ lines.append(f"{iv_cont}│ └── text: \"{_truncate(m.get('text'), 100)}\"")
514
+
515
+ steps = iv.get("steps", [])
516
+ tsbound = iv.get("timestamp_bound_gateway_calls", [])
517
+ tsb_by_step: Dict[str, List[dict]] = {}
518
+ tsb_interaction_level: List[dict] = []
519
+ for tb in tsbound:
520
+ bsid = tb.get("bound_to_step_id")
521
+ if bsid:
522
+ tsb_by_step.setdefault(bsid, []).append(tb)
523
+ else:
524
+ tsb_interaction_level.append(tb)
525
+
526
+ remaining_groups: List[str] = []
527
+ if steps:
528
+ remaining_groups.append("steps")
529
+ if tsb_interaction_level:
530
+ remaining_groups.append("ts_il")
531
+
532
+ for grp_idx, grp in enumerate(remaining_groups):
533
+ grp_is_last = grp_idx == len(remaining_groups) - 1
534
+ grp_branch = "└──" if grp_is_last else "├──"
535
+ grp_cont = " " if grp_is_last else "│ "
536
+ if grp == "steps":
537
+ lines.append(f"{iv_cont}{grp_branch} STEPS:")
538
+ for st_idx, st in enumerate(steps):
539
+ lines.extend(_render_step(
540
+ st, st_idx, len(steps), iv_cont, grp_cont,
541
+ start_dt, tsb_by_step,
542
+ ))
543
+ else: # grp == "ts_il"
544
+ lines.append(f"{iv_cont}{grp_branch} INTERACTION-LEVEL TIMESTAMP-BOUND GATEWAY CALLS:")
545
+ for tb_idx, tb in enumerate(tsb_interaction_level):
546
+ tb_is_last = tb_idx == len(tsb_interaction_level) - 1
547
+ tb_branch = "└──" if tb_is_last else "├──"
548
+ lines.append(
549
+ f"{iv_cont}{grp_cont}{tb_branch} "
550
+ f"gateway_request [timestamp_window, interaction-level] "
551
+ f"{_short(tb.get('gateway_request_id'))} "
552
+ f"feature={tb.get('feature') or '—'} "
553
+ f"model={tb.get('model') or '—'}"
554
+ )
555
+
556
+ if not is_last_iv:
557
+ lines.append("│")
558
+
559
+ # Unbound gateway calls (session root level)
560
+ ub = sess.get("unbound_gateway_calls", [])
561
+ if ub:
562
+ lines.append("")
563
+ lines.append(
564
+ f"UNBOUND GATEWAY CALLS ({len(ub)}) — neither declared chain "
565
+ "nor timestamp window matched"
566
+ )
567
+ for i, g in enumerate(ub):
568
+ branch = "└──" if i == len(ub) - 1 else "├──"
569
+ lines.append(
570
+ f"{branch} {_short(g.get('gateway_request_id'))} "
571
+ f"feature={g.get('feature') or '—'} model={g.get('model') or '—'}"
572
+ )
573
+
574
+ lines.append("```")
575
+ lines.append("")
576
+ return lines
577
+
578
+
579
+ def _render_step(st: dict, st_idx: int, n_steps: int,
580
+ iv_cont: str, grp_cont: str,
581
+ start_dt: Optional[datetime],
582
+ tsb_by_step: Dict[str, List[dict]]) -> List[str]:
583
+ """Render one step + its nested generation / gateway_request / ts-bound GWs."""
584
+ st_is_last = st_idx == n_steps - 1
585
+ st_branch = "└──" if st_is_last else "├──"
586
+ st_cont = " " if st_is_last else "│ "
587
+ st_start_off = _fmt_offset(st.get("start_ts"), start_dt)
588
+ st_end_off = _fmt_offset(st.get("end_ts"), start_dt)
589
+ st_dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
590
+
591
+ # Show the bound LLM model alongside the step name when known.
592
+ # `model_name` is mirrored from the bound gateway_request by
593
+ # assemble_dc; None when the declared chain didn't reach or when STDM
594
+ # dropped writes (the `gateway_requests_dropped_by_stdm` shape).
595
+ name_cell = st.get("name") or "—"
596
+ model_name = st.get("model_name")
597
+ if model_name:
598
+ name_cell = f"{name_cell} · {model_name}"
599
+
600
+ lines = [
601
+ f"{iv_cont}{grp_cont}{st_branch} {st.get('type') or '?'} {_short(st.get('id'))} "
602
+ f"name={name_cell} {st_start_off} + {st_dur} = {st_end_off}"
603
+ ]
604
+
605
+ # Step children: error, generation (+ trust signals), gateway_request, collision, bound GWs.
606
+ step_kids: List[tuple] = []
607
+ if st.get("error_text"):
608
+ step_kids.append(("error", None))
609
+ gen = st.get("generation")
610
+ if gen:
611
+ step_kids.append(("gen", gen))
612
+ gw = st.get("gateway_request")
613
+ if gw:
614
+ step_kids.append(("gw", gw))
615
+ if st.get("gateway_request_collision"):
616
+ step_kids.append(("collision", None))
617
+ for tb in tsb_by_step.get(st.get("id", ""), []):
618
+ step_kids.append(("tsb", tb))
619
+
620
+ prefix = f"{iv_cont}{grp_cont}{st_cont}"
621
+ for k_idx, (ktype, kval) in enumerate(step_kids):
622
+ k_is_last = k_idx == len(step_kids) - 1
623
+ k_branch = "└──" if k_is_last else "├──"
624
+ k_cont = " " if k_is_last else "│ "
625
+
626
+ if ktype == "error":
627
+ lines.append(f"{prefix}{k_branch} ERROR: {st['error_text']}")
628
+ elif ktype == "collision":
629
+ lines.append(
630
+ f"{prefix}{k_branch} ⚠ gateway_request_collision: "
631
+ "earlier step claimed the declared GW"
632
+ )
633
+ elif ktype == "gen":
634
+ lines.extend(_render_generation(kval, prefix, k_branch, k_cont,
635
+ step_name=st.get("name")))
636
+ elif ktype == "gw":
637
+ lines.extend(_render_gw_declared(kval, prefix, k_branch, k_cont))
638
+ elif ktype == "tsb":
639
+ tb = kval
640
+ lines.append(
641
+ f"{prefix}{k_branch} gateway_request [timestamp_window] "
642
+ f"{_short(tb.get('gateway_request_id'))} "
643
+ f"feature={tb.get('feature') or '—'} "
644
+ f"model={tb.get('model') or '—'}"
645
+ )
646
+ return lines
647
+
648
+
649
+ _ROLE_LABELS = {
650
+ "ReactTopicPrompt": "topic-classification output",
651
+ "ReactInitialPrompt": "ReAct planner step",
652
+ "ReactValidationPrompt": "ReAct validator",
653
+ "ReactFormatSurfaceResponsePrompt": "response formatter",
654
+ }
655
+
656
+
657
+ def _role_label_for(step_name: Optional[str]) -> Optional[str]:
658
+ if not step_name:
659
+ return None
660
+ for key, label in _ROLE_LABELS.items():
661
+ if key in step_name:
662
+ return label
663
+ return None
664
+
665
+
666
+ def _parse_finish_reason(response_parameters: Optional[str]) -> Optional[str]:
667
+ """Pull finish_reason out of the HTML-escaped JSON in responseParameters__c.
668
+ Shape: `{&quot;finish_reason&quot;:&quot;&#92;&quot;stop&#92;&quot;&quot;,...}`."""
669
+ if not response_parameters:
670
+ return None
671
+ try:
672
+ decoded = html.unescape(response_parameters)
673
+ parsed = json.loads(decoded)
674
+ except (ValueError, TypeError):
675
+ return None
676
+ raw = parsed.get("finish_reason") if isinstance(parsed, dict) else None
677
+ if not isinstance(raw, str):
678
+ return None
679
+ # value often comes wrapped in escaped quotes: `\"stop\"` → `stop`
680
+ return raw.replace("\\", "").strip('"').strip()
681
+
682
+
683
+ def _decoded_line(response_text: Optional[str]) -> str:
684
+ """Render `decoded:` content. Detect tool-call JSON and summarize.
685
+ responseText__c is HTML-escaped (&quot; etc.) in the wire format — unescape
686
+ before trying to parse as JSON."""
687
+ if not response_text:
688
+ return "—"
689
+ candidate = html.unescape(response_text).strip()
690
+ if candidate.startswith("{") and '"toolInvocations"' in candidate:
691
+ try:
692
+ obj = json.loads(candidate)
693
+ tools = obj.get("toolInvocations") or []
694
+ content = (obj.get("content") or "").strip()
695
+ if tools:
696
+ first = tools[0].get("function") or {}
697
+ name = first.get("name") or "?"
698
+ args_raw = first.get("arguments") or ""
699
+ arg_summary = ""
700
+ try:
701
+ args_obj = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
702
+ if isinstance(args_obj, dict) and args_obj:
703
+ k, v = next(iter(args_obj.items()))
704
+ v_str = str(v)
705
+ if len(v_str) > 60:
706
+ v_str = v_str[:57] + "…"
707
+ arg_summary = f'{k}="{v_str}"'
708
+ except (ValueError, TypeError):
709
+ pass
710
+ prefix = "no user text" if not content else f'"{_truncate(content, 60)}"'
711
+ n = len(tools)
712
+ call_word = "tool call" if n == 1 else "tool calls"
713
+ return f"{prefix}; {n} {call_word} → {name}({arg_summary})"
714
+ except (ValueError, TypeError):
715
+ pass
716
+ return f'"{_truncate(response_text, 140)}"'
717
+
718
+
719
+ def _float_or_none(v) -> Optional[float]:
720
+ try:
721
+ return float(v)
722
+ except (ValueError, TypeError):
723
+ return None
724
+
725
+
726
+ def _trust_line(g: dict) -> str:
727
+ """Summarize trust signals. TOXICITY sub-categories (parented on the
728
+ quality row) carry a dedicated `safety_score` category in the 0-1 range
729
+ (1.0 = clean). Non-`safety_score` sub-categories are the 8 hazard axes
730
+ (profanity, hate, violence, …); report the max of those when a detection
731
+ fires so the reader sees which axis tripped.
732
+ Non-TOXICITY detectors (InstructionAdherence, etc.) are parented directly
733
+ on the generation and render as textual verdicts."""
734
+ quality = g.get("quality") or []
735
+ cats = g.get("categories") or []
736
+ if not quality and not cats:
737
+ return "— (no quality/category rows)"
738
+ parts: List[str] = []
739
+ for q in quality:
740
+ subs = q.get("_toxicity_subcategories") or []
741
+ detected = str(q.get("isToxicityDetected__c", "")).lower() == "true"
742
+ safety: Optional[float] = None
743
+ hazard_subs: List[tuple] = []
744
+ for s in subs:
745
+ if (s.get("detectorType__c") or "").upper() != "TOXICITY":
746
+ continue
747
+ name = s.get("category__c") or ""
748
+ val = _float_or_none(s.get("value__c"))
749
+ if val is None:
750
+ continue
751
+ if name == "safety_score":
752
+ safety = val
753
+ else:
754
+ hazard_subs.append((name, val))
755
+ status = "detected" if detected else "clean"
756
+ safety_str = f"{safety:.2f}" if safety is not None else "—"
757
+ if not detected and hazard_subs and all(v == 0 for _, v in hazard_subs):
758
+ detail = f" — all {len(hazard_subs)} sub-categories 0.00"
759
+ elif hazard_subs:
760
+ top_name, top_val = max(hazard_subs, key=lambda kv: kv[1])
761
+ detail = f" — max {top_name}={top_val:.2f}"
762
+ else:
763
+ detail = ""
764
+ parts.append(f"TOXICITY safety_score={safety_str} ({status}{detail})")
765
+ # Non-TOXICITY detectors (generation-direct).
766
+ for c in cats:
767
+ dtype = (c.get("detectorType__c") or "?").strip()
768
+ if dtype.upper() == "TOXICITY":
769
+ continue
770
+ val = c.get("value__c")
771
+ parts.append(f"{dtype}: {_truncate(val, 80)}")
772
+ return "; ".join(parts) if parts else "—"
773
+
774
+
775
+ def _render_generation(g: dict, prefix: str, k_branch: str, k_cont: str,
776
+ *, step_name: Optional[str] = None) -> List[str]:
777
+ """Generation node: 3 fixed children (decoded / finish_reason+masked / trust).
778
+ Header is naked (id only); `response_id` and `feature` live in the ID reference
779
+ block / on the sibling gateway_request line."""
780
+ lines = [
781
+ f"{prefix}{k_branch} generation {_short(g.get('generation_id'))}"
782
+ ]
783
+ role = _role_label_for(step_name)
784
+ decoded = _decoded_line(g.get("response_text"))
785
+ if role:
786
+ decoded_line = f"decoded: {decoded} ({role})"
787
+ else:
788
+ decoded_line = f"decoded: {decoded}"
789
+ masked = g.get("masked_response_text")
790
+ masked_disp = _truncate(masked, 60) if masked else "—"
791
+ finish_reason = _parse_finish_reason(g.get("response_parameters")) or "—"
792
+ gprefix = f"{prefix}{k_cont}"
793
+ lines.append(f"{gprefix}├── {decoded_line}")
794
+ lines.append(f"{gprefix}├── finish_reason={finish_reason} masked={masked_disp}")
795
+ lines.append(f"{gprefix}└── trust: {_trust_line(g)}")
796
+ return lines
797
+
798
+
799
+ def _render_gw_declared(g: dict, prefix: str, k_branch: str, k_cont: str) -> List[str]:
800
+ tokens = (
801
+ f"prompt={int(g.get('prompt_tokens') or 0)} "
802
+ f"completion={int(g.get('completion_tokens') or 0)} "
803
+ f"total={int(g.get('total_tokens') or 0)}"
804
+ )
805
+ tags = g.get("tags") or []
806
+ md = g.get("metadata") or []
807
+ recs = g.get("records") or []
808
+ llm = g.get("llm") or []
809
+ audit_line = (
810
+ f"audit: tags={len(tags)} metadata={len(md)} records={len(recs)} llm={len(llm)}"
811
+ if (tags or md or recs or llm)
812
+ else "audit: (none)"
813
+ )
814
+ return [
815
+ f"{prefix}{k_branch} gateway_request [declared] "
816
+ f"{_short(g.get('gateway_request_id'))} "
817
+ f"feature={g.get('feature') or '—'} "
818
+ f"model={g.get('model') or '—'} "
819
+ f"provider={g.get('provider') or '—'}",
820
+ f"{prefix}{k_cont}├── tokens: {tokens}",
821
+ f"{prefix}{k_cont}└── {audit_line}",
822
+ ]
823
+
824
+
825
+ def _section_per_turn_summary(sess: dict, start_dt: Optional[datetime]) -> List[str]:
826
+ lines: List[str] = [
827
+ "## Per-turn summary",
828
+ "",
829
+ "| Interaction | Type | Start offset | Duration | Steps | "
830
+ "GW declared | GW ts_window | USER → AGENT |",
831
+ "|---|---|---|---|---|---|---|---|",
832
+ ]
833
+ for iv in sess.get("interactions", []):
834
+ iv_type = iv.get("type") or "?"
835
+ start_off = _fmt_offset(iv.get("start_ts"), start_dt)
836
+ dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
837
+ step_count = len(iv.get("steps", []))
838
+ declared_gws = sum(
839
+ 1 for st in iv.get("steps", [])
840
+ if st.get("gateway_request")
841
+ and st["gateway_request"].get("binding_method") == "declared"
842
+ )
843
+ tsw_gws = len(iv.get("timestamp_bound_gateway_calls", []))
844
+ user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
845
+ agent_msg = next((m for m in iv.get("messages", []) if m.get("role") == "AGENT"), None)
846
+ ut = _truncate(user_msg.get("text") if user_msg else None, 40)
847
+ at = _truncate(agent_msg.get("text") if agent_msg else None, 40)
848
+ lines.append(
849
+ f"| `{_short(iv.get('id'))}` | {iv_type} | {start_off} | {dur} | "
850
+ f"{step_count} | {declared_gws} | {tsw_gws} | {ut} → {at} |"
851
+ )
852
+ lines.append("")
853
+ return lines
854
+
855
+
856
+ # ---- visual analysis (mermaid) -------------------------------------------
857
+
858
+ # Chars that break mermaid label parsing when bare; quote-wrap the string
859
+ # when any of these show up. Underscore+digit tokens in topic strings are
860
+ # legal inside mermaid node labels — only syntax-significant chars need
861
+ # escaping here.
862
+ _MERMAID_LABEL_SPECIALS = set(',()<>:"#')
863
+
864
+
865
+ def _escape_mermaid_label(s: str) -> str:
866
+ if not s:
867
+ return '""'
868
+ if any(ch in _MERMAID_LABEL_SPECIALS for ch in s):
869
+ return '"' + s.replace('"', '\\"') + '"'
870
+ return s
871
+
872
+
873
+ def _sequence_msg(s: str) -> str:
874
+ """Sanitize free-form text for the right-hand side of a sequenceDiagram
875
+ arrow. Mermaid parses `A->>B: <text>` up to newline — the text itself is
876
+ unquoted, so wrapping in `"..."` (as node-label escape does) renders the
877
+ quotes literally. We just strip newlines and escape the one char that
878
+ ends the message field (semicolon is the statement terminator)."""
879
+ if not s:
880
+ return "—"
881
+ return s.replace("\n", " ").replace(";", ",").strip()
882
+
883
+
884
+ def _flowchart_edge_label(s: str) -> str:
885
+ """Sanitize free-form text for a flowchart edge label `A -->|label| B`.
886
+ The delimiter is the pipe itself, so a literal `|` in the label shatters
887
+ the parser. Square brackets also clash with node-shape syntax. Strip/
888
+ substitute these; other specials (commas, colons, parens) are fine
889
+ inside quoted labels, which `_escape_mermaid_label` handles."""
890
+ if not s:
891
+ return ""
892
+ cleaned = s.replace("|", "/").replace("[", "(").replace("]", ")")
893
+ return _escape_mermaid_label(cleaned)
894
+
895
+
896
+ def _step_display_name(st: dict) -> str:
897
+ """Short, mermaid-safe row label for a step.
898
+
899
+ - Strip `AiCopilot__` prefix on LLM prompts.
900
+ - For ACTION_STEP names shaped `Topic_xxx.AGNT_Action_yyy`, keep the
901
+ action-name portion after the last `.` so the row reads as the
902
+ invoked action, not the topic.
903
+ """
904
+ name = st.get("name") or st.get("type") or "?"
905
+ if name.startswith("AiCopilot__"):
906
+ name = name[len("AiCopilot__"):]
907
+ if st.get("type") == "ACTION_STEP" and "." in name:
908
+ name = name.rsplit(".", 1)[-1]
909
+ # Gantt task names cannot contain colons (task syntax is `label : id, start, dur`).
910
+ return name.replace(":", "·")
911
+
912
+
913
+ def _iter_turns(sess: dict) -> List[dict]:
914
+ return [iv for iv in sess.get("interactions", []) if iv.get("type") == "TURN"]
915
+
916
+
917
+ def _mermaid_gantt(
918
+ sess: dict,
919
+ start_dt: Optional[datetime],
920
+ llm_calls: Optional[List[dict]] = None,
921
+ ) -> List[str]:
922
+ """Gantt chart — wall-clock timeline.
923
+
924
+ Rows per turn (ACTION steps tagged :crit). When `llm_calls` is
925
+ non-empty, a trailing "LLM calls" section renders per-call rows
926
+ tagged by region-class (:active = cross_region, :done = same-
927
+ region, plain = unknown). Mermaid's built-in 3-class palette is
928
+ enough signal for v1; full per-region color would need a classDef
929
+ block (deferred — see plan OOS-2.A).
930
+
931
+ Kill-criterion: <3 total rows after dropping zero-duration steps
932
+ AND no LLM-call rows → return []. If the step set is thin but the
933
+ call set is non-empty, we still render (the call rows are the
934
+ whole point of the overlay).
935
+ """
936
+ if start_dt is None:
937
+ return []
938
+ rows: List[tuple] = [] # (section_label, row_label, start_ms, end_ms, is_action)
939
+ turns = _iter_turns(sess)
940
+ for iv in sess.get("interactions", []):
941
+ if iv.get("type") != "TURN":
942
+ continue
943
+ topic = iv.get("topic") or "(no topic)"
944
+ section = f"Turn {turns.index(iv) + 1} ({topic})"
945
+ for st in iv.get("steps", []):
946
+ if st.get("type") == "SESSION_END":
947
+ continue
948
+ s_dt = _parse_iso(st.get("start_ts"))
949
+ e_dt = _parse_iso(st.get("end_ts"))
950
+ if s_dt is None or e_dt is None:
951
+ continue
952
+ s_ms = int((s_dt - start_dt).total_seconds() * 1000)
953
+ e_ms = int((e_dt - start_dt).total_seconds() * 1000)
954
+ if e_ms <= s_ms:
955
+ continue # skip zero-duration (e.g. TOPIC_STEP markers)
956
+ rows.append((section, _step_display_name(st), s_ms, e_ms,
957
+ st.get("type") == "ACTION_STEP"))
958
+
959
+ # LLM-call rows — one per gateway call, derived from _llm_calls.json.
960
+ # Rendered in a trailing section so they don't interleave with step
961
+ # rows (mermaid sections don't sort across boundaries).
962
+ call_rows: List[tuple] = [] # (row_label, start_ms, end_ms, class_tag)
963
+ for call in (llm_calls or []):
964
+ t_iso = call.get("_time")
965
+ dur_ms = call.get("duration_ms")
966
+ if not t_iso or dur_ms is None:
967
+ continue
968
+ t_dt = _parse_iso(t_iso)
969
+ if t_dt is None:
970
+ continue
971
+ s_ms = int((t_dt - start_dt).total_seconds() * 1000)
972
+ e_ms = s_ms + int(dur_ms)
973
+ if e_ms <= s_ms:
974
+ continue
975
+ # class_tag: cross_region=True → :active (mermaid's yellow),
976
+ # cross_region=False → :done (grey), unknown/None → no tag
977
+ # (default blue). Operators can scan for the yellow bars as
978
+ # the cross-region outliers.
979
+ cr = call.get("cross_region")
980
+ if cr is True:
981
+ class_tag = ":active, "
982
+ elif cr is False:
983
+ class_tag = ":done, "
984
+ else:
985
+ class_tag = ":"
986
+ model = call.get("model_name") or "call"
987
+ region = call.get("routing_decision") or "—"
988
+ label = _escape_mermaid_label(f"{model} [{region}]")
989
+ call_rows.append((label, s_ms, e_ms, class_tag))
990
+
991
+ if len(rows) < 3 and not call_rows:
992
+ return []
993
+
994
+ # axisFormat: `%M:%S` renders as minutes:seconds via d3. Our offsets
995
+ # are ms-from-start passed through `dateFormat x` (unix epoch); d3
996
+ # treats them as Jan 1 1970 timestamps, so for any session under
997
+ # 1 hour this reads cleanly as m:ss elapsed. `%L` (ms-of-second)
998
+ # always rendered 000 because ticks land at second boundaries.
999
+ # The leading `section anchor` + 1ms milestone forces the x-axis to
1000
+ # start at 00:00 — mermaid derives axis min from the smallest task
1001
+ # timestamp, and without the anchor that's the first step's offset
1002
+ # (~5s into a typical session), making the axis misleadingly slide.
1003
+ lines = [
1004
+ "```mermaid",
1005
+ "gantt",
1006
+ " title Session timeline (m:ss from start)",
1007
+ " dateFormat x",
1008
+ " axisFormat %M:%S",
1009
+ " section Session start",
1010
+ " t0 :milestone, 0, 0",
1011
+ ]
1012
+ current_section: Optional[str] = None
1013
+ for section, label, s_ms, e_ms, is_action in rows:
1014
+ if section != current_section:
1015
+ lines.append(f" section {section}")
1016
+ current_section = section
1017
+ # Mermaid gantt task: `<name> :[tag,] <start>, <end>` — single colon,
1018
+ # tag is the first comma-separated field after it. Two colons (the
1019
+ # `:crit:` shape) is a parse error.
1020
+ prefix = ":crit, " if is_action else ":"
1021
+ lines.append(f" {label} {prefix}{s_ms}, {e_ms}")
1022
+ if call_rows:
1023
+ lines.append(" section LLM calls")
1024
+ for label, s_ms, e_ms, class_tag in call_rows:
1025
+ lines.append(f" {label} {class_tag}{s_ms}, {e_ms}")
1026
+ lines.append("```")
1027
+ lines.append("")
1028
+ return lines
1029
+
1030
+
1031
+ def _action_short_name(st: dict) -> str:
1032
+ name = st.get("name") or ""
1033
+ if "." in name:
1034
+ name = name.rsplit(".", 1)[-1]
1035
+ return name or "action"
1036
+
1037
+
1038
+ def _mermaid_sequence_per_turn(iv: dict, start_dt: Optional[datetime],
1039
+ turn_no: int) -> List[str]:
1040
+ steps = iv.get("steps", [])
1041
+ has_error = any(st.get("error_text") for st in steps)
1042
+ action_steps = [st for st in steps if st.get("type") == "ACTION_STEP"]
1043
+ if not has_error and len(action_steps) < 2:
1044
+ return []
1045
+
1046
+ topic = iv.get("topic") or "(no topic)"
1047
+ heading = f"### Turn {turn_no} ({topic}) — control flow"
1048
+ lines: List[str] = [heading, "", "```mermaid", "sequenceDiagram"]
1049
+
1050
+ # Participants: U, P, L are always present; actions get A1, A2, …
1051
+ lines.append(" participant U as USER")
1052
+ lines.append(" participant P as Planner")
1053
+ lines.append(" participant L as LLM Gateway")
1054
+ action_alias: Dict[str, str] = {} # step id → alias
1055
+ for i, st in enumerate(action_steps, start=1):
1056
+ alias = f"A{i}"
1057
+ action_alias[st.get("id") or f"_a{i}"] = alias
1058
+ label = _escape_mermaid_label(_action_short_name(st))
1059
+ lines.append(f" participant {alias} as {label}")
1060
+
1061
+ # User → Planner (utterance).
1062
+ user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
1063
+ user_text = _truncate(user_msg.get("text") if user_msg else None, 40)
1064
+ if user_msg:
1065
+ lines.append(f" U->>P: {_sequence_msg(user_text)}")
1066
+
1067
+ # Walk steps in order. LLM → L, ACTION → A<n>, errors → Note over.
1068
+ for st in steps:
1069
+ stype = st.get("type")
1070
+ if stype == "LLM_STEP":
1071
+ label = _step_display_name(st)
1072
+ gw = st.get("gateway_request") or {}
1073
+ model = gw.get("model")
1074
+ suffix = f" ({model})" if model else ""
1075
+ lines.append(f" P->>L: {_sequence_msg(label + suffix)}")
1076
+ dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
1077
+ if st.get("error_text"):
1078
+ lines.append(f" Note over L: ERROR in {dur}")
1079
+ else:
1080
+ lines.append(f" L-->>P: ok ({dur})")
1081
+ elif stype == "ACTION_STEP":
1082
+ alias = action_alias.get(st.get("id") or "", "A?")
1083
+ dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
1084
+ lines.append(f" P->>{alias}: invoke")
1085
+ lines.append(f" {alias}-->>P: result ({dur})")
1086
+
1087
+ # Agent reply (if any).
1088
+ agent_msg = next((m for m in iv.get("messages", []) if m.get("role") == "AGENT"), None)
1089
+ if agent_msg:
1090
+ agent_text = _truncate(agent_msg.get("text"), 40)
1091
+ lines.append(f" P-->>U: {_sequence_msg(agent_text)}")
1092
+
1093
+ lines.append("```")
1094
+ lines.append("")
1095
+ return lines
1096
+
1097
+
1098
+ def _mermaid_topic_flowchart(sess: dict) -> List[str]:
1099
+ turns = _iter_turns(sess)
1100
+ if not turns:
1101
+ return []
1102
+ # Order-preserving unique topics.
1103
+ topic_ids: Dict[str, str] = {}
1104
+ for iv in turns:
1105
+ t = iv.get("topic") or "(no topic)"
1106
+ if t not in topic_ids:
1107
+ topic_ids[t] = f"T{len(topic_ids) + 1}"
1108
+ if len(topic_ids) == len(turns):
1109
+ return [] # linear — no repeats, skip
1110
+
1111
+ lines: List[str] = ["```mermaid", "flowchart LR"]
1112
+ # Node declarations.
1113
+ for topic, node_id in topic_ids.items():
1114
+ lines.append(f" {node_id}[{_escape_mermaid_label(topic)}]")
1115
+
1116
+ # Edges: one per turn transition, labelled by the user utterance that
1117
+ # drove into that turn. The first turn has no predecessor so we just
1118
+ # declare the node; edges start from turn 2.
1119
+ prev_topic = turns[0].get("topic") or "(no topic)"
1120
+ for iv in turns[1:]:
1121
+ cur_topic = iv.get("topic") or "(no topic)"
1122
+ user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
1123
+ utter = _truncate(user_msg.get("text") if user_msg else None, 30)
1124
+ src = topic_ids[prev_topic]
1125
+ dst = topic_ids[cur_topic]
1126
+ label = _flowchart_edge_label(utter) if utter and utter != "—" else ""
1127
+ arrow = f" {src} -->|{label}| {dst}" if label else f" {src} --> {dst}"
1128
+ lines.append(arrow)
1129
+ prev_topic = cur_topic
1130
+
1131
+ # Error class for topics whose turn ended with an error_text.
1132
+ errored_topic_ids: List[str] = []
1133
+ for iv in turns:
1134
+ if any(st.get("error_text") for st in iv.get("steps", [])):
1135
+ tid = topic_ids[iv.get("topic") or "(no topic)"]
1136
+ if tid not in errored_topic_ids:
1137
+ errored_topic_ids.append(tid)
1138
+ if errored_topic_ids:
1139
+ lines.append(f" classDef err fill:#fee,stroke:#c00")
1140
+ for tid in errored_topic_ids:
1141
+ lines.append(f" class {tid} err")
1142
+
1143
+ lines.append("```")
1144
+ lines.append("")
1145
+ return lines
1146
+
1147
+
1148
+ def _mermaid_token_pie(sess: dict) -> List[str]:
1149
+ buckets: Dict[str, int] = {}
1150
+ total = 0
1151
+ for iv in sess.get("interactions", []):
1152
+ for st in iv.get("steps", []):
1153
+ gw = st.get("gateway_request") or {}
1154
+ p = int(gw.get("prompt_tokens") or 0)
1155
+ c = int(gw.get("completion_tokens") or 0)
1156
+ tok = p + c
1157
+ if tok <= 0:
1158
+ continue
1159
+ role = _role_label_for(st.get("name")) or "other"
1160
+ buckets[role] = buckets.get(role, 0) + tok
1161
+ total += tok
1162
+ if total < 1000 or not buckets:
1163
+ return []
1164
+
1165
+ lines = [
1166
+ "```mermaid",
1167
+ "pie showData",
1168
+ f" title Prompt-role token attribution (total = {total:,})",
1169
+ ]
1170
+ # Largest slice first reads better in most renderers.
1171
+ for role, n in sorted(buckets.items(), key=lambda kv: -kv[1]):
1172
+ lines.append(f' "{role}" : {n}')
1173
+ lines.append("```")
1174
+ lines.append("")
1175
+ return lines
1176
+
1177
+
1178
+ def _section_visual_analysis(
1179
+ sess: dict,
1180
+ start_dt: Optional[datetime],
1181
+ llm_calls: Optional[List[dict]] = None,
1182
+ ) -> List[str]:
1183
+ blocks: List[List[str]] = []
1184
+ gantt = _mermaid_gantt(sess, start_dt, llm_calls=llm_calls)
1185
+ if gantt:
1186
+ blocks.append(gantt)
1187
+ # Per-turn sequence diagrams (at most one per turn; most turns skip).
1188
+ turns = _iter_turns(sess)
1189
+ for idx, iv in enumerate(turns, start=1):
1190
+ seq = _mermaid_sequence_per_turn(iv, start_dt, idx)
1191
+ if seq:
1192
+ blocks.append(seq)
1193
+ flow = _mermaid_topic_flowchart(sess)
1194
+ if flow:
1195
+ blocks.append(flow)
1196
+ pie = _mermaid_token_pie(sess)
1197
+ if pie:
1198
+ blocks.append(pie)
1199
+
1200
+ if not blocks:
1201
+ return []
1202
+ lines: List[str] = ["## Visual analysis", ""]
1203
+ for b in blocks:
1204
+ lines.extend(b)
1205
+ return lines
1206
+
1207
+
1208
+ def _section_session_counts(sess: dict) -> List[str]:
1209
+ c = sess.get("counts", {})
1210
+ st_by_type = c.get("steps_by_type", {})
1211
+ gwb = c.get("gw_binding", {})
1212
+ lines: List[str] = [
1213
+ "## Session counts",
1214
+ "",
1215
+ "| metric | value |",
1216
+ "|---|---|",
1217
+ f"| interactions | {c.get('interactions_total', 0)} |",
1218
+ f"| steps | {c.get('steps_total', 0)} |",
1219
+ f"| llm_steps | {st_by_type.get('LLM_STEP', 0)} |",
1220
+ f"| action_steps | {st_by_type.get('ACTION_STEP', 0)} |",
1221
+ f"| gateway_requests | {c.get('gateway_requests', 0)} |",
1222
+ f"| gateway_responses | {c.get('gateway_responses', 0)} |",
1223
+ f"| 1:1 invariant | {'✓' if c.get('audit_chain_1to1_ok') else '✗'} |",
1224
+ f"| gw declared | {gwb.get('declared', 0)} |",
1225
+ f"| gw timestamp_window | {gwb.get('timestamp_window', 0)} |",
1226
+ f"| gw unbound | {gwb.get('unbound', 0)} |",
1227
+ ]
1228
+ if gwb.get("declared_collisions"):
1229
+ lines.append(f"| ⚠ declared_collisions | {gwb['declared_collisions']} |")
1230
+ lines.append("")
1231
+ return lines
1232
+
1233
+
1234
+ def _section_empties_diagnostics(manifest: dict) -> List[str]:
1235
+ """Operator-actionable: lift `_unavailable_reason` verbatim from the manifest."""
1236
+ queries = manifest.get("queries", []) if manifest else []
1237
+ empties = [
1238
+ q for q in queries
1239
+ if q.get("rows") == 0 and q.get("_unavailable_reason")
1240
+ ]
1241
+ if not empties:
1242
+ return []
1243
+ lines: List[str] = [
1244
+ "## Empties diagnostics",
1245
+ "",
1246
+ "DMOs that returned zero rows, with the reason lifted verbatim from the manifest:",
1247
+ "",
1248
+ "| DMO | Reason |",
1249
+ "|---|---|",
1250
+ ]
1251
+ for q in empties:
1252
+ name = q.get("name") or "?"
1253
+ # Pipe characters in reason text would break markdown tables.
1254
+ reason = str(q.get("_unavailable_reason") or "").replace("|", "\\|")
1255
+ lines.append(f"| {name} | {reason} |")
1256
+ lines.append("")
1257
+ return lines
1258
+
1259
+
1260
+ def _section_catalog(tree: dict) -> List[str]:
1261
+ catalog = tree.get("catalog", {}) or {}
1262
+ agents = ", ".join(catalog.get("agents_observed", []) or []) or "—"
1263
+ return [
1264
+ "## Catalog (session-filtered)",
1265
+ "",
1266
+ f"- TagDefinitions: {len(catalog.get('tag_definitions', []) or [])}",
1267
+ f"- TagDefinitionAssociations: "
1268
+ f"{len(catalog.get('tag_definition_associations', []) or [])} (agents: {agents})",
1269
+ f"- Tags: {len(catalog.get('tags', []) or [])}",
1270
+ "",
1271
+ ]
1272
+
1273
+
1274
+ # ---- top-level render + entry points -------------------------------------
1275
+
1276
+ def render(
1277
+ tree: dict,
1278
+ manifest: Optional[dict] = None,
1279
+ session_dir: Optional[Path] = None,
1280
+ *,
1281
+ show_prompts: bool = False,
1282
+ ) -> str:
1283
+ """Produce the summary markdown for a single session.
1284
+
1285
+ Branches on tree shape:
1286
+ - gateway-direct tree (``_source == "gateway_direct"``) → identity +
1287
+ lag banner + gateway-chain table + per-call detail; skips the
1288
+ Interaction-dependent sections.
1289
+ - minimal tree (session-not-found) → short markdown with just identity.
1290
+ - full tree → multi-section summary (see SKILL.md for the full list).
1291
+
1292
+ ``session_dir`` is reserved for callers that want the renderer to look
1293
+ up artifacts beside the tree on disk. The standalone d360 skill produces
1294
+ no runtime-telemetry rollups — DC alone doesn't expose per-turn LLM
1295
+ latency in a useful form — so when ``None`` (the test-friendly default),
1296
+ the gantt simply draws without the LLM-call overlay.
1297
+
1298
+ ``show_prompts`` (opt-in): when True, the full-tree branch appends
1299
+ a "Planner LLM calls" section with full prompt + response text per
1300
+ LLM call. Default False — the section can add hundreds of KB to the
1301
+ summary on multi-turn sessions.
1302
+
1303
+ Refuses incompatible tree schema versions (see `_assert_schema_version`).
1304
+ """
1305
+ _assert_schema_version(tree)
1306
+
1307
+ # Gateway-direct branch — session resolved but STDM hierarchy hasn't
1308
+ # materialized yet. Handled before the has_interactions check because
1309
+ # the gateway-direct tree does set `session.interactions = []`.
1310
+ if tree.get("_source") == "gateway_direct":
1311
+ return _render_gateway_direct(tree, manifest, show_prompts=show_prompts)
1312
+
1313
+ sess = tree.get("session") or {}
1314
+ sid = sess.get("id") or "<unknown>"
1315
+ has_interactions = "interactions" in sess
1316
+
1317
+ # Minimal-tree early return.
1318
+ if not has_interactions:
1319
+ return _render_minimal(sid, sess)
1320
+
1321
+ start_iso = sess.get("start_ts")
1322
+ start_dt = _parse_iso(start_iso)
1323
+ effective_end_iso, end_source = _derive_session_end(sess)
1324
+
1325
+ # The d360 skill produces no runtime-telemetry rollups — DC alone
1326
+ # doesn't expose per-turn LLM latency in a useful form. Visual
1327
+ # analysis falls back to its pre-rollup output.
1328
+ llm_calls: List[dict] = []
1329
+
1330
+ lines: List[str] = [f"# Session {sid}", ""]
1331
+ lines.extend(_section_session_identity(sess, effective_end_iso, end_source))
1332
+ # VariableText__c bootstrap (channel-mode diagnostic).
1333
+ lines.extend(_section_session_bootstrap(
1334
+ sess.get("identity") or {}, channel=sess.get("channel"),
1335
+ ))
1336
+ lines.extend(_section_id_reference(sess))
1337
+ lines.extend(_section_transcript(sess, start_dt))
1338
+ lines.extend(_section_hierarchical_trace(sess, start_dt, effective_end_iso, end_source))
1339
+ lines.extend(_section_per_turn_summary(sess, start_dt))
1340
+ # Opt-in full prompt + response per LLM call. Off by
1341
+ # default — multi-turn sessions can produce hundreds of KB here.
1342
+ lines.extend(_section_planner_llm_calls(sess, show_prompts=show_prompts))
1343
+ lines.extend(_section_visual_analysis(sess, start_dt, llm_calls=llm_calls))
1344
+ lines.extend(_section_session_counts(sess))
1345
+ lines.extend(_section_empties_diagnostics(manifest or {}))
1346
+ lines.extend(_section_catalog(tree))
1347
+ return "\n".join(lines) + "\n"
1348
+
1349
+
1350
+ def _render_minimal(sid: str, sess: dict) -> str:
1351
+ """Short markdown for session-not-found minimal trees."""
1352
+ shape = (sess.get("counts") or {}).get("session_shape", "session_not_found")
1353
+ lines = [
1354
+ f"# Session {sid}",
1355
+ "",
1356
+ "## Session identity",
1357
+ "",
1358
+ "| Field | Value |",
1359
+ "|---|---|",
1360
+ f"| Session id | {sid} |",
1361
+ f"| Session shape | {shape} |",
1362
+ "",
1363
+ "_No interactions resolved in Data Cloud. Check the session id, or wait for "
1364
+ "STDM materialization._",
1365
+ "",
1366
+ ]
1367
+ return "\n".join(lines) + "\n"
1368
+
1369
+
1370
+ # Display-only cap on `prompt_text` inside per-call detail. The raw JSON on
1371
+ # disk (dc.gateway_requests.json) is authoritative and never truncated — the
1372
+ # assembler stores the full prompt, and this slice only applies to markdown.
1373
+ _PROMPT_DISPLAY_CAP_BYTES = 65536
1374
+
1375
+
1376
+ def _fmt_token_count(value) -> str:
1377
+ """Tolerate ints, stringified ints, and None/''/NOT_SET."""
1378
+ if value in (None, "", "NOT_SET"):
1379
+ return "—"
1380
+ return str(value)
1381
+
1382
+
1383
+ def _render_gateway_direct(tree: dict, manifest: Optional[dict],
1384
+ *, show_prompts: bool = False) -> str:
1385
+ """Render the STDM-hasn't-materialized-yet view.
1386
+
1387
+ Sections:
1388
+ 1. Session identity (reused)
1389
+ 2. ID reference (reused; gracefully handles the empty
1390
+ interactions/participants on this path)
1391
+ 3. STDM lag banner (gateway-direct specific)
1392
+ 4. Gateway chain table (gateway-direct specific)
1393
+ 5. Per-call detail (gateway-direct specific)
1394
+ 6. Empties diagnostics (reused; reads manifest, not tree)
1395
+ 7. Catalog (reused; catalog may be empty on a
1396
+ fresh-session gateway-direct tree)
1397
+
1398
+ Skipped: Transcript, Hierarchical trace, Per-turn summary, Session
1399
+ counts — all require Interaction rows that don't exist yet.
1400
+
1401
+ ``show_prompts`` (default False): forwarded to per-call detail so the
1402
+ full prompt block only renders under ``--show-prompts``. The prior
1403
+ behavior unconditionally leaked prompts on this branch, contradicting
1404
+ the documented contract for ``dc._session_summary.md``.
1405
+ """
1406
+ sess = tree.get("session") or {}
1407
+ sid = sess.get("id") or "<unknown>"
1408
+ start_iso = sess.get("start_ts")
1409
+ start_dt = _parse_iso(start_iso)
1410
+ # No derivation needed — sessions.end_ts is the only source on this path.
1411
+ effective_end_iso = sess.get("end_ts")
1412
+
1413
+ lines: List[str] = [f"# Session {sid}", ""]
1414
+ lines.extend(_section_session_identity(sess, effective_end_iso, None))
1415
+ # VariableText__c bootstrap (channel-mode diagnostic).
1416
+ lines.extend(_section_session_bootstrap(
1417
+ sess.get("identity") or {}, channel=sess.get("channel"),
1418
+ ))
1419
+ lines.extend(_section_id_reference(sess))
1420
+ lines.extend(_section_stdm_lag_banner())
1421
+ lines.extend(_section_gateway_chain_table(sess, start_dt))
1422
+ lines.extend(_section_gateway_per_call_detail(sess, show_prompts=show_prompts))
1423
+ lines.extend(_section_empties_diagnostics(manifest or {}))
1424
+ lines.extend(_section_catalog(tree))
1425
+ return "\n".join(lines) + "\n"
1426
+
1427
+
1428
+ def _section_stdm_lag_banner() -> List[str]:
1429
+ return [
1430
+ "## STDM materialization lag",
1431
+ "",
1432
+ "> **Note** STDM Interaction/Step/Message DMOs have not yet "
1433
+ "materialized for this session. The view below is the gateway chain "
1434
+ "harvested directly from Gateway DMOs (materialize in minutes). "
1435
+ "Re-run in 24–72h for the full hierarchical trace.",
1436
+ "",
1437
+ ]
1438
+
1439
+
1440
+ def _section_gateway_chain_table(sess: dict,
1441
+ start_dt: Optional[datetime]) -> List[str]:
1442
+ chain = sess.get("gateway_chain") or []
1443
+ lines: List[str] = [
1444
+ "## Gateway chain",
1445
+ "",
1446
+ "| # | Request ts | Model | Provider | Prompt template | "
1447
+ "Prompt tok | Completion tok | Total tok | Response ts |",
1448
+ "|---|---|---|---|---|---|---|---|---|",
1449
+ ]
1450
+ for i, call in enumerate(chain, start=1):
1451
+ req_offset = _fmt_offset(call.get("timestamp"), start_dt)
1452
+ resp_offset = _fmt_offset(
1453
+ (call.get("response") or {}).get("timestamp"), start_dt)
1454
+ model = call.get("model") or "—"
1455
+ provider = call.get("provider") or "—"
1456
+ template = call.get("prompt_template_dev_name") or "—"
1457
+ prompt_tok = _fmt_token_count(call.get("prompt_tokens"))
1458
+ completion_tok = _fmt_token_count(call.get("completion_tokens"))
1459
+ total_tok = _fmt_token_count(call.get("total_tokens"))
1460
+ lines.append(
1461
+ f"| {i} | {req_offset} | {model} | {provider} | {template} | "
1462
+ f"{prompt_tok} | {completion_tok} | {total_tok} | {resp_offset} |"
1463
+ )
1464
+ lines.append("")
1465
+ return lines
1466
+
1467
+
1468
+ def _pick_fence(text: str) -> str:
1469
+ """Pick a backtick fence long enough to wrap `text` safely.
1470
+
1471
+ CommonMark lets a fenced code block use any run of 3+ backticks; the
1472
+ closing fence must be at least as long as the opening. LLM prompts
1473
+ routinely contain triple-backticks inside tool-use examples, so a
1474
+ hardcoded ``` fence closes early and corrupts the rest of the doc.
1475
+ """
1476
+ if not isinstance(text, str) or not text:
1477
+ return "```"
1478
+ longest = 0
1479
+ run = 0
1480
+ for ch in text:
1481
+ if ch == "`":
1482
+ run += 1
1483
+ if run > longest:
1484
+ longest = run
1485
+ else:
1486
+ run = 0
1487
+ return "`" * max(3, longest + 1)
1488
+
1489
+
1490
+ def _capped_payload(text: Optional[str], note_source: str) -> tuple[str, bool, str]:
1491
+ """Cap a payload string at `_PROMPT_DISPLAY_CAP_BYTES` for display.
1492
+
1493
+ Returns ``(body, truncated, source_note)``. Byte-length check so
1494
+ multi-byte chars don't blow through the limit when the renderer emits
1495
+ UTF-8 text. Slicing happens on the encoded form, then decodes with
1496
+ ``errors="ignore"`` so we never split a multi-byte char mid-sequence.
1497
+ `source_note` names the on-disk file with the authoritative full text.
1498
+ """
1499
+ if not isinstance(text, str) or not text:
1500
+ return ("(empty)", False, note_source)
1501
+ encoded = text.encode("utf-8")
1502
+ if len(encoded) <= _PROMPT_DISPLAY_CAP_BYTES:
1503
+ return (text, False, note_source)
1504
+ body = encoded[:_PROMPT_DISPLAY_CAP_BYTES].decode("utf-8", errors="ignore")
1505
+ return (body, True, note_source)
1506
+
1507
+
1508
+ def _render_call_detail_block(call: dict, idx: int, *,
1509
+ show_prompts: bool = False,
1510
+ show_response_text: bool = False) -> List[str]:
1511
+ """Render one ``#### LLM call N — <short-id>`` block.
1512
+
1513
+ Used by both:
1514
+ - the gateway-direct branch (``_section_gateway_per_call_detail``)
1515
+ - the full-tree opt-in section (``_section_planner_llm_calls``)
1516
+
1517
+ ``call`` shape (subset of fields used here):
1518
+ gateway_request_id, model, provider, prompt_template_dev_name,
1519
+ prompt_tokens, completion_tokens, total_tokens, prompt_text,
1520
+ response (-> finish_reason), response_text (only on full-tree path).
1521
+
1522
+ The prompt and response blocks are independently gated:
1523
+ - ``show_prompts`` controls the **Prompt** block.
1524
+ - ``show_response_text`` controls the **Response** block (full-tree
1525
+ only — gateway-direct chain entries don't carry response_text).
1526
+ Both default off so callers must opt in explicitly. The summary line
1527
+ (model/provider/template/tokens/finish_reason) always renders.
1528
+ """
1529
+ gw_id = call.get("gateway_request_id") or "—"
1530
+ short_id = _short(gw_id)
1531
+ lines = [f"#### LLM call {idx} — {short_id}", ""]
1532
+ finish_reason = (call.get("response") or {}).get("finish_reason") or "—"
1533
+ summary = (
1534
+ f"- model={call.get('model') or '—'}"
1535
+ f" provider={call.get('provider') or '—'}"
1536
+ f" template={call.get('prompt_template_dev_name') or '—'}"
1537
+ f" prompt_tok={_fmt_token_count(call.get('prompt_tokens'))}"
1538
+ f" completion_tok={_fmt_token_count(call.get('completion_tokens'))}"
1539
+ f" total_tok={_fmt_token_count(call.get('total_tokens'))}"
1540
+ f" finish_reason={finish_reason}"
1541
+ )
1542
+ lines.append(summary)
1543
+ lines.append("")
1544
+
1545
+ # Prompt block — gated by show_prompts. Default-off everywhere; the
1546
+ # summary file should never leak full prompt text without an explicit
1547
+ # --show-prompts opt-in (matches the doc contract in SKILL.md).
1548
+ if show_prompts:
1549
+ body, truncated, src = _capped_payload(
1550
+ call.get("prompt_text"), "dc.gateway_requests.json")
1551
+ fence = _pick_fence(body)
1552
+ lines.append("**Prompt** (full input sent to the model):")
1553
+ lines.append(fence)
1554
+ lines.append(body)
1555
+ if truncated:
1556
+ lines.append(f"…[truncated; full prompt in {src}]")
1557
+ lines.append(fence)
1558
+ lines.append("")
1559
+
1560
+ # Response block — only the full-tree path carries response_text;
1561
+ # gateway-direct rows get finish_reason in the header line above and
1562
+ # nothing else (the response DMO doesn't carry text on that path).
1563
+ if show_response_text:
1564
+ body, truncated, src = _capped_payload(
1565
+ call.get("response_text"), "dc.generations.json")
1566
+ # html.unescape so the rendered block reads as plain JSON instead of
1567
+ # &quot;-laden text — matches the existing _decoded_line treatment.
1568
+ if body and body != "(empty)":
1569
+ body = html.unescape(body)
1570
+ fence = _pick_fence(body)
1571
+ lines.append("**Response** (model output, including tool invocations):")
1572
+ lines.append(fence)
1573
+ lines.append(body)
1574
+ if truncated:
1575
+ lines.append(f"…[truncated; full response in {src}]")
1576
+ lines.append(fence)
1577
+ lines.append("")
1578
+
1579
+ return lines
1580
+
1581
+
1582
+ def _section_gateway_per_call_detail(sess: dict, *,
1583
+ show_prompts: bool = False) -> List[str]:
1584
+ """Per-call detail for the gateway-direct branch.
1585
+
1586
+ ``gateway_chain`` does NOT carry `response_text` (the responses DMO
1587
+ doesn't include it on that path), so the response block is always
1588
+ suppressed. The prompt block is gated by ``show_prompts`` so the
1589
+ default summary doesn't leak full prompt text — matches the
1590
+ documented contract for ``dc._session_summary.md``.
1591
+ """
1592
+ chain = sess.get("gateway_chain") or []
1593
+ lines: List[str] = ["## Per-call detail", ""]
1594
+ for i, call in enumerate(chain, start=1):
1595
+ lines.extend(_render_call_detail_block(
1596
+ call, i, show_prompts=show_prompts, show_response_text=False))
1597
+ return lines
1598
+
1599
+
1600
+ def _collect_planner_llm_calls(sess: dict) -> List[dict]:
1601
+ """Walk ``interactions[].steps[]`` and collect call-view dicts.
1602
+
1603
+ Each step that has a ``gateway_request`` (regardless of binding method)
1604
+ contributes one call view. ``response_text`` is sourced from the step's
1605
+ sibling ``generation.response_text`` when present; otherwise the call is
1606
+ still emitted with the prompt + token summary.
1607
+
1608
+ Returned dicts use the same field names as ``gateway_chain`` entries so
1609
+ ``_render_call_detail_block`` can consume both shapes uniformly.
1610
+ """
1611
+ calls: List[dict] = []
1612
+ for iv in sess.get("interactions") or []:
1613
+ for st in iv.get("steps") or []:
1614
+ gw = st.get("gateway_request")
1615
+ if not gw:
1616
+ continue
1617
+ gen = st.get("generation") or {}
1618
+ calls.append({
1619
+ "gateway_request_id": gw.get("gateway_request_id"),
1620
+ "model": gw.get("model"),
1621
+ "provider": gw.get("provider"),
1622
+ "prompt_template_dev_name": gw.get("prompt_template_dev_name"),
1623
+ "prompt_tokens": gw.get("prompt_tokens"),
1624
+ "completion_tokens": gw.get("completion_tokens"),
1625
+ "total_tokens": gw.get("total_tokens"),
1626
+ "prompt_text": gw.get("prompt_text"),
1627
+ "response": gw.get("response"),
1628
+ "response_text": gen.get("response_text"),
1629
+ })
1630
+ return calls
1631
+
1632
+
1633
+ def _section_planner_llm_calls(sess: dict, *, show_prompts: bool) -> List[str]:
1634
+ """Opt-in full-tree section showing the input prompt + response
1635
+ for every LLM call in the session's hierarchical trace.
1636
+
1637
+ Off by default — prompts can be 30 KB+ each on multi-turn sessions
1638
+ and would otherwise dominate the summary. Enable with
1639
+ ``render_dc.py --show-prompts``.
1640
+ """
1641
+ if not show_prompts:
1642
+ return []
1643
+ calls = _collect_planner_llm_calls(sess)
1644
+ if not calls:
1645
+ return []
1646
+ lines: List[str] = [
1647
+ "## Planner LLM calls (full prompts + responses)",
1648
+ "",
1649
+ f"_Found {len(calls)} LLM call(s) across the session's hierarchical "
1650
+ f"trace. Prompts are capped at "
1651
+ f"{_PROMPT_DISPLAY_CAP_BYTES // 1024} KB for display; full payloads "
1652
+ f"are on disk in `dc.gateway_requests.json` and "
1653
+ f"`dc.generations.json`._",
1654
+ "",
1655
+ ]
1656
+ for i, call in enumerate(calls, start=1):
1657
+ # show_prompts is True here by section-guard above (early return when
1658
+ # show_prompts is False); pass through explicitly so the helper's new
1659
+ # prompt-gate stays aligned with the section's intent.
1660
+ lines.extend(_render_call_detail_block(
1661
+ call, i, show_prompts=True, show_response_text=True))
1662
+ return lines
1663
+
1664
+
1665
+ def _assert_schema_version(tree: dict) -> None:
1666
+ """Refuse unsupported versions; warn on missing version (older assembler)."""
1667
+ version = (tree.get("session") or {}).get("_schema_version")
1668
+ if version is None:
1669
+ print(
1670
+ "render_dc: WARN tree has no _schema_version "
1671
+ "(produced by an older assembler?); rendering anyway",
1672
+ file=sys.stderr,
1673
+ )
1674
+ return
1675
+ if version != _SUPPORTED_SCHEMA_VERSION:
1676
+ raise SystemExit(
1677
+ f"render_dc: unsupported tree _schema_version={version}; "
1678
+ f"expected {_SUPPORTED_SCHEMA_VERSION}"
1679
+ )
1680
+
1681
+
1682
+ def main_for_session(sid: str, *, show_prompts: bool = False) -> int:
1683
+ """Read session tree + manifest from the nested session dir; emit summary.md.
1684
+
1685
+ Uses ``assemble_dc._find_session_dir`` to locate the session under
1686
+ ``DATA_ROOT/<org>/<agent>__<ver>/<sid>/`` — follows the ``_sessions/*.link``
1687
+ breadcrumb when present, globs otherwise. No callers need to know the
1688
+ full identity triple upfront.
1689
+
1690
+ ``show_prompts``: pass through to ``render`` to include the
1691
+ opt-in "Planner LLM calls" section.
1692
+ """
1693
+ from assemble_dc import _find_session_dir
1694
+ session_dir = _find_session_dir(sid)
1695
+ tree_path = session_dir / "dc._session_tree.json"
1696
+ if not tree_path.is_file():
1697
+ raise SystemExit(
1698
+ f"render_dc: tree not found at {tree_path}; "
1699
+ f"run `python3 scripts/assemble_dc.py --session {sid}` first"
1700
+ )
1701
+ manifest_path = session_dir / "dc._session_manifest.json"
1702
+ manifest: Optional[dict] = None
1703
+ if manifest_path.is_file():
1704
+ try:
1705
+ manifest = json.loads(manifest_path.read_text())
1706
+ except (json.JSONDecodeError, OSError) as e:
1707
+ print(
1708
+ f"render_dc: WARN could not read manifest: {str(e).splitlines()[0]}",
1709
+ file=sys.stderr,
1710
+ )
1711
+
1712
+ tree = json.loads(tree_path.read_text())
1713
+ _assert_schema_version(tree)
1714
+
1715
+ md_path = session_dir / "dc._session_summary.md"
1716
+ md_path.write_text(render(tree, manifest, session_dir=session_dir,
1717
+ show_prompts=show_prompts))
1718
+ print(f"render_dc: wrote {md_path}", file=sys.stderr)
1719
+ return 0
1720
+
1721
+
1722
+ def main() -> int:
1723
+ ap = argparse.ArgumentParser(
1724
+ prog="render_dc.py",
1725
+ description="Render dc._session_summary.md from dc._session_tree.json for one session.",
1726
+ )
1727
+ ap.add_argument("--session", required=True,
1728
+ help="AI-agent session UUID or MessagingSession id (0Mw...). "
1729
+ "Messaging ids are resolved from disk "
1730
+ "(DATA_ROOT/*/dc.sessions.json); run fetch_dc.py first "
1731
+ "if the session hasn't been fetched yet.")
1732
+ ap.add_argument("--show-prompts", action="store_true",
1733
+ help="Include the opt-in 'Planner LLM calls' section "
1734
+ "with the full input prompt + response per LLM call. "
1735
+ "Off by default — multi-turn sessions can produce "
1736
+ "hundreds of KB here. Per-prompt display is capped "
1737
+ "at 64 KB; full payloads remain on disk in "
1738
+ "dc.gateway_requests.json + dc.generations.json.")
1739
+ # Runtime-agnostic path overrides; default to ~/.vibe/...
1740
+ from _shared.cli_override import add_cli_flags, apply_overrides
1741
+ add_cli_flags(ap)
1742
+ args = ap.parse_args()
1743
+ apply_overrides(args, caller_globals=globals())
1744
+ from resolve_session import resolve_disk_or_live
1745
+ sid = resolve_disk_or_live(args.session)
1746
+ return main_for_session(sid, show_prompts=args.show_prompts)
1747
+
1748
+
1749
+ if __name__ == "__main__":
1750
+ sys.exit(main())