@sns-myagent/cli 0.2.0 → 0.3.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.
- package/CHANGELOG.md +12 -1
- package/README.md +7 -8
- package/bin/.gitkeep +0 -0
- package/bin/snscoder.js +7 -96
- package/dist/cli.js +22026 -0
- package/package.json +4 -3
- package/scripts/apply-pi-natives-patch.js +82 -56
- package/scripts/fetch-binary.mjs +106 -217
- package/scripts/smoke-test.sh +92 -0
- package/src/adapters/telegram/bot.ts +41 -1
- package/src/adapters/telegram/bridge.ts +186 -0
- package/src/adapters/telegram/handler.ts +138 -13
- package/src/adapters/telegram/index.ts +12 -2
- package/src/agents/__tests__/config.test.ts +79 -0
- package/src/agents/__tests__/resilience.test.ts +119 -0
- package/src/agents/__tests__/strategies.test.ts +83 -0
- package/src/agents/config.ts +367 -0
- package/src/agents/ensemble.ts +230 -0
- package/src/agents/resilience.ts +332 -0
- package/src/agents/strategies/best-of-n.ts +108 -0
- package/src/agents/strategies/consensus.ts +104 -0
- package/src/agents/strategies/critic.ts +131 -0
- package/src/agents/strategies/index.ts +12 -0
- package/src/agents/strategies/types.ts +68 -0
- package/src/async/__tests__/task-runner.test.ts +162 -0
- package/src/async/__tests__/task-store.test.ts +146 -0
- package/src/async/index.ts +28 -1
- package/src/async/notifier.ts +133 -0
- package/src/async/task-runner.ts +175 -0
- package/src/async/task-store.ts +170 -0
- package/src/async/types.ts +70 -0
- package/src/cli/entry.ts +3 -1
- package/src/cli/index.ts +74 -55
- package/src/config/index.ts +1 -1
- package/src/debug/index.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +0 -2
- package/src/modes/components/welcome.ts +13 -6
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/session/agent-session.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +63 -0
- package/src/slash-commands/helpers/task.ts +181 -0
- package/src/tbm/__tests__/tbm.test.ts +660 -0
- package/src/tbm/comm-modes.ts +165 -0
- package/src/tbm/config.ts +136 -0
- package/src/tbm/context-delta.ts +146 -0
- package/src/tbm/context-pyramid.ts +202 -0
- package/src/tbm/dashboard.ts +131 -0
- package/src/tbm/index.ts +247 -0
- package/src/tbm/lazy-skills.ts +182 -0
- package/src/tbm/response-cache.ts +220 -0
- package/src/tbm/tombstone.ts +189 -0
- package/src/tbm/tool-compress.ts +230 -0
- package/src/tools/ask.ts +1 -1
- package/src/tui/chat-blocks.ts +205 -0
- package/src/tui/chat-ui.ts +270 -0
- package/src/tui/code-cell.ts +90 -1
- package/src/tui/command-palette.ts +189 -0
- package/src/tui/index.ts +4 -0
- package/src/tui/splash.ts +130 -0
- package/src/ui/banner.ts +69 -29
- package/src/ui/colors.ts +24 -0
- package/src/ui/error-display.ts +130 -0
- package/src/ui/gradient.ts +104 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/memory-toast.ts +102 -0
- package/src/ui/status-bar.ts +36 -30
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Cache — exact and semantic match for cached responses.
|
|
3
|
+
*
|
|
4
|
+
* - Exact match: hash(query) → cached response
|
|
5
|
+
* - Semantic match: embedding similarity > 0.95
|
|
6
|
+
* - TTL-based expiry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { estimateTokens } from "./context-delta";
|
|
11
|
+
|
|
12
|
+
export interface CacheEntry {
|
|
13
|
+
/** Hash of the query for exact match. */
|
|
14
|
+
queryHash: string;
|
|
15
|
+
/** Original query text. */
|
|
16
|
+
query: string;
|
|
17
|
+
/** Cached response. */
|
|
18
|
+
response: string;
|
|
19
|
+
/** When this entry was created. */
|
|
20
|
+
createdAt: number;
|
|
21
|
+
/** When this entry expires. */
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
/** How many times this cache was hit. */
|
|
24
|
+
hitCount: number;
|
|
25
|
+
/** Token count of the cached response. */
|
|
26
|
+
responseTokens: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResponseCacheStats {
|
|
30
|
+
/** Total queries processed. */
|
|
31
|
+
totalQueries: number;
|
|
32
|
+
/** Exact cache hits. */
|
|
33
|
+
exactHits: number;
|
|
34
|
+
/** Semantic cache hits. */
|
|
35
|
+
semanticHits: number;
|
|
36
|
+
/** Cache misses. */
|
|
37
|
+
misses: number;
|
|
38
|
+
/** Total tokens saved by cache hits. */
|
|
39
|
+
tokensSaved: number;
|
|
40
|
+
/** Current cache size. */
|
|
41
|
+
cacheSize: number;
|
|
42
|
+
/** Hit rate (exact + semantic / total). */
|
|
43
|
+
hitRate: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashQuery(query: string): string {
|
|
47
|
+
return createHash("sha256").update(query.trim().toLowerCase()).digest("hex").slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Simple Jaccard similarity for semantic matching.
|
|
52
|
+
* Uses word-level n-grams (bigrams) for comparison.
|
|
53
|
+
*/
|
|
54
|
+
function jaccardSimilarity(a: string, b: string): number {
|
|
55
|
+
const bigramsA = getBigrams(a);
|
|
56
|
+
const bigramsB = getBigrams(b);
|
|
57
|
+
|
|
58
|
+
if (bigramsA.size === 0 && bigramsB.size === 0) return 1;
|
|
59
|
+
if (bigramsA.size === 0 || bigramsB.size === 0) return 0;
|
|
60
|
+
|
|
61
|
+
let intersection = 0;
|
|
62
|
+
for (const bg of bigramsA) {
|
|
63
|
+
if (bigramsB.has(bg)) intersection++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const union = bigramsA.size + bigramsB.size - intersection;
|
|
67
|
+
return union > 0 ? intersection / union : 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getBigrams(text: string): Set<string> {
|
|
71
|
+
const words = text.toLowerCase().trim().split(/\s+/);
|
|
72
|
+
const bigrams = new Set<string>();
|
|
73
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
74
|
+
bigrams.add(`${words[i]}_${words[i + 1]}`);
|
|
75
|
+
}
|
|
76
|
+
return bigrams;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class ResponseCache {
|
|
80
|
+
#cache: Map<string, CacheEntry> = new Map();
|
|
81
|
+
#ttlMs: number;
|
|
82
|
+
#maxEntries: number;
|
|
83
|
+
#similarityThreshold: number;
|
|
84
|
+
#stats: ResponseCacheStats = {
|
|
85
|
+
totalQueries: 0,
|
|
86
|
+
exactHits: 0,
|
|
87
|
+
semanticHits: 0,
|
|
88
|
+
misses: 0,
|
|
89
|
+
tokensSaved: 0,
|
|
90
|
+
cacheSize: 0,
|
|
91
|
+
hitRate: 0,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
constructor(ttlSeconds: number = 3600, maxEntries: number = 100, similarityThreshold: number = 0.95) {
|
|
95
|
+
this.#ttlMs = ttlSeconds * 1000;
|
|
96
|
+
this.#maxEntries = maxEntries;
|
|
97
|
+
this.#similarityThreshold = similarityThreshold;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Look up a cached response for a query.
|
|
102
|
+
* Tries exact match first, then semantic match.
|
|
103
|
+
*/
|
|
104
|
+
get(query: string): { hit: boolean; response?: string; matchType?: "exact" | "semantic" } {
|
|
105
|
+
this.#stats.totalQueries++;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
|
|
108
|
+
// Clean expired entries lazily
|
|
109
|
+
this.#cleanExpired(now);
|
|
110
|
+
|
|
111
|
+
const qHash = hashQuery(query);
|
|
112
|
+
|
|
113
|
+
// 1. Exact match
|
|
114
|
+
const exact = this.#cache.get(qHash);
|
|
115
|
+
if (exact && exact.expiresAt > now) {
|
|
116
|
+
exact.hitCount++;
|
|
117
|
+
this.#stats.exactHits++;
|
|
118
|
+
this.#stats.tokensSaved += exact.responseTokens;
|
|
119
|
+
this.#updateHitRate();
|
|
120
|
+
return { hit: true, response: exact.response, matchType: "exact" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. Semantic match (Jaccard on bigrams)
|
|
124
|
+
for (const [, entry] of this.#cache) {
|
|
125
|
+
if (entry.expiresAt <= now) continue;
|
|
126
|
+
const sim = jaccardSimilarity(query, entry.query);
|
|
127
|
+
if (sim >= this.#similarityThreshold) {
|
|
128
|
+
entry.hitCount++;
|
|
129
|
+
this.#stats.semanticHits++;
|
|
130
|
+
this.#stats.tokensSaved += entry.responseTokens;
|
|
131
|
+
this.#updateHitRate();
|
|
132
|
+
return { hit: true, response: entry.response, matchType: "semantic" };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.#stats.misses++;
|
|
137
|
+
this.#updateHitRate();
|
|
138
|
+
return { hit: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Store a response in the cache.
|
|
143
|
+
*/
|
|
144
|
+
set(query: string, response: string): void {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const qHash = hashQuery(query);
|
|
147
|
+
|
|
148
|
+
// Evict oldest if at capacity
|
|
149
|
+
if (this.#cache.size >= this.#maxEntries) {
|
|
150
|
+
this.#evictOldest();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.#cache.set(qHash, {
|
|
154
|
+
queryHash: qHash,
|
|
155
|
+
query,
|
|
156
|
+
response,
|
|
157
|
+
createdAt: now,
|
|
158
|
+
expiresAt: now + this.#ttlMs,
|
|
159
|
+
hitCount: 0,
|
|
160
|
+
responseTokens: estimateTokens(response),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
this.#stats.cacheSize = this.#cache.size;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Get stats snapshot. */
|
|
167
|
+
get stats(): Readonly<ResponseCacheStats> {
|
|
168
|
+
return { ...this.#stats };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Clear all cache entries. */
|
|
172
|
+
clear(): void {
|
|
173
|
+
this.#cache.clear();
|
|
174
|
+
this.#stats.cacheSize = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reset stats and cache. */
|
|
178
|
+
reset(): void {
|
|
179
|
+
this.#cache.clear();
|
|
180
|
+
this.#stats = {
|
|
181
|
+
totalQueries: 0,
|
|
182
|
+
exactHits: 0,
|
|
183
|
+
semanticHits: 0,
|
|
184
|
+
misses: 0,
|
|
185
|
+
tokensSaved: 0,
|
|
186
|
+
cacheSize: 0,
|
|
187
|
+
hitRate: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#cleanExpired(now: number): void {
|
|
192
|
+
for (const [key, entry] of this.#cache) {
|
|
193
|
+
if (entry.expiresAt <= now) {
|
|
194
|
+
this.#cache.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.#stats.cacheSize = this.#cache.size;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#evictOldest(): void {
|
|
201
|
+
let oldestKey: string | null = null;
|
|
202
|
+
let oldestTime = Infinity;
|
|
203
|
+
|
|
204
|
+
for (const [key, entry] of this.#cache) {
|
|
205
|
+
if (entry.createdAt < oldestTime) {
|
|
206
|
+
oldestTime = entry.createdAt;
|
|
207
|
+
oldestKey = key;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (oldestKey) {
|
|
212
|
+
this.#cache.delete(oldestKey);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#updateHitRate(): void {
|
|
217
|
+
const hits = this.#stats.exactHits + this.#stats.semanticHits;
|
|
218
|
+
this.#stats.hitRate = this.#stats.totalQueries > 0 ? hits / this.#stats.totalQueries : 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Tombstoning — compress old messages to minimal references.
|
|
3
|
+
*
|
|
4
|
+
* Old messages are replaced with tombstone entries that reference
|
|
5
|
+
* the original content. Model can still look up originals if needed.
|
|
6
|
+
*
|
|
7
|
+
* Target: 85% context reduction.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TombstoneEntry {
|
|
11
|
+
/** Original message index in conversation. */
|
|
12
|
+
originalIndex: number;
|
|
13
|
+
/** Role of the original message. */
|
|
14
|
+
role: "user" | "assistant";
|
|
15
|
+
/** One-line summary of the original content. */
|
|
16
|
+
summary: string;
|
|
17
|
+
/** Token count of original message. */
|
|
18
|
+
originalTokens: number;
|
|
19
|
+
/** Token count of tombstone (~10% of original). */
|
|
20
|
+
tombstoneTokens: number;
|
|
21
|
+
/** Hash of original content for lookup. */
|
|
22
|
+
originalHash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TombstoneStats {
|
|
26
|
+
/** Total messages tombstoned. */
|
|
27
|
+
messagesTombstoned: number;
|
|
28
|
+
/** Original total tokens. */
|
|
29
|
+
originalTokens: number;
|
|
30
|
+
/** Tombstone total tokens. */
|
|
31
|
+
tombstoneTokens: number;
|
|
32
|
+
/** Tokens saved. */
|
|
33
|
+
tokensSaved: number;
|
|
34
|
+
/** Compression ratio (tombstone/original). */
|
|
35
|
+
compressionRatio: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
import { hashContent, estimateTokens } from "./context-delta";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a one-line summary from message content.
|
|
42
|
+
* Heuristic: first sentence or first 100 chars.
|
|
43
|
+
*/
|
|
44
|
+
function summarizeMessage(content: string): string {
|
|
45
|
+
// Take first sentence or first 100 chars
|
|
46
|
+
const firstSentence = content.match(/^[^.!?\n]{1,150}[.!?]?/);
|
|
47
|
+
if (firstSentence) {
|
|
48
|
+
const s = firstSentence[0].trim();
|
|
49
|
+
return s.length > 120 ? s.slice(0, 117) + "..." : s;
|
|
50
|
+
}
|
|
51
|
+
return content.length > 100 ? content.slice(0, 97) + "..." : content;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a tombstone line from an original message.
|
|
56
|
+
* Format: `[Turn N - user/assistant] Summary...`
|
|
57
|
+
*/
|
|
58
|
+
function buildTombstoneLine(entry: TombstoneEntry): string {
|
|
59
|
+
return `[Turn ${entry.originalIndex + 1} - ${entry.role}] ${entry.summary}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ConversationTombstoner {
|
|
63
|
+
#tombstones: TombstoneEntry[] = [];
|
|
64
|
+
#originals: Map<string, string> = new Map(); // hash → original content
|
|
65
|
+
#afterTurns: number;
|
|
66
|
+
#keepRecent: number;
|
|
67
|
+
#stats: TombstoneStats = {
|
|
68
|
+
messagesTombstoned: 0,
|
|
69
|
+
originalTokens: 0,
|
|
70
|
+
tombstoneTokens: 0,
|
|
71
|
+
tokensSaved: 0,
|
|
72
|
+
compressionRatio: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
constructor(afterTurns: number = 20, keepRecent: number = 5) {
|
|
76
|
+
this.#afterTurns = afterTurns;
|
|
77
|
+
this.#keepRecent = keepRecent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Process conversation messages — tombstone old ones, keep recent.
|
|
82
|
+
* Returns the compressed conversation with tombstones replacing old messages.
|
|
83
|
+
*/
|
|
84
|
+
tombstone(messages: Array<{ role: "user" | "assistant"; content: string }>): {
|
|
85
|
+
compressed: Array<{ role: "user" | "assistant"; content: string }>;
|
|
86
|
+
tombstoned: number;
|
|
87
|
+
tokensSaved: number;
|
|
88
|
+
} {
|
|
89
|
+
if (messages.length <= this.#keepRecent + this.#afterTurns) {
|
|
90
|
+
return { compressed: messages, tombstoned: 0, tokensSaved: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cutoff = messages.length - this.#keepRecent;
|
|
94
|
+
const toTombstone = messages.slice(0, cutoff);
|
|
95
|
+
const toKeep = messages.slice(cutoff);
|
|
96
|
+
|
|
97
|
+
let totalOriginalTokens = 0;
|
|
98
|
+
let totalTombstoneTokens = 0;
|
|
99
|
+
let tombstoned = 0;
|
|
100
|
+
|
|
101
|
+
const compressedTombstones: Array<{ role: "user" | "assistant"; content: string }> = [];
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < toTombstone.length; i++) {
|
|
104
|
+
const msg = toTombstone[i];
|
|
105
|
+
const originalTokens = estimateTokens(msg.content);
|
|
106
|
+
const summary = summarizeMessage(msg.content);
|
|
107
|
+
const hash = hashContent(msg.content);
|
|
108
|
+
|
|
109
|
+
// Store original for lookup
|
|
110
|
+
this.#originals.set(hash, msg.content);
|
|
111
|
+
|
|
112
|
+
const tombstoneTokens = estimateTokens(summary) + 5; // +5 for formatting
|
|
113
|
+
|
|
114
|
+
this.#tombstones.push({
|
|
115
|
+
originalIndex: i,
|
|
116
|
+
role: msg.role,
|
|
117
|
+
summary,
|
|
118
|
+
originalTokens,
|
|
119
|
+
tombstoneTokens,
|
|
120
|
+
originalHash: hash,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
totalOriginalTokens += originalTokens;
|
|
124
|
+
totalTombstoneTokens += tombstoneTokens;
|
|
125
|
+
tombstoned++;
|
|
126
|
+
|
|
127
|
+
compressedTombstones.push({
|
|
128
|
+
role: msg.role,
|
|
129
|
+
content: buildTombstoneLine(this.#tombstones[this.#tombstones.length - 1]),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tokensSaved = totalOriginalTokens - totalTombstoneTokens;
|
|
134
|
+
|
|
135
|
+
this.#stats.messagesTombstoned += tombstoned;
|
|
136
|
+
this.#stats.originalTokens += totalOriginalTokens;
|
|
137
|
+
this.#stats.tombstoneTokens += totalTombstoneTokens;
|
|
138
|
+
this.#stats.tokensSaved += tokensSaved;
|
|
139
|
+
this.#stats.compressionRatio = this.#stats.originalTokens > 0
|
|
140
|
+
? this.#stats.tombstoneTokens / this.#stats.originalTokens
|
|
141
|
+
: 0;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
compressed: [...compressedTombstones, ...toKeep],
|
|
145
|
+
tombstoned,
|
|
146
|
+
tokensSaved,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Look up original content by tombstone hash.
|
|
152
|
+
* Returns null if not found.
|
|
153
|
+
*/
|
|
154
|
+
lookupOriginal(hash: string): string | null {
|
|
155
|
+
return this.#originals.get(hash) ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Look up original by turn index.
|
|
160
|
+
*/
|
|
161
|
+
lookupByTurn(turnIndex: number): string | null {
|
|
162
|
+
const entry = this.#tombstones.find((t) => t.originalIndex === turnIndex);
|
|
163
|
+
if (!entry) return null;
|
|
164
|
+
return this.#originals.get(entry.originalHash) ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Get stats snapshot. */
|
|
168
|
+
get stats(): Readonly<TombstoneStats> {
|
|
169
|
+
return { ...this.#stats };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Get all tombstone entries. */
|
|
173
|
+
get entries(): ReadonlyArray<TombstoneEntry> {
|
|
174
|
+
return this.#tombstones;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reset tombstoner state. */
|
|
178
|
+
reset(): void {
|
|
179
|
+
this.#tombstones = [];
|
|
180
|
+
this.#originals.clear();
|
|
181
|
+
this.#stats = {
|
|
182
|
+
messagesTombstoned: 0,
|
|
183
|
+
originalTokens: 0,
|
|
184
|
+
tombstoneTokens: 0,
|
|
185
|
+
tokensSaved: 0,
|
|
186
|
+
compressionRatio: 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Auto-Compress — truncate/summarize tool output to fit budgets.
|
|
3
|
+
*
|
|
4
|
+
* Each tool type has a token budget. Output exceeding the budget is compressed.
|
|
5
|
+
*
|
|
6
|
+
* | Tool | Max Budget | Strategy |
|
|
7
|
+
* |------|-----------|----------|
|
|
8
|
+
* | terminal | 500 tokens | Truncate + strip ANSI |
|
|
9
|
+
* | read_file | 800 tokens | Only relevant lines |
|
|
10
|
+
* | web_extract | 1,000 tokens | Summarize key content |
|
|
11
|
+
* | search_files | 300 tokens | Top N results only |
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TbmConfig } from "./config";
|
|
15
|
+
import { estimateTokens } from "./context-delta";
|
|
16
|
+
|
|
17
|
+
export interface CompressStats {
|
|
18
|
+
/** Total tool outputs processed. */
|
|
19
|
+
totalOutputs: number;
|
|
20
|
+
/** Outputs that needed compression. */
|
|
21
|
+
compressed: number;
|
|
22
|
+
/** Tokens saved by compression. */
|
|
23
|
+
tokensSaved: number;
|
|
24
|
+
/** Per-tool compression counts. */
|
|
25
|
+
perTool: Record<string, { compressed: number; tokensSaved: number }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip ANSI escape codes from text. */
|
|
29
|
+
function stripAnsi(text: string): string {
|
|
30
|
+
return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Strip duplicate blank lines (3+ → 2). */
|
|
34
|
+
function collapseBlankLines(text: string): string {
|
|
35
|
+
return text.replace(/\n{3,}/g, "\n\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Truncate text to fit within token budget, preserving start and end. */
|
|
39
|
+
function truncateToBudget(text: string, maxTokens: number): { text: string; truncated: boolean } {
|
|
40
|
+
const currentTokens = estimateTokens(text);
|
|
41
|
+
if (currentTokens <= maxTokens) return { text, truncated: false };
|
|
42
|
+
|
|
43
|
+
// Keep 70% from start, 30% from end
|
|
44
|
+
const charBudget = maxTokens * 4;
|
|
45
|
+
const startChars = Math.floor(charBudget * 0.7);
|
|
46
|
+
const endChars = charBudget - startChars;
|
|
47
|
+
|
|
48
|
+
const start = text.slice(0, startChars);
|
|
49
|
+
const end = text.slice(-endChars);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
text: `${start}\n\n[... ${currentTokens - maxTokens} tokens compressed ...]\n\n${end}`,
|
|
53
|
+
truncated: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract top N search results from search_files output. */
|
|
58
|
+
function topNResults(text: string, maxTokens: number): { text: string; truncated: boolean } {
|
|
59
|
+
const lines = text.split("\n");
|
|
60
|
+
const resultLines: string[] = [];
|
|
61
|
+
let tokens = 0;
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const lineTokens = estimateTokens(line);
|
|
65
|
+
if (tokens + lineTokens > maxTokens) break;
|
|
66
|
+
resultLines.push(line);
|
|
67
|
+
tokens += lineTokens;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (resultLines.length === lines.length) return { text, truncated: false };
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
text: resultLines.join("\n") + `\n\n[... ${lines.length - resultLines.length} more results truncated ...]`,
|
|
74
|
+
truncated: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compress terminal output: strip ANSI, collapse blanks, truncate. */
|
|
79
|
+
function compressTerminal(text: string, maxTokens: number): { text: string; tokensSaved: number } {
|
|
80
|
+
let processed = stripAnsi(text);
|
|
81
|
+
processed = collapseBlankLines(processed);
|
|
82
|
+
|
|
83
|
+
const originalTokens = estimateTokens(text);
|
|
84
|
+
const result = truncateToBudget(processed, maxTokens);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
text: result.text,
|
|
88
|
+
tokensSaved: Math.max(0, originalTokens - estimateTokens(result.text)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Compress file output: keep relevant lines around the middle. */
|
|
93
|
+
function compressFileOutput(text: string, maxTokens: number): { text: string; tokensSaved: number } {
|
|
94
|
+
const originalTokens = estimateTokens(text);
|
|
95
|
+
const result = truncateToBudget(text, maxTokens);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
text: result.text,
|
|
99
|
+
tokensSaved: Math.max(0, originalTokens - estimateTokens(result.text)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compress web extract: keep first and last portions. */
|
|
104
|
+
function compressWebExtract(text: string, maxTokens: number): { text: string; tokensSaved: number } {
|
|
105
|
+
const originalTokens = estimateTokens(text);
|
|
106
|
+
|
|
107
|
+
// Remove markdown formatting bloat
|
|
108
|
+
let processed = text
|
|
109
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
110
|
+
.replace(/^\s*[-*]\s*$/gm, ""); // remove empty list items
|
|
111
|
+
|
|
112
|
+
const result = truncateToBudget(processed, maxTokens);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
text: result.text,
|
|
116
|
+
tokensSaved: Math.max(0, originalTokens - estimateTokens(result.text)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Compress search output: keep top results. */
|
|
121
|
+
function compressSearch(text: string, maxTokens: number): { text: string; tokensSaved: number } {
|
|
122
|
+
const originalTokens = estimateTokens(text);
|
|
123
|
+
const result = topNResults(text, maxTokens);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
text: result.text,
|
|
127
|
+
tokensSaved: Math.max(0, originalTokens - estimateTokens(result.text)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class ToolOutputCompressor {
|
|
132
|
+
#budgets: TbmConfig["compress"]["budgets"];
|
|
133
|
+
#stats: CompressStats = {
|
|
134
|
+
totalOutputs: 0,
|
|
135
|
+
compressed: 0,
|
|
136
|
+
tokensSaved: 0,
|
|
137
|
+
perTool: {},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
constructor(budgets: TbmConfig["compress"]["budgets"]) {
|
|
141
|
+
this.#budgets = budgets;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compress tool output to fit within its budget.
|
|
146
|
+
* Returns the (possibly compressed) output and stats.
|
|
147
|
+
*/
|
|
148
|
+
compress(toolName: string, output: string): { output: string; compressed: boolean; tokensSaved: number } {
|
|
149
|
+
this.#stats.totalOutputs++;
|
|
150
|
+
|
|
151
|
+
const budget = this.#getBudget(toolName);
|
|
152
|
+
const currentTokens = estimateTokens(output);
|
|
153
|
+
|
|
154
|
+
if (currentTokens <= budget) {
|
|
155
|
+
return { output, compressed: false, tokensSaved: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let result: { text: string; tokensSaved: number };
|
|
159
|
+
|
|
160
|
+
switch (toolName) {
|
|
161
|
+
case "terminal":
|
|
162
|
+
case "execute_command":
|
|
163
|
+
result = compressTerminal(output, budget);
|
|
164
|
+
break;
|
|
165
|
+
case "read_file":
|
|
166
|
+
case "readFile":
|
|
167
|
+
result = compressFileOutput(output, budget);
|
|
168
|
+
break;
|
|
169
|
+
case "web_extract":
|
|
170
|
+
case "webExtract":
|
|
171
|
+
result = compressWebExtract(output, budget);
|
|
172
|
+
break;
|
|
173
|
+
case "search_files":
|
|
174
|
+
case "searchFiles":
|
|
175
|
+
case "grep":
|
|
176
|
+
result = compressSearch(output, budget);
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
result = { text: truncateToBudget(output, budget).text, tokensSaved: Math.max(0, currentTokens - budget) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#stats.compressed++;
|
|
183
|
+
this.#stats.tokensSaved += result.tokensSaved;
|
|
184
|
+
|
|
185
|
+
if (!this.#stats.perTool[toolName]) {
|
|
186
|
+
this.#stats.perTool[toolName] = { compressed: 0, tokensSaved: 0 };
|
|
187
|
+
}
|
|
188
|
+
this.#stats.perTool[toolName].compressed++;
|
|
189
|
+
this.#stats.perTool[toolName].tokensSaved += result.tokensSaved;
|
|
190
|
+
|
|
191
|
+
return { output: result.text, compressed: true, tokensSaved: result.tokensSaved };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Get stats snapshot. */
|
|
195
|
+
get stats(): Readonly<CompressStats> {
|
|
196
|
+
return { ...this.#stats };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Get compression ratio (compressed / total). */
|
|
200
|
+
get compressionRatio(): number {
|
|
201
|
+
if (this.#stats.totalOutputs === 0) return 0;
|
|
202
|
+
return this.#stats.compressed / this.#stats.totalOutputs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#getBudget(toolName: string): number {
|
|
206
|
+
const budgets = this.#budgets;
|
|
207
|
+
switch (toolName) {
|
|
208
|
+
case "terminal":
|
|
209
|
+
case "execute_command":
|
|
210
|
+
return budgets.terminal;
|
|
211
|
+
case "read_file":
|
|
212
|
+
case "readFile":
|
|
213
|
+
return budgets.read_file;
|
|
214
|
+
case "web_extract":
|
|
215
|
+
case "webExtract":
|
|
216
|
+
return budgets.web_extract;
|
|
217
|
+
case "search_files":
|
|
218
|
+
case "searchFiles":
|
|
219
|
+
case "grep":
|
|
220
|
+
return budgets.search_files;
|
|
221
|
+
default:
|
|
222
|
+
return budgets.default;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Reset stats. */
|
|
227
|
+
reset(): void {
|
|
228
|
+
this.#stats = { totalOutputs: 0, compressed: 0, tokensSaved: 0, perTool: {} };
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/tools/ask.ts
CHANGED
|
@@ -484,7 +484,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
484
484
|
const method = this.session.settings.get("ask.notify");
|
|
485
485
|
if (method === "off") return;
|
|
486
486
|
TERMINAL.sendNotification({
|
|
487
|
-
title: "
|
|
487
|
+
title: "SnsCoder",
|
|
488
488
|
body: "Waiting for input",
|
|
489
489
|
type: "ask",
|
|
490
490
|
urgency: "normal",
|