arcane-os 0.44.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.45.0
4
+
5
+ - Add public MarkdownMedia save, read, reference parsing and hydration helpers.
6
+ Store complete image records in application-owned DBOPFS tables outside raw
7
+ Markdown, with stable references and application-selected filenames. Preserve
8
+ JSONL/NDJSON singleton records across cold reads and backup restoration.
9
+ - Hydrate local images independently in shared Markdown views while preserving
10
+ raw Markdown, external URLs, source maps and saving. Observe cancellation and
11
+ individual failures without removing successful sibling images. Retain print
12
+ resources through afterprint; explicit PrintView preparation awaits local
13
+ media while native beforeprint remains synchronous.
14
+ - Apply no-retention semantics to entity-wide persistence choices as well as
15
+ request persist:false: complete input and response remain available to the
16
+ active operation, then stay outside retained transcript, recurring model
17
+ context, DBOPFS and memory extraction. Capture the accepted choice before
18
+ rendering waits and preserve original pending calls after temporary tool
19
+ continuations. Existing retained turns and saved history remain untouched;
20
+ structural sanitization applies to new durable writes only, without migration.
21
+ - Keep image model selection, credentials, storage selection and app orchestration
22
+ application-owned. No model/provider/default changes are introduced.
23
+
3
24
  ## 0.44.0
4
25
 
5
26
  - Add stateless `generateImages` through `arcane-os/ai/twin-cloud` with
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.44.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.45.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -426,7 +426,8 @@ history stores each tool's required user-facing message as an ordinary
426
426
  call envelope or ID and therefore does not recreate a pending executable call
427
427
  after reload. `submitToolResult()` and `submitToolResults()` accept `status` as
428
428
  the public result term; `disposition` remains an accepted input spelling.
429
- When a submitted turn uses `persist:false`, Chat may show its cards while that
429
+ When a submitted turn uses `persist:false`, or its session's Chat entity has
430
+ `persist=false`, Chat may show its cards while that
430
431
  one operation is active, then removes them when the operation settles; neither
431
432
  the input nor response remains in the retained transcript or model context.
432
433
  This cleanup also applies to failed, cancelled, detached, or replaced-session
@@ -437,6 +438,15 @@ operation's nodes so a completion listener can start another turn safely.
437
438
  Submission code passes those card references directly when it creates them;
438
439
  reusing an operation identifier never selects earlier conversation cards for
439
440
  cleanup or timestamp updates.
441
+ The session's terminal `retained` boolean reports its actual commit decision;
442
+ Chat uses that decision instead of retroactively interpreting a flag changed
443
+ for a later request. A completion listener cannot erase an earlier settled
444
+ turn by changing the next operation's retention setting.
445
+ Chat carries a submission's no-retention choice into its copied session request
446
+ before any rendering wait, so re-enabling the entity before dispatch cannot
447
+ retain that temporary input. Existing falsy entity settings retain their
448
+ no-retention meaning; a compatible custom session without a Chat entity keeps
449
+ its existing default behavior.
440
450
  Persistent-turn recovery and existing saved history remain unchanged.
441
451
 
442
452
  For streaming sessions, every provisional structural card and the terminal
@@ -926,6 +936,16 @@ Native PDF and inaccessible embedded viewers require their own print command.
926
936
  was produced. Closed, destroyed, or unavailable previews return `false`;
927
937
  preparation failures reject and the visible control reports a concise failure.
928
938
 
939
+ Markdown previews resolve saved `arcane-media:` images through
940
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs) before connecting IMG
941
+ nodes. Text appears while independent image reads finish; failures leave the
942
+ complete text and successful images visible with a concise recovery status.
943
+ Explicit printing waits for local reads and decoding. Native Print reports
944
+ pending media because it cannot await them. Closing or replacing a preview
945
+ cancels display readiness without waiting for storage; URLs already retained
946
+ by a requested print remain until `afterprint`. Original Markdown and ordinary
947
+ external image URLs remain unchanged.
948
+
929
949
  `await close()` cancels pending presentation work and closes delete, file, and
930
950
  directory dialogs in that order. A settled preview keeps its rendered content,
931
951
  media, and printing available until the file dialog actually closes; refusing
@@ -1054,7 +1074,16 @@ Events: `markdown-document-ready`, `markdown-document-state`,
1054
1074
  `markdown-document-empty`, `markdown-document-error`, and
1055
1075
  `markdown-document-navigate`.
1056
1076
 
1057
- Shared dependencies: [`MD.js`](runtime-modules.md#mdjs).
1077
+ Saved local image references use
1078
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs). Text rendering and
1079
+ the existing rendered event do not wait for image reads. The optional
1080
+ `labels.mediaError` supplies concise visible failure text while complete
1081
+ diagnostics remain in the developer console; successful images and document
1082
+ text remain visible. Replacing, clearing, failing, or destroying the document
1083
+ releases its display resources without deleting stored media. Markdown source
1084
+ and ordinary external image URLs remain unchanged.
1085
+
1086
+ Shared dependencies: [`MD.js`](runtime-modules.md#mdjs), [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs).
1058
1087
 
1059
1088
  ### Availability and normalization
1060
1089
 
@@ -1104,6 +1133,16 @@ when the browser dialog was requested. Completion of physical printing or PDF
1104
1133
  saving is not observable through this method. See
1105
1134
  [`PrintView.js`](runtime-modules.md#printviewjs).
1106
1135
 
1136
+ Saved `arcane-media:` image references are hydrated through
1137
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs) in detached rendered
1138
+ nodes. Source text, edit selection, source-map comments, ordinary image URLs,
1139
+ and the existing save flow remain unchanged. Image status has its own
1140
+ idle-hidden `media-status` part, separate from save status. Explicit printing
1141
+ waits for the current image reads/decoding, follows a replacement preview, and
1142
+ rechecks readiness before capture. Native Print reports still-pending media
1143
+ synchronously; it cannot wait or cancel the native dialog. Print-owned URLs
1144
+ outlive a changed or destroyed editor until `afterprint`.
1145
+
1107
1146
  `configure({fit:true,followPreview:true})` opts into the two independent
1108
1147
  behaviors. Both options default to `false`; omitted options retain their
1109
1148
  previous values during reconfiguration, and explicit `false` disables either.
@@ -1140,7 +1179,7 @@ lists, quotes, and tables follow their block, while invisible source positions
1140
1179
  use a nearby available rendered block rather than exact glyph geometry.
1141
1180
 
1142
1181
  Public CSS parts are `editor`, `title`, `toolbar`, `input`, `preview`,
1143
- `actions`, `status`, `print-action`, and `save-action`. Applications can make the title
1182
+ `actions`, `status`, `media-status`, `print-action`, and `save-action`. Applications can make the title
1144
1183
  borderless and compact through `::part(title)` while retaining its native
1145
1184
  editing behavior and visible keyboard focus. These parts are styling hooks,
1146
1185
  not slots or replacement controls.
@@ -96,6 +96,7 @@ own asynchronous work, cancellation, and backpressure.
96
96
  | [`Mail.js`](#mailjs) | esm | Builds complete reports and prefers the native mail capability with an explicit HTTP transport fallback. | Browser/native hybrid + cloud | Mail inputs/results normalized; transport failures mixed. |
97
97
  | [`MailOutbox.mjs`](#mailoutboxmjs) | esm | Persists complete mail reports before delivery and normalizes idempotent enqueue, retry, reconciliation, and invalid-record maintenance. | Browser/native WebView or compatible injected host | Complete records, full work, cancellation, and lifecycle states normalized; storage, lock, and delivery failures coded. |
98
98
  | [`MailTransport.mjs`](#mailtransportmjs) | esm | Sends one complete mail report to a normalized HTTP(S) endpoint. | Browser/server with fetch + cloud | Normalized endpoint and transport errors; remote detail preserved. |
99
+ | [`MarkdownMedia.js`](#markdownmediajs) | esm | Saves local Markdown images separately and resolves their stable references in rendered views. | Browser / supported native WebView | Complete image records, concurrent display reads, cancellation, and print-owned URL lifetime. |
99
100
  | [`MarkdownSpeech.js`](#markdownspeechjs) | esm | Removes repeated Markdown formatting marks from streamed narration. | Cross-host | Speech-only filtering; single marks and ordinary punctuation remain literal. |
100
101
  | [`Marked.min.js`](#markedminjs) | esm | Vendored Marked 18.0.5 Markdown lexer, parser, renderer, extension, and walk-token API. | Cross-host vendor module | Vendor-native Marked contract. |
101
102
  | [`MD.js`](#mdjs) | esm | Renders complete Markdown with Marked and optionally maps source blocks to rendered comment anchors. | Browser / native WebView | Complete raw and rendered Marked values; opt-in original-source offsets; parse errors vendor-native. |
@@ -2794,6 +2795,73 @@ import * as module from '/arcane/modules/Marked.min.js';
2794
2795
  console.log(Object.keys(module));
2795
2796
  ```
2796
2797
 
2798
+ ## MarkdownMedia.js
2799
+
2800
+ ### Overview
2801
+
2802
+ Stores complete images in the application's existing DBOPFS database while
2803
+ Markdown contains only a short, stable `arcane-media:` reference. Rendering
2804
+ resolves those local references into temporary display URLs. Importing the
2805
+ module starts no storage, network, provider, or rendering operation.
2806
+
2807
+ ### Public surface
2808
+
2809
+ Public package import: `arcane-os/modules/MarkdownMedia.js`.
2810
+
2811
+ - `await saveMarkdownMedia({blob,tableName='markdown-media',fileName})` writes
2812
+ `{mediaType,dataUrl}` as a JSON-compatible record and returns
2813
+ `{reference,tableName,fileName,mediaType}` only after the write completes.
2814
+ The default filename is a new UUID followed by `.json`. There is no abort
2815
+ option: accepted storage writes settle through the storage owner.
2816
+ - `parseMarkdownMediaReference(reference)` returns `{tableName,fileName}` for
2817
+ a local reference, or `null` for an ordinary URL. Malformed local addresses
2818
+ report their actual parsing error.
2819
+ - `await readMarkdownMedia(reference)` returns the complete `Blob`, using
2820
+ only local storage and data-URL decoding; stored strings are never fetched
2821
+ as network addresses.
2822
+ - `hydrateMarkdownMedia(root,{signal}={})` returns `{ready,destroy,retain}`
2823
+ immediately. It includes an IMG root and descendant IMG elements, removes
2824
+ local-reference `src` values synchronously, starts independent reads
2825
+ together, and assigns owned object URLs. `ready` includes image decoding.
2826
+ Use a detached fragment before connecting those images to the document.
2827
+
2828
+ Hydration preserves Markdown source, image descriptions, ordinary URLs, and
2829
+ surrounding content. Failed reads or decodes reject `ready` with an
2830
+ `AggregateError` whose complete `failures` array contains
2831
+ `{image,reference,reason}`; successful sibling images remain visible.
2832
+
2833
+ `destroy()` or abort settles display readiness promptly, prevents late display,
2834
+ and releases owned URLs. An already-started storage read remains observed and
2835
+ settles independently. `retain()` returns an idempotent release callback for
2836
+ printing; retained URLs survive destruction of the original view until every
2837
+ print owner releases them. Destruction never deletes stored images.
2838
+
2839
+ Applications own record names, entry association, cleanup policy, and which
2840
+ records their existing JSON backup/export includes. Reusing the same filename
2841
+ supports an application-selected save-only retry without another generation
2842
+ request. The helper performs no existing-data migration.
2843
+
2844
+ ### Availability and normalization
2845
+
2846
+ Browser or native WebView with the existing app-scoped DBOPFS owner, Blob,
2847
+ FileReader, object URLs, and IMG decoding. Encoding, storage, missing-record,
2848
+ parsing, and decoding errors remain observable. No Core capability is selected.
2849
+
2850
+ ### Example
2851
+
2852
+ ```javascript
2853
+ import {saveMarkdownMedia} from 'arcane-os/modules/MarkdownMedia.js';
2854
+
2855
+ const mural = new Blob([
2856
+ '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"><text x="10" y="45">Octopus parking only</text></svg>'
2857
+ ], {type:'image/svg+xml'});
2858
+ const {reference} = await saveMarkdownMedia({blob:mural});
2859
+ editor.insertMarkdown(`![Octopus parking only](${reference})`);
2860
+ ```
2861
+
2862
+ Here `editor` is the ready shared Markdown Editor. Its normal change/save flow
2863
+ still owns saving the Markdown; this operation saves the image record.
2864
+
2797
2865
  ## MD.js
2798
2866
 
2799
2867
  ### Overview
@@ -3071,6 +3139,9 @@ assistant content in configured model context and ChatEntity/DBOPFS history.
3071
3139
  That assistant-only opening survives reload and empty-chat maintenance without
3072
3140
  a fabricated user turn. A second opening rejects as `AI_CHAT_OPENING_EXISTS`;
3073
3141
  an unavailable durable ChatEntity rejects as `AI_CHAT_PERSISTENCE_UNAVAILABLE`.
3142
+ If retention is disabled while the opening is being prepared, that same error
3143
+ rolls back the opening before its append; it does not leave hidden model context.
3144
+ An opening write already accepted by DBOPFS still completes normally.
3074
3145
 
3075
3146
  `send()` accepts either
3076
3147
  `{message:{content,role:'user'|'tool',tool_call_id?,message?,name?,status?,persist},...}`
@@ -3086,6 +3157,20 @@ the retained transcript, memory extraction, or DBOPFS. A nonpersistent response
3086
3157
  therefore does not open a retained structural-tool continuation. A retained
3087
3158
  structural tool result must use the persistence choice captured by its matching
3088
3159
  assistant tool call.
3160
+ An explicitly nonretained tool-result continuation uses its matching pending
3161
+ call only for that operation, then rolls back without settling or changing the
3162
+ retained call. A later retained continuation can still settle it normally.
3163
+
3164
+ The entity-wide `session.chatEntity.persist=false` applies the same no-retention
3165
+ rule to new turns and direct entity additions; it is not a disk-only switch.
3166
+ The session captures that setting when it accepts a request and observes it
3167
+ again before accepting a durable append. Changing it later cannot bring back
3168
+ discarded inputs or responses. A write already accepted by DBOPFS finishes
3169
+ normally; toggling the setting does not undo that write or rewrite saved data.
3170
+ Completed `send()` and `stream()` responses include a session-owned `retained`
3171
+ boolean outside `message` and the provider payload, identifying whether the
3172
+ turn entered recurring history. Existing retained and stored records remain
3173
+ untouched: this boundary applies to new additions and performs no migration.
3089
3174
 
3090
3175
  `history()` returns provider-safe configured model context, including the
3091
3176
  system prompt and every complete ordinary visible committed turn. Only a
@@ -3263,11 +3348,16 @@ print-only surface expands scrollable content without editing the live screen.
3263
3348
 
3264
3349
  ### Public surface
3265
3350
 
3266
- Named `createPrintView({host,content,title,active,priority=0,signal,onError,retain})`
3351
+ Named `createPrintView({host,content,title,active,priority=0,signal,onError,retain,prepare})`
3267
3352
  returns `{print,destroy}`. `host` is a connected element; synchronous callbacks
3268
3353
  return the rendered content node, string title, and whether the view is active.
3269
3354
  Optional `retain()` returns a resource-release callback. It keeps owned media
3270
3355
  available until preparation is cancelled or the browser emits `afterprint`.
3356
+ Optional `prepare(signal)` may await view-owned readiness during an explicit
3357
+ print request. After it settles, current content and title are read again
3358
+ synchronously immediately before capture. A view whose content changes during
3359
+ preparation must keep its owned media retained and reject a still-pending
3360
+ snapshot through its synchronous `content()` callback.
3271
3361
 
3272
3362
  `await print()` selects that view, waits for accessible HTML frame, image, and
3273
3363
  font readiness, and requests the browser print dialog. It returns `false` when
@@ -3281,6 +3371,8 @@ on ties. File preview uses priority `1`; editor uses `0`. Native `beforeprint`
3281
3371
  is synchronous: it snapshots currently rendered content, cannot await a newly
3282
3372
  loading resource, and cannot cancel the browser's dialog. `onError(error)` or
3283
3373
  the developer console receives complete preparation failures.
3374
+ Native printing never invokes the asynchronous `prepare` callback; prepared
3375
+ views still receive the synchronous content/title read at capture.
3284
3376
 
3285
3377
  `destroy()` unregisters the view and cancels pending preparation. Once the
3286
3378
  dialog has been requested, its snapshot and retained media remain until
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.44.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-images.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
 
@@ -0,0 +1,211 @@
1
+ import Is from 'strong-type';
2
+
3
+ const is = new Is(false);
4
+ const referencePrefix = 'arcane-media:';
5
+
6
+ /** Return the local record address, or null for an ordinary image URL. */
7
+ export function parseMarkdownMediaReference(reference) {
8
+ if (!is.string(reference) || !reference.startsWith(referencePrefix)) {
9
+ return null;
10
+ }
11
+ const address = reference.substring(referencePrefix.length);
12
+ const separator = address.indexOf('/');
13
+ if (separator < 0) {
14
+ throw new TypeError('A Markdown media reference needs a table and filename.');
15
+ }
16
+ return {
17
+ tableName: decodeURIComponent(address.substring(0, separator)),
18
+ fileName: decodeURIComponent(address.substring(separator + 1))
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Save a complete image in the app's existing DBOPFS database. The returned
24
+ * reference belongs in Markdown; the JSON-compatible record belongs in its
25
+ * separate table so the existing database backup can include it unchanged.
26
+ * Applications own filename reuse, entry association, and export selection.
27
+ * The promise settles the durable write; this operation has no abort option.
28
+ */
29
+ export async function saveMarkdownMedia({
30
+ blob,
31
+ tableName = 'markdown-media',
32
+ fileName = `${crypto.randomUUID()}.json`
33
+ }) {
34
+ const reference = `${referencePrefix}${encodeURIComponent(tableName)}/${encodeURIComponent(fileName)}`;
35
+ const encoding = new Promise(function encodeImage(resolve, reject) {
36
+ const reader = new FileReader();
37
+ reader.onload = function imageEncoded() {
38
+ resolve(reader.result);
39
+ };
40
+ reader.onerror = function imageEncodingFailed() {
41
+ reject(reader.error || new Error('Image encoding failed.'));
42
+ };
43
+ reader.onabort = function imageEncodingAborted() {
44
+ reject(new DOMException('Image encoding was aborted.', 'AbortError'));
45
+ };
46
+ reader.readAsDataURL(blob);
47
+ });
48
+ const [dataUrl, {default: DBOPFS}] = await Promise.all([
49
+ encoding,
50
+ import('./DBOPFS.js')
51
+ ]);
52
+ const mediaType = blob.type || 'application/octet-stream';
53
+ await new DBOPFS().set(tableName, fileName, {mediaType, dataUrl});
54
+ return {
55
+ reference,
56
+ tableName,
57
+ fileName,
58
+ mediaType
59
+ };
60
+ }
61
+
62
+ /** Read the complete local image for display or application-owned export. */
63
+ export async function readMarkdownMedia(reference) {
64
+ const address = parseMarkdownMediaReference(reference);
65
+ if (!address) {
66
+ throw new TypeError('Expected an arcane-media: reference.');
67
+ }
68
+ const {default: DBOPFS} = await import('./DBOPFS.js');
69
+ const stored = await new DBOPFS().get(address.tableName, address.fileName);
70
+ if (stored === null || stored === undefined) {
71
+ throw new DOMException(`Markdown image was not found: ${reference}`, 'NotFoundError');
72
+ }
73
+ // DBOPFS parses JSONL/NDJSON as record arrays; restoring a parsed backup can
74
+ // wrap its one record again. Never choose one image from multiple records.
75
+ let record = is.string(stored) ? JSON.parse(stored) : stored;
76
+ while (is.array(record) && record.length === 1) record = record[0];
77
+ const encoded = /^data:[^,]*;base64,([\s\S]*)$/u.exec(record?.dataUrl);
78
+ if (!encoded) {
79
+ throw new TypeError(`Markdown image has an unreadable data URL: ${reference}`);
80
+ }
81
+ // Decode locally; a stored string is never passed to Fetch or a network API.
82
+ const decoded = atob(encoded[1]);
83
+ const content = Uint8Array.from(decoded, function imageCharacter(character) {
84
+ return character.charCodeAt(0);
85
+ });
86
+ return new Blob([content], {type: record.mediaType});
87
+ }
88
+
89
+ /**
90
+ * Resolve only local Markdown IMG references beneath a rendered root (including
91
+ * an IMG root). Markdown source and ordinary URLs are never changed. Independent
92
+ * reads start together; ready waits for decoding or display cancellation.
93
+ * Failures reject with AggregateError.failures [{image, reference, reason}];
94
+ * successful siblings remain visible until their owner is destroyed.
95
+ *
96
+ * destroy() prevents late assignments and releases owned object URLs. A print
97
+ * owner calls retain() before copying the rendered DOM and releases its returned
98
+ * idempotent callback after printing, allowing URLs to outlive the source view.
99
+ * Abort promptly rejects display readiness. An already-started DBOPFS read
100
+ * remains observed and settles independently; its late result is not displayed.
101
+ */
102
+ export function hydrateMarkdownMedia(root, {signal} = {}) {
103
+ const images = [...root.querySelectorAll('img')];
104
+ if (root.localName === 'img') images.unshift(root);
105
+ const owned = [];
106
+ const displayCancellations = new Set();
107
+ let destroyed = false;
108
+ let retainers = 0;
109
+ let destructionReason;
110
+
111
+ function releaseURLs() {
112
+ if (!destroyed || retainers > 0) return;
113
+ for (const {image, url} of owned) {
114
+ if (image.getAttribute('src') === url) image.removeAttribute('src');
115
+ URL.revokeObjectURL(url);
116
+ }
117
+ owned.length = 0;
118
+ }
119
+
120
+ function destroy() {
121
+ if (destroyed) return;
122
+ destroyed = true;
123
+ destructionReason = signal?.aborted
124
+ ? signal.reason
125
+ : new DOMException('Markdown media display was destroyed.', 'AbortError');
126
+ signal?.removeEventListener('abort', destroy);
127
+ for (const cancel of displayCancellations) cancel();
128
+ displayCancellations.clear();
129
+ releaseURLs();
130
+ }
131
+
132
+ function retain() {
133
+ if (destroyed) throw destructionReason;
134
+ retainers += 1;
135
+ let released = false;
136
+ return function releaseMarkdownMedia() {
137
+ if (released) return;
138
+ released = true;
139
+ retainers -= 1;
140
+ releaseURLs();
141
+ };
142
+ }
143
+
144
+ function waitForDisplay(operation) {
145
+ return new Promise(function observeDisplayOperation(resolve, reject) {
146
+ let settled = false;
147
+ function settle(callback, value) {
148
+ if (settled) return;
149
+ settled = true;
150
+ displayCancellations.delete(cancelDisplay);
151
+ callback(value);
152
+ }
153
+ function cancelDisplay() {
154
+ settle(reject, destructionReason);
155
+ }
156
+ displayCancellations.add(cancelDisplay);
157
+ // Both outcomes remain observed after display cancellation; this
158
+ // wait does not claim to cancel the underlying storage or decode.
159
+ Promise.resolve(operation).then(
160
+ function displayOperationFinished(value) {settle(resolve, value);},
161
+ function displayOperationFailed(error) {settle(reject, error);}
162
+ );
163
+ if (destroyed) cancelDisplay();
164
+ });
165
+ }
166
+
167
+ async function loadImage(image, reference) {
168
+ if (destroyed) throw destructionReason;
169
+ const blob = await waitForDisplay(readMarkdownMedia(reference));
170
+ if (destroyed) throw destructionReason;
171
+ const url = URL.createObjectURL(blob);
172
+ owned.push({image, url});
173
+ image.setAttribute('src', url);
174
+ await waitForDisplay(image.decode());
175
+ if (destroyed) throw destructionReason;
176
+ }
177
+
178
+ signal?.addEventListener('abort', destroy, {once: true});
179
+ if (signal?.aborted) destroy();
180
+ const pending = [];
181
+ for (const image of images) {
182
+ const reference = image.getAttribute('src');
183
+ if (!is.string(reference) || !reference.startsWith(referencePrefix)) continue;
184
+ // Remove the custom scheme before the first asynchronous storage wait.
185
+ if (!destroyed) image.removeAttribute('src');
186
+ pending.push({image, reference, operation: loadImage(image, reference)});
187
+ }
188
+ const ready = Promise.allSettled(pending.map(function imageOperation(item) {
189
+ return item.operation;
190
+ })).then(function imagesSettled(results) {
191
+ const failures = [];
192
+ for (const [index, result] of results.entries()) {
193
+ if (result.status === 'rejected') {
194
+ failures.push({
195
+ image: pending[index].image,
196
+ reference: pending[index].reference,
197
+ reason: result.reason
198
+ });
199
+ }
200
+ }
201
+ if (failures.length > 0) {
202
+ const error = new AggregateError(failures.map(function imageFailure(failure) {
203
+ return failure.reason;
204
+ }), 'Some local Markdown images could not be displayed.');
205
+ error.failures = failures;
206
+ throw error;
207
+ }
208
+ if (destroyed) throw destructionReason;
209
+ });
210
+ return {ready, destroy, retain};
211
+ }
@@ -556,6 +556,7 @@ class PersistentAIChatSession{
556
556
  /** Persists one model-authored opening while retaining none of its bootstrap request. */
557
557
  async open(input){
558
558
  const settings=normalizeOpening(input);
559
+ const retainOpening=this.#entity.persist===true;
559
560
  const name=this.aiName;
560
561
  if(this.#pending){
561
562
  throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
@@ -565,7 +566,7 @@ class PersistentAIChatSession{
565
566
  try{
566
567
  await this.ready();
567
568
  if(this.#historyError)throw this.#historyError;
568
- if(this.#entity.persist!==true){
569
+ if(!retainOpening||this.#entity.persist!==true){
569
570
  throw coded(
570
571
  new Error('The model-authored chat opening requires durable chat persistence.'),
571
572
  'AI_CHAT_PERSISTENCE_UNAVAILABLE',
@@ -576,6 +577,15 @@ class PersistentAIChatSession{
576
577
  settings.message,
577
578
  {request:settings.request,signal:settings.signal},
578
579
  );
580
+ if(this.#entity.persist!==true){
581
+ throw coded(
582
+ new Error(
583
+ 'The model-authored chat opening requires durable chat persistence.',
584
+ {cause:prepared.response},
585
+ ),
586
+ 'AI_CHAT_PERSISTENCE_UNAVAILABLE',
587
+ );
588
+ }
579
589
  await this.#entity.addAIMessage(
580
590
  prepared.response.message.content,
581
591
  {extractMemory:false,persist:true,name},
@@ -597,6 +607,7 @@ class PersistentAIChatSession{
597
607
 
598
608
  async #requestTurn(input,streamHandlers=null){
599
609
  const settings=normalizeSend(input);
610
+ const retainTurn=settings.messagePersist&&Boolean(this.#entity.persist);
600
611
  const name=this.aiName;
601
612
  if(this.#pending){
602
613
  throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
@@ -621,7 +632,7 @@ class PersistentAIChatSession{
621
632
  if(persistence===undefined||submittedIds.has(message.tool_call_id)){
622
633
  throw coded(new TypeError('A tool message has no unique pending structural tool call.'),'AI_CHAT_INVALID_TOOL_MESSAGE');
623
634
  }
624
- if(persistence!==settings.messagePersist){
635
+ if(retainTurn&&persistence!==settings.messagePersist){
625
636
  throw coded(
626
637
  new TypeError('A tool result must use the persistence of its assistant tool call.'),
627
638
  'AI_CHAT_INCOHERENT_PERSISTENCE',
@@ -674,10 +685,10 @@ class PersistentAIChatSession{
674
685
  );
675
686
  }
676
687
  }
677
- if(!settings.messagePersist){
688
+ if(!retainTurn||!this.#entity.persist){
678
689
  prepared.rollback();
679
690
  prepared=null;
680
- return assistantDisplayResponse(result,name);
691
+ return {...assistantDisplayResponse(result,name),retained:false};
681
692
  }
682
693
  await this.#entity.addTurn({
683
694
  assistantMessage:result.message,
@@ -706,10 +717,13 @@ class PersistentAIChatSession{
706
717
  }
707
718
  const transcript=this.#entity.transcript;
708
719
  const assistantRecord=transcript.at(-1);
709
- return assistantDisplayResponse(
710
- committed,name,
711
- assistantRecord?.role==='assistant'?assistantRecord.timestamp:undefined,
712
- );
720
+ return {
721
+ ...assistantDisplayResponse(
722
+ committed,name,
723
+ assistantRecord?.role==='assistant'?assistantRecord.timestamp:undefined,
724
+ ),
725
+ retained:true,
726
+ };
713
727
  }catch(error){
714
728
  prepared?.rollback();
715
729
  throw error;
@@ -5,8 +5,9 @@ const registries=new WeakMap();
5
5
  let fontSequence=0;
6
6
 
7
7
  /**
8
- * Print current rendered DOM. Callbacks are synchronous; retain() may return a
9
- * resource release function. print() reports a browser request, never an output.
8
+ * Print current rendered DOM. prepare(signal) may await explicit-print readiness;
9
+ * other callbacks are synchronous. retain() may return a resource release function.
10
+ * print() reports a browser request, never an output.
10
11
  * Native beforeprint cannot await readiness or cancel the browser's dialog.
11
12
  */
12
13
  export function createPrintView({
@@ -17,7 +18,8 @@ export function createPrintView({
17
18
  priority=0,
18
19
  signal,
19
20
  onError,
20
- retain
21
+ retain,
22
+ prepare
21
23
  }){
22
24
  if(!host?.ownerDocument||!is.function(content)||!is.function(title)
23
25
  ||!is.function(active)||!is.finite(priority)){
@@ -27,8 +29,11 @@ export function createPrintView({
27
29
  ||(retain!==undefined&&!is.function(retain))){
28
30
  throw new TypeError('Print error and resource-retention callbacks must be functions.');
29
31
  }
32
+ if(prepare!==undefined&&!is.function(prepare)){
33
+ throw new TypeError('A print preparation callback must be a function.');
34
+ }
30
35
  const registry=documentRegistry(host.ownerDocument);
31
- const view={host,content,title,active,priority,onError,retain,destroyed:false,pending:null};
36
+ const view={host,content,title,active,priority,onError,retain,prepare,destroyed:false,pending:null};
32
37
  registry.views.add(view);
33
38
  signal?.addEventListener('abort',destroy,{once:true});
34
39
  if(signal?.aborted){
@@ -98,6 +103,10 @@ function documentRegistry(document){
98
103
  }
99
104
  const session=registry.session||startSession(registry,view);
100
105
  if(!session.stage){
106
+ if(view.prepare){
107
+ session.content=readContent(view);
108
+ session.title=readTitle(view);
109
+ }
101
110
  capture(registry,session);
102
111
  }
103
112
  session.requested=true;
@@ -153,14 +162,8 @@ function startSession(registry,view){
153
162
  if(session.release&&!is.function(session.release)){
154
163
  throw new TypeError('A print resource-retention callback must return a release function.');
155
164
  }
156
- session.content=view.content();
157
- if(!session.content||!is.function(session.content.cloneNode)){
158
- throw new TypeError('Print content must be a rendered DOM Node.');
159
- }
160
- session.title=view.title();
161
- if(!is.string(session.title)){
162
- throw new TypeError('A print title must be a string.');
163
- }
165
+ session.content=readContent(view);
166
+ session.title=readTitle(view);
164
167
  return session;
165
168
  }catch(error){
166
169
  finishSession(registry,session);
@@ -168,6 +171,22 @@ function startSession(registry,view){
168
171
  }
169
172
  }
170
173
 
174
+ function readContent(view){
175
+ const content=view.content();
176
+ if(!content||!is.function(content.cloneNode)){
177
+ throw new TypeError('Print content must be a rendered DOM Node.');
178
+ }
179
+ return content;
180
+ }
181
+
182
+ function readTitle(view){
183
+ const title=view.title();
184
+ if(!is.string(title)){
185
+ throw new TypeError('A print title must be a string.');
186
+ }
187
+ return title;
188
+ }
189
+
171
190
  async function printRendered(registry,view){
172
191
  if(registry.session){
173
192
  throw new Error('A rendered print request is already being prepared or displayed.');
@@ -186,6 +205,23 @@ async function printRendered(registry,view){
186
205
  finishSession(registry,session);
187
206
  return false;
188
207
  }
208
+ if(view.prepare){
209
+ await abortable(view.prepare(signal),signal);
210
+ if(session.error){
211
+ throw session.error;
212
+ }
213
+ if(session.requested){
214
+ return true;
215
+ }
216
+ if(signal.aborted||!available(view)){
217
+ finishSession(registry,session);
218
+ return false;
219
+ }
220
+ // Read and capture together: a changing view can reject pending
221
+ // content synchronously without an unobserved readiness gap.
222
+ session.content=readContent(view);
223
+ session.title=readTitle(view);
224
+ }
189
225
  capture(registry,session);
190
226
  const images=Array.from(session.stage.shadowRoot.querySelectorAll('img'));
191
227
  await Promise.all(images.map(function readyImage(image){
@@ -224,6 +260,9 @@ async function printRendered(registry,view){
224
260
  if(cancelled){
225
261
  return false;
226
262
  }
263
+ if(view.prepare&&session.error){
264
+ throw session.error;
265
+ }
227
266
  throw error;
228
267
  }
229
268
  }