@goodandready/dsh-context-lens 0.1.9 → 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
@@ -127,6 +127,14 @@ dsh-context-lens:
127
127
 
128
128
  ## 📝 Version History
129
129
 
130
+ ### v0.1.10
131
+ * **Fix**: Register session header chip in `conversation.session.header.utilities` (`order: 7`).
132
+ * **Fix**: Ensure chip is always visible (`◐ Lens` on initial session, `◐ <N>%` when savings available, `⚠` on low budget).
133
+ * **Feature**: Interactive dropdown Popover on chip click: token savings breakdown, budget progress bar, recent operations, and refresh button.
134
+
135
+ ### v0.1.9
136
+ * **Fix**: Remove obsolete kernel modules from client injects for DSH 0.1.2-rc.1 compatibility.
137
+
130
138
  ### v0.1.8
131
139
  * **Fix**: Support both `text` and `log` parameter names in `context_lens_compress_log`.
132
140
  * **Fix**: Cross-platform path resolution in unit tests on Windows (`fileURLToPath`).
@@ -139,3 +147,21 @@ dsh-context-lens:
139
147
  ## 📄 License
140
148
 
141
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
@@ -124,6 +124,14 @@ dsh-context-lens:
124
124
 
125
125
  ## 📝 История версий
126
126
 
127
+ ### v0.1.10
128
+ * **Fix**: Регистрация индикатора сессии в актуальном слоте ядра `conversation.session.header.utilities` (`order: 7`).
129
+ * **Fix**: Чип теперь отображается всегда (`◐ Lens` при отсутствии сжатий, `◐ <N>%` при наличии сэкономленных токенов, `⚠` при низком остатке бюджета).
130
+ * **Feature**: Интерактивный выпадающий Popover по клику на чип: детальные метрики токенов, шкала прогресса бюджета, последние 3 операции и кнопка обновления.
131
+
132
+ ### v0.1.9
133
+ * **Fix**: Удаление устаревших модулей ядра из инъекций клиента для совместимости с DSH 0.1.2-rc.1.
134
+
127
135
  ### v0.1.8
128
136
  * **Fix**: Поддержка как `text`, так и `log` в параметрах инструмента `context_lens_compress_log`.
129
137
  * **Fix**: Кроссплатформенное разрешение путей в тестах на Windows (`fileURLToPath`).
@@ -136,3 +144,13 @@ dsh-context-lens:
136
144
  ## 📄 Лицензия
137
145
 
138
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,73 +46,216 @@ 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
+ ));
94
+ }
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));
52
103
  }
53
- if (!keep.some(Boolean)) return lines.slice(0, 20).join('\n');
54
- return lines.filter((_, i) => keep[i]).slice(0, 200).join('\n');
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
 
84
- function HeaderBadge({ ctx: _ctx }) {
181
+ function HeaderChip({ ctx: _ctx }) {
85
182
  const [stats, setStats] = React.useState(null);
183
+ const [history, setHistory] = React.useState([]);
184
+ const [open, setOpen] = React.useState(false);
185
+ const ref = React.useRef(null);
186
+
187
+ const fetchStatus = () => fetchLensStatus().then((j) => {
188
+ if (j && j.stats) setStats(j.stats);
189
+ if (j && j.history) setHistory(j.history || []);
190
+ }).catch(() => {});
191
+
86
192
  React.useEffect(() => {
87
- const tick = () => fetch('/dsh-context-lens/status').then(r => r.ok ? r.json() : null).then(j => { if (j && j.stats) setStats(j.stats); }).catch(() => {});
88
- tick();
89
- const id = setInterval(tick, 5000);
90
- return () => clearInterval(id);
193
+ let alive = true;
194
+ fetchStatus();
195
+ const id = setInterval(() => { if (alive) fetchStatus(); }, 5000);
196
+ return () => { alive = false; clearInterval(id); };
91
197
  }, []);
92
- if (!stats || !stats.savedTokens) return null;
93
- const warn = !!stats.lowBudget;
94
- return React.createElement('span', { style: { fontSize: 11, padding: '2px 6px', borderRadius: 999, background: 'var(--dsw-alias-bg-layer-2)', color: warn ? '#d73a4a' : 'var(--dsw-alias-label-secondary)', border: '1px solid ' + (warn ? '#d73a4a' : 'var(--dsw-alias-border-l2)'), marginLeft: 8 } }, `Lens ${stats.savedPercent}%${warn ? ' ⚠' : ''}`);
198
+
199
+ React.useEffect(() => {
200
+ if (!open) return;
201
+ const onDocClick = (e) => {
202
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
203
+ };
204
+ document.addEventListener('click', onDocClick);
205
+ return () => document.removeEventListener('click', onDocClick);
206
+ }, [open]);
207
+
208
+ const warn = !!(stats && stats.lowBudget);
209
+ const hasSavings = !!(stats && stats.savedTokens > 0);
210
+ const label = hasSavings ? `Lens ${stats.savedPercent}%` : 'Lens';
211
+ const color = warn ? '#d73a4a' : 'var(--dsw-alias-label-secondary)';
212
+ const borderColor = warn ? '#d73a4a' : open ? 'var(--dsw-alias-border-l1)' : 'var(--dsw-alias-border-l2)';
213
+
214
+ const popover = open ? React.createElement('div', {
215
+ style: {
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'
220
+ },
221
+ onClick: (e) => e.stopPropagation()
222
+ },
223
+ React.createElement(StatusPanel, {
224
+ stats, history, labels: en, onRefresh: fetchStatus, title: 'Context Lens', showBadge: true, compact: true
225
+ })
226
+ ) : null;
227
+
228
+ return React.createElement('div', { ref, style: { position: 'relative', display: 'inline-flex', alignItems: 'center' } },
229
+ React.createElement('button', {
230
+ type: 'button',
231
+ title: hasSavings ? `Context Lens: ${en.saved} ${stats.savedTokens} tk (${stats.savedPercent}%)` : 'Context Lens: Active',
232
+ onClick: () => setOpen((v) => !v),
233
+ style: {
234
+ appearance: 'none', font: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4,
235
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, lineHeight: '16px',
236
+ background: open ? 'var(--dsw-alias-bg-layer-3)' : 'var(--dsw-alias-bg-layer-2)',
237
+ color, border: '1px solid ' + borderColor, marginLeft: 6
238
+ }
239
+ },
240
+ React.createElement('span', { style: { fontSize: 10, opacity: 0.8 } }, '◐'),
241
+ label,
242
+ warn ? React.createElement('span', { style: { color: '#d73a4a' } }, ' ⚠') : null
243
+ ),
244
+ popover
245
+ );
95
246
  }
96
247
 
97
248
  function PluginCard({ ctx: _ctx, t }) {
98
249
  const [expanded, setExpanded] = React.useState(false);
99
250
  // hooks must be before any return — React 310
100
- 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 });
101
252
  const [status, setStatus] = React.useState('loading');
102
253
  const [saving, setSaving] = React.useState(false);
103
254
  const [saveErr, setSaveErr] = React.useState('');
104
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');
105
256
  const [previewOut, setPreviewOut] = React.useState('');
106
257
  const [stats, setStats] = React.useState(null);
258
+ const [budgetWarn, setBudgetWarn] = React.useState(false);
107
259
 
108
260
  const scopeRef = React.useRef(null);
109
261
  if (!scopeRef.current && _ctx && _ctx.settingsScope) {
@@ -137,16 +289,15 @@ window.__ModuleLoader__.load({
137
289
  }, [scope]);
138
290
 
139
291
  React.useEffect(() => {
140
- if (expanded) {
141
- fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
142
- if (j && j.stats) {
143
- setStats(j.stats);
144
- // Budget guard (#16): auto-collapse when nearly exhausted
145
- if (j.stats.lowBudget && draft.autoCollapse !== false) setExpanded(false);
146
- }
147
- }).catch(() => {});
148
- }
149
- }, [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]);
150
301
 
151
302
  const tt = t || ((k) => (en[k] || k));
152
303
 
@@ -174,9 +325,18 @@ window.__ModuleLoader__.load({
174
325
  if (errs.length) setSaveErr(errs.join('; '));
175
326
  }
176
327
 
177
- function onPreview() {
178
- const mode = draft.compressionMode || 'balanced';
179
- 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
+ }
180
340
  }
181
341
 
182
342
  // styles: ponytail minimal, theme vars only
@@ -194,6 +354,7 @@ window.__ModuleLoader__.load({
194
354
  React.createElement(Chevron, { open: expanded })
195
355
  ),
196
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,
197
358
  status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
198
359
  status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
199
360
  React.createElement(React.Fragment, null,
@@ -229,8 +390,35 @@ window.__ModuleLoader__.load({
229
390
  }),
230
391
  React.createElement('label', { htmlFor: 'cl-tracking', style: { fontSize: 13 } }, tt('tracking'))
231
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
+ ),
232
420
  React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
233
- 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') + ')'),
234
422
  React.createElement('textarea', {
235
423
  value: previewIn,
236
424
  onChange: (e) => setPreviewIn(e.target.value),
@@ -258,7 +446,7 @@ window.__ModuleLoader__.load({
258
446
  );
259
447
  }
260
448
 
261
- module.exports.inject = ['slots', 'locale', 'betterSidebar'];
449
+ module.exports.inject = ['slots', 'locale'];
262
450
  module.exports.apply = function apply(ctx) {
263
451
  try { ctx.locale.register(NS, { en, ru }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
264
452
  if (!ctx.slots) return;
@@ -308,24 +496,29 @@ window.__ModuleLoader__.load({
308
496
  console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
309
497
  }
310
498
  }
311
- // Header badge (#20) — compact savings in conversation header
499
+ // Header chip (#31) — telemetry and token guard in conversation session utilities slot
312
500
  if (ctx.slots) {
313
- const headerBadgeRegister = () => {
501
+ const headerChipRegister = () => {
314
502
  try {
315
503
  return ctx.slots.register({
316
- name: 'conversation.header',
317
- id: 'dsh-context-lens:header-badge',
318
- order: 50,
504
+ name: 'conversation.session.header.utilities',
505
+ id: 'dsh-context-lens-header-chip',
506
+ order: 7,
319
507
  inject: () => ({ ctx })
320
- }, HeaderBadge);
508
+ }, HeaderChip);
321
509
  } catch (e) {
322
- console.warn('[dsh-context-lens] header badge register failed', e && e.message || e);
510
+ console.warn('[dsh-context-lens] header chip register failed', e && e.message || e);
323
511
  }
324
512
  };
325
513
  if (typeof ctx.slots.inject === 'function') {
326
- try { ctx.slots.inject('conversation.header', headerBadgeRegister); } catch (e) { console.warn('[dsh-context-lens] header inject failed', e && e.message || e); try { headerBadgeRegister(); } catch (e2) {} }
514
+ try {
515
+ ctx.slots.inject('conversation.session.header.utilities', headerChipRegister);
516
+ } catch (e) {
517
+ console.warn('[dsh-context-lens] utilities inject failed', e && e.message || e);
518
+ try { headerChipRegister(); } catch (e2) {}
519
+ }
327
520
  } else {
328
- try { headerBadgeRegister(); } catch (e) {}
521
+ try { headerChipRegister(); } catch (e) {}
329
522
  }
330
523
  }
331
524
  };
@@ -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.9",
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",