@goodandready/dsh-context-lens 0.1.10 → 0.1.13

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,28 @@ 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).
168
+
169
+
170
+ ## Changed in v0.1.13
171
+
172
+ - **Fix (#50)**: Guard optional `betterSidebar` tab registration via `ctx.inject(['betterSidebar'], ...)` instead of direct property access on Cordis context proxy, resolving `cannot get property "betterSidebar" without inject` on client boot.
173
+ - **Fix (#50)**: Safely guard `_ctx.settingsScope` access in `PluginCard` with try/catch to prevent proxy property errors.
174
+ - **Test**: Added regression test suite simulating strict Cordis Context Proxy behavior.
package/README.ru.md CHANGED
@@ -144,3 +144,20 @@ 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).
157
+
158
+
159
+ ## Changed in v0.1.13
160
+
161
+ - **Fix (#50)**: Безопасная регистрация вкладки BetterSidebar через `ctx.inject(['betterSidebar'], ...)` вместо прямого чтения свойства из Cordis Context Proxy. Устранена ошибка `cannot get property "betterSidebar" without inject` при загрузке клиентской половины в DSH.
162
+ - **Fix (#50)**: Безопасное обращение к `_ctx.settingsScope` в `PluginCard` через try/catch.
163
+ - **Тесты**: Добавлены регрессионные тесты со строгим Cordis Context Proxy.
@@ -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
+ ));
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));
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
+ ));
52
138
  }
53
- if (!keep.some(Boolean)) return lines.slice(0, 20).join('\n');
54
- return lines.filter((_, i) => keep[i]).slice(0, 200).join('\n');
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,17 +248,22 @@ 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
- if (!scopeRef.current && _ctx && _ctx.settingsScope) {
256
- try { scopeRef.current = _ctx.settingsScope.bind({ namespace: NS }); } catch (e) { scopeRef.current = null; }
261
+ if (!scopeRef.current && _ctx) {
262
+ try {
263
+ if (_ctx.settingsScope) {
264
+ scopeRef.current = _ctx.settingsScope.bind({ namespace: NS });
265
+ }
266
+ } catch (e) { scopeRef.current = null; }
257
267
  }
258
268
  const scope = scopeRef.current;
259
269
 
@@ -283,16 +293,15 @@ window.__ModuleLoader__.load({
283
293
  }, [scope]);
284
294
 
285
295
  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]);
296
+ if (!expanded) return;
297
+ fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
298
+ if (j && j.stats) {
299
+ setStats(j.stats);
300
+ // #41: warn instead of force-closing the card
301
+ setBudgetWarn(!!(j.stats.lowBudget && draft.autoCollapse !== false));
302
+ }
303
+ }).catch(() => {});
304
+ }, [expanded, draft.autoCollapse]);
296
305
 
297
306
  const tt = t || ((k) => (en[k] || k));
298
307
 
@@ -320,9 +329,18 @@ window.__ModuleLoader__.load({
320
329
  if (errs.length) setSaveErr(errs.join('; '));
321
330
  }
322
331
 
323
- function onPreview() {
324
- const mode = draft.compressionMode || 'balanced';
325
- setPreviewOut(compressPreview(previewIn, mode));
332
+ async function onPreview() {
333
+ try {
334
+ const res = await fetch('/dsh-context-lens/compress-preview', {
335
+ method: 'POST',
336
+ headers: { 'content-type': 'application/json' },
337
+ body: JSON.stringify({ text: previewIn, mode: draft.compressionMode || 'balanced' })
338
+ });
339
+ const j = await res.json();
340
+ setPreviewOut((j && j.compressed) || (j && j.error) || '');
341
+ } catch (e) {
342
+ setPreviewOut(String(e && e.message || e));
343
+ }
326
344
  }
327
345
 
328
346
  // styles: ponytail minimal, theme vars only
@@ -340,6 +358,7 @@ window.__ModuleLoader__.load({
340
358
  React.createElement(Chevron, { open: expanded })
341
359
  ),
342
360
  expanded ? React.createElement('div', { className: 'cl-body', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', margin: '0 16px', paddingBottom: 8 } },
361
+ 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
362
  status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
344
363
  status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
345
364
  React.createElement(React.Fragment, null,
@@ -375,8 +394,35 @@ window.__ModuleLoader__.load({
375
394
  }),
376
395
  React.createElement('label', { htmlFor: 'cl-tracking', style: { fontSize: 13 } }, tt('tracking'))
377
396
  ),
397
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, padding: '12px 0' } },
398
+ React.createElement('input', {
399
+ type: 'checkbox',
400
+ checked: !!draft.autoCollapse,
401
+ onChange: (e) => setDraft((d) => ({ ...d, autoCollapse: e.target.checked })),
402
+ id: 'cl-autocollapse'
403
+ }),
404
+ React.createElement('label', { htmlFor: 'cl-autocollapse', style: { fontSize: 13 } }, tt('autoCollapse'))
405
+ ),
378
406
  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')),
407
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('budget') + ' (tokens)'),
408
+ React.createElement('input', {
409
+ className: 'cl-input', type: 'number', min: 1000, step: 1000,
410
+ value: draft.budgetLimit,
411
+ onChange: (e) => setDraft((d) => ({ ...d, budgetLimit: parseInt(e.target.value, 10) || 100000 })),
412
+ 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 }
413
+ })
414
+ ),
415
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
416
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('threshold')),
417
+ React.createElement('input', {
418
+ className: 'cl-input', type: 'number', min: 0, step: 500,
419
+ value: draft.autoCompressThreshold,
420
+ onChange: (e) => setDraft((d) => ({ ...d, autoCompressThreshold: parseInt(e.target.value, 10) || 0 })),
421
+ 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 }
422
+ })
423
+ ),
424
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '12px 0' } },
425
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('preview') + ' (' + tt('previewApprox') + ')'),
380
426
  React.createElement('textarea', {
381
427
  value: previewIn,
382
428
  onChange: (e) => setPreviewIn(e.target.value),
@@ -404,7 +450,7 @@ window.__ModuleLoader__.load({
404
450
  );
405
451
  }
406
452
 
407
- module.exports.inject = ['slots', 'locale', 'betterSidebar'];
453
+ module.exports.inject = ['slots', 'locale'];
408
454
  module.exports.apply = function apply(ctx) {
409
455
  try { ctx.locale.register(NS, { en, ru }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
410
456
  if (!ctx.slots) return;
@@ -440,18 +486,33 @@ window.__ModuleLoader__.load({
440
486
  } else {
441
487
  try { doRegister(); } catch (e) { console.error('[dsh-context-lens] direct register failed (no inject)', e && e.stack || e); throw e; }
442
488
  }
443
- // BetterSidebar tab (optional, for dsh-better-sidebar)
444
- if (ctx.betterSidebar && typeof ctx.betterSidebar.registerTab === 'function') {
489
+ // BetterSidebar tab (optional, for dsh-better-sidebar via ctx.inject)
490
+ if (typeof ctx.inject === 'function') {
445
491
  try {
446
- ctx.effect(() => ctx.betterSidebar.registerTab({
447
- id: 'dsh-context-lens:tab',
448
- title: () => 'Lens',
449
- icon: () => React.createElement('span', null, '◐'),
450
- order: 50,
451
- component: ({ scope }) => React.createElement(LensTab, { ctx, scope })
452
- }));
492
+ ctx.inject(['betterSidebar'], (sctx) => {
493
+ const svc = sctx && sctx.betterSidebar;
494
+ if (!svc || typeof svc.registerTab !== 'function') return;
495
+ try {
496
+ const register = () => svc.registerTab({
497
+ id: 'dsh-context-lens:tab',
498
+ title: () => 'Lens',
499
+ icon: () => React.createElement('span', null, '◐'),
500
+ order: 50,
501
+ component: ({ scope }) => React.createElement(LensTab, { ctx, scope })
502
+ });
503
+ if (typeof sctx.effect === 'function') {
504
+ sctx.effect(register);
505
+ } else if (typeof ctx.effect === 'function') {
506
+ ctx.effect(register);
507
+ } else {
508
+ register();
509
+ }
510
+ } catch (e) {
511
+ console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
512
+ }
513
+ });
453
514
  } catch (e) {
454
- console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
515
+ // host without betterSidebar declared or inject failure
455
516
  }
456
517
  }
457
518
  // Header chip (#31) — telemetry and token guard in conversation session utilities slot
@@ -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.13",
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",