@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/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 { shouldAutoCompress } from './auto-compress.js';
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
- // #37/#18: focus keyed by session id (not process-global singleton)
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
- const scope = sctx.settings.register(NS, Config, { base: config });
57
- getConfig = () => scope.get() ?? config;
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
- try {
62
- const cfg = getConfig();
63
- if (cfg && cfg.tokenSavingsTracking === false) return null;
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.tools.register({
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
- sId = u.searchParams.get('sessionId') || u.searchParams.get('session_id') || lastActiveSession || '__default__';
222
- } catch {}
223
- res.setHeader('content-type', 'application/json');
224
- res.end(JSON.stringify({
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
- let sId = lastActiveSession || '__default__';
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) sId = qId;
245
- } catch {}
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.setHeader('content-type', 'application/json');
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
- // #45: server-backed preview (same compressor as tools)
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 chunks = [];
259
- for await (const c of req) chunks.push(c);
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
- res.statusCode = 400;
269
- res.setHeader('content-type', 'application/json');
270
- res.end(JSON.stringify({ ok: false, error: e && e.message || String(e) }));
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
  }
@@ -81,10 +81,6 @@ export function getHistory() {
81
81
  return [...history];
82
82
  }
83
83
 
84
- export function clearHistory() {
85
- history.length = 0;
86
- }
87
-
88
84
  export function reset() {
89
85
  totalOriginal = 0;
90
86
  totalCompressed = 0;
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
+ }