@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/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'];
@@ -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
- // #37/#18: focus keyed by session id (not process-global singleton)
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
- const scope = sctx.settings.register(NS, Config, { base: config });
48
- 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
+ }
49
48
  });
50
49
 
51
50
  function maybeTrack(original, compressed) {
52
- try {
53
- const cfg = getConfig();
54
- if (cfg && cfg.tokenSavingsTracking === false) return null;
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.tools.register({
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
- let sId = '__default__';
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
- sId = u.searchParams.get('sessionId') || u.searchParams.get('session_id') || lastActiveSession || '__default__';
209
- } catch {}
210
- res.setHeader('content-type', 'application/json');
211
- 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, {
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
- // #45: server-backed preview (same compressor as tools)
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 chunks = [];
229
- for await (const c of req) chunks.push(c);
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
- res.statusCode = 400;
239
- res.setHeader('content-type', 'application/json');
240
- 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);
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
+ }
@@ -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 > 90;
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
- defaultBudgetLimit = 100000;
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
+ }