arcane-os 0.5.6 → 0.5.7

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7
4
+
5
+ - Added `PersistentAIChatSession.open()` for model-authored conversation
6
+ openings. Its application-authored bootstrap remains transient, while the
7
+ complete nonblank assistant response is committed atomically as durable
8
+ assistant-only chat history and survives maintenance and reload.
9
+
3
10
  ## 0.5.6
4
11
 
5
12
  - Sanitized newly persisted chat history into complete human-readable user,
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 preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.5.6` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.5.7` 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
 
@@ -176,8 +176,14 @@ The SDK runtime also owns `DBOPFSDocumentLibrary`,
176
176
  schema-driven and explicit; chat never searches a corpus unless the app wires
177
177
  that library into the request context builder. Persistent chat automatically
178
178
  maintains recurring model context and `ChatEntity` history/memory. A turn may
179
- set `persist:false` to remain in the current session context without entering
180
- durable chat or memory. `createArcaneAI(...).createChatSession(options)` wires
179
+ set `persist:false` to participate in one request and response only; after that
180
+ operation settles, neither side remains in subsequent model context, the
181
+ retained transcript, durable chat, memory, or DBOPFS.
182
+ For an automatic model-authored opening, call
183
+ `session.open({message:{content:bootstrap,persist:false}})`: the bootstrap is
184
+ request-only, while the complete nonblank assistant response becomes the first
185
+ durable conversation row without a fabricated user turn.
186
+ `createArcaneAI(...).createChatSession(options)` wires
181
187
  that session to the same selected LLM controller, creates its `ChatEntity`, and
182
188
  uses the same controller for automatic memory extraction.
183
189
 
@@ -186,7 +192,7 @@ uses the same controller for automatic memory extraction.
186
192
  Create a new repository-shaped Arcane application with the exact stable SDK:
187
193
 
188
194
  ```bash
189
- npx arcane-os@0.5.6 new my-app --path ./my-app --target portable --git
195
+ npx arcane-os@0.5.7 new my-app --path ./my-app --target portable --git
190
196
  cd my-app
191
197
  npm install
192
198
  npm run check
@@ -197,7 +203,7 @@ To enroll an existing repository, install the exact SDK and initialize only
197
203
  missing Arcane files:
198
204
 
199
205
  ```bash
200
- npm install --save-dev --save-exact arcane-os@0.5.6
206
+ npm install --save-dev --save-exact arcane-os@0.5.7
201
207
  npm exec -- arcane init my-app --target portable
202
208
  ```
203
209
 
@@ -213,7 +219,7 @@ npm exec -- arcane-os targets
213
219
  No global SDK install or standalone Arcane CLI is required. The application
214
220
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
215
221
 
216
- Use `npx arcane-os@0.5.6` for the initial bootstrap because it names this npm
222
+ Use `npx arcane-os@0.5.7` for the initial bootstrap because it names this npm
217
223
  package explicitly; bare `npx arcane` outside an installed project could resolve
218
224
  a different package. Both installed commands invoke the same headless toolchain.
219
225
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -234,7 +240,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
234
240
 
235
241
  # From the generated app repository
236
242
  cd ../local-app
237
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.6.tgz
243
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.7.tgz
238
244
  npm run check
239
245
  npm ci
240
246
  ```
@@ -244,7 +250,7 @@ same location. The lockfile retains the selected package dependency while
244
250
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
245
251
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
246
252
  runner also needs that tarball at the locked path. After publication, replace
247
- the local declaration with the exact `arcane-os@0.5.6` registry package and
253
+ the local declaration with the exact `arcane-os@0.5.7` registry package and
248
254
  commit the regenerated lock.
249
255
 
250
256
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -382,7 +388,7 @@ package installation, or assertions.
382
388
 
383
389
  ## Current target support
384
390
 
385
- Version `0.5.6` exposes one browser target and five explicitly paired
391
+ Version `0.5.7` exposes one browser target and five explicitly paired
386
392
  native development targets: a non-runnable portable directory, a
387
393
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
388
394
  unsigned-local-test DEBs, and an Android development-signed APK. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
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",
@@ -1,7 +1,7 @@
1
1
  import Is from '../../node_modules/strong-type/index.js';
2
2
  import '../modules/DBOPFS.js';
3
3
  import '../modules/AI.js';
4
- import {hasUserEntry} from '../modules/ChatRecords.js';
4
+ import {hasConversationEntry,hasUserEntry} from '../modules/ChatRecords.js';
5
5
  import {normalizeMemoryContent} from '../modules/MemoryRecords.js';
6
6
 
7
7
  const is = new Is(false);
@@ -925,7 +925,7 @@ ${JSON.stringify(transcript)}`
925
925
  * @returns {Promise<*>}
926
926
  */
927
927
  async save(){
928
- if(!hasUserEntry(this.#durableMessages())){
928
+ if(!hasConversationEntry(this.#durableMessages())){
929
929
  this.#saved=false;
930
930
  return false;
931
931
  }
@@ -1073,7 +1073,7 @@ ${JSON.stringify(transcript)}`
1073
1073
  return;
1074
1074
  }
1075
1075
 
1076
- if(!hasUserEntry(this.#durableMessages())){
1076
+ if(!hasConversationEntry(this.#durableMessages())){
1077
1077
  return false;
1078
1078
  }
1079
1079
 
@@ -8,6 +8,20 @@ function hasUserEntry(chat=[]){
8
8
  );
9
9
  }
10
10
 
11
+ function hasConversationEntry(chat=[]){
12
+ const messages=Array.isArray(chat)
13
+ ?chat
14
+ :chat?.messages||[];
15
+
16
+ return hasUserEntry(messages)||messages.some(
17
+ message=>
18
+ message?.role==='assistant'
19
+ &&typeof message.content==='string'
20
+ &&Boolean(message.content.trim())
21
+ );
22
+ }
23
+
11
24
  export {
25
+ hasConversationEntry,
12
26
  hasUserEntry
13
27
  };
@@ -584,6 +584,83 @@ export default class ConfiguredAIChatSession{
584
584
  return context;
585
585
  }
586
586
 
587
+ /** Prepares one transient bootstrap request whose assistant response alone may be committed. */
588
+ async prepareOpening(input,options={}){
589
+ if(!isPlainRecord(options)) throw new TypeError('Chat opening options must be a plain object.');
590
+ const unsupported=Object.keys(options).find(key=>!['request','signal'].includes(key));
591
+ if(unsupported) throw new TypeError(`Unsupported chat opening option: ${unsupported}`);
592
+ if(!signalLike(options.signal)) throw new TypeError('signal must be an AbortSignal.');
593
+ if(options.signal?.aborted) throw abortError();
594
+ const turnRequest=normalizeRequestOptions(options.request,'request');
595
+ const [normalizedInputMessage]=normalizeInputMessages(input);
596
+ if(normalizedInputMessage.role!=='user'){
597
+ throw new TypeError('The chat opening bootstrap must be a user message.');
598
+ }
599
+ const inputMessage=message('user',normalizedInputMessage.content);
600
+ if(this.#pending){
601
+ throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
602
+ }
603
+ if(this.#conversation.length){
604
+ throw coded(
605
+ new Error('The chat already contains a retained conversation turn.'),
606
+ 'AI_CHAT_OPENING_EXISTS',
607
+ );
608
+ }
609
+ this.#pending=true;
610
+ try{
611
+ const context=await this.#contextFor(inputMessage.content,options.signal);
612
+ if(options.signal?.aborted) throw abortError();
613
+ const transientContext=context
614
+ ?message('user',context)
615
+ :null;
616
+ const requestMessages=completeHistory(
617
+ this.#systemPrompt,
618
+ [...(transientContext?[transientContext]:[]),inputMessage],
619
+ );
620
+ let providerResponse;
621
+ try{
622
+ providerResponse=await this.#chat({
623
+ ...this.#request,
624
+ ...turnRequest,
625
+ ...(options.signal?{signal:options.signal}:{}),
626
+ messages:requestMessages.map(publicMessage),
627
+ });
628
+ }catch(error){
629
+ if(options.signal?.aborted) throw abortError();
630
+ throw error;
631
+ }
632
+ const response=normalizeResponse(providerResponse);
633
+ if(options.signal?.aborted) throw abortError();
634
+ if(response.message.tool_calls?.length||!response.message.content.trim()){
635
+ throw coded(
636
+ new TypeError('The model-authored chat opening must contain visible assistant text.'),
637
+ 'AI_CHAT_INVALID_OPENING_RESPONSE',
638
+ );
639
+ }
640
+ const openingMessage=message('assistant',response.message.content);
641
+ let settled=false;
642
+ return {
643
+ response,
644
+ commit:()=>{
645
+ if(settled) throw coded(new Error('The prepared chat opening is already settled.'),'AI_CHAT_TRANSACTION_SETTLED');
646
+ this.#conversation=[openingMessage];
647
+ settled=true;
648
+ this.#pending=false;
649
+ return response;
650
+ },
651
+ rollback:()=>{
652
+ if(settled) return false;
653
+ settled=true;
654
+ this.#pending=false;
655
+ return true;
656
+ },
657
+ };
658
+ }catch(error){
659
+ this.#pending=false;
660
+ throw error;
661
+ }
662
+ }
663
+
587
664
  async prepare(input,options={}){
588
665
  if(!isPlainRecord(options)) throw new TypeError('Chat send options must be a plain object.');
589
666
  const unsupported=Object.keys(options).find(key=>!['request','signal'].includes(key));
@@ -1,5 +1,5 @@
1
1
  import './DBOPFS.js';
2
- import {hasUserEntry} from './ChatRecords.js';
2
+ import {hasConversationEntry,hasUserEntry} from './ChatRecords.js';
3
3
  import {hasMemoryContent} from './MemoryRecords.js';
4
4
  import {arcaneEvents} from 'arcane-os/event-manager';
5
5
 
@@ -16,7 +16,7 @@ async function clearEmptyChatsAndMemories(){
16
16
  true
17
17
  );
18
18
 
19
- if(!hasUserEntry(chat)){
19
+ if(!hasConversationEntry(chat)){
20
20
  emptyChats.push(chatFileNames[i]);
21
21
  }
22
22
  }
@@ -96,6 +96,7 @@ function waitForDBOPFS(){
96
96
 
97
97
  export {
98
98
  clearEmptyChatsAndMemories,
99
+ hasConversationEntry,
99
100
  hasMemoryContent,
100
101
  hasUserEntry
101
102
  };
@@ -286,6 +286,35 @@ function normalizeSend(input){
286
286
  };
287
287
  }
288
288
 
289
+ function normalizeOpening(input){
290
+ if(!isPlainRecord(input)) throw new TypeError('Persistent chat opening input must be a plain object.');
291
+ assertKnownKeys(input,new Set(['message','request','signal']),'Persistent chat opening input');
292
+ if(!isPlainRecord(input.message)) throw new TypeError('message must be a plain object.');
293
+ assertKnownKeys(input.message,new Set(['content','persist','role']),'message');
294
+ if(typeof input.message.content!=='string'||!input.message.content.trim()){
295
+ throw new TypeError('message.content must contain text.');
296
+ }
297
+ const role=input.message.role??'user';
298
+ if(role!=='user') throw new TypeError('message.role must be user.');
299
+ if(input.message.persist!==undefined&&input.message.persist!==false){
300
+ throw new TypeError('The chat opening bootstrap is always nonpersistent.');
301
+ }
302
+ const request=input.request??{};
303
+ if(!isPlainRecord(request)) throw new TypeError('request must be a plain object.');
304
+ const managedRequestField=Object.keys(request).find(
305
+ key=>SESSION_MANAGED_REQUEST_FIELDS.has(key)
306
+ );
307
+ if(managedRequestField){
308
+ throw new TypeError(`request.${managedRequestField} is managed by the chat session.`);
309
+ }
310
+ if(!signalLike(input.signal)) throw new TypeError('signal must be an AbortSignal.');
311
+ return {
312
+ message:{role:'user',content:input.message.content},
313
+ request:{...request},
314
+ signal:input.signal??null,
315
+ };
316
+ }
317
+
289
318
  function fileName(value){
290
319
  if(typeof value!=='string'||value.length===0){
291
320
  throw new TypeError('chatFileName must be a nonempty string.');
@@ -506,6 +535,54 @@ class PersistentAIChatSession{
506
535
  return this.#entity.settleMemory();
507
536
  }
508
537
 
538
+ /** Persists one model-authored opening while retaining none of its bootstrap request. */
539
+ async open(input){
540
+ const settings=normalizeOpening(input);
541
+ if(this.#pending){
542
+ throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
543
+ }
544
+ this.#pending=true;
545
+ let prepared=null;
546
+ try{
547
+ await this.ready();
548
+ if(this.#historyError)throw this.#historyError;
549
+ if(this.#entity.persist!==true){
550
+ throw coded(
551
+ new Error('The model-authored chat opening requires durable chat persistence.'),
552
+ 'AI_CHAT_PERSISTENCE_UNAVAILABLE',
553
+ );
554
+ }
555
+ await this.#entity.settleMemory();
556
+ prepared=await this.#configured.prepareOpening(
557
+ settings.message,
558
+ {request:settings.request,signal:settings.signal},
559
+ );
560
+ await this.#entity.addAIMessage(
561
+ prepared.response.message.content,
562
+ {extractMemory:false,persist:true},
563
+ );
564
+ const committed=prepared.commit();
565
+ prepared=null;
566
+ const assistantRecord=this.#entity.transcript.at(-1);
567
+ return assistantRecord?.role==='assistant'
568
+ ?{
569
+ ...committed,
570
+ message:{
571
+ ...committed.message,
572
+ ...(assistantRecord.timestamp!==undefined
573
+ ?{timestamp:assistantRecord.timestamp}
574
+ :{}),
575
+ },
576
+ }
577
+ :committed;
578
+ }catch(error){
579
+ prepared?.rollback();
580
+ throw error;
581
+ }finally{
582
+ this.#pending=false;
583
+ }
584
+ }
585
+
509
586
  async #requestTurn(input,streamHandlers=null){
510
587
  const settings=normalizeSend(input);
511
588
  if(this.#pending){