@pie-players/pie-assessment-toolkit 0.3.65 → 0.3.66

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 (43) hide show
  1. package/README.md +36 -16
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +9 -8
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/{ItemToolBar-cckwpz6c.js → ItemToolBar-8jgdz50p.js} +9 -9
  6. package/dist/components/chunks/ItemToolBar-cvs646j3.js +36 -0
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +1 -1
  9. package/dist/policy/core/PolicySource.d.ts +2 -2
  10. package/dist/policy/core/PolicySource.js +2 -2
  11. package/dist/policy/core/ToolPolicyEngine.d.ts +7 -3
  12. package/dist/policy/core/ToolPolicyEngine.js +11 -3
  13. package/dist/policy/core/compose-decision.d.ts +3 -2
  14. package/dist/policy/core/compose-decision.js +3 -2
  15. package/dist/policy/core/decision-types.d.ts +2 -2
  16. package/dist/policy/core/decision-types.js +2 -2
  17. package/dist/policy/core/feature-decision.d.ts +28 -1
  18. package/dist/policy/core/feature-decision.js +25 -3
  19. package/dist/policy/core/pnp-policy-inputs.d.ts +2 -2
  20. package/dist/policy/core/pnp-policy-inputs.js +2 -2
  21. package/dist/policy/core/provenance.d.ts +4 -1
  22. package/dist/policy/core/provenance.js +4 -1
  23. package/dist/policy/sources/PnpPolicySource.d.ts +2 -1
  24. package/dist/policy/sources/PnpPolicySource.js +2 -1
  25. package/dist/runtime/core/engine-transition.js +3 -1
  26. package/dist/services/AccessibilityCatalogResolver.d.ts +49 -10
  27. package/dist/services/AccessibilityCatalogResolver.js +180 -11
  28. package/dist/services/TTSService.d.ts +11 -0
  29. package/dist/services/TTSService.js +220 -32
  30. package/dist/services/ToolRegistry.d.ts +55 -14
  31. package/dist/services/ToolRegistry.js +57 -3
  32. package/dist/services/ToolkitCoordinator.d.ts +18 -1
  33. package/dist/services/ToolkitCoordinator.js +24 -2
  34. package/dist/services/catalog-owner.d.ts +71 -0
  35. package/dist/services/catalog-owner.js +64 -0
  36. package/dist/services/framework-error.d.ts +1 -1
  37. package/dist/services/interfaces.d.ts +15 -3
  38. package/dist/services/tts/browser-provider.js +189 -23
  39. package/dist/tools/internal.d.ts +1 -0
  40. package/package.json +9 -9
  41. package/dist/components/chunks/ItemToolBar-pryf0rtz.js +0 -22
  42. package/dist/runtime/catalog-registration.d.ts +0 -67
  43. package/dist/runtime/catalog-registration.js +0 -86
@@ -97,6 +97,13 @@ export class TTSService {
97
97
  sentenceHighlightSegments = [];
98
98
  currentSeekSegmentIndex = 0;
99
99
  activeSentenceStartOffset = null;
100
+ playbackStartDeferredRunId = null;
101
+ pendingPlaybackStartHighlights = [];
102
+ playbackStartBarrierCleanup = null;
103
+ playbackRateWriteQueue = Promise.resolve();
104
+ playbackRateRequestId = 0;
105
+ pendingPlaybackRateRequest = null;
106
+ activePlaybackRate = null;
100
107
  activeHighlightMode = "word";
101
108
  lastRenderedRegionTarget = null;
102
109
  telemetryReporter = null;
@@ -130,6 +137,61 @@ export class TTSService {
130
137
  console.debug(message, detail);
131
138
  }
132
139
  }
140
+ clearPlaybackStartBarrier(cleanup = this.playbackStartBarrierCleanup) {
141
+ cleanup?.();
142
+ if (this.playbackStartBarrierCleanup === cleanup) {
143
+ this.playbackStartBarrierCleanup = null;
144
+ }
145
+ }
146
+ notifyPlaybackStarted(runId) {
147
+ if (this.playbackStartDeferredRunId !== runId ||
148
+ runId !== this.speakRunId) {
149
+ return;
150
+ }
151
+ this.setState(PlaybackState.PLAYING);
152
+ const pendingHighlights = this.pendingPlaybackStartHighlights.splice(0);
153
+ for (const applyHighlight of pendingHighlights) {
154
+ applyHighlight();
155
+ }
156
+ }
157
+ installPlaybackStartBarrier(runId, hasMediaStartSignal = false) {
158
+ this.clearPlaybackStartBarrier();
159
+ const provider = this.provider;
160
+ const startAwareProvider = provider && "onPlaybackStart" in provider ? provider : null;
161
+ if (!startAwareProvider && !hasMediaStartSignal)
162
+ return null;
163
+ const previousOnPlaybackStart = startAwareProvider?.onPlaybackStart;
164
+ const onPlaybackStart = () => {
165
+ try {
166
+ this.notifyPlaybackStarted(runId);
167
+ }
168
+ finally {
169
+ previousOnPlaybackStart?.();
170
+ }
171
+ };
172
+ if (startAwareProvider) {
173
+ startAwareProvider.onPlaybackStart = onPlaybackStart;
174
+ }
175
+ this.playbackStartDeferredRunId = runId;
176
+ const cleanup = () => {
177
+ if (startAwareProvider?.onPlaybackStart === onPlaybackStart) {
178
+ startAwareProvider.onPlaybackStart = previousOnPlaybackStart;
179
+ }
180
+ if (this.playbackStartDeferredRunId === runId) {
181
+ this.playbackStartDeferredRunId = null;
182
+ this.pendingPlaybackStartHighlights = [];
183
+ }
184
+ };
185
+ this.playbackStartBarrierCleanup = cleanup;
186
+ return cleanup;
187
+ }
188
+ runWhenPlaybackStarts(runId, callback) {
189
+ if (this.playbackStartDeferredRunId === runId) {
190
+ this.pendingPlaybackStartHighlights.push(callback);
191
+ return;
192
+ }
193
+ callback();
194
+ }
133
195
  async emitTelemetry(eventName, payload) {
134
196
  try {
135
197
  await this.telemetryReporter?.(eventName, payload);
@@ -810,7 +872,9 @@ export class TTSService {
810
872
  }
811
873
  this.currentBoundaryOffset = segment.startOffset;
812
874
  if (shouldTrackSentenceProgress) {
813
- this.highlightSentenceSegment(segment.startOffset, segment.text);
875
+ this.runWhenPlaybackStarts(runId, () => {
876
+ this.highlightSentenceSegment(segment.startOffset, segment.text);
877
+ });
814
878
  }
815
879
  await this.provider.speak(segment.text);
816
880
  const pauseMs = segment.pauseMsAfter ?? 0;
@@ -923,6 +987,8 @@ export class TTSService {
923
987
  throw new Error("TTS service not initialized");
924
988
  }
925
989
  const runId = ++this.speakRunId;
990
+ this.clearPlaybackStartBarrier();
991
+ let playbackStartBarrier = null;
926
992
  try {
927
993
  await this.applyLanguageSettings(options);
928
994
  if (runId !== this.speakRunId)
@@ -962,11 +1028,13 @@ export class TTSService {
962
1028
  playbackSegments: this.seekSegments,
963
1029
  });
964
1030
  this.setState(PlaybackState.LOADING);
1031
+ playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!this.playbackChunks[0]?.audio);
965
1032
  this.prepareHighlightsForSpeak({
966
1033
  contentToSpeak: highlightText,
967
1034
  options,
968
1035
  highlightMode,
969
1036
  shouldUsePlan,
1037
+ runId,
970
1038
  });
971
1039
  if (speechMatchesVisibleText) {
972
1040
  this.configureWordBoundaryHighlighting({
@@ -977,7 +1045,10 @@ export class TTSService {
977
1045
  else {
978
1046
  this.clearWordBoundaryHighlighting();
979
1047
  }
980
- this.setState(PlaybackState.PLAYING);
1048
+ if (!playbackStartBarrier) {
1049
+ this.setState(PlaybackState.PLAYING);
1050
+ }
1051
+ this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
981
1052
  await this.executeSpeakPlayback({
982
1053
  shouldUsePlan,
983
1054
  runId,
@@ -1001,13 +1072,30 @@ export class TTSService {
1001
1072
  this.clearHighlightsAndTracking();
1002
1073
  throw finalError;
1003
1074
  }
1075
+ finally {
1076
+ this.clearPlaybackStartBarrier(playbackStartBarrier);
1077
+ }
1004
1078
  }
1005
1079
  async switchProviderToBrowser(reason, context) {
1006
1080
  if (!this.currentProvider)
1007
1081
  return false;
1008
1082
  const fallbackProvider = new BrowserTTSProvider();
1009
1083
  const previousProviderId = this.currentProvider.providerId;
1010
- const browserConfig = { ...this.ttsConfig };
1084
+ // Voice identifiers and provider extensions belong to the provider that
1085
+ // rejected initialization. Carry only settings whose meaning is portable
1086
+ // when crossing the provider boundary; otherwise a Polly/Google/SC voice
1087
+ // such as "Joanna" can incorrectly become an explicit Browser voice.
1088
+ const browserConfig = {};
1089
+ if (this.ttsConfig.rate !== undefined) {
1090
+ browserConfig.rate = this.ttsConfig.rate;
1091
+ }
1092
+ if (this.ttsConfig.pitch !== undefined) {
1093
+ browserConfig.pitch = this.ttsConfig.pitch;
1094
+ }
1095
+ if (this.ttsConfig.mathTokenHighlighting !== undefined) {
1096
+ browserConfig.mathTokenHighlighting =
1097
+ this.ttsConfig.mathTokenHighlighting;
1098
+ }
1011
1099
  const operation = context?.operation || "tts-initialize";
1012
1100
  const offendingText = context?.contentToSpeak ?? "";
1013
1101
  const offendingPreview = offendingText
@@ -1431,20 +1519,22 @@ export class TTSService {
1431
1519
  return;
1432
1520
  }
1433
1521
  const initialSegment = this.sentenceHighlightSegments[0] || this.seekSegments[0];
1434
- if (initialSegment) {
1435
- this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
1436
- this.debugLog("[TTSService] Applied initial sentence highlighting");
1437
- }
1438
- else {
1439
- try {
1440
- const range = document.createRange();
1441
- range.selectNodeContents(this.currentContentElement);
1442
- this.paintTTSSentenceRanges([range]);
1522
+ this.runWhenPlaybackStarts(args.runId, () => {
1523
+ if (initialSegment) {
1524
+ this.highlightSentenceSegment(initialSegment.startOffset, initialSegment.text);
1525
+ this.debugLog("[TTSService] Applied initial sentence highlighting");
1443
1526
  }
1444
- catch {
1445
- // No-op fallback when DOM range creation fails.
1527
+ else {
1528
+ try {
1529
+ const range = document.createRange();
1530
+ range.selectNodeContents(this.currentContentElement);
1531
+ this.paintTTSSentenceRanges([range]);
1532
+ }
1533
+ catch {
1534
+ // No-op fallback when DOM range creation fails.
1535
+ }
1446
1536
  }
1447
- }
1537
+ });
1448
1538
  }
1449
1539
  configureWordBoundaryHighlighting(args) {
1450
1540
  if (!this.provider ||
@@ -1605,6 +1695,16 @@ export class TTSService {
1605
1695
  // fallback, so this cannot recurse.
1606
1696
  if (chunk.plainFallback && runId === this.speakRunId) {
1607
1697
  this.debugLog("[TTSService] SSML chunk speak failed; retrying plain text", { message: error instanceof Error ? error.message : String(error) });
1698
+ // A recorded-first run can own the start barrier even when its fallback
1699
+ // provider has no formal start signal. Drop the failed recording's queued
1700
+ // highlight and resume that non-start-aware provider's immediate-start lifecycle.
1701
+ if (this.playbackStartDeferredRunId === runId &&
1702
+ this.provider &&
1703
+ !("onPlaybackStart" in this.provider)) {
1704
+ this.pendingPlaybackStartHighlights = [];
1705
+ this.clearPlaybackStartBarrier();
1706
+ this.setState(PlaybackState.PLAYING);
1707
+ }
1608
1708
  await this.speakCatalogChunkOnce(chunk.plainFallback, runId);
1609
1709
  return;
1610
1710
  }
@@ -1621,7 +1721,7 @@ export class TTSService {
1621
1721
  * would highlight the wrong words confidently rather than the right region
1622
1722
  * vaguely.
1623
1723
  */
1624
- async playRecordedAudio(media) {
1724
+ async playRecordedAudio(media, onPlaybackStart) {
1625
1725
  if (typeof document === "undefined") {
1626
1726
  throw new Error("[tts] no document available to play recorded audio");
1627
1727
  }
@@ -1632,9 +1732,18 @@ export class TTSService {
1632
1732
  element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
1633
1733
  await new Promise((resolve, reject) => {
1634
1734
  let endGuard;
1735
+ let didStart = false;
1736
+ let settled = false;
1737
+ const markStarted = () => {
1738
+ if (settled || didStart)
1739
+ return;
1740
+ didStart = true;
1741
+ onPlaybackStart?.();
1742
+ };
1635
1743
  const cleanup = () => {
1636
1744
  element.removeEventListener("ended", onEnded);
1637
1745
  element.removeEventListener("error", onError);
1746
+ element.removeEventListener("playing", markStarted);
1638
1747
  if (endGuard !== undefined)
1639
1748
  clearInterval(endGuard);
1640
1749
  if (this.activeRecordedAudio?.element === element) {
@@ -1642,10 +1751,16 @@ export class TTSService {
1642
1751
  }
1643
1752
  };
1644
1753
  const onEnded = () => {
1754
+ if (settled)
1755
+ return;
1756
+ settled = true;
1645
1757
  cleanup();
1646
1758
  resolve();
1647
1759
  };
1648
1760
  const onError = () => {
1761
+ if (settled)
1762
+ return;
1763
+ settled = true;
1649
1764
  cleanup();
1650
1765
  reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1651
1766
  };
@@ -1655,6 +1770,9 @@ export class TTSService {
1655
1770
  this.activeRecordedAudio = {
1656
1771
  element,
1657
1772
  cancel: () => {
1773
+ if (settled)
1774
+ return;
1775
+ settled = true;
1658
1776
  cleanup();
1659
1777
  element.pause();
1660
1778
  resolve();
@@ -1662,6 +1780,7 @@ export class TTSService {
1662
1780
  };
1663
1781
  element.addEventListener("ended", onEnded);
1664
1782
  element.addEventListener("error", onError);
1783
+ element.addEventListener("playing", markStarted);
1665
1784
  // Browsers honour a Media Fragments start offset but are inconsistent
1666
1785
  // about the end bound, so the end is enforced here — the same reason the
1667
1786
  // signing region enforces its own.
@@ -1672,7 +1791,7 @@ export class TTSService {
1672
1791
  onEnded();
1673
1792
  }, 100);
1674
1793
  }
1675
- Promise.resolve(element.play()).catch(onError);
1794
+ Promise.resolve(element.play()).then(markStarted).catch(onError);
1676
1795
  }).finally(() => {
1677
1796
  if (this.activeRecordedAudio?.element === element) {
1678
1797
  this.activeRecordedAudio = null;
@@ -1693,8 +1812,12 @@ export class TTSService {
1693
1812
  return;
1694
1813
  this.lastRenderedRegionTarget = null;
1695
1814
  if (chunk.audio) {
1696
- this.highlightCatalogRegion(chunk);
1697
- await this.playRecordedAudio(chunk.audio);
1815
+ this.runWhenPlaybackStarts(runId, () => {
1816
+ this.highlightCatalogRegion(chunk);
1817
+ });
1818
+ await this.playRecordedAudio(chunk.audio, () => {
1819
+ this.notifyPlaybackStarted(runId);
1820
+ });
1698
1821
  return;
1699
1822
  }
1700
1823
  const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
@@ -1713,12 +1836,14 @@ export class TTSService {
1713
1836
  mathTokenHighlighting,
1714
1837
  })
1715
1838
  : null;
1716
- if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
1717
- this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
1718
- }
1719
- else {
1720
- this.highlightCatalogRegion(chunk);
1721
- }
1839
+ this.runWhenPlaybackStarts(runId, () => {
1840
+ if (highlightPlan && pipelineChunk && chunk.mathAlignment) {
1841
+ this.renderHighlightDecision(highlightPlan.resolveInitial(pipelineChunk.id));
1842
+ }
1843
+ else {
1844
+ this.highlightCatalogRegion(chunk);
1845
+ }
1846
+ });
1722
1847
  const canUseChunkBoundaries = chunk.sourceElement &&
1723
1848
  ((chunk.mathAlignment && chunk.mathAlignment.speech.tokens.length > 0) ||
1724
1849
  (chunk.mathAlignments && chunk.mathAlignments.length > 0) ||
@@ -1805,6 +1930,8 @@ export class TTSService {
1805
1930
  }
1806
1931
  }
1807
1932
  clearHighlightsAndTracking() {
1933
+ this.pendingPlaybackStartHighlights = [];
1934
+ this.activePlaybackRate = null;
1808
1935
  if (this.highlightCoordinator) {
1809
1936
  this.highlightCoordinator.clearTTS();
1810
1937
  }
@@ -1923,13 +2050,21 @@ export class TTSService {
1923
2050
  ? this.playbackChunks.slice(safeTargetIndex)
1924
2051
  : null;
1925
2052
  this.speakRunId += 1;
2053
+ this.clearPlaybackStartBarrier();
1926
2054
  this.cancelRecordedAudio();
1927
2055
  this.provider.onWordBoundary = undefined;
1928
2056
  this.provider.stop();
1929
2057
  this.currentSeekSegmentIndex = safeTargetIndex;
1930
2058
  const runId = ++this.speakRunId;
1931
2059
  const restartSegments = this.seekSegments.slice(safeTargetIndex);
1932
- this.setState(PlaybackState.PLAYING);
2060
+ this.highlightCoordinator?.clearTTS();
2061
+ this.activeSentenceStartOffset = null;
2062
+ this.setState(PlaybackState.LOADING);
2063
+ const playbackStartBarrier = this.installPlaybackStartBarrier(runId, !!restartChunks?.[0]?.audio);
2064
+ if (!playbackStartBarrier) {
2065
+ this.setState(PlaybackState.PLAYING);
2066
+ }
2067
+ this.activePlaybackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
1933
2068
  try {
1934
2069
  this.configureWordBoundaryHighlighting({
1935
2070
  highlightMode: this.activeHighlightMode,
@@ -1968,17 +2103,68 @@ export class TTSService {
1968
2103
  this.highlightTargetResolverProvider = null;
1969
2104
  throw error;
1970
2105
  }
2106
+ finally {
2107
+ this.clearPlaybackStartBarrier(playbackStartBarrier);
2108
+ }
1971
2109
  }
1972
2110
  async setPlaybackRate(rate) {
2111
+ if (!this.provider) {
2112
+ throw new Error("TTSService not initialized. Call initialize() first.");
2113
+ }
1973
2114
  const nextRate = this.normalizePlaybackRate(rate);
1974
- await this.updateSettings({ rate: nextRate });
1975
- if (this.state !== PlaybackState.PLAYING ||
1976
- !this.currentText ||
1977
- this.seekSegments.length === 0 ||
1978
- this.hasExplicitBreakSemantics(this.currentText)) {
2115
+ const pendingRequest = this.pendingPlaybackRateRequest;
2116
+ if (pendingRequest?.rate === nextRate) {
2117
+ await pendingRequest.promise;
1979
2118
  return;
1980
2119
  }
1981
- await this.restartFromSeekIndex(this.getCurrentSeekSegmentIndex());
2120
+ const requestId = ++this.playbackRateRequestId;
2121
+ const settingsWrite = this.playbackRateWriteQueue.then(async () => {
2122
+ const playbackRunId = this.speakRunId;
2123
+ // A newer distinct target supersedes queued work before it mutates either
2124
+ // provider settings or playback. An already-running write cannot be
2125
+ // cancelled, so its post-write generation check still suppresses restart.
2126
+ if (requestId !== this.playbackRateRequestId) {
2127
+ return { didUpdate: false, playbackRunId };
2128
+ }
2129
+ // An omitted rate is the provider-default 1× rate. Re-check inside the
2130
+ // serialized operation so overlapping callers observe the preceding write.
2131
+ const currentRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate ?? 1));
2132
+ if (nextRate === currentRate) {
2133
+ return { didUpdate: false, playbackRunId };
2134
+ }
2135
+ await this.updateSettings({ rate: nextRate });
2136
+ return { didUpdate: true, playbackRunId };
2137
+ });
2138
+ this.playbackRateWriteQueue = settingsWrite.then(() => undefined, () => undefined);
2139
+ const operation = settingsWrite.then(async (result) => {
2140
+ if (requestId !== this.playbackRateRequestId ||
2141
+ result.playbackRunId !== this.speakRunId) {
2142
+ return;
2143
+ }
2144
+ const activePlaybackNeedsRate = this.activePlaybackRate !== null &&
2145
+ this.activePlaybackRate !== nextRate;
2146
+ if (!result.didUpdate && !activePlaybackNeedsRate)
2147
+ return;
2148
+ if ((this.state !== PlaybackState.PLAYING &&
2149
+ this.state !== PlaybackState.LOADING) ||
2150
+ !this.currentText ||
2151
+ this.seekSegments.length === 0 ||
2152
+ this.hasExplicitBreakSemantics(this.currentText)) {
2153
+ return;
2154
+ }
2155
+ await this.restartFromSeekIndex(this.getCurrentSeekSegmentIndex());
2156
+ });
2157
+ let trackedOperation;
2158
+ trackedOperation = operation.finally(() => {
2159
+ if (this.pendingPlaybackRateRequest?.promise === trackedOperation) {
2160
+ this.pendingPlaybackRateRequest = null;
2161
+ }
2162
+ });
2163
+ this.pendingPlaybackRateRequest = {
2164
+ rate: nextRate,
2165
+ promise: trackedOperation,
2166
+ };
2167
+ await trackedOperation;
1982
2168
  }
1983
2169
  async seekBy(units) {
1984
2170
  if (!this.provider || !this.currentText)
@@ -2015,6 +2201,7 @@ export class TTSService {
2015
2201
  if (!this.provider)
2016
2202
  return;
2017
2203
  this.speakRunId += 1;
2204
+ this.clearPlaybackStartBarrier();
2018
2205
  this.cancelRecordedAudio();
2019
2206
  this.provider.onWordBoundary = undefined;
2020
2207
  this.provider.stop();
@@ -2036,6 +2223,7 @@ export class TTSService {
2036
2223
  this.sentenceHighlightSegments = [];
2037
2224
  this.currentSeekSegmentIndex = 0;
2038
2225
  this.activeSentenceStartOffset = null;
2226
+ this.activePlaybackRate = null;
2039
2227
  }
2040
2228
  /**
2041
2229
  * Request UI-level TTS controls to hand off/deactivate.
@@ -4,8 +4,7 @@
4
4
  * Central registry for all assessment tools. Manages tool metadata, visibility logic,
5
5
  * and button/instance creation. Supports dynamic registration and override by integrators.
6
6
  */
7
- import type { ItemEntity } from "@pie-players/pie-players-shared/types";
8
- import type { CatalogOwnerContext } from "./AccessibilityCatalogResolver.js";
7
+ import type { CatalogOwnerSnapshot } from "./AccessibilityCatalogResolver.js";
9
8
  import type { ToolContext, ToolLevel } from "./tool-context.js";
10
9
  import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
11
10
  import type { AccessibilityCatalogResolverApi, ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
@@ -13,6 +12,13 @@ import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
13
12
  import type { ToolProviderConfig as ToolRuntimeConfig } from "./tools-config-normalizer.js";
14
13
  import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
15
14
  export type ToolModuleLoader = () => Promise<unknown>;
15
+ export type ToolRegistryChangeKind = "register" | "override" | "unregister" | "clear" | "component-overrides" | "module-loaders";
16
+ /** A successful mutation to discoverable or rendering registry state. */
17
+ export interface ToolRegistryChangeEvent {
18
+ kind: ToolRegistryChangeKind;
19
+ toolIds: readonly string[];
20
+ }
21
+ export type ToolRegistryChangeListener = (event: ToolRegistryChangeEvent) => void;
16
22
  export interface ToolToolbarButtonDefinition {
17
23
  toolId: string;
18
24
  label: string;
@@ -178,8 +184,8 @@ export interface ToolSurfaceServices {
178
184
  * `surface` is a host-defined slot name. Core defines none and validates only
179
185
  * that a region capability claims at least one, so a host can open a new surface
180
186
  * without a change here and a capability can declare which of a host's surfaces
181
- * it fits. Section-player ships `"content-media"` (the media region on an item
182
- * or passage card) and `"section-overlay"` (the section-scoped singleton).
187
+ * it fits. Section-player ships `"content-lead"` and `"content-media"` on item
188
+ * and passage cards, plus `"section-overlay"` at section scope.
183
189
  *
184
190
  * `content` carries whatever the capability's own `requiresAuthoredContent`
185
191
  * resolved, so the host neither inspects nor names it — it hands back what the
@@ -207,11 +213,23 @@ export interface ToolContentDependencyContext {
207
213
  featureId: string;
208
214
  /** Feature parameters from the policy decision, if any. */
209
215
  parameters?: unknown;
210
- catalogResolver: AccessibilityCatalogResolverApi | null;
211
- /** Owner scope for catalog lookups, without `modelId`. */
212
- ownerContext: CatalogOwnerContext;
213
- /** The item in scope, when the host renders per item. */
214
- item?: ItemEntity | null;
216
+ /**
217
+ * Owner-scoped catalog cards, or `null` when no resolver is available.
218
+ *
219
+ * The catalog module has already applied item/passage/model traversal and
220
+ * registration precedence. A capability interprets its own card type without
221
+ * reconstructing the owner scope or reading the raw entity.
222
+ */
223
+ catalogs: CatalogOwnerSnapshot | null;
224
+ /**
225
+ * Whether policy granted one of this capability's support ids.
226
+ *
227
+ * `false` reaches `resolve` only for a capability that declares
228
+ * {@link ToolRegistration.resolvesWithoutGrant}, and it is the signal that the
229
+ * capability must answer from the content alone. Everything else sees `true`,
230
+ * because the host asks about eligibility first and stops there.
231
+ */
232
+ granted: boolean;
215
233
  }
216
234
  /**
217
235
  * A capability's declaration that it needs authored content to have anything to
@@ -240,8 +258,8 @@ export interface ToolContentDependencyContext {
240
258
  * keeps the resolver and the host from knowing which accommodation they are
241
259
  * resolving.
242
260
  *
243
- * Resolvable only on a surface the host renders per item or per passage:
244
- * `ownerContext` names an item model or a passage, never a section, because a DRD
261
+ * Resolvable only on a surface the host renders per item or per passage. The
262
+ * owner snapshot represents an item/model or passage, never a section, because a DRD
245
263
  * resource pairs with a piece of content and not with a container. A capability
246
264
  * declaring one and claiming a section-scoped surface is declined there rather
247
265
  * than mounted with no content. `resolve` is also synchronous — a capability whose
@@ -255,9 +273,9 @@ export interface ToolContentDependency {
255
273
  * Must be JSON-serializable. A host re-resolves on every policy and catalog
256
274
  * signal and compares the answer structurally to decide whether anything moved,
257
275
  * because every resolution builds fresh objects and identity would report a
258
- * change each time. A `Map`, a function or a DOM node therefore compares equal
259
- * to itself across a real change and the capability never hears about it; a
260
- * cyclic value throws inside the host's own reconciliation.
276
+ * change each time. A `Map`, function, DOM node, cyclic value, or other
277
+ * non-serializable result is rejected for that capability and reported as a
278
+ * recoverable surface warning instead of escaping into the player.
261
279
  */
262
280
  resolve(context: ToolContentDependencyContext): unknown | null;
263
281
  /**
@@ -339,6 +357,22 @@ export interface ToolRegistration {
339
357
  * {@link ToolContentDependency}.
340
358
  */
341
359
  requiresAuthoredContent?: ToolContentDependency;
360
+ /**
361
+ * Ask this capability for its content even when policy granted nothing.
362
+ *
363
+ * Only meaningful together with {@link ToolRegistration.requiresAuthoredContent},
364
+ * and only correct for a capability whose authored content can declare itself
365
+ * *presentation* rather than an accommodation — content authored to be delivered
366
+ * that way to everyone, which no profile grants and none revokes. Such a
367
+ * capability must return `null` from `resolve` when its content is the
368
+ * accommodation kind and the grant is absent, and the `granted` flag on the
369
+ * context is how it tells the two apart.
370
+ *
371
+ * Without this, "no grant" ends the question before content is consulted, which
372
+ * is the right default: it is what keeps an accommodation off the item of a
373
+ * learner with no documented need.
374
+ */
375
+ resolvesWithoutGrant?: boolean;
342
376
  /**
343
377
  * Optional provider registration metadata.
344
378
  * When present, ToolkitCoordinator can register provider(s) generically
@@ -393,6 +427,13 @@ export declare class ToolRegistry {
393
427
  private moduleLoaders;
394
428
  private loadedToolModules;
395
429
  private moduleLoadPromises;
430
+ private changeListeners;
431
+ private emitChange;
432
+ /**
433
+ * Observe successful registry mutations. Delivery is synchronous, listener
434
+ * failures are isolated, and the returned unsubscribe is idempotent.
435
+ */
436
+ onRegistryChange(listener: ToolRegistryChangeListener): () => void;
396
437
  /**
397
438
  * Normalize a single tool alias to canonical toolId.
398
439
  */
@@ -93,6 +93,17 @@ function assertToolRegistrationShape(registration) {
93
93
  if (isRegion && typeof registration.renderSurface !== "function") {
94
94
  throw new Error(`Invalid tool registration "${registration.toolId}": region tools must implement "renderSurface".`);
95
95
  }
96
+ if (registration.resolvesWithoutGrant !== undefined &&
97
+ typeof registration.resolvesWithoutGrant !== "boolean") {
98
+ throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" must be a boolean.`);
99
+ }
100
+ if (registration.resolvesWithoutGrant &&
101
+ !registration.requiresAuthoredContent) {
102
+ // The flag only decides whether content is consulted without a grant, so on a
103
+ // capability with no content dependency it reads as "granted to everyone" and
104
+ // does nothing at all.
105
+ throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" requires "requiresAuthoredContent".`);
106
+ }
96
107
  if (registration.renderSurface !== undefined &&
97
108
  typeof registration.renderSurface !== "function") {
98
109
  throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" must be a function.`);
@@ -157,6 +168,31 @@ export class ToolRegistry {
157
168
  moduleLoaders = new Map();
158
169
  loadedToolModules = new Set();
159
170
  moduleLoadPromises = new Map();
171
+ changeListeners = new Set();
172
+ emitChange(event) {
173
+ for (const listener of this.changeListeners) {
174
+ try {
175
+ listener(event);
176
+ }
177
+ catch (error) {
178
+ console.warn("[ToolRegistry] change listener failed:", error);
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Observe successful registry mutations. Delivery is synchronous, listener
184
+ * failures are isolated, and the returned unsubscribe is idempotent.
185
+ */
186
+ onRegistryChange(listener) {
187
+ this.changeListeners.add(listener);
188
+ let subscribed = true;
189
+ return () => {
190
+ if (!subscribed)
191
+ return;
192
+ subscribed = false;
193
+ this.changeListeners.delete(listener);
194
+ };
195
+ }
160
196
  /**
161
197
  * Normalize a single tool alias to canonical toolId.
162
198
  */
@@ -190,6 +226,7 @@ export class ToolRegistry {
190
226
  this.pnpIndex.get(pnpId).add(registration.toolId);
191
227
  }
192
228
  }
229
+ this.emitChange({ kind: "register", toolIds: [registration.toolId] });
193
230
  }
194
231
  /**
195
232
  * Override an existing tool registration
@@ -219,6 +256,7 @@ export class ToolRegistry {
219
256
  this.pnpIndex.get(pnpId).add(registration.toolId);
220
257
  }
221
258
  }
259
+ this.emitChange({ kind: "override", toolIds: [registration.toolId] });
222
260
  }
223
261
  /**
224
262
  * Unregister a tool
@@ -236,6 +274,7 @@ export class ToolRegistry {
236
274
  }
237
275
  }
238
276
  this.tools.delete(toolId);
277
+ this.emitChange({ kind: "unregister", toolIds: [toolId] });
239
278
  }
240
279
  /**
241
280
  * Get a tool registration by ID
@@ -416,28 +455,43 @@ export class ToolRegistry {
416
455
  * Clear all registrations (useful for testing)
417
456
  */
418
457
  clear() {
458
+ const toolIds = this.getAllToolIds();
459
+ if (toolIds.length === 0)
460
+ return;
419
461
  this.tools.clear();
420
462
  this.pnpIndex.clear();
463
+ this.emitChange({ kind: "clear", toolIds });
421
464
  }
422
465
  /**
423
466
  * Configure global component overrides used by tool instance creation.
424
467
  */
425
468
  setComponentOverrides(overrides) {
469
+ if (this.componentOverrides === overrides)
470
+ return;
426
471
  this.componentOverrides = overrides;
472
+ this.emitChange({ kind: "component-overrides", toolIds: [] });
427
473
  }
428
474
  /**
429
475
  * Register lazy module loaders by toolId.
430
476
  * Toolbars call ensureToolModuleLoaded(toolId) before instance creation.
431
477
  */
432
478
  setToolModuleLoaders(loaders) {
433
- for (const [toolId, loader] of Object.entries(loaders)) {
434
- if (!loader)
435
- continue;
479
+ const entries = Object.entries(loaders).filter((entry) => entry[1] !== undefined);
480
+ for (const [toolId, loader] of entries) {
436
481
  assertNonEmptyString(toolId, "tool module loader id");
437
482
  if (typeof loader !== "function") {
438
483
  throw new Error(`Invalid tool module loader for "${toolId}": expected a function.`);
439
484
  }
485
+ }
486
+ const changedToolIds = [];
487
+ for (const [toolId, loader] of entries) {
488
+ if (this.moduleLoaders.get(toolId) === loader)
489
+ continue;
440
490
  this.moduleLoaders.set(toolId, loader);
491
+ changedToolIds.push(toolId);
492
+ }
493
+ if (changedToolIds.length > 0) {
494
+ this.emitChange({ kind: "module-loaders", toolIds: changedToolIds });
441
495
  }
442
496
  }
443
497
  /**