arcane-os 0.5.6 → 0.5.8

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.8
4
+
5
+ - Added provider-neutral `reasoningEffort` to `AI.fetchRequest()` and
6
+ `AI.streamRequest()`, accepting `none`, `low`, `medium`, `high`, or
7
+ `max`. TWiN Cloud forwards an explicit value as `reasoning_effort`, omits
8
+ the field when unspecified, and preserves explicit
9
+ `openai-gpt-oss-120b` or `openai-gpt-oss-20b` model selection with the
10
+ existing streaming and structural-tool behavior.
11
+
12
+ ## 0.5.7
13
+
14
+ - Added `PersistentAIChatSession.open()` for model-authored conversation
15
+ openings. Its application-authored bootstrap remains transient, while the
16
+ complete nonblank assistant response is committed atomically as durable
17
+ assistant-only chat history and survives maintenance and reload.
18
+
3
19
  ## 0.5.6
4
20
 
5
21
  - 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.8` 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
 
@@ -93,6 +93,13 @@ on device: Whisper (`LOCAL_SPEACH` / `whisper-small`) owns transcription and
93
93
  Kokoro (`LOCAL_SPEACH` / `kokoro`) owns speech synthesis. Neither audio route
94
94
  uses the TWiN key, and no OpenAI audio key is required.
95
95
 
96
+ `fetchRequest()` and `streamRequest()` accept the provider-neutral
97
+ `reasoningEffort` option with `none`, `low`, `medium`, `high`, or `max`. TWiN
98
+ Cloud maps it to DigitalOcean Serverless Inference `reasoning_effort`; omitting
99
+ it preserves the provider default. TWiN Cloud defaults to
100
+ `openai-gpt-oss-120b`, and applications may explicitly select
101
+ `openai-gpt-oss-20b` without changing streaming or structural-tool behavior.
102
+
96
103
  ## Browser-local AI
97
104
 
98
105
  `arcane-os/ai/browser-wasm` provides the shared Wllama LLM provider. The app
@@ -176,8 +183,14 @@ The SDK runtime also owns `DBOPFSDocumentLibrary`,
176
183
  schema-driven and explicit; chat never searches a corpus unless the app wires
177
184
  that library into the request context builder. Persistent chat automatically
178
185
  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
186
+ set `persist:false` to participate in one request and response only; after that
187
+ operation settles, neither side remains in subsequent model context, the
188
+ retained transcript, durable chat, memory, or DBOPFS.
189
+ For an automatic model-authored opening, call
190
+ `session.open({message:{content:bootstrap,persist:false}})`: the bootstrap is
191
+ request-only, while the complete nonblank assistant response becomes the first
192
+ durable conversation row without a fabricated user turn.
193
+ `createArcaneAI(...).createChatSession(options)` wires
181
194
  that session to the same selected LLM controller, creates its `ChatEntity`, and
182
195
  uses the same controller for automatic memory extraction.
183
196
 
@@ -186,7 +199,7 @@ uses the same controller for automatic memory extraction.
186
199
  Create a new repository-shaped Arcane application with the exact stable SDK:
187
200
 
188
201
  ```bash
189
- npx arcane-os@0.5.6 new my-app --path ./my-app --target portable --git
202
+ npx arcane-os@0.5.8 new my-app --path ./my-app --target portable --git
190
203
  cd my-app
191
204
  npm install
192
205
  npm run check
@@ -197,7 +210,7 @@ To enroll an existing repository, install the exact SDK and initialize only
197
210
  missing Arcane files:
198
211
 
199
212
  ```bash
200
- npm install --save-dev --save-exact arcane-os@0.5.6
213
+ npm install --save-dev --save-exact arcane-os@0.5.8
201
214
  npm exec -- arcane init my-app --target portable
202
215
  ```
203
216
 
@@ -213,7 +226,7 @@ npm exec -- arcane-os targets
213
226
  No global SDK install or standalone Arcane CLI is required. The application
214
227
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
215
228
 
216
- Use `npx arcane-os@0.5.6` for the initial bootstrap because it names this npm
229
+ Use `npx arcane-os@0.5.8` for the initial bootstrap because it names this npm
217
230
  package explicitly; bare `npx arcane` outside an installed project could resolve
218
231
  a different package. Both installed commands invoke the same headless toolchain.
219
232
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -234,7 +247,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
234
247
 
235
248
  # From the generated app repository
236
249
  cd ../local-app
237
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.6.tgz
250
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.8.tgz
238
251
  npm run check
239
252
  npm ci
240
253
  ```
@@ -244,7 +257,7 @@ same location. The lockfile retains the selected package dependency while
244
257
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
245
258
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
246
259
  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
260
+ the local declaration with the exact `arcane-os@0.5.8` registry package and
248
261
  commit the regenerated lock.
249
262
 
250
263
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -382,7 +395,7 @@ package installation, or assertions.
382
395
 
383
396
  ## Current target support
384
397
 
385
- Version `0.5.6` exposes one browser target and five explicitly paired
398
+ Version `0.5.8` exposes one browser target and five explicitly paired
386
399
  native development targets: a non-runnable portable directory, a
387
400
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
388
401
  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.8",
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
 
@@ -30,6 +30,7 @@ const TTS_ANY_PUNCTUATION_AT_END=/(?<!\p{P})(?:(?<![\p{L}\p{N}])\p{P}+|\p{P}+(?!
30
30
  credentials='omit';
31
31
 
32
32
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
33
+ const AI_REASONING_EFFORTS=new Set(['none','low','medium','high','max']);
33
34
  export const AI_READY_EVENT='ai-ready';
34
35
  const AI_TTS_FAILURE_EVENT='ai-tts-failure';
35
36
  export const AI_INITIALIZATION_ERROR_CODES=completeValue({
@@ -128,6 +129,20 @@ function normalizeAIRequestAbort(error){
128
129
  return normalized;
129
130
  }
130
131
 
132
+ function normalizeAIReasoningEffort(value){
133
+ if(value===undefined||value===null||value===''){
134
+ return '';
135
+ }
136
+ if(typeof value!=='string'||!AI_REASONING_EFFORTS.has(value)){
137
+ const error=new TypeError(
138
+ 'AI reasoningEffort must be none, low, medium, high, or max.'
139
+ );
140
+ error.code='AI_REASONING_EFFORT_INVALID';
141
+ throw error;
142
+ }
143
+ return value;
144
+ }
145
+
131
146
  function legacyAIProviderError(message,code,cause){
132
147
  const error=cause===undefined
133
148
  ?new Error(message)
@@ -2000,7 +2015,7 @@ class AI {
2000
2015
  ||model;
2001
2016
  }
2002
2017
  if(service==='OPENAI'){
2003
- return this.#models.OPENAI;
2018
+ return model==='OPENAI'?this.#models.OPENAI:model;
2004
2019
  }
2005
2020
  return model;
2006
2021
  }
@@ -4126,7 +4141,8 @@ class AI {
4126
4141
  parallelToolCalls,
4127
4142
  payload.id??Date.now(),
4128
4143
  function ignoreLegacyLLMProviderRequest(){},
4129
- signal
4144
+ signal,
4145
+ payload.reasoningEffort
4130
4146
  );
4131
4147
  }
4132
4148
 
@@ -4153,7 +4169,9 @@ class AI {
4153
4169
  payload.structuredOutput??false,
4154
4170
  false,
4155
4171
  true,
4156
- emitLegacyLLMStreamData
4172
+ emitLegacyLLMStreamData,
4173
+ function ignoreLegacyLLMStreamResult(){},
4174
+ payload.reasoningEffort
4157
4175
  );
4158
4176
  }
4159
4177
 
@@ -4323,9 +4341,13 @@ class AI {
4323
4341
  minP,
4324
4342
  seed,
4325
4343
  stop,
4326
- templateOptions
4344
+ templateOptions,
4345
+ reasoningEffort
4327
4346
  }={}){
4328
4347
  validateAIStructuralRequest(messages,tools,parallelToolCalls);
4348
+ const normalizedReasoningEffort=normalizeAIReasoningEffort(
4349
+ reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
4350
+ );
4329
4351
  if(localOnly!==true&&localOnly!==false){
4330
4352
  throw new TypeError('AI localOnly must be a boolean.');
4331
4353
  }
@@ -4355,7 +4377,10 @@ class AI {
4355
4377
  ...(minP!==undefined?{minP}:{}),
4356
4378
  ...(seed!==undefined?{seed}:{}),
4357
4379
  ...(stop!==undefined?{stop}:{}),
4358
- ...(templateOptions!==undefined?{templateOptions}:{})
4380
+ ...(templateOptions!==undefined?{templateOptions}:{}),
4381
+ ...(normalizedReasoningEffort
4382
+ ?{reasoningEffort:normalizedReasoningEffort}
4383
+ :{})
4359
4384
  };
4360
4385
  const displayId=`M-${id}`;
4361
4386
  let handle=null;
@@ -4457,7 +4482,8 @@ class AI {
4457
4482
  false,
4458
4483
  true,
4459
4484
  onDataChunk,
4460
- onDataResult
4485
+ onDataResult,
4486
+ normalizedReasoningEffort
4461
4487
  );
4462
4488
  const structuralToolCalls=normalizeAICompletionToolCalls(
4463
4489
  completion
@@ -4563,7 +4589,8 @@ class AI {
4563
4589
  finishSpeech=true,
4564
4590
  returnCompletion=false,
4565
4591
  dataChunkHandler=function ignoreLegacyStreamDataChunk(){},
4566
- dataResultHandler=function ignoreLegacyStreamDataResult(){}
4592
+ dataResultHandler=function ignoreLegacyStreamDataResult(){},
4593
+ reasoningEffort
4567
4594
  ){
4568
4595
  let speechTurnCompleted=false;
4569
4596
 
@@ -4583,6 +4610,9 @@ class AI {
4583
4610
  structuredOutput
4584
4611
  );
4585
4612
 
4613
+ const normalizedReasoningEffort=normalizeAIReasoningEffort(
4614
+ reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
4615
+ );
4586
4616
  const request={
4587
4617
  model:this.model,
4588
4618
  messages:messages,
@@ -4603,8 +4633,11 @@ class AI {
4603
4633
  }
4604
4634
  }
4605
4635
 
4606
- if(this.llmService==='OLLAMA'&&this.reasoningEffort){
4607
- request.reasoning_effort=this.reasoningEffort;
4636
+ if(
4637
+ normalizedReasoningEffort
4638
+ &&(this.llmService==='OPENAI'||this.llmService==='OLLAMA')
4639
+ ){
4640
+ request.reasoning_effort=normalizedReasoningEffort;
4608
4641
  }
4609
4642
 
4610
4643
  let isThinking=true;
@@ -4631,7 +4664,9 @@ class AI {
4631
4664
  model:this.model,
4632
4665
  messages:ollamaMessages,
4633
4666
  stream:true,
4634
- ...(this.reasoningEffort?{think:this.reasoningEffort}:{}),
4667
+ ...(normalizedReasoningEffort
4668
+ ?{think:normalizedReasoningEffort}
4669
+ :{}),
4635
4670
  ...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
4636
4671
  ...(ollamaTools.length?{tools:ollamaTools}:{})
4637
4672
  };
@@ -5272,9 +5307,13 @@ class AI {
5272
5307
  minP,
5273
5308
  seed,
5274
5309
  stop,
5275
- templateOptions
5310
+ templateOptions,
5311
+ reasoningEffort
5276
5312
  }={}){
5277
5313
  validateAIStructuralRequest(messages,tools,parallelToolCalls);
5314
+ const normalizedReasoningEffort=normalizeAIReasoningEffort(
5315
+ reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
5316
+ );
5278
5317
  if(localOnly!==true&&localOnly!==false){
5279
5318
  throw new TypeError('AI localOnly must be a boolean.');
5280
5319
  }
@@ -5306,7 +5345,10 @@ class AI {
5306
5345
  ...(minP!==undefined?{minP}:{}),
5307
5346
  ...(seed!==undefined?{seed}:{}),
5308
5347
  ...(stop!==undefined?{stop}:{}),
5309
- ...(templateOptions!==undefined?{templateOptions}:{})
5348
+ ...(templateOptions!==undefined?{templateOptions}:{}),
5349
+ ...(normalizedReasoningEffort
5350
+ ?{reasoningEffort:normalizedReasoningEffort}
5351
+ :{})
5310
5352
  };
5311
5353
  await this.#reportRequest(onRequest,request,id);
5312
5354
  if(signal?.aborted){
@@ -5332,7 +5374,7 @@ class AI {
5332
5374
  return response;
5333
5375
  }
5334
5376
 
5335
- return this.fetch(
5377
+ return this.#fetchLegacy(
5336
5378
  messages,
5337
5379
  onResponse,
5338
5380
  structuredOutput,
@@ -5341,7 +5383,8 @@ class AI {
5341
5383
  parallelToolCalls,
5342
5384
  id,
5343
5385
  onRequest,
5344
- signal
5386
+ signal,
5387
+ normalizedReasoningEffort
5345
5388
  );
5346
5389
  }
5347
5390
 
@@ -5354,7 +5397,7 @@ class AI {
5354
5397
  parallel_tool_calls,
5355
5398
  id=Date.now(),
5356
5399
  requestHandler=function ignoreFetchRequest(){},
5357
- signal=null,
5400
+ signal=null
5358
5401
  ){
5359
5402
  if(this.#shouldUseProviderRuntime('llm',this.llmService,false)){
5360
5403
  return this.fetchRequest({
@@ -5394,6 +5437,7 @@ class AI {
5394
5437
  id=Date.now(),
5395
5438
  requestHandler=function ignoreFetchRequest(){},
5396
5439
  signal=null,
5440
+ reasoningEffort
5397
5441
  ){
5398
5442
  validateAIStructuralRequest(messages,tools,parallel_tool_calls);
5399
5443
  this.#assertServiceConfigured(this.llmService);
@@ -5408,6 +5452,9 @@ class AI {
5408
5452
  }
5409
5453
  const structuredOutputFormat=this.#structuredOutputFormat(structuredOutput);
5410
5454
 
5455
+ const normalizedReasoningEffort=normalizeAIReasoningEffort(
5456
+ reasoningEffort===undefined?this.reasoningEffort:reasoningEffort
5457
+ );
5411
5458
  const request={
5412
5459
  model:this.model,
5413
5460
  messages:messages,
@@ -5428,8 +5475,11 @@ class AI {
5428
5475
  }
5429
5476
  }
5430
5477
 
5431
- if(this.llmService==='OLLAMA'&&this.reasoningEffort){
5432
- request.reasoning_effort=this.reasoningEffort;
5478
+ if(
5479
+ normalizedReasoningEffort
5480
+ &&(this.llmService==='OPENAI'||this.llmService==='OLLAMA')
5481
+ ){
5482
+ request.reasoning_effort=normalizedReasoningEffort;
5433
5483
  }
5434
5484
 
5435
5485
  const nativeOllama=this.#nativeOllama();
@@ -5448,7 +5498,9 @@ class AI {
5448
5498
  model:this.model,
5449
5499
  messages:ollamaMessages,
5450
5500
  stream:false,
5451
- ...(this.reasoningEffort?{think:this.reasoningEffort}:{}),
5501
+ ...(normalizedReasoningEffort
5502
+ ?{think:normalizedReasoningEffort}
5503
+ :{}),
5452
5504
  ...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
5453
5505
  ...(ollamaTools.length?{tools:ollamaTools}:{})
5454
5506
  };
@@ -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){