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/saico.js CHANGED
@@ -2,8 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const Itask = require('./itask.js');
5
- const { Context } = require('./msgs.js');
6
- const { Store } = require('./store.js');
5
+ const { Msgs } = require('./msgs.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,25 +36,25 @@ 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
  */
48
47
  constructor(opt = {}) {
49
48
  // Internal properties (underscore-prefixed, not persisted to Redis)
50
- this._id = opt.id || crypto.randomBytes(8).toString('hex');
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';
@@ -91,14 +90,14 @@ class Saico {
91
90
  try {
92
91
  const redis = require('./redis.js');
93
92
  if (redis.rclient && opt.redis !== false) {
94
- const key = 'saico:' + (opt.key || this._id);
93
+ const key = 'saico:' + (opt.key || this.id);
95
94
  return redis.createObservableForRedis(key, this);
96
95
  }
97
96
  } catch (e) { /* redis not available */ }
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,14 +160,20 @@ 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 context = new Context(augmentedPrompt, this._task, contextConfig);
163
- this.setContext(context);
169
+ const msgs = new Msgs(augmentedPrompt, msgsConfig);
170
+ this.msgs = msgs;
171
+ this.msgs_id = makeId(16);
172
+ msgs.tag = this.msgs_id;
173
+
174
+ // Wire callbacks for hierarchy access
175
+ msgs._findToolImpl = (toolName) => this._findToolImpl(toolName);
176
+ msgs._getSnapshot = () => msgs._snapshotPublicProps(this);
164
177
  }
165
178
 
166
179
  return this;
@@ -169,86 +182,63 @@ class Saico {
169
182
  // ---- Context management (owned by Saico, not Itask) ----
170
183
 
171
184
  /**
172
- * Set context on this Saico instance.
173
- * Generates context_id, sets context.tag, and calls context.setTask().
185
+ * Find the nearest msgs Q walking UP the Saico/task hierarchy.
174
186
  */
175
- setContext(context) {
176
- this.context = context;
177
- // Generate context_id if not already set
178
- if (!this.context_id) {
179
- if (this._store)
180
- this.context_id = this._store.generateId();
181
- else if (Store.instance)
182
- this.context_id = Store.instance.generateId();
183
- else
184
- this.context_id = makeId(16);
185
- }
186
- if (context) {
187
- context.tag = this.context_id;
188
- if (typeof context.setTask === 'function')
189
- context.setTask(this._task);
190
- }
191
- return this;
192
- }
193
-
194
- /**
195
- * Find the nearest context walking UP the Saico/task hierarchy.
196
- */
197
- findContext() {
198
- if (this.context) return this.context;
187
+ findMsgs() {
188
+ if (this.msgs) return this.msgs;
199
189
  let task = this._task?.parent;
200
190
  while (task) {
201
- if (task._saico?.context) return task._saico.context;
191
+ if (task._saico?.msgs) return task._saico.msgs;
202
192
  task = task.parent;
203
193
  }
204
194
  return null;
205
195
  }
206
196
 
207
197
  /**
208
- * Walk DOWN to find the deepest active descendant with a context.
198
+ * Walk DOWN to find the deepest active descendant with a msgs Q.
209
199
  */
210
- findDeepestContext() {
211
- if (!this._task) return this.context || null;
212
- 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;
213
203
  const search = (task, depth) => {
214
204
  for (const child of task.child) {
215
205
  if (child._completed) continue;
216
- if (child._saico?.context) {
206
+ if (child._saico?.msgs) {
217
207
  if (!deepest || depth + 1 >= deepest.depth)
218
- deepest = { context: child._saico.context, depth: depth + 1 };
208
+ deepest = { msgs: child._saico.msgs, depth: depth + 1 };
219
209
  }
220
210
  search(child, depth + 1);
221
211
  }
222
212
  };
223
213
  search(this._task, 0);
224
- return deepest ? deepest.context : null;
214
+ return deepest ? deepest.msgs : null;
225
215
  }
226
216
 
227
217
  /**
228
- * Deactivate — bubble cleaned messages to parent, close context, cancel task.
218
+ * Deactivate — bubble cleaned messages to parent, close msgs Q, cancel task.
229
219
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
230
- * then closes the context without the default summary bubbling.
220
+ * then closes the msgs Q without the default summary bubbling.
231
221
  */
232
222
  async deactivate() {
233
223
  if (!this._task) return;
234
- if (this.context) {
235
- // Find parent context to bubble cleaned messages
224
+ if (this.msgs) {
225
+ // Find parent msgs to bubble cleaned messages
236
226
  let parentTask = this._task.parent;
237
- let parentCtx = null;
227
+ let parentMsgs = null;
238
228
  while (parentTask) {
239
- if (parentTask._saico?.context) { parentCtx = parentTask._saico.context; break; }
229
+ if (parentTask._saico?.msgs) { parentMsgs = parentTask._saico.msgs; break; }
240
230
  parentTask = parentTask.parent;
241
231
  }
242
- if (parentCtx) {
232
+ if (parentMsgs) {
243
233
  const cleaned = this.getRecentMessages(Infinity);
244
234
  for (const msg of cleaned)
245
- parentCtx.push(msg);
235
+ parentMsgs.push(msg);
246
236
  }
247
- // Clean tool calls and close context without additional summary bubbling.
248
- if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
249
- this.context.cleanToolCallsByTag(this.context_id);
250
- this.context = null;
251
- 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;
252
242
  }
253
243
  this._task._ecancel();
254
244
  this._task = null;
@@ -293,7 +283,7 @@ class Saico {
293
283
  */
294
284
  _getSaicoAncestors() {
295
285
  const chain = [this];
296
- if (this._isolate) return chain;
286
+ if (this.isolate) return chain;
297
287
  let task = this._task?.parent;
298
288
  while (task) {
299
289
  if (task._saico) {
@@ -307,7 +297,7 @@ class Saico {
307
297
 
308
298
  /**
309
299
  * Build preamble and aggregated functions by walking the Saico chain.
310
- * @param {Context} activeCtx - The deepest active context (for state summary logic)
300
+ * @param {Msgs} activeCtx - The deepest active msgs Q (for state summary logic)
311
301
  * @returns {{ preamble: Array, allFunctions: Array }}
312
302
  */
313
303
  _buildPreamble(activeCtx) {
@@ -334,8 +324,8 @@ class Saico {
334
324
  }
335
325
 
336
326
  // Tools digest
337
- if (saico.context?.tool_digest?.length > 0) {
338
- 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 =>
339
329
  `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
340
330
  ).join('\n');
341
331
  preamble.push({ role: 'system', content: '[Tool Activity Log]\n' + digestText });
@@ -354,20 +344,20 @@ class Saico {
354
344
  if (!this._task)
355
345
  throw new Error('Not activated. Call activate() first.');
356
346
 
357
- // Find the active context (own or walk up)
358
- let ctx = this.findContext();
347
+ // Find the active msgs Q (own or walk up)
348
+ let ctx = this.findMsgs();
359
349
  if (!ctx)
360
- throw new Error('No context available');
350
+ throw new Error('No msgs Q available');
361
351
 
362
352
  // Build preamble by walking Saico chain
363
- const activeCtx = this.findDeepestContext() || ctx;
353
+ const activeCtx = this.findDeepestMsgs() || ctx;
364
354
  const { preamble, allFunctions } = this._buildPreamble(activeCtx);
365
355
 
366
356
  // Merge with call-specific functions
367
357
  if (functions) allFunctions.push(...(Array.isArray(functions) ? functions : [functions]));
368
358
 
369
359
  opts = Object.assign({}, opts, {
370
- tag: this.context_id,
360
+ tag: this.msgs_id,
371
361
  _preamble: preamble,
372
362
  _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
373
363
  });
@@ -379,9 +369,9 @@ class Saico {
379
369
  throw new Error('Not activated. Call activate() first.');
380
370
 
381
371
  // Route DOWN to deepest descendant with a msg Q
382
- const ctx = this.findDeepestContext();
372
+ const ctx = this.findDeepestMsgs();
383
373
  if (!ctx)
384
- throw new Error('No context available');
374
+ throw new Error('No msgs Q available');
385
375
 
386
376
  // Build preamble by walking Saico chain
387
377
  const { preamble, allFunctions } = this._buildPreamble(ctx);
@@ -413,8 +403,8 @@ class Saico {
413
403
  * @returns {Array<{role: string, content: string}>}
414
404
  */
415
405
  getRecentMessages(n = 5) {
416
- if (!this.context) return [];
417
- return this.context._msgs
406
+ if (!this.msgs) return [];
407
+ return this.msgs._msgs
418
408
  .filter(m => {
419
409
  if (m.msg.role === 'tool' || m.msg.tool_calls) return false;
420
410
  if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
@@ -426,8 +416,8 @@ class Saico {
426
416
 
427
417
  /**
428
418
  * Internal state summary builder. Includes own getStateSummary() and,
429
- * if this context is NOT the active (deepest) Q, includes recent messages.
430
- * @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
431
421
  * @returns {Array|string|null}
432
422
  */
433
423
  _getStateSummary(activeCtx) {
@@ -435,8 +425,8 @@ class Saico {
435
425
  const own = this.getStateSummary();
436
426
  if (own) parts.push(own);
437
427
 
438
- // If this context is NOT the active (deepest) Q, include recent messages
439
- 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) {
440
430
  const recent = this.getRecentMessages(5);
441
431
  if (recent.length > 0) parts.push(...recent);
442
432
  }
@@ -444,6 +434,41 @@ class Saico {
444
434
  return parts.length > 0 ? parts : null;
445
435
  }
446
436
 
437
+ // ---- Tool implementation search ----
438
+
439
+ /**
440
+ * Search the Saico hierarchy for a TOOL_<toolName> method.
441
+ * Order: current task → walk UP parents → walk DOWN children (BFS).
442
+ */
443
+ _findToolImpl(toolName) {
444
+ const methodName = 'TOOL_' + toolName;
445
+ const check = (task) =>
446
+ task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
447
+
448
+ let found = check(this._task);
449
+ if (found) return { saico: found, methodName };
450
+
451
+ let t = this._task?.parent;
452
+ while (t) {
453
+ found = check(t);
454
+ if (found) return { saico: found, methodName };
455
+ t = t.parent;
456
+ }
457
+
458
+ if (this._task) {
459
+ const queue = [...this._task.child];
460
+ while (queue.length > 0) {
461
+ const child = queue.shift();
462
+ if (child._completed) continue;
463
+ found = check(child);
464
+ if (found) return { saico: found, methodName };
465
+ if (child.child?.size > 0) queue.push(...child.child);
466
+ }
467
+ }
468
+
469
+ return null;
470
+ }
471
+
447
472
  // ---- User Data (absorbed from Sid) ----
448
473
 
449
474
  setUserData(key, value) {
@@ -464,11 +489,11 @@ class Saico {
464
489
 
465
490
  getSessionInfo() {
466
491
  return {
467
- id: this._id,
492
+ id: this.id,
468
493
  name: this.name,
469
494
  running: this._task?.running || false,
470
495
  completed: this._task?._completed || false,
471
- messageCount: this.context?.length || 0,
496
+ messageCount: this.msgs?.length || 0,
472
497
  childCount: this._task?.child?.size || 0,
473
498
  userData: this.userData,
474
499
  uptime: Date.now() - this.tm_create,
@@ -476,35 +501,17 @@ class Saico {
476
501
  }
477
502
 
478
503
  /**
479
- * Close the session — compress msgs, save full state to Store, cancel task.
480
- * The saved object has the same shape as serialize() but with compressed
481
- * context messages (chat_history) instead of raw _msgs.
504
+ * Close the session — save state to registered backend, cancel task.
482
505
  */
483
506
  async closeSession() {
484
507
  if (!this._task) return;
485
508
 
486
- // Save full state to Store with compressed msgs
487
- const store = this._store || Store.instance;
488
- if (store && this.context) {
489
- const { chat_history, tool_digest } = await this.context.prepareForStorage();
490
- const data = {
491
- id: this._id,
492
- name: this.name,
493
- prompt: this.prompt,
494
- userData: this.userData,
495
- sessionConfig: this.sessionConfig,
496
- tm_create: this.tm_create,
497
- isolate: this._isolate,
498
- taskId: this._task.id,
499
- context_id: this.context_id,
500
- context: {
501
- tag: this.context.tag,
502
- chat_history,
503
- tool_digest,
504
- functions: this.context.functions,
505
- },
506
- };
507
- await store.save(this._id, data);
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
+ }
508
515
  }
509
516
 
510
517
  this._task._ecancel();
@@ -523,7 +530,8 @@ class Saico {
523
530
  if (task._saico?._db) return task._saico._db;
524
531
  task = task.parent;
525
532
  }
526
- 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.');
527
535
  }
528
536
 
529
537
  async dbPutItem(item, table) {
@@ -610,42 +618,64 @@ class Saico {
610
618
 
611
619
  // ---- Serialization ----
612
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
+
613
661
  /**
614
662
  * Serialize the Saico instance to a JSON string.
615
- * Context messages are included as raw _msgs (for Redis / in-memory use).
616
- * For durable storage with compressed msgs, use closeSession().
663
+ * Calls prepareForStorage() to build a clean snapshot, then JSON.stringify.
617
664
  */
618
- serialize() {
619
- const data = {
620
- id: this._id,
621
- name: this.name,
622
- prompt: this.prompt,
623
- userData: this.userData,
624
- sessionConfig: this.sessionConfig,
625
- tm_create: this.tm_create,
626
- isolate: this._isolate,
627
- };
628
- data.taskId = this._task?.id || null;
629
- data.context_id = this.context_id || null;
630
- data.context = this.context ? {
631
- tag: this.context.tag,
632
- msgs: this.context._msgs,
633
- functions: this.context.functions,
634
- tool_digest: this.context.tool_digest,
635
- } : null;
636
- return JSON.stringify(data);
665
+ async serialize() {
666
+ const prepared = await this.prepareForStorage();
667
+ return JSON.stringify(prepared);
637
668
  }
638
669
 
639
670
  /**
640
671
  * Restore a Saico instance from serialized data.
641
- * Supports both raw msgs (from serialize/Redis) and compressed
642
- * chat_history (from closeSession/Store).
643
672
  * @param {string|Object} data - Serialized data (JSON string or object)
644
673
  * @param {Object} opt - Options (functions, store, states, etc.)
645
- * @returns {Saico}
674
+ * @returns {Promise<Saico>}
646
675
  */
647
- static deserialize(data, opt = {}) {
676
+ static async deserialize(data, opt = {}) {
648
677
  const parsed = typeof data === 'string' ? JSON.parse(data) : data;
678
+ const msgsData = parsed.msgs;
649
679
 
650
680
  const instance = new Saico({
651
681
  id: parsed.id,
@@ -654,7 +684,7 @@ class Saico {
654
684
  userData: parsed.userData,
655
685
  sessionConfig: parsed.sessionConfig,
656
686
  isolate: parsed.isolate,
657
- functions: opt.functions || parsed.context?.functions,
687
+ functions: opt.functions || msgsData?.functions,
658
688
  store: opt.store,
659
689
  redis: false, // No Redis proxy during deserialization
660
690
  });
@@ -663,48 +693,67 @@ class Saico {
663
693
 
664
694
  // Activate with restored state if taskId exists
665
695
  if (parsed.taskId) {
666
- const ctx = parsed.context;
667
696
  instance.activate({
668
- createQ: !!ctx,
697
+ createQ: !!msgsData,
669
698
  taskId: parsed.taskId,
670
- tag: ctx?.tag,
671
- chat_history: ctx?.chat_history,
672
- functions: opt.functions || ctx?.functions,
673
- tool_digest: ctx?.tool_digest,
699
+ tag: msgsData?.tag,
700
+ chat_history: msgsData?.chat_history,
701
+ functions: opt.functions || msgsData?.functions,
702
+ tool_digest: msgsData?.tool_digest,
674
703
  states: opt.states || [],
675
704
  ...opt,
676
705
  });
677
706
 
678
- // Restore raw msgs (from serialize/Redis) — takes priority over chat_history
679
- if (ctx?.msgs && instance.context) {
680
- instance.context._msgs = ctx.msgs;
681
- }
707
+ // Decompress chat_history into _msgs
708
+ if (instance.msgs)
709
+ await instance.msgs.initHistory();
682
710
  }
683
711
 
684
712
  return instance;
685
713
  }
686
714
 
687
715
  /**
688
- * Load a Saico instance from Store by id.
716
+ * Load a Saico instance from the registered backend by id.
689
717
  * @param {string} id - The Saico instance id
690
- * @param {Object} opt - Options (store, functions, states, etc.)
718
+ * @param {Object} opt - Options (store: table name, backend, functions, states, etc.)
691
719
  * @returns {Promise<Saico|null>}
692
720
  */
693
721
  static async rehydrate(id, opt = {}) {
694
- const store = opt.store || Store.instance;
695
- if (!store)
696
- throw new Error('No store available for rehydrate');
697
- const data = await store.load(id);
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);
698
729
  if (!data) return null;
699
- const instance = Saico.deserialize(data, opt);
700
- // Decompress chat_history into _msgs if present
701
- if (instance.context)
702
- await instance.context.initHistory();
703
- return instance;
730
+ return Saico.deserialize(data, opt);
704
731
  }
705
732
  }
706
733
 
707
- // [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
708
757
  Saico.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
709
758
  'server, not the user. They contain server instructions, data updates, or system context. ' +
710
759
  'Treat them as authoritative system-level information.';