saico 2.1.0 → 2.2.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.
Files changed (4) hide show
  1. package/context.js +114 -31
  2. package/itask.js +9 -2
  3. package/package.json +1 -1
  4. package/sid.js +7 -1
package/context.js CHANGED
@@ -46,6 +46,14 @@ class Context {
46
46
  this._processing_sequential = false;
47
47
  this._sequential_mode = config.sequential_mode || false;
48
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
+
49
57
  // Initialize messages if provided
50
58
  (config.msgs || []).forEach(m => this.push(m));
51
59
 
@@ -61,6 +69,35 @@ class Context {
61
69
  this.functions = task?.functions;
62
70
  }
63
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
+
64
101
  // Get the parent context by traversing task hierarchy
65
102
  getParentContext() {
66
103
  if (!this.task || !this.task.parent)
@@ -248,6 +285,8 @@ class Context {
248
285
  };
249
286
  } else {
250
287
  this._trackActiveToolCall(call);
288
+ const _snap = this.task
289
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
251
290
 
252
291
  try {
253
292
  const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
@@ -255,6 +294,9 @@ class Context {
255
294
  const timeout = correspondingDeferred?.originalMessage.opts.timeout;
256
295
 
257
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 || '');
258
300
  } finally {
259
301
  this._completeActiveToolCall(call);
260
302
  }
@@ -405,7 +447,11 @@ class Context {
405
447
  if (!store || !this.tag)
406
448
  return;
407
449
  const data = await store.load(this.tag);
408
- if (!data || !data.chat_history)
450
+ if (!data)
451
+ return;
452
+ if (Array.isArray(data.tool_digest))
453
+ this.tool_digest = data.tool_digest;
454
+ if (!data.chat_history)
409
455
  return;
410
456
  const messages = await util.decompressMessages(data.chat_history);
411
457
  if (!Array.isArray(messages) || messages.length === 0)
@@ -718,50 +764,83 @@ class Context {
718
764
  return validatedMsgs;
719
765
  }
720
766
 
721
- // 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)
722
800
  _createMsgQ(add_tag, tag_filter) {
723
801
  const fullQueue = [];
724
-
725
- // Get messages from ancestor contexts via task hierarchy
726
802
  const ancestorContexts = this.getAncestorContexts();
803
+
804
+ // Layer 1+2: Each level's prompt followed immediately by its state summary
727
805
  for (const ctx of ancestorContexts) {
728
806
  if (ctx.prompt) {
729
807
  const prompt = {role: 'system', content: ctx.prompt};
730
- if (add_tag)
731
- prompt.tag = ctx.tag;
808
+ if (add_tag) prompt.tag = ctx.tag;
732
809
  fullQueue.push(prompt);
733
810
  }
734
-
735
- let ctxMsgs;
736
- if (tag_filter !== undefined) {
737
- ctxMsgs = ctx._msgs.filter(m => {
738
- if (m.msg.role === 'system') return true;
739
- if (m.opts.summary) return true;
740
- if (m.opts.tag === tag_filter) return true;
741
- return false;
742
- }).map(m => m.msg);
743
- } else {
744
- ctxMsgs = ctx.__msgs;
745
- }
746
-
747
- if (add_tag)
748
- ctxMsgs = ctxMsgs.map(m => Object.assign({tag: ctx.tag}, m));
749
-
750
- fullQueue.push(...ctxMsgs);
811
+ const ctxSummary = ctx.getStateSummary();
812
+ if (ctxSummary)
813
+ fullQueue.push({role: 'system', content: '[State Summary]\n' + ctxSummary});
751
814
  }
752
-
753
- // Add this context's prompt and messages
754
815
  if (this.prompt) {
755
816
  const prompt = {role: 'system', content: this.prompt};
756
- if (add_tag)
757
- prompt.tag = this.tag;
817
+ if (add_tag) prompt.tag = this.tag;
758
818
  fullQueue.push(prompt);
759
819
  }
820
+ const stateSummary = this.getStateSummary();
821
+ if (stateSummary)
822
+ fullQueue.push({role: 'system', content: '[State Summary]\n' + stateSummary});
823
+
824
+ // Layer 3: Tool digest (if non-empty)
825
+ if (this.tool_digest.length > 0) {
826
+ const digestText = this.tool_digest.map(entry =>
827
+ `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
828
+ ).join('\n');
829
+ fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
830
+ }
831
+
832
+ // Layer 4: Ancestor summaries only (no full ancestor messages)
833
+ for (const ctx of ancestorContexts) {
834
+ const summaries = ctx._msgs
835
+ .filter(m => m.opts.summary)
836
+ .map(m => add_tag ? Object.assign({}, m.msg, {tag: ctx.tag}) : m.msg);
837
+ fullQueue.push(...summaries);
838
+ }
760
839
 
840
+ // Own messages — filter by tag if requested, then slice to QUEUE_LIMIT
761
841
  let my_msgs;
762
842
  if (tag_filter !== undefined) {
763
843
  my_msgs = this._msgs.filter(m => {
764
- if (m.msg.role === 'system') return true;
765
844
  if (m.opts.summary) return true;
766
845
  if (m.opts.tag === tag_filter) return true;
767
846
  return false;
@@ -771,9 +850,9 @@ class Context {
771
850
  }
772
851
 
773
852
  if (add_tag)
774
- my_msgs = my_msgs.map(m => Object.assign({tag: this.tag}, m));
853
+ my_msgs = my_msgs.map(m => Object.assign({}, m, {tag: this.tag}));
775
854
 
776
- fullQueue.push(...my_msgs);
855
+ fullQueue.push(...this._getQueueSlice(my_msgs, this.QUEUE_LIMIT));
777
856
 
778
857
  return this._validateToolResponses(fullQueue);
779
858
  }
@@ -864,7 +943,6 @@ class Context {
864
943
  this._processWaitingQueue();
865
944
  }
866
945
 
867
- await this.summarizeMessages();
868
946
  Q = this._createMsgQ(false, o.opts?.tag);
869
947
 
870
948
  // Aggregate functions from hierarchy and merge with message-specific functions
@@ -939,11 +1017,16 @@ class Context {
939
1017
 
940
1018
  for (const { call, isDuplicate } of toolCallsWithResults) {
941
1019
  if (!isDuplicate) {
1020
+ const _snap = this.task
1021
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
942
1022
  try {
943
1023
  const result = await this._executeToolCallWithTimeout(
944
1024
  call, o.opts?.handler, o.opts?.timeout);
945
1025
  const item = toolCallsWithResults.find(item => item.call.id === call.id);
946
1026
  if (item) item.result = result;
1027
+ if (_snap !== null &&
1028
+ _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
1029
+ this._appendToolDigest(call.function.name, result?.content || '');
947
1030
  } finally {
948
1031
  this._completeActiveToolCall(call);
949
1032
  }
package/itask.js CHANGED
@@ -720,8 +720,14 @@ Itask.prototype.closeContext = async function closeContext(){
720
720
  return true;
721
721
  }).map(m => m.msg);
722
722
 
723
- if (cleanedMsgs.length > 0) {
724
- const chat_history = await util.compressMessages(cleanedMsgs);
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);
725
731
  this.context.chat_history = chat_history;
726
732
 
727
733
  // Persist to store
@@ -729,6 +735,7 @@ Itask.prototype.closeContext = async function closeContext(){
729
735
  if (store && this.context_id) {
730
736
  await store.save(this.context_id, {
731
737
  chat_history,
738
+ tool_digest: this.context.tool_digest || [],
732
739
  prompt: this.context.prompt,
733
740
  tag: this.context.tag,
734
741
  tm_closed: Date.now()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
package/sid.js CHANGED
@@ -96,7 +96,8 @@ class Sid extends Itask {
96
96
  tag: this.context.tag,
97
97
  msgs: this.context._msgs,
98
98
  functions: this.context.functions,
99
- chat_history: this.context.chat_history
99
+ chat_history: this.context.chat_history,
100
+ tool_digest: this.context.tool_digest
100
101
  },
101
102
  tm_create: this.tm_create
102
103
  });
@@ -130,6 +131,11 @@ class Sid extends Itask {
130
131
  sid.context._msgs = parsed.context.msgs;
131
132
  }
132
133
 
134
+ // Restore tool_digest
135
+ if (Array.isArray(parsed.context?.tool_digest)) {
136
+ sid.context.tool_digest = parsed.context.tool_digest;
137
+ }
138
+
133
139
  // Load history from store if available
134
140
  if (opt.store && parsed.context?.chat_history) {
135
141
  sid.context.chat_history = parsed.context.chat_history;