@goodandready/dsh-context-lens 0.1.17 → 0.1.19
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 +107 -0
- package/README.md +86 -112
- package/README.ru.md +95 -110
- package/README.zh.md +99 -4
- package/lib/client.js +207 -20
- package/lib/http.js +114 -0
- package/lib/index.js +121 -203
- package/lib/tokens/tracker.js +0 -4
- package/lib/tools.js +179 -0
- package/lib/updater.js +237 -0
- package/package.json +3 -2
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'];
|
|
@@ -19,195 +26,52 @@ export const Config = z.object({
|
|
|
19
26
|
|
|
20
27
|
const NS = '@goodandready/dsh-context-lens';
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
const focusBySession = new Map();
|
|
24
|
-
let lastActiveSession = '__default__';
|
|
25
|
-
|
|
26
|
-
function sessionKey(params, meta) {
|
|
27
|
-
const k = (
|
|
28
|
-
(params && (params.sessionId || params.session_id)) ||
|
|
29
|
-
(meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
|
|
30
|
-
'__default__'
|
|
31
|
-
);
|
|
32
|
-
if (k !== '__default__') lastActiveSession = k;
|
|
33
|
-
return k;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function getFocus(key) {
|
|
37
|
-
if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
|
|
38
|
-
return focusBySession.get(key);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function clearFocus(key) {
|
|
42
|
-
if (key) {
|
|
43
|
-
focusBySession.set(key, { paths: [], updatedAt: new Date().toISOString() });
|
|
44
|
-
} else {
|
|
45
|
-
focusBySession.clear();
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
|
|
50
|
-
const renderOutput = (_args, result) => JSON.stringify(result, null, 2);
|
|
29
|
+
export { renderOutput };
|
|
51
30
|
|
|
52
31
|
export function apply(ctx, config) {
|
|
53
32
|
let getConfig = () => config;
|
|
54
33
|
|
|
55
34
|
ctx.inject(['settings'], (sctx) => {
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
48
|
});
|
|
59
49
|
|
|
60
50
|
function maybeTrack(original, compressed) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (cfg && typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
65
|
-
if (cfg && typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
|
|
66
|
-
return tracker.record(original, compressed);
|
|
67
|
-
} catch {
|
|
68
|
-
// #39: never record on config/read errors
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
51
|
+
const cfg = getConfig();
|
|
52
|
+
if (cfg.tokenSavingsTracking === false) return;
|
|
53
|
+
tracker.record(original, compressed);
|
|
71
54
|
}
|
|
72
55
|
|
|
56
|
+
// Register AI agent tools
|
|
73
57
|
if (ctx.tools) {
|
|
74
|
-
ctx
|
|
75
|
-
name: 'context_lens_focus',
|
|
76
|
-
description: 'Set focus files/folders for a session; other context will be auto-collapsed via skeletonizer',
|
|
77
|
-
parameters: {
|
|
78
|
-
type: 'object',
|
|
79
|
-
properties: {
|
|
80
|
-
paths: { type: 'array', items: { type: 'string' }, description: 'Focused file/folder paths' },
|
|
81
|
-
maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' },
|
|
82
|
-
sessionId: { type: 'string', description: 'Session id (defaults to host session when available)' }
|
|
83
|
-
},
|
|
84
|
-
required: ['paths']
|
|
85
|
-
},
|
|
86
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
87
|
-
execute: async (params, meta) => {
|
|
88
|
-
const key = sessionKey(params, meta);
|
|
89
|
-
const paths = Array.isArray(params.paths) ? params.paths : [];
|
|
90
|
-
const focus = { paths, updatedAt: new Date().toISOString() };
|
|
91
|
-
focusBySession.set(key, focus);
|
|
92
|
-
return { success: true, sessionId: key, focus, hint: 'Use skeletonize helper via compress or read files with focus set' };
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
ctx.tools.register({
|
|
97
|
-
name: 'context_lens_compress_log',
|
|
98
|
-
description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
|
|
99
|
-
parameters: {
|
|
100
|
-
type: 'object',
|
|
101
|
-
properties: {
|
|
102
|
-
text: { type: 'string', description: 'Raw log text to compress' },
|
|
103
|
-
log: { type: 'string', description: 'Alias for text' },
|
|
104
|
-
mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
|
|
105
|
-
maxLines: { type: 'number', description: 'Max output lines' },
|
|
106
|
-
auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
|
|
107
|
-
}
|
|
108
|
-
},
|
|
109
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
110
|
-
execute: async (params) => {
|
|
111
|
-
const cfg = getConfig();
|
|
112
|
-
const inputText = params.text || params.log || '';
|
|
113
|
-
const mode = params.mode || cfg.compressionMode || 'balanced';
|
|
114
|
-
const maxLines = params.maxLines || 400;
|
|
115
|
-
const threshold = cfg.autoCompressThreshold ?? 4000;
|
|
116
|
-
const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
|
|
117
|
-
// #33: never force balanced — keep configured/explicit mode
|
|
118
|
-
const effectiveMode = mode;
|
|
119
|
-
const res = compressLog(inputText, { mode: effectiveMode, maxLines });
|
|
120
|
-
maybeTrack(inputText, res.compressed);
|
|
121
|
-
return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
ctx.tools.register({
|
|
126
|
-
name: 'context_lens_compress_code',
|
|
127
|
-
description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java/C/C++/SQL) to save context',
|
|
128
|
-
parameters: {
|
|
129
|
-
type: 'object',
|
|
130
|
-
properties: {
|
|
131
|
-
code: { type: 'string', description: 'Source code to skeletonize' },
|
|
132
|
-
language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint' },
|
|
133
|
-
maxDepth: { type: 'number', description: 'Max depth' },
|
|
134
|
-
filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
|
|
135
|
-
sessionId: { type: 'string', description: 'Session id for focus lookup' }
|
|
136
|
-
},
|
|
137
|
-
required: ['code']
|
|
138
|
-
},
|
|
139
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
140
|
-
execute: async (params, meta) => {
|
|
141
|
-
const cfg = getConfig();
|
|
142
|
-
const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
|
|
143
|
-
const key = sessionKey(params, meta);
|
|
144
|
-
const focusState = getFocus(key);
|
|
145
|
-
const isFocused = params.filePath && focusState.paths.length > 0
|
|
146
|
-
? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
|
|
147
|
-
: false;
|
|
148
|
-
if (isFocused) {
|
|
149
|
-
maybeTrack(params.code, params.code);
|
|
150
|
-
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' };
|
|
151
|
-
}
|
|
152
|
-
const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
|
|
153
|
-
maybeTrack(params.code, skeleton);
|
|
154
|
-
return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
ctx.tools.register({
|
|
159
|
-
name: 'context_lens_reset',
|
|
160
|
-
description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
|
|
161
|
-
parameters: {
|
|
162
|
-
type: 'object',
|
|
163
|
-
properties: {
|
|
164
|
-
sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
168
|
-
execute: async (params, meta) => {
|
|
169
|
-
tracker.reset();
|
|
170
|
-
const key = sessionKey(params || {}, meta);
|
|
171
|
-
if (params && params.sessionId) {
|
|
172
|
-
focusBySession.delete(key);
|
|
173
|
-
}
|
|
174
|
-
return { success: true, message: 'Context lens stats and history reset', sessionId: key };
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
ctx.tools.register({
|
|
179
|
-
name: 'context_lens_stats',
|
|
180
|
-
description: 'Show session token savings stats for context-lens',
|
|
181
|
-
parameters: {
|
|
182
|
-
type: 'object',
|
|
183
|
-
properties: {
|
|
184
|
-
sessionId: { type: 'string', description: 'Session id for focus snapshot' }
|
|
185
|
-
}
|
|
186
|
-
},
|
|
187
|
-
output: { schema: OUTPUT_SCHEMA, render: renderOutput },
|
|
188
|
-
execute: async (params, meta) => {
|
|
189
|
-
const cfg = getConfig();
|
|
190
|
-
if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
191
|
-
if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
|
|
192
|
-
const stats = tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent);
|
|
193
|
-
const history = tracker.getHistory();
|
|
194
|
-
const key = sessionKey(params || {}, meta);
|
|
195
|
-
return {
|
|
196
|
-
success: true,
|
|
197
|
-
...stats,
|
|
198
|
-
history,
|
|
199
|
-
trackingEnabled: cfg.tokenSavingsTracking !== false,
|
|
200
|
-
autoCollapse: cfg.autoCollapse !== false,
|
|
201
|
-
focus: getFocus(key),
|
|
202
|
-
sessionId: key,
|
|
203
|
-
autoCompressThreshold: cfg.autoCompressThreshold,
|
|
204
|
-
budgetAlertPercent: cfg.budgetAlertPercent ?? 90
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
});
|
|
58
|
+
registerTools(ctx, { getConfig, maybeTrack });
|
|
208
59
|
}
|
|
209
60
|
|
|
210
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)
|
|
211
75
|
ctx.effect(() => ctx.webServer.register({
|
|
212
76
|
kind: 'exact',
|
|
213
77
|
path: '/dsh-context-lens/status',
|
|
@@ -215,60 +79,114 @@ export function apply(ctx, config) {
|
|
|
215
79
|
const cfg = getConfig();
|
|
216
80
|
if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
|
|
217
81
|
if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
|
|
218
|
-
let sId = '__default__';
|
|
82
|
+
let sId = getLastActiveSession() || '__default__';
|
|
219
83
|
try {
|
|
220
84
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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, {
|
|
225
100
|
ok: true,
|
|
226
101
|
plugin: 'dsh-context-lens',
|
|
227
102
|
stats: tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent),
|
|
228
103
|
history: tracker.getHistory(),
|
|
229
104
|
focus: getFocus(sId),
|
|
230
105
|
sessionId: sId
|
|
231
|
-
})
|
|
106
|
+
});
|
|
232
107
|
}
|
|
233
108
|
}), 'dsh-context-lens status route');
|
|
234
109
|
|
|
235
|
-
// Quick clear-focus API route
|
|
110
|
+
// Quick clear-focus API route (#62: POST only, trusted origin check; #70: clean URL error handling)
|
|
236
111
|
ctx.effect(() => ctx.webServer.register({
|
|
237
112
|
kind: 'exact',
|
|
238
113
|
path: '/dsh-context-lens/clear-focus',
|
|
239
114
|
handler: async (req, res) => {
|
|
240
|
-
|
|
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__';
|
|
241
126
|
try {
|
|
242
127
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
243
128
|
const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
|
|
244
|
-
if (qId
|
|
245
|
-
|
|
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
|
+
|
|
246
142
|
clearFocus(sId);
|
|
247
|
-
res
|
|
248
|
-
res.end(JSON.stringify({ ok: true, sessionId: sId, focus: getFocus(sId) }));
|
|
143
|
+
writeJson(res, 200, { ok: true, sessionId: sId, focus: getFocus(sId) });
|
|
249
144
|
}
|
|
250
145
|
}), 'dsh-context-lens clear-focus route');
|
|
251
146
|
|
|
252
|
-
//
|
|
147
|
+
// Server-backed preview (#63: POST only, trusted origin, bounded body size, clamped maxLines)
|
|
253
148
|
ctx.effect(() => ctx.webServer.register({
|
|
254
149
|
kind: 'exact',
|
|
255
150
|
path: '/dsh-context-lens/compress-preview',
|
|
256
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;
|
|
257
163
|
try {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
261
|
-
const cfg = getConfig();
|
|
262
|
-
const mode = body.mode || cfg.compressionMode || 'balanced';
|
|
263
|
-
const text = body.text || '';
|
|
264
|
-
const out = compressLog(text, { mode, maxLines: body.maxLines || 400 });
|
|
265
|
-
res.setHeader('content-type', 'application/json');
|
|
266
|
-
res.end(JSON.stringify({ ok: true, mode, ...out }));
|
|
164
|
+
const raw = await readBody(req, 256 * 1024);
|
|
165
|
+
body = JSON.parse(raw.toString('utf8') || '{}');
|
|
267
166
|
} catch (e) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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);
|
|
271
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 });
|
|
272
190
|
}
|
|
273
191
|
}), 'dsh-context-lens compress-preview route');
|
|
274
192
|
}
|
package/lib/tokens/tracker.js
CHANGED
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
|
+
}
|