@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 +26 -0
- package/README.ru.md +18 -0
- package/lib/ast/skeletonizer.js +4 -2
- package/lib/auto-compress.js +7 -0
- package/lib/client.js +259 -66
- package/lib/compression/log-compressor.js +2 -4
- package/lib/index.js +94 -30
- package/lib/tokens/estimate.js +6 -0
- package/lib/tokens/tracker.js +10 -5
- package/package.json +1 -2
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).
|
package/lib/ast/skeletonizer.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
const id = setInterval(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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'
|
|
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
|
|
499
|
+
// Header chip (#31) — telemetry and token guard in conversation session utilities slot
|
|
312
500
|
if (ctx.slots) {
|
|
313
|
-
const
|
|
501
|
+
const headerChipRegister = () => {
|
|
314
502
|
try {
|
|
315
503
|
return ctx.slots.register({
|
|
316
|
-
name: 'conversation.header',
|
|
317
|
-
id: 'dsh-context-lens
|
|
318
|
-
order:
|
|
504
|
+
name: 'conversation.session.header.utilities',
|
|
505
|
+
id: 'dsh-context-lens-header-chip',
|
|
506
|
+
order: 7,
|
|
319
507
|
inject: () => ({ ctx })
|
|
320
|
-
},
|
|
508
|
+
}, HeaderChip);
|
|
321
509
|
} catch (e) {
|
|
322
|
-
console.warn('[dsh-context-lens] header
|
|
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 {
|
|
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 {
|
|
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
|
|
15
|
-
autoCollapse: z.boolean().default(true).description('
|
|
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
|
-
//
|
|
21
|
-
|
|
21
|
+
// #37/#18: focus keyed by session id (not process-global singleton)
|
|
22
|
+
const focusBySession = new Map();
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
if (!
|
|
28
|
-
return
|
|
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 {
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
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: {
|
|
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
|
-
|
|
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({
|
|
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
|
|
package/lib/tokens/tracker.js
CHANGED
|
@@ -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
|
|
9
|
+
let defaultBudgetLimit = 100000; // tokens
|
|
8
10
|
|
|
9
|
-
export
|
|
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.
|
|
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",
|