saico 2.6.1 → 2.7.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
@@ -135,29 +135,32 @@ class OrderAgent extends Saico {
135
135
 
136
136
  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
137
 
138
- ### Spawning Child Tasks
138
+ ### Spawning Child Saico Instances
139
139
 
140
140
  ```js
141
141
  // Child with its own conversation context
142
- const child = agent.spawnTaskWithContext({
142
+ const child = new Saico({
143
143
  name: 'subtask',
144
144
  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
- ]);
145
+ functions: [/* child-specific tools */],
146
+ });
147
+ child.activate({ createQ: true });
148
+ agent.spawn(child);
149
+ await child.sendMessage('Working on subtask...');
150
+
151
+ // Child without context (uses parent's via findContext())
152
+ const simple = new Saico({ name: 'simple' });
153
+ simple.activate();
154
+ agent.spawn(simple);
155
+ await simple.sendMessage('Quick operation');
156
+
157
+ // spawnAndRun: spawn + schedule child task to run on nextTick
158
+ const runner = new Saico({ name: 'runner' });
159
+ runner.activate({ states: [async function() { return await this.sendMessage('Go'); }] });
160
+ agent.spawnAndRun(runner);
158
161
  ```
159
162
 
160
- Child tasks inherit `sessionConfig` defaults (token_limit, max_depth, etc.) from the parent Saico.
163
+ Both parent and child must be activated before calling `spawn()` or `spawnAndRun()`.
161
164
 
162
165
  ### Deactivation and Message Bubbling
163
166
 
@@ -207,7 +210,6 @@ agent.activate({
207
210
  createQ: true, // Create message queue context
208
211
  prompt: 'Extra prompt', // Appended to class-level prompt
209
212
  states: [], // Task state functions
210
- parent: parentTask, // Parent task to spawn under
211
213
  taskId: 'custom-id',
212
214
  sequential_mode: true, // Process messages sequentially
213
215
 
@@ -336,21 +338,13 @@ Return a string or `{ content: string, functions?: [] }`.
336
338
 
337
339
  ## Low-Level API
338
340
 
339
- For cases where you don't need the Saico master class:
341
+ For cases where you need a standalone context without the Saico master class:
340
342
 
341
343
  ```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');
344
+ const { createContext } = require('saico');
351
345
 
352
- // Standalone context (legacy)
353
- const ctx = createQ('System prompt', null, 'tag', 4000);
346
+ // Standalone context
347
+ const ctx = createContext('System prompt', null, { tag: 'my-tag', token_limit: 4000 });
354
348
  const reply = await ctx.sendMessage('user', 'Hello', functions);
355
349
  ```
356
350
 
@@ -358,11 +352,10 @@ const reply = await ctx.sendMessage('user', 'Hello', functions);
358
352
 
359
353
  ```
360
354
  saico/
361
- +-- index.js # Entry point, exports all components
362
- +-- saico.js # Saico master class
363
- +-- itask.js # Base task class (hierarchy, states, cancellation)
355
+ +-- index.js # Thin barrel file, exports all components
356
+ +-- saico.js # Saico master class — owns context, spawn, DB, orchestration
357
+ +-- itask.js # Pure task runner hierarchy, states, cancellation, promises
364
358
  +-- msgs.js # Conversation context (message queue, tool calls, summarization)
365
- +-- context.js # Backward-compat shim for msgs.js
366
359
  +-- dynamo.js # DynamoDB storage adapter
367
360
  +-- store.js # Storage abstraction (Redis + pluggable backends)
368
361
  +-- openai.js # OpenAI API wrapper with retry logic
@@ -376,7 +369,7 @@ saico/
376
369
  npm test
377
370
  ```
378
371
 
379
- 293 tests covering Saico lifecycle, task hierarchy, message handling, tool calls, DB adapters, serialization, and integration flows.
372
+ 284 tests covering Saico lifecycle, context ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, serialization, and integration flows.
380
373
 
381
374
  ## Requirements
382
375
 
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.0",
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.
@@ -47,6 +52,10 @@ class Saico {
47
52
  this._opt = opt;
48
53
  this._isolate = opt.isolate || false;
49
54
 
55
+ // Context owned directly by Saico (not Itask)
56
+ this.context = null;
57
+ this.context_id = null;
58
+
50
59
  // Public configuration
51
60
  this.name = opt.name || this.constructor.name || 'saico';
52
61
  this.prompt = opt.prompt || '';
@@ -94,7 +103,6 @@ class Saico {
94
103
  * @param {string} [opts.prompt] - Additional prompt (appended to class-level)
95
104
  * @param {Array} [opts.functions] - Override functions
96
105
  * @param {Array} [opts.states] - Task state functions
97
- * @param {Itask} [opts.parent] - Parent task to spawn under
98
106
  * @param {string} [opts.taskId] - Custom task ID
99
107
  * @param {number} [opts.token_limit] - Token limit for context
100
108
  * @param {number} [opts.max_depth] - Max tool call depth
@@ -120,14 +128,9 @@ class Saico {
120
128
  name: this.name,
121
129
  id: opts.taskId,
122
130
  async: true,
123
- store: this._store,
124
- functions: opts.functions || this.functions,
125
131
  bind: this, // State functions run with Saico instance as `this`
126
132
  };
127
133
 
128
- if (opts.parent)
129
- taskOpt.spawn_parent = opts.parent;
130
-
131
134
  this._task = new Itask(taskOpt, states);
132
135
 
133
136
  // Store Saico reference on task for parent chain traversal
@@ -135,6 +138,7 @@ class Saico {
135
138
 
136
139
  // Create message Q context if requested (only via createQ flag, NOT prompt)
137
140
  if (opts.createQ) {
141
+ const functions = opts.functions || this.functions;
138
142
  const contextConfig = {
139
143
  tag: opts.tag || this._task.id,
140
144
  token_limit: opts.token_limit ?? this.sessionConfig.token_limit,
@@ -142,7 +146,7 @@ class Saico {
142
146
  max_tool_repetition: opts.max_tool_repetition ?? this.sessionConfig.max_tool_repetition,
143
147
  queue_limit: opts.queue_limit ?? this.sessionConfig.queue_limit,
144
148
  min_chat_messages: opts.min_chat_messages ?? this.sessionConfig.min_chat_messages,
145
- functions: taskOpt.functions,
149
+ functions,
146
150
  sequential_mode: opts.sequential_mode,
147
151
  msgs: opts.msgs,
148
152
  chat_history: opts.chat_history,
@@ -150,15 +154,118 @@ class Saico {
150
154
  };
151
155
 
152
156
  const augmentedPrompt = effectivePrompt
153
- ? effectivePrompt + Itask.BACKEND_EXPLANATION
157
+ ? effectivePrompt + Saico.BACKEND_EXPLANATION
154
158
  : '';
155
159
  const context = new Context(augmentedPrompt, this._task, contextConfig);
156
- this._task.setContext(context);
160
+ this.setContext(context);
157
161
  }
158
162
 
159
163
  return this;
160
164
  }
161
165
 
166
+ // ---- Context management (owned by Saico, not Itask) ----
167
+
168
+ /**
169
+ * Set context on this Saico instance.
170
+ * Generates context_id, sets context.tag, and calls context.setTask().
171
+ */
172
+ setContext(context) {
173
+ this.context = context;
174
+ // Generate context_id if not already set
175
+ if (!this.context_id) {
176
+ if (this._store)
177
+ this.context_id = this._store.generateId();
178
+ else if (Store.instance)
179
+ this.context_id = Store.instance.generateId();
180
+ else
181
+ this.context_id = makeId(16);
182
+ }
183
+ if (context) {
184
+ context.tag = this.context_id;
185
+ if (typeof context.setTask === 'function')
186
+ context.setTask(this._task);
187
+ }
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * Find the nearest context walking UP the Saico/task hierarchy.
193
+ */
194
+ findContext() {
195
+ if (this.context) return this.context;
196
+ let task = this._task?.parent;
197
+ while (task) {
198
+ if (task._saico?.context) return task._saico.context;
199
+ task = task.parent;
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Walk DOWN to find the deepest active descendant with a context.
206
+ */
207
+ findDeepestContext() {
208
+ if (!this._task) return this.context || null;
209
+ let deepest = this.context ? { context: this.context, depth: 0 } : null;
210
+ const search = (task, depth) => {
211
+ for (const child of task.child) {
212
+ if (child._completed) continue;
213
+ if (child._saico?.context) {
214
+ if (!deepest || depth + 1 >= deepest.depth)
215
+ deepest = { context: child._saico.context, depth: depth + 1 };
216
+ }
217
+ search(child, depth + 1);
218
+ }
219
+ };
220
+ search(this._task, 0);
221
+ return deepest ? deepest.context : null;
222
+ }
223
+
224
+ /**
225
+ * Close this Saico's context and bubble summary to parent.
226
+ */
227
+ async closeContext() {
228
+ if (!this.context)
229
+ return;
230
+
231
+ // Clean tool call messages tagged with this context_id
232
+ if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
233
+ this.context.cleanToolCallsByTag(this.context_id);
234
+
235
+ // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
236
+ const cleanedMsgs = this.context._msgs.filter(m => {
237
+ if (m.msg.tool_calls) return false;
238
+ if (m.msg.role === 'tool') return false;
239
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
240
+ return true;
241
+ }).map(m => m.msg);
242
+
243
+ // Trim to last QUEUE_LIMIT before persisting
244
+ const queueLimit = this.context.QUEUE_LIMIT || 30;
245
+ const trimmedMsgs = cleanedMsgs.length > queueLimit
246
+ ? cleanedMsgs.slice(-queueLimit)
247
+ : cleanedMsgs;
248
+
249
+ if (trimmedMsgs.length > 0) {
250
+ const chat_history = await util.compressMessages(trimmedMsgs);
251
+ this.context.chat_history = chat_history;
252
+
253
+ // Persist to store
254
+ const store = this._store || Store.instance;
255
+ if (store && this.context_id) {
256
+ await store.save(this.context_id, {
257
+ chat_history,
258
+ tool_digest: this.context.tool_digest || [],
259
+ prompt: this.context.prompt,
260
+ tag: this.context.tag,
261
+ tm_closed: Date.now()
262
+ });
263
+ }
264
+ }
265
+
266
+ await this.context.close();
267
+ }
268
+
162
269
  /**
163
270
  * Deactivate — bubble cleaned messages to parent, close context, cancel task.
164
271
  * Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
@@ -166,12 +273,12 @@ class Saico {
166
273
  */
167
274
  async deactivate() {
168
275
  if (!this._task) return;
169
- if (this._task.context) {
276
+ if (this.context) {
170
277
  // Find parent context to bubble cleaned messages
171
278
  let parentTask = this._task.parent;
172
279
  let parentCtx = null;
173
280
  while (parentTask) {
174
- if (parentTask.context) { parentCtx = parentTask.context; break; }
281
+ if (parentTask._saico?.context) { parentCtx = parentTask._saico.context; break; }
175
282
  parentTask = parentTask.parent;
176
283
  }
177
284
  if (parentCtx) {
@@ -180,16 +287,45 @@ class Saico {
180
287
  parentCtx.push(msg);
181
288
  }
182
289
  // 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;
290
+ if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
291
+ this.context.cleanToolCallsByTag(this.context_id);
292
+ this.context = null;
293
+ this.context_id = null;
188
294
  }
189
295
  this._task._ecancel();
190
296
  this._task = null;
191
297
  }
192
298
 
299
+ // ---- Spawn ----
300
+
301
+ /**
302
+ * Spawn a child Saico under this Saico's task hierarchy.
303
+ * Both parent and child must be activated.
304
+ * @param {Saico} child - An activated Saico instance
305
+ * @returns {Saico} the child (for chaining)
306
+ */
307
+ spawn(child) {
308
+ if (!this._task)
309
+ throw new Error('Not activated. Call activate() first.');
310
+ if (!(child instanceof Saico) || !child._task)
311
+ throw new Error('Child must be an activated Saico instance.');
312
+ this._task.spawn(child._task);
313
+ return child;
314
+ }
315
+
316
+ /**
317
+ * Spawn a child Saico and start its task running.
318
+ * @param {Saico} child - An activated Saico instance
319
+ * @returns {Saico} the child (for chaining)
320
+ */
321
+ spawnAndRun(child) {
322
+ this.spawn(child);
323
+ process.nextTick(() => {
324
+ try { child._task._run(); } catch (e) { console.error(e); }
325
+ });
326
+ return child;
327
+ }
328
+
193
329
  // ---- Saico parent chain traversal ----
194
330
 
195
331
  /**
@@ -260,19 +396,19 @@ class Saico {
260
396
  throw new Error('Not activated. Call activate() first.');
261
397
 
262
398
  // Find the active context (own or walk up)
263
- let ctx = this._task.getContext() || this._task.findContext();
399
+ let ctx = this.findContext();
264
400
  if (!ctx)
265
401
  throw new Error('No context available');
266
402
 
267
403
  // Build preamble by walking Saico chain
268
- const activeCtx = this._task.findDeepestContext() || ctx;
404
+ const activeCtx = this.findDeepestContext() || ctx;
269
405
  const { preamble, allFunctions } = this._buildPreamble(activeCtx);
270
406
 
271
407
  // Merge with call-specific functions
272
408
  if (functions) allFunctions.push(...(Array.isArray(functions) ? functions : [functions]));
273
409
 
274
410
  opts = Object.assign({}, opts, {
275
- tag: this._task.context_id,
411
+ tag: this.context_id,
276
412
  _preamble: preamble,
277
413
  _aggregatedFunctions: allFunctions.length > 0 ? allFunctions : null,
278
414
  });
@@ -284,7 +420,7 @@ class Saico {
284
420
  throw new Error('Not activated. Call activate() first.');
285
421
 
286
422
  // Route DOWN to deepest descendant with a msg Q
287
- const ctx = this._task.findDeepestContext();
423
+ const ctx = this.findDeepestContext();
288
424
  if (!ctx)
289
425
  throw new Error('No context available');
290
426
 
@@ -302,63 +438,8 @@ class Saico {
302
438
  // ---- Task delegation ----
303
439
 
304
440
  get task() { return this._task; }
305
- get context() { return this._task?.context || null; }
306
- get context_id() { return this._task?.context_id || null; }
307
441
  get isActive() { return !!this._task && !this._task._completed; }
308
442
 
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
443
  // ---- State Summary ----
363
444
 
364
445
  /**
@@ -437,8 +518,8 @@ class Saico {
437
518
 
438
519
  async closeSession() {
439
520
  if (!this._task) return;
440
- if (this._task.context)
441
- await this._task.context.close();
521
+ if (this.context)
522
+ await this.context.close();
442
523
  this._task._ecancel();
443
524
  }
444
525
 
@@ -555,13 +636,13 @@ class Saico {
555
636
  if (this._task) {
556
637
  data.task = {
557
638
  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,
639
+ context_id: this.context_id,
640
+ context: this.context ? {
641
+ tag: this.context.tag,
642
+ msgs: this.context._msgs,
643
+ functions: this.context.functions,
644
+ chat_history: this.context.chat_history,
645
+ tool_digest: this.context.tool_digest,
565
646
  } : null,
566
647
  };
567
648
  }
@@ -604,13 +685,13 @@ class Saico {
604
685
  });
605
686
 
606
687
  // Restore messages to context
607
- if (parsed.task.context?.msgs && instance._task.context) {
608
- instance._task.context._msgs = parsed.task.context.msgs;
688
+ if (parsed.task.context?.msgs && instance.context) {
689
+ instance.context._msgs = parsed.task.context.msgs;
609
690
  }
610
691
 
611
692
  // 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;
693
+ if (Array.isArray(parsed.task.context?.tool_digest) && instance.context) {
694
+ instance.context.tool_digest = parsed.task.context.tool_digest;
614
695
  }
615
696
  }
616
697
 
@@ -618,4 +699,9 @@ class Saico {
618
699
  }
619
700
  }
620
701
 
702
+ // [BACKEND] explanation text appended to context prompts
703
+ Saico.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
704
+ 'server, not the user. They contain server instructions, data updates, or system context. ' +
705
+ 'Treat them as authoritative system-level information.';
706
+
621
707
  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 };