arcane-os 0.1.2 → 0.2.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/NOTICE +5 -3
  3. package/README.md +73 -24
  4. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
  5. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
  6. package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
  7. package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
  8. package/browser-runtime/ai/browser-speech-providers.mjs +780 -0
  9. package/browser-runtime/ai/browser-speech.mjs +9 -0
  10. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
  11. package/browser-runtime/ai/browser-wasm.mjs +46 -1
  12. package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
  13. package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
  14. package/browser-runtime/ai/model-controller.mjs +138 -12
  15. package/browser-runtime/ai/speech-worker-client.mjs +207 -0
  16. package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
  17. package/browser-runtime/ai/wllama/index.mjs +389 -0
  18. package/docs/architecture.md +132 -22
  19. package/docs/reference/README.md +1 -1
  20. package/docs/reference/ai/browser-wasm.md +101 -42
  21. package/docs/reference/availability-and-normalization.md +19 -5
  22. package/docs/reference/behavioral-testing.md +18 -5
  23. package/docs/reference/cli.md +2 -2
  24. package/docs/reference/inventory/package-api.json +14 -14
  25. package/docs/reference/protocols.md +4 -4
  26. package/docs/reference/sdk-api.md +68 -38
  27. package/docs/work-amplification.md +8 -4
  28. package/package.json +7 -3
  29. package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
  30. package/runtime/arcane/components/chat.html +551 -62
  31. package/runtime/arcane/components/speech.html +1113 -265
  32. package/runtime/arcane/entities/Chat.js +246 -43
  33. package/runtime/arcane/modules/AI.js +1394 -162
  34. package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
  35. package/runtime/arcane/modules/AIRuntimeState.js +872 -0
  36. package/runtime/arcane/modules/ConfiguredAIChatSession.js +382 -31
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -0
  38. package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
  39. package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
  40. package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
  41. package/schemas/arcane-lock.schema.json +10 -6
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +273 -26
  45. package/src/doctor.mjs +1 -3
  46. package/src/import-map.mjs +193 -84
  47. package/src/packager/core.mjs +313 -41
  48. package/src/runtime.mjs +14 -4
  49. package/src/scaffold.mjs +45 -17
  50. package/src/sdk-browser-runtime.mjs +28 -75
  51. package/src/templates/workspace-template.mjs +27 -8
  52. package/src/toolchain.mjs +13 -2
  53. package/src/workspace-runtime.mjs +1 -1
  54. package/src/workspace.mjs +178 -25
@@ -0,0 +1,292 @@
1
+ const DOCUMENT_SEARCH_FIELD_ORDER=Object.freeze([
2
+ 'title','searchTerms','tags','headings','summary','category','navigationGroup',
3
+ 'navigationParent','audiences','platforms','sourcePath','path','language','id'
4
+ ]);
5
+ const SEARCH_STOP_WORDS=new Set([
6
+ 'a','an','and','are','as','at','be','by','do','does','for','from','how','i',
7
+ 'in','is','it','of','on','or','that','the','this','to','use','using','what',
8
+ 'when','where','which','who','why','with','you','your'
9
+ ]);
10
+ const CONTROL_CHARACTERS=/[\u0000-\u001f\u007f]/;
11
+
12
+ function fail(message,code='DOCUMENT_SEARCH_INVALID'){
13
+ const error=new TypeError(message);
14
+ error.code=code;
15
+ throw error;
16
+ }
17
+
18
+ function isPlainRecord(value){
19
+ return Boolean(value)
20
+ &&typeof value==='object'
21
+ &&!Array.isArray(value)
22
+ &&Object.getPrototypeOf(value)===Object.prototype;
23
+ }
24
+
25
+ function normalizedDocumentSearchText(value){
26
+ return String(value??'').normalize('NFKD').toLowerCase();
27
+ }
28
+
29
+ function documentSearchTokens(value){
30
+ return [...new Set(normalizedDocumentSearchText(value).match(/[\p{L}\p{N}]+/gu)??[])]
31
+ .filter(token=>!SEARCH_STOP_WORDS.has(token))
32
+ .slice(0,32);
33
+ }
34
+
35
+ function list(value,mapper=normalizedDocumentSearchText){
36
+ return Array.isArray(value)?value.map(mapper):[];
37
+ }
38
+
39
+ function createDocumentLexicalIndex(record){
40
+ if(!isPlainRecord(record)) fail('Document search records must be plain objects.');
41
+ return Object.freeze({
42
+ audiences:list(record.audiences),
43
+ category:normalizedDocumentSearchText(record.category),
44
+ headings:list(record.headings,heading=>normalizedDocumentSearchText(heading?.text)),
45
+ id:normalizedDocumentSearchText(record.id),
46
+ language:normalizedDocumentSearchText(record.language),
47
+ navigationGroup:normalizedDocumentSearchText(record.navigationGroup),
48
+ navigationParent:normalizedDocumentSearchText(record.navigationParent),
49
+ path:normalizedDocumentSearchText(record.path),
50
+ platforms:list(record.platforms),
51
+ searchTerms:list(record.searchTerms),
52
+ sourcePath:normalizedDocumentSearchText(record.sourcePath),
53
+ summary:normalizedDocumentSearchText(record.summary),
54
+ tags:list(record.tags),
55
+ title:normalizedDocumentSearchText(record.title),
56
+ });
57
+ }
58
+
59
+ function scoreDocumentLexicalIndex(index,phrase,tokens){
60
+ const matched=new Set();
61
+ let score=0;
62
+ if(index.title===phrase){score+=120;matched.add('title');}
63
+ else if(index.title.includes(phrase)){score+=60;matched.add('title');}
64
+ if(index.id===phrase){score+=100;matched.add('id');}
65
+ else if(index.id.includes(phrase)){score+=20;matched.add('id');}
66
+ if(index.path.includes(phrase)){score+=24;matched.add('path');}
67
+ if(index.sourcePath.includes(phrase)){score+=24;matched.add('sourcePath');}
68
+ if(index.language===phrase){score+=30;matched.add('language');}
69
+ if(index.summary.includes(phrase)){score+=18;matched.add('summary');}
70
+ if(index.category===phrase){score+=40;matched.add('category');}
71
+ else if(index.category.includes(phrase)){score+=16;matched.add('category');}
72
+ if(index.navigationGroup===phrase){score+=40;matched.add('navigationGroup');}
73
+ else if(index.navigationGroup.includes(phrase)){score+=16;matched.add('navigationGroup');}
74
+ if(index.navigationParent===phrase){score+=36;matched.add('navigationParent');}
75
+ else if(index.navigationParent.includes(phrase)){score+=14;matched.add('navigationParent');}
76
+ if(index.audiences.some(value=>value===phrase)){score+=32;matched.add('audiences');}
77
+ else if(index.audiences.some(value=>value.includes(phrase))){score+=12;matched.add('audiences');}
78
+ if(index.platforms.some(value=>value===phrase)){score+=32;matched.add('platforms');}
79
+ else if(index.platforms.some(value=>value.includes(phrase))){score+=12;matched.add('platforms');}
80
+ if(index.searchTerms.some(term=>term===phrase)){score+=110;matched.add('searchTerms');}
81
+ else if(index.searchTerms.some(term=>term.includes(phrase))){score+=52;matched.add('searchTerms');}
82
+ for(const tag of index.tags){
83
+ if(tag===phrase){score+=40;matched.add('tags');}
84
+ else if(tag.includes(phrase)){score+=16;matched.add('tags');}
85
+ }
86
+ if(index.headings.some(heading=>heading.includes(phrase))){score+=22;matched.add('headings');}
87
+
88
+ for(const token of tokens){
89
+ if(index.title.split(/[^\p{L}\p{N}]+/u).includes(token)){score+=14;matched.add('title');}
90
+ else if(index.title.includes(token)){score+=7;matched.add('title');}
91
+ if(index.tags.some(tag=>tag===token)){score+=12;matched.add('tags');}
92
+ else if(index.tags.some(tag=>tag.includes(token))){score+=5;matched.add('tags');}
93
+ if(index.headings.some(heading=>heading.includes(token))){score+=5;matched.add('headings');}
94
+ if(index.summary.includes(token)){score+=3;matched.add('summary');}
95
+ if(index.category.includes(token)){score+=6;matched.add('category');}
96
+ if(index.navigationGroup.includes(token)){score+=6;matched.add('navigationGroup');}
97
+ if(index.navigationParent.includes(token)){score+=6;matched.add('navigationParent');}
98
+ if(index.audiences.some(value=>value.includes(token))){score+=6;matched.add('audiences');}
99
+ if(index.platforms.some(value=>value.includes(token))){score+=6;matched.add('platforms');}
100
+ if(index.searchTerms.some(term=>term===token)){score+=18;matched.add('searchTerms');}
101
+ else if(index.searchTerms.some(term=>term.includes(token))){score+=9;matched.add('searchTerms');}
102
+ if(index.sourcePath.includes(token)){score+=4;matched.add('sourcePath');}
103
+ if(index.language===token){score+=8;matched.add('language');}
104
+ if(index.path.includes(token)){score+=4;matched.add('path');}
105
+ if(index.id.includes(token)){score+=5;matched.add('id');}
106
+ }
107
+ return Object.freeze({matched,score});
108
+ }
109
+
110
+ function scoreDocumentBody(value,phrase,tokens){
111
+ const body=normalizedDocumentSearchText(value);
112
+ let score=0;
113
+ if(phrase&&body.includes(phrase)) score+=30;
114
+ for(const token of tokens){
115
+ if(body.includes(token)) score+=6;
116
+ }
117
+ return score;
118
+ }
119
+
120
+ function canonicalKey(value){
121
+ return String(value).normalize('NFC').toLowerCase();
122
+ }
123
+
124
+ function compareText(left,right){
125
+ return left<right?-1:left>right?1:0;
126
+ }
127
+
128
+ function boundedSearchResults(results,limit){
129
+ if(results.length<=limit) return results;
130
+ const collectionLimit=Math.max(1,Math.floor(limit/4));
131
+ const collectionCounts=new Map();
132
+ const selected=new Set();
133
+ const deferred=[];
134
+ for(const result of results){
135
+ if(selected.size>=limit) break;
136
+ if(!result.navigationParent){selected.add(result);continue;}
137
+ const parent=canonicalKey(result.navigationParent);
138
+ const count=collectionCounts.get(parent)??0;
139
+ if(count>=collectionLimit){deferred.push(result);continue;}
140
+ collectionCounts.set(parent,count+1);
141
+ selected.add(result);
142
+ }
143
+ for(const result of deferred){
144
+ if(selected.size>=limit) break;
145
+ selected.add(result);
146
+ }
147
+ return results.filter(result=>selected.has(result));
148
+ }
149
+
150
+ function normalizeQuery(value){
151
+ if(typeof value!=='string') fail('Search query must be a string.','DOCUMENT_SEARCH_INVALID_QUERY');
152
+ const query=value.trim();
153
+ if(query.length>512||CONTROL_CHARACTERS.test(query)){
154
+ fail('Search query must be bounded plain text.','DOCUMENT_SEARCH_INVALID_QUERY');
155
+ }
156
+ return query;
157
+ }
158
+
159
+ function normalizeFilter(value,label){
160
+ if(value===undefined) return null;
161
+ if(!Array.isArray(value)||value.length>64) fail(`${label} must be a bounded array.`,'DOCUMENT_SEARCH_INVALID_QUERY');
162
+ return new Set(value.map((item,index)=>{
163
+ if(typeof item!=='string'||!item.trim()||item.length>64){
164
+ fail(`${label} entry ${index+1} must be bounded text.`,'DOCUMENT_SEARCH_INVALID_QUERY');
165
+ }
166
+ return canonicalKey(item.trim());
167
+ }));
168
+ }
169
+
170
+ function safeSlice(value,maximum){
171
+ if(value.length<=maximum) return value;
172
+ let end=maximum;
173
+ const code=value.charCodeAt(end-1);
174
+ if(code>=0xd800&&code<=0xdbff) end--;
175
+ return value.slice(0,end);
176
+ }
177
+
178
+ function relevantSliceStart(value,query,maximum){
179
+ if(value.length<=maximum) return 0;
180
+ const phrase=String(query||'').trim().toLowerCase();
181
+ const tokens=documentSearchTokens(query);
182
+ const body=value.toLowerCase();
183
+ const positions=[phrase,...tokens]
184
+ .filter(Boolean)
185
+ .map(term=>body.indexOf(term))
186
+ .filter(index=>index>=0);
187
+ const match=positions.length?Math.min(...positions):0;
188
+ let start=Math.max(0,match-Math.floor(maximum/3));
189
+ const priorNewline=start>0?value.lastIndexOf('\n',start-1):-1;
190
+ const alignedStart=priorNewline+1;
191
+ if(match-alignedStart<=Math.floor(maximum*2/3)) start=alignedStart;
192
+ if(start>0){
193
+ const code=value.charCodeAt(start);
194
+ if(code>=0xdc00&&code<=0xdfff) start++;
195
+ }
196
+ return start;
197
+ }
198
+
199
+ function lineNumberAt(value,offset){
200
+ let line=1;
201
+ let cursor=value.indexOf('\n');
202
+ while(cursor>=0&&cursor<offset){line++;cursor=value.indexOf('\n',cursor+1);}
203
+ return line;
204
+ }
205
+
206
+ function documentContextExcerpt(value,query,maximum,{relevant=false}={}){
207
+ if(typeof value!=='string') fail('Document context must be text.');
208
+ if(!Number.isSafeInteger(maximum)||maximum<1) fail('Document context limit must be a positive integer.');
209
+ const start=relevant?relevantSliceStart(value,query,maximum):0;
210
+ const text=safeSlice(value.slice(start),maximum);
211
+ const end=start+text.length;
212
+ return Object.freeze({
213
+ lineEnd:lineNumberAt(value,Math.max(start,end-1)),
214
+ lineStart:lineNumberAt(value,start),
215
+ text,
216
+ truncated:start>0||end<value.length,
217
+ });
218
+ }
219
+
220
+ class DocumentLexicalSearch{
221
+ #indexes;
222
+ #maxResults;
223
+ #records;
224
+
225
+ constructor(records,{maxResults=20}={}){
226
+ if(!Array.isArray(records)) fail('Document search records must be an array.');
227
+ if(!Number.isSafeInteger(maxResults)||maxResults<1||maxResults>100){
228
+ fail('maxResults must be an integer from 1 through 100.');
229
+ }
230
+ this.#records=Object.freeze([...records]);
231
+ this.#indexes=new Map(this.#records.map(record=>[record.id,createDocumentLexicalIndex(record)]));
232
+ this.#maxResults=maxResults;
233
+ }
234
+
235
+ rank(query,options={}){
236
+ if(!isPlainRecord(options)) fail('Search options must be a plain object.','DOCUMENT_SEARCH_INVALID_QUERY');
237
+ const unknown=Object.keys(options).find(key=>!['kinds','tags'].includes(key));
238
+ if(unknown) fail(`Search options contain an unsupported field: ${unknown}.`,'DOCUMENT_SEARCH_INVALID_QUERY');
239
+ const text=normalizeQuery(query);
240
+ const phrase=normalizedDocumentSearchText(text);
241
+ const tokens=documentSearchTokens(text);
242
+ const kinds=normalizeFilter(options.kinds,'kinds');
243
+ const tags=normalizeFilter(options.tags,'tags');
244
+ const results=[];
245
+ for(const record of this.#records){
246
+ if(kinds&&!kinds.has(canonicalKey(record.kind))) continue;
247
+ if(tags&&![...tags].every(tag=>(record.tags??[]).some(item=>canonicalKey(item)===tag))) continue;
248
+ const {matched,score}=text
249
+ ?scoreDocumentLexicalIndex(this.#indexes.get(record.id),phrase,tokens)
250
+ :{matched:new Set(),score:0};
251
+ if(text&&!score) continue;
252
+ results.push(Object.freeze({
253
+ ...record,
254
+ matchedFields:Object.freeze(DOCUMENT_SEARCH_FIELD_ORDER.filter(field=>matched.has(field))),
255
+ score,
256
+ }));
257
+ }
258
+ results.sort((left,right)=>
259
+ right.score-left.score
260
+ ||compareText(normalizedDocumentSearchText(left.title),normalizedDocumentSearchText(right.title))
261
+ ||compareText(String(left.id),String(right.id))
262
+ );
263
+ return Object.freeze(results);
264
+ }
265
+
266
+ search(query,options={}){
267
+ if(!isPlainRecord(options)) fail('Search options must be a plain object.','DOCUMENT_SEARCH_INVALID_QUERY');
268
+ const unknown=Object.keys(options).find(key=>!['kinds','limit','tags'].includes(key));
269
+ if(unknown) fail(`Search options contain an unsupported field: ${unknown}.`,'DOCUMENT_SEARCH_INVALID_QUERY');
270
+ const limit=options.limit??this.#maxResults;
271
+ if(!Number.isSafeInteger(limit)||limit<1||limit>this.#maxResults){
272
+ fail(`Search result limit must be an integer from 1 through ${this.#maxResults}.`,'DOCUMENT_SEARCH_INVALID_QUERY');
273
+ }
274
+ return Object.freeze(boundedSearchResults(this.rank(query,{
275
+ kinds:options.kinds,
276
+ tags:options.tags,
277
+ }),limit));
278
+ }
279
+ }
280
+
281
+ export {
282
+ DOCUMENT_SEARCH_FIELD_ORDER,
283
+ DocumentLexicalSearch,
284
+ createDocumentLexicalIndex,
285
+ documentContextExcerpt,
286
+ documentSearchTokens,
287
+ normalizedDocumentSearchText,
288
+ scoreDocumentBody,
289
+ scoreDocumentLexicalIndex,
290
+ };
291
+
292
+ export default DocumentLexicalSearch;
@@ -0,0 +1,268 @@
1
+ import ChatEntity from '../entities/Chat.js';
2
+ import ConfiguredAIChatSession from './ConfiguredAIChatSession.js';
3
+
4
+ function coded(error,code){
5
+ if(!error.code) error.code=code;
6
+ return error;
7
+ }
8
+
9
+ function isPlainRecord(value){
10
+ return Boolean(value)
11
+ &&typeof value==='object'
12
+ &&!Array.isArray(value)
13
+ &&Object.getPrototypeOf(value)===Object.prototype;
14
+ }
15
+
16
+ function assertKnownKeys(value,allowed,label){
17
+ const unknown=Object.keys(value).find(key=>!allowed.has(key));
18
+ if(unknown) throw new TypeError(`${label} contains an unsupported field: ${unknown}.`);
19
+ }
20
+
21
+ function boolean(value,label,defaultValue){
22
+ if(value===undefined) return defaultValue;
23
+ if(typeof value!=='boolean') throw new TypeError(`${label} must be a boolean.`);
24
+ return value;
25
+ }
26
+
27
+ function signalLike(value){
28
+ return value===undefined||value===null||(
29
+ typeof value==='object'
30
+ &&typeof value.aborted==='boolean'
31
+ &&typeof value.addEventListener==='function'
32
+ &&typeof value.removeEventListener==='function'
33
+ );
34
+ }
35
+
36
+ async function configuredArcaneChat(request){
37
+ const api=globalThis.Arcane?.ai;
38
+ if(typeof api?.chat!=='function'){
39
+ throw coded(
40
+ new Error('The configured Arcane AI chat capability is unavailable.'),
41
+ 'AI_CHAT_UNAVAILABLE'
42
+ );
43
+ }
44
+ return api.chat(request);
45
+ }
46
+
47
+ function normalizeSend(input){
48
+ if(!isPlainRecord(input)) throw new TypeError('Persistent chat input must be a plain object.');
49
+ assertKnownKeys(input,new Set(['message','response','signal']),'Persistent chat input');
50
+ if(!isPlainRecord(input.message)) throw new TypeError('message must be a plain object.');
51
+ assertKnownKeys(input.message,new Set(['content','persist','role','tool_call_id']),'message');
52
+ if(typeof input.message.content!=='string'||!input.message.content.trim()){
53
+ throw new TypeError('message.content must contain text.');
54
+ }
55
+ const role=input.message.role??'user';
56
+ if(!['tool','user'].includes(role)) throw new TypeError('message.role must be user or tool.');
57
+ let toolCallId=null;
58
+ if(role==='tool'){
59
+ if(typeof input.message.tool_call_id!=='string'||!input.message.tool_call_id.trim()){
60
+ throw new TypeError('message.tool_call_id is required for tool messages.');
61
+ }
62
+ toolCallId=input.message.tool_call_id;
63
+ }else if(input.message.tool_call_id!==undefined){
64
+ throw new TypeError('message.tool_call_id is supported only for tool messages.');
65
+ }
66
+ const messagePersist=boolean(input.message.persist,'message.persist',true);
67
+ const response=input.response??{};
68
+ if(!isPlainRecord(response)) throw new TypeError('response must be a plain object.');
69
+ assertKnownKeys(response,new Set(['persist']),'response');
70
+ const responsePersist=boolean(response.persist,'response.persist',messagePersist);
71
+ if(responsePersist!==messagePersist){
72
+ throw coded(
73
+ new TypeError('message.persist and response.persist must match for one coherent durable turn.'),
74
+ 'AI_CHAT_INCOHERENT_PERSISTENCE',
75
+ );
76
+ }
77
+ if(!signalLike(input.signal)) throw new TypeError('signal must be an AbortSignal.');
78
+ return Object.freeze({
79
+ messagePersist,
80
+ requestMessage:Object.freeze({
81
+ content:input.message.content,
82
+ role,
83
+ ...(toolCallId?{tool_call_id:toolCallId}:{}),
84
+ }),
85
+ responsePersist,
86
+ signal:input.signal??null,
87
+ });
88
+ }
89
+
90
+ function fileName(value){
91
+ if(typeof value!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}\.jsonl$/.test(value)){
92
+ throw new TypeError('chatFileName must be a safe .jsonl file name.');
93
+ }
94
+ return value;
95
+ }
96
+
97
+ /**
98
+ * Composes the bounded configured chat session with one automatically selected
99
+ * ChatEntity. Request-only context is delegated to ConfiguredAIChatSession;
100
+ * per-turn persistence affects DBOPFS and memory, never the live model context.
101
+ */
102
+ class PersistentAIChatSession{
103
+ #configured=null;
104
+ #entity;
105
+ #memory;
106
+ #options;
107
+ #pending=false;
108
+ #readyPromise;
109
+ #toolCallPersistence=new Map();
110
+
111
+ constructor(options={}){
112
+ if(!isPlainRecord(options)) throw new TypeError('Persistent chat options must be a plain object.');
113
+ assertKnownKeys(
114
+ options,
115
+ new Set([
116
+ 'chat','chatEntity','chatFileName','contextBuilder','loadExisting','maxContextCharacters',
117
+ 'maxMessageCharacters','maxMessages','memory','request','responseLength','systemPrompt'
118
+ ]),
119
+ 'Persistent chat options',
120
+ );
121
+ if(options.chatEntity!==undefined&&!(options.chatEntity instanceof ChatEntity)){
122
+ throw new TypeError('chatEntity must be a ChatEntity.');
123
+ }
124
+ const chat=options.chat??configuredArcaneChat;
125
+ if(typeof chat!=='function') throw new TypeError('chat must be a function.');
126
+ const systemPrompt=options.systemPrompt??'';
127
+ if(typeof systemPrompt!=='string') throw new TypeError('systemPrompt must be a string.');
128
+ this.#entity=options.chatEntity??new ChatEntity(systemPrompt);
129
+ if(options.chatFileName!==undefined){
130
+ this.#entity.fileName=fileName(options.chatFileName);
131
+ }
132
+ const loadExisting=boolean(
133
+ options.loadExisting,
134
+ 'loadExisting',
135
+ options.chatFileName!==undefined,
136
+ );
137
+ if(loadExisting&&!options.chatEntity&&options.chatFileName===undefined){
138
+ throw new TypeError('loadExisting requires chatFileName or chatEntity.');
139
+ }
140
+ this.#memory=boolean(options.memory,'memory',true);
141
+ this.#options=Object.freeze({...options,chat,loadExisting,systemPrompt});
142
+ this.#readyPromise=this.#initialize();
143
+ }
144
+
145
+ static async create(options={}){
146
+ const session=new PersistentAIChatSession(options);
147
+ await session.ready();
148
+ return session;
149
+ }
150
+
151
+ get chatEntity(){return this.#entity;}
152
+ get fileName(){return this.#entity.fileName;}
153
+
154
+ async #initialize(){
155
+ if(this.#options.loadExisting) await this.#entity.load();
156
+ const storedMessages=this.#entity.messages;
157
+ const storedSystem=storedMessages.find(message=>message.role==='system');
158
+ const initialMessages=storedMessages
159
+ .filter(message=>['user','assistant','tool'].includes(message.role))
160
+ .map(message=>Object.freeze({
161
+ role:message.role,
162
+ content:String(message.content??''),
163
+ ...(message.tool_calls?{tool_calls:message.tool_calls}:{}),
164
+ ...(message.tool_call_id?{tool_call_id:message.tool_call_id}:{}),
165
+ }));
166
+ for(const message of initialMessages){
167
+ if(message.role==='assistant'){
168
+ for(const call of message.tool_calls??[]) this.#toolCallPersistence.set(call.id,true);
169
+ }else if(message.role==='tool'){
170
+ this.#toolCallPersistence.delete(message.tool_call_id);
171
+ }
172
+ }
173
+ const configuredOptions={
174
+ chat:this.#options.chat,
175
+ contextBuilder:this.#options.contextBuilder,
176
+ initialMessages,
177
+ maxContextCharacters:this.#options.maxContextCharacters,
178
+ maxMessageCharacters:this.#options.maxMessageCharacters,
179
+ maxMessages:this.#options.maxMessages,
180
+ request:this.#options.request,
181
+ systemPrompt:this.#options.systemPrompt||String(storedSystem?.content??''),
182
+ };
183
+ if(Object.hasOwn(this.#options,'responseLength')){
184
+ configuredOptions.responseLength=this.#options.responseLength;
185
+ }
186
+ for(const key of Object.keys(configuredOptions)){
187
+ if(configuredOptions[key]===undefined) delete configuredOptions[key];
188
+ }
189
+ this.#configured=new ConfiguredAIChatSession(configuredOptions);
190
+ return this;
191
+ }
192
+
193
+ async ready(){
194
+ await this.#readyPromise;
195
+ return this;
196
+ }
197
+
198
+ async history(){
199
+ await this.ready();
200
+ return this.#configured.history();
201
+ }
202
+
203
+ async settleMemory(){
204
+ await this.ready();
205
+ return this.#entity.settleMemory();
206
+ }
207
+
208
+ async send(input){
209
+ const settings=normalizeSend(input);
210
+ if(this.#pending){
211
+ throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
212
+ }
213
+ this.#pending=true;
214
+ let prepared=null;
215
+ try{
216
+ await this.ready();
217
+ await this.#entity.settleMemory();
218
+ if(settings.requestMessage.role==='user'&&this.#toolCallPersistence.size){
219
+ throw coded(
220
+ new TypeError('The pending structural tool result must be supplied before a new user turn.'),
221
+ 'AI_CHAT_TOOL_RESULT_REQUIRED',
222
+ );
223
+ }
224
+ if(settings.requestMessage.role==='tool'){
225
+ const persistence=this.#toolCallPersistence.get(settings.requestMessage.tool_call_id);
226
+ if(persistence===undefined){
227
+ throw coded(new TypeError('The tool message has no pending structural tool call.'),'AI_CHAT_INVALID_TOOL_MESSAGE');
228
+ }
229
+ if(persistence!==settings.messagePersist){
230
+ throw coded(
231
+ new TypeError('A tool result must use the persistence of its assistant tool call.'),
232
+ 'AI_CHAT_INCOHERENT_PERSISTENCE',
233
+ );
234
+ }
235
+ }
236
+ prepared=await this.#configured.prepare(settings.requestMessage,{signal:settings.signal});
237
+ const result=prepared.response;
238
+ await this.#entity.addTurn({
239
+ assistantMessage:result.message,
240
+ extractMemory:this.#memory&&settings.messagePersist&&settings.responsePersist,
241
+ memoryRequest:messages=>this.#options.chat({messages}),
242
+ messagePersist:settings.messagePersist,
243
+ requestMessage:settings.requestMessage,
244
+ responsePersist:settings.responsePersist,
245
+ });
246
+ const committed=prepared.commit();
247
+ if(settings.requestMessage.role==='tool'){
248
+ this.#toolCallPersistence.delete(settings.requestMessage.tool_call_id);
249
+ }
250
+ for(const call of result.message.tool_calls??[]){
251
+ this.#toolCallPersistence.set(call.id,settings.responsePersist);
252
+ }
253
+ return committed;
254
+ }catch(error){
255
+ prepared?.rollback();
256
+ throw error;
257
+ }finally{
258
+ this.#pending=false;
259
+ }
260
+ }
261
+ }
262
+
263
+ function createPersistentAIChatSession(options){
264
+ return PersistentAIChatSession.create(options);
265
+ }
266
+
267
+ export {PersistentAIChatSession,createPersistentAIChatSession};
268
+ export default PersistentAIChatSession;