@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,1624 @@
1
+ """Assemble dc._session_tree.json from fetched DC artifacts.
2
+
3
+ Given `DATA_ROOT/<sid>/dc.*.json` + `dc._session_manifest.json` (produced by
4
+ `scripts/fetch_dc.py`), this module joins the rows in memory and emits:
5
+
6
+ - dc._session_tree.json — session-rooted hierarchical view
7
+ (Interaction → Step → Generation →
8
+ GatewayRequest, with audit rows nested)
9
+
10
+ The human-readable markdown summary is produced by a separate stage,
11
+ `scripts/render_dc.py`, which reads only the tree.
12
+
13
+ Design contract (see references/dc_pipeline_contract.md):
14
+
15
+ - No DMO fetches. Pure in-memory compute over already-fetched artifacts.
16
+ - Driven off `manifest["queries"][*]["name"]` — adding a 25th DMO to
17
+ fetch_dc.py doesn't require changes here (it just won't be placed in
18
+ the tree until the logic is extended).
19
+ - Declared binding chain nests GatewayRequest under LLM_STEP via
20
+ `Step.ssot__GenerationId__c → Generation → GatewayResponse → Request`.
21
+ - Chain-orphan GW calls fall through to a timestamp-window rule
22
+ (tier dominates: ACTION → TOPIC → TRUST_GUARDRAILS → any other;
23
+ innermost Step wins within a tier).
24
+ - PK collisions and parse warnings surface in `counts.*`, not stderr-only.
25
+
26
+ Invocation:
27
+ python3 scripts/assemble_dc.py --session <sid>
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import functools
33
+ import html
34
+ import json
35
+ import re
36
+ import sys
37
+ from collections import defaultdict
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime
40
+ from pathlib import Path
41
+ from typing import Any, Dict, Iterable, List, Literal, Optional, Set, Tuple
42
+
43
+ sys.path.insert(0, str(Path(__file__).parent))
44
+
45
+ from config import DATA_ROOT, paths
46
+
47
+
48
+ # ---- sentinels + constants -------------------------------------------------
49
+
50
+ _NOT_SET = {"", "NOT_SET", None}
51
+ _INTERNAL_TRACE_RE = re.compile(r'"internalTraceId":"([a-f0-9]+)"') # @rule-suppress starter-sec-002 — re.compile, not eval/exec
52
+
53
+ # Real, non-placeholder agent version. Matches the canonical `^v[0-9]+$`
54
+ # shape that paths.session_dir requires AND excludes the `v0` placeholder
55
+ # stamped by fetch_dc's MyAgent fallback (fetch_dc.py:570-597).
56
+ # Used by `_promote_identity` to decide whether session_identity carries
57
+ # a richer agent_version that should win over a manifest placeholder.
58
+ _REAL_VERSION_RE = re.compile(r'^v[0-9]+$') # @rule-suppress starter-sec-002 — re.compile, not eval/exec
59
+
60
+ # Tier order for timestamp-window fallback. "any other" is an implicit last-resort
61
+ # catch-all covering LLM_STEP (without declared binding), SESSION_END, and any
62
+ # future step types not explicitly listed.
63
+ _TIER_ORDER = ("ACTION_STEP", "TOPIC_STEP", "TRUST_GUARDRAILS_STEP")
64
+
65
+ # Canonical identity-field name → ordered list of `gateway_request_tags.tag__c`
66
+ # values that carry it, tried in order. Agent versions emit different tag
67
+ # names: newer Atlas ReAct agents use `agent_developer_name` /
68
+ # `agent_version_api_name`; legacy MyAgent builds omit the developer
69
+ # name entirely and use the unprefixed `version_api_name`. First non-null
70
+ # value wins. A single-element list means "no fallback known."
71
+ _TAG_KEY_ALIASES: Dict[str, Tuple[str, ...]] = {
72
+ "agent_api_name": ("agent_developer_name",),
73
+ "agent_version": ("agent_version_api_name", "version_api_name"),
74
+ }
75
+
76
+
77
+ # ---- typed namespaces ------------------------------------------------------
78
+ #
79
+ # Four frozen dataclasses replace the former dict-bags. frozen=True guards
80
+ # attribute re-assignment, not mutation of the dict values themselves — the
81
+ # producers are responsible for handing in plain dicts (not defaultdicts) so
82
+ # downstream helpers don't rely on auto-creation the type doesn't promise.
83
+
84
+ @dataclass(frozen=True)
85
+ class Indexes:
86
+ interactions_by_id: Dict[str, dict]
87
+ participants_by_id: Dict[str, dict]
88
+ generations_by_id: Dict[str, dict]
89
+ gw_req_by_id: Dict[str, dict]
90
+ gw_resp_by_resp_id: Dict[str, dict]
91
+ feedback_by_id: Dict[str, dict]
92
+ gw_resp_by_req_id: Dict[str, List[dict]]
93
+ steps_by_interaction: Dict[str, List[dict]]
94
+ messages_by_interaction: Dict[str, List[dict]]
95
+ gw_tags_by_parent: Dict[str, List[dict]]
96
+ gw_md_by_parent: Dict[str, List[dict]]
97
+ gw_llm_by_parent: Dict[str, List[dict]]
98
+ quality_by_parent: Dict[str, List[dict]]
99
+ quality_by_id: Dict[str, dict]
100
+ feedback_by_gen: Dict[str, List[dict]]
101
+ feedback_details_by_parent: Dict[str, List[dict]]
102
+ participant_role_by_id: Dict[str, Optional[str]]
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class PolymorphicSplits:
107
+ categories_by_generation: Dict[str, List[dict]]
108
+ categories_by_quality: Dict[str, List[dict]]
109
+ gw_records_by_gw_req: Dict[str, List[dict]]
110
+ gw_records_by_feedback: Dict[str, List[dict]]
111
+ tag_assoc_session: List[dict]
112
+ tag_assoc_by_interaction: Dict[str, List[dict]]
113
+ tag_assoc_by_moment: Dict[str, List[dict]]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class BindingResults:
118
+ declared_gw_ids: Set[str]
119
+ declared_steps_with_gw: frozenset
120
+ step_id_to_gw_id: Dict[str, Optional[str]]
121
+ declared_collisions: int
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Catalog:
126
+ agents_observed: List[str]
127
+ tag_definitions: List[dict]
128
+ tag_definition_associations: List[dict]
129
+ tags: List[dict]
130
+
131
+
132
+ @dataclass
133
+ class BinderCtx:
134
+ """Per-interaction scratch state used only by the timestamp-window pass.
135
+
136
+ Kept in a parallel `Dict[iid, BinderCtx]` instead of stashed on the
137
+ interaction view, so binder state can never leak into the emitted tree.
138
+ """
139
+ start_ts: Optional[datetime]
140
+ end_ts: Optional[datetime]
141
+ steps_with_ts: List[Tuple[dict, Optional[datetime], Optional[datetime]]]
142
+ reserved_step_ids: frozenset
143
+
144
+
145
+ # ---- session-dir resolution ----------------------------------------------
146
+
147
+ def _find_session_dir(sid: str) -> Path:
148
+ """Locate the session dir under the nested layout.
149
+
150
+ Given only a session id, we don't know ``(org, agent, version)`` upfront.
151
+ Strategy:
152
+
153
+ 1. Validate ``sid`` against ``paths.SESSION_ID_RE`` at entry. ``sid`` comes
154
+ in via argv / resolve_session and flows directly into path composition
155
+ (``<org>/_sessions/<sid>.link``) and glob patterns; an unvalidated
156
+ value here would undo the traversal guard added in PR #657 BLOCKER-2.
157
+ 2. Breadcrumb lookup: ``DATA_ROOT/<org>/_sessions/<sid>.link`` is a plain-
158
+ text relative-path pointer written by ``storage.save``. Iterate all
159
+ orgs, read each ``.link``, resolve against the breadcrumb's parent,
160
+ and **enforce containment** — a tampered or stale breadcrumb whose
161
+ target escapes ``DATA_ROOT`` is skipped (not raised) so a single
162
+ malicious breadcrumb can't DoS the whole resolver. Falls through to
163
+ the glob fallback on any breadcrumb miss.
164
+ 3. Glob fallback: ``DATA_ROOT/*/*/<sid>/`` — ``sid`` is now validated at
165
+ entry so the pattern is fixed-depth and cannot glob outside its
166
+ intended 2-level subtree.
167
+ 4. Raise ``SystemExit`` with a clear hint to run fetch_dc.py first.
168
+
169
+ Returns the absolute directory path.
170
+ """
171
+ # Validate first — rejects "../etc", "a/b", "", None, and control chars.
172
+ # ``sid`` from here on is safe to use as a path segment and as the tail
173
+ # of a fixed-depth glob.
174
+ paths.validate_session_id(sid)
175
+ root = paths.DATA_ROOT
176
+ if root.is_dir():
177
+ # Resolve DATA_ROOT once so containment checks don't repeat the walk.
178
+ root_resolved = root.resolve()
179
+ for org_dir in root.iterdir():
180
+ if not org_dir.is_dir():
181
+ continue
182
+ link = org_dir / "_sessions" / f"{sid}.link"
183
+ if link.is_file():
184
+ try:
185
+ rel = link.read_text().strip()
186
+ except OSError:
187
+ continue
188
+ # Resolve relative to the breadcrumb's parent (_sessions/),
189
+ # then enforce that the resolved path stays inside
190
+ # DATA_ROOT. ``.link`` contents are user-writable — a planted
191
+ # breadcrumb with ``../../../../etc/passwd`` must NOT pivot
192
+ # the assembler outside the plugin's data tree.
193
+ target = (link.parent / rel).resolve()
194
+ if not target.is_relative_to(root_resolved):
195
+ # Stale or malicious breadcrumb. Skip — fall through to
196
+ # the glob fallback rather than raising, so one bad
197
+ # breadcrumb in one org doesn't block discovery in
198
+ # another.
199
+ continue
200
+ if target.is_dir():
201
+ return target
202
+ # Glob fallback. ``sid`` is validated; the pattern is fixed-depth.
203
+ matches = list(root.glob(f"*/*/{sid}"))
204
+ if len(matches) == 1:
205
+ return matches[0]
206
+ if len(matches) > 1:
207
+ # Placeholder agent dirs (leading-underscore name like
208
+ # ``<org>/_agent_<botid>__v0/``) mark provisional sessions
209
+ # whose identity wasn't fully resolved at first write. When a
210
+ # real (agent, version) dir also exists for the same session,
211
+ # it's the authoritative home; the placeholder is stale.
212
+ # Prefer the real dir.
213
+ real = [p for p in matches if not p.parent.name.startswith("_")]
214
+ if len(real) == 1:
215
+ return real[0]
216
+ raise SystemExit(
217
+ f"assemble_dc: session {sid} resolves to {len(matches)} dirs "
218
+ f"under {root} — ambiguous. Check for duplicate sessions "
219
+ f"across agents."
220
+ )
221
+ raise SystemExit(
222
+ f"assemble_dc: session dir for {sid} not found under {root}; "
223
+ f"run fetch_dc.py first"
224
+ )
225
+
226
+
227
+ # ---- loaders ---------------------------------------------------------------
228
+
229
+ def _load(session_dir: Path, name: str, parse_warnings: List[str]) -> List[dict]:
230
+ """Load dc.<name>.json from session_dir. Missing → []. Malformed → [] + warning."""
231
+ p = session_dir / f"dc.{name}.json"
232
+ if not p.is_file():
233
+ return []
234
+ try:
235
+ return json.loads(p.read_text())
236
+ except (json.JSONDecodeError, OSError) as e:
237
+ print(f"assemble_dc: WARN dc.{name}.json unreadable: {str(e).splitlines()[0]}",
238
+ file=sys.stderr)
239
+ parse_warnings.append(name)
240
+ return []
241
+
242
+
243
+ def _load_manifest(session_dir: Path) -> dict:
244
+ p = session_dir / "dc._session_manifest.json"
245
+ if not p.is_file():
246
+ raise SystemExit(
247
+ f"assemble_dc: manifest not found at {p}; run fetch_dc.py first"
248
+ )
249
+ manifest = json.loads(p.read_text())
250
+ if "queries" not in manifest:
251
+ raise SystemExit("assemble_dc: manifest schema changed — no 'queries' key")
252
+ return manifest
253
+
254
+
255
+ def _load_all(sid: str) -> Tuple[dict, Dict[str, List[dict]], List[str], Path]:
256
+ """Return (manifest, rows_by_name, parse_warnings, session_dir).
257
+
258
+ Iterates manifest["queries"][*]["name"] rather than a hard-coded list —
259
+ a new DMO added to fetch_dc.py is picked up automatically.
260
+ """
261
+ session_dir = _find_session_dir(sid)
262
+ manifest = _load_manifest(session_dir)
263
+ parse_warnings: List[str] = []
264
+ rows = {
265
+ q["name"]: _load(session_dir, q["name"], parse_warnings)
266
+ for q in manifest["queries"]
267
+ }
268
+ return manifest, rows, parse_warnings, session_dir
269
+
270
+
271
+ # ---- small helpers ---------------------------------------------------------
272
+
273
+ def _clean(value: Any) -> Any:
274
+ """NOT_SET sentinel → None. Other values pass through."""
275
+ return None if value in _NOT_SET else value
276
+
277
+
278
+ def _harvest_str(value: Any) -> Optional[str]:
279
+ """Harvest-layer string normalizer for the session-identity block.
280
+
281
+ Handles three quirks that `_clean` deliberately does not:
282
+ 1. **html.unescape** — tag values arrive double-escaped
283
+ (`"&quot;0Xx…&quot;"`).
284
+ 2. **Quote-strip** — after unescape most tag values are wrapped in
285
+ literal `"` characters; strip them.
286
+ 3. **`UNSET_VALUE` sentinel** — Data Cloud emits this on a small set
287
+ of optional columns (e.g. `gateway_requests.promptTemplateVersionNo__c`,
288
+ certain `tag_first` values on cold-start sessions). Not observed
289
+ on the columns `_build_session_identity` currently reads, but
290
+ included defensively since the sentinel is part of the DC schema
291
+ contract and a harvest-layer reader should collapse it to None.
292
+
293
+ The binding / index layer uses `_clean()` / `_NOT_SET` which
294
+ intentionally omits these rules — they would be noise there.
295
+ """
296
+ if value is None:
297
+ return None
298
+ s = html.unescape(str(value)).strip()
299
+ if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
300
+ s = s[1:-1]
301
+ return None if s in ("", "NOT_SET", "UNSET_VALUE") else s
302
+
303
+
304
+ def _promote_identity(
305
+ manifest_value: Any,
306
+ session_identity_value: Any,
307
+ *,
308
+ kind: Literal["api_name", "version"],
309
+ ) -> Any:
310
+ """Pick the richer of (manifest, session_identity) for a top-level identity slot.
311
+
312
+ Background. ``fetch_dc._resolve_identity`` walks AGENT-role participant
313
+ rows for ``(api_name, version)``. On agent shapes like MyAgent the
314
+ AGENT rows can leave both fields NOT_SET, so the resolver falls back
315
+ (fetch_dc.py:570-597) to picking ``api_name`` from any participant row
316
+ and stamping ``version="v0"`` as a placeholder satisfying
317
+ ``paths.session_dir``'s ``^v[0-9]+$`` shape. The placeholder is enough
318
+ to land the session in a directory, but it's wrong: by wave 5 the
319
+ fetch has materialized ``gateway_request_tags`` rows carrying the real
320
+ ``agent_version_api_name`` (e.g. ``v24``), and ``_build_session_identity``
321
+ correctly harvests it onto ``session.identity``. The top-level
322
+ ``identity`` block was previously copied verbatim from the manifest,
323
+ so the placeholder leaked downstream while the right value sat
324
+ visible in the same JSON.
325
+
326
+ Policy:
327
+
328
+ - ``kind="version"``: if manifest is the ``"v0"`` placeholder AND
329
+ session_identity carries a non-``v0`` value matching ``^v[0-9]+$``,
330
+ promote. Otherwise keep manifest.
331
+ - ``kind="api_name"``: if manifest is NOT_SET-ish (None / "" / "NOT_SET"
332
+ / "NOT SET") AND session_identity has a real value, promote.
333
+ Otherwise keep manifest. (Crucially, when session_identity is None
334
+ and manifest has a value, we keep manifest — the strict AGENT-row
335
+ pick is intentional on healthy sessions.)
336
+ - When manifest and session_identity both carry real-but-disagreeing
337
+ values, the manifest wins. The strict AGENT-row pick is the
338
+ authoritative source on a normal session; we only promote in the
339
+ narrow case where the manifest carries a known placeholder /
340
+ NOT_SET sentinel and the harvest layer has something better.
341
+ """
342
+ if kind == "version":
343
+ if manifest_value != "v0":
344
+ return manifest_value
345
+ if not isinstance(session_identity_value, str):
346
+ return manifest_value
347
+ if session_identity_value == "v0":
348
+ return manifest_value
349
+ if not _REAL_VERSION_RE.match(session_identity_value):
350
+ return manifest_value
351
+ return session_identity_value
352
+ # kind == "api_name"
353
+ if manifest_value not in (None, "", "NOT_SET", "NOT SET"):
354
+ return manifest_value
355
+ if isinstance(session_identity_value, str) and session_identity_value:
356
+ return session_identity_value
357
+ return manifest_value
358
+
359
+
360
+ def _reconcile_top_identity(
361
+ manifest: dict, session_identity: dict, org_id_15: Any,
362
+ ) -> dict:
363
+ """Build the top-level ``identity`` block, promoting placeholders.
364
+
365
+ Centralizes the policy in one place so the happy path
366
+ (`_assemble_session`) and the gateway-direct fallback don't drift
367
+ apart. Emits a stderr note when promotion fires so an investigator
368
+ sees the divergence at run time.
369
+ """
370
+ manifest_api = manifest.get("agent_api_name")
371
+ manifest_ver = manifest.get("agent_version")
372
+ session_api = session_identity.get("agent_api_name")
373
+ session_ver = session_identity.get("agent_version")
374
+
375
+ promoted_api = _promote_identity(manifest_api, session_api, kind="api_name")
376
+ promoted_ver = _promote_identity(manifest_ver, session_ver, kind="version")
377
+
378
+ if promoted_api != manifest_api:
379
+ print(
380
+ f"assemble_dc: identity promoted: agent_api_name "
381
+ f"{manifest_api!r} -> {promoted_api!r} (from session.identity)",
382
+ file=sys.stderr,
383
+ )
384
+ if promoted_ver != manifest_ver:
385
+ print(
386
+ f"assemble_dc: identity promoted: agent_version "
387
+ f"{manifest_ver} -> {promoted_ver} (from session.identity)",
388
+ file=sys.stderr,
389
+ )
390
+
391
+ return {
392
+ "org_id_15": org_id_15,
393
+ "agent_api_name": promoted_api,
394
+ "agent_version": promoted_ver,
395
+ }
396
+
397
+
398
+ def _resolve_end_type(session_row: dict, rows: dict) -> Optional[str]:
399
+ """Resolve the session's terminal outcome with a Session→Step fallback.
400
+
401
+ Session DMO's ``ssot__AiAgentSessionEndType__c`` is authoritative when
402
+ populated, but on Messaging-channel and short E&O sessions it stays
403
+ ``NOT_SET`` even after the SESSION_END interaction has materialized.
404
+ The runtime writes the actual outcome (``CLOSED_USER_REQUEST``,
405
+ ``USER_ENDED``, ``ESCALATED``, ``TRANSFERRED``, ``TIMEOUT``) onto the
406
+ SESSION_END step's ``ssot__Name__c`` instead. Fall through to that
407
+ step when Session.EndType is missing, so the rendered summary stops
408
+ saying "session end not yet materialized in STDM" for sessions that
409
+ actually completed cleanly.
410
+ """
411
+ primary = _clean(session_row.get("ssot__AiAgentSessionEndType__c"))
412
+ if primary:
413
+ return primary
414
+ for step in rows.get("steps", []) or ():
415
+ if step.get("ssot__AiAgentInteractionStepType__c") == "SESSION_END":
416
+ name = _clean(step.get("ssot__Name__c"))
417
+ if name:
418
+ return name
419
+ return None
420
+
421
+
422
+ def _ts(value: Any) -> Optional[datetime]:
423
+ """Parse an ISO-8601 timestamp. NOT_SET / non-string → None (unbounded)."""
424
+ if not isinstance(value, str) or value in _NOT_SET:
425
+ return None
426
+ try:
427
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
428
+ except ValueError:
429
+ return None
430
+
431
+
432
+ def _index_unique(rows: Iterable[dict], key: str,
433
+ collisions: List[dict], dmo_label: str) -> Dict[str, dict]:
434
+ """Build a {key_value: row} dict. On collision: first-write-wins + record."""
435
+ out: Dict[str, dict] = {}
436
+ for r in rows:
437
+ k = r.get(key)
438
+ if k in _NOT_SET:
439
+ continue
440
+ if k in out:
441
+ collisions.append({"dmo": dmo_label, "key": k})
442
+ else:
443
+ out[k] = r
444
+ return out
445
+
446
+
447
+ def _groupby(rows: Iterable[dict], key: str) -> Dict[str, List[dict]]:
448
+ out: Dict[str, List[dict]] = defaultdict(list)
449
+ for r in rows:
450
+ k = r.get(key)
451
+ if k not in _NOT_SET:
452
+ out[k].append(r)
453
+ return dict(out)
454
+
455
+
456
+ def _extract_trace_id(interaction: dict) -> Optional[str]:
457
+ """Prefer the primary column; fall back to AttributeText regex."""
458
+ tid = interaction.get("ssot__TelemetryTraceId__c")
459
+ if tid and tid not in _NOT_SET:
460
+ return tid
461
+ attr = interaction.get("ssot__AttributeText__c") or ""
462
+ if not attr:
463
+ return None
464
+ m = _INTERNAL_TRACE_RE.search(html.unescape(attr))
465
+ return m.group(1) if m else None
466
+
467
+
468
+ # ---- declared binding chain ------------------------------------------------
469
+
470
+ def _declared_gw_for_step(step: dict,
471
+ generations_by_id: Dict[str, dict],
472
+ gw_resp_by_resp_id: Dict[str, dict],
473
+ gw_req_by_id: Dict[str, dict]) -> Optional[dict]:
474
+ """Step → Generation → Response → Request. Returns the GatewayRequest row or None."""
475
+ gen_id = step.get("ssot__GenerationId__c")
476
+ if gen_id in _NOT_SET:
477
+ return None
478
+ gen = generations_by_id.get(gen_id)
479
+ if not gen:
480
+ return None
481
+ resp_id = gen.get("generationResponseId__c")
482
+ if resp_id in _NOT_SET:
483
+ return None
484
+ resp = gw_resp_by_resp_id.get(resp_id)
485
+ if not resp:
486
+ return None
487
+ req_id = resp.get("generationRequestId__c")
488
+ if req_id in _NOT_SET:
489
+ return None
490
+ return gw_req_by_id.get(req_id)
491
+
492
+
493
+ # ---- timestamp-window fallback --------------------------------------------
494
+
495
+ def _tier(step_type: str) -> int:
496
+ """Lower is better. 0 = ACTION, 1 = TOPIC, 2 = GUARDRAIL, 3 = any other."""
497
+ try:
498
+ return _TIER_ORDER.index(step_type)
499
+ except ValueError:
500
+ return len(_TIER_ORDER) # "any other"
501
+
502
+
503
+ def _window_contains(gw_ts: datetime, start: Optional[datetime],
504
+ end: Optional[datetime]) -> bool:
505
+ """Closed-closed containment. None end_ts → +∞. Missing start → no match."""
506
+ if start is None:
507
+ return False
508
+ if gw_ts < start:
509
+ return False
510
+ if end is None:
511
+ return True # open-ended upward
512
+ return gw_ts <= end
513
+
514
+
515
+ def _bind_ts_window(gw_req: dict,
516
+ steps_with_ts: List[Tuple[dict, datetime, Optional[datetime]]],
517
+ interaction_window: Tuple[Optional[datetime], Optional[datetime]],
518
+ reserved_step_ids: "frozenset[str]") -> Tuple[str, Optional[str]]:
519
+ """Return (placement, bound_step_id).
520
+
521
+ placement ∈ {"step", "interaction", "unbound"}.
522
+ bound_step_id is set only when placement == "step".
523
+ """
524
+ gw_ts_raw = gw_req.get("timestamp__c")
525
+ gw_ts = _ts(gw_ts_raw)
526
+ if gw_ts is None:
527
+ return ("unbound", None)
528
+
529
+ # Step candidates: contains gw_ts AND not already declared-bound.
530
+ candidates = [
531
+ (step, start, end)
532
+ for step, start, end in steps_with_ts
533
+ if step["ssot__Id__c"] not in reserved_step_ids
534
+ and _window_contains(gw_ts, start, end)
535
+ ]
536
+ if candidates:
537
+ # Best tier → innermost (shortest window) → latest start_ts.
538
+ def sort_key(c):
539
+ step, start, end = c
540
+ tier = _tier(step.get("ssot__AiAgentInteractionStepType__c", ""))
541
+ # Window size; treat None end_ts as "longest" (so nested closed wins).
542
+ if end is None:
543
+ width = float("inf")
544
+ else:
545
+ width = (end - start).total_seconds()
546
+ # Invert latest-start-wins by negating seconds since epoch.
547
+ return (tier, width, -start.timestamp())
548
+ candidates.sort(key=sort_key)
549
+ winner = candidates[0][0]
550
+ return ("step", winner["ssot__Id__c"])
551
+
552
+ # Interaction window.
553
+ i_start, i_end = interaction_window
554
+ if _window_contains(gw_ts, i_start, i_end):
555
+ return ("interaction", None)
556
+ return ("unbound", None)
557
+
558
+
559
+ # ---- gateway-request view builder -----------------------------------------
560
+
561
+ def _build_gw_view(gw_req: dict, binding_method: str, *,
562
+ idx: Indexes, dispatch: PolymorphicSplits,
563
+ bound_step_id: Optional[str] = None) -> dict:
564
+ """Build one GatewayRequest view row.
565
+
566
+ `idx` and `dispatch` are kw-only so `functools.partial(_build_gw_view,
567
+ idx=..., dispatch=...)` bindings don't collide with positional args at
568
+ the 3 call sites (declared / timestamp_window / unbound).
569
+ """
570
+ gw_id = gw_req["gatewayRequestId__c"]
571
+ responses = idx.gw_resp_by_req_id.get(gw_id, [])
572
+ view: Dict[str, Any] = {
573
+ "binding_method": binding_method,
574
+ "gateway_request_id": gw_id,
575
+ "feature": _clean(gw_req.get("feature__c")),
576
+ "model": _clean(gw_req.get("model__c")),
577
+ "provider": _clean(gw_req.get("provider__c")),
578
+ "prompt_template_dev_name": _clean(gw_req.get("promptTemplateDevName__c")),
579
+ "prompt_tokens": gw_req.get("promptTokens__c"),
580
+ "completion_tokens": gw_req.get("completionTokens__c"),
581
+ "total_tokens": gw_req.get("totalTokens__c"),
582
+ # Carry the raw input prompt through the hierarchical view so the
583
+ # renderer can surface it in the opt-in "Planner LLM calls" section.
584
+ # The 64 KB display cap lives in render_dc, not here — the tree
585
+ # stores the authoritative payload.
586
+ "prompt_text": gw_req.get("prompt__c"),
587
+ "response": responses[0] if responses else None,
588
+ "tags": idx.gw_tags_by_parent.get(gw_id, []),
589
+ "records": dispatch.gw_records_by_gw_req.get(gw_id, []),
590
+ "metadata": idx.gw_md_by_parent.get(gw_id, []),
591
+ "llm": idx.gw_llm_by_parent.get(gw_id, []),
592
+ }
593
+ if bound_step_id is not None:
594
+ view["bound_to_step_id"] = bound_step_id
595
+ return view
596
+
597
+
598
+ # ---- main assembly ---------------------------------------------------------
599
+
600
+ def _build_indexes(rows: Dict[str, List[dict]], collisions: List[dict]) -> Indexes:
601
+ """Build all primary-key dicts and groupby tables. Returns a frozen Indexes."""
602
+ return Indexes(
603
+ interactions_by_id=_index_unique(
604
+ rows.get("interactions", []), "ssot__Id__c", collisions, "interactions_by_id"),
605
+ participants_by_id=_index_unique(
606
+ rows.get("participants", []), "ssot__Id__c", collisions, "participants_by_id"),
607
+ generations_by_id=_index_unique(
608
+ rows.get("generations", []), "generationId__c", collisions, "generations_by_id"),
609
+ gw_req_by_id=_index_unique(
610
+ rows.get("gateway_requests", []), "gatewayRequestId__c", collisions, "gw_req_by_id"),
611
+ gw_resp_by_resp_id=_index_unique(
612
+ rows.get("gateway_responses", []), "generationResponseId__c",
613
+ collisions, "gw_resp_by_resp_id"),
614
+ feedback_by_id=_index_unique(
615
+ rows.get("feedback", []), "feedbackId__c", collisions, "feedback_by_id"),
616
+ gw_resp_by_req_id=_groupby(rows.get("gateway_responses", []), "generationRequestId__c"),
617
+ steps_by_interaction=_groupby(rows.get("steps", []), "ssot__AiAgentInteractionId__c"),
618
+ messages_by_interaction=_groupby(rows.get("messages", []), "ssot__AiAgentInteractionId__c"),
619
+ gw_tags_by_parent=_groupby(rows.get("gateway_request_tags", []), "parent__c"),
620
+ gw_md_by_parent=_groupby(rows.get("gateway_request_metadata", []), "parent__c"),
621
+ gw_llm_by_parent=_groupby(rows.get("gateway_request_llm", []), "parent__c"),
622
+ quality_by_parent=_groupby(rows.get("content_quality", []), "parent__c"),
623
+ quality_by_id={q["id__c"]: q for q in rows.get("content_quality", []) if q.get("id__c")},
624
+ feedback_by_gen=_groupby(rows.get("feedback", []), "generationId__c"),
625
+ feedback_details_by_parent=_groupby(rows.get("feedback_details", []), "parent__c"),
626
+ participant_role_by_id={
627
+ p["ssot__Id__c"]: p.get("ssot__AiAgentSessionParticipantRole__c")
628
+ for p in rows.get("participants", []) if p.get("ssot__Id__c")
629
+ },
630
+ )
631
+
632
+
633
+ def _dispatch_polymorphic(rows: Dict[str, List[dict]], idx: Indexes) -> PolymorphicSplits:
634
+ """Split ContentCategory, GtwyObjRecord, and TagAssociation by polymorphic parent.
635
+
636
+ Producers accumulate into defaultdicts for ergonomics, but the returned
637
+ dataclass stores plain dicts — frozen `Dict[...]` typing can't promise
638
+ auto-creation, so don't let it leak.
639
+ """
640
+ # ContentCategory: parent is either a generation or a quality row.
641
+ cat_by_gen: Dict[str, List[dict]] = defaultdict(list)
642
+ cat_by_qual: Dict[str, List[dict]] = defaultdict(list)
643
+ for cat in rows.get("content_category", []):
644
+ parent = cat.get("parent__c")
645
+ if parent in _NOT_SET:
646
+ continue
647
+ if parent in idx.generations_by_id:
648
+ cat_by_gen[parent].append(cat)
649
+ elif parent in idx.quality_by_id:
650
+ cat_by_qual[parent].append(cat)
651
+
652
+ # GtwyObjRecord: parent is either a gateway_request or a feedback row.
653
+ rec_by_gw: Dict[str, List[dict]] = defaultdict(list)
654
+ rec_by_fb: Dict[str, List[dict]] = defaultdict(list)
655
+ for rec in rows.get("gateway_records", []):
656
+ parent = rec.get("parent__c")
657
+ if parent in _NOT_SET:
658
+ continue
659
+ if parent in idx.gw_req_by_id:
660
+ rec_by_gw[parent].append(rec)
661
+ elif parent in idx.feedback_by_id:
662
+ rec_by_fb[parent].append(rec)
663
+
664
+ # TagAssociation: exactly one of session/interaction/moment FK is populated.
665
+ ta_session: List[dict] = []
666
+ ta_by_int: Dict[str, List[dict]] = defaultdict(list)
667
+ ta_by_mom: Dict[str, List[dict]] = defaultdict(list)
668
+ for ta in rows.get("tag_associations", []):
669
+ if ta.get("ssot__AiAgentSessionId__c") not in _NOT_SET:
670
+ ta_session.append(ta)
671
+ elif ta.get("ssot__AiAgentInteractionId__c") not in _NOT_SET:
672
+ ta_by_int[ta["ssot__AiAgentInteractionId__c"]].append(ta)
673
+ elif ta.get("ssot__AiAgentMomentId__c") not in _NOT_SET:
674
+ ta_by_mom[ta["ssot__AiAgentMomentId__c"]].append(ta)
675
+
676
+ return PolymorphicSplits(
677
+ categories_by_generation=dict(cat_by_gen),
678
+ categories_by_quality=dict(cat_by_qual),
679
+ gw_records_by_gw_req=dict(rec_by_gw),
680
+ gw_records_by_feedback=dict(rec_by_fb),
681
+ tag_assoc_session=ta_session,
682
+ tag_assoc_by_interaction=dict(ta_by_int),
683
+ tag_assoc_by_moment=dict(ta_by_mom),
684
+ )
685
+
686
+
687
+ def _filter_catalog(rows: Dict[str, List[dict]]) -> Catalog:
688
+ """Filter org-wide tag vocabulary to only what's reachable from session agents."""
689
+ agents = {
690
+ p.get("ssot__AiAgentApiName__c") for p in rows.get("participants", [])
691
+ if p.get("ssot__AiAgentSessionParticipantRole__c") == "AGENT"
692
+ and p.get("ssot__AiAgentApiName__c") not in _NOT_SET
693
+ } | {
694
+ m.get("ssot__AiAgentApiName__c") for m in rows.get("moments", [])
695
+ if m.get("ssot__AiAgentApiName__c") not in _NOT_SET
696
+ }
697
+ # Mirror fetch_dc._resolve_identity's USER-row fallback. On agent shapes
698
+ # like MyAgent, AGENT-role rows leave api_name=NOT_SET while USER
699
+ # rows correctly carry the agent's api_name. Without this fallback, the
700
+ # session lands in a `<api_name>__v0/` directory but `agents_observed`
701
+ # is empty — directory and rendered catalog disagree. Only fires when the
702
+ # primary (AGENT + moments) sources turned up nothing usable; in normal
703
+ # sessions the USER and AGENT rows agree and the union is idempotent.
704
+ if not agents:
705
+ agents = {
706
+ p.get("ssot__AiAgentApiName__c") for p in rows.get("participants", [])
707
+ if p.get("ssot__AiAgentApiName__c") not in _NOT_SET
708
+ }
709
+ agents_observed = sorted(a for a in agents if a)
710
+ relevant_assocs = [
711
+ a for a in rows.get("tag_definition_associations", [])
712
+ if a.get("ssot__AiAgentApiName__c") in agents_observed
713
+ ]
714
+ relevant_def_ids = {
715
+ a["ssot__AiAgentTagDefinitionId__c"] for a in relevant_assocs
716
+ if a.get("ssot__AiAgentTagDefinitionId__c") not in _NOT_SET
717
+ }
718
+ return Catalog(
719
+ agents_observed=agents_observed,
720
+ tag_definitions=[d for d in rows.get("tag_definitions", [])
721
+ if d.get("ssot__Id__c") in relevant_def_ids],
722
+ tag_definition_associations=relevant_assocs,
723
+ tags=[t for t in rows.get("tags", [])
724
+ if t.get("ssot__AiAgentTagDefinitionId__c") in relevant_def_ids],
725
+ )
726
+
727
+
728
+ def _build_session_identity(rows: Dict[str, List[dict]], manifest: dict) -> dict:
729
+ """Harvest 18 identity fields from 4 DMOs for the `session.identity` block.
730
+
731
+ All row iteration is preceded by a deterministic sort so repeated runs
732
+ produce byte-identical output regardless of fetch order. `_harvest_str()` is
733
+ the shared normalizer — html.unescape + quote-strip + NOT_SET /
734
+ UNSET_VALUE / empty coercion. See references/dc_pipeline_contract.md
735
+ §2.9a for the field-to-column mapping.
736
+ """
737
+ # --- gateway_requests: sort by (timestamp__c, gatewayRequestId__c) ---
738
+ gwr_sorted = sorted(
739
+ rows.get("gateway_requests", []),
740
+ key=lambda r: (r.get("timestamp__c") or "",
741
+ r.get("gatewayRequestId__c") or ""),
742
+ )
743
+
744
+ def _first_gwr(key: str) -> Optional[str]:
745
+ for r in gwr_sorted:
746
+ v = _harvest_str(r.get(key))
747
+ if v is not None:
748
+ return v
749
+ return None
750
+
751
+ org_id = _first_gwr("orgId__c")
752
+ platform_user_id = _first_gwr("userId__c")
753
+ planner_id = _first_gwr("plannerId__c")
754
+ bot_version_id = _first_gwr("botVersionId__c")
755
+ app_type = _first_gwr("appType__c")
756
+
757
+ # --- gateway_request_tags: sort by (parent__c, tag__c, tagValue__c) ---
758
+ tags_sorted = sorted(
759
+ rows.get("gateway_request_tags", []),
760
+ key=lambda r: (r.get("parent__c") or "",
761
+ r.get("tag__c") or "",
762
+ r.get("tagValue__c") or ""),
763
+ )
764
+ tag_first: Dict[str, Optional[str]] = {}
765
+ for t in tags_sorted:
766
+ k = t.get("tag__c")
767
+ if not k or k in tag_first:
768
+ continue
769
+ tag_first[k] = _harvest_str(t.get("tagValue__c"))
770
+
771
+ # --- sessions[0] — always exactly one row per session on this path ---
772
+ sessions = rows.get("sessions", [])
773
+ session_row = sessions[0] if sessions else {}
774
+
775
+ # --- participants: first USER-role row by ssot__Id__c ---
776
+ participants_sorted = sorted(
777
+ rows.get("participants", []),
778
+ key=lambda r: r.get("ssot__Id__c") or "",
779
+ )
780
+ messaging_end_user_id = None
781
+ for p in participants_sorted:
782
+ if p.get("ssot__AiAgentSessionParticipantRole__c") == "USER":
783
+ v = _harvest_str(p.get("ssot__ParticipantId__c"))
784
+ if v is not None:
785
+ messaging_end_user_id = v
786
+ break
787
+
788
+ def _aliased(identity_key: str) -> Optional[str]:
789
+ """Resolve an identity field via its tag-name fallback chain.
790
+
791
+ Parameter name intentionally avoids shadowing `dataclasses.field`.
792
+ """
793
+ for tag_key in _TAG_KEY_ALIASES[identity_key]:
794
+ v = tag_first.get(tag_key)
795
+ if v is not None:
796
+ return v
797
+ return None
798
+
799
+ # Expose VariableText__c bootstrap variables. Production messaging
800
+ # sessions leave this NOT_SET; Builder Previewer populates it with
801
+ # test-harness keys (__resolved_locale__, __supports_result_display__,
802
+ # etc.). Surfacing this is what makes channel-mode visible in the
803
+ # renderer.
804
+ messaging_session_id = _harvest_str(session_row.get("ssot__RelatedMessagingSessionId__c"))
805
+ voice_call_id = _harvest_str(session_row.get("ssot__RelatedVoiceCallId__c"))
806
+ bootstrap_variables = _parse_bootstrap_variables(
807
+ session_row.get("ssot__VariableText__c")
808
+ )
809
+
810
+ # Derive a `mode` field. ssot__AiAgentChannelType__c is identical for
811
+ # MIAW production and Builder Previewer (`SCRT2 - EmbeddedMessaging`);
812
+ # we have to look at related-id population and bootstrap_variables to
813
+ # tell them apart.
814
+ mode = _derive_mode(messaging_session_id, voice_call_id, bootstrap_variables)
815
+
816
+ # `voice_call_id` + `individual_id` are null on EmbeddedMessaging sessions.
817
+ # They populate on authenticated channels (voice, Experience Cloud with
818
+ # linked Individual). Kept for schema parallelism with messaging_session_id.
819
+ return {
820
+ "org_id": org_id,
821
+ "platform_user_id": platform_user_id,
822
+ "planner_id": planner_id,
823
+ "bot_version_id": bot_version_id,
824
+ "app_type": app_type,
825
+ "bot_id": tag_first.get("bot_id"),
826
+ "bot_name": tag_first.get("bot_name"),
827
+ "agent_api_name": _aliased("agent_api_name"),
828
+ "agent_label": tag_first.get("agent_label"),
829
+ "agent_version": _aliased("agent_version"),
830
+ "agent_type": tag_first.get("agent_type"),
831
+ "planner_name": tag_first.get("planner_name"),
832
+ "planner_type": tag_first.get("planner_type"),
833
+ "configured_model": tag_first.get("configured_model_name"),
834
+ "messaging_session_id": messaging_session_id,
835
+ "messaging_end_user_id": messaging_end_user_id,
836
+ "voice_call_id": voice_call_id,
837
+ "individual_id": _harvest_str(session_row.get("ssot__IndividualId__c")),
838
+ "bootstrap_variables": bootstrap_variables,
839
+ "mode": mode,
840
+ }
841
+
842
+
843
+ # Test-harness bootstrap keys that are observed in Builder Previewer sessions
844
+ # but NOT in MIAW production. The presence of any of these in
845
+ # `ssot__VariableText__c` is the strongest at-rest signal that a session was
846
+ # run through the Previewer rather than against a real customer messaging
847
+ # session. Listed here as a frozenset so it's read-only at module level.
848
+ # Builder Previewer adds these keys to ssot__VariableText__c at session
849
+ # bootstrap; MIAW production sessions don't seed them. Used by _derive_mode
850
+ # to distinguish previewer runs from real customer messaging sessions.
851
+ _BUILDER_PREVIEWER_INDICATOR_KEYS: frozenset[str] = frozenset({
852
+ "__resolved_locale__",
853
+ "__locale_instruction__",
854
+ "__supports_result_display__",
855
+ "__show_tool_results_invoked__",
856
+ })
857
+
858
+
859
+ def _parse_bootstrap_variables(raw: Any) -> Optional[dict]:
860
+ """Parse `ssot__VariableText__c` defensively.
861
+
862
+ On real sessions this field can be:
863
+ - missing / None / NOT_SET / UNSET_VALUE → returns None
864
+ - well-formed JSON (Builder Previewer) → returns the dict
865
+ - HTML-entity-encoded JSON (some surfaces emit
866
+ the `&quot;`-escaped form) → unescaped, returns the dict
867
+ - truncated or malformed JSON → returns
868
+ `{"_parse_error": True, "_raw": <first 200 chars>}` so the renderer
869
+ can still flag that a bootstrap exists, just not parseable.
870
+
871
+ Returns None for the empty cases so the caller can treat None as
872
+ "no bootstrap" without distinguishing missing from sentinel.
873
+ """
874
+ if raw is None:
875
+ return None
876
+ s = html.unescape(str(raw)).strip()
877
+ if not s or s in _NOT_SET or s in ("NOT_SET", "UNSET_VALUE"):
878
+ return None
879
+ try:
880
+ parsed = json.loads(s)
881
+ except (json.JSONDecodeError, ValueError):
882
+ return {"_parse_error": True, "_raw": s[:200]}
883
+ # Defensive: VariableText__c is documented as a JSON object; if a future
884
+ # version emits a JSON array or scalar, surface it under `_raw` rather
885
+ # than letting downstream code crash on `.get()`.
886
+ if not isinstance(parsed, dict):
887
+ return {"_parse_error": True, "_raw": s[:200]}
888
+ return parsed
889
+
890
+
891
+ def _derive_mode(
892
+ messaging_session_id: Optional[str],
893
+ voice_call_id: Optional[str],
894
+ bootstrap_variables: Optional[dict],
895
+ ) -> str:
896
+ """Distinguish MIAW production from Builder Previewer from voice.
897
+
898
+ `ssot__AiAgentChannelType__c` is identical (`SCRT2 - EmbeddedMessaging`)
899
+ for MIAW and Builder Previewer — useless for distinguishing them. The
900
+ real signals, in priority order:
901
+
902
+ 1. `ssot__RelatedVoiceCallId__c` set ↔ voice channel.
903
+ 2. `ssot__RelatedMessagingSessionId__c` set ↔ MIAW production
904
+ (a real MessagingSession record exists).
905
+ 3. `RelatedMessagingSessionId__c` NOT_SET AND bootstrap_variables
906
+ contains test-harness keys ↔ Builder Previewer.
907
+ 4. None of the above ↔ unknown (e.g. headless API runs, agent script
908
+ previewer cases that don't seed VariableText__c).
909
+
910
+ Return a stable enum string the renderer can match on.
911
+ """
912
+ if voice_call_id:
913
+ return "voice"
914
+ if messaging_session_id:
915
+ return "production_messaging"
916
+ if isinstance(bootstrap_variables, dict):
917
+ if set(bootstrap_variables) & _BUILDER_PREVIEWER_INDICATOR_KEYS:
918
+ return "builder_previewer"
919
+ return "unknown"
920
+
921
+
922
+ def _declared_binding_pass(rows: Dict[str, List[dict]], idx: Indexes) -> BindingResults:
923
+ """Walk every step; claim GWs reachable via the declared chain.
924
+
925
+ Returns BindingResults(declared_gw_ids, declared_steps_with_gw,
926
+ step_id_to_gw_id, declared_collisions). `declared_steps_with_gw` is a
927
+ frozenset so downstream consumers can't accidentally mutate it.
928
+ `step_id_to_gw_id[step_id]` is the GW id when declared, or None when this
929
+ step's declared GW was already claimed by an earlier step (collision sentinel).
930
+ `declared_collisions` is the aggregate count of collision-sentinel entries.
931
+ """
932
+ declared_gw_ids: set = set()
933
+ declared_steps_with_gw: set = set()
934
+ step_id_to_gw_id: Dict[str, Optional[str]] = {}
935
+ for step in rows.get("steps", []):
936
+ gw_req = _declared_gw_for_step(
937
+ step, idx.generations_by_id, idx.gw_resp_by_resp_id, idx.gw_req_by_id)
938
+ if gw_req is None:
939
+ continue
940
+ gw_id = gw_req["gatewayRequestId__c"]
941
+ if gw_id in declared_gw_ids:
942
+ # Collision: second+ step reaches a GW already claimed.
943
+ step_id_to_gw_id[step["ssot__Id__c"]] = None
944
+ continue
945
+ declared_gw_ids.add(gw_id)
946
+ declared_steps_with_gw.add(step["ssot__Id__c"])
947
+ step_id_to_gw_id[step["ssot__Id__c"]] = gw_id
948
+ return BindingResults(
949
+ declared_gw_ids=declared_gw_ids,
950
+ declared_steps_with_gw=frozenset(declared_steps_with_gw),
951
+ step_id_to_gw_id=step_id_to_gw_id,
952
+ declared_collisions=sum(1 for v in step_id_to_gw_id.values() if v is None),
953
+ )
954
+
955
+
956
+ def _build_step_view(step: dict, idx: Indexes, dispatch: PolymorphicSplits,
957
+ step_id_to_gw_id: Dict[str, Optional[str]],
958
+ build_gw) -> dict:
959
+ """Emit one Step view, including its Generation and (if declared) GatewayRequest.
960
+
961
+ `build_gw` is a `functools.partial` pre-bound with `idx`/`dispatch`
962
+ (see `assemble()`); call sites only supply `gw_req`, `binding_method`,
963
+ and optionally `bound_step_id`.
964
+ """
965
+ sid_step = step["ssot__Id__c"]
966
+ gen_id = step.get("ssot__GenerationId__c")
967
+ gen = idx.generations_by_id.get(gen_id) if gen_id not in _NOT_SET else None
968
+ generation_view = _build_generation_view(
969
+ gen, idx.quality_by_parent, dispatch.categories_by_generation,
970
+ dispatch.categories_by_quality, idx.feedback_by_gen,
971
+ idx.feedback_details_by_parent, dispatch.gw_records_by_feedback,
972
+ ) if gen is not None else None
973
+
974
+ gw_id = step_id_to_gw_id.get(sid_step)
975
+ gw_view = None
976
+ collision_flag = gw_id is None and sid_step in step_id_to_gw_id
977
+ if gw_id is not None:
978
+ gw_view = build_gw(idx.gw_req_by_id[gw_id], "declared")
979
+
980
+ # Mirror the bound gateway_request's model identifier onto the step
981
+ # itself so renderers can show "LLM_STEP <name> · <model>" without
982
+ # dereferencing the nested gateway view. The mirror is None when no
983
+ # gateway_request is bound (declared chain didn't reach, or the STDM
984
+ # exporter dropped writes — see the `gateway_requests_dropped_by_stdm`
985
+ # session_shape).
986
+ step_model_name = gw_view.get("model") if gw_view else None
987
+
988
+ step_view: Dict[str, Any] = {
989
+ "id": sid_step,
990
+ "type": step.get("ssot__AiAgentInteractionStepType__c"),
991
+ "name": step.get("ssot__Name__c"),
992
+ "start_ts": step.get("ssot__StartTimestamp__c"),
993
+ "end_ts": step.get("ssot__EndTimestamp__c"),
994
+ "error_text": _clean(step.get("ssot__ErrorMessageText__c")),
995
+ "model_name": step_model_name,
996
+ "generation": generation_view,
997
+ "gateway_request": gw_view,
998
+ }
999
+ if collision_flag:
1000
+ step_view["gateway_request_collision"] = True
1001
+ return step_view
1002
+
1003
+
1004
+ def _build_message_view(m: dict, participant_role_by_id: Dict[str, str]) -> dict:
1005
+ mtype = m.get("ssot__AiAgentInteractionMessageType__c")
1006
+ pid = m.get("ssot__AiAgentSessionParticipantId__c")
1007
+ role = participant_role_by_id.get(pid)
1008
+ if role is None:
1009
+ role = "USER" if mtype == "Input" else "AGENT" if mtype == "Output" else None
1010
+ return {
1011
+ "message_id": m.get("ssot__Id__c"),
1012
+ "type": mtype,
1013
+ "role": role,
1014
+ "participant_id": pid,
1015
+ "text": m.get("ssot__ContentText__c"),
1016
+ "content_type": m.get("ssot__AiAgentInteractionMsgContentType__c"),
1017
+ "modality": m.get("Modality__c"),
1018
+ "ts": m.get("ssot__MessageSentTimestamp__c"),
1019
+ }
1020
+
1021
+
1022
+ def _build_interaction_view(interaction: dict, rows: Dict[str, List[dict]],
1023
+ idx: Indexes, dispatch: PolymorphicSplits,
1024
+ binding: BindingResults,
1025
+ build_gw) -> Tuple[dict, BinderCtx]:
1026
+ """Emit one Interaction view plus its BinderCtx.
1027
+
1028
+ Returns (view, binder_ctx). Binder scratch state lives in the `BinderCtx`
1029
+ and is keyed externally by `iid`; it never touches the emitted view, so
1030
+ it can't leak into `dc._session_tree.json`. `build_gw` is the
1031
+ `functools.partial` from `assemble()`.
1032
+ """
1033
+ iid = interaction["ssot__Id__c"]
1034
+ trace_id = _extract_trace_id(interaction)
1035
+
1036
+ steps_sorted = sorted(
1037
+ idx.steps_by_interaction.get(iid, []),
1038
+ key=lambda s: (s.get("ssot__StartTimestamp__c") or "", s.get("ssot__Id__c") or ""))
1039
+ step_views = [_build_step_view(s, idx, dispatch, binding.step_id_to_gw_id, build_gw)
1040
+ for s in steps_sorted]
1041
+
1042
+ # Step windows consumed only by _ts_window_pass via the parallel BinderCtx.
1043
+ steps_with_ts: List[Tuple[dict, Optional[datetime], Optional[datetime]]] = [
1044
+ (s, _ts(s.get("ssot__StartTimestamp__c")), _ts(s.get("ssot__EndTimestamp__c")))
1045
+ for s in steps_sorted if _ts(s.get("ssot__StartTimestamp__c")) is not None
1046
+ ]
1047
+
1048
+ messages_sorted = sorted(
1049
+ idx.messages_by_interaction.get(iid, []),
1050
+ key=lambda r: (r.get("ssot__MessageSentTimestamp__c") or "",
1051
+ r.get("ssot__Id__c") or ""))
1052
+ msg_views = [_build_message_view(m, idx.participant_role_by_id)
1053
+ for m in messages_sorted]
1054
+
1055
+ view = {
1056
+ "id": iid,
1057
+ "type": interaction.get("ssot__AiAgentInteractionType__c"),
1058
+ "topic": _clean(interaction.get("ssot__TopicApiName__c")),
1059
+ "trace_id": trace_id,
1060
+ "start_ts": interaction.get("ssot__StartTimestamp__c"),
1061
+ "end_ts": interaction.get("ssot__EndTimestamp__c"),
1062
+ "messages": msg_views,
1063
+ "telemetry_spans": [s for s in rows.get("telemetry_spans", [])
1064
+ if s.get("ssot__TelemetryTrace__c") == trace_id],
1065
+ "steps": step_views,
1066
+ "timestamp_bound_gateway_calls": [], # appended by _ts_window_pass
1067
+ "tag_associations": dispatch.tag_assoc_by_interaction.get(iid, []),
1068
+ }
1069
+ binder_ctx = BinderCtx(
1070
+ start_ts=_ts(interaction.get("ssot__StartTimestamp__c")),
1071
+ end_ts=_ts(interaction.get("ssot__EndTimestamp__c")),
1072
+ steps_with_ts=steps_with_ts,
1073
+ reserved_step_ids=binding.declared_steps_with_gw,
1074
+ )
1075
+ return view, binder_ctx
1076
+
1077
+
1078
+ def _ts_window_pass(interactions_view: List[dict],
1079
+ binders: Dict[str, BinderCtx],
1080
+ idx: Indexes,
1081
+ binding: BindingResults,
1082
+ build_gw) -> Tuple[List[dict], dict]:
1083
+ """Place every chain-orphan GW via timestamp-window, or into unbound[].
1084
+
1085
+ Reads per-interaction binder state from the parallel `binders` dict keyed
1086
+ by `iv["id"]` — never from the view itself. Mutates `interactions_view`
1087
+ in place only to append to `timestamp_bound_gateway_calls[]` (an emission
1088
+ field, pre-initialized to `[]` in `_build_interaction_view`).
1089
+
1090
+ Returns (unbound_gw_calls, gw_binding_counts).
1091
+ """
1092
+ unbound: List[dict] = []
1093
+ counts = {
1094
+ "declared": len(binding.declared_gw_ids),
1095
+ "timestamp_window": 0,
1096
+ "unbound": 0,
1097
+ "declared_collisions": binding.declared_collisions,
1098
+ }
1099
+ for gw_id, gw_req in idx.gw_req_by_id.items():
1100
+ if gw_id in binding.declared_gw_ids:
1101
+ continue
1102
+ placed = False
1103
+ for iv in interactions_view:
1104
+ bctx = binders[iv["id"]]
1105
+ placement, bound_step_id = _bind_ts_window(
1106
+ gw_req,
1107
+ bctx.steps_with_ts,
1108
+ (bctx.start_ts, bctx.end_ts),
1109
+ bctx.reserved_step_ids,
1110
+ )
1111
+ if placement in ("step", "interaction"):
1112
+ iv["timestamp_bound_gateway_calls"].append(
1113
+ build_gw(gw_req, "timestamp_window", bound_step_id=bound_step_id))
1114
+ counts["timestamp_window"] += 1
1115
+ placed = True
1116
+ break
1117
+ if not placed:
1118
+ unbound.append(build_gw(gw_req, "unbound"))
1119
+ counts["unbound"] += 1
1120
+
1121
+ # Defense in depth: if anyone reintroduces the binder-cache-on-view
1122
+ # pattern in a future edit, this catches it before the tree ever writes.
1123
+ assert not any(k.startswith("_") for iv in interactions_view for k in iv), \
1124
+ "binder scratch state leaked into interaction view — do not stash on the view dict"
1125
+
1126
+ return unbound, counts
1127
+
1128
+
1129
+ def _build_moments_view(rows: Dict[str, List[dict]], dispatch: PolymorphicSplits) -> List[dict]:
1130
+ """session.moments[] with interaction_ids[] back-refs derived from MomentInteraction."""
1131
+ by_moment: Dict[str, List[str]] = defaultdict(list)
1132
+ for mi in rows.get("moment_interactions", []):
1133
+ mid = mi.get("ssot__AiAgentMomentId__c")
1134
+ iid = mi.get("ssot__AiAgentInteractionId__c")
1135
+ if mid not in _NOT_SET and iid not in _NOT_SET:
1136
+ by_moment[mid].append(iid)
1137
+
1138
+ moments_sorted = sorted(
1139
+ rows.get("moments", []),
1140
+ key=lambda r: (r.get("ssot__StartTimestamp__c") or "", r.get("ssot__Id__c") or ""))
1141
+ return [
1142
+ {
1143
+ "moment_id": m.get("ssot__Id__c"),
1144
+ "agent_api_name": _clean(m.get("ssot__AiAgentApiName__c")),
1145
+ "agent_version": _clean(m.get("ssot__AiAgentVersionApiName__c")),
1146
+ "request_summary_text": _clean(m.get("ssot__RequestSummaryText__c")),
1147
+ "response_summary_text": _clean(m.get("ssot__ResponseSummaryText__c")),
1148
+ "interaction_ids": sorted(by_moment.get(m.get("ssot__Id__c"), [])),
1149
+ "start_ts": m.get("ssot__StartTimestamp__c"),
1150
+ "end_ts": m.get("ssot__EndTimestamp__c"),
1151
+ "tag_associations": dispatch.tag_assoc_by_moment.get(m.get("ssot__Id__c"), []),
1152
+ }
1153
+ for m in moments_sorted
1154
+ ]
1155
+
1156
+
1157
+ def _build_participants_view(rows: Dict[str, List[dict]]) -> List[dict]:
1158
+ return [
1159
+ {
1160
+ "participant_id": p.get("ssot__Id__c"),
1161
+ "role": p.get("ssot__AiAgentSessionParticipantRole__c"),
1162
+ "agent_api_name": _clean(p.get("ssot__AiAgentApiName__c")),
1163
+ "agent_version": _clean(p.get("ssot__AiAgentVersionApiName__c")),
1164
+ "agent_type": _clean(p.get("ssot__AiAgentType__c")),
1165
+ }
1166
+ for p in sorted(
1167
+ rows.get("participants", []),
1168
+ key=lambda r: (r.get("ssot__StartTimestamp__c") or "", r.get("ssot__Id__c") or ""))
1169
+ ]
1170
+
1171
+
1172
+ def _build_counts(rows: Dict[str, List[dict]], dispatch: PolymorphicSplits,
1173
+ binding_counts: dict,
1174
+ manifest: dict, collisions: List[dict],
1175
+ parse_warnings: List[str]) -> dict:
1176
+ int_by_type: Dict[str, int] = defaultdict(int)
1177
+ step_by_type: Dict[str, int] = defaultdict(int)
1178
+ for i in rows.get("interactions", []):
1179
+ int_by_type[i.get("ssot__AiAgentInteractionType__c")] += 1
1180
+ for s in rows.get("steps", []):
1181
+ step_by_type[s.get("ssot__AiAgentInteractionStepType__c")] += 1
1182
+
1183
+ return {
1184
+ "interactions_total": len(rows.get("interactions", [])),
1185
+ "interactions_turn": int_by_type.get("TURN", 0),
1186
+ "interactions_session_end": int_by_type.get("SESSION_END", 0),
1187
+ "steps_total": len(rows.get("steps", [])),
1188
+ "steps_by_type": {
1189
+ k: step_by_type.get(k, 0) for k in
1190
+ ("LLM_STEP", "ACTION_STEP", "TOPIC_STEP", "TRUST_GUARDRAILS_STEP", "SESSION_END")
1191
+ },
1192
+ "generations": len(rows.get("generations", [])),
1193
+ "gateway_requests": len(rows.get("gateway_requests", [])),
1194
+ "gateway_responses": len(rows.get("gateway_responses", [])),
1195
+ "gateway_metadata": len(rows.get("gateway_request_metadata", [])),
1196
+ "gateway_llm": len(rows.get("gateway_request_llm", [])),
1197
+ "gateway_records_grounded": sum(len(v) for v in dispatch.gw_records_by_gw_req.values()),
1198
+ "gateway_records_feedback": sum(len(v) for v in dispatch.gw_records_by_feedback.values()),
1199
+ "feedback": len(rows.get("feedback", [])),
1200
+ "audit_chain_1to1_ok": (
1201
+ len(rows.get("gateway_requests", [])) == len(rows.get("gateway_responses", []))
1202
+ ),
1203
+ "gw_binding": binding_counts,
1204
+ "session_shape": manifest.get("session_shape", "unknown"),
1205
+ "pk_collisions": collisions,
1206
+ "parse_warnings": parse_warnings,
1207
+ }
1208
+
1209
+
1210
+ def assemble(sid: str) -> Tuple[dict, Path]:
1211
+ """Orchestrate: load → index → dispatch → bind → build views → counts → tree.
1212
+
1213
+ Returns (tree, session_dir). The session_dir is resolved by
1214
+ ``_find_session_dir`` (via breadcrumb or glob) and passed back so
1215
+ the caller can write ``dc._session_tree.json`` next to the inputs
1216
+ without re-scanning the disk.
1217
+ """
1218
+ manifest, rows, parse_warnings, session_dir = _load_all(sid)
1219
+
1220
+ # Short-circuit: session row not found.
1221
+ sessions = rows.get("sessions", [])
1222
+ if not sessions:
1223
+ return _minimal_tree_session_not_found(sid, manifest, parse_warnings), session_dir
1224
+
1225
+ # Short-circuit: STDM Interaction/Step/Message DMOs haven't materialized yet
1226
+ # (gateway_requests present, interactions/steps empty). Render the gateway
1227
+ # chain directly instead of silently emitting an empty tree.
1228
+ if manifest.get("session_shape") == "interactions_not_materialized_yet":
1229
+ return _assemble_gateway_direct(sid, rows, manifest, parse_warnings), session_dir
1230
+
1231
+ collisions: List[dict] = []
1232
+ # Phase 1: independent bags.
1233
+ idx = _build_indexes(rows, collisions)
1234
+ dispatch = _dispatch_polymorphic(rows, idx)
1235
+ catalog = _filter_catalog(rows)
1236
+ # Phase 2: derived bag that depends on idx.
1237
+ binding = _declared_binding_pass(rows, idx)
1238
+
1239
+ # Bind the invariant gw-view args once; call sites supply only the varying ones.
1240
+ build_gw = functools.partial(_build_gw_view, idx=idx, dispatch=dispatch)
1241
+
1242
+ # Build per-Interaction views + parallel binder state (sorted by start_ts).
1243
+ interactions_view: List[dict] = []
1244
+ binders: Dict[str, BinderCtx] = {}
1245
+ for interaction in sorted(
1246
+ rows.get("interactions", []),
1247
+ key=lambda r: (r.get("ssot__StartTimestamp__c") or "",
1248
+ r.get("ssot__Id__c") or "")):
1249
+ view, bctx = _build_interaction_view(interaction, rows, idx, dispatch, binding, build_gw)
1250
+ interactions_view.append(view)
1251
+ binders[view["id"]] = bctx
1252
+
1253
+ # Timestamp-window fallback; mutates interactions_view only via
1254
+ # timestamp_bound_gateway_calls[].append.
1255
+ unbound_gw_calls, gw_binding_counts = _ts_window_pass(
1256
+ interactions_view, binders, idx, binding, build_gw)
1257
+
1258
+ session_row = sessions[0]
1259
+ session_identity = _build_session_identity(rows, manifest)
1260
+ # `org_id_15` is the canonical 15-char slice used by path helpers.
1261
+ # Prefer the manifest-stamped value (resolved in wave 1a of fetch_dc
1262
+ # from sessions[0].ssot__InternalOrganizationId__c); fall back to
1263
+ # slicing session_identity.org_id (the 18-char form from
1264
+ # gateway_requests) when the manifest is missing the field (older
1265
+ # artifacts predate the manifest stamp).
1266
+ org_id_15 = manifest.get("org_id_15")
1267
+ if not org_id_15 and session_identity.get("org_id"):
1268
+ org_id_15 = session_identity["org_id"][:15]
1269
+ session_identity["org_id_15"] = org_id_15
1270
+
1271
+ # Top-level identity block — canonical location for the 3 segments
1272
+ # needed to name the session dir. Richer identity fields live under
1273
+ # `session.identity` as before.
1274
+ # Promote richer values from `session_identity` when the manifest carries
1275
+ # placeholders (notably ``agent_version="v0"`` from the MyAgent
1276
+ # fallback in fetch_dc.py:570-597) — without this, the top-level block
1277
+ # diverges from `session.identity` in the same JSON file.
1278
+ top_identity = _reconcile_top_identity(manifest, session_identity, org_id_15)
1279
+
1280
+ return {
1281
+ "identity": top_identity,
1282
+ "session": {
1283
+ "id": sid,
1284
+ "_schema_version": 1,
1285
+ "org": {
1286
+ "alias": manifest.get("org_alias"),
1287
+ "instance_url": manifest.get("instance_url"),
1288
+ },
1289
+ "identity": session_identity,
1290
+ "start_ts": session_row.get("ssot__StartTimestamp__c"),
1291
+ "end_ts": session_row.get("ssot__EndTimestamp__c"),
1292
+ "end_type": _resolve_end_type(session_row, rows),
1293
+ "channel": _harvest_str(session_row.get("ssot__AiAgentChannelType__c")),
1294
+ "participants": _build_participants_view(rows),
1295
+ "moments": _build_moments_view(rows, dispatch),
1296
+ "interactions": interactions_view,
1297
+ "session_tag_associations": dispatch.tag_assoc_session,
1298
+ "unbound_gateway_calls": unbound_gw_calls,
1299
+ "counts": _build_counts(rows, dispatch, gw_binding_counts,
1300
+ manifest, collisions, parse_warnings),
1301
+ },
1302
+ "catalog": {
1303
+ "agents_observed": catalog.agents_observed,
1304
+ "tag_definitions": catalog.tag_definitions,
1305
+ "tag_definition_associations": catalog.tag_definition_associations,
1306
+ "tags": catalog.tags,
1307
+ },
1308
+ "_doc": (
1309
+ "Assembled from "
1310
+ "DATA_ROOT/<org>/<agent>__<ver>/<sid>/dc.*.json. "
1311
+ "See dc._session_manifest.json for per-query counts and empty reasons. "
1312
+ "Contract: references/dc_pipeline_contract.md."
1313
+ ),
1314
+ }, session_dir
1315
+
1316
+
1317
+ def _build_generation_view(gen: dict,
1318
+ quality_by_parent: Dict[str, List[dict]],
1319
+ categories_by_generation: Dict[str, List[dict]],
1320
+ categories_by_quality: Dict[str, List[dict]],
1321
+ feedback_by_gen: Dict[str, List[dict]],
1322
+ feedback_details_by_parent: Dict[str, List[dict]],
1323
+ gw_records_by_feedback: Dict[str, List[dict]]) -> dict:
1324
+ gen_id = gen["generationId__c"]
1325
+ # Quality rows with their TOXICITY sub-categories nested.
1326
+ quality_rows = []
1327
+ for q in quality_by_parent.get(gen_id, []):
1328
+ q_view = dict(q)
1329
+ q_view["_toxicity_subcategories"] = categories_by_quality.get(q.get("id__c"), [])
1330
+ quality_rows.append(q_view)
1331
+
1332
+ # Feedback rows with details + feedback-attachment records nested.
1333
+ feedback_rows = []
1334
+ for fb in feedback_by_gen.get(gen_id, []):
1335
+ fid = fb.get("feedbackId__c")
1336
+ feedback_rows.append({
1337
+ "feedback_id": fid,
1338
+ "feedback": fb.get("feedback__c"),
1339
+ "action": fb.get("action__c"),
1340
+ "details": feedback_details_by_parent.get(fid, []),
1341
+ "records": gw_records_by_feedback.get(fid, []),
1342
+ })
1343
+
1344
+ return {
1345
+ "generation_id": gen_id,
1346
+ "response_id": _clean(gen.get("generationResponseId__c")),
1347
+ "response_text": gen.get("responseText__c"),
1348
+ "masked_response_text": gen.get("maskedResponseText__c"),
1349
+ "response_parameters": gen.get("responseParameters__c"),
1350
+ "feature": _clean(gen.get("feature__c")),
1351
+ "quality": quality_rows,
1352
+ "categories": categories_by_generation.get(gen_id, []),
1353
+ "feedback": feedback_rows,
1354
+ }
1355
+
1356
+
1357
+ _STDM_LAG_NOTE = (
1358
+ "Interaction/Step/Message DMOs materialize on a separate cadence from "
1359
+ "Gateway DMOs. For fresh sessions this view reflects the gateway chain "
1360
+ "directly. Re-run in 24–72 hours for the full hierarchical trace."
1361
+ )
1362
+
1363
+
1364
+ def _assemble_gateway_direct(sid: str, rows: Dict[str, List[dict]],
1365
+ manifest: dict,
1366
+ parse_warnings: List[str]) -> dict:
1367
+ """Build a gateway-chain-only tree for sessions whose STDM hierarchy hasn't materialized.
1368
+
1369
+ Mirrors ``_minimal_tree_session_not_found`` in shape, but populates a
1370
+ ``session.gateway_chain[]`` harvested directly from ``gateway_requests``
1371
+ joined to ``gateway_request_tags`` / ``gateway_request_metadata`` /
1372
+ ``gateway_responses``. ``session.interactions`` is an explicit empty list
1373
+ so consumers that walk it simply no-op.
1374
+
1375
+ The sentinel ``_source = "gateway_direct"`` is consumed by
1376
+ ``render_dc._render_gateway_direct``.
1377
+ """
1378
+ sessions = rows.get("sessions", [])
1379
+ session_row = sessions[0] if sessions else {}
1380
+
1381
+ # Reuse the canonical identity harvester — same tag-alias fallbacks,
1382
+ # same normalization — and apply the same org_id_15 fallback as the
1383
+ # happy path so the top-level identity block is consistent across shapes.
1384
+ session_identity = _build_session_identity(rows, manifest)
1385
+ org_id_15 = manifest.get("org_id_15")
1386
+ if not org_id_15 and session_identity.get("org_id"):
1387
+ org_id_15 = session_identity["org_id"][:15]
1388
+ session_identity["org_id_15"] = org_id_15
1389
+
1390
+ # Same placeholder-promotion policy as the happy path — see
1391
+ # `_reconcile_top_identity` for why we don't trust the manifest blindly.
1392
+ top_identity = _reconcile_top_identity(manifest, session_identity, org_id_15)
1393
+
1394
+ # Group the child DMOs by parent once so the per-request loop stays O(n).
1395
+ tags_by_req: Dict[str, List[dict]] = _groupby(
1396
+ rows.get("gateway_request_tags", []), "parent__c")
1397
+ md_by_req: Dict[str, List[dict]] = _groupby(
1398
+ rows.get("gateway_request_metadata", []), "parent__c")
1399
+ resp_by_req: Dict[str, List[dict]] = _groupby(
1400
+ rows.get("gateway_responses", []), "generationRequestId__c")
1401
+
1402
+ # Deterministic order: sort by (timestamp__c, gatewayRequestId__c) so
1403
+ # repeat runs on the same inputs produce byte-identical output.
1404
+ gw_sorted = sorted(
1405
+ rows.get("gateway_requests", []),
1406
+ key=lambda r: (r.get("timestamp__c") or "",
1407
+ r.get("gatewayRequestId__c") or ""),
1408
+ )
1409
+
1410
+ gateway_chain: List[dict] = []
1411
+ for gw in gw_sorted:
1412
+ gw_id = gw.get("gatewayRequestId__c")
1413
+ # sf__Id (platform row id) isn't harvested by fetch_dc; the logical
1414
+ # PK is gatewayRequestId__c. Keep both keys on the view so readers
1415
+ # can lift either without re-joining.
1416
+ sf_id = gw.get("sf__Id")
1417
+
1418
+ # timestamp: prefer the columns named in the change-request
1419
+ # (requestTimeStamp__c, createdAt__c), then fall back to the
1420
+ # documented column (timestamp__c in references/dc_dmo_fields.md).
1421
+ timestamp = (
1422
+ _clean(gw.get("requestTimeStamp__c"))
1423
+ or _clean(gw.get("createdAt__c"))
1424
+ or _clean(gw.get("timestamp__c"))
1425
+ )
1426
+
1427
+ # Tag-driven fields. Use _harvest_str for html-unescape + quote-strip —
1428
+ # same normalizer _build_session_identity uses on tag values.
1429
+ tag_first: Dict[str, Optional[str]] = {}
1430
+ for t in sorted(
1431
+ tags_by_req.get(gw_id, []),
1432
+ key=lambda r: (r.get("tag__c") or "",
1433
+ r.get("tagValue__c") or "")):
1434
+ k = t.get("tag__c")
1435
+ if not k or k in tag_first:
1436
+ continue
1437
+ tag_first[k] = _harvest_str(t.get("tagValue__c"))
1438
+
1439
+ # Prompt-template name: prefer the direct column on the request;
1440
+ # fall back to the tag alias used by older agent builds.
1441
+ prompt_template_dev_name = (
1442
+ _clean(gw.get("promptTemplateDevName__c"))
1443
+ or tag_first.get("prompt_template_dev_name")
1444
+ )
1445
+ # `feature` likewise prefers the typed column, falls back to tag.
1446
+ feature = _clean(gw.get("feature__c")) or tag_first.get("feature")
1447
+
1448
+ # Response side — take the first by response timestamp. 1:1 invariant
1449
+ # holds on every live session we've observed, but defend against the
1450
+ # in-flight-call edge case by sorting deterministically.
1451
+ responses_sorted = sorted(
1452
+ resp_by_req.get(gw_id, []),
1453
+ key=lambda r: (r.get("timestamp__c") or "",
1454
+ r.get("generationResponseId__c") or ""),
1455
+ )
1456
+ response_view: Optional[dict] = None
1457
+ if responses_sorted:
1458
+ resp = responses_sorted[0]
1459
+ response_view = {
1460
+ "timestamp": _clean(resp.get("timestamp__c")),
1461
+ "finish_reason": _parse_finish_reason_params(resp.get("parameters__c")),
1462
+ }
1463
+
1464
+ gateway_chain.append({
1465
+ "gateway_request_id": gw_id,
1466
+ "sf_id": sf_id,
1467
+ "timestamp": timestamp,
1468
+ "model": _clean(gw.get("model__c")),
1469
+ "provider": _clean(gw.get("provider__c")),
1470
+ "prompt_template_dev_name": prompt_template_dev_name,
1471
+ "feature": feature,
1472
+ "prompt_tokens": gw.get("promptTokens__c"),
1473
+ "completion_tokens": gw.get("completionTokens__c"),
1474
+ "total_tokens": gw.get("totalTokens__c"),
1475
+ # Raw prompt is authoritative on disk (dc.gateway_requests.json);
1476
+ # the 64 KB display cap lives in the render layer, not here.
1477
+ "prompt_text": gw.get("prompt__c"),
1478
+ "metadata": md_by_req.get(gw_id, []),
1479
+ "tags": tags_by_req.get(gw_id, []),
1480
+ "response": response_view,
1481
+ })
1482
+
1483
+ return {
1484
+ "identity": top_identity,
1485
+ "_source": "gateway_direct",
1486
+ "session": {
1487
+ "id": sid,
1488
+ "_schema_version": 1,
1489
+ "org": {
1490
+ "alias": manifest.get("org_alias"),
1491
+ "instance_url": manifest.get("instance_url"),
1492
+ },
1493
+ "identity": session_identity,
1494
+ "start_ts": session_row.get("ssot__StartTimestamp__c"),
1495
+ "end_ts": session_row.get("ssot__EndTimestamp__c"),
1496
+ "end_type": _resolve_end_type(session_row, rows),
1497
+ "channel": _harvest_str(session_row.get("ssot__AiAgentChannelType__c")),
1498
+ "participants": _build_participants_view(rows),
1499
+ # Explicit empty list — downstream consumers walk this and must
1500
+ # no-op safely when STDM hasn't materialized yet.
1501
+ "interactions": [],
1502
+ "gateway_chain": gateway_chain,
1503
+ "_stdm_lag_note": _STDM_LAG_NOTE,
1504
+ "counts": {
1505
+ "session_shape": manifest.get("session_shape",
1506
+ "interactions_not_materialized_yet"),
1507
+ "gateway_requests": len(rows.get("gateway_requests", [])),
1508
+ "gateway_responses": len(rows.get("gateway_responses", [])),
1509
+ "gateway_metadata": len(rows.get("gateway_request_metadata", [])),
1510
+ "gateway_tags": len(rows.get("gateway_request_tags", [])),
1511
+ "interactions_total": 0,
1512
+ "steps_total": 0,
1513
+ "parse_warnings": parse_warnings,
1514
+ },
1515
+ },
1516
+ "_doc": (
1517
+ f"Session {sid}: STDM Interaction/Step/Message DMOs have not "
1518
+ "materialized yet (they lag Gateway DMOs by hours to days). "
1519
+ "Tree carries the gateway chain harvested directly from "
1520
+ "GenAIGatewayRequest + related audit DMOs. Re-run fetch_dc.py "
1521
+ "in 24–72h once the STDM hierarchy has caught up for the full "
1522
+ "hierarchical trace."
1523
+ ),
1524
+ }
1525
+
1526
+
1527
+ def _parse_finish_reason_params(parameters: Optional[str]) -> Optional[str]:
1528
+ """Lift finish_reason out of GatewayResponse.parameters__c.
1529
+
1530
+ The field arrives HTML-escaped and the finish_reason value itself is
1531
+ often wrapped in escaped quotes (e.g. `\\"stop\\"`). Mirrors
1532
+ ``render_dc._parse_finish_reason`` but against the gateway-response
1533
+ parameters column rather than Generation.responseParameters__c.
1534
+ """
1535
+ if not parameters:
1536
+ return None
1537
+ try:
1538
+ decoded = html.unescape(parameters)
1539
+ parsed = json.loads(decoded)
1540
+ except (ValueError, TypeError):
1541
+ return None
1542
+ raw = parsed.get("finish_reason") if isinstance(parsed, dict) else None
1543
+ if not isinstance(raw, str):
1544
+ return None
1545
+ return raw.replace("\\", "").strip('"').strip() or None
1546
+
1547
+
1548
+ def _minimal_tree_session_not_found(sid: str, manifest: dict,
1549
+ parse_warnings: List[str]) -> dict:
1550
+ # Note: deliberately does NOT include `session.identity` — harvest
1551
+ # sources (gateway_requests, gateway_request_tags, sessions[0],
1552
+ # participants) are all empty on this path. Renderer's minimal-tree
1553
+ # branch must handle absent identity. DOES include _schema_version so
1554
+ # the renderer's version check doesn't warn on every not-found session.
1555
+ #
1556
+ # Top-level `identity` still carries the manifest-stamped (org, agent,
1557
+ # version) when available — the session dir already exists under that
1558
+ # identity or the manifest couldn't have been read. Breadcrumb readers
1559
+ # depend on this block being present on EVERY tree, not just
1560
+ # full-populated ones.
1561
+ return {
1562
+ "identity": {
1563
+ "org_id_15": manifest.get("org_id_15"),
1564
+ "agent_api_name": manifest.get("agent_api_name"),
1565
+ "agent_version": manifest.get("agent_version"),
1566
+ },
1567
+ "session": {
1568
+ "id": sid,
1569
+ "_schema_version": 1,
1570
+ "org": {
1571
+ "alias": manifest.get("org_alias"),
1572
+ "instance_url": manifest.get("instance_url"),
1573
+ },
1574
+ "counts": {
1575
+ "session_shape": manifest.get("session_shape", "session_not_found"),
1576
+ "parse_warnings": parse_warnings,
1577
+ },
1578
+ },
1579
+ "_doc": (
1580
+ f"Session {sid} did not resolve in Data Cloud "
1581
+ "(sessions.json returned 0 rows). Check the session id, or wait for "
1582
+ "STDM materialization. No interactions, catalog, or audit rows available."
1583
+ ),
1584
+ }
1585
+
1586
+
1587
+ # ---- public entry points --------------------------------------------------
1588
+
1589
+ def main_for_session(sid: str) -> int:
1590
+ """Called by fetch_dc.py's auto-run hook and by `--session` CLI.
1591
+
1592
+ Writes ``dc._session_tree.json`` into the session dir located by
1593
+ ``_find_session_dir`` (via breadcrumb / glob) — the caller does not
1594
+ need to know ``(org, agent, version)`` to invoke the assembler.
1595
+ """
1596
+ tree, session_dir = assemble(sid)
1597
+ tree_path = session_dir / "dc._session_tree.json"
1598
+ tree_path.write_text(json.dumps(tree, indent=2, sort_keys=True, default=str) + "\n")
1599
+ print(f"assemble_dc: wrote {tree_path}", file=sys.stderr)
1600
+ return 0
1601
+
1602
+
1603
+ def main() -> int:
1604
+ ap = argparse.ArgumentParser(
1605
+ prog="assemble_dc.py",
1606
+ description="Assemble dc._session_tree.json for one session.",
1607
+ )
1608
+ ap.add_argument("--session", required=True,
1609
+ help="AI-agent session UUID or MessagingSession id (0Mw...). "
1610
+ "Messaging ids are resolved from disk "
1611
+ "(DATA_ROOT/*/dc.sessions.json); run fetch_dc.py first "
1612
+ "if the session hasn't been fetched yet.")
1613
+ # Runtime-agnostic path overrides; default to ~/.vibe/...
1614
+ from _shared.cli_override import add_cli_flags, apply_overrides
1615
+ add_cli_flags(ap)
1616
+ args = ap.parse_args()
1617
+ apply_overrides(args, caller_globals=globals())
1618
+ from resolve_session import resolve_disk_or_live
1619
+ sid = resolve_disk_or_live(args.session)
1620
+ return main_for_session(sid)
1621
+
1622
+
1623
+ if __name__ == "__main__":
1624
+ sys.exit(main())