arcane-os 0.5.5 → 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,15 @@
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
+
3
13
  ## 0.5.5
4
14
 
5
15
  - Added configurable complete text-to-speech stream segmentation through
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.5` 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
 
@@ -186,7 +186,7 @@ uses the same controller for automatic memory extraction.
186
186
  Create a new repository-shaped Arcane application with the exact stable SDK:
187
187
 
188
188
  ```bash
189
- npx arcane-os@0.5.5 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
190
190
  cd my-app
191
191
  npm install
192
192
  npm run check
@@ -197,7 +197,7 @@ To enroll an existing repository, install the exact SDK and initialize only
197
197
  missing Arcane files:
198
198
 
199
199
  ```bash
200
- npm install --save-dev --save-exact arcane-os@0.5.5
200
+ npm install --save-dev --save-exact arcane-os@0.5.6
201
201
  npm exec -- arcane init my-app --target portable
202
202
  ```
203
203
 
@@ -213,7 +213,7 @@ npm exec -- arcane-os targets
213
213
  No global SDK install or standalone Arcane CLI is required. The application
214
214
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
215
215
 
216
- Use `npx arcane-os@0.5.5` 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
217
217
  package explicitly; bare `npx arcane` outside an installed project could resolve
218
218
  a different package. Both installed commands invoke the same headless toolchain.
219
219
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -234,7 +234,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
234
234
 
235
235
  # From the generated app repository
236
236
  cd ../local-app
237
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.5.tgz
237
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.6.tgz
238
238
  npm run check
239
239
  npm ci
240
240
  ```
@@ -244,7 +244,7 @@ same location. The lockfile retains the selected package dependency while
244
244
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
245
245
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
246
246
  runner also needs that tarball at the locked path. After publication, replace
247
- the local declaration with the exact `arcane-os@0.5.5` registry package and
247
+ the local declaration with the exact `arcane-os@0.5.6` registry package and
248
248
  commit the regenerated lock.
249
249
 
250
250
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -382,7 +382,7 @@ package installation, or assertions.
382
382
 
383
383
  ## Current target support
384
384
 
385
- Version `0.5.5` exposes one browser target and five explicitly paired
385
+ Version `0.5.6` exposes one browser target and five explicitly paired
386
386
  native development targets: a non-runnable portable directory, a
387
387
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
388
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.5",
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,
@@ -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();