saico 2.3.0 → 2.4.0

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 (7) hide show
  1. package/README.md +287 -300
  2. package/index.js +1 -4
  3. package/itask.js +16 -3
  4. package/msgs.js +35 -81
  5. package/package.json +1 -2
  6. package/saico.js +307 -35
  7. package/sid.js +0 -248
package/saico.js CHANGED
@@ -17,6 +17,9 @@ const { Store } = require('./store.js');
17
17
  * message Q context (when opts.createQ is true).
18
18
  * - DB access works before and after activation.
19
19
  *
20
+ * Saico orchestrates the full message payload sent to the LLM by walking its
21
+ * parent chain to aggregate prompts, tools, digests, and state summaries.
22
+ *
20
23
  * `new Saico(opt)` returns a Redis observable proxy of the instance when
21
24
  * Redis is available, enabling automatic persistence of public properties.
22
25
  */
@@ -30,10 +33,14 @@ class Saico {
30
33
  * @param {Array} [opt.functions] - Available AI functions
31
34
  * @param {string} [opt.key] - Redis key override (default: 'saico:<id>')
32
35
  * @param {boolean} [opt.redis=true] - Set false to skip Redis proxy
36
+ * @param {boolean} [opt.isolate] - Isolate: don't aggregate from ancestors
33
37
  * @param {string} [opt.dynamodb_table] - DynamoDB table name (enables db accessor)
34
38
  * @param {string} [opt.dynamodb_region] - AWS region for DynamoDB
35
39
  * @param {Object} [opt.dynamodb_client] - Injectable DynamoDB client (for testing)
40
+ * @param {Object} [opt.db] - Pluggable DB backend
36
41
  * @param {Object} [opt.store] - Store instance override
42
+ * @param {Object} [opt.userData] - Initial user data
43
+ * @param {Object} [opt.sessionConfig] - Session config overrides
37
44
  */
38
45
  constructor(opt = {}) {
39
46
  // Internal properties (underscore-prefixed, not persisted to Redis)
@@ -41,6 +48,7 @@ class Saico {
41
48
  this._task = null;
42
49
  this._store = opt.store || Store.instance || null;
43
50
  this._opt = opt;
51
+ this._isolate = opt.isolate || false;
44
52
 
45
53
  // Public configuration
46
54
  this.name = opt.name || this.constructor.name || 'saico';
@@ -48,10 +56,19 @@ class Saico {
48
56
  this.tool_handler = opt.tool_handler || null;
49
57
  this.functions = opt.functions || null;
50
58
 
59
+ // Absorbed from Sid
60
+ this.userData = opt.userData || {};
61
+ this.tm_create = Date.now();
62
+ this.sessionConfig = {
63
+ token_limit: opt.token_limit,
64
+ max_depth: opt.max_depth,
65
+ max_tool_repetition: opt.max_tool_repetition,
66
+ queue_limit: opt.queue_limit,
67
+ min_chat_messages: opt.min_chat_messages,
68
+ ...opt.sessionConfig,
69
+ };
70
+
51
71
  // DB backend — pluggable storage adapter.
52
- // Any adapter that implements the same interface (put/get/delete/query/
53
- // getAll/update/updatePath/listAppend/listAppendPath/nextCounterId/
54
- // getCounterValue/setCounterValue/countItems) can be used.
55
72
  this._db = opt.db || null;
56
73
  if (!this._db && opt.dynamodb_table) {
57
74
  const { DynamoDBAdapter } = require('./dynamo.js');
@@ -119,21 +136,18 @@ class Saico {
119
136
 
120
137
  this._task = new Itask(taskOpt, states);
121
138
 
122
- // Delegate getStateSummary from task to this Saico instance
123
- const saicoInstance = this;
124
- this._task.getStateSummary = function () {
125
- return saicoInstance.getStateSummary();
126
- };
139
+ // Store Saico reference on task for parent chain traversal
140
+ this._task._saico = this;
127
141
 
128
142
  // Create message Q context if requested (only via createQ flag, NOT prompt)
129
143
  if (opts.createQ) {
130
144
  const contextConfig = {
131
145
  tag: opts.tag || this._task.id,
132
- token_limit: opts.token_limit,
133
- max_depth: opts.max_depth,
134
- max_tool_repetition: opts.max_tool_repetition,
135
- queue_limit: opts.queue_limit,
136
- min_chat_messages: opts.min_chat_messages,
146
+ token_limit: opts.token_limit ?? this.sessionConfig.token_limit,
147
+ max_depth: opts.max_depth ?? this.sessionConfig.max_depth,
148
+ max_tool_repetition: opts.max_tool_repetition ?? this.sessionConfig.max_tool_repetition,
149
+ queue_limit: opts.queue_limit ?? this.sessionConfig.queue_limit,
150
+ min_chat_messages: opts.min_chat_messages ?? this.sessionConfig.min_chat_messages,
137
151
  tool_handler: taskOpt.tool_handler,
138
152
  functions: taskOpt.functions,
139
153
  sequential_mode: opts.sequential_mode,
@@ -153,28 +167,143 @@ class Saico {
153
167
  }
154
168
 
155
169
  /**
156
- * Deactivate — close context, cancel task, clean up.
170
+ * Deactivate — bubble cleaned messages to parent, close context, cancel task.
171
+ * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
172
+ * then closes the context without the default summary bubbling.
157
173
  */
158
174
  async deactivate() {
159
175
  if (!this._task) return;
160
- if (this._task.context)
161
- await this._task.closeContext();
176
+ if (this._task.context) {
177
+ // Find parent context to bubble cleaned messages
178
+ let parentTask = this._task.parent;
179
+ let parentCtx = null;
180
+ while (parentTask) {
181
+ if (parentTask.context) { parentCtx = parentTask.context; break; }
182
+ parentTask = parentTask.parent;
183
+ }
184
+ if (parentCtx) {
185
+ const cleaned = this.getRecentMessages(Infinity);
186
+ for (const msg of cleaned)
187
+ parentCtx.push(msg);
188
+ }
189
+ // Clean tool calls and close context without additional summary bubbling.
190
+ // We already pushed cleaned messages above — closeContext's own
191
+ // summarization would double-bubble.
192
+ if (this._task.context_id && typeof this._task.context.cleanToolCallsByTag === 'function')
193
+ this._task.context.cleanToolCallsByTag(this._task.context_id);
194
+ this._task.context = null;
195
+ }
162
196
  this._task._ecancel();
163
197
  this._task = null;
164
198
  }
165
199
 
166
- // ---- Message relay ----
200
+ // ---- Saico parent chain traversal ----
201
+
202
+ /**
203
+ * Walk up the Saico parent chain (stop at isolate boundary or root).
204
+ * Returns array ordered root -> ... -> this.
205
+ */
206
+ _getSaicoAncestors() {
207
+ const chain = [this];
208
+ if (this._isolate) return chain;
209
+ let task = this._task?.parent;
210
+ while (task) {
211
+ if (task._saico) {
212
+ chain.unshift(task._saico);
213
+ if (task._saico._isolate) break;
214
+ }
215
+ task = task.parent;
216
+ }
217
+ return chain; // root -> ... -> this
218
+ }
219
+
220
+ /**
221
+ * Build preamble and aggregated functions by walking the Saico chain.
222
+ * @param {Context} activeCtx - The deepest active context (for state summary logic)
223
+ * @returns {{ preamble: Array, allFunctions: Array }}
224
+ */
225
+ _buildPreamble(activeCtx) {
226
+ const chain = this._getSaicoAncestors();
227
+ const preamble = [];
228
+ const allFunctions = [];
229
+
230
+ for (const saico of chain) {
231
+ // Prompt
232
+ if (saico.prompt)
233
+ preamble.push({ role: 'system', content: saico.prompt });
234
+
235
+ // State summary (can return array)
236
+ const summary = saico._getStateSummary(activeCtx);
237
+ if (Array.isArray(summary)) {
238
+ for (const item of summary) {
239
+ if (typeof item === 'string')
240
+ preamble.push({ role: 'system', content: '[State Summary]\n' + item });
241
+ else
242
+ preamble.push(item); // {role, content} message object
243
+ }
244
+ } else if (summary) {
245
+ preamble.push({ role: 'system', content: '[State Summary]\n' + summary });
246
+ }
247
+
248
+ // Tools digest
249
+ if (saico.context?.tool_digest?.length > 0) {
250
+ const digestText = saico.context.tool_digest.map(entry =>
251
+ `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
252
+ ).join('\n');
253
+ preamble.push({ role: 'system', content: '[Tool Activity Log]\n' + digestText });
254
+ }
255
+
256
+ // Collect functions
257
+ if (saico.functions) allFunctions.push(...saico.functions);
258
+ }
259
+
260
+ return { preamble, allFunctions };
261
+ }
262
+
263
+ // ---- Message orchestration ----
167
264
 
168
265
  async sendMessage(content, functions, opts) {
169
266
  if (!this._task)
170
267
  throw new Error('Not activated. Call activate() first.');
171
- return this._task.sendMessage(content, functions, opts);
268
+
269
+ // Find the active context (own or walk up)
270
+ let ctx = this._task.getContext() || this._task.findContext();
271
+ if (!ctx)
272
+ throw new Error('No context available');
273
+
274
+ // Build preamble by walking Saico chain
275
+ const activeCtx = this._task.findDeepestContext() || ctx;
276
+ const { preamble, allFunctions } = this._buildPreamble(activeCtx);
277
+
278
+ // Merge with call-specific functions
279
+ if (functions) allFunctions.push(...(Array.isArray(functions) ? functions : [functions]));
280
+
281
+ opts = Object.assign({}, opts, {
282
+ tag: this._task.context_id,
283
+ _preamble: preamble,
284
+ _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
285
+ });
286
+ return ctx.sendMessage('user', '[BACKEND] ' + content, null, opts);
172
287
  }
173
288
 
174
289
  async recvChatMessage(content, opts) {
175
290
  if (!this._task)
176
291
  throw new Error('Not activated. Call activate() first.');
177
- return this._task.recvChatMessage(content, opts);
292
+
293
+ // Route DOWN to deepest descendant with a msg Q
294
+ const ctx = this._task.findDeepestContext();
295
+ if (!ctx)
296
+ throw new Error('No context available');
297
+
298
+ // Build preamble by walking Saico chain
299
+ const { preamble, allFunctions } = this._buildPreamble(ctx);
300
+
301
+ opts = Object.assign({}, opts, {
302
+ tag: ctx.tag,
303
+ _preamble: preamble,
304
+ _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
305
+ });
306
+ return ctx.sendMessage('user', content, null, opts);
178
307
  }
179
308
 
180
309
  // ---- Task delegation ----
@@ -200,11 +329,11 @@ class Saico {
200
329
  if (opt.prompt) {
201
330
  const childContext = new Context(opt.prompt, childTask, {
202
331
  tag: opt.tag || childTask.id,
203
- token_limit: opt.token_limit,
204
- max_depth: opt.max_depth,
205
- max_tool_repetition: opt.max_tool_repetition,
206
- queue_limit: opt.queue_limit,
207
- min_chat_messages: opt.min_chat_messages,
332
+ token_limit: opt.token_limit ?? this.sessionConfig.token_limit,
333
+ max_depth: opt.max_depth ?? this.sessionConfig.max_depth,
334
+ max_tool_repetition: opt.max_tool_repetition ?? this.sessionConfig.max_tool_repetition,
335
+ queue_limit: opt.queue_limit ?? this.sessionConfig.queue_limit,
336
+ min_chat_messages: opt.min_chat_messages ?? this.sessionConfig.min_chat_messages,
208
337
  tool_handler: opt.tool_handler || this.tool_handler,
209
338
  functions: opt.functions || this.functions,
210
339
  });
@@ -238,10 +367,90 @@ class Saico {
238
367
  return childTask;
239
368
  }
240
369
 
370
+ // ---- State Summary ----
371
+
372
+ /**
373
+ * Override in subclasses to provide a state summary.
374
+ * @returns {string}
375
+ */
376
+ getStateSummary() { return ''; }
377
+
378
+ /**
379
+ * Get recent user/assistant messages (filtering out tool calls and BACKEND msgs).
380
+ * @param {number} n - Max number of messages to return
381
+ * @returns {Array<{role: string, content: string}>}
382
+ */
383
+ getRecentMessages(n = 5) {
384
+ if (!this.context) return [];
385
+ return this.context._msgs
386
+ .filter(m => {
387
+ if (m.msg.role === 'tool' || m.msg.tool_calls) return false;
388
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
389
+ return m.msg.role === 'user' || m.msg.role === 'assistant';
390
+ })
391
+ .slice(-n)
392
+ .map(m => ({ role: m.msg.role, content: m.msg.content }));
393
+ }
394
+
395
+ /**
396
+ * Internal state summary builder. Includes own getStateSummary() and,
397
+ * if this context is NOT the active (deepest) Q, includes recent messages.
398
+ * @param {Context} activeCtx - The deepest active context
399
+ * @returns {Array|string|null}
400
+ */
401
+ _getStateSummary(activeCtx) {
402
+ const parts = [];
403
+ const own = this.getStateSummary();
404
+ if (own) parts.push(own);
405
+
406
+ // If this context is NOT the active (deepest) Q, include recent messages
407
+ if (this.context && activeCtx && this.context !== activeCtx) {
408
+ const recent = this.getRecentMessages(5);
409
+ if (recent.length > 0) parts.push(...recent);
410
+ }
411
+
412
+ return parts.length > 0 ? parts : null;
413
+ }
414
+
415
+ // ---- User Data (absorbed from Sid) ----
416
+
417
+ setUserData(key, value) {
418
+ this.userData[key] = value;
419
+ return this;
420
+ }
421
+
422
+ getUserData(key) {
423
+ return key ? this.userData[key] : this.userData;
424
+ }
425
+
426
+ clearUserData() {
427
+ this.userData = {};
428
+ return this;
429
+ }
430
+
431
+ // ---- Session Info ----
432
+
433
+ getSessionInfo() {
434
+ return {
435
+ id: this._id,
436
+ name: this.name,
437
+ running: this._task?.running || false,
438
+ completed: this._task?._completed || false,
439
+ messageCount: this.context?.length || 0,
440
+ childCount: this._task?.child?.size || 0,
441
+ userData: this.userData,
442
+ uptime: Date.now() - this.tm_create,
443
+ };
444
+ }
445
+
446
+ async closeSession() {
447
+ if (!this._task) return;
448
+ if (this._task.context)
449
+ await this._task.context.close();
450
+ this._task._ecancel();
451
+ }
452
+
241
453
  // ---- Generic DB access ----
242
- // These delegate to whatever _db backend was configured (DynamoDB, MongoDB,
243
- // etc). Upper layers call these and don't care about the storage impl.
244
- // All are no-ops (return undefined) when no backend is configured.
245
454
 
246
455
  async dbPutItem(item, table) {
247
456
  if (!this._db) return;
@@ -250,7 +459,8 @@ class Saico {
250
459
 
251
460
  async dbGetItem(key, value, table) {
252
461
  if (!this._db) return;
253
- return this._db.get(key, value, table);
462
+ const result = await this._db.get(key, value, table);
463
+ return result ? this._deserializeRecord(result) : result;
254
464
  }
255
465
 
256
466
  async dbDeleteItem(key, value, table) {
@@ -260,12 +470,18 @@ class Saico {
260
470
 
261
471
  async dbQuery(index, key, value, table) {
262
472
  if (!this._db) return;
263
- return this._db.query(index, key, value, table);
473
+ const results = await this._db.query(index, key, value, table);
474
+ return Array.isArray(results)
475
+ ? results.map(r => this._deserializeRecord(r))
476
+ : results;
264
477
  }
265
478
 
266
479
  async dbGetAll(table) {
267
480
  if (!this._db) return;
268
- return this._db.getAll(table);
481
+ const results = await this._db.getAll(table);
482
+ return Array.isArray(results)
483
+ ? results.map(r => this._deserializeRecord(r))
484
+ : results;
269
485
  }
270
486
 
271
487
  async dbUpdate(key, keyValue, setKey, item, table) {
@@ -308,14 +524,15 @@ class Saico {
308
524
  return this._db.countItems(table);
309
525
  }
310
526
 
311
- // ---- Overridable hooks ----
527
+ // ---- DB deserialization hook ----
312
528
 
313
529
  /**
314
- * Override in subclasses to provide a state summary that appears
315
- * in the message queue sent to the AI model.
316
- * @returns {string}
530
+ * Override in subclasses to transform raw DB records (e.g. restore class instances).
531
+ * Called by dbGetItem, dbQuery, dbGetAll.
532
+ * @param {Object} raw - Raw record from DB
533
+ * @returns {Object} Transformed record
317
534
  */
318
- getStateSummary() { return ''; }
535
+ _deserializeRecord(raw) { return raw; }
319
536
 
320
537
  // ---- Serialization ----
321
538
 
@@ -324,6 +541,10 @@ class Saico {
324
541
  id: this._id,
325
542
  name: this.name,
326
543
  prompt: this.prompt,
544
+ userData: this.userData,
545
+ sessionConfig: this.sessionConfig,
546
+ tm_create: this.tm_create,
547
+ isolate: this._isolate,
327
548
  };
328
549
  if (this._task) {
329
550
  data.task = {
@@ -340,6 +561,57 @@ class Saico {
340
561
  }
341
562
  return JSON.stringify(data);
342
563
  }
564
+
565
+ /**
566
+ * Restore a Saico instance from serialized data.
567
+ * @param {string|Object} data - Serialized data (JSON string or object)
568
+ * @param {Object} opt - Options (tool_handler, functions, store, states, etc.)
569
+ * @returns {Saico}
570
+ */
571
+ static deserialize(data, opt = {}) {
572
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
573
+
574
+ const instance = new Saico({
575
+ id: parsed.id,
576
+ name: parsed.name,
577
+ prompt: parsed.prompt,
578
+ userData: parsed.userData,
579
+ sessionConfig: parsed.sessionConfig,
580
+ isolate: parsed.isolate,
581
+ tool_handler: opt.tool_handler,
582
+ functions: opt.functions || parsed.task?.context?.functions,
583
+ store: opt.store,
584
+ redis: false, // No Redis proxy during deserialization
585
+ });
586
+
587
+ instance.tm_create = parsed.tm_create || instance.tm_create;
588
+
589
+ // Activate with restored context if task data exists
590
+ if (parsed.task) {
591
+ instance.activate({
592
+ createQ: !!parsed.task.context,
593
+ taskId: parsed.task.id,
594
+ tag: parsed.task.context?.tag,
595
+ chat_history: parsed.task.context?.chat_history,
596
+ tool_handler: opt.tool_handler,
597
+ functions: opt.functions || parsed.task.context?.functions,
598
+ states: opt.states || [],
599
+ ...opt,
600
+ });
601
+
602
+ // Restore messages to context
603
+ if (parsed.task.context?.msgs && instance._task.context) {
604
+ instance._task.context._msgs = parsed.task.context.msgs;
605
+ }
606
+
607
+ // Restore tool_digest
608
+ if (Array.isArray(parsed.task.context?.tool_digest) && instance._task.context) {
609
+ instance._task.context.tool_digest = parsed.task.context.tool_digest;
610
+ }
611
+ }
612
+
613
+ return instance;
614
+ }
343
615
  }
344
616
 
345
617
  module.exports = { Saico };