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