arcane-os 0.44.0 → 0.46.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.
@@ -0,0 +1,239 @@
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
+ /**
63
+ * Decode a complete local media record or its JSON text without storage or
64
+ * network access. Unrecognized content returns null; recognized records with
65
+ * unreadable encoding throw. Records carry dataUrl and an optional mediaType;
66
+ * omitting mediaType preserves Blob's empty type. No source content is changed.
67
+ */
68
+ export function decodeMarkdownMediaRecord(value) {
69
+ let record = value;
70
+ if (is.string(record)) {
71
+ try {
72
+ record = JSON.parse(record);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ // DBOPFS parses JSONL/NDJSON as record arrays; restoring a parsed backup can
78
+ // wrap its one record again. Never choose one image from multiple records.
79
+ while (is.array(record) && record.length === 1) record = record[0];
80
+ if (!record || !is.object(record) || is.array(record) || !('dataUrl' in record)) return null;
81
+ const encoded = /^data:[^,]*;base64,([\s\S]*)$/u.exec(record.dataUrl);
82
+ if (!encoded) {
83
+ throw new TypeError('Markdown image has an unreadable data URL.');
84
+ }
85
+ // Decode locally; a stored string is never passed to Fetch or a network API.
86
+ const decoded = atob(encoded[1]);
87
+ const content = Uint8Array.from(decoded, function imageCharacter(character) {
88
+ return character.charCodeAt(0);
89
+ });
90
+ return new Blob([content], {type: record.mediaType});
91
+ }
92
+
93
+ /** Read the complete local image for display or application-owned export. */
94
+ export async function readMarkdownMedia(reference) {
95
+ const address = parseMarkdownMediaReference(reference);
96
+ if (!address) {
97
+ throw new TypeError('Expected an arcane-media: reference.');
98
+ }
99
+ const {default: DBOPFS} = await import('./DBOPFS.js');
100
+ const stored = await new DBOPFS().get(address.tableName, address.fileName);
101
+ if (stored === null || stored === undefined) {
102
+ throw new DOMException(`Markdown image was not found: ${reference}`, 'NotFoundError');
103
+ }
104
+ // Preserve the strict stored-JSON parser boundary for explicit reads.
105
+ const record = is.string(stored) ? JSON.parse(stored) : stored;
106
+ const unreadable = `Markdown image has an unreadable data URL: ${reference}`;
107
+ try {
108
+ const blob = decodeMarkdownMediaRecord(record);
109
+ if (blob) return blob;
110
+ } catch (error) {
111
+ if (!(error instanceof TypeError)) throw error;
112
+ throw new TypeError(unreadable, {cause: error});
113
+ }
114
+ throw new TypeError(unreadable);
115
+ }
116
+
117
+ /**
118
+ * Resolve only local Markdown IMG references beneath a rendered root (including
119
+ * an IMG root). Markdown source and ordinary URLs are never changed. Independent
120
+ * reads start together; ready waits for decoding or display cancellation.
121
+ * Failures reject with AggregateError.failures [{image, reference, reason}];
122
+ * successful siblings remain visible until their owner is destroyed.
123
+ *
124
+ * destroy() prevents late assignments and releases owned object URLs. A print
125
+ * owner calls retain() before copying the rendered DOM and releases its returned
126
+ * idempotent callback after printing, allowing URLs to outlive the source view.
127
+ * Abort promptly rejects display readiness. An already-started DBOPFS read
128
+ * remains observed and settles independently; its late result is not displayed.
129
+ */
130
+ export function hydrateMarkdownMedia(root, {signal} = {}) {
131
+ const images = [...root.querySelectorAll('img')];
132
+ if (root.localName === 'img') images.unshift(root);
133
+ const owned = [];
134
+ const displayCancellations = new Set();
135
+ let destroyed = false;
136
+ let retainers = 0;
137
+ let destructionReason;
138
+
139
+ function releaseURLs() {
140
+ if (!destroyed || retainers > 0) return;
141
+ for (const {image, url} of owned) {
142
+ if (image.getAttribute('src') === url) image.removeAttribute('src');
143
+ URL.revokeObjectURL(url);
144
+ }
145
+ owned.length = 0;
146
+ }
147
+
148
+ function destroy() {
149
+ if (destroyed) return;
150
+ destroyed = true;
151
+ destructionReason = signal?.aborted
152
+ ? signal.reason
153
+ : new DOMException('Markdown media display was destroyed.', 'AbortError');
154
+ signal?.removeEventListener('abort', destroy);
155
+ for (const cancel of displayCancellations) cancel();
156
+ displayCancellations.clear();
157
+ releaseURLs();
158
+ }
159
+
160
+ function retain() {
161
+ if (destroyed) throw destructionReason;
162
+ retainers += 1;
163
+ let released = false;
164
+ return function releaseMarkdownMedia() {
165
+ if (released) return;
166
+ released = true;
167
+ retainers -= 1;
168
+ releaseURLs();
169
+ };
170
+ }
171
+
172
+ function waitForDisplay(operation) {
173
+ return new Promise(function observeDisplayOperation(resolve, reject) {
174
+ let settled = false;
175
+ function settle(callback, value) {
176
+ if (settled) return;
177
+ settled = true;
178
+ displayCancellations.delete(cancelDisplay);
179
+ callback(value);
180
+ }
181
+ function cancelDisplay() {
182
+ settle(reject, destructionReason);
183
+ }
184
+ displayCancellations.add(cancelDisplay);
185
+ // Both outcomes remain observed after display cancellation; this
186
+ // wait does not claim to cancel the underlying storage or decode.
187
+ Promise.resolve(operation).then(
188
+ function displayOperationFinished(value) {settle(resolve, value);},
189
+ function displayOperationFailed(error) {settle(reject, error);}
190
+ );
191
+ if (destroyed) cancelDisplay();
192
+ });
193
+ }
194
+
195
+ async function loadImage(image, reference) {
196
+ if (destroyed) throw destructionReason;
197
+ const blob = await waitForDisplay(readMarkdownMedia(reference));
198
+ if (destroyed) throw destructionReason;
199
+ const url = URL.createObjectURL(blob);
200
+ owned.push({image, url});
201
+ image.setAttribute('src', url);
202
+ await waitForDisplay(image.decode());
203
+ if (destroyed) throw destructionReason;
204
+ }
205
+
206
+ signal?.addEventListener('abort', destroy, {once: true});
207
+ if (signal?.aborted) destroy();
208
+ const pending = [];
209
+ for (const image of images) {
210
+ const reference = image.getAttribute('src');
211
+ if (!is.string(reference) || !reference.startsWith(referencePrefix)) continue;
212
+ // Remove the custom scheme before the first asynchronous storage wait.
213
+ if (!destroyed) image.removeAttribute('src');
214
+ pending.push({image, reference, operation: loadImage(image, reference)});
215
+ }
216
+ const ready = Promise.allSettled(pending.map(function imageOperation(item) {
217
+ return item.operation;
218
+ })).then(function imagesSettled(results) {
219
+ const failures = [];
220
+ for (const [index, result] of results.entries()) {
221
+ if (result.status === 'rejected') {
222
+ failures.push({
223
+ image: pending[index].image,
224
+ reference: pending[index].reference,
225
+ reason: result.reason
226
+ });
227
+ }
228
+ }
229
+ if (failures.length > 0) {
230
+ const error = new AggregateError(failures.map(function imageFailure(failure) {
231
+ return failure.reason;
232
+ }), 'Some local Markdown images could not be displayed.');
233
+ error.failures = failures;
234
+ throw error;
235
+ }
236
+ if (destroyed) throw destructionReason;
237
+ });
238
+ return {ready, destroy, retain};
239
+ }
@@ -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;
@@ -1,12 +1,14 @@
1
1
  import Is from 'strong-type';
2
+ import {arcaneLightThemeTokens} from '../entities/Theme.js';
2
3
 
3
4
  const is=new Is(false);
4
5
  const registries=new WeakMap();
5
6
  let fontSequence=0;
6
7
 
7
8
  /**
8
- * Print current rendered DOM. Callbacks are synchronous; retain() may return a
9
- * resource release function. print() reports a browser request, never an output.
9
+ * Print current rendered DOM. prepare(signal) may await explicit-print readiness;
10
+ * other callbacks are synchronous. retain() may return a resource release function.
11
+ * print() reports a browser request, never an output.
10
12
  * Native beforeprint cannot await readiness or cancel the browser's dialog.
11
13
  */
12
14
  export function createPrintView({
@@ -17,7 +19,8 @@ export function createPrintView({
17
19
  priority=0,
18
20
  signal,
19
21
  onError,
20
- retain
22
+ retain,
23
+ prepare
21
24
  }){
22
25
  if(!host?.ownerDocument||!is.function(content)||!is.function(title)
23
26
  ||!is.function(active)||!is.finite(priority)){
@@ -27,8 +30,11 @@ export function createPrintView({
27
30
  ||(retain!==undefined&&!is.function(retain))){
28
31
  throw new TypeError('Print error and resource-retention callbacks must be functions.');
29
32
  }
33
+ if(prepare!==undefined&&!is.function(prepare)){
34
+ throw new TypeError('A print preparation callback must be a function.');
35
+ }
30
36
  const registry=documentRegistry(host.ownerDocument);
31
- const view={host,content,title,active,priority,onError,retain,destroyed:false,pending:null};
37
+ const view={host,content,title,active,priority,onError,retain,prepare,destroyed:false,pending:null};
32
38
  registry.views.add(view);
33
39
  signal?.addEventListener('abort',destroy,{once:true});
34
40
  if(signal?.aborted){
@@ -98,6 +104,10 @@ function documentRegistry(document){
98
104
  }
99
105
  const session=registry.session||startSession(registry,view);
100
106
  if(!session.stage){
107
+ if(view.prepare){
108
+ session.content=readContent(view);
109
+ session.title=readTitle(view);
110
+ }
101
111
  capture(registry,session);
102
112
  }
103
113
  session.requested=true;
@@ -153,14 +163,8 @@ function startSession(registry,view){
153
163
  if(session.release&&!is.function(session.release)){
154
164
  throw new TypeError('A print resource-retention callback must return a release function.');
155
165
  }
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
- }
166
+ session.content=readContent(view);
167
+ session.title=readTitle(view);
164
168
  return session;
165
169
  }catch(error){
166
170
  finishSession(registry,session);
@@ -168,6 +172,22 @@ function startSession(registry,view){
168
172
  }
169
173
  }
170
174
 
175
+ function readContent(view){
176
+ const content=view.content();
177
+ if(!content||!is.function(content.cloneNode)){
178
+ throw new TypeError('Print content must be a rendered DOM Node.');
179
+ }
180
+ return content;
181
+ }
182
+
183
+ function readTitle(view){
184
+ const title=view.title();
185
+ if(!is.string(title)){
186
+ throw new TypeError('A print title must be a string.');
187
+ }
188
+ return title;
189
+ }
190
+
171
191
  async function printRendered(registry,view){
172
192
  if(registry.session){
173
193
  throw new Error('A rendered print request is already being prepared or displayed.');
@@ -186,6 +206,23 @@ async function printRendered(registry,view){
186
206
  finishSession(registry,session);
187
207
  return false;
188
208
  }
209
+ if(view.prepare){
210
+ await abortable(view.prepare(signal),signal);
211
+ if(session.error){
212
+ throw session.error;
213
+ }
214
+ if(session.requested){
215
+ return true;
216
+ }
217
+ if(signal.aborted||!available(view)){
218
+ finishSession(registry,session);
219
+ return false;
220
+ }
221
+ // Read and capture together: a changing view can reject pending
222
+ // content synchronously without an unobserved readiness gap.
223
+ session.content=readContent(view);
224
+ session.title=readTitle(view);
225
+ }
189
226
  capture(registry,session);
190
227
  const images=Array.from(session.stage.shadowRoot.querySelectorAll('img'));
191
228
  await Promise.all(images.map(function readyImage(image){
@@ -224,6 +261,9 @@ async function printRendered(registry,view){
224
261
  if(cancelled){
225
262
  return false;
226
263
  }
264
+ if(view.prepare&&session.error){
265
+ throw session.error;
266
+ }
227
267
  throw error;
228
268
  }
229
269
  }
@@ -361,17 +401,36 @@ function capture(registry,session){
361
401
  const heading=document.createElement('h1');
362
402
  heading.textContent=session.title;
363
403
  const rendered=copyRendered(session.content,state,true);
364
- styles.textContent=state.rules.join('\n');
404
+ // Screen layout and fonts describe the rendered content; screen-theme paint
405
+ // does not describe a readable paper surface. Keep this palette in the
406
+ // snapshot, including its pseudo-elements, without recoloring SVG artwork.
407
+ styles.textContent=`
408
+ :host{color-scheme:light!important;color:${arcaneLightThemeTokens.text}!important;background:${arcaneLightThemeTokens.surface}!important;font:12pt/1.5 serif}
409
+ :host>h1{margin:0 0 .75em;font-size:1.5em;line-height:1.2}
410
+ [data-arcane-print-node]:not(svg,svg *,img,video,audio),
411
+ [data-arcane-print-node]:not(svg,svg *,img,video,audio)::before,
412
+ [data-arcane-print-node]:not(svg,svg *,img,video,audio)::after{
413
+ color:${arcaneLightThemeTokens.text}!important;background-color:transparent!important;
414
+ border-color:${arcaneLightThemeTokens.border}!important;color-scheme:light!important;
415
+ -webkit-text-fill-color:currentColor!important;-webkit-text-stroke:0!important;
416
+ text-decoration-color:currentColor!important;text-shadow:none!important;box-shadow:none!important
417
+ }
418
+ a[data-arcane-print-node]:not(svg *){text-decoration:underline!important}
419
+ table[data-arcane-print-node]:not(svg *){border-collapse:collapse!important}
420
+ th[data-arcane-print-node]:not(svg *),td[data-arcane-print-node]:not(svg *){border:1px solid ${arcaneLightThemeTokens.border}!important;padding:.35em .5em!important}
421
+ ${state.rules.join('\n')}
422
+ `;
365
423
  root.append(styles,heading,rendered);
366
424
  const sheet=document.createElement('style');
367
425
  session.sheet=sheet;
368
426
  sheet.textContent=`
369
427
  [data-arcane-print-stage]{display:none!important}
370
428
  @media print{
371
- html:root,html:root>body{display:block!important;height:auto!important;min-height:0!important;max-height:none!important;overflow:visible!important;contain:none!important}
429
+ @page{margin:15mm}
430
+ html:root,html:root>body{display:block!important;height:auto!important;min-height:0!important;max-height:none!important;overflow:visible!important;contain:none!important;margin:0!important;padding:0!important;border:0!important;background:${arcaneLightThemeTokens.surface}!important;color:${arcaneLightThemeTokens.text}!important;color-scheme:light!important}
372
431
  html:root>body>*:not([data-arcane-print-stage]){display:none!important}
373
432
  html:root>body::before,html:root>body::after{display:none!important}
374
- html:root>body>[data-arcane-print-stage]{display:block!important;position:static!important;width:auto!important;height:auto!important;overflow:visible!important}
433
+ html:root>body>[data-arcane-print-stage]{display:block!important;position:static!important;width:auto!important;height:auto!important;overflow:visible!important;margin:0!important;padding:0!important;border:0!important}
375
434
  }
376
435
  `;
377
436
  document.head.append(sheet);