dsh 插件开发学习

08-替换内置能力的提供方与消费者

读完这篇你能:把 DSH 的一项能力换成自己的实现——让 web_search 查自己的笔记而不是公网,消费者一行代码不用改。

前置:07-配置与源码的热重载。约 25 分钟。

示例代码:dsh-web-search-swap

DSH 版本:0.2.0-rc.2

模型手里的 web_search 工具,背后是 DSH 自带的搜索:一次检索就是一次完整的模型请求,走 DeepSeek 的官方接口加服务端检索,要 API key,也按 token 计费。

我在本机攒了一堆 Markdown 笔记。想让模型搜的是它们——随手一问「缓存那个结论是哪次会定的」,比翻公网有用得多。

麻烦在于 web_search 这个工具不该动。它属于 DSH 自带的一百多行配置(03-用 patch 覆盖 DSH 的默认配置),改它等于给自己开一个永远要跟着上游走的 fork。所以这篇不是改工具,而是把工具背后那项能力换掉。

目标

  • 一处配置改动,web_search 从”查公网”变成”查我的笔记”。
  • 想换回去,改的还是那一处;工具、提示词、schema 全程不动。
  • 顺手能用 curl 看见”这次换源到底生效没有”,不必每次都去问模型。

DSH 设计

要理解「改一行配置就换掉一项能力」,得先看运行中的 DSH 长什么样。它是一张按行组织的插件清单,由启动器按当前配置方案「profile」(DSH 允许保存多套配置方案,启动时选一套)叠加各层补丁得到。清单上的插件之间不直接 import 对方的实现,而是通过共享上下文上的服务名找到对方。这两件事合起来,就是可替换性的地基。

DSH 把一项可替换能力叫一个「seam」(能力缝:一道「整层可以换掉」的接缝),并规定它永远拆成三种角色:

  • 服务定义声明接口——这项能力叫什么、方法长什么样;
  • 提供方实现接口——具体怎么把这件事做成;
  • 消费者使用接口——通常是面向模型的工具。

添加一项能力意味着把三者一并设计;只写其中一个角色,不构成 seam。可替换性因此是产品结构的一部分,不是某个包的好心。架构文档的原话是「seam 正是替换一个提供方就能改变整个产品的原因」:把文件系统与进程提供方指向远程沙箱,Bash、终端、语言服务就一并搬了过去,不需要为哪个提供方单独开分叉版本。消费者这边的耦合被刻意收窄成上下文上的一个服务名:它只认那个键,不认识任何提供方,所以换掉提供方动不到它。

那「这次用哪个提供方」是什么时候定的?答案是每次调用时现算,注册表只是候选集。以「访问 Web」这个 seam 为例(搜索与抓取共用一项选择服务),它的说明写明三条设计理念:一个 seam 管搜索与抓取两个独立操作;选择绝不依赖注册或加载顺序;结果上限由服务在提供方返回后强制执行。每次调用都在执行时解析提供方,规则是公开的:配置里指定了提供方 id 且它已注册、可用,就用它;没指定、恰好一个可用,就自动选;其余情况一律抛带稳定错误码的失败——配置了但没注册、注册了但不可用、多个可用、一个可用都没有,各有自己的码。绝不「悄悄用先注册的那个」。

每次调用都要跑选择,这个设计能成立,是因为可用性检查被规定得很廉价。提供方的「我能不能用」只能是廉价的本地检查(比如 key 在不在、目录在不在),从不发起网络请求。选择因此保持快速且确定。

最后是注册的寿命。DSH 把一个已加载的插件实例叫「fiber」,实例上注册的每个动作都是一个可逆的「effect」(配好清理函数,实例卸载时自动撤销)。提供方注册就基于 effect 实现:随调用方的 fiber 注册,fiber 释放时自动注销。所以注册只要一行、卸载不用写清理。提供方映射是服务的私有数据,同一种能力下重复的 id 在注册时直接被拒——那算编程错误,不是配置错误。

mermaid 图表

「换一个提供方」在这张图里只动一条虚线:注册表、消费者、工具的描述全程不碰。

三件套

web_search 背后的搜索能力分成三个角色(DSH 管这套分工叫能力 seam——seam 是”整层可以换掉的接缝”),跟”面向接口编程”是同一个套路:接口 / 实现 / 调用方。

角色 相当于 在 web 这一项里是谁 它只负责回答
服务定义 接口 ctx.web(@deepseek-ai/dsh-web) 这项能力叫什么、长什么样、现在该用哪个实现
提供方 实现 web-search-deepseek;我们要加的 dsh-notes-search 具体怎么把这一次搜索做成
消费者 调用方 dsh-tool-web(web_search 工具) 什么时候搜、结果怎么给模型看

调用链就是接口编程里那条,中间那层是路口:

mermaid 图表

提供方的契约

提供方要满足的接口不长(下面这份来自 @deepseek-ai/dsh-web 的类型):

interface WebSearchProvider {
    /** 稳定的 id,在 search 这张表里唯一;配置里引用的就是它。 */
    readonly id: string
    /** 廉价的可用性检查:只能看本地状态,禁止发网络请求。 */
    available(): boolean
    /** 执行一次搜索;被取消时通过 signal 得知。 */
    search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}

接口之外还有三条约定:

  • available() 每次搜索前都会被调用,所以它只能做本地的廉价判断(key 在不在、目录在不在)。在这里发网络请求,等于每次搜索都先付一次往返。
  • search() 不用自己按条数截断。 上限 maxResults 由消费者传进来(dsh-tool-web 的 searchMaxResults 配置,默认 8),ctx.web.search() 拿到 provider 的完整结果后再按它裁一遍,裁了就返回 truncated: true。所以 provider 只管”我找到了这些”。
  • signal 是取消通知,要一路传到底。 一次搜索可以被中途叫停:用户点了停止、或者这一轮被取消时,DSH 会把一个 AbortSignal 从 web_search 经 ctx.web.search() 传到 provider.search()。provider 不能把它丢掉——交给 fetch(url, { signal }) 由它自动中止,或者在遍历过程中像我们这样查一句 signal?.throwIfAborted()。丢掉的后果不是报错,是明明没人要了还在继续读盘、继续发请求。

提供方的写法

src/dsh-notes-search.ts,全文如下。

/**
 * 把自己的 Markdown 笔记接到 Web 搜索 seam 上。
 *
 * 这个插件只做一件事:向 `ctx.web` 注册一个 `WebSearchProvider`。
 * 它不知道搜索请求是谁发起的(模型的 `web_search` 工具?还是一个 HTTP 路由?),
 * 也不需要知道——消费者认的是 `ctx.web` 这个名字,不是它。
 */
import { existsSync } from 'node:fs'
import { readFile, readdir } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import type {
    WebSearchProvider,
    WebSearchRequest,
    WebSearchResult,
    WebSearchSource,
} from '@deepseek-ai/dsh-web'

export const name = 'dsh-notes-search'

/** 必需依赖:`ctx.web` 不在,本插件就不启动。 */
export const inject = ['web']

/** provider 在 seam 里的唯一键;`web` 行的 config 用它选中我们。 */
export const PROVIDER_ID = 'notes-local'

const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
/** 单次搜索最多看这么多文件,免得一次查询把磁盘翻个底朝天。 */
const MAX_FILES = 2000

export interface Config {
    /** 笔记根目录;不配就是"没准备好",由 available() 报告。 */
    root?: string
    /** 返回的摘要最多多少字符。 */
    snippetChars: number
}

export const Config: Schema<Config> = Schema.object({
    root: Schema.string(),
    snippetChars: Schema.number().step(1).min(1).default(160),
})

/** 列出 root 下所有像笔记的文件,跳过点开头的目录(.git、.obsidian 之类)。 */
async function collectFiles(root: string): Promise<string[]> {
    const found: string[] = []
    const queue = [root]
    while (queue.length > 0 && found.length < MAX_FILES) {
        const dir = queue.shift() as string
        let entries
        try {
            entries = await readdir(dir, { withFileTypes: true })
        } catch {
            continue
        }
        for (const entry of entries) {
            if (entry.name.startsWith('.')) continue
            const full = join(dir, entry.name)
            if (entry.isDirectory()) queue.push(full)
            else if (TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) found.push(full)
        }
    }
    return found
}

/** 笔记里第一行命中关键词的正文;一行都没命中就返回 undefined。 */
function findLine(lines: readonly string[], terms: readonly string[]): string | undefined {
    return lines.find(line => terms.some(term => line.toLowerCase().includes(term)))
}

class NotesSearchProvider implements WebSearchProvider {
    /** seam 用它做注册表的键,也是 `searchProvider` 配置里写的那个值。 */
    readonly id = PROVIDER_ID

    // 不用参数属性(`constructor(private readonly config: Config)`):
    // dsh 直接跑你的 .ts,走 Node 的类型擦除,参数属性这种"带运行时代码的类型语法"
    // 不在可擦除范围内,会直接 import 失败。
    private readonly config: Config

    constructor(config: Config) {
        this.config = config
    }

    /**
     * 廉价的本地检查:只看目录在不在。
     * 这里发网络请求的话,每次搜索前都要付一次往返——所以约定禁止。
     */
    available(): boolean {
        return this.config.root !== undefined && existsSync(this.config.root)
    }

    async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
        const root = this.config.root
        // available() 已经拦过没配 root 的情况,这里只是收窄类型。
        if (root === undefined) return { sources: [], truncated: false }

        const terms = request.query.toLowerCase().split(/\s+/).filter(term => term.length > 0)
        const limit = this.config.snippetChars
        const sources: WebSearchSource[] = []
        for (const path of await collectFiles(root)) {
            // 用户点停止时 signal 会被中止:每读一篇查一次,别把已经没人要的结果做完
            signal?.throwIfAborted()
            const lines = (await readFile(path, 'utf8')).split('\n')
            const hit = findLine(lines, terms)
            if (hit === undefined) continue
            const relativePath = relative(root, path)
            const title = lines.find(line => line.startsWith('#'))?.replace(/^#+\s*/, '').trim() || relativePath
            const snippet = hit.trim()
            sources.push({
                // 我们的源不是网页,就自己定一个 scheme;seam 只要求 url 是个字符串。
                url: `note://${relativePath}`,
                title,
                snippet: hit.length <= limit ? hit : `${hit.slice(0, limit)}…`,
            })
        }

        return {
            content: `本地笔记检索:命中 ${sources.length} 篇(关键词 ${terms.join(' / ')})`,
            sources,
            // 截断交给 seam:maxResults 是消费者给的上限,provider 只负责"我找到了这些"。
            truncated: false,
        }
    }
}

export function apply(ctx: Context, config: Config): void {
    ctx.web.registerSearchProvider(new NotesSearchProvider(config))
    // 07-配置与源码的热重载说过:ctx.logger 不往终端打,所以想要一行肉眼可见的证据就自己 console.log。
    console.log(`[${name}] 已注册 provider ${PROVIDER_ID}(root=${config.root ?? '(未配置)'})`)
}

这段代码里有四处要说明。

注册就一行。 registerSearchProvider 把 provider 塞进 ctx.web 的 search 表里,键就是它的 id,没有别的注册代码。

root 是可选的,snippetChars 有默认值。 06-用 Config schema 声明插件配置讲过 schema:Schema.string() 没加 .required() 就是可选,缺了不报错、由代码自己判断;Schema.number().step(1).min(1).default(160) 则是”没配就用 160”。把”没配置”和”配置不对”两种状态分开,available() 才有东西可报。

available() 只做 existsSync。 这是有意的:让”笔记目录不在”在搜索之前就变成一个明确的失败,而不是等遍历完磁盘返回一个空列表——空结果分不清”没命中”和”根目录写错了”。

search() 不自己截断。 命中多少就返回多少,truncated 恒为 false。消费者的 maxResults 由 seam 在返回路上执行,所以 provider 不用为每个消费者各写一遍截断逻辑。

消费者的写法

消费者是 dsh-tool-web,不用我们写。但要看换源生效没有,总不能每次都去问模型。所以演示包里多一个探针插件,把 ctx.web.search() 接到一条 HTTP 路由上,用 curl 就能看。

src/dsh-search-probe.ts:

import type { Context } from '@deepseek-ai/cordis'
// 只引类型声明(module augmentation),编译后剥离,运行时零开销。
import type {} from '@deepseek-ai/dsh-host-webserver'

export const name = 'dsh-search-probe'

/** 消费者的依赖就是这两个服务名,没有任何 provider 的名字。 */
export const inject = ['web', 'webServer']

const PATH = '/dsh-search-probe/search'

export function apply(ctx: Context): void {
    ctx.effect(() => ctx.webServer.register({
        kind: 'exact',
        path: PATH,
        handler: async (req, res) => {
            const url = new URL(req.url ?? '/', 'http://127.0.0.1')
            const query = url.searchParams.get('q') ?? ''
            const limit = url.searchParams.get('limit')
            const payload = await run(ctx, query, limit)
            res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
            res.end(JSON.stringify(payload, null, 2))
        },
    }))
}

/** 调用 seam;选择失败时把结构化错误码原样打出来,省得猜。 */
async function run(ctx: Context, query: string, limit: string | null) {
    try {
        return await ctx.web.search(limit === null ? { query } : { query, maxResults: Number(limit) })
    } catch (error) {
        return {
            error: (error as { code?: string }).code ?? 'UNKNOWN',
            message: (error as Error).message,
        }
    }
}

这个插件从装上到能被 curl 命中走三步:patch.yaml 在树尾插一行,loader 加载这个 .ts 并给它建一个 fiber;fiber 先停在 PENDING 等 inject 里的两个服务就位;就位后才跑 apply,把 /dsh-search-probe/search 注册进 webServer 的路由表——路由在,请求才命得中。卸载时 effect 收着的注销函数把它摘掉,路由跟着消失。

这段代码有几处要说明:

  • 依赖声明是模块级的。 04-注册 HTTP 路由与注入页面样式写的是 ctx.inject(['webServer'], (serverCtx) => …),只有那个回调里的代码等 webServer;这里是 export const inject = ['web', 'webServer'],整个插件都等这两个服务——任何一个不在,插件整体卸载。后面「服务消失与重现」里”停用 web 行、路由跟着 404”能成立,就是因为写的是这一种。
  • web 和 webServer 是两回事,只是名字都带 web。 ctx.webServer(@deepseek-ai/dsh-host-webserver)是本机的 HTTP 服务器:监听端口、把 DSH 的页面发给浏览器,我们借它注册一条路由,管的是”请求怎么进来”。ctx.web(@deepseek-ai/dsh-web)是访问 Web 的能力:搜公网、抓页面,管的是”怎么访问出去”。树上它们分别是 webserver 行和 web 行。消费者一方的耦合就是这两个名字。
  • catch 里读的是 error.code。选不中 provider 时,seam 抛的是一个带 code 字段的错误对象(WebError),code 是字符串、以后可能加新的,所以这里什么都不分支,原样打出来——比打印堆栈有用得多。

配置

配置相关的是三个文件。package.json 跟 06-用 Config schema 声明插件配置那份一样,只是多一行依赖——@deepseek-ai/dsh-web,用它的类型:

"dependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-web": "^0.2.0-rc.2",
    "@deepseek-ai/schemastery": "^3.18.2"
}

插件包里被 dsh.bundle.patch 声明的那份补丁(本包叫 patch.yaml)插两行,不带 config(配置是”这台机器上的选择”,留给 profile):

- insert:
    - id: dsh-notes-search
      name: '/绝对路径/dsh-web-search-swap/src/dsh-notes-search.ts'
    - id: dsh-search-probe
      name: '/绝对路径/dsh-web-search-swap/src/dsh-search-probe.ts'

换源那一处写在 profile 的 cordis.patch.yml:

# 1) 换供应商:只改这一行 config 里的 searchProvider
- id: web
  config:
    searchProvider: notes-local
    fetchProvider: http

# 2) 给自己那行配参数:笔记根目录
- id: dsh-notes-search
  config:
    root: '/绝对路径/dsh-web-search-swap/notes'

第 1 条要留意 03-用 patch 覆盖 DSH 的默认配置那个易错点:config 是整块替换。这里把 fetchProvider 又写了一遍,不是因为”要改它”,而是因为不写就等于没指定——web 这行的 schema 里两个字段都是可选的,漏写不报错,含义却从”保持原值”变成了”不指定”,选择方式跟着就变了。这正是下面「选择失败」一节的第一个实验。

install.sh 一次装完(本目录 pnpm install + dsh plugin add link:. + 写覆盖层):

cd dsh_plugin/dsh-web-search-swap && ./install.sh

验证

先看配置层:

dsh --profile test --dump-config | grep -A4 'id: web$'
# == @deepseek-ai/dsh-base, patched by ~/.dsh/profiles/test/cordis.patch.yml
- id: web
  name: '@deepseek-ai/dsh-web'
  config:
    searchProvider: notes-local
    fetchProvider: http

注意 # == 那行:这一行还是 base 装的那个 @deepseek-ai/dsh-web,只是被你的覆盖层改了 config。换的是它选中谁,不是它自己。

再启动,日志里 provider 自己打了一行(07-配置与源码的热重载说过 ctx.logger 不往终端打,所以要肉眼可见的证据就自己 console.log):

[dsh-notes-search] 已注册 provider notes-local(root=…/dsh-web-search-swap/notes)
dsh web: http://127.0.0.1:3099/?token=…

搜个词看看:

curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=缓存'
{
  "content": "本地笔记检索:命中 2 篇(关键词 缓存)",
  "sources": [
    {
      "url": "note://九月周会记录.md",
      "title": "九月周会记录",
      "snippet": "## 缓存一致性"
    },
    {
      "url": "note://前端构建缓存.md",
      "title": "前端构建缓存笔记",
      "snippet": "# 前端构建缓存笔记"
    }
  ],
  "truncated": false
}

结果来自我的笔记,不是公网。content 是 provider 顺手给的一句话摘要(WebSearchResult.content 是可选字段,DeepSeek 和 Exa 都不给,Perplexity 会给一段生成答案);url 用的是自定义 scheme,因为我们的源本来就不是网页。

加上 limit 再看一次,能看到 seam 替 provider 做的截断:

curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=缓存' --data-urlencode 'limit=1'
{
  "content": "本地笔记检索:命中 2 篇(关键词 缓存)",
  "sources": [ { "url": "note://九月周会记录.md", "title": "九月周会记录", "snippet": "## 缓存一致性" } ],
  "truncated": true
}

同一个 provider,两次返回的 content 都是”命中 2 篇”,sources 却是 2 条和 1 条、truncated 是 false 和 true:裁剪发生在 provider 返回之后,是 ctx.web.search() 按请求里的 maxResults 做的。provider 既不知道这次要几条,也不用知道。

最后确认”消费者一行没改、换的还是同一件事”:把覆盖层里的 searchProvider 改回 deepseek-official,保存(配置热重载默认开着,不用重启),同一个地址、同一个词,返回的是真的网页:

{
  "sources": [
    { "url": "https://wsjk.tj.gov.cn/…", "title": "【专家共识】10保9减8防:早期预防慢性疾病" },
    { "url": "https://news.medlive.cn/…", "title": "指南共识|慢加急性肝衰竭临床分型专家共识(2026年版)" }
  ],
  "truncated": true
}

(这条要真发一次检索请求,走官方接口、花一次 token;没配 DEEPSEEK_API_KEY 就别试。)从头到尾,web_search 的工具 schema、描述、提示词一个字没动。

选择失败

选不中 provider 时,DSH 不报一个含糊的错,而是给结构化的 code。

选择规则一共六条,search() 每次执行时现算:

searchProvider 注册情况 结果
写了 注册了且 available() 用它
写了 没注册(比如拼错) WEB_PROVIDER_CONFIGURED_MISSING
写了 注册了但 available() 为 false WEB_PROVIDER_CONFIGURED_UNAVAILABLE
没写 恰好一个可用 用它
没写 多个可用 WEB_PROVIDER_AMBIGUOUS
没写 一个可用的都没有 WEB_PROVIDER_UNAVAILABLE

四个实测。把覆盖层第 1 条的 searchProvider 删掉(只留 fetchProvider)——两个 provider 都注册在案、都可用:

{"error":"WEB_PROVIDER_AMBIGUOUS",
 "message":"multiple usable web providers are registered (notes-local, deepseek-official); configure one explicitly"}

把 searchProvider 写成 notes-locl:

{"error":"WEB_PROVIDER_CONFIGURED_MISSING",
 "message":"configured web provider \"notes-locl\" is not registered"}

把 root 指向一个不存在的目录:

{"error":"WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
 "message":"configured web provider \"notes-local\" is registered but unavailable"}

再把官方那行也停掉,就落到最后一格:

{"error":"WEB_PROVIDER_UNAVAILABLE","message":"no usable web provider is registered"}

四个分别对应”没选”、”选错了”、”选了但用不了”、”一个都没有”。注意中间两个的区别:注册发生在插件启动时,可用在每次搜索时才算;一个是配置错误,一个是运行状态。

还有一条隐含的规则:选择从不看注册顺序。多个 provider 可用又没指定 id 时,DSH 不会”用最先注册的那个”,而是直接报歧义——就是为了防止你因为配置顺序变了、搜索结果悄悄换了个源。

服务消失与重现

inject = ['web'] 是必需依赖。试试把 web 那一行停掉(03-用 patch 覆盖 DSH 的默认配置的写法):

- id: web
  disabled: true

保存后等一两秒,再 curl 那个地址:

$ curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=共识'
(404,路由没了)

web 服务一消失,两个插件跟着卸载:provider 注销、探针路由注销。把 disabled 去掉保存,日志里那行”已注册”会重新出现,路由也回来了。这就是 Cordis 的依赖语义:服务没了就 dispose,服务回来就重新 apply,你不需要写任何重连代码。

一次停用和恢复,依次经过这些状态:

mermaid 图表

由此得到一条硬要求:provider 的注册必须能被撤销。否则每次重载都会往表里多加一条,撞上 WEB_DUPLICATE_PROVIDER。这件事我们没写 ctx.effect,是 seam 自己做的:

// packages/web/web/src/index.ts
private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {
    if (store.has(provider.id)) throw new WebError(`…`, 'WEB_DUPLICATE_PROVIDER')
    const dispose = this.ctx.effect(function* () {
        store.set(provider.id, provider)
        yield () => store.delete(provider.id)
    }, 'web.registerProvider()')
    return () => void dispose()
}

注意写的是 this.ctx.effect(...),而 this.ctx 是服务实例上的 ctx。它为什么会等于调用方(我们的插件)的上下文?因为 Cordis 取服务时会顺手把这个实例的 ctx 绑到你这次访问用的上下文上(源码里管这叫 tracker)。所以这一行落在调用方 fiber 上——你的插件卸载,注册自动消失。

不是所有依赖都该写进 inject。官方那个 provider 要在”有凭据服务就用它、没有就退回环境变量”之间选,所以它没声明依赖,而是用的时候现问:

const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
// 没有凭据服务,退回启动环境变量
return launchEnvironmentOf(ctx).get(apiKeyEnv)?.value

ctx.get(name) 拿到的是 undefined 或者服务本身,不阻塞加载。必需依赖写 inject,可选依赖用 ctx.get——区别在于你要的是”没有它我就不该启动”,还是”有它更好”。

注意事项

  • config 整块替换。 改 web 行时把 fetchProvider 一起写全,别指望”只写要改的那个键”。
  • 官方那行不用动。 web-search-deepseek 还挂在树上、还在注册,只是没被选中。注册和选中是两件事,留着它反而方便你随时改回来。真不想要,用 disabled: true 停掉。
  • id 不能撞。 两个 provider 用同一个 id,第二个注册时直接抛 WEB_DUPLICATE_PROVIDER——这是编程错误,不是配置错误。
  • .ts 只能写可擦除的语法。 插件源码是 Node 直接跑的(类型擦除),参数属性、enum、namespace 这类”带运行时代码的类型语法”会让插件 failed to import,而且终端只说一句”1 entry did not activate”,不告诉你原因。类里把字段声明出来、在构造函数里赋值就行。
  • 笔记不是 HTTP 资源。 note:// 的 URL 交给 web_fetch 是抓不到的——fetch 是同一 seam 的第二张 provider 表(fetchProvider)。真想让模型读笔记原文,就得照这篇再写一个 WebFetchProvider,注册进 registerFetchProvider。
  • 改 provider 源码要重启。 配置热重载默认开着(改 searchProvider 保存就生效),源码热重载默认关着,见07-配置与源码的热重载。