@salesforce/afv-skills 1.67.0 → 1.68.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 (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
package/README.md CHANGED
@@ -1,15 +1,52 @@
1
- # Salesforce Skills Library
1
+ # Salesforce Skills and Plugins Library
2
2
 
3
- This repository provides a curated collection of Salesforce agent skills for building applications. It includes skills for Agentforce agents, Lightning apps, Flow, Apex, SOQL, Lightning Web Components (LWC), UI bundles, objects and fields, permission sets, and related areas.
3
+ This repository provides Salesforce agent skills, plugins, and sample apps for building applications. It includes skills for Agentforce agents, Lightning apps, Flow, Apex, SOQL, Lightning Web Components (LWC), UI bundles, objects and fields, permission sets, and related areas. The [builder plugins](plugins/builder/) package skills with additional capabilities such as agents, hooks, commands, and Model Context Protocol (MCP) servers. Each client loads the components supported by that plugin's manifest.
4
4
 
5
- The skills are contributed by Salesforce and the broader community. It’s optimized for Agentforce Vibes and can be used with any AI tool that supports skills.
5
+ The skills are contributed by Salesforce and the broader community. The skills library is optimized for Agentforce Vibes and can be used with any AI tool that supports skills. For guided Salesforce development, start with the [Salesforce Development plugin README](plugins/builder/salesforce-development/README.md) and the [Salesforce Development plugin documentation](https://developer.salesforce.com/docs/platform/salesforce-skills-plugins/guide/salesforce-development-overview.html).
6
6
 
7
- > ⚠️ **Expect frequent changes.** The Salesforce skills library is evolving rapidly as we refine patterns and incorporate feedback. Skills may be renamed, restructured, or removed between releases — they do not follow the same stability guarantees as GA platform APIs. If you’ve forked or synced the repository, be prepared for upstream changes that may conflict with local modifications. This repository is always the source of truth.
7
+ ## See the development workflow
8
+
9
+ Go from a natural-language request to an app deployed in Salesforce. These short demos
10
+ were recorded with Salesforce Development 2.5.0 and show a terminal workflow followed by the finished app.
11
+
12
+ ![Claude Code building and deploying a Salesforce Lightning app](docs/media/development-demos/claude-salesforce-development-2.5.0.gif)
13
+
14
+ | Demo | Length | Watch or download |
15
+ |---|---:|---|
16
+ | Claude Code: Lightning app | 35 seconds | [GIF](docs/media/development-demos/claude-salesforce-development-2.5.0.gif) · [MP4](docs/media/development-demos/claude-salesforce-development-2.5.0.mp4) |
17
+ | Codex CLI: Lightning app | 30 seconds | [GIF](docs/media/development-demos/codex-salesforce-development-2.5.0.gif) · [MP4](docs/media/development-demos/codex-salesforce-development-2.5.0.mp4) |
18
+ | Claude Code: internal React app | 32 seconds | [GIF](docs/media/development-demos/claude-react-salesforce-development-2.5.0.gif) · [MP4](docs/media/development-demos/claude-react-salesforce-development-2.5.0.mp4) |
19
+
20
+ The Codex Lightning demo also uses Experience LWC 0.3.0; the React demo uses
21
+ Experience React 0.2.1. The clips combine real terminal excerpts with captured views
22
+ of the deployed apps. Accelerated terminal sections are labeled. All Account data
23
+ shown is fictional. See the [recording notes](docs/media/development-demos/README.md).
24
+
25
+ <details>
26
+ <summary>Preview Codex CLI and React</summary>
27
+
28
+ ### Codex CLI: Lightning app
29
+
30
+ ![Codex CLI building and deploying a Salesforce Lightning app](docs/media/development-demos/codex-salesforce-development-2.5.0.gif)
31
+
32
+ ### Claude Code: internal React app
33
+
34
+ ![Claude Code building and deploying an internal Salesforce React app](docs/media/development-demos/claude-react-salesforce-development-2.5.0.gif)
35
+
36
+ </details>
37
+
38
+ > ⚠️ **Expect frequent changes.** The Salesforce skills and plugins library is evolving rapidly as we refine patterns and incorporate feedback. Skills and plugins may be renamed, restructured, or removed between releases — they do not follow the same stability guarantees as GA platform APIs. If you’ve forked or synced the repository, be prepared for upstream changes that may conflict with local modifications. This repository is always the source of truth.
8
39
 
9
40
  ## 🗂️ Structure
10
41
 
11
42
  ```
12
43
  sf-skills/
44
+ ├── .claude-plugin/
45
+ │ └── marketplace.json # Salesforce plugin marketplace catalog
46
+ ├── plugins/
47
+ │ └── builder/ # Plugins for Salesforce development
48
+ │ ├── salesforce-development/
49
+ │ └── ...
13
50
  ├── skills/ # Directory-based executable workflows
14
51
  │ ├── platform-apex-generate/
15
52
  │ ├── platform-custom-object-generate/
@@ -28,7 +65,20 @@ sf-skills/
28
65
  | **Tool** | **Usage** |
29
66
  |----------|-------------|
30
67
  | **Agentforce Vibes** | Skills are auto-installed and auto-updated |
31
- | **OpenCode, Claude Code, Codex, Cursor, [more](https://agentskills.io/)** | `npx skills add forcedotcom/sf-skills` |
68
+ | **Claude Code — Salesforce Development plugin** | Follow the [Claude Code installation guide](https://developer.salesforce.com/docs/platform/salesforce-skills-plugins/guide/install-salesforce-development.html) for skills, guided workflows, and integrated tools |
69
+ | **Codex — Salesforce Development plugin** | Follow [Codex setup and requirements](plugins/builder/salesforce-development/README.md#codex-lifecycle-support); use a release containing the Codex manifests and Codex CLI 0.145.0 or later |
70
+ | **OpenCode, Claude Code, Codex, Cursor, [more](https://agentskills.io/) — standalone skills** | `npx skills add forcedotcom/sf-skills` |
71
+
72
+ ## 🔌 Plugins
73
+
74
+ Plugins bundle skills with other components that the coding client installs and loads together. The Salesforce Development plugin provides shared workflows for building, testing, and deploying on the Salesforce Platform, with client-specific commands, hooks, and MCP configuration. Claude Code agents and hooks aren't automatically available in other clients. Specialized builder plugins add capabilities for areas such as Agentforce, code quality, LWC, and React development.
75
+
76
+ - [Salesforce marketplace manifest](.claude-plugin/marketplace.json) — available plugins, descriptions, and source paths
77
+ - [Salesforce Development plugin README](plugins/builder/salesforce-development/README.md) — prerequisites, quick start, and component inventory
78
+ - [Salesforce Development plugin documentation](https://developer.salesforce.com/docs/platform/salesforce-skills-plugins/guide/salesforce-development-overview.html) — installation, project and org setup, workflows, and troubleshooting; use the plugin README for Codex-specific setup
79
+ - [Claude Code plugin documentation](https://code.claude.com/docs/en/plugins) — plugin components and how plugins work
80
+
81
+ Check the [plugin changelog](plugins/builder/salesforce-development/CHANGELOG.md) and installed version for release availability. Other clients, including Cursor, can use standalone skills where supported; full plugin support depends on the client and plugin manifest.
32
82
 
33
83
  ## 📦 Samples
34
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/afv-skills",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "Salesforce's curated collection of agent skills for building applications. Optimized for Agentforce Vibes, compatible with all AI tools.",
5
5
  "license": "Apache-2.0",
6
6
  "files": [
@@ -24,14 +24,13 @@ metadata:
24
24
 
25
25
  ## When to Use This Skill
26
26
 
27
- Execute this workflow when a user wants to test how a different LLM model would handle existing Agentforce agent conversations. Fetches session traces from Data Cloud STDM, replays prompts via the SFAP AI Gateway REST API with structured tool calling, scores across four metrics (topic agreement, function name accuracy, function argument accuracy via LLM-as-judge, text response quality via LLM-as-judge), and produces CSV + HTML reports with SAFE/REVIEW/UNSAFE verdict badges. Requires Data Cloud with Agentforce Activity data streaming enabled, an authenticated org, and a target model ID (llmgateway__ prefix for SFAP replay). Estimated runtime: 10-30 minutes for 10 sessions.
27
+ Execute this workflow when a user wants to test how a different LLM model would handle existing Agentforce agent conversations. Fetches session traces from Data Cloud STDM, replays prompts via the SFAP AI Gateway REST API with structured tool calling, scores across four metrics (topic agreement, function name accuracy, function argument accuracy via LLM-as-judge, text response quality via LLM-as-judge), and produces a run directory with units.jsonl, results.jsonl, summary.json, and an HTML report. Requires Data Cloud with Agentforce Activity data streaming enabled, an authenticated org, and a target model ID (llmgateway__ prefix for SFAP replay). Estimated runtime: 10-30 minutes for 10 sessions.
28
28
 
29
29
  ## Workflow
30
30
 
31
- discover-data-space → verify-stdm-availability → discover-agents → list-available-models (if user needs help choosing) → find-sessions → get-conversation-details → get-llm-step-details → obtain-sfap-token → replay-turn-prompts → [score-exact-matches, judge-function-args, judge-text-responses] → generate-csv-report → generate-html-report.
31
+ discover-data-space → verify-stdm-availability → discover-agents → list-available-models (if user needs help choosing) → find-sessions → get-conversation-details → get-llm-step-details → obtain-sfap-token → replay-turn-prompts (scoring happens inline) → generate-run-results → generate-html-report.
32
32
  WORKFLOW GATE: Do not proceed past Phase 0 until org alias (auto-discover from authenticated orgs), agent API name (auto-discover from STDM), and target model ID (ask user) are all confirmed.
33
33
  SFAP JWT expires after ~2 hours. For 20+ session replays, refresh the token between batches. Each SFAP replay call takes ~1-3 seconds; keep under 20 sessions for reasonable wall-clock time (~10-30 minutes).
34
- The scoring and judging steps (Phase 3) can run in parallel once replay completes. Exact-match scoring is instant; LLM judge calls add ~1-2 seconds per turn.
35
34
 
36
35
  ## Critical Constraints
37
36
 
@@ -61,8 +60,8 @@ Operations grouped by purpose. Use these as the building blocks for the workflow
61
60
  | `score-exact-matches` | read | proposed | — | `replay-turn-prompts` |
62
61
  | `judge-function-args` | read | proposed | — | `score-exact-matches`, `obtain-sfap-token` |
63
62
  | `judge-text-responses` | read | proposed | — | `replay-turn-prompts`, `obtain-sfap-token` |
64
- | `generate-csv-report` | write | proposed | — | `score-exact-matches`, `judge-function-args`, `judge-text-responses` |
65
- | `generate-html-report` | write | proposed | — | `generate-csv-report` |
63
+ | `generate-run-results` | write | proposed | — | `score-exact-matches`, `judge-function-args`, `judge-text-responses` |
64
+ | `generate-html-report` | write | proposed | — | `generate-run-results` |
66
65
 
67
66
  ### Dependency graph
68
67
 
@@ -80,7 +79,7 @@ graph TD
80
79
  score_exact_matches["score-exact-matches (read)"]
81
80
  judge_function_args["judge-function-args (read)"]
82
81
  judge_text_responses["judge-text-responses (read)"]
83
- generate_csv_report["generate-csv-report (write)"]
82
+ generate_run_results["generate-run-results (write)"]
84
83
  generate_html_report["generate-html-report (write)"]
85
84
  discover_data_space --> verify_stdm_availability
86
85
  verify_stdm_availability --> discover_agents
@@ -95,10 +94,10 @@ graph TD
95
94
  obtain_sfap_token --> judge_function_args
96
95
  replay_turn_prompts --> judge_text_responses
97
96
  obtain_sfap_token --> judge_text_responses
98
- score_exact_matches --> generate_csv_report
99
- judge_function_args --> generate_csv_report
100
- judge_text_responses --> generate_csv_report
101
- generate_csv_report --> generate_html_report
97
+ score_exact_matches --> generate_run_results
98
+ judge_function_args --> generate_run_results
99
+ judge_text_responses --> generate_run_results
100
+ generate_run_results --> generate_html_report
102
101
  ```
103
102
 
104
103
  ### Read operations
@@ -194,29 +193,22 @@ LLM-as-judge scoring of text responses for turns that end with text output
194
193
 
195
194
  ### Write operations
196
195
 
197
- #### `generate-csv-report`
196
+ #### `generate-run-results`
198
197
 
199
- Generate CSV report with per-turn replay results and aggregate summary scores
198
+ sfap_replay.py produces the run directory with units.jsonl (one line per replay unit with request and golden), results.jsonl (one line per scored result), and summary.json (run config, model, counts, aggregate scores). Scoring happens inline during replay — no separate scoring step needed.
200
199
 
201
- **Status:** `proposed`
202
-
203
- **Inputs:**
204
200
 
205
- - `output_csv` *(`String`)*
201
+ **Status:** `proposed`
206
202
 
207
203
  **Depends on:** `score-exact-matches`, `judge-function-args`, `judge-text-responses`
208
204
 
209
205
  #### `generate-html-report`
210
206
 
211
- Build a self-contained HTML analysis report with verdict badges, scorecards, and failure breakdown
207
+ Build a self-contained HTML analysis report with scorecards and failure breakdown
212
208
 
213
209
  **Status:** `proposed`
214
210
 
215
- **Inputs:**
216
-
217
- - `html_output` *(`String`)*
218
-
219
- **Depends on:** `generate-csv-report`
211
+ **Depends on:** `generate-run-results`
220
212
 
221
213
  ### Other operations
222
214
 
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """Compare two replay runs of the same units, unit by unit, against the noise between repeats.
3
+
4
+ python3 scripts/bt_compare.py runs/base runs/cand [--noise-from runs/v0] [--out FILE]
5
+
6
+ Each unit x repeat scored in both runs is a pair. Per group (overall, step kind, node, golden tool)
7
+ the delta is in units: the sum of each unit's cand - base score, averaged over its repeats. Per
8
+ failure bucket, the net is the pairs that scored lower and ended in it, minus those that scored
9
+ higher and left it. A metric's tolerance is the largest difference on it between two repeats of
10
+ the noise run (default: BASE), at least 1 unit; a delta beyond it is a regression or a gain. A
11
+ bootstrap over sessions gives a 95% interval of the mean unit delta, for reading only. A failure
12
+ drops the pair from both runs, except a no answer: a timeout where the other run answered another
13
+ request or model scores 0, in the bucket no_answer.
14
+
15
+ Exit 0: no metric got worse beyond its tolerance. Exit 1: some did. Exit 2: the runs are not
16
+ comparable (other units, judge, k, seed, steps, router nodes, equivalent tools, timeout or btlib
17
+ code; or more than 10% infra errors, or more than 10% no answers, in a run). --force compares them
18
+ anyway, on the units both share. Exit 2 also when a dir is not a finished run, or bt_compare
19
+ fails: never a verdict.
20
+
21
+ Writes the comparison to --out (default CAND/compare.json, bt.compare/1). It lists the units that
22
+ moved by unit id, so keep it with the run dirs, out of git.
23
+ """
24
+
25
+ import argparse
26
+ import os
27
+ import sys
28
+ import traceback
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ from btlib import compare as comparelib # noqa: E402
33
+ from btlib import results # noqa: E402
34
+ from btlib.redact import redact # noqa: E402
35
+
36
+
37
+ def parse_args(argv=None):
38
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
39
+ p.add_argument("base", help="run dir of the baseline (sfap_replay.py --out-dir)")
40
+ p.add_argument("cand", help="run dir of the candidate, on the same units")
41
+ p.add_argument("--noise-from", metavar="DIR",
42
+ help="run dir whose repeats set the tolerances (default: BASE); same units, judge, steps, "
43
+ "router nodes, equivalent tools and btlib code as BASE")
44
+ p.add_argument("--out", metavar="FILE", help="where to write the comparison (default CAND/compare.json)")
45
+ p.add_argument("--force", action="store_true",
46
+ help="compare runs that are not comparable, on the units both share (recorded in the output)")
47
+ p.add_argument("--bootstrap-samples", type=int, default=comparelib.BOOTSTRAP_SAMPLES, metavar="N",
48
+ help="session resamples for the 95%% intervals (default %(default)s; 0: none)")
49
+ args = p.parse_args(argv)
50
+ if args.bootstrap_samples < 0:
51
+ p.error("--bootstrap-samples must be >= 0")
52
+ return args
53
+
54
+
55
+ def main(argv=None):
56
+ args = parse_args(argv)
57
+ out = args.out or os.path.join(args.cand, "compare.json")
58
+ try:
59
+ res = comparelib.compare(args.base, args.cand, args.noise_from, force=args.force,
60
+ samples=args.bootstrap_samples)
61
+ results.write_json(out, res) # 0600: unit ids name sessions
62
+ lines = comparelib.report_lines(res)
63
+ except comparelib.CompareError as e:
64
+ print(f"error: {redact(str(e))}", file=sys.stderr)
65
+ return 2
66
+ except OSError as e:
67
+ print(f"error: cannot write {out}: {e.strerror or e}", file=sys.stderr)
68
+ return 2
69
+ except Exception as e: # a bug, or a run dir it did not foresee: exit 1 would read as a regression
70
+ frame = traceback.extract_tb(e.__traceback__)[-1]
71
+ print(f"error: bt_compare failed ({type(e).__name__} at {os.path.basename(frame.filename)}:{frame.lineno}); "
72
+ "no verdict (exit 2)", file=sys.stderr)
73
+ return 2
74
+ for line in lines:
75
+ print(redact(line))
76
+ print(f"comparison: {out}")
77
+ return comparelib.exit_code(res)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main())
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch an agent's STDM sessions with read-only anonymous Apex: nothing is deployed.
3
+
4
+ python3 scripts/bt_fetch.py --org <alias> --probe [--agent NAME --start DATE --end DATE]
5
+ python3 scripts/bt_fetch.py --org <alias> --agent GPT41_Shopper_Agent \\
6
+ --start 2026-09-01 --end 2026-10-08 --out-dir runs/fetch-shop [--exclude-sessions FILE]
7
+ python3 scripts/sfap_replay.py --details runs/fetch-shop/details.json ...
8
+
9
+ Every query runs with `sf apex run --json` in --org, the org that holds the STDM data (with Data
10
+ Cloud One, not the agent's own org), in --dataspace. Each generated script is checked to be
11
+ read-only before it runs (SELECT queries only: no DML, no callouts) and is kept in OUT/apex/.
12
+
13
+ A data space that names the STDM DMOs and fields its own way (not ssot__AiAgentSession__dlm,
14
+ ssot__Id__c, ...) takes --stdm-names FILE, a bt.stdm_names/1 names map (references/replay-
15
+ procedure.md 1.2): the queries are sent in its names. Without it, nothing is renamed.
16
+
17
+ --probe counts the rows of each STDM DMO (and the agent's participant rows, and the sessions in
18
+ the window, when given) and prints each error as the org words it: run it first, and quote it
19
+ in an access request. With --stdm-names it also runs every query a fetch runs, with keys no row
20
+ has, and names each that fails.
21
+
22
+ The fetch finds the agent's sessions started in [--start, --end] with at least one turn, keeps
23
+ the --channels wanted (by default all but Builder*, Agent Builder's test traffic), drops the
24
+ sessions of --exclude-sessions and, with --split, those of the other split, then takes up to
25
+ --max-sessions in salted-hash order (or --order recent) and fetches them, --batch-size sessions
26
+ per Apex run.
27
+ An interrupted fetch rerun into the same --out-dir does not fetch its finished batches again.
28
+
29
+ Outputs in --out-dir (0700, files 0600): details.json (ConversationData, the --details input of
30
+ sfap_replay.py), sessions.json (SessionSummary), fetch.json (bt.fetch/1: parameters, counts,
31
+ digests; no session ids), apex/ (the scripts run). details.json holds user utterances and the
32
+ scripts hold session ids: keep them out of git.
33
+
34
+ --emit-kit DIR writes the same fetch as a kit for someone who can read the STDM data: this script,
35
+ the modules it runs, run_kit.sh with the parameters set (and the --stdm-names file), and a README.
36
+ Its out/ comes back.
37
+
38
+ Exit 0: done. 1: the fetch failed, or --probe found a required DMO unreadable (or, with
39
+ --stdm-names, a query that fails). 2: usage.
40
+ 130: interrupted (rerun into the same --out-dir to go on).
41
+ """
42
+
43
+ import argparse
44
+ import hashlib
45
+ import json
46
+ import os
47
+ import shlex
48
+ import shutil
49
+ import sys
50
+ import tempfile
51
+ import traceback
52
+ from datetime import datetime, timezone
53
+
54
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55
+
56
+ from btlib import SCHEMA_KIT # noqa: E402
57
+ from btlib import fetch as fetchlib # noqa: E402
58
+ from btlib import splits as splitlib # noqa: E402
59
+
60
+ HERE = os.path.dirname(os.path.abspath(__file__))
61
+ KIT_MODULES = ("__init__.py", "fetch.py", "redact.py", "splits.py", "stdm.py") # all bt_fetch.py imports
62
+
63
+
64
+ def parse_args(argv=None):
65
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
66
+ mode = p.add_mutually_exclusive_group()
67
+ mode.add_argument("--probe", action="store_true", help="count the rows each STDM DMO gives, print the errors")
68
+ mode.add_argument("--emit-kit", metavar="DIR", help="write this fetch as a kit for someone with STDM access")
69
+ p.add_argument("--org", help="alias or username of the org holding the STDM data")
70
+ p.add_argument("--dataspace", default="default", help="Data Cloud data space (default %(default)s)")
71
+ p.add_argument("--stdm-names", metavar="FILE",
72
+ help="the data space's own DMO and field names, a bt.stdm_names/1 file (default: the "
73
+ "ssot__ names, as they are)")
74
+ p.add_argument("--agent", help="the agent's API name (participant ssot__AiAgentApiName__c)")
75
+ p.add_argument("--start", help="sessions started from this date or ISO 8601 time (UTC)")
76
+ p.add_argument("--end", help="... up to this one; a date alone includes its whole day")
77
+ p.add_argument("--out-dir", metavar="DIR", help="where the fetch goes (created 0700; must not hold a fetch)")
78
+ p.add_argument("--max-sessions", type=int, default=fetchlib.MAX_SESSIONS,
79
+ help="sessions to fetch at most (default %(default)s)")
80
+ p.add_argument("--channels", help="comma-separated channel names to keep (default: all but Builder*)")
81
+ p.add_argument("--exclude-sessions", metavar="FILE",
82
+ help="never fetch these sessions: a file that names them, e.g. one ID per line, a "
83
+ "units.jsonl, a sessions or details JSON, a replay CSV (references/splits.md)")
84
+ p.add_argument("--val-fraction", type=float, metavar="F", help="session split as sfap_replay.py labels it")
85
+ p.add_argument("--split", choices=splitlib.SPLITS, help="fetch only this split (needs --val-fraction)")
86
+ p.add_argument("--split-salt", default=splitlib.DEFAULT_SALT, help="salt of the split (default %(default)s)")
87
+ p.add_argument("--order", choices=("hash", "recent"), default="hash",
88
+ help="which sessions come first when there are more than --max-sessions: a salted hash "
89
+ "(an even sample, stable when the window grows) or the newest (default %(default)s)")
90
+ p.add_argument("--sample-salt", default=fetchlib.SAMPLE_SALT, help="salt of --order hash (default %(default)s)")
91
+ p.add_argument("--batch-size", type=int, default=fetchlib.BATCH_SIZE,
92
+ help="sessions per Apex run, halved when a run hits a limit (default %(default)s)")
93
+ p.add_argument("--timeout", type=int, default=fetchlib.APEX_TIMEOUT, help="seconds per Apex run")
94
+ p.add_argument("--dry-run", action="store_true", help="find and select the sessions, print the counts, write nothing")
95
+ p.add_argument("--overwrite", action="store_true", help="replace an earlier fetch in --out-dir")
96
+ args = p.parse_args(argv)
97
+ if not args.emit_kit and not args.org:
98
+ p.error("--org is required (the org whose STDM data to read)")
99
+ if not args.probe:
100
+ missing = [f"--{n}" for n in ("agent", "start", "end") if not getattr(args, n)]
101
+ if missing:
102
+ p.error(" ".join(missing) + " required")
103
+ if not (args.emit_kit or args.dry_run or args.out_dir):
104
+ p.error("--out-dir is required (or --dry-run)")
105
+ if args.val_fraction is not None and not 0 < args.val_fraction < 1:
106
+ p.error("--val-fraction must be in (0, 1)")
107
+ if args.split and args.val_fraction is None:
108
+ p.error("--split needs --val-fraction (and the --split-salt of the units)")
109
+ if args.max_sessions < 1 or args.batch_size < 1 or args.timeout < 1:
110
+ p.error("--max-sessions, --batch-size and --timeout must be >= 1")
111
+ args.channels = [c.strip() for c in args.channels.split(",") if c.strip()] if args.channels else None
112
+ return args
113
+
114
+
115
+ def read_excluded(path):
116
+ if not path:
117
+ return frozenset()
118
+ try:
119
+ return frozenset(splitlib.read_session_ids(path))
120
+ except (OSError, splitlib.SplitError) as e:
121
+ raise fetchlib.FetchError(f"--exclude-sessions {path}: {e}") from None
122
+
123
+
124
+ def read_names(path):
125
+ """The StdmNames of --stdm-names, or None: the default names."""
126
+ return fetchlib.load_names(path) if path else None
127
+
128
+
129
+ def run_probe(args):
130
+ names = read_names(args.stdm_names)
131
+ with tempfile.TemporaryDirectory(prefix="bt_fetch_") as tmp:
132
+ f = fetchlib.Fetcher(args.org, os.path.join(tmp, "apex"), args.dataspace, timeout=args.timeout, names=names)
133
+ got = fetchlib.probe(f, args.agent, args.start, args.end)
134
+ print(f"STDM probe of {args.org}, data space {args.dataspace} (row counts, read-only):")
135
+ labels = {"agent": f"participant rows of {args.agent}", "window": "sessions started in the window"}
136
+ if names is not None:
137
+ print(f"names: {names.source} (sha256 {names.sha256})" + (f": {names.about}" if names.about else ""))
138
+ for key, table in fetchlib.PROBE_DMOS.items(): # table: the default name, quoted
139
+ labels[key] = f"{key} ({names.dmo(table[1:-1])})"
140
+ labels.update((key, "query: " + what) for key, what in fetchlib.SHAPES.items())
141
+ width = max(len(labels.get(key, key)) for key in got)
142
+ for key, res in got.items():
143
+ print(f" {labels.get(key, key):<{width}} " + (f"{res['rows']}" if "rows" in res else f"ERROR {res['error']}"))
144
+ unreadable = [k for k in fetchlib.REQUIRED_DMOS if "error" in got[k]]
145
+ if unreadable:
146
+ print(f"not readable: {', '.join(unreadable)}. A fetch needs them; quote the errors above in an access "
147
+ "request, or hand someone with access a kit (--emit-kit).")
148
+ return 1
149
+ if names is None:
150
+ print("the DMOs a fetch reads are readable")
151
+ return 0
152
+ failed = [what for key, what in fetchlib.SHAPES.items() if "error" in got[key]]
153
+ if failed:
154
+ print(f"these queries fail with the names of {names.source}: {', '.join(failed)}. The errors above "
155
+ "name what the data space lacks; fix the names map, and probe again.")
156
+ return 1
157
+ print("the DMOs a fetch reads are readable, and every query a fetch runs works with these names")
158
+ return 0
159
+
160
+
161
+ def prepare_out_dir(out_dir, overwrite):
162
+ if os.path.isfile(os.path.join(out_dir, "fetch.json")) and not overwrite:
163
+ raise fetchlib.FetchError(f"{out_dir} already holds a fetch (fetch.json); use a new --out-dir or --overwrite")
164
+ if not os.path.isdir(out_dir):
165
+ os.makedirs(out_dir)
166
+ os.chmod(out_dir, 0o700) # user utterances inside
167
+ elif overwrite: # the old fetch's files go first: one that stops early must not pass for it
168
+ fetchlib.clear_outputs(out_dir)
169
+
170
+
171
+ def run_fetch(args):
172
+ exclude = read_excluded(args.exclude_sessions)
173
+ names = read_names(args.stdm_names)
174
+ kw = dict(channels=args.channels, exclude=exclude, split=args.split, val_fraction=args.val_fraction,
175
+ split_salt=args.split_salt, order=args.order, sample_salt=args.sample_salt,
176
+ max_sessions=args.max_sessions, batch_size=args.batch_size, log=print,
177
+ params={"exclude_sessions": os.path.basename(args.exclude_sessions) if args.exclude_sessions else None,
178
+ "batch_size": args.batch_size})
179
+ if args.dry_run:
180
+ with tempfile.TemporaryDirectory(prefix="bt_fetch_") as tmp:
181
+ f = fetchlib.Fetcher(args.org, os.path.join(tmp, "apex"), args.dataspace, timeout=args.timeout,
182
+ names=names)
183
+ fetchlib.fetch(f, args.agent, args.start, args.end, dry_run=True, **kw)
184
+ print("dry run: nothing fetched")
185
+ return 0
186
+ prepare_out_dir(args.out_dir, args.overwrite)
187
+ f = fetchlib.Fetcher(args.org, os.path.join(args.out_dir, "apex"), args.dataspace, timeout=args.timeout,
188
+ names=names)
189
+ m = fetchlib.fetch(f, args.agent, args.start, args.end, out_dir=args.out_dir, **kw)
190
+ c = m["counts"]
191
+ print(f"fetched {c['fetched']} sessions, {c['turns']} turns, {c['llm_steps']} LLM steps in {c['apex_runs']} "
192
+ f"Apex runs: {os.path.join(args.out_dir, 'details.json')}")
193
+ if os.path.isfile(os.path.join(HERE, "sfap_replay.py")):
194
+ print(f"next: python3 {os.path.join(HERE, 'sfap_replay.py')} --details "
195
+ f"{os.path.join(args.out_dir, 'details.json')} ...")
196
+ else: # a kit
197
+ print(f"send back the whole {args.out_dir} dir (README.md)")
198
+ return 0
199
+
200
+
201
+ _RUN_KIT = """#!/usr/bin/env bash
202
+ # Fetch the STDM sessions of {agent} for a backtest, with read-only anonymous Apex (README.md).
203
+ # Usage: ./run_kit.sh <org-alias> [data-space] [more bt_fetch.py flags: --probe, --dry-run, --overwrite]
204
+ set -euo pipefail
205
+ here="$(cd "$(dirname "$0")" && pwd)"
206
+ if [ $# -lt 1 ]; then
207
+ echo "usage: $0 <org-alias> [data-space] [--probe | --dry-run | --overwrite]" >&2
208
+ exit 2
209
+ fi
210
+ org="$1"; shift
211
+ dataspace={dataspace}
212
+ if [ $# -gt 0 ] && [ "${{1#-}}" = "$1" ]; then dataspace="$1"; shift; fi
213
+ exec python3 "$here/bt_fetch.py" --org "$org" --dataspace "$dataspace" \\
214
+ {flags} \\
215
+ --out-dir "$here/out" "$@"
216
+ """
217
+
218
+ _README = """# STDM fetch kit: {agent}
219
+
220
+ Fetches up to {max_sessions} Agentforce sessions of the agent `{agent}`, started from {start} to
221
+ {end} (UTC), from the Session Tracing data (STDM) of a Data Cloud data space. They are the
222
+ input of an offline backtest. Made by `bt_fetch.py --emit-kit` (code sha {code_sha}).
223
+
224
+ ## What it does
225
+
226
+ `run_kit.sh` runs `bt_fetch.py`, which writes small anonymous Apex scripts and runs each with
227
+ `sf apex run --json --file <script> --target-org <your org>`. They only read:
228
+
229
+ - SELECT queries through `ConnectApi.CdpQuery.queryAnsiSqlV2` on the DMOs
230
+ {session}, {participant}, {interaction},
231
+ {message} and {step} (and a row count of
232
+ {generation} and {gateway} with `--probe`);
233
+ - a SOQL query of GenAiPlannerDefinition (Id, DeveloperName, MasterLabel), only when no session
234
+ names the agent.
235
+
236
+ There is no DML, no callout, nothing is deployed and nothing is changed in the org. Each script
237
+ is checked before it runs (`btlib/fetch.py`, `check_read_only`: only the words those reads need,
238
+ and SELECT statements only) and stays in `out/apex/` for you to read.
239
+
240
+ {names_note}## Run it
241
+
242
+ Needs python3 (3.9 or later) and the Salesforce CLI `sf`, logged in to the org that holds the
243
+ STDM data, as a user who can query its data space.
244
+
245
+ ./run_kit.sh <org-alias> --probe # can this user read the STDM DMOs? (counts only)
246
+ ./run_kit.sh <org-alias> --dry-run # how many sessions would come (counts only)
247
+ ./run_kit.sh <org-alias> # the fetch, into out/
248
+ ./run_kit.sh <org-alias> <data-space> ... # another data space than {dataspace}
249
+
250
+ {exclude_note}## What to send back
251
+
252
+ The whole `out/` dir: `details.json` (the conversations), `sessions.json`, `fetch.json` (what was
253
+ asked and the counts) and `apex/` (the scripts that ran). The conversations hold what users
254
+ wrote: share them only the way your team shares production transcripts.
255
+ """
256
+
257
+ _NAMES_NOTE = """## Names
258
+
259
+ This data space names the STDM DMOs and fields its own way. The queries are sent in the names
260
+ of `stdm_names.json` (sha256 {sha256}){user_agent}.{about} `--probe` also runs every query
261
+ the fetch runs, with keys no row has, and names each that fails.
262
+
263
+ """
264
+
265
+
266
+ def emit_kit(args):
267
+ """A self-contained copy of this fetch; returns the kit dir."""
268
+ kit = args.emit_kit
269
+ if os.path.exists(kit) and (not os.path.isdir(kit) or os.listdir(kit)):
270
+ raise fetchlib.FetchError(f"{kit} exists and is not an empty dir")
271
+ fetchlib.check_name(args.agent)
272
+ fetchlib.check_name(args.dataspace, "data space name")
273
+ start, end = fetchlib.window_bound(args.start), fetchlib.window_bound(args.end, end=True)
274
+ exclude = read_excluded(args.exclude_sessions)
275
+ names = read_names(args.stdm_names)
276
+ os.makedirs(os.path.join(kit, "btlib"), exist_ok=True)
277
+ os.chmod(kit, 0o700)
278
+ files = {"bt_fetch.py": os.path.join(HERE, "bt_fetch.py")}
279
+ files.update({"btlib/" + name: os.path.join(HERE, "btlib", name) for name in KIT_MODULES})
280
+ if names is not None:
281
+ files["stdm_names.json"] = args.stdm_names
282
+ for rel, src in files.items():
283
+ shutil.copyfile(src, os.path.join(kit, rel))
284
+ if names is not None:
285
+ os.chmod(os.path.join(kit, "stdm_names.json"), 0o600) # data, like the kit's other files
286
+ flags =["--agent", args.agent, "--start", start, "--end", end, "--max-sessions", str(args.max_sessions),
287
+ "--batch-size", str(args.batch_size), "--order", args.order, "--sample-salt", args.sample_salt]
288
+ if args.channels:
289
+ flags += ["--channels", ",".join(args.channels)]
290
+ if args.split:
291
+ flags += ["--val-fraction", repr(args.val_fraction), "--split", args.split, "--split-salt", args.split_salt]
292
+ texts = {"run_kit.sh": _RUN_KIT.format(agent=args.agent, dataspace=shlex.quote(args.dataspace),
293
+ flags=" ".join(shlex.quote(x) for x in flags)
294
+ + (' --stdm-names "$here/stdm_names.json"' if names else "")
295
+ + (' --exclude-sessions "$here/exclude_sessions.txt"' if exclude else ""))}
296
+ if exclude: # the ids are the org's own, and the kit goes to people who can read its sessions
297
+ texts["exclude_sessions.txt"] = ("# sessions this fetch must not return (already in the backtest)\n"
298
+ + "".join(s + "\n" for s in sorted(exclude)))
299
+ dmos = {key: table[1:-1] for key, table in fetchlib.PROBE_DMOS.items()} # the default names, unquoted
300
+ names_note = ""
301
+ if names is not None:
302
+ dmos = {key: names.dmo(name) for key, name in dmos.items()}
303
+ about = names.about + ("" if names.about.endswith((".", "!", "?")) else ".") if names.about else ""
304
+ names_note = _NAMES_NOTE.format(
305
+ sha256=names.sha256, about=f" It says: {about}" if about else "",
306
+ user_agent=f", each with the userAgent `{names.user_agent}`" if names.user_agent else "")
307
+ texts["README.md"] = _README.format(
308
+ agent=args.agent, max_sessions=args.max_sessions, start=start, end=end, dataspace=args.dataspace,
309
+ code_sha=fetchlib.code_sha(), names_note=names_note, **dmos,
310
+ exclude_note=(f"`exclude_sessions.txt` lists {len(exclude)} sessions the backtest already has; the fetch "
311
+ "skips them.\n\n" if exclude else ""))
312
+ for rel, text in texts.items():
313
+ fetchlib.write_private(os.path.join(kit, rel), text)
314
+ os.chmod(os.path.join(kit, "run_kit.sh"), 0o700)
315
+ shas = {}
316
+ for rel in sorted(list(files) + list(texts)):
317
+ with open(os.path.join(kit, rel), "rb") as fh:
318
+ shas[rel] = hashlib.sha256(fh.read()).hexdigest()[:16]
319
+ manifest = {"schema": SCHEMA_KIT, "created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
320
+ "params": {"agent": args.agent, "window": [start, end], "dataspace": args.dataspace,
321
+ "max_sessions": args.max_sessions, "batch_size": args.batch_size,
322
+ "channels": args.channels, "order": args.order, "sample_salt": args.sample_salt,
323
+ "split": None if not args.split else {"split": args.split, "val_fraction": args.val_fraction,
324
+ "salt": args.split_salt},
325
+ "excluded_ids": len(exclude)},
326
+ "code": {"fetch_sha256": fetchlib.code_sha()}, "files": shas}
327
+ if names is not None:
328
+ manifest["params"]["stdm_names"] = names.manifest()
329
+ fetchlib.write_private(os.path.join(kit, "kit.json"), json.dumps(manifest, indent=2) + "\n")
330
+ print(f"kit: {kit} ({len(shas) + 1} files); they run ./run_kit.sh <org-alias> and send back {kit}/out/")
331
+ return kit
332
+
333
+
334
+ def main(argv=None):
335
+ args = parse_args(argv)
336
+ try:
337
+ if args.emit_kit:
338
+ emit_kit(args)
339
+ return 0
340
+ return run_probe(args) if args.probe else run_fetch(args)
341
+ except fetchlib.FetchError as e:
342
+ print(f"error: {fetchlib.mask_ids(str(e))}", file=sys.stderr)
343
+ return 1
344
+ except OSError as e:
345
+ print(f"error: {fetchlib.mask_ids(str(e))}", file=sys.stderr)
346
+ return 1
347
+ except KeyboardInterrupt:
348
+ print("interrupted: rerun into the same --out-dir to go on from the finished batches", file=sys.stderr)
349
+ return 130
350
+ except Exception as e: # a bug, or an answer it did not foresee: no traceback (it can quote session data)
351
+ frame = traceback.extract_tb(e.__traceback__)[-1]
352
+ print(f"error: bt_fetch failed ({type(e).__name__} at {os.path.basename(frame.filename)}:{frame.lineno}: "
353
+ f"{fetchlib.mask_ids(str(e))[:200]})", file=sys.stderr)
354
+ return 1
355
+
356
+
357
+ if __name__ == "__main__":
358
+ sys.exit(main())