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.
@@ -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
  }