@goodandready/dsh-context-lens 0.1.10 → 0.1.12

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/README.md CHANGED
@@ -147,3 +147,21 @@ dsh-context-lens:
147
147
  ## 📄 License
148
148
 
149
149
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
150
+
151
+
152
+ ## Changed in v0.1.11
153
+
154
+ Audit fixes (#33–#47, #18):
155
+
156
+ - Auto-compress no longer forces `balanced`; uses configured `compressionMode`.
157
+ - `budgetLimit` stops token counting after the limit (matches settings description).
158
+ - Focus state is per-session (`sessionId`), not process-global.
159
+ - Settings card exposes `budgetLimit`, `autoCollapse`, `autoCompressThreshold`; low-budget shows a warning instead of force-closing the card.
160
+ - Client `betterSidebar` inject is optional; preview uses server `/dsh-context-lens/compress-preview`.
161
+ - Python imports kept in skeletons; Java locals no longer mistaken for signatures.
162
+ - Shared `estimateTokens` helper; unused `dsh-credentials` peer removed.
163
+
164
+
165
+ ## Changed in v0.1.12
166
+
167
+ #43: extract shared `StatusPanel` used by LensTab and HeaderChip popover (budget bar, history, refresh).
package/README.ru.md CHANGED
@@ -144,3 +144,13 @@ dsh-context-lens:
144
144
  ## 📄 Лицензия
145
145
 
146
146
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
147
+
148
+
149
+ ## Changed in v0.1.11
150
+
151
+ Исправления audit #33–#47 / #18: режим auto-compress не форсирует balanced; budgetLimit останавливает подсчёт; focus per-session; поля settings card; optional betterSidebar; серверный preview; Python imports / Java locals; общий estimateTokens; удалён unused peer dsh-credentials.
152
+
153
+
154
+ ## Changed in v0.1.12
155
+
156
+ #43: extract shared `StatusPanel` used by LensTab and HeaderChip popover (budget bar, history, refresh).
@@ -3,7 +3,7 @@ const JS_FUNC_RE = /^\s*(export\s+)?(async\s+)?(function\s+(\w+)|const\s+(\w+)\s
3
3
  const PY_RE = /^\s*(def\s+(\w+)\s*\([^)]*\)|class\s+(\w+).*?:|async def\s+(\w+)\s*\([^)]*\))/;
4
4
  const GO_RE = /^\s*(func\s+(\([^)]+\)\s+)?(\w+)\s*\([^)]*\)|type\s+(\w+)\s+(struct|interface))/;
5
5
  const RUST_RE = /^\s*(pub(\([^)]+\))?\s+)?(async\s+)?(fn|struct|enum|impl|trait|type|const|static)\s+\w+/;
6
- const IMPORT_RE = /^\s*import\s+.*from\s+['"].*['"]|^\s*import\s+['"].*['"]|^\s*export\s+.*from\s+['"]|^\s*use\s+[a-zA-Z0-9_:]+/;
6
+ const IMPORT_RE = /^\s*import\s+.*from\s+['"].*['"]|^\s*import\s+['"].*['"]|^\s*export\s+.*from\s+['"]|^\s*use\s+[a-zA-Z0-9_:]+|^\s*(from\s+\S+\s+)?import\s+\S+|^\s*from\s+\S+\s+import\s+/;
7
7
  const COMMENT_RE = /^\s*(\/\/.*|\/\*.*\*\/|\/\*.*|\*.*|#.*)/;
8
8
 
9
9
  function indentDepth(line) {
@@ -77,7 +77,9 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
77
77
  if (RUST_RE.test(line)) sig = line.trim();
78
78
  }
79
79
  if (!sig && (!language || language === 'java')) {
80
- if (/^\s*(public|protected|private|static|\s)+\s+(class|interface|enum|record|\w+)\s+\w+/.test(line)) sig = line.trim();
80
+ if (/^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default)\s+)+(?:class|interface|enum|record)\s+\w+/.test(line)
81
+ || /^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default)\s+)+[\w.<>,\[\]?]+\s+\w+\s*\(/.test(line)
82
+ || /^\s*(class|interface|enum|record)\s+\w+/.test(line)) sig = line.trim();
81
83
  }
82
84
  // generic fallback: if no specific language, try all
83
85
  if (!sig && !language) {
@@ -0,0 +1,7 @@
1
+ /** Decide whether auto-compress should trigger for a log blob. */
2
+ export function shouldAutoCompress(text, threshold) {
3
+ if (!threshold || threshold <= 0) return false;
4
+ return (text || '').length > threshold || (text || '').split('\n').length > 100;
5
+ }
6
+
7
+ export default { shouldAutoCompress };
package/lib/client.js CHANGED
@@ -20,7 +20,16 @@ window.__ModuleLoader__.load({
20
20
  ready: 'Ready',
21
21
  budget: 'Budget',
22
22
  history: 'Recent ops',
23
- lowBudget: 'Budget nearly exhausted'
23
+ lowBudget: 'Budget nearly exhausted',
24
+ threshold: 'Auto-compress threshold (chars)',
25
+ autoCollapse: 'Warn when budget is nearly exhausted',
26
+ previewApprox: 'Server preview',
27
+ noData: 'No data yet',
28
+ refresh: 'Refresh',
29
+ ops: 'ops',
30
+ active: 'Active',
31
+ readyState: 'Ready',
32
+ lowBudgetBadge: 'Low Budget ⚠'
24
33
  };
25
34
  const ru = {
26
35
  title: 'Context Lens & Token Guard',
@@ -37,47 +46,135 @@ window.__ModuleLoader__.load({
37
46
  ready: 'Готово',
38
47
  budget: 'Бюджет',
39
48
  history: 'Последние операции',
40
- lowBudget: 'Бюджет почти исчерпан'
49
+ lowBudget: 'Бюджет почти исчерпан',
50
+ threshold: 'Порог авто-сжатия (символы)',
51
+ autoCollapse: 'Предупреждать при почти исчерпанном бюджете',
52
+ previewApprox: 'Серверный предпросмотр',
53
+ noData: 'Пока нет данных',
54
+ refresh: 'Обновить',
55
+ ops: 'оп.',
56
+ active: 'Активен',
57
+ readyState: 'Готов',
58
+ lowBudgetBadge: 'Мало бюджета ⚠'
41
59
  };
42
60
 
43
- // ponytail: client-side replica of compressor for preview (no import)
44
- function compressPreview(text, mode) {
45
- if (!text) return '';
46
- const KEEP = /(FAIL|FAILED|Error|AssertionError|Exception|Traceback|panic|npm ERR!|Expected|Received|at\s+.*:\d+:\d+)/i;
47
- const lines = text.split(/\r?\n/);
48
- const keep = new Array(lines.length).fill(false);
49
- const ctx = mode === 'aggressive' ? 1 : 2;
50
- for (let i = 0; i < lines.length; i++) if (KEEP.test(lines[i])) {
51
- for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) keep[j] = true;
61
+ // #45: preview uses POST /dsh-context-lens/compress-preview (server compressor)
62
+
63
+ // #43: shared status UI for LensTab + HeaderChip popover
64
+ function StatusPanel({ stats, history, labels, onRefresh, title, showBadge, compact }) {
65
+ const L = labels || en;
66
+ const warn = !!(stats && stats.lowBudget);
67
+ const hasSavings = !!(stats && stats.savedTokens > 0);
68
+ const barColor = warn ? '#d73a4a' : 'var(--dsw-alias-label-primary)';
69
+ const previewLen = compact ? 28 : 40;
70
+ const barH = compact ? 5 : 6;
71
+ const children = [];
72
+ if (title || showBadge) {
73
+ children.push(React.createElement('div', {
74
+ key: 'head',
75
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }
76
+ },
77
+ title
78
+ ? React.createElement('div', { style: { fontWeight: 600, fontSize: 13, display: 'flex', alignItems: 'center', gap: 6, color: 'var(--dsw-alias-label-primary)' } },
79
+ React.createElement('span', { style: { opacity: 0.8 } }, '◐'),
80
+ title
81
+ )
82
+ : React.createElement('span'),
83
+ showBadge
84
+ ? React.createElement('span', {
85
+ style: {
86
+ fontSize: 11, padding: '1px 6px', borderRadius: 4,
87
+ background: warn ? 'rgba(215, 58, 74, 0.15)' : 'var(--dsw-alias-bg-layer-2)',
88
+ color: warn ? '#d73a4a' : 'var(--dsw-alias-label-tertiary)',
89
+ border: '1px solid ' + (warn ? 'rgba(215, 58, 74, 0.3)' : 'var(--dsw-alias-border-l2)')
90
+ }
91
+ }, warn ? L.lowBudgetBadge : (stats ? L.active : L.readyState))
92
+ : null
93
+ ));
52
94
  }
53
- if (!keep.some(Boolean)) return lines.slice(0, 20).join('\n');
54
- return lines.filter((_, i) => keep[i]).slice(0, 200).join('\n');
95
+ children.push(
96
+ hasSavings
97
+ ? React.createElement('div', { key: 'sum', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: compact ? 8 : 0 } },
98
+ `${L.saved} ${stats.savedTokens} ${L.tokens} (${stats.savedPercent}%) · ${stats.calls} ${L.ops}`)
99
+ : React.createElement('div', { key: 'sum', style: { fontSize: 12, color: compact ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)', marginBottom: compact ? 8 : 0 } }, L.noData)
100
+ );
101
+ if (stats && stats.lowBudget && !showBadge) {
102
+ children.push(React.createElement('div', { key: 'warn', style: { marginTop: 6, fontSize: 11, color: '#d73a4a' } }, '⚠ ' + L.lowBudget));
103
+ }
104
+ if (stats && stats.budgetLimit) {
105
+ children.push(React.createElement('div', { key: 'budget', style: { marginTop: compact ? 6 : 8, marginBottom: compact ? 8 : 0 } },
106
+ React.createElement('div', {
107
+ style: {
108
+ display: compact ? 'flex' : 'block',
109
+ justifyContent: 'space-between',
110
+ fontSize: 11,
111
+ color: warn ? '#d73a4a' : 'var(--dsw-alias-label-secondary)',
112
+ marginBottom: 4
113
+ }
114
+ },
115
+ compact
116
+ ? React.createElement(React.Fragment, null,
117
+ React.createElement('span', null, L.budget),
118
+ React.createElement('span', null, `${stats.budgetUsed || 0} / ${stats.budgetLimit} (${stats.budgetPercent || 0}%)`)
119
+ )
120
+ : `${L.budget} ${stats.budgetUsed}/${stats.budgetLimit} ${L.tokens} (${stats.budgetPercent}%)${warn ? ' ⚠' : ''}`
121
+ ),
122
+ React.createElement('div', { style: { height: barH, borderRadius: 3, background: compact ? 'var(--dsw-alias-bg-layer-1)' : 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-3))', overflow: 'hidden' } },
123
+ React.createElement('div', { style: { height: '100%', width: Math.min(100, stats.budgetPercent || 0) + '%', background: barColor, transition: 'width .2s' } })
124
+ )
125
+ ));
126
+ }
127
+ if (history && history.length) {
128
+ children.push(React.createElement('div', {
129
+ key: 'hist',
130
+ style: { marginTop: compact ? 8 : 10, paddingTop: compact ? 8 : 0, borderTop: compact ? '1px solid var(--dsw-alias-border-l3)' : undefined }
131
+ },
132
+ React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', marginBottom: 4 } }, L.history),
133
+ ...history.slice(0, 3).map((h) => React.createElement('div', {
134
+ key: h.id,
135
+ style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', padding: '2px 0', overflow: compact ? 'hidden' : undefined, textOverflow: compact ? 'ellipsis' : undefined, whiteSpace: compact ? 'nowrap' : undefined }
136
+ }, `−${h.savedTokens} tk (${h.savedPercent}%) · ${(h.preview || '').slice(0, previewLen)}`))
137
+ ));
138
+ }
139
+ if (onRefresh) {
140
+ children.push(React.createElement('div', {
141
+ key: 'act',
142
+ style: { marginTop: compact ? 10 : 12, display: 'flex', gap: 8, justifyContent: compact ? 'flex-end' : undefined }
143
+ },
144
+ React.createElement('button', {
145
+ type: 'button',
146
+ onClick: onRefresh,
147
+ style: {
148
+ appearance: 'none', cursor: 'pointer', fontSize: compact ? 11 : 12,
149
+ padding: compact ? '3px 8px' : '4px 10px', borderRadius: 6,
150
+ border: '1px solid var(--dsw-alias-border-l2)',
151
+ background: compact ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-layer-3)',
152
+ color: compact ? 'var(--dsw-alias-label-secondary)' : 'var(--dsw-alias-label-primary)'
153
+ }
154
+ }, L.refresh)
155
+ ));
156
+ }
157
+ return React.createElement(React.Fragment, null, ...children);
158
+ }
159
+
160
+ function fetchLensStatus() {
161
+ return fetch('/dsh-context-lens/status', { headers: { accept: 'application/json' } })
162
+ .then((r) => r.ok ? r.json() : null);
55
163
  }
56
164
 
57
165
  function LensTab({ ctx: _ctx, scope }) {
58
166
  const [stats, setStats] = React.useState(null);
59
167
  const [history, setHistory] = React.useState([]);
60
- React.useEffect(() => {
61
- fetch('/dsh-context-lens/status').then(r => r.ok ? r.json() : null).then(j => { if (j && j.stats) { setStats(j.stats); setHistory(j.history || []); } }).catch(() => {});
62
- }, []);
63
- const refresh = () => fetch('/dsh-context-lens/status').then(r=>r.json()).then(j=>{ if (j && j.stats) { setStats(j.stats); setHistory(j.history || []); } }).catch(()=>{});
64
- const barColor = stats && stats.lowBudget ? '#d73a4a' : 'var(--dsw-alias-label-primary)';
168
+ const apply = (j) => {
169
+ if (j && j.stats) setStats(j.stats);
170
+ if (j && j.history) setHistory(j.history || []);
171
+ };
172
+ const refresh = () => fetchLensStatus().then(apply).catch(() => {});
173
+ React.useEffect(() => { refresh(); }, []);
65
174
  return React.createElement('div', { style: { padding: 12 } },
66
- React.createElement('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--dsw-alias-label-primary)' } }, 'Context Lens'),
67
- stats ? React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, `Saved ${stats.savedTokens} tokens (${stats.savedPercent}%) · ${stats.calls} ops`) : React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'No data yet'),
68
- stats && stats.budgetLimit ? React.createElement('div', { style: { marginTop: 8 } },
69
- React.createElement('div', { style: { fontSize: 11, color: stats.lowBudget ? '#d73a4a' : 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, `Budget ${stats.budgetUsed}/${stats.budgetLimit} tokens (${stats.budgetPercent}%)${stats.lowBudget ? ' ⚠' : ''}`),
70
- React.createElement('div', { style: { height: 6, borderRadius: 3, background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-3))', overflow: 'hidden' } },
71
- React.createElement('div', { style: { height: '100%', width: Math.min(100, stats.budgetPercent) + '%', background: barColor, transition: 'width .2s' } })
72
- )
73
- ) : null,
74
- history.length ? React.createElement('div', { style: { marginTop: 10 } },
75
- React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', marginBottom: 4 } }, 'Recent ops'),
76
- ...history.slice(0, 3).map((h) => React.createElement('div', { key: h.id, style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', padding: '2px 0' } }, `−${h.savedTokens} tk (${h.savedPercent}%) · ${(h.preview || '').slice(0, 40)}`))
77
- ) : null,
78
- React.createElement('div', { style: { marginTop: 12, display: 'flex', gap: 8 } },
79
- React.createElement('button', { onClick: refresh, style: { fontSize: 12, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', cursor: 'pointer' } }, 'Refresh')
80
- )
175
+ React.createElement(StatusPanel, {
176
+ stats, history, labels: en, onRefresh: refresh, title: 'Context Lens', showBadge: false, compact: false
177
+ })
81
178
  );
82
179
  }
83
180
 
@@ -87,15 +184,10 @@ window.__ModuleLoader__.load({
87
184
  const [open, setOpen] = React.useState(false);
88
185
  const ref = React.useRef(null);
89
186
 
90
- const fetchStatus = () => {
91
- fetch('/dsh-context-lens/status', { headers: { accept: 'application/json' } })
92
- .then(r => r.ok ? r.json() : null)
93
- .then(j => {
94
- if (j && j.stats) setStats(j.stats);
95
- if (j && j.history) setHistory(j.history);
96
- })
97
- .catch(() => {});
98
- };
187
+ const fetchStatus = () => fetchLensStatus().then((j) => {
188
+ if (j && j.stats) setStats(j.stats);
189
+ if (j && j.history) setHistory(j.history || []);
190
+ }).catch(() => {});
99
191
 
100
192
  React.useEffect(() => {
101
193
  let alive = true;
@@ -121,115 +213,28 @@ window.__ModuleLoader__.load({
121
213
 
122
214
  const popover = open ? React.createElement('div', {
123
215
  style: {
124
- position: 'absolute',
125
- top: 'calc(100% + 6px)',
126
- right: 0,
127
- width: 270,
128
- background: 'var(--dsw-alias-bg-layer-3)',
129
- border: '1px solid var(--dsw-alias-border-l2)',
130
- borderRadius: 10,
131
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)',
132
- padding: '12px 14px',
133
- zIndex: 1000,
134
- fontSize: 12,
135
- color: 'var(--dsw-alias-label-primary)',
136
- cursor: 'default',
137
- textAlign: 'left'
216
+ position: 'absolute', top: 'calc(100% + 6px)', right: 0, width: 270,
217
+ background: 'var(--dsw-alias-bg-layer-3)', border: '1px solid var(--dsw-alias-border-l2)',
218
+ borderRadius: 10, boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)', padding: '12px 14px',
219
+ zIndex: 1000, fontSize: 12, color: 'var(--dsw-alias-label-primary)', cursor: 'default', textAlign: 'left'
138
220
  },
139
221
  onClick: (e) => e.stopPropagation()
140
222
  },
141
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
142
- React.createElement('div', { style: { fontWeight: 600, fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 } },
143
- React.createElement('span', { style: { opacity: 0.8 } }, '◐'),
144
- 'Context Lens'
145
- ),
146
- React.createElement('span', {
147
- style: {
148
- fontSize: 11,
149
- padding: '1px 6px',
150
- borderRadius: 4,
151
- background: warn ? 'rgba(215, 58, 74, 0.15)' : 'var(--dsw-alias-bg-layer-2)',
152
- color: warn ? '#d73a4a' : 'var(--dsw-alias-label-tertiary)',
153
- border: '1px solid ' + (warn ? 'rgba(215, 58, 74, 0.3)' : 'var(--dsw-alias-border-l2)')
154
- }
155
- }, warn ? 'Low Budget ⚠' : (stats ? 'Active' : 'Ready'))
156
- ),
157
- hasSavings
158
- ? React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
159
- `Saved ${stats.savedTokens} tokens (${stats.savedPercent}%) · ${stats.calls} ops`
160
- )
161
- : React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginBottom: 8 } },
162
- 'No compression yet · Monitoring active'
163
- ),
164
- stats && stats.budgetLimit ? React.createElement('div', { style: { marginTop: 6, marginBottom: 8 } },
165
- React.createElement('div', {
166
- style: {
167
- display: 'flex',
168
- justifyContent: 'space-between',
169
- fontSize: 11,
170
- color: warn ? '#d73a4a' : 'var(--dsw-alias-label-tertiary)',
171
- marginBottom: 4
172
- }
173
- },
174
- React.createElement('span', null, 'Budget'),
175
- React.createElement('span', null, `${stats.budgetUsed || 0} / ${stats.budgetLimit} (${stats.budgetPercent || 0}%)`)
176
- ),
177
- React.createElement('div', { style: { height: 5, borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1)', overflow: 'hidden' } },
178
- React.createElement('div', {
179
- style: {
180
- height: '100%',
181
- width: Math.min(100, stats.budgetPercent || 0) + '%',
182
- background: warn ? '#d73a4a' : 'var(--dsw-alias-label-primary)',
183
- transition: 'width .2s'
184
- }
185
- })
186
- )
187
- ) : null,
188
- history && history.length ? React.createElement('div', { style: { marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--dsw-alias-border-l3)' } },
189
- React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', marginBottom: 4 } }, 'Recent operations'),
190
- ...history.slice(0, 3).map((h) => React.createElement('div', {
191
- key: h.id,
192
- style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', padding: '2px 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
193
- }, `−${h.savedTokens} tk (${h.savedPercent}%) · ${(h.preview || '').slice(0, 28)}`))
194
- ) : null,
195
- React.createElement('div', { style: { marginTop: 10, display: 'flex', justifyContent: 'flex-end' } },
196
- React.createElement('button', {
197
- type: 'button',
198
- onClick: fetchStatus,
199
- style: {
200
- appearance: 'none',
201
- cursor: 'pointer',
202
- fontSize: 11,
203
- padding: '3px 8px',
204
- borderRadius: 6,
205
- border: '1px solid var(--dsw-alias-border-l2)',
206
- background: 'var(--dsw-alias-bg-layer-2)',
207
- color: 'var(--dsw-alias-label-secondary)'
208
- }
209
- }, 'Refresh')
210
- )
223
+ React.createElement(StatusPanel, {
224
+ stats, history, labels: en, onRefresh: fetchStatus, title: 'Context Lens', showBadge: true, compact: true
225
+ })
211
226
  ) : null;
212
227
 
213
228
  return React.createElement('div', { ref, style: { position: 'relative', display: 'inline-flex', alignItems: 'center' } },
214
229
  React.createElement('button', {
215
230
  type: 'button',
216
- title: hasSavings ? `Context Lens: Saved ${stats.savedTokens} tk (${stats.savedPercent}%)` : 'Context Lens: Active',
231
+ title: hasSavings ? `Context Lens: ${en.saved} ${stats.savedTokens} tk (${stats.savedPercent}%)` : 'Context Lens: Active',
217
232
  onClick: () => setOpen((v) => !v),
218
233
  style: {
219
- appearance: 'none',
220
- font: 'inherit',
221
- cursor: 'pointer',
222
- display: 'inline-flex',
223
- alignItems: 'center',
224
- gap: 4,
225
- padding: '2px 8px',
226
- borderRadius: 999,
227
- fontSize: 11,
228
- lineHeight: '16px',
234
+ appearance: 'none', font: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4,
235
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, lineHeight: '16px',
229
236
  background: open ? 'var(--dsw-alias-bg-layer-3)' : 'var(--dsw-alias-bg-layer-2)',
230
- color,
231
- border: '1px solid ' + borderColor,
232
- marginLeft: 6
237
+ color, border: '1px solid ' + borderColor, marginLeft: 6
233
238
  }
234
239
  },
235
240
  React.createElement('span', { style: { fontSize: 10, opacity: 0.8 } }, '◐'),
@@ -243,13 +248,14 @@ window.__ModuleLoader__.load({
243
248
  function PluginCard({ ctx: _ctx, t }) {
244
249
  const [expanded, setExpanded] = React.useState(false);
245
250
  // hooks must be before any return — React 310
246
- const [draft, setDraft] = React.useState({ compressionMode: 'balanced', astSkeletonMaxDepth: 3, tokenSavingsTracking: true, budgetLimit: 100000, autoCollapse: true });
251
+ const [draft, setDraft] = React.useState({ compressionMode: 'balanced', astSkeletonMaxDepth: 3, tokenSavingsTracking: true, budgetLimit: 100000, autoCollapse: true, autoCompressThreshold: 4000 });
247
252
  const [status, setStatus] = React.useState('loading');
248
253
  const [saving, setSaving] = React.useState(false);
249
254
  const [saveErr, setSaveErr] = React.useState('');
250
255
  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');
251
256
  const [previewOut, setPreviewOut] = React.useState('');
252
257
  const [stats, setStats] = React.useState(null);
258
+ const [budgetWarn, setBudgetWarn] = React.useState(false);
253
259
 
254
260
  const scopeRef = React.useRef(null);
255
261
  if (!scopeRef.current && _ctx && _ctx.settingsScope) {
@@ -283,16 +289,15 @@ window.__ModuleLoader__.load({
283
289
  }, [scope]);
284
290
 
285
291
  React.useEffect(() => {
286
- if (expanded) {
287
- fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
288
- if (j && j.stats) {
289
- setStats(j.stats);
290
- // Budget guard (#16): auto-collapse when nearly exhausted
291
- if (j.stats.lowBudget && draft.autoCollapse !== false) setExpanded(false);
292
- }
293
- }).catch(() => {});
294
- }
295
- }, [expanded]);
292
+ if (!expanded) return;
293
+ fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
294
+ if (j && j.stats) {
295
+ setStats(j.stats);
296
+ // #41: warn instead of force-closing the card
297
+ setBudgetWarn(!!(j.stats.lowBudget && draft.autoCollapse !== false));
298
+ }
299
+ }).catch(() => {});
300
+ }, [expanded, draft.autoCollapse]);
296
301
 
297
302
  const tt = t || ((k) => (en[k] || k));
298
303
 
@@ -320,9 +325,18 @@ window.__ModuleLoader__.load({
320
325
  if (errs.length) setSaveErr(errs.join('; '));
321
326
  }
322
327
 
323
- function onPreview() {
324
- const mode = draft.compressionMode || 'balanced';
325
- setPreviewOut(compressPreview(previewIn, mode));
328
+ async function onPreview() {
329
+ try {
330
+ const res = await fetch('/dsh-context-lens/compress-preview', {
331
+ method: 'POST',
332
+ headers: { 'content-type': 'application/json' },
333
+ body: JSON.stringify({ text: previewIn, mode: draft.compressionMode || 'balanced' })
334
+ });
335
+ const j = await res.json();
336
+ setPreviewOut((j && j.compressed) || (j && j.error) || '');
337
+ } catch (e) {
338
+ setPreviewOut(String(e && e.message || e));
339
+ }
326
340
  }
327
341
 
328
342
  // styles: ponytail minimal, theme vars only
@@ -340,6 +354,7 @@ window.__ModuleLoader__.load({
340
354
  React.createElement(Chevron, { open: expanded })
341
355
  ),
342
356
  expanded ? React.createElement('div', { className: 'cl-body', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', margin: '0 16px', paddingBottom: 8 } },
357
+ budgetWarn ? React.createElement('div', { style: { marginTop: 10, padding: '8px 10px', borderRadius: 8, background: 'rgba(215,58,74,0.12)', color: '#d73a4a', fontSize: 12 } }, '⚠ ' + tt('lowBudget')) : null,
343
358
  status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
344
359
  status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
345
360
  React.createElement(React.Fragment, null,
@@ -375,8 +390,35 @@ window.__ModuleLoader__.load({
375
390
  }),
376
391
  React.createElement('label', { htmlFor: 'cl-tracking', style: { fontSize: 13 } }, tt('tracking'))
377
392
  ),
393
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, padding: '12px 0' } },
394
+ React.createElement('input', {
395
+ type: 'checkbox',
396
+ checked: !!draft.autoCollapse,
397
+ onChange: (e) => setDraft((d) => ({ ...d, autoCollapse: e.target.checked })),
398
+ id: 'cl-autocollapse'
399
+ }),
400
+ React.createElement('label', { htmlFor: 'cl-autocollapse', style: { fontSize: 13 } }, tt('autoCollapse'))
401
+ ),
402
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
403
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('budget') + ' (tokens)'),
404
+ React.createElement('input', {
405
+ className: 'cl-input', type: 'number', min: 1000, step: 1000,
406
+ value: draft.budgetLimit,
407
+ onChange: (e) => setDraft((d) => ({ ...d, budgetLimit: parseInt(e.target.value, 10) || 100000 })),
408
+ 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 }
409
+ })
410
+ ),
411
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
412
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('threshold')),
413
+ React.createElement('input', {
414
+ className: 'cl-input', type: 'number', min: 0, step: 500,
415
+ value: draft.autoCompressThreshold,
416
+ onChange: (e) => setDraft((d) => ({ ...d, autoCompressThreshold: parseInt(e.target.value, 10) || 0 })),
417
+ 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 }
418
+ })
419
+ ),
378
420
  React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
379
- React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('preview')),
421
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('preview') + ' (' + tt('previewApprox') + ')'),
380
422
  React.createElement('textarea', {
381
423
  value: previewIn,
382
424
  onChange: (e) => setPreviewIn(e.target.value),
@@ -404,7 +446,7 @@ window.__ModuleLoader__.load({
404
446
  );
405
447
  }
406
448
 
407
- module.exports.inject = ['slots', 'locale', 'betterSidebar'];
449
+ module.exports.inject = ['slots', 'locale'];
408
450
  module.exports.apply = function apply(ctx) {
409
451
  try { ctx.locale.register(NS, { en, ru }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
410
452
  if (!ctx.slots) return;
@@ -1,3 +1,4 @@
1
+ import { estimateTokens } from '../tokens/estimate.js';
1
2
  // ponytail: heuristic line filter, not ML — O(n) scan, no deps
2
3
  const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g;
3
4
  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;
@@ -15,10 +16,6 @@ function keepLine(line) {
15
16
  return false;
16
17
  }
17
18
 
18
- export function estimateTokens(text) {
19
- return Math.ceil((text || '').length / 4);
20
- }
21
-
22
19
  export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
23
20
  if (!text || typeof text !== 'string') return { compressed: '', originalTokens: 0, compressedTokens: 0, savedTokens: 0, savedPercent: 0, keptLines: 0, totalLines: 0 };
24
21
  const lines = text.split(/\r?\n/);
@@ -91,4 +88,5 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
91
88
  return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: out.length, totalLines };
92
89
  }
93
90
 
91
+ export { estimateTokens };
94
92
  export default { compressLog, estimateTokens };
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import z from '@deepseek-ai/schemastery';
2
2
  import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
3
3
  import { skeletonize } from './ast/skeletonizer.js';
4
4
  import * as tracker from './tokens/tracker.js';
5
+ import { shouldAutoCompress } from './auto-compress.js';
5
6
 
6
7
  export const name = '@goodandready/dsh-context-lens';
7
8
  export const inject = ['tools', 'settings', 'webServer'];
@@ -11,23 +12,31 @@ export const Config = z.object({
11
12
  astSkeletonMaxDepth: z.number().default(3).description('Max depth for AST skeleton generation'),
12
13
  tokenSavingsTracking: z.boolean().default(true).description('Track and display token budget savings'),
13
14
  autoCompressThreshold: z.number().default(4000).description('Auto-compress threshold in chars (0 to disable)'),
14
- budgetLimit: z.number().default(100000).description('Session token budget (compressions stop counting after this)'),
15
- autoCollapse: z.boolean().default(true).description('Auto-collapse UI when budget is nearly exhausted')
15
+ budgetLimit: z.number().default(100000).description('Session token budget; compressions stop counting after this limit'),
16
+ autoCollapse: z.boolean().default(true).description('Warn in UI when budget is nearly exhausted (does not force-close the settings card)')
16
17
  });
17
18
 
18
19
  const NS = '@goodandready/dsh-context-lens';
19
20
 
20
- // in-memory focus state
21
- let focusState = { paths: [], updatedAt: null };
21
+ // #37/#18: focus keyed by session id (not process-global singleton)
22
+ const focusBySession = new Map();
22
23
 
23
- const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
24
- const renderOutput = (_args, result) => JSON.stringify(result, null, 2);
24
+ function sessionKey(params, meta) {
25
+ return (
26
+ (params && (params.sessionId || params.session_id)) ||
27
+ (meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
28
+ '__default__'
29
+ );
30
+ }
25
31
 
26
- function shouldAutoCompress(text, threshold) {
27
- if (!threshold || threshold <= 0) return false;
28
- return (text || '').length > threshold || (text || '').split('\n').length > 100;
32
+ function getFocus(key) {
33
+ if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
34
+ return focusBySession.get(key);
29
35
  }
30
36
 
37
+ const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
38
+ const renderOutput = (_args, result) => JSON.stringify(result, null, 2);
39
+
31
40
  export function apply(ctx, config) {
32
41
  let getConfig = () => config;
33
42
 
@@ -36,32 +45,38 @@ export function apply(ctx, config) {
36
45
  getConfig = () => scope.get() ?? config;
37
46
  });
38
47
 
39
- // helper to record savings if enabled
40
48
  function maybeTrack(original, compressed) {
41
49
  try {
42
50
  const cfg = getConfig();
43
51
  if (cfg && cfg.tokenSavingsTracking === false) return null;
52
+ if (cfg && typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
44
53
  return tracker.record(original, compressed);
45
- } catch { return tracker.record(original, compressed); }
54
+ } catch {
55
+ // #39: never record on config/read errors
56
+ return null;
57
+ }
46
58
  }
47
59
 
48
60
  if (ctx.tools) {
49
61
  ctx.tools.register({
50
62
  name: 'context_lens_focus',
51
- description: 'Set focus files/folders; other context will be auto-collapsed via skeletonizer',
63
+ description: 'Set focus files/folders for a session; other context will be auto-collapsed via skeletonizer',
52
64
  parameters: {
53
65
  type: 'object',
54
66
  properties: {
55
67
  paths: { type: 'array', items: { type: 'string' }, description: 'Focused file/folder paths' },
56
- maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' }
68
+ maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' },
69
+ sessionId: { type: 'string', description: 'Session id (defaults to host session when available)' }
57
70
  },
58
71
  required: ['paths']
59
72
  },
60
73
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
61
- execute: async (params) => {
74
+ execute: async (params, meta) => {
75
+ const key = sessionKey(params, meta);
62
76
  const paths = Array.isArray(params.paths) ? params.paths : [];
63
- focusState = { paths, updatedAt: new Date().toISOString() };
64
- return { success: true, focus: focusState, hint: 'Use skeletonize helper via compress or read files with focus set' };
77
+ const focus = { paths, updatedAt: new Date().toISOString() };
78
+ focusBySession.set(key, focus);
79
+ return { success: true, sessionId: key, focus, hint: 'Use skeletonize helper via compress or read files with focus set' };
65
80
  }
66
81
  });
67
82
 
@@ -75,7 +90,7 @@ export function apply(ctx, config) {
75
90
  log: { type: 'string', description: 'Alias for text' },
76
91
  mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
77
92
  maxLines: { type: 'number', description: 'Max output lines' },
78
- auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold)' }
93
+ auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
79
94
  }
80
95
  },
81
96
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
@@ -84,10 +99,10 @@ export function apply(ctx, config) {
84
99
  const inputText = params.text || params.log || '';
85
100
  const mode = params.mode || cfg.compressionMode || 'balanced';
86
101
  const maxLines = params.maxLines || 400;
87
- // Auto-compress check (#15)
88
102
  const threshold = cfg.autoCompressThreshold ?? 4000;
89
103
  const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
90
- const effectiveMode = (useAuto && !params.mode) ? 'balanced' : mode;
104
+ // #33: never force balanced keep configured/explicit mode
105
+ const effectiveMode = mode;
91
106
  const res = compressLog(inputText, { mode: effectiveMode, maxLines });
92
107
  maybeTrack(inputText, res.compressed);
93
108
  return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
@@ -103,51 +118,100 @@ export function apply(ctx, config) {
103
118
  code: { type: 'string', description: 'Source code to skeletonize' },
104
119
  language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java'], description: 'Language hint' },
105
120
  maxDepth: { type: 'number', description: 'Max depth' },
106
- filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' }
121
+ filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
122
+ sessionId: { type: 'string', description: 'Session id for focus lookup' }
107
123
  },
108
124
  required: ['code']
109
125
  },
110
126
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
111
- execute: async (params) => {
127
+ execute: async (params, meta) => {
112
128
  const cfg = getConfig();
113
129
  const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
114
- // Focus mode (#18): if file is in focus, return full code, else skeleton
115
- const isFocused = params.filePath && focusState.paths.length > 0 ? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath)) : false;
130
+ const key = sessionKey(params, meta);
131
+ const focusState = getFocus(key);
132
+ const isFocused = params.filePath && focusState.paths.length > 0
133
+ ? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
134
+ : false;
116
135
  if (isFocused) {
117
136
  maybeTrack(params.code, params.code);
118
- return { success: true, skeleton: params.code, originalTokens: estLog(params.code), skeletonTokens: estLog(params.code), maxDepth, focused: true, hint: 'File is in focus, returned full code' };
137
+ 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' };
119
138
  }
120
139
  const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
121
140
  maybeTrack(params.code, skeleton);
122
- return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false };
141
+ return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
123
142
  }
124
143
  });
125
144
 
126
145
  ctx.tools.register({
127
146
  name: 'context_lens_stats',
128
147
  description: 'Show session token savings stats for context-lens',
129
- parameters: { type: 'object', properties: {} },
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ sessionId: { type: 'string', description: 'Session id for focus snapshot' }
152
+ }
153
+ },
130
154
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
131
- execute: async () => {
155
+ execute: async (params, meta) => {
132
156
  const cfg = getConfig();
157
+ if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
133
158
  const stats = tracker.getStats(cfg.budgetLimit);
134
159
  const history = tracker.getHistory();
135
- return { success: true, ...stats, history, trackingEnabled: cfg.tokenSavingsTracking !== false, autoCollapse: cfg.autoCollapse !== false, focus: focusState, autoCompressThreshold: cfg.autoCompressThreshold };
160
+ const key = sessionKey(params || {}, meta);
161
+ return {
162
+ success: true,
163
+ ...stats,
164
+ history,
165
+ trackingEnabled: cfg.tokenSavingsTracking !== false,
166
+ autoCollapse: cfg.autoCollapse !== false,
167
+ focus: getFocus(key),
168
+ sessionId: key,
169
+ autoCompressThreshold: cfg.autoCompressThreshold
170
+ };
136
171
  }
137
172
  });
138
173
  }
139
174
 
140
- // optional status route
141
175
  if (ctx.webServer) {
142
176
  ctx.effect(() => ctx.webServer.register({
143
177
  kind: 'exact',
144
178
  path: '/dsh-context-lens/status',
145
179
  handler: (req, res) => {
146
180
  const cfg = getConfig();
181
+ if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
147
182
  res.setHeader('content-type', 'application/json');
148
- res.end(JSON.stringify({ ok: true, plugin: 'dsh-context-lens', stats: tracker.getStats(cfg.budgetLimit), history: tracker.getHistory(), focus: focusState }));
183
+ res.end(JSON.stringify({
184
+ ok: true,
185
+ plugin: 'dsh-context-lens',
186
+ stats: tracker.getStats(cfg.budgetLimit),
187
+ history: tracker.getHistory(),
188
+ focus: getFocus('__default__')
189
+ }));
149
190
  }
150
191
  }), 'dsh-context-lens status route');
192
+
193
+ // #45: server-backed preview (same compressor as tools)
194
+ ctx.effect(() => ctx.webServer.register({
195
+ kind: 'exact',
196
+ path: '/dsh-context-lens/compress-preview',
197
+ handler: async (req, res) => {
198
+ try {
199
+ const chunks = [];
200
+ for await (const c of req) chunks.push(c);
201
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
202
+ const cfg = getConfig();
203
+ const mode = body.mode || cfg.compressionMode || 'balanced';
204
+ const text = body.text || '';
205
+ const out = compressLog(text, { mode, maxLines: body.maxLines || 400 });
206
+ res.setHeader('content-type', 'application/json');
207
+ res.end(JSON.stringify({ ok: true, mode, ...out }));
208
+ } catch (e) {
209
+ res.statusCode = 400;
210
+ res.setHeader('content-type', 'application/json');
211
+ res.end(JSON.stringify({ ok: false, error: e && e.message || String(e) }));
212
+ }
213
+ }
214
+ }), 'dsh-context-lens compress-preview route');
151
215
  }
152
216
  }
153
217
 
@@ -0,0 +1,6 @@
1
+ // shared token estimate (~chars/4)
2
+ export function estimateTokens(text) {
3
+ return Math.ceil((text || '').length / 4);
4
+ }
5
+
6
+ export default { estimateTokens };
@@ -1,14 +1,14 @@
1
1
  // ponytail: in-memory per-process tracker + history, no DB
2
+ import { estimateTokens } from './estimate.js';
3
+
2
4
  let totalOriginal = 0;
3
5
  let totalCompressed = 0;
4
6
  let calls = 0;
5
7
  const history = []; // last 10
6
8
  const MAX_HISTORY = 10;
7
- let defaultBudgetLimit = 100000; // tokens, ~100k default budget
9
+ let defaultBudgetLimit = 100000; // tokens
8
10
 
9
- export function estimateTokens(text) {
10
- return Math.ceil((text || '').length / 4);
11
- }
11
+ export { estimateTokens };
12
12
 
13
13
  export function setBudgetLimit(limit) {
14
14
  if (typeof limit === 'number' && limit > 0) {
@@ -20,6 +20,10 @@ export function record(originalText, compressedText) {
20
20
  const o = estimateTokens(originalText);
21
21
  const c = estimateTokens(compressedText);
22
22
  const saved = Math.max(0, o - c);
23
+ // #34: stop counting after budgetLimit (description promised this)
24
+ if (totalCompressed >= defaultBudgetLimit) {
25
+ return { originalTokens: o, compressedTokens: c, savedTokens: 0, stopped: true };
26
+ }
23
27
  totalOriginal += o;
24
28
  totalCompressed += c;
25
29
  calls++;
@@ -57,7 +61,8 @@ export function getStats(budgetLimitOverride) {
57
61
  budgetRemaining,
58
62
  budgetPercent,
59
63
  lowBudget,
60
- budgetLimit: limit
64
+ budgetLimit: limit,
65
+ countingStopped: budgetUsed >= limit
61
66
  };
62
67
  }
63
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-context-lens",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "DSH plugin for AST context compression, test log filtering, and token budget guard",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -50,7 +50,6 @@
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@deepseek-ai/cordis": "^4.0.1",
53
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
54
53
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
55
54
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
56
55
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",