@goodandready/dsh-context-lens 0.1.17 → 0.1.18

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
@@ -154,6 +154,16 @@ dsh-context-lens:
154
154
 
155
155
  ---
156
156
 
157
+
158
+ ## Changed in v0.1.18
159
+
160
+ - **Critical Fix (#71, GH #2)**: Conformed `output.render` to DeepSeek Harness core contract by returning `ContentBlock[]` (`[{ type: 'text', text: ... }]`). Prevents irreversible DSH session poisoning caused by `TypeError: content.some is not a function` in `@deepseek-ai/dsh-llm`.
161
+ - **Security & Reliability (#62, #63, #70)**: Hardened HTTP endpoints (`/clear-focus`, `/compress-preview`, `/status`): strictly requires `POST` on write actions, enforces loopback/same-origin trust checks, caps body size to 256KB (413 on exceed), validates `maxLines` (1-5000), and handles malformed session queries with clear 400 responses.
162
+ - **One-Click In-App Updater (#61)**: Added canonical plugin updater module (`lib/updater.js`) with `/api/dsh-context-lens/update` endpoint and an in-app update UI banner with one-click update button in the settings card.
163
+ - **Settings & Lifecycle (#64, #66)**: Injected `settingsScope` directly into client plugin dependencies and removed foreign `lanSettings` fallback. Wrapped settings and locale registrations in `ctx.effect` with disposable cleanups.
164
+ - **UI & Theme Alignment (#67, #68)**: Implemented smooth vector SVG 14x14 chevron with 180° rotation animation and replaced hardcoded colors with `--dsw-alias-*` theme tokens for seamless light/dark mode presentation.
165
+ - **Repository Hygiene (#65)**: Purged internal agent artifacts (`AGENTS.md`, `index.md`, `docs/plans/`) from tracked git tree and added denylist to `.gitignore`.
166
+
157
167
  ## 📄 License
158
168
 
159
169
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.ru.md CHANGED
@@ -151,6 +151,16 @@ dsh-context-lens:
151
151
 
152
152
  ---
153
153
 
154
+
155
+ ## Изменения в v0.1.18
156
+
157
+ - **Критическое исправление (#71, GH #2)**: Приведение контракта `output.render` к стандарту ядра DeepSeek Harness: теперь возвращается массив `ContentBlock[]` (`[{ type: 'text', text: ... }]`). Полностью устранено необратимое повреждение сессий DSH с ошибкой `TypeError: content.some is not a function` в модуле `@deepseek-ai/dsh-llm`.
158
+ - **Безопасность и отказоустойчивость HTTP (#62, #63, #70)**: Защита маршрутов (`/clear-focus`, `/compress-preview`, `/status`): проверка метода (`POST` для изменений), верификация loopback и same-origin, ограничение размера тела запроса до 256 КБ (код 413 при превышении), клампинг `maxLines` (1–5000), возврат 400 Bad Request при невалидном идентификаторе сессии.
159
+ - **One-Click автообновление плагина (#61)**: Внедрен стандартный модуль автообновления `lib/updater.js`, endpoint `/api/dsh-context-lens/update` и блок проверки/установки обновления в карточке настроек.
160
+ - **Сервис настроек и жизненный цикл (#64, #66)**: Клиентский плагин объявляет зависимость `settingsScope`, убрана привязка к чужому сервису `lanSettings`. Регистрации настроек и локали обёрнуты в `ctx.effect` с корректными уборщиками.
161
+ - **Визуальная часть UI и темы (#67, #68)**: Запасной шеврон заменён на векторный SVG 14x14 с плавной анимацией поворота на 180°. Исключены захардкоженные цвета `rgba()`, интерфейс переведён на токены `--dsw-alias-*`.
162
+ - **Санитайзинг репозитория (#65)**: Из отслеживаемого дерева исключены внутренние файлы (`AGENTS.md`, `index.md`, `docs/plans/`), обновлён `.gitignore`.
163
+
154
164
  ## 📄 Лицензия
155
165
 
156
166
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.zh.md CHANGED
@@ -81,3 +81,13 @@ dsh plugin --profile web add @goodandready/dsh-context-lens
81
81
  ## 📄 开源协议
82
82
 
83
83
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
84
+
85
+
86
+ ## v0.1.18 更新日志
87
+
88
+ - **严重问题修复 (#71, GH #2)**:严格遵循 DeepSeek Harness 核心工具返回规范,将 `output.render` 调整为返回 `ContentBlock[]` 数组 (`[{ type: 'text', text: ... }]`)。彻底解决第三方工具调用后会话写入非数组导致 `@deepseek-ai/dsh-llm` 报错 `TypeError: content.some is not a function` 并永久损坏会话的严重缺陷。
89
+ - **安全性与稳定性加固 (#62, #63, #70)**:全面加固 HTTP 接口 (`/clear-focus`, `/compress-preview`, `/status`):写操作严格要求 `POST` 请求,校验环回与同源请求来源,限制请求体上限为 256KB(超限返回 413),限制 `maxLines` 范围为 1–5000,无效 session 参数返回规范 400 错误。
90
+ - **一键在线更新支持 (#61)**:引入标准更新管理模块 (`lib/updater.js`) 及 `/api/dsh-context-lens/update` 接口,在设置卡片中增加版本检查与一键更新按钮。
91
+ - **设置域与生命周期规范 (#64, #66)**:客户端显式声明 `settingsScope` 依赖并移除对外部 `lanSettings` 的隐式依赖,将设置注册与多语言注册统一纳入 `ctx.effect` 生命周期清理管理。
92
+ - **UI 与主题深度适配 (#67, #68)**:将折叠箭头升级为 14x14 矢量 SVG 并支持平滑 180° 旋转动画,全面替换硬编码色值,使用 `--dsw-alias-*` 主题变量完美适配明暗主题。
93
+ - **仓库代码纯净性 (#65)**:从 Git 跟踪树中彻底剥离内部计划文件 (`AGENTS.md`, `index.md`, `docs/plans/`),并更新 `.gitignore` 防护清单。
package/lib/client.js CHANGED
@@ -6,6 +6,13 @@ window.__ModuleLoader__.load({
6
6
  const NS = '@goodandready/dsh-context-lens';
7
7
 
8
8
  const en = {
9
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
10
+ 'update.btn': 'Update Now',
11
+ 'update.updating': 'Updating…',
12
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
13
+ 'update.failed': 'Update failed: {error}',
14
+ 'update.checking': 'Checking for updates…',
15
+ 'update.up_to_date': 'Up to date',
9
16
  title: 'Context Lens & Token Guard',
10
17
  sub: 'AST compression, log filtering, token budget',
11
18
  mode: 'Compression mode',
@@ -37,6 +44,13 @@ window.__ModuleLoader__.load({
37
44
  };
38
45
 
39
46
  const zh = {
47
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
48
+ 'update.btn': '立即更新',
49
+ 'update.updating': '正在更新…',
50
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
51
+ 'update.failed': '更新失败:{error}',
52
+ 'update.checking': '正在检查更新…',
53
+ 'update.up_to_date': '已是最新版本',
40
54
  title: 'Context Lens 上下文镜头与 Token 守卫',
41
55
  sub: 'AST 代码精简、日志过滤与 Token 预算监控',
42
56
  mode: '压缩模式',
@@ -108,12 +122,12 @@ window.__ModuleLoader__.load({
108
122
  .cl-badge-ok {
109
123
  border-color: var(--dsw-alias-state-success-primary, #10b981);
110
124
  color: var(--dsw-alias-state-success-primary, #10b981);
111
- background: rgba(16, 185, 129, 0.08);
125
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent);
112
126
  }
113
127
  .cl-badge-warn {
114
128
  border-color: var(--dsw-alias-state-error-primary, #ef4444);
115
129
  color: var(--dsw-alias-state-error-primary, #ef4444);
116
- background: rgba(239, 68, 68, 0.08);
130
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent);
117
131
  }
118
132
  .cl-stat-grid {
119
133
  display: grid;
@@ -211,7 +225,7 @@ window.__ModuleLoader__.load({
211
225
  .cl-banner-warn {
212
226
  padding: 10px 14px;
213
227
  border-radius: 8px;
214
- background: rgba(239, 68, 68, 0.1);
228
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);
215
229
  border: 1px solid var(--dsw-alias-state-error-primary, #ef4444);
216
230
  color: var(--dsw-alias-state-error-primary, #ef4444);
217
231
  font-size: 12px;
@@ -244,7 +258,7 @@ window.__ModuleLoader__.load({
244
258
  .cl-error-box {
245
259
  padding: 12px;
246
260
  border-radius: 8px;
247
- background: rgba(239, 68, 68, 0.08);
261
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent);
248
262
  border: 1px solid var(--dsw-alias-state-error-primary, #ef4444);
249
263
  color: var(--dsw-alias-state-error-primary, #ef4444);
250
264
  font-size: 12px;
@@ -253,6 +267,18 @@ window.__ModuleLoader__.load({
253
267
  document.head.appendChild(style);
254
268
  }
255
269
 
270
+ function makeT(dict, fallback) {
271
+ return function t(key, vars) {
272
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key;
273
+ if (vars && typeof val === 'string') {
274
+ for (const k of Object.keys(vars)) {
275
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]));
276
+ }
277
+ }
278
+ return val;
279
+ };
280
+ }
281
+
256
282
  function createErrorBoundary() {
257
283
  if (!React || typeof React.Component !== 'function') {
258
284
  return function NoopBoundary(props) { return (props && props.children) || null; };
@@ -293,7 +319,7 @@ window.__ModuleLoader__.load({
293
319
  const snap = ctx.locale.getSnapshot();
294
320
  if (snap && snap.active && snap.active.startsWith('zh')) return zh;
295
321
  }
296
- } catch {}
322
+ } catch (err) { void err; }
297
323
  return en;
298
324
  }
299
325
 
@@ -534,7 +560,7 @@ window.__ModuleLoader__.load({
534
560
  style: {
535
561
  position: 'absolute', top: 'calc(100% + 6px)', right: 0, width: 280,
536
562
  background: 'var(--dsw-alias-bg-layer-3)', border: '1px solid var(--dsw-alias-border-l2)',
537
- borderRadius: 10, boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)', padding: '12px 14px',
563
+ borderRadius: 10, boxShadow: 'var(--dsw-alias-modal-shadow, var(--dsw-alias-shadow-large, 0 4px 16px var(--dsw-alias-border-l1)))', padding: '12px 14px',
538
564
  zIndex: 1000, fontSize: 12, color: 'var(--dsw-alias-label-primary)', cursor: 'default', textAlign: 'left'
539
565
  },
540
566
  onClick: (e) => e.stopPropagation()
@@ -587,7 +613,7 @@ window.__ModuleLoader__.load({
587
613
  const scopeRef = React.useRef(null);
588
614
  if (!scopeRef.current && _ctx) {
589
615
  try {
590
- const s = (_ctx.get && _ctx.get('lanSettings')) || _ctx.settingsScope;
616
+ const s = _ctx.settingsScope || (_ctx.get && _ctx.get('settingsScope'));
591
617
  if (s && typeof s.bind === 'function') {
592
618
  scopeRef.current = s.bind({ namespace: NS });
593
619
  }
@@ -627,7 +653,7 @@ window.__ModuleLoader__.load({
627
653
  unsub = scope.subscribe(() => {
628
654
  if (!cancelled) loadSnap();
629
655
  });
630
- } catch (_) {}
656
+ } catch (_err) { void _err; }
631
657
  }
632
658
 
633
659
  return () => {
@@ -636,6 +662,69 @@ window.__ModuleLoader__.load({
636
662
  };
637
663
  }, [scope]);
638
664
 
665
+ const [updateState, setUpdateState] = React.useState({
666
+ checking: false,
667
+ updating: false,
668
+ currentVersion: '0.1.18',
669
+ latestVersion: '',
670
+ updateAvailable: false,
671
+ canAutoUpdate: true,
672
+ notice: '',
673
+ error: ''
674
+ });
675
+
676
+ const checkUpdate = React.useCallback(async () => {
677
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }));
678
+ try {
679
+ const res = await fetch('/api/dsh-context-lens/update');
680
+ if (!res.ok) throw new Error('HTTP ' + res.status);
681
+ const data = await res.json().catch(() => ({}));
682
+ setUpdateState((s) => ({
683
+ ...s,
684
+ checking: false,
685
+ currentVersion: data.currentVersion || s.currentVersion,
686
+ latestVersion: data.latestVersion || '',
687
+ updateAvailable: !!data.updateAvailable,
688
+ canAutoUpdate: data.canAutoUpdate !== false,
689
+ }));
690
+ } catch (_) {
691
+ setUpdateState((s) => ({ ...s, checking: false }));
692
+ }
693
+ }, []);
694
+
695
+ React.useEffect(() => {
696
+ if (expanded) checkUpdate();
697
+ }, [expanded, checkUpdate]);
698
+
699
+ async function handleTriggerUpdate() {
700
+ if (updateState.updating) return;
701
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }));
702
+ try {
703
+ const res = await fetch('/api/dsh-context-lens/update', {
704
+ method: 'POST',
705
+ headers: { 'x-dsh-plugin-update': '1', 'x-dsh-context-lens-update': '1' }
706
+ });
707
+ const data = await res.json().catch(() => ({}));
708
+ if (!res.ok || data.ok === false || data.error) {
709
+ throw new Error(data.error || ('HTTP ' + res.status));
710
+ }
711
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion;
712
+ setUpdateState((s) => ({
713
+ ...s,
714
+ updating: false,
715
+ updateAvailable: false,
716
+ currentVersion: newVer,
717
+ notice: tt('update.done', { version: newVer }),
718
+ }));
719
+ } catch (e) {
720
+ setUpdateState((s) => ({
721
+ ...s,
722
+ updating: false,
723
+ error: tt('update.failed', { error: e && e.message || String(e) }),
724
+ }));
725
+ }
726
+ }
727
+
639
728
  React.useEffect(() => {
640
729
  if (!expanded) return;
641
730
  fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
@@ -646,7 +735,8 @@ window.__ModuleLoader__.load({
646
735
  }).catch(() => {});
647
736
  }, [expanded, draft.autoCollapse]);
648
737
 
649
- const tt = t || ((k) => (en[k] || k));
738
+ const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
739
+ const tt = React.useMemo(() => (t ? t : makeT(L, en)), [t, L]);
650
740
 
651
741
  let ChevronIcon = null;
652
742
  try {
@@ -655,9 +745,29 @@ window.__ModuleLoader__.load({
655
745
  } catch (e) { ChevronIcon = null; }
656
746
 
657
747
  const Chevron = ChevronIcon ? function ChevronNode(p) {
658
- return React.createElement(ChevronIcon, { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s', transform: p.open ? 'rotate(180deg)' : 'none' } });
748
+ return React.createElement(ChevronIcon, { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s ease', transform: p.open ? 'rotate(180deg)' : 'none' } });
659
749
  } : function Fallback(p) {
660
- return React.createElement('span', { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)' } }, '▼');
750
+ return React.createElement('svg', {
751
+ className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''),
752
+ viewBox: '0 0 14 14',
753
+ width: 14,
754
+ height: 14,
755
+ style: {
756
+ marginLeft: 'auto',
757
+ color: 'var(--dsw-alias-label-tertiary)',
758
+ transform: p.open ? 'rotate(180deg)' : 'none',
759
+ transition: 'transform .16s ease',
760
+ display: 'inline-block',
761
+ flexShrink: 0
762
+ }
763
+ }, React.createElement('path', {
764
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
765
+ fill: 'none',
766
+ stroke: 'currentColor',
767
+ strokeWidth: '1.5',
768
+ strokeLinecap: 'round',
769
+ strokeLinejoin: 'round'
770
+ }));
661
771
  };
662
772
 
663
773
  async function onSave() {
@@ -711,6 +821,39 @@ window.__ModuleLoader__.load({
711
821
  React.createElement(Chevron, { open: expanded })
712
822
  ),
713
823
  expanded ? React.createElement('div', { className: 'cl-body', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 12 } },
824
+ React.createElement('div', {
825
+ style: {
826
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
827
+ padding: '6px 10px', marginBottom: 10, borderRadius: 8,
828
+ background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', fontSize: 12
829
+ }
830
+ },
831
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
832
+ React.createElement('span', { style: { fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } },
833
+ 'v' + (updateState.currentVersion || '0.1.18')
834
+ ),
835
+ updateState.checking
836
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11 } }, tt('update.checking'))
837
+ : updateState.updateAvailable
838
+ ? React.createElement('span', { className: 'cl-badge cl-badge-warn' },
839
+ tt('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
840
+ )
841
+ : React.createElement('span', { className: 'cl-badge cl-badge-ok' },
842
+ '✓ ' + tt('update.up_to_date')
843
+ )
844
+ ),
845
+ updateState.updateAvailable
846
+ ? React.createElement('button', {
847
+ type: 'button',
848
+ className: 'cl-btn cl-btn-primary',
849
+ disabled: updateState.updating,
850
+ onClick: handleTriggerUpdate,
851
+ style: { padding: '3px 8px', fontSize: 11 }
852
+ }, updateState.updating ? tt('update.updating') : tt('update.btn'))
853
+ : null
854
+ ),
855
+ updateState.notice ? React.createElement('div', { className: 'cl-banner-ok', style: { marginBottom: 10, padding: '8px 12px', borderRadius: 8, background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent)', border: '1px solid var(--dsw-alias-state-success-primary, #10b981)', color: 'var(--dsw-alias-state-success-primary, #10b981)', fontSize: 12 } }, '✓ ' + updateState.notice) : null,
856
+ updateState.error ? React.createElement('div', { className: 'cl-banner-warn', style: { marginBottom: 10 } }, updateState.error) : null,
714
857
  budgetWarn ? React.createElement('div', { className: 'cl-banner-warn', style: { marginBottom: 12 } }, '⚠ ' + tt('lowBudget')) : null,
715
858
  status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
716
859
  status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
@@ -815,9 +958,28 @@ window.__ModuleLoader__.load({
815
958
  );
816
959
  }
817
960
 
818
- module.exports.inject = ['slots', 'locale'];
961
+ module.exports.inject = ['slots', 'locale', 'settingsScope'];
819
962
  module.exports.apply = function apply(ctx) {
820
- try { ctx.locale.register(NS, { en, zh }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
963
+ let hasEffect = false;
964
+ try {
965
+ hasEffect = typeof ctx.effect === 'function';
966
+ } catch (_) {
967
+ hasEffect = false;
968
+ }
969
+ if (hasEffect) {
970
+ ctx.effect(() => {
971
+ try {
972
+ const dispose = ctx.locale?.register ? ctx.locale.register(NS, { en, zh }) : undefined;
973
+ return () => {
974
+ if (typeof dispose === 'function') dispose();
975
+ };
976
+ } catch (e) {
977
+ console.warn('[dsh-context-lens] locale register failed', e && e.message || e);
978
+ }
979
+ }, 'dsh-context-lens: locale');
980
+ } else {
981
+ try { ctx.locale.register(NS, { en, zh }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
982
+ }
821
983
  if (!ctx.slots) return;
822
984
  let registered = false;
823
985
  const doRegister = () => {
@@ -893,14 +1055,14 @@ window.__ModuleLoader__.load({
893
1055
  try { registerPaneTab(); } catch (e2) {}
894
1056
  }
895
1057
  } else {
896
- try { registerPaneTab(); } catch (e) {}
1058
+ try { registerPaneTab(); } catch (err) { void err; }
897
1059
  }
898
1060
  }
899
1061
  } catch (e) {
900
1062
  console.warn("[dsh-context-lens] native sidebar registration failed", e && e.message || e);
901
1063
  }
902
1064
  });
903
- } catch (e) {}
1065
+ } catch (err) { void err; }
904
1066
  }
905
1067
 
906
1068
  if (typeof ctx.inject === 'function') {
@@ -925,7 +1087,7 @@ window.__ModuleLoader__.load({
925
1087
  console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
926
1088
  }
927
1089
  });
928
- } catch (e) {}
1090
+ } catch (err) { void err; }
929
1091
  }
930
1092
 
931
1093
  if (ctx.slots) {
@@ -949,7 +1111,7 @@ window.__ModuleLoader__.load({
949
1111
  try { headerChipRegister(); } catch (e2) {}
950
1112
  }
951
1113
  } else {
952
- try { headerChipRegister(); } catch (e) {}
1114
+ try { headerChipRegister(); } catch (err) { void err; }
953
1115
  }
954
1116
  }
955
1117
  };
package/lib/http.js ADDED
@@ -0,0 +1,114 @@
1
+ export function writeJson(res, code, body) {
2
+ try {
3
+ res.writeHead(code, {
4
+ 'Content-Type': 'application/json; charset=utf-8',
5
+ 'Cache-Control': 'no-store',
6
+ });
7
+ res.end(JSON.stringify(body));
8
+ } catch {
9
+ /* socket already closed */
10
+ }
11
+ }
12
+
13
+ export function readBody(req, maxBytes = 256 * 1024) {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = [];
16
+ let size = 0;
17
+ req.on('data', (c) => {
18
+ size += c.length;
19
+ if (size > maxBytes) {
20
+ req.destroy();
21
+ reject(new Error('body too large'));
22
+ return;
23
+ }
24
+ chunks.push(c);
25
+ });
26
+ req.on('end', () => resolve(Buffer.concat(chunks)));
27
+ req.on('error', reject);
28
+ });
29
+ }
30
+
31
+ export function header(request, name) {
32
+ const value = request?.headers?.[name.toLowerCase()];
33
+ return Array.isArray(value) ? value[0] : value;
34
+ }
35
+
36
+ export function isLoopbackAddress(value) {
37
+ const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '');
38
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
39
+ || address.startsWith('127.')
40
+ || address.startsWith('::ffff:127.');
41
+ }
42
+
43
+ /** Reject cross-site writes while allowing local, LAN, and reverse-proxy UIs. */
44
+ export function isTrustedRequest(request) {
45
+ const secFetchSite = header(request, 'sec-fetch-site');
46
+ if (secFetchSite === 'cross-site') {
47
+ return false;
48
+ }
49
+
50
+ const host = header(request, 'x-forwarded-host') || header(request, 'host');
51
+ const origin = header(request, 'origin');
52
+ if (origin) {
53
+ try {
54
+ const url = new URL(origin);
55
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
56
+ return true;
57
+ }
58
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
59
+ return true;
60
+ }
61
+ return false;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ const referer = header(request, 'referer');
68
+ if (referer) {
69
+ try {
70
+ const url = new URL(referer);
71
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
72
+ return true;
73
+ }
74
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
75
+ return true;
76
+ }
77
+ return false;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Requests without origin/referer (e.g. curl, loopback tests, or internal calls)
84
+ if (isLoopbackAddress(request?.socket?.remoteAddress)) {
85
+ return true;
86
+ }
87
+
88
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
89
+ return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ export const UPDATE_HEADER = 'x-dsh-plugin-update';
96
+ export const ALT_UPDATE_HEADER = 'x-dsh-context-lens-update';
97
+
98
+ export function isTrustedUpdateRequest(request) {
99
+ const updHdr = header(request, UPDATE_HEADER) || header(request, ALT_UPDATE_HEADER);
100
+ if (updHdr !== '1') return false;
101
+ if (!isLoopbackAddress(request?.socket?.remoteAddress)) return false;
102
+ const site = header(request, 'sec-fetch-site');
103
+ if (site !== undefined && site !== 'same-origin') return false;
104
+ const origin = header(request, 'origin');
105
+ const host = header(request, 'host');
106
+ if (origin === undefined || host === undefined) return false;
107
+ try {
108
+ const url = new URL(origin);
109
+ return (url.protocol === 'http:' || url.protocol === 'https:')
110
+ && isLoopbackAddress(url.hostname) && url.host === host;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
package/lib/index.js CHANGED
@@ -1,8 +1,15 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
- import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
3
- import { skeletonize } from './ast/skeletonizer.js';
4
2
  import * as tracker from './tokens/tracker.js';
5
- import { shouldAutoCompress } from './auto-compress.js';
3
+ import { compressLog } from './compression/log-compressor.js';
4
+ import { writeJson, readBody, isTrustedRequest } from './http.js';
5
+ import { registerPluginUpdater } from './updater.js';
6
+ import {
7
+ renderOutput,
8
+ registerTools,
9
+ getFocus,
10
+ clearFocus,
11
+ getLastActiveSession
12
+ } from './tools.js';
6
13
 
7
14
  export const name = '@goodandready/dsh-context-lens';
8
15
  export const inject = ['tools', 'settings', 'webServer'];
@@ -19,195 +26,52 @@ export const Config = z.object({
19
26
 
20
27
  const NS = '@goodandready/dsh-context-lens';
21
28
 
22
- // #37/#18: focus keyed by session id (not process-global singleton)
23
- const focusBySession = new Map();
24
- let lastActiveSession = '__default__';
25
-
26
- function sessionKey(params, meta) {
27
- const k = (
28
- (params && (params.sessionId || params.session_id)) ||
29
- (meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
30
- '__default__'
31
- );
32
- if (k !== '__default__') lastActiveSession = k;
33
- return k;
34
- }
35
-
36
- function getFocus(key) {
37
- if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
38
- return focusBySession.get(key);
39
- }
40
-
41
- function clearFocus(key) {
42
- if (key) {
43
- focusBySession.set(key, { paths: [], updatedAt: new Date().toISOString() });
44
- } else {
45
- focusBySession.clear();
46
- }
47
- }
48
-
49
- const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
50
- const renderOutput = (_args, result) => JSON.stringify(result, null, 2);
29
+ export { renderOutput };
51
30
 
52
31
  export function apply(ctx, config) {
53
32
  let getConfig = () => config;
54
33
 
55
34
  ctx.inject(['settings'], (sctx) => {
56
- const scope = sctx.settings.register(NS, Config, { base: config });
57
- getConfig = () => scope.get() ?? config;
35
+ if (typeof sctx.effect === 'function') {
36
+ sctx.effect(() => {
37
+ const scope = sctx.settings.register(NS, Config, { base: config });
38
+ getConfig = () => scope.get() ?? config;
39
+ return () => {
40
+ if (typeof scope?.dispose === 'function') scope.dispose();
41
+ getConfig = () => config;
42
+ };
43
+ }, 'dsh-context-lens: settings');
44
+ } else {
45
+ const scope = sctx.settings.register(NS, Config, { base: config });
46
+ getConfig = () => scope.get() ?? config;
47
+ }
58
48
  });
59
49
 
60
50
  function maybeTrack(original, compressed) {
61
- try {
62
- const cfg = getConfig();
63
- if (cfg && cfg.tokenSavingsTracking === false) return null;
64
- if (cfg && typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
65
- if (cfg && typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
66
- return tracker.record(original, compressed);
67
- } catch {
68
- // #39: never record on config/read errors
69
- return null;
70
- }
51
+ const cfg = getConfig();
52
+ if (cfg.tokenSavingsTracking === false) return;
53
+ tracker.record(original, compressed);
71
54
  }
72
55
 
56
+ // Register AI agent tools
73
57
  if (ctx.tools) {
74
- ctx.tools.register({
75
- name: 'context_lens_focus',
76
- description: 'Set focus files/folders for a session; other context will be auto-collapsed via skeletonizer',
77
- parameters: {
78
- type: 'object',
79
- properties: {
80
- paths: { type: 'array', items: { type: 'string' }, description: 'Focused file/folder paths' },
81
- maxDepth: { type: 'number', description: 'Max skeleton depth (overrides settings)' },
82
- sessionId: { type: 'string', description: 'Session id (defaults to host session when available)' }
83
- },
84
- required: ['paths']
85
- },
86
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
87
- execute: async (params, meta) => {
88
- const key = sessionKey(params, meta);
89
- const paths = Array.isArray(params.paths) ? params.paths : [];
90
- const focus = { paths, updatedAt: new Date().toISOString() };
91
- focusBySession.set(key, focus);
92
- return { success: true, sessionId: key, focus, hint: 'Use skeletonize helper via compress or read files with focus set' };
93
- }
94
- });
95
-
96
- ctx.tools.register({
97
- name: 'context_lens_compress_log',
98
- description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
99
- parameters: {
100
- type: 'object',
101
- properties: {
102
- text: { type: 'string', description: 'Raw log text to compress' },
103
- log: { type: 'string', description: 'Alias for text' },
104
- mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
105
- maxLines: { type: 'number', description: 'Max output lines' },
106
- auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
107
- }
108
- },
109
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
110
- execute: async (params) => {
111
- const cfg = getConfig();
112
- const inputText = params.text || params.log || '';
113
- const mode = params.mode || cfg.compressionMode || 'balanced';
114
- const maxLines = params.maxLines || 400;
115
- const threshold = cfg.autoCompressThreshold ?? 4000;
116
- const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
117
- // #33: never force balanced — keep configured/explicit mode
118
- const effectiveMode = mode;
119
- const res = compressLog(inputText, { mode: effectiveMode, maxLines });
120
- maybeTrack(inputText, res.compressed);
121
- return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
122
- }
123
- });
124
-
125
- ctx.tools.register({
126
- name: 'context_lens_compress_code',
127
- description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java/C/C++/SQL) to save context',
128
- parameters: {
129
- type: 'object',
130
- properties: {
131
- code: { type: 'string', description: 'Source code to skeletonize' },
132
- language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint' },
133
- maxDepth: { type: 'number', description: 'Max depth' },
134
- filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
135
- sessionId: { type: 'string', description: 'Session id for focus lookup' }
136
- },
137
- required: ['code']
138
- },
139
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
140
- execute: async (params, meta) => {
141
- const cfg = getConfig();
142
- const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
143
- const key = sessionKey(params, meta);
144
- const focusState = getFocus(key);
145
- const isFocused = params.filePath && focusState.paths.length > 0
146
- ? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
147
- : false;
148
- if (isFocused) {
149
- maybeTrack(params.code, params.code);
150
- 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' };
151
- }
152
- const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
153
- maybeTrack(params.code, skeleton);
154
- return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
155
- }
156
- });
157
-
158
- ctx.tools.register({
159
- name: 'context_lens_reset',
160
- description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
161
- parameters: {
162
- type: 'object',
163
- properties: {
164
- sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
165
- }
166
- },
167
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
168
- execute: async (params, meta) => {
169
- tracker.reset();
170
- const key = sessionKey(params || {}, meta);
171
- if (params && params.sessionId) {
172
- focusBySession.delete(key);
173
- }
174
- return { success: true, message: 'Context lens stats and history reset', sessionId: key };
175
- }
176
- });
177
-
178
- ctx.tools.register({
179
- name: 'context_lens_stats',
180
- description: 'Show session token savings stats for context-lens',
181
- parameters: {
182
- type: 'object',
183
- properties: {
184
- sessionId: { type: 'string', description: 'Session id for focus snapshot' }
185
- }
186
- },
187
- output: { schema: OUTPUT_SCHEMA, render: renderOutput },
188
- execute: async (params, meta) => {
189
- const cfg = getConfig();
190
- if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
191
- if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
192
- const stats = tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent);
193
- const history = tracker.getHistory();
194
- const key = sessionKey(params || {}, meta);
195
- return {
196
- success: true,
197
- ...stats,
198
- history,
199
- trackingEnabled: cfg.tokenSavingsTracking !== false,
200
- autoCollapse: cfg.autoCollapse !== false,
201
- focus: getFocus(key),
202
- sessionId: key,
203
- autoCompressThreshold: cfg.autoCompressThreshold,
204
- budgetAlertPercent: cfg.budgetAlertPercent ?? 90
205
- };
206
- }
207
- });
58
+ registerTools(ctx, { getConfig, maybeTrack });
208
59
  }
209
60
 
210
61
  if (ctx.webServer) {
62
+ // #61: One-click plugin updater endpoint (/api/dsh-context-lens/update)
63
+ const mountUpdater = () => registerPluginUpdater(ctx, {
64
+ packageName: '@goodandready/dsh-context-lens',
65
+ manifestUrl: new URL('../package.json', import.meta.url),
66
+ endpoint: '/api/dsh-context-lens/update'
67
+ });
68
+ if (typeof ctx.effect === 'function') {
69
+ ctx.effect(mountUpdater, 'dsh-context-lens: updater');
70
+ } else {
71
+ mountUpdater();
72
+ }
73
+
74
+ // Status route (#70: explicit URL parsing and validation)
211
75
  ctx.effect(() => ctx.webServer.register({
212
76
  kind: 'exact',
213
77
  path: '/dsh-context-lens/status',
@@ -215,60 +79,114 @@ export function apply(ctx, config) {
215
79
  const cfg = getConfig();
216
80
  if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
217
81
  if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
218
- let sId = '__default__';
82
+ let sId = getLastActiveSession() || '__default__';
219
83
  try {
220
84
  const u = new URL(req.url, 'http://127.0.0.1');
221
- sId = u.searchParams.get('sessionId') || u.searchParams.get('session_id') || lastActiveSession || '__default__';
222
- } catch {}
223
- res.setHeader('content-type', 'application/json');
224
- res.end(JSON.stringify({
85
+ const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
86
+ if (qId !== null) {
87
+ if (!qId || typeof qId !== 'string' || qId.trim() === '') {
88
+ writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
89
+ return;
90
+ }
91
+ sId = qId.trim();
92
+ }
93
+ } catch (err) {
94
+ ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in status', err);
95
+ writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
96
+ return;
97
+ }
98
+
99
+ writeJson(res, 200, {
225
100
  ok: true,
226
101
  plugin: 'dsh-context-lens',
227
102
  stats: tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent),
228
103
  history: tracker.getHistory(),
229
104
  focus: getFocus(sId),
230
105
  sessionId: sId
231
- }));
106
+ });
232
107
  }
233
108
  }), 'dsh-context-lens status route');
234
109
 
235
- // Quick clear-focus API route
110
+ // Quick clear-focus API route (#62: POST only, trusted origin check; #70: clean URL error handling)
236
111
  ctx.effect(() => ctx.webServer.register({
237
112
  kind: 'exact',
238
113
  path: '/dsh-context-lens/clear-focus',
239
114
  handler: async (req, res) => {
240
- let sId = lastActiveSession || '__default__';
115
+ if (req.method !== 'POST') {
116
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
117
+ res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
118
+ return;
119
+ }
120
+ if (!isTrustedRequest(req)) {
121
+ writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
122
+ return;
123
+ }
124
+
125
+ let sId = getLastActiveSession() || '__default__';
241
126
  try {
242
127
  const u = new URL(req.url, 'http://127.0.0.1');
243
128
  const qId = u.searchParams.get('sessionId') || u.searchParams.get('session_id');
244
- if (qId) sId = qId;
245
- } catch {}
129
+ if (qId !== null) {
130
+ if (!qId || typeof qId !== 'string' || qId.trim() === '') {
131
+ writeJson(res, 400, { ok: false, error: 'Invalid sessionId parameter' });
132
+ return;
133
+ }
134
+ sId = qId.trim();
135
+ }
136
+ } catch (err) {
137
+ ctx.logger?.warn?.('dsh-context-lens: failed to parse request url in clear-focus', err);
138
+ writeJson(res, 400, { ok: false, error: 'Malformed request URL' });
139
+ return;
140
+ }
141
+
246
142
  clearFocus(sId);
247
- res.setHeader('content-type', 'application/json');
248
- res.end(JSON.stringify({ ok: true, sessionId: sId, focus: getFocus(sId) }));
143
+ writeJson(res, 200, { ok: true, sessionId: sId, focus: getFocus(sId) });
249
144
  }
250
145
  }), 'dsh-context-lens clear-focus route');
251
146
 
252
- // #45: server-backed preview (same compressor as tools)
147
+ // Server-backed preview (#63: POST only, trusted origin, bounded body size, clamped maxLines)
253
148
  ctx.effect(() => ctx.webServer.register({
254
149
  kind: 'exact',
255
150
  path: '/dsh-context-lens/compress-preview',
256
151
  handler: async (req, res) => {
152
+ if (req.method !== 'POST') {
153
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json' });
154
+ res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: POST required' }));
155
+ return;
156
+ }
157
+ if (!isTrustedRequest(req)) {
158
+ writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
159
+ return;
160
+ }
161
+
162
+ let body;
257
163
  try {
258
- const chunks = [];
259
- for await (const c of req) chunks.push(c);
260
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
261
- const cfg = getConfig();
262
- const mode = body.mode || cfg.compressionMode || 'balanced';
263
- const text = body.text || '';
264
- const out = compressLog(text, { mode, maxLines: body.maxLines || 400 });
265
- res.setHeader('content-type', 'application/json');
266
- res.end(JSON.stringify({ ok: true, mode, ...out }));
164
+ const raw = await readBody(req, 256 * 1024);
165
+ body = JSON.parse(raw.toString('utf8') || '{}');
267
166
  } catch (e) {
268
- res.statusCode = 400;
269
- res.setHeader('content-type', 'application/json');
270
- res.end(JSON.stringify({ ok: false, error: e && e.message || String(e) }));
167
+ if (e?.message === 'body too large') {
168
+ writeJson(res, 413, { ok: false, error: 'Payload Too Large: maximum 256KB allowed' });
169
+ return;
170
+ }
171
+ writeJson(res, 400, { ok: false, error: 'Malformed JSON body' });
172
+ return;
173
+ }
174
+
175
+ let maxLines = 400;
176
+ if (body.maxLines !== undefined) {
177
+ const parsed = Number(body.maxLines);
178
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 5000) {
179
+ writeJson(res, 400, { ok: false, error: 'maxLines must be a number between 1 and 5000' });
180
+ return;
181
+ }
182
+ maxLines = Math.floor(parsed);
271
183
  }
184
+
185
+ const cfg = getConfig();
186
+ const mode = body.mode || cfg.compressionMode || 'balanced';
187
+ const text = typeof body.text === 'string' ? body.text : '';
188
+ const out = compressLog(text, { mode, maxLines });
189
+ writeJson(res, 200, { ok: true, mode, ...out });
272
190
  }
273
191
  }), 'dsh-context-lens compress-preview route');
274
192
  }
package/lib/tools.js ADDED
@@ -0,0 +1,179 @@
1
+ import { compressLog, estimateTokens as estLog } from './compression/log-compressor.js';
2
+ import { skeletonize } from './ast/skeletonizer.js';
3
+ import * as tracker from './tokens/tracker.js';
4
+ import { shouldAutoCompress } from './auto-compress.js';
5
+
6
+ export const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
7
+
8
+ // #71 (GH #2): output.render MUST return ContentBlock[] [{ type: 'text', text: ... }]
9
+ export const renderOutput = (_args, result) => [
10
+ { type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }
11
+ ];
12
+
13
+ const focusBySession = new Map();
14
+ let lastActiveSession = '__default__';
15
+
16
+ export function sessionKey(params, meta) {
17
+ const k = (
18
+ (params && (params.sessionId || params.session_id)) ||
19
+ (meta && (meta.sessionId || meta.session_id || (meta.session && meta.session.id))) ||
20
+ '__default__'
21
+ );
22
+ if (k !== '__default__') lastActiveSession = k;
23
+ return k;
24
+ }
25
+
26
+ export function getFocus(key) {
27
+ if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
28
+ return focusBySession.get(key);
29
+ }
30
+
31
+ export function clearFocus(key) {
32
+ if (key) {
33
+ focusBySession.set(key, { paths: [], updatedAt: new Date().toISOString() });
34
+ } else {
35
+ focusBySession.clear();
36
+ }
37
+ }
38
+
39
+ export function getLastActiveSession() {
40
+ return lastActiveSession;
41
+ }
42
+
43
+ export function registerTools(ctx, { getConfig, maybeTrack }) {
44
+ if (!ctx?.tools?.register) return;
45
+
46
+ ctx.tools.register({
47
+ name: 'context_lens_focus',
48
+ description: 'Set active file paths for current session focus (these files skip AST compression)',
49
+ parameters: {
50
+ type: 'object',
51
+ properties: {
52
+ paths: { type: 'array', items: { type: 'string' }, description: 'File paths or patterns currently in focus' },
53
+ sessionId: { type: 'string', description: 'Session id (uses active session if omitted)' }
54
+ },
55
+ required: ['paths']
56
+ },
57
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
58
+ execute: async (params, meta) => {
59
+ const key = sessionKey(params, meta);
60
+ const paths = Array.isArray(params.paths) ? params.paths : [];
61
+ const state = getFocus(key);
62
+ state.paths = paths;
63
+ state.updatedAt = new Date().toISOString();
64
+ return { success: true, sessionId: key, focus: state };
65
+ }
66
+ });
67
+
68
+ ctx.tools.register({
69
+ name: 'context_lens_compress_log',
70
+ description: 'Compress a large log/test output block, keeping failures and stacktraces (Jest/Pytest/Go/npm)',
71
+ parameters: {
72
+ type: 'object',
73
+ properties: {
74
+ text: { type: 'string', description: 'Raw log text to compress' },
75
+ log: { type: 'string', description: 'Alias for text' },
76
+ mode: { type: 'string', enum: ['raw', 'balanced', 'aggressive'], description: 'Compression aggressiveness' },
77
+ maxLines: { type: 'number', description: 'Max output lines' },
78
+ auto: { type: 'boolean', description: 'Auto-compress if large (uses threshold); keeps configured compressionMode' }
79
+ }
80
+ },
81
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
82
+ execute: async (params) => {
83
+ const cfg = getConfig();
84
+ const inputText = params.text || params.log || '';
85
+ const mode = params.mode || cfg.compressionMode || 'balanced';
86
+ const maxLines = params.maxLines || 400;
87
+ const threshold = cfg.autoCompressThreshold ?? 4000;
88
+ const useAuto = params.auto !== false && shouldAutoCompress(inputText, threshold);
89
+ const effectiveMode = mode;
90
+ const res = compressLog(inputText, { mode: effectiveMode, maxLines });
91
+ maybeTrack(inputText, res.compressed);
92
+ return { success: true, mode: effectiveMode, autoCompressed: useAuto, ...res };
93
+ }
94
+ });
95
+
96
+ ctx.tools.register({
97
+ name: 'context_lens_compress_code',
98
+ description: 'Generate AST skeleton for a large source file (JS/TS/Python/Go/Rust/Java/C/C++/SQL) to save context',
99
+ parameters: {
100
+ type: 'object',
101
+ properties: {
102
+ code: { type: 'string', description: 'Source code to skeletonize' },
103
+ language: { type: 'string', enum: ['js', 'ts', 'py', 'python', 'go', 'rust', 'java', 'c', 'cpp', 'sql'], description: 'Language hint' },
104
+ maxDepth: { type: 'number', description: 'Max depth' },
105
+ filePath: { type: 'string', description: 'File path to check focus (if in focus, returns full code)' },
106
+ sessionId: { type: 'string', description: 'Session id for focus lookup' }
107
+ },
108
+ required: ['code']
109
+ },
110
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
111
+ execute: async (params, meta) => {
112
+ const cfg = getConfig();
113
+ const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
114
+ const key = sessionKey(params, meta);
115
+ const focusState = getFocus(key);
116
+ const isFocused = params.filePath && focusState.paths.length > 0
117
+ ? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
118
+ : false;
119
+ if (isFocused) {
120
+ maybeTrack(params.code, params.code);
121
+ 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' };
122
+ }
123
+ const skeleton = skeletonize(params.code, { maxDepth, language: params.language });
124
+ maybeTrack(params.code, skeleton);
125
+ return { success: true, skeleton, originalTokens: estLog(params.code), skeletonTokens: estLog(skeleton), maxDepth, focused: false, sessionId: key };
126
+ }
127
+ });
128
+
129
+ ctx.tools.register({
130
+ name: 'context_lens_reset',
131
+ description: 'Reset context-lens savings stats and history (e.g. at the start of a new task)',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ sessionId: { type: 'string', description: 'Optional session id to also reset session focus' }
136
+ }
137
+ },
138
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
139
+ execute: async (params, meta) => {
140
+ tracker.reset();
141
+ const key = sessionKey(params || {}, meta);
142
+ if (params && params.sessionId) {
143
+ focusBySession.delete(key);
144
+ }
145
+ return { success: true, message: 'Context lens stats and history reset', sessionId: key };
146
+ }
147
+ });
148
+
149
+ ctx.tools.register({
150
+ name: 'context_lens_stats',
151
+ description: 'Show session token savings stats for context-lens',
152
+ parameters: {
153
+ type: 'object',
154
+ properties: {
155
+ sessionId: { type: 'string', description: 'Session id for focus snapshot' }
156
+ }
157
+ },
158
+ output: { schema: OUTPUT_SCHEMA, render: renderOutput },
159
+ execute: async (params, meta) => {
160
+ const cfg = getConfig();
161
+ if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
162
+ if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
163
+ const stats = tracker.getStats(cfg.budgetLimit, cfg.budgetAlertPercent);
164
+ const history = tracker.getHistory();
165
+ const key = sessionKey(params || {}, meta);
166
+ return {
167
+ success: true,
168
+ ...stats,
169
+ history,
170
+ trackingEnabled: cfg.tokenSavingsTracking !== false,
171
+ autoCollapse: cfg.autoCollapse !== false,
172
+ focus: getFocus(key),
173
+ sessionId: key,
174
+ autoCompressThreshold: cfg.autoCompressThreshold,
175
+ budgetAlertPercent: cfg.budgetAlertPercent ?? 90
176
+ };
177
+ }
178
+ });
179
+ }
package/lib/updater.js ADDED
@@ -0,0 +1,237 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { writeJson, isTrustedUpdateRequest } from './http.js';
8
+
9
+ const VERSION_CACHE_MS = 60_000;
10
+ const UPDATE_TIMEOUT_MS = 120_000;
11
+
12
+ let latestCache;
13
+
14
+ function validProfileName(value) {
15
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
16
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
17
+ }
18
+
19
+ function profileNameFromArgv(argv) {
20
+ if (!Array.isArray(argv)) return undefined;
21
+ for (let index = 2; index < argv.length; index += 1) {
22
+ if (argv[index] === '--profile') return argv[index + 1];
23
+ if (typeof argv[index] === 'string' && argv[index].startsWith('--profile=')) {
24
+ return argv[index].slice('--profile='.length);
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ function findDshCliEntry() {
31
+ const value = process.argv[1];
32
+ if (typeof value !== 'string' || value.length === 0) return undefined;
33
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
34
+ if (!existsSync(entry)) return undefined;
35
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
36
+ const manifestPath = resolve(directory, 'package.json');
37
+ if (existsSync(manifestPath)) {
38
+ try {
39
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
40
+ const bin = typeof manifest.bin === 'string'
41
+ ? manifest.bin
42
+ : typeof manifest.bin === 'object' && manifest.bin !== null
43
+ ? manifest.bin.dsh
44
+ : undefined;
45
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
46
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
47
+ } catch {
48
+ /* continue */
49
+ }
50
+ }
51
+ const parent = dirname(directory);
52
+ if (parent === directory) return undefined;
53
+ }
54
+ }
55
+
56
+ export function detectRuntime() {
57
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
58
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
59
+ const selected = profileNameFromArgv(process.argv);
60
+ const profileName = validProfileName(selected)
61
+ ? selected
62
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
63
+ const cliEntry = findDshCliEntry();
64
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
65
+ }
66
+
67
+ export function parseSemver(value) {
68
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ''));
69
+ if (match === null) return undefined;
70
+ return {
71
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
72
+ prerelease: match[4]?.split('.') ?? [],
73
+ };
74
+ }
75
+
76
+ function comparePrerelease(candidateParts, currentParts) {
77
+ if (candidateParts.length === 0 && currentParts.length > 0) return 1;
78
+ if (candidateParts.length > 0 && currentParts.length === 0) return -1;
79
+ if (candidateParts.length === 0 && currentParts.length === 0) return 0;
80
+
81
+ const len = Math.max(candidateParts.length, currentParts.length);
82
+ for (let i = 0; i < len; i += 1) {
83
+ const a = candidateParts[i];
84
+ const b = currentParts[i];
85
+ if (a === undefined) return -1;
86
+ if (b === undefined) return 1;
87
+ if (a === b) continue;
88
+
89
+ const aNum = /^\d+$/.test(a) ? Number(a) : undefined;
90
+ const bNum = /^\d+$/.test(b) ? Number(b) : undefined;
91
+
92
+ if (aNum !== undefined && bNum !== undefined) {
93
+ return aNum > bNum ? 1 : -1;
94
+ }
95
+ if (aNum !== undefined && bNum === undefined) {
96
+ return -1;
97
+ }
98
+ if (aNum === undefined && bNum !== undefined) {
99
+ return 1;
100
+ }
101
+ return a.localeCompare(b) > 0 ? 1 : -1;
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ export function isNewerVersion(currentValue, candidateValue) {
107
+ const current = parseSemver(currentValue);
108
+ const candidate = parseSemver(candidateValue);
109
+ if (current === undefined || candidate === undefined) return false;
110
+ for (let index = 0; index < 3; index += 1) {
111
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
112
+ }
113
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
114
+ }
115
+
116
+ async function fetchLatestVersion(packageName, registry) {
117
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
118
+ return latestCache.version;
119
+ }
120
+ try {
121
+ const reg = registry.replace(/\/+$/, '');
122
+ const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
123
+ signal: AbortSignal.timeout(6000),
124
+ });
125
+ if (!response.ok) return undefined;
126
+ const value = await response.json();
127
+ if (typeof value?.version !== 'string' || !value.version) return undefined;
128
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
129
+ return value.version;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ async function readCurrentVersion(manifestUrl) {
136
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
137
+ if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.');
138
+ return value.version;
139
+ }
140
+
141
+ export async function checkUpdateStatus(options, target = detectRuntime()) {
142
+ const current = await readCurrentVersion(options.manifestUrl);
143
+ const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org');
144
+ return {
145
+ packageName: options.packageName,
146
+ currentVersion: current,
147
+ ...(latest === undefined ? {} : { latestVersion: latest }),
148
+ latestCheckFailed: latest === undefined,
149
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
150
+ profileName: target.profileName,
151
+ canAutoUpdate: target.cliEntry !== undefined,
152
+ };
153
+ }
154
+
155
+ async function installExact(target, packageSpec, options) {
156
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
157
+ await new Promise((resolvePromise, reject) => {
158
+ // Note: per issue #61 / dsh-clinebot#24 / dsh-agent-loop-guard#38, do NOT disable pnpm minimum release age quarantine
159
+ const child = spawn(process.execPath, [
160
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
161
+ packageSpec,
162
+ `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
163
+ ], {
164
+ cwd: target.profileDir,
165
+ windowsHide: true,
166
+ stdio: ['ignore', 'pipe', 'pipe'],
167
+ env: { ...process.env, NO_COLOR: '1' },
168
+ });
169
+ let detail = '';
170
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000); });
171
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000); });
172
+ const timer = setTimeout(() => {
173
+ child.kill();
174
+ reject(new Error('Update timed out.'));
175
+ }, UPDATE_TIMEOUT_MS);
176
+ child.once('error', (error) => { clearTimeout(timer); reject(error); });
177
+ child.once('exit', (code) => {
178
+ clearTimeout(timer);
179
+ if (code === 0) resolvePromise();
180
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
181
+ });
182
+ });
183
+ }
184
+
185
+ export function registerPluginUpdater(ctx, options) {
186
+ if (!ctx?.webServer?.register) return () => {};
187
+ let installing = false;
188
+ return ctx.webServer.register({
189
+ kind: 'exact',
190
+ path: options.endpoint,
191
+ handler: async (request, response) => {
192
+ try {
193
+ const target = detectRuntime();
194
+ if (request.method === 'GET' || request.method === 'HEAD') {
195
+ const payload = await checkUpdateStatus(options, target);
196
+ writeJson(response, 200, payload);
197
+ return;
198
+ }
199
+ if (request.method !== 'POST') {
200
+ response.writeHead(405, { Allow: 'GET, HEAD, POST' });
201
+ response.end();
202
+ return;
203
+ }
204
+ if (!isTrustedUpdateRequest(request)) {
205
+ writeJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' });
206
+ return;
207
+ }
208
+ if (installing) {
209
+ writeJson(response, 409, { ok: false, error: 'This plugin is already updating.' });
210
+ return;
211
+ }
212
+ installing = true;
213
+ try {
214
+ const before = await checkUpdateStatus(options, target);
215
+ if (before.latestVersion === undefined) {
216
+ writeJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' });
217
+ return;
218
+ }
219
+ if (!before.updateAvailable) {
220
+ writeJson(response, 200, before);
221
+ return;
222
+ }
223
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
224
+ writeJson(response, 200, {
225
+ ...before,
226
+ updatedVersion: before.latestVersion,
227
+ restartRequired: true,
228
+ });
229
+ } finally {
230
+ installing = false;
231
+ }
232
+ } catch (error) {
233
+ writeJson(response, 500, { ok: false, error: String(error?.message || error) });
234
+ }
235
+ },
236
+ });
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-context-lens",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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",