@goodandready/dsh-context-lens 0.1.13 → 0.1.14

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,12 @@ 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.14
178
+
179
+ - **Fix (#52)**: Multiline AST signature parsing in `skeletonizer.js` for TypeScript, JavaScript, Rust, and Go with complex parameter types and return annotations.
180
+ - **Feature (#52)**: Registered `context_lens_reset` tool to allow resetting tracker statistics and history at the start of new tasks.
181
+ - **Quality & UX (#52)**: Dynamic RU/EN locale detection in `HeaderChip`, `LensTab`, and `StatusPanel` via `ctx.locale` for seamless UI integration.
182
+ - **Perf & Stability (#52)**: Adaptive polling in `HeaderChip` pausing on hidden tabs (`visibilitychange`) and polling actively (4s) only when popover is open.
183
+ - **Reliability (#52)**: Atomic settings save (`scope.patch` / `scope.setAll`) in `PluginCard` with graceful fallback.
package/README.ru.md CHANGED
@@ -161,3 +161,12 @@ 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.14
167
+
168
+ - **Исправление (#52)**: Поддержка многострочных сигнатур функций и методов в `skeletonizer.js` (TypeScript, JavaScript, Rust, Go) со сложными типами параметров и аннотациями возвращаемых значений.
169
+ - **Функция (#52)**: Добавлен инструмент `context_lens_reset` для сброса собранной статистики токенов и истории сжатий перед запуском новой задачи.
170
+ - **Качество и локализация (#52)**: Динамическое переключение языка интерфейса (RU/EN) в чипе шапки (`HeaderChip`), боковой вкладке (`LensTab`) и панели статуса (`StatusPanel`) через `ctx.locale`.
171
+ - **Производительность (#52)**: Адаптивный поллинг в `HeaderChip` с паузой при неактивной вкладке браузера (`document.visibilityState`) и учащённым опросом (4 сек) только при открытом поповере.
172
+ - **Стабильность (#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() {
@@ -493,19 +537,17 @@ window.__ModuleLoader__.load({
493
537
  const svc = sctx && sctx.betterSidebar;
494
538
  if (!svc || typeof svc.registerTab !== 'function') return;
495
539
  try {
496
- const register = () => svc.registerTab({
540
+ const tabDef = {
497
541
  id: 'dsh-context-lens:tab',
498
542
  title: () => 'Lens',
499
543
  icon: () => React.createElement('span', null, '◐'),
500
544
  order: 50,
501
545
  component: ({ scope }) => React.createElement(LensTab, { ctx, scope })
502
- });
546
+ };
503
547
  if (typeof sctx.effect === 'function') {
504
- sctx.effect(register);
505
- } else if (typeof ctx.effect === 'function') {
506
- ctx.effect(register);
548
+ sctx.effect(() => svc.registerTab(tabDef));
507
549
  } else {
508
- register();
550
+ svc.registerTab(tabDef);
509
551
  }
510
552
  } catch (e) {
511
553
  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.14",
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",