saico 2.7.1 → 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 +15 -13
  2. package/msgs.js +47 -32
  3. package/package.json +1 -1
  4. package/saico.js +82 -79
package/README.md CHANGED
@@ -156,29 +156,28 @@ When a Saico's context is not the deepest active one, its last 5 user/assistant
156
156
  ### Spawning Child Saico Instances
157
157
 
158
158
  ```js
159
- // Child with its own conversation context
159
+ // Child with its own conversation context (auto-activated by spawn)
160
160
  const child = new Saico({
161
161
  name: 'subtask',
162
162
  prompt: 'Handle this specific sub-task',
163
+ createQ: true,
163
164
  functions: [/* child-specific tools */],
164
165
  });
165
- child.activate({ createQ: true });
166
166
  agent.spawn(child);
167
167
  await child.sendMessage('Working on subtask...');
168
168
 
169
169
  // Child without context (uses parent's via findContext())
170
170
  const simple = new Saico({ name: 'simple' });
171
- simple.activate();
172
171
  agent.spawn(simple);
173
172
  await simple.sendMessage('Quick operation');
174
173
 
175
174
  // spawnAndRun: spawn + schedule child task to run on nextTick
176
175
  const runner = new Saico({ name: 'runner' });
177
- runner.activate({ states: [async function() { return await this.sendMessage('Go'); }] });
176
+ runner.states = [async function() { return await this.sendMessage('Go'); }];
178
177
  agent.spawnAndRun(runner);
179
178
  ```
180
179
 
181
- 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.
182
181
 
183
182
  ### Deactivation and Message Bubbling
184
183
 
@@ -258,7 +257,10 @@ agent.getSessionInfo();
258
257
  // userData, uptime
259
258
  // }
260
259
 
261
- 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 });
262
264
  ```
263
265
 
264
266
  ## Database Access
@@ -299,16 +301,16 @@ class MyAgent extends Saico {
299
301
  ## Serialization
300
302
 
301
303
  ```js
302
- // Save
304
+ // In-memory snapshot (raw msgs, used by Redis proxy)
303
305
  const json = agent.serialize();
306
+ const restored = Saico.deserialize(json);
304
307
 
305
- // Restore
306
- const restored = Saico.deserialize(json, {
307
- functions: myFunctions,
308
- });
308
+ // Durable persistence (compressed msgs, saved to Store)
309
+ await agent.closeSession();
310
+ const restored2 = await Saico.rehydrate(agent._id, { store });
309
311
  ```
310
312
 
311
- 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.
312
314
 
313
315
  ## Redis Persistence
314
316
 
@@ -388,7 +390,7 @@ saico/
388
390
  npm test
389
391
  ```
390
392
 
391
- 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.
392
394
 
393
395
  ## Requirements
394
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.1",
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
@@ -152,6 +152,7 @@ class Saico {
152
152
  sequential_mode: opts.sequential_mode,
153
153
  msgs: opts.msgs,
154
154
  chat_history: opts.chat_history,
155
+ tool_digest: opts.tool_digest,
155
156
  ...opts.contextConfig,
156
157
  };
157
158
 
@@ -223,51 +224,6 @@ class Saico {
223
224
  return deepest ? deepest.context : null;
224
225
  }
225
226
 
226
- /**
227
- * Close this Saico's context and bubble summary to parent.
228
- */
229
- async closeContext() {
230
- if (!this.context)
231
- return;
232
-
233
- // Clean tool call messages tagged with this context_id
234
- if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
235
- this.context.cleanToolCallsByTag(this.context_id);
236
-
237
- // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
238
- const cleanedMsgs = this.context._msgs.filter(m => {
239
- if (m.msg.tool_calls) return false;
240
- if (m.msg.role === 'tool') return false;
241
- if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
242
- return true;
243
- }).map(m => m.msg);
244
-
245
- // Trim to last QUEUE_LIMIT before persisting
246
- const queueLimit = this.context.QUEUE_LIMIT || 30;
247
- const trimmedMsgs = cleanedMsgs.length > queueLimit
248
- ? cleanedMsgs.slice(-queueLimit)
249
- : cleanedMsgs;
250
-
251
- if (trimmedMsgs.length > 0) {
252
- const chat_history = await util.compressMessages(trimmedMsgs);
253
- this.context.chat_history = chat_history;
254
-
255
- // Persist to store
256
- const store = this._store || Store.instance;
257
- if (store && this.context_id) {
258
- await store.save(this.context_id, {
259
- chat_history,
260
- tool_digest: this.context.tool_digest || [],
261
- prompt: this.context.prompt,
262
- tag: this.context.tag,
263
- tm_closed: Date.now()
264
- });
265
- }
266
- }
267
-
268
- await this.context.close();
269
- }
270
-
271
227
  /**
272
228
  * Deactivate — bubble cleaned messages to parent, close context, cancel task.
273
229
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
@@ -309,15 +265,16 @@ class Saico {
309
265
  spawn(child) {
310
266
  if (!this._task)
311
267
  throw new Error('Not activated. Call activate() first.');
312
- if (!(child instanceof Saico) || !child._task)
313
- 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();
314
271
  this._task.spawn(child._task);
315
272
  return child;
316
273
  }
317
274
 
318
275
  /**
319
276
  * Spawn a child Saico and start its task running.
320
- * @param {Saico} child - An activated Saico instance
277
+ * @param {Saico} child - A Saico instance (auto-activated if needed)
321
278
  * @returns {Saico} the child (for chaining)
322
279
  */
323
280
  spawnAndRun(child) {
@@ -518,10 +475,38 @@ class Saico {
518
475
  };
519
476
  }
520
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
+ */
521
483
  async closeSession() {
522
484
  if (!this._task) return;
523
- if (this.context)
524
- 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
+
525
510
  this._task._ecancel();
526
511
  }
527
512
 
@@ -625,6 +610,11 @@ class Saico {
625
610
 
626
611
  // ---- Serialization ----
627
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
+ */
628
618
  serialize() {
629
619
  const data = {
630
620
  id: this._id,
@@ -635,24 +625,21 @@ class Saico {
635
625
  tm_create: this.tm_create,
636
626
  isolate: this._isolate,
637
627
  };
638
- if (this._task) {
639
- data.task = {
640
- id: this._task.id,
641
- context_id: this.context_id,
642
- context: this.context ? {
643
- tag: this.context.tag,
644
- msgs: this.context._msgs,
645
- functions: this.context.functions,
646
- chat_history: this.context.chat_history,
647
- tool_digest: this.context.tool_digest,
648
- } : null,
649
- };
650
- }
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;
651
636
  return JSON.stringify(data);
652
637
  }
653
638
 
654
639
  /**
655
640
  * Restore a Saico instance from serialized data.
641
+ * Supports both raw msgs (from serialize/Redis) and compressed
642
+ * chat_history (from closeSession/Store).
656
643
  * @param {string|Object} data - Serialized data (JSON string or object)
657
644
  * @param {Object} opt - Options (functions, store, states, etc.)
658
645
  * @returns {Saico}
@@ -667,38 +654,54 @@ class Saico {
667
654
  userData: parsed.userData,
668
655
  sessionConfig: parsed.sessionConfig,
669
656
  isolate: parsed.isolate,
670
- functions: opt.functions || parsed.task?.context?.functions,
657
+ functions: opt.functions || parsed.context?.functions,
671
658
  store: opt.store,
672
659
  redis: false, // No Redis proxy during deserialization
673
660
  });
674
661
 
675
662
  instance.tm_create = parsed.tm_create || instance.tm_create;
676
663
 
677
- // Activate with restored context if task data exists
678
- if (parsed.task) {
664
+ // Activate with restored state if taskId exists
665
+ if (parsed.taskId) {
666
+ const ctx = parsed.context;
679
667
  instance.activate({
680
- createQ: !!parsed.task.context,
681
- taskId: parsed.task.id,
682
- tag: parsed.task.context?.tag,
683
- chat_history: parsed.task.context?.chat_history,
684
- 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,
685
674
  states: opt.states || [],
686
675
  ...opt,
687
676
  });
688
677
 
689
- // Restore messages to context
690
- if (parsed.task.context?.msgs && instance.context) {
691
- instance.context._msgs = parsed.task.context.msgs;
692
- }
693
-
694
- // Restore tool_digest
695
- if (Array.isArray(parsed.task.context?.tool_digest) && instance.context) {
696
- 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;
697
681
  }
698
682
  }
699
683
 
700
684
  return instance;
701
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
+ }
702
705
  }
703
706
 
704
707
  // [BACKEND] explanation text appended to context prompts