@goodandready/dsh-context-lens 0.1.16 → 0.1.18
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 +21 -5
- package/README.ru.md +21 -5
- package/README.zh.md +21 -1
- package/lib/ast/skeletonizer.js +31 -15
- package/lib/client.js +284 -48
- package/lib/http.js +114 -0
- package/lib/index.js +136 -190
- package/lib/tokens/tracker.js +14 -6
- package/lib/tools.js +179 -0
- package/lib/updater.js +237 -0
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
|
-
import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
|
|
3
|
-
import { skeletonize } from './ast/skeletonizer.js';
|
|
4
2
|
import * as tracker from './tokens/tracker.js';
|
|
5
|
-
import {
|
|
3
|
+
import { compressLog } from './compression/log-compressor.js';
|
|
4
|
+
import { writeJson, readBody, isTrustedRequest } from './http.js';
|
|
5
|
+
import { registerPluginUpdater } from './updater.js';
|
|
6
|
+
import {
|
|
7
|
+
renderOutput,
|
|
8
|
+
registerTools,
|
|
9
|
+
getFocus,
|
|
10
|
+
clearFocus,
|
|
11
|
+
getLastActiveSession
|
|
12
|
+
} from './tools.js';
|
|
6
13
|
|
|
7
14
|
export const name = '@goodandready/dsh-context-lens';
|
|
8
15
|
export const inject = ['tools', 'settings', 'webServer'];
|
|
@@ -13,235 +20,174 @@ export const Config = z.object({
|
|
|
13
20
|
tokenSavingsTracking: z.boolean().default(true).description('Track and display token budget savings'),
|
|
14
21
|
autoCompressThreshold: z.number().default(4000).description('Auto-compress threshold in chars (0 to disable)'),
|
|
15
22
|
budgetLimit: z.number().default(100000).description('Session token budget; compressions stop counting after this limit'),
|
|
23
|
+
budgetAlertPercent: z.number().default(90).description('Budget percentage threshold (50-99%) for warning alert'),
|
|
16
24
|
autoCollapse: z.boolean().default(true).description('Warn in UI when budget is nearly exhausted (does not force-close the settings card)')
|
|
17
25
|
});
|
|
18
26
|
|
|
19
27
|
const NS = '@goodandready/dsh-context-lens';
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
const focusBySession = new Map();
|
|
23
|
-
let lastActiveSession = '__default__';
|
|
24
|
-
|
|
25
|
-
function sessionKey(params, meta) {
|
|
26
|
-
const k = (
|
|
27
|
-
(params && (params.sessionId || params.session_id)) ||
|
|
28
|
-
(meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
|
|
29
|
-
'__default__'
|
|
30
|
-
);
|
|
31
|
-
if (k !== '__default__') lastActiveSession = k;
|
|
32
|
-
return k;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function getFocus(key) {
|
|
36
|
-
if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
|
|
37
|
-
return focusBySession.get(key);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
|
|
41
|
-
const renderOutput = (_args, result) => JSON.stringify(result, null, 2);
|
|
29
|
+
export { renderOutput };
|
|
42
30
|
|
|
43
31
|
export function apply(ctx, config) {
|
|
44
32
|
let getConfig = () => config;
|
|
45
33
|
|
|
46
34
|
ctx.inject(['settings'], (sctx) => {
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
if (typeof sctx.effect === 'function') {
|
|
36
|
+
sctx.effect(() => {
|
|
37
|
+
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
38
|
+
getConfig = () => scope.get() ?? config;
|
|
39
|
+
return () => {
|
|
40
|
+
if (typeof scope?.dispose === 'function') scope.dispose();
|
|
41
|
+
getConfig = () => config;
|
|
42
|
+
};
|
|
43
|
+
}, 'dsh-context-lens: settings');
|
|
44
|
+
} else {
|
|
45
|
+
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
46
|
+
getConfig = () => scope.get() ?? config;
|
|
47
|
+
}
|
|
49
48
|
});
|
|
50
49
|
|
|
51
50
|
function maybeTrack(original, compressed) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (cfg && typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
56
|
-
return tracker.record(original, compressed);
|
|
57
|
-
} catch {
|
|
58
|
-
// #39: never record on config/read errors
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
51
|
+
const cfg = getConfig();
|
|
52
|
+
if (cfg.tokenSavingsTracking === false) return;
|
|
53
|
+
tracker.record(original, compressed);
|
|
61
54
|
}
|
|
62
55
|
|
|
56
|
+
// Register AI agent tools
|
|
63
57
|
if (ctx.tools) {
|
|
64
|
-
ctx
|
|
65
|
-
name: 'context_lens_focus',
|
|
66
|
-
description: 'Set focus files/folders for a session; other context will be auto-collapsed via skeletonizer',
|
|
67
|
-
parameters: {
|
|
68
|
-
type: 'object',
|
|
69
|
-
properties: {
|
|
70
|
-
paths: { type: 'array', items: { type: 'string' }, description: 'Focused file/folder paths' },
|
|
71
|
-
maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' },
|
|
72
|
-
sessionId: { type: 'string', description: 'Session id (defaults to host session when available)' }
|
|
73
|
-
},
|
|
74
|
-
required: ['paths']
|
|
75
|
-
},
|
|
76
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
77
|
-
execute: async (params, meta) => {
|
|
78
|
-
const key = sessionKey(params, meta);
|
|
79
|
-
const paths = Array.isArray(params.paths) ? params.paths : [];
|
|
80
|
-
const focus = { paths, updatedAt: new Date().toISOString() };
|
|
81
|
-
focusBySession.set(key, focus);
|
|
82
|
-
return { success: true, sessionId: key, focus, hint: 'Use skeletonize helper via compress or read files with focus set' };
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
ctx.tools.register({
|
|
87
|
-
name: 'context_lens_compress_log',
|
|
88
|
-
description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
|
|
89
|
-
parameters: {
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
text: { type: 'string', description: 'Raw log text to compress' },
|
|
93
|
-
log: { type: 'string', description: 'Alias for text' },
|
|
94
|
-
mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
|
|
95
|
-
maxLines: { type: 'number', description: 'Max output lines' },
|
|
96
|
-
auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
100
|
-
execute: async (params) => {
|
|
101
|
-
const cfg = getConfig();
|
|
102
|
-
const inputText = params.text || params.log || '';
|
|
103
|
-
const mode = params.mode || cfg.compressionMode || 'balanced';
|
|
104
|
-
const maxLines = params.maxLines || 400;
|
|
105
|
-
const threshold = cfg.autoCompressThreshold ?? 4000;
|
|
106
|
-
const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
|
|
107
|
-
// #33: never force balanced — keep configured/explicit mode
|
|
108
|
-
const effectiveMode = mode;
|
|
109
|
-
const res = compressLog(inputText, { mode: effectiveMode, maxLines });
|
|
110
|
-
maybeTrack(inputText, res.compressed);
|
|
111
|
-
return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
ctx.tools.register({
|
|
116
|
-
name: 'context_lens_compress_code',
|
|
117
|
-
description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java) to save context',
|
|
118
|
-
parameters: {
|
|
119
|
-
type: 'object',
|
|
120
|
-
properties: {
|
|
121
|
-
code: { type: 'string', description: 'Source code to skeletonize' },
|
|
122
|
-
language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java'], description: 'Language hint' },
|
|
123
|
-
maxDepth: { type: 'number', description: 'Max depth' },
|
|
124
|
-
filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
|
|
125
|
-
sessionId: { type: 'string', description: 'Session id for focus lookup' }
|
|
126
|
-
},
|
|
127
|
-
required: ['code']
|
|
128
|
-
},
|
|
129
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
130
|
-
execute: async (params, meta) => {
|
|
131
|
-
const cfg = getConfig();
|
|
132
|
-
const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
|
|
133
|
-
const key = sessionKey(params, meta);
|
|
134
|
-
const focusState = getFocus(key);
|
|
135
|
-
const isFocused = params.filePath && focusState.paths.length > 0
|
|
136
|
-
? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
|
|
137
|
-
: false;
|
|
138
|
-
if (isFocused) {
|
|
139
|
-
maybeTrack(params.code, params.code);
|
|
140
|
-
return { success: true, skeleton: params.code, originalTokens: estLog(params.code), skeletonTokens: estLog(params.code), maxDepth, focused: true, sessionId: key, hint: 'File is in focus, returned full code' };
|
|
141
|
-
}
|
|
142
|
-
const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
|
|
143
|
-
maybeTrack(params.code, skeleton);
|
|
144
|
-
return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
ctx.tools.register({
|
|
149
|
-
name: 'context_lens_reset',
|
|
150
|
-
description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
|
|
151
|
-
parameters: {
|
|
152
|
-
type: 'object',
|
|
153
|
-
properties: {
|
|
154
|
-
sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
158
|
-
execute: async (params, meta) => {
|
|
159
|
-
tracker.reset();
|
|
160
|
-
const key = sessionKey(params || {}, meta);
|
|
161
|
-
if (params && params.sessionId) {
|
|
162
|
-
focusBySession.delete(key);
|
|
163
|
-
}
|
|
164
|
-
return { success: true, message: 'Context lens stats and history reset', sessionId: key };
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
ctx.tools.register({
|
|
169
|
-
name: 'context_lens_stats',
|
|
170
|
-
description: 'Show session token savings stats for context-lens',
|
|
171
|
-
parameters: {
|
|
172
|
-
type: 'object',
|
|
173
|
-
properties: {
|
|
174
|
-
sessionId: { type: 'string', description: 'Session id for focus snapshot' }
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
178
|
-
execute: async (params, meta) => {
|
|
179
|
-
const cfg = getConfig();
|
|
180
|
-
if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
181
|
-
const stats = tracker.getStats(cfg.budgetLimit);
|
|
182
|
-
const history = tracker.getHistory();
|
|
183
|
-
const key = sessionKey(params || {}, meta);
|
|
184
|
-
return {
|
|
185
|
-
success: true,
|
|
186
|
-
...stats,
|
|
187
|
-
history,
|
|
188
|
-
trackingEnabled: cfg.tokenSavingsTracking !== false,
|
|
189
|
-
autoCollapse: cfg.autoCollapse !== false,
|
|
190
|
-
focus: getFocus(key),
|
|
191
|
-
sessionId: key,
|
|
192
|
-
autoCompressThreshold: cfg.autoCompressThreshold
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
});
|
|
58
|
+
registerTools(ctx, { getConfig, maybeTrack });
|
|
196
59
|
}
|
|
197
60
|
|
|
198
61
|
if (ctx.webServer) {
|
|
62
|
+
// #61: One-click plugin updater endpoint (/api/dsh-context-lens/update)
|
|
63
|
+
const mountUpdater = () => registerPluginUpdater(ctx, {
|
|
64
|
+
packageName: '@goodandready/dsh-context-lens',
|
|
65
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
66
|
+
endpoint: '/api/dsh-context-lens/update'
|
|
67
|
+
});
|
|
68
|
+
if (typeof ctx.effect === 'function') {
|
|
69
|
+
ctx.effect(mountUpdater, 'dsh-context-lens: updater');
|
|
70
|
+
} else {
|
|
71
|
+
mountUpdater();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Status route (#70: explicit URL parsing and validation)
|
|
199
75
|
ctx.effect(() => ctx.webServer.register({
|
|
200
76
|
kind: 'exact',
|
|
201
77
|
path: '/dsh-context-lens/status',
|
|
202
78
|
handler: (req, res) => {
|
|
203
79
|
const cfg = getConfig();
|
|
204
80
|
if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
205
|
-
|
|
81
|
+
if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
|
|
82
|
+
let sId = getLastActiveSession() || '__default__';
|
|
206
83
|
try {
|
|
207
84
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
85
|
+
const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
|
|
86
|
+
if (qId !== null) {
|
|
87
|
+
if (!qId || typeof qId !== 'string' || qId.trim() === '') {
|
|
88
|
+
writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
sId = qId.trim();
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in status', err);
|
|
95
|
+
writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
writeJson(res, 200, {
|
|
212
100
|
ok: true,
|
|
213
101
|
plugin: 'dsh-context-lens',
|
|
214
|
-
stats: tracker.getStats(cfg.budgetLimit),
|
|
102
|
+
stats: tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent),
|
|
215
103
|
history: tracker.getHistory(),
|
|
216
104
|
focus: getFocus(sId),
|
|
217
105
|
sessionId: sId
|
|
218
|
-
})
|
|
106
|
+
});
|
|
219
107
|
}
|
|
220
108
|
}), 'dsh-context-lens status route');
|
|
221
109
|
|
|
222
|
-
//
|
|
110
|
+
// Quick clear-focus API route (#62: POST only, trusted origin check; #70: clean URL error handling)
|
|
111
|
+
ctx.effect(() => ctx.webServer.register({
|
|
112
|
+
kind: 'exact',
|
|
113
|
+
path: '/dsh-context-lens/clear-focus',
|
|
114
|
+
handler: async (req, res) => {
|
|
115
|
+
if (req.method !== 'POST') {
|
|
116
|
+
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
|
|
117
|
+
res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!isTrustedRequest(req)) {
|
|
121
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let sId = getLastActiveSession() || '__default__';
|
|
126
|
+
try {
|
|
127
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
128
|
+
const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
|
|
129
|
+
if (qId !== null) {
|
|
130
|
+
if (!qId || typeof qId !== 'string' || qId.trim() === '') {
|
|
131
|
+
writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
sId = qId.trim();
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in clear-focus', err);
|
|
138
|
+
writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
clearFocus(sId);
|
|
143
|
+
writeJson(res, 200, { ok: true, sessionId: sId, focus: getFocus(sId) });
|
|
144
|
+
}
|
|
145
|
+
}), 'dsh-context-lens clear-focus route');
|
|
146
|
+
|
|
147
|
+
// Server-backed preview (#63: POST only, trusted origin, bounded body size, clamped maxLines)
|
|
223
148
|
ctx.effect(() => ctx.webServer.register({
|
|
224
149
|
kind: 'exact',
|
|
225
150
|
path: '/dsh-context-lens/compress-preview',
|
|
226
151
|
handler: async (req, res) => {
|
|
152
|
+
if (req.method !== 'POST') {
|
|
153
|
+
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
|
|
154
|
+
res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!isTrustedRequest(req)) {
|
|
158
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let body;
|
|
227
163
|
try {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
231
|
-
const cfg = getConfig();
|
|
232
|
-
const mode = body.mode || cfg.compressionMode || 'balanced';
|
|
233
|
-
const text = body.text || '';
|
|
234
|
-
const out = compressLog(text, { mode, maxLines: body.maxLines || 400 });
|
|
235
|
-
res.setHeader('content-type', 'application/json');
|
|
236
|
-
res.end(JSON.stringify({ ok: true, mode, ...out }));
|
|
164
|
+
const raw = await readBody(req, 256 * 1024);
|
|
165
|
+
body = JSON.parse(raw.toString('utf8') || '{}');
|
|
237
166
|
} catch (e) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
167
|
+
if (e?.message === 'body too large') {
|
|
168
|
+
writeJson(res, 413, { ok: false, error: 'Payload Too Large: maximum 256KB allowed' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
writeJson(res, 400, { ok: false, error: 'Malformed JSON body' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let maxLines = 400;
|
|
176
|
+
if (body.maxLines !== undefined) {
|
|
177
|
+
const parsed = Number(body.maxLines);
|
|
178
|
+
if (Number.isNaN(parsed) || parsed < 1 || parsed > 5000) {
|
|
179
|
+
writeJson(res, 400, { ok: false, error: 'maxLines must be a number between 1 and 5000' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
maxLines = Math.floor(parsed);
|
|
241
183
|
}
|
|
184
|
+
|
|
185
|
+
const cfg = getConfig();
|
|
186
|
+
const mode = body.mode || cfg.compressionMode || 'balanced';
|
|
187
|
+
const text = typeof body.text === 'string' ? body.text : '';
|
|
188
|
+
const out = compressLog(text, { mode, maxLines });
|
|
189
|
+
writeJson(res, 200, { ok: true, mode, ...out });
|
|
242
190
|
}
|
|
243
191
|
}), 'dsh-context-lens compress-preview route');
|
|
244
192
|
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export { compressLog, skeletonize, shouldAutoCompress };
|
|
193
|
+
}
|
package/lib/tokens/tracker.js
CHANGED
|
@@ -7,6 +7,7 @@ let calls = 0;
|
|
|
7
7
|
const history = []; // last 10
|
|
8
8
|
const MAX_HISTORY = 10;
|
|
9
9
|
let defaultBudgetLimit = 100000; // tokens
|
|
10
|
+
let defaultBudgetAlertPercent = 90; // threshold percent for low budget warning
|
|
10
11
|
|
|
11
12
|
export { estimateTokens };
|
|
12
13
|
|
|
@@ -16,6 +17,12 @@ export function setBudgetLimit(limit) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export function setBudgetAlertPercent(percent) {
|
|
21
|
+
if (typeof percent === 'number' && percent >= 50 && percent <= 99) {
|
|
22
|
+
defaultBudgetAlertPercent = percent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
export function record(originalText, compressedText) {
|
|
20
27
|
const o = estimateTokens(originalText);
|
|
21
28
|
const c = estimateTokens(compressedText);
|
|
@@ -41,16 +48,19 @@ export function record(originalText, compressedText) {
|
|
|
41
48
|
return { originalTokens: o, compressedTokens: c, savedTokens: saved };
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
export function getStats(budgetLimitOverride) {
|
|
51
|
+
export function getStats(budgetLimitOverride, budgetAlertPercentOverride) {
|
|
45
52
|
const limit = (typeof budgetLimitOverride === 'number' && budgetLimitOverride > 0)
|
|
46
53
|
? budgetLimitOverride
|
|
47
54
|
: defaultBudgetLimit;
|
|
55
|
+
const alertPercent = (typeof budgetAlertPercentOverride === 'number' && budgetAlertPercentOverride >= 50 && budgetAlertPercentOverride <= 99)
|
|
56
|
+
? budgetAlertPercentOverride
|
|
57
|
+
: defaultBudgetAlertPercent;
|
|
48
58
|
const saved = Math.max(0, totalOriginal - totalCompressed);
|
|
49
59
|
const pct = totalOriginal ? Math.round((1 - totalCompressed / totalOriginal) * 100) : 0;
|
|
50
60
|
const budgetUsed = totalCompressed;
|
|
51
61
|
const budgetRemaining = Math.max(0, limit - budgetUsed);
|
|
52
62
|
const budgetPercent = Math.min(100, Math.round((budgetUsed / limit) * 100));
|
|
53
|
-
const lowBudget = budgetPercent
|
|
63
|
+
const lowBudget = budgetPercent >= alertPercent;
|
|
54
64
|
return {
|
|
55
65
|
totalOriginal,
|
|
56
66
|
totalCompressed,
|
|
@@ -62,6 +72,7 @@ export function getStats(budgetLimitOverride) {
|
|
|
62
72
|
budgetPercent,
|
|
63
73
|
lowBudget,
|
|
64
74
|
budgetLimit: limit,
|
|
75
|
+
budgetAlertPercent: alertPercent,
|
|
65
76
|
countingStopped: budgetUsed >= limit
|
|
66
77
|
};
|
|
67
78
|
}
|
|
@@ -79,7 +90,4 @@ export function reset() {
|
|
|
79
90
|
totalCompressed = 0;
|
|
80
91
|
calls = 0;
|
|
81
92
|
history.length = 0;
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export default { estimateTokens, setBudgetLimit, record, getStats, getHistory, clearHistory, reset };
|
|
93
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
|
|
2
|
+
import { skeletonize } from './ast/skeletonizer.js';
|
|
3
|
+
import * as tracker from './tokens/tracker.js';
|
|
4
|
+
import { shouldAutoCompress } from './auto-compress.js';
|
|
5
|
+
|
|
6
|
+
export const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
|
|
7
|
+
|
|
8
|
+
// #71 (GH #2): output.render MUST return ContentBlock[] [{ type: 'text', text: ... }]
|
|
9
|
+
export const renderOutput = (_args, result) => [
|
|
10
|
+
{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const focusBySession = new Map();
|
|
14
|
+
let lastActiveSession = '__default__';
|
|
15
|
+
|
|
16
|
+
export function sessionKey(params, meta) {
|
|
17
|
+
const k = (
|
|
18
|
+
(params && (params.sessionId || params.session_id)) ||
|
|
19
|
+
(meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
|
|
20
|
+
'__default__'
|
|
21
|
+
);
|
|
22
|
+
if (k !== '__default__') lastActiveSession = k;
|
|
23
|
+
return k;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getFocus(key) {
|
|
27
|
+
if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
|
|
28
|
+
return focusBySession.get(key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearFocus(key) {
|
|
32
|
+
if (key) {
|
|
33
|
+
focusBySession.set(key, { paths: [], updatedAt: new Date().toISOString() });
|
|
34
|
+
} else {
|
|
35
|
+
focusBySession.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getLastActiveSession() {
|
|
40
|
+
return lastActiveSession;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerTools(ctx, { getConfig, maybeTrack }) {
|
|
44
|
+
if (!ctx?.tools?.register) return;
|
|
45
|
+
|
|
46
|
+
ctx.tools.register({
|
|
47
|
+
name: 'context_lens_focus',
|
|
48
|
+
description: 'Set active file paths for current session focus (these files skip AST compression)',
|
|
49
|
+
parameters: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
paths: { type: 'array', items: { type: 'string' }, description: 'File paths or patterns currently in focus' },
|
|
53
|
+
sessionId: { type: 'string', description: 'Session id (uses active session if omitted)' }
|
|
54
|
+
},
|
|
55
|
+
required: ['paths']
|
|
56
|
+
},
|
|
57
|
+
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
58
|
+
execute: async (params, meta) => {
|
|
59
|
+
const key = sessionKey(params, meta);
|
|
60
|
+
const paths = Array.isArray(params.paths) ? params.paths : [];
|
|
61
|
+
const state = getFocus(key);
|
|
62
|
+
state.paths = paths;
|
|
63
|
+
state.updatedAt = new Date().toISOString();
|
|
64
|
+
return { success: true, sessionId: key, focus: state };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ctx.tools.register({
|
|
69
|
+
name: 'context_lens_compress_log',
|
|
70
|
+
description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
text: { type: 'string', description: 'Raw log text to compress' },
|
|
75
|
+
log: { type: 'string', description: 'Alias for text' },
|
|
76
|
+
mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
|
|
77
|
+
maxLines: { type: 'number', description: 'Max output lines' },
|
|
78
|
+
auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
82
|
+
execute: async (params) => {
|
|
83
|
+
const cfg = getConfig();
|
|
84
|
+
const inputText = params.text || params.log || '';
|
|
85
|
+
const mode = params.mode || cfg.compressionMode || 'balanced';
|
|
86
|
+
const maxLines = params.maxLines || 400;
|
|
87
|
+
const threshold = cfg.autoCompressThreshold ?? 4000;
|
|
88
|
+
const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
|
|
89
|
+
const effectiveMode = mode;
|
|
90
|
+
const res = compressLog(inputText, { mode: effectiveMode, maxLines });
|
|
91
|
+
maybeTrack(inputText, res.compressed);
|
|
92
|
+
return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ctx.tools.register({
|
|
97
|
+
name: 'context_lens_compress_code',
|
|
98
|
+
description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java/C/C++/SQL) to save context',
|
|
99
|
+
parameters: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
code: { type: 'string', description: 'Source code to skeletonize' },
|
|
103
|
+
language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint' },
|
|
104
|
+
maxDepth: { type: 'number', description: 'Max depth' },
|
|
105
|
+
filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
|
|
106
|
+
sessionId: { type: 'string', description: 'Session id for focus lookup' }
|
|
107
|
+
},
|
|
108
|
+
required: ['code']
|
|
109
|
+
},
|
|
110
|
+
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
111
|
+
execute: async (params, meta) => {
|
|
112
|
+
const cfg = getConfig();
|
|
113
|
+
const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
|
|
114
|
+
const key = sessionKey(params, meta);
|
|
115
|
+
const focusState = getFocus(key);
|
|
116
|
+
const isFocused = params.filePath && focusState.paths.length > 0
|
|
117
|
+
? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
|
|
118
|
+
: false;
|
|
119
|
+
if (isFocused) {
|
|
120
|
+
maybeTrack(params.code, params.code);
|
|
121
|
+
return { success: true, skeleton: params.code, originalTokens: estLog(params.code), skeletonTokens: estLog(params.code), maxDepth, focused: true, sessionId: key, hint: 'File is in focus, returned full code' };
|
|
122
|
+
}
|
|
123
|
+
const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
|
|
124
|
+
maybeTrack(params.code, skeleton);
|
|
125
|
+
return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ctx.tools.register({
|
|
130
|
+
name: 'context_lens_reset',
|
|
131
|
+
description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
|
|
132
|
+
parameters: {
|
|
133
|
+
type: 'object',
|
|
134
|
+
properties: {
|
|
135
|
+
sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
139
|
+
execute: async (params, meta) => {
|
|
140
|
+
tracker.reset();
|
|
141
|
+
const key = sessionKey(params || {}, meta);
|
|
142
|
+
if (params && params.sessionId) {
|
|
143
|
+
focusBySession.delete(key);
|
|
144
|
+
}
|
|
145
|
+
return { success: true, message: 'Context lens stats and history reset', sessionId: key };
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ctx.tools.register({
|
|
150
|
+
name: 'context_lens_stats',
|
|
151
|
+
description: 'Show session token savings stats for context-lens',
|
|
152
|
+
parameters: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
sessionId: { type: 'string', description: 'Session id for focus snapshot' }
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
159
|
+
execute: async (params, meta) => {
|
|
160
|
+
const cfg = getConfig();
|
|
161
|
+
if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
162
|
+
if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
|
|
163
|
+
const stats = tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent);
|
|
164
|
+
const history = tracker.getHistory();
|
|
165
|
+
const key = sessionKey(params || {}, meta);
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
...stats,
|
|
169
|
+
history,
|
|
170
|
+
trackingEnabled: cfg.tokenSavingsTracking !== false,
|
|
171
|
+
autoCollapse: cfg.autoCollapse !== false,
|
|
172
|
+
focus: getFocus(key),
|
|
173
|
+
sessionId: key,
|
|
174
|
+
autoCompressThreshold: cfg.autoCompressThreshold,
|
|
175
|
+
budgetAlertPercent: cfg.budgetAlertPercent ?? 90
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|