@goodandready/dsh-context-lens 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GooDAnDReaDY
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # dsh-context-lens
2
+
3
+ DSH plugin for AST context compression, test log filtering, and token budget guard for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
+
5
+ Compresses noisy test output (Jest/Pytest/Go/Vitest/npm) to failures + stacktraces and generates compact AST skeletons for large source files to save context tokens.
6
+
7
+ ## Tools
8
+ - `context_lens_focus` — set focused files/folders (other context collapsed)
9
+ - `context_lens_compress_log` — compress a log block (`text`, `mode` raw/balanced/aggressive, `maxLines`)
10
+ - `context_lens_compress_code` — skeletonize source code (`code`, `language` js/ts/py/go, `maxDepth`)
11
+ - `context_lens_stats` — session token savings stats
12
+
13
+ Status route: `GET /dsh-context-lens/status`
14
+
15
+ ## Settings
16
+ Located in **Settings → Plugins → Context Lens & Token Guard**:
17
+ - `compressionMode`: `raw` | `balanced` | `aggressive` (default `balanced`)
18
+ - `astSkeletonMaxDepth`: max skeleton depth (default `3`)
19
+ - `tokenSavingsTracking`: track savings (default `true`)
20
+
21
+ Example config:
22
+ ```yaml
23
+ # `compressionMode` — aggressiveness, `astSkeletonMaxDepth` — skeleton depth
24
+ compressionMode: balanced
25
+ astSkeletonMaxDepth: 3
26
+ tokenSavingsTracking: true
27
+ ```
28
+
29
+ ## Verification
30
+ ```bash
31
+ npm test
32
+ ```
33
+
34
+ ## License
35
+ MIT
@@ -0,0 +1,2 @@
1
+ - id: dsh-context-lens
2
+ name: '@goodandready/dsh-context-lens'
@@ -0,0 +1,61 @@
1
+ // ponytail: regex skeletons, no tree-sitter — handles JS/TS/Python/Go signatures
2
+ const JS_FUNC_RE = /^\s*(export\s+)?(async\s+)?(function\s+(\w+)|const\s+(\w+)\s*=\s*(async\s+)?\([^)]*\)\s*=>|(\w+)\s*:\s*\([^)]*\)\s*=>|class\s+(\w+)|interface\s+(\w+)|type\s+(\w+)\s*=)/;
3
+ const PY_RE = /^\s*(def\s+(\w+)\s*\([^)]*\)|class\s+(\w+).*?:|async def\s+(\w+)\s*\([^)]*\))/;
4
+ const GO_RE = /^\s*(func\s+(\([^)]+\)\s+)?(\w+)\s*\([^)]*\)|type\s+(\w+)\s+(struct|interface))/;
5
+
6
+ function indentDepth(line) {
7
+ const m = line.match(/^(\s*)/);
8
+ return m ? Math.floor(m[1].replace(/\t/g, ' ').length / 2) : 0;
9
+ }
10
+
11
+ export function skeletonize(text, { maxDepth = 3, language } = {}) {
12
+ if (!text || typeof text !== 'string') return '';
13
+ const lines = text.split(/\r?\n/);
14
+ const out = [];
15
+ let seen = new Set();
16
+ for (const raw of lines) {
17
+ const line = raw.trimEnd();
18
+ if (!line.trim()) continue;
19
+ const d = indentDepth(raw);
20
+ if (d > maxDepth) continue;
21
+ let sig = null;
22
+ // try JS/TS
23
+ if (!language || language === 'js' || language === 'ts') {
24
+ const m = line.match(/^\s*(export\s+)?(async\s+)?(function\s+\w+[^\n]*|const\s+\w+\s*=.*=>.*|class\s+\w+.*|interface\s+\w+.*|type\s+\w+\s*=.*|(?:public|private|protected)?\s*(async\s+)?\w+\s*\([^)]*\)\s*[:{])/);
25
+ if (m) sig = line.trim().replace(/\s*\{.*$/, '').replace(/\s+$/, '') + (line.trim().endsWith('{') ? '' : '');
26
+ // fallback simple
27
+ if (!sig && /^\s*(export\s+)?(async\s+)?function\s+\w+/.test(line)) sig = line.trim();
28
+ if (!sig && /^\s*class\s+\w+/.test(line)) sig = line.trim();
29
+ }
30
+ if (!sig && (!language || language === 'py' || language === 'python')) {
31
+ const m = line.match(PY_RE);
32
+ if (m) sig = line.trim();
33
+ }
34
+ if (!sig && (!language || language === 'go')) {
35
+ const m = line.match(GO_RE);
36
+ if (m) sig = line.trim();
37
+ }
38
+ // generic fallback: if no specific language, try all
39
+ if (!sig && !language) {
40
+ if (JS_FUNC_RE.test(line) || PY_RE.test(line) || GO_RE.test(line)) sig = line.trim();
41
+ }
42
+ if (sig) {
43
+ // normalize: trim trailing { : etc, keep signature short
44
+ sig = sig.replace(/\s*\{\s*$/, '').replace(/:\s*$/, '').trim();
45
+ if (sig.length > 120) sig = sig.slice(0, 117) + '...';
46
+ const key = sig;
47
+ if (!seen.has(key)) {
48
+ seen.add(key);
49
+ out.push(' '.repeat(Math.min(d, maxDepth)) + sig);
50
+ }
51
+ }
52
+ }
53
+ // if nothing found, fallback to first N non-empty lines truncated
54
+ if (out.length === 0) {
55
+ const fallback = lines.filter((l) => l.trim()).slice(0, Math.min(20, maxDepth * 6)).map((l) => l.trim().slice(0, 120));
56
+ return fallback.join('\n');
57
+ }
58
+ return out.join('\n');
59
+ }
60
+
61
+ export default { skeletonize };
package/lib/client.js ADDED
@@ -0,0 +1,223 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@goodandready/dsh-context-lens',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ const React = require('react');
6
+ const NS = '@goodandready/dsh-context-lens';
7
+
8
+ const en = {
9
+ title: 'Context Lens & Token Guard',
10
+ sub: 'AST compression, log filtering, token budget',
11
+ mode: 'Compression mode',
12
+ depth: 'AST max depth',
13
+ tracking: 'Track token savings',
14
+ preview: 'Log preview',
15
+ compress: 'Compress',
16
+ saved: 'Saved',
17
+ tokens: 'tokens',
18
+ placeholder: 'Paste Jest/Pytest log here…',
19
+ saving: 'Saving…',
20
+ ready: 'Ready'
21
+ };
22
+ const ru = {
23
+ title: 'Context Lens & Token Guard',
24
+ sub: 'Сжатие AST, фильтрация логов, контроль токенов',
25
+ mode: 'Режим сжатия',
26
+ depth: 'Глубина AST',
27
+ tracking: 'Считать экономию',
28
+ preview: 'Предпросмотр лога',
29
+ compress: 'Сжать',
30
+ saved: 'Сэкономлено',
31
+ tokens: 'токенов',
32
+ placeholder: 'Вставьте лог Jest/Pytest…',
33
+ saving: 'Сохранение…',
34
+ ready: 'Готово'
35
+ };
36
+
37
+ // ponytail: client-side replica of compressor for preview (no import)
38
+ function compressPreview(text, mode) {
39
+ if (!text) return '';
40
+ const KEEP = /(FAIL|FAILED|Error|AssertionError|Exception|Traceback|panic|npm ERR!|Expected|Received|at\s+.*:\d+:\d+)/i;
41
+ const lines = text.split(/\r?\n/);
42
+ const keep = new Array(lines.length).fill(false);
43
+ const ctx = mode === 'aggressive' ? 1 : 2;
44
+ for (let i = 0; i < lines.length; i++) if (KEEP.test(lines[i])) {
45
+ for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) keep[j] = true;
46
+ }
47
+ if (!keep.some(Boolean)) return lines.slice(0, 20).join('\n');
48
+ return lines.filter((_, i) => keep[i]).slice(0, 200).join('\n');
49
+ }
50
+
51
+ function PluginCard({ ctx: _ctx, t }) {
52
+ const [expanded, setExpanded] = React.useState(false);
53
+ // hooks must be before any return — React 310
54
+ const [draft, setDraft] = React.useState({ compressionMode: 'balanced', astSkeletonMaxDepth: 3, tokenSavingsTracking: true });
55
+ const [status, setStatus] = React.useState('loading');
56
+ const [saving, setSaving] = React.useState(false);
57
+ const [saveErr, setSaveErr] = React.useState('');
58
+ const [previewIn, setPreviewIn] = React.useState('FAIL src/app.test.js\n ● should handle\n Expected 1 got 2\n at Object.<anonymous> (src/app.test.js:10:5)\nPASS src/ok.test.js\n');
59
+ const [previewOut, setPreviewOut] = React.useState('');
60
+ const [stats, setStats] = React.useState(null);
61
+
62
+ const scopeRef = React.useRef(null);
63
+ if (!scopeRef.current && _ctx && _ctx.settingsScope) {
64
+ try { scopeRef.current = _ctx.settingsScope.bind({ namespace: NS }); } catch (e) { scopeRef.current = null; }
65
+ }
66
+ const scope = scopeRef.current;
67
+
68
+ React.useEffect(() => {
69
+ if (!scope) { setStatus('unavailable'); return; }
70
+ let cancelled = false;
71
+ (async () => {
72
+ try {
73
+ const snap = await scope.get();
74
+ if (cancelled) return;
75
+ // check snapshot status if available
76
+ if (snap && typeof snap === 'object' && 'status' in snap) {
77
+ if (snap.status === 'loading') { setStatus('loading'); return; }
78
+ if (snap.status === 'unavailable') { setStatus('unavailable'); return; }
79
+ }
80
+ // snap is either values or wrapper with values
81
+ const vals = snap && snap.values ? snap.values : snap;
82
+ if (vals && typeof vals === 'object') {
83
+ setDraft((d) => ({ ...d, ...vals }));
84
+ }
85
+ setStatus('ready');
86
+ } catch (e) {
87
+ if (!cancelled) setStatus('unavailable');
88
+ }
89
+ })();
90
+ return () => { cancelled = true; };
91
+ }, [scope]);
92
+
93
+ React.useEffect(() => {
94
+ if (expanded) {
95
+ fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => { if (j && j.stats) setStats(j.stats); }).catch(() => {});
96
+ }
97
+ }, [expanded]);
98
+
99
+ const tt = t || ((k) => (en[k] || k));
100
+
101
+ let ChevronIcon = null;
102
+ try {
103
+ const prim = require('@deepseek-ai/dsh-client-ui-primitives');
104
+ ChevronIcon = prim && prim.IconChevronDownOutline14;
105
+ } catch (e) { ChevronIcon = null; }
106
+
107
+ const Chevron = ChevronIcon ? function ChevronNode(p) {
108
+ return React.createElement(ChevronIcon, { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s', transform: p.open ? 'rotate(180deg)' : 'none' } });
109
+ } : function Fallback(p) {
110
+ return React.createElement('span', { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)' } }, '▼');
111
+ };
112
+
113
+ async function onSave() {
114
+ if (!scope) { setSaveErr('Settings unavailable'); return; }
115
+ setSaving(true); setSaveErr('');
116
+ const keys = Object.keys(draft);
117
+ const errs = [];
118
+ for (const k of keys) {
119
+ try { await scope.set(k, draft[k]); } catch (e) { errs.push(k + ': ' + (e && e.message || String(e))); }
120
+ }
121
+ setSaving(false);
122
+ if (errs.length) setSaveErr(errs.join('; '));
123
+ }
124
+
125
+ function onPreview() {
126
+ const mode = draft.compressionMode || 'balanced';
127
+ setPreviewOut(compressPreview(previewIn, mode));
128
+ }
129
+
130
+ // styles: ponytail minimal, theme vars only
131
+ return React.createElement('li', { className: 'cl-card', style: { border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', borderRadius: 12, listStyle: 'none' } },
132
+ React.createElement('button', {
133
+ className: 'cl-head',
134
+ onClick: () => setExpanded(!expanded),
135
+ 'aria-expanded': expanded,
136
+ style: { appearance: 'none', width: '100%', font: 'inherit', color: 'inherit', textAlign: 'left', cursor: 'pointer', background: '0 0', border: 0, borderRadius: 12, display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px' }
137
+ },
138
+ React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
139
+ React.createElement('span', { className: 'cl-title', style: { color: 'var(--dsw-alias-label-primary)', fontSize: 15, fontWeight: 600, lineHeight: 1.4 } }, tt('title')),
140
+ React.createElement('span', { className: 'cl-sub', style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, tt('sub') + (stats ? ' · ' + tt('saved') + ' ' + (stats.savedTokens || 0) + ' ' + tt('tokens') + ' (' + (stats.savedPercent || 0) + '%)' : ''))
141
+ ),
142
+ React.createElement(Chevron, { open: expanded })
143
+ ),
144
+ expanded ? React.createElement('div', { className: 'cl-body', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', margin: '0 16px', paddingBottom: 8 } },
145
+ status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
146
+ status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
147
+ React.createElement(React.Fragment, null,
148
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
149
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('mode')),
150
+ React.createElement('select', {
151
+ className: 'cl-input',
152
+ value: draft.compressionMode,
153
+ onChange: (e) => setDraft((d) => ({ ...d, compressionMode: e.target.value })),
154
+ style: { height: 34, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', borderRadius: 8, padding: '0 12px', fontSize: 13 }
155
+ },
156
+ React.createElement('option', { value: 'raw' }, 'raw'),
157
+ React.createElement('option', { value: 'balanced' }, 'balanced'),
158
+ React.createElement('option', { value: 'aggressive' }, 'aggressive')
159
+ )
160
+ ),
161
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
162
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('depth')),
163
+ React.createElement('input', {
164
+ className: 'cl-input',
165
+ type: 'number', min: 1, max: 10,
166
+ value: draft.astSkeletonMaxDepth,
167
+ onChange: (e) => setDraft((d) => ({ ...d, astSkeletonMaxDepth: parseInt(e.target.value, 10) || 3 })),
168
+ style: { height: 34, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', borderRadius: 8, padding: '0 12px', fontSize: 13 }
169
+ })
170
+ ),
171
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, padding: '12px 0' } },
172
+ React.createElement('input', {
173
+ type: 'checkbox',
174
+ checked: !!draft.tokenSavingsTracking,
175
+ onChange: (e) => setDraft((d) => ({ ...d, tokenSavingsTracking: e.target.checked })),
176
+ id: 'cl-tracking'
177
+ }),
178
+ React.createElement('label', { htmlFor: 'cl-tracking', style: { fontSize: 13 } }, tt('tracking'))
179
+ ),
180
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
181
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('preview')),
182
+ React.createElement('textarea', {
183
+ value: previewIn,
184
+ onChange: (e) => setPreviewIn(e.target.value),
185
+ placeholder: tt('placeholder'),
186
+ rows: 5,
187
+ style: { border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', borderRadius: 8, padding: 12, fontSize: 12, fontFamily: 'monospace' }
188
+ }),
189
+ React.createElement('button', {
190
+ onClick: onPreview,
191
+ style: { appearance: 'none', font: 'inherit', cursor: 'pointer', border: '1px solid transparent', borderRadius: 8, padding: '5px 14px', fontSize: 13, background: 'var(--dsw-alias-label-primary)', color: 'var(--dsw-alias-bg-layer-3)', alignSelf: 'flex-start' }
192
+ }, tt('compress')),
193
+ previewOut ? React.createElement('pre', { style: { whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-3))', padding: 12, borderRadius: 8, maxHeight: 200, overflow: 'auto' } }, previewOut) : null
194
+ ),
195
+ saveErr ? React.createElement('div', { style: { color: '#d73a4a', fontSize: 12, padding: '4px 0' } }, saveErr) : null,
196
+ React.createElement('div', { className: 'cl-foot', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, padding: '12px 0 4px' } },
197
+ React.createElement('button', {
198
+ className: 'cl-save',
199
+ onClick: onSave,
200
+ disabled: saving,
201
+ style: { appearance: 'none', font: 'inherit', cursor: 'pointer', border: '1px solid transparent', borderRadius: 8, padding: '5px 14px', fontSize: 13, background: 'var(--dsw-alias-label-primary)', color: 'var(--dsw-alias-bg-layer-3)', opacity: saving ? 0.6 : 1 }
202
+ }, saving ? tt('saving') : tt('ready'))
203
+ )
204
+ )
205
+ ) : null
206
+ );
207
+ }
208
+
209
+ module.exports.inject = ['slots', 'locale'];
210
+ module.exports.apply = function apply(ctx) {
211
+ try { ctx.locale.register(NS, { en, ru }); } catch (e) {}
212
+ if (ctx.slots) {
213
+ ctx.slots.register({
214
+ name: 'settings.plugin.item',
215
+ key: NS,
216
+ locale: NS,
217
+ inject: () => ({ ctx })
218
+ }, PluginCard);
219
+ }
220
+ };
221
+ return module.exports;
222
+ }
223
+ });
@@ -0,0 +1,84 @@
1
+ // ponytail: heuristic line filter, not ML — O(n) scan, no deps
2
+ 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)/i;
3
+ const PASS_RE = /^(PASS|\s*✓|\s*✔|\s*ok\s|…+\s*$|\s*\.\s*$)/i;
4
+ const NOISE_RE = /^(npm (notice|warn|info)|Browserslist|cached|Downloading|Done in)/i;
5
+ const STACK_RE = /^\s*(at\s+|File ".*", line \d+|#\d+\s+0x|goroutine \d+ \[)/;
6
+
7
+ function keepLine(line, mode) {
8
+ if (KEEP_RE.test(line) || STACK_RE.test(line)) return true;
9
+ if (mode === 'raw') return !(PASS_RE.test(line) || NOISE_RE.test(line)) ? true : false;
10
+ // balanced/aggressive rely on context window, not bare line
11
+ return false;
12
+ }
13
+
14
+ export function estimateTokens(text) {
15
+ return Math.ceil((text || '').length / 4);
16
+ }
17
+
18
+ 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 };
20
+ const lines = text.split(/\r?\n/);
21
+ const totalLines = lines.length;
22
+ if (mode === 'raw') {
23
+ // raw: drop only obvious noise, keep rest
24
+ const filtered = lines.filter((l) => !NOISE_RE.test(l) || KEEP_RE.test(l));
25
+ const truncated = filtered.length > maxLines ? [...filtered.slice(0, maxLines - 1), `… truncated ${filtered.length - maxLines + 1} lines`] : filtered;
26
+ const compressed = truncated.join('\n');
27
+ const originalTokens = estimateTokens(text);
28
+ const compressedTokens = estimateTokens(compressed);
29
+ return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: truncated.length, totalLines };
30
+ }
31
+
32
+ const keep = new Array(lines.length).fill(false);
33
+ const context = mode === 'aggressive' ? 1 : 2;
34
+ for (let i = 0; i < lines.length; i++) {
35
+ if (keepLine(lines[i], mode)) {
36
+ const s = Math.max(0, i - context);
37
+ const e = Math.min(lines.length - 1, i + context);
38
+ for (let j = s; j <= e; j++) keep[j] = true;
39
+ }
40
+ }
41
+ // if nothing matched, keep first/last few lines as hint
42
+ if (!keep.some(Boolean)) {
43
+ const head = Math.min(6, lines.length);
44
+ for (let i = 0; i < head; i++) keep[i] = true;
45
+ if (lines.length > head) {
46
+ keep[lines.length - 1] = true;
47
+ }
48
+ }
49
+ // drop pure PASS/noise unless within keep window already marked via context
50
+ let out = [];
51
+ for (let i = 0; i < lines.length; i++) if (keep[i]) {
52
+ const l = lines[i];
53
+ // aggressive drops more noise even inside window
54
+ if (mode === 'aggressive' && (PASS_RE.test(l) || NOISE_RE.test(l)) && !KEEP_RE.test(l) && !STACK_RE.test(l)) continue;
55
+ out.push(l);
56
+ }
57
+
58
+ // collapse consecutive empty lines
59
+ const collapsed = [];
60
+ let emptyStreak = 0;
61
+ for (const l of out) {
62
+ if (l.trim() === '') { emptyStreak++; if (emptyStreak <= 1) collapsed.push(l); }
63
+ else { emptyStreak = 0; collapsed.push(l); }
64
+ }
65
+ out = collapsed;
66
+
67
+ // deduplicate repeated progress dots lines like "................"
68
+ out = out.filter((l, idx, arr) => {
69
+ if (/^[.\s]+$/.test(l) && l.length > 20) return false;
70
+ if (idx > 0 && l === arr[idx - 1] && l.trim() !== '') return false;
71
+ return true;
72
+ });
73
+
74
+ if (out.length > maxLines) {
75
+ const half = Math.floor((maxLines - 1) / 2);
76
+ out = [...out.slice(0, half), `… truncated ${out.length - maxLines + 1} lines …`, ...out.slice(out.length - half)];
77
+ }
78
+ const compressed = out.join('\n');
79
+ const originalTokens = estimateTokens(text);
80
+ const compressedTokens = estimateTokens(compressed);
81
+ return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: out.length, totalLines };
82
+ }
83
+
84
+ export default { compressLog, estimateTokens };
package/lib/index.js ADDED
@@ -0,0 +1,124 @@
1
+ import { Schema } from '@deepseek-ai/schemastery';
2
+ import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
3
+ import { skeletonize } from './ast/skeletonizer.js';
4
+ import * as tracker from './tokens/tracker.js';
5
+
6
+ export const name = '@goodandready/dsh-context-lens';
7
+ export const inject = ['tools', 'settings', 'webServer'];
8
+
9
+ export const Config = Schema.object({
10
+ compressionMode: Schema.string().default('balanced').description('Log compression aggressiveness (raw/balanced/aggressive)'),
11
+ astSkeletonMaxDepth: Schema.number().default(3).description('Max depth for AST skeleton generation'),
12
+ tokenSavingsTracking: Schema.boolean().default(true).description('Track and display token budget savings')
13
+ });
14
+
15
+ const NS = '@goodandready/dsh-context-lens';
16
+
17
+ // in-memory focus state
18
+ let focusState = { paths: [], updatedAt: null };
19
+
20
+ export function apply(ctx, config) {
21
+ let getConfig = () => config;
22
+
23
+ ctx.inject(['settings'], (sctx) => {
24
+ const scope = sctx.settings.register(NS, Config, { base: config });
25
+ getConfig = () => scope.get() ?? config;
26
+ });
27
+
28
+ // helper to record savings if enabled
29
+ function maybeTrack(original, compressed) {
30
+ try {
31
+ const cfg = getConfig();
32
+ if (cfg && cfg.tokenSavingsTracking === false) return null;
33
+ return tracker.record(original, compressed);
34
+ } catch { return tracker.record(original, compressed); }
35
+ }
36
+
37
+ if (ctx.tools) {
38
+ ctx.tools.register({
39
+ name: 'context_lens_focus',
40
+ description: 'Set focus files/folders; other context will be auto-collapsed via skeletonizer',
41
+ parameters: {
42
+ type: 'object',
43
+ properties: {
44
+ paths: { type: 'array', items: { type: 'string' }, description: 'Focused file/folder paths' },
45
+ maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' }
46
+ },
47
+ required: ['paths']
48
+ },
49
+ execute: async (params) => {
50
+ const paths = Array.isArray(params.paths) ? params.paths : [];
51
+ focusState = { paths, updatedAt: new Date().toISOString() };
52
+ return { success: true, focus: focusState, hint: 'Use skeletonize helper via compress or read files with focus set' };
53
+ }
54
+ });
55
+
56
+ ctx.tools.register({
57
+ name: 'context_lens_compress_log',
58
+ description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/nnpm)',
59
+ parameters: {
60
+ type: 'object',
61
+ properties: {
62
+ text: { type: 'string', description: 'Raw log text to compress' },
63
+ mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
64
+ maxLines: { type: 'number', description: 'Max output lines' }
65
+ },
66
+ required: ['text']
67
+ },
68
+ execute: async (params) => {
69
+ const cfg = getConfig();
70
+ const mode = params.mode || cfg.compressionMode || 'balanced';
71
+ const maxLines = params.maxLines || 400;
72
+ const res = compressLog(params.text, { mode, maxLines });
73
+ maybeTrack(params.text, res.compressed);
74
+ return { success: true, mode, ...res };
75
+ }
76
+ });
77
+
78
+ ctx.tools.register({
79
+ name: 'context_lens_compress_code',
80
+ description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go) to save context',
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ code: { type: 'string', description: 'Source code to skeletonize' },
85
+ language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go'], description: 'Language hint' },
86
+ maxDepth: { type: 'number', description: 'Max depth' }
87
+ },
88
+ required: ['code']
89
+ },
90
+ execute: async (params) => {
91
+ const cfg = getConfig();
92
+ const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
93
+ const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
94
+ maybeTrack(params.code, skeleton);
95
+ return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth };
96
+ }
97
+ });
98
+
99
+ ctx.tools.register({
100
+ name: 'context_lens_stats',
101
+ description: 'Show session token savings stats for context-lens',
102
+ parameters: { type: 'object', properties: {} },
103
+ execute: async () => {
104
+ const stats = tracker.getStats();
105
+ const cfg = getConfig();
106
+ return { success: true, ...stats, trackingEnabled: cfg.tokenSavingsTracking !== false, focus: focusState };
107
+ }
108
+ });
109
+ }
110
+
111
+ // optional status route
112
+ if (ctx.webServer) {
113
+ ctx.effect(() => ctx.webServer.register({
114
+ kind: 'exact',
115
+ path: '/dsh-context-lens/status',
116
+ handler: (req, res) => {
117
+ res.setHeader('content-type', 'application/json');
118
+ res.end(JSON.stringify({ ok: true, plugin: 'dsh-context-lens', stats: tracker.getStats(), focus: focusState }));
119
+ }
120
+ }), 'dsh-context-lens status route');
121
+ }
122
+ }
123
+
124
+ export { compressLog, skeletonize };
@@ -0,0 +1,31 @@
1
+ // ponytail: in-memory per-process tracker, no DB
2
+ let totalOriginal = 0;
3
+ let totalCompressed = 0;
4
+ let calls = 0;
5
+
6
+ export function estimateTokens(text) {
7
+ return Math.ceil((text || '').length / 4);
8
+ }
9
+
10
+ export function record(originalText, compressedText) {
11
+ const o = estimateTokens(originalText);
12
+ const c = estimateTokens(compressedText);
13
+ totalOriginal += o;
14
+ totalCompressed += c;
15
+ calls++;
16
+ return { originalTokens: o, compressedTokens: c, savedTokens: Math.max(0, o - c) };
17
+ }
18
+
19
+ export function getStats() {
20
+ const saved = Math.max(0, totalOriginal - totalCompressed);
21
+ const pct = totalOriginal ? Math.round((1 - totalCompressed / totalOriginal) * 100) : 0;
22
+ return { totalOriginal, totalCompressed, savedTokens: saved, savedPercent: pct, calls };
23
+ }
24
+
25
+ export function reset() {
26
+ totalOriginal = 0;
27
+ totalCompressed = 0;
28
+ calls = 0;
29
+ }
30
+
31
+ export default { estimateTokens, record, getStats, reset };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@goodandready/dsh-context-lens",
3
+ "version": "0.1.0",
4
+ "description": "DSH plugin for AST context compression, test log filtering, and token budget guard",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs"
21
+ },
22
+ "keywords": [
23
+ "dsh",
24
+ "dsh-plugin",
25
+ "context",
26
+ "token-guard",
27
+ "ast-compression",
28
+ "log-filter"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-context-lens.git"
33
+ },
34
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-context-lens#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/GooDAnDReaDY/dsh-context-lens/issues"
37
+ },
38
+ "license": "MIT",
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-runtime",
47
+ "@deepseek-ai/dsh-client-ui-slots"
48
+ ]
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.1",
53
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
57
+ "@deepseek-ai/schemastery": "^3.18.1"
58
+ }
59
+ }