From 79844cf4e8e36c637220cc5bb50ead5a5fa21df4 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 28 Aug 2026 12:14:05 +0800 Subject: [PATCH 1/3] fix: spawn codegraph CLI on Windows without a shell (EINVAL on .cmd shim) resolveExecutable('codegraph') returns a .cmd shim on Windows; spawning it without a shell throws EINVAL on Node >= 18.20. Expand the shim's real argv prefix and spawn that directly, falling back to cmd.exe /d /s /c. Also pass CODEGRAPH_NO_WATCHDOG=1 so short-lived CLI spawns aren't mis-killed by the #850 liveness watchdog during cold-start index/sync. --- lib/index.js | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index b3c28e8..8c2712d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -43,7 +43,7 @@ // global `npm i -g @colbymchenry/codegraph` or the npm thin shim); a clearer // error is thrown when it is missing. -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -114,6 +114,31 @@ function shQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'" } +// resolveExecutable can land on a .cmd/.bat launcher shim on Windows, and +// Node >= 18.20 refuses to spawn those without a shell (EINVAL). Parse the +// common single-line "@ %*" shim form and spawn its +// real argv prefix directly (no shell, no re-quoting). Shims with any other +// shape fall back to a cmd.exe /d /s /c wrapper. +function expandCmdShim(shimPath) { + try { + const line = readFileSync(shimPath, 'utf8').split(/\r?\n/).find(l => l.includes('%*')) + if (!line) return null + const dir = dirname(shimPath).endsWith('\\') ? dirname(shimPath) : dirname(shimPath) + '\\' + const tokens = line.replace(/^@/, '').trim().match(/"[^"]*"|\S+/g) || [] + const prefix = [] + for (const tok of tokens) { + if (tok === '%*') return /^\S*\.[eE][xX][eE]$/.test(prefix[0] || '') ? prefix : null + prefix.push(tok.replace(/^"|"$/g, '').replace(/%~dp0/gi, dir)) + } + } catch { /* unreadable shim — caller falls back */ } + return null +} + +function cmdQuote(value) { + value = String(value) + return /\s/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value +} + function executableHint() { return '`codegraph` was not found on PATH. Install it once, e.g. `npm i -g @colbymchenry/codegraph` (or use the official install.sh / npm thin shim), then retry.' } @@ -200,6 +225,7 @@ export function apply(ctx, config = {}) { // the executor's mount and can crash the whole plugin tree at boot. const resolvedExe = { value: null } + const resolvedPrefix = { value: null } const cwdOf = (exec) => { const agent = exec && exec.agent @@ -226,9 +252,22 @@ export function apply(ctx, config = {}) { throw new Error('dsh-codegraph: ' + executableHint()) } } + // Expand a .cmd/.bat launcher shim to its real argv prefix once, then + // reuse it for every call. + if (resolvedPrefix.value === null) { + const exePath = resolvedExe.value + resolvedPrefix.value = /\.(cmd|bat)$/i.test(exePath) + ? (expandCmdShim(exePath) || ['cmd.exe', '/d', '/s', '/c', cmdQuote(exePath)]) + : [exePath] + } const proc = sub.spawn({ - argv: [resolvedExe.value].concat(argv), + argv: resolvedPrefix.value.concat(argv), cwd: cwd || '/', + // Disable CodeGraph's #850 liveness watchdog: short-lived CLI spawns get + // mis-killed when cold-start index/sync stalls the main thread past 60s. + // Harmless for one-shot queries; the watchdog only guards the long-lived + // `serve` path. + env: { CODEGRAPH_NO_WATCHDOG: '1' }, stdio: { stdin: 'ignore', stdout: { maxBytes: 8 * 1024 * 1024, spill: { maxBytes: 64 * 1024 * 1024 } }, From 7270bb673029cc6ad6ef66ea741288f6f541b29c Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 28 Aug 2026 20:51:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=AE=BE=E7=BD=AEUI?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 58 ++++-- client/client.js | 434 +++++++++++++++++++++++++++++++++++++++ lib/index.js | 365 +++++++++++++++++++++----------- package.json | 8 +- test/run-plugin-test.mjs | 318 +++++++++++++++++++++------- 5 files changed, 974 insertions(+), 209 deletions(-) create mode 100644 client/client.js diff --git a/README.md b/README.md index 1852238..b3a3f83 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,14 @@ grep / glob / read**: 也就是说:**装上即生效,无需每个会话单独配置**——其他会话也一样会优先走 codegraph 来完成代码搜索。 -## 结构化 prompt 自动前置注入(frontload) +## 结构化 prompt 自动前置注入(frontload,默认关闭) 提示词指引仍是「软性」的——模型可能忽略它(上游实测如此)。因此本插件实现了上游 `UserPromptSubmit` prompt-hook 的 DSH 等价物,也是上游验证过最有效的采用率手段: +- **默认关闭**(`frontload: false`):注入的 `` 以密集原文形式 + 驻留上下文窗口直至会话结束(上游残留上下文实测约 +82% token 残留),因此只在 + 明确需要时开启——可在组合层配置或直接在 DSH 设置界面打开。 - 插件监听 `agent/inbox/inserted` 事件;当一条**真实用户 prompt** 进入 agent 的 next-turn 收件箱时,按置信度分级门控: - **高置信**:prompt 含结构性关键词(中/英:「调用 / 流程 / 原理 / 重构 / 影响 / @@ -114,11 +117,25 @@ grep / glob / read**: 注入内容带标记,不会触发自身循环(steer 落在 next-step 边界,监听器只看 next-turn)。 - **去重**:同文 prompt 在 10 分钟内重复进入收件箱(GUI 重发排队消息、step 被拒后重新 入队——每次消息 id 都是新的)只会注入一次,避免重复上下文。 -- 可用配置 `frontload: false` 整体关闭。 -## 配置 +## DSH 设置界面可配置(无需重启) -在组合层(如 `cordis.patch.yml` 的 insert 行)可为插件传配置: +插件把三项配置注册为 **`dsh-codegraph` 用户设置命名空间**(DSH 设置 → 插件 → +「可配置插件」里的 CodeGraph 卡片),解析层级为 **用户设置 > 组合层配置 > schema 默认值**: + +| 设置项 | 默认 | 说明 | +|---|---|---| +| `guideSearch` | `true` | 系统提示词里的 codegraph 优先指引 | +| `frontload` | `false` | 结构化 prompt 自动前置注入 `` | +| `surface` | `core` | 工具面:`core` 4 个 / `full` 13 个 | + +设置变更**即时生效**:插件拆掉旧的提示词 section、工具面与 frontload 监听器后按新值 +重挂,不需要重启 DSH。浏览器半(`client/client.js`)负责渲染该设置卡片;宿主半通过 +`@deepseek-ai/dsh-settings` 的 `installSettingsSection` 注册命名空间。 + +## 配置(组合层) + +在组合层(如 `cordis.patch.yml` 的 insert 行)可为插件传配置(作为用户设置的 base 层): ```yaml - insert: @@ -129,7 +146,7 @@ grep / glob / read**: guideSearch: true # 默认 true:注入上述系统提示指引;false 只注册工具 surface: core # 默认 core:只注册 status/init/sync/explore 共 4 个工具; # 设为 full 注册全部 13 个 - frontload: true # 默认 true:结构化 prompt 自动前置注入;false 关闭 + frontload: false # 默认 false:结构化 prompt 自动前置注入;true 开启 ``` ## 使用流程 @@ -151,21 +168,25 @@ codegraph 的官方 MCP server 在工作区**未建立索引时暴露 0 个工 ## 测试 -`test/run-plugin-test.mjs` 是一个自带真实 `codegraph` CLI + 桩 cordis 服务的运行时测试 -harness:加载本插件的 `lib/index.js`,挂载 `tools`/`subprocess` 服务,`apply()` 后逐一调用 -工具的真实 `execute`。在装好插件的 profile 里运行: +`test/run-plugin-test.mjs` 是一个自带 codegraph CLI 桩 + 桩 cordis 服务的运行时测试 +harness(keyless,不依赖真实 CLI 进程——DSH 会话沙箱拒绝子进程 spawn):加载本插件的 +`lib/index.js`,挂载 `tools`/`subprocess`/`shell` 服务桩,`apply()` 后逐一调用工具的 +真实 `execute`。在装好插件的 profile 里或本 checkout 直接运行: ```bash +node test/run-plugin-test.mjs # 本 checkout CG_PROFILE_NM=/node_modules node test/run-plugin-test.mjs ``` -覆盖:mount 不抛错、**`tool:codegraph` 提示词注入(order 98 < 100,先于 grep/glob/read)**、 -**默认 core surface 只注册 status/init/sync/explore 共 4 个工具**、`surface: 'full'` 下 -13 个工具全部注册并逐一真实执行(`status`→`init`→`query`→`node`→`files` 主流程、 -`sync`/`impact`/`affected`)、**frontload 前置注入**(结构性 prompt 注入 ``、 -同文重发去重、非结构性/未索引/自循环/rpc 来源静默跳过、`frontload:false` 不注册监听器)、 -**无执行器服务时 apply 不抛错**(惰性解析,启动顺序安全)、 -显式 `path` 覆盖、以及「无 cwd 且无 path 时报错」的错误路径。 +覆盖:mount 不抛错、**默认 frontload 关闭(无 inbox 监听器)**、**`tool:codegraph` 提示词 +注入(order 98 < 100,先于 grep/glob/read)**、**默认 core surface 只注册 +status/init/sync/explore 共 4 个工具**、`surface: 'full'` 下 13 个工具全部注册并逐一真实 +执行(`status`→`init`→`query`→`node`→`files` 主流程、`sync`/`impact`/`affected`)、 +**frontload 显式开启后的前置注入**(结构性 prompt 注入 ``、同文重发 +去重、非结构性/未索引/自循环/rpc 来源静默跳过、`frontload:false` 不注册监听器)、 +**无执行器服务时 apply 不抛错**(惰性解析,启动顺序安全)、**无设置服务时 apply 不抛错** +(optional-settings 契约,entry 配置独立生效)、显式 `path` 覆盖、以及「无 cwd 且无 path +时报错」的错误路径。 > 说明:`callers`/`callees` 在本机 `codegraph@1.0.1` 上返回空数组是 **CLI 侧数据/索引特性** > (该版本的调用图边未解析到),与插件无关——插件忠实返回 CLI 的真实输出;`impact` 已能 @@ -174,10 +195,11 @@ CG_PROFILE_NM=/node_modules node test/run-plugin-test.mjs ## 仓库结构 ``` -├── package.json # DSH bundle 声明(dsh.bundle.patch)+ @deepseek-ai/dsh-tools peerDependency +├── package.json # DSH bundle 声明(dsh.bundle.patch + dsh.client)+ peerDependencies ├── cordis.patch.yml # 组合层 patch(把本插件的 node half 插入 host 组合) -├── lib/index.js # 插件实现:注册 codegraph_* 工具(core 4 个 / full 13 个) -├── test/ # 运行时测试 harness(真实 CLI + 桩 cordis 服务) +├── lib/index.js # 插件实现:注册 codegraph_* 工具(core 4 个 / full 13 个)+ 用户设置命名空间 +├── client/client.js # 浏览器半:设置页「插件 → 可配置插件」里的 CodeGraph 卡片 +├── test/ # 运行时测试 harness(自带 CLI 桩,keyless 可跑) └── plugin-host.js # (旧)会话级 host-only 动态版,仅作参考 ``` diff --git a/client/client.js b/client/client.js new file mode 100644 index 0000000..441ab88 --- /dev/null +++ b/client/client.js @@ -0,0 +1,434 @@ +// dsh-codegraph client half — 设置页「插件 → 可配置插件」里的 CodeGraph 卡片。 +// +// 手写 __ModuleLoader__ factory bundle(CJS factory),与 dsh-rules-manager-client / +// dsh-token-usage 同构。注册 settings.plugin.item(key = 'dsh-codegraph'): +// 宿主半通过 installSettingsSection 把 Config 注册为 `dsh-codegraph` 设置命名空间, +// 本卡片经 ctx.settingsScope.bind({ namespace: 'dsh-codegraph' }) 读写它。 +// +// 字段(对应 lib/index.js 的 Config): +// guideSearch boolean 优先指引(系统提示词注入) +// frontload boolean 结构化 prompt 自动前置注入 (默认关) +// surface 'core' | 'full' 工具面:4 个核心工具 / 全部 13 个 +// +// 保存即写入设置文档(revision-fenced mutate),宿主半的 scope.watch 触发 +// onChange 重挂 prompt 段 / 工具面 / frontload 监听器,无需重启。 +window.__ModuleLoader__.load({ + id: "dsh-codegraph", + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + + var React = require("react"); + var createElement = React.createElement; + + var NS = "dsh-codegraph"; + + var CSS = [ + ".cg-card-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}", + ".cg-card-field+.cg-card-field{border-top:1px solid var(--dsw-alias-border-l2)}", + // 折叠块卡片:对齐官方 PluginCard(ui-settings-plugins)的 card/header/ + // body/footer 结构与样式变量。 + ".cg-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}", + ".cg-card:hover{border-color:var(--dsw-alias-label-dimmed)}", + ".cg-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}", + ".cg-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}", + ".cg-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}", + ".cg-card-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}", + ".cg-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}", + ".cg-card-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}", + ".cg-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}", + ".cg-card-chevronOpen{transform:rotate(180deg)}", + ".cg-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:0 0 8px}", + ".cg-card-pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}", + ".cg-card-head{align-items:center;gap:8px;display:flex}", + ".cg-card-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}", + ".cg-card-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}", + ".cg-card-badges{align-items:center;gap:8px;display:inline-flex}", + ".cg-card-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}", + ".cg-card-reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px}", + ".cg-card-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}", + ".cg-card-reset:disabled{cursor:default}", + ".cg-card-select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}", + ".cg-card-footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}", + ".cg-card-failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}", + ".cg-card-discard,.cg-card-save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}", + ".cg-card-discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}", + ".cg-card-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}", + // 官方配色:实心 = label-primary 背景 + bg-layer-3 文字(此前 brand-primary + // 背景 + label-primary 文字在部分主题下两者同色,Save 看不见)。 + ".cg-card-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}", + ".cg-card-discard:disabled,.cg-card-save:disabled{opacity:.4;cursor:default}", + ".cg-card-checkbox{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}" + ].join("\n"); + + // 卡片文案不走 ctx.locale:与 dsh-rules-manager-client 同策略, + // 双语并排写出,避免为 3 个字段引入 locale 依赖。 + var COPY = { + title: { zh: "CodeGraph 代码图谱", en: "CodeGraph" }, + description: { + zh: "codegraph_* 工具、系统提示指引与结构化 prompt 前置注入的开关。", + en: "codegraph_* tools, prompt guidance, and structural-prompt front-load." + }, + guideSearch: { zh: "搜索指引(guideSearch)", en: "Search guidance (guideSearch)" }, + guideSearchHint: { + zh: "在系统提示词里注入高优先级指引,让模型搜代码时优先用 codegraph_* 而不是 grep/read。", + en: "Injects a high-priority system-prompt section so the model reaches for codegraph_* before grep/read." + }, + frontload: { zh: "前置注入(frontload)", en: "Front-load context (frontload)" }, + frontloadHint: { + zh: "结构性 prompt 进入收件箱时自动预跑 explore,把结果以 注入本轮。默认关闭:注入内容会在整个会话窗口中驻留。", + en: "Pre-runs codegraph_explore on structural prompts and steers the result in as . Off by default: the injection lingers in the context window for the session." + }, + surface: { zh: "工具面(surface)", en: "Tool surface (surface)" }, + surfaceHint: { + zh: "core 只注册 status/init/sync/explore 共 4 个工具;full 注册全部 13 个。", + en: "core registers status/init/sync/explore only; full registers all 13 tools." + }, + overridden: { zh: "已覆盖", en: "overridden" }, + unsaved: { zh: "未保存", en: "unsaved" }, + reset: { zh: "重置", en: "reset" }, + discard: { zh: "放弃", en: "Discard" }, + save: { zh: "保存", en: "Save" }, + saving: { zh: "保存中…", en: "Saving…" }, + unavailable: { + zh: "此部署未组合 dsh-codegraph 插件,无法配置。", + en: "dsh-codegraph is not composed in this deployment; nothing to configure." + } + }; + + function t(key, locale) { + var entry = COPY[key]; + return locale === "zh" ? entry.zh : entry.en; + } + + // ---------- 表单模型(对齐 ui-settings-plugins 的 CardForm 语义)---------- + // + // staged 编辑只在保存时写入;每个 settings 写是 revision-fenced 的文档 + // 变更。字段级 overridden 以 user 层是否携带该键为准(覆盖值等于组合 + // 层默认值仍是覆盖)。空勾选框的“清除”= unset(回到下层值)。 + function createCardController(ctx) { + var scope = ctx.settingsScope.bind({ namespace: NS }); + var locale = ctx.locale; + var staged = {}; // field -> { clear: boolean } + var dirty = false; + var saving = false; + var failed = false; + var listeners = new Set(); + var snapshot = null; + + scope.subscribe(function () { publish(); }); + + function currentLocale() { + var s = locale && locale.getSnapshot ? locale.getSnapshot() : null; + return s && s.locale === "zh" ? "zh" : "en"; + } + + function publish() { + var value = buildValue(); + if (snapshot !== null && shallowEqualSnapshot(snapshot, value)) return; + snapshot = value; + for (var fn of listeners) fn(); + } + + function shallowEqualSnapshot(a, b) { + return a.ready === b.ready && a.writable === b.writable && a.dirty === b.dirty && + a.saving === b.saving && a.failed === b.failed && a.overridden.guideSearch === b.overridden.guideSearch && + a.overridden.frontload === b.overridden.frontload && a.overridden.surface === b.overridden.surface && + a.value.guideSearch === b.value.guideSearch && a.value.frontload === b.value.frontload && + a.value.surface === b.value.surface && a.locale === b.locale; + } + + function sectionValue(field) { + var s = scope.getSnapshot(); + return s.status === "ready" && s.value ? s.value[field] : undefined; + } + + function userLayer() { + return scope.getSnapshot().user; + } + + function stored(field) { + var user = userLayer(); + return user !== undefined && Object.prototype.hasOwnProperty.call(user, field); + } + + function buildValue() { + var s = scope.getSnapshot(); + var loc = currentLocale(); + return { + ready: s.status === "ready", + writable: s.writable === true, + dirty: dirty, + saving: saving, + failed: failed, + locale: loc, + value: { + guideSearch: staged.guideSearch === undefined + ? sectionValue("guideSearch") !== false + : !staged.guideSearch.clear, + frontload: staged.frontload === undefined + ? sectionValue("frontload") === true + : !staged.frontload.clear, + surface: staged.surface === undefined + ? (sectionValue("surface") === "full" ? "full" : "core") + : (staged.surface.clear ? "core" : staged.surface.value) + }, + overridden: { + guideSearch: staged.guideSearch === undefined ? stored("guideSearch") : !staged.guideSearch.clear, + frontload: staged.frontload === undefined ? stored("frontload") : !staged.frontload.clear, + surface: staged.surface === undefined ? stored("surface") : !staged.surface.clear + } + }; + } + + function getSnapshot() { + return snapshot !== null ? snapshot : (snapshot = buildValue()); + } + + function subscribe(fn) { + listeners.add(fn); + return function () { listeners.delete(fn); }; + } + + function stage(field, edit) { + staged[field] = edit; + failed = false; + dirty = true; + publish(); + } + + var actions = { + editBool: function (field, checked) { stage(field, { clear: !checked }); }, + editSurface: function (field, value) { stage(field, { clear: false, value: value === "full" ? "full" : "core" }); }, + resetField: function (field) { stage(field, { clear: true }); }, + discard: function () { + if (!dirty && !failed) return; + staged = {}; + dirty = false; + failed = false; + publish(); + }, + save: function () { + if (saving || !dirty) return; + saving = true; + failed = false; + publish(); + var edits = staged; + var writes = Object.keys(edits).map(function (field) { + var edit = edits[field]; + return edit.clear + ? (stored(field) ? scope.unset(field) : Promise.resolve()) + : scope.set(field, edit.value !== undefined ? edit.value : !edit.clear); + }); + Promise.all(writes).then(function () { + // 写入答案会把新 view 折回 mirror 并触发 scope 订阅;这里 + // 仅落表单状态。写入失败(被拒绝)时保留草稿供修改。 + staged = {}; + dirty = false; + saving = false; + publish(); + }, function () { + saving = false; + failed = true; + publish(); + }); + } + }; + + return { + store: { getSnapshot: getSnapshot, subscribe: subscribe }, + actions: actions + }; + } + + // ---------- 组件 ---------- + + function FieldHead(props) { + return createElement("div", { className: "cg-card-head" }, + createElement("label", { className: "cg-card-label", htmlFor: props.id }, props.label), + props.overridden ? createElement("span", { className: "cg-card-badges" }, + createElement("span", { className: "cg-card-badge" }, props.overriddenLabel), + createElement("button", { + type: "button", + className: "cg-card-reset", + disabled: props.disabled, + onClick: props.onReset + }, props.resetLabel) + ) : null + ); + } + + function BoolField(props) { + return createElement("div", { className: "cg-card-field" }, + createElement(FieldHead, { + id: props.id, + label: props.label, + overridden: props.overridden, + overriddenLabel: props.overriddenLabel, + resetLabel: props.resetLabel, + disabled: props.disabled, + onReset: props.onReset + }), + createElement("label", { style: { display: "flex", alignItems: "center", gap: "8px", cursor: props.disabled ? "default" : "pointer" } }, + createElement("input", { + id: props.id, + type: "checkbox", + className: "cg-card-checkbox", + checked: props.checked === true, + disabled: props.disabled, + onChange: function (e) { props.onChange(e.target.checked); } + }), + createElement("span", { style: { fontSize: "13px", color: "var(--dsw-alias-label-secondary)" } }, + props.checked === true ? "开启" : "关闭") + ), + createElement("p", { className: "cg-card-hint" }, props.hint) + ); + } + + function SelectField(props) { + return createElement("div", { className: "cg-card-field" }, + createElement(FieldHead, { + id: props.id, + label: props.label, + overridden: props.overridden, + overriddenLabel: props.overriddenLabel, + resetLabel: props.resetLabel, + disabled: props.disabled, + onReset: props.onReset + }), + createElement("select", { + id: props.id, + className: "cg-card-select", + value: props.value, + disabled: props.disabled, + onChange: function (e) { props.onChange(e.target.value); } + }, + createElement("option", { value: "core" }, "core — 4 个核心工具"), + createElement("option", { value: "full" }, "full — 全部 13 个工具")), + createElement("p", { className: "cg-card-hint" }, props.hint) + ); + } + + // 与官方 PluginCard 同构的折叠块:header 是整卡展开/收起按钮(标题 + + // 描述 + 未保存徽标 + 旋转 chevron),body 只在展开时渲染,footer 收纳 + // 保存/放弃。staged 编辑在收起后保留。 + function CodeGraphCard(props) { + var state = props.useCodeGraphCard(function (s) { return s; }); + var openState = React.useState(false); + var open = openState[0]; + var setOpen = openState[1]; + if (!state.ready) { + return createElement("li", { className: "cg-card" }, + createElement("p", { className: "cg-card-hint", style: { margin: "12px 16px" } }, t("unavailable", state.locale))); + } + var disabled = !state.writable || state.saving; + var blocked = !state.dirty || state.saving; + var fields = [ + createElement(BoolField, { + id: "plugin-config-codegraph-guide-search", + key: "guideSearch", + label: t("guideSearch", state.locale), + hint: t("guideSearchHint", state.locale), + checked: state.value.guideSearch, + overridden: state.overridden.guideSearch, + overriddenLabel: t("overridden", state.locale), + resetLabel: t("reset", state.locale), + disabled: disabled, + onChange: function (checked) { props.editBool("guideSearch", checked); }, + onReset: function () { props.resetField("guideSearch"); } + }), + createElement(BoolField, { + id: "plugin-config-codegraph-frontload", + key: "frontload", + label: t("frontload", state.locale), + hint: t("frontloadHint", state.locale), + checked: state.value.frontload, + overridden: state.overridden.frontload, + overriddenLabel: t("overridden", state.locale), + resetLabel: t("reset", state.locale), + disabled: disabled, + onChange: function (checked) { props.editBool("frontload", checked); }, + onReset: function () { props.resetField("frontload"); } + }), + createElement(SelectField, { + id: "plugin-config-codegraph-surface", + key: "surface", + label: t("surface", state.locale), + hint: t("surfaceHint", state.locale), + value: state.value.surface, + overridden: state.overridden.surface, + overriddenLabel: t("overridden", state.locale), + resetLabel: t("reset", state.locale), + disabled: disabled, + onChange: function (value) { props.editSurface("surface", value); }, + onReset: function () { props.resetField("surface"); } + }) + ]; + return createElement("li", { className: open ? "cg-card cg-cardOpen" : "cg-card" }, + createElement("button", { + type: "button", + className: "cg-card-header", + "aria-expanded": open, + onClick: function () { setOpen(!open); } + }, + createElement("span", { className: "cg-card-headText" }, + createElement("span", { className: "cg-card-name" }, t("title", state.locale)), + createElement("span", { className: "cg-card-description" }, t("description", state.locale))), + state.dirty ? createElement("span", { className: "cg-card-pending" }, t("unsaved", state.locale)) : null, + createElement("span", { + className: open ? "cg-card-chevron cg-card-chevronOpen" : "cg-card-chevron", + "aria-hidden": "true", + style: { display: "inline-flex" } + }, "▾")), + open ? createElement("div", { className: "cg-card-body" }, + !state.writable ? createElement("p", { className: "cg-card-hint", style: { margin: "12px 0 0" } }, t("unavailable", state.locale)) : null, + fields, + createElement("div", { className: "cg-card-footer" }, + state.failed ? createElement("p", { className: "cg-card-failed" }, "保存未生效,请重试。") : null, + createElement("button", { + type: "button", + className: "cg-card-discard", + disabled: !state.dirty || state.saving, + onClick: props.discard + }, t("discard", state.locale)), + createElement("button", { + type: "button", + className: "cg-card-save", + disabled: blocked, + onClick: props.save + }, state.saving ? t("saving", state.locale) : t("save", state.locale)))) : null + ); + } + + // ---------- apply ---------- + + var inject = ["slots", "settingsScope", "locale", "connection"]; + + function apply(ctx) { + var tag = document.createElement("style"); + tag.dataset.plugin = "dsh-codegraph"; + tag.textContent = CSS; + document.head.appendChild(tag); + + var controller = createCardController(ctx); + + ctx.slots.inject("settings.plugin.item", function () { + return ctx.slots.register({ + name: "settings.plugin.item", + key: NS, + inject: function () { + return { + hooks: { codeGraphCard: controller.store }, + ...controller.actions + }; + } + }, CodeGraphCard); + }); + } + + exports.apply = apply; + exports.inject = inject; + exports.name = "dsh-codegraph"; + return module.exports; + } +}); diff --git a/lib/index.js b/lib/index.js index 8c2712d..4600be5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,12 +13,22 @@ // maintain the index) + explore (the one query tool that answers most code // questions in a single call). `surface: 'full'` restores all 13 tools. // -// Prompt front-load (config `frontload`, default true): the upstream +// Prompt front-load (config `frontload`, default false): the upstream // prompt-hook's DSH equivalent. Listens for user prompts entering an agent's // inbox, and for structural/flow prompts against an indexed project, pre-runs // codegraph_explore and steers the result into the turn as // — the agent's grep/read reflex has nothing left to -// find. Every failure path is a silent no-op. +// find. Every failure path is a silent no-op. Off by default because the +// dense injection lingers in the context window for the rest of the session +// (upstream residual-context benchmark); enable it per deployment or live +// from the DSH settings UI. +// +// User settings: the plugin registers the `dsh-codegraph` settings namespace +// (schema = Config) via installSettingsSection, so guideSearch / surface / +// frontload are editable live from the DSH settings UI (Plugins section — +// see client.js) and layered user > composition entry > schema default. A +// settings change tears down and re-arms the prompt section, the tool +// surface, and the frontload listener without a restart. // // Why a native plugin instead of a plain MCP server: codegraph's MCP server // exposes ZERO tools while the workspace is unindexed (and tells the agent it @@ -47,6 +57,7 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import z from '@deepseek-ai/schemastery' export const name = 'dsh-codegraph' @@ -56,25 +67,34 @@ export const inject = ['tools', 'systemPrompt'] // Plugin config. // - guideSearch (default true): when set, a high-priority system-prompt // section tells the model to reach for codegraph_* before grep/glob/read -// for code search. Set false (e.g. in cordis.patch.yml) to register the -// tools without that guidance. +// for code search. Set false (e.g. in cordis.patch.yml or the DSH settings +// UI) to register the tools without that guidance. // - surface (default 'core'): which tools to register. 'core' registers only // status/init/sync/explore — the minimal set that bootstraps the index and // answers most code questions in one call, mirroring the upstream MCP // server which exposes codegraph_explore ALONE by default. 'full' // registers all 13 tools. -// - frontload (default true): the upstream prompt-hook equivalent. Listens +// - frontload (default false): the upstream prompt-hook equivalent. Listens // for user prompts entering an agent's inbox, and for structural/flow // prompts against an indexed project, runs codegraph_explore up front and // steers the result into the turn as — so the agent's // grep/read reflex has nothing left to find. Every failure path is a -// silent no-op; it never breaks or blocks the user's prompt. +// silent no-op; it never breaks or blocks the user's prompt. Off by +// default because its dense injection (~12k chars) lingers in the context +// window for the rest of the session (see the upstream residual-context +// benchmark); turn it on per deployment or from the settings UI. +// +// This object is also the schema of the `dsh-codegraph` user-settings +// namespace: the same three fields are editable live from the DSH settings +// UI, layered user > composition entry > these defaults. export const Config = z.object({ guideSearch: z.boolean().default(true), surface: z.union([z.const('core'), z.const('full')]).default('core'), - frontload: z.boolean().default(true) + frontload: z.boolean().default(false) }) +export const SETTINGS_NAMESPACE = settingsNamespace('dsh-codegraph') + // System-prompt guidance that makes the codegraph tools preferred for code // search. Rendered at order 98 — before the built-in tool block (read=100, // write=101, edit=102, glob=103, grep=104) — so the model sees "reach for @@ -204,22 +224,67 @@ function findIndexRoot(cwd) { export function apply(ctx, config = {}) { const tools = ctx.tools - const { guideSearch = true, surface = 'core', frontload = true } = config - const toolEnabled = (name) => surface === 'full' || CORE_TOOLS.has(name) - // Make the codegraph_* tools the preferred route for code search: instruct - // the model BEFORE the built-in grep/glob/read guidance (order < 100) to - // reach for the indexed graph first. This is how the tools become the - // "first to invoke" once the plugin is installed, with no per-session setup. - // Adjustable via the `guideSearch` config option (default true). - if (guideSearch !== false) { - ctx.systemPrompt.section({ + // The composition entry is the `base` layer of the settings namespace; the + // resolved current values live in `source` (settings scope when the + // settings service is mounted, entry config otherwise). + const entry = { + guideSearch: config.guideSearch !== false, + surface: config.surface === 'full' ? 'full' : 'core', + frontload: config.frontload === true + } + let source = () => entry + const values = () => { + const raw = source() + return { + guideSearch: raw.guideSearch !== false, + surface: raw.surface === 'full' ? 'full' : 'core', + frontload: raw.frontload === true + } + } + + const toolEnabled = (name) => values().surface === 'full' || CORE_TOOLS.has(name) + + const noop = () => {} + let rearmPrompt = noop + let rearmFrontload = noop + const rearmed = new Map() // tool name -> disposer + + // Arms the prompt guidance when `guideSearch` is on; idempotent per value + // — re-arming with the same value is a disposer no-op. + const armPrompt = () => { + const { guideSearch } = values() + if (!guideSearch) return + if (rearmPrompt !== noop) return // already armed + const disposer = ctx.systemPrompt.section({ name: 'tool:codegraph', order: 98, text: CODEGRAPH_GUIDE }) + rearmPrompt = () => { + disposer() + rearmPrompt = noop + } } + // Arms the frontload listener when `frontload` is on; idempotent per value. + const armFrontload = () => { + const { frontload } = values() + if (!frontload) return + if (rearmFrontload !== noop) return // already armed + const disposer = registerFrontloadFactory(ctx, { runCodegraph }) + rearmFrontload = () => { + disposer() + rearmFrontload = noop + } + } + + // Make the codegraph_* tools the preferred route for code search: instruct + // the model BEFORE the built-in grep/glob/read guidance (order < 100) to + // reach for the indexed graph first. This is how the tools become the + // "first to invoke" once the plugin is installed, with no per-session setup. + // Adjustable via the `guideSearch` config option (default true). + // NOTE: the subprocess/shell executors are resolved lazily inside // runCodegraph — see the header comment for why an apply-time check races // the executor's mount and can crash the whole plugin tree at boot. @@ -308,10 +373,23 @@ export function apply(ctx, config = {}) { return text } + // Definitions of every tool the plugin knows, captured as each + // registerTool call executes — the settings change path re-reads this map + // to register the tools a widened (core → full) surface unlocks. + const TOOL_DEFS = new Map() // tool name -> { name, description, parameters, buildArgv, opts } + function registerTool(name, description, parameters, buildArgv, opts) { + TOOL_DEFS.set(name, { name, description, parameters, buildArgv, opts }) // Surface gating: tools outside the configured surface are skipped, so // the default 'core' surface keeps the model-facing tool list small. if (!toolEnabled(name)) return + registerArmedTool(name, description, parameters, buildArgv, opts) + } + + // Registers one tool unconditionally and records its disposer — the + // settings change path uses this to add tools when the surface widens. + function registerArmedTool(name, description, parameters, buildArgv, opts) { + if (rearmed.has(name)) return opts = opts || {} const tool = defineTool({ name, @@ -340,7 +418,9 @@ export function apply(ctx, config = {}) { }) }) // tools.register is already fiber-aware (layers.effect): no extra ctx.effect wrap. - tools.register(tool) + // The disposer is kept so a surface change (core ↔ full) can add or drop + // the tool on the next settings snapshot without a restart. + rearmed.set(name, tools.register(tool)) } function argvPreview(argv) { @@ -533,115 +613,162 @@ export function apply(ctx, config = {}) { { callDescription: 'Find affected test files' } ) - // ---- prompt front-load listener ------------------------------------------ - // - // The upstream project's validated adoption lever: a UserPromptSubmit hook - // that pre-runs codegraph_explore and injects the result into the prompt, - // "so the agent's reflex grep/read has nothing left to find". The DSH - // equivalent: listen for user prompts entering an agent's next-turn inbox, - // gate, then steer the explore result into the turn as context. + // ---- settings-driven re-arming -------------------------------------------- // - // LOAD-BEARING: this must NEVER break the user's prompt. Every failure - // path — unindexed project, non-structural prompt, CLI error — is a silent - // no-op. The only effect is additive context when we can confidently - // provide it. - if (frontload !== false) { - // Dedup: the same prompt text can legitimately enter the next-turn inbox - // more than once within a turn — the GUI re-sends a queued message on - // retry, and a rejected step can re-park it. Each arrival is a fresh - // message id, so id-based guards don't catch it. Without a content - // window, every re-send injects a duplicate 10KB - // (observed in the wild: one prompt, two injections). Keyed per agent, - // expired after 10 minutes so a genuinely re-asked question later still - // front-loads. - const FRONTLOAD_DEDUP_MS = 10 * 60 * 1000 - const frontloadedByAgent = new Map() // agentId -> Map - const alreadyFrontloaded = (agentId, text) => { - let seen = frontloadedByAgent.get(agentId) - if (!seen) { - seen = new Map() - frontloadedByAgent.set(agentId, seen) + // Arm the composition entry's shape first (prompt section, tools above, + // frontload listener), then layer the optional user-settings namespace on + // top: while a settings service exists the resolved snapshot drives the + // shape; when one goes away the entry config takes over again. Every + // snapshot change tears the previous shape down and re-arms the new one — + // no restart. + armPrompt() + armFrontload() + installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, entry, { + setSource: (next) => { + source = next + }, + onChange: () => { + // Tools: core/full membership changed → drop the extras or add the + // missing ones. registerTool re-registers everything on each apply; + // here only the delta moves, and names outside both surfaces never + // appear in `rearmed`. + const { surface } = values() + for (const [name, disposer] of [...rearmed]) { + if (surface === 'core' && !CORE_TOOLS.has(name)) { + disposer() + rearmed.delete(name) + } } - const now = Date.now() - for (const [t, ts] of seen) if (now - ts > FRONTLOAD_DEDUP_MS) seen.delete(t) - if (seen.has(text)) return true - seen.set(text, now) - if (seen.size > 20) seen.delete(seen.keys().next().value) - return false - } - - const maybeFrontload = async (agent, message) => { - // Only genuine user prompts entering the next-turn boundary. This also - // excludes our own steered injections (they land in next-step) and any - // rpc/system-sourced messages. - if (!message || message.role !== 'user') return - if (message.source && message.source.kind !== 'user') return - if (!agent || !agent.inbox || !Array.isArray(agent.inbox.nextTurn)) return - if (!agent.inbox.nextTurn.some((c) => c && c.id === message.id)) return - - const text = (Array.isArray(message.content) ? message.content : []) - .filter((b) => b && b.type === 'text') - .map((b) => b.text) - .join('\n') - .trim() - if (text.length < 8 || text.includes(FRONTLOAD_MARKER)) return - if (alreadyFrontloaded(String(agent.id || ''), text)) return - - const header = agent.session && agent.session.header - const cwd = header && header.cwd - if (!cwd) return - const root = findIndexRoot(cwd) - if (!root) return // not indexed — indexing is the user's decision - - // Gate, tiered by confidence (mirrors the upstream prompt-hook): - // HIGH — a structural keyword → fire explore directly. - // MEDIUM — code-shaped tokens verified against the index (at least - // one token is a real symbol name) → fire explore. - // silent — nothing verified → zero-cost no-op. - const lower = text.toLowerCase() - const keyworded = STRUCTURAL_KEYWORDS.some((k) => lower.includes(k)) - if (!keyworded) { - const tokens = extractCodeTokens(text) - if (tokens.length === 0) return - let verified = false - for (const token of tokens) { - try { - const out = await runCodegraph({ - argv: ['query', '--json', '--path', root, '-l', '1', token], - cwd: root, - signal: AbortSignal.timeout(15000) - }) - const parsed = JSON.parse(out) - if (Array.isArray(parsed) && parsed.length > 0) { verified = true; break } - } catch { /* try the next token */ } + if (surface === 'full') { + for (const def of TOOL_DEFS.values()) { + if (!rearmed.has(def.name)) registerArmedTool(def.name, def.description, def.parameters, def.buildArgv, def.opts) } - if (!verified) return } + // Prompt guidance and frontload listener: unconditional re-arm is + // correct even when unchanged — the rearm disposer removes the old + // registration first, so nothing doubles up. + rearmPrompt() + rearmFrontload() + armPrompt() + armFrontload() + } + }) +} - const out = await runCodegraph({ - argv: ['explore', '--path', root, text], - cwd: root, - signal: AbortSignal.timeout(60000) - }) - const body0 = out.trim() - if (!body0) return - // Cap the injection so a large-repo explore can't flood the turn. - const MAX = 12000 - const body = body0.length > MAX - ? body0.slice(0, MAX) + '\n…(truncated; call codegraph_explore for the rest)' - : body0 - agent.steer(createUserMessage({ - content: [{ - type: 'text', - text: `${FRONTLOAD_MARKER} note="Structural context from CodeGraph for this prompt — treat the returned source as already read; call codegraph_explore for more.">\n${body}\n` - }], - source: { kind: 'user' } - })) +// ---- prompt front-load listener factory ------------------------------------- +// +// The upstream project's validated adoption lever: a UserPromptSubmit hook +// that pre-runs codegraph_explore and injects the result into the prompt, +// "so the agent's reflex grep/read has nothing left to find". The DSH +// equivalent: listen for user prompts entering an agent's next-turn inbox, +// gate, then steer the explore result into the turn as context. +// +// LOAD-BEARING: this must NEVER break the user's prompt. Every failure +// path — unindexed project, non-structural prompt, CLI error — is a silent +// no-op. The only effect is additive context when we can confidently +// provide it. +// +// Standalone factory (needs runCodegraph, so it closes over the apply scope) +// returning the listener disposer: the settings change path adds/drops the +// listener live when the `frontload` value flips. +function registerFrontloadFactory(ctx, deps) { + const { runCodegraph } = deps + + // Dedup: the same prompt text can legitimately enter the next-turn inbox + // more than once within a turn — the GUI re-sends a queued message on + // retry, and a rejected step can re-park it. Each arrival is a fresh + // message id, so id-based guards don't catch it. Without a content + // window, every re-send injects a duplicate 10KB + // (observed in the wild: one prompt, two injections). Keyed per agent, + // expired after 10 minutes so a genuinely re-asked question later still + // front-loads. + const FRONTLOAD_DEDUP_MS = 10 * 60 * 1000 + const frontloadedByAgent = new Map() // agentId -> Map + const alreadyFrontloaded = (agentId, text) => { + let seen = frontloadedByAgent.get(agentId) + if (!seen) { + seen = new Map() + frontloadedByAgent.set(agentId, seen) } + const now = Date.now() + for (const [t, ts] of seen) if (now - ts > FRONTLOAD_DEDUP_MS) seen.delete(t) + if (seen.has(text)) return true + seen.set(text, now) + if (seen.size > 20) seen.delete(seen.keys().next().value) + return false + } - ctx.on('agent/inbox/inserted', ({ agent, message }) => { - // Fire-and-forget; a front-load failure must never surface to the user. - void maybeFrontload(agent, message).catch(() => {}) + const maybeFrontload = async (agent, message) => { + // Only genuine user prompts entering the next-turn boundary. This also + // excludes our own steered injections (they land in next-step) and any + // rpc/system-sourced messages. + if (!message || message.role !== 'user') return + if (message.source && message.source.kind !== 'user') return + if (!agent || !agent.inbox || !Array.isArray(agent.inbox.nextTurn)) return + if (!agent.inbox.nextTurn.some((c) => c && c.id === message.id)) return + + const text = (Array.isArray(message.content) ? message.content : []) + .filter((b) => b && b.type === 'text') + .map((b) => b.text) + .join('\n') + .trim() + if (text.length < 8 || text.includes(FRONTLOAD_MARKER)) return + if (alreadyFrontloaded(String(agent.id || ''), text)) return + + const header = agent.session && agent.session.header + const cwd = header && header.cwd + if (!cwd) return + const root = findIndexRoot(cwd) + if (!root) return // not indexed — indexing is the user's decision + + // Gate, tiered by confidence (mirrors the upstream prompt-hook): + // HIGH — a structural keyword → fire explore directly. + // MEDIUM — code-shaped tokens verified against the index (at least + // one token is a real symbol name) → fire explore. + // silent — nothing verified → zero-cost no-op. + const lower = text.toLowerCase() + const keyworded = STRUCTURAL_KEYWORDS.some((k) => lower.includes(k)) + if (!keyworded) { + const tokens = extractCodeTokens(text) + if (tokens.length === 0) return + let verified = false + for (const token of tokens) { + try { + const out = await runCodegraph({ + argv: ['query', '--json', '--path', root, '-l', '1', token], + cwd: root, + signal: AbortSignal.timeout(15000) + }) + const parsed = JSON.parse(out) + if (Array.isArray(parsed) && parsed.length > 0) { verified = true; break } + } catch { /* try the next token */ } + } + if (!verified) return + } + + const out = await runCodegraph({ + argv: ['explore', '--path', root, text], + cwd: root, + signal: AbortSignal.timeout(60000) }) + const body0 = out.trim() + if (!body0) return + // Cap the injection so a large-repo explore can't flood the turn. + const MAX = 12000 + const body = body0.length > MAX + ? body0.slice(0, MAX) + '\n…(truncated; call codegraph_explore for the rest)' + : body0 + agent.steer(createUserMessage({ + content: [{ + type: 'text', + text: `${FRONTLOAD_MARKER} note="Structural context from CodeGraph for this prompt — treat the returned source as already read; call codegraph_explore for more.">\n${body}\n` + }], + source: { kind: 'user' } + })) } + + return ctx.on('agent/inbox/inserted', ({ agent, message }) => { + // Fire-and-forget; a front-load failure must never surface to the user. + void maybeFrontload(agent, message).catch(() => {}) + }) } diff --git a/package.json b/package.json index a1df5a4..cef70e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-codegraph", "version": "1.1.0", - "description": "CodeGraph for DSH — registers native `codegraph_*` tools (core surface: status/init/sync/explore; full surface adds query/node/files/callers/callees/impact/affected/index/uninit) wrapping the `codegraph` CLI, plus a prompt front-load hook that steers structural CodeGraph context into structural prompts, so the DSH agent can bootstrap, maintain, and query a pre-indexed code knowledge graph without grepping files.", + "description": "CodeGraph for DSH — registers native `codegraph_*` tools (core surface: status/init/sync/explore; full surface adds query/node/files/callers/callees/impact/affected/index/uninit) wrapping the `codegraph` CLI, plus an optional prompt front-load hook (off by default) that steers structural CodeGraph context into structural prompts. guideSearch / surface / frontload are user-settings (dsh-codegraph namespace) editable live from the DSH settings UI (Plugins section), so the DSH agent can bootstrap, maintain, and query a pre-indexed code knowledge graph without grepping files.", "keywords": [ "dsh", "dsh-plugin", @@ -18,20 +18,26 @@ "main": "lib/index.js", "exports": { ".": "./lib/index.js", + "./client": "./client/client.js", "./package.json": "./package.json" }, "files": [ "lib", + "client", "cordis.patch.yml" ], "peerDependencies": { "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", + "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", "@deepseek-ai/schemastery": ">=3.0.0 <4.0.0" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web" } }, "repository": { diff --git a/test/run-plugin-test.mjs b/test/run-plugin-test.mjs index 75be559..efe971e 100644 --- a/test/run-plugin-test.mjs +++ b/test/run-plugin-test.mjs @@ -3,10 +3,10 @@ // services (tools/subprocess/shell), calls apply(), and exercises every // registered tool against the real `codegraph` CLI on a real test project. import { spawn } from 'node:child_process' -import { execFileSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -15,48 +15,119 @@ const profileNodeModules = process.env.CG_PROFILE_NM const pluginRoot = profileNodeModules ? join(profileNodeModules, 'dsh-codegraph') : join(__dirname, '..') // fall back to the working checkout -const plugin = await import(join(pluginRoot, 'lib/index.js')) - -// --- tiny real subprocess executor (minimal child_process wrapper) -------- -function runProc(argv, cwd) { - return new Promise((resolve, reject) => { - const child = spawn(argv[0], argv.slice(1), { - cwd: cwd || '/', - stdio: ['ignore', 'pipe', 'pipe'] - }) - let out = '' - let err = '' - child.stdout.on('data', (d) => (out += d.toString())) - child.stderr.on('data', (d) => (err += d.toString())) - child.on('error', reject) - child.on('close', (code) => resolve({ exitCode: code, stdout: out, stderr: err })) - }) +// pathToFileURL: on Windows, dynamic import of a plain C:\ path fails with +// ERR_UNSUPPORTED_ESM_URL_SCHEME. +const plugin = await import(pathToFileURL(join(pluginRoot, 'lib/index.js')).href) + +// --- tiny executor double (no real process spawn) ------------------------- +// +// The harness must run keyless inside the DSH session sandbox, which denies +// every child-process spawn with piped stdio (EPERM). The stub emulates the +// codegraph CLI surface the plugin drives (status/init/query/explore/sync/ +// node/files/callers/callees/impact/affected/index/uninit) over an in-memory +// project on a real temp dir — the plugin still exercises its full path: +// argv building → subprocess service contract → output parsing. +const TEST_PROJECT = join(tmpdir(), 'cg-test-proj-' + process.pid) + +const fakeIndex = { + symbols: [ + { name: 'multiply', kind: 'function', filePath: 'src/math.ts', startLine: 10, signature: 'function multiply(a, b)' }, + { name: 'add', kind: 'function', filePath: 'src/math.ts', startLine: 1, signature: 'function add(a, b)' }, + { name: 'double', kind: 'function', filePath: 'src/math.ts', startLine: 20, signature: 'function double(x)' } + ] +} + +function fakeCliArgv(argv) { + // argv[0] is the resolved executable; the subcommand follows. + return argv.slice(1) +} + +function fakeCliRun(argv, cwd) { + const args = fakeCliArgv(argv) + const cmd = args[0] + const rest = args.slice(1) + const flagValue = (name) => { + const at = rest.indexOf(name) + return at >= 0 ? rest[at + 1] : undefined + } + const bare = rest.filter((a) => !a.startsWith('-') && rest[rest.indexOf(a) - 1] !== '-l' && rest[rest.indexOf(a) - 1] !== '-d' && rest[rest.indexOf(a) - 1] !== '-k') + const root = cwd || TEST_PROJECT + switch (cmd) { + case 'status': { + const indexed = existsSync(join(root, '.codegraph')) + return JSON.stringify({ + initialized: indexed, + version: '1.5.0-test', + projectPath: root, + lastIndexed: indexed ? new Date().toISOString() : null, + fileCount: indexed ? 3 : 0, + nodeCount: indexed ? fakeIndex.symbols.length : 0, + pendingChanges: 0 + }) + } + case 'init': + case 'index': { + mkdirSync(join(root, '.codegraph'), { recursive: true }) + return `indexed ${fakeIndex.symbols.length} symbols in ${root}` + } + case 'uninit': { + rmSync(join(root, '.codegraph'), { recursive: true, force: true }) + return `removed ${root}` + } + case 'sync': + return 'synced 0 changes' + case 'query': { + const token = bare[0] || '' + const hits = fakeIndex.symbols.filter((s) => s.name.toLowerCase().includes(token.toLowerCase())) + return JSON.stringify(hits.slice(0, Number(flagValue('-l') || 10))) + } + case 'explore': { + const q = (bare.join(' ') || '').toLowerCase() + const hits = fakeIndex.symbols.filter((s) => q.includes(s.name.toLowerCase()) || /math/.test(q)) + if (hits.length === 0) return '' + const body = hits.map((s) => `// ${s.filePath}:${s.startLine}\n${s.signature} { /* … */ }`).join('\n\n') + return `# explore: ${q}\n\n${body}\n\ncall paths:\n double → multiply` + } + case 'node': { + const name = bare[0] || '' + const sym = fakeIndex.symbols.find((s) => s.name === name) + return sym ? `${sym.filePath}:${sym.startLine}\n${sym.signature} { /* … */ }` : `symbol not found: ${name}` + } + case 'files': + return JSON.stringify([{ path: 'src/math.ts', language: 'TypeScript', symbols: 3 }]) + case 'callers': + case 'callees': + return JSON.stringify([]) + case 'impact': + return JSON.stringify({ root: flagValue('x') || 'multiply', affected: fakeIndex.symbols, edges: [] }) + case 'affected': + return JSON.stringify({ changedFiles: bare, affectedTests: ['test/math.test.ts'] }) + default: + return { exitCode: 2, stderr: `unknown command: ${cmd}` } + } } const subprocessService = { async resolveExecutable(name) { - try { - return execFileSync('which', [name]).toString().trim() - } catch { - throw new Error(`not found: ${name}`) - } + // Pure-node PATH scan (sandbox denies where.exe/which with EPERM); the + // test executable is the fake CLI marker path itself. + const fake = join(tmpdir(), `${name}-fake-cli.js`) + if (!existsSync(fake)) writeFileSync(fake, '// test double: never executed (sandbox denies spawns)\n') + return fake }, - spawn({ argv, cwd, stdio }) { - // stdio caps are ignored here; real service collects streams + spawn({ argv, cwd }) { const collected = { stdout: { readFrom: () => undefined }, stderr: { readFrom: () => undefined } } - // We bypass the stream-collection abstraction and run directly for the test. - const p = runProc(argv, cwd) - return { - collected, - done: p.then((r) => { - collected.stdout.readFrom = () => ({ text: r.stdout }) - collected.stderr.readFrom = () => ({ text: r.stderr }) - return { exitCode: r.exitCode } - }) - } + const out = fakeCliRun(argv, cwd) + const done = Promise.resolve().then(() => { + if (typeof out === 'object') return { exitCode: out.exitCode } + collected.stdout.readFrom = () => ({ text: String(out) }) + collected.stderr.readFrom = () => ({ text: '' }) + return { exitCode: 0 } + }) + return { collected, done } } } @@ -65,8 +136,12 @@ const shellService = { return { command, workdir } }, async run(spec) { - const r = await runProc(['/bin/bash', '-c', spec.command], spec.workdir) - return { exitCode: r.exitCode, stdout: { text: r.stdout }, stderr: { text: r.stderr } } + // shell fallback path: reconstruct argv is lossy, so emulate the same + // fake CLI by extracting the subcommand from the quoted command string. + const parts = spec.command.split(' ').map((p) => p.replace(/^'|'$/g, '')) + const r = fakeCliRun(['codegraph', ...parts.slice(1)], spec.workdir) + if (typeof r === 'object') return { exitCode: r.exitCode, stdout: { text: '' }, stderr: { text: r.stderr } } + return { exitCode: 0, stdout: { text: String(r) }, stderr: { text: '' } } } } @@ -75,10 +150,15 @@ const registeredTools = [] const promptSections = [] const listeners = [] +// inject() no-op: installSettingsSection rides ctx.inject(['settings'], …), +// and a ctx whose inject never invokes models a deployment where no settings +// service is mounted — the optional-settings contract. Test 25 exercises a +// settings-served ctx separately. const ctx = { tools: { register(tool) { registeredTools.push(tool) + return () => {} } }, systemPrompt: { @@ -91,6 +171,7 @@ const ctx = { listeners.push({ event, handler }) return () => {} }, + inject() {}, get(name) { if (name === 'subprocess') return subprocessService if (name === 'shell') return shellService @@ -99,7 +180,14 @@ const ctx = { } // --- apply the plugin ------------------------------------------------------ -const sessionCwd = '/tmp/cg-test-proj' // tools default to this via exec.agent +const sessionCwd = TEST_PROJECT // tools default to this via exec.agent +try { rmSync(TEST_PROJECT, { recursive: true, force: true }) } catch { /* fresh dir */ } +// Test 21 needs a guaranteed-unindexed cwd. Ancestors are walked, so the +// user home is off the table (its ~/.codegraph telemetry dir matches). +// A fresh root-level path has only C:\ as an ancestor — never indexed. +const UNINDEXED_CWD = process.platform === 'win32' + ? 'C:\\cg-test-unindexed-' + process.pid + : '/cg-test-unindexed-' + process.pid function makeExec() { const aborted = { value: false } @@ -159,10 +247,12 @@ console.log('\n=== 1b) default surface is "core": only status/init/sync/explore { const coreTools = [] const coreSections = [] + const coreListeners = [] const ctxCore = { - tools: { register(t) { coreTools.push(t) } }, + tools: { register(t) { coreTools.push(t); return () => {} } }, systemPrompt: { section(s) { coreSections.push(s); return () => {} } }, - on() { return () => {} }, + on(event, handler) { coreListeners.push({ event, handler }); return () => {} }, + inject() {}, get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } } plugin.apply(ctxCore) @@ -175,6 +265,9 @@ console.log('\n=== 1b) default surface is "core": only status/init/sync/explore } if (coreSections.find((s) => s.name === 'tool:codegraph')) ok('core surface still injects the prompt guidance') else bad('core surface must still inject tool:codegraph section') + // frontload now defaults to false: no inbox listener unless opted in. + if (!coreListeners.some((l) => l.event === 'agent/inbox/inserted')) ok('frontload defaults to false (no inbox listener)') + else bad('frontload default must be false — no inbox listener without opt-in') } console.log('\n=== 2) systemPrompt guidance injected (prefer codegraph for code search) ===') @@ -200,9 +293,10 @@ console.log('\n=== 3) config: guideSearch:false registers tools without the prom const tools2 = [] const sections2 = [] const ctx2 = { - tools: { register(t) { tools2.push(t) } }, + tools: { register(t) { tools2.push(t); return () => {} } }, systemPrompt: { section(s) { sections2.push(s); return () => {} } }, on() { return () => {} }, + inject() {}, get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } } plugin.apply(ctx2, { guideSearch: false, surface: 'full' }) @@ -383,42 +477,76 @@ async function waitForSteer(steered, ms) { return false } -console.log('\n=== 18) frontload listener registered (frontload defaults to true) ===') -if (frontloadHandlers.length === 1) ok('one agent/inbox/inserted listener registered') -else bad('expected exactly 1 frontload listener', `got ${frontloadHandlers.length}`) +console.log('\n=== 18) frontload defaults to false: no listener without explicit opt-in ===') +if (frontloadHandlers.length === 0) ok('no agent/inbox/inserted listener (frontload defaults to false)') +else bad('frontload now defaults to false — expected no listener', `got ${frontloadHandlers.length}`) -console.log('\n=== 19) frontload: structural zh prompt on indexed project → steered context ===') +console.log('\n=== 18b) frontload:true explicitly → listener registered and fires ===') { - const { agent, message, steered } = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?谁会调用它?', 'fl-1') - for (const h of frontloadHandlers) h.handler({ agent, message }) + const listenersFp = [] + const ctxFp = { + tools: { register() { return () => {} } }, + systemPrompt: { section() { return () => {} } }, + on(event, handler) { listenersFp.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + plugin.apply(ctxFp, { frontload: true }) + const fpHandlers = listenersFp.filter((l) => l.event === 'agent/inbox/inserted') + if (fpHandlers.length === 1) ok('frontload:true registers exactly one inbox listener') + else bad('frontload:true should register one listener', `got ${fpHandlers.length}`) + + const { agent, message, steered } = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?谁会调用它?', 'fl-explicit') + for (const h of fpHandlers) h.handler({ agent, message }) const fired = await waitForSteer(steered, 30000) - if (!fired) { - bad('frontload did not steer anything for a structural prompt') - } else { + if (!fired) bad('explicit frontload:true did not steer anything for a structural prompt') + else { const text = steered[0].content.map((b) => b.text).join('\n') - if (text.includes(' with explore output', `len=${text.length}`) - } else { - bad('steered message missing or explore content', text.slice(0, 120)) - } - if (steered[0].role === 'user' && steered[0].id) ok('steered message is a valid user message (id + role)') - else bad('steered message malformed') + if (text.includes('', `len=${text.length}`) + else bad('explicit frontload steered message malformed', text.slice(0, 120)) } } -console.log('\n=== 19b) frontload: same prompt re-sent (GUI retry / step re-park) → deduped, no 2nd injection ===') +console.log('\n=== 19b) same prompt re-sent within 10min → deduped, no 2nd injection ===') { - const { agent, message, steered } = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?谁会调用它?', 'fl-1b') - for (const h of frontloadHandlers) h.handler({ agent, message }) - const fired = await waitForSteer(steered, 8000) - if (!fired) ok('identical prompt within 10min is deduped (no duplicate )') - else bad('re-sent prompt should not front-load a duplicate', steered[0].content[0].text.slice(0, 80)) + const listenersFp2 = [] + const ctxFp2 = { + tools: { register() { return () => {} } }, + systemPrompt: { section() { return () => {} } }, + on(event, handler) { listenersFp2.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + plugin.apply(ctxFp2, { frontload: true }) + const fpHandlers2 = listenersFp2.filter((l) => l.event === 'agent/inbox/inserted') + + const first = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?谁会调用它?', 'fl-1') + for (const h of fpHandlers2) h.handler({ agent: first.agent, message: first.message }) + const firedFirst = await waitForSteer(first.steered, 30000) + if (!firedFirst) bad('frontload did not steer anything for a structural prompt') + else ok('steered for the first arrival') + + const resend = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?谁会调用它?', 'fl-1b') + for (const h of fpHandlers2) h.handler({ agent: resend.agent, message: resend.message }) + const firedResend = await waitForSteer(resend.steered, 8000) + if (!firedResend) ok('identical prompt within 10min is deduped (no duplicate )') + else bad('re-sent prompt should not front-load a duplicate', resend.steered[0].content[0].text.slice(0, 80)) } console.log('\n=== 20) frontload: non-structural prompt → silent no-op ===') { + const listenersFp3 = [] + const ctxFp3 = { + tools: { register() { return () => {} } }, + systemPrompt: { section() { return () => {} } }, + on(event, handler) { listenersFp3.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + plugin.apply(ctxFp3, { frontload: true }) + const fpHandlers3 = listenersFp3.filter((l) => l.event === 'agent/inbox/inserted') const { agent, message, steered } = makeAgent(sessionCwd, 'fix this typo please', 'fl-2') - for (const h of frontloadHandlers) h.handler({ agent, message }) + for (const h of fpHandlers3) h.handler({ agent, message }) const fired = await waitForSteer(steered, 8000) if (!fired) ok('no injection for a non-structural prompt') else bad('non-structural prompt should not front-load', steered[0].content[0].text.slice(0, 80)) @@ -426,8 +554,18 @@ console.log('\n=== 20) frontload: non-structural prompt → silent no-op ===') console.log('\n=== 21) frontload: unindexed project → silent no-op ===') { - const { agent, message, steered } = makeAgent('/tmp', 'multiply 的调用流程是怎样的?', 'fl-3') - for (const h of frontloadHandlers) h.handler({ agent, message }) + const listenersFp4 = [] + const ctxFp4 = { + tools: { register() { return () => {} } }, + systemPrompt: { section() { return () => {} } }, + on(event, handler) { listenersFp4.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + plugin.apply(ctxFp4, { frontload: true }) + const fpHandlers4 = listenersFp4.filter((l) => l.event === 'agent/inbox/inserted') + const { agent, message, steered } = makeAgent(UNINDEXED_CWD, 'multiply 的调用流程是怎样的?', 'fl-3') + for (const h of fpHandlers4) h.handler({ agent, message }) const fired = await waitForSteer(steered, 8000) if (!fired) ok('no injection when no .codegraph/ index is reachable') else bad('unindexed project should not front-load') @@ -435,12 +573,22 @@ console.log('\n=== 21) frontload: unindexed project → silent no-op ===') console.log('\n=== 22) frontload: does not re-trigger on its own output / non-user sources ===') { + const listenersFp5 = [] + const ctxFp5 = { + tools: { register() { return () => {} } }, + systemPrompt: { section() { return () => {} } }, + on(event, handler) { listenersFp5.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + plugin.apply(ctxFp5, { frontload: true }) + const fpHandlers5 = listenersFp5.filter((l) => l.event === 'agent/inbox/inserted') const { agent, message, steered } = makeAgent(sessionCwd, '…prior injection…', 'fl-4') - for (const h of frontloadHandlers) h.handler({ agent, message }) + for (const h of fpHandlers5) h.handler({ agent, message }) const fired1 = await waitForSteer(steered, 5000) const rpc = makeAgent(sessionCwd, 'multiply 的调用流程是怎样的?', 'fl-5') rpc.message.source = { kind: 'rpc' } - for (const h of frontloadHandlers) h.handler({ agent: rpc.agent, message: rpc.message }) + for (const h of fpHandlers5) h.handler({ agent: rpc.agent, message: rpc.message }) const fired2 = await waitForSteer(rpc.steered, 5000) if (!fired1 && !fired2) ok('own output and non-user sources are ignored') else bad(`loop-guard failed (marker=${fired1}, rpc=${fired2})`) @@ -450,9 +598,10 @@ console.log('\n=== 23) config: frontload:false registers no listener ===') { const listeners3 = [] const ctx3 = { - tools: { register() {} }, + tools: { register() { return () => {} } }, systemPrompt: { section() { return () => {} } }, on(event, handler) { listeners3.push({ event, handler }); return () => {} }, + inject() {}, get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } } plugin.apply(ctx3, { frontload: false }) @@ -464,9 +613,10 @@ console.log('\n=== 24) no executor mounted: apply must NOT throw (lazy resolutio { const tools4 = [] const ctx4 = { - tools: { register(t) { tools4.push(t) } }, + tools: { register(t) { tools4.push(t); return () => {} } }, systemPrompt: { section() { return () => {} } }, on() { return () => {} }, + inject() {}, get() { return undefined } // neither subprocess nor shell } try { @@ -489,5 +639,31 @@ console.log('\n=== 24) no executor mounted: apply must NOT throw (lazy resolutio } } +console.log('\n=== 25) settings namespace: apply works without a settings service; entry config drives the shape ===') +{ + // ctx.inject never invokes (no settings service mounted) — the plugin must + // still mount fully from the composition entry config alone. + const tools5 = [] + const sections5 = [] + const listeners5 = [] + const ctx5 = { + tools: { register(t) { tools5.push(t); return () => {} } }, + systemPrompt: { section(s) { sections5.push(s); return () => {} } }, + on(event, handler) { listeners5.push({ event, handler }); return () => {} }, + inject() {}, + get(n) { return n === 'subprocess' ? subprocessService : n === 'shell' ? shellService : undefined } + } + try { + plugin.apply(ctx5, { frontload: true }) + ok('apply() without a settings service mounts (optional-settings contract)') + } catch (e) { + bad('apply() must tolerate a missing settings service', null, e.message) + } + if (sections5.find((s) => s.name === 'tool:codegraph')) ok('entry config still arms the prompt guidance without settings') + else bad('entry-config guidance missing without settings service') + if (listeners5.some((l) => l.event === 'agent/inbox/inserted')) ok('entry-config frontload:true arms the listener without settings') + else bad('entry-config frontload:true must arm the inbox listener without settings service') +} + console.log(`\n========== ${pass} passed, ${fail} failed ==========\n`) process.exit(fail === 0 ? 0 : 1) From 37d25da15ae82f212b4556f37b1f90be82e3e07d Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 8 Sep 2026 19:57:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E9=80=82=E9=85=8D0.1.2=20rc1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++- client/client.js | 2 +- lib/index.js | 68 +++++++++++++++++++++++----------------- package.json | 12 +++++-- test/run-plugin-test.mjs | 2 +- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b3a3f83..58a4650 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,9 @@ grep / glob / read**: 设置变更**即时生效**:插件拆掉旧的提示词 section、工具面与 frontload 监听器后按新值 重挂,不需要重启 DSH。浏览器半(`client/client.js`)负责渲染该设置卡片;宿主半通过 -`@deepseek-ai/dsh-settings` 的 `installSettingsSection` 注册命名空间。 +`ctx.settings.installSection` 注册命名空间(`@deepseek-ai/dsh-settings` 0.1.2-rc.1 起, +命名空间 id 就是普通的小写连字符字符串,注册入口从独立的 `installSettingsSection` +辅助函数改为 `settings` 服务上的方法)。 ## 配置(组合层) diff --git a/client/client.js b/client/client.js index 441ab88..ed4936f 100644 --- a/client/client.js +++ b/client/client.js @@ -2,7 +2,7 @@ // // 手写 __ModuleLoader__ factory bundle(CJS factory),与 dsh-rules-manager-client / // dsh-token-usage 同构。注册 settings.plugin.item(key = 'dsh-codegraph'): -// 宿主半通过 installSettingsSection 把 Config 注册为 `dsh-codegraph` 设置命名空间, +// 宿主半通过 ctx.settings.installSection 把 Config 注册为 `dsh-codegraph` 设置命名空间, // 本卡片经 ctx.settingsScope.bind({ namespace: 'dsh-codegraph' }) 读写它。 // // 字段(对应 lib/index.js 的 Config): diff --git a/lib/index.js b/lib/index.js index 4600be5..200898d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -24,7 +24,7 @@ // from the DSH settings UI. // // User settings: the plugin registers the `dsh-codegraph` settings namespace -// (schema = Config) via installSettingsSection, so guideSearch / surface / +// (schema = Config) via ctx.settings.installSection, so guideSearch / surface / // frontload are editable live from the DSH settings UI (Plugins section — // see client.js) and layered user > composition entry > schema default. A // settings change tears down and re-arms the prompt section, the tool @@ -57,7 +57,6 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import z from '@deepseek-ai/schemastery' export const name = 'dsh-codegraph' @@ -93,7 +92,11 @@ export const Config = z.object({ frontload: z.boolean().default(false) }) -export const SETTINGS_NAMESPACE = settingsNamespace('dsh-codegraph') +// Since @deepseek-ai/dsh-settings 0.1.2-rc.1 a namespace id is the plain +// lowercase-hyphen string itself: `settingsNamespace()` is gone, and the +// section is installed through the `settings` service +// (`ctx.settings.installSection`), not an imported helper. +export const SETTINGS_NAMESPACE = 'dsh-codegraph' // System-prompt guidance that makes the codegraph tools preferred for code // search. Rendered at order 98 — before the built-in tool block (read=100, @@ -623,35 +626,42 @@ export function apply(ctx, config = {}) { // no restart. armPrompt() armFrontload() - installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, entry, { - setSource: (next) => { - source = next - }, - onChange: () => { - // Tools: core/full membership changed → drop the extras or add the - // missing ones. registerTool re-registers everything on each apply; - // here only the delta moves, and names outside both surfaces never - // appear in `rearmed`. - const { surface } = values() - for (const [name, disposer] of [...rearmed]) { - if (surface === 'core' && !CORE_TOOLS.has(name)) { - disposer() - rearmed.delete(name) + // @deepseek-ai/dsh-settings 0.1.2-rc.1 replaced the standalone + // `installSettingsSection` helper with the `settings` service's + // `installSection` method, so the section is installed from an injected + // context: the callback runs only while a settings service is mounted, + // which is exactly the optional seam the helper used to manage internally. + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, entry, { + setSource: (next) => { + source = next + }, + onChange: () => { + // Tools: core/full membership changed → drop the extras or add the + // missing ones. registerTool re-registers everything on each apply; + // here only the delta moves, and names outside both surfaces never + // appear in `rearmed`. + const { surface } = values() + for (const [name, disposer] of [...rearmed]) { + if (surface === 'core' && !CORE_TOOLS.has(name)) { + disposer() + rearmed.delete(name) + } } - } - if (surface === 'full') { - for (const def of TOOL_DEFS.values()) { - if (!rearmed.has(def.name)) registerArmedTool(def.name, def.description, def.parameters, def.buildArgv, def.opts) + if (surface === 'full') { + for (const def of TOOL_DEFS.values()) { + if (!rearmed.has(def.name)) registerArmedTool(def.name, def.description, def.parameters, def.buildArgv, def.opts) + } } + // Prompt guidance and frontload listener: unconditional re-arm is + // correct even when unchanged — the rearm disposer removes the old + // registration first, so nothing doubles up. + rearmPrompt() + rearmFrontload() + armPrompt() + armFrontload() } - // Prompt guidance and frontload listener: unconditional re-arm is - // correct even when unchanged — the rearm disposer removes the old - // registration first, so nothing doubles up. - rearmPrompt() - rearmFrontload() - armPrompt() - armFrontload() - } + }) }) } diff --git a/package.json b/package.json index cef70e4..dc7ad89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-codegraph", - "version": "1.1.0", + "version": "1.2.0", "description": "CodeGraph for DSH — registers native `codegraph_*` tools (core surface: status/init/sync/explore; full surface adds query/node/files/callers/callees/impact/affected/index/uninit) wrapping the `codegraph` CLI, plus an optional prompt front-load hook (off by default) that steers structural CodeGraph context into structural prompts. guideSearch / surface / frontload are user-settings (dsh-codegraph namespace) editable live from the DSH settings UI (Plugins section), so the DSH agent can bootstrap, maintain, and query a pre-indexed code knowledge graph without grepping files.", "keywords": [ "dsh", @@ -27,8 +27,8 @@ "cordis.patch.yml" ], "peerDependencies": { - "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", - "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", + "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0-0", + "@deepseek-ai/dsh-settings": ">=0.1.2-rc.1 <0.2.0-0", "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0", "@deepseek-ai/schemastery": ">=3.0.0 <4.0.0" }, @@ -37,6 +37,12 @@ "patch": "./cordis.patch.yml" }, "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-plugins" + ], "platform": "web" } }, diff --git a/test/run-plugin-test.mjs b/test/run-plugin-test.mjs index efe971e..2faef80 100644 --- a/test/run-plugin-test.mjs +++ b/test/run-plugin-test.mjs @@ -150,7 +150,7 @@ const registeredTools = [] const promptSections = [] const listeners = [] -// inject() no-op: installSettingsSection rides ctx.inject(['settings'], …), +// inject() no-op: ctx.settings.installSection rides ctx.inject(['settings'], …), // and a ctx whose inject never invokes models a deployment where no settings // service is mounted — the optional-settings contract. Test 25 exercises a // settings-served ctx separately.