saico 2.0.0 → 2.2.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/context.js CHANGED
@@ -34,6 +34,9 @@ class Context {
34
34
  this._deferred_tool_calls = [];
35
35
  this._tool_call_sequence = [];
36
36
 
37
+ // Chat history persistence
38
+ this.chat_history = config.chat_history || null;
39
+
37
40
  this._msgs = [];
38
41
  this._waitingQueue = [];
39
42
  this._active_tool_calls = new Map();
@@ -43,6 +46,14 @@ class Context {
43
46
  this._processing_sequential = false;
44
47
  this._sequential_mode = config.sequential_mode || false;
45
48
 
49
+ // Queue structure limits
50
+ this.QUEUE_LIMIT = config.queue_limit ?? 30;
51
+ this.TOOL_DIGEST_LIMIT = config.tool_digest_limit ?? 10;
52
+ this.MIN_CHAT_MESSAGES = config.min_chat_messages ?? 10;
53
+
54
+ // Tool digest — persistent history of tool calls that mutated task state
55
+ this.tool_digest = config.tool_digest || [];
56
+
46
57
  // Initialize messages if provided
47
58
  (config.msgs || []).forEach(m => this.push(m));
48
59
 
@@ -58,6 +69,35 @@ class Context {
58
69
  this.functions = task?.functions;
59
70
  }
60
71
 
72
+ // Overridable: extending classes provide current state summary
73
+ getStateSummary() { return ''; }
74
+
75
+ // Snapshot all public (non-underscore) task properties for dirty detection.
76
+ // Mirrors the observable proxy convention: _ prefix = internal, ignored.
77
+ // Does NOT call serialize() — that is for persistence, not dirty detection.
78
+ _snapshotPublicProps(obj, seen = new Set()) {
79
+ if (typeof obj !== 'object' || obj === null) return obj;
80
+ if (seen.has(obj)) return undefined; // circular ref — skip
81
+ seen.add(obj);
82
+ const out = Array.isArray(obj) ? [] : {};
83
+ for (const key of Object.keys(obj)) {
84
+ if (!key.startsWith('_') && typeof obj[key] !== 'function')
85
+ out[key] = this._snapshotPublicProps(obj[key], seen);
86
+ }
87
+ seen.delete(obj);
88
+ return out;
89
+ }
90
+
91
+ // Append a tool result to the persistent tool digest
92
+ _appendToolDigest(toolName, resultContent) {
93
+ const truncated = typeof resultContent === 'string'
94
+ ? resultContent.slice(0, 500)
95
+ : String(resultContent ?? '').slice(0, 500);
96
+ this.tool_digest.push({ tool: toolName, result: truncated, tm: Date.now() });
97
+ if (this.tool_digest.length > this.TOOL_DIGEST_LIMIT)
98
+ this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
99
+ }
100
+
61
101
  // Get the parent context by traversing task hierarchy
62
102
  getParentContext() {
63
103
  if (!this.task || !this.task.parent)
@@ -245,6 +285,8 @@ class Context {
245
285
  };
246
286
  } else {
247
287
  this._trackActiveToolCall(call);
288
+ const _snap = this.task
289
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
248
290
 
249
291
  try {
250
292
  const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
@@ -252,6 +294,9 @@ class Context {
252
294
  const timeout = correspondingDeferred?.originalMessage.opts.timeout;
253
295
 
254
296
  result = await this._executeToolCallWithTimeout(call, handler, timeout);
297
+ if (_snap !== null &&
298
+ _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
299
+ this._appendToolDigest(call.function.name, result?.content || '');
255
300
  } finally {
256
301
  this._completeActiveToolCall(call);
257
302
  }
@@ -397,6 +442,48 @@ class Context {
397
442
  _log('Finished closing Context tag', this.tag);
398
443
  }
399
444
 
445
+ // Load chat history from store into message queue
446
+ async loadHistory(store) {
447
+ if (!store || !this.tag)
448
+ return;
449
+ const data = await store.load(this.tag);
450
+ if (!data)
451
+ return;
452
+ if (Array.isArray(data.tool_digest))
453
+ this.tool_digest = data.tool_digest;
454
+ if (!data.chat_history)
455
+ return;
456
+ const messages = await util.decompressMessages(data.chat_history);
457
+ if (!Array.isArray(messages) || messages.length === 0)
458
+ return;
459
+ // Find the index after the last system message to insert history
460
+ let insertIdx = 0;
461
+ for (let i = 0; i < this._msgs.length; i++) {
462
+ if (this._msgs[i].msg.role === 'system')
463
+ insertIdx = i + 1;
464
+ }
465
+ const historyMsgs = messages.map(m => ({
466
+ msg: m,
467
+ opts: {},
468
+ msgid: crypto.randomBytes(2).toString('hex'),
469
+ replied: 1
470
+ }));
471
+ this._msgs.splice(insertIdx, 0, ...historyMsgs);
472
+ }
473
+
474
+ // Remove tool-related messages tagged with a specific tag
475
+ cleanToolCallsByTag(tag) {
476
+ this._msgs = this._msgs.filter(m => {
477
+ if (m.opts.tag !== tag)
478
+ return true;
479
+ if (m.msg.tool_calls)
480
+ return false;
481
+ if (m.msg.role === 'tool')
482
+ return false;
483
+ return true;
484
+ });
485
+ }
486
+
400
487
  async _summarizeContext(close, targetCtx) {
401
488
  const keep = this._msgs.filter(m => !close && m.summary);
402
489
  const summarize = this._msgs.filter(m => (!close || !m.summary) && m.replied);
@@ -677,50 +764,82 @@ class Context {
677
764
  return validatedMsgs;
678
765
  }
679
766
 
680
- // Build message queue - aggregates from task hierarchy
767
+ // Slice the last `limit` messages, walking back to avoid orphaning tool responses
768
+ // and expanding if fewer than MIN_CHAT_MESSAGES user/assistant messages are included.
769
+ _getQueueSlice(msgs, limit) {
770
+ if (msgs.length <= limit) return msgs;
771
+
772
+ let startIdx = msgs.length - limit;
773
+
774
+ // Walk backward if we'd start mid-tool-pair (tool response without its call)
775
+ while (startIdx > 0 && msgs[startIdx] && msgs[startIdx].role === 'tool')
776
+ startIdx--;
777
+
778
+ // Count user/assistant messages in the current window
779
+ let chatCount = 0;
780
+ for (let i = startIdx; i < msgs.length; i++) {
781
+ const role = msgs[i].role;
782
+ if (role === 'user' || role === 'assistant') chatCount++;
783
+ }
784
+
785
+ // Expand backward until we have at least MIN_CHAT_MESSAGES chat messages
786
+ while (chatCount < this.MIN_CHAT_MESSAGES && startIdx > 0) {
787
+ startIdx--;
788
+ const role = msgs[startIdx].role;
789
+ if (role === 'user' || role === 'assistant') chatCount++;
790
+ }
791
+
792
+ return msgs.slice(startIdx);
793
+ }
794
+
795
+ // Build message queue — layered structure:
796
+ // Layer 1: System prompts from ancestor hierarchy + own prompt (transient)
797
+ // Layer 2: [State Summary] from getStateSummary() override (transient, if non-empty)
798
+ // Layer 3: [Tool Activity Log] from tool_digest (transient, if non-empty)
799
+ // Layer 4: Ancestor summaries only + last QUEUE_LIMIT of own messages (persistent)
681
800
  _createMsgQ(add_tag, tag_filter) {
682
801
  const fullQueue = [];
683
-
684
- // Get messages from ancestor contexts via task hierarchy
685
802
  const ancestorContexts = this.getAncestorContexts();
803
+
804
+ // Layer 1: System prompts from ancestor hierarchy + own prompt
686
805
  for (const ctx of ancestorContexts) {
687
806
  if (ctx.prompt) {
688
807
  const prompt = {role: 'system', content: ctx.prompt};
689
- if (add_tag)
690
- prompt.tag = ctx.tag;
808
+ if (add_tag) prompt.tag = ctx.tag;
691
809
  fullQueue.push(prompt);
692
810
  }
693
-
694
- let ctxMsgs;
695
- if (tag_filter !== undefined) {
696
- ctxMsgs = ctx._msgs.filter(m => {
697
- if (m.msg.role === 'system') return true;
698
- if (m.opts.summary) return true;
699
- if (m.opts.tag === tag_filter) return true;
700
- return false;
701
- }).map(m => m.msg);
702
- } else {
703
- ctxMsgs = ctx.__msgs;
704
- }
705
-
706
- if (add_tag)
707
- ctxMsgs = ctxMsgs.map(m => Object.assign({tag: ctx.tag}, m));
708
-
709
- fullQueue.push(...ctxMsgs);
710
811
  }
711
-
712
- // Add this context's prompt and messages
713
812
  if (this.prompt) {
714
813
  const prompt = {role: 'system', content: this.prompt};
715
- if (add_tag)
716
- prompt.tag = this.tag;
814
+ if (add_tag) prompt.tag = this.tag;
717
815
  fullQueue.push(prompt);
718
816
  }
719
817
 
818
+ // Layer 2: State summary (if non-empty)
819
+ const stateSummary = this.getStateSummary();
820
+ if (stateSummary)
821
+ fullQueue.push({role: 'system', content: '[State Summary]\n' + stateSummary});
822
+
823
+ // Layer 3: Tool digest (if non-empty)
824
+ if (this.tool_digest.length > 0) {
825
+ const digestText = this.tool_digest.map(entry =>
826
+ `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
827
+ ).join('\n');
828
+ fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
829
+ }
830
+
831
+ // Layer 4: Ancestor summaries only (no full ancestor messages)
832
+ for (const ctx of ancestorContexts) {
833
+ const summaries = ctx._msgs
834
+ .filter(m => m.opts.summary)
835
+ .map(m => add_tag ? Object.assign({}, m.msg, {tag: ctx.tag}) : m.msg);
836
+ fullQueue.push(...summaries);
837
+ }
838
+
839
+ // Own messages — filter by tag if requested, then slice to QUEUE_LIMIT
720
840
  let my_msgs;
721
841
  if (tag_filter !== undefined) {
722
842
  my_msgs = this._msgs.filter(m => {
723
- if (m.msg.role === 'system') return true;
724
843
  if (m.opts.summary) return true;
725
844
  if (m.opts.tag === tag_filter) return true;
726
845
  return false;
@@ -730,9 +849,9 @@ class Context {
730
849
  }
731
850
 
732
851
  if (add_tag)
733
- my_msgs = my_msgs.map(m => Object.assign({tag: this.tag}, m));
852
+ my_msgs = my_msgs.map(m => Object.assign({}, m, {tag: this.tag}));
734
853
 
735
- fullQueue.push(...my_msgs);
854
+ fullQueue.push(...this._getQueueSlice(my_msgs, this.QUEUE_LIMIT));
736
855
 
737
856
  return this._validateToolResponses(fullQueue);
738
857
  }
@@ -823,7 +942,6 @@ class Context {
823
942
  this._processWaitingQueue();
824
943
  }
825
944
 
826
- await this.summarizeMessages();
827
945
  Q = this._createMsgQ(false, o.opts?.tag);
828
946
 
829
947
  // Aggregate functions from hierarchy and merge with message-specific functions
@@ -898,11 +1016,16 @@ class Context {
898
1016
 
899
1017
  for (const { call, isDuplicate } of toolCallsWithResults) {
900
1018
  if (!isDuplicate) {
1019
+ const _snap = this.task
1020
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
901
1021
  try {
902
1022
  const result = await this._executeToolCallWithTimeout(
903
1023
  call, o.opts?.handler, o.opts?.timeout);
904
1024
  const item = toolCallsWithResults.find(item => item.call.id === call.id);
905
1025
  if (item) item.result = result;
1026
+ if (_snap !== null &&
1027
+ _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
1028
+ this._appendToolDigest(call.function.name, result?.content || '');
906
1029
  } finally {
907
1030
  this._completeActiveToolCall(call);
908
1031
  }
@@ -971,7 +1094,10 @@ class Context {
971
1094
  if (content && typeof content !== 'string')
972
1095
  content = JSON.stringify(content);
973
1096
  else if (!content)
974
- content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg from the user`;
1097
+ {
1098
+ content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg `
1099
+ +`from the user`;
1100
+ }
975
1101
 
976
1102
  _log('FUNCTION RESULT', call.function.name, call.id, content.substring(0, 50) + '...',
977
1103
  functions ? 'with functions' : 'no functions');
package/index.js CHANGED
@@ -9,20 +9,44 @@
9
9
  * - Optional conversation contexts attached to tasks
10
10
  * - Hierarchical message aggregation with function collection
11
11
  * - Full tool_calls support with depth control
12
+ * - Storage persistence (Redis cache + optional DB backend)
12
13
  *
13
14
  * Main Components:
14
15
  * - Itask: Base task class for all tasks (supports states, cancellation, promises)
15
16
  * - Context: Conversation context with message handling and tool calls
16
17
  * - Sid: Session root task (extends Itask, always has a context)
18
+ * - Store: Storage abstraction layer (Redis + optional backends like DynamoDB)
17
19
  */
18
20
 
19
21
  const Itask = require('./itask.js');
20
22
  const { Context, createContext } = require('./context.js');
21
23
  const { Sid, createSid } = require('./sid.js');
24
+ const { Store, DynamoBackend } = require('./store.js');
22
25
 
23
26
  // Wire up Context class reference in Itask to avoid circular dependency
24
27
  Itask.Context = Context;
25
28
 
29
+ /**
30
+ * Initialize Saico with storage configuration.
31
+ * Sets up the Store singleton and optionally initializes Redis.
32
+ *
33
+ * @param {Object} config - Configuration options
34
+ * @param {boolean} config.redis - Whether to initialize Redis
35
+ * @param {Object} config.dynamodb - DynamoDB backend config {table, aws}
36
+ * @returns {Store} The initialized Store instance
37
+ */
38
+ async function init(config = {}) {
39
+ const store = Store.init(config);
40
+
41
+ if (config.redis) {
42
+ const redis = require('./redis.js');
43
+ await redis.init();
44
+ store.setRedis(redis.rclient);
45
+ }
46
+
47
+ return store;
48
+ }
49
+
26
50
  /**
27
51
  * Create a new task with optional context.
28
52
  *
@@ -35,6 +59,7 @@ Itask.Context = Context;
35
59
  * @param {Object} opt.bind - Bind context for state functions
36
60
  * @param {Itask} opt.spawn_parent - Parent task to spawn under
37
61
  * @param {boolean} opt.async - If true, don't auto-run
62
+ * @param {Object} opt.store - Store instance for persistence
38
63
  * @param {Array} states - Array of state functions
39
64
  * @returns {Itask} The created task
40
65
  */
@@ -42,6 +67,9 @@ function createTask(opt, states = []) {
42
67
  if (typeof opt === 'string')
43
68
  opt = { name: opt };
44
69
 
70
+ if (!opt.store)
71
+ opt.store = Store.instance;
72
+
45
73
  const task = new Itask(opt, states);
46
74
 
47
75
  // Auto-create context if prompt is provided
@@ -95,7 +123,8 @@ function createQ(prompt, parent, tag, token_limit, msgs, tool_handler, config =
95
123
  const childTask = new Itask({
96
124
  name: tag || 'child-context',
97
125
  async: true,
98
- spawn_parent: parent.task
126
+ spawn_parent: parent.task,
127
+ store: Store.instance
99
128
  }, []);
100
129
  context.setTask(childTask);
101
130
  childTask.setContext(context);
@@ -110,6 +139,11 @@ module.exports = {
110
139
  Itask,
111
140
  Context,
112
141
  Sid,
142
+ Store,
143
+ DynamoBackend,
144
+
145
+ // Initialization
146
+ init,
113
147
 
114
148
  // Factory functions
115
149
  createTask,
package/itask.js CHANGED
@@ -18,6 +18,7 @@ const assert = require('assert');
18
18
  const EventEmitter = require('events');
19
19
  const crypto = require('crypto');
20
20
  const util = require('./util.js');
21
+ const { Store } = require('./store.js');
21
22
 
22
23
  const { _log, lerr , _ldbg, daysSince, minSince, shallowEqual, filterArray, logEvent } = util;
23
24
 
@@ -102,6 +103,10 @@ function Itask(opt, states){
102
103
  this.context = null;
103
104
  this._contextConfig = opt.contextConfig || {};
104
105
 
106
+ // Storage persistence
107
+ this.context_id = opt.context_id || null;
108
+ this._store = opt.store || Store.instance || null;
109
+
105
110
  // Store options for context creation (prompt, functions, etc.)
106
111
  this.prompt = opt.prompt;
107
112
  this.functions = opt.functions;
@@ -370,6 +375,15 @@ Itask.prototype.spawn = function spawn(child){
370
375
  Itask.root.delete(child);
371
376
  child._root_registered = false;
372
377
  }
378
+ // Auto-wrap with redis observable for live state persistence
379
+ if (child.context_id) {
380
+ try {
381
+ const redis = require('./redis.js');
382
+ if (redis.rclient) {
383
+ redis.createObservableForRedis('saico:' + child.context_id, child);
384
+ }
385
+ } catch (e) { /* redis not available */ }
386
+ }
373
387
  // ensure async-created children begin execution
374
388
  if (!child.running && !child._completed){
375
389
  process.nextTick(() => {
@@ -578,6 +592,11 @@ Itask.ps = function ps(){
578
592
  };
579
593
 
580
594
  /* ---------- context management ---------- */
595
+ // [BACKEND] explanation text appended to context prompts
596
+ Itask.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
597
+ 'server, not the user. They contain server instructions, data updates, or system context. ' +
598
+ 'Treat them as authoritative system-level information.';
599
+
581
600
  // Get the context for this task, optionally creating one if needed
582
601
  Itask.prototype.getContext = function getContext(createIfMissing = false){
583
602
  if (this.context)
@@ -585,7 +604,9 @@ Itask.prototype.getContext = function getContext(createIfMissing = false){
585
604
  if (createIfMissing && this.prompt){
586
605
  // Lazy context creation - requires Context class to be set
587
606
  if (Itask.Context){
588
- this.context = new Itask.Context(this.prompt, this, this._contextConfig);
607
+ const augmentedPrompt = this.prompt + Itask.BACKEND_EXPLANATION;
608
+ this.context = new Itask.Context(augmentedPrompt, this, this._contextConfig);
609
+ this.setContext(this.context);
589
610
  return this.context;
590
611
  }
591
612
  }
@@ -595,8 +616,20 @@ Itask.prototype.getContext = function getContext(createIfMissing = false){
595
616
  // Set context for this task
596
617
  Itask.prototype.setContext = function setContext(context){
597
618
  this.context = context;
598
- if (context && typeof context.setTask === 'function')
599
- context.setTask(this);
619
+ // Generate context_id if not already set
620
+ if (!this.context_id) {
621
+ if (this._store)
622
+ this.context_id = this._store.generateId();
623
+ else if (Store.instance)
624
+ this.context_id = Store.instance.generateId();
625
+ else
626
+ this.context_id = makeId(16);
627
+ }
628
+ if (context) {
629
+ context.tag = this.context_id;
630
+ if (typeof context.setTask === 'function')
631
+ context.setTask(this);
632
+ }
600
633
  return this;
601
634
  };
602
635
 
@@ -623,9 +656,10 @@ Itask.prototype.findContext = function findContext(){
623
656
  return null;
624
657
  };
625
658
 
626
- // Send a message using the context hierarchy
627
- // Delegates to the task's context, or walks up to find one
628
- Itask.prototype.sendMessage = async function sendMessage(role, content, functions, opts){
659
+ // Send a backend message using the context hierarchy
660
+ // New signature: sendMessage(content, functions, opts)
661
+ // Always sends as role='user' with '[BACKEND] ' prefix
662
+ Itask.prototype.sendMessage = async function sendMessage(content, functions, opts){
629
663
  // First try our own context
630
664
  let ctx = this.getContext();
631
665
  if (!ctx){
@@ -635,9 +669,21 @@ Itask.prototype.sendMessage = async function sendMessage(role, content, function
635
669
  if (!ctx){
636
670
  throw new Error('No context available in task hierarchy to send message');
637
671
  }
638
- // Don't pass functions here - let context aggregate from hierarchy
639
- // If caller wants to override, they can pass functions in opts
640
- return ctx.sendMessage(role, content, functions, opts);
672
+ opts = Object.assign({}, opts, { tag: this.context_id });
673
+ return ctx.sendMessage('user', '[BACKEND] ' + content, functions, opts);
674
+ };
675
+
676
+ // Receive a user chat message (no [BACKEND] prefix)
677
+ Itask.prototype.recvChatMessage = async function recvChatMessage(content, opts){
678
+ let ctx = this.getContext();
679
+ if (!ctx){
680
+ ctx = this.findContext();
681
+ }
682
+ if (!ctx){
683
+ throw new Error('No context available in task hierarchy to receive message');
684
+ }
685
+ opts = Object.assign({}, opts, { tag: this.context_id });
686
+ return ctx.sendMessage('user', content, null, opts);
641
687
  };
642
688
 
643
689
  // Aggregate functions from all contexts in the hierarchy
@@ -658,6 +704,45 @@ Itask.prototype.getHierarchyFunctions = function getHierarchyFunctions(){
658
704
  Itask.prototype.closeContext = async function closeContext(){
659
705
  if (!this.context)
660
706
  return;
707
+
708
+ // Clean tool call messages tagged with this context_id
709
+ if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
710
+ this.context.cleanToolCallsByTag(this.context_id);
711
+
712
+ // Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
713
+ const cleanedMsgs = this.context._msgs.filter(m => {
714
+ if (m.msg.tool_calls)
715
+ return false;
716
+ if (m.msg.role === 'tool')
717
+ return false;
718
+ if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]'))
719
+ return false;
720
+ return true;
721
+ }).map(m => m.msg);
722
+
723
+ // Trim to last QUEUE_LIMIT before persisting
724
+ const queueLimit = this.context.QUEUE_LIMIT || 30;
725
+ const trimmedMsgs = cleanedMsgs.length > queueLimit
726
+ ? cleanedMsgs.slice(-queueLimit)
727
+ : cleanedMsgs;
728
+
729
+ if (trimmedMsgs.length > 0) {
730
+ const chat_history = await util.compressMessages(trimmedMsgs);
731
+ this.context.chat_history = chat_history;
732
+
733
+ // Persist to store
734
+ const store = this._store || Store.instance;
735
+ if (store && this.context_id) {
736
+ await store.save(this.context_id, {
737
+ chat_history,
738
+ tool_digest: this.context.tool_digest || [],
739
+ prompt: this.context.prompt,
740
+ tag: this.context.tag,
741
+ tm_closed: Date.now()
742
+ });
743
+ }
744
+ }
745
+
661
746
  await this.context.close();
662
747
  };
663
748
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
@@ -20,6 +20,7 @@
20
20
  "openai.js",
21
21
  "util.js",
22
22
  "redis.js",
23
+ "store.js",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
@@ -43,7 +44,16 @@
43
44
  "test": "NODE_ENV=test mocha --exit 'test/**/*.test.js'",
44
45
  "start": "node server.js"
45
46
  },
46
- "keywords": ["ai", "conversation", "orchestrator", "task", "hierarchy", "openai", "chatgpt", "llm"],
47
+ "keywords": [
48
+ "ai",
49
+ "conversation",
50
+ "orchestrator",
51
+ "task",
52
+ "hierarchy",
53
+ "openai",
54
+ "chatgpt",
55
+ "llm"
56
+ ],
47
57
  "author": "wanderli-ai",
48
58
  "license": "ISC"
49
59
  }
package/sid.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Itask = require('./itask.js');
4
4
  const { Context, createContext } = require('./context.js');
5
+ const { Store } = require('./store.js');
5
6
 
6
7
  /**
7
8
  * Sid - Session/User Context root task.
@@ -25,6 +26,7 @@ class Sid extends Itask {
25
26
  ...opt,
26
27
  name,
27
28
  prompt,
29
+ store: opt.store || Store.instance || null,
28
30
  async: true // We'll manage running ourselves
29
31
  }, states);
30
32
 
@@ -39,16 +41,23 @@ class Sid extends Itask {
39
41
  ...opt.sessionConfig
40
42
  };
41
43
 
44
+ // Generate context_id if not already set by parent constructor
45
+ if (!this.context_id) {
46
+ const store = this._store || Store.instance;
47
+ this.context_id = store ? store.generateId() : require('crypto').randomBytes(8).toString('hex');
48
+ }
49
+
42
50
  // Always create a context for Sid (root session task)
43
51
  const contextConfig = {
44
- tag: opt.tag || this.id,
52
+ tag: this.context_id,
45
53
  token_limit: this.sessionConfig.token_limit,
46
54
  max_depth: this.sessionConfig.max_depth,
47
55
  max_tool_repetition: this.sessionConfig.max_tool_repetition,
48
56
  tool_handler: opt.tool_handler,
49
57
  functions: opt.functions,
50
58
  sequential_mode: opt.sequential_mode,
51
- msgs: opt.msgs
59
+ msgs: opt.msgs,
60
+ chat_history: opt.chat_history
52
61
  };
53
62
 
54
63
  this.context = new Context(prompt, this, contextConfig);
@@ -61,9 +70,17 @@ class Sid extends Itask {
61
70
  }
62
71
  }
63
72
 
64
- // Override sendMessage to always use our context
65
- async sendMessage(role, content, functions, opts) {
66
- return this.context.sendMessage(role, content, functions || this.functions, opts);
73
+ // Override sendMessage new signature: sendMessage(content, functions, opts)
74
+ // Always sends as role='user' with '[BACKEND] ' prefix
75
+ async sendMessage(content, functions, opts) {
76
+ opts = Object.assign({}, opts, { tag: this.context_id });
77
+ return this.context.sendMessage('user', '[BACKEND] ' + content, functions || this.functions, opts);
78
+ }
79
+
80
+ // Receive a user chat message (no [BACKEND] prefix)
81
+ async recvChatMessage(content, opts) {
82
+ opts = Object.assign({}, opts, { tag: this.context_id });
83
+ return this.context.sendMessage('user', content, null, opts);
67
84
  }
68
85
 
69
86
  // Serialize the session for persistence
@@ -72,12 +89,15 @@ class Sid extends Itask {
72
89
  id: this.id,
73
90
  name: this.name,
74
91
  prompt: this.prompt,
92
+ context_id: this.context_id,
75
93
  userData: this.userData,
76
94
  sessionConfig: this.sessionConfig,
77
95
  context: {
78
96
  tag: this.context.tag,
79
97
  msgs: this.context._msgs,
80
- functions: this.context.functions
98
+ functions: this.context.functions,
99
+ chat_history: this.context.chat_history,
100
+ tool_digest: this.context.tool_digest
81
101
  },
82
102
  tm_create: this.tm_create
83
103
  });
@@ -90,11 +110,14 @@ class Sid extends Itask {
90
110
  const sid = new Sid({
91
111
  name: parsed.name,
92
112
  prompt: parsed.prompt,
113
+ context_id: parsed.context_id,
93
114
  userData: parsed.userData,
94
115
  sessionConfig: parsed.sessionConfig,
95
116
  tag: parsed.context?.tag,
96
117
  tool_handler: opt.tool_handler,
97
118
  functions: opt.functions || parsed.context?.functions,
119
+ chat_history: parsed.context?.chat_history,
120
+ store: opt.store,
98
121
  async: true, // Don't auto-run states
99
122
  ...opt
100
123
  }, opt.states || []);
@@ -108,6 +131,16 @@ class Sid extends Itask {
108
131
  sid.context._msgs = parsed.context.msgs;
109
132
  }
110
133
 
134
+ // Restore tool_digest
135
+ if (Array.isArray(parsed.context?.tool_digest)) {
136
+ sid.context.tool_digest = parsed.context.tool_digest;
137
+ }
138
+
139
+ // Load history from store if available
140
+ if (opt.store && parsed.context?.chat_history) {
141
+ sid.context.chat_history = parsed.context.chat_history;
142
+ }
143
+
111
144
  return sid;
112
145
  }
113
146
 
@@ -119,6 +152,7 @@ class Sid extends Itask {
119
152
  const childTask = new Itask({
120
153
  ...opt,
121
154
  spawn_parent: this,
155
+ store: this._store,
122
156
  async: true
123
157
  }, states);
124
158
 
@@ -150,6 +184,7 @@ class Sid extends Itask {
150
184
  const childTask = new Itask({
151
185
  ...opt,
152
186
  spawn_parent: this,
187
+ store: this._store,
153
188
  async: true
154
189
  }, states);
155
190
 
package/store.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ let _instance = null;
6
+
7
+ class DynamoBackend {
8
+ constructor({ table, aws }) {
9
+ this.table = table;
10
+ this.aws = aws;
11
+ }
12
+
13
+ async save(id, data) {
14
+ await this.aws.dynamoPutItem(this.table, {
15
+ id,
16
+ data: typeof data === 'string' ? data : JSON.stringify(data),
17
+ updated_at: Date.now()
18
+ });
19
+ }
20
+
21
+ async load(id) {
22
+ const item = await this.aws.dynamoGetItem(this.table, 'id', id);
23
+ if (!item)
24
+ return null;
25
+ const data = item.data;
26
+ if (typeof data === 'string') {
27
+ try { return JSON.parse(data); }
28
+ catch (e) { return data; }
29
+ }
30
+ return data;
31
+ }
32
+
33
+ async delete(id) {
34
+ await this.aws.dynamoDeleteItem(this.table, 'id', id);
35
+ }
36
+ }
37
+
38
+ class Store {
39
+ constructor(config = {}) {
40
+ this._redis = null;
41
+ this._backends = {};
42
+ this._config = config;
43
+
44
+ if (config.dynamodb) {
45
+ this._backends.dynamodb = new DynamoBackend(config.dynamodb);
46
+ }
47
+ }
48
+
49
+ static get instance() {
50
+ return _instance;
51
+ }
52
+
53
+ static set instance(val) {
54
+ _instance = val;
55
+ }
56
+
57
+ static init(config = {}) {
58
+ _instance = new Store(config);
59
+ // If redis module provided or redis is already initialized, grab the client
60
+ const redis = require('./redis.js');
61
+ if (redis.rclient)
62
+ _instance._redis = redis.rclient;
63
+ return _instance;
64
+ }
65
+
66
+ setRedis(rclient) {
67
+ this._redis = rclient;
68
+ }
69
+
70
+ addBackend(name, backend) {
71
+ this._backends[name] = backend;
72
+ }
73
+
74
+ generateId() {
75
+ return crypto.randomBytes(8).toString('hex');
76
+ }
77
+
78
+ async save(id, data) {
79
+ const key = 'saico:' + id;
80
+ const serialized = typeof data === 'string' ? data : JSON.stringify(data);
81
+
82
+ // Always save to Redis if available
83
+ if (this._redis) {
84
+ try {
85
+ await this._redis.set(key, serialized);
86
+ } catch (e) {
87
+ console.error('Store: Redis save error:', e.message);
88
+ }
89
+ }
90
+
91
+ // Save to all configured backends
92
+ for (const [name, backend] of Object.entries(this._backends)) {
93
+ try {
94
+ await backend.save(id, data);
95
+ } catch (e) {
96
+ console.error(`Store: ${name} backend save error:`, e.message);
97
+ }
98
+ }
99
+ }
100
+
101
+ async load(id) {
102
+ const key = 'saico:' + id;
103
+
104
+ // Try Redis first
105
+ if (this._redis) {
106
+ try {
107
+ const cached = await this._redis.get(key);
108
+ if (cached) {
109
+ try { return JSON.parse(cached); }
110
+ catch (e) { return cached; }
111
+ }
112
+ } catch (e) {
113
+ console.error('Store: Redis load error:', e.message);
114
+ }
115
+ }
116
+
117
+ // Fall back to backends
118
+ for (const [name, backend] of Object.entries(this._backends)) {
119
+ try {
120
+ const data = await backend.load(id);
121
+ if (data) {
122
+ // Cache to Redis for next time
123
+ if (this._redis) {
124
+ try {
125
+ const serialized = typeof data === 'string'
126
+ ? data : JSON.stringify(data);
127
+ await this._redis.set(key, serialized);
128
+ } catch (e) {
129
+ console.error('Store: Redis cache-back error:', e.message);
130
+ }
131
+ }
132
+ return data;
133
+ }
134
+ } catch (e) {
135
+ console.error(`Store: ${name} backend load error:`, e.message);
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ async delete(id) {
143
+ const key = 'saico:' + id;
144
+
145
+ if (this._redis) {
146
+ try {
147
+ await this._redis.del(key);
148
+ } catch (e) {
149
+ console.error('Store: Redis delete error:', e.message);
150
+ }
151
+ }
152
+
153
+ for (const [name, backend] of Object.entries(this._backends)) {
154
+ try {
155
+ await backend.delete(id);
156
+ } catch (e) {
157
+ console.error(`Store: ${name} backend delete error:`, e.message);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ module.exports = { Store, DynamoBackend };
package/util.js CHANGED
@@ -1,5 +1,9 @@
1
1
  const is_mocha = process.env.NODE_ENV == 'test';
2
2
  const tiktoken = require('tiktoken');
3
+ const zlib = require('zlib');
4
+ const { promisify } = require('util');
5
+ const gzip = promisify(zlib.gzip);
6
+ const gunzip = promisify(zlib.gunzip);
3
7
 
4
8
  const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
5
9
 
@@ -58,6 +62,8 @@ const lerr = _lerr;
58
62
 
59
63
  module.exports = {
60
64
  countTokens,
65
+ compressMessages,
66
+ decompressMessages,
61
67
  is_mocha,
62
68
  _log,
63
69
  _lerr,
@@ -70,6 +76,34 @@ module.exports = {
70
76
  logEvent,
71
77
  }
72
78
 
79
+ async function compressMessages(messages) {
80
+ const json = JSON.stringify(messages);
81
+ const compressed = await gzip(Buffer.from(json, 'utf8'));
82
+ return compressed.toString('base64');
83
+ }
84
+
85
+ async function decompressMessages(data) {
86
+ if (Array.isArray(data))
87
+ return data;
88
+ if (typeof data === 'string') {
89
+ // Try JSON parse first (plain JSON string)
90
+ try {
91
+ const parsed = JSON.parse(data);
92
+ if (Array.isArray(parsed))
93
+ return parsed;
94
+ } catch (e) { /* not plain JSON, try base64/gzip */ }
95
+ // Try base64 gzip
96
+ try {
97
+ const buf = Buffer.from(data, 'base64');
98
+ const decompressed = await gunzip(buf);
99
+ return JSON.parse(decompressed.toString('utf8'));
100
+ } catch (e) {
101
+ throw new Error('decompressMessages: unable to decompress data: ' + e.message);
102
+ }
103
+ }
104
+ throw new Error('decompressMessages: unsupported data type: ' + typeof data);
105
+ }
106
+
73
107
  function countTokens(messages, model = "gpt-4o") {
74
108
  // Load the encoding for the specified model
75
109
  const encoding = tiktoken.encoding_for_model(model);