arcane-os 0.5.4 → 0.5.6

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ - Sanitized newly persisted chat history into complete human-readable user,
6
+ assistant, and public tool-result records without storing internal prompts,
7
+ provider envelopes, raw tool protocol fields, or hidden request metadata.
8
+ Existing stored rows remain untouched.
9
+ - Made `persist: false` turn-scoped: the request can use the complete temporary
10
+ turn, then retains neither side in model history, memory, DBOPFS, nor the Chat
11
+ transcript after the operation settles.
12
+
13
+ ## 0.5.5
14
+
15
+ - Added configurable complete text-to-speech stream segmentation through
16
+ `AI.configureTTSSegmentation()`. Applications can speak on Unicode punctuation
17
+ or a whole-word cadence without discarding any text, while apostrophes,
18
+ commas, and hyphens that join Unicode letters or numbers remain intact.
19
+ Synthesis and playback stay sequential, and mute or cancellation still stops
20
+ the complete queue.
21
+
3
22
  ## 0.5.4
4
23
 
5
24
  - Reduced Browser-WASM HTTP Range parts from roughly 128 MB to roughly 4 MB
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.4` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.5.6` 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
 
@@ -153,6 +153,24 @@ must supply each runtime/model selection explicitly. Speech roles
153
153
  load, cancel, unload, fail, and recover independently, so speech failure never
154
154
  silently falls back or prevents text chat.
155
155
 
156
+ Applications that need faster spoken-response onset can configure the shared
157
+ TTS stream without taking over synthesis or playback:
158
+
159
+ ```js
160
+ ai.configureTTSSegmentation({
161
+ punctuation:'any',
162
+ wordCadence:4
163
+ });
164
+ ```
165
+
166
+ The compatibility default remains sentence punctuation with no word cadence.
167
+ The configured stream preserves every character and punctuation mark, chooses
168
+ the earliest complete boundary, and keeps synthesis and playback sequential.
169
+ Any-punctuation mode recognizes boundaries without requiring whitespace while
170
+ keeping apostrophes, commas, and hyphens that join Unicode letters or numbers
171
+ inside the same segment.
172
+ Mute, stop, provider transition, and cancellation still govern the whole queue.
173
+
156
174
  The SDK runtime also owns `DBOPFSDocumentLibrary`,
157
175
  `DocumentLexicalSearch`, and `PersistentAIChatSession`. Document bootstrap is
158
176
  schema-driven and explicit; chat never searches a corpus unless the app wires
@@ -168,7 +186,7 @@ uses the same controller for automatic memory extraction.
168
186
  Create a new repository-shaped Arcane application with the exact stable SDK:
169
187
 
170
188
  ```bash
171
- npx arcane-os@0.5.4 new my-app --path ./my-app --target portable --git
189
+ npx arcane-os@0.5.6 new my-app --path ./my-app --target portable --git
172
190
  cd my-app
173
191
  npm install
174
192
  npm run check
@@ -179,7 +197,7 @@ To enroll an existing repository, install the exact SDK and initialize only
179
197
  missing Arcane files:
180
198
 
181
199
  ```bash
182
- npm install --save-dev --save-exact arcane-os@0.5.4
200
+ npm install --save-dev --save-exact arcane-os@0.5.6
183
201
  npm exec -- arcane init my-app --target portable
184
202
  ```
185
203
 
@@ -195,7 +213,7 @@ npm exec -- arcane-os targets
195
213
  No global SDK install or standalone Arcane CLI is required. The application
196
214
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
197
215
 
198
- Use `npx arcane-os@0.5.4` for the initial bootstrap because it names this npm
216
+ Use `npx arcane-os@0.5.6` for the initial bootstrap because it names this npm
199
217
  package explicitly; bare `npx arcane` outside an installed project could resolve
200
218
  a different package. Both installed commands invoke the same headless toolchain.
201
219
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -216,7 +234,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
216
234
 
217
235
  # From the generated app repository
218
236
  cd ../local-app
219
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.4.tgz
237
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.6.tgz
220
238
  npm run check
221
239
  npm ci
222
240
  ```
@@ -226,7 +244,7 @@ same location. The lockfile retains the selected package dependency while
226
244
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
227
245
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
228
246
  runner also needs that tarball at the locked path. After publication, replace
229
- the local declaration with the exact `arcane-os@0.5.4` registry package and
247
+ the local declaration with the exact `arcane-os@0.5.6` registry package and
230
248
  commit the regenerated lock.
231
249
 
232
250
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -364,7 +382,7 @@ package installation, or assertions.
364
382
 
365
383
  ## Current target support
366
384
 
367
- Version `0.5.4` exposes one browser target and five explicitly paired
385
+ Version `0.5.6` exposes one browser target and five explicitly paired
368
386
  native development targets: a non-runnable portable directory, a
369
387
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
370
388
  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.4",
3
+ "version": "0.5.6",
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",
@@ -1333,12 +1333,13 @@
1333
1333
  }
1334
1334
 
1335
1335
  function sameTranscriptRequest(actual,expected){
1336
- return actual?.role===expected?.role
1337
- &&actual.content===expected.content
1338
- &&(
1339
- expected.role!=='tool'
1340
- ||actual.tool_call_id===expected.tool_call_id
1341
- );
1336
+ if(actual?.role!==expected?.role) return false;
1337
+ if(expected.role==='tool'){
1338
+ return actual.content===(expected.message??expected.content)
1339
+ &&(expected.name===undefined||actual.name===expected.name)
1340
+ &&(expected.status===undefined||actual.status===expected.status);
1341
+ }
1342
+ return actual.content===expected.content;
1342
1343
  }
1343
1344
 
1344
1345
  function latestCommittedTranscriptTurn(transcript,requestMessages){
@@ -1460,18 +1461,20 @@
1460
1461
  }
1461
1462
  if(actionable){
1462
1463
  if(message.role==='tool'){
1463
- if(
1464
- typeof message.tool_call_id!=='string'
1465
- ||!message.tool_call_id.trim()
1466
- ||!message.content.trim()
1467
- ||!restoredPendingToolCalls.has(message.tool_call_id)
1468
- ){
1469
- throw chatError(
1470
- 'Saved chat contains a tool result that does not match a pending structural tool call.',
1471
- 'AI_CHAT_INVALID_TOOL_MESSAGE'
1472
- );
1464
+ if(message.tool_call_id!==undefined){
1465
+ if(
1466
+ typeof message.tool_call_id!=='string'
1467
+ ||!message.tool_call_id.trim()
1468
+ ||!message.content.trim()
1469
+ ||!restoredPendingToolCalls.has(message.tool_call_id)
1470
+ ){
1471
+ throw chatError(
1472
+ 'Saved chat contains a tool result that does not match a pending structural tool call.',
1473
+ 'AI_CHAT_INVALID_TOOL_MESSAGE'
1474
+ );
1475
+ }
1476
+ restoredPendingToolCalls.delete(message.tool_call_id);
1473
1477
  }
1474
- restoredPendingToolCalls.delete(message.tool_call_id);
1475
1478
  }else{
1476
1479
  if(restoredPendingToolCalls.size){
1477
1480
  throw chatError(
@@ -1489,7 +1492,9 @@
1489
1492
  const name=message.role==='user'
1490
1493
  ?host.name
1491
1494
  :message.role==='tool'
1492
- ?'Tool'
1495
+ ?typeof message.name==='string'&&message.name.trim()
1496
+ ?`Tool · ${message.name}`
1497
+ :'Tool'
1493
1498
  :message.role==='system'
1494
1499
  ?'System'
1495
1500
  :host.aiName;
@@ -3069,6 +3074,7 @@
3069
3074
  if(!sessionRequestMessages.length){
3070
3075
  throw new TypeError('A chat session request must contain at least one message.');
3071
3076
  }
3077
+ const retainTurn=sessionRequestMessages.every(message=>message.persist!==false);
3072
3078
  const request={
3073
3079
  ...(sessionRequestMessages.length===1
3074
3080
  ?{message:sessionRequestMessages[0]}
@@ -3179,22 +3185,29 @@
3179
3185
  ??committedTurn?.request?.timestamp
3180
3186
  );
3181
3187
  }
3182
- const terminalToolCalls=normalizeVisibleToolCalls(result.message.tool_calls);
3183
- if(
3184
- streamedStructuralToolCalls.length
3185
- &&!sameStructuralToolCalls(streamedStructuralToolCalls,terminalToolCalls)
3186
- ){
3187
- throw chatError(
3188
- 'The terminal structural tool calls do not match the streamed calls.',
3189
- 'AI_CHAT_STREAM_TOOL_CALL_MISMATCH'
3190
- );
3191
- }
3192
- if(!streamedStructuralToolCalls.length){
3193
- for(const call of terminalToolCalls){
3194
- appendVisibleToolCall(message,call);
3188
+ if(retainTurn){
3189
+ const terminalToolCalls=normalizeVisibleToolCalls(result.message.tool_calls);
3190
+ if(
3191
+ streamedStructuralToolCalls.length
3192
+ &&!sameStructuralToolCalls(streamedStructuralToolCalls,terminalToolCalls)
3193
+ ){
3194
+ throw chatError(
3195
+ 'The terminal structural tool calls do not match the streamed calls.',
3196
+ 'AI_CHAT_STREAM_TOOL_CALL_MISMATCH'
3197
+ );
3195
3198
  }
3199
+ if(!streamedStructuralToolCalls.length){
3200
+ for(const call of terminalToolCalls){
3201
+ appendVisibleToolCall(message,call);
3202
+ }
3203
+ }
3204
+ setPendingStructuralToolCalls(terminalToolCalls);
3205
+ }else{
3206
+ for(const requestMessage of requestMessages) requestMessage.remove();
3207
+ message.remove();
3208
+ setPendingStructuralToolCalls(previousPendingToolCalls);
3209
+ scrollTranscriptToBottom();
3196
3210
  }
3197
- setPendingStructuralToolCalls(terminalToolCalls);
3198
3211
  setSessionStatus(
3199
3212
  pendingStructuralToolMessage?'tool':'ready',
3200
3213
  pendingStructuralToolMessage||'Chat ready.'
@@ -3339,7 +3352,7 @@
3339
3352
  if(!Array.isArray(options.results)||options.results.length===0){
3340
3353
  throw new TypeError('Tool results must be a nonempty array.');
3341
3354
  }
3342
- const dispositions=new Map([
3355
+ const statuses=new Map([
3343
3356
  ['executed','Executed'],
3344
3357
  ['declined','Declined'],
3345
3358
  ['cancelled','Cancelled'],
@@ -3369,7 +3382,7 @@
3369
3382
  throw new TypeError(`Tool result ${index+1} must be a plain object.`);
3370
3383
  }
3371
3384
  const unsupportedResultField=Object.keys(result).find(
3372
- key=>!['disposition','message','persist','toolCallId'].includes(key)
3385
+ key=>!['disposition','message','persist','status','toolCallId'].includes(key)
3373
3386
  );
3374
3387
  if(unsupportedResultField){
3375
3388
  throw new TypeError(
@@ -3388,9 +3401,17 @@
3388
3401
  error.code='AI_CHAT_INVALID_TOOL_MESSAGE';
3389
3402
  throw error;
3390
3403
  }
3391
- if(!dispositions.has(result.disposition)){
3404
+ if(
3405
+ result.status!==undefined
3406
+ &&result.disposition!==undefined
3407
+ &&result.status!==result.disposition
3408
+ ){
3409
+ throw new TypeError('Tool-result status and legacy disposition must match.');
3410
+ }
3411
+ const status=result.status??result.disposition;
3412
+ if(!statuses.has(status)){
3392
3413
  throw new TypeError(
3393
- 'Tool-result disposition must be executed, declined, cancelled, or not-executed.'
3414
+ 'Tool-result status must be executed, declined, cancelled, or not-executed.'
3394
3415
  );
3395
3416
  }
3396
3417
  if(typeof result.message!=='string'||!result.message.trim()){
@@ -3401,9 +3422,12 @@
3401
3422
  throw new TypeError('Tool-result persist must be boolean.');
3402
3423
  }
3403
3424
  resultsById.set(toolCallId,{
3404
- content:`${dispositions.get(result.disposition)} — ${result.message}`,
3425
+ content:`${statuses.get(status)} — ${result.message}`,
3426
+ message:result.message,
3427
+ name:pendingById.get(toolCallId).function.name,
3405
3428
  persist,
3406
3429
  role:'tool',
3430
+ status,
3407
3431
  tool_call_id:toolCallId
3408
3432
  });
3409
3433
  }
@@ -3440,7 +3464,11 @@
3440
3464
  }
3441
3465
  try{
3442
3466
  for(const result of orderedResults){
3443
- const visibleMessage=appendTranscriptMessage('tool',result.content,'Tool');
3467
+ const visibleMessage=appendTranscriptMessage(
3468
+ 'tool',
3469
+ result.content,
3470
+ `Tool · ${result.name}`
3471
+ );
3444
3472
  visibleMessage.dataset.operationId=operationId;
3445
3473
  visibleMessage.dataset.toolCallId=result.tool_call_id;
3446
3474
  }
@@ -3472,7 +3500,7 @@
3472
3500
  throw new TypeError('Tool-result options must be a plain object.');
3473
3501
  }
3474
3502
  const unsupportedOption=Object.keys(options).find(
3475
- key=>!['disposition','message','persist','request','toolCallId'].includes(key)
3503
+ key=>!['disposition','message','persist','request','status','toolCallId'].includes(key)
3476
3504
  );
3477
3505
  if(unsupportedOption){
3478
3506
  throw new TypeError(`Unsupported tool-result option: ${unsupportedOption}.`);
@@ -102,6 +102,96 @@ function copyToolCalls(value){
102
102
  });
103
103
  }
104
104
 
105
+ function structuralToolMessage(call){
106
+ const argumentRecord=JSON.parse(call.function.arguments);
107
+ return argumentRecord.message;
108
+ }
109
+
110
+ function storedStructuralToolCalls(value){
111
+ if(value===undefined) return [];
112
+ if(!Array.isArray(value)){
113
+ console.error('Arcane stored assistant tool calls were not an array and were not retained.');
114
+ return [];
115
+ }
116
+ const result=[];
117
+ for(const [index,call] of value.entries()){
118
+ try{
119
+ result.push(copyToolCalls([call])[0]);
120
+ }catch(error){
121
+ console.error(
122
+ `Arcane stored assistant tool call ${index+1} had no usable user-facing message and was not retained.`,
123
+ error
124
+ );
125
+ }
126
+ }
127
+ return result;
128
+ }
129
+
130
+ function storedToolRecord({content,name,status,timestamp}){
131
+ if(typeof content!=='string'||!content.trim()) return [];
132
+ return [{
133
+ role:'tool',
134
+ content,
135
+ ...(typeof name==='string'&&name.trim()?{name}:{}),
136
+ ...(typeof status==='string'&&status.trim()?{status}:{}),
137
+ ...(timestamp!==undefined?{timestamp}:{}),
138
+ }];
139
+ }
140
+
141
+ function storedChatRecords(messages,{memoryOnly=false}={}){
142
+ const result=[];
143
+ for(const message of messages){
144
+ if(!plainRecord(message)) continue;
145
+ if(message.persistence_excluded===true) continue;
146
+ if(memoryOnly&&message.memory_excluded===true) continue;
147
+ const timestamp=message.timestamp;
148
+ if(message.role==='user'){
149
+ if(typeof message.content!=='string') continue;
150
+ result.push({
151
+ role:'user',
152
+ content:message.content,
153
+ ...(timestamp!==undefined?{timestamp}:{}),
154
+ });
155
+ continue;
156
+ }
157
+ if(message.role==='assistant'){
158
+ const content=message.content===undefined||message.content===null
159
+ ?''
160
+ :String(message.content);
161
+ if(content){
162
+ result.push({
163
+ role:'assistant',
164
+ content,
165
+ ...(timestamp!==undefined?{timestamp}:{}),
166
+ });
167
+ }
168
+ for(const call of storedStructuralToolCalls(message.tool_calls)){
169
+ result.push(...storedToolRecord({
170
+ content:structuralToolMessage(call),
171
+ name:call.function.name,
172
+ status:'requested',
173
+ timestamp,
174
+ }));
175
+ }
176
+ continue;
177
+ }
178
+ if(message.role==='tool'){
179
+ const protocolResult=typeof message.tool_call_id==='string'&&message.tool_call_id;
180
+ result.push(...storedToolRecord({
181
+ content:protocolResult?message.persistence_message:message.content,
182
+ name:message.persistence_name??message.name,
183
+ status:message.persistence_status??message.status,
184
+ timestamp,
185
+ }));
186
+ }
187
+ }
188
+ return result;
189
+ }
190
+
191
+ function retainedChatMessages(messages){
192
+ return messages.filter(message=>message?.persistence_excluded!==true);
193
+ }
194
+
105
195
  function turnMessage(value,label){
106
196
  if(!plainRecord(value)||!['tool','user'].includes(value.role)||typeof value.content!=='string'){
107
197
  throw new TypeError(`${label} must be a user or tool message.`);
@@ -179,6 +269,9 @@ function pendingToolCalls(messages){
179
269
  }
180
270
  if(message.role==='tool'){
181
271
  const toolCallId=message.tool_call_id;
272
+ if(toolCallId===undefined){
273
+ continue;
274
+ }
182
275
  if(typeof toolCallId!=='string'||!toolCallId.trim()){
183
276
  throw coded(
184
277
  new TypeError(`Chat message ${index+1} has an invalid tool_call_id.`),
@@ -231,18 +324,8 @@ function turnMessages(requestMessage,requestMessages){
231
324
  *
232
325
  * @property {string|number} content
233
326
  * Message text content.
234
- * @property {boolean} [memory_excluded]
235
- * Internal persistence marker omitted from model-facing messages.
236
- * @property {boolean} [ui_hidden]
237
- * Legacy persistence metadata retained without suppressing the public transcript.
238
- * @property {boolean} [persistence_excluded]
239
- * Internal marker for session-only messages omitted from durable chat and memory.
240
- * @property {Array<Object>} [tool_calls]
241
- * Assistant tool calls retained in the saved conversation log.
242
- * @property {string} [reasoning_content]
243
- * Optional complete provider reasoning retained with an assistant record.
244
- * @property {string} [tool_call_id]
245
- * Matching tool-call identifier for a tool result.
327
+ * Active provider messages may also contain transient protocol and internal
328
+ * fields. The DBOPFS projection never retains those fields.
246
329
  */
247
330
 
248
331
  /**
@@ -258,13 +341,12 @@ function turnMessages(requestMessage,requestMessages){
258
341
  * chat-1719930112231.json
259
342
  * ```
260
343
  *
261
- * File contents:
344
+ * New file contents:
262
345
  *
263
346
  * ```
264
347
  * [
265
- * { "role":"system","content":"..." },
266
- * { "role":"user","content":"Hello" },
267
- * { "role":"assistant","content":"Hi there" }
348
+ * { "role":"user","content":"Hello","timestamp":1719930112231 },
349
+ * { "role":"assistant","content":"Hi there","timestamp":1719930112240 }
268
350
  * ]
269
351
  * ```
270
352
  *
@@ -328,6 +410,9 @@ class ChatEntity{
328
410
  /** Number of leading in-memory messages durably present in the chat file. */
329
411
  #persistedMessageCount=0;
330
412
 
413
+ /** Existing stored records preserved exactly while new records use the narrow format. */
414
+ #preservedStoredMessageCount=0;
415
+
331
416
  /** Serializes snapshot and append writes for this chat instance. */
332
417
  #persistenceQueue=Promise.resolve();
333
418
 
@@ -375,8 +460,10 @@ class ChatEntity{
375
460
  * @returns {Array<*>}
376
461
  */
377
462
  get messages(){
378
- pendingToolCalls(this.#messages);
379
- return this.#messages.map(function publicChatMessage(message){
463
+ const retainedMessages=retainedChatMessages(this.#messages);
464
+ pendingToolCalls(retainedMessages);
465
+ return retainedMessages
466
+ .map(function publicChatMessage(message){
380
467
  const copy={...message};
381
468
  if(copy.tool_calls){
382
469
  copy.tool_calls=copyToolCalls(copy.tool_calls).map(call=>({
@@ -388,19 +475,27 @@ class ChatEntity{
388
475
  delete copy.persistence_excluded;
389
476
  delete copy.ui_hidden;
390
477
  delete copy.timestamp;
478
+ delete copy.persistence_message;
479
+ delete copy.persistence_name;
480
+ delete copy.persistence_status;
481
+ if(copy.role==='tool'&&copy.tool_call_id===undefined){
482
+ delete copy.name;
483
+ delete copy.status;
484
+ copy.role='assistant';
485
+ }
391
486
  return copy;
392
487
  });
393
488
  }
394
489
 
395
490
  /**
396
- * Returns the UI-visible conversation records with their persisted timestamps.
397
- * Model-facing callers should continue to use `messages`, which intentionally
398
- * omits display metadata.
491
+ * Returns the sanitized human-readable conversation records. User and
492
+ * assistant records contain only role, complete visible content, and timestamp.
493
+ * Tool records may also contain their public name and result status.
399
494
  *
400
495
  * @returns {Array<*>}
401
496
  */
402
497
  get transcript(){
403
- return this.#messages.map(function publicTranscriptMessage(message){
498
+ return storedChatRecords(this.#messages).map(function publicTranscriptMessage(message){
404
499
  return copyCompleteValue(message);
405
500
  });
406
501
  }
@@ -417,6 +512,7 @@ class ChatEntity{
417
512
  }
418
513
  this.#messages=v.map(message=>copyCompleteValue(message));
419
514
  this.#persistedMessageCount=0;
515
+ this.#preservedStoredMessageCount=0;
420
516
  this.#saved=false;
421
517
  return this.transcript;
422
518
  }
@@ -465,7 +561,7 @@ class ChatEntity{
465
561
  if(!is.boolean(persist)){
466
562
  throw new Error('persist must be boolean');
467
563
  }
468
- if(pendingToolCalls(this.#messages).size){
564
+ if(pendingToolCalls(retainedChatMessages(this.#messages)).size){
469
565
  throw coded(
470
566
  new TypeError('The pending structural tool results must be supplied before a new user turn.'),
471
567
  'AI_CHAT_TOOL_RESULT_REQUIRED'
@@ -509,7 +605,7 @@ class ChatEntity{
509
605
  if(!is.boolean(persist)){
510
606
  throw new Error('persist must be boolean');
511
607
  }
512
- if(pendingToolCalls(this.#messages).size){
608
+ if(pendingToolCalls(retainedChatMessages(this.#messages)).size){
513
609
  throw coded(
514
610
  new TypeError('The pending structural tool results must be supplied before another assistant turn.'),
515
611
  'AI_CHAT_TOOL_RESULT_REQUIRED'
@@ -537,7 +633,8 @@ class ChatEntity{
537
633
 
538
634
  /**
539
635
  * Adds an assistant tool call and its result as one hidden, atomic log exchange.
540
- * The host application decides whether anything is rendered in the chat UI.
636
+ * The active provider context keeps the complete exchange. New durable history
637
+ * retains only the call's required user-facing arguments.message record.
541
638
  */
542
639
  addToolExchange({id='',name='',arguments:argumentValue='',result='',persist=true}={}){
543
640
  const toolCallId=id;
@@ -553,7 +650,7 @@ class ChatEntity{
553
650
  if(!is.boolean(persist)){
554
651
  throw new TypeError('persist must be boolean.');
555
652
  }
556
- if(pendingToolCalls(this.#messages).size){
653
+ if(pendingToolCalls(retainedChatMessages(this.#messages)).size){
557
654
  throw coded(
558
655
  new TypeError('The pending structural tool results must be supplied before another tool exchange.'),
559
656
  'AI_CHAT_TOOL_RESULT_REQUIRED'
@@ -629,7 +726,7 @@ class ChatEntity{
629
726
  'AI_CHAT_INCOHERENT_PERSISTENCE'
630
727
  );
631
728
  }
632
- const pendingTools=pendingToolCalls(this.#messages);
729
+ const pendingTools=pendingToolCalls(retainedChatMessages(this.#messages));
633
730
  const userRequest=requests.find(message=>message.role==='user');
634
731
  const toolRequests=requests.filter(message=>message.role==='tool');
635
732
  if(userRequest&&pendingTools.size){
@@ -690,15 +787,10 @@ class ChatEntity{
690
787
  };
691
788
  if(!extractMemory||toolCalls?.length) assistantRecord.memory_excluded=true;
692
789
  else delete assistantRecord.memory_excluded;
693
- if(!messagePersist){
694
- for(const requestRecord of requestRecords){
695
- requestRecord.persistence_excluded=true;
696
- }
697
- }
698
- if(!responsePersist) assistantRecord.persistence_excluded=true;
699
- else delete assistantRecord.persistence_excluded;
700
-
701
- const appended=this.#appendMessages([...requestRecords,assistantRecord],{prepared:true});
790
+ const appended=this.#appendMessages(
791
+ [...requestRecords,assistantRecord],
792
+ {persist:messagePersist,prepared:true}
793
+ );
702
794
  if(extractMemory&&messagePersist&&responsePersist&&!toolCalls?.length&&this.persist){
703
795
  return Promise.resolve(appended).then(result=>{
704
796
  this.#queueMemoryUpdate(memoryRequest);
@@ -729,6 +821,7 @@ class ChatEntity{
729
821
  const systemMessage=this.#messages.find(message=>message.role==='system');
730
822
  this.#messages=systemMessage?[systemMessage]:[];
731
823
  this.#persistedMessageCount=0;
824
+ this.#preservedStoredMessageCount=0;
732
825
  this.#saved=true;
733
826
  return this.transcript;
734
827
  }
@@ -751,7 +844,8 @@ class ChatEntity{
751
844
  }
752
845
  });
753
846
  this.#messages=loadedMessages;
754
- this.#persistedMessageCount=this.#durableMessages().length;
847
+ this.#preservedStoredMessageCount=loadedMessages.length;
848
+ this.#persistedMessageCount=loadedMessages.length;
755
849
  this.#saved=true;
756
850
 
757
851
  return this.transcript;
@@ -762,7 +856,8 @@ class ChatEntity{
762
856
  throw new TypeError('Memory request must be a function.');
763
857
  }
764
858
  return this.#writeMemory(
765
- this.#durableMessages().map(message=>({...message})),
859
+ storedChatRecords(this.#messages,{memoryOnly:true})
860
+ .map(message=>({...message})),
766
861
  request
767
862
  );
768
863
  }
@@ -844,7 +939,11 @@ ${JSON.stringify(transcript)}`
844
939
  }
845
940
 
846
941
  #queueMemoryUpdate(request){
847
- const snapshot=this.#durableMessages().map(message=>copyCompleteValue(message));
942
+ const snapshot=storedChatRecords(
943
+ this.#messages,
944
+ {memoryOnly:true}
945
+ )
946
+ .map(message=>copyCompleteValue(message));
848
947
  const queued=this.#memoryQueue.then(()=>this.#writeMemory(snapshot,request));
849
948
  this.#memoryQueue=queued.catch(()=>{
850
949
  console.warn('Unable to update chat memory.');
@@ -889,12 +988,11 @@ ${JSON.stringify(transcript)}`
889
988
  *
890
989
  * The log file is stored in **NDJSON format** (newline-delimited JSON).
891
990
  *
892
- * Example stored file:
991
+ * Example newly stored file:
893
992
  *
894
993
  * ```
895
- * {"role":"system","content":"You are a calm evaluator"}
896
- * {"role":"user","content":"Hello"}
897
- * {"role":"assistant","content":"Hi there"}
994
+ * {"role":"user","content":"Hello","timestamp":1719930112231}
995
+ * {"role":"assistant","content":"Hi there","timestamp":1719930112240}
898
996
  * ```
899
997
  *
900
998
  * Each call to `appendMessage` writes one JSON object followed
@@ -948,21 +1046,25 @@ ${JSON.stringify(transcript)}`
948
1046
  }
949
1047
 
950
1048
  #durableMessages(){
951
- return this.#messages.filter(
952
- message=>!message||typeof message!=='object'||message.persistence_excluded!==true
953
- );
1049
+ return [
1050
+ ...this.#messages
1051
+ .slice(0,this.#preservedStoredMessageCount)
1052
+ .map(message=>copyCompleteValue(message)),
1053
+ ...storedChatRecords(
1054
+ this.#messages.slice(this.#preservedStoredMessageCount)
1055
+ ),
1056
+ ];
954
1057
  }
955
1058
 
956
1059
  async #appendMessages(messages,{persist=true,prepared=false}={}){
957
- const records=Array.from(messages||[]).map(message=>
958
- prepared?message:(persist?message:{...message,persistence_excluded:true})
959
- );
1060
+ const records=Array.from(messages||[]).map(message=>prepared?message:{...message});
960
1061
  if(!records.length){
961
1062
  return false;
962
1063
  }
1064
+ if(!persist) return false;
963
1065
 
964
1066
  this.#messages.push(...records);
965
- const durableRecords=records.filter(message=>message.persistence_excluded!==true);
1067
+ const durableRecords=storedChatRecords(records);
966
1068
  if(durableRecords.length){
967
1069
  this.#saved=false;
968
1070
  }
@@ -994,17 +1096,8 @@ ${JSON.stringify(transcript)}`
994
1096
  );
995
1097
  }
996
1098
  const durableSnapshot=this.#durableMessages();
997
- const durableIndex=durableSnapshot.indexOf(durableRecords[0]);
998
- const durableRecordsAreContiguous=durableRecords.every(
999
- (record,index)=>durableSnapshot[durableIndex+index]===record
1000
- );
1001
- if(durableIndex<0||!durableRecordsAreContiguous){
1002
- throw coded(
1003
- new Error('Durable chat records changed before persistence completed.'),
1004
- 'AI_CHAT_INCOHERENT_PERSISTENCE'
1005
- );
1006
- }
1007
- const lastDurableIndex=durableIndex+durableRecords.length-1;
1099
+ const durableIndex=durableSnapshot.length-durableRecords.length;
1100
+ const lastDurableIndex=durableSnapshot.length-1;
1008
1101
  if(this.#persistedMessageCount===durableIndex){
1009
1102
  await dbopfs.set(
1010
1103
  this.#tableName,
@@ -17,6 +17,16 @@ const completeValue=(value)=>value;
17
17
 
18
18
  let credentials='include';
19
19
  const LEGACY_TTS_RESPONSE_FORMAT='opus';
20
+ const DEFAULT_TTS_SEGMENTATION={
21
+ punctuation:'sentence',
22
+ wordCadence:null
23
+ };
24
+ const TTS_PUNCTUATION_MODES=new Set(['sentence','any','none']);
25
+ // A complete punctuation run is a boundary unless the whole run consists of
26
+ // apostrophe, comma, or dash punctuation joining Unicode letters or numbers.
27
+ // The streaming expression leaves a trailing joining run for the next chunk.
28
+ const TTS_ANY_PUNCTUATION=/(?<!\p{P})(?:(?<![\p{L}\p{N}])\p{P}+(?!\p{P})(?=[\s\S])|\p{P}+(?!\p{P})(?=[^\p{L}\p{N}])|\p{P}*[^\P{P}'\u2019\uFF07,\u060C\u3001\uFF0C\p{Pd}]\p{P}*)(?!\p{P})/gu;
29
+ const TTS_ANY_PUNCTUATION_AT_END=/(?<!\p{P})(?:(?<![\p{L}\p{N}])\p{P}+|\p{P}+(?![\p{L}\p{N}])|\p{P}*[^\P{P}'\u2019\uFF07,\u060C\u3001\uFF0C\p{Pd}]\p{P}*)(?!\p{P})/gu;
20
30
  credentials='omit';
21
31
 
22
32
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
@@ -142,6 +152,38 @@ function isPlainAIRecord(value){
142
152
  return prototype===Object.prototype||prototype===null;
143
153
  }
144
154
 
155
+ function normalizeTTSSegmentation(value,current=DEFAULT_TTS_SEGMENTATION){
156
+ if(value===undefined||value===null){
157
+ return {...current};
158
+ }
159
+ if(!isPlainAIRecord(value)){
160
+ throw new TypeError('TTS segmentation must be a plain object.');
161
+ }
162
+
163
+ const punctuation=value.punctuation===undefined
164
+ ?current.punctuation
165
+ :value.punctuation;
166
+ if(!TTS_PUNCTUATION_MODES.has(punctuation)){
167
+ throw new TypeError(
168
+ 'TTS segmentation punctuation must be sentence, any, or none.'
169
+ );
170
+ }
171
+
172
+ const wordCadence=value.wordCadence===undefined
173
+ ?current.wordCadence
174
+ :value.wordCadence;
175
+ if(
176
+ wordCadence!==null
177
+ &&(!Number.isSafeInteger(wordCadence)||wordCadence<1)
178
+ ){
179
+ throw new RangeError(
180
+ 'TTS segmentation wordCadence must be null or a positive integer.'
181
+ );
182
+ }
183
+
184
+ return {punctuation,wordCadence};
185
+ }
186
+
145
187
  function requireAIToolMessageSchemas(value,label='AI tools'){
146
188
  if(!Array.isArray(value)){
147
189
  throw aiStructuralError(
@@ -1069,6 +1111,7 @@ class AI {
1069
1111
  #speechControlGeneration=0;
1070
1112
  #speechFailureSequence=0;
1071
1113
  #stopOllamaReady=null;
1114
+ #ttsSegmentation={...DEFAULT_TTS_SEGMENTATION};
1072
1115
  #preferenceTuple=completeValue([
1073
1116
  'OPENAI',
1074
1117
  'LOCAL_SPEACH',
@@ -5512,6 +5555,18 @@ class AI {
5512
5555
  return responseJSON;
5513
5556
  }
5514
5557
 
5558
+ get ttsSegmentation(){
5559
+ return {...this.#ttsSegmentation};
5560
+ }
5561
+
5562
+ configureTTSSegmentation(options={}){
5563
+ this.#ttsSegmentation=normalizeTTSSegmentation(
5564
+ options,
5565
+ this.#ttsSegmentation
5566
+ );
5567
+ return this.ttsSegmentation;
5568
+ }
5569
+
5515
5570
  streamTTS(
5516
5571
  text='',
5517
5572
  end=false
@@ -5563,10 +5618,16 @@ class AI {
5563
5618
  let remainder=this.audioMessageChunks;
5564
5619
 
5565
5620
  while(remainder.length>0){
5566
- const terminator=this.#findSpeechTerminator(remainder,end);
5567
- let boundary=terminator
5568
- ?terminator.index+terminator[0].length
5569
- :-1;
5621
+ const punctuationBoundary=this.#findSpeechPunctuationBoundary(
5622
+ remainder,
5623
+ end
5624
+ );
5625
+ const cadenceBoundary=this.#findSpeechCadenceBoundary(remainder,end);
5626
+ let boundary=punctuationBoundary;
5627
+
5628
+ if(boundary<0||(cadenceBoundary>0&&cadenceBoundary<boundary)){
5629
+ boundary=cadenceBoundary;
5630
+ }
5570
5631
 
5571
5632
  if(boundary<0&&end){
5572
5633
  boundary=remainder.length;
@@ -5588,7 +5649,14 @@ class AI {
5588
5649
  return segments;
5589
5650
  }
5590
5651
 
5591
- #findSpeechTerminator(text,end=false){
5652
+ #findSpeechPunctuationBoundary(text,end=false){
5653
+ if(this.#ttsSegmentation.punctuation==='none'){
5654
+ return -1;
5655
+ }
5656
+ if(this.#ttsSegmentation.punctuation==='any'){
5657
+ return this.#findAnySpeechPunctuationBoundary(text,end);
5658
+ }
5659
+
5592
5660
  const pattern=end
5593
5661
  ?/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s|$))/g
5594
5662
  :/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s+\S))/g;
@@ -5602,10 +5670,71 @@ class AI {
5602
5670
  continue;
5603
5671
  }
5604
5672
 
5605
- return terminator;
5673
+ return this.#includeSpeechBoundaryWhitespace(
5674
+ text,
5675
+ terminator.index+terminator[0].length
5676
+ );
5606
5677
  }
5607
5678
 
5608
- return null;
5679
+ return -1;
5680
+ }
5681
+
5682
+ #findAnySpeechPunctuationBoundary(text,end=false){
5683
+ const pattern=end
5684
+ ?TTS_ANY_PUNCTUATION_AT_END
5685
+ :TTS_ANY_PUNCTUATION;
5686
+ pattern.lastIndex=0;
5687
+ const firstContent=text.search(/\S/u);
5688
+ let punctuation;
5689
+
5690
+ while((punctuation=pattern.exec(text))){
5691
+ if(firstContent<0||punctuation.index<=firstContent){
5692
+ continue;
5693
+ }
5694
+ const boundary=punctuation.index+punctuation[0].length;
5695
+ return this.#includeSpeechBoundaryWhitespace(text,boundary);
5696
+ }
5697
+
5698
+ return -1;
5699
+ }
5700
+
5701
+ #findSpeechCadenceBoundary(text,end=false){
5702
+ const wordCadence=this.#ttsSegmentation.wordCadence;
5703
+ if(wordCadence===null){
5704
+ return -1;
5705
+ }
5706
+
5707
+ const words=/\S+/gu;
5708
+ let word;
5709
+ let wordCount=0;
5710
+
5711
+ while((word=words.exec(text))){
5712
+ wordCount+=1;
5713
+ if(wordCount!==wordCadence){
5714
+ continue;
5715
+ }
5716
+ const boundary=word.index+word[0].length;
5717
+ if(boundary===text.length&&!end){
5718
+ return -1;
5719
+ }
5720
+ if(boundary<text.length&&!/\s/u.test(text[boundary])){
5721
+ return -1;
5722
+ }
5723
+ return this.#includeSpeechBoundaryWhitespace(text,boundary);
5724
+ }
5725
+
5726
+ return -1;
5727
+ }
5728
+
5729
+ #includeSpeechBoundaryWhitespace(text,boundary){
5730
+ let completeBoundary=boundary;
5731
+ while(
5732
+ completeBoundary<text.length
5733
+ &&/\s/u.test(text[completeBoundary])
5734
+ ){
5735
+ completeBoundary+=1;
5736
+ }
5737
+ return completeBoundary;
5609
5738
  }
5610
5739
 
5611
5740
  #isSpeechAbbreviation(text,periodIndex){
@@ -197,24 +197,57 @@ function normalizeSend(input){
197
197
  throw new TypeError('messages accepts only tool-result messages.');
198
198
  }
199
199
  let toolCallId=null;
200
+ let persistenceMessage=null;
201
+ let persistenceName=null;
202
+ let persistenceStatus=null;
200
203
  if(role==='tool'){
201
204
  if(typeof value.tool_call_id!=='string'||!value.tool_call_id.trim()){
202
205
  throw new TypeError(`${label}.tool_call_id is required for tool messages.`);
203
206
  }
204
207
  toolCallId=value.tool_call_id;
208
+ if(value.message!==undefined){
209
+ if(typeof value.message!=='string'||!value.message.trim()){
210
+ throw new TypeError(`${label}.message must contain user-facing text when provided.`);
211
+ }
212
+ persistenceMessage=value.message;
213
+ }
214
+ if(value.name!==undefined){
215
+ if(typeof value.name!=='string'||!value.name.trim()){
216
+ throw new TypeError(`${label}.name must contain text when provided.`);
217
+ }
218
+ persistenceName=value.name;
219
+ }
220
+ if(value.status!==undefined){
221
+ if(typeof value.status!=='string'||!value.status.trim()){
222
+ throw new TypeError(`${label}.status must contain text when provided.`);
223
+ }
224
+ persistenceStatus=value.status;
225
+ }
205
226
  }else if(value.tool_call_id!==undefined){
206
227
  throw new TypeError(`${label}.tool_call_id is supported only for tool messages.`);
207
228
  }
208
229
  const completeMessage={...value};
209
230
  delete completeMessage.persist;
231
+ delete completeMessage.message;
232
+ delete completeMessage.name;
233
+ delete completeMessage.status;
234
+ const providerMessage={
235
+ ...completeMessage,
236
+ content:value.content,
237
+ role,
238
+ ...(toolCallId?{tool_call_id:toolCallId}:{})
239
+ };
210
240
  return {
211
241
  persist:boolean(value.persist,`${label}.persist`,true),
212
- message:{
213
- ...completeMessage,
214
- content:value.content,
215
- role,
216
- ...(toolCallId?{tool_call_id:toolCallId}:{})
217
- }
242
+ message:providerMessage,
243
+ entityMessage:role==='tool'
244
+ ?{
245
+ ...providerMessage,
246
+ ...(persistenceMessage?{persistence_message:persistenceMessage}:{}),
247
+ ...(persistenceName?{persistence_name:persistenceName}:{}),
248
+ ...(persistenceStatus?{persistence_status:persistenceStatus}:{}),
249
+ }
250
+ :providerMessage,
218
251
  };
219
252
  });
220
253
  const messagePersist=normalizedMessages[0].persist;
@@ -245,6 +278,7 @@ function normalizeSend(input){
245
278
  if(!signalLike(input.signal)) throw new TypeError('signal must be an AbortSignal.');
246
279
  return {
247
280
  messagePersist,
281
+ entityRequestMessages:normalizedMessages.map(item=>item.entityMessage),
248
282
  requestMessages:normalizedMessages.map(item=>item.message),
249
283
  responsePersist,
250
284
  request:{...request},
@@ -262,7 +296,7 @@ function fileName(value){
262
296
  /**
263
297
  * Composes the configured chat session with one automatically selected
264
298
  * ChatEntity. Request-only context is delegated to ConfiguredAIChatSession;
265
- * per-turn persistence affects DBOPFS and memory, never the live model context.
299
+ * persist:false uses the turn for one request and retains it nowhere afterward.
266
300
  */
267
301
  class PersistentAIChatSession{
268
302
  #activeStream=null;
@@ -550,6 +584,11 @@ class PersistentAIChatSession{
550
584
  );
551
585
  }
552
586
  }
587
+ if(!settings.messagePersist){
588
+ prepared.rollback();
589
+ prepared=null;
590
+ return result;
591
+ }
553
592
  await this.#entity.addTurn({
554
593
  assistantMessage:result.message,
555
594
  extractMemory:this.#memory&&settings.messagePersist&&settings.responsePersist,
@@ -560,9 +599,9 @@ class PersistentAIChatSession{
560
599
  })
561
600
  ),
562
601
  messagePersist:settings.messagePersist,
563
- ...(settings.requestMessages.length===1
564
- ?{requestMessage:settings.requestMessages[0]}
565
- :{requestMessages:settings.requestMessages}),
602
+ ...(settings.entityRequestMessages.length===1
603
+ ?{requestMessage:settings.entityRequestMessages[0]}
604
+ :{requestMessages:settings.entityRequestMessages}),
566
605
  responsePersist:settings.responsePersist,
567
606
  });
568
607
  const committed=prepared.commit();