saico 2.8.0 → 2.9.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.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Saico - Hierarchical AI Conversation Orchestrator
2
2
 
3
- Saico is a Node.js library for building AI agents with hierarchical conversations, automatic context aggregation, and enterprise-grade tool calling. It manages nested task trees where each node can have its own conversation context, system prompt, tools, and state — and the library automatically assembles the full payload sent to the LLM by walking the tree.
3
+ Saico is a Node.js library for building AI agents with hierarchical conversations, automatic context aggregation, and enterprise-grade tool calling. It manages nested task trees where each node can have its own message queue, system prompt, tools, and state — and the library automatically assembles the full payload sent to the LLM by walking the tree.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Hierarchical conversations** — Parent-child task trees with automatic prompt, tool, and state summary aggregation
8
8
  - **Token-aware summarization** — Automatic summarization when message history approaches token limits
9
9
  - **Tool calling** — Depth control, deferred execution, duplicate detection, repetition prevention, and timeout handling
10
- - **Pluggable storage** — Optional Redis persistence (auto-save via proxy) and pluggable DB backends (DynamoDB adapter included)
10
+ - **Pluggable storage** — Optional Redis persistence (auto-save via proxy), library-level backend registration (`Saico.registerBackend`), and pluggable DB backends (DynamoDB adapter included)
11
11
  - **Isolation boundaries** — `opt.isolate` stops ancestor aggregation at any node in the tree
12
12
  - **Serialization** — Full state save/restore for long-running agents
13
13
 
@@ -54,7 +54,7 @@ agent.activate({ createQ: true });
54
54
  // Backend message (prefixed with [BACKEND] automatically)
55
55
  const reply = await agent.sendMessage('What is the weather in Tokyo?');
56
56
 
57
- // User-facing chat message (routed to deepest active context)
57
+ // User-facing chat message (routed to deepest active msgs Q)
58
58
  const chatReply = await agent.recvChatMessage('Hello!');
59
59
  ```
60
60
 
@@ -83,7 +83,7 @@ agent.activate();
83
83
  await agent.sendMessage('Do something');
84
84
  await agent.recvChatMessage('User says hello');
85
85
 
86
- // 4. Deactivate — bubbles cleaned messages to parent, closes context
86
+ // 4. Deactivate — bubbles cleaned messages to parent, closes msgs Q
87
87
  await agent.deactivate();
88
88
  ```
89
89
 
@@ -124,7 +124,7 @@ Root Saico (prompt: "You are a manager")
124
124
  Functions aggregated from all levels.
125
125
  ```
126
126
 
127
- - **`sendMessage(content, functions, opts)`** — Sends a backend message (auto-prefixed `[BACKEND]`). Uses the current or nearest ancestor context.
127
+ - **`sendMessage(content, functions, opts)`** — Sends a backend message (auto-prefixed `[BACKEND]`). Uses the current or nearest ancestor msgs Q.
128
128
  - **`recvChatMessage(content, opts)`** — Routes a user chat message DOWN to the deepest descendant with a message queue.
129
129
 
130
130
  ### Isolation
@@ -151,12 +151,12 @@ class OrderAgent extends Saico {
151
151
  }
152
152
  ```
153
153
 
154
- When a Saico's context is not the deepest active one, its last 5 user/assistant messages are also included in the state summary automatically.
154
+ When a Saico's msgs Q is not the deepest active one, its last 5 user/assistant messages are also included in the state summary automatically.
155
155
 
156
156
  ### Spawning Child Saico Instances
157
157
 
158
158
  ```js
159
- // Child with its own conversation context (auto-activated by spawn)
159
+ // Child with its own msgs Q (auto-activated by spawn)
160
160
  const child = new Saico({
161
161
  name: 'subtask',
162
162
  prompt: 'Handle this specific sub-task',
@@ -166,7 +166,7 @@ const child = new Saico({
166
166
  agent.spawn(child);
167
167
  await child.sendMessage('Working on subtask...');
168
168
 
169
- // Child without context (uses parent's via findContext())
169
+ // Child without msgs Q (uses parent's via findMsgs())
170
170
  const simple = new Saico({ name: 'simple' });
171
171
  agent.spawn(simple);
172
172
  await simple.sendMessage('Quick operation');
@@ -210,7 +210,8 @@ new Saico({
210
210
  // Storage
211
211
  redis: true, // Set false to skip Redis proxy
212
212
  key: 'custom-redis-key',
213
- dynamodb: { // DynamoDB config (creates adapter)
213
+ store: 'my-table', // Table name for instance persistence (closeSession/rehydrate)
214
+ dynamodb: { // DynamoDB config (creates instance-level adapter)
214
215
  region: 'us-east-1',
215
216
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
216
217
  },
@@ -257,15 +258,15 @@ agent.getSessionInfo();
257
258
  // userData, uptime
258
259
  // }
259
260
 
260
- await agent.closeSession(); // Saves full state to Store, cancels task
261
+ await agent.closeSession(); // prepareForStorage + save to registered backend, cancels task
261
262
 
262
- // Restore from Store
263
- const restored = await Saico.rehydrate(agent._id, { store });
263
+ // Restore from registered backend
264
+ const restored = await Saico.rehydrate(agent.id, { store: 'sessions' });
264
265
  ```
265
266
 
266
267
  ## Database Access
267
268
 
268
- Saico provides backend-agnostic DB methods. Configure via `opt.dynamodb` (auto-creates DynamoDB adapter) or `opt.db` (any adapter). Table name is required on every call. Child Saico instances without their own DB inherit the parent's adapter automatically via `_getDb()`.
269
+ Saico provides backend-agnostic DB methods. Configure via `Saico.registerBackend('dynamodb', config)` (library-level), `opt.dynamodb` (instance-level auto-creates adapter), or `opt.db` (any adapter). Table name is required on every call. Child Saico instances without their own DB inherit the parent's adapter automatically via `_getDb()`, which also falls back to the registered backend.
269
270
 
270
271
  ```js
271
272
  // CRUD — table name required on every call
@@ -300,28 +301,42 @@ class MyAgent extends Saico {
300
301
 
301
302
  ## Serialization
302
303
 
304
+ Both `serialize()` and `Saico.deserialize()` are async. `serialize()` calls `prepareForStorage()` first (strips `_` props, skips functions/states, compresses msgs) then `JSON.stringify`s the result.
305
+
303
306
  ```js
304
- // In-memory snapshot (raw msgs, used by Redis proxy)
305
- const json = agent.serialize();
306
- const restored = Saico.deserialize(json);
307
+ // prepareForStorage clean snapshot
308
+ const data = await agent.prepareForStorage();
309
+
310
+ // serialize/deserialize
311
+ const json = await agent.serialize();
312
+ const restored = await Saico.deserialize(json);
307
313
 
308
- // Durable persistence (compressed msgs, saved to Store)
314
+ // Durable persistence (uses registered backend + opt.store table name)
309
315
  await agent.closeSession();
310
- const restored2 = await Saico.rehydrate(agent._id, { store });
316
+ const restored2 = await Saico.rehydrate(agent.id, { store: 'sessions' });
311
317
  ```
312
318
 
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.
314
-
315
- ## Redis Persistence
319
+ `prepareForStorage()` automatically picks up all non-underscore properties (id, name, prompt, userData, sessionConfig, tm_create, isolate, etc.) and produces compressed chat_history for the msgs Q.
316
320
 
317
- When Redis is initialized, Saico instances are automatically wrapped in an observable proxy. Any property change triggers a debounced save to Redis.
321
+ ## Initialization
318
322
 
319
323
  ```js
320
- const { init } = require('saico');
324
+ const { Saico, init } = require('saico');
325
+
326
+ // Initialize Redis (default: enabled) and register DynamoDB backend
327
+ await init({
328
+ dynamodb: { region: 'us-east-1', credentials: { accessKeyId: 'AK', secretAccessKey: 'SK' } },
329
+ });
330
+
331
+ // Or register backend directly
332
+ Saico.registerBackend('dynamodb', { region: 'us-east-1', credentials: { ... } });
333
+ ```
321
334
 
322
- // Initialize with Redis
323
- await init({ redis: true });
335
+ ## Redis Persistence
336
+
337
+ When Redis is initialized (default: enabled via `init()`), Saico instances are automatically wrapped in an observable proxy. Any property change triggers a debounced save to Redis.
324
338
 
339
+ ```js
325
340
  const agent = new Saico({ name: 'persistent-agent' });
326
341
  agent.someProperty = 'value'; // Auto-saved to Redis
327
342
  ```
@@ -330,7 +345,7 @@ Properties prefixed with `_` are internal and not persisted.
330
345
 
331
346
  ## Tool Implementation (TOOL_ methods)
332
347
 
333
- 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.
348
+ 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.
334
349
 
335
350
  ```js
336
351
  class MyAgent extends Saico {
@@ -359,13 +374,13 @@ Return a string or `{ content: string, functions?: [] }`.
359
374
 
360
375
  ## Low-Level API
361
376
 
362
- For cases where you need a standalone context without the Saico master class:
377
+ For cases where you need a standalone message queue without the Saico master class:
363
378
 
364
379
  ```js
365
- const { createContext } = require('saico');
380
+ const { createMsgs } = require('saico');
366
381
 
367
- // Standalone context
368
- const ctx = createContext('System prompt', null, { tag: 'my-tag', token_limit: 4000 });
382
+ // Standalone message queue
383
+ const ctx = createMsgs('System prompt', { tag: 'my-tag', token_limit: 4000 });
369
384
  const reply = await ctx.sendMessage('user', 'Hello', functions);
370
385
  ```
371
386
 
@@ -374,11 +389,11 @@ const reply = await ctx.sendMessage('user', 'Hello', functions);
374
389
  ```
375
390
  saico/
376
391
  +-- index.js # Thin barrel file, exports all components
377
- +-- saico.js # Saico master class — owns context, spawn, DB, orchestration
392
+ +-- saico.js # Saico master class — owns msgs Q, spawn, DB, orchestration
378
393
  +-- itask.js # Pure task runner — hierarchy, states, cancellation, promises
379
394
  +-- msgs.js # Conversation context (message queue, tool calls, summarization)
380
395
  +-- dynamo.js # DynamoDB storage adapter
381
- +-- store.js # Storage abstraction (Redis + pluggable backends)
396
+ +-- store.js # Minimal storage shell (Redis helper + ID generation)
382
397
  +-- openai.js # OpenAI API wrapper with retry logic
383
398
  +-- redis.js # Redis persistence with observable proxy
384
399
  +-- util.js # Utilities (token counting, logging)
@@ -390,7 +405,7 @@ saico/
390
405
  npm test
391
406
  ```
392
407
 
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.
408
+ 300 tests covering Saico lifecycle, msgs Q ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, async serialization, prepareForStorage, backend registration, persistence (closeSession/rehydrate via registered backend), storage integration, and full hierarchy flows.
394
409
 
395
410
  ## Requirements
396
411
 
package/index.js CHANGED
@@ -1,29 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  const Itask = require('./itask.js');
4
- const { Context, createContext } = require('./msgs.js');
5
- const { Store, DynamoBackend } = require('./store.js');
4
+ const { Msgs, createMsgs } = require('./msgs.js');
5
+ const { Store } = require('./store.js');
6
6
  const { Saico } = require('./saico.js');
7
7
  const { DynamoDBAdapter } = require('./dynamo.js');
8
8
 
9
9
  /**
10
10
  * Initialize Saico with storage configuration.
11
- * Sets up the Store singleton and optionally initializes Redis.
11
+ * Registers the backend and optionally initializes Redis.
12
12
  *
13
13
  * @param {Object} config - Configuration options
14
- * @param {boolean} config.redis - Whether to initialize Redis
15
- * @param {Object} config.dynamodb - DynamoDB backend config {table, aws}
14
+ * @param {boolean} [config.redis=true] - Set false to skip Redis init
15
+ * @param {Object} [config.dynamodb] - DynamoDB config { region, credentials, client }
16
16
  * @returns {Store} The initialized Store instance
17
17
  */
18
18
  async function init(config = {}) {
19
- const store = Store.init(config);
20
-
21
- if (config.redis) {
19
+ if (config.redis !== false) {
22
20
  const redis = require('./redis.js');
23
21
  await redis.init();
24
- store.setRedis(redis.rclient);
25
22
  }
26
23
 
24
+ if (config.dynamodb)
25
+ Saico.registerBackend('dynamodb', config.dynamodb);
26
+
27
+ // Legacy: still init Store shell
28
+ const store = Store.init(config);
27
29
  return store;
28
30
  }
29
31
 
@@ -34,15 +36,14 @@ module.exports = {
34
36
 
35
37
  // Core classes
36
38
  Itask,
37
- Context,
39
+ Msgs,
38
40
  Store,
39
- DynamoBackend,
40
41
 
41
42
  // Initialization
42
43
  init,
43
44
 
44
45
  // Factory
45
- createContext,
46
+ createMsgs,
46
47
 
47
48
  // Utilities (re-export from util.js)
48
49
  util: require('./util.js'),
package/itask.js CHANGED
@@ -534,6 +534,9 @@ Itask.prototype.continue = function(ret){
534
534
  return this;
535
535
  };
536
536
 
537
+ /* ---------- serialization ---------- */
538
+ Itask.prototype.serialize = function(){ return JSON.stringify({}); };
539
+
537
540
  /* ---------- introspection / ps ---------- */
538
541
  Itask.prototype.is_running = function(){ return this.running && !this._completed; };
539
542
  Itask.prototype.is_completed = function(){ return this._completed; };
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;
@@ -50,18 +44,15 @@ class Context {
50
44
  // Tool digest — persistent history of tool calls that mutated task state
51
45
  this.tool_digest = config.tool_digest || [];
52
46
 
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
+
53
51
  // Initialize messages: explicit msgs take priority over chat_history
54
52
  this._chat_history = config.chat_history || null;
55
53
  (config.msgs || []).forEach(m => this.push(m));
56
54
 
57
- _log('created Context for tag', this.tag);
58
- }
59
-
60
- // Set the task reference (used when context is created separately)
61
- setTask(task) {
62
- this.task = task;
63
- if (!this.functions)
64
- this.functions = task?.functions;
55
+ _log('created Msgs for tag', this.tag);
65
56
  }
66
57
 
67
58
  /**
@@ -135,32 +126,6 @@ class Context {
135
126
  this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
136
127
  }
137
128
 
138
- // Get the parent context by traversing task hierarchy (via Saico)
139
- getParentContext() {
140
- if (!this.task || !this.task.parent)
141
- return null;
142
- let task = this.task.parent;
143
- while (task) {
144
- if (task._saico?.context) return task._saico.context;
145
- task = task.parent;
146
- }
147
- return null;
148
- }
149
-
150
- // Get all ancestor contexts via task hierarchy (via Saico)
151
- getAncestorContexts() {
152
- if (!this.task)
153
- return [];
154
- const contexts = [];
155
- let task = this.task.parent;
156
- while (task) {
157
- if (task._saico?.context)
158
- contexts.unshift(task._saico.context);
159
- task = task.parent;
160
- }
161
- return contexts;
162
- }
163
-
164
129
  _hasPendingToolCalls() {
165
130
  const toolCallMsgs = this._msgs.filter(m => m.msg.tool_calls);
166
131
 
@@ -334,16 +299,15 @@ class Context {
334
299
  };
335
300
  } else {
336
301
  this._trackActiveToolCall(call);
337
- const _snap = this.task
338
- ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
302
+ const _snap = this._getSnapshot
303
+ ? JSON.stringify(this._getSnapshot()) : null;
339
304
 
340
305
  try {
341
306
  const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
342
307
  const timeout = correspondingDeferred?.originalMessage.opts.timeout;
343
308
 
344
309
  result = await this._executeToolCallWithTimeout(call, timeout);
345
- if (_snap !== null &&
346
- _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
310
+ if (_snap !== null && _snap !== JSON.stringify(this._getSnapshot()))
347
311
  this._appendToolDigest(call.function.name, result?.content || '');
348
312
  } finally {
349
313
  this._completeActiveToolCall(call);
@@ -429,28 +393,18 @@ class Context {
429
393
  return this._msgs.length;
430
394
  }
431
395
 
432
- serialize() { return JSON.stringify(this._msgs); }
396
+ async serialize() {
397
+ const { chat_history, tool_digest } = await this.prepareForStorage();
398
+ return JSON.stringify({
399
+ tag: this.tag,
400
+ chat_history,
401
+ tool_digest,
402
+ functions: this.functions,
403
+ });
404
+ }
433
405
 
434
406
  getSummaries() { return this._msgs.filter(m => m.opts.summary); }
435
407
 
436
- // Get functions aggregated from this context and all ancestor contexts
437
- getFunctions() {
438
- const allFunctions = [];
439
-
440
- // Get functions from ancestor contexts via task hierarchy
441
- const ancestorContexts = this.getAncestorContexts();
442
- for (const ctx of ancestorContexts) {
443
- if (ctx.functions && Array.isArray(ctx.functions))
444
- allFunctions.push(...ctx.functions);
445
- }
446
-
447
- // Add our own functions
448
- if (this.functions && Array.isArray(this.functions))
449
- allFunctions.push(...this.functions);
450
-
451
- return allFunctions.length > 0 ? allFunctions : null;
452
- }
453
-
454
408
  async summarizeMessages() {
455
409
  const tokens = util.countTokens(this.__msgs);
456
410
  if (tokens < this.lower_limit)
@@ -459,7 +413,7 @@ class Context {
459
413
  }
460
414
 
461
415
  async close() {
462
- _log('Closing Context tag', this.tag);
416
+ _log('Closing Msgs tag', this.tag);
463
417
 
464
418
  if (this._sequential_mode && this._processing_sequential) {
465
419
  _ldbg('Sequential mode: waiting for current message to complete before closing tag', this.tag);
@@ -472,22 +426,8 @@ class Context {
472
426
  }
473
427
  }
474
428
 
475
- // Move waiting messages to parent context via task hierarchy
476
- const parentCtx = this.getParentContext();
477
- if (parentCtx && this._waitingQueue.length > 0) {
478
- _log('Moving', this._waitingQueue.length, 'waiting messages to parent context');
479
- parentCtx._waitingQueue.push(...this._waitingQueue);
480
- this._waitingQueue = [];
481
- }
482
-
483
- if (parentCtx && this._sequential_queue.length > 0) {
484
- _log('Moving', this._sequential_queue.length, 'sequential queue messages to parent context');
485
- parentCtx._sequential_queue.push(...this._sequential_queue);
486
- this._sequential_queue = [];
487
- }
488
-
489
- await this._summarizeContext(true, parentCtx);
490
- _log('Finished closing Context tag', this.tag);
429
+ await this._summarizeContext(true);
430
+ _log('Finished closing Msgs tag', this.tag);
491
431
  }
492
432
 
493
433
 
@@ -591,38 +531,6 @@ class Context {
591
531
  return summary;
592
532
  }
593
533
 
594
- // Get message context - walks up task hierarchy to collect prompts and summaries
595
- getMsgContext(add_tag) {
596
- const msgs = [];
597
-
598
- // Get context from ancestor tasks via task hierarchy
599
- const ancestorContexts = this.getAncestorContexts();
600
- for (const ctx of ancestorContexts) {
601
- if (ctx.prompt)
602
- msgs.push({role: 'system', content: ctx.prompt});
603
- // Add summaries from ancestor contexts
604
- const summaries = ctx._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
605
- if (add_tag)
606
- m.msg.tag = ctx.tag;
607
- return m.msg;
608
- });
609
- msgs.push(...summaries);
610
- }
611
-
612
- // Add this context's prompt
613
- if (this.prompt)
614
- msgs.push({role: 'system', content: this.prompt});
615
-
616
- // Add this context's summaries
617
- const mySummaries = this._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
618
- if (add_tag)
619
- m.msg.tag = this.tag;
620
- return m.msg;
621
- });
622
-
623
- return msgs.concat(mySummaries);
624
- }
625
-
626
534
  _createMsgObj(role, content, functions, opts) {
627
535
  const name = opts?.name;
628
536
  const tool_call_id = opts?.tool_call_id;
@@ -952,10 +860,10 @@ class Context {
952
860
  ? [...o.opts._aggregatedFunctions, ...messageFuncs]
953
861
  : null;
954
862
  } else {
955
- const hierarchyFuncs = this.getFunctions() || [];
863
+ const ownFuncs = this.functions || [];
956
864
  const messageFuncs = o.functions || [];
957
- funcs = [...hierarchyFuncs, ...messageFuncs].length > 0
958
- ? [...hierarchyFuncs, ...messageFuncs]
865
+ funcs = [...ownFuncs, ...messageFuncs].length > 0
866
+ ? [...ownFuncs, ...messageFuncs]
959
867
  : null;
960
868
  }
961
869
 
@@ -1024,15 +932,15 @@ class Context {
1024
932
 
1025
933
  for (const { call, isDuplicate } of toolCallsWithResults) {
1026
934
  if (!isDuplicate) {
1027
- const _snap = this.task
1028
- ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
935
+ const _snap = this._getSnapshot
936
+ ? JSON.stringify(this._getSnapshot()) : null;
1029
937
  try {
1030
938
  const result = await this._executeToolCallWithTimeout(
1031
939
  call, o.opts?.timeout);
1032
940
  const item = toolCallsWithResults.find(item => item.call.id === call.id);
1033
941
  if (item) item.result = result;
1034
942
  if (_snap !== null &&
1035
- _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
943
+ _snap !== JSON.stringify(this._getSnapshot()))
1036
944
  this._appendToolDigest(call.function.name, result?.content || '');
1037
945
  } finally {
1038
946
  this._completeActiveToolCall(call);
@@ -1088,39 +996,11 @@ class Context {
1088
996
  }
1089
997
 
1090
998
  /**
1091
- * Search the Saico hierarchy for a TOOL_<toolName> method.
1092
- * Order: current task walk UP parents → walk DOWN children (BFS).
999
+ * Find a TOOL_<toolName> implementation. Delegates to _findToolImpl callback
1000
+ * set by Saico, which searches the hierarchy.
1093
1001
  */
1094
1002
  _findToolImplementation(toolName) {
1095
- const methodName = 'TOOL_' + toolName;
1096
- const check = (task) =>
1097
- task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
1098
-
1099
- // 1. Current task
1100
- let found = check(this.task);
1101
- if (found) return { saico: found, methodName };
1102
-
1103
- // 2. Walk UP parent chain
1104
- let t = this.task?.parent;
1105
- while (t) {
1106
- found = check(t);
1107
- if (found) return { saico: found, methodName };
1108
- t = t.parent;
1109
- }
1110
-
1111
- // 3. Walk DOWN from this.task (BFS)
1112
- if (this.task) {
1113
- const queue = [...this.task.child];
1114
- while (queue.length > 0) {
1115
- const child = queue.shift();
1116
- if (child._completed) continue;
1117
- found = check(child);
1118
- if (found) return { saico: found, methodName };
1119
- if (child.child?.size > 0) queue.push(...child.child);
1120
- }
1121
- }
1122
-
1123
- return null;
1003
+ return this._findToolImpl ? this._findToolImpl(toolName) : null;
1124
1004
  }
1125
1005
 
1126
1006
  async interpretAndApplyChanges(call) {
@@ -1169,35 +1049,11 @@ class Context {
1169
1049
  return { content, functions };
1170
1050
  }
1171
1051
 
1172
- // Spawn child context (creates a child task with its own context)
1173
- spawnChild(prompt, tag, config = {}) {
1174
- if (!this.task) {
1175
- // If no task, create a standalone context
1176
- return createContext(prompt, null, { ...config, tag });
1177
- }
1178
-
1179
- // Create a child task with its own context
1180
- const Itask = require('./itask.js');
1181
- const childTask = new Itask({
1182
- name: tag || 'child-context',
1183
- async: true,
1184
- }, []);
1185
- this.task.spawn(childTask);
1186
-
1187
- const childContext = new Context(prompt, childTask, { ...config, tag });
1188
- // Store context on Saico if present, otherwise just set on task reference
1189
- if (childTask._saico) {
1190
- childTask._saico.context = childContext;
1191
- childTask._saico.context_id = childContext.tag;
1192
- }
1193
-
1194
- return childContext;
1195
- }
1196
1052
  }
1197
1053
 
1198
- // Factory function to create a Context with Proxy wrapper
1199
- function createContext(prompt, task, config = {}) {
1200
- const instance = new Context(prompt, task, config);
1054
+ // Factory function to create a Msgs instance with Proxy wrapper
1055
+ function createMsgs(prompt, config = {}) {
1056
+ const instance = new Msgs(prompt, config);
1201
1057
 
1202
1058
  return new Proxy(instance, {
1203
1059
  get(target, prop, receiver) {
@@ -1246,4 +1102,4 @@ function createContext(prompt, task, config = {}) {
1246
1102
  });
1247
1103
  }
1248
1104
 
1249
- module.exports = { Context, createContext };
1105
+ module.exports = { Msgs, createMsgs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
package/redis.js CHANGED
@@ -39,11 +39,13 @@ function createObservableForRedis(key, obj) {
39
39
  let lastSavedObject = null; // Cache for the last-saved sanitized object
40
40
  let lastSavedTimestamp = null; // Timestamp of the last save to Redis
41
41
 
42
- const saveToRedis = debounce(() => {
42
+ const saveToRedis = debounce(async () => {
43
43
  const sanitizedObj = sanitizeObject(obj);
44
44
 
45
45
  // Compare sanitized object with the last-saved object
46
- if (serialize(sanitizedObj) === serialize(lastSavedObject)) {
46
+ const serializedNew = await serialize(sanitizedObj);
47
+ const serializedOld = await serialize(lastSavedObject);
48
+ if (serializedNew === serializedOld) {
47
49
  logDebug("No changes detected, skipping save.");
48
50
  return;
49
51
  }
@@ -51,7 +53,7 @@ function createObservableForRedis(key, obj) {
51
53
  lastSavedObject = sanitizedObj;
52
54
  lastSavedTimestamp = Date.now(); // Update the last saved timestamp
53
55
  sanitizedObj.lastSave = lastSavedTimestamp;
54
- rclient.set(key, serialize(sanitizedObj));
56
+ await rclient.set(key, serializedNew);
55
57
  logDebug("Saved to Redis:", key, `at ${lastSavedTimestamp}`);
56
58
  }, 1000);
57
59
 
@@ -100,9 +102,9 @@ function createObservableForRedis(key, obj) {
100
102
  },
101
103
  };
102
104
 
103
- function serialize(obj) {
105
+ async function serialize(obj) {
104
106
  if (typeof obj == 'object' && typeof obj?.serialize == 'function')
105
- return obj.serialize();
107
+ return await obj.serialize();
106
108
  return JSON.stringify(obj);
107
109
  }
108
110