adaptive-memory-multi-model-router 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -44
- package/dist/integrations/airtable.js +20 -0
- package/dist/integrations/discord.js +18 -0
- package/dist/integrations/github.js +23 -0
- package/dist/integrations/gmail.js +19 -0
- package/dist/integrations/google-calendar.js +18 -0
- package/dist/integrations/index.js +61 -0
- package/dist/integrations/jira.js +21 -0
- package/dist/integrations/linear.js +19 -0
- package/dist/integrations/notion.js +19 -0
- package/dist/integrations/oauth.js +26 -0
- package/dist/integrations/slack.js +18 -0
- package/dist/integrations/telegram.js +19 -0
- package/dist/memory/autoFetch.js +59 -0
- package/dist/memory/autoFetch.ts +109 -0
- package/dist/memory/memoryTree.js +43 -0
- package/dist/memory/obsidianVault.js +26 -0
- package/dist/utils/enhancedCompression.js +180 -0
- package/package.json +1 -1
- package/package.json.tmp +0 -0
- package/src/integrations/oauth.ts +280 -0
- package/src/memory/autoFetch.ts +109 -0
- package/src/memory/memoryTree.ts +242 -0
- package/src/memory/obsidianVault.ts +224 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Tree Hierarchy
|
|
3
|
+
*
|
|
4
|
+
* Canonicalizes data into ≤3k-token chunks, scores them,
|
|
5
|
+
* and builds hierarchical summary trees.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface MemoryChunk {
|
|
9
|
+
id: string;
|
|
10
|
+
content: string;
|
|
11
|
+
score: number;
|
|
12
|
+
parentId?: string;
|
|
13
|
+
depth: number;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
accessCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TreeNode {
|
|
19
|
+
id: string;
|
|
20
|
+
chunks: MemoryChunk[];
|
|
21
|
+
summary: string;
|
|
22
|
+
children: TreeNode[];
|
|
23
|
+
depth: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MemoryTree {
|
|
27
|
+
private maxChunkSize: number;
|
|
28
|
+
private root: TreeNode;
|
|
29
|
+
private chunks: Map<string, MemoryChunk>;
|
|
30
|
+
private idCounter: number;
|
|
31
|
+
|
|
32
|
+
constructor(maxChunkSize = 3000) {
|
|
33
|
+
this.maxChunkSize = maxChunkSize;
|
|
34
|
+
this.root = this.createNode('root');
|
|
35
|
+
this.chunks = new Map();
|
|
36
|
+
this.idCounter = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private createNode(id: string, depth = 0): TreeNode {
|
|
40
|
+
return { id, chunks: [], summary: '', children: [], depth };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private generateId(): string {
|
|
44
|
+
return `chunk_${Date.now()}_${this.idCounter++}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add data to the memory tree
|
|
49
|
+
*/
|
|
50
|
+
async add(data: string): Promise<MemoryChunk[]> {
|
|
51
|
+
const textChunks = this.chunk(data);
|
|
52
|
+
const addedChunks: MemoryChunk[] = [];
|
|
53
|
+
|
|
54
|
+
for (const text of textChunks) {
|
|
55
|
+
const score = await this.scoreChunk(text);
|
|
56
|
+
const chunk: MemoryChunk = {
|
|
57
|
+
id: this.generateId(),
|
|
58
|
+
content: text,
|
|
59
|
+
score,
|
|
60
|
+
depth: 0,
|
|
61
|
+
createdAt: Date.now(),
|
|
62
|
+
accessCount: 0
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
this.chunks.set(chunk.id, chunk);
|
|
66
|
+
this.root.chunks.push(chunk);
|
|
67
|
+
this.insertIntoTree(chunk);
|
|
68
|
+
addedChunks.push(chunk);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await this.updateSummaries();
|
|
72
|
+
return addedChunks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split text into chunks of maxChunkSize
|
|
77
|
+
*/
|
|
78
|
+
private chunk(text: string): string[] {
|
|
79
|
+
const chunks: string[] = [];
|
|
80
|
+
const words = text.split(/\s+/);
|
|
81
|
+
let current: string[] = [];
|
|
82
|
+
let currentSize = 0;
|
|
83
|
+
|
|
84
|
+
for (const word of words) {
|
|
85
|
+
currentSize += word.length + 1;
|
|
86
|
+
if (currentSize > this.maxChunkSize) {
|
|
87
|
+
chunks.push(current.join(' '));
|
|
88
|
+
current = [word];
|
|
89
|
+
currentSize = word.length + 1;
|
|
90
|
+
} else {
|
|
91
|
+
current.push(word);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (current.length > 0) {
|
|
96
|
+
chunks.push(current.join(' '));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return chunks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Score a chunk by relevance
|
|
104
|
+
*/
|
|
105
|
+
private async scoreChunk(content: string): Promise<number> {
|
|
106
|
+
// Simple scoring: length + unique words ratio
|
|
107
|
+
const words = content.split(/\s+/);
|
|
108
|
+
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
|
|
109
|
+
const uniqueRatio = uniqueWords.size / words.length;
|
|
110
|
+
|
|
111
|
+
// Base score + bonus for high unique ratio
|
|
112
|
+
return Math.min(1, (content.length / this.maxChunkSize) * uniqueRatio * 1.5);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Insert chunk into tree hierarchy
|
|
117
|
+
*/
|
|
118
|
+
private insertIntoTree(chunk: MemoryChunk) {
|
|
119
|
+
let parent = this.root;
|
|
120
|
+
|
|
121
|
+
while (parent.children.length > 0 && chunk.score < 0.5) {
|
|
122
|
+
// Find best matching child
|
|
123
|
+
let bestChild = parent.children[0];
|
|
124
|
+
let bestScore = 0;
|
|
125
|
+
|
|
126
|
+
for (const child of parent.children) {
|
|
127
|
+
const avgScore = this.getAverageScore(child);
|
|
128
|
+
if (avgScore > bestScore && avgScore >= chunk.score) {
|
|
129
|
+
bestScore = avgScore;
|
|
130
|
+
bestChild = child;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (bestScore >= chunk.score) {
|
|
135
|
+
parent = bestChild;
|
|
136
|
+
chunk.depth = parent.depth + 1;
|
|
137
|
+
chunk.parentId = parent.id;
|
|
138
|
+
} else {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
parent.chunks.push(chunk);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private getAverageScore(node: TreeNode): number {
|
|
147
|
+
if (node.chunks.length === 0) return 0;
|
|
148
|
+
return node.chunks.reduce((sum, c) => sum + c.score, 0) / node.chunks.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Update summaries for tree nodes
|
|
153
|
+
*/
|
|
154
|
+
private async updateSummaries() {
|
|
155
|
+
this.summarizeNode(this.root);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private summarizeNode(node: TreeNode) {
|
|
159
|
+
const allContent = node.chunks.map(c => c.content).join(' ');
|
|
160
|
+
node.summary = allContent.slice(0, 200) + (allContent.length > 200 ? '...' : '');
|
|
161
|
+
|
|
162
|
+
for (const child of node.children) {
|
|
163
|
+
this.summarizeNode(child);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Search chunks by content
|
|
169
|
+
*/
|
|
170
|
+
search(query: string): MemoryChunk[] {
|
|
171
|
+
const results: MemoryChunk[] = [];
|
|
172
|
+
const queryLower = query.toLowerCase();
|
|
173
|
+
|
|
174
|
+
for (const chunk of this.chunks.values()) {
|
|
175
|
+
if (chunk.content.toLowerCase().includes(queryLower)) {
|
|
176
|
+
chunk.accessCount++;
|
|
177
|
+
results.push(chunk);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return results.sort((a, b) => b.score - a.score);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get context for routing
|
|
186
|
+
*/
|
|
187
|
+
getContext(maxTokens = 3000): string {
|
|
188
|
+
const allChunks = Array.from(this.chunks.values())
|
|
189
|
+
.sort((a, b) => b.score - a.score)
|
|
190
|
+
.slice(0, 10);
|
|
191
|
+
|
|
192
|
+
let context = allChunks.map(c => c.content).join('\n\n');
|
|
193
|
+
|
|
194
|
+
if (context.length > maxTokens) {
|
|
195
|
+
context = context.slice(0, maxTokens) + '...';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return context;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Export as markdown for Obsidian
|
|
203
|
+
*/
|
|
204
|
+
toMarkdown(): string {
|
|
205
|
+
const lines: string[] = ['# Memory Tree\n'];
|
|
206
|
+
|
|
207
|
+
const traverse = (node: TreeNode, prefix = '') => {
|
|
208
|
+
for (const chunk of node.chunks) {
|
|
209
|
+
lines.push(`${prefix}## ${chunk.id} (score: ${chunk.score.toFixed(2)})`);
|
|
210
|
+
lines.push(chunk.content);
|
|
211
|
+
lines.push('');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const child of node.children) {
|
|
215
|
+
lines.push(`${prefix}### ${child.id}`);
|
|
216
|
+
traverse(child, prefix + '#');
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
traverse(this.root);
|
|
221
|
+
return lines.join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get tree stats
|
|
226
|
+
*/
|
|
227
|
+
getStats() {
|
|
228
|
+
return {
|
|
229
|
+
totalChunks: this.chunks.size,
|
|
230
|
+
maxDepth: this.getMaxDepth(this.root),
|
|
231
|
+
rootChunks: this.root.chunks.length,
|
|
232
|
+
treeSize: JSON.stringify(this.root).length
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private getMaxDepth(node: TreeNode): number {
|
|
237
|
+
if (node.children.length === 0) return node.depth;
|
|
238
|
+
return Math.max(...node.children.map(c => this.getMaxDepth(c)));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default MemoryTree;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian Vault Integration
|
|
3
|
+
*
|
|
4
|
+
* Stores routing decisions and context as markdown files
|
|
5
|
+
* compatible with Obsidian note-taking app.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
|
|
11
|
+
export interface VaultConfig {
|
|
12
|
+
path: string;
|
|
13
|
+
autoSave: boolean;
|
|
14
|
+
maxFileAge: number; // days
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RoutingDecision {
|
|
18
|
+
id: string;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
prompt: string;
|
|
21
|
+
context: any;
|
|
22
|
+
selectedProvider: string;
|
|
23
|
+
selectedModel: string;
|
|
24
|
+
reasoning: string;
|
|
25
|
+
cost: number;
|
|
26
|
+
latency: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ObsidianVault {
|
|
30
|
+
private config: VaultConfig;
|
|
31
|
+
private decisions: RoutingDecision[];
|
|
32
|
+
|
|
33
|
+
constructor(config: Partial<VaultConfig> = {}) {
|
|
34
|
+
this.config = {
|
|
35
|
+
path: config.path || './vault',
|
|
36
|
+
autoSave: config.autoSave !== false,
|
|
37
|
+
maxFileAge: config.maxFileAge || 30
|
|
38
|
+
};
|
|
39
|
+
this.decisions = [];
|
|
40
|
+
this.ensureDirectory();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private ensureDirectory() {
|
|
44
|
+
if (!fs.existsSync(this.config.path)) {
|
|
45
|
+
fs.mkdirSync(this.config.path, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Save a routing decision
|
|
51
|
+
*/
|
|
52
|
+
async saveDecision(decision: RoutingDecision): Promise<string> {
|
|
53
|
+
this.decisions.push(decision);
|
|
54
|
+
|
|
55
|
+
const filename = `routing-decision-${decision.id}.md`;
|
|
56
|
+
const filepath = path.join(this.config.path, filename);
|
|
57
|
+
const content = this.formatDecisionAsMarkdown(decision);
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(filepath, content, 'utf-8');
|
|
60
|
+
|
|
61
|
+
// Update index
|
|
62
|
+
await this.updateIndex();
|
|
63
|
+
|
|
64
|
+
return filepath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format decision as markdown
|
|
69
|
+
*/
|
|
70
|
+
private formatDecisionAsMarkdown(decision: RoutingDecision): string {
|
|
71
|
+
const date = new Date(decision.timestamp).toISOString();
|
|
72
|
+
|
|
73
|
+
return `# Routing Decision ${decision.id}
|
|
74
|
+
|
|
75
|
+
**Date:** ${date}
|
|
76
|
+
**Prompt:** ${this.escapeMarkdown(decision.prompt.substring(0, 200))}
|
|
77
|
+
|
|
78
|
+
## Decision
|
|
79
|
+
|
|
80
|
+
| Field | Value |
|
|
81
|
+
|-------|-------|
|
|
82
|
+
| Provider | ${decision.selectedProvider} |
|
|
83
|
+
| Model | ${decision.selectedModel} |
|
|
84
|
+
| Reasoning | ${this.escapeMarkdown(decision.reasoning)} |
|
|
85
|
+
| Cost | $${decision.cost.toFixed(4)} |
|
|
86
|
+
| Latency | ${decision.latency}ms |
|
|
87
|
+
|
|
88
|
+
## Context
|
|
89
|
+
|
|
90
|
+
\`\`\`json
|
|
91
|
+
${JSON.stringify(decision.context, null, 2)}
|
|
92
|
+
\`\`\`
|
|
93
|
+
|
|
94
|
+
## Prompt (Full)
|
|
95
|
+
|
|
96
|
+
${decision.prompt}
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
*Generated by A3M Router*
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Escape markdown special characters
|
|
105
|
+
*/
|
|
106
|
+
private escapeMarkdown(text: string): string {
|
|
107
|
+
return text
|
|
108
|
+
.replace(/\*/g, '\\*')
|
|
109
|
+
.replace(/#/g, '\\#')
|
|
110
|
+
.replace(/\|/g, '\\|')
|
|
111
|
+
.replace(/`/g, '\\`');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Update the vault index
|
|
116
|
+
*/
|
|
117
|
+
private async updateIndex() {
|
|
118
|
+
const indexPath = path.join(this.config.path, 'routing-index.md');
|
|
119
|
+
|
|
120
|
+
const lines = [
|
|
121
|
+
'# Routing Decisions Index',
|
|
122
|
+
'',
|
|
123
|
+
`Last updated: ${new Date().toISOString()}`,
|
|
124
|
+
'',
|
|
125
|
+
`Total decisions: ${this.decisions.length}`,
|
|
126
|
+
'',
|
|
127
|
+
'## Recent Decisions',
|
|
128
|
+
''
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const decision of this.decisions.slice(-20).reverse()) {
|
|
132
|
+
const date = new Date(decision.timestamp).toLocaleDateString();
|
|
133
|
+
lines.push(`- [${decision.id}](./routing-decision-${decision.id}.md) - ${date} - ${decision.selectedProvider}/${decision.selectedModel}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fs.writeFileSync(indexPath, lines.join('\n'), 'utf-8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get recent decisions
|
|
141
|
+
*/
|
|
142
|
+
getRecentDecisions(count = 10): RoutingDecision[] {
|
|
143
|
+
return this.decisions.slice(-count).reverse();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Search decisions
|
|
148
|
+
*/
|
|
149
|
+
searchDecisions(query: string): RoutingDecision[] {
|
|
150
|
+
const queryLower = query.toLowerCase();
|
|
151
|
+
return this.decisions.filter(d =>
|
|
152
|
+
d.prompt.toLowerCase().includes(queryLower) ||
|
|
153
|
+
d.selectedProvider.toLowerCase().includes(queryLower) ||
|
|
154
|
+
d.selectedModel.toLowerCase().includes(queryLower)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Export all decisions
|
|
160
|
+
*/
|
|
161
|
+
async exportAll(filepath: string) {
|
|
162
|
+
const content = this.decisions.map(d =>
|
|
163
|
+
this.formatDecisionAsMarkdown(d)
|
|
164
|
+
).join('\n\n---\n\n');
|
|
165
|
+
|
|
166
|
+
fs.writeFileSync(filepath, content, 'utf-8');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get vault statistics
|
|
171
|
+
*/
|
|
172
|
+
getStats() {
|
|
173
|
+
return {
|
|
174
|
+
totalDecisions: this.decisions.length,
|
|
175
|
+
vaultPath: this.config.path,
|
|
176
|
+
fileCount: fs.readdirSync(this.config.path).filter(f => f.endsWith('.md')).length,
|
|
177
|
+
totalSize: this.getDirectorySize(this.config.path)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private getDirectorySize(dir: string): number {
|
|
182
|
+
let size = 0;
|
|
183
|
+
const files = fs.readdirSync(dir);
|
|
184
|
+
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const filepath = path.join(dir, file);
|
|
187
|
+
const stat = fs.statSync(filepath);
|
|
188
|
+
|
|
189
|
+
if (stat.isDirectory()) {
|
|
190
|
+
size += this.getDirectorySize(filepath);
|
|
191
|
+
} else {
|
|
192
|
+
size += stat.size;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return size;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Clean old files
|
|
201
|
+
*/
|
|
202
|
+
async cleanOldFiles() {
|
|
203
|
+
const maxAge = this.config.maxFileAge * 24 * 60 * 60 * 1000;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const files = fs.readdirSync(this.config.path);
|
|
206
|
+
let cleaned = 0;
|
|
207
|
+
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
if (!file.endsWith('.md') || file === 'routing-index.md') continue;
|
|
210
|
+
|
|
211
|
+
const filepath = path.join(this.config.path, file);
|
|
212
|
+
const stat = fs.statSync(filepath);
|
|
213
|
+
|
|
214
|
+
if (now - stat.mtimeMs > maxAge) {
|
|
215
|
+
fs.unlinkSync(filepath);
|
|
216
|
+
cleaned++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return cleaned;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export default ObsidianVault;
|