arcane-os 0.43.0 → 0.45.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -86,7 +86,7 @@
86
86
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
87
87
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
88
88
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
89
- "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
89
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
90
90
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
91
91
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
92
92
  "check": "node tools/check-source.mjs && npm test",
@@ -3255,13 +3255,22 @@
3255
3255
  );
3256
3256
  }
3257
3257
 
3258
- const sessionRequestMessages=is.array(sessionRequest)
3258
+ const submittedMessages=is.array(sessionRequest)
3259
3259
  ?sessionRequest
3260
3260
  :[sessionRequest];
3261
- if(!sessionRequestMessages.length){
3261
+ if(!submittedMessages.length){
3262
3262
  throw new TypeError('A chat session request must contain at least one message.');
3263
3263
  }
3264
- const retainTurn=sessionRequestMessages.every(message=>message.persist!==false);
3264
+ function entityRetainsMessages(){
3265
+ return !session.chatEntity||Boolean(session.chatEntity.persist);
3266
+ }
3267
+ let retainTurn=submittedMessages.every(message=>message.persist!==false)
3268
+ &&entityRetainsMessages();
3269
+ // Preserve this submission's choice across the rendering await before
3270
+ // dispatch; a later entity setting belongs to a later submission.
3271
+ const sessionRequestMessages=retainTurn
3272
+ ?submittedMessages
3273
+ :submittedMessages.map(message=>({...message,persist:false}));
3265
3274
  const request={
3266
3275
  ...(sessionRequestMessages.length===1
3267
3276
  ?{message:sessionRequestMessages[0]}
@@ -3276,9 +3285,18 @@
3276
3285
  ?session.aiName
3277
3286
  :host.aiName;
3278
3287
  const sessionMessageToken=Symbol(messageId);
3279
- const transientCards=retainTurn?[]:[...requestMessages];
3288
+ const transientCards=[...requestMessages];
3289
+ let retentionSettled=false;
3290
+ function settleTurnRetention(result){
3291
+ if(retentionSettled) return;
3292
+ // The session reports the decision made at its actual commit boundary.
3293
+ retainTurn=retainTurn&&(is.boolean(result?.retained)
3294
+ ?result.retained
3295
+ :entityRetainsMessages());
3296
+ retentionSettled=true;
3297
+ }
3280
3298
  function removeTransientSessionCards(){
3281
- if(!transientCards.length) return;
3299
+ if(retainTurn||!transientCards.length) return;
3282
3300
  // The submitter supplies only nodes created for this submission.
3283
3301
  for(const card of transientCards) card.remove();
3284
3302
  transientCards.length=0;
@@ -3290,7 +3308,7 @@
3290
3308
  setSessionStatus('sending','AI is responding…');
3291
3309
  try{
3292
3310
  const responseCard=receivedMessage(responseName, '', messageId);
3293
- if(!retainTurn) transientCards.push(responseCard);
3311
+ transientCards.push(responseCard);
3294
3312
  await streamMessage('',messageId,true);
3295
3313
  setMessageProgress(
3296
3314
  messageId,
@@ -3344,6 +3362,7 @@
3344
3362
  }
3345
3363
  )
3346
3364
  :await session.send(request);
3365
+ settleTurnRetention(result);
3347
3366
  if(
3348
3367
  destroyed
3349
3368
  ||bindingGeneration!==sessionBindingGeneration
@@ -3441,6 +3460,7 @@
3441
3460
  );
3442
3461
  return result;
3443
3462
  }catch(error){
3463
+ settleTurnRetention();
3444
3464
  if(!destroyed&&bindingGeneration===sessionBindingGeneration){
3445
3465
  setPendingStructuralToolCalls(previousPendingToolCalls);
3446
3466
  }
@@ -3501,6 +3521,7 @@
3501
3521
  }
3502
3522
  throw error;
3503
3523
  }finally{
3524
+ settleTurnRetention();
3504
3525
  removeTransientSessionCards();
3505
3526
  if(activeSessionMessageToken===sessionMessageToken){
3506
3527
  activeSessionMessageToken=null;
@@ -1555,6 +1555,8 @@
1555
1555
  generation,
1556
1556
  urls:new Set(),
1557
1557
  media:new Set(),
1558
+ markdownMedia:new Set(),
1559
+ markdownStatus:false,
1558
1560
  title:`${directory}/${fileName}`,
1559
1561
  rendered:null,
1560
1562
  printPreparation:null,
@@ -1660,6 +1662,7 @@
1660
1662
  status.remove();
1661
1663
  content.append(rendered);
1662
1664
  preview.rendered=rendered;
1665
+ updateMarkdownPreviewStatus(preview);
1663
1666
  void preparePreviewPrint(preview).catch(function reportPrintPreparation(error){
1664
1667
  reportPreviewPrintFailure(error,preview);
1665
1668
  });
@@ -1683,11 +1686,12 @@
1683
1686
  }
1684
1687
  const view=createPrintView({
1685
1688
  host,
1686
- content:()=>preview.rendered,
1689
+ content:()=>previewPrintContent(preview),
1687
1690
  title:()=>preview.title,
1688
1691
  active:()=>isCurrentPreview(preview)&&fileModal.opened===true,
1689
1692
  priority:1,
1690
1693
  signal:preview.controller.signal,
1694
+ prepare:function preparePreviewMedia(signal){return waitForPreviewMarkdown(preview,signal);},
1691
1695
  retain:()=>retainPreviewPrintResources(preview),
1692
1696
  onError:error=>reportPreviewPrintFailure(error,preview)
1693
1697
  });
@@ -1703,20 +1707,93 @@
1703
1707
  if(!preview?.rendered||!isCurrentPreview(preview)||fileModal.opened!==true){
1704
1708
  return false;
1705
1709
  }
1710
+ if(!await waitForPreviewMarkdown(preview)){
1711
+ return false;
1712
+ }
1706
1713
  const view=await preparePreviewPrint(preview);
1707
1714
  return view&&isCurrentPreview(preview)?view.print():false;
1708
1715
  }
1709
1716
 
1717
+ async function waitForPreviewMarkdown(preview,signal){
1718
+ signal?.throwIfAborted();
1719
+ const results=await Promise.allSettled(
1720
+ [...preview.markdownMedia].map(function markdownReadiness(media){
1721
+ return media.owner.ready;
1722
+ })
1723
+ );
1724
+ signal?.throwIfAborted();
1725
+ if(!isCurrentPreview(preview)||fileModal.opened!==true){
1726
+ return false;
1727
+ }
1728
+ const failures=results.filter(function failedMarkdown(result){
1729
+ return result.status==='rejected';
1730
+ }).map(function markdownFailure(result){
1731
+ return result.reason;
1732
+ });
1733
+ if(failures.length){
1734
+ throw new AggregateError(failures,'Local images could not be prepared for printing.');
1735
+ }
1736
+ return true;
1737
+ }
1738
+
1739
+ function previewPrintContent(preview){
1740
+ const failures=[];
1741
+ for(const media of preview.markdownMedia){
1742
+ if(!media.settled){
1743
+ throw new Error('Local images are still loading. Use Print after they finish.');
1744
+ }
1745
+ if(media.error){
1746
+ failures.push(media.error);
1747
+ }
1748
+ }
1749
+ if(failures.length){
1750
+ throw new AggregateError(failures,'Local images could not be prepared for printing.');
1751
+ }
1752
+ return preview.rendered;
1753
+ }
1754
+
1755
+ function updateMarkdownPreviewStatus(preview){
1756
+ if(!isCurrentPreview(preview)||!preview.rendered){
1757
+ return;
1758
+ }
1759
+ if([...preview.markdownMedia].some(function failedMarkdown(media){return media.error;})){
1760
+ preview.markdownStatus=true;
1761
+ preview.printStatus.textContent='Some images could not be loaded. Reopen this file to try again.';
1762
+ preview.printStatus.setAttribute('role','alert');
1763
+ preview.container.append(preview.printStatus);
1764
+ }else if(preview.markdownStatus&&[...preview.markdownMedia].every(function settledMarkdown(media){return media.settled;})){
1765
+ preview.markdownStatus=false;
1766
+ preview.printStatus.remove();
1767
+ }
1768
+ }
1769
+
1710
1770
  function reportPreviewPrintFailure(error,preview){
1711
1771
  arcaneLogging.error('Unable to print file preview:',error);
1712
1772
  if(isCurrentPreview(preview)){
1713
- preview.printStatus.textContent='Unable to open print preview. Please try again.';
1773
+ const media=[...preview.markdownMedia];
1774
+ preview.markdownStatus=media.some(function unavailableImage(item){return !item.settled||item.error;});
1775
+ preview.printStatus.textContent=media.some(function pendingImage(item){return !item.settled;})
1776
+ ?'Images are still loading. Close this print dialog and use Print after they finish.'
1777
+ :media.some(function failedImage(item){return item.error;})
1778
+ ?'Some images could not be loaded. Close any print dialog and reopen this file to try again.'
1779
+ :'Unable to open print preview. Please try again.';
1714
1780
  preview.printStatus.setAttribute('role','alert');
1715
1781
  preview.container.append(preview.printStatus);
1716
1782
  }
1717
1783
  }
1718
1784
 
1719
1785
  function retainPreviewPrintResources(preview){
1786
+ const markdownReleases=[];
1787
+ try{
1788
+ for(const media of preview.markdownMedia){
1789
+ markdownReleases.push(media.owner.retain());
1790
+ }
1791
+ }catch(error){
1792
+ for(const release of markdownReleases){
1793
+ release();
1794
+ }
1795
+ throw error;
1796
+ }
1720
1797
  preview.printRetains+=1;
1721
1798
  let retained=true;
1722
1799
  return function releasePrintedPreviewResources(){
@@ -1724,6 +1801,9 @@
1724
1801
  return;
1725
1802
  }
1726
1803
  retained=false;
1804
+ for(const release of markdownReleases){
1805
+ release();
1806
+ }
1727
1807
  preview.printRetains-=1;
1728
1808
  if(preview.released&&preview.printRetains===0){
1729
1809
  releasePreviewURLs(preview);
@@ -1760,6 +1840,10 @@
1760
1840
  }
1761
1841
  preview.released=true;
1762
1842
  preview.controller.abort(reason);
1843
+ for(const media of preview.markdownMedia){
1844
+ media.owner.destroy();
1845
+ }
1846
+ preview.markdownMedia.clear();
1763
1847
  for(const element of preview.media){
1764
1848
  if(element.localName==='audio'){
1765
1849
  element.pause();
@@ -1817,10 +1901,32 @@
1817
1901
  }
1818
1902
  const body=document.createElement(descriptor.kind==='html'?'iframe':'div');
1819
1903
  if(descriptor.kind==='markdown'){
1820
- markdownModulePromise=markdownModulePromise||import('../modules/MD.js');
1821
- const {default:MD}=await markdownModulePromise;
1904
+ markdownModulePromise=markdownModulePromise||Promise.all([
1905
+ import('../modules/MD.js'),
1906
+ import('../modules/MarkdownMedia.js')
1907
+ ]);
1908
+ const [{default:MD},{hydrateMarkdownMedia}]=await markdownModulePromise;
1822
1909
  preview.controller.signal.throwIfAborted();
1823
- body.innerHTML=new MD(descriptor.content).safeRendered;
1910
+ const template=document.createElement('template');
1911
+ template.innerHTML=new MD(descriptor.content).safeRendered;
1912
+ const owner=hydrateMarkdownMedia(template.content,{signal:preview.controller.signal});
1913
+ const media={owner,settled:false,error:null};
1914
+ preview.markdownMedia.add(media);
1915
+ void owner.ready.then(
1916
+ function markdownImagesReady(){
1917
+ media.settled=true;
1918
+ updateMarkdownPreviewStatus(preview);
1919
+ },
1920
+ function markdownImagesFailed(error){
1921
+ media.settled=true;
1922
+ media.error=error;
1923
+ if(isCurrentPreview(preview)){
1924
+ arcaneLogging.error('Unable to load local Markdown images:',error);
1925
+ updateMarkdownPreviewStatus(preview);
1926
+ }
1927
+ }
1928
+ );
1929
+ body.append(template.content);
1824
1930
  }else{
1825
1931
  body.title=descriptor.title||'HTML preview';
1826
1932
  body.srcdoc=descriptor.content;
@@ -219,7 +219,10 @@
219
219
  const host=this;
220
220
  host.ready=false;
221
221
 
222
- const {default:MD}=await import('../modules/MD.js');
222
+ const [{default:MD},{hydrateMarkdownMedia}]=await Promise.all([
223
+ import('../modules/MD.js'),
224
+ import('../modules/MarkdownMedia.js')
225
+ ]);
223
226
  const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
224
227
  const events=createArcaneEventSource(host,{source:'arcane.component.markdown-document',eventTypes:['markdown-document-state','markdown-document-empty','markdown-document-error','markdown-document-loading','markdown-document-rendered','markdown-document-navigate','markdown-document-ready']});
225
228
  const lifecycleController=new AbortController();
@@ -331,6 +334,7 @@
331
334
  content:'Document content',
332
335
  empty:'No document content is available.',
333
336
  error:'Unable to display this document.',
337
+ mediaError:'Some images could not be displayed.',
334
338
  loading:'Loading document…',
335
339
  tableOfContents:'On this page'
336
340
  },
@@ -348,6 +352,8 @@
348
352
  let operationSequence=0;
349
353
  let loadController=null;
350
354
  let destroyed=false;
355
+ let renderedMedia=null;
356
+ let mediaError=false;
351
357
 
352
358
  host.clear=clear;
353
359
  host.configure=configure;
@@ -393,7 +399,7 @@
393
399
  }
394
400
 
395
401
  const labels={...previous.labels};
396
- for(const name of ['content','empty','error','loading','tableOfContents']){
402
+ for(const name of ['content','empty','error','mediaError','loading','tableOfContents']){
397
403
  if(labelInput[name]!==undefined){
398
404
  if(!is.string(labelInput[name])||!labelInput[name].trim()){
399
405
  throw new TypeError(`Markdown document labels.${name} must be a nonempty string.`);
@@ -607,6 +613,7 @@
607
613
 
608
614
  currentMarkdown=markdown;
609
615
  currentSourceURL=sourceURL;
616
+ releaseRenderedMedia();
610
617
  contentElement.replaceChildren();
611
618
  tableOfContents=[];
612
619
  tableOfContentsList.replaceChildren();
@@ -631,6 +638,16 @@
631
638
 
632
639
  tableOfContents=decorateHeadings(fragment);
633
640
  decorateTables(fragment);
641
+ // Resolve local references before connecting their IMG elements. The
642
+ // complete Markdown and ordinary external URLs remain unchanged.
643
+ const media=hydrateMarkdownMedia(fragment,{signal:lifecycleController.signal});
644
+ renderedMedia=media;
645
+ media.ready.catch(function reportMarkdownMediaFailure(error){
646
+ if(destroyed||renderedMedia!==media) return;
647
+ mediaError=true;
648
+ arcaneLogging.error('Markdown document image display failed.',error);
649
+ updateStateDOM();
650
+ });
634
651
  contentElement.replaceChildren(fragment);
635
652
  buildTableOfContents(tableOfContents);
636
653
  transition(
@@ -644,6 +661,13 @@
644
661
  return true;
645
662
  }
646
663
 
664
+ function releaseRenderedMedia(){
665
+ const previous=renderedMedia;
666
+ renderedMedia=null;
667
+ mediaError=false;
668
+ previous?.destroy();
669
+ }
670
+
647
671
  function renderedMarkdownFragment(html='',sourceURL=options.sourceURL){
648
672
  const template=document.createElement('template');
649
673
  template.innerHTML=html;
@@ -764,6 +788,7 @@
764
788
  const operationId=`${events.descriptor.instanceId}:clear:${++operationSequence}`;
765
789
  currentMarkdown='';
766
790
  currentSourceURL=options.sourceURL;
791
+ releaseRenderedMedia();
767
792
  contentElement.replaceChildren();
768
793
  tableOfContents=[];
769
794
  tableOfContentsList.replaceChildren();
@@ -781,6 +806,7 @@
781
806
 
782
807
  function showError(error,operationId=null){
783
808
  currentMarkdown='';
809
+ releaseRenderedMedia();
784
810
  contentElement.replaceChildren();
785
811
  tableOfContents=[];
786
812
  tableOfContentsList.replaceChildren();
@@ -839,7 +865,7 @@
839
865
  host.setAttribute('aria-busy',String(loadingState));
840
866
  host.dataset.state=state;
841
867
  statusElement.dataset.state=state;
842
- statusElement.hidden=readyState;
868
+ statusElement.hidden=readyState&&!mediaError;
843
869
  contentElement.hidden=!readyState;
844
870
  tableOfContentsElement.hidden=(
845
871
  !readyState
@@ -851,7 +877,7 @@
851
877
  empty:options.labels.empty,
852
878
  error:options.labels.error,
853
879
  loading:options.labels.loading,
854
- ready:''
880
+ ready:mediaError?options.labels.mediaError:''
855
881
  }[state]||'';
856
882
  }
857
883
 
@@ -948,6 +974,7 @@
948
974
  lifecycleController.abort('markdown-document-destroyed');
949
975
  currentMarkdown='';
950
976
  tableOfContents=[];
977
+ releaseRenderedMedia();
951
978
  contentElement.replaceChildren();
952
979
  tableOfContentsList.replaceChildren();
953
980
  events.dispose();
@@ -63,6 +63,10 @@
63
63
  margin-right:auto;
64
64
  }
65
65
 
66
+ .media-status:empty{
67
+ display:none;
68
+ }
69
+
66
70
  .markdown-editor.fit{
67
71
  height:100%;
68
72
  min-height:0;
@@ -118,6 +122,7 @@
118
122
  <textarea id="entryMarkdown" class="entry-markdown" part="input"></textarea>
119
123
  <section class="actions" part="actions">
120
124
  <span id="status" class="status" part="status" aria-live="polite"></span>
125
+ <span id="mediaStatus" class="media-status" part="media-status" aria-live="polite"></span>
121
126
  <button id="print" part="print-action" type="button">Print</button>
122
127
  <button id="save" part="save-action" type="button" disabled></button>
123
128
  </section>
@@ -130,7 +135,10 @@
130
135
  const {arcaneLogging}=await import('arcane-os/logging');
131
136
 
132
137
  const host=this;
133
- const {default:MD}=await import('../modules/MD.js');
138
+ const [{default:MD},{hydrateMarkdownMedia}]=await Promise.all([
139
+ import('../modules/MD.js'),
140
+ import('../modules/MarkdownMedia.js')
141
+ ]);
134
142
  const {
135
143
  applyMarkdownFormat,
136
144
  normalizeMarkdownOptions
@@ -146,9 +154,12 @@
146
154
  const saveButton=host.shadowRoot.querySelector('#save');
147
155
  const printButton=host.shadowRoot.querySelector('#print');
148
156
  const status=host.shadowRoot.querySelector('#status');
157
+ const mediaStatus=host.shadowRoot.querySelector('#mediaStatus');
149
158
 
150
159
  let previewFrame=0;
151
160
  let previewFollowPending=false;
161
+ let previewMedia=null;
162
+ let printMediaRetains=null;
152
163
  let operationSequence=0;
153
164
  let saveGeneration=0;
154
165
  let saveController=null;
@@ -166,6 +177,8 @@
166
177
  title:()=>titleInput.value,
167
178
  active:()=>!destroyed&&host.isConnected&&host.getClientRects().length>0,
168
179
  signal:lifecycleController.signal,
180
+ retain:retainPrintMedia,
181
+ prepare:preparePrintMedia,
169
182
  onError:reportPrintFailure
170
183
  });
171
184
  }
@@ -401,7 +414,41 @@
401
414
  const scrollTop=preview.scrollTop;
402
415
  const scrollLeft=preview.scrollLeft;
403
416
  const markdown=new MD(textarea.value,{sourceMap:options.followPreview});
404
- preview.innerHTML=markdown.safeRendered;
417
+ const template=document.createElement('template');
418
+ template.innerHTML=markdown.safeRendered;
419
+ const pending=[...template.content.querySelectorAll('img')].some(
420
+ image=>image.getAttribute('src')?.startsWith('arcane-media:')
421
+ );
422
+ // Hydration removes custom src values before these nodes join the live DOM.
423
+ const handle=hydrateMarkdownMedia(template.content,{signal:lifecycleController.signal});
424
+ const media={handle,pending,error:null};
425
+ const previous=previewMedia;
426
+ previewMedia=media;
427
+ if(printMediaRetains){
428
+ // The print owner may still be awaiting its final current snapshot.
429
+ printMediaRetains.add(handle.retain());
430
+ }
431
+ previous?.handle.destroy();
432
+ mediaStatus.innerText=pending?'Loading saved images…':'';
433
+ media.ready=handle.ready.then(function mediaReady(){
434
+ media.pending=false;
435
+ if(!destroyed&&previewMedia===media){
436
+ mediaStatus.innerText='';
437
+ }
438
+ },function mediaFailed(error){
439
+ media.pending=false;
440
+ media.error=error;
441
+ const cancelled=error?.name==='AbortError'||error?.failures?.every(
442
+ failure=>failure.reason?.name==='AbortError'
443
+ );
444
+ if(!cancelled){
445
+ arcaneLogging.error('Unable to display saved Markdown images:',error);
446
+ }
447
+ if(!destroyed&&previewMedia===media&&!cancelled){
448
+ mediaStatus.innerText='Some saved images could not be displayed.';
449
+ }
450
+ });
451
+ preview.replaceChildren(template.content);
405
452
  preview.querySelectorAll('a').forEach(
406
453
  link=>{
407
454
  link.target='_blank';
@@ -589,9 +636,38 @@
589
636
  }
590
637
 
591
638
  function renderedPrintContent(){
639
+ if(previewFrame){
640
+ updatePreview(false);
641
+ }
642
+ if(previewMedia?.pending){
643
+ const error=new Error('Images are still loading. Print again when they finish.');
644
+ error.code='ARCANE_MARKDOWN_MEDIA_PENDING';
645
+ throw error;
646
+ }
647
+ if(previewMedia?.error){
648
+ throw previewMedia.error;
649
+ }
592
650
  return preview;
593
651
  }
594
652
 
653
+ function retainPrintMedia(){
654
+ renderedPrintContent();
655
+ const releases=new Set();
656
+ if(previewMedia){
657
+ releases.add(previewMedia.handle.retain());
658
+ }
659
+ printMediaRetains=releases;
660
+ return function releasePrintMedia(){
661
+ if(printMediaRetains===releases){
662
+ printMediaRetains=null;
663
+ }
664
+ for(const release of releases){
665
+ release();
666
+ }
667
+ releases.clear();
668
+ };
669
+ }
670
+
595
671
  async function print(){
596
672
  if(destroyed){
597
673
  return false;
@@ -600,12 +676,40 @@
600
676
  if(destroyed||!printView){
601
677
  return false;
602
678
  }
603
- if(previewFrame){
604
- updatePreview(false);
679
+ if(!await preparePrintMedia()){
680
+ return false;
605
681
  }
606
682
  return printView.print();
607
683
  }
608
684
 
685
+ async function preparePrintMedia(signal){
686
+ let cancel;
687
+ const cancelled=new Promise(function waitForCancellation(resolve){cancel=resolve;});
688
+ signal?.addEventListener('abort',cancel,{once:true});
689
+ try{
690
+ while(!destroyed&&!signal?.aborted){
691
+ if(previewFrame){
692
+ updatePreview(false);
693
+ }
694
+ const media=previewMedia;
695
+ await Promise.race([media.ready,cancelled]);
696
+ if(destroyed||signal?.aborted){
697
+ return false;
698
+ }
699
+ if(previewFrame||previewMedia!==media){
700
+ continue;
701
+ }
702
+ if(media.error){
703
+ throw media.error;
704
+ }
705
+ return true;
706
+ }
707
+ return false;
708
+ }finally{
709
+ signal?.removeEventListener('abort',cancel);
710
+ }
711
+ }
712
+
609
713
  function printFromControl(){
610
714
  void print().catch(reportPrintFailure);
611
715
  }
@@ -615,6 +719,10 @@
615
719
  return;
616
720
  }
617
721
  arcaneLogging.error('Unable to print rendered Markdown:',error);
722
+ if(error?.code==='ARCANE_MARKDOWN_MEDIA_PENDING'){
723
+ mediaStatus.innerText=error.message;
724
+ return;
725
+ }
618
726
  status.innerText='Unable to open print preview. Please try again.';
619
727
  }
620
728
 
@@ -686,6 +794,7 @@
686
794
  }
687
795
  destroyed=true;
688
796
  supersedeSave('markdown-editor-destroyed');
797
+ previewMedia?.handle.destroy();
689
798
  lifecycleController.abort('markdown-editor-destroyed');
690
799
  previewFollowPending=false;
691
800
  if(previewFrame){
@@ -391,10 +391,12 @@ class ChatEntity{
391
391
  fileName='';
392
392
 
393
393
  /**
394
- * Controls automatic persistence.
394
+ * Controls retention of new message additions.
395
395
  *
396
396
  * When true, any message addition automatically writes
397
- * the updated message list to OPFS.
397
+ * the updated message list to OPFS. When false, new additions are excluded
398
+ * from the transcript, recurring context, storage, and memory extraction.
399
+ * Existing retained and saved messages remain unchanged.
398
400
  *
399
401
  * @type {boolean}
400
402
  */
@@ -1080,7 +1082,7 @@ ${JSON.stringify(transcript)}`
1080
1082
  if(!records.length){
1081
1083
  return false;
1082
1084
  }
1083
- if(!persist) return false;
1085
+ if(!persist||!this.persist) return false;
1084
1086
 
1085
1087
  this.#messages.push(...records);
1086
1088
  const durableRecords=storedChatRecords(records);
@@ -1088,7 +1090,7 @@ ${JSON.stringify(transcript)}`
1088
1090
  this.#saved=false;
1089
1091
  }
1090
1092
 
1091
- if(!this.persist||!durableRecords.length){
1093
+ if(!durableRecords.length){
1092
1094
  return;
1093
1095
  }
1094
1096