diff --git a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml index fbe6d14eb4..7d6ea61eb1 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml +++ b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml @@ -18,8 +18,8 @@ en: dea20f16d15856af zh: 6548dc9da63375e5 /deepseek-ai-dsh-plugin-package-inventory-deepseek/collection: - en: f66c9c9d4c3f79c0 - zh: ef792b9986c12413 + en: 694eb443f46a14f0 + zh: 76e1f846335a96e5 /deepseek-ai-dsh-plugin-package-inventory-deepseek/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/llm/plugin-package-inventory-deepseek/README.md b/packages/llm/plugin-package-inventory-deepseek/README.md index 72af2b9e62..0776a63188 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.md @@ -37,7 +37,7 @@ Every request re-reads active non-group entries from the host Loader tree. When At activation, the plugin samples the optional `ctx.pluginPackages` service. Shipped app boot mounts that service before profile Loader entries, so bare package and package-subpath identities use the same authoritative runtime resolution as their imports, including its misses, without requiring a `./package.json` export. If the service is absent, the plugin retains native Node package-search lookup for its lifetime and does not switch an existing inventory when a resolver mounts later. A low-level embedder that wants runtime resolution identities must therefore mount `PluginPackages` before both the Loader entries and this plugin. -Each ordinary entry uses its owning Loader tree base. A standing preset's root entries use the harness base, matching the preset Loader's deliberate bare-package override; nested includes retain their own bases. Relative and absolute modules walk to their nearest manifest; a manifest without `name` marks a loose module and contributes no package identity. A named package manifest must also declare a non-empty `version`, and malformed package metadata fails request preparation. Exact name/version pairs are deduplicated and sorted with a locale-independent comparison, while simultaneously active different versions remain separate. +Each ordinary entry uses its owning Loader tree base. A standing preset's root entries use the harness base, matching the preset Loader's deliberate bare-package override; nested includes retain their own bases. Relative and absolute modules walk to their nearest manifest; a manifest without `name` marks a loose module and contributes no package identity. Named private packages (`private: true`) without a `version` are also omitted; a nested package does not inherit its parent's identity. Other named package manifests must declare a non-empty `version`, and malformed package metadata, including an explicitly invalid private-package version, fails request preparation. Exact name/version pairs are deduplicated and sorted with a locale-independent comparison, while simultaneously active different versions remain separate. The version-1 `dsh_plugin_packages` field contains only `{ name, version }` pairs. Disabled, pending, failed, disposed, unloading, structural `cordis:` rows, ordinary dependencies, loose files without an owning package identity, programmatically mounted child fibers, and in-memory dynamic plugins are excluded. diff --git a/packages/llm/plugin-package-inventory-deepseek/README.zh.md b/packages/llm/plugin-package-inventory-deepseek/README.zh.md index e1e5189239..15b6afb4d7 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.zh.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" 插件在激活时读取一次可选的 `ctx.pluginPackages` 服务。随附 app boot 会在 profile Loader 配置项之前挂载该服务,因此裸包和包子路径的身份使用与其 import 相同的权威 runtime resolution,包括它未命中的情况,并且无需包导出 `./package.json`。若该服务不存在,插件会在整个生命周期中保留 Node 原生包搜索,之后再挂载 resolver 也不会切换既有清单。因此,希望使用 runtime resolution 身份的底层嵌入方必须先挂载 `PluginPackages`,再挂载 Loader 配置项和本插件。 -每个普通配置项使用其所属 Loader 树的基址。standing preset 的根配置项使用宿主基址,与 preset Loader 对裸包的显式覆写保持一致;嵌套 include 仍使用自身基址。相对与绝对模块会向上查找最近的 manifest(元数据清单);没有 `name` 的 manifest 只标记松散模块,不贡献包身份。具名包 manifest 还必须声明非空 `version`,格式错误的包元数据会使请求准备失败。系统使用与 locale 无关的比较按确切名称/版本对去重并排序,同时存活的不同版本仍会分开保留。 +每个普通配置项使用其所属 Loader 树的基址。standing preset 的根配置项使用宿主基址,与 preset Loader 对裸包的显式覆写保持一致;嵌套 include 仍使用自身基址。相对与绝对模块会向上查找最近的 manifest(元数据清单);没有 `name` 的 manifest 只标记松散模块,不贡献包身份。未声明 `version` 的具名私有包(`private: true`)也会被省略;嵌套包不会继承父包的身份。其他具名包 manifest 必须声明非空 `version`,格式错误的包元数据(包括显式无效的私有包版本)会使请求准备失败。系统使用与 locale 无关的比较按确切名称/版本对去重并排序,同时存活的不同版本仍会分开保留。 版本 1 的 `dsh_plugin_packages` 字段只包含 `{ name, version }` 对。系统会排除禁用、pending、failed、disposed、unloading 状态,结构性 `cordis:` 配置项,普通依赖,没有所属包身份的松散文件,以编程方式挂载的子 fiber,以及内存动态插件。 diff --git a/packages/llm/plugin-package-inventory-deepseek/src/index.ts b/packages/llm/plugin-package-inventory-deepseek/src/index.ts index c6f91be7e9..6cce1a353a 100644 --- a/packages/llm/plugin-package-inventory-deepseek/src/index.ts +++ b/packages/llm/plugin-package-inventory-deepseek/src/index.ts @@ -42,6 +42,7 @@ export const Config: z = z.object({ interface PackageManifest { readonly name?: unknown readonly version?: unknown + readonly private?: unknown } interface ActiveEntry { @@ -58,10 +59,12 @@ function barePackageName(specifier: string): string | undefined { return first.startsWith('@') ? `${first}/${second}` : first } -/** Read one manifest identity, optionally treating an absent name as a loose-module marker. */ +/** Read a versioned identity; omit unversioned private packages and optional anonymous module markers. */ function identityFromManifest(path: string, allowAnonymous: boolean): DeepSeekPluginPackageIdentity | undefined { const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest if (allowAnonymous && manifest.name === undefined) return undefined + if (manifest.private === true && manifest.version === undefined + && typeof manifest.name === 'string' && manifest.name.length > 0) return undefined if (typeof manifest.name !== 'string' || manifest.name.length === 0 || typeof manifest.version !== 'string' || manifest.version.length === 0) { throw new Error(`plugin-package-inventory-deepseek: ${path} must declare non-empty name and version`) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 74bfdd647b..f185c90e4a 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -114,6 +114,41 @@ describe('DeepSeek plugin package inventory', () => { .resolves.toMatchObject({ fields: { dsh_plugin_packages: { version: 1, packages: [] } } }) }) + it.each(['file', 'bare'])('omits an unversioned private %s package without borrowing its parent identity', async (kind) => { + const { ctx, root } = await harness(undefined, true) + const parent = await packagePlugin(root, 'node_modules/inspector', { name: 'inspector', version: '1.0.0' }) + const nested = await packagePlugin(root, kind === 'file' ? 'node_modules/inspector/skill' : 'node_modules/inspector-skill', + { name: 'inspector-skill', private: true }) + const versioned = await packagePlugin(root, 'versioned-private', { name: 'versioned-private', private: true, version: '2.0.0' }) + await ctx.loader.create({ name: kind === 'file' ? pathToFileURL(join(root, nested)).href : 'inspector-skill/plugin.mjs' }) + await ctx.loader.create({ name: versioned }) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([ + { name: 'versioned-private', version: '2.0.0' }, + ]) + await ctx.loader.create({ name: parent }) + const withParent = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) + expect(withParent.fields.dsh_plugin_packages?.packages).toEqual([ + { name: 'inspector', version: '1.0.0' }, + { name: 'versioned-private', version: '2.0.0' }, + ]) + }) + + it.each([ + { name: 'private-invalid', private: true, version: '' }, + { name: 'private-invalid', private: true, version: null }, + { name: 'private-invalid', private: true, version: 1 }, + { name: '', private: true }, + { name: 'public-unversioned' }, + ])('rejects malformed package identity %j', async (manifest) => { + const { ctx, root } = await harness() + const plugin = await packagePlugin(root, 'invalid', manifest) + await ctx.loader.create({ name: plugin }) + await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })) + .rejects.toThrow('must declare non-empty name and version') + }) + it('uses the host inventory when a request has no matching or joined live agent', async () => { const { ctx, root } = await harness() const plugin = await packagePlugin(root, 'host-only', { name: 'host-only', version: '3.0.0' }) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts new file mode 100644 index 0000000000..f04f3302af --- /dev/null +++ b/packages/llm/plugin-package-inventory-deepseek/tests/private-package-composition.spec.ts @@ -0,0 +1,82 @@ +/** Unversioned private file plugins do not block Loader-composed DeepSeek requests. */ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as PluginInventory from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + try { + await context?.fiber.dispose() + } finally { + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + } +}) + +it('prepares DeepSeek inventory with a versioned sibling while omitting a nested private package', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-private-package-composition-')) + const parent = join(root, 'inspector') + const privateDir = join(parent, 'skill') + const siblingDir = join(parent, 'sibling') + await mkdir(privateDir, { recursive: true }) + await mkdir(siblingDir) + await writeFile(join(parent, 'package.json'), JSON.stringify({ name: 'inspector', version: '1.0.0', type: 'module' })) + await writeFile(join(privateDir, 'package.json'), JSON.stringify({ name: 'inspector-skill', private: true, type: 'module' })) + await writeFile(join(siblingDir, 'package.json'), JSON.stringify({ name: 'inspector-sibling', version: '2.0.0', type: 'module' })) + await writeFile(join(privateDir, 'plugin.js'), 'export default () => {}\n') + await writeFile(join(siblingDir, 'plugin.js'), 'export default () => {}\n') + const privateUrl = pathToFileURL(join(privateDir, 'plugin.js')).href + const siblingUrl = pathToFileURL(join(siblingDir, 'plugin.js')).href + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- name: cordis:agents', + '- name: cordis:deepseek-extensions', + '- name: cordis:plugin-inventory', + `- name: ${JSON.stringify(privateUrl)}`, + `- name: ${JSON.stringify(siblingUrl)}`, + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(configPath).href + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.builtins.agents = AgentRegistry + ctx.loader.builtins['deepseek-extensions'] = DeepSeekLlmApiExtensionRegistry + ctx.loader.builtins['plugin-inventory'] = PluginInventory + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + const activeNames = [...ctx.loader.entries()] + .filter(entry => entry.fiber?.state === FiberState.ACTIVE) + .map(entry => entry.options.name) + expect(activeNames).toEqual(expect.arrayContaining(['cordis:plugin-inventory', privateUrl, siblingUrl])) + + const prepared = await ctx.deepseekLlmApiExtensions.prepare({ + body: { messages: [] }, signal: new AbortController().signal, + }) + expect(prepared.fields).toMatchInlineSnapshot(` + { + "dsh_plugin_packages": { + "packages": [ + { + "name": "inspector-sibling", + "version": "2.0.0", + }, + ], + "version": 1, + }, + } + `) +})