@goodandready/dsh-context-lens 0.1.13 → 0.1.15

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
@@ -172,3 +172,19 @@ Audit fixes (#33–#47, #18):
172
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
173
  - **Fix (#50)**: Safely guard `_ctx.settingsScope` access in `PluginCard` with try/catch to prevent proxy property errors.
174
174
  - **Test**: Added regression test suite simulating strict Cordis Context Proxy behavior.
175
+
176
+
177
+ ## Changed in v0.1.15
178
+
179
+ - **Feature (#54)**: Support native DSH right sidebar (`ctx.sidebarRightTabs` + `sidebar.right.pane.tab` slot) introduced in DSH 0.1.5-alpha.1.
180
+ - **Compatibility (#54)**: Preserve legacy `dsh-better-sidebar` integration with deterministic, non-conflicting IDs (`@goodandready/dsh-context-lens` vs `dsh-context-lens:tab`).
181
+ - **Resilience (#54)**: Clean boot and graceful fallback across all 4 layouts (Native only, Legacy only, Both active, Neither active).
182
+ - **Tests (#54)**: Added comprehensive test matrix in `test/sidebar-matrix-54.test.mjs`.
183
+
184
+ ## Changed in v0.1.14
185
+
186
+ - **Fix (#52)**: Multiline AST signature parsing in `skeletonizer.js` for TypeScript, JavaScript, Rust, and Go with complex parameter types and return annotations.
187
+ - **Feature (#52)**: Registered `context_lens_reset` tool to allow resetting tracker statistics and history at the start of new tasks.
188
+ - **Quality & UX (#52)**: Dynamic RU/EN locale detection in `HeaderChip`, `LensTab`, and `StatusPanel` via `ctx.locale` for seamless UI integration.
189
+ - **Perf & Stability (#52)**: Adaptive polling in `HeaderChip` pausing on hidden tabs (`visibilitychange`) and polling actively (4s) only when popover is open.
190
+ - **Reliability (#52)**: Atomic settings save (`scope.patch` / `scope.setAll`) in `PluginCard` with graceful fallback.
package/README.ru.md CHANGED
@@ -161,3 +161,19 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
161
161
  - **Fix (#50)**: Безопасная регистрация вкладки BetterSidebar через `ctx.inject(['betterSidebar'], ...)` вместо прямого чтения свойства из Cordis Context Proxy. Устранена ошибка `cannot get property "betterSidebar" without inject` при загрузке клиентской половины в DSH.
162
162
  - **Fix (#50)**: Безопасное обращение к `_ctx.settingsScope` в `PluginCard` через try/catch.
163
163
  - **Тесты**: Добавлены регрессионные тесты со строгим Cordis Context Proxy.
164
+
165
+
166
+ ## Changed in v0.1.15
167
+
168
+ - **Функциональность (#54)**: Поддержка встроенной правой боковой панели DSH (`ctx.sidebarRightTabs` + слот `sidebar.right.pane.tab`) из DSH 0.1.5-alpha.1.
169
+ - **Совместимость (#54)**: Сохранена интеграция с легаси `dsh-better-sidebar` с детерминированными неконфликтующими идентификаторами (`@goodandready/dsh-context-lens` и `dsh-context-lens:tab`).
170
+ - **Отказоустойчивость (#54)**: Чистая инициализация во всех 4 конфигурациях (только Native, только Legacy, обе активны, обе отсутствуют).
171
+ - **Тесты (#54)**: Добавлена матрица тестов в `test/sidebar-matrix-54.test.mjs`.
172
+
173
+ ## Changed in v0.1.14
174
+
175
+ - **Исправление (#52)**: Поддержка многострочных сигнатур функций и методов в `skeletonizer.js` (TypeScript, JavaScript, Rust, Go) со сложными типами параметров и аннотациями возвращаемых значений.
176
+ - **Функция (#52)**: Добавлен инструмент `context_lens_reset` для сброса собранной статистики токенов и истории сжатий перед запуском новой задачи.
177
+ - **Качество и локализация (#52)**: Динамическое переключение языка интерфейса (RU/EN) в чипе шапки (`HeaderChip`), боковой вкладке (`LensTab`) и панели статуса (`StatusPanel`) через `ctx.locale`.
178
+ - **Производительность (#52)**: Адаптивный поллинг в `HeaderChip` с паузой при неактивной вкладке браузера (`document.visibilityState`) и учащённым опросом (4 сек) только при открытом поповере.
179
+ - **Стабильность (#52)**: Атомарное сохранение конфигурации в карточке настроек (`scope.patch` / `scope.setAll`) без риска частичной записи.
@@ -1,4 +1,4 @@
1
- // ponytail: regex skeletons, no tree-sitter — handles JS/TS/Python/Go/Rust/Java signatures + imports/comments
1
+ // ponytail: regex skeletons, no tree-sitter — handles JS/TS/Python/Go/Rust/Java signatures + imports/comments + multiline signatures
2
2
  const JS_FUNC_RE = /^\s*(export\s+)?(async\s+)?(function\s+(\w+)|const\s+(\w+)\s*=\s*(async\s+)?\([^)]*\)\s*=>|(\w+)\s*:\s*\([^)]*\)\s*=>|class\s+(\w+)|interface\s+(\w+)|type\s+(\w+)\s*=)/;
3
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))/;
@@ -6,6 +6,9 @@ const RUST_RE = /^\s*(pub(\([^)]+\))?\s+)?(async\s+)?(fn|struct|enum|impl|trait|
6
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
+ // Multiline signature starters
10
+ const MULTILINE_START_RE = /^\s*(export\s+)?(async\s+)?(function\b|const\s+\w+\s*=|\w+\s*:\s*(async\s+)?\(|def\b|async\s+def\b|func\b|(pub(\([^)]+\))?\s+)?(async\s+)?fn\b|(?:(?:public|protected|private|static|final|abstract|synchronized|native|default)\s+)+[\w.<>,\[\]?]+\s+\w+\s*\()/;
11
+
9
12
  function indentDepth(line) {
10
13
  const m = line.match(/^(\s*)/);
11
14
  return m ? Math.floor(m[1].replace(/\t/g, ' ').length / 2) : 0;
@@ -17,12 +20,14 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
17
20
  const out = [];
18
21
  let seen = new Set();
19
22
  let pendingComment = null;
23
+
20
24
  for (let idx = 0; idx < lines.length; idx++) {
21
25
  const raw = lines[idx];
22
26
  const line = raw.trimEnd();
23
27
  if (!line.trim()) { pendingComment = null; continue; }
24
28
  const d = indentDepth(raw);
25
29
  if (d > maxDepth) { pendingComment = null; continue; }
30
+
26
31
  // Handle imports
27
32
  if (includeImports && IMPORT_RE.test(line)) {
28
33
  const sig = line.trim().slice(0, 120);
@@ -33,14 +38,14 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
33
38
  pendingComment = null;
34
39
  continue;
35
40
  }
41
+
36
42
  // Handle comments (keep JSDoc or line comments directly above a definition)
37
43
  if (includeComments && COMMENT_RE.test(line)) {
38
- // Keep comment if next non-empty line is a definition
39
44
  let nextIdx = idx + 1;
40
45
  while (nextIdx < lines.length && !lines[nextIdx].trim()) nextIdx++;
41
46
  if (nextIdx < lines.length) {
42
47
  const nextLine = lines[nextIdx];
43
- if (JS_FUNC_RE.test(nextLine) || PY_RE.test(nextLine) || GO_RE.test(nextLine) || RUST_RE.test(nextLine) || /^\s*(class|function|interface|type|def |func |pub |fn )/.test(nextLine)) {
48
+ if (JS_FUNC_RE.test(nextLine) || PY_RE.test(nextLine) || GO_RE.test(nextLine) || RUST_RE.test(nextLine) || MULTILINE_START_RE.test(nextLine) || /^\s*(class|function|interface|type|def |func |pub |fn )/.test(nextLine)) {
44
49
  pendingComment = line.trim().slice(0, 120);
45
50
  continue;
46
51
  }
@@ -56,36 +61,61 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
56
61
  pendingComment = null;
57
62
  continue;
58
63
  }
64
+
65
+ // Multiline signature detection: if line starts a signature but doesn't finish with '{', '=>', or ':'
66
+ let candidateLine = line;
67
+ let accumulatedIdx = idx;
68
+ const isComplete = (s) => (s.includes('{') && s.includes(')')) || (s.includes('=>') && s.includes(')')) || (s.endsWith(':') && s.includes(')'));
69
+ if (MULTILINE_START_RE.test(candidateLine) && !isComplete(candidateLine)) {
70
+ let buffer = candidateLine.trim();
71
+ let scanIdx = idx + 1;
72
+ while (scanIdx < lines.length && scanIdx <= idx + 12) {
73
+ const nextRaw = lines[scanIdx].trim();
74
+ if (!nextRaw) { scanIdx++; continue; }
75
+ buffer += ' ' + nextRaw;
76
+ if ((nextRaw.includes('{') || nextRaw.includes('=>') || nextRaw.endsWith(':')) && buffer.includes(')')) {
77
+ candidateLine = buffer;
78
+ accumulatedIdx = scanIdx;
79
+ break;
80
+ }
81
+ scanIdx++;
82
+ }
83
+ }
84
+
59
85
  let sig = null;
60
86
  // try JS/TS
61
87
  if (!language || language === 'js' || language === 'ts') {
62
- const m = line.match(/^\s*(export\s+)?(async\s+)?(function\s+\w+[^\n]*|const\s+\w+\s*=.*=>.*|class\s+\w+.*|interface\s+\w+.*|type\s+\w+\s*=.*|(?:public|private|protected)?\s*(async\s+)?\w+\s*\([^)]*\)\s*[:{]|import\s+.*|export\s+.*)/);
63
- if (m) sig = line.trim().replace(/\s*\{.*$/, '').replace(/\s+$/, '');
64
- if (!sig && /^\s*(export\s+)?(async\s+)?function\s+\w+/.test(line)) sig = line.trim();
65
- if (!sig && /^\s*class\s+\w+/.test(line)) sig = line.trim();
66
- if (!sig && /^\s*import\s+/.test(line)) sig = line.trim().slice(0, 120);
88
+ const m = candidateLine.match(/^\s*(export\s+)?(async\s+)?(function\s+\w+[^\n]*|const\s+\w+\s*=.*=>.*|class\s+\w+.*|interface\s+\w+.*|type\s+\w+\s*=.*|(?:public|private|protected)?\s*(async\s+)?\w+\s*\([^)]*\)\s*[:{]|import\s+.*|export\s+.*)/);
89
+ if (m) sig = candidateLine.trim().replace(/\s*\{\s*$/, '').replace(/\s+$/, '');
90
+ if (!sig && /^\s*(export\s+)?(async\s+)?function\s+\w+/.test(candidateLine)) sig = candidateLine.trim();
91
+ if (!sig && /^\s*class\s+\w+/.test(candidateLine)) sig = candidateLine.trim();
92
+ if (!sig && /^\s*import\s+/.test(candidateLine)) sig = candidateLine.trim().slice(0, 120);
67
93
  }
68
94
  if (!sig && (!language || language === 'py' || language === 'python')) {
69
- const m = line.match(PY_RE);
70
- if (m) sig = line.trim();
95
+ const m = candidateLine.match(PY_RE);
96
+ if (m) sig = candidateLine.trim();
71
97
  }
72
98
  if (!sig && (!language || language === 'go')) {
73
- const m = line.match(GO_RE);
74
- if (m) sig = line.trim();
99
+ const m = candidateLine.match(GO_RE);
100
+ if (m) sig = candidateLine.trim();
75
101
  }
76
102
  if (!sig && (!language || language === 'rust')) {
77
- if (RUST_RE.test(line)) sig = line.trim();
103
+ if (RUST_RE.test(candidateLine)) sig = candidateLine.trim();
78
104
  }
79
105
  if (!sig && (!language || language === 'java')) {
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();
106
+ if (/^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default)\s+)+(?:class|interface|enum|record)\s+\w+/.test(candidateLine)
107
+ || /^\s*(?:(?:public|protected|private|static|final|abstract|synchronized|native|default)\s+)+[\w.<>,\[\]?]+\s+\w+\s*\(/.test(candidateLine)
108
+ || /^\s*(class|interface|enum|record)\s+\w+/.test(candidateLine)) sig = candidateLine.trim();
83
109
  }
84
110
  // generic fallback: if no specific language, try all
85
111
  if (!sig && !language) {
86
- if (JS_FUNC_RE.test(line) || PY_RE.test(line) || GO_RE.test(line) || RUST_RE.test(line)) sig = line.trim();
112
+ if (JS_FUNC_RE.test(candidateLine) || PY_RE.test(candidateLine) || GO_RE.test(candidateLine) || RUST_RE.test(candidateLine)) sig = candidateLine.trim();
87
113
  }
114
+
88
115
  if (sig) {
116
+ if (accumulatedIdx > idx) {
117
+ idx = accumulatedIdx; // advance loop past multiline signature lines
118
+ }
89
119
  // If we have a pending comment, prepend it
90
120
  if (pendingComment) {
91
121
  const commentSig = pendingComment;
@@ -97,7 +127,7 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
97
127
  }
98
128
  // normalize: trim trailing { : etc, keep signature short
99
129
  sig = sig.replace(/\s*\{\s*$/, '').replace(/:\s*$/, '').trim();
100
- if (sig.length > 120) sig = sig.slice(0, 117) + '...';
130
+ if (sig.length > 180) sig = sig.slice(0, 177) + '...';
101
131
  const key = sig;
102
132
  if (!seen.has(key)) {
103
133
  seen.add(key);
@@ -107,6 +137,7 @@ export function skeletonize(text, { maxDepth = 3, language, includeImports = tru
107
137
  pendingComment = null;
108
138
  }
109
139
  }
140
+
110
141
  // if nothing found, fallback to first N non-empty lines truncated
111
142
  if (out.length === 0) {
112
143
  const fallback = lines.filter((l) => l.trim()).slice(0, Math.min(20, maxDepth * 6)).map((l) => l.trim().slice(0, 120));
package/lib/client.js CHANGED
@@ -61,6 +61,16 @@ window.__ModuleLoader__.load({
61
61
  // #45: preview uses POST /dsh-context-lens/compress-preview (server compressor)
62
62
 
63
63
  // #43: shared status UI for LensTab + HeaderChip popover
64
+ function getActiveLocale(ctx) {
65
+ try {
66
+ if (ctx && ctx.locale && typeof ctx.locale.getSnapshot === 'function') {
67
+ const snap = ctx.locale.getSnapshot();
68
+ if (snap && snap.active && snap.active.startsWith('ru')) return ru;
69
+ }
70
+ } catch {}
71
+ return en;
72
+ }
73
+
64
74
  function StatusPanel({ stats, history, labels, onRefresh, title, showBadge, compact }) {
65
75
  const L = labels || en;
66
76
  const warn = !!(stats && stats.lowBudget);
@@ -165,6 +175,7 @@ window.__ModuleLoader__.load({
165
175
  function LensTab({ ctx: _ctx, scope }) {
166
176
  const [stats, setStats] = React.useState(null);
167
177
  const [history, setHistory] = React.useState([]);
178
+ const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
168
179
  const apply = (j) => {
169
180
  if (j && j.stats) setStats(j.stats);
170
181
  if (j && j.history) setHistory(j.history || []);
@@ -173,7 +184,7 @@ window.__ModuleLoader__.load({
173
184
  React.useEffect(() => { refresh(); }, []);
174
185
  return React.createElement('div', { style: { padding: 12 } },
175
186
  React.createElement(StatusPanel, {
176
- stats, history, labels: en, onRefresh: refresh, title: 'Context Lens', showBadge: false, compact: false
187
+ stats, history, labels: L, onRefresh: refresh, title: 'Context Lens', showBadge: false, compact: false
177
188
  })
178
189
  );
179
190
  }
@@ -183,6 +194,7 @@ window.__ModuleLoader__.load({
183
194
  const [history, setHistory] = React.useState([]);
184
195
  const [open, setOpen] = React.useState(false);
185
196
  const ref = React.useRef(null);
197
+ const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
186
198
 
187
199
  const fetchStatus = () => fetchLensStatus().then((j) => {
188
200
  if (j && j.stats) setStats(j.stats);
@@ -192,9 +204,30 @@ window.__ModuleLoader__.load({
192
204
  React.useEffect(() => {
193
205
  let alive = true;
194
206
  fetchStatus();
195
- const id = setInterval(() => { if (alive) fetchStatus(); }, 5000);
196
- return () => { alive = false; clearInterval(id); };
197
- }, []);
207
+ // Adaptive polling: poll every 4s when popover is open, 15s when closed, pause when tab hidden
208
+ const pollInterval = open ? 4000 : 15000;
209
+ const id = setInterval(() => {
210
+ if (!alive) return;
211
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
212
+ fetchStatus();
213
+ }, pollInterval);
214
+
215
+ const onVisChange = () => {
216
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible' && alive) {
217
+ fetchStatus();
218
+ }
219
+ };
220
+ if (typeof document !== 'undefined') {
221
+ document.addEventListener('visibilitychange', onVisChange);
222
+ }
223
+ return () => {
224
+ alive = false;
225
+ clearInterval(id);
226
+ if (typeof document !== 'undefined') {
227
+ document.removeEventListener('visibilitychange', onVisChange);
228
+ }
229
+ };
230
+ }, [open]);
198
231
 
199
232
  React.useEffect(() => {
200
233
  if (!open) return;
@@ -221,14 +254,14 @@ window.__ModuleLoader__.load({
221
254
  onClick: (e) => e.stopPropagation()
222
255
  },
223
256
  React.createElement(StatusPanel, {
224
- stats, history, labels: en, onRefresh: fetchStatus, title: 'Context Lens', showBadge: true, compact: true
257
+ stats, history, labels: L, onRefresh: fetchStatus, title: 'Context Lens', showBadge: true, compact: true
225
258
  })
226
259
  ) : null;
227
260
 
228
261
  return React.createElement('div', { ref, style: { position: 'relative', display: 'inline-flex', alignItems: 'center' } },
229
262
  React.createElement('button', {
230
263
  type: 'button',
231
- title: hasSavings ? `Context Lens: ${en.saved} ${stats.savedTokens} tk (${stats.savedPercent}%)` : 'Context Lens: Active',
264
+ title: hasSavings ? `Context Lens: ${L.saved} ${stats.savedTokens} ${L.tokens} (${stats.savedPercent}%)` : `Context Lens: ${L.active}`,
232
265
  onClick: () => setOpen((v) => !v),
233
266
  style: {
234
267
  appearance: 'none', font: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4,
@@ -320,13 +353,24 @@ window.__ModuleLoader__.load({
320
353
  async function onSave() {
321
354
  if (!scope) { setSaveErr('Settings unavailable'); return; }
322
355
  setSaving(true); setSaveErr('');
323
- const keys = Object.keys(draft);
324
- const errs = [];
325
- for (const k of keys) {
326
- try { await scope.set(k, draft[k]); } catch (e) { errs.push(k + ': ' + (e && e.message || String(e))); }
356
+ try {
357
+ if (typeof scope.patch === 'function') {
358
+ await scope.patch(draft);
359
+ } else if (typeof scope.setAll === 'function') {
360
+ await scope.setAll(draft);
361
+ } else {
362
+ const keys = Object.keys(draft);
363
+ const errs = [];
364
+ for (const k of keys) {
365
+ try { await scope.set(k, draft[k]); } catch (e) { errs.push(k + ': ' + (e && e.message || String(e))); }
366
+ }
367
+ if (errs.length) throw new Error(errs.join('; '));
368
+ }
369
+ } catch (e) {
370
+ setSaveErr(e && e.message || String(e));
371
+ } finally {
372
+ setSaving(false);
327
373
  }
328
- setSaving(false);
329
- if (errs.length) setSaveErr(errs.join('; '));
330
374
  }
331
375
 
332
376
  async function onPreview() {
@@ -486,6 +530,65 @@ window.__ModuleLoader__.load({
486
530
  } else {
487
531
  try { doRegister(); } catch (e) { console.error('[dsh-context-lens] direct register failed (no inject)', e && e.stack || e); throw e; }
488
532
  }
533
+ // Native DSH Right Sidebar (#54, DSH 0.1.5-alpha.1+)
534
+ if (typeof ctx.inject === "function") {
535
+ try {
536
+ ctx.inject(["sidebarRightTabs"], (sctx) => {
537
+ const tabs = sctx && sctx.sidebarRightTabs;
538
+ if (!tabs || typeof tabs.register !== "function") return;
539
+ try {
540
+ const def = {
541
+ id: "@goodandready/dsh-context-lens",
542
+ kind: "context-lens",
543
+ priority: "extension",
544
+ title: () => "Lens",
545
+ guide: [{
546
+ order: 50,
547
+ title: () => "Context Lens",
548
+ description: () => "AST compression, log filtering, token budget",
549
+ icon: () => React.createElement("span", null, "◐")
550
+ }]
551
+ };
552
+ if (typeof sctx.effect === "function") {
553
+ sctx.effect(() => tabs.register(def));
554
+ } else {
555
+ tabs.register(def);
556
+ }
557
+
558
+ // Register body in sidebar.right.pane.tab slot
559
+ if (ctx.slots) {
560
+ const registerPaneTab = () => {
561
+ try {
562
+ return ctx.slots.register({
563
+ name: "sidebar.right.pane.tab",
564
+ key: "@goodandready/dsh-context-lens",
565
+ locale: NS,
566
+ inject: () => ({ ctx })
567
+ }, LensTab);
568
+ } catch (e) {
569
+ console.warn("[dsh-context-lens] native sidebar pane tab register failed", e && e.message || e);
570
+ }
571
+ };
572
+ if (typeof ctx.slots.inject === "function") {
573
+ try {
574
+ const ok = ctx.slots.inject("sidebar.right.pane.tab", registerPaneTab);
575
+ if (!ok) console.warn("[dsh-context-lens] sidebar.right.pane.tab inject returned false");
576
+ } catch (e) {
577
+ try { registerPaneTab(); } catch (e2) {}
578
+ }
579
+ } else {
580
+ try { registerPaneTab(); } catch (e) {}
581
+ }
582
+ }
583
+ } catch (e) {
584
+ console.warn("[dsh-context-lens] native sidebar registration failed", e && e.message || e);
585
+ }
586
+ });
587
+ } catch (e) {
588
+ // host without sidebarRightTabs declared or inject failure
589
+ }
590
+ }
591
+
489
592
  // BetterSidebar tab (optional, for dsh-better-sidebar via ctx.inject)
490
593
  if (typeof ctx.inject === 'function') {
491
594
  try {
@@ -493,19 +596,17 @@ window.__ModuleLoader__.load({
493
596
  const svc = sctx && sctx.betterSidebar;
494
597
  if (!svc || typeof svc.registerTab !== 'function') return;
495
598
  try {
496
- const register = () => svc.registerTab({
599
+ const tabDef = {
497
600
  id: 'dsh-context-lens:tab',
498
601
  title: () => 'Lens',
499
602
  icon: () => React.createElement('span', null, '◐'),
500
603
  order: 50,
501
604
  component: ({ scope }) => React.createElement(LensTab, { ctx, scope })
502
- });
605
+ };
503
606
  if (typeof sctx.effect === 'function') {
504
- sctx.effect(register);
505
- } else if (typeof ctx.effect === 'function') {
506
- ctx.effect(register);
607
+ sctx.effect(() => svc.registerTab(tabDef));
507
608
  } else {
508
- register();
609
+ svc.registerTab(tabDef);
509
610
  }
510
611
  } catch (e) {
511
612
  console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
package/lib/index.js CHANGED
@@ -20,13 +20,16 @@ const NS = '@goodandready/dsh-context-lens';
20
20
 
21
21
  // #37/#18: focus keyed by session id (not process-global singleton)
22
22
  const focusBySession = new Map();
23
+ let lastActiveSession = '__default__';
23
24
 
24
25
  function sessionKey(params, meta) {
25
- return (
26
+ const k = (
26
27
  (params && (params.sessionId || params.session_id)) ||
27
28
  (meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
28
29
  '__default__'
29
30
  );
31
+ if (k !== '__default__') lastActiveSession = k;
32
+ return k;
30
33
  }
31
34
 
32
35
  function getFocus(key) {
@@ -142,6 +145,26 @@ export function apply(ctx, config) {
142
145
  }
143
146
  });
144
147
 
148
+ ctx.tools.register({
149
+ name: 'context_lens_reset',
150
+ description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
151
+ parameters: {
152
+ type: 'object',
153
+ properties: {
154
+ sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
155
+ }
156
+ },
157
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
158
+ execute: async (params, meta) => {
159
+ tracker.reset();
160
+ const key = sessionKey(params || {}, meta);
161
+ if (params && params.sessionId) {
162
+ focusBySession.delete(key);
163
+ }
164
+ return { success: true, message: 'Context lens stats and history reset', sessionId: key };
165
+ }
166
+ });
167
+
145
168
  ctx.tools.register({
146
169
  name: 'context_lens_stats',
147
170
  description: 'Show session token savings stats for context-lens',
@@ -179,13 +202,19 @@ export function apply(ctx, config) {
179
202
  handler: (req, res) => {
180
203
  const cfg = getConfig();
181
204
  if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
205
+ let sId = '__default__';
206
+ try {
207
+ const u = new URL(req.url, 'http://127.0.0.1');
208
+ sId = u.searchParams.get('sessionId') || u.searchParams.get('session_id') || lastActiveSession || '__default__';
209
+ } catch {}
182
210
  res.setHeader('content-type', 'application/json');
183
211
  res.end(JSON.stringify({
184
212
  ok: true,
185
213
  plugin: 'dsh-context-lens',
186
214
  stats: tracker.getStats(cfg.budgetLimit),
187
215
  history: tracker.getHistory(),
188
- focus: getFocus('__default__')
216
+ focus: getFocus(sId),
217
+ sessionId: sId
189
218
  }));
190
219
  }
191
220
  }), 'dsh-context-lens status route');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-context-lens",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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",