saico 2.8.1 → 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
  ```
@@ -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
- 290 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
@@ -2,28 +2,30 @@
2
2
 
3
3
  const Itask = require('./itask.js');
4
4
  const { Msgs, createMsgs } = require('./msgs.js');
5
- const { Store, DynamoBackend } = require('./store.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
 
@@ -36,7 +38,6 @@ module.exports = {
36
38
  Itask,
37
39
  Msgs,
38
40
  Store,
39
- DynamoBackend,
40
41
 
41
42
  // Initialization
42
43
  init,
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
@@ -393,7 +393,15 @@ class Msgs {
393
393
  return this._msgs.length;
394
394
  }
395
395
 
396
- 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
+ }
397
405
 
398
406
  getSummaries() { return this._msgs.filter(m => m.opts.summary); }
399
407
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.8.1",
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
 
package/saico.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const crypto = require('crypto');
4
4
  const Itask = require('./itask.js');
5
5
  const { Msgs } = require('./msgs.js');
6
- const { Store } = require('./store.js');
7
6
  const util = require('./util.js');
8
7
 
9
8
  function makeId(len = 12){
@@ -19,7 +18,7 @@ function makeId(len = 12){
19
18
  * - Construction: sets up storage (Redis observable + optional DynamoDB),
20
19
  * class-level prompt, tool config. No Itask is created yet.
21
20
  * - activate(opts): creates the internal Itask and optionally attaches a
22
- * message Q context (when opts.createQ is true).
21
+ * message Q (when opts.createQ is true).
23
22
  * - DB access works before and after activation.
24
23
  *
25
24
  * Saico orchestrates the full message payload sent to the LLM by walking its
@@ -37,11 +36,11 @@ class Saico {
37
36
  * @param {Array} [opt.functions] - Available AI functions
38
37
  * @param {string} [opt.key] - Redis key override (default: 'saico:<id>')
39
38
  * @param {boolean} [opt.redis=true] - Set false to skip Redis proxy
40
- * @param {boolean} [opt.createQ] - Create message Q context on activate()
39
+ * @param {boolean} [opt.createQ] - Create message Q on activate()
41
40
  * @param {boolean} [opt.isolate] - Isolate: don't aggregate from ancestors
42
41
  * @param {Object} [opt.dynamodb] - DynamoDB config { region, credentials: { accessKeyId, secretAccessKey }, client }
43
42
  * @param {Object} [opt.db] - Pluggable DB backend
44
- * @param {Object} [opt.store] - Store instance override
43
+ * @param {string} [opt.store] - Table name for instance persistence
45
44
  * @param {Object} [opt.userData] - Initial user data
46
45
  * @param {Object} [opt.sessionConfig] - Session config overrides
47
46
  */
@@ -49,13 +48,13 @@ class Saico {
49
48
  // Internal properties (underscore-prefixed, not persisted to Redis)
50
49
  this.id = opt.id || crypto.randomBytes(8).toString('hex');
51
50
  this._task = null;
52
- this._store = opt.store || Store.instance || null;
51
+ this._storeName = (typeof opt.store === 'string') ? opt.store : null;
53
52
  this._opt = opt;
54
- this._isolate = opt.isolate || false;
53
+ this.isolate = opt.isolate || false;
55
54
 
56
- // Context owned directly by Saico (not Itask)
57
- this.context = null;
58
- this.context_id = null;
55
+ // Msgs Q owned directly by Saico (not Itask)
56
+ this.msgs = null;
57
+ this.msgs_id = null;
59
58
 
60
59
  // Public configuration
61
60
  this.name = opt.name || this.constructor.name || 'saico';
@@ -98,7 +97,7 @@ class Saico {
98
97
  }
99
98
 
100
99
  /**
101
- * Create the internal Itask and optionally a message Q context.
100
+ * Create the internal Itask and optionally a message Q.
102
101
  *
103
102
  * @param {Object} opts
104
103
  * @param {boolean} [opts.createQ] - Override this.createQ for this activation
@@ -106,7 +105,7 @@ class Saico {
106
105
  * @param {Array} [opts.functions] - Override functions
107
106
  * @param {Array} [opts.states] - Override this.states for this activation
108
107
  * @param {string} [opts.taskId] - Custom task ID
109
- * @param {number} [opts.token_limit] - Token limit for context
108
+ * @param {number} [opts.token_limit] - Token limit for msgs Q
110
109
  * @param {number} [opts.max_depth] - Max tool call depth
111
110
  * @param {number} [opts.max_tool_repetition] - Max tool repetition
112
111
  * @param {number} [opts.queue_limit] - Message queue limit
@@ -114,14 +113,22 @@ class Saico {
114
113
  * @param {boolean} [opts.sequential_mode] - Sequential message processing
115
114
  * @param {Array} [opts.msgs] - Initial messages
116
115
  * @param {*} [opts.chat_history] - Chat history to restore
117
- * @param {Object} [opts.contextConfig] - Additional Context config overrides
116
+ * @param {Object} [opts.msgsConfig] - Additional Msgs config overrides
118
117
  * @returns {Saico} this instance (for chaining)
119
118
  */
120
119
  activate(opts = {}) {
121
120
  if (this._task)
122
121
  throw new Error('Already activated. Call deactivate() first.');
123
122
 
124
- const states = opts.states || this.states || [];
123
+ const defaultStates = [
124
+ async function main() {
125
+ return this._task.wait();
126
+ },
127
+ async function catch$error_handler(err) {
128
+ console.error(`${this.name} caught error:`, err);
129
+ },
130
+ ];
131
+ const states = opts.states || this.states || defaultStates;
125
132
 
126
133
  // Build effective prompt: class-level + activation-level
127
134
  const effectivePrompt = [this.prompt, opts.prompt].filter(Boolean).join('\n');
@@ -138,10 +145,10 @@ class Saico {
138
145
  // Store Saico reference on task for parent chain traversal
139
146
  this._task._saico = this;
140
147
 
141
- // Create message Q context if requested (class-level or activate-level)
148
+ // Create message Q if requested (class-level or activate-level)
142
149
  if (opts.createQ ?? this.createQ) {
143
150
  const functions = opts.functions || this.functions;
144
- const contextConfig = {
151
+ const msgsConfig = {
145
152
  tag: opts.tag || this._task.id,
146
153
  token_limit: opts.token_limit ?? this.sessionConfig.token_limit,
147
154
  max_depth: opts.max_depth ?? this.sessionConfig.max_depth,
@@ -153,16 +160,16 @@ class Saico {
153
160
  msgs: opts.msgs,
154
161
  chat_history: opts.chat_history,
155
162
  tool_digest: opts.tool_digest,
156
- ...opts.contextConfig,
163
+ ...opts.msgsConfig,
157
164
  };
158
165
 
159
166
  const augmentedPrompt = effectivePrompt
160
167
  ? effectivePrompt + Saico.BACKEND_EXPLANATION
161
168
  : '';
162
- const msgs = new Msgs(augmentedPrompt, contextConfig);
163
- this.context = msgs;
164
- this.context_id = makeId(16);
165
- msgs.tag = this.context_id;
169
+ const msgs = new Msgs(augmentedPrompt, msgsConfig);
170
+ this.msgs = msgs;
171
+ this.msgs_id = makeId(16);
172
+ msgs.tag = this.msgs_id;
166
173
 
167
174
  // Wire callbacks for hierarchy access
168
175
  msgs._findToolImpl = (toolName) => this._findToolImpl(toolName);
@@ -175,63 +182,63 @@ class Saico {
175
182
  // ---- Context management (owned by Saico, not Itask) ----
176
183
 
177
184
  /**
178
- * Find the nearest context walking UP the Saico/task hierarchy.
185
+ * Find the nearest msgs Q walking UP the Saico/task hierarchy.
179
186
  */
180
- findContext() {
181
- if (this.context) return this.context;
187
+ findMsgs() {
188
+ if (this.msgs) return this.msgs;
182
189
  let task = this._task?.parent;
183
190
  while (task) {
184
- if (task._saico?.context) return task._saico.context;
191
+ if (task._saico?.msgs) return task._saico.msgs;
185
192
  task = task.parent;
186
193
  }
187
194
  return null;
188
195
  }
189
196
 
190
197
  /**
191
- * Walk DOWN to find the deepest active descendant with a context.
198
+ * Walk DOWN to find the deepest active descendant with a msgs Q.
192
199
  */
193
- findDeepestContext() {
194
- if (!this._task) return this.context || null;
195
- let deepest = this.context ? { context: this.context, depth: 0 } : null;
200
+ findDeepestMsgs() {
201
+ if (!this._task) return this.msgs || null;
202
+ let deepest = this.msgs ? { msgs: this.msgs, depth: 0 } : null;
196
203
  const search = (task, depth) => {
197
204
  for (const child of task.child) {
198
205
  if (child._completed) continue;
199
- if (child._saico?.context) {
206
+ if (child._saico?.msgs) {
200
207
  if (!deepest || depth + 1 >= deepest.depth)
201
- deepest = { context: child._saico.context, depth: depth + 1 };
208
+ deepest = { msgs: child._saico.msgs, depth: depth + 1 };
202
209
  }
203
210
  search(child, depth + 1);
204
211
  }
205
212
  };
206
213
  search(this._task, 0);
207
- return deepest ? deepest.context : null;
214
+ return deepest ? deepest.msgs : null;
208
215
  }
209
216
 
210
217
  /**
211
- * Deactivate — bubble cleaned messages to parent, close context, cancel task.
218
+ * Deactivate — bubble cleaned messages to parent, close msgs Q, cancel task.
212
219
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
213
- * then closes the context without the default summary bubbling.
220
+ * then closes the msgs Q without the default summary bubbling.
214
221
  */
215
222
  async deactivate() {
216
223
  if (!this._task) return;
217
- if (this.context) {
218
- // Find parent context to bubble cleaned messages
224
+ if (this.msgs) {
225
+ // Find parent msgs to bubble cleaned messages
219
226
  let parentTask = this._task.parent;
220
- let parentCtx = null;
227
+ let parentMsgs = null;
221
228
  while (parentTask) {
222
- if (parentTask._saico?.context) { parentCtx = parentTask._saico.context; break; }
229
+ if (parentTask._saico?.msgs) { parentMsgs = parentTask._saico.msgs; break; }
223
230
  parentTask = parentTask.parent;
224
231
  }
225
- if (parentCtx) {
232
+ if (parentMsgs) {
226
233
  const cleaned = this.getRecentMessages(Infinity);
227
234
  for (const msg of cleaned)
228
- parentCtx.push(msg);
235
+ parentMsgs.push(msg);
229
236
  }
230
- // Clean tool calls and close context without additional summary bubbling.
231
- if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
232
- this.context.cleanToolCallsByTag(this.context_id);
233
- this.context = null;
234
- this.context_id = null;
237
+ // Clean tool calls and close msgs Q without additional summary bubbling.
238
+ if (this.msgs_id && typeof this.msgs.cleanToolCallsByTag === 'function')
239
+ this.msgs.cleanToolCallsByTag(this.msgs_id);
240
+ this.msgs = null;
241
+ this.msgs_id = null;
235
242
  }
236
243
  this._task._ecancel();
237
244
  this._task = null;
@@ -276,7 +283,7 @@ class Saico {
276
283
  */
277
284
  _getSaicoAncestors() {
278
285
  const chain = [this];
279
- if (this._isolate) return chain;
286
+ if (this.isolate) return chain;
280
287
  let task = this._task?.parent;
281
288
  while (task) {
282
289
  if (task._saico) {
@@ -290,7 +297,7 @@ class Saico {
290
297
 
291
298
  /**
292
299
  * Build preamble and aggregated functions by walking the Saico chain.
293
- * @param {Context} activeCtx - The deepest active context (for state summary logic)
300
+ * @param {Msgs} activeCtx - The deepest active msgs Q (for state summary logic)
294
301
  * @returns {{ preamble: Array, allFunctions: Array }}
295
302
  */
296
303
  _buildPreamble(activeCtx) {
@@ -317,8 +324,8 @@ class Saico {
317
324
  }
318
325
 
319
326
  // Tools digest
320
- if (saico.context?.tool_digest?.length > 0) {
321
- const digestText = saico.context.tool_digest.map(entry =>
327
+ if (saico.msgs?.tool_digest?.length > 0) {
328
+ const digestText = saico.msgs.tool_digest.map(entry =>
322
329
  `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
323
330
  ).join('\n');
324
331
  preamble.push({ role: 'system', content: '[Tool Activity Log]\n' + digestText });
@@ -337,20 +344,20 @@ class Saico {
337
344
  if (!this._task)
338
345
  throw new Error('Not activated. Call activate() first.');
339
346
 
340
- // Find the active context (own or walk up)
341
- let ctx = this.findContext();
347
+ // Find the active msgs Q (own or walk up)
348
+ let ctx = this.findMsgs();
342
349
  if (!ctx)
343
- throw new Error('No context available');
350
+ throw new Error('No msgs Q available');
344
351
 
345
352
  // Build preamble by walking Saico chain
346
- const activeCtx = this.findDeepestContext() || ctx;
353
+ const activeCtx = this.findDeepestMsgs() || ctx;
347
354
  const { preamble, allFunctions } = this._buildPreamble(activeCtx);
348
355
 
349
356
  // Merge with call-specific functions
350
357
  if (functions) allFunctions.push(...(Array.isArray(functions) ? functions : [functions]));
351
358
 
352
359
  opts = Object.assign({}, opts, {
353
- tag: this.context_id,
360
+ tag: this.msgs_id,
354
361
  _preamble: preamble,
355
362
  _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
356
363
  });
@@ -362,9 +369,9 @@ class Saico {
362
369
  throw new Error('Not activated. Call activate() first.');
363
370
 
364
371
  // Route DOWN to deepest descendant with a msg Q
365
- const ctx = this.findDeepestContext();
372
+ const ctx = this.findDeepestMsgs();
366
373
  if (!ctx)
367
- throw new Error('No context available');
374
+ throw new Error('No msgs Q available');
368
375
 
369
376
  // Build preamble by walking Saico chain
370
377
  const { preamble, allFunctions } = this._buildPreamble(ctx);
@@ -396,8 +403,8 @@ class Saico {
396
403
  * @returns {Array<{role: string, content: string}>}
397
404
  */
398
405
  getRecentMessages(n = 5) {
399
- if (!this.context) return [];
400
- return this.context._msgs
406
+ if (!this.msgs) return [];
407
+ return this.msgs._msgs
401
408
  .filter(m => {
402
409
  if (m.msg.role === 'tool' || m.msg.tool_calls) return false;
403
410
  if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
@@ -409,8 +416,8 @@ class Saico {
409
416
 
410
417
  /**
411
418
  * Internal state summary builder. Includes own getStateSummary() and,
412
- * if this context is NOT the active (deepest) Q, includes recent messages.
413
- * @param {Context} activeCtx - The deepest active context
419
+ * if this msgs Q is NOT the active (deepest) Q, includes recent messages.
420
+ * @param {Msgs} activeCtx - The deepest active msgs Q
414
421
  * @returns {Array|string|null}
415
422
  */
416
423
  _getStateSummary(activeCtx) {
@@ -418,8 +425,8 @@ class Saico {
418
425
  const own = this.getStateSummary();
419
426
  if (own) parts.push(own);
420
427
 
421
- // If this context is NOT the active (deepest) Q, include recent messages
422
- if (this.context && activeCtx && this.context !== activeCtx) {
428
+ // If this msgs Q is NOT the active (deepest) Q, include recent messages
429
+ if (this.msgs && activeCtx && this.msgs !== activeCtx) {
423
430
  const recent = this.getRecentMessages(5);
424
431
  if (recent.length > 0) parts.push(...recent);
425
432
  }
@@ -486,7 +493,7 @@ class Saico {
486
493
  name: this.name,
487
494
  running: this._task?.running || false,
488
495
  completed: this._task?._completed || false,
489
- messageCount: this.context?.length || 0,
496
+ messageCount: this.msgs?.length || 0,
490
497
  childCount: this._task?.child?.size || 0,
491
498
  userData: this.userData,
492
499
  uptime: Date.now() - this.tm_create,
@@ -494,35 +501,17 @@ class Saico {
494
501
  }
495
502
 
496
503
  /**
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.
504
+ * Close the session — save state to registered backend, cancel task.
500
505
  */
501
506
  async closeSession() {
502
507
  if (!this._task) return;
503
508
 
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);
509
+ if (this._storeName && this.msgs) {
510
+ const backend = Saico.getBackend();
511
+ if (backend) {
512
+ const data = await this.prepareForStorage();
513
+ await backend.put(data, this._storeName);
514
+ }
526
515
  }
527
516
 
528
517
  this._task._ecancel();
@@ -541,7 +530,8 @@ class Saico {
541
530
  if (task._saico?._db) return task._saico._db;
542
531
  task = task.parent;
543
532
  }
544
- throw new Error('No DB backend configured. Set opt.dynamodb or opt.db on this Saico or an ancestor.');
533
+ if (Saico._backend) return Saico._backend;
534
+ throw new Error('No DB backend configured. Call Saico.registerBackend() or set opt.db.');
545
535
  }
546
536
 
547
537
  async dbPutItem(item, table) {
@@ -628,42 +618,64 @@ class Saico {
628
618
 
629
619
  // ---- Serialization ----
630
620
 
621
+ /**
622
+ * Prepare this instance for storage. Creates a clean snapshot:
623
+ * - Strips all '_' prefixed properties
624
+ * - Strips functions (including states)
625
+ * - Builds compressed chat_history from msgs Q (via Msgs.prepareForStorage)
626
+ * - Adds taskId from internal Itask
627
+ * @returns {Promise<Object>} Plain serializable object
628
+ */
629
+ async prepareForStorage() {
630
+ const data = {};
631
+ for (const key of Object.keys(this)) {
632
+ if (key.startsWith('_')) continue;
633
+ if (typeof this[key] === 'function') continue;
634
+ if (key === 'msgs') continue; // handled specially below
635
+ if (key === 'states') continue; // function array, not serializable
636
+ data[key] = this[key];
637
+ }
638
+
639
+ // Deep clone to detach from live instance
640
+ const cloned = JSON.parse(JSON.stringify(data));
641
+
642
+ // Handle msgs — compress via Msgs.prepareForStorage
643
+ if (this.msgs) {
644
+ const { chat_history, tool_digest } = await this.msgs.prepareForStorage();
645
+ cloned.msgs = {
646
+ tag: this.msgs.tag,
647
+ chat_history,
648
+ tool_digest,
649
+ functions: this.msgs.functions,
650
+ };
651
+ } else {
652
+ cloned.msgs = null;
653
+ }
654
+
655
+ // Derived properties from underscore-prefixed internals
656
+ cloned.taskId = this._task?.id || null;
657
+
658
+ return cloned;
659
+ }
660
+
631
661
  /**
632
662
  * 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().
663
+ * Calls prepareForStorage() to build a clean snapshot, then JSON.stringify.
635
664
  */
636
- serialize() {
637
- const data = {
638
- id: this.id,
639
- name: this.name,
640
- prompt: this.prompt,
641
- userData: this.userData,
642
- sessionConfig: this.sessionConfig,
643
- tm_create: this.tm_create,
644
- isolate: this._isolate,
645
- };
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;
654
- return JSON.stringify(data);
665
+ async serialize() {
666
+ const prepared = await this.prepareForStorage();
667
+ return JSON.stringify(prepared);
655
668
  }
656
669
 
657
670
  /**
658
671
  * Restore a Saico instance from serialized data.
659
- * Supports both raw msgs (from serialize/Redis) and compressed
660
- * chat_history (from closeSession/Store).
661
672
  * @param {string|Object} data - Serialized data (JSON string or object)
662
673
  * @param {Object} opt - Options (functions, store, states, etc.)
663
- * @returns {Saico}
674
+ * @returns {Promise<Saico>}
664
675
  */
665
- static deserialize(data, opt = {}) {
676
+ static async deserialize(data, opt = {}) {
666
677
  const parsed = typeof data === 'string' ? JSON.parse(data) : data;
678
+ const msgsData = parsed.msgs;
667
679
 
668
680
  const instance = new Saico({
669
681
  id: parsed.id,
@@ -672,7 +684,7 @@ class Saico {
672
684
  userData: parsed.userData,
673
685
  sessionConfig: parsed.sessionConfig,
674
686
  isolate: parsed.isolate,
675
- functions: opt.functions || parsed.context?.functions,
687
+ functions: opt.functions || msgsData?.functions,
676
688
  store: opt.store,
677
689
  redis: false, // No Redis proxy during deserialization
678
690
  });
@@ -681,48 +693,67 @@ class Saico {
681
693
 
682
694
  // Activate with restored state if taskId exists
683
695
  if (parsed.taskId) {
684
- const ctx = parsed.context;
685
696
  instance.activate({
686
- createQ: !!ctx,
697
+ createQ: !!msgsData,
687
698
  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,
699
+ tag: msgsData?.tag,
700
+ chat_history: msgsData?.chat_history,
701
+ functions: opt.functions || msgsData?.functions,
702
+ tool_digest: msgsData?.tool_digest,
692
703
  states: opt.states || [],
693
704
  ...opt,
694
705
  });
695
706
 
696
- // Restore raw msgs (from serialize/Redis) — takes priority over chat_history
697
- if (ctx?.msgs && instance.context) {
698
- instance.context._msgs = ctx.msgs;
699
- }
707
+ // Decompress chat_history into _msgs
708
+ if (instance.msgs)
709
+ await instance.msgs.initHistory();
700
710
  }
701
711
 
702
712
  return instance;
703
713
  }
704
714
 
705
715
  /**
706
- * Load a Saico instance from Store by id.
716
+ * Load a Saico instance from the registered backend by id.
707
717
  * @param {string} id - The Saico instance id
708
- * @param {Object} opt - Options (store, functions, states, etc.)
718
+ * @param {Object} opt - Options (store: table name, backend, functions, states, etc.)
709
719
  * @returns {Promise<Saico|null>}
710
720
  */
711
721
  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);
722
+ const backend = opt.backend || Saico.getBackend();
723
+ if (!backend)
724
+ throw new Error('No backend registered. Call Saico.registerBackend() first.');
725
+ const table = opt.store;
726
+ if (!table)
727
+ throw new Error('No table specified. Pass opt.store.');
728
+ const data = await backend.get('id', id, table);
716
729
  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;
730
+ return Saico.deserialize(data, opt);
722
731
  }
723
732
  }
724
733
 
725
- // [BACKEND] explanation text appended to context prompts
734
+ // ---- Static backend registration ----
735
+
736
+ Saico._backend = null;
737
+
738
+ /**
739
+ * Register a storage backend at library level (once, outside instance context).
740
+ * @param {string} type - Backend type ('dynamodb')
741
+ * @param {Object} config - Backend config (passed to adapter constructor)
742
+ */
743
+ Saico.registerBackend = function(type, config) {
744
+ if (type === 'dynamodb') {
745
+ const { DynamoDBAdapter } = require('./dynamo.js');
746
+ Saico._backend = new DynamoDBAdapter(config);
747
+ } else {
748
+ throw new Error('Unknown backend type: ' + type);
749
+ }
750
+ };
751
+
752
+ Saico.getBackend = function() {
753
+ return Saico._backend;
754
+ };
755
+
756
+ // [BACKEND] explanation text appended to msgs Q prompts
726
757
  Saico.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
727
758
  'server, not the user. They contain server instructions, data updates, or system context. ' +
728
759
  'Treat them as authoritative system-level information.';
package/store.js CHANGED
@@ -4,46 +4,10 @@ const crypto = require('crypto');
4
4
 
5
5
  let _instance = null;
6
6
 
7
- class DynamoBackend {
8
- constructor({ table, aws }) {
9
- this.table = table;
10
- this.aws = aws;
11
- }
12
-
13
- async save(id, data) {
14
- await this.aws.dynamoPutItem(this.table, {
15
- id,
16
- data: typeof data === 'string' ? data : JSON.stringify(data),
17
- updated_at: Date.now()
18
- });
19
- }
20
-
21
- async load(id) {
22
- const item = await this.aws.dynamoGetItem(this.table, 'id', id);
23
- if (!item)
24
- return null;
25
- const data = item.data;
26
- if (typeof data === 'string') {
27
- try { return JSON.parse(data); }
28
- catch (e) { return data; }
29
- }
30
- return data;
31
- }
32
-
33
- async delete(id) {
34
- await this.aws.dynamoDeleteItem(this.table, 'id', id);
35
- }
36
- }
37
-
38
7
  class Store {
39
8
  constructor(config = {}) {
40
9
  this._redis = null;
41
- this._backends = {};
42
10
  this._config = config;
43
-
44
- if (config.dynamodb) {
45
- this._backends.dynamodb = new DynamoBackend(config.dynamodb);
46
- }
47
11
  }
48
12
 
49
13
  static get instance() {
@@ -56,7 +20,6 @@ class Store {
56
20
 
57
21
  static init(config = {}) {
58
22
  _instance = new Store(config);
59
- // If redis module provided or redis is already initialized, grab the client
60
23
  const redis = require('./redis.js');
61
24
  if (redis.rclient)
62
25
  _instance._redis = redis.rclient;
@@ -67,97 +30,9 @@ class Store {
67
30
  this._redis = rclient;
68
31
  }
69
32
 
70
- addBackend(name, backend) {
71
- this._backends[name] = backend;
72
- }
73
-
74
33
  generateId() {
75
34
  return crypto.randomBytes(8).toString('hex');
76
35
  }
77
-
78
- async save(id, data) {
79
- const key = 'saico:' + id;
80
- const serialized = typeof data === 'string' ? data : JSON.stringify(data);
81
-
82
- // Always save to Redis if available
83
- if (this._redis) {
84
- try {
85
- await this._redis.set(key, serialized);
86
- } catch (e) {
87
- console.error('Store: Redis save error:', e.message);
88
- }
89
- }
90
-
91
- // Save to all configured backends
92
- for (const [name, backend] of Object.entries(this._backends)) {
93
- try {
94
- await backend.save(id, data);
95
- } catch (e) {
96
- console.error(`Store: ${name} backend save error:`, e.message);
97
- }
98
- }
99
- }
100
-
101
- async load(id) {
102
- const key = 'saico:' + id;
103
-
104
- // Try Redis first
105
- if (this._redis) {
106
- try {
107
- const cached = await this._redis.get(key);
108
- if (cached) {
109
- try { return JSON.parse(cached); }
110
- catch (e) { return cached; }
111
- }
112
- } catch (e) {
113
- console.error('Store: Redis load error:', e.message);
114
- }
115
- }
116
-
117
- // Fall back to backends
118
- for (const [name, backend] of Object.entries(this._backends)) {
119
- try {
120
- const data = await backend.load(id);
121
- if (data) {
122
- // Cache to Redis for next time
123
- if (this._redis) {
124
- try {
125
- const serialized = typeof data === 'string'
126
- ? data : JSON.stringify(data);
127
- await this._redis.set(key, serialized);
128
- } catch (e) {
129
- console.error('Store: Redis cache-back error:', e.message);
130
- }
131
- }
132
- return data;
133
- }
134
- } catch (e) {
135
- console.error(`Store: ${name} backend load error:`, e.message);
136
- }
137
- }
138
-
139
- return null;
140
- }
141
-
142
- async delete(id) {
143
- const key = 'saico:' + id;
144
-
145
- if (this._redis) {
146
- try {
147
- await this._redis.del(key);
148
- } catch (e) {
149
- console.error('Store: Redis delete error:', e.message);
150
- }
151
- }
152
-
153
- for (const [name, backend] of Object.entries(this._backends)) {
154
- try {
155
- await backend.delete(id);
156
- } catch (e) {
157
- console.error(`Store: ${name} backend delete error:`, e.message);
158
- }
159
- }
160
- }
161
36
  }
162
37
 
163
- module.exports = { Store, DynamoBackend };
38
+ module.exports = { Store };