@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 +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/client.js +179 -17
- package/lib/http.js +114 -0
- package/lib/index.js +121 -203
- package/lib/tools.js +179 -0
- package/lib/updater.js +237 -0
- package/package.json +1 -1
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: '
|
|
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('
|
|
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
|
|
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('
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
245
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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