arcane-os 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -1,3 +1,8 @@
1
+ import {
2
+ createArcaneEventSource,
3
+ projectArcaneDOMEvent
4
+ } from 'arcane-os/event-manager';
5
+
1
6
  const htmlImportHostRegistryKey=Symbol.for('arcane.html-import.hosts');
2
7
  const htmlImportHostRegistry=globalThis[htmlImportHostRegistryKey] instanceof Map
3
8
  ?globalThis[htmlImportHostRegistryKey]
@@ -7,7 +12,24 @@ globalThis[htmlImportHostRegistryKey]=htmlImportHostRegistry;
7
12
 
8
13
  let htmlImportScriptId=0;
9
14
 
15
+ function samePropertyDescriptor(left,right){
16
+ if(!left||!right)return left===right;
17
+ return left.configurable===right.configurable
18
+ &&left.enumerable===right.enumerable
19
+ &&left.writable===right.writable
20
+ &&left.value===right.value
21
+ &&left.get===right.get
22
+ &&left.set===right.set;
23
+ }
24
+
10
25
  class HTMLImport extends HTMLElement {
26
+ #connectionGeneration=0;
27
+ #eventOwner=Object.freeze({kind:'html-import-lifecycle-owner'});
28
+ #events;
29
+ #importedDestroy=null;
30
+ #loadController=null;
31
+ #loadTask=Promise.resolve();
32
+
11
33
  ready=false;
12
34
 
13
35
  constructor() {
@@ -15,12 +37,40 @@ class HTMLImport extends HTMLElement {
15
37
  this.attachShadow({ mode: 'open' });
16
38
  }
17
39
 
18
- async connectedCallback() {
40
+ #createEvents(){
41
+ this.#events=createArcaneEventSource(this.#eventOwner,{
42
+ source:'html-import',
43
+ eventTypes:Object.freeze([
44
+ 'html-import-error',
45
+ 'html-import-ready'
46
+ ])
47
+ });
48
+ return this.#events;
49
+ }
50
+
51
+ connectedCallback() {
52
+ const generation=++this.#connectionGeneration;
53
+ const controller=new AbortController();
19
54
  this.ready=false;
55
+ this.#loadController?.abort('html-import-load-superseded');
56
+ this.#loadController=controller;
57
+ const previous=this.#loadTask;
58
+ const task=Promise.resolve(previous)
59
+ .catch(()=>{})
60
+ .then(()=>this.#connect(generation,controller));
61
+ this.#loadTask=task;
62
+ return task;
63
+ }
64
+
65
+ async #connect(generation,controller){
66
+ if(!this.#isCurrentConnection(generation,controller))return;
67
+ if(!this.#events||this.#events.disposed)this.#createEvents();
20
68
  const href=this.getAttribute('href');
21
69
  let resolvedHref='';
70
+ const operationId=`html-import-load-${this.#events.instanceId}-${generation}`;
22
71
  if(!href){
23
72
  console.log('no href provided for html-import tag',this);
73
+ this.#loadController=null;
24
74
  return;
25
75
  }
26
76
 
@@ -35,46 +85,167 @@ class HTMLImport extends HTMLElement {
35
85
  cache:'default',
36
86
  credentials:'same-origin',
37
87
  method:'GET',
38
- redirect:'error'
88
+ redirect:'error',
89
+ signal:controller.signal
39
90
  });
40
- await this.#loadHTML(href,resolvedHref,response);
91
+ if(!this.#isCurrentConnection(generation,controller))return;
92
+ await this.#loadHTML(href,resolvedHref,response,generation,controller,operationId);
41
93
  }catch(err){
94
+ if(!this.#isCurrentConnection(generation,controller)){
95
+ await this.#destroyImportedHost();
96
+ return;
97
+ }
42
98
  console.error('Error loading HTML component:',err);
43
- this.dispatchEvent(new CustomEvent('html-import-error',{
44
- bubbles:true,
45
- composed:true,
46
- detail:{
99
+ const detail=Object.freeze({
47
100
  code:'HTML_IMPORT_FAILED',
48
101
  href,
49
102
  message:'The component could not be loaded.',
103
+ reason:'component-import-rejected',
50
104
  resolvedHref
105
+ });
106
+ const {occurrence}=this.#events.dispatch(
107
+ 'html-import-error',
108
+ detail,
109
+ {
110
+ operationId,
111
+ publicDetail:Object.freeze({code:detail.code,reason:detail.reason})
51
112
  }
52
- }));
113
+ );
114
+ projectArcaneDOMEvent(this,occurrence,{
115
+ bubbles:true,
116
+ composed:true
117
+ });
118
+ await this.#destroyImportedHost();
119
+ }finally{
120
+ if(this.#loadController===controller)this.#loadController=null;
121
+ }
122
+ }
123
+
124
+ disconnectedCallback(){
125
+ this.ready=false;
126
+ ++this.#connectionGeneration;
127
+ this.#loadController?.abort('html-import-disconnected');
128
+ this.#loadController=null;
129
+ const events=this.#events;
130
+ const previous=this.#loadTask;
131
+ const immediateTeardown=this.#destroyImportedHost();
132
+ const task=Promise.allSettled([previous,immediateTeardown]).then(()=>{
133
+ events?.dispose();
134
+ if(this.#events===events)this.#events=null;
135
+ });
136
+ this.#loadTask=task;
137
+ return task;
138
+ }
139
+
140
+ #isCurrentConnection(generation,controller){
141
+ return this.isConnected
142
+ &&generation===this.#connectionGeneration
143
+ &&this.#loadController===controller
144
+ &&!controller.signal.aborted;
145
+ }
146
+
147
+ #reportImportedDestroyError(error,{events,href,operationId}){
148
+ console.error('Error destroying imported HTML component:',error);
149
+ if(!events||events.disposed)return;
150
+ const detail=Object.freeze({
151
+ code:'HTML_IMPORT_IMPORTED_COMPONENT_DESTROY_REJECTED',
152
+ href,
153
+ message:'The imported component could not be destroyed.',
154
+ reason:'imported-component-destroy-rejected'
155
+ });
156
+ const {occurrence}=events.dispatch(
157
+ 'html-import-error',
158
+ detail,
159
+ {
160
+ operationId,
161
+ publicDetail:Object.freeze({code:detail.code,reason:detail.reason})
162
+ }
163
+ );
164
+ projectArcaneDOMEvent(this,occurrence,{
165
+ bubbles:true,
166
+ composed:true
167
+ });
168
+ }
169
+
170
+ #restoreImportedDestroy(record){
171
+ const current=Object.getOwnPropertyDescriptor(this,'destroy')??null;
172
+ if(!samePropertyDescriptor(current,record.installedDescriptor))return false;
173
+ if(record.previousDescriptor){
174
+ Object.defineProperty(this,'destroy',record.previousDescriptor);
175
+ }else{
176
+ delete this.destroy;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ #destroyImportedHost(){
182
+ const record=this.#importedDestroy;
183
+ if(!record)return Promise.resolve(false);
184
+ this.#importedDestroy=null;
185
+ const events=this.#events;
186
+ const generation=this.#connectionGeneration;
187
+ const reportContext={
188
+ events,
189
+ href:this.getAttribute('href')||'',
190
+ operationId:events
191
+ ?`html-import-destroy-${events.instanceId}-${generation}`
192
+ :null
193
+ };
194
+ let result;
195
+ try{
196
+ result=record.destroy.call(this);
197
+ }catch(error){
198
+ this.#reportImportedDestroyError(error,reportContext);
199
+ this.#restoreImportedDestroy(record);
200
+ return Promise.resolve(false);
53
201
  }
202
+ return Promise.resolve(result)
203
+ .then(
204
+ ()=>true,
205
+ error=>{
206
+ this.#reportImportedDestroyError(error,reportContext);
207
+ return false;
208
+ }
209
+ )
210
+ .finally(()=>this.#restoreImportedDestroy(record));
54
211
  }
55
212
 
56
- async #loadHTML(href,resolvedHref,response){
213
+ async #loadHTML(href,resolvedHref,response,generation,controller,operationId){
57
214
  if (!response.ok) {
58
215
  throw new Error(`HTTP error! Status: ${response.status}`);
59
216
  }
60
217
 
61
218
  const html = await response.text();
219
+ if(!this.#isCurrentConnection(generation,controller))return false;
220
+ await this.#destroyImportedHost();
221
+ if(!this.#isCurrentConnection(generation,controller))return false;
62
222
  this.shadowRoot.innerHTML = html;
63
223
 
64
224
  await this.#executeScripts();
225
+ if(!this.#isCurrentConnection(generation,controller)){
226
+ await this.#destroyImportedHost();
227
+ return false;
228
+ }
65
229
 
66
230
  this.ready=true;
67
- this.dispatchEvent(new CustomEvent('html-import-ready',{
231
+ const {occurrence}=this.#events.dispatch(
232
+ 'html-import-ready',
233
+ Object.freeze({href,resolvedHref}),
234
+ {operationId,publicDetail:Object.freeze({ready:true})}
235
+ );
236
+ projectArcaneDOMEvent(this,occurrence,{
68
237
  bubbles:true,
69
- composed:true,
70
- detail:{href,resolvedHref}
71
- }));
238
+ composed:true
239
+ });
72
240
  return true;
73
241
  }
74
242
 
75
243
  async #executeScripts() {
76
244
  const scripts = Array.from(this.shadowRoot.querySelectorAll('script'));
77
- for(const script of scripts){
245
+ const previousDescriptor=Object.getOwnPropertyDescriptor(this,'destroy')??null;
246
+ const previousDestroy=this.destroy;
247
+ try{
248
+ for(const script of scripts){
78
249
  if (script.src) {
79
250
  console.error('ONLY INLINE SCRIPTS SUPPORTED AT THIS TIME FOR SECURITY REASONS');
80
251
  console.warn('script src path will need to be limited to ./{text}.js, ../{text}.js), or acceptable sub folders. This can be complex.');
@@ -105,6 +276,19 @@ class HTMLImport extends HTMLElement {
105
276
  htmlImportHostRegistry.delete(hostToken);
106
277
  delete executable.dataset.arcaneHostToken;
107
278
  }
279
+ }
280
+ }finally{
281
+ const installedDescriptor=Object.getOwnPropertyDescriptor(this,'destroy')??null;
282
+ const installedDestroy=this.destroy;
283
+ if(typeof installedDestroy==='function'
284
+ &&installedDestroy!==previousDestroy
285
+ &&!samePropertyDescriptor(installedDescriptor,previousDescriptor)){
286
+ this.#importedDestroy={
287
+ destroy:installedDestroy,
288
+ installedDescriptor,
289
+ previousDescriptor
290
+ };
291
+ }
108
292
  }
109
293
  }
110
294
  }
@@ -2,9 +2,28 @@ import {
2
2
  checkLocalAIReadiness,
3
3
  deriveLocalAIRequirements
4
4
  } from './LocalAIReadiness.js?v=4';
5
+ import {
6
+ createArcaneEventSource,
7
+ projectArcaneDOMEvent
8
+ } from 'arcane-os/event-manager';
5
9
 
6
10
  const SLOT_NAMES=Object.freeze(['llm','stt','tts']);
7
11
 
12
+ export const LOCAL_AI_READINESS_CONTROLLER_EVENT_TYPES=Object.freeze({
13
+ changed:'local-ai-readiness-change'
14
+ });
15
+ export const LOCAL_AI_READINESS_CONTROLLER_ERROR_CODES=Object.freeze({
16
+ aborted:'ARCANE_LOCAL_AI_READINESS_CONTROLLER_ABORTED',
17
+ disposed:'ARCANE_LOCAL_AI_READINESS_CONTROLLER_DISPOSED',
18
+ statusReadyTimeout:'ARCANE_LOCAL_AI_STATUS_COMPONENT_READY_TIMEOUT'
19
+ });
20
+ export const LOCAL_AI_READINESS_CONTROLLER_REASONS=Object.freeze({
21
+ checked:'local-ai-readiness-checked',
22
+ aborted:'local-ai-readiness-controller-aborted',
23
+ disposed:'local-ai-readiness-controller-disposed',
24
+ statusReadyTimeout:'local-ai-status-component-ready-timeout'
25
+ });
26
+
8
27
  function availabilityFromReport(report={}){
9
28
  const slots=report.slots||{};
10
29
  return Object.freeze(Object.fromEntries(SLOT_NAMES.map(name=>{
@@ -29,19 +48,85 @@ function pendingReport(requirements){
29
48
  });
30
49
  }
31
50
 
32
- async function waitForStatusComponent(status){
51
+ function localAIReadinessAbortError(reason,disposed=false){
52
+ const code=disposed
53
+ ?LOCAL_AI_READINESS_CONTROLLER_ERROR_CODES.disposed
54
+ :LOCAL_AI_READINESS_CONTROLLER_ERROR_CODES.aborted;
55
+ const errorReason=disposed
56
+ ?LOCAL_AI_READINESS_CONTROLLER_REASONS.disposed
57
+ :LOCAL_AI_READINESS_CONTROLLER_REASONS.aborted;
58
+ if(reason instanceof Error
59
+ &&reason.name==='AbortError'
60
+ &&reason.code===code
61
+ &&reason.reason===errorReason){
62
+ return reason;
63
+ }
64
+
65
+ const error=new Error(
66
+ disposed
67
+ ?'The local AI readiness controller has been disposed.'
68
+ :'The local AI readiness check was aborted.'
69
+ );
70
+ error.name='AbortError';
71
+ error.code=code;
72
+ error.reason=errorReason;
73
+ if(reason!==undefined){
74
+ error.cause=reason;
75
+ }
76
+ return error;
77
+ }
78
+
79
+ async function waitForStatusComponent(status,signal){
33
80
  if(!status||status.ready){
34
81
  return status;
35
82
  }
36
83
 
37
- await new Promise(resolve=>{
38
- const timeout=globalThis.setTimeout(done,10000);
39
- function done(){
84
+ await new Promise(function waitForLocalAIStatusPromise(resolve,reject){
85
+ let settled=false;
86
+ const timeout=globalThis.setTimeout(failTimeout,10000);
87
+
88
+ function cleanup(){
40
89
  globalThis.clearTimeout(timeout);
41
- status.removeEventListener('local-ai-status-ready',done);
90
+ status.removeEventListener('local-ai-status-ready',complete);
91
+ signal?.removeEventListener('abort',abortWait);
92
+ }
93
+
94
+ function complete(){
95
+ if(settled){
96
+ return;
97
+ }
98
+ settled=true;
99
+ cleanup();
42
100
  resolve();
43
101
  }
44
- status.addEventListener('local-ai-status-ready',done,{once:true});
102
+
103
+ function fail(error){
104
+ if(settled){
105
+ return;
106
+ }
107
+ settled=true;
108
+ cleanup();
109
+ reject(error);
110
+ }
111
+
112
+ function failTimeout(){
113
+ const error=new Error('Local AI status component readiness timed out after 10000 ms.');
114
+ error.code=LOCAL_AI_READINESS_CONTROLLER_ERROR_CODES.statusReadyTimeout;
115
+ error.reason=LOCAL_AI_READINESS_CONTROLLER_REASONS.statusReadyTimeout;
116
+ fail(error);
117
+ }
118
+
119
+ function abortWait(){
120
+ fail(localAIReadinessAbortError(signal?.reason));
121
+ }
122
+
123
+ if(signal?.aborted){
124
+ abortWait();
125
+ return;
126
+ }
127
+
128
+ signal?.addEventListener('abort',abortWait,{once:true});
129
+ status.addEventListener('local-ai-status-ready',complete,{once:true});
45
130
  });
46
131
  return status;
47
132
  }
@@ -62,35 +147,76 @@ export function createLocalAIReadinessController({
62
147
  throw new TypeError('A chat component is required.');
63
148
  }
64
149
 
150
+ const lifecycleController=new AbortController();
151
+ const eventOwner=Object.freeze({kind:'local-ai-readiness-controller'});
152
+ const events=createArcaneEventSource(
153
+ eventOwner,
154
+ {
155
+ source:'local-ai-readiness-controller',
156
+ eventTypes:Object.freeze(
157
+ Object.values(LOCAL_AI_READINESS_CONTROLLER_EVENT_TYPES)
158
+ )
159
+ }
160
+ );
65
161
  let active=null;
162
+ let chatReadyListenerPending=false;
163
+ let destroyed=false;
66
164
  let latestReport=null;
67
165
  let currentAvailability=availabilityFromReport({});
166
+ let operationSequence=0;
68
167
 
69
- const publishAvailability=()=>{
168
+ function publishAvailability(){
169
+ if(destroyed){
170
+ return;
171
+ }
70
172
  if(typeof chat.setAIAvailability==='function'){
173
+ if(chatReadyListenerPending){
174
+ chat.removeEventListener('chat-ready',publishAvailability);
175
+ }
176
+ chatReadyListenerPending=false;
71
177
  chat.setAIAvailability(currentAvailability);
72
- }else{
73
- chat.addEventListener('chat-ready',publishAvailability,{once:true});
178
+ }else if(!chatReadyListenerPending){
179
+ chatReadyListenerPending=true;
180
+ chat.addEventListener(
181
+ 'chat-ready',
182
+ publishAvailability,
183
+ {
184
+ once:true,
185
+ signal:lifecycleController.signal
186
+ }
187
+ );
74
188
  }
75
- };
189
+ }
76
190
 
77
- const applyAvailability=report=>{
191
+ function applyAvailability(report){
78
192
  currentAvailability=availabilityFromReport(report);
79
193
  publishAvailability();
80
- };
194
+ }
81
195
 
82
- const check=async()=>{
196
+ async function check(){
197
+ if(destroyed){
198
+ throw localAIReadinessAbortError(
199
+ lifecycleController.signal.reason,
200
+ true
201
+ );
202
+ }
83
203
  if(active){
84
204
  return active;
85
205
  }
86
206
 
87
- active=(async()=>{
207
+ active=(async function runLocalAIReadinessCheck(){
88
208
  const preferenceTuple=selectedPreferences(preferences);
89
209
  const requirements=deriveLocalAIRequirements(preferenceTuple);
90
210
  const required=SLOT_NAMES.some(name=>requirements[name].required);
91
211
 
92
212
  applyAvailability(pendingReport(requirements));
93
- await waitForStatusComponent(status);
213
+ await waitForStatusComponent(status,lifecycleController.signal);
214
+ if(destroyed){
215
+ throw localAIReadinessAbortError(
216
+ lifecycleController.signal.reason,
217
+ true
218
+ );
219
+ }
94
220
  status?.configure?.({profileHref});
95
221
  if(required){
96
222
  status?.begin?.(requirements);
@@ -102,42 +228,95 @@ export function createLocalAIReadinessController({
102
228
  ...checkOptions,
103
229
  preferences:preferenceTuple
104
230
  });
231
+ if(destroyed){
232
+ throw localAIReadinessAbortError(
233
+ lifecycleController.signal.reason,
234
+ true
235
+ );
236
+ }
105
237
  status?.present?.(latestReport);
106
238
  applyAvailability(latestReport);
107
239
  if(typeof onChange==='function'){
108
240
  onChange(latestReport);
109
241
  }
110
- chat.dispatchEvent(new CustomEvent('local-ai-readiness-change',{
111
- bubbles:true,
112
- composed:true,
113
- detail:{report:latestReport}
114
- }));
242
+ const availability=availabilityFromReport(latestReport);
243
+ if(destroyed){
244
+ throw localAIReadinessAbortError(
245
+ lifecycleController.signal.reason,
246
+ true
247
+ );
248
+ }
249
+ operationSequence+=1;
250
+ const operationId=`${events.instanceId}:check:${operationSequence.toString(36)}`;
251
+ const reason=LOCAL_AI_READINESS_CONTROLLER_REASONS.checked;
252
+ const publication=events.dispatch(
253
+ LOCAL_AI_READINESS_CONTROLLER_EVENT_TYPES.changed,
254
+ Object.freeze({operationId,reason,report:latestReport}),
255
+ {
256
+ operationId,
257
+ publicDetail:Object.freeze({availability,reason})
258
+ }
259
+ );
260
+ projectArcaneDOMEvent(
261
+ chat,
262
+ publication.occurrence,
263
+ {
264
+ bubbles:true,
265
+ composed:true
266
+ }
267
+ );
115
268
  return latestReport;
116
- })().finally(()=>{
269
+ })().finally(function releaseLocalAIReadinessCheck(){
117
270
  active=null;
118
271
  });
119
272
 
120
273
  return active;
121
- };
274
+ }
122
275
 
123
- const ensure=()=>{
276
+ function ensure(){
124
277
  if(active){
125
278
  return active;
126
279
  }
127
280
  return latestReport
128
281
  ?Promise.resolve(latestReport)
129
282
  :check();
130
- };
283
+ }
284
+
285
+ function retry(){
286
+ void check().catch(function ignoreDestroyedLocalAIRetry(error){
287
+ if(error?.name!=='AbortError'){
288
+ console.error('Local AI readiness retry failed.',error);
289
+ }
290
+ });
291
+ }
292
+ status?.addEventListener(
293
+ 'local-ai-retry',
294
+ retry,
295
+ {signal:lifecycleController.signal}
296
+ );
131
297
 
132
- const retry=()=>void check();
133
- status?.addEventListener('local-ai-retry',retry);
298
+ function destroy(){
299
+ if(destroyed){
300
+ return false;
301
+ }
302
+ destroyed=true;
303
+ lifecycleController.abort(
304
+ localAIReadinessAbortError(undefined,true)
305
+ );
306
+ if(chatReadyListenerPending){
307
+ chat.removeEventListener('chat-ready',publishAvailability);
308
+ chatReadyListenerPending=false;
309
+ }
310
+ status?.removeEventListener('local-ai-retry',retry);
311
+ events.dispose();
312
+ return true;
313
+ }
134
314
 
135
315
  return Object.freeze({
136
316
  check,
137
317
  ensure,
138
- destroy(){
139
- status?.removeEventListener('local-ai-retry',retry);
140
- },
318
+ destroy,
319
+ dispose:destroy,
141
320
  get availability(){
142
321
  return currentAvailability;
143
322
  },