@goodandready/dsh-context-lens 0.1.25 → 0.1.26

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.
@@ -1,12 +1,12 @@
1
- import { estimateTokens } from '../tokens/estimate.js';
2
1
  // ponytail: heuristic line filter, not ML — O(n) scan, no deps
3
2
  const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g;
4
3
  const KEEP_RE = /(FAIL|FAILED|Error|AssertionError|Exception|Traceback|panic|npm ERR!|ERR!|Expected|Received|missing|×|●|✕|FAIL:|--- FAIL|not ok|at\s+.*:\d+:\d+|stack|Caused by|test result:\s*FAILED|running \d+ test|test .* \.\.\. FAILED|BUILD FAILED|Tests run:|FAILURE:|cargo:.*error|error\[E\d+\]|thread '.*' panicked)/i;
5
4
  const PASS_RE = /^(PASS|\s*✓|\s*✔|\s*ok\s|…+\s*$|\s*\.\s*$|test result:\s*ok|running \d+ test.*ok)/i;
6
5
  const NOISE_RE = /^\s*(npm (notice|warn|info)|Browserslist|cached|Downloading|Done in|Compiling\s|cargo:.*Finished)/i;
7
6
  const STACK_RE = /^\s*(at\s+|File ".*", line \d+|#\d+\s+0x|goroutine \d+ \[|thread '.*' panicked|note: run with)/;
7
+ const SUMMARY_RE = /(tests?\s+(passed|failed|run)|test result:|suites?\s+\d+|\d+\s+passed|BUILD (SUCCESS|FAILED)|===.*passed.*===|passed in \d+|built in|modules transformed|Finished.*release|Finished.*dev)/i;
8
8
 
9
- function cleanAnsi(str) {
9
+ export function cleanAnsi(str) {
10
10
  if (!str || !str.includes('\u001b')) return str || '';
11
11
  return str.replace(ANSI_RE, '');
12
12
  }
@@ -15,8 +15,14 @@ function keepLine(clean) {
15
15
  return KEEP_RE.test(clean) || STACK_RE.test(clean);
16
16
  }
17
17
 
18
+ function isSummaryLine(clean) {
19
+ return SUMMARY_RE.test(clean);
20
+ }
21
+
18
22
  export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
19
- if (!text || typeof text !== 'string') return { compressed: '', originalTokens: 0, compressedTokens: 0, savedTokens: 0, savedPercent: 0, keptLines: 0, totalLines: 0 };
23
+ if (!text || typeof text !== 'string') {
24
+ return { compressed: '', keptLines: 0, totalLines: 0 };
25
+ }
20
26
  const lines = text.split(/\r?\n/);
21
27
  const totalLines = lines.length;
22
28
  if (mode === 'raw') {
@@ -27,53 +33,70 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
27
33
  if (NOISE_RE.test(clean)) return false;
28
34
  return true;
29
35
  });
30
- const truncated = filtered.length > maxLines ? [...filtered.slice(0, maxLines - 1), `… truncated ${filtered.length - maxLines + 1} lines`] : filtered;
36
+ const truncated = filtered.length > maxLines
37
+ ? [...filtered.slice(0, maxLines - 1), `… truncated ${filtered.length - maxLines + 1} lines`]
38
+ : filtered;
31
39
  const compressed = truncated.join('\n');
32
- const originalTokens = estimateTokens(text);
33
- const compressedTokens = estimateTokens(compressed);
34
- return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: truncated.length, totalLines };
40
+ return { compressed, keptLines: truncated.length, totalLines };
35
41
  }
36
42
 
37
43
  const keep = new Array(lines.length).fill(false);
38
44
  const cleanLines = new Array(lines.length);
39
45
  const context = mode === 'aggressive' ? 1 : 2;
46
+ let hasFailure = false;
47
+
40
48
  for (let i = 0; i < lines.length; i++) {
41
49
  const clean = cleanAnsi(lines[i]);
42
50
  cleanLines[i] = clean;
43
51
  if (keepLine(clean)) {
52
+ hasFailure = true;
44
53
  const s = Math.max(0, i - context);
45
54
  const e = Math.min(lines.length - 1, i + context);
46
55
  for (let j = s; j <= e; j++) keep[j] = true;
47
56
  }
48
57
  }
49
- // if nothing matched, keep first/last few lines as hint
50
- if (!keep.some(Boolean)) {
51
- const head = Math.min(6, lines.length);
58
+
59
+ // If no failure was detected (e.g. successful test or build run),
60
+ // collapse into summary: keep first few lines and summary lines
61
+ if (!hasFailure) {
62
+ const head = Math.min(3, lines.length);
52
63
  for (let i = 0; i < head; i++) keep[i] = true;
53
- if (lines.length > head) {
54
- keep[lines.length - 1] = true;
64
+ for (let i = head; i < lines.length; i++) {
65
+ if (isSummaryLine(cleanLines[i])) {
66
+ keep[i] = true;
67
+ }
55
68
  }
69
+ if (lines.length > 0) keep[lines.length - 1] = true;
56
70
  }
57
- // drop pure PASS/noise unless within keep window already marked via context
71
+
72
+ // drop pure PASS/noise unless marked
58
73
  let out = [];
59
- for (let i = 0; i < lines.length; i++) if (keep[i]) {
60
- const l = lines[i];
61
- const clean = cleanLines[i];
62
- // aggressive drops more noise even inside window
63
- if (mode === 'aggressive' && (PASS_RE.test(clean) || NOISE_RE.test(clean)) && !keepLine(clean)) continue;
64
- out.push(l);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ if (keep[i]) {
76
+ const l = lines[i];
77
+ const clean = cleanLines[i];
78
+ if (mode === 'aggressive' && (PASS_RE.test(clean) || NOISE_RE.test(clean)) && !keepLine(clean) && !isSummaryLine(clean)) {
79
+ continue;
80
+ }
81
+ out.push(l);
82
+ }
65
83
  }
66
84
 
67
85
  // collapse consecutive empty lines
68
86
  const collapsed = [];
69
87
  let emptyStreak = 0;
70
88
  for (const l of out) {
71
- if (l.trim() === '') { emptyStreak++; if (emptyStreak <= 1) collapsed.push(l); }
72
- else { emptyStreak = 0; collapsed.push(l); }
89
+ if (l.trim() === '') {
90
+ emptyStreak++;
91
+ if (emptyStreak <= 1) collapsed.push(l);
92
+ } else {
93
+ emptyStreak = 0;
94
+ collapsed.push(l);
95
+ }
73
96
  }
74
97
  out = collapsed;
75
98
 
76
- // deduplicate repeated progress dots lines like "................"
99
+ // deduplicate repeated progress dots lines like '................'
77
100
  out = out.filter((l, idx, arr) => {
78
101
  if (/^[.\s]+$/.test(l) && l.length > 20) return false;
79
102
  if (idx > 0 && l === arr[idx - 1] && l.trim() !== '') return false;
@@ -82,13 +105,15 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
82
105
 
83
106
  if (out.length > maxLines) {
84
107
  const half = Math.floor((maxLines - 1) / 2);
85
- out = [...out.slice(0, half), `… truncated ${out.length - maxLines + 1} lines …`, ...out.slice(out.length - half)];
108
+ out = [
109
+ ...out.slice(0, half),
110
+ `… truncated ${out.length - maxLines + 1} lines …`,
111
+ ...out.slice(out.length - half)
112
+ ];
86
113
  }
114
+
87
115
  const compressed = out.join('\n');
88
- const originalTokens = estimateTokens(text);
89
- const compressedTokens = estimateTokens(compressed);
90
- return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: out.length, totalLines };
116
+ return { compressed, keptLines: out.length, totalLines };
91
117
  }
92
118
 
93
- export { cleanAnsi, estimateTokens };
94
- export default { compressLog, cleanAnsi, estimateTokens };
119
+ export default { compressLog, cleanAnsi };
package/lib/index.js CHANGED
@@ -1,27 +1,13 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
- import * as tracker from './tokens/tracker.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';
2
+ import { renderOutput, registerTools } from './tools.js';
13
3
 
14
4
  export const name = '@goodandready/dsh-context-lens';
15
- export const inject = ['tools', 'settings', 'webServer'];
5
+ export const inject = ['tools', 'settings'];
16
6
 
17
7
  export const Config = z.object({
18
8
  compressionMode: z.string().default('balanced').description('Log compression aggressiveness (raw/balanced/aggressive)'),
19
9
  astSkeletonMaxDepth: z.number().default(3).description('Max depth for AST skeleton generation'),
20
- tokenSavingsTracking: z.boolean().default(true).description('Track and display token budget savings'),
21
- autoCompressThreshold: z.number().default(4000).description('Auto-compress threshold in chars (0 to disable)'),
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'),
24
- autoCollapse: z.boolean().default(true).description('Warn in UI when budget is nearly exhausted (does not force-close the settings card)')
10
+ autoCompressThreshold: z.number().default(4000).description('Auto-compress threshold in chars for test/build logs (0 to disable)')
25
11
  });
26
12
 
27
13
  const NS = '@goodandready/dsh-context-lens';
@@ -95,157 +81,8 @@ export function apply(ctx, config) {
95
81
  }
96
82
  });
97
83
 
98
- function maybeTrack(original, compressed) {
99
- const cfg = getConfig();
100
- if (cfg.tokenSavingsTracking === false) return;
101
- tracker.record(original, compressed);
102
- }
103
-
104
- // Register AI agent tools
84
+ // Register consolidated AI agent tools (context_lens_code, context_lens_log)
105
85
  if (ctx.tools) {
106
- registerTools(ctx, { getConfig, maybeTrack });
107
- }
108
-
109
- if (ctx.webServer) {
110
- // #61: One-click plugin updater endpoint (/api/dsh-context-lens/update)
111
- const mountUpdater = () => registerPluginUpdater(ctx, {
112
- packageName: '@goodandready/dsh-context-lens',
113
- manifestUrl: new URL('../package.json', import.meta.url),
114
- endpoint: '/api/dsh-context-lens/update'
115
- });
116
- if (typeof ctx.effect === 'function') {
117
- ctx.effect(mountUpdater, 'dsh-context-lens: updater');
118
- } else {
119
- mountUpdater();
120
- }
121
-
122
- // Status route (#70: explicit URL parsing and validation; #87: GET only, trusted source check)
123
- ctx.effect(() => ctx.webServer.register({
124
- kind: 'exact',
125
- path: '/dsh-context-lens/status',
126
- handler: (req, res) => {
127
- if (req.method !== 'GET') {
128
- res.writeHead(405, { Allow: 'GET', 'Content-Type': 'application/json' });
129
- res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: GET required' }));
130
- return;
131
- }
132
- if (!isTrustedRequest(req)) {
133
- writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
134
- return;
135
- }
136
-
137
- const cfg = getConfig();
138
- if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
139
- if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
140
- let sId = getLastActiveSession() || '__default__';
141
- try {
142
- const u = new URL(req.url, 'http://127.0.0.1');
143
- const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
144
- if (qId !== null) {
145
- if (!qId || typeof qId !== 'string' || qId.trim() === '') {
146
- writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
147
- return;
148
- }
149
- sId = qId.trim();
150
- }
151
- } catch (err) {
152
- ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in status', err);
153
- writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
154
- return;
155
- }
156
-
157
- writeJson(res, 200, {
158
- ok: true,
159
- plugin: 'dsh-context-lens',
160
- stats: tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent),
161
- history: tracker.getHistory(),
162
- focus: getFocus(sId),
163
- sessionId: sId
164
- });
165
- }
166
- }), 'dsh-context-lens status route');
167
-
168
- // Quick clear-focus API route (#62: POST only, trusted origin check; #70: clean URL error handling)
169
- ctx.effect(() => ctx.webServer.register({
170
- kind: 'exact',
171
- path: '/dsh-context-lens/clear-focus',
172
- handler: async (req, res) => {
173
- if (req.method !== 'POST') {
174
- res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
175
- res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
176
- return;
177
- }
178
- if (!isTrustedRequest(req)) {
179
- writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
180
- return;
181
- }
182
-
183
- let sId = getLastActiveSession() || '__default__';
184
- try {
185
- const u = new URL(req.url, 'http://127.0.0.1');
186
- const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
187
- if (qId !== null) {
188
- if (!qId || typeof qId !== 'string' || qId.trim() === '') {
189
- writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
190
- return;
191
- }
192
- sId = qId.trim();
193
- }
194
- } catch (err) {
195
- ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in clear-focus', err);
196
- writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
197
- return;
198
- }
199
-
200
- clearFocus(sId);
201
- writeJson(res, 200, { ok: true, sessionId: sId, focus: getFocus(sId) });
202
- }
203
- }), 'dsh-context-lens clear-focus route');
204
-
205
- // Server-backed preview (#63: POST only, trusted origin, bounded body size, clamped maxLines)
206
- ctx.effect(() => ctx.webServer.register({
207
- kind: 'exact',
208
- path: '/dsh-context-lens/compress-preview',
209
- handler: async (req, res) => {
210
- if (req.method !== 'POST') {
211
- res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
212
- res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
213
- return;
214
- }
215
- if (!isTrustedRequest(req)) {
216
- writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
217
- return;
218
- }
219
-
220
- let body;
221
- try {
222
- const raw = await readBody(req, 256 * 1024);
223
- body = JSON.parse(raw.toString('utf8') || '{}');
224
- } catch (e) {
225
- if (e?.message === 'body too large') {
226
- writeJson(res, 413, { ok: false, error: 'Payload Too Large: maximum 256KB allowed' });
227
- return;
228
- }
229
- writeJson(res, 400, { ok: false, error: 'Malformed JSON body' });
230
- return;
231
- }
232
-
233
- let maxLines = 400;
234
- if (body.maxLines !== undefined) {
235
- const parsed = Number(body.maxLines);
236
- if (Number.isNaN(parsed) || parsed < 1 || parsed > 5000) {
237
- writeJson(res, 400, { ok: false, error: 'maxLines must be a number between 1 and 5000' });
238
- return;
239
- }
240
- maxLines = Math.floor(parsed);
241
- }
242
-
243
- const cfg = getConfig();
244
- const mode = body.mode || cfg.compressionMode || 'balanced';
245
- const text = typeof body.text === 'string' ? body.text : '';
246
- const out = compressLog(text, { mode, maxLines });
247
- writeJson(res, 200, { ok: true, mode, ...out });
248
- }
249
- }), 'dsh-context-lens compress-preview route');
86
+ registerTools(ctx, { getConfig });
250
87
  }
251
88
  }
package/lib/tools.js CHANGED
@@ -1,9 +1,8 @@
1
- import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
2
1
  import { skeletonize } from './ast/skeletonizer.js';
3
- import * as tracker from './tokens/tracker.js';
2
+ import { compressLog } from './compression/log-compressor.js';
4
3
  import { shouldAutoCompress } from './auto-compress.js';
5
4
 
6
- function normalizePath(p) {
5
+ export function normalizePath(p) {
7
6
  return (p || '').replace(/\\/g, '/').replace(/\/+$/, '');
8
7
  }
9
8
 
@@ -17,7 +16,11 @@ export function isPathFocused(pattern, filePath) {
17
16
  return false;
18
17
  }
19
18
 
20
- export const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
19
+ export const OUTPUT_SCHEMA = {
20
+ type: 'object',
21
+ properties: { success: { type: 'boolean' } },
22
+ additionalProperties: true
23
+ };
21
24
 
22
25
  // #71 (GH #2): output.render MUST return ContentBlock[] [{ type: 'text', text: ... }]
23
26
  export const renderOutput = (_args, result) => [
@@ -67,148 +70,131 @@ export function getFocusBySessionSize() {
67
70
  return focusBySession.size;
68
71
  }
69
72
 
70
- export function getLastActiveSession() {
71
- return lastActiveSession;
72
- }
73
-
74
- export function registerTools(ctx, { getConfig, maybeTrack }) {
73
+ export function registerTools(ctx, { getConfig } = {}) {
75
74
  if (!ctx?.tools?.register) return;
76
75
 
76
+ // Tool 1: Code semantic compression & focus management
77
77
  ctx.tools.register({
78
- name: 'context_lens_focus',
79
- description: 'Set active file paths for current session focus (these files skip AST compression)',
78
+ name: 'context_lens_code',
79
+ description: 'Semantic AST skeletonization of code (JS/TS/Python/Go/Rust/Java/C/C++/SQL) and path-based focus management',
80
80
  parameters: {
81
81
  type: 'object',
82
82
  properties: {
83
- paths: { type: 'array', items: { type: 'string' }, description: 'File paths or patterns currently in focus' },
84
- sessionId: { type: 'string', description: 'Session id (uses active session if omitted)' }
85
- },
86
- required: ['paths']
83
+ action: {
84
+ type: 'string',
85
+ enum: ['skeleton', 'focus', 'get_focus', 'clear_focus'],
86
+ description: "Action to perform: 'skeleton' (default: AST code skeleton), 'focus' (set session focus paths), 'get_focus' (view active focus), or 'clear_focus' (reset focus)"
87
+ },
88
+ code: { type: 'string', description: 'Source code to skeletonize (for action=skeleton)' },
89
+ language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint for skeletonization' },
90
+ maxDepth: { type: 'number', description: 'Max depth for AST skeleton' },
91
+ filePath: { type: 'string', description: 'File path to check against focus paths (if in focus, returns full code without skeletonization)' },
92
+ paths: { type: 'array', items: { type: 'string' }, description: 'File paths or patterns to put in focus (for action=focus)' },
93
+ sessionId: { type: 'string', description: 'Session id for session-isolated focus' }
94
+ }
87
95
  },
88
96
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
89
97
  execute: async (params, meta) => {
98
+ const action = (params && params.action) || 'skeleton';
90
99
  const key = sessionKey(params, meta);
91
- const rawPaths = Array.isArray(params.paths) ? params.paths : [];
92
- const paths = rawPaths
93
- .filter(p => typeof p === 'string')
94
- .map(p => p.trim())
95
- .filter(p => p.length > 0);
96
- const state = getFocus(key);
97
- state.paths = paths;
98
- state.updatedAt = new Date().toISOString();
99
- return { success: true, sessionId: key, focus: state };
100
- }
101
- });
102
100
 
103
- ctx.tools.register({
104
- name: 'context_lens_compress_log',
105
- description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
106
- parameters: {
107
- type: 'object',
108
- properties: {
109
- text: { type: 'string', description: 'Raw log text to compress' },
110
- log: { type: 'string', description: 'Alias for text' },
111
- mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
112
- maxLines: { type: 'number', description: 'Max output lines' },
113
- auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
101
+ if (action === 'focus') {
102
+ const rawPaths = Array.isArray(params?.paths) ? params.paths : [];
103
+ const paths = rawPaths
104
+ .filter(p => typeof p === 'string')
105
+ .map(p => p.trim())
106
+ .filter(p => p.length > 0);
107
+ const state = getFocus(key);
108
+ state.paths = paths;
109
+ state.updatedAt = new Date().toISOString();
110
+ return { success: true, action: 'focus', sessionId: key, focus: state };
114
111
  }
115
- },
116
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
117
- execute: async (params) => {
118
- const cfg = getConfig();
119
- const inputText = params.text || params.log || '';
120
- const mode = params.mode || cfg.compressionMode || 'balanced';
121
- const maxLines = params.maxLines || 400;
122
- const threshold = cfg.autoCompressThreshold ?? 4000;
123
- const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
124
- const effectiveMode = mode;
125
- const res = compressLog(inputText, { mode: effectiveMode, maxLines });
126
- maybeTrack(inputText, res.compressed);
127
- return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
128
- }
129
- });
130
112
 
131
- ctx.tools.register({
132
- name: 'context_lens_compress_code',
133
- description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java/C/C++/SQL) to save context',
134
- parameters: {
135
- type: 'object',
136
- properties: {
137
- code: { type: 'string', description: 'Source code to skeletonize' },
138
- language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint' },
139
- maxDepth: { type: 'number', description: 'Max depth' },
140
- filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
141
- sessionId: { type: 'string', description: 'Session id for focus lookup' }
142
- },
143
- required: ['code']
144
- },
145
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
146
- execute: async (params, meta) => {
147
- const cfg = getConfig();
148
- const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
149
- const key = sessionKey(params, meta);
113
+ if (action === 'get_focus') {
114
+ return { success: true, action: 'get_focus', sessionId: key, focus: getFocus(key) };
115
+ }
116
+
117
+ if (action === 'clear_focus') {
118
+ clearFocus(key);
119
+ return { success: true, action: 'clear_focus', sessionId: key, focus: getFocus(key) };
120
+ }
121
+
122
+ // Default: skeleton
123
+ const cfg = getConfig ? getConfig() : {};
124
+ const maxDepth = params?.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
125
+ const code = params?.code || '';
126
+ const rawFilePath = typeof params?.filePath === 'string' ? params.filePath.trim() : '';
150
127
  const focusState = getFocus(key);
151
- const rawFilePath = typeof params.filePath === 'string' ? params.filePath.trim() : '';
152
128
  const isFocused = rawFilePath && focusState.paths.length > 0
153
129
  ? focusState.paths.some(p => isPathFocused(p, rawFilePath))
154
130
  : false;
131
+
155
132
  if (isFocused) {
156
- maybeTrack(params.code, params.code);
157
- 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' };
133
+ return {
134
+ success: true,
135
+ action: 'skeleton',
136
+ skeleton: code,
137
+ maxDepth,
138
+ focused: true,
139
+ sessionId: key,
140
+ hint: 'File is in focus, returned full code'
141
+ };
158
142
  }
159
- const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
160
- maybeTrack(params.code, skeleton);
161
- return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
143
+
144
+ const skeleton = skeletonize(code, { maxDepth, language: params?.language });
145
+ return {
146
+ success: true,
147
+ action: 'skeleton',
148
+ skeleton,
149
+ maxDepth,
150
+ focused: false,
151
+ sessionId: key
152
+ };
162
153
  }
163
154
  });
164
155
 
156
+ // Tool 2: Test & build log compression
165
157
  ctx.tools.register({
166
- name: 'context_lens_reset',
167
- description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
158
+ name: 'context_lens_log',
159
+ description: 'Compress test and build output logs, keeping failures, stack traces, and summary',
168
160
  parameters: {
169
161
  type: 'object',
170
162
  properties: {
171
- sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
163
+ text: { type: 'string', description: 'Raw log text to compress' },
164
+ log: { type: 'string', description: 'Alias for text' },
165
+ mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression mode (default: balanced)' },
166
+ maxLines: { type: 'number', description: 'Max output lines' },
167
+ auto: { type: 'boolean', description: 'Auto-compress only if large and matches test/build signatures' },
168
+ command: { type: 'string', description: 'Command that generated output (used to identify test/build logs in auto mode)' }
172
169
  }
173
170
  },
174
171
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
175
- execute: async (params, meta) => {
176
- tracker.reset();
177
- const key = sessionKey(params || {}, meta);
178
- if (params && params.sessionId) {
179
- focusBySession.delete(key);
180
- }
181
- return { success: true, message: 'Context lens stats and history reset', sessionId: key };
182
- }
183
- });
172
+ execute: async (params) => {
173
+ const cfg = getConfig ? getConfig() : {};
174
+ const inputText = params?.text || params?.log || '';
175
+ const mode = params?.mode || cfg.compressionMode || 'balanced';
176
+ const maxLines = params?.maxLines || 400;
177
+ const threshold = cfg.autoCompressThreshold ?? 4000;
184
178
 
185
- ctx.tools.register({
186
- name: 'context_lens_stats',
187
- description: 'Show session token savings stats for context-lens',
188
- parameters: {
189
- type: 'object',
190
- properties: {
191
- sessionId: { type: 'string', description: 'Session id for focus snapshot' }
179
+ if (params?.auto === true) {
180
+ if (!shouldAutoCompress(inputText, threshold, params?.command)) {
181
+ return {
182
+ success: true,
183
+ mode,
184
+ autoCompressed: false,
185
+ compressed: inputText,
186
+ skipped: true,
187
+ reason: 'Not recognized as test/build output or below threshold; preserved for core pruner'
188
+ };
189
+ }
192
190
  }
193
- },
194
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
195
- execute: async (params, meta) => {
196
- const cfg = getConfig();
197
- if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
198
- if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
199
- const stats = tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent);
200
- const history = tracker.getHistory();
201
- const key = sessionKey(params || {}, meta);
191
+
192
+ const res = compressLog(inputText, { mode, maxLines });
202
193
  return {
203
194
  success: true,
204
- ...stats,
205
- history,
206
- trackingEnabled: cfg.tokenSavingsTracking !== false,
207
- autoCollapse: cfg.autoCollapse !== false,
208
- focus: getFocus(key),
209
- sessionId: key,
210
- autoCompressThreshold: cfg.autoCompressThreshold,
211
- budgetAlertPercent: cfg.budgetAlertPercent ?? 90
195
+ mode,
196
+ autoCompressed: params?.auto === true,
197
+ ...res
212
198
  };
213
199
  }
214
200
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-context-lens",
3
- "version": "0.1.25",
4
- "description": "DSH plugin for AST context compression, test log filtering, and token budget guard",
3
+ "version": "0.1.26",
4
+ "description": "DSH plugin for semantic AST code skeletonization and test/build log compression",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -26,9 +26,9 @@
26
26
  "dsh",
27
27
  "dsh-plugin",
28
28
  "context",
29
- "token-guard",
30
29
  "ast-compression",
31
- "log-filter"
30
+ "log-compression",
31
+ "semantic-compression"
32
32
  ],
33
33
  "repository": {
34
34
  "type": "git",
@@ -54,15 +54,8 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.1",
57
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
58
57
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
59
58
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
- "@deepseek-ai/schemastery": "^3.18.1",
61
- "dsh-better-sidebar": "^0.18.0-alpha.0"
62
- },
63
- "peerDependenciesMeta": {
64
- "dsh-better-sidebar": {
65
- "optional": true
66
- }
59
+ "@deepseek-ai/schemastery": "^3.18.1"
67
60
  }
68
61
  }