@pie-players/pie-assessment-toolkit 0.3.74 → 0.3.75

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 (228) hide show
  1. package/README.md +349 -313
  2. package/dist/attempt/TestSession.d.ts +1 -20
  3. package/dist/attempt/TestSession.js +8 -99
  4. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  5. package/dist/components/ItemScope.custom-element.js +1 -0
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  10. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-97hx60z4.js} +1 -1
  11. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  13. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  14. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  15. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  16. package/dist/components/item-scope-element.d.ts +2 -0
  17. package/dist/components/item-scope-element.js +2 -0
  18. package/dist/context/assessment-toolkit-context.d.ts +27 -6
  19. package/dist/index.d.ts +22 -47
  20. package/dist/index.js +12 -36
  21. package/dist/policy/core/PolicySource.d.ts +1 -1
  22. package/dist/policy/core/ToolPolicyEngine.d.ts +77 -32
  23. package/dist/policy/core/ToolPolicyEngine.js +136 -46
  24. package/dist/policy/core/compose-decision.d.ts +28 -7
  25. package/dist/policy/core/compose-decision.js +75 -54
  26. package/dist/policy/core/decision-types.d.ts +65 -34
  27. package/dist/policy/core/feature-decision.d.ts +16 -9
  28. package/dist/policy/core/feature-decision.js +14 -8
  29. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  30. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  31. package/dist/policy/core/provenance.d.ts +9 -4
  32. package/dist/policy/core/provenance.js +1 -1
  33. package/dist/policy/engine.d.ts +7 -23
  34. package/dist/policy/engine.js +5 -20
  35. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  36. package/dist/policy/sources/PnpPolicySource.js +117 -118
  37. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  38. package/dist/runtime/SectionControllerBinding.js +450 -0
  39. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  40. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  41. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  42. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  43. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  44. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  45. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  46. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  47. package/dist/runtime/catalog-context.d.ts +19 -0
  48. package/dist/runtime/catalog-context.js +28 -0
  49. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  50. package/dist/runtime/composition-emit-scheduler.js +9 -15
  51. package/dist/runtime/content-language.d.ts +32 -0
  52. package/dist/runtime/content-language.js +46 -0
  53. package/dist/runtime/content-region.d.ts +17 -0
  54. package/dist/runtime/content-region.js +29 -0
  55. package/dist/runtime/content-registration.d.ts +13 -0
  56. package/dist/runtime/content-registration.js +25 -0
  57. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  58. package/dist/runtime/core/SectionEngineCore.js +2 -2
  59. package/dist/runtime/core/cohort.d.ts +1 -1
  60. package/dist/runtime/core/cohort.js +1 -1
  61. package/dist/runtime/core/engine-input.d.ts +18 -30
  62. package/dist/runtime/core/engine-input.js +11 -12
  63. package/dist/runtime/core/engine-output.d.ts +16 -40
  64. package/dist/runtime/core/engine-output.js +9 -21
  65. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  66. package/dist/runtime/core/engine-readiness.js +8 -41
  67. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  68. package/dist/runtime/core/engine-resolver.js +18 -43
  69. package/dist/runtime/core/engine-state.d.ts +28 -36
  70. package/dist/runtime/core/engine-state.js +18 -21
  71. package/dist/runtime/core/engine-transition.d.ts +14 -19
  72. package/dist/runtime/core/engine-transition.js +71 -51
  73. package/dist/runtime/engine.d.ts +16 -19
  74. package/dist/runtime/engine.js +12 -19
  75. package/dist/runtime/page-warnings.d.ts +13 -0
  76. package/dist/runtime/page-warnings.js +22 -0
  77. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  78. package/dist/runtime/policy-input-forwarding.js +25 -0
  79. package/dist/runtime/registration-events.d.ts +11 -0
  80. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  81. package/dist/runtime/runtime-event-claim.js +15 -0
  82. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  83. package/dist/runtime/shell-event-bridge.js +127 -0
  84. package/dist/runtime/shell-registration.d.ts +73 -0
  85. package/dist/runtime/shell-registration.js +95 -0
  86. package/dist/runtime/shell-scope.d.ts +68 -0
  87. package/dist/runtime/shell-scope.js +188 -0
  88. package/dist/runtime/tool-host-contract.d.ts +8 -10
  89. package/dist/runtime/tool-host-contract.js +11 -11
  90. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  91. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  92. package/dist/services/ElementToolStateStore.d.ts +14 -10
  93. package/dist/services/ElementToolStateStore.js +37 -13
  94. package/dist/services/HighlightCoordinator.d.ts +21 -21
  95. package/dist/services/HighlightCoordinator.js +431 -325
  96. package/dist/services/RangeSerializer.d.ts +10 -1
  97. package/dist/services/RangeSerializer.js +87 -14
  98. package/dist/services/TTSService.d.ts +117 -70
  99. package/dist/services/TTSService.js +668 -578
  100. package/dist/services/ToolCoordinator.d.ts +43 -60
  101. package/dist/services/ToolCoordinator.js +164 -206
  102. package/dist/services/ToolRegistry.d.ts +74 -57
  103. package/dist/services/ToolRegistry.js +104 -114
  104. package/dist/services/ToolkitCoordinator.d.ts +183 -189
  105. package/dist/services/ToolkitCoordinator.js +477 -431
  106. package/dist/services/audio-handoff.d.ts +6 -7
  107. package/dist/services/audio-handoff.js +20 -14
  108. package/dist/services/catalog-owner.d.ts +0 -1
  109. package/dist/services/framework-error-bus.d.ts +3 -6
  110. package/dist/services/framework-error-bus.js +3 -6
  111. package/dist/services/framework-error.d.ts +22 -7
  112. package/dist/services/framework-error.js +9 -7
  113. package/dist/services/interfaces.d.ts +158 -129
  114. package/dist/services/section-session-assignment.d.ts +21 -0
  115. package/dist/services/section-session-assignment.js +82 -0
  116. package/dist/services/spoken-audio-cards.d.ts +0 -2
  117. package/dist/services/spoken-audio-cards.js +0 -2
  118. package/dist/services/tool-config-defaults.d.ts +3 -3
  119. package/dist/services/tool-config-defaults.js +3 -3
  120. package/dist/services/tool-config-validation.d.ts +4 -5
  121. package/dist/services/tool-config-validation.js +36 -61
  122. package/dist/services/tool-context.d.ts +0 -8
  123. package/dist/services/tool-context.js +0 -12
  124. package/dist/services/tool-icons.d.ts +4 -3
  125. package/dist/services/tool-icons.js +4 -3
  126. package/dist/services/tool-instance-id.d.ts +0 -1
  127. package/dist/services/tool-instance-id.js +0 -6
  128. package/dist/services/tool-providers/TTSToolProvider.d.ts +18 -19
  129. package/dist/services/tool-providers/TTSToolProvider.js +37 -34
  130. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  131. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +30 -26
  132. package/dist/services/tool-providers/ToolProviderRegistry.js +119 -93
  133. package/dist/services/tool-request.d.ts +27 -12
  134. package/dist/services/tool-request.js +24 -14
  135. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  136. package/dist/services/toolbar-decided-tools.js +28 -0
  137. package/dist/services/tools-config-normalizer.d.ts +17 -22
  138. package/dist/services/tools-config-normalizer.js +2 -10
  139. package/dist/services/tts/browser-provider.d.ts +38 -2
  140. package/dist/services/tts/browser-provider.js +276 -140
  141. package/dist/services/tts/flat-tree.d.ts +71 -0
  142. package/dist/services/tts/flat-tree.js +269 -0
  143. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  144. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  145. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  146. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  147. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  148. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  149. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +12 -4
  150. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  151. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  152. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  153. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  154. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  155. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  156. package/dist/services/tts/math-aware-text-processing.js +32 -18
  157. package/dist/services/tts/math-control-names.d.ts +21 -0
  158. package/dist/services/tts/math-control-names.js +142 -0
  159. package/dist/services/tts/math-speech.d.ts +11 -0
  160. package/dist/services/tts/math-speech.js +51 -18
  161. package/dist/services/tts/provider-options.d.ts +41 -0
  162. package/dist/services/tts/provider-options.js +1 -0
  163. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  164. package/dist/services/tts/ssml/spoken-text.js +29 -0
  165. package/dist/services/tts/start-failure.d.ts +11 -0
  166. package/dist/services/tts/start-failure.js +19 -0
  167. package/dist/services/tts/text-processing.d.ts +9 -0
  168. package/dist/services/tts/text-processing.js +46 -33
  169. package/dist/services/tts/text-segmentation.d.ts +7 -0
  170. package/dist/services/tts/text-segmentation.js +47 -0
  171. package/dist/services/tts-runtime-config.d.ts +25 -17
  172. package/dist/services/tts-runtime-config.js +16 -95
  173. package/dist/tools/content-capability-resolution.d.ts +7 -9
  174. package/dist/tools/content-capability-resolution.js +8 -45
  175. package/dist/tools/registration.d.ts +58 -0
  176. package/dist/tools/registration.js +54 -0
  177. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  178. package/dist/tools/tool-surface-host.d.ts +6 -10
  179. package/dist/tools/tool-surface-host.js +44 -42
  180. package/dist/tools/tool-tag-map.d.ts +5 -15
  181. package/dist/tools/tool-tag-map.js +6 -20
  182. package/dist/utils/structural-equality.d.ts +11 -0
  183. package/dist/utils/structural-equality.js +47 -0
  184. package/package.json +13 -29
  185. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  186. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  187. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  188. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  189. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  190. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  191. package/dist/context/runtime-context-consumer.d.ts +0 -13
  192. package/dist/context/runtime-context-consumer.js +0 -18
  193. package/dist/item-loader.d.ts +0 -44
  194. package/dist/item-loader.js +0 -61
  195. package/dist/policy/core/engine-context.d.ts +0 -29
  196. package/dist/policy/core/engine-context.js +0 -27
  197. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  198. package/dist/policy/core/policy-source-tag.js +0 -28
  199. package/dist/policy/internal.d.ts +0 -25
  200. package/dist/policy/internal.js +0 -25
  201. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  202. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  203. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  204. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  205. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  206. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  207. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  208. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  209. package/dist/runtime/engine-context.d.ts +0 -39
  210. package/dist/runtime/engine-context.js +0 -31
  211. package/dist/runtime/internal.d.ts +0 -43
  212. package/dist/runtime/internal.js +0 -45
  213. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  214. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  215. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  216. package/dist/runtime/stage-emit-gate.js +0 -82
  217. package/dist/services/I18nService.d.ts +0 -47
  218. package/dist/services/I18nService.js +0 -84
  219. package/dist/services/ThemeProvider.d.ts +0 -95
  220. package/dist/services/ThemeProvider.js +0 -334
  221. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  222. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  223. package/dist/services/pnp-standard-features.d.ts +0 -238
  224. package/dist/services/pnp-standard-features.js +0 -338
  225. package/dist/tools/client.d.ts +0 -10
  226. package/dist/tools/client.js +0 -10
  227. package/dist/tools/internal.d.ts +0 -44
  228. package/dist/tools/internal.js +0 -39
@@ -22,13 +22,13 @@ import { FrameworkErrorBus, } from "./framework-error-bus.js";
22
22
  import { HighlightCoordinator } from "./HighlightCoordinator.js";
23
23
  import { ToolCoordinator } from "./ToolCoordinator.js";
24
24
  import { TTSService } from "./TTSService.js";
25
- import { BrowserTTSProvider } from "./tts/browser-provider.js";
25
+ import { BrowserTTSProvider, browserFallbackConfig, } from "./tts/browser-provider.js";
26
26
  import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
27
27
  import { ToolProviderRegistry } from "./tool-providers/index.js";
28
- import { resolveToolProviderId, ToolRegistry } from "./ToolRegistry.js";
28
+ import { ToolRegistry } from "./ToolRegistry.js";
29
29
  import { ToolRequestRegistry } from "./tool-request.js";
30
30
  import { ToolPolicyEngine, } from "../policy/engine.js";
31
- import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
31
+ import { resolveSectionSessionAssignment } from "./section-session-assignment.js";
32
32
  class ToolkitCoordinatorDisposedError extends Error {
33
33
  constructor() {
34
34
  super("ToolkitCoordinator has been disposed.");
@@ -45,16 +45,9 @@ const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.
45
45
  const mergeToolConfigUpdate = (toolId, current, updates) => {
46
46
  const next = { ...current, ...updates };
47
47
  if (toolId === "textToSpeech" &&
48
- isPlainRecord(current.settings) &&
49
- isPlainRecord(updates.settings)) {
50
- next.settings = { ...current.settings, ...updates.settings };
51
- if (isPlainRecord(current.settings.mathSpeech) &&
52
- isPlainRecord(updates.settings.mathSpeech)) {
53
- next.settings.mathSpeech = {
54
- ...current.settings.mathSpeech,
55
- ...updates.settings.mathSpeech,
56
- };
57
- }
48
+ isPlainRecord(current.mathSpeech) &&
49
+ isPlainRecord(updates.mathSpeech)) {
50
+ next.mathSpeech = { ...current.mathSpeech, ...updates.mathSpeech };
58
51
  }
59
52
  return next;
60
53
  };
@@ -81,8 +74,10 @@ const SECTION_SCOPED_EVENT_TYPES = [
81
74
  *
82
75
  * @example
83
76
  * ```typescript
84
- * // createPackagedToolRegistry is exported by @pie-players/pie-default-tool-loaders
85
- * const toolRegistry = createPackagedToolRegistry();
77
+ * // Both are exported by @pie-players/pie-default-tool-loaders
78
+ * const toolRegistry = createPackagedToolRegistry({
79
+ * toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS,
80
+ * });
86
81
  *
87
82
  * // Create coordinator with configuration
88
83
  * const coordinator = new ToolkitCoordinator({
@@ -91,7 +86,7 @@ const SECTION_SCOPED_EVENT_TYPES = [
91
86
  * tools: {
92
87
  * providers: {
93
88
  * textToSpeech: { enabled: true, backend: 'browser' },
94
- * answerEliminator: { enabled: true, strategy: 'strikethrough' }
89
+ * answerEliminator: { enabled: true }
95
90
  * },
96
91
  * placement: {
97
92
  * item: ['textToSpeech', 'answerEliminator']
@@ -123,22 +118,43 @@ export class ToolkitCoordinator {
123
118
  lazyInit;
124
119
  /** Track TTS initialization state */
125
120
  ttsInitialized = false;
121
+ /** Text-to-speech failed to start and policy does not grant it. */
122
+ ttsDegraded = false;
126
123
  ttsInitPromise;
127
124
  ttsReconfigurePromise;
128
125
  stateLoaded = false;
126
+ /**
127
+ * The loader ran, whether or not it returned state. A failed load is
128
+ * reported once and the coordinator runs without saved tool state.
129
+ */
130
+ stateLoadSettled = false;
129
131
  stateLoadPromise;
130
132
  coordinatorReadyPromise;
131
133
  coordinatorReadyNotified = false;
132
134
  providerInitPromises = new Map();
135
+ /**
136
+ * Recoverable failures, by tool. A policy change that grants one of these
137
+ * tools reports its failure again as fatal.
138
+ */
139
+ degradedTools = new Map();
140
+ /** Tools a toolbar reported a module failure for, each reported once. */
141
+ toolModuleFailures = new Set();
142
+ readyChangeListeners = new Set();
143
+ eagerInit;
133
144
  toolRegistry;
134
145
  /** Whether the host passed `toolRegistry`, which then always stands. */
135
146
  toolRegistrySupplied;
136
147
  /**
137
148
  * Whether the registry is final: supplied at construction, or adopted from
138
- * the toolkit this coordinator is bound to. Until then the empty placeholder
139
- * reports no missing registry, since the toolkit will supply one.
149
+ * the toolkit this coordinator is bound to.
140
150
  */
141
151
  toolRegistrySettled;
152
+ /**
153
+ * Whether a bound toolkit reported having no registry. Until then, or until a
154
+ * registry settles, the empty placeholder reports no missing registry, since
155
+ * the toolkit may supply one.
156
+ */
157
+ toolRegistryAbsent = false;
142
158
  toolContextResolvers = new Map();
143
159
  toolContextResolverChangeListeners = new Set();
144
160
  toolRequests = new ToolRequestRegistry();
@@ -165,14 +181,16 @@ export class ToolkitCoordinator {
165
181
  * Phase D: map key of the section controller currently treated as
166
182
  * the *active cohort*. Set by `getOrCreateSectionController` (both
167
183
  * the create-new and resolve-existing paths) and cleared when the
168
- * matching controller is disposed. `null` while no cohort exists,
169
- * which is the only state where `subscribeSectionEvents` throws.
184
+ * matching controller is disposed, and `null` while the next requested
185
+ * cohort is still starting.
170
186
  */
171
187
  activeCohortMapKey = null;
172
188
  /**
173
189
  * Latest requested active cohort. Async controller initialization can
174
190
  * resolve out of order; only the most recent request may migrate active
175
191
  * subscriptions. Older completions still populate the controller cache.
192
+ * `null` until the first request, which is the only state where
193
+ * `subscribeSectionEvents` throws.
176
194
  */
177
195
  latestRequestedActiveCohortMapKey = null;
178
196
  telemetryListeners = new Set();
@@ -188,32 +206,20 @@ export class ToolkitCoordinator {
188
206
  */
189
207
  policyEngine;
190
208
  /**
191
- * Host-set override for PNP/profile enforcement. `null` (the default) means
192
- * "auto" — the coordinator infers the effective mode from the
193
- * PNP/profile policy inputs the bound `AssessmentEntity` and `AssessmentItemRef`
194
- * actually carry (see {@link resolveEffectivePnpEnforcement}).
209
+ * Host-set override for PNP/profile enforcement, passed to the engine.
210
+ * `null` (the default) is auto-mode, which the engine resolves per decision
211
+ * from the policy material the bound assessment and the decision's item carry.
195
212
  * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
196
- * across subsequent assessment / item swaps until the host clears
213
+ * across subsequent assessment swaps until the host clears
197
214
  * the override by calling `setPnpEnforcement(null)`.
198
215
  */
199
216
  pnpEnforcementOverride = null;
200
217
  /**
201
- * Last assessment passed to {@link updateAssessment}. Read by
202
- * {@link resolveEffectivePnpEnforcement} to compute auto-mode.
203
- * The engine's own copy is the canonical record for decisions;
204
- * this mirror exists only so the auto-mode helper does not need
205
- * to round-trip through {@link policyEngine}'s frozen snapshot.
206
- */
207
- boundAssessment = null;
208
- /**
209
- * Last item reference passed to {@link updateCurrentItemRef}.
210
- * Mirrored alongside {@link boundAssessment} so
211
- * {@link resolveEffectivePnpEnforcement} can detect item-level
212
- * profile policy inputs (`requiredTools` / `restrictedTools` /
213
- * `toolParameters`) without round-tripping through the engine's
214
- * frozen snapshot.
218
+ * Policy diagnostics already logged, keyed by code, tool id and, for
219
+ * `tool-policy.itemSettingNotApplied`, item id; see
220
+ * {@link warnPolicyDiagnostics}.
215
221
  */
216
- boundCurrentItemRef = null;
222
+ warnedPolicyDiagnostics = new Set();
217
223
  /**
218
224
  * Whether {@link decideFeaturePolicy} has already reported serving a decision
219
225
  * with no assessment bound.
@@ -227,7 +233,7 @@ export class ToolkitCoordinator {
227
233
  reportedUnboundFeaturePolicy = false;
228
234
  /**
229
235
  * Whether {@link _initializeTTS} has already reported a non-browser backend
230
- * falling back to browser speech because no `tts` provider is registered.
236
+ * falling back to browser speech because no text-to-speech provider is registered.
231
237
  * Once per coordinator: a text-to-speech config change re-runs
232
238
  * initialization, and the missing provider is the same gap each time.
233
239
  */
@@ -292,7 +298,7 @@ export class ToolkitCoordinator {
292
298
  */
293
299
  validateToolsConfig(tools, options) {
294
300
  const { config, diagnostics } = collectToolConfigDiagnostics(tools, options.toolRegistry);
295
- const unreported = this.reportedRegistryUnavailable || !this.toolRegistrySettled
301
+ const unreported = this.reportedRegistryUnavailable || this.toolRegistryPending()
296
302
  ? diagnostics.filter((entry) => entry.code !== "tools.registryUnavailable")
297
303
  : diagnostics;
298
304
  reportToolConfigDiagnostics(unreported, options);
@@ -313,8 +319,11 @@ export class ToolkitCoordinator {
313
319
  this.config = resolvedConfig;
314
320
  this.toolRegistry = resolvedConfig.toolRegistry ?? new ToolRegistry();
315
321
  this.installToolContextResolvers(resolvedConfig.toolContextResolvers);
316
- this.hooks = resolvedConfig.hooks ?? {};
322
+ // The coordinator's own copy, so `setHooks` leaves the host's object as it
323
+ // passed it.
324
+ this.hooks = { ...resolvedConfig.hooks };
317
325
  this.lazyInit = config.lazyInit === true;
326
+ this.eagerInit = config.eagerInit ?? !this.lazyInit;
318
327
  this.pnpEnforcementOverride = this.resolveConfiguredPnpEnforcement();
319
328
  // Use the host-provided framework-error bus if one was passed
320
329
  // (typical when embedded inside <pie-assessment-toolkit>, so
@@ -337,31 +346,35 @@ export class ToolkitCoordinator {
337
346
  // Selection read-aloud speaks through this service without the inline TTS
338
347
  // tool ever having run, so it cannot rely on that tool to attach highlights.
339
348
  this.ttsService.setHighlightCoordinator(this.highlightCoordinator);
349
+ // A caller that speaks without `ensureTTSReady()` first, such as selection
350
+ // read-aloud, starts the service instead of finding it uninitialized.
351
+ this.ttsService.setReadinessGate(() => this.ensureTTSReady());
352
+ this.ttsService.setMathSpeechSource(() => buildRuntimeTTSConfig(resolveTTSRuntimeSettings(this.resolveTTSToolConfig())).providerOptions?.mathSpeech);
340
353
  this.setupStatePersistenceHooks();
341
- // M8 PR 2 — construct the unified ToolPolicyEngine seeded with
342
- // the validated tools config. PNP/profile inputs (`assessment`,
343
- // `currentItemRef`) start `null`; `pnpEnforcement` is resolved
344
- // through {@link resolveEffectivePnpEnforcement}, which flips
345
- // to `"on"` only once the bound assessment or item carries
346
- // actual profile material. Hosts that only consume the
347
- // engine for placement/policy gating get the pre-PR-2 behavior
348
- // bit-for-bit.
354
+ // The unified ToolPolicyEngine, seeded with the validated tools config.
355
+ // The assessment starts `null` and items register their settings as they
356
+ // mount; auto-mode enforcement turns on only once the policy material a
357
+ // decision reads is present, so a host that consumes the engine for
358
+ // placement/policy gating alone sees no PNP/profile gate.
349
359
  this.policyEngine = new ToolPolicyEngine({
350
360
  toolRegistry: this.toolRegistry,
351
361
  contextId: `toolkit-coordinator:${this.assessmentId}`,
352
362
  inputs: {
353
363
  tools: this.config.tools,
354
364
  assessment: null,
355
- currentItemRef: null,
356
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
365
+ pnpEnforcement: this.pnpEnforcementOverride,
357
366
  },
358
367
  });
359
- if (!this.lazyInit) {
368
+ this.policyEngine.onPolicyChange((event) => {
369
+ if (event.reason !== "disposed")
370
+ this.reportNewlyGrantedFailures();
371
+ });
372
+ if (this.eagerInit) {
360
373
  void this.waitUntilReady().catch((err) => {
361
374
  if (err instanceof ToolkitCoordinatorDisposedError)
362
375
  return;
363
376
  console.error("[ToolkitCoordinator] Failed eager initialization:", err);
364
- this.handleError(err, { phase: "coordinator-ready" });
377
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
365
378
  });
366
379
  }
367
380
  }
@@ -445,13 +458,92 @@ export class ToolkitCoordinator {
445
458
  }
446
459
  }
447
460
  }
448
- handleError(error, context) {
461
+ /**
462
+ * Every report site states whether the coordinator carries on after the
463
+ * failure; one that takes down only its section passes `scope: "cohort"`.
464
+ */
465
+ handleError(error, context, scope = "runtime") {
449
466
  const model = frameworkErrorFromCoordinatorContext({
450
467
  error,
451
468
  context,
469
+ recoverable: context.recoverable,
470
+ scope,
452
471
  });
453
472
  this.frameworkErrorBus.reportFrameworkError(model);
454
473
  }
474
+ /**
475
+ * Whether policy grants `toolId` as an accommodation on some surface: a
476
+ * test-administration override, a mounted item's or a district requirement,
477
+ * or profile support. Read without the unbound-assessment warning.
478
+ */
479
+ isToolGranted(toolId) {
480
+ try {
481
+ return this.policyEngine.grantsFeatureAnywhere(toolId);
482
+ }
483
+ catch {
484
+ return false;
485
+ }
486
+ }
487
+ /**
488
+ * Reports a tool's start failure: recoverable, so the tool reports itself
489
+ * unavailable and the assessment goes on, unless policy grants the tool.
490
+ * `fallbackFollows` marks a failure another path absorbs, which is recoverable
491
+ * whatever the policy.
492
+ */
493
+ reportToolFailure(error, context, toolIds, fallbackFollows = false) {
494
+ const ids = Array.from(toolIds);
495
+ const recoverable = fallbackFollows || !ids.some((toolId) => this.isToolGranted(toolId));
496
+ if (recoverable && !fallbackFollows) {
497
+ for (const toolId of ids)
498
+ this.degradedTools.set(toolId, { error, context });
499
+ }
500
+ this.handleError(error, { ...context, recoverable });
501
+ }
502
+ /**
503
+ * Report that a toolbar could not load a tool's module. The failure follows
504
+ * the start-failure policy: the tool degrades, so the toolbar withholds it,
505
+ * unless policy grants it. Reported once per tool however many toolbars fail
506
+ * to load it. Every report re-announces the request targets, since the
507
+ * reporting toolbar no longer hosts the tool.
508
+ */
509
+ reportToolModuleFailure(toolId, error) {
510
+ this.toolRequests.notifyTargetsChange();
511
+ if (this.toolModuleFailures.has(toolId))
512
+ return;
513
+ this.toolModuleFailures.add(toolId);
514
+ const detail = error instanceof Error && error.message.trim().length > 0
515
+ ? error.message
516
+ : String(error);
517
+ this.reportToolFailure(new Error(`Tool "${toolId}" failed to load: ${detail}`, { cause: error }), { phase: "tool-module-load", details: { toolId } }, [toolId]);
518
+ }
519
+ reportNewlyGrantedFailures() {
520
+ for (const [toolId, failure] of this.degradedTools) {
521
+ if (!this.isToolGranted(toolId))
522
+ continue;
523
+ this.degradedTools.delete(toolId);
524
+ this.handleError(failure.error, { ...failure.context, recoverable: false });
525
+ }
526
+ }
527
+ /**
528
+ * Subscribe to changes of {@link isReady}: state loading, text-to-speech
529
+ * starting, failing or reconfiguring. The listener reads the status itself.
530
+ */
531
+ onReadyChange(listener) {
532
+ this.readyChangeListeners.add(listener);
533
+ return () => {
534
+ this.readyChangeListeners.delete(listener);
535
+ };
536
+ }
537
+ notifyReadyChange() {
538
+ for (const listener of this.readyChangeListeners) {
539
+ try {
540
+ listener();
541
+ }
542
+ catch (error) {
543
+ console.warn("[ToolkitCoordinator] ready-change listener failed:", error);
544
+ }
545
+ }
546
+ }
455
547
  /**
456
548
  * Subscribe to framework-error events emitted by this coordinator.
457
549
  *
@@ -495,15 +587,20 @@ export class ToolkitCoordinator {
495
587
  return null;
496
588
  }
497
589
  })();
590
+ // Stored per attempt. Without an attempt id nothing tells two learners on
591
+ // one device apart, so the section is neither read nor written.
498
592
  const getStorageKey = (context) => {
499
593
  const { assessmentId, sectionId, attemptId } = context.key;
500
- return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId || "default"}`;
594
+ if (!attemptId)
595
+ return null;
596
+ return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId}`;
501
597
  };
502
598
  return {
503
599
  async loadSession(context) {
504
- if (!storage)
600
+ const key = getStorageKey(context);
601
+ if (!storage || !key)
505
602
  return null;
506
- const value = storage.getItem(getStorageKey(context));
603
+ const value = storage.getItem(key);
507
604
  if (!value)
508
605
  return null;
509
606
  try {
@@ -514,14 +611,16 @@ export class ToolkitCoordinator {
514
611
  }
515
612
  },
516
613
  async saveSession(context, session) {
517
- if (!storage)
614
+ const key = getStorageKey(context);
615
+ if (!storage || !key)
518
616
  return;
519
- storage.setItem(getStorageKey(context), JSON.stringify(session));
617
+ storage.setItem(key, JSON.stringify(session));
520
618
  },
521
619
  async clearSession(context) {
522
- if (!storage)
620
+ const key = getStorageKey(context);
621
+ if (!storage || !key)
523
622
  return;
524
- storage.removeItem(getStorageKey(context));
623
+ storage.removeItem(key);
525
624
  },
526
625
  };
527
626
  }
@@ -543,12 +642,12 @@ export class ToolkitCoordinator {
543
642
  if (!this.hooks.saveToolState)
544
643
  return;
545
644
  void Promise.resolve(this.hooks.saveToolState(state)).catch((err) => {
546
- this.handleError(err, { phase: "state-save" });
645
+ this.handleError(err, { phase: "state-save", recoverable: true });
547
646
  });
548
647
  });
549
648
  }
550
649
  async ensureStateLoaded() {
551
- if (this.stateLoaded)
650
+ if (this.stateLoadSettled)
552
651
  return;
553
652
  if (this.stateLoadPromise)
554
653
  return this.stateLoadPromise;
@@ -564,6 +663,7 @@ export class ToolkitCoordinator {
564
663
  this.elementToolStateStore.loadState(state);
565
664
  }
566
665
  this.stateLoaded = true;
666
+ this.stateLoadSettled = true;
567
667
  await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
568
668
  hasState: Boolean(state),
569
669
  });
@@ -571,43 +671,52 @@ export class ToolkitCoordinator {
571
671
  catch (err) {
572
672
  if (err instanceof ToolkitCoordinatorDisposedError)
573
673
  throw err;
574
- this.handleError(err, { phase: "state-load" });
674
+ this.stateLoadSettled = true;
675
+ this.handleError(err, { phase: "state-load", recoverable: true });
575
676
  }
576
677
  })().finally(() => {
577
678
  this.stateLoadPromise = undefined;
679
+ this.notifyReadyChange();
578
680
  });
579
681
  return this.stateLoadPromise;
580
682
  }
683
+ /** Whether the toolkit this coordinator binds to may still supply a registry. */
684
+ toolRegistryPending() {
685
+ return !this.toolRegistrySettled && !this.toolRegistryAbsent;
686
+ }
581
687
  reportMissingTTSProvider(backend) {
582
688
  // The toolkit this coordinator binds to may still supply the provider.
583
- if (!this.toolRegistrySettled) {
689
+ if (this.toolRegistryPending()) {
584
690
  this.heldMissingTTSProviderBackend = backend;
585
691
  return;
586
692
  }
587
693
  if (this.reportedMissingTTSProvider)
588
694
  return;
589
695
  this.reportedMissingTTSProvider = true;
590
- console.warn(`[ToolkitCoordinator] Text-to-speech is configured for the "${backend}" backend but falls back to browser speech: no "tts" tool provider is registered. A coordinator registers tool providers only from its \`toolRegistry\`, or from its toolkit's when constructed without one, so supply one that carries the text-to-speech registration — for the packaged capability set, \`createPackagedToolRegistry()\` from "@pie-players/pie-default-tool-loaders". Reported once per coordinator.`);
696
+ console.warn(`[ToolkitCoordinator] Text-to-speech is configured for the "${backend}" backend but falls back to browser speech: no "textToSpeech" tool provider is registered. A coordinator registers tool providers only from its \`toolRegistry\`, or from its toolkit's when constructed without one, so supply one that carries the text-to-speech registration — for the packaged capability set, \`createPackagedToolRegistry()\` from "@pie-players/pie-default-tool-loaders". Reported once per coordinator.`);
591
697
  }
592
698
  /**
593
699
  * Take the registry of the toolkit this coordinator is bound to, when the host
594
700
  * constructed it without one: the config is validated against it, its
595
701
  * providers register, and text-to-speech re-initializes through it if it
596
- * already started. The toolkit elements call this on binding. A registry
597
- * passed at construction stands, and the first call wins; `null` records a
598
- * toolkit without one, which reports the registry as missing.
702
+ * already started. The toolkit elements call this on binding, and again when
703
+ * their registry changes. A registry passed at construction stands, and the
704
+ * first registry adopted wins; `null` records a toolkit without one, which
705
+ * reports the registry as missing and still adopts one that arrives later.
599
706
  *
600
707
  * @returns Whether `registry` was adopted.
601
708
  */
602
709
  adoptToolRegistry(registry) {
603
710
  if (this.disposePromise !== null || this.toolRegistrySettled)
604
711
  return false;
605
- this.toolRegistrySettled = true;
712
+ if (!registry && this.toolRegistryAbsent)
713
+ return false;
606
714
  const heldBackend = this.heldMissingTTSProviderBackend;
607
715
  this.heldMissingTTSProviderBackend = null;
608
716
  const strictness = this.config.toolConfigStrictness ?? "error";
609
717
  const source = "ToolkitCoordinator.adoptToolRegistry";
610
718
  if (!registry) {
719
+ this.toolRegistryAbsent = true;
611
720
  this.validateToolsConfig(this.config.tools, {
612
721
  strictness,
613
722
  source,
@@ -617,6 +726,7 @@ export class ToolkitCoordinator {
617
726
  this.reportMissingTTSProvider(heldBackend);
618
727
  return false;
619
728
  }
729
+ this.toolRegistrySettled = true;
620
730
  this.toolRegistry = registry;
621
731
  this.config.toolRegistry = registry;
622
732
  try {
@@ -658,35 +768,32 @@ export class ToolkitCoordinator {
658
768
  }
659
769
  /**
660
770
  * Register the provider `tool`'s descriptor creates for the tool's current
661
- * config, unless the tool is disabled or that provider id is registered. A
662
- * descriptor that throws is reported the way a failed registration is.
771
+ * config under the tool's id, unless the tool is disabled or, without
772
+ * `replace`, a provider is registered under that id. A descriptor that throws
773
+ * is reported the way a failed registration is.
663
774
  */
664
- async registerProviderFromTool(tool) {
775
+ async registerProviderFromTool(tool, replace = false) {
665
776
  const descriptor = tool.provider;
666
777
  if (!descriptor)
667
778
  return;
668
- let providerId = null;
779
+ const { toolId } = tool;
669
780
  let registration;
670
781
  try {
671
- const toolConfig = this.getToolConfig(tool.toolId) || undefined;
672
- if (toolConfig?.enabled === false)
782
+ const toolConfig = this.getToolConfig(toolId) || undefined;
783
+ if (toolConfig?.enabled === false) {
784
+ // A disabled tool keeps no provider, including the one it replaces.
785
+ if (replace)
786
+ void this.toolProviderRegistry.unregister(toolId);
673
787
  return;
674
- providerId = resolveToolProviderId(tool, toolConfig);
675
- if (!providerId || this.toolProviderRegistry.has(providerId))
788
+ }
789
+ if (!replace && this.toolProviderRegistry.has(toolId))
676
790
  return;
677
791
  const provider = descriptor.createProvider(toolConfig);
678
792
  const initConfig = descriptor.getInitConfig?.(toolConfig) ??
679
793
  toolConfig?.provider?.init ??
680
794
  {};
681
- const initConfigWithTelemetry = this.addToolTelemetryReporter({
682
- toolId: tool.toolId,
683
- providerId,
684
- initConfig,
685
- });
686
- const registryTelemetry = this.createToolTelemetryForwarder({
687
- toolId: tool.toolId,
688
- providerId,
689
- });
795
+ const initConfigWithTelemetry = this.addToolTelemetryReporter(toolId, initConfig);
796
+ const registryTelemetry = this.createToolTelemetryForwarder(toolId);
690
797
  const authFetcher = descriptor.getAuthFetcher?.(toolConfig) ??
691
798
  toolConfig?.provider?.runtime?.authFetcher;
692
799
  registration = {
@@ -698,31 +805,24 @@ export class ToolkitCoordinator {
698
805
  };
699
806
  }
700
807
  catch (err) {
701
- this.reportProviderRegisterFailure(err, providerId, tool.toolId);
808
+ this.reportProviderRegisterFailure(err, toolId);
702
809
  return;
703
810
  }
704
- await this.registerProvider(providerId, registration);
811
+ await this.registerProvider(toolId, registration);
705
812
  }
706
- createToolTelemetryForwarder(args) {
813
+ createToolTelemetryForwarder(toolId) {
707
814
  return async (eventName, payload) => {
708
- await this.emitTelemetry(eventName, {
709
- ...(payload || {}),
710
- toolId: args.toolId,
711
- providerId: args.providerId,
712
- });
815
+ await this.emitTelemetry(eventName, { ...(payload || {}), toolId });
713
816
  };
714
817
  }
715
- addToolTelemetryReporter(args) {
716
- const configObject = args.initConfig && typeof args.initConfig === "object"
717
- ? { ...args.initConfig }
818
+ addToolTelemetryReporter(toolId, initConfig) {
819
+ const configObject = initConfig && typeof initConfig === "object"
820
+ ? { ...initConfig }
718
821
  : {};
719
822
  const existingReporter = typeof configObject.onTelemetry === "function"
720
823
  ? configObject.onTelemetry
721
824
  : null;
722
- const forwardTelemetry = this.createToolTelemetryForwarder({
723
- toolId: args.toolId,
724
- providerId: args.providerId,
725
- });
825
+ const forwardTelemetry = this.createToolTelemetryForwarder(toolId);
726
826
  configObject.onTelemetry = async (eventName, payload) => {
727
827
  if (existingReporter) {
728
828
  await existingReporter(eventName, payload);
@@ -731,56 +831,58 @@ export class ToolkitCoordinator {
731
831
  };
732
832
  return configObject;
733
833
  }
734
- async registerProvider(providerId, config) {
834
+ async registerProvider(toolId, config) {
735
835
  try {
736
- this.toolProviderRegistry.register(providerId, config);
836
+ this.toolProviderRegistry.register(toolId, config);
737
837
  const meta = {
738
- providerId,
739
838
  providerName: config.provider.providerName,
740
839
  };
741
- await this.hooks.onProviderRegistered?.(providerId, meta);
840
+ await this.hooks.onProviderRegistered?.(toolId, meta);
742
841
  await this.emitTelemetry("pie-toolkit-provider-registered", {
743
- providerId,
842
+ toolId,
744
843
  providerName: config.provider.providerName,
745
844
  });
746
845
  }
747
846
  catch (err) {
748
- this.reportProviderRegisterFailure(err, providerId);
847
+ this.reportProviderRegisterFailure(err, toolId);
749
848
  }
750
849
  }
751
850
  /**
752
- * A console warning and a `provider-register` framework error. `providerId`
753
- * is null when resolving it is what failed; the warning names the tool then.
851
+ * A console warning and a `provider-register` framework error, under the
852
+ * start-failure policy every other tool failure follows.
754
853
  */
755
- reportProviderRegisterFailure(err, providerId, toolId) {
756
- console.warn(providerId
757
- ? `[ToolkitCoordinator] Failed to register provider "${providerId}":`
758
- : `[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
759
- this.handleError(err, {
760
- phase: "provider-register",
761
- providerId: providerId ?? undefined,
762
- });
854
+ reportProviderRegisterFailure(err, toolId) {
855
+ console.warn(`[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
856
+ this.reportToolFailure(err, { phase: "provider-register", toolId }, [toolId]);
763
857
  }
764
- async ensureProviderReady(providerId) {
858
+ async ensureProviderReady(toolId) {
859
+ return this.initializeProvider(toolId, false);
860
+ }
861
+ /** `fallbackFollows`: the caller recovers from a failure on its own. */
862
+ async initializeProvider(toolId, fallbackFollows) {
765
863
  this.assertNotDisposed();
766
- const existing = this.providerInitPromises.get(providerId);
864
+ const existing = this.providerInitPromises.get(toolId);
767
865
  if (existing)
768
866
  return existing;
769
867
  const promise = (async () => {
770
- const provider = await this.toolProviderRegistry.getProvider(providerId, false);
771
- this.assertNotDisposed();
772
- const meta = {
773
- providerId,
774
- providerName: provider.providerName,
775
- };
776
868
  try {
777
- await this.hooks.onProviderInitStart?.(providerId, meta);
869
+ let provider = await this.toolProviderRegistry.getProvider(toolId, false);
870
+ this.assertNotDisposed();
871
+ // A tool asks each time it opens; the lifecycle hooks report the start once.
872
+ if (this.toolProviderRegistry.isInitialized(toolId))
873
+ return provider;
874
+ const meta = {
875
+ providerName: provider.providerName,
876
+ };
877
+ await this.hooks.onProviderInitStart?.(toolId, meta);
778
878
  this.assertNotDisposed();
779
- await this.toolProviderRegistry.initialize(providerId);
879
+ await this.toolProviderRegistry.initialize(toolId);
780
880
  this.assertNotDisposed();
781
- await this.hooks.onProviderReady?.(providerId, meta);
881
+ // A config update may have replaced the provider during its start.
882
+ provider = await this.toolProviderRegistry.getProvider(toolId, false);
883
+ await this.hooks.onProviderReady?.(toolId, meta);
782
884
  this.assertNotDisposed();
783
- await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
885
+ await this.emitTelemetry("pie-toolkit-provider-ready", { toolId });
784
886
  this.assertNotDisposed();
785
887
  return provider;
786
888
  }
@@ -788,13 +890,16 @@ export class ToolkitCoordinator {
788
890
  if (err instanceof ToolkitCoordinatorDisposedError)
789
891
  throw err;
790
892
  const error = err instanceof Error ? err : new Error(String(err));
791
- this.handleError(error, { phase: "provider-init", providerId });
893
+ this.reportToolFailure(error, { phase: "provider-init", toolId },
894
+ // Attributed when the id names a tool with a provider; any other id
895
+ // was never a tool's.
896
+ this.toolRegistry.get(toolId)?.provider ? [toolId] : [], fallbackFollows);
792
897
  throw error;
793
898
  }
794
899
  })().finally(() => {
795
- this.providerInitPromises.delete(providerId);
900
+ this.providerInitPromises.delete(toolId);
796
901
  });
797
- this.providerInitPromises.set(providerId, promise);
902
+ this.providerInitPromises.set(toolId, promise);
798
903
  return promise;
799
904
  }
800
905
  setHooks(hooks) {
@@ -804,7 +909,7 @@ export class ToolkitCoordinator {
804
909
  hooks.onCoordinatorReady &&
805
910
  this.isReady()) {
806
911
  void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
807
- this.handleError(err, { phase: "coordinator-ready" });
912
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
808
913
  });
809
914
  }
810
915
  }
@@ -827,8 +932,11 @@ export class ToolkitCoordinator {
827
932
  // cohort* automatically. We bind the listener to whichever
828
933
  // controller is currently active and re-bind it on every
829
934
  // `getOrCreateSectionController` / `disposeSectionController`
830
- // transition, with snapshot replay on each migration.
831
- if (this.activeCohortMapKey === null) {
935
+ // transition, with snapshot replay on each migration. A listener
936
+ // added while the next section is still starting binds when it
937
+ // becomes active.
938
+ if (this.activeCohortMapKey === null &&
939
+ this.latestRequestedActiveCohortMapKey === null) {
832
940
  throw new Error("[ToolkitCoordinator] subscribeSectionEvents requires an active section cohort; call getOrCreateSectionController first.");
833
941
  }
834
942
  const previous = this.activeSubscriptions.get(args.listener);
@@ -843,7 +951,9 @@ export class ToolkitCoordinator {
843
951
  unsubscribeCurrent: null,
844
952
  };
845
953
  this.activeSubscriptions.set(args.listener, sub);
846
- const controller = this.sectionControllers.get(this.activeCohortMapKey);
954
+ const controller = this.activeCohortMapKey === null
955
+ ? undefined
956
+ : this.sectionControllers.get(this.activeCohortMapKey);
847
957
  if (controller) {
848
958
  this.bindSubscriptionToController(sub, controller);
849
959
  }
@@ -1121,18 +1231,29 @@ export class ToolkitCoordinator {
1121
1231
  const mapKey = this.getSectionControllerMapKey(key);
1122
1232
  this.latestRequestedActiveCohortMapKey = mapKey;
1123
1233
  this.suspendActiveCohortIfSuperseded(mapKey);
1124
- const existingController = await this.resolveExistingSectionController({
1125
- mapKey,
1126
- key,
1127
- input: args.input,
1128
- updateExisting: args.updateExisting,
1129
- });
1130
- if (existingController) {
1131
- if (this.disposePromise !== null) {
1132
- throw new ToolkitCoordinatorDisposedError();
1234
+ let existingController;
1235
+ try {
1236
+ existingController = await this.resolveExistingSectionController({
1237
+ mapKey,
1238
+ key,
1239
+ input: args.input,
1240
+ updateExisting: args.updateExisting,
1241
+ });
1242
+ if (existingController) {
1243
+ if (this.disposePromise !== null) {
1244
+ throw new ToolkitCoordinatorDisposedError();
1245
+ }
1246
+ if (args.initialSession) {
1247
+ await this.assignSessionToPublishedController(existingController, args.initialSession);
1248
+ }
1133
1249
  }
1134
- return existingController;
1135
1250
  }
1251
+ catch (err) {
1252
+ this.deliverSectionStartError(mapKey, err);
1253
+ throw err;
1254
+ }
1255
+ if (existingController)
1256
+ return existingController;
1136
1257
  const pendingDisposal = this.sectionControllerDisposePromises.get(mapKey);
1137
1258
  if (pendingDisposal) {
1138
1259
  // Persistence and hydration share the cohort's durable state. Keep the
@@ -1145,8 +1266,13 @@ export class ToolkitCoordinator {
1145
1266
  throw new ToolkitCoordinatorDisposedError();
1146
1267
  }
1147
1268
  const existingEntry = this.sectionControllerInitEntries.get(mapKey);
1148
- if (existingEntry)
1149
- return existingEntry.promise;
1269
+ if (existingEntry) {
1270
+ if (!args.initialSession)
1271
+ return existingEntry.promise;
1272
+ const pendingController = await existingEntry.promise;
1273
+ await this.assignSessionToPublishedController(pendingController, args.initialSession);
1274
+ return pendingController;
1275
+ }
1150
1276
  const token = {
1151
1277
  retired: false,
1152
1278
  candidateClaimed: false,
@@ -1159,6 +1285,7 @@ export class ToolkitCoordinator {
1159
1285
  })
1160
1286
  .catch((err) => {
1161
1287
  this.handleSectionControllerInitError(err, args);
1288
+ this.deliverSectionStartError(mapKey, err);
1162
1289
  throw err;
1163
1290
  })
1164
1291
  .finally(() => {
@@ -1239,7 +1366,20 @@ export class ToolkitCoordinator {
1239
1366
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1240
1367
  await controller.initialize?.(args.args.input);
1241
1368
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1242
- await controller.hydrate?.();
1369
+ // A session the host supplied takes the place of the strategy's stored
1370
+ // snapshot, and is applied before publication so the ready lifecycle
1371
+ // event and every reader after it see it. The strategy still receives
1372
+ // every `persist()`.
1373
+ const initialSession = args.args.initialSession;
1374
+ if (initialSession) {
1375
+ if (!controller.applySession) {
1376
+ throw new Error("Section controller cannot apply the session it was created with: it has no applySession.");
1377
+ }
1378
+ await controller.applySession(initialSession, { mode: "replace" });
1379
+ }
1380
+ else {
1381
+ await controller.hydrate?.();
1382
+ }
1243
1383
  await this.finalizeSectionControllerReady({
1244
1384
  ...candidate,
1245
1385
  context,
@@ -1251,6 +1391,22 @@ export class ToolkitCoordinator {
1251
1391
  throw error;
1252
1392
  }
1253
1393
  }
1394
+ /**
1395
+ * Apply a host-supplied session to a controller that is already published,
1396
+ * under the rules `resolveSectionSessionAssignment` sets: an equal session is a
1397
+ * no-op and recorded responses are not replaced by response-free ones.
1398
+ */
1399
+ async assignSessionToPublishedController(controller, session) {
1400
+ if (!session)
1401
+ return;
1402
+ if (!controller.applySession) {
1403
+ throw new Error("Section controller cannot apply the assigned session: it has no applySession.");
1404
+ }
1405
+ const resolved = resolveSectionSessionAssignment(controller.getSession?.() ?? null, session);
1406
+ if (!resolved)
1407
+ return;
1408
+ await controller.applySession(resolved, { mode: "replace" });
1409
+ }
1254
1410
  createSectionControllerRetirementError() {
1255
1411
  return this.disposePromise !== null
1256
1412
  ? new ToolkitCoordinatorDisposedError()
@@ -1276,6 +1432,7 @@ export class ToolkitCoordinator {
1276
1432
  sectionId: args.key.sectionId,
1277
1433
  attemptId: args.key.attemptId,
1278
1434
  },
1435
+ recoverable: true,
1279
1436
  });
1280
1437
  }
1281
1438
  finally {
@@ -1331,7 +1488,33 @@ export class ToolkitCoordinator {
1331
1488
  sectionId: args.sectionId,
1332
1489
  attemptId: args.attemptId,
1333
1490
  },
1334
- });
1491
+ recoverable: false,
1492
+ }, "cohort");
1493
+ }
1494
+ /**
1495
+ * A section that fails to start has no controller of its own to emit its
1496
+ * `section-error`, and the host's subscriptions are already off the outgoing
1497
+ * section, so the coordinator delivers the error to them while that section
1498
+ * is still the one requested.
1499
+ */
1500
+ deliverSectionStartError(mapKey, error) {
1501
+ if (error instanceof ToolkitCoordinatorDisposedError ||
1502
+ error instanceof SectionControllerRetiredError ||
1503
+ this.latestRequestedActiveCohortMapKey !== mapKey) {
1504
+ return;
1505
+ }
1506
+ const event = {
1507
+ type: "section-error",
1508
+ source: "section-runtime",
1509
+ error,
1510
+ currentItemIndex: 0,
1511
+ timestamp: Date.now(),
1512
+ };
1513
+ for (const sub of Array.from(this.activeSubscriptions.values())) {
1514
+ if (this.buildSectionEventPredicate(sub)(event)) {
1515
+ this.deliverSectionEventSafely(sub.listener, event);
1516
+ }
1517
+ }
1335
1518
  }
1336
1519
  disposeSectionController(args) {
1337
1520
  const key = {
@@ -1418,6 +1601,7 @@ export class ToolkitCoordinator {
1418
1601
  sectionId: args.args.sectionId,
1419
1602
  attemptId: args.args.attemptId,
1420
1603
  },
1604
+ recoverable: true,
1421
1605
  });
1422
1606
  }
1423
1607
  finally {
@@ -1517,7 +1701,7 @@ export class ToolkitCoordinator {
1517
1701
  this.activeSubscriptions.clear();
1518
1702
  this.activeCohortMapKey = null;
1519
1703
  this.latestRequestedActiveCohortMapKey = null;
1520
- await cleanup(() => this.ttsService.stop());
1704
+ await cleanup(() => this.ttsService.dispose());
1521
1705
  await cleanup(() => this.toolProviderRegistry.destroy());
1522
1706
  await cleanup(() => this.highlightCoordinator.destroy());
1523
1707
  for (const toolId of this.toolCoordinator.getRegisteredTools()) {
@@ -1571,6 +1755,7 @@ export class ToolkitCoordinator {
1571
1755
  .then(() => this.assertNotDisposed())
1572
1756
  .finally(() => {
1573
1757
  this.ttsInitPromise = undefined;
1758
+ this.notifyReadyChange();
1574
1759
  });
1575
1760
  return this.ttsInitPromise;
1576
1761
  }
@@ -1604,9 +1789,10 @@ export class ToolkitCoordinator {
1604
1789
  backend: resolvedBackend,
1605
1790
  });
1606
1791
  // Try to use TTS provider from registry if available
1607
- if (this.toolProviderRegistry.has("tts")) {
1792
+ if (this.toolProviderRegistry.has("textToSpeech")) {
1608
1793
  try {
1609
- const ttsProvider = await this.ensureProviderReady("tts");
1794
+ // Browser speech follows a server provider that fails to start.
1795
+ const ttsProvider = await this.initializeProvider("textToSpeech", true);
1610
1796
  const providerInstance = await ttsProvider.createInstance();
1611
1797
  await this.initializeTTSService(providerInstance, runtimeTTSConfig);
1612
1798
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
@@ -1618,25 +1804,14 @@ export class ToolkitCoordinator {
1618
1804
  backend: resolvedBackend,
1619
1805
  provider: "registry",
1620
1806
  });
1621
- console.log("[ToolkitCoordinator] TTS initialized via ToolProviderRegistry");
1622
1807
  return;
1623
1808
  }
1624
1809
  catch (error) {
1625
1810
  if (error instanceof ToolkitCoordinatorDisposedError)
1626
1811
  throw error;
1627
1812
  const normalized = error instanceof Error ? error : new Error(String(error));
1628
- await this.emitTelemetry("pie-tool-init-error", {
1629
- toolId: "textToSpeech",
1630
- providerId: "tts",
1631
- operation: "tts-init",
1632
- backend: resolvedBackend,
1633
- errorType: "TTSRegistryInitError",
1634
- message: normalized.message,
1635
- recovered: true,
1636
- });
1637
1813
  await this.emitTelemetry("pie-tool-init-fallback", {
1638
1814
  toolId: "textToSpeech",
1639
- providerId: "tts",
1640
1815
  operation: "tts-init",
1641
1816
  backend: resolvedBackend,
1642
1817
  fromProvider: "registry",
@@ -1649,10 +1824,13 @@ export class ToolkitCoordinator {
1649
1824
  else if (resolvedBackend !== "browser") {
1650
1825
  this.reportMissingTTSProvider(resolvedBackend);
1651
1826
  }
1652
- // Fallback to browser provider
1827
+ // Browser speech, configured or standing in for a backend that has no
1828
+ // provider or failed to start.
1653
1829
  const provider = new BrowserTTSProvider();
1654
1830
  try {
1655
- await this.initializeTTSService(provider, runtimeTTSConfig);
1831
+ await this.initializeTTSService(provider, resolvedBackend === "browser"
1832
+ ? runtimeTTSConfig
1833
+ : browserFallbackConfig(runtimeTTSConfig));
1656
1834
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
1657
1835
  provider: "browser-fallback",
1658
1836
  });
@@ -1667,7 +1845,7 @@ export class ToolkitCoordinator {
1667
1845
  if (error instanceof ToolkitCoordinatorDisposedError)
1668
1846
  throw error;
1669
1847
  const normalized = error instanceof Error ? error : new Error(String(error));
1670
- this.handleError(normalized, { phase: "tts-init" });
1848
+ this.reportToolFailure(normalized, { phase: "tts-init" }, ["textToSpeech"]);
1671
1849
  await this.emitTelemetry("pie-toolkit-tts-init-error", {
1672
1850
  message: normalized.message,
1673
1851
  });
@@ -1686,7 +1864,7 @@ export class ToolkitCoordinator {
1686
1864
  }
1687
1865
  async initializeTTSService(provider, config) {
1688
1866
  const nextProviderOptions = {
1689
- ...((config.providerOptions || {}) || {}),
1867
+ ...config.providerOptions,
1690
1868
  __pieTelemetry: async (eventName, payload) => {
1691
1869
  await this.emitTelemetry(eventName, {
1692
1870
  toolId: "textToSpeech",
@@ -1699,93 +1877,14 @@ export class ToolkitCoordinator {
1699
1877
  providerOptions: nextProviderOptions,
1700
1878
  };
1701
1879
  await this.ttsService.initialize(provider, nextConfig);
1702
- await this.ensureBrowserVoicesReady(provider);
1703
1880
  this.assertNotDisposed();
1704
1881
  this.ttsService.setCatalogResolver(this.catalogResolver);
1705
1882
  this.ttsInitialized = true;
1883
+ this.ttsDegraded = false;
1884
+ this.degradedTools.delete("textToSpeech");
1706
1885
  await this.hooks.onTTSReady?.();
1707
1886
  this.assertNotDisposed();
1708
1887
  }
1709
- async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
1710
- if (provider.providerId !== "browser")
1711
- return;
1712
- if (typeof window === "undefined")
1713
- return;
1714
- if (!("speechSynthesis" in window))
1715
- return;
1716
- const synth = window.speechSynthesis;
1717
- const voices = synth.getVoices();
1718
- if (voices.length > 0)
1719
- return;
1720
- await new Promise((resolve) => {
1721
- let settled = false;
1722
- const canUseEventTarget = typeof synth.addEventListener === "function" &&
1723
- typeof synth.removeEventListener === "function";
1724
- const canUseHandler = !canUseEventTarget && "onvoiceschanged" in synth;
1725
- const previousHandler = canUseHandler ? synth.onvoiceschanged : null;
1726
- let assignedHandler = false;
1727
- const finish = () => {
1728
- if (settled)
1729
- return;
1730
- settled = true;
1731
- window.clearTimeout(timeoutId);
1732
- if (canUseEventTarget) {
1733
- synth.removeEventListener("voiceschanged", onVoicesChanged);
1734
- }
1735
- else if (assignedHandler &&
1736
- synth.onvoiceschanged === onVoicesChanged) {
1737
- synth.onvoiceschanged = previousHandler;
1738
- }
1739
- resolve();
1740
- };
1741
- const onVoicesChanged = (event) => {
1742
- if (!canUseEventTarget &&
1743
- typeof previousHandler === "function" &&
1744
- event) {
1745
- previousHandler.call(synth, event);
1746
- }
1747
- finish();
1748
- };
1749
- const timeoutId = window.setTimeout(finish, timeoutMs);
1750
- if (canUseEventTarget) {
1751
- synth.addEventListener("voiceschanged", onVoicesChanged, {
1752
- once: true,
1753
- });
1754
- }
1755
- else if (canUseHandler) {
1756
- synth.onvoiceschanged = onVoicesChanged;
1757
- assignedHandler = true;
1758
- }
1759
- });
1760
- const voicesAfterWait = synth.getVoices();
1761
- await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
1762
- voiceCount: voicesAfterWait.length,
1763
- timedOut: voicesAfterWait.length === 0,
1764
- });
1765
- }
1766
- /**
1767
- * Get all services as a bundle for section player convenience.
1768
- * Section player can extract and pass services to child components.
1769
- */
1770
- getServiceBundle() {
1771
- return {
1772
- ttsService: this.ttsService,
1773
- toolCoordinator: this.toolCoordinator,
1774
- highlightCoordinator: this.highlightCoordinator,
1775
- elementToolStateStore: this.elementToolStateStore,
1776
- catalogResolver: this.catalogResolver,
1777
- toolProviderRegistry: this.toolProviderRegistry,
1778
- };
1779
- }
1780
- /**
1781
- * Get a tool provider from the registry
1782
- *
1783
- * @param providerId Provider identifier
1784
- * @returns Tool provider instance
1785
- */
1786
- async getToolProvider(providerId) {
1787
- return this.ensureProviderReady(providerId);
1788
- }
1789
1888
  async waitUntilReady() {
1790
1889
  this.assertNotDisposed();
1791
1890
  if (this.isReady())
@@ -1795,9 +1894,8 @@ export class ToolkitCoordinator {
1795
1894
  this.coordinatorReadyPromise = (async () => {
1796
1895
  await this.ensureStateLoaded();
1797
1896
  this.assertNotDisposed();
1798
- const ttsConfig = this.getTTSConfigFromProviders();
1799
- if (ttsConfig?.enabled !== false) {
1800
- await this.ensureTTSReady(ttsConfig);
1897
+ if (this.ttsRequiredForReadiness()) {
1898
+ await this.startTTSForReadiness();
1801
1899
  }
1802
1900
  this.assertNotDisposed();
1803
1901
  if (!this.coordinatorReadyNotified) {
@@ -1814,22 +1912,37 @@ export class ToolkitCoordinator {
1814
1912
  return this.coordinatorReadyPromise;
1815
1913
  }
1816
1914
  isReady() {
1817
- return this.getInitStatus().coordinator;
1915
+ return ((this.stateLoadSettled || !this.hooks.loadToolState) &&
1916
+ (this.ttsInitialized ||
1917
+ this.ttsDegraded ||
1918
+ !this.ttsRequiredForReadiness()));
1818
1919
  }
1819
- getInitStatus() {
1820
- const providers = {};
1821
- for (const providerId of this.toolProviderRegistry.getProviderIds()) {
1822
- providers[providerId] =
1823
- this.toolProviderRegistry.isInitialized(providerId);
1920
+ /**
1921
+ * Readiness waits for text-to-speech when it is enabled and either starts
1922
+ * eagerly or is granted: a granted read-aloud fails before the learner starts.
1923
+ */
1924
+ ttsRequiredForReadiness() {
1925
+ if (this.getTTSConfigFromProviders()?.enabled === false)
1926
+ return false;
1927
+ return !this.lazyInit || this.isToolGranted("textToSpeech");
1928
+ }
1929
+ /**
1930
+ * Starts text-to-speech for readiness. A failure policy does not grant leaves
1931
+ * the tool degraded, reporting itself unavailable, and readiness settles; a
1932
+ * granted tool's failure rejects.
1933
+ */
1934
+ async startTTSForReadiness() {
1935
+ try {
1936
+ await this.ensureTTSReady(this.getTTSConfigFromProviders());
1937
+ }
1938
+ catch (error) {
1939
+ if (error instanceof ToolkitCoordinatorDisposedError)
1940
+ throw error;
1941
+ if (this.isToolGranted("textToSpeech"))
1942
+ throw error;
1943
+ this.ttsDegraded = true;
1944
+ this.notifyReadyChange();
1824
1945
  }
1825
- const ttsConfig = this.getTTSConfigFromProviders();
1826
- return {
1827
- tts: this.ttsInitialized,
1828
- stateLoaded: this.stateLoaded || !this.hooks.loadToolState,
1829
- coordinator: (this.stateLoaded || !this.hooks.loadToolState) &&
1830
- (this.ttsInitialized || ttsConfig?.enabled === false),
1831
- providers,
1832
- };
1833
1946
  }
1834
1947
  getTTSConfigFromProviders() {
1835
1948
  return this.config.tools?.providers?.textToSpeech;
@@ -1838,9 +1951,6 @@ export class ToolkitCoordinator {
1838
1951
  if (typeof toolId !== "string" || toolId.trim().length === 0) {
1839
1952
  throw new Error("Tool id must be a non-empty string.");
1840
1953
  }
1841
- if (toolId === "tts") {
1842
- throw new Error(`Tool id "tts" is no longer supported. Use "textToSpeech".`);
1843
- }
1844
1954
  // An empty registry means the host supplied none, not that every id is
1845
1955
  // wrong. There is nothing to check an id against, and throwing turns a
1846
1956
  // host's every tool-config call into an exception — including the calls
@@ -1867,6 +1977,11 @@ export class ToolkitCoordinator {
1867
1977
  this.toolContextResolvers.set(toolId, resolver);
1868
1978
  }
1869
1979
  }
1980
+ /**
1981
+ * Replace all host-owned render-context resolvers. The toolkit element
1982
+ * calls this from `runtime.toolContextResolvers`; it is not on
1983
+ * `ToolkitCoordinatorApi`.
1984
+ */
1870
1985
  setToolContextResolvers(resolvers) {
1871
1986
  this.toolContextResolvers.clear();
1872
1987
  this.installToolContextResolvers(resolvers ?? undefined);
@@ -1882,19 +1997,6 @@ export class ToolkitCoordinator {
1882
1997
  }
1883
1998
  }
1884
1999
  }
1885
- /**
1886
- * Check if a tool is enabled.
1887
- * Tools are enabled by default unless explicitly disabled.
1888
- *
1889
- * @param toolId Tool identifier (e.g., 'textToSpeech', 'answerEliminator')
1890
- * @returns True if tool is enabled
1891
- */
1892
- isToolEnabled(toolId) {
1893
- this.assertCanonicalToolId(toolId);
1894
- const toolConfig = this.config.tools?.providers?.[toolId];
1895
- // Enabled by default unless explicitly set to false
1896
- return toolConfig?.enabled !== false;
1897
- }
1898
2000
  getToolConfig(toolId) {
1899
2001
  this.assertCanonicalToolId(toolId);
1900
2002
  return this.config.tools?.providers?.[toolId] || null;
@@ -1925,31 +2027,23 @@ export class ToolkitCoordinator {
1925
2027
  source: "ToolkitCoordinator.updateToolConfig",
1926
2028
  toolRegistry: this.toolRegistry,
1927
2029
  });
1928
- // M8 PR 2 — keep the policy engine's tools input in lockstep
1929
- // with the validated coordinator config. The engine emits an
1930
- // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
1931
- // re-decide without us managing a parallel pub/sub.
2030
+ // The engine's tools input follows the validated config; its `inputs`
2031
+ // change event is what makes toolbars re-decide.
1932
2032
  this.policyEngine.updateInputs({
1933
2033
  tools: this.config.tools,
1934
2034
  });
1935
2035
  void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
1936
2036
  // Apply configuration changes to services
1937
- this._applyToolConfigChange(toolId, current);
2037
+ this._applyToolConfigChange(toolId);
1938
2038
  }
1939
2039
  /**
1940
- * Update the enabled tool list for one placement level.
2040
+ * Patch one or more placement levels in the canonical tools config.
1941
2041
  *
1942
2042
  * This is the generic placement companion to {@link updateToolConfig}:
1943
2043
  * it validates the next canonical tools config, keeps the policy
1944
2044
  * engine in lockstep, and emits the same policy-change event used by
1945
2045
  * live toolbars/debug panels.
1946
2046
  */
1947
- updateToolPlacement(level, toolIds) {
1948
- this.updateToolsPlacement({ [level]: [...toolIds] });
1949
- }
1950
- /**
1951
- * Patch one or more placement levels in the canonical tools config.
1952
- */
1953
2047
  updateToolsPlacement(partial) {
1954
2048
  if (!this.config.tools) {
1955
2049
  this.config.tools = this.validateToolsConfig(undefined, {
@@ -1985,10 +2079,40 @@ export class ToolkitCoordinator {
1985
2079
  /**
1986
2080
  * Resolve the visible tool set for a given placement level + scope.
1987
2081
  *
1988
- * Thin shim over the owned tool-policy engine.
2082
+ * Delegates to the owned tool-policy engine and logs the decision's
2083
+ * diagnostics through {@link warnPolicyDiagnostics}.
1989
2084
  */
1990
2085
  decideToolPolicy(request) {
1991
- return this.policyEngine.decide(request);
2086
+ const decision = this.policyEngine.decide(request);
2087
+ this.warnPolicyDiagnostics(decision.diagnostics);
2088
+ return decision;
2089
+ }
2090
+ /**
2091
+ * Log each policy diagnostic as a console warning, once per code and tool
2092
+ * id, and per item for `tool-policy.itemSettingNotApplied`. Decisions re-run
2093
+ * on every input change, so a conflict would otherwise repeat on each.
2094
+ */
2095
+ warnPolicyDiagnostics(diagnostics) {
2096
+ for (const diagnostic of diagnostics) {
2097
+ const itemId = diagnostic.code === "tool-policy.itemSettingNotApplied"
2098
+ ? diagnostic.details.itemId
2099
+ : "";
2100
+ const key = `${diagnostic.code}\0${diagnostic.toolId}\0${itemId}`;
2101
+ if (this.warnedPolicyDiagnostics.has(key))
2102
+ continue;
2103
+ this.warnedPolicyDiagnostics.add(key);
2104
+ console.warn(`[ToolkitCoordinator] ${diagnostic.message}`);
2105
+ }
2106
+ }
2107
+ /**
2108
+ * File a mounted item's policy settings (`requiredTools`, `restrictedTools`,
2109
+ * `toolParameters`) under its canonical id. They govern the decisions scoped
2110
+ * to that item: its own item-level toolbar, and the feature decisions its
2111
+ * content asks with the item's scope. `pie-item-scope` registers them from
2112
+ * its `settings` property; the returned function withdraws them.
2113
+ */
2114
+ registerItemSettings(itemId, settings) {
2115
+ return this.policyEngine.registerItemSettings(itemId, settings);
1992
2116
  }
1993
2117
  /**
1994
2118
  * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
@@ -2008,9 +2132,13 @@ export class ToolkitCoordinator {
2008
2132
  * enforcement is explicitly `"on"`. Its toolkit binds whatever assessment the
2009
2133
  * host gave, usually none, and a host that passes no profile and leaves
2010
2134
  * enforcement unset or `"off"` has asked for no accommodation.
2135
+ *
2136
+ * `scope` is the surface asking; an item's scope brings in that item's
2137
+ * registered settings ({@link registerItemSettings}).
2011
2138
  */
2012
- decideFeaturePolicy(featureId) {
2013
- const decision = this.policyEngine.decideFeature(featureId);
2139
+ decideFeaturePolicy(featureId, scope) {
2140
+ const decision = this.policyEngine.decideFeature(featureId, scope);
2141
+ this.warnPolicyDiagnostics(decision.diagnostics);
2014
2142
  const unboundIsMisconfigured = this.config.assessmentOptional !== true ||
2015
2143
  this.pnpEnforcementOverride === "on";
2016
2144
  if (!decision.assessmentBound &&
@@ -2024,9 +2152,10 @@ export class ToolkitCoordinator {
2024
2152
  /**
2025
2153
  * Subscribe to policy-engine change events. Fires whenever the
2026
2154
  * coordinator's bound inputs change (`updateToolConfig`,
2027
- * `updateToolPlacement`, `updateAssessment`, `updateCurrentItemRef`,
2028
- * `setPnpEnforcement`) or a custom `PolicySource` is registered /
2029
- * removed via {@link registerPolicySource}.
2155
+ * `updateToolsPlacement`, `updateAssessment`, `setPnpEnforcement`), an
2156
+ * item's settings are registered or withdrawn ({@link registerItemSettings}),
2157
+ * or a custom `PolicySource` is registered / removed via
2158
+ * {@link registerPolicySource}.
2030
2159
  *
2031
2160
  * The listener receives a `ToolPolicyChangeEvent` with the event
2032
2161
  * `reason` and a frozen snapshot of the engine inputs. Listeners
@@ -2056,49 +2185,26 @@ export class ToolkitCoordinator {
2056
2185
  /**
2057
2186
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
2058
2187
  *
2059
- * Under auto-mode (no host override via {@link setPnpEnforcement}),
2060
- * the coordinator promotes the engine to `pnpEnforcement: "on"`
2061
- * iff the assessment carries any profile precedence material
2062
- * (`personalNeedsProfile`, `settings.districtPolicy`,
2063
- * `settings.testAdministration`) or the currently-bound item ref
2064
- * carries item-level profile policy inputs. A bare assessment record (just
2188
+ * Under auto-mode (no host override via {@link setPnpEnforcement}), a
2189
+ * decision enforces PNP/profile policy iff the assessment carries profile
2190
+ * precedence material (`personalNeedsProfile`, `settings.districtPolicy`,
2191
+ * `settings.testAdministration`), or the decision is scoped to an item whose
2192
+ * settings carry item-level policy inputs. A bare assessment record (just
2065
2193
  * `id` / `name`, no PNP, no settings) keeps `"off"`.
2066
2194
  *
2067
2195
  * The host override set via {@link setPnpEnforcement} is sticky
2068
- * across assessment swaps; calling with `null` clears the binding
2069
- * and re-runs the auto-mode helper.
2196
+ * across assessment swaps.
2070
2197
  */
2071
2198
  updateAssessment(assessment) {
2072
- this.boundAssessment = assessment;
2073
- this.policyEngine.updateInputs({
2074
- assessment,
2075
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2076
- });
2077
- }
2078
- /**
2079
- * Bind (or clear) the current item reference for policy decisions.
2080
- *
2081
- * Used by item-level profile gates (item `requiredTools` /
2082
- * `restrictedTools` / `toolParameters`). Item-level profile material
2083
- * also feeds {@link resolveEffectivePnpEnforcement} — navigating
2084
- * to an item with profile settings can flip auto-mode to `"on"` even
2085
- * when the parent assessment carries no profile block of its own.
2086
- */
2087
- updateCurrentItemRef(itemRef) {
2088
- this.boundCurrentItemRef = itemRef;
2089
- this.policyEngine.updateInputs({
2090
- currentItemRef: itemRef,
2091
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2092
- });
2199
+ this.policyEngine.updateInputs({ assessment });
2093
2200
  }
2094
2201
  /**
2095
- * Override the auto-mode PNP/profile enforcement decision.
2202
+ * Override the auto-mode PNP/profile enforcement decision; `null` returns
2203
+ * to auto-mode.
2096
2204
  */
2097
2205
  setPnpEnforcement(mode) {
2098
2206
  this.pnpEnforcementOverride = mode;
2099
- this.policyEngine.updateInputs({
2100
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2101
- });
2207
+ this.policyEngine.updateInputs({ pnpEnforcement: mode });
2102
2208
  }
2103
2209
  /**
2104
2210
  * Get the policy engine inputs currently driving decisions. Useful
@@ -2122,29 +2228,6 @@ export class ToolkitCoordinator {
2122
2228
  registerPolicySource(source) {
2123
2229
  return this.policyEngine.registerPolicySource(source);
2124
2230
  }
2125
- /**
2126
- * Register a host-owned resolver for one tool's render context.
2127
- *
2128
- * Resolvers are evaluated only for tools that already survived the
2129
- * framework policy pipeline. They can hide the tool for the current
2130
- * scope or attach render params consumed by the packaged registration;
2131
- * they cannot re-enable tools blocked by placement, provider config,
2132
- * host policy, or PNP/profile rules.
2133
- */
2134
- registerToolContextResolver(toolId, resolver) {
2135
- this.assertCanonicalToolId(toolId);
2136
- if (typeof resolver !== "function") {
2137
- throw new Error(`Invalid tool context resolver for "${toolId}": expected a function.`);
2138
- }
2139
- this.toolContextResolvers.set(toolId, resolver);
2140
- this.notifyToolContextResolverChange();
2141
- return () => {
2142
- if (this.toolContextResolvers.get(toolId) !== resolver)
2143
- return;
2144
- this.toolContextResolvers.delete(toolId);
2145
- this.notifyToolContextResolverChange();
2146
- };
2147
- }
2148
2231
  hasToolContextResolver(toolId) {
2149
2232
  this.assertCanonicalToolId(toolId);
2150
2233
  return this.toolContextResolvers.has(toolId);
@@ -2192,6 +2275,9 @@ export class ToolkitCoordinator {
2192
2275
  this.toolContextResolverChangeListeners.delete(listener);
2193
2276
  };
2194
2277
  }
2278
+ getToolRegistry() {
2279
+ return this.toolRegistry;
2280
+ }
2195
2281
  /**
2196
2282
  * Claim requests for one placement level. Called by a toolbar on mount.
2197
2283
  */
@@ -2215,95 +2301,50 @@ export class ToolkitCoordinator {
2215
2301
  * surface, and a host that swapped the registry for one without the tool would
2216
2302
  * otherwise lose the whole surface to an exception over an absent action.
2217
2303
  */
2218
- canRequestTool(toolId, level) {
2304
+ canRequestTool(toolId, level, scopeId) {
2219
2305
  try {
2220
2306
  this.assertCanonicalToolId(toolId);
2221
2307
  }
2222
2308
  catch {
2223
2309
  return false;
2224
2310
  }
2225
- return this.toolRequests.canRequest(toolId, level);
2311
+ return this.toolRequests.canRequest(toolId, level, scopeId);
2226
2312
  }
2227
2313
  onToolRequestTargetsChange(listener) {
2228
2314
  return this.toolRequests.onTargetsChange(listener);
2229
2315
  }
2230
- /**
2231
- * Compute the effective PNP/profile enforcement mode given the explicit
2232
- * host override and the auto-mode helper.
2233
- *
2234
- * Auto-mode (no override) defers to
2235
- * {@link resolveDefaultPnpEnforcement}, which returns `"on"`
2236
- * exactly when the bound assessment or current item ref carries
2237
- * profile precedence material (PNP, district policy, test
2238
- * administration, item-level required/restricted/parameters), and
2239
- * `"off"` otherwise. Profile gates engage the moment profile material
2240
- * is present.
2241
- */
2242
- resolveEffectivePnpEnforcement() {
2243
- if (this.pnpEnforcementOverride !== null) {
2244
- return this.pnpEnforcementOverride;
2245
- }
2246
- return resolveDefaultPnpEnforcement({
2247
- assessment: this.boundAssessment,
2248
- currentItemRef: this.boundCurrentItemRef,
2249
- });
2250
- }
2251
2316
  resolveConfiguredPnpEnforcement() {
2252
2317
  const tools = this.config.tools;
2253
2318
  return tools?.pnpEnforcement ?? null;
2254
2319
  }
2255
2320
  /**
2256
2321
  * Apply tool configuration changes to underlying services.
2257
- * Called after updateToolConfig() with the tool's config before the update.
2322
+ * Called after updateToolConfig().
2258
2323
  */
2259
- _applyToolConfigChange(toolId, previousConfig) {
2324
+ _applyToolConfigChange(toolId) {
2260
2325
  if (this.disposePromise !== null)
2261
2326
  return;
2262
- // Apply configuration changes based on tool
2263
- switch (toolId) {
2264
- case "textToSpeech":
2265
- this.scheduleTTSReconfigure();
2266
- break;
2267
- case "answerEliminator":
2268
- // Future: Could notify answer eliminator tools of strategy change
2269
- break;
2270
- default:
2271
- this._registerChangedToolProvider(toolId, previousConfig);
2327
+ if (toolId === "textToSpeech") {
2328
+ this.scheduleTTSReconfigure();
2329
+ return;
2272
2330
  }
2331
+ this._registerChangedToolProvider(toolId);
2273
2332
  }
2274
2333
  /**
2275
- * Register the provider a tool's updated config names, and unregister the
2276
- * one its previous config named when the provider id changed, as
2277
- * {@link _reconfigureTTSProvider} does for `tts`. Registration completes
2278
- * before this returns, so a check on the policy change the update dispatched
2279
- * finds the new provider.
2334
+ * Register the provider a tool's updated config builds, replacing the one
2335
+ * registered under the tool's id. Registration completes before this returns,
2336
+ * so a check on the policy change the update dispatched finds the new provider.
2280
2337
  */
2281
- _registerChangedToolProvider(toolId, previousConfig) {
2338
+ _registerChangedToolProvider(toolId) {
2282
2339
  const registration = this.getProviderDescriptorTools().find((tool) => tool.toolId === toolId);
2283
2340
  if (!registration)
2284
2341
  return;
2285
- void this.registerProviderFromTool(registration);
2286
- const resolveQuietly = (config) => {
2287
- try {
2288
- return resolveToolProviderId(registration, config);
2289
- }
2290
- catch {
2291
- return null;
2292
- }
2293
- };
2294
- const previousId = resolveQuietly(previousConfig);
2295
- const nextId = resolveQuietly(this.getToolConfig(toolId) || undefined);
2296
- if (!previousId ||
2297
- previousId === nextId ||
2298
- !this.toolProviderRegistry.has(previousId)) {
2299
- return;
2300
- }
2301
- void this.toolProviderRegistry.unregister(previousId).catch((err) => {
2302
- console.warn(`[ToolkitCoordinator] Failed to unregister provider "${previousId}":`, err);
2303
- });
2342
+ // A failure of the replaced provider no longer describes the tool.
2343
+ this.degradedTools.delete(toolId);
2344
+ void this.registerProviderFromTool(registration, true);
2304
2345
  }
2305
2346
  /**
2306
- * Re-register the `tts` provider for the current config and registry, then
2347
+ * Re-register the text-to-speech provider for the current config and registry, then
2307
2348
  * re-initialize unless initialization is lazy. An initialization already
2308
2349
  * running finishes first: it would otherwise mark its provider ready after
2309
2350
  * the reset.
@@ -2319,26 +2360,31 @@ export class ToolkitCoordinator {
2319
2360
  this.ttsReconfigurePromise = undefined;
2320
2361
  }
2321
2362
  });
2322
- void reconfigurePromise.then(async () => {
2363
+ void reconfigurePromise
2364
+ .then(async () => {
2323
2365
  if (this.disposePromise !== null)
2324
2366
  return;
2325
- const ttsConfig = this.getTTSConfigFromProviders();
2326
- if (!this.lazyInit && ttsConfig?.enabled !== false) {
2327
- await this.ensureTTSReady(ttsConfig);
2328
- }
2329
- });
2367
+ if (this.ttsRequiredForReadiness())
2368
+ await this.startTTSForReadiness();
2369
+ })
2370
+ // Reported where it failed; a granted failure has nowhere else to go.
2371
+ .catch(() => { });
2330
2372
  }
2331
2373
  async _reconfigureTTSProvider() {
2332
2374
  this.ttsInitialized = false;
2375
+ this.ttsDegraded = false;
2376
+ this.degradedTools.delete("textToSpeech");
2333
2377
  this.ttsInitPromise = undefined;
2378
+ this.notifyReadyChange();
2334
2379
  try {
2335
- this.ttsService.stop();
2380
+ // The next speak waits on readiness, which starts the new provider.
2381
+ this.ttsService.releaseProvider();
2336
2382
  }
2337
2383
  catch {
2338
- // noop: stop best effort
2384
+ // noop: release best effort
2339
2385
  }
2340
- if (this.toolProviderRegistry.has("tts")) {
2341
- await this.toolProviderRegistry.unregister("tts");
2386
+ if (this.toolProviderRegistry.has("textToSpeech")) {
2387
+ await this.toolProviderRegistry.unregister("textToSpeech");
2342
2388
  }
2343
2389
  if (this.disposePromise !== null)
2344
2390
  return;