saico 2.7.0 → 2.8.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 (4) hide show
  1. package/README.md +38 -17
  2. package/msgs.js +47 -32
  3. package/package.json +1 -1
  4. package/saico.js +89 -84
package/README.md CHANGED
@@ -69,14 +69,15 @@ Saico separates construction from activation:
69
69
  const agent = new Saico({
70
70
  name: 'agent',
71
71
  prompt: 'System prompt here',
72
+ createQ: true, // message Q created on activate()
72
73
  dynamodb: { region: 'us-east-1', credentials: { accessKeyId: 'AK', secretAccessKey: 'SK' } },
73
74
  });
74
75
 
75
76
  // DB methods work before activation
76
77
  const item = await agent.dbGetItem('id', '123');
77
78
 
78
- // 2. Activate — creates internal task + optional message queue context
79
- agent.activate({ createQ: true });
79
+ // 2. Activate — creates internal task + message Q (from this.createQ)
80
+ agent.activate();
80
81
 
81
82
  // 3. Use — send messages, spawn children
82
83
  await agent.sendMessage('Do something');
@@ -86,6 +87,23 @@ await agent.recvChatMessage('User says hello');
86
87
  await agent.deactivate();
87
88
  ```
88
89
 
90
+ Subclasses can also define `this.states` (task functions) in the constructor — `activate()` picks them up automatically:
91
+
92
+ ```js
93
+ class MyAgent extends Saico {
94
+ constructor() {
95
+ super({ name: 'agent', prompt: 'You are helpful', createQ: true });
96
+ this.states = [
97
+ async function main() {
98
+ return await this.sendMessage('Starting...');
99
+ }
100
+ ];
101
+ }
102
+ }
103
+ const agent = new MyAgent();
104
+ agent.activate(); // no params needed — uses this.createQ and this.states
105
+ ```
106
+
89
107
  ### Message Orchestration
90
108
 
91
109
  When `sendMessage()` or `recvChatMessage()` is called, Saico walks the parent chain to build the full LLM payload:
@@ -138,29 +156,28 @@ When a Saico's context is not the deepest active one, its last 5 user/assistant
138
156
  ### Spawning Child Saico Instances
139
157
 
140
158
  ```js
141
- // Child with its own conversation context
159
+ // Child with its own conversation context (auto-activated by spawn)
142
160
  const child = new Saico({
143
161
  name: 'subtask',
144
162
  prompt: 'Handle this specific sub-task',
163
+ createQ: true,
145
164
  functions: [/* child-specific tools */],
146
165
  });
147
- child.activate({ createQ: true });
148
166
  agent.spawn(child);
149
167
  await child.sendMessage('Working on subtask...');
150
168
 
151
169
  // Child without context (uses parent's via findContext())
152
170
  const simple = new Saico({ name: 'simple' });
153
- simple.activate();
154
171
  agent.spawn(simple);
155
172
  await simple.sendMessage('Quick operation');
156
173
 
157
174
  // spawnAndRun: spawn + schedule child task to run on nextTick
158
175
  const runner = new Saico({ name: 'runner' });
159
- runner.activate({ states: [async function() { return await this.sendMessage('Go'); }] });
176
+ runner.states = [async function() { return await this.sendMessage('Go'); }];
160
177
  agent.spawnAndRun(runner);
161
178
  ```
162
179
 
163
- Both parent and child must be activated before calling `spawn()` or `spawnAndRun()`.
180
+ Parent must be activated before calling `spawn()` or `spawnAndRun()`. Children are auto-activated if needed.
164
181
 
165
182
  ### Deactivation and Message Bubbling
166
183
 
@@ -177,6 +194,7 @@ new Saico({
177
194
  // AI config
178
195
  prompt: 'System prompt',
179
196
  functions: [], // OpenAI function definitions
197
+ createQ: false, // Create message Q on activate() (also settable as this.createQ)
180
198
 
181
199
  // Behavior
182
200
  isolate: false, // Stop ancestor aggregation
@@ -207,9 +225,9 @@ new Saico({
207
225
 
208
226
  ```js
209
227
  agent.activate({
210
- createQ: true, // Create message queue context
228
+ createQ: true, // Override this.createQ for this activation
211
229
  prompt: 'Extra prompt', // Appended to class-level prompt
212
- states: [], // Task state functions
230
+ states: [], // Override this.states for this activation
213
231
  taskId: 'custom-id',
214
232
  sequential_mode: true, // Process messages sequentially
215
233
 
@@ -239,7 +257,10 @@ agent.getSessionInfo();
239
257
  // userData, uptime
240
258
  // }
241
259
 
242
- await agent.closeSession(); // Close context and cancel task
260
+ await agent.closeSession(); // Saves full state to Store, cancels task
261
+
262
+ // Restore from Store
263
+ const restored = await Saico.rehydrate(agent._id, { store });
243
264
  ```
244
265
 
245
266
  ## Database Access
@@ -280,16 +301,16 @@ class MyAgent extends Saico {
280
301
  ## Serialization
281
302
 
282
303
  ```js
283
- // Save
304
+ // In-memory snapshot (raw msgs, used by Redis proxy)
284
305
  const json = agent.serialize();
306
+ const restored = Saico.deserialize(json);
285
307
 
286
- // Restore
287
- const restored = Saico.deserialize(json, {
288
- functions: myFunctions,
289
- });
308
+ // Durable persistence (compressed msgs, saved to Store)
309
+ await agent.closeSession();
310
+ const restored2 = await Saico.rehydrate(agent._id, { store });
290
311
  ```
291
312
 
292
- Serialization includes: id, name, prompt, userData, sessionConfig, tm_create, isolate, and full context state (messages, tool_digest, chat_history).
313
+ `serialize()` includes: id, name, prompt, userData, sessionConfig, tm_create, isolate, and full context state (raw messages, tool_digest). `closeSession()` saves the same shape but with compressed messages for durable storage.
293
314
 
294
315
  ## Redis Persistence
295
316
 
@@ -369,7 +390,7 @@ saico/
369
390
  npm test
370
391
  ```
371
392
 
372
- 284 tests covering Saico lifecycle, context ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, serialization, and integration flows.
393
+ 296 tests covering Saico lifecycle, context ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, serialization, persistence (closeSession/rehydrate), and integration flows.
373
394
 
374
395
  ## Requirements
375
396
 
package/msgs.js CHANGED
@@ -33,9 +33,6 @@ class Context {
33
33
  this._deferred_tool_calls = [];
34
34
  this._tool_call_sequence = [];
35
35
 
36
- // Chat history persistence
37
- this.chat_history = config.chat_history || null;
38
-
39
36
  this._msgs = [];
40
37
  this._waitingQueue = [];
41
38
  this._active_tool_calls = new Map();
@@ -53,7 +50,8 @@ class Context {
53
50
  // Tool digest — persistent history of tool calls that mutated task state
54
51
  this.tool_digest = config.tool_digest || [];
55
52
 
56
- // Initialize messages if provided
53
+ // Initialize messages: explicit msgs take priority over chat_history
54
+ this._chat_history = config.chat_history || null;
57
55
  (config.msgs || []).forEach(m => this.push(m));
58
56
 
59
57
  _log('created Context for tag', this.tag);
@@ -66,6 +64,51 @@ class Context {
66
64
  this.functions = task?.functions;
67
65
  }
68
66
 
67
+ /**
68
+ * Decompress _chat_history into _msgs. Call after construction when
69
+ * restoring from persisted state. No-op if chat_history is absent or
70
+ * _msgs were already provided via config.msgs.
71
+ */
72
+ async initHistory() {
73
+ if (!this._chat_history || this._msgs.length > 0)
74
+ return;
75
+ const messages = await util.decompressMessages(this._chat_history);
76
+ if (!Array.isArray(messages) || messages.length === 0)
77
+ return;
78
+ for (const m of messages) {
79
+ this._msgs.push({
80
+ msg: m,
81
+ opts: {},
82
+ msgid: crypto.randomBytes(2).toString('hex'),
83
+ replied: 1,
84
+ });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Prepare the message Q for storage. Filters out tool calls, tool
90
+ * responses, and [BACKEND] messages, trims to QUEUE_LIMIT, compresses.
91
+ * Returns { chat_history, tool_digest }. Does NOT mutate _msgs.
92
+ */
93
+ async prepareForStorage() {
94
+ const cleaned = this._msgs.filter(m => {
95
+ if (m.msg.tool_calls) return false;
96
+ if (m.msg.role === 'tool') return false;
97
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
98
+ return true;
99
+ }).map(m => m.msg);
100
+
101
+ const trimmed = cleaned.length > this.QUEUE_LIMIT
102
+ ? cleaned.slice(-this.QUEUE_LIMIT)
103
+ : cleaned;
104
+
105
+ const chat_history = trimmed.length > 0
106
+ ? await util.compressMessages(trimmed)
107
+ : null;
108
+
109
+ return { chat_history, tool_digest: this.tool_digest || [] };
110
+ }
111
+
69
112
  // Snapshot all public (non-underscore) task properties for dirty detection.
70
113
  // Mirrors the observable proxy convention: _ prefix = internal, ignored.
71
114
  // Does NOT call serialize() — that is for persistence, not dirty detection.
@@ -447,34 +490,6 @@ class Context {
447
490
  _log('Finished closing Context tag', this.tag);
448
491
  }
449
492
 
450
- // Load chat history from store into message queue
451
- async loadHistory(store) {
452
- if (!store || !this.tag)
453
- return;
454
- const data = await store.load(this.tag);
455
- if (!data)
456
- return;
457
- if (Array.isArray(data.tool_digest))
458
- this.tool_digest = data.tool_digest;
459
- if (!data.chat_history)
460
- return;
461
- const messages = await util.decompressMessages(data.chat_history);
462
- if (!Array.isArray(messages) || messages.length === 0)
463
- return;
464
- // Find the index after the last system message to insert history
465
- let insertIdx = 0;
466
- for (let i = 0; i < this._msgs.length; i++) {
467
- if (this._msgs[i].msg.role === 'system')
468
- insertIdx = i + 1;
469
- }
470
- const historyMsgs = messages.map(m => ({
471
- msg: m,
472
- opts: {},
473
- msgid: crypto.randomBytes(2).toString('hex'),
474
- replied: 1
475
- }));
476
- this._msgs.splice(insertIdx, 0, ...historyMsgs);
477
- }
478
493
 
479
494
  // Remove tool-related messages tagged with a specific tag
480
495
  cleanToolCallsByTag(tag) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
package/saico.js CHANGED
@@ -37,6 +37,7 @@ class Saico {
37
37
  * @param {Array} [opt.functions] - Available AI functions
38
38
  * @param {string} [opt.key] - Redis key override (default: 'saico:<id>')
39
39
  * @param {boolean} [opt.redis=true] - Set false to skip Redis proxy
40
+ * @param {boolean} [opt.createQ] - Create message Q context on activate()
40
41
  * @param {boolean} [opt.isolate] - Isolate: don't aggregate from ancestors
41
42
  * @param {Object} [opt.dynamodb] - DynamoDB config { region, credentials: { accessKeyId, secretAccessKey }, client }
42
43
  * @param {Object} [opt.db] - Pluggable DB backend
@@ -60,6 +61,7 @@ class Saico {
60
61
  this.name = opt.name || this.constructor.name || 'saico';
61
62
  this.prompt = opt.prompt || '';
62
63
  this.functions = opt.functions || null;
64
+ this.createQ = opt.createQ || false;
63
65
 
64
66
  // Absorbed from Sid
65
67
  this.userData = opt.userData || {};
@@ -99,10 +101,10 @@ class Saico {
99
101
  * Create the internal Itask and optionally a message Q context.
100
102
  *
101
103
  * @param {Object} opts
102
- * @param {boolean} [opts.createQ] - If true, attach a message Q (Context)
104
+ * @param {boolean} [opts.createQ] - Override this.createQ for this activation
103
105
  * @param {string} [opts.prompt] - Additional prompt (appended to class-level)
104
106
  * @param {Array} [opts.functions] - Override functions
105
- * @param {Array} [opts.states] - Task state functions
107
+ * @param {Array} [opts.states] - Override this.states for this activation
106
108
  * @param {string} [opts.taskId] - Custom task ID
107
109
  * @param {number} [opts.token_limit] - Token limit for context
108
110
  * @param {number} [opts.max_depth] - Max tool call depth
@@ -119,7 +121,7 @@ class Saico {
119
121
  if (this._task)
120
122
  throw new Error('Already activated. Call deactivate() first.');
121
123
 
122
- const states = opts.states || [];
124
+ const states = opts.states || this.states || [];
123
125
 
124
126
  // Build effective prompt: class-level + activation-level
125
127
  const effectivePrompt = [this.prompt, opts.prompt].filter(Boolean).join('\n');
@@ -136,8 +138,8 @@ class Saico {
136
138
  // Store Saico reference on task for parent chain traversal
137
139
  this._task._saico = this;
138
140
 
139
- // Create message Q context if requested (only via createQ flag, NOT prompt)
140
- if (opts.createQ) {
141
+ // Create message Q context if requested (class-level or activate-level)
142
+ if (opts.createQ ?? this.createQ) {
141
143
  const functions = opts.functions || this.functions;
142
144
  const contextConfig = {
143
145
  tag: opts.tag || this._task.id,
@@ -150,6 +152,7 @@ class Saico {
150
152
  sequential_mode: opts.sequential_mode,
151
153
  msgs: opts.msgs,
152
154
  chat_history: opts.chat_history,
155
+ tool_digest: opts.tool_digest,
153
156
  ...opts.contextConfig,
154
157
  };
155
158
 
@@ -221,51 +224,6 @@ class Saico {
221
224
  return deepest ? deepest.context : null;
222
225
  }
223
226
 
224
- /**
225
- * Close this Saico's context and bubble summary to parent.
226
- */
227
- async closeContext() {
228
- if (!this.context)
229
- return;
230
-
231
- // Clean tool call messages tagged with this context_id
232
- if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
233
- this.context.cleanToolCallsByTag(this.context_id);
234
-
235
- // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
236
- const cleanedMsgs = this.context._msgs.filter(m => {
237
- if (m.msg.tool_calls) return false;
238
- if (m.msg.role === 'tool') return false;
239
- if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
240
- return true;
241
- }).map(m => m.msg);
242
-
243
- // Trim to last QUEUE_LIMIT before persisting
244
- const queueLimit = this.context.QUEUE_LIMIT || 30;
245
- const trimmedMsgs = cleanedMsgs.length > queueLimit
246
- ? cleanedMsgs.slice(-queueLimit)
247
- : cleanedMsgs;
248
-
249
- if (trimmedMsgs.length > 0) {
250
- const chat_history = await util.compressMessages(trimmedMsgs);
251
- this.context.chat_history = chat_history;
252
-
253
- // Persist to store
254
- const store = this._store || Store.instance;
255
- if (store && this.context_id) {
256
- await store.save(this.context_id, {
257
- chat_history,
258
- tool_digest: this.context.tool_digest || [],
259
- prompt: this.context.prompt,
260
- tag: this.context.tag,
261
- tm_closed: Date.now()
262
- });
263
- }
264
- }
265
-
266
- await this.context.close();
267
- }
268
-
269
227
  /**
270
228
  * Deactivate — bubble cleaned messages to parent, close context, cancel task.
271
229
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
@@ -307,15 +265,16 @@ class Saico {
307
265
  spawn(child) {
308
266
  if (!this._task)
309
267
  throw new Error('Not activated. Call activate() first.');
310
- if (!(child instanceof Saico) || !child._task)
311
- throw new Error('Child must be an activated Saico instance.');
268
+ if (!(child instanceof Saico))
269
+ throw new Error('Child must be a Saico instance.');
270
+ if (!child._task) child.activate();
312
271
  this._task.spawn(child._task);
313
272
  return child;
314
273
  }
315
274
 
316
275
  /**
317
276
  * Spawn a child Saico and start its task running.
318
- * @param {Saico} child - An activated Saico instance
277
+ * @param {Saico} child - A Saico instance (auto-activated if needed)
319
278
  * @returns {Saico} the child (for chaining)
320
279
  */
321
280
  spawnAndRun(child) {
@@ -516,10 +475,38 @@ class Saico {
516
475
  };
517
476
  }
518
477
 
478
+ /**
479
+ * Close the session — compress msgs, save full state to Store, cancel task.
480
+ * The saved object has the same shape as serialize() but with compressed
481
+ * context messages (chat_history) instead of raw _msgs.
482
+ */
519
483
  async closeSession() {
520
484
  if (!this._task) return;
521
- if (this.context)
522
- await this.context.close();
485
+
486
+ // Save full state to Store with compressed msgs
487
+ const store = this._store || Store.instance;
488
+ if (store && this.context) {
489
+ const { chat_history, tool_digest } = await this.context.prepareForStorage();
490
+ const data = {
491
+ id: this._id,
492
+ name: this.name,
493
+ prompt: this.prompt,
494
+ userData: this.userData,
495
+ sessionConfig: this.sessionConfig,
496
+ tm_create: this.tm_create,
497
+ isolate: this._isolate,
498
+ taskId: this._task.id,
499
+ context_id: this.context_id,
500
+ context: {
501
+ tag: this.context.tag,
502
+ chat_history,
503
+ tool_digest,
504
+ functions: this.context.functions,
505
+ },
506
+ };
507
+ await store.save(this._id, data);
508
+ }
509
+
523
510
  this._task._ecancel();
524
511
  }
525
512
 
@@ -623,6 +610,11 @@ class Saico {
623
610
 
624
611
  // ---- Serialization ----
625
612
 
613
+ /**
614
+ * Serialize the Saico instance to a JSON string.
615
+ * Context messages are included as raw _msgs (for Redis / in-memory use).
616
+ * For durable storage with compressed msgs, use closeSession().
617
+ */
626
618
  serialize() {
627
619
  const data = {
628
620
  id: this._id,
@@ -633,24 +625,21 @@ class Saico {
633
625
  tm_create: this.tm_create,
634
626
  isolate: this._isolate,
635
627
  };
636
- if (this._task) {
637
- data.task = {
638
- id: this._task.id,
639
- context_id: this.context_id,
640
- context: this.context ? {
641
- tag: this.context.tag,
642
- msgs: this.context._msgs,
643
- functions: this.context.functions,
644
- chat_history: this.context.chat_history,
645
- tool_digest: this.context.tool_digest,
646
- } : null,
647
- };
648
- }
628
+ data.taskId = this._task?.id || null;
629
+ data.context_id = this.context_id || null;
630
+ data.context = this.context ? {
631
+ tag: this.context.tag,
632
+ msgs: this.context._msgs,
633
+ functions: this.context.functions,
634
+ tool_digest: this.context.tool_digest,
635
+ } : null;
649
636
  return JSON.stringify(data);
650
637
  }
651
638
 
652
639
  /**
653
640
  * Restore a Saico instance from serialized data.
641
+ * Supports both raw msgs (from serialize/Redis) and compressed
642
+ * chat_history (from closeSession/Store).
654
643
  * @param {string|Object} data - Serialized data (JSON string or object)
655
644
  * @param {Object} opt - Options (functions, store, states, etc.)
656
645
  * @returns {Saico}
@@ -665,38 +654,54 @@ class Saico {
665
654
  userData: parsed.userData,
666
655
  sessionConfig: parsed.sessionConfig,
667
656
  isolate: parsed.isolate,
668
- functions: opt.functions || parsed.task?.context?.functions,
657
+ functions: opt.functions || parsed.context?.functions,
669
658
  store: opt.store,
670
659
  redis: false, // No Redis proxy during deserialization
671
660
  });
672
661
 
673
662
  instance.tm_create = parsed.tm_create || instance.tm_create;
674
663
 
675
- // Activate with restored context if task data exists
676
- if (parsed.task) {
664
+ // Activate with restored state if taskId exists
665
+ if (parsed.taskId) {
666
+ const ctx = parsed.context;
677
667
  instance.activate({
678
- createQ: !!parsed.task.context,
679
- taskId: parsed.task.id,
680
- tag: parsed.task.context?.tag,
681
- chat_history: parsed.task.context?.chat_history,
682
- functions: opt.functions || parsed.task.context?.functions,
668
+ createQ: !!ctx,
669
+ taskId: parsed.taskId,
670
+ tag: ctx?.tag,
671
+ chat_history: ctx?.chat_history,
672
+ functions: opt.functions || ctx?.functions,
673
+ tool_digest: ctx?.tool_digest,
683
674
  states: opt.states || [],
684
675
  ...opt,
685
676
  });
686
677
 
687
- // Restore messages to context
688
- if (parsed.task.context?.msgs && instance.context) {
689
- instance.context._msgs = parsed.task.context.msgs;
690
- }
691
-
692
- // Restore tool_digest
693
- if (Array.isArray(parsed.task.context?.tool_digest) && instance.context) {
694
- instance.context.tool_digest = parsed.task.context.tool_digest;
678
+ // Restore raw msgs (from serialize/Redis) — takes priority over chat_history
679
+ if (ctx?.msgs && instance.context) {
680
+ instance.context._msgs = ctx.msgs;
695
681
  }
696
682
  }
697
683
 
698
684
  return instance;
699
685
  }
686
+
687
+ /**
688
+ * Load a Saico instance from Store by id.
689
+ * @param {string} id - The Saico instance id
690
+ * @param {Object} opt - Options (store, functions, states, etc.)
691
+ * @returns {Promise<Saico|null>}
692
+ */
693
+ static async rehydrate(id, opt = {}) {
694
+ const store = opt.store || Store.instance;
695
+ if (!store)
696
+ throw new Error('No store available for rehydrate');
697
+ const data = await store.load(id);
698
+ if (!data) return null;
699
+ const instance = Saico.deserialize(data, opt);
700
+ // Decompress chat_history into _msgs if present
701
+ if (instance.context)
702
+ await instance.context.initHistory();
703
+ return instance;
704
+ }
700
705
  }
701
706
 
702
707
  // [BACKEND] explanation text appended to context prompts