saico 2.7.1 → 2.8.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 (5) hide show
  1. package/README.md +20 -18
  2. package/index.js +3 -3
  3. package/msgs.js +74 -211
  4. package/package.json +1 -1
  5. package/saico.js +130 -109
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
 
@@ -328,7 +330,7 @@ Properties prefixed with `_` are internal and not persisted.
328
330
 
329
331
  ## Tool Implementation (TOOL_ methods)
330
332
 
331
- Define tool implementations as `TOOL_`-prefixed methods on your Saico subclass. When the LLM returns a tool call, Context automatically searches the Saico hierarchy (current → up parents → down children) to find and invoke the matching method with parsed arguments.
333
+ Define tool implementations as `TOOL_`-prefixed methods on your Saico subclass. When the LLM returns a tool call, Saico automatically searches the task hierarchy (current → up parents → down children) to find and invoke the matching method with parsed arguments.
332
334
 
333
335
  ```js
334
336
  class MyAgent extends Saico {
@@ -357,13 +359,13 @@ Return a string or `{ content: string, functions?: [] }`.
357
359
 
358
360
  ## Low-Level API
359
361
 
360
- For cases where you need a standalone context without the Saico master class:
362
+ For cases where you need a standalone message queue without the Saico master class:
361
363
 
362
364
  ```js
363
- const { createContext } = require('saico');
365
+ const { createMsgs } = require('saico');
364
366
 
365
- // Standalone context
366
- const ctx = createContext('System prompt', null, { tag: 'my-tag', token_limit: 4000 });
367
+ // Standalone message queue
368
+ const ctx = createMsgs('System prompt', { tag: 'my-tag', token_limit: 4000 });
367
369
  const reply = await ctx.sendMessage('user', 'Hello', functions);
368
370
  ```
369
371
 
@@ -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
+ 290 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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const Itask = require('./itask.js');
4
- const { Context, createContext } = require('./msgs.js');
4
+ const { Msgs, createMsgs } = require('./msgs.js');
5
5
  const { Store, DynamoBackend } = require('./store.js');
6
6
  const { Saico } = require('./saico.js');
7
7
  const { DynamoDBAdapter } = require('./dynamo.js');
@@ -34,7 +34,7 @@ module.exports = {
34
34
 
35
35
  // Core classes
36
36
  Itask,
37
- Context,
37
+ Msgs,
38
38
  Store,
39
39
  DynamoBackend,
40
40
 
@@ -42,7 +42,7 @@ module.exports = {
42
42
  init,
43
43
 
44
44
  // Factory
45
- createContext,
45
+ createMsgs,
46
46
 
47
47
  // Utilities (re-export from util.js)
48
48
  util: require('./util.js'),
package/msgs.js CHANGED
@@ -8,23 +8,17 @@ const { _log, _lerr, _ldbg } = util;
8
8
  const debug = 0;
9
9
 
10
10
  /**
11
- * Context - Conversation context that can be attached to any Itask.
12
- *
13
- * Key differences from the old Messages class:
14
- * - Uses task hierarchy instead of parent/child messages
15
- * - task reference replaces parent reference
16
- * - getMsgContext() traverses task hierarchy
17
- * - _createMsgQ() aggregates from task ancestors
11
+ * Msgs - Pure message queue with tool call handling, summarization, and LLM communication.
12
+ * Saico sets callback hooks after construction to wire in hierarchy access.
18
13
  */
19
- class Context {
20
- constructor(prompt, task, config = {}) {
14
+ class Msgs {
15
+ constructor(prompt, config = {}) {
21
16
  this.prompt = prompt;
22
- this.task = task; // Reference to owning Itask (replaces parent)
23
17
  this.tag = config.tag || crypto.randomBytes(4).toString('hex');
24
18
  this.token_limit = config.token_limit || 1000000000;
25
19
  this.lower_limit = this.token_limit * 0.85;
26
20
  this.upper_limit = this.token_limit * 0.98;
27
- this.functions = config.functions || task?.functions || null;
21
+ this.functions = config.functions || null;
28
22
 
29
23
  // Recursive depth and repetition control
30
24
  this.max_depth = config.max_depth || 5;
@@ -33,9 +27,6 @@ class Context {
33
27
  this._deferred_tool_calls = [];
34
28
  this._tool_call_sequence = [];
35
29
 
36
- // Chat history persistence
37
- this.chat_history = config.chat_history || null;
38
-
39
30
  this._msgs = [];
40
31
  this._waitingQueue = [];
41
32
  this._active_tool_calls = new Map();
@@ -53,17 +44,60 @@ class Context {
53
44
  // Tool digest — persistent history of tool calls that mutated task state
54
45
  this.tool_digest = config.tool_digest || [];
55
46
 
56
- // Initialize messages if provided
47
+ // Callback hooks set by Saico after construction
48
+ this._findToolImpl = null; // (toolName) => { saico, methodName } | null
49
+ this._getSnapshot = null; // () => serializable snapshot for dirty detection
50
+
51
+ // Initialize messages: explicit msgs take priority over chat_history
52
+ this._chat_history = config.chat_history || null;
57
53
  (config.msgs || []).forEach(m => this.push(m));
58
54
 
59
- _log('created Context for tag', this.tag);
55
+ _log('created Msgs for tag', this.tag);
60
56
  }
61
57
 
62
- // Set the task reference (used when context is created separately)
63
- setTask(task) {
64
- this.task = task;
65
- if (!this.functions)
66
- this.functions = task?.functions;
58
+ /**
59
+ * Decompress _chat_history into _msgs. Call after construction when
60
+ * restoring from persisted state. No-op if chat_history is absent or
61
+ * _msgs were already provided via config.msgs.
62
+ */
63
+ async initHistory() {
64
+ if (!this._chat_history || this._msgs.length > 0)
65
+ return;
66
+ const messages = await util.decompressMessages(this._chat_history);
67
+ if (!Array.isArray(messages) || messages.length === 0)
68
+ return;
69
+ for (const m of messages) {
70
+ this._msgs.push({
71
+ msg: m,
72
+ opts: {},
73
+ msgid: crypto.randomBytes(2).toString('hex'),
74
+ replied: 1,
75
+ });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Prepare the message Q for storage. Filters out tool calls, tool
81
+ * responses, and [BACKEND] messages, trims to QUEUE_LIMIT, compresses.
82
+ * Returns { chat_history, tool_digest }. Does NOT mutate _msgs.
83
+ */
84
+ async prepareForStorage() {
85
+ const cleaned = this._msgs.filter(m => {
86
+ if (m.msg.tool_calls) return false;
87
+ if (m.msg.role === 'tool') return false;
88
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
89
+ return true;
90
+ }).map(m => m.msg);
91
+
92
+ const trimmed = cleaned.length > this.QUEUE_LIMIT
93
+ ? cleaned.slice(-this.QUEUE_LIMIT)
94
+ : cleaned;
95
+
96
+ const chat_history = trimmed.length > 0
97
+ ? await util.compressMessages(trimmed)
98
+ : null;
99
+
100
+ return { chat_history, tool_digest: this.tool_digest || [] };
67
101
  }
68
102
 
69
103
  // Snapshot all public (non-underscore) task properties for dirty detection.
@@ -92,32 +126,6 @@ class Context {
92
126
  this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
93
127
  }
94
128
 
95
- // Get the parent context by traversing task hierarchy (via Saico)
96
- getParentContext() {
97
- if (!this.task || !this.task.parent)
98
- return null;
99
- let task = this.task.parent;
100
- while (task) {
101
- if (task._saico?.context) return task._saico.context;
102
- task = task.parent;
103
- }
104
- return null;
105
- }
106
-
107
- // Get all ancestor contexts via task hierarchy (via Saico)
108
- getAncestorContexts() {
109
- if (!this.task)
110
- return [];
111
- const contexts = [];
112
- let task = this.task.parent;
113
- while (task) {
114
- if (task._saico?.context)
115
- contexts.unshift(task._saico.context);
116
- task = task.parent;
117
- }
118
- return contexts;
119
- }
120
-
121
129
  _hasPendingToolCalls() {
122
130
  const toolCallMsgs = this._msgs.filter(m => m.msg.tool_calls);
123
131
 
@@ -291,16 +299,15 @@ class Context {
291
299
  };
292
300
  } else {
293
301
  this._trackActiveToolCall(call);
294
- const _snap = this.task
295
- ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
302
+ const _snap = this._getSnapshot
303
+ ? JSON.stringify(this._getSnapshot()) : null;
296
304
 
297
305
  try {
298
306
  const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
299
307
  const timeout = correspondingDeferred?.originalMessage.opts.timeout;
300
308
 
301
309
  result = await this._executeToolCallWithTimeout(call, timeout);
302
- if (_snap !== null &&
303
- _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
310
+ if (_snap !== null && _snap !== JSON.stringify(this._getSnapshot()))
304
311
  this._appendToolDigest(call.function.name, result?.content || '');
305
312
  } finally {
306
313
  this._completeActiveToolCall(call);
@@ -390,24 +397,6 @@ class Context {
390
397
 
391
398
  getSummaries() { return this._msgs.filter(m => m.opts.summary); }
392
399
 
393
- // Get functions aggregated from this context and all ancestor contexts
394
- getFunctions() {
395
- const allFunctions = [];
396
-
397
- // Get functions from ancestor contexts via task hierarchy
398
- const ancestorContexts = this.getAncestorContexts();
399
- for (const ctx of ancestorContexts) {
400
- if (ctx.functions && Array.isArray(ctx.functions))
401
- allFunctions.push(...ctx.functions);
402
- }
403
-
404
- // Add our own functions
405
- if (this.functions && Array.isArray(this.functions))
406
- allFunctions.push(...this.functions);
407
-
408
- return allFunctions.length > 0 ? allFunctions : null;
409
- }
410
-
411
400
  async summarizeMessages() {
412
401
  const tokens = util.countTokens(this.__msgs);
413
402
  if (tokens < this.lower_limit)
@@ -416,7 +405,7 @@ class Context {
416
405
  }
417
406
 
418
407
  async close() {
419
- _log('Closing Context tag', this.tag);
408
+ _log('Closing Msgs tag', this.tag);
420
409
 
421
410
  if (this._sequential_mode && this._processing_sequential) {
422
411
  _ldbg('Sequential mode: waiting for current message to complete before closing tag', this.tag);
@@ -429,52 +418,10 @@ class Context {
429
418
  }
430
419
  }
431
420
 
432
- // Move waiting messages to parent context via task hierarchy
433
- const parentCtx = this.getParentContext();
434
- if (parentCtx && this._waitingQueue.length > 0) {
435
- _log('Moving', this._waitingQueue.length, 'waiting messages to parent context');
436
- parentCtx._waitingQueue.push(...this._waitingQueue);
437
- this._waitingQueue = [];
438
- }
439
-
440
- if (parentCtx && this._sequential_queue.length > 0) {
441
- _log('Moving', this._sequential_queue.length, 'sequential queue messages to parent context');
442
- parentCtx._sequential_queue.push(...this._sequential_queue);
443
- this._sequential_queue = [];
444
- }
445
-
446
- await this._summarizeContext(true, parentCtx);
447
- _log('Finished closing Context tag', this.tag);
421
+ await this._summarizeContext(true);
422
+ _log('Finished closing Msgs tag', this.tag);
448
423
  }
449
424
 
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
425
 
479
426
  // Remove tool-related messages tagged with a specific tag
480
427
  cleanToolCallsByTag(tag) {
@@ -576,38 +523,6 @@ class Context {
576
523
  return summary;
577
524
  }
578
525
 
579
- // Get message context - walks up task hierarchy to collect prompts and summaries
580
- getMsgContext(add_tag) {
581
- const msgs = [];
582
-
583
- // Get context from ancestor tasks via task hierarchy
584
- const ancestorContexts = this.getAncestorContexts();
585
- for (const ctx of ancestorContexts) {
586
- if (ctx.prompt)
587
- msgs.push({role: 'system', content: ctx.prompt});
588
- // Add summaries from ancestor contexts
589
- const summaries = ctx._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
590
- if (add_tag)
591
- m.msg.tag = ctx.tag;
592
- return m.msg;
593
- });
594
- msgs.push(...summaries);
595
- }
596
-
597
- // Add this context's prompt
598
- if (this.prompt)
599
- msgs.push({role: 'system', content: this.prompt});
600
-
601
- // Add this context's summaries
602
- const mySummaries = this._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
603
- if (add_tag)
604
- m.msg.tag = this.tag;
605
- return m.msg;
606
- });
607
-
608
- return msgs.concat(mySummaries);
609
- }
610
-
611
526
  _createMsgObj(role, content, functions, opts) {
612
527
  const name = opts?.name;
613
528
  const tool_call_id = opts?.tool_call_id;
@@ -937,10 +852,10 @@ class Context {
937
852
  ? [...o.opts._aggregatedFunctions, ...messageFuncs]
938
853
  : null;
939
854
  } else {
940
- const hierarchyFuncs = this.getFunctions() || [];
855
+ const ownFuncs = this.functions || [];
941
856
  const messageFuncs = o.functions || [];
942
- funcs = [...hierarchyFuncs, ...messageFuncs].length > 0
943
- ? [...hierarchyFuncs, ...messageFuncs]
857
+ funcs = [...ownFuncs, ...messageFuncs].length > 0
858
+ ? [...ownFuncs, ...messageFuncs]
944
859
  : null;
945
860
  }
946
861
 
@@ -1009,15 +924,15 @@ class Context {
1009
924
 
1010
925
  for (const { call, isDuplicate } of toolCallsWithResults) {
1011
926
  if (!isDuplicate) {
1012
- const _snap = this.task
1013
- ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
927
+ const _snap = this._getSnapshot
928
+ ? JSON.stringify(this._getSnapshot()) : null;
1014
929
  try {
1015
930
  const result = await this._executeToolCallWithTimeout(
1016
931
  call, o.opts?.timeout);
1017
932
  const item = toolCallsWithResults.find(item => item.call.id === call.id);
1018
933
  if (item) item.result = result;
1019
934
  if (_snap !== null &&
1020
- _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
935
+ _snap !== JSON.stringify(this._getSnapshot()))
1021
936
  this._appendToolDigest(call.function.name, result?.content || '');
1022
937
  } finally {
1023
938
  this._completeActiveToolCall(call);
@@ -1073,39 +988,11 @@ class Context {
1073
988
  }
1074
989
 
1075
990
  /**
1076
- * Search the Saico hierarchy for a TOOL_<toolName> method.
1077
- * Order: current task walk UP parents → walk DOWN children (BFS).
991
+ * Find a TOOL_<toolName> implementation. Delegates to _findToolImpl callback
992
+ * set by Saico, which searches the hierarchy.
1078
993
  */
1079
994
  _findToolImplementation(toolName) {
1080
- const methodName = 'TOOL_' + toolName;
1081
- const check = (task) =>
1082
- task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
1083
-
1084
- // 1. Current task
1085
- let found = check(this.task);
1086
- if (found) return { saico: found, methodName };
1087
-
1088
- // 2. Walk UP parent chain
1089
- let t = this.task?.parent;
1090
- while (t) {
1091
- found = check(t);
1092
- if (found) return { saico: found, methodName };
1093
- t = t.parent;
1094
- }
1095
-
1096
- // 3. Walk DOWN from this.task (BFS)
1097
- if (this.task) {
1098
- const queue = [...this.task.child];
1099
- while (queue.length > 0) {
1100
- const child = queue.shift();
1101
- if (child._completed) continue;
1102
- found = check(child);
1103
- if (found) return { saico: found, methodName };
1104
- if (child.child?.size > 0) queue.push(...child.child);
1105
- }
1106
- }
1107
-
1108
- return null;
995
+ return this._findToolImpl ? this._findToolImpl(toolName) : null;
1109
996
  }
1110
997
 
1111
998
  async interpretAndApplyChanges(call) {
@@ -1154,35 +1041,11 @@ class Context {
1154
1041
  return { content, functions };
1155
1042
  }
1156
1043
 
1157
- // Spawn child context (creates a child task with its own context)
1158
- spawnChild(prompt, tag, config = {}) {
1159
- if (!this.task) {
1160
- // If no task, create a standalone context
1161
- return createContext(prompt, null, { ...config, tag });
1162
- }
1163
-
1164
- // Create a child task with its own context
1165
- const Itask = require('./itask.js');
1166
- const childTask = new Itask({
1167
- name: tag || 'child-context',
1168
- async: true,
1169
- }, []);
1170
- this.task.spawn(childTask);
1171
-
1172
- const childContext = new Context(prompt, childTask, { ...config, tag });
1173
- // Store context on Saico if present, otherwise just set on task reference
1174
- if (childTask._saico) {
1175
- childTask._saico.context = childContext;
1176
- childTask._saico.context_id = childContext.tag;
1177
- }
1178
-
1179
- return childContext;
1180
- }
1181
1044
  }
1182
1045
 
1183
- // Factory function to create a Context with Proxy wrapper
1184
- function createContext(prompt, task, config = {}) {
1185
- const instance = new Context(prompt, task, config);
1046
+ // Factory function to create a Msgs instance with Proxy wrapper
1047
+ function createMsgs(prompt, config = {}) {
1048
+ const instance = new Msgs(prompt, config);
1186
1049
 
1187
1050
  return new Proxy(instance, {
1188
1051
  get(target, prop, receiver) {
@@ -1231,4 +1094,4 @@ function createContext(prompt, task, config = {}) {
1231
1094
  });
1232
1095
  }
1233
1096
 
1234
- module.exports = { Context, createContext };
1097
+ module.exports = { Msgs, createMsgs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const Itask = require('./itask.js');
5
- const { Context } = require('./msgs.js');
5
+ const { Msgs } = require('./msgs.js');
6
6
  const { Store } = require('./store.js');
7
7
  const util = require('./util.js');
8
8
 
@@ -47,7 +47,7 @@ class Saico {
47
47
  */
48
48
  constructor(opt = {}) {
49
49
  // Internal properties (underscore-prefixed, not persisted to Redis)
50
- this._id = opt.id || crypto.randomBytes(8).toString('hex');
50
+ this.id = opt.id || crypto.randomBytes(8).toString('hex');
51
51
  this._task = null;
52
52
  this._store = opt.store || Store.instance || null;
53
53
  this._opt = opt;
@@ -91,7 +91,7 @@ class Saico {
91
91
  try {
92
92
  const redis = require('./redis.js');
93
93
  if (redis.rclient && opt.redis !== false) {
94
- const key = 'saico:' + (opt.key || this._id);
94
+ const key = 'saico:' + (opt.key || this.id);
95
95
  return redis.createObservableForRedis(key, this);
96
96
  }
97
97
  } catch (e) { /* redis not available */ }
@@ -152,14 +152,21 @@ 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
 
158
159
  const augmentedPrompt = effectivePrompt
159
160
  ? effectivePrompt + Saico.BACKEND_EXPLANATION
160
161
  : '';
161
- const context = new Context(augmentedPrompt, this._task, contextConfig);
162
- this.setContext(context);
162
+ const msgs = new Msgs(augmentedPrompt, contextConfig);
163
+ this.context = msgs;
164
+ this.context_id = makeId(16);
165
+ msgs.tag = this.context_id;
166
+
167
+ // Wire callbacks for hierarchy access
168
+ msgs._findToolImpl = (toolName) => this._findToolImpl(toolName);
169
+ msgs._getSnapshot = () => msgs._snapshotPublicProps(this);
163
170
  }
164
171
 
165
172
  return this;
@@ -167,29 +174,6 @@ class Saico {
167
174
 
168
175
  // ---- Context management (owned by Saico, not Itask) ----
169
176
 
170
- /**
171
- * Set context on this Saico instance.
172
- * Generates context_id, sets context.tag, and calls context.setTask().
173
- */
174
- setContext(context) {
175
- this.context = context;
176
- // Generate context_id if not already set
177
- if (!this.context_id) {
178
- if (this._store)
179
- this.context_id = this._store.generateId();
180
- else if (Store.instance)
181
- this.context_id = Store.instance.generateId();
182
- else
183
- this.context_id = makeId(16);
184
- }
185
- if (context) {
186
- context.tag = this.context_id;
187
- if (typeof context.setTask === 'function')
188
- context.setTask(this._task);
189
- }
190
- return this;
191
- }
192
-
193
177
  /**
194
178
  * Find the nearest context walking UP the Saico/task hierarchy.
195
179
  */
@@ -223,51 +207,6 @@ class Saico {
223
207
  return deepest ? deepest.context : null;
224
208
  }
225
209
 
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
210
  /**
272
211
  * Deactivate — bubble cleaned messages to parent, close context, cancel task.
273
212
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
@@ -309,15 +248,16 @@ class Saico {
309
248
  spawn(child) {
310
249
  if (!this._task)
311
250
  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.');
251
+ if (!(child instanceof Saico))
252
+ throw new Error('Child must be a Saico instance.');
253
+ if (!child._task) child.activate();
314
254
  this._task.spawn(child._task);
315
255
  return child;
316
256
  }
317
257
 
318
258
  /**
319
259
  * Spawn a child Saico and start its task running.
320
- * @param {Saico} child - An activated Saico instance
260
+ * @param {Saico} child - A Saico instance (auto-activated if needed)
321
261
  * @returns {Saico} the child (for chaining)
322
262
  */
323
263
  spawnAndRun(child) {
@@ -487,6 +427,41 @@ class Saico {
487
427
  return parts.length > 0 ? parts : null;
488
428
  }
489
429
 
430
+ // ---- Tool implementation search ----
431
+
432
+ /**
433
+ * Search the Saico hierarchy for a TOOL_<toolName> method.
434
+ * Order: current task → walk UP parents → walk DOWN children (BFS).
435
+ */
436
+ _findToolImpl(toolName) {
437
+ const methodName = 'TOOL_' + toolName;
438
+ const check = (task) =>
439
+ task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
440
+
441
+ let found = check(this._task);
442
+ if (found) return { saico: found, methodName };
443
+
444
+ let t = this._task?.parent;
445
+ while (t) {
446
+ found = check(t);
447
+ if (found) return { saico: found, methodName };
448
+ t = t.parent;
449
+ }
450
+
451
+ if (this._task) {
452
+ const queue = [...this._task.child];
453
+ while (queue.length > 0) {
454
+ const child = queue.shift();
455
+ if (child._completed) continue;
456
+ found = check(child);
457
+ if (found) return { saico: found, methodName };
458
+ if (child.child?.size > 0) queue.push(...child.child);
459
+ }
460
+ }
461
+
462
+ return null;
463
+ }
464
+
490
465
  // ---- User Data (absorbed from Sid) ----
491
466
 
492
467
  setUserData(key, value) {
@@ -507,7 +482,7 @@ class Saico {
507
482
 
508
483
  getSessionInfo() {
509
484
  return {
510
- id: this._id,
485
+ id: this.id,
511
486
  name: this.name,
512
487
  running: this._task?.running || false,
513
488
  completed: this._task?._completed || false,
@@ -518,10 +493,38 @@ class Saico {
518
493
  };
519
494
  }
520
495
 
496
+ /**
497
+ * Close the session — compress msgs, save full state to Store, cancel task.
498
+ * The saved object has the same shape as serialize() but with compressed
499
+ * context messages (chat_history) instead of raw _msgs.
500
+ */
521
501
  async closeSession() {
522
502
  if (!this._task) return;
523
- if (this.context)
524
- await this.context.close();
503
+
504
+ // Save full state to Store with compressed msgs
505
+ const store = this._store || Store.instance;
506
+ if (store && this.context) {
507
+ const { chat_history, tool_digest } = await this.context.prepareForStorage();
508
+ const data = {
509
+ id: this.id,
510
+ name: this.name,
511
+ prompt: this.prompt,
512
+ userData: this.userData,
513
+ sessionConfig: this.sessionConfig,
514
+ tm_create: this.tm_create,
515
+ isolate: this._isolate,
516
+ taskId: this._task.id,
517
+ context_id: this.context_id,
518
+ context: {
519
+ tag: this.context.tag,
520
+ chat_history,
521
+ tool_digest,
522
+ functions: this.context.functions,
523
+ },
524
+ };
525
+ await store.save(this.id, data);
526
+ }
527
+
525
528
  this._task._ecancel();
526
529
  }
527
530
 
@@ -625,9 +628,14 @@ class Saico {
625
628
 
626
629
  // ---- Serialization ----
627
630
 
631
+ /**
632
+ * Serialize the Saico instance to a JSON string.
633
+ * Context messages are included as raw _msgs (for Redis / in-memory use).
634
+ * For durable storage with compressed msgs, use closeSession().
635
+ */
628
636
  serialize() {
629
637
  const data = {
630
- id: this._id,
638
+ id: this.id,
631
639
  name: this.name,
632
640
  prompt: this.prompt,
633
641
  userData: this.userData,
@@ -635,24 +643,21 @@ class Saico {
635
643
  tm_create: this.tm_create,
636
644
  isolate: this._isolate,
637
645
  };
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
- }
646
+ data.taskId = this._task?.id || null;
647
+ data.context_id = this.context_id || null;
648
+ data.context = this.context ? {
649
+ tag: this.context.tag,
650
+ msgs: this.context._msgs,
651
+ functions: this.context.functions,
652
+ tool_digest: this.context.tool_digest,
653
+ } : null;
651
654
  return JSON.stringify(data);
652
655
  }
653
656
 
654
657
  /**
655
658
  * Restore a Saico instance from serialized data.
659
+ * Supports both raw msgs (from serialize/Redis) and compressed
660
+ * chat_history (from closeSession/Store).
656
661
  * @param {string|Object} data - Serialized data (JSON string or object)
657
662
  * @param {Object} opt - Options (functions, store, states, etc.)
658
663
  * @returns {Saico}
@@ -667,38 +672,54 @@ class Saico {
667
672
  userData: parsed.userData,
668
673
  sessionConfig: parsed.sessionConfig,
669
674
  isolate: parsed.isolate,
670
- functions: opt.functions || parsed.task?.context?.functions,
675
+ functions: opt.functions || parsed.context?.functions,
671
676
  store: opt.store,
672
677
  redis: false, // No Redis proxy during deserialization
673
678
  });
674
679
 
675
680
  instance.tm_create = parsed.tm_create || instance.tm_create;
676
681
 
677
- // Activate with restored context if task data exists
678
- if (parsed.task) {
682
+ // Activate with restored state if taskId exists
683
+ if (parsed.taskId) {
684
+ const ctx = parsed.context;
679
685
  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,
686
+ createQ: !!ctx,
687
+ taskId: parsed.taskId,
688
+ tag: ctx?.tag,
689
+ chat_history: ctx?.chat_history,
690
+ functions: opt.functions || ctx?.functions,
691
+ tool_digest: ctx?.tool_digest,
685
692
  states: opt.states || [],
686
693
  ...opt,
687
694
  });
688
695
 
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;
696
+ // Restore raw msgs (from serialize/Redis) — takes priority over chat_history
697
+ if (ctx?.msgs && instance.context) {
698
+ instance.context._msgs = ctx.msgs;
697
699
  }
698
700
  }
699
701
 
700
702
  return instance;
701
703
  }
704
+
705
+ /**
706
+ * Load a Saico instance from Store by id.
707
+ * @param {string} id - The Saico instance id
708
+ * @param {Object} opt - Options (store, functions, states, etc.)
709
+ * @returns {Promise<Saico|null>}
710
+ */
711
+ static async rehydrate(id, opt = {}) {
712
+ const store = opt.store || Store.instance;
713
+ if (!store)
714
+ throw new Error('No store available for rehydrate');
715
+ const data = await store.load(id);
716
+ if (!data) return null;
717
+ const instance = Saico.deserialize(data, opt);
718
+ // Decompress chat_history into _msgs if present
719
+ if (instance.context)
720
+ await instance.context.initHistory();
721
+ return instance;
722
+ }
702
723
  }
703
724
 
704
725
  // [BACKEND] explanation text appended to context prompts