saico 2.0.0 → 2.1.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();
@@ -397,6 +400,44 @@ class Context {
397
400
  _log('Finished closing Context tag', this.tag);
398
401
  }
399
402
 
403
+ // Load chat history from store into message queue
404
+ async loadHistory(store) {
405
+ if (!store || !this.tag)
406
+ return;
407
+ const data = await store.load(this.tag);
408
+ if (!data || !data.chat_history)
409
+ return;
410
+ const messages = await util.decompressMessages(data.chat_history);
411
+ if (!Array.isArray(messages) || messages.length === 0)
412
+ return;
413
+ // Find the index after the last system message to insert history
414
+ let insertIdx = 0;
415
+ for (let i = 0; i < this._msgs.length; i++) {
416
+ if (this._msgs[i].msg.role === 'system')
417
+ insertIdx = i + 1;
418
+ }
419
+ const historyMsgs = messages.map(m => ({
420
+ msg: m,
421
+ opts: {},
422
+ msgid: crypto.randomBytes(2).toString('hex'),
423
+ replied: 1
424
+ }));
425
+ this._msgs.splice(insertIdx, 0, ...historyMsgs);
426
+ }
427
+
428
+ // Remove tool-related messages tagged with a specific tag
429
+ cleanToolCallsByTag(tag) {
430
+ this._msgs = this._msgs.filter(m => {
431
+ if (m.opts.tag !== tag)
432
+ return true;
433
+ if (m.msg.tool_calls)
434
+ return false;
435
+ if (m.msg.role === 'tool')
436
+ return false;
437
+ return true;
438
+ });
439
+ }
440
+
400
441
  async _summarizeContext(close, targetCtx) {
401
442
  const keep = this._msgs.filter(m => !close && m.summary);
402
443
  const summarize = this._msgs.filter(m => (!close || !m.summary) && m.replied);
@@ -971,7 +1012,10 @@ class Context {
971
1012
  if (content && typeof content !== 'string')
972
1013
  content = JSON.stringify(content);
973
1014
  else if (!content)
974
- content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg from the user`;
1015
+ {
1016
+ content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg `
1017
+ +`from the user`;
1018
+ }
975
1019
 
976
1020
  _log('FUNCTION RESULT', call.function.name, call.id, content.substring(0, 50) + '...',
977
1021
  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,38 @@ 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
+ if (cleanedMsgs.length > 0) {
724
+ const chat_history = await util.compressMessages(cleanedMsgs);
725
+ this.context.chat_history = chat_history;
726
+
727
+ // Persist to store
728
+ const store = this._store || Store.instance;
729
+ if (store && this.context_id) {
730
+ await store.save(this.context_id, {
731
+ chat_history,
732
+ prompt: this.context.prompt,
733
+ tag: this.context.tag,
734
+ tm_closed: Date.now()
735
+ });
736
+ }
737
+ }
738
+
661
739
  await this.context.close();
662
740
  };
663
741
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.0.0",
3
+ "version": "2.1.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,14 @@ 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
81
100
  },
82
101
  tm_create: this.tm_create
83
102
  });
@@ -90,11 +109,14 @@ class Sid extends Itask {
90
109
  const sid = new Sid({
91
110
  name: parsed.name,
92
111
  prompt: parsed.prompt,
112
+ context_id: parsed.context_id,
93
113
  userData: parsed.userData,
94
114
  sessionConfig: parsed.sessionConfig,
95
115
  tag: parsed.context?.tag,
96
116
  tool_handler: opt.tool_handler,
97
117
  functions: opt.functions || parsed.context?.functions,
118
+ chat_history: parsed.context?.chat_history,
119
+ store: opt.store,
98
120
  async: true, // Don't auto-run states
99
121
  ...opt
100
122
  }, opt.states || []);
@@ -108,6 +130,11 @@ class Sid extends Itask {
108
130
  sid.context._msgs = parsed.context.msgs;
109
131
  }
110
132
 
133
+ // Load history from store if available
134
+ if (opt.store && parsed.context?.chat_history) {
135
+ sid.context.chat_history = parsed.context.chat_history;
136
+ }
137
+
111
138
  return sid;
112
139
  }
113
140
 
@@ -119,6 +146,7 @@ class Sid extends Itask {
119
146
  const childTask = new Itask({
120
147
  ...opt,
121
148
  spawn_parent: this,
149
+ store: this._store,
122
150
  async: true
123
151
  }, states);
124
152
 
@@ -150,6 +178,7 @@ class Sid extends Itask {
150
178
  const childTask = new Itask({
151
179
  ...opt,
152
180
  spawn_parent: this,
181
+ store: this._store,
153
182
  async: true
154
183
  }, states);
155
184
 
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);