saico 2.6.1 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,14 +69,15 @@ Saico separates construction from activation:
69
69
  const agent = new Saico({
70
70
  name: 'agent',
71
71
  prompt: 'System prompt here',
72
+ createQ: true, // message Q created on activate()
72
73
  dynamodb: { region: 'us-east-1', credentials: { accessKeyId: 'AK', secretAccessKey: 'SK' } },
73
74
  });
74
75
 
75
76
  // DB methods work before activation
76
77
  const item = await agent.dbGetItem('id', '123');
77
78
 
78
- // 2. Activate — creates internal task + optional message queue context
79
- agent.activate({ createQ: true });
79
+ // 2. Activate — creates internal task + message Q (from this.createQ)
80
+ agent.activate();
80
81
 
81
82
  // 3. Use — send messages, spawn children
82
83
  await agent.sendMessage('Do something');
@@ -86,6 +87,23 @@ await agent.recvChatMessage('User says hello');
86
87
  await agent.deactivate();
87
88
  ```
88
89
 
90
+ Subclasses can also define `this.states` (task functions) in the constructor — `activate()` picks them up automatically:
91
+
92
+ ```js
93
+ class MyAgent extends Saico {
94
+ constructor() {
95
+ super({ name: 'agent', prompt: 'You are helpful', createQ: true });
96
+ this.states = [
97
+ async function main() {
98
+ return await this.sendMessage('Starting...');
99
+ }
100
+ ];
101
+ }
102
+ }
103
+ const agent = new MyAgent();
104
+ agent.activate(); // no params needed — uses this.createQ and this.states
105
+ ```
106
+
89
107
  ### Message Orchestration
90
108
 
91
109
  When `sendMessage()` or `recvChatMessage()` is called, Saico walks the parent chain to build the full LLM payload:
@@ -135,29 +153,32 @@ class OrderAgent extends Saico {
135
153
 
136
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.
137
155
 
138
- ### Spawning Child Tasks
156
+ ### Spawning Child Saico Instances
139
157
 
140
158
  ```js
141
159
  // Child with its own conversation context
142
- const child = agent.spawnTaskWithContext({
160
+ const child = new Saico({
143
161
  name: 'subtask',
144
162
  prompt: 'Handle this specific sub-task',
145
- functions: [/* child-specific tools */]
146
- }, [
147
- async function main() {
148
- return await this.sendMessage('Working on subtask...');
149
- }
150
- ]);
151
-
152
- // Child without context (uses parent's)
153
- const simple = agent.spawnTask({ name: 'simple' }, [
154
- async function main() {
155
- await this.sendMessage('Quick operation');
156
- }
157
- ]);
163
+ functions: [/* child-specific tools */],
164
+ });
165
+ child.activate({ createQ: true });
166
+ agent.spawn(child);
167
+ await child.sendMessage('Working on subtask...');
168
+
169
+ // Child without context (uses parent's via findContext())
170
+ const simple = new Saico({ name: 'simple' });
171
+ simple.activate();
172
+ agent.spawn(simple);
173
+ await simple.sendMessage('Quick operation');
174
+
175
+ // spawnAndRun: spawn + schedule child task to run on nextTick
176
+ const runner = new Saico({ name: 'runner' });
177
+ runner.activate({ states: [async function() { return await this.sendMessage('Go'); }] });
178
+ agent.spawnAndRun(runner);
158
179
  ```
159
180
 
160
- Child tasks inherit `sessionConfig` defaults (token_limit, max_depth, etc.) from the parent Saico.
181
+ Both parent and child must be activated before calling `spawn()` or `spawnAndRun()`.
161
182
 
162
183
  ### Deactivation and Message Bubbling
163
184
 
@@ -174,6 +195,7 @@ new Saico({
174
195
  // AI config
175
196
  prompt: 'System prompt',
176
197
  functions: [], // OpenAI function definitions
198
+ createQ: false, // Create message Q on activate() (also settable as this.createQ)
177
199
 
178
200
  // Behavior
179
201
  isolate: false, // Stop ancestor aggregation
@@ -204,10 +226,9 @@ new Saico({
204
226
 
205
227
  ```js
206
228
  agent.activate({
207
- createQ: true, // Create message queue context
229
+ createQ: true, // Override this.createQ for this activation
208
230
  prompt: 'Extra prompt', // Appended to class-level prompt
209
- states: [], // Task state functions
210
- parent: parentTask, // Parent task to spawn under
231
+ states: [], // Override this.states for this activation
211
232
  taskId: 'custom-id',
212
233
  sequential_mode: true, // Process messages sequentially
213
234
 
@@ -336,21 +357,13 @@ Return a string or `{ content: string, functions?: [] }`.
336
357
 
337
358
  ## Low-Level API
338
359
 
339
- For cases where you don't need the Saico master class:
360
+ For cases where you need a standalone context without the Saico master class:
340
361
 
341
362
  ```js
342
- const { createTask, createContext, createQ } = require('saico');
343
-
344
- // Create a task with context
345
- const task = createTask({
346
- name: 'my-task',
347
- prompt: 'You are helpful',
348
- functions: tools
349
- });
350
- const reply = await task.sendMessage('Hello');
363
+ const { createContext } = require('saico');
351
364
 
352
- // Standalone context (legacy)
353
- const ctx = createQ('System prompt', null, 'tag', 4000);
365
+ // Standalone context
366
+ const ctx = createContext('System prompt', null, { tag: 'my-tag', token_limit: 4000 });
354
367
  const reply = await ctx.sendMessage('user', 'Hello', functions);
355
368
  ```
356
369
 
@@ -358,11 +371,10 @@ const reply = await ctx.sendMessage('user', 'Hello', functions);
358
371
 
359
372
  ```
360
373
  saico/
361
- +-- index.js # Entry point, exports all components
362
- +-- saico.js # Saico master class
363
- +-- itask.js # Base task class (hierarchy, states, cancellation)
374
+ +-- index.js # Thin barrel file, exports all components
375
+ +-- saico.js # Saico master class — owns context, spawn, DB, orchestration
376
+ +-- itask.js # Pure task runner hierarchy, states, cancellation, promises
364
377
  +-- msgs.js # Conversation context (message queue, tool calls, summarization)
365
- +-- context.js # Backward-compat shim for msgs.js
366
378
  +-- dynamo.js # DynamoDB storage adapter
367
379
  +-- store.js # Storage abstraction (Redis + pluggable backends)
368
380
  +-- openai.js # OpenAI API wrapper with retry logic
@@ -376,7 +388,7 @@ saico/
376
388
  npm test
377
389
  ```
378
390
 
379
- 293 tests covering Saico lifecycle, task hierarchy, message handling, tool calls, DB adapters, serialization, and integration flows.
391
+ 284 tests covering Saico lifecycle, context ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, serialization, and integration flows.
380
392
 
381
393
  ## Requirements
382
394
 
package/index.js CHANGED
@@ -1,32 +1,11 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * Saico Library - Hierarchical AI Conversation Orchestrator
5
- *
6
- * Combines task hierarchy (Itask) with conversation contexts (Context) to create
7
- * a unified system for managing AI conversations with:
8
- * - Task-based organizational structure
9
- * - Optional conversation contexts attached to tasks
10
- * - Hierarchical message aggregation with function collection
11
- * - Full tool_calls support with depth control
12
- * - Storage persistence (Redis cache + optional DB backend)
13
- *
14
- * Main Components:
15
- * - Saico: Master class (external users extend this)
16
- * - Itask: Base task class for all tasks (supports states, cancellation, promises)
17
- * - Context: Conversation context with message handling and tool calls
18
- * - Store: Storage abstraction layer (Redis + optional backends like DynamoDB)
19
- */
20
-
21
3
  const Itask = require('./itask.js');
22
4
  const { Context, createContext } = require('./msgs.js');
23
5
  const { Store, DynamoBackend } = require('./store.js');
24
6
  const { Saico } = require('./saico.js');
25
7
  const { DynamoDBAdapter } = require('./dynamo.js');
26
8
 
27
- // Wire up Context class reference in Itask to avoid circular dependency
28
- Itask.Context = Context;
29
-
30
9
  /**
31
10
  * Initialize Saico with storage configuration.
32
11
  * Sets up the Store singleton and optionally initializes Redis.
@@ -48,89 +27,6 @@ async function init(config = {}) {
48
27
  return store;
49
28
  }
50
29
 
51
- /**
52
- * Create a new task with optional context.
53
- *
54
- * @param {Object|string} opt - Task options or name string
55
- * @param {string} opt.name - Task name
56
- * @param {string} opt.prompt - System prompt (if provided, creates a context)
57
- * @param {Array} opt.functions - Available functions for AI
58
- * @param {boolean} opt.cancel - Whether task is cancelable
59
- * @param {Object} opt.bind - Bind context for state functions
60
- * @param {Itask} opt.spawn_parent - Parent task to spawn under
61
- * @param {boolean} opt.async - If true, don't auto-run
62
- * @param {Object} opt.store - Store instance for persistence
63
- * @param {Array} states - Array of state functions
64
- * @returns {Itask} The created task
65
- */
66
- function createTask(opt, states = []) {
67
- if (typeof opt === 'string')
68
- opt = { name: opt };
69
-
70
- if (!opt.store)
71
- opt.store = Store.instance;
72
-
73
- const task = new Itask(opt, states);
74
-
75
- // Auto-create context if prompt is provided
76
- if (opt.prompt) {
77
- const context = new Context(opt.prompt, task, {
78
- tag: opt.tag || task.id,
79
- token_limit: opt.token_limit,
80
- max_depth: opt.max_depth,
81
- max_tool_repetition: opt.max_tool_repetition,
82
- functions: opt.functions,
83
- sequential_mode: opt.sequential_mode
84
- });
85
- task.setContext(context);
86
- }
87
-
88
- return task;
89
- }
90
-
91
- /**
92
- * Legacy createQ function for backward compatibility.
93
- * Creates a standalone Context (not attached to a task).
94
- *
95
- * @param {string} prompt - System prompt
96
- * @param {Context} parent - Parent context (legacy, will be converted to task-based)
97
- * @param {string} tag - Context tag identifier
98
- * @param {number} token_limit - Token limit for summarization
99
- * @param {Array} msgs - Initial messages
100
- * @param {Object} config - Additional configuration
101
- * @returns {Context} Proxied Context instance
102
- */
103
- function createQ(prompt, parent, tag, token_limit, msgs, config = {}) {
104
- // For backward compatibility, if parent is a Context, get its task
105
- let task = null;
106
- if (parent && parent.task) {
107
- task = parent.task;
108
- }
109
-
110
- const context = createContext(prompt, task, {
111
- tag,
112
- token_limit,
113
- msgs,
114
- ...config
115
- });
116
-
117
- // If there's a parent context, set up the relationship via tasks
118
- if (parent && parent.task) {
119
- // Create a child task to hold this context
120
- const childTask = new Itask({
121
- name: tag || 'child-context',
122
- async: true,
123
- spawn_parent: parent.task,
124
- store: Store.instance
125
- }, []);
126
- context.setTask(childTask);
127
- childTask.setContext(context);
128
- }
129
-
130
- return context;
131
- }
132
-
133
- // Export all components
134
30
  module.exports = {
135
31
  // Master class (external users extend this)
136
32
  Saico,
@@ -145,13 +41,9 @@ module.exports = {
145
41
  // Initialization
146
42
  init,
147
43
 
148
- // Factory functions
149
- createTask,
44
+ // Factory
150
45
  createContext,
151
46
 
152
- // Legacy compatibility
153
- createQ,
154
-
155
47
  // Utilities (re-export from util.js)
156
48
  util: require('./util.js'),
157
49
 
@@ -159,5 +51,5 @@ module.exports = {
159
51
  openai: require('./openai.js'),
160
52
 
161
53
  // Redis persistence (re-export)
162
- redis: require('./redis.js')
54
+ redis: require('./redis.js'),
163
55
  };
package/itask.js CHANGED
@@ -14,13 +14,11 @@
14
14
 
15
15
  'use strict';
16
16
 
17
- const assert = require('assert');
18
17
  const EventEmitter = require('events');
19
18
  const crypto = require('crypto');
20
19
  const util = require('./util.js');
21
- const { Store } = require('./store.js');
22
20
 
23
- const { _log, lerr , _ldbg, daysSince, minSince, shallowEqual, filterArray, logEvent } = util;
21
+ const { _log, lerr , _ldbg } = util;
24
22
 
25
23
  /* ---------- utility ---------- */
26
24
  function makeId(len = 12){
@@ -99,26 +97,9 @@ function Itask(opt, states){
99
97
  this._cancel_state_idx = this.states.findIndex(s => s.cancel);
100
98
  this._root_registered = false;
101
99
 
102
- // Context support - optional conversation context attached to this task
103
- this.context = null;
104
- this._contextConfig = opt.contextConfig || {};
105
-
106
- // Storage persistence
107
- this.context_id = opt.context_id || null;
108
- this._store = opt.store || Store.instance || null;
109
-
110
- // Store options for context creation (prompt, functions, etc.)
111
- this.prompt = opt.prompt;
112
- this.functions = opt.functions;
113
-
114
- // register root if no explicit spawn_parent provided
115
- // If opt.spawn_parent provided, spawn under it
116
- if (opt.spawn_parent && opt.spawn_parent instanceof Itask){
117
- opt.spawn_parent.spawn(this);
118
- } else {
119
- Itask.root.add(this);
120
- this._root_registered = true;
121
- }
100
+ // All tasks register as root; parent set via itask.spawn()
101
+ Itask.root.add(this);
102
+ this._root_registered = true;
122
103
 
123
104
  // async option defers immediate run
124
105
  if (!opt.async){
@@ -376,15 +357,6 @@ Itask.prototype.spawn = function spawn(child){
376
357
  Itask.root.delete(child);
377
358
  child._root_registered = false;
378
359
  }
379
- // Auto-wrap with redis observable for live state persistence
380
- if (child.context_id) {
381
- try {
382
- const redis = require('./redis.js');
383
- if (redis.rclient) {
384
- redis.createObservableForRedis('saico:' + child.context_id, child);
385
- }
386
- } catch (e) { /* redis not available */ }
387
- }
388
360
  // ensure async-created children begin execution
389
361
  if (!child.running && !child._completed){
390
362
  process.nextTick(() => {
@@ -594,181 +566,6 @@ Itask.ps = function ps(){
594
566
  return out || '<no roots>';
595
567
  };
596
568
 
597
- /* ---------- context management ---------- */
598
- // [BACKEND] explanation text appended to context prompts
599
- Itask.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
600
- 'server, not the user. They contain server instructions, data updates, or system context. ' +
601
- 'Treat them as authoritative system-level information.';
602
-
603
- // Get the context for this task, optionally creating one if needed
604
- Itask.prototype.getContext = function getContext(createIfMissing = false){
605
- if (this.context)
606
- return this.context;
607
- if (createIfMissing && this.prompt){
608
- // Lazy context creation - requires Context class to be set
609
- if (Itask.Context){
610
- const augmentedPrompt = this.prompt + Itask.BACKEND_EXPLANATION;
611
- this.context = new Itask.Context(augmentedPrompt, this, this._contextConfig);
612
- this.setContext(this.context);
613
- return this.context;
614
- }
615
- }
616
- return null;
617
- };
618
-
619
- // Set context for this task
620
- Itask.prototype.setContext = function setContext(context){
621
- this.context = context;
622
- // Generate context_id if not already set
623
- if (!this.context_id) {
624
- if (this._store)
625
- this.context_id = this._store.generateId();
626
- else if (Store.instance)
627
- this.context_id = Store.instance.generateId();
628
- else
629
- this.context_id = makeId(16);
630
- }
631
- if (context) {
632
- context.tag = this.context_id;
633
- if (typeof context.setTask === 'function')
634
- context.setTask(this);
635
- }
636
- return this;
637
- };
638
-
639
- // Get all ancestor contexts (walking up the task hierarchy)
640
- Itask.prototype.getAncestorContexts = function getAncestorContexts(){
641
- const contexts = [];
642
- let task = this;
643
- while (task){
644
- if (task.context)
645
- contexts.unshift(task.context); // Add to front so ancestors come first
646
- task = task.parent;
647
- }
648
- return contexts;
649
- };
650
-
651
- // Find the nearest context in the hierarchy (this task or ancestors)
652
- Itask.prototype.findContext = function findContext(){
653
- let task = this;
654
- while (task){
655
- if (task.context)
656
- return task.context;
657
- task = task.parent;
658
- }
659
- return null;
660
- };
661
-
662
- // Send a backend message using the context hierarchy
663
- // New signature: sendMessage(content, functions, opts)
664
- // Always sends as role='user' with '[BACKEND] ' prefix
665
- Itask.prototype.sendMessage = async function sendMessage(content, functions, opts){
666
- // First try our own context
667
- let ctx = this.getContext();
668
- if (!ctx){
669
- // Walk up to find a context
670
- ctx = this.findContext();
671
- }
672
- if (!ctx){
673
- throw new Error('No context available in task hierarchy to send message');
674
- }
675
- opts = Object.assign({}, opts, { tag: this.context_id });
676
- return ctx.sendMessage('user', '[BACKEND] ' + content, functions, opts);
677
- };
678
-
679
- // Receive a user chat message (no [BACKEND] prefix)
680
- Itask.prototype.recvChatMessage = async function recvChatMessage(content, opts){
681
- let ctx = this.getContext();
682
- if (!ctx){
683
- ctx = this.findContext();
684
- }
685
- if (!ctx){
686
- throw new Error('No context available in task hierarchy to receive message');
687
- }
688
- opts = Object.assign({}, opts, { tag: this.context_id });
689
- return ctx.sendMessage('user', content, null, opts);
690
- };
691
-
692
- // Aggregate functions from all contexts in the hierarchy
693
- Itask.prototype.getHierarchyFunctions = function getHierarchyFunctions(){
694
- const allFunctions = [];
695
- const contexts = this.getAncestorContexts();
696
- for (const ctx of contexts){
697
- if (ctx.functions && Array.isArray(ctx.functions))
698
- allFunctions.push(...ctx.functions);
699
- }
700
- // Add this task's own functions if not already in a context
701
- if (this.functions && !this.context)
702
- allFunctions.push(...this.functions);
703
- return allFunctions;
704
- };
705
-
706
- // Close this task's context (if any) and bubble summary to parent
707
- Itask.prototype.closeContext = async function closeContext(){
708
- if (!this.context)
709
- return;
710
-
711
- // Clean tool call messages tagged with this context_id
712
- if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
713
- this.context.cleanToolCallsByTag(this.context_id);
714
-
715
- // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
716
- const cleanedMsgs = this.context._msgs.filter(m => {
717
- if (m.msg.tool_calls)
718
- return false;
719
- if (m.msg.role === 'tool')
720
- return false;
721
- if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]'))
722
- return false;
723
- return true;
724
- }).map(m => m.msg);
725
-
726
- // Trim to last QUEUE_LIMIT before persisting
727
- const queueLimit = this.context.QUEUE_LIMIT || 30;
728
- const trimmedMsgs = cleanedMsgs.length > queueLimit
729
- ? cleanedMsgs.slice(-queueLimit)
730
- : cleanedMsgs;
731
-
732
- if (trimmedMsgs.length > 0) {
733
- const chat_history = await util.compressMessages(trimmedMsgs);
734
- this.context.chat_history = chat_history;
735
-
736
- // Persist to store
737
- const store = this._store || Store.instance;
738
- if (store && this.context_id) {
739
- await store.save(this.context_id, {
740
- chat_history,
741
- tool_digest: this.context.tool_digest || [],
742
- prompt: this.context.prompt,
743
- tag: this.context.tag,
744
- tm_closed: Date.now()
745
- });
746
- }
747
- }
748
-
749
- await this.context.close();
750
- };
751
-
752
- // Walk DOWN to find the deepest active descendant with a context
753
- Itask.prototype.findDeepestContext = function findDeepestContext() {
754
- let deepest = this.context ? { context: this.context, depth: 0 } : null;
755
- const search = (task, depth) => {
756
- for (const child of task.child) {
757
- if (child._completed) continue;
758
- if (child.context) {
759
- if (!deepest || depth + 1 >= deepest.depth)
760
- deepest = { context: child.context, depth: depth + 1 };
761
- }
762
- search(child, depth + 1);
763
- }
764
- };
765
- search(this, 0);
766
- return deepest ? deepest.context : null;
767
- };
768
-
769
- // Reference to Context class (set by index.js to avoid circular dependency)
770
- Itask.Context = null;
771
-
772
569
  /* ---------- export ---------- */
773
570
  module.exports = Itask;
774
571
 
package/msgs.js CHANGED
@@ -92,18 +92,30 @@ class Context {
92
92
  this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
93
93
  }
94
94
 
95
- // Get the parent context by traversing task hierarchy
95
+ // Get the parent context by traversing task hierarchy (via Saico)
96
96
  getParentContext() {
97
97
  if (!this.task || !this.task.parent)
98
98
  return null;
99
- return this.task.parent.findContext ? this.task.parent.findContext() : null;
99
+ let task = this.task.parent;
100
+ while (task) {
101
+ if (task._saico?.context) return task._saico.context;
102
+ task = task.parent;
103
+ }
104
+ return null;
100
105
  }
101
106
 
102
- // Get all ancestor contexts via task hierarchy
107
+ // Get all ancestor contexts via task hierarchy (via Saico)
103
108
  getAncestorContexts() {
104
109
  if (!this.task)
105
110
  return [];
106
- return this.task.getAncestorContexts().filter(ctx => ctx !== this);
111
+ const contexts = [];
112
+ let task = this.task.parent;
113
+ while (task) {
114
+ if (task._saico?.context)
115
+ contexts.unshift(task._saico.context);
116
+ task = task.parent;
117
+ }
118
+ return contexts;
107
119
  }
108
120
 
109
121
  _hasPendingToolCalls() {
@@ -1145,7 +1157,7 @@ class Context {
1145
1157
  // Spawn child context (creates a child task with its own context)
1146
1158
  spawnChild(prompt, tag, config = {}) {
1147
1159
  if (!this.task) {
1148
- // If no task, create a standalone context (legacy mode)
1160
+ // If no task, create a standalone context
1149
1161
  return createContext(prompt, null, { ...config, tag });
1150
1162
  }
1151
1163
 
@@ -1153,14 +1165,16 @@ class Context {
1153
1165
  const Itask = require('./itask.js');
1154
1166
  const childTask = new Itask({
1155
1167
  name: tag || 'child-context',
1156
- prompt,
1157
1168
  async: true,
1158
- spawn_parent: this.task,
1159
- contextConfig: config
1160
1169
  }, []);
1170
+ this.task.spawn(childTask);
1161
1171
 
1162
1172
  const childContext = new Context(prompt, childTask, { ...config, tag });
1163
- childTask.setContext(childContext);
1173
+ // Store context on Saico if present, otherwise just set on task reference
1174
+ if (childTask._saico) {
1175
+ childTask._saico.context = childContext;
1176
+ childTask._saico.context_id = childContext.tag;
1177
+ }
1164
1178
 
1165
1179
  return childContext;
1166
1180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
@@ -15,7 +15,6 @@
15
15
  "files": [
16
16
  "index.js",
17
17
  "itask.js",
18
- "context.js",
19
18
  "msgs.js",
20
19
  "saico.js",
21
20
  "dynamo.js",
package/saico.js CHANGED
@@ -4,6 +4,11 @@ const crypto = require('crypto');
4
4
  const Itask = require('./itask.js');
5
5
  const { Context } = require('./msgs.js');
6
6
  const { Store } = require('./store.js');
7
+ const util = require('./util.js');
8
+
9
+ function makeId(len = 12){
10
+ return crypto.randomBytes(Math.ceil(len/2)).toString('hex').slice(0, len);
11
+ }
7
12
 
8
13
  /**
9
14
  * Saico — Master class for building AI-powered services.
@@ -32,6 +37,7 @@ class Saico {
32
37
  * @param {Array} [opt.functions] - Available AI functions
33
38
  * @param {string} [opt.key] - Redis key override (default: 'saico:<id>')
34
39
  * @param {boolean} [opt.redis=true] - Set false to skip Redis proxy
40
+ * @param {boolean} [opt.createQ] - Create message Q context on activate()
35
41
  * @param {boolean} [opt.isolate] - Isolate: don't aggregate from ancestors
36
42
  * @param {Object} [opt.dynamodb] - DynamoDB config { region, credentials: { accessKeyId, secretAccessKey }, client }
37
43
  * @param {Object} [opt.db] - Pluggable DB backend
@@ -47,10 +53,15 @@ class Saico {
47
53
  this._opt = opt;
48
54
  this._isolate = opt.isolate || false;
49
55
 
56
+ // Context owned directly by Saico (not Itask)
57
+ this.context = null;
58
+ this.context_id = null;
59
+
50
60
  // Public configuration
51
61
  this.name = opt.name || this.constructor.name || 'saico';
52
62
  this.prompt = opt.prompt || '';
53
63
  this.functions = opt.functions || null;
64
+ this.createQ = opt.createQ || false;
54
65
 
55
66
  // Absorbed from Sid
56
67
  this.userData = opt.userData || {};
@@ -90,11 +101,10 @@ class Saico {
90
101
  * Create the internal Itask and optionally a message Q context.
91
102
  *
92
103
  * @param {Object} opts
93
- * @param {boolean} [opts.createQ] - If true, attach a message Q (Context)
104
+ * @param {boolean} [opts.createQ] - Override this.createQ for this activation
94
105
  * @param {string} [opts.prompt] - Additional prompt (appended to class-level)
95
106
  * @param {Array} [opts.functions] - Override functions
96
- * @param {Array} [opts.states] - Task state functions
97
- * @param {Itask} [opts.parent] - Parent task to spawn under
107
+ * @param {Array} [opts.states] - Override this.states for this activation
98
108
  * @param {string} [opts.taskId] - Custom task ID
99
109
  * @param {number} [opts.token_limit] - Token limit for context
100
110
  * @param {number} [opts.max_depth] - Max tool call depth
@@ -111,7 +121,7 @@ class Saico {
111
121
  if (this._task)
112
122
  throw new Error('Already activated. Call deactivate() first.');
113
123
 
114
- const states = opts.states || [];
124
+ const states = opts.states || this.states || [];
115
125
 
116
126
  // Build effective prompt: class-level + activation-level
117
127
  const effectivePrompt = [this.prompt, opts.prompt].filter(Boolean).join('\n');
@@ -120,21 +130,17 @@ class Saico {
120
130
  name: this.name,
121
131
  id: opts.taskId,
122
132
  async: true,
123
- store: this._store,
124
- functions: opts.functions || this.functions,
125
133
  bind: this, // State functions run with Saico instance as `this`
126
134
  };
127
135
 
128
- if (opts.parent)
129
- taskOpt.spawn_parent = opts.parent;
130
-
131
136
  this._task = new Itask(taskOpt, states);
132
137
 
133
138
  // Store Saico reference on task for parent chain traversal
134
139
  this._task._saico = this;
135
140
 
136
- // Create message Q context if requested (only via createQ flag, NOT prompt)
137
- if (opts.createQ) {
141
+ // Create message Q context if requested (class-level or activate-level)
142
+ if (opts.createQ ?? this.createQ) {
143
+ const functions = opts.functions || this.functions;
138
144
  const contextConfig = {
139
145
  tag: opts.tag || this._task.id,
140
146
  token_limit: opts.token_limit ?? this.sessionConfig.token_limit,
@@ -142,7 +148,7 @@ class Saico {
142
148
  max_tool_repetition: opts.max_tool_repetition ?? this.sessionConfig.max_tool_repetition,
143
149
  queue_limit: opts.queue_limit ?? this.sessionConfig.queue_limit,
144
150
  min_chat_messages: opts.min_chat_messages ?? this.sessionConfig.min_chat_messages,
145
- functions: taskOpt.functions,
151
+ functions,
146
152
  sequential_mode: opts.sequential_mode,
147
153
  msgs: opts.msgs,
148
154
  chat_history: opts.chat_history,
@@ -150,15 +156,118 @@ class Saico {
150
156
  };
151
157
 
152
158
  const augmentedPrompt = effectivePrompt
153
- ? effectivePrompt + Itask.BACKEND_EXPLANATION
159
+ ? effectivePrompt + Saico.BACKEND_EXPLANATION
154
160
  : '';
155
161
  const context = new Context(augmentedPrompt, this._task, contextConfig);
156
- this._task.setContext(context);
162
+ this.setContext(context);
157
163
  }
158
164
 
159
165
  return this;
160
166
  }
161
167
 
168
+ // ---- Context management (owned by Saico, not Itask) ----
169
+
170
+ /**
171
+ * Set context on this Saico instance.
172
+ * Generates context_id, sets context.tag, and calls context.setTask().
173
+ */
174
+ setContext(context) {
175
+ this.context = context;
176
+ // Generate context_id if not already set
177
+ if (!this.context_id) {
178
+ if (this._store)
179
+ this.context_id = this._store.generateId();
180
+ else if (Store.instance)
181
+ this.context_id = Store.instance.generateId();
182
+ else
183
+ this.context_id = makeId(16);
184
+ }
185
+ if (context) {
186
+ context.tag = this.context_id;
187
+ if (typeof context.setTask === 'function')
188
+ context.setTask(this._task);
189
+ }
190
+ return this;
191
+ }
192
+
193
+ /**
194
+ * Find the nearest context walking UP the Saico/task hierarchy.
195
+ */
196
+ findContext() {
197
+ if (this.context) return this.context;
198
+ let task = this._task?.parent;
199
+ while (task) {
200
+ if (task._saico?.context) return task._saico.context;
201
+ task = task.parent;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Walk DOWN to find the deepest active descendant with a context.
208
+ */
209
+ findDeepestContext() {
210
+ if (!this._task) return this.context || null;
211
+ let deepest = this.context ? { context: this.context, depth: 0 } : null;
212
+ const search = (task, depth) => {
213
+ for (const child of task.child) {
214
+ if (child._completed) continue;
215
+ if (child._saico?.context) {
216
+ if (!deepest || depth + 1 >= deepest.depth)
217
+ deepest = { context: child._saico.context, depth: depth + 1 };
218
+ }
219
+ search(child, depth + 1);
220
+ }
221
+ };
222
+ search(this._task, 0);
223
+ return deepest ? deepest.context : null;
224
+ }
225
+
226
+ /**
227
+ * Close this Saico's context and bubble summary to parent.
228
+ */
229
+ async closeContext() {
230
+ if (!this.context)
231
+ return;
232
+
233
+ // Clean tool call messages tagged with this context_id
234
+ if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
235
+ this.context.cleanToolCallsByTag(this.context_id);
236
+
237
+ // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
238
+ const cleanedMsgs = this.context._msgs.filter(m => {
239
+ if (m.msg.tool_calls) return false;
240
+ if (m.msg.role === 'tool') return false;
241
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
242
+ return true;
243
+ }).map(m => m.msg);
244
+
245
+ // Trim to last QUEUE_LIMIT before persisting
246
+ const queueLimit = this.context.QUEUE_LIMIT || 30;
247
+ const trimmedMsgs = cleanedMsgs.length > queueLimit
248
+ ? cleanedMsgs.slice(-queueLimit)
249
+ : cleanedMsgs;
250
+
251
+ if (trimmedMsgs.length > 0) {
252
+ const chat_history = await util.compressMessages(trimmedMsgs);
253
+ this.context.chat_history = chat_history;
254
+
255
+ // Persist to store
256
+ const store = this._store || Store.instance;
257
+ if (store && this.context_id) {
258
+ await store.save(this.context_id, {
259
+ chat_history,
260
+ tool_digest: this.context.tool_digest || [],
261
+ prompt: this.context.prompt,
262
+ tag: this.context.tag,
263
+ tm_closed: Date.now()
264
+ });
265
+ }
266
+ }
267
+
268
+ await this.context.close();
269
+ }
270
+
162
271
  /**
163
272
  * Deactivate — bubble cleaned messages to parent, close context, cancel task.
164
273
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
@@ -166,12 +275,12 @@ class Saico {
166
275
  */
167
276
  async deactivate() {
168
277
  if (!this._task) return;
169
- if (this._task.context) {
278
+ if (this.context) {
170
279
  // Find parent context to bubble cleaned messages
171
280
  let parentTask = this._task.parent;
172
281
  let parentCtx = null;
173
282
  while (parentTask) {
174
- if (parentTask.context) { parentCtx = parentTask.context; break; }
283
+ if (parentTask._saico?.context) { parentCtx = parentTask._saico.context; break; }
175
284
  parentTask = parentTask.parent;
176
285
  }
177
286
  if (parentCtx) {
@@ -180,16 +289,45 @@ class Saico {
180
289
  parentCtx.push(msg);
181
290
  }
182
291
  // Clean tool calls and close context without additional summary bubbling.
183
- // We already pushed cleaned messages above — closeContext's own
184
- // summarization would double-bubble.
185
- if (this._task.context_id && typeof this._task.context.cleanToolCallsByTag === 'function')
186
- this._task.context.cleanToolCallsByTag(this._task.context_id);
187
- this._task.context = null;
292
+ if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
293
+ this.context.cleanToolCallsByTag(this.context_id);
294
+ this.context = null;
295
+ this.context_id = null;
188
296
  }
189
297
  this._task._ecancel();
190
298
  this._task = null;
191
299
  }
192
300
 
301
+ // ---- Spawn ----
302
+
303
+ /**
304
+ * Spawn a child Saico under this Saico's task hierarchy.
305
+ * Both parent and child must be activated.
306
+ * @param {Saico} child - An activated Saico instance
307
+ * @returns {Saico} the child (for chaining)
308
+ */
309
+ spawn(child) {
310
+ if (!this._task)
311
+ throw new Error('Not activated. Call activate() first.');
312
+ if (!(child instanceof Saico) || !child._task)
313
+ throw new Error('Child must be an activated Saico instance.');
314
+ this._task.spawn(child._task);
315
+ return child;
316
+ }
317
+
318
+ /**
319
+ * Spawn a child Saico and start its task running.
320
+ * @param {Saico} child - An activated Saico instance
321
+ * @returns {Saico} the child (for chaining)
322
+ */
323
+ spawnAndRun(child) {
324
+ this.spawn(child);
325
+ process.nextTick(() => {
326
+ try { child._task._run(); } catch (e) { console.error(e); }
327
+ });
328
+ return child;
329
+ }
330
+
193
331
  // ---- Saico parent chain traversal ----
194
332
 
195
333
  /**
@@ -260,19 +398,19 @@ class Saico {
260
398
  throw new Error('Not activated. Call activate() first.');
261
399
 
262
400
  // Find the active context (own or walk up)
263
- let ctx = this._task.getContext() || this._task.findContext();
401
+ let ctx = this.findContext();
264
402
  if (!ctx)
265
403
  throw new Error('No context available');
266
404
 
267
405
  // Build preamble by walking Saico chain
268
- const activeCtx = this._task.findDeepestContext() || ctx;
406
+ const activeCtx = this.findDeepestContext() || ctx;
269
407
  const { preamble, allFunctions } = this._buildPreamble(activeCtx);
270
408
 
271
409
  // Merge with call-specific functions
272
410
  if (functions) allFunctions.push(...(Array.isArray(functions) ? functions : [functions]));
273
411
 
274
412
  opts = Object.assign({}, opts, {
275
- tag: this._task.context_id,
413
+ tag: this.context_id,
276
414
  _preamble: preamble,
277
415
  _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
278
416
  });
@@ -284,7 +422,7 @@ class Saico {
284
422
  throw new Error('Not activated. Call activate() first.');
285
423
 
286
424
  // Route DOWN to deepest descendant with a msg Q
287
- const ctx = this._task.findDeepestContext();
425
+ const ctx = this.findDeepestContext();
288
426
  if (!ctx)
289
427
  throw new Error('No context available');
290
428
 
@@ -302,63 +440,8 @@ class Saico {
302
440
  // ---- Task delegation ----
303
441
 
304
442
  get task() { return this._task; }
305
- get context() { return this._task?.context || null; }
306
- get context_id() { return this._task?.context_id || null; }
307
443
  get isActive() { return !!this._task && !this._task._completed; }
308
444
 
309
- spawnTaskWithContext(opt, states) {
310
- if (!this._task)
311
- throw new Error('Not activated. Call activate() first.');
312
- if (typeof opt === 'string')
313
- opt = { name: opt };
314
-
315
- const childTask = new Itask({
316
- ...opt,
317
- spawn_parent: this._task,
318
- store: this._store,
319
- async: true,
320
- }, states || []);
321
-
322
- if (opt.prompt) {
323
- const childContext = new Context(opt.prompt, childTask, {
324
- tag: opt.tag || childTask.id,
325
- token_limit: opt.token_limit ?? this.sessionConfig.token_limit,
326
- max_depth: opt.max_depth ?? this.sessionConfig.max_depth,
327
- max_tool_repetition: opt.max_tool_repetition ?? this.sessionConfig.max_tool_repetition,
328
- queue_limit: opt.queue_limit ?? this.sessionConfig.queue_limit,
329
- min_chat_messages: opt.min_chat_messages ?? this.sessionConfig.min_chat_messages,
330
- functions: opt.functions || this.functions,
331
- });
332
- childTask.setContext(childContext);
333
- }
334
-
335
- process.nextTick(() => {
336
- try { childTask._run(); } catch (e) { console.error(e); }
337
- });
338
-
339
- return childTask;
340
- }
341
-
342
- spawnTask(opt, states) {
343
- if (!this._task)
344
- throw new Error('Not activated. Call activate() first.');
345
- if (typeof opt === 'string')
346
- opt = { name: opt };
347
-
348
- const childTask = new Itask({
349
- ...opt,
350
- spawn_parent: this._task,
351
- store: this._store,
352
- async: true,
353
- }, states || []);
354
-
355
- process.nextTick(() => {
356
- try { childTask._run(); } catch (e) { console.error(e); }
357
- });
358
-
359
- return childTask;
360
- }
361
-
362
445
  // ---- State Summary ----
363
446
 
364
447
  /**
@@ -437,8 +520,8 @@ class Saico {
437
520
 
438
521
  async closeSession() {
439
522
  if (!this._task) return;
440
- if (this._task.context)
441
- await this._task.context.close();
523
+ if (this.context)
524
+ await this.context.close();
442
525
  this._task._ecancel();
443
526
  }
444
527
 
@@ -555,13 +638,13 @@ class Saico {
555
638
  if (this._task) {
556
639
  data.task = {
557
640
  id: this._task.id,
558
- context_id: this._task.context_id,
559
- context: this._task.context ? {
560
- tag: this._task.context.tag,
561
- msgs: this._task.context._msgs,
562
- functions: this._task.context.functions,
563
- chat_history: this._task.context.chat_history,
564
- tool_digest: this._task.context.tool_digest,
641
+ context_id: this.context_id,
642
+ context: this.context ? {
643
+ tag: this.context.tag,
644
+ msgs: this.context._msgs,
645
+ functions: this.context.functions,
646
+ chat_history: this.context.chat_history,
647
+ tool_digest: this.context.tool_digest,
565
648
  } : null,
566
649
  };
567
650
  }
@@ -604,13 +687,13 @@ class Saico {
604
687
  });
605
688
 
606
689
  // Restore messages to context
607
- if (parsed.task.context?.msgs && instance._task.context) {
608
- instance._task.context._msgs = parsed.task.context.msgs;
690
+ if (parsed.task.context?.msgs && instance.context) {
691
+ instance.context._msgs = parsed.task.context.msgs;
609
692
  }
610
693
 
611
694
  // Restore tool_digest
612
- if (Array.isArray(parsed.task.context?.tool_digest) && instance._task.context) {
613
- instance._task.context.tool_digest = parsed.task.context.tool_digest;
695
+ if (Array.isArray(parsed.task.context?.tool_digest) && instance.context) {
696
+ instance.context.tool_digest = parsed.task.context.tool_digest;
614
697
  }
615
698
  }
616
699
 
@@ -618,4 +701,9 @@ class Saico {
618
701
  }
619
702
  }
620
703
 
704
+ // [BACKEND] explanation text appended to context prompts
705
+ Saico.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
706
+ 'server, not the user. They contain server instructions, data updates, or system context. ' +
707
+ 'Treat them as authoritative system-level information.';
708
+
621
709
  module.exports = { Saico };
package/context.js DELETED
@@ -1,5 +0,0 @@
1
- // context.js — backward compatibility shim
2
- // The Context class has moved to msgs.js. This file re-exports for compatibility.
3
- 'use strict';
4
- const { Context, createContext } = require('./msgs.js');
5
- module.exports = { Context, createContext };