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 +45 -1
- package/index.js +35 -1
- package/itask.js +87 -9
- package/package.json +12 -2
- package/sid.js +35 -6
- package/store.js +163 -0
- package/util.js +34 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
//
|
|
628
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
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": [
|
|
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:
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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);
|