diff --git a/apps/cli/package.json b/apps/cli/package.json index cdf9d6f364..b5c6da4aed 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -99,6 +99,13 @@ "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-auto-review": "workspace:*", "@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-badge-skill-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-ralph-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*", + "@deepseek-ai/dsh-session-search": "workspace:*", + "@deepseek-ai/dsh-string-editor": "workspace:*", + "@deepseek-ai/dsh-tmux-location": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*" }, "devDependencies": { diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts new file mode 100644 index 0000000000..0a83ca5631 --- /dev/null +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -0,0 +1,372 @@ +/** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import type {} from '@deepseek-ai/dsh-hmr' +import type {} from '@deepseek-ai/dsh-plugin-manager' +import type {} from '@deepseek-ai/dsh-permission-presets' +import type {} from '@deepseek-ai/dsh-session-title-llm' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { textResponse, toolCallResponse } from '../../../packages/core/agent-loop/tests/mock-adapter.ts' + +const root = fileURLToPath(new URL('../../..', import.meta.url)) +const installAnchor = join(root, 'apps/cli/package.json') +const bundles = { + search: '@deepseek-ai/dsh-session-search', + editor: '@deepseek-ai/dsh-string-editor', + tmux: '@deepseek-ai/dsh-tmux-location', + ralph: '@deepseek-ai/dsh-experimental-ralph-bundle', + terminal: '@deepseek-ai/dsh-experimental-terminal-bundle', + badge: '@deepseek-ai/dsh-experimental-badge-skill-bundle', + titles: '@deepseek-ai/dsh-experimental-session-titles-bundle', +} as const +type Response = StreamChunk[] | ((request: GenerateOptions) => StreamChunk[]) + +/** Only the external model is scripted; tools and child Agents use the shipped implementations. */ +class BundleModel extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + readonly scripts = new Map() + private titleNumber = 0 + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model }) + } + + async * stream(request: GenerateOptions): AsyncIterable { + this.requests.push(request) + if (request.purpose === 'session-title') { + yield* textResponse(`Bundle title ${String(++this.titleNumber)}`) + return + } + if (request.tools?.some(tool => tool.name === 'structured_output')) { + yield* toolCallResponse(`report-${randomUUID()}`, 'structured_output', { + status: 'complete', summary: 'OPTIONAL_RALPH_DONE', evidence: ['The scripted worker completed.'], nextSteps: [], blocker: '', + }) + return + } + const response = this.scripts.get(request.sessionId ?? '')?.shift() + if (response === undefined) throw new Error(`No scripted model response for ${String(request.sessionId)}`) + yield* typeof response === 'function' ? response(request) : response + } +} + +describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official optional bundle transitions', () => { + let ctx: Context + let temporary: string + let workspace: string + const model = new BundleModel() + const handles = new Set() + const selected = new Set() + let originalEnvironment: Record = {} + + beforeAll(async () => { + temporary = await mkdtemp(join(tmpdir(), 'dsh-optional-bundles-')) + workspace = join(temporary, 'workspace') + await mkdir(workspace) + const home = join(temporary, 'home') + const values = { + DSH_HOME: home, DSH_AGENTS_HOME: join(temporary, 'agents'), DSH_BUNDLED_SKILL_DIR: join(temporary, 'skills'), + DSH_TELEMETRY_DISABLED: '1', TMUX: '', TMUX_PANE: '', + } + originalEnvironment = Object.fromEntries(Object.keys(values).map(key => [key, process.env[key]])) + Object.assign(process.env, values) + const require = createRequire(import.meta.url) + const app = require(join(root, 'packages/boot/app-boot/lib/index.js')) as typeof import('@deepseek-ai/dsh-app-boot') + const cmdline = require(join(root, 'packages/boot/cmdline/lib/index.js')) as typeof import('@deepseek-ai/dsh-cmdline') + const directory = join(home, 'profiles', 'web') + app.initProfile(directory, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + await writeFile(join(directory, 'cordis.yml'), '[]\n') + await writeFile(join(directory, 'cordis.patch.yml'), JSON.stringify([ + { id: 'agent-default-model', config: { provider: 'bundle-test', model: 'scripted' } }, + ])) + const profile = app.loadProfileDirectory('bundle-test', directory, installAnchor) + const profileContext: ProfileContext = { + name: 'web', dir: directory, patchPath: profile.patchPath, installAnchor, cwd: workspace, home, + startedBundles: profile.layers.map(layer => layer.packageName), overlays: [], telemetryDisabledEnv: '1', + } + const resolution = await app.createRuntimeResolution({ installAnchor, home, profile }) + const listeners = new Set<() => void>() + let ready = false + ctx = await app.boot('bundle-test', join(directory, 'cordis.yml'), app.readProfilePatches('bundle-test', profileContext), async (owner) => { + owner.provide('profileContext', profileContext) + owner.provide('appReady', { onReady: (listener) => { + if (ready) { listener(); return () => {} } + listeners.add(listener) + return () => { listeners.delete(listener) } + } }) + await owner.plugin(app.PluginPackages, { resolution }) + cmdline.provideCmdline(owner, { args: ['--no-open', '--port', '0'], exit: (code) => { throw new Error(`Web command exited ${String(code)}`) } }) + }) + ready = true + for (const listener of listeners) listener() + listeners.clear() + ctx.llm.registerAdapter(['bundle-test'], model) + expect(ctx.get('hmr')).toBeDefined() + }, 120_000) + + afterEach(async () => { + for (const handle of handles) await handle.dispose() + handles.clear() + for (const name of [...selected]) { + expect(await ctx.pluginManager.setBundleEnabled(name, false)).toMatchObject({ application: 'applied' }) + selected.delete(name) + } + }, 120_000) + + afterAll(async () => { + try { + for (const handle of handles) await handle.dispose() + await ctx?.fiber.dispose() + } finally { + for (const [key, value] of Object.entries(originalEnvironment)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + if (temporary !== undefined) await rm(temporary, { recursive: true, force: true }) + } + }, 120_000) + + async function agent(preset = 'standard'): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(`optional-${preset}-${randomUUID()}`), meta: { cwd: workspace, agentPreset: preset }, + agentOptions: { provider: 'bundle-test', model: 'scripted' }, + setup: scoped => ctx.agentPresets.mount(scoped, preset).then(() => undefined), + }) + handles.add(handle) + ctx.permissionPresets.set(handle.agent.session, 'danger-full-access') + return handle + } + + async function turn(owner: Agent, prompt: string, responses: Response[]): Promise { + model.scripts.set(owner.session.id, [...responses]) + owner.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) + await owner.whenIdle() + expect(model.scripts.get(owner.session.id)).toEqual([]) + } + + async function select(name: string, enabled: boolean): Promise { + if (enabled) selected.add(name) + const result = await ctx.pluginManager.setBundleEnabled(name, enabled) + if (!enabled && result.application === 'applied') selected.delete(name) + expect(result).toMatchObject({ changed: true, application: 'applied' }) + } + + const names = (owner?: Agent) => ctx.tools.schemas(owner).map(tool => tool.name).sort() + const transcript = (owner: Agent) => JSON.stringify(owner.session.deriveMessages()) + const toolResult = (owner: Agent, callId: string) => owner.session.deriveMessages() + .flatMap(message => message.role === 'tool' && message.toolCallId === callId ? message.content : []) + .flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + + it('keeps the default host and minimal tool catalogs while every optional bundle is off', async () => { + const minimal = await agent('minimal') + const standard = await agent() + expect(names()).toEqual([]) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash']) + for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) + expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) + }) + + it('preserves existing Agent generations across editor off/on/off and resumes with the current preset', async () => { + const old = await agent() + await select(bundles.editor, true) + const enabled = await agent() + expect(names(old.agent)).not.toContain('str_replace_editor') + const first = join(workspace, 'editor-enabled.txt') + await turn(enabled.agent, 'Create the requested file with the string editor.', [ + toolCallResponse('editor-on', 'str_replace_editor', { command: 'create', path: first, file_text: 'EDITOR_ENABLED' }), textResponse('EDITOR_DONE'), + ]) + expect(await readFile(first, 'utf8')).toBe('EDITOR_ENABLED') + expect(transcript(enabled.agent)).toContain('EDITOR_DONE') + await select(bundles.editor, false) + const after = await agent() + expect(names(after.agent)).not.toContain('str_replace_editor') + const retained = join(workspace, 'editor-retained.txt') + await turn(enabled.agent, 'Use the retained editor to create the second file.', [ + toolCallResponse('editor-retained', 'str_replace_editor', { command: 'create', path: retained, file_text: 'RETAINED_EDITOR' }), textResponse('RETAINED_DONE'), + ]) + expect(await readFile(retained, 'utf8')).toBe('RETAINED_EDITOR') + const sessionId = enabled.agent.session.id + await enabled.dispose() + handles.delete(enabled) + const resumed = await ctx.agents.resume({ resumeSessionId: sessionId, setup: scoped => ctx.agentPresets.mount(scoped, 'standard').then(() => undefined) }) + handles.add(resumed) + expect(names(resumed.agent)).not.toContain('str_replace_editor') + }) + + it('generates and executes the PTC SDK for a selected editor bundle', async () => { + await select(bundles.editor, true) + const coded = await agent('ptc') + const native = await agent('cordis') + const minimal = await agent('minimal') + const target = join(workspace, 'ptc-editor.txt') + const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('str_replace_editor') + expect(names(native.agent)).toContain('str_replace_editor') + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash']) + await turn(coded.agent, 'Create the file with the editor through run_code.', [ + toolCallResponse('ptc-editor', 'run_code', { + code: `console.log(await tools.str_replace_editor(${JSON.stringify({ command: 'create', path: target, file_text: 'PTC_EDITOR_OK' })}));`, + description: 'Create the optional editor file', + }), textResponse('PTC_EDITOR_DONE'), + ]) + expect(await readFile(target, 'utf8')).toBe('PTC_EDITOR_OK') + expect(coded.agent.session.snapshotEvents().some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'str_replace_editor')).toBe(true) + await select(bundles.editor, false) + }) + + it('searches persisted conversation content only through the selected search bundle', async () => { + const seed = await agent() + await turn(seed.agent, 'Remember OPTIONAL_SEARCH_MARKER for a later session.', [textResponse('Recorded the marker.')]) + const seedId = seed.agent.session.id + await seed.dispose() + handles.delete(seed) + await select(bundles.search, true) + const searcher = await agent() + await turn(searcher.agent, 'Find the previous marker in session history.', [ + toolCallResponse('search-history', 'session_search', { query: 'OPTIONAL_SEARCH_MARKER' }), textResponse('SEARCH_DONE'), + ]) + expect(toolResult(searcher.agent, 'search-history')).toContain(seedId) + expect(toolResult(searcher.agent, 'search-history')).toContain('OPTIONAL_SEARCH_MARKER') + await select(bundles.search, false) + expect(names((await agent()).agent)).not.toContain('session_search') + }) + + it('loads the actual badge asset only while its bundle is selected', async () => { + const owner = await agent() + const load = (callId: string) => ctx.tools.execute({ + callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' }, + agent: owner.agent, signal: new AbortController().signal, + }) + expect((await load('badge-before')).isError).toBe(true) + await select(bundles.badge, true) + await turn(owner.agent, 'Load the dsh badge skill.', [ + toolCallResponse('badge-on', 'skill', { name: 'dsh-badge' }), textResponse('BADGE_DONE'), + ]) + expect(toolResult(owner.agent, 'badge-on')).toContain('powered by dsh') + expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') + const minimal = await agent('minimal') + expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash']) + await select(bundles.badge, false) + expect((await load('badge-after')).isError).toBe(true) + }) + + it('runs and closes a real persistent terminal delivered by its bundle', async () => { + await select(bundles.terminal, true) + const owner = await agent() + const target = join(workspace, 'terminal-output.txt') + let terminalId: string | undefined + const id = () => { + if (terminalId === undefined) throw new Error('terminal_open returned no terminal id') + return terminalId + } + await turn(owner.agent, 'Use a persistent terminal to retain a variable, write its value, and close the terminal.', [ + toolCallResponse('terminal-open', 'terminal_open', { type: 'shell', name: 'optional', cwd: workspace }), + (request) => { + terminalId = /started terminal session (pty-\d+)/.exec(JSON.stringify(request.messages))?.[1] + return toolCallResponse('terminal-set', 'terminal_send', { + sessionId: id(), text: process.platform === 'win32' ? "$env:DSH_OPTIONAL = 'TERMINAL_BUNDLE_OK'" : 'export DSH_OPTIONAL=TERMINAL_BUNDLE_OK', + }) + }, + () => toolCallResponse('terminal-write', 'terminal_send', { + sessionId: id(), text: process.platform === 'win32' + ? `Set-Content -NoNewline -Path '${target.replaceAll("'", "''")}' -Value $env:DSH_OPTIONAL` + : `printf '%s' "$DSH_OPTIONAL" > ${JSON.stringify(target)}`, + }), + () => toolCallResponse('terminal-close', 'terminal_close', { sessionId: id() }), + textResponse('TERMINAL_DONE'), + ]) + expect(await readFile(target, 'utf8')).toBe('TERMINAL_BUNDLE_OK') + expect(toolResult(owner.agent, 'terminal-close')).toContain('closed terminal session') + await select(bundles.terminal, false) + expect(names((await agent()).agent)).not.toContain('terminal_open') + }) + + it('runs a real fresh-Agent Ralph workflow through the selected bundle', async () => { + await select(bundles.ralph, true) + const owner = await agent() + await turn(owner.agent, 'The human explicitly requests one Ralph loop.', [ + toolCallResponse('ralph-run', 'ralph', { objective: 'Report completion with the structured output tool.', maxRounds: 1 }), + textResponse('RALPH_DONE'), + ]) + expect(toolResult(owner.agent, 'ralph-run')).toContain('Ralph worker reported completion after 1 round') + expect(toolResult(owner.agent, 'ralph-run')).toContain('OPTIONAL_RALPH_DONE') + expect(model.requests.some(request => request.sessionId !== owner.agent.session.id && request.tools?.some(tool => tool.name === 'structured_output'))).toBe(true) + await select(bundles.ralph, false) + expect(names((await agent()).agent)).not.toContain('ralph') + }) + + it('runs the selected tmux context plugin without inventing a location outside tmux', async () => { + await select(bundles.tmux, true) + expect((await ctx.agentPresets.readDocument('standard')).content).toContain('@deepseek-ai/dsh-tmux-context') + expect((await ctx.agentPresets.readDocument('minimal')).content).not.toContain('@deepseek-ai/dsh-tmux-context') + const owner = await agent() + await turn(owner.agent, 'Reply from this ordinary GUI process.', [textResponse('TMUX_UNAVAILABLE')]) + expect(owner.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'tmux-context')).toBe(false) + expect(transcript(owner.agent)).toContain('TMUX_UNAVAILABLE') + await select(bundles.tmux, false) + expect((await ctx.agentPresets.readDocument('standard')).content).not.toContain('@deepseek-ai/dsh-tmux-context') + }) + + it('switches title cadence off/on/off through the live manager', async () => { + const titleRequests = (owner: Agent) => owner.session.snapshotEvents().filter(event => event.type === 'session/title-llm-request') + const first = await agent() + await turn(first.agent, 'First title prompt.', [textResponse('ONE')]) + await expect.poll(() => titleRequests(first.agent).length).toBe(1) + await turn(first.agent, 'Second title prompt.', [textResponse('TWO')]) + expect(titleRequests(first.agent)).toHaveLength(1) + await select(bundles.titles, true) + const following = await agent() + await turn(following.agent, 'Start a growing conversation.', [textResponse('ONE')]) + await expect.poll(() => titleRequests(following.agent).length).toBe(1) + await turn(following.agent, 'Continue the growing conversation.', [textResponse('TWO')]) + await expect.poll(() => titleRequests(following.agent).length).toBe(2) + expect(titleRequests(following.agent).every(event => event.data.titleProvider === 'session-title-all-prompts-llm')).toBe(true) + await select(bundles.titles, false) + const restored = await agent() + await turn(restored.agent, 'Restore first-prompt titles.', [textResponse('ONE')]) + await expect.poll(() => titleRequests(restored.agent).length).toBe(1) + await turn(restored.agent, 'The title stays on the first prompt.', [textResponse('TWO')]) + expect(titleRequests(restored.agent)).toHaveLength(1) + expect(titleRequests(restored.agent)[0]?.data.titleProvider).toBe('session-title-first-prompt-llm') + }) + + it('composes all seven bundles while leaving minimal and host tools unchanged', async () => { + for (const name of Object.values(bundles)) await select(name, true) + let coded: Agent | undefined + for (const preset of ['standard', 'cordis', 'ptc']) { + const owner = await agent(preset) + expect(names(owner.agent)).toEqual(expect.arrayContaining(['str_replace_editor', 'session_search', 'terminal_open', 'ralph'])) + if (preset === 'ptc') coded = owner.agent + } + if (coded === undefined) throw new Error('the PTC Agent was not created') + const target = join(workspace, 'combined-bundles.txt') + await turn(coded, 'Create the file and load the badge through one program.', [ + toolCallResponse('combined-program', 'run_code', { + code: `await tools.str_replace_editor(${JSON.stringify({ command: 'create', path: target, file_text: 'COMBINED_BUNDLES_OK' })}); console.log(await tools.skill({ name: 'dsh-badge' }));`, + description: 'Use the optional editor and badge together', + }), textResponse('COMBINED_DONE'), + ]) + expect(await readFile(target, 'utf8')).toBe('COMBINED_BUNDLES_OK') + expect(toolResult(coded, 'combined-program')).toContain('dsh-badge') + const minimal = await agent('minimal') + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash']) + expect(names()).toEqual([]) + for (const name of [...Object.values(bundles)].reverse()) await select(name, false) + expect(names((await agent()).agent)).not.toContain('str_replace_editor') + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9eeb22b5aa..45f330815f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", "tests/ptc-escalation.e2e.ts", + "tests/optional-string-editor.e2e.ts", "tests/plan-control-row.e2e.ts", "tests/plan-review.e2e.ts", "tests/steering.e2e.ts", diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 39fbc85407..f5bbcd548c 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 2d5acb4065719db7 - zh: 3d21e44376092d77 + en: e98476b16ce9b978 + zh: 6c6b18c78c1d1a5f diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6a63dbae56..61b331003e 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -6,6 +6,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p ## Management records +`ProfilePatch` adds an optional literal `preset` row id to native Include patch fields; `id` then targets a child in that preset. [App-boot](../../packages/boot/app-boot/README.md#profiles) owns compilation, ordering, validation, and path resolution. + `PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id. `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index b042f9517b..925e703c45 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -6,6 +6,8 @@ ## 管理记录 +`ProfilePatch` 在原生 Include patch 字段上增加可选的字面量 `preset` 条目 id;此时 `id` 指向该 preset 内的子条目。[App-boot](../../packages/boot/app-boot/README.zh.md#profiles) 定义编译、顺序、校验与路径解析规则。 + `PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。 `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index de1b491364..f241c0503b 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 1099528bde0e2aee zh: b68772bdb9bb7856 /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: d50b0fdaf62353dd - zh: 0fccb6968602e5de + en: ea35d8b939e84ff5 + zh: 1bc1f7a14b1628f8 /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b @@ -36,8 +36,8 @@ en: 98ef2935171952c0 zh: 8c160bdeffd68b0f /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: - en: 09890e218ab49c84 - zh: e3d86597e7fb1adb + en: 87142b964869f25c + zh: 2003ce6923a4afd2 /deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives: en: 07c459f26f4f8517 zh: 210f1e4894731b1a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 06260487c4..72b5e90a86 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -62,7 +62,21 @@ Your machine-local preferences also live in the Harness home: The enabled `dsh-hmr` plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) configuration writes; package operations run outside its queue. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart. -Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal. +Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows, their nested groups, and inserted preset definitions; existing-entry name assertions and replacement `config` values remain literal. + +A profile patch may set `preset` to an outer row id and apply the remaining ordinary patch fields inside that row's literal `config.plugins` list. Any bundle or user layer can contribute these operations. Each operation sees the current child list; later user operations take precedence, and a later whole-preset `config` replacement replaces every earlier child contribution. Ordinary outer patches retain Include's single-index lookup: children introduced by replacing a group's `config` are not addressable outer targets. Missing or malformed outer preset targets and duplicate child ids introduced by an insertion fail composition. An unmatched inner `id` warns and skips, so disabling a bundle does not invalidate an old user override of its child. + +```yaml +- preset: preset-standard + insert: + - id: optional-string-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +- preset: preset-standard + id: optional-string-editor + disabled: true +``` + +`ProfilePatch` extends the native patch fields with `preset`. `applyProfilePatches` composes these operations; `compileProfilePatches` returns ordinary Include patches. Startup, profile reload, effective-config dumps, schema inspection, and compatibility preflight share this compiler. Scoped `insert` module paths resolve beside their declaring patch, including nested groups; `!!js` remains unevaluated until each child activates. Native included-file patch lists keep their ordinary Include syntax. Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links. @@ -87,7 +101,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi ### Startup and reload failures -Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. +Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Active root rows that disappear or become literally disabled release their resources before replacement rows activate, so a singleton provider can drain in-flight work before its successor registers. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 746c8d5127..57a0f1fc1c 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -62,7 +62,21 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- 启用的 `dsh-hmr` 插件会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的配置写入串行化;包操作在其队列之外执行。启动器不安装 HMR 或监视器;HMR 被禁用或不存在时,更改需要重启。 -插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 +插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目、其嵌套分组和插入的 preset 定义中的绝对路径,以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 + +profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch 字段应用到该条目的字面量 `config.plugins` 列表中。任何 bundle 或用户层都可以提供这些操作。每个操作作用于当前子条目列表;较晚的用户操作优先,而较晚的整个 preset `config` 替换会覆盖此前所有子条目贡献。普通外层 patch 保留 Include 的单次索引查找语义:替换 group 的 `config` 所引入的子条目无法作为外层目标。外层 preset 目标缺失或格式错误、插入造成子条目 id 重复都会使组合失败。内层 `id` 不匹配时只警告并跳过,因此禁用 bundle 不会使用户保留的子条目覆盖失效。 + +```yaml +- preset: preset-standard + insert: + - id: optional-string-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +- preset: preset-standard + id: optional-string-editor + disabled: true +``` + +`ProfilePatch` 在原生 patch 字段上增加 `preset`。`applyProfilePatches` 组合这些操作;`compileProfilePatches` 返回普通 Include patch。启动、profile 重载、有效配置 dump、schema 检查及兼容性预检共用此编译器。限定到 preset 的 `insert` 模块路径以声明它的 patch 所在目录为基准,包括嵌套分组;`!!js` 保持不求值,直至对应子条目激活。原生被 Include 引入文件的 patch 列表仍使用普通 Include 语法。 挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。 @@ -87,7 +101,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- ### 启动与重载失败 -profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 +profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。消失或被字面量禁用的活跃根条目先释放资源,再激活替代条目,因此单实例提供方可以先排空进行中的工作,再让后继者注册。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 diff --git a/packages/boot/app-boot/src/compatibility-preflight.ts b/packages/boot/app-boot/src/compatibility-preflight.ts index 5b4589b117..1e33abe184 100644 --- a/packages/boot/app-boot/src/compatibility-preflight.ts +++ b/packages/boot/app-boot/src/compatibility-preflight.ts @@ -7,6 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { load } from 'js-yaml' +import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts' import { resolvePluginResource } from './package-meta.ts' import { barePackageName } from './profile-resolution/resolver.ts' import type {} from './profile-resolution/service.ts' @@ -178,10 +179,10 @@ function preflight( * @returns One prepared insertion patch for profiles, or the original patches for non-profile callers. */ export function prepareProfilePatches( - ctx: Context, patches: PatchOptions[], parentURL: string, binName = 'dsh', + ctx: Context, patches: ProfilePatch[], parentURL: string, binName = 'dsh', ): PatchOptions[] { if (ctx.get('profileContext') === undefined) return patches - const entries = applyEntryPatches([], patches, patchWarning(ctx)) + const entries = applyProfilePatches([], patches, patchWarning(ctx)) const rows = prepareProfileEntries(ctx, entries, parentURL, binName) return rows.length === 0 ? [] : [{ insert: rows }] } diff --git a/packages/boot/app-boot/src/config-schema/document.ts b/packages/boot/app-boot/src/config-schema/document.ts index 70428c6671..768fb3c152 100644 --- a/packages/boot/app-boot/src/config-schema/document.ts +++ b/packages/boot/app-boot/src/config-schema/document.ts @@ -143,13 +143,23 @@ export async function buildConfigSchemaDocument( required: ['id'], properties: { id: { const: id } }, allOf: [ { not: { required: ['insert'] } }, + { not: { required: ['preset'] } }, { anyOf: [{ not: { required: ['name'] } }, { properties: { name: { enum: ['', target.name] } } }] }, ], }, then: { properties: { config: { $ref: configRef } } }, }) } - definitions.patch = { ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules] } + definitions.patch = { + ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules], + properties: { + insert: ref('entryList'), + preset: { + type: 'string', pattern: '\\S', + description: 'Outer preset row id. The remaining patch fields apply to its current config.plugins list; a missing or unaddressable preset fails composition.', + }, + }, + } const positions = new Map(entries.map((entry, index) => [entry.path, index])) diagnostics.sort((left, right) => (positions.get(left.path ?? '') ?? -1) - (positions.get(right.path ?? '') ?? -1)) return { diff --git a/packages/boot/app-boot/src/config-schema/index.ts b/packages/boot/app-boot/src/config-schema/index.ts index 44bb553249..e515c961f0 100644 --- a/packages/boot/app-boot/src/config-schema/index.ts +++ b/packages/boot/app-boot/src/config-schema/index.ts @@ -1,6 +1,6 @@ /** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */ -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { ProfilePatch } from '../profile-patches.ts' import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts' import { collectConfigSchemas } from './collect.ts' import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts' @@ -20,7 +20,7 @@ export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts' */ export async function generateConfigSchema( profile: Profile, - layers: readonly PatchOptions[][], + layers: readonly ProfilePatch[][], installAnchor: string, ): Promise { const diagnostics: ConfigSchemaDiagnostic[] = [] diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 22332c31d3..7ecbda8fd4 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -13,7 +13,7 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from '@deepseek-ai/cordis' import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' @@ -25,6 +25,8 @@ export { setProfileVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' +import { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch } from './profile-patches.ts' +export { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch, type ProfilePatchWarning } from './profile-patches.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta } from './package-meta.ts' export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' @@ -272,7 +274,7 @@ const userPatchesSchema = entryListSchema * @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject. */ export async function reconcileProfilePatches( - ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [], + ctx: Context, patches: ProfilePatch[], binName: string, requiredIds: readonly string[] = [], ): Promise { const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) @@ -286,7 +288,8 @@ export async function reconcileProfilePatches( const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config // The recomposition judges the rows the launch judged, resolved from the file this Include read. const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href - const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) + const prepared = prepareRootPatches(ctx, patches, includeConfig.path, parentURL, binName) + await drainRetiringEntries(ctx, entry, includeConfig.path, parentURL, prepared) await entry.update({ config: { ...includeConfig, patches: prepared } }) const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) await ctx.loader.await() @@ -302,10 +305,36 @@ export async function reconcileProfilePatches( return failures.map(inactiveDiagnostic) } +/** Disappearing and literal-disabled rows release singleton services before their replacements activate. */ +async function drainRetiringEntries( + ctx: Context, root: Entry, configPath: string, parentURL: string, patches: readonly ProfilePatch[], +): Promise { + const parsed = yaml.load(readFileSync(new URL(configPath, parentURL), 'utf8'), { schema: entryListSchema }) as EntryOptions[] + const rows = applyProfilePatches(parsed, patches) + const next = new Map() + const visit = (rows: EntryOptions[], outerDisabled = false): void => { + for (const row of rows) { + const disabled = outerDisabled || row.disabled === true + next.set(row.id, !row.group && disabled) + if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], disabled) + } + } + visit(rows) + const retiring = [...ctx.loader.entries()].flatMap((entry) => { + const fiber = entry.fiber + return entry.parent.tree === root.subtree && fiber?.state === FIBER_ACTIVE + && (!next.has(entry.options.id) || next.get(entry.options.id)) ? [fiber] : [] + }) + await Promise.all(retiring.map(async (fiber) => { + await fiber.dispose() + await fiber.await() + })) +} + /** - * Load an optional patch-list file: a top-level YAML array of loader patch - * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config - * overrides and `insert` lists, with `!!js` expressions allowed. A missing + * Load an optional patch-list file: a top-level YAML array of profile patch + * entries: id-targeted overrides, `insert` lists, and preset-scoped operations, + * with `!!js` expressions allowed. A missing * file means "no layer"; an unreadable, unparsable, or non-array file throws — * a present patch file that cannot apply is a misconfiguration and must fail * loud at boot, never be silently skipped. @@ -313,7 +342,7 @@ export async function reconcileProfilePatches( * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ -export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { +export function loadOptionalPatches(binName: string, file: string): ProfilePatch[] | undefined { let content: string try { content = readFileSync(file, 'utf8') @@ -333,7 +362,7 @@ export function loadOptionalPatches(binName: string, file: string): PatchOptions * @param file - absolute path of the overlay file. * @returns the parsed patch list. */ -export function loadOverlayPatches(binName: string, file: string): PatchOptions[] { +export function loadOverlayPatches(binName: string, file: string): ProfilePatch[] { let content: string try { content = readFileSync(file, 'utf8') @@ -344,24 +373,31 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } /** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */ -function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] { +function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): ProfilePatch[] { const base = dirname(resolve(file)) const visit = (entry: EntryOptions): void => { if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) { entry.name = pathToFileURL(resolve(base, entry.name)).href } if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit) + const config = entry.config as { plugins?: unknown } | undefined + if (entry.name === '@deepseek-ai/dsh-agent-preset' && Array.isArray(config?.plugins)) { + (config.plugins as EntryOptions[]).forEach(visit) + } + } + for (const patch of patches) { + profilePatchPreset(patch) + patch.insert?.forEach(visit) } - for (const patch of patches) patch.insert?.forEach(visit) return patches } /** * Parse one loader patch list: a top-level YAML array of - * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, - * because a patch file that cannot be applied at all is a misconfiguration; a - * single patch whose target row is absent stays a per-entry Loader warning, so - * one overlay shared across surfaces does not have to match every tree. + * profile patches (id-targeted overrides, `insert` lists, and preset-scoped + * operations, with `!!js` expressions allowed). Invalid lists and malformed + * preset targets throw because they cannot be applied. An ordinary or scoped + * child patch whose target is absent stays a per-entry Loader warning; a + * missing outer preset target fails composition. * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. @@ -370,7 +406,7 @@ function anchorInsertedPluginNames(patches: PatchOptions[], file: string): Patch */ function parsePatchList( binName: string, file: string, content: string, label: string, -): PatchOptions[] { +): ProfilePatch[] { let parsed: unknown try { parsed = yaml.load(content, { schema: userPatchesSchema }) @@ -385,7 +421,7 @@ function parsePatchList( throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) - return anchorInsertedPluginNames(parsed as PatchOptions[], file) + return anchorInsertedPluginNames(parsed as ProfilePatch[], file) } /** One overlay patch list with the source label printed in dump comments. */ @@ -393,13 +429,13 @@ export interface ConfigDumpLayer { /** Source name shown in dump comments (a file basename or path). */ label: string /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ - patches: PatchOptions[] + patches: ProfilePatch[] } /** * Compose the configured entry list: parse the base config file with the - * include's entry-list dialect, apply every layer's patches as ONE flattened - * list through the include's own patch algorithm (`applyEntryPatches`) — the + * include's entry-list dialect, compile preset operations, then apply the + * flattened native patch list through Include's `applyEntryPatches` — the * same call `boot()` makes, so even patch-visibility corner cases (a later * layer targeting a group child a plain config replacement introduced, which * the single-pass id index never sees) compose identically — then render the @@ -419,8 +455,8 @@ export interface ConfigDumpLayer { * A patch that matches no row is reported through `warn` with its layer * label, mirroring the Loader's boot-time warning. Earlier layers' patches * see an identical preceding state in every snapshot that includes them, so - * each snapshot's warning list extends the previous one and the new tail - * belongs to the added layer. + * additional warnings belong to the added layer. Warning multiplicity is + * preserved when scoped and ordinary diagnostics have different ordering. * @param binName - the diagnostic prefix on read/parse errors. * @param absoluteConfigPath - the base config file `boot()` would include. * @param layers - overlay layers in application order (later wins). @@ -453,16 +489,14 @@ export function renderConfigDump( // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. - const base = parsed as Parameters[0] + const base = parsed as EntryOptions[] // snapshot_k = ONE application of layers 1..k flattened, using the exact // arguments boot passes for that prefix. snapshot_N is the mounted composition. - // The patches are cloned per call: applyEntryPatches detaches the entry - // list but pushes `insert` rows by reference from the patch list, so - // sharing patch objects across snapshot calls would leak a later - // snapshot's mutations into an earlier one's result. - const snapshot = (count: number, warnings: string[]): ReturnType => { - const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) - return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { + // The compiler clones patch objects for each snapshot: native insertions retain + // references, so sharing them would let later snapshots mutate earlier results. + const snapshot = (count: number, warnings: string[]): EntryOptions[] => { + const flattened = layers.slice(0, count).flatMap(layer => layer.patches) + return applyProfilePatches(base, flattened, (message: string, ...args: unknown[]) => { // The include logs through cordis's printf-style logger (`%C` = code); a // dump has no logger, so substitute inline for a plain line. let index = 0 @@ -479,8 +513,11 @@ export function renderConfigDump( if (layer === undefined) continue const warnings: string[] = [] composed = snapshot(count, warnings) - for (const line of warnings.slice(previousWarnings.length)) { - warn(`${binName}: [${layer.label}] ${line}`) + const remaining = [...previousWarnings] + for (const line of warnings) { + const previousIndex = remaining.indexOf(line) + if (previousIndex >= 0) remaining.splice(previousIndex, 1) + else warn(`${binName}: [${layer.label}] ${line}`) } const before = previous.map(entry => JSON.stringify(entry)) for (let index = 0; index < composed.length; index += 1) { @@ -524,6 +561,17 @@ function groupedDump( return lines.join('\n') + '\n' } +/** Compile profile operations before passing an ordinary patch list to the root Include. */ +function prepareRootPatches( + ctx: Context, patches: ProfilePatch[], configPath: string, parentURL: string, binName: string, +): ProfilePatch[] { + if (ctx.get('profileContext') !== undefined) return prepareProfilePatches(ctx, patches, parentURL, binName) + if (!patches.some(patch => profilePatchPreset(patch) !== undefined)) return patches + const parsed: unknown = yaml.load(readFileSync(new URL(configPath, parentURL), 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`${binName}: config ${configPath} must be a top-level array of entries`) + return compileProfilePatches(parsed as EntryOptions[], patches, (message, ...args) => { ctx.logger.warn(message, ...args) }) +} + /** * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. @@ -539,7 +587,7 @@ function groupedDump( export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, - patches: readonly PatchOptions[] = [], + patches: readonly ProfilePatch[] = [], bareModuleBaseUrl?: string, binName = 'dsh', ): Promise { @@ -567,7 +615,7 @@ export async function mountRootInclude( // diagnostics unstable across runs (and snapshot fixtures). // The launcher's own copy is prepared here: compatibility decisions must be made before the root // Include imports anything, and they change no profile patch layer, manifest, or bundle list. - const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName) + const prepared = prepareRootPatches(ctx, [...patches], pathToFileURL(absoluteConfigPath).href, pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName) const includeConfig: Include.Config = { path: pathToFileURL(absoluteConfigPath).href, ...prepared.length > 0 ? { patches: prepared } : {}, @@ -973,7 +1021,7 @@ export async function auditStartupEntries( export async function boot( binName: string, absoluteConfigPath: string, - patches?: PatchOptions[], + patches?: ProfilePatch[], prepare?: (ctx: Context) => Promise | void, bareModuleBaseUrl?: string, ): Promise { diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts index 328b543e00..99a6de3954 100644 --- a/packages/boot/app-boot/src/profile-context.ts +++ b/packages/boot/app-boot/src/profile-context.ts @@ -2,7 +2,7 @@ import { join } from 'node:path' import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Profile } from './profile.ts' import { loadOptionalPatches } from './index.ts' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { ProfilePatch } from './profile-patches.ts' /** Application-owned package manager executable; environment applies only to package operations. */ export interface ProfilePnpmInvocation { @@ -24,7 +24,7 @@ export interface ProfileContext { /** Bundle packages used to start this process, before any persisted edits. */ readonly startedBundles: readonly string[] /** Parsed command-line overlays, applied above profile and home patches. */ - readonly overlays: readonly PatchOptions[] + readonly overlays: readonly ProfilePatch[] /** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */ readonly telemetryDisabledEnv: string | undefined } @@ -49,7 +49,7 @@ const TELEMETRY_ROW_ID = 'session-telemetry-otel' * @param hasRow - whether the composition carries the telemetry row. * @returns the disable patch, or `undefined` when no hard-disable patch is required. */ -export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { +export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): ProfilePatch | undefined { if ((disabledEnv ?? '') === '' || !hasRow) return undefined return { id: TELEMETRY_ROW_ID, disabled: true } } @@ -60,7 +60,7 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @param initialProfile Already loaded startup profile; omitted reads the current files. * @returns Detached ordered patches; this function does not update the Loader. */ -export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { +export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): ProfilePatch[] { const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) const patches = structuredClone([ ...profile.layers.flatMap(layer => layer.patches), diff --git a/packages/boot/app-boot/src/profile-patches.ts b/packages/boot/app-boot/src/profile-patches.ts new file mode 100644 index 0000000000..e90f197f2b --- /dev/null +++ b/packages/boot/app-boot/src/profile-patches.ts @@ -0,0 +1,103 @@ +/** Compile preset-scoped profile operations to the native Include patch language. */ + +import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' + +/** A native entry patch, optionally applied inside one preset row's `config.plugins`. */ +export interface ProfilePatch extends PatchOptions { + /** Literal id of an addressable outer preset row; `id` then addresses one of its child rows. */ + preset?: string +} + +/** Native Include diagnostic arguments. + * @param message Diagnostic format; `%C` marks a value rendered as code. + * @param args Values substituted into the diagnostic format. + */ +export type ProfilePatchWarning = (message: string, ...args: unknown[]) => void + +function visitRows(rows: readonly EntryOptions[], visit: (row: EntryOptions) => void): void { + for (const row of rows) { + visit(row) + if (row.group && Array.isArray(row.config)) visitRows(row.config as EntryOptions[], visit) + } +} + +/** Validate the profile-only target while keeping native patch fields under Include's existing rules. + * @param patch Parsed or programmatically supplied profile patch. + * @returns The literal outer row id, or undefined for an ordinary patch. + * @throws When a declared preset target is not a nonempty literal string. + */ +export function profilePatchPreset(patch: ProfilePatch): string | undefined { + if (!Object.hasOwn(patch, 'preset')) return undefined + if (typeof patch.preset !== 'string' || patch.preset.trim() === '') { + throw new Error('profile patch preset must be a nonempty literal row id') + } + return patch.preset +} + +function targetOf(initial: EntryOptions[], patches: PatchOptions[], preset: string): EntryOptions { + // The native index retains replaced-away children and never indexes replacement-created children. + // A marker override locates its actual reachable target without rebuilding that index from the result. + const marker = {} + const rows = applyEntryPatches(initial, [...structuredClone(patches), { id: preset, inject: marker }], () => {}) + let target: EntryOptions | undefined + visitRows(rows, (row) => { if (row.inject === marker) target = row }) + if (target === undefined) throw new Error(`profile patch preset ${JSON.stringify(preset)} is missing or not addressable`) + const config: unknown = target.config + if (config === null || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(Reflect.get(config, 'plugins'))) { + throw new Error(`profile patch preset ${JSON.stringify(preset)} must have a literal config.plugins entry list`) + } + return target +} + +function assertInsertedIds(preset: string, rows: readonly EntryOptions[]): void { + const ids = new Set() + visitRows(rows, (row) => { + if (!row.id) return + if (ids.has(row.id)) throw new Error(`profile patch preset ${JSON.stringify(preset)} has duplicate entry id ${JSON.stringify(row.id)}`) + ids.add(row.id) + }) +} + +/** Lower preset operations to native whole-config replacements without changing native root patch indexing. + * Each scoped operation sees the then-current preset children. Inputs remain unchanged. + * @param initial Root entries before profile patches. + * @param patches Complete ordered profile patch list. + * @param warn Diagnostic sink for scoped child patches; ordinary patches report when Include applies the result. + * @returns Detached native patches, suitable for the root Include. + * @throws For malformed or unaddressable preset targets and duplicate inserted child ids. + */ +export function compileProfilePatches( + initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {}, +): PatchOptions[] { + const lowered: PatchOptions[] = [] + for (const patch of structuredClone(patches)) { + const preset = profilePatchPreset(patch) + if (preset === undefined) { + lowered.push(patch) + continue + } + const target = targetOf(initial, lowered, preset) + const config = target.config as Record & { plugins: EntryOptions[] } + const { preset: _preset, ...operation } = patch + const plugins = applyEntryPatches(config.plugins, [operation], (message: string, ...args: unknown[]) => { + warn(`preset ${JSON.stringify(preset)}: ${message}`, ...args) + }) + if (operation.insert !== undefined) assertInsertedIds(preset, plugins) + lowered.push({ id: preset, config: { ...config, plugins } }) + } + return lowered +} + +/** Compose root entries and profile patches with the same compiler used for startup and reload. + * @param initial Root entries before profile patches. + * @param patches Complete ordered profile patch list. + * @param warn Diagnostic sink for unmatched ordinary and scoped child patches. + * @returns The effective entry list, with expressions unevaluated. + * @throws For malformed or unaddressable preset targets and duplicate inserted child ids. + */ +export function applyProfilePatches( + initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {}, +): EntryOptions[] { + return applyEntryPatches(initial, compileProfilePatches(initial, patches, warn), warn) +} diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 1c9435f34d..ec8ca5cac5 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -25,12 +25,12 @@ import { createRequire } from 'node:module' import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { basename, dirname, join, relative, resolve, sep } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest' import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts' import { readProfileVersionExemptions } from './profile-compatibility.ts' import { loadOverlayPatches } from './index.ts' +import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts' import { realModuleDirectory } from './profile-resolution/legacy-links.ts' /** Directory under the Harness home holding every profile. */ @@ -83,7 +83,7 @@ export interface ProfileLayer { /** Absolute paths of the bundle's patch files, in application order. */ patchPaths: readonly string[] /** The parsed patch lists of every file, concatenated in application order. */ - patches: PatchOptions[] + patches: ProfilePatch[] } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -97,7 +97,7 @@ export interface Profile { /** Absolute path of the profile's own patch file. */ patchPath: string /** The profile's own patches; empty when the file is absent. */ - patches: PatchOptions[] + patches: ProfilePatch[] /** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */ skippedBundles: SkippedBundle[] } @@ -221,6 +221,13 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas * [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)). */ export const OPTIONAL_BUNDLES: readonly string[] = [ + '@deepseek-ai/dsh-session-search', + '@deepseek-ai/dsh-string-editor', + '@deepseek-ai/dsh-tmux-location', + '@deepseek-ai/dsh-experimental-ralph-bundle', + '@deepseek-ai/dsh-experimental-terminal-bundle', + '@deepseek-ai/dsh-experimental-badge-skill-bundle', + '@deepseek-ai/dsh-experimental-session-titles-bundle', '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', '@deepseek-ai/dsh-experimental-cot-translation-bundle', @@ -801,16 +808,16 @@ export function loadProfile( /** * Compose patch layers into the effective entry list over an empty root — - * the same single `applyEntryPatches` call the boot include makes, so flag - * derivation and config dumps see exactly what mounts. + * compile preset operations and apply the same native patch list as boot, + * so flag derivation and config dumps see exactly what mounts. * @param layers - patch lists in application order. * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them). * @returns the composed entry list. */ export function composeEntries( - layers: readonly PatchOptions[][], warn: (message: string) => void = () => {}, + layers: readonly ProfilePatch[][], warn: (message: string) => void = () => {}, ): EntryOptions[] { - return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => { + return applyProfilePatches([], layers.flat(), (message: string, ...args: unknown[]) => { let index = 0 warn(message.replace(/%C/g, () => JSON.stringify(args[index++]))) }) diff --git a/packages/boot/app-boot/tests/config-schema.spec.ts b/packages/boot/app-boot/tests/config-schema.spec.ts index debb11641a..aaacfcd68e 100644 --- a/packages/boot/app-boot/tests/config-schema.spec.ts +++ b/packages/boot/app-boot/tests/config-schema.spec.ts @@ -500,6 +500,10 @@ describe('collectConfigSchemas', () => { expect(validates(result, [{ name: 'server', config: { port: 'wrong' } }])).toBe(false) expect(validates(result, [{ name: 'new-plugin', config: { arbitrary: true } }])).toBe(true) expect(validates(result, [{ id: 'server', disabled: true }], 'patchList')).toBe(true) + expect(validates(result, [{ preset: 'preset-standard', id: 'server', config: 'child-owned' }], 'patchList')).toBe(true) + expect(validates(result, [{ preset: '', insert: [] }], 'patchList')).toBe(false) + expect(validates(result, [{ preset: ' ', insert: [] }], 'patchList')).toBe(false) + expect(validates(result, [{ preset: { __jsExpr: 'ctx.preset' }, insert: [] }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', config: { port: 'wrong' } }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', config: {} }], 'patchList')).toBe(false) expect(validates(result, [{ id: 'server', name: 'not-server', config: 'ignored' }], 'patchList')).toBe(true) diff --git a/packages/boot/app-boot/tests/profile-patches.spec.ts b/packages/boot/app-boot/tests/profile-patches.spec.ts new file mode 100644 index 0000000000..5b03693714 --- /dev/null +++ b/packages/boot/app-boot/tests/profile-patches.spec.ts @@ -0,0 +1,243 @@ +/** Preset operations preserve native root patch lookup and share startup/dump/reload compilation. */ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it, onTestFinished } from 'vitest' +import { load } from 'js-yaml' +import { Context } from '@deepseek-ai/cordis' +import { EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import Group from '@deepseek-ai/cordis-plugin-group' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { + applyProfilePatches, boot, compileProfilePatches, composeEntries, loadOverlayPatches, + prepareProfilePatches, profilePatchPreset, reconcileProfilePatches, renderConfigDump, type ProfilePatch, +} from '../src/index.ts' + +const preset = (plugins: EntryOptions[] = []): Omit & { + config: { id: string; description: string; plugins: EntryOptions[] } +} => ({ + id: 'preset-standard', name: 'cordis:test-preset', config: { id: 'standard', description: 'Original', plugins }, +}) +const row = (id: string): EntryOptions => ({ id, name: 'test-plugin', config: { value: 'initial' } }) +const children = (rows: EntryOptions[]): EntryOptions[] => (rows[0]!.config as { plugins: EntryOptions[] }).plugins +const temp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-preset-patches-')) + onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) + return dir +} + +describe('preset patch composition', () => { + it('composes bundle insertions and later scoped user overrides without mutating inputs', () => { + const initial = [preset([row('base')])] + const patches: ProfilePatch[] = [ + { preset: 'preset-standard', insert: [row('optional')] }, + { preset: 'preset-standard', id: 'optional', config: { value: { __jsExpr: 'ctx.value' } } }, + { preset: 'preset-standard', id: 'base', disabled: true }, + ] + const original = structuredClone({ initial, patches }) + const result = applyProfilePatches(initial, patches) + expect(children(result)).toEqual([ + { ...row('base'), disabled: true }, + { ...row('optional'), config: { value: { __jsExpr: 'ctx.value' } } }, + ]) + expect(result[0]?.config).toMatchObject({ description: 'Original' }) + expect({ initial, patches }).toEqual(original) + expect(composeEntries([[{ insert: initial }], patches])).toEqual(result) + expect(compileProfilePatches(initial, patches).every(patch => !Object.hasOwn(patch, 'preset'))).toBe(true) + }) + + it('allows a later whole-preset replacement to replace all scoped edits', () => { + const replacement = { id: 'standard', plugins: [row('user')] } + expect(applyProfilePatches([preset()], [ + { preset: 'preset-standard', insert: [row('optional')] }, + { id: 'preset-standard', config: replacement }, + ])[0]?.config).toEqual(replacement) + }) + + it('applies each scoped operation to the current child list, including replacement children', () => { + const group = { id: 'group', name: 'cordis:group', group: true, config: [] } + const result = applyProfilePatches([preset([group])], [ + { preset: 'preset-standard', id: 'group', config: [row('replaced-child')] }, + { preset: 'preset-standard', id: 'replaced-child', disabled: true }, + { preset: 'preset-standard', id: 'group', insert: [row('inserted-child')] }, + ]) + expect(children(result)[0]?.config).toEqual([ + { ...row('replaced-child'), disabled: true }, row('inserted-child'), + ]) + }) + + it('retains ordinary root single-index behavior before and after a scoped operation', () => { + const warn: string[] = [] + const result = applyProfilePatches([ + { id: 'group', name: 'cordis:group', group: true, config: [] }, preset(), + ], [ + { id: 'group', config: [row('replacement')] }, + { preset: 'preset-standard', insert: [row('optional')] }, + { id: 'replacement', disabled: true }, + ], message => warn.push(message)) + expect(result[0]?.config).toEqual([row('replacement')]) + expect(warn).toEqual(['patch: entry %C not found']) + }) + + it.each([ + [{ id: 'group', name: 'cordis:group', group: true, config: [] }, [preset()]], + [{ id: 'group', name: 'cordis:group', group: true, config: [preset()] }, []], + ])('rejects an outer target added or removed by a whole-group replacement', (group, replacement) => { + expect(() => applyProfilePatches([group as EntryOptions], [ + { id: 'group', config: replacement }, { preset: 'preset-standard', insert: [row('optional')] }, + ])).toThrow('is missing or not addressable') + }) + + it('accepts an outer target inserted in a patchable group', () => { + const result = applyProfilePatches([{ id: 'group', name: 'cordis:group', group: true, config: [] }], [ + { id: 'group', insert: [preset()] }, + { preset: 'preset-standard', insert: [row('optional')] }, + ]) + expect(children(result[0]?.config as EntryOptions[])).toEqual([row('optional')]) + }) + + it('warns and skips a stale user override after its optional bundle is deselected', () => { + const messages: unknown[][] = [] + expect(applyProfilePatches([preset()], [{ preset: 'preset-standard', id: 'optional', disabled: true }], + (...args) => messages.push(args))).toEqual([preset()]) + expect(messages).toEqual([['preset "preset-standard": patch: entry %C not found', 'optional']]) + }) + + it.each([null, '', ' ', 1, { __jsExpr: 'ctx.preset' }])('rejects a nonliteral or empty outer target %j', (target) => { + const dir = temp() + const path = join(dir, 'patch.yml') + writeFileSync(path, JSON.stringify([{ preset: target, insert: [] }])) + expect(() => loadOverlayPatches('test', path)).toThrow('nonempty literal row id') + }) + + it('allows callers to omit a warning sink for skipped ordinary and scoped rows', () => { + expect(applyProfilePatches([], [{ id: 'missing', disabled: true }])).toEqual([]) + expect(compileProfilePatches([preset()], [{ preset: 'preset-standard', id: 'missing', disabled: true }])) + .toEqual([{ id: 'preset-standard', config: preset().config }]) + }) + + it('rejects a missing outer row', () => { + expect(() => applyProfilePatches([], [{ preset: 'missing', insert: [] }])).toThrow('preset "missing" is missing') + expect(profilePatchPreset({ id: 'ordinary' })).toBeUndefined() + }) + + it.each([undefined, null, [], { plugins: { __jsExpr: '[]' } }])('rejects a malformed preset config %j', (config) => { + expect(() => applyProfilePatches([{ ...preset(), config }], [{ preset: 'preset-standard', insert: [] }])) + .toThrow('literal config.plugins entry list') + }) + + it.each([ + { inserted: [row('same')] }, + { inserted: [{ id: 'nested', name: 'cordis:group', group: true, config: [row('same')] }] }, + ])('rejects inserted ids colliding with existing nested ids', ({ inserted }) => { + expect(() => applyProfilePatches([preset([ + { id: 'old-group', name: 'cordis:group', group: true, config: [row('same')] }, + ])], [{ preset: 'preset-standard', insert: inserted }])).toThrow('duplicate entry id "same"') + }) + + it('rejects duplicate ids inside an insertion and allows anonymous rows', () => { + expect(() => applyProfilePatches([preset()], [{ preset: 'preset-standard', insert: [row('same'), row('same')] }])) + .toThrow('duplicate entry id "same"') + expect(children(applyProfilePatches([preset()], [{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }]))) + .toEqual([{ name: 'anonymous' }]) + }) + + it('attributes ordinary and scoped warnings to their own dump layers', () => { + const dir = temp() + const path = join(dir, 'cordis.yml') + writeFileSync(path, JSON.stringify([preset()])) + const warnings: string[] = [] + renderConfigDump('test', path, [ + { label: 'ordinary', patches: [{ id: 'missing', disabled: true }] }, + { label: 'scoped', patches: [{ preset: 'preset-standard', id: 'missing', disabled: true }] }, + ], message => warnings.push(message)) + expect(warnings).toEqual([ + 'test: [ordinary] patch: entry "missing" not found', + 'test: [scoped] preset "preset-standard": patch: entry "missing" not found', + ]) + }) +}) + +const capturePreset = { + [EntryGroup.key]: true, + async apply(ctx: Context, config: { plugins: EntryOptions[] }) { await ctx.plugin(Group, config.plugins) }, +} + +it('boots, dumps, and reloads a third-party relative module inside a contributed group', async () => { + const dir = temp() + const bundleDir = join(dir, 'third-party') + mkdirSync(bundleDir) + writeFileSync(join(bundleDir, 'relative.mjs'), [ + 'export function apply(ctx, config) {', + ' ctx.effect(() => ctx.reflect.provide("presetPatchResult", config.value))', + '}', + '', + ].join('\n')) + const path = join(dir, 'cordis.yml') + writeFileSync(path, JSON.stringify([preset()])) + const patchPath = join(bundleDir, 'cordis.patch.yml') + writeFileSync(patchPath, [ + '- preset: preset-standard', + ' insert:', + ' - id: nested', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: contributed', + ' name: ./relative.mjs', + ' config:', + ' value: !!js ctx.get("fixtureValue")', + '', + ].join('\n')) + const patches = loadOverlayPatches('test', patchPath) + const ctx = await boot('test', path, patches, (context) => { + context.loader.builtins['test-preset'] = capturePreset + context.provide('fixtureValue', 'from-child-context') + }) + onTestFinished(() => ctx.fiber.dispose()) + expect(ctx.get('presetPatchResult')).toBe('from-child-context') + const declared = [...ctx.loader.entries()].find(entry => entry.options.id === 'preset-standard')! + const dump = renderConfigDump('test', path, [{ label: 'third-party', patches }]) + expect(load(dump, { schema: entryListSchema })).toEqual([declared.options]) + expect(dump).toContain(pathToFileURL(join(bundleDir, 'relative.mjs')).href) + expect(dump).toContain('!!js ctx.get("fixtureValue")') + await reconcileProfilePatches(ctx, [...patches, { + preset: 'preset-standard', id: 'contributed', config: { value: 'user' }, + }], 'test') + expect(ctx.get('presetPatchResult')).toBe('user') + await reconcileProfilePatches(ctx, [], 'test') + expect(ctx.get('presetPatchResult')).toBeUndefined() + await reconcileProfilePatches(ctx, [{ preset: 'preset-standard', id: 'contributed', disabled: true }], 'test') + expect(ctx.get('presetPatchResult')).toBeUndefined() + await reconcileProfilePatches(ctx, patches, 'test') + expect(ctx.get('presetPatchResult')).toBe('from-child-context') +}) + +it('rejects a malformed root configuration before compiling its scoped patch', async () => { + const dir = temp() + const path = join(dir, 'cordis.yml') + writeFileSync(path, '{}\n') + await expect(boot('test', path, [{ preset: 'preset-standard', insert: [] }])) + .rejects.toThrow('must be a top-level array of entries') +}) + +it('leaves lower-level nonprofile compatibility callers in control of their native patches', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const patches = [{ id: 'custom', disabled: true }] + expect(prepareProfilePatches(ctx, patches, pathToFileURL(temp()).href)).toBe(patches) +}) + +it('anchors relative modules in an inserted preset definition and its nested groups', () => { + const dir = temp() + const path = join(dir, 'cordis.patch.yml') + writeFileSync(path, JSON.stringify([{ insert: [{ + ...preset([{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: './child.mjs' }] }]), + name: '@deepseek-ai/dsh-agent-preset', + }] }])) + const patches = loadOverlayPatches('test', path) + expect(patches[0]?.insert?.[0]?.config).toMatchObject({ plugins: [{ config: [{ + name: pathToFileURL(join(dir, 'child.mjs')).href, + }] }] }) +}) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 465f9d82c6..6db0154321 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -414,11 +414,13 @@ describe('profile reconciliation settlement', () => { onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() }) let settled = false const operation = reconcileProfilePatches(ctx, [], NAME).then(() => { settled = true }) + onTestFinished(async () => { release.resolve(undefined); await operation }) await started.promise - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false) + expect([...ctx.loader.entries()].find(entry => entry.options.id === 'held')?.fiber?.uid).toBeNull() expect(settled).toBe(false) release.resolve(undefined) await operation + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false) expect(settled).toBe(true) }) }) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index f96c60684a..08a452ed26 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 2cbe6e4a68994e2f - zh: ba0328f5538d8746 + en: fdc35c44b1b0fe86 + zh: 7af592e2aa7f0dc8 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index d70c1ec19c..ef47a2836a 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,6 +41,8 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. +Scoped bundle rows carry both their declared `rowId` and outer `preset` target. The manager reads declarations without composing the bundle alone, so rows remain visible while Off. Their optional `composition` state comes from the current preset inventory; they have no editable root `entryId`. Enabling audits every affected preset, including failures the registry retained instead of throwing; unrelated unchanged preset failures become warnings. Remove refuses while any current or retained preset generation uses the contributed modules or their owning packages, including after Off, until those generations release them. + `listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index dd06c123c6..a32745c3ba 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,6 +41,8 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 +限定到 preset 的组合包行同时携带声明的 `rowId` 和外层 `preset` 目标。管理器读取声明,不单独组合该 bundle,因此 Off 状态下仍能列出这些行。可选的 `composition` 状态来自当前 preset 清单;这些行没有可编辑的根条目 `entryId`。启用会检查所有受影响的 preset,包括注册表保留而未抛出的故障;无关且未变化的 preset 故障作为警告返回。任何当前或被保留的 preset 代仍使用贡献的模块或其所属包时,Remove 都会拒绝,包括已经 Off 的情况,直到这些代释放它们。 + `listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index a93090ef67..7f86a7a62e 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -75,7 +75,8 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-hmr": { @@ -83,6 +84,9 @@ }, "@deepseek-ai/dsh-user-approval": { "optional": true + }, + "@deepseek-ai/dsh-agent-preset-registry": { + "optional": true } }, "devDependencies": { @@ -103,6 +107,10 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-agent-preset": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*" } } diff --git a/packages/boot/plugin-manager/src/bundle-rows.ts b/packages/boot/plugin-manager/src/bundle-rows.ts new file mode 100644 index 0000000000..a0e34fc141 --- /dev/null +++ b/packages/boot/plugin-manager/src/bundle-rows.ts @@ -0,0 +1,54 @@ +/** Raw bundle declarations remain inspectable while their prerequisite layers are disabled. */ +import { existsSync, realpathSync } from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { profilePatchPreset, type ProfilePatch, type PluginPackages } from '@deepseek-ai/dsh-app-boot' + +/** A declared child row and the outer preset operation that contributes it. */ +export interface BundleDeclaration { + readonly row: EntryOptions + readonly preset?: string +} + +/** Read inserted rows without applying a bundle against an incomplete profile. + * @param patches Parsed bundle patches, whose module paths are already anchored. + * @returns Declarations in patch order, including nested groups. + */ +export function bundleDeclarations(patches: readonly ProfilePatch[]): BundleDeclaration[] { + const result: BundleDeclaration[] = [] + const visit = (rows: EntryOptions[], preset?: string): void => { + for (const row of rows) { + result.push({ row, ...preset === undefined ? {} : { preset } }) + if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], preset) + } + } + for (const patch of patches) { + if (patch.insert !== undefined) visit(patch.insert, profilePatchPreset(patch)) + } + return result +} + +/** Locate the package owning a bare import or filesystem module without importing its code. + * @param specifier Configured module name or anchored file URL. + * @param base Declaring Loader tree's resolution URL. + * @param packages Optional profile package lookup for bare specifiers. + * @returns Canonical package manifest path, or undefined for builtins, missing modules, and unowned files. + */ +export function bundleModuleOwner( + specifier: string, base: string, packages?: Pick, +): string | undefined { + const known = packages?.packageOf(specifier, base) + if (known !== undefined) return realpathSync(known.manifestPath) + if (!specifier.startsWith('file:') && !specifier.startsWith('.') && !isAbsolute(specifier)) return undefined + const file = isAbsolute(specifier) ? specifier : fileURLToPath(new URL(specifier, base)) + if (!existsSync(file)) return undefined + let dir = dirname(realpathSync(file)) + while (true) { + const manifest = join(dir, 'package.json') + if (existsSync(manifest)) return realpathSync(manifest) + const parent = dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ddec3b2620..f7f5b6f81b 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -7,7 +7,8 @@ import { pathToFileURL } from 'node:url' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import z from '@deepseek-ai/schemastery' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' @@ -15,7 +16,7 @@ import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, - setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, + setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, profilePatchPreset, type ProfilePatch, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' @@ -26,6 +27,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } import { writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, readPendingBuilds } from './build-approval.ts' +import { bundleDeclarations, bundleModuleOwner, type BundleDeclaration } from './bundle-rows.ts' import { checkGithubConnection } from './github-connection.ts' import type { BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange, @@ -277,7 +279,8 @@ export class PluginManager extends TypertRemoteService { * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ @Remote - listBundles(): Promise { + async listBundles(): Promise { + const inventory = await readPluginInventory(this.ctx) const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] @@ -316,7 +319,7 @@ export class PluginManager extends TypertRemoteService { ...meta === undefined ? {} : { meta }, ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), - ...this.declaredRows(name, info) }) + ...this.declaredRows(name, info, inventory) }) } catch (error) { if (enabled || installed) { bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, @@ -324,7 +327,7 @@ export class PluginManager extends TypertRemoteService { } } } - return Promise.resolve(bundles) + return bundles } /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. @@ -453,7 +456,9 @@ export class PluginManager extends TypertRemoteService { return this.change(result => this.configure(async () => { await this.selectBundle(name, enabled) if (enabled) await this.refreshPackages() - result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []) + const rows = enabled ? this.bundleRows(name) : [] + result.warnings = await this.reload(rows.filter(row => row.preset === undefined).map(({ row }) => row.id), + enabled ? this.bundlePresets(name) : []) if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages() }), { stage: 'enable', target: name, enabled }, 'bundle') } @@ -574,7 +579,7 @@ export class PluginManager extends TypertRemoteService { if (options?.enabled !== false) await this.selectBundle(name, true) if (Object.hasOwn(before, name)) return 'restart-required' await this.refreshPackages() - if (options?.enabled !== false) result.warnings = await this.reload() + if (options?.enabled !== false) result.warnings = await this.reload([], this.bundlePresets(name)) }) }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') control.result = result @@ -619,18 +624,21 @@ export class PluginManager extends TypertRemoteService { const installed = await this.configure(async () => { const bundle = (await this.listBundles()).find(item => item.name === name) if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable') + const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name) - || (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()] - .some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) { + || contributions.some(({ row, preset }) => preset === undefined && [...this.ctx.loader.entries()] + .some(entry => entry.options.id === row.id && entry.fiber !== undefined)) + || this.retainedBundleModules(name, contributions))) { throw new ManagementFailure('stop-profile') } - const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (bundle.enabled) { await this.selectBundle(name, false) result.warnings = await this.reload() } if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null - && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) { + && contributions.some(({ row, preset }) => preset === undefined + && row.id === entry.options.id && row.name === entry.options.name)) + || this.retainedBundleModules(name, contributions)) { throw new ManagementFailure('bundle-in-use') } return bundle.installed @@ -644,38 +652,59 @@ export class PluginManager extends TypertRemoteService { }, { stage: 'remove', target: name }, 'remove') } - /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */ - private declaredRows(name: string, info: ProfileManifest): Pick { - const bundle = info.dsh?.bundle - /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */ - if (bundle === undefined) return { rows: [], overrides: [] } + /** The rows a bundle declares, joined by outer preset and child id where applicable. */ + private declaredRows(name: string, info: ProfileManifest, inventory: PluginInventorySnapshot): Pick { + const patches = this.bundlePatches(name, info) const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) - const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file)) - // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. - const live = new Map() - for (const entry of this.ctx.loader.entries()) { - /* v8 ignore next -- the Loader gives every entry an id before it is listed */ - if (typeof entry.options.id === 'string') live.set(entry.options.id, { - entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl, - }) - } + const entries = [...this.ctx.loader.entries()] const rows: BundleRowInfo[] = [] const packages = this.ctx.get('pluginPackages') - for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) { + for (const { row, preset } of bundleDeclarations(patches)) { if (typeof row.id !== 'string' || typeof row.name !== 'string') continue - const active = live.get(row.id) - const entryId = active?.entryId - const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href + const matches = preset === undefined ? entries.filter(entry => entry.options.id === row.id && entry.options.name === row.name) : [] + const active = matches.length === 1 ? matches[0] : undefined + const base = active?.parent.tree.ctx.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href const meta = packages?.metaOf(row.name, base) + const presetId = preset === undefined ? undefined : this.presetId(preset) + const composition = inventory.agentPresets?.find(item => item.id === presetId)?.rows + .find(item => item.entryId === row.id && item.moduleName === row.name) rows.push({ rowId: row.id, moduleName: row.name, - ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } }) + ...preset === undefined ? {} : { preset }, + ...composition === undefined ? {} : { composition: { enabled: composition.enabled, fiberPhase: composition.fiberPhase } }, + ...active === undefined ? {} : { entryId: pluginEntryId(active.id) }, ...meta === undefined ? {} : { meta } }) } - const declared = new Set(rows.map(row => row.rowId)) - const overrides = [...new Set(patches.flatMap(item => - item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))] + const key = (preset: string | undefined, id: string): string => JSON.stringify([preset, id]) + const declared = new Set(rows.map(row => key(row.preset, row.rowId))) + const overrides = [...new Set(patches.flatMap((item) => { + const preset = profilePatchPreset(item) + return item.insert === undefined && typeof item.id === 'string' && !declared.has(key(preset, item.id)) + ? [preset === undefined ? item.id : `${preset}/${item.id}`] : [] + }))] return { rows, overrides } } + /** Map an outer profile row to the preset identity registered by its current configuration. */ + private presetId(outer: string): string | undefined { + const config = [...this.ctx.loader.entries()].find(entry => entry.options.id === outer)?.options.config as { id?: unknown } | undefined + return typeof config?.id === 'string' ? config.id : undefined + } + + /** Retained revisions still hold module code even after a bundle has been deselected. */ + private retainedBundleModules(name: string, declarations: readonly BundleDeclaration[]): boolean { + const presets = this.ctx.get('agentPresets') + if (presets === undefined || declarations.length === 0) return false + const base = pathToFileURL(join(resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir), 'package.json')).href + const owners = new Set(declarations.flatMap(({ row }) => { + const owner = bundleModuleOwner(row.name, base, this.ctx.get('pluginPackages')) + return owner === undefined ? [] : [owner] + })) + return presets.inspectCompositions().some(composition => composition.modules.some((module) => { + const owner = bundleModuleOwner(module.moduleName, module.baseUrl ?? base, this.ctx.get('pluginPackages')) + if (owner !== undefined && owners.size > 0) return owners.has(owner) + return declarations.some(({ row }) => row.name === module.moduleName) + })) + } + /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ private async runPnpm( args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId, @@ -753,21 +782,31 @@ export class PluginManager extends TypertRemoteService { if (enabled) this.protectsManager(name) } - private bundleRows(name: string): EntryOptions[] { - const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) + private bundlePatches(name: string, known?: ProfileManifest): ProfilePatch[] { + const info = known ?? bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info?.dsh?.bundle === undefined) return [] const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) - return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))])) + return bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file)) + } + + private bundleRows(name: string): BundleDeclaration[] { return bundleDeclarations(this.bundlePatches(name)) } + + private bundlePresets(name: string): string[] { + return [...new Set(this.bundlePatches(name).flatMap((patch) => { + const preset = profilePatchPreset(patch) + return preset === undefined ? [] : [preset] + }))] } private protectsManager(name: string): boolean { if (this.managementBundles.has(name)) return true - let rows: EntryOptions[] + let rows: BundleDeclaration[] try { rows = this.bundleRows(name) } catch (_error) { // Unreadable bundles contribute no new rows; listBundles reports their diagnostics. return false } - const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId) + const protectedBundle = rows.some(({ row, preset }) => preset === undefined + && (protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)) if (protectedBundle) this.managementBundles.add(name) return protectedBundle } @@ -787,9 +826,27 @@ export class PluginManager extends TypertRemoteService { await this.ownerContext.get('pluginPackages')?.refresh() } - private async reload(requiredIds: readonly string[] = []): Promise { + /** Configuration identity distinguishes a changed failed preset from an unrelated pre-existing warning. */ + private presetConfiguration(id: string): string { + return JSON.stringify([...this.ctx.loader.entries()].flatMap((entry) => { + const config = entry.options.config as { id?: unknown; plugins?: unknown } | undefined + return config?.id === id && Array.isArray(config.plugins) ? [config] : [] + })) + } + + private async reload(requiredIds: readonly string[] = [], requiredPresets: readonly string[] = []): Promise { if (this.ownerContext.get('hmr') === undefined) return [] - return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds) + const presets = this.ctx.get('agentPresets') + const before = new Map((await presets?.list() ?? []).map(row => + [row.id, { broken: row.broken, config: this.presetConfiguration(row.id) }])) + const warnings = await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds) + const required = new Set([...requiredIds, ...requiredPresets].map(id => this.presetId(id))) + const after = await this.ctx.get('agentPresets')?.list() ?? [] + const broken = after.filter(row => row.broken !== undefined) + const failed = broken.filter(row => required.has(row.id) || before.get(row.id)?.broken !== row.broken + || before.get(row.id)?.config !== this.presetConfiguration(row.id)) + if (failed.length > 0) throw new Error(failed.map(row => `agent preset ${row.id}: ${row.broken}`).join('\n')) + return [...warnings, ...broken.map(row => `agent preset ${row.id}: ${row.broken}`)] } private async change( diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 90194e9427..e18d45c534 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -1,7 +1,7 @@ /** Public plugin management records shared with clients. */ import type { Branded } from '@deepseek-ai/dsh-brand' import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' -import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type { PluginInventoryEntry, AgentPresetPluginRow } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' @@ -35,6 +35,10 @@ export type PluginInfo = PluginInventoryEntry & ( export interface BundleRowInfo { /** The row id as the patch declares it. */ rowId: string + /** Outer preset row id; together with rowId identifies a scoped declaration. */ + preset?: string + /** Current preset composition state; scoped rows have no editable root Loader entry. */ + composition?: Pick /** The module the row names. */ moduleName: string /** Local package display metadata, including rows whose bundle is disabled. */ diff --git a/packages/boot/plugin-manager/tests/bundle-rows.spec.ts b/packages/boot/plugin-manager/tests/bundle-rows.spec.ts new file mode 100644 index 0000000000..f35a86b82c --- /dev/null +++ b/packages/boot/plugin-manager/tests/bundle-rows.spec.ts @@ -0,0 +1,51 @@ +/** Declaration identity and module ownership before activating a bundle. */ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { PluginPackages } from '@deepseek-ai/dsh-app-boot' +import { bundleDeclarations, bundleModuleOwner } from '../src/bundle-rows.ts' + +it('reads nested declarations without requiring their target profile or preset', () => { + const row = { id: 'child', name: './child.mjs' } + expect(bundleDeclarations([ + { id: 'absent-group', insert: [row] }, + { preset: 'absent-preset', insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row] }] }, + { preset: 'absent-preset', id: 'other', disabled: true }, + ])).toEqual([ + { row }, + { row: { id: 'group', name: 'cordis:group', group: true, config: [row] }, preset: 'absent-preset' }, + { row, preset: 'absent-preset' }, + ]) +}) + +it('finds canonical owners for bare, relative, absolute, and file URL modules without evaluating them', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bundle-owner-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) }) + const packageDir = join(dir, 'node_modules', 'fixture') + mkdirSync(join(packageDir, 'lib'), { recursive: true }) + const manifest = join(packageDir, 'package.json') + writeFileSync(manifest, '{"name":"fixture","version":"1.0.0","exports":"./lib/index.mjs"}\n') + const module = join(packageDir, 'lib', 'index.mjs') + writeFileSync(module, 'throw new Error("must not import ownership probes")\n') + const base = pathToFileURL(join(dir, 'profile.json')).href + await ctx.plugin(PluginPackages) + const expected = realpathSync(manifest) + expect(bundleModuleOwner('fixture', base, ctx.pluginPackages)).toBe(expected) + expect(bundleModuleOwner('fixture/lib/index.mjs', base, ctx.pluginPackages)).toBe(expected) + expect(bundleModuleOwner('./node_modules/fixture/lib/index.mjs', base)).toBe(expected) + expect(bundleModuleOwner(module, base)).toBe(expected) + expect(bundleModuleOwner(pathToFileURL(module).href, base)).toBe(expected) + expect(bundleModuleOwner('cordis:group', base)).toBeUndefined() + expect(bundleModuleOwner('uninstalled', base)).toBeUndefined() + expect(bundleModuleOwner('./absent.mjs', base)).toBeUndefined() + const loose = join(dir, 'loose.mjs') + writeFileSync(loose, '') + expect(bundleModuleOwner(loose, base)).toBeUndefined() + const linked = join(dir, 'linked') + symlinkSync(packageDir, linked, process.platform === 'win32' ? 'junction' : 'dir') + expect(bundleModuleOwner(pathToFileURL(join(linked, 'lib/index.mjs')).href, base)).toBe(expected) +}) diff --git a/packages/boot/plugin-manager/tests/preset-bundles.spec.ts b/packages/boot/plugin-manager/tests/preset-bundles.spec.ts new file mode 100644 index 0000000000..de028a8883 --- /dev/null +++ b/packages/boot/plugin-manager/tests/preset-bundles.spec.ts @@ -0,0 +1,237 @@ +/** Scoped bundles use real preset generations, profile reloads, and package-removal admission. */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { boot, initProfile, PluginPackages, readProfileManifest, readProfilePatches, createRuntimeResolution, loadProfileDirectory, type ProfileContext, type ProfilePatch } from '@deepseek-ai/dsh-app-boot' +import Hmr from '@deepseek-ai/dsh-hmr' +import AgentPreset from '@deepseek-ai/dsh-agent-preset' +import AgentPresets from '@deepseek-ai/dsh-agent-preset-registry' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SessionProjections from '@deepseek-ai/dsh-session-projection' +import SessionTitle, { SessionTitleProviderId, type SessionTitleProviderRequest, type SessionTitleProviderResult } from '@deepseek-ai/dsh-session-title' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import PluginManager from '../src/index.ts' +import * as operations from '../src/operations.ts' + +async function fixture(options: { title?: boolean } = {}) { + const home = mkdtempSync(join(tmpdir(), 'preset-bundle-')) + let owner: Context | undefined + onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) + const dir = join(home, 'profiles', 'test') + const anchor = join(home, 'package.json') + writeFileSync(anchor, '{"name":"test-installation","dependencies":{}}\n') + initProfile(dir, ['core']) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const bundle = (name: string, patches: ProfilePatch[]): string => { + const path = join(dir, 'node_modules', name) + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify(patches)) + writeFileSync(join(path, 'plugin.mjs'), 'export function apply(ctx, config) { if (config?.fail) throw new Error("scoped plugin failed"); ctx.get("scopedBundleTrace").push("start"); ctx.effect(() => () => { ctx.get("scopedBundleTrace").push("stop") }) }\n') + return path + } + bundle('core', [{ insert: [ + { id: 'manager', name: 'cordis:manager' }, + { id: 'sessions', name: 'cordis:sessions' }, + { id: 'session-projections', name: 'cordis:session-projections' }, + { id: 'presets', name: 'cordis:presets', config: { default: 'standard' } }, + ...['standard', 'cordis'].map(id => ({ id: `preset-${id}`, name: 'cordis:preset', config: { id, plugins: [] } })), + ...options.title === true ? [ + { id: 'title', name: 'cordis:title', config: { fallbackMaxWords: 5, fallbackMaxBytes: 100, maxTitleBytes: 100 } }, + { id: 'default-title', name: 'cordis:title-provider', config: { id: 'default' } }, + ] : [], + ] }]) + const addon = bundle('addon', ['standard', 'cordis'].map(preset => ({ preset: `preset-${preset}`, insert: [{ + id: 'shared-id', name: './plugin.mjs', + }] }))) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { addon: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const profile: ProfileContext = { + name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, + cwd: home, home, startedBundles: ['core'], overlays: [], telemetryDisabledEnv: undefined, + } + const resolution = await createRuntimeResolution({ installAnchor: anchor, profile: loadProfileDirectory('test', dir, anchor), home }) + const calls: { + id: string + request: SessionTitleProviderRequest + pending: ReturnType> + }[] = [] + const entered: { current: ReturnType> } = { current: Promise.withResolvers() } + const trace: string[] = [] + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), async (host) => { + owner = host + host.provide('profileContext', profile) + host.provide('scopedBundleTrace', trace) + host.provide('appReady', { onReady(listener) { listener(); return () => {} } }) + Object.assign(host.loader.builtins, { + manager: PluginManager, sessions: SessionStore, 'session-projections': SessionProjections, + presets: AgentPresets, preset: AgentPreset, title: SessionTitle, + 'title-provider': { + inject: ['sessionTitle'], + apply(ctx: Context, config: { id: string }) { + ctx.sessionTitle.register({ + id: SessionTitleProviderId(config.id), automatic: 'all-prompts', + generate(request) { + const pending = Promise.withResolvers() + calls.push({ id: config.id, request, pending }) + entered.current.resolve(undefined) + return pending.promise + }, + }) + }, + }, + }) + await host.plugin(PluginPackages, { resolution }) + }) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.hmr.runExclusive(async () => {}) + onTestFinished(() => { + for (const call of calls) call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) }) + }) + return { ctx, manager: ctx.pluginManager, profile, dir, addon, bundle, trace, calls, entered } +} + +it('lists scoped declarations while Off and keeps equal inner ids distinct across presets', async () => { + const { manager, addon } = await fixture() + const patchFile = join(addon, 'cordis.patch.yml') + const patches = JSON.parse(readFileSync(patchFile, 'utf8')) as ProfilePatch[] + writeFileSync(patchFile, JSON.stringify([...patches, + { id: 'shared-id', disabled: true }, + { preset: 'preset-standard', id: 'absent', disabled: true }, + ])) + const moduleName = pathToFileURL(join(addon, 'plugin.mjs')).href + expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows).toEqual([ + { rowId: 'shared-id', preset: 'preset-standard', moduleName }, + { rowId: 'shared-id', preset: 'preset-cordis', moduleName }, + ]) + expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied', changed: true }) + const enabled = (await manager.listBundles()).find(row => row.name === 'addon')! + expect(enabled.rows).toEqual([ + { rowId: 'shared-id', preset: 'preset-standard', moduleName, composition: { enabled: true, fiberPhase: 'active' } }, + { rowId: 'shared-id', preset: 'preset-cordis', moduleName, composition: { enabled: true, fiberPhase: 'active' } }, + ]) + expect(enabled.rows.every(row => row.entryId === undefined)).toBe(true) + expect(enabled.overrides).toEqual(['shared-id', 'preset-standard/absent']) + expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) + expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows.every(row => row.composition === undefined)).toBe(true) +}) + +it('retains a failed selection, reports affected preset failures again, and keeps unrelated failures as warnings', async () => { + const { manager, dir, bundle, ctx } = await fixture() + bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }]) + const result = await manager.setBundleEnabled('broken', true) + expect(result).toMatchObject({ application: 'failed', changed: true, error: { diagnostic: expect.stringContaining('agent preset standard') as string } }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('broken') + expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed', changed: false }) + expect((await ctx.agentPresets.list()).find(row => row.id === 'standard')?.broken).toContain('scoped plugin failed') + bundle('unrelated', [{ insert: [{ id: 'unrelated', name: './plugin.mjs' }] }]) + expect(await manager.setBundleEnabled('unrelated', true)).toMatchObject({ application: 'applied', warnings: [expect.stringContaining('agent preset standard')] }) + expect(await manager.setBundleEnabled('broken', false)).toMatchObject({ application: 'applied' }) + expect((await ctx.agentPresets.list()).every(row => row.broken === undefined)).toBe(true) +}) + +it('refuses Remove after Off while an old Agent scope retains the bundle, then removes after release', async () => { + const { manager, ctx, dir, trace } = await fixture() + expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) + const agent = createScope(ctx, {}) + onTestFinished(() => agent.dispose()) + await ctx.agentPresets.mount(agent.ctx, 'standard') + expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) + expect(ctx.agentPresets.inspectCompositions(agent.ctx)[0]?.modules).toHaveLength(1) + const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies?.addon + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') } + }) + onTestFinished(() => { remove.mockRestore() }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' }, changed: false }) + expect(remove).not.toHaveBeenCalled() + await agent.dispose() + expect(trace.filter(item => item === 'stop')).toHaveLength(2) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied', changed: true }) + expect(remove).toHaveBeenCalledOnce() +}) + +it('retains contributed builtin rows even when no package owns their module names', async () => { + const { manager, ctx, bundle } = await fixture() + ctx.loader.builtins['retained-fixture'] = { apply() {} } + bundle('addon', [{ preset: 'preset-standard', insert: [{ id: 'builtin', name: 'cordis:retained-fixture' }] }]) + expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) + const agent = createScope(ctx, {}) + onTestFinished(() => agent.dispose()) + await ctx.agentPresets.mount(agent.ctx, 'standard') + expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) + const inspect = ctx.agentPresets.inspectCompositions.bind(ctx.agentPresets) + const projection = vi.spyOn(ctx.agentPresets, 'inspectCompositions').mockImplementation(() => inspect().map(composition => ({ + ...composition, modules: composition.modules.map(({ baseUrl: _baseUrl, ...module }) => module), + }))) + onTestFinished(() => { projection.mockRestore() }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) +}) + +it('refuses removal when a different module from the same package remains in a preset', async () => { + const { manager, ctx, dir, addon } = await fixture() + const source = readFileSync(join(addon, 'plugin.mjs'), 'utf8') + writeFileSync(join(addon, 'other.mjs'), source) + expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' }) + writeFileSync(join(dir, 'cordis.patch.yml'), JSON.stringify([{ + preset: 'preset-standard', insert: [{ id: 'independent', name: pathToFileURL(join(addon, 'other.mjs')).href }], + }])) + expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' }) + expect(ctx.agentPresets.inspectCompositions().some(row => row.modules.some(module => module.moduleName.endsWith('/other.mjs')))).toBe(true) + const remove = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { remove.mockRestore() }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } }) + expect(remove).not.toHaveBeenCalled() +}) + +async function requestTitle(session: Session): Promise { + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Title for provider switch' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + await new Promise((resolve) => { queueMicrotask(resolve) }) + session.append('request/header', { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' }) +} + +it('drains and rejects stale title completions before activating each replacement in both directions', async () => { + const { manager, ctx, bundle, calls, entered } = await fixture({ title: true }) + bundle('titles', [ + { id: 'default-title', disabled: true }, + { insert: [{ id: 'optional-title', name: 'cordis:title-provider', config: { id: 'optional' } }] }, + ]) + for (const [enabled, expected] of [[true, 'default'], [false, 'optional']] as const) { + entered.current = Promise.withResolvers() + const session = ctx.sessions.create(SessionId(`title-switch-${String(enabled)}`)) + await requestTitle(session) + await entered.current.promise + const call = calls.at(-1)! + expect(call.id).toBe(expected) + const aborted = Promise.withResolvers() + call.request.signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + let complete = false + const switching = manager.setBundleEnabled('titles', enabled).then((result) => { complete = true; return result }) + onTestFinished(async () => { + call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) }) + await switching + }) + await aborted.promise + expect(complete).toBe(false) + expect(calls).toHaveLength(enabled ? 1 : 2) + call.pending.resolve({ title: 'stale result', messageSeqs: call.request.messages.map(message => message.seq) }) + expect(await switching).toMatchObject({ application: 'applied' }) + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + } + entered.current = Promise.withResolvers() + const final = ctx.sessions.create(SessionId('title-switch-final')) + await requestTitle(final) + await entered.current.promise + expect(calls.at(-1)?.id).toBe('default') + calls.at(-1)?.pending.resolve({ title: 'final title', messageSeqs: calls.at(-1)!.request.messages.map(message => message.seq) }) +}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index ba181795a1..643f870407 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -64,6 +64,9 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../preset/agent-preset-registry" } ] } diff --git a/packages/bundle/session-search/README.md b/packages/bundle/session-search/README.md new file mode 100644 index 0000000000..8461f85c7f --- /dev/null +++ b/packages/bundle/session-search/README.md @@ -0,0 +1,78 @@ +--- +description: "Search and read earlier sessions from the current workspace." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-session-search + +English | [中文](README.zh.md) + +## Summary + +Search earlier sessions with five read-only tools in standard, cordis, and ptc. The shared content index opens on the first search. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **Session search** in Official. Switch it off to remove its profile layer. + +New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. + +----- + + +## Understand the implementation + +
+Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
+ +----- + + +## Further Exploration + +[Capability implementation](../../session-query/tool-session-query/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../../session-query/tool-session-query/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- The in-memory index is rebuilt after restarting the application. Search remains restricted to authorized workspaces. + +----- + + +### Dev Note + +
+Maintainer context — click to expand + +None. + +
diff --git a/packages/bundle/session-search/README.zh.md b/packages/bundle/session-search/README.zh.md new file mode 100644 index 0000000000..bbc159b8f3 --- /dev/null +++ b/packages/bundle/session-search/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "搜索和读取当前工作区的历史会话。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-session-search + +[English](README.md) | 中文 + +## 概要 + +在 standard、cordis 和 ptc 中使用五个只读工具搜索历史会话。共享内容索引在首次搜索时打开。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **会话搜索**。关闭后移除其配置层。 + +新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 + +----- + + +## 理解实现 + +
+实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
+ +----- + + +## 进一步探索 + +[能力实现](../../session-query/tool-session-query/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../../session-query/tool-session-query/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 内存索引会在应用重启后重建。搜索仍仅限获授权的工作区。 + +----- + + +### 开发备注 + +
+维护者上下文 — 点击展开 + +无。 + +
diff --git a/packages/bundle/session-search/cordis.patch.yml b/packages/bundle/session-search/cordis.patch.yml new file mode 100644 index 0000000000..bcb880e2e8 --- /dev/null +++ b/packages/bundle/session-search/cordis.patch.yml @@ -0,0 +1,19 @@ +- id: session-query-sqlite + config: + path: ':memory:' + openAt: first-search + +- preset: preset-standard + insert: + - id: optional-tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + +- preset: preset-cordis + insert: + - id: optional-tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + +- preset: preset-ptc + insert: + - id: optional-tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' diff --git a/packages/bundle/session-search/icon.svg b/packages/bundle/session-search/icon.svg new file mode 100644 index 0000000000..aea5f37e59 --- /dev/null +++ b/packages/bundle/session-search/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/bundle/session-search/locale/en.json b/packages/bundle/session-search/locale/en.json new file mode 100644 index 0000000000..47bffa8eff --- /dev/null +++ b/packages/bundle/session-search/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Session search", + "description": "Search and read earlier sessions from the current workspace." + } +} diff --git a/packages/bundle/session-search/locale/zh.json b/packages/bundle/session-search/locale/zh.json new file mode 100644 index 0000000000..2d02add8db --- /dev/null +++ b/packages/bundle/session-search/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话搜索", + "description": "搜索和读取当前工作区的历史会话。" + } +} diff --git a/packages/bundle/session-search/package.json b/packages/bundle/session-search/package.json new file mode 100644 index 0000000000..022f7a6925 --- /dev/null +++ b/packages/bundle/session-search/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-session-search", + "description": "Search and read earlier sessions from the current workspace.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/bundle/session-search" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-tool-session-query": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/bundle/session-search/src/index.ts b/packages/bundle/session-search/src/index.ts new file mode 100644 index 0000000000..ad451978f1 --- /dev/null +++ b/packages/bundle/session-search/src/index.ts @@ -0,0 +1,2 @@ +/** Session search profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/bundle/session-search/tsconfig.json b/packages/bundle/session-search/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/bundle/session-search/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/bundle/string-editor/README.md b/packages/bundle/string-editor/README.md new file mode 100644 index 0000000000..e41fff72bb --- /dev/null +++ b/packages/bundle/string-editor/README.md @@ -0,0 +1,78 @@ +--- +description: "Edit files with the str_replace_editor tool." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-string-editor + +English | [中文](README.zh.md) + +## Summary + +Add str_replace_editor to standard, cordis, and ptc alongside their existing filesystem tools. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **String editor** in Official. Switch it off to remove its profile layer. + +New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. + +----- + + +## Understand the implementation + +
+Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
+ +----- + + +## Further Exploration + +[Capability implementation](../../fs/tool-str-replace-editor/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../../fs/tool-str-replace-editor/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- This editor overlaps with existing filesystem tools and follows their workspace authorization and observation requirements. + +----- + + +### Dev Note + +
+Maintainer context — click to expand + +None. + +
diff --git a/packages/bundle/string-editor/README.zh.md b/packages/bundle/string-editor/README.zh.md new file mode 100644 index 0000000000..412e496aee --- /dev/null +++ b/packages/bundle/string-editor/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "使用 str_replace_editor 工具编辑文件。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-string-editor + +[English](README.md) | 中文 + +## 概要 + +为 standard、cordis 和 ptc 添加 str_replace_editor,保留原有文件系统工具。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **字符串编辑器**。关闭后移除其配置层。 + +新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 + +----- + + +## 理解实现 + +
+实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
+ +----- + + +## 进一步探索 + +[能力实现](../../fs/tool-str-replace-editor/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../../fs/tool-str-replace-editor/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 此编辑器与现有文件系统工具的功能重叠,并遵守工作区授权和文件观察要求。 + +----- + + +### 开发备注 + +
+维护者上下文 — 点击展开 + +无。 + +
diff --git a/packages/bundle/string-editor/cordis.patch.yml b/packages/bundle/string-editor/cordis.patch.yml new file mode 100644 index 0000000000..e78f0fd35d --- /dev/null +++ b/packages/bundle/string-editor/cordis.patch.yml @@ -0,0 +1,14 @@ +- preset: preset-standard + insert: + - id: optional-tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + +- preset: preset-cordis + insert: + - id: optional-tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + +- preset: preset-ptc + insert: + - id: optional-tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/packages/bundle/string-editor/icon.svg b/packages/bundle/string-editor/icon.svg new file mode 100644 index 0000000000..995e1b1a76 --- /dev/null +++ b/packages/bundle/string-editor/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/bundle/string-editor/locale/en.json b/packages/bundle/string-editor/locale/en.json new file mode 100644 index 0000000000..813030ce65 --- /dev/null +++ b/packages/bundle/string-editor/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "String editor", + "description": "Edit files with the str_replace_editor tool." + } +} diff --git a/packages/bundle/string-editor/locale/zh.json b/packages/bundle/string-editor/locale/zh.json new file mode 100644 index 0000000000..0cd1f9b40b --- /dev/null +++ b/packages/bundle/string-editor/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "字符串编辑器", + "description": "使用 str_replace_editor 工具编辑文件。" + } +} diff --git a/packages/bundle/string-editor/package.json b/packages/bundle/string-editor/package.json new file mode 100644 index 0000000000..b50ffebed6 --- /dev/null +++ b/packages/bundle/string-editor/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-string-editor", + "description": "Edit files with the str_replace_editor tool.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/bundle/string-editor" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/bundle/string-editor/src/index.ts b/packages/bundle/string-editor/src/index.ts new file mode 100644 index 0000000000..d53829cf34 --- /dev/null +++ b/packages/bundle/string-editor/src/index.ts @@ -0,0 +1,2 @@ +/** String editor profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/bundle/string-editor/tsconfig.json b/packages/bundle/string-editor/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/bundle/string-editor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/bundle/tmux-location/README.md b/packages/bundle/tmux-location/README.md new file mode 100644 index 0000000000..b471576271 --- /dev/null +++ b/packages/bundle/tmux-location/README.md @@ -0,0 +1,78 @@ +--- +description: "Let agents read the current tmux pane and layout when available." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-tmux-location + +English | [中文](README.zh.md) + +## Summary + +Give standard, cordis, and ptc the current tmux session, window, pane, and layout when the process runs inside tmux. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **tmux location** in Official. Switch it off to remove its profile layer. + +New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. + +----- + + +## Understand the implementation + +
+Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
+ +----- + + +## Further Exploration + +[Capability implementation](../../context/tmux-context/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../../context/tmux-context/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- No context is added outside a verified tmux pane. Inherited tmux variables alone do not establish that the process belongs to the pane. + +----- + + +### Dev Note + +
+Maintainer context — click to expand + +None. + +
diff --git a/packages/bundle/tmux-location/README.zh.md b/packages/bundle/tmux-location/README.zh.md new file mode 100644 index 0000000000..9a99373edd --- /dev/null +++ b/packages/bundle/tmux-location/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "在可用时让智能体读取当前 tmux 窗格和布局。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-tmux-location + +[English](README.md) | 中文 + +## 概要 + +当进程在 tmux 中运行时,为 standard、cordis 和 ptc 提供当前 tmux 会话、窗口、窗格和布局。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **tmux 位置**。关闭后移除其配置层。 + +新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 + +----- + + +## 理解实现 + +
+实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
+ +----- + + +## 进一步探索 + +[能力实现](../../context/tmux-context/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../../context/tmux-context/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 在未验证的 tmux 窗格之外不添加上下文。仅继承 tmux 变量不能证明进程属于该窗格。 + +----- + + +### 开发备注 + +
+维护者上下文 — 点击展开 + +无。 + +
diff --git a/packages/bundle/tmux-location/cordis.patch.yml b/packages/bundle/tmux-location/cordis.patch.yml new file mode 100644 index 0000000000..fca150eec7 --- /dev/null +++ b/packages/bundle/tmux-location/cordis.patch.yml @@ -0,0 +1,14 @@ +- preset: preset-standard + insert: + - id: optional-tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- preset: preset-cordis + insert: + - id: optional-tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- preset: preset-ptc + insert: + - id: optional-tmux-context + name: '@deepseek-ai/dsh-tmux-context' diff --git a/packages/bundle/tmux-location/icon.svg b/packages/bundle/tmux-location/icon.svg new file mode 100644 index 0000000000..a4f516ecf2 --- /dev/null +++ b/packages/bundle/tmux-location/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/bundle/tmux-location/locale/en.json b/packages/bundle/tmux-location/locale/en.json new file mode 100644 index 0000000000..ef25ff6492 --- /dev/null +++ b/packages/bundle/tmux-location/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "tmux location", + "description": "Let agents read the current tmux pane and layout when available." + } +} diff --git a/packages/bundle/tmux-location/locale/zh.json b/packages/bundle/tmux-location/locale/zh.json new file mode 100644 index 0000000000..3c5441ce06 --- /dev/null +++ b/packages/bundle/tmux-location/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "tmux 位置", + "description": "在可用时让智能体读取当前 tmux 窗格和布局。" + } +} diff --git a/packages/bundle/tmux-location/package.json b/packages/bundle/tmux-location/package.json new file mode 100644 index 0000000000..a8242e7911 --- /dev/null +++ b/packages/bundle/tmux-location/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tmux-location", + "description": "Let agents read the current tmux pane and layout when available.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/bundle/tmux-location" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-tmux-context": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/bundle/tmux-location/src/index.ts b/packages/bundle/tmux-location/src/index.ts new file mode 100644 index 0000000000..12a0b5988d --- /dev/null +++ b/packages/bundle/tmux-location/src/index.ts @@ -0,0 +1,2 @@ +/** tmux location profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/bundle/tmux-location/tsconfig.json b/packages/bundle/tmux-location/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/bundle/tmux-location/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index fc2756e9e0..7905459979 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2d29970dcd0b2836 zh: 73f6b519546d2b6b /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: - en: 620c6a3ee20848bd - zh: a9429f047a12bb4c + en: f3e6c8d8a4d03f02 + zh: 992f0c2520140c61 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/configuration-pages: en: c32019330fd9586d zh: dd22110abd10159c diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 64f3413aa2..3fa0697d5f 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -62,7 +62,7 @@ A bundle's page shows its full package name under the title. Its **Source** sect ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. Scoped preset rows show their preset target beside the declared id and their current composition state; their controls and configuration pages remain read-only. Equal child ids in different presets retain distinct list and navigation identities. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. ### Configuration pages diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 5a8d5d130c..d2db6bdc48 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -62,7 +62,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。限定到 preset 的行在声明 id 旁显示 preset 目标及当前组合状态;其控件和配置页面保持只读。不同 preset 中相同的子条目 id 在列表和导航中保留不同标识。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 ### 配置页 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 1c1f742537..416eccca03 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -26,7 +26,7 @@ import type { createNavigationStore } from './navigation-store.ts' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts' import { - asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, + asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, packageRowKey, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type RegistryChoice, } from './manager-store.ts' @@ -241,7 +241,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { const query = filter.trim().toLowerCase() const localized = rows.map(row => ({ row, ...rowText(row, resolveText) })) const shown = query === '' ? localized : localized.filter(({ row, title, description }) => - [title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query))) + [title, description, row.rowId, row.preset, row.moduleName].some(value => value?.toLowerCase().includes(query))) return (
@@ -268,9 +268,9 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
    {shown.map(({ row, title, description }) => (
  • @@ -285,7 +285,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { ) : {title}} {description === undefined ? null : {description}} - {title === row.rowId ? null : {row.rowId}} + {title === row.rowId && row.preset === undefined ? null : {row.preset === undefined ? row.rowId : `${row.preset}/${row.rowId}`}} {title === row.moduleName ? null : {row.moduleName}}
    @@ -1370,7 +1370,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined - const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined + const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => packageRowKey(row) === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name)) const setRowEnabled = (row: PackageRow, enabled: boolean): void => { @@ -1378,8 +1378,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) } const configure = (pkg: PackageView): RowConfigure => ({ - has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), - open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId }) }, + has: row => row.preset === undefined && ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), + open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: packageRowKey(row) }) }, }) const packageCard = (pkg: PackageView): ReactNode => ( ): string { + return row.preset === undefined ? row.rowId : JSON.stringify([row.preset, row.rowId]) +} + /** * One bundle as the page shows it: its rows joined with the Host's entries. * @param bundle - the Host's bundle. @@ -416,12 +426,14 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): const live = row.entryId === undefined ? undefined : plugins.find(plugin => plugin.entryId === row.entryId) return { rowId: row.rowId, + ...row.preset === undefined ? {} : { preset: row.preset }, moduleName: row.moduleName, - enabled: live?.enabled ?? false, - phase: live?.fiberPhase ?? null, + enabled: row.composition === undefined ? live?.enabled ?? false : row.composition.enabled === true, + phase: row.composition?.fiberPhase ?? live?.fiberPhase ?? null, ...row.meta === undefined ? {} : { meta: row.meta }, ...row.entryId === undefined ? {} : { entryId: row.entryId }, - ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, + ...row.preset !== undefined ? { readOnlyReason: 'unaddressable' as const } + : live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, } }) return { diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 35f5faea8f..cf34fdb1b4 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -729,6 +729,22 @@ describe('PluginManagerPage', () => { } }) + it('shows distinct preset targets and keeps scoped rows read-only despite a matching root configuration page', () => { + const rows: PackageRow[] = ['preset-standard', 'preset-cordis'].map(preset => ({ + rowId: 'shared', preset, moduleName: 'shared-tool', enabled: true, phase: 'active', + readOnlyReason: 'unaddressable', meta: { title: 'shared' }, + })) + const { actions } = renderTab({ packages: [pkg({ rows })] }, { rows: new Set(['dsh-better-sidebar#shared']) }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + const listed = [...document.querySelectorAll('[data-plugin-row]')] + expect(new Set(listed.map(item => item.getAttribute('data-plugin-row'))).size).toBe(2) + expect(screen.getByText('preset-standard/shared')).toBeTruthy() + expect(screen.getByText('preset-cordis/shared')).toBeTruthy() + for (const item of listed) expect(within(item as HTMLElement).getByRole('switch')).toHaveProperty('disabled', true) + expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'shared') })).toBeNull() + expect(actions.setRowEnabled).not.toHaveBeenCalled() + }) + it('resolves row fields independently from bundle metadata and preserves subpath specifiers', () => { const rows = [ row({ moduleName: '@acme/dsh-sidebar/navigation', meta: { description: { en: 'Navigation description.' } } }), diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 1a9fc03f8e..5d231393fa 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -8,7 +8,7 @@ import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginIn import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ConfigLedger } from '../src/client/config-ledger.ts' -import { offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' +import { offeredRegistries, packageView, packageRowKey, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0' } } const ROW_ENTRY = 'include:sidebar' as PluginEntryId @@ -114,6 +114,19 @@ it('hands a custom page the shared configuration form of its entry', () => { }) describe('packageView', () => { + it('keeps scoped rows read-only with distinct identities and their observed preset state', () => { + const view = packageView({ ...BUNDLE, rows: [ + { rowId: 'shared', preset: 'preset-standard', moduleName: 'tool', composition: { enabled: true, fiberPhase: 'active' } }, + { rowId: 'shared', preset: 'preset-cordis', moduleName: 'tool', composition: { enabled: 'conditional', fiberPhase: null } }, + ] }, []) + expect(view.rows).toEqual([ + { rowId: 'shared', preset: 'preset-standard', moduleName: 'tool', enabled: true, phase: 'active', readOnlyReason: 'unaddressable' }, + { rowId: 'shared', preset: 'preset-cordis', moduleName: 'tool', enabled: false, phase: null, readOnlyReason: 'unaddressable' }, + ]) + expect(new Set(view.rows.map(packageRowKey)).size).toBe(2) + expect(packageRowKey({ rowId: 'ordinary' })).toBe('ordinary') + }) + it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', diff --git a/packages/experimental/badge-skill-bundle/README.md b/packages/experimental/badge-skill-bundle/README.md new file mode 100644 index 0000000000..4d30b81278 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/README.md @@ -0,0 +1,78 @@ +--- +description: "Offer the powered-by-DSH badge as a discoverable skill." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-badge-skill-bundle + +English | [中文](README.zh.md) + +## Summary + +Offer the experimental DSH badge skill to Agents whose presets load skills. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **DSH badge skill** in Official. Switch it off to remove its profile layer. + +This host-level layer applies independently of preset selection. It adds no tool to the minimal preset or the host tool catalog. + +----- + + +## Understand the implementation + +
    +Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes the provider on the host. Later profile patches can override its settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
    + +----- + + +## Further Exploration + +[Capability implementation](../skill-badge/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../skill-badge/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle. + +----- + + +### Dev Note + +
    +Maintainer context — click to expand + +None. + +
    diff --git a/packages/experimental/badge-skill-bundle/README.zh.md b/packages/experimental/badge-skill-bundle/README.zh.md new file mode 100644 index 0000000000..f0092bf393 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "提供可发现的 powered-by-DSH 徽章技能。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-badge-skill-bundle + +[English](README.md) | 中文 + +## 概要 + +向预设支持技能加载的智能体提供实验性 DSH 徽章技能。共享提供者不添加模型工具。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **DSH 徽章技能**。关闭后移除其配置层。 + +此 Host 层配置与预设选择无关,不会向 minimal 预设或 Host 工具目录添加工具。 + +----- + + +## 理解实现 + +
    +实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 在 Host 上添加提供者。后续配置补丁可以覆盖其设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
    + +----- + + +## 进一步探索 + +[能力实现](../skill-badge/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../skill-badge/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 预设必须支持技能发现和加载才能使用徽章。此 bundle 不会为 minimal 预设添加技能工具。 + +----- + + +### 开发备注 + +
    +维护者上下文 — 点击展开 + +无。 + +
    diff --git a/packages/experimental/badge-skill-bundle/cordis.patch.yml b/packages/experimental/badge-skill-bundle/cordis.patch.yml new file mode 100644 index 0000000000..9cf002be57 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: optional-skill-badge + name: '@deepseek-ai/dsh-experimental-skill-badge' diff --git a/packages/experimental/badge-skill-bundle/icon.svg b/packages/experimental/badge-skill-bundle/icon.svg new file mode 100644 index 0000000000..ac9fe534e9 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/experimental/badge-skill-bundle/locale/en.json b/packages/experimental/badge-skill-bundle/locale/en.json new file mode 100644 index 0000000000..42cfcce322 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DSH badge skill", + "description": "Offer the powered-by-DSH badge as a discoverable skill." + } +} diff --git a/packages/experimental/badge-skill-bundle/locale/zh.json b/packages/experimental/badge-skill-bundle/locale/zh.json new file mode 100644 index 0000000000..ce1b448ab6 --- /dev/null +++ b/packages/experimental/badge-skill-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DSH 徽章技能", + "description": "提供可发现的 powered-by-DSH 徽章技能。" + } +} diff --git a/packages/experimental/badge-skill-bundle/package.json b/packages/experimental/badge-skill-bundle/package.json new file mode 100644 index 0000000000..8b2804094f --- /dev/null +++ b/packages/experimental/badge-skill-bundle/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-experimental-badge-skill-bundle", + "description": "Offer the powered-by-DSH badge as a discoverable skill.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/experimental/badge-skill-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-experimental-skill-badge": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/experimental/badge-skill-bundle/src/index.ts b/packages/experimental/badge-skill-bundle/src/index.ts new file mode 100644 index 0000000000..a84b8f4e4a --- /dev/null +++ b/packages/experimental/badge-skill-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** DSH badge skill profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/badge-skill-bundle/tsconfig.json b/packages/experimental/badge-skill-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/badge-skill-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/experimental/ralph-bundle/README.md b/packages/experimental/ralph-bundle/README.md new file mode 100644 index 0000000000..eb6f8a1ed4 --- /dev/null +++ b/packages/experimental/ralph-bundle/README.md @@ -0,0 +1,78 @@ +--- +description: "Run explicitly requested repeated work with fresh agents." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-ralph-bundle + +English | [中文](README.zh.md) + +## Summary + +Add the experimental Ralph tool to standard, cordis, and ptc for human-requested repeated work with fresh child agents. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **Ralph loops** in Official. Switch it off to remove its profile layer. + +New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. + +----- + + +## Understand the implementation + +
    +Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
    + +----- + + +## Further Exploration + +[Capability implementation](../tool-ralph/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../tool-ralph/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- Ralph requires explicit human intent to run a loop and can consume substantial model tokens across rounds. Its workflow engine is isolated from other workflow tools. + +----- + + +### Dev Note + +
    +Maintainer context — click to expand + +None. + +
    diff --git a/packages/experimental/ralph-bundle/README.zh.md b/packages/experimental/ralph-bundle/README.zh.md new file mode 100644 index 0000000000..1da08dde54 --- /dev/null +++ b/packages/experimental/ralph-bundle/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "使用新智能体执行用户明确要求的循环工作。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-ralph-bundle + +[English](README.md) | 中文 + +## 概要 + +为 standard、cordis 和 ptc 添加实验性 Ralph 工具,使用新的子智能体执行用户要求的循环工作。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **Ralph 循环**。关闭后移除其配置层。 + +新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 + +----- + + +## 理解实现 + +
    +实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
    + +----- + + +## 进一步探索 + +[能力实现](../tool-ralph/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../tool-ralph/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- Ralph 需要用户明确要求执行循环,多轮运行可能消耗大量模型 token。其工作流引擎与其他工作流工具隔离。 + +----- + + +### 开发备注 + +
    +维护者上下文 — 点击展开 + +无。 + +
    diff --git a/packages/experimental/ralph-bundle/cordis.patch.yml b/packages/experimental/ralph-bundle/cordis.patch.yml new file mode 100644 index 0000000000..8419356e29 --- /dev/null +++ b/packages/experimental/ralph-bundle/cordis.patch.yml @@ -0,0 +1,53 @@ +- preset: preset-standard + insert: + - id: optional-ralph + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: optional-ralph-workflow + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: optional-tool-ralph + name: '@deepseek-ai/dsh-experimental-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +- preset: preset-cordis + insert: + - id: optional-ralph + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: optional-ralph-workflow + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: optional-tool-ralph + name: '@deepseek-ai/dsh-experimental-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +- preset: preset-ptc + insert: + - id: optional-ralph + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: optional-ralph-workflow + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: optional-tool-ralph + name: '@deepseek-ai/dsh-experimental-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 diff --git a/packages/experimental/ralph-bundle/icon.svg b/packages/experimental/ralph-bundle/icon.svg new file mode 100644 index 0000000000..9d4bc1bdf5 --- /dev/null +++ b/packages/experimental/ralph-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/experimental/ralph-bundle/locale/en.json b/packages/experimental/ralph-bundle/locale/en.json new file mode 100644 index 0000000000..bbcf2b481e --- /dev/null +++ b/packages/experimental/ralph-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Ralph loops", + "description": "Run explicitly requested repeated work with fresh agents." + } +} diff --git a/packages/experimental/ralph-bundle/locale/zh.json b/packages/experimental/ralph-bundle/locale/zh.json new file mode 100644 index 0000000000..6f3e159c92 --- /dev/null +++ b/packages/experimental/ralph-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Ralph 循环", + "description": "使用新智能体执行用户明确要求的循环工作。" + } +} diff --git a/packages/experimental/ralph-bundle/package.json b/packages/experimental/ralph-bundle/package.json new file mode 100644 index 0000000000..1265c006cb --- /dev/null +++ b/packages/experimental/ralph-bundle/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-experimental-ralph-bundle", + "description": "Run explicitly requested repeated work with fresh agents.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/experimental/ralph-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-experimental-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-workflow-ptc": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/experimental/ralph-bundle/src/index.ts b/packages/experimental/ralph-bundle/src/index.ts new file mode 100644 index 0000000000..7f95a6900b --- /dev/null +++ b/packages/experimental/ralph-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** Ralph loops profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/ralph-bundle/tsconfig.json b/packages/experimental/ralph-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/ralph-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/experimental/session-titles-bundle/README.md b/packages/experimental/session-titles-bundle/README.md new file mode 100644 index 0000000000..17ba1483f7 --- /dev/null +++ b/packages/experimental/session-titles-bundle/README.md @@ -0,0 +1,78 @@ +--- +description: "Refresh session titles as new human prompts arrive." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-session-titles-bundle + +English | [中文](README.zh.md) + +## Summary + +Generate experimental session titles from the conversation's eligible human prompts. This replaces the first-prompt title provider and applies across presets. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **Conversation-following titles** in Official. Switch it off to remove its profile layer. + +This host-level layer applies independently of preset selection. It adds no tool to the minimal preset or the host tool catalog. + +----- + + +## Understand the implementation + +
    +Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes the provider on the host. Later profile patches can override its settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. The layer disables `session-title-llm` before selecting its replacement; deselection restores the default provider. + +
    + +----- + + +## Further Exploration + +[Capability implementation](../session-title-all-prompts-llm/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../session-title-all-prompts-llm/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- Automatic title failures retain the previous title. The 4096-byte title-input budget rejects oversized input instead of truncating conversation history. + +----- + + +### Dev Note + +
    +Maintainer context — click to expand + +None. + +
    diff --git a/packages/experimental/session-titles-bundle/README.zh.md b/packages/experimental/session-titles-bundle/README.zh.md new file mode 100644 index 0000000000..9e4d6ec4c3 --- /dev/null +++ b/packages/experimental/session-titles-bundle/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "在收到新的用户提示时更新会话标题。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-session-titles-bundle + +[English](README.md) | 中文 + +## 概要 + +根据对话中符合条件的用户提示生成实验性会话标题。此功能替换首条提示标题提供者,并适用于所有预设。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **随对话更新的标题**。关闭后移除其配置层。 + +此 Host 层配置与预设选择无关,不会向 minimal 预设或 Host 工具目录添加工具。 + +----- + + +## 理解实现 + +
    +实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 在 Host 上添加提供者。后续配置补丁可以覆盖其设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 此层禁用 `session-title-llm` 后选择替代提供者;取消选择会恢复默认提供者。 + +
    + +----- + + +## 进一步探索 + +[能力实现](../session-title-all-prompts-llm/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../session-title-all-prompts-llm/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 自动标题生成失败时保留旧标题。标题输入预算为 4096 字节,超限时拒绝输入而不截断对话历史。 + +----- + + +### 开发备注 + +
    +维护者上下文 — 点击展开 + +无。 + +
    diff --git a/packages/experimental/session-titles-bundle/cordis.patch.yml b/packages/experimental/session-titles-bundle/cordis.patch.yml new file mode 100644 index 0000000000..56c16526dd --- /dev/null +++ b/packages/experimental/session-titles-bundle/cordis.patch.yml @@ -0,0 +1,12 @@ +- id: session-title-llm + disabled: true + +- insert: + - id: optional-session-title-all-prompts + name: '@deepseek-ai/dsh-experimental-session-title-all-prompts-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 diff --git a/packages/experimental/session-titles-bundle/icon.svg b/packages/experimental/session-titles-bundle/icon.svg new file mode 100644 index 0000000000..7dbda4cd6f --- /dev/null +++ b/packages/experimental/session-titles-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/experimental/session-titles-bundle/locale/en.json b/packages/experimental/session-titles-bundle/locale/en.json new file mode 100644 index 0000000000..e0213ebddc --- /dev/null +++ b/packages/experimental/session-titles-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Conversation-following titles", + "description": "Refresh session titles as new human prompts arrive." + } +} diff --git a/packages/experimental/session-titles-bundle/locale/zh.json b/packages/experimental/session-titles-bundle/locale/zh.json new file mode 100644 index 0000000000..87b2dec889 --- /dev/null +++ b/packages/experimental/session-titles-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "随对话更新的标题", + "description": "在收到新的用户提示时更新会话标题。" + } +} diff --git a/packages/experimental/session-titles-bundle/package.json b/packages/experimental/session-titles-bundle/package.json new file mode 100644 index 0000000000..5da7bcd9b5 --- /dev/null +++ b/packages/experimental/session-titles-bundle/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-experimental-session-titles-bundle", + "description": "Refresh session titles as new human prompts arrive.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/experimental/session-titles-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-experimental-session-title-all-prompts-llm": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/experimental/session-titles-bundle/src/index.ts b/packages/experimental/session-titles-bundle/src/index.ts new file mode 100644 index 0000000000..1b2c8b1664 --- /dev/null +++ b/packages/experimental/session-titles-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** Conversation-following titles profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/session-titles-bundle/tsconfig.json b/packages/experimental/session-titles-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/session-titles-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/experimental/terminal-bundle/README.md b/packages/experimental/terminal-bundle/README.md new file mode 100644 index 0000000000..ea39f9e3fb --- /dev/null +++ b/packages/experimental/terminal-bundle/README.md @@ -0,0 +1,78 @@ +--- +description: "Keep shell processes alive across tool calls." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-terminal-bundle + +English | [中文](README.zh.md) + +## Summary + +Add six experimental terminal tools to standard, cordis, and ptc. Each Agent receives an isolated terminal registry with the platform shell backend. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In Web or Desktop, open Plugins and enable **Persistent terminals** in Official. Switch it off to remove its profile layer. + +New Agents and subsequently reopened Sessions use the selected composition; live Agents retain their existing plugins. The minimal preset and host-level tool catalog remain unchanged. + +----- + + +## Understand the implementation + +
    +Implementation details — click to expand + +[`cordis.patch.yml`](cordis.patch.yml) contributes rows through `preset: preset-standard`, `preset: preset-cordis`, and `preset: preset-ptc`. Later profile patches can override their settings. The [profile composer](../../boot/app-boot/README.md) owns patch ordering and errors. + +
    + +----- + + +## Further Exploration + +[Capability implementation](../tool-terminal/README.md) + +----- + + +## Model Experience + +Indirectly, through the [capability implementation](../tool-terminal/README.md), which owns model context and results. + +#### KV Cache effect + +The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + +## Known Limitations and Deferred Work + + + +- Terminals belong to the Agent that created them. POSIX uses Bash and Windows uses PowerShell; the bundle does not share the minimal preset's terminal registry. + +----- + + +### Dev Note + +
    +Maintainer context — click to expand + +None. + +
    diff --git a/packages/experimental/terminal-bundle/README.zh.md b/packages/experimental/terminal-bundle/README.zh.md new file mode 100644 index 0000000000..b9eeff41d7 --- /dev/null +++ b/packages/experimental/terminal-bundle/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "让 shell 进程在多次工具调用之间保持运行。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-experimental-terminal-bundle + +[English](README.md) | 中文 + +## 概要 + +为 standard、cordis 和 ptc 添加六个实验性终端工具。每个智能体获得隔离的终端注册表和平台 shell 后端。 Web 和 Desktop 随附此功能,默认关闭。在插件页选择它即可启用。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +在 Web 或 Desktop 中打开插件页,在官方分组启用 **持久终端**。关闭后移除其配置层。 + +新智能体和随后重新打开的会话使用所选组合;正在运行的智能体保留已有插件。minimal 预设和 Host 层工具目录保持不变。 + +----- + + +## 理解实现 + +
    +实现细节 — 点击展开 + +[`cordis.patch.yml`](cordis.patch.yml) 通过 `preset: preset-standard`、`preset: preset-cordis` 和 `preset: preset-ptc` 贡献插件行。后续配置补丁可以覆盖这些设置。[配置组合器](../../boot/app-boot/README.zh.md) 负责补丁顺序和错误处理。 + +
    + +----- + + +## 进一步探索 + +[能力实现](../tool-terminal/README.zh.md) + +----- + + +## 模型体验 + +间接影响,通过[能力实现](../tool-terminal/README.zh.md) 贡献模型上下文和结果。 + +#### KV Cache 影响 + +此配置层不直接添加请求内容;能力实现负责工具目录、提示和结果的缓存影响。 + +## 已知限制与暂缓工作 + + + +- 终端属于创建它们的智能体。POSIX 使用 Bash,Windows 使用 PowerShell;此 bundle 不共享 minimal 预设的终端注册表。 + +----- + + +### 开发备注 + +
    +维护者上下文 — 点击展开 + +无。 + +
    diff --git a/packages/experimental/terminal-bundle/cordis.patch.yml b/packages/experimental/terminal-bundle/cordis.patch.yml new file mode 100644 index 0000000000..20d39b1352 --- /dev/null +++ b/packages/experimental/terminal-bundle/cordis.patch.yml @@ -0,0 +1,62 @@ +- preset: preset-standard + insert: + - id: optional-persistent-terminals + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: optional-terminal-registry + name: '@deepseek-ai/dsh-terminal' + - id: optional-terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' + - id: optional-terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + - id: optional-tool-terminal + name: '@deepseek-ai/dsh-experimental-tool-terminal' + +- preset: preset-cordis + insert: + - id: optional-persistent-terminals + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: optional-terminal-registry + name: '@deepseek-ai/dsh-terminal' + - id: optional-terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' + - id: optional-terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + - id: optional-tool-terminal + name: '@deepseek-ai/dsh-experimental-tool-terminal' + +- preset: preset-ptc + insert: + - id: optional-persistent-terminals + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: optional-terminal-registry + name: '@deepseek-ai/dsh-terminal' + - id: optional-terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' + - id: optional-terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + - id: optional-tool-terminal + name: '@deepseek-ai/dsh-experimental-tool-terminal' diff --git a/packages/experimental/terminal-bundle/icon.svg b/packages/experimental/terminal-bundle/icon.svg new file mode 100644 index 0000000000..05923f6581 --- /dev/null +++ b/packages/experimental/terminal-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/experimental/terminal-bundle/locale/en.json b/packages/experimental/terminal-bundle/locale/en.json new file mode 100644 index 0000000000..e90aaabf56 --- /dev/null +++ b/packages/experimental/terminal-bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Persistent terminals", + "description": "Keep shell processes alive across tool calls." + } +} diff --git a/packages/experimental/terminal-bundle/locale/zh.json b/packages/experimental/terminal-bundle/locale/zh.json new file mode 100644 index 0000000000..fa392a4962 --- /dev/null +++ b/packages/experimental/terminal-bundle/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "持久终端", + "description": "让 shell 进程在多次工具调用之间保持运行。" + } +} diff --git a/packages/experimental/terminal-bundle/package.json b/packages/experimental/terminal-bundle/package.json new file mode 100644 index 0000000000..96c173c49e --- /dev/null +++ b/packages/experimental/terminal-bundle/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-experimental-terminal-bundle", + "description": "Keep shell processes alive across tool calls.", + "version": "0.2.1-alpha.1", + "icon": "./icon.svg", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-harness/deepseek-harness.git", + "directory": "packages/experimental/terminal-bundle" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "icon.svg", + "locale/*.json", + "lib/index.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-experimental-tool-terminal": "workspace:*", + "@deepseek-ai/dsh-terminal": "workspace:*", + "@deepseek-ai/dsh-terminal-bash": "workspace:*" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/experimental/terminal-bundle/src/index.ts b/packages/experimental/terminal-bundle/src/index.ts new file mode 100644 index 0000000000..b76558cdb7 --- /dev/null +++ b/packages/experimental/terminal-bundle/src/index.ts @@ -0,0 +1,2 @@ +/** Persistent terminals profile layer; runtime entries are declared in cordis.patch.yml. */ +export {} diff --git a/packages/experimental/terminal-bundle/tsconfig.json b/packages/experimental/terminal-bundle/tsconfig.json new file mode 100644 index 0000000000..f1a449634c --- /dev/null +++ b/packages/experimental/terminal-bundle/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 76c5776f1a..14bb6debe7 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -78,6 +78,8 @@ Retained historical scenarios keep their canonical Session files unchanged and s Headless/ACP and SDK adapters compare raw catalog child-creation times with harvested child headers before normalization. Refresh preserves the chosen child header time in every matching catalog entry, including inherited copies, so retained header clocks cannot diverge from freshly written catalog clocks. Committed historical inconsistencies remain unchanged migration-refusal evidence; normalizing their clocks does not establish successful migration. +`materializeProfilePatch` keeps an authored patch usable after moving it into an isolated launch directory. It resolves inserted relative module names beside the source patch and links their resolvable bare packages into the temporary profile, including nested groups, preset-scoped insertions, and inserted complete preset definitions. Preset target ids and `!!js` expressions remain literal. + ### Record, replay, and refresh `pnpm run test:snapshot:record` calls the live LLM and writes the harvested current generation under its canonical versioned filename. Record and refresh never rename or delete a completed generation, including generations of a child role absent from a later run; reviewed source-tree curation removes a predecessor only after the same role has a verified current replacement. Scenarios with an explicit `sessionFormat` remain read-only in record mode. `pnpm run test:snapshot:refresh` stays keyless, runs the selected highest replay input, and writes stdout, owned prompt and tool-schema sidecars, and a fresh current-generation comparable Session output; retained historical scenarios write the separate writer-output oracles instead of a canonical current-format replay generation. Headless and SDK scenarios may add a local `runtime.cordis.yml` patch for execution fixtures. Each composition owner keeps its replay patch beside its live patch; top-level `snapshots/` owns Session-driven scenarios, while other expected outputs stay beside their owning package. [`dsh-llm-replay`](../llm-replay/README.md) serves the recorded streams selected through `DSH_SNAPSHOT_*` environment values. diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index cf476dee7f..e615f87f6f 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -78,6 +78,8 @@ spill 场景通过真实本地提供方保存到私有临时根目录。fixture Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创建时间与采集到的子 header 比较。刷新会在每条匹配的目录记录中保留选定的子 header 时间,包括继承副本,防止保留的 header 时钟与新写入的目录时钟分歧。已提交的历史不一致数据保持不变,作为迁移拒绝证据;将其时钟规范化不能证明迁移成功。 +`materializeProfilePatch` 让编写好的 patch 移入隔离启动目录后仍可使用。它以源 patch 所在目录解析插入条目的相对模块名,并把可解析的裸包链接到临时 profile,涵盖嵌套分组、限定到 preset 的插入,以及插入的完整 preset 定义。preset 目标 id 和 `!!js` 表达式保持字面量。 + ### 录制、回放与刷新 `pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并在规范具名版本文件下写入收集到的当前 generation。record 与 refresh 绝不重命名或删除已完成的 generation,即使后续运行不再产生某个 child 角色也一样;受审阅的源树整理只有在同角色存在已验证的当前替代文件后才移除前代。显式声明 `sessionFormat` 的场景在录制模式下保持只读。`pnpm run test:snapshot:refresh` 保持无密钥,运行选定的最高 replay 输入,并写入 stdout、各 pin 自有的提示词与工具 schema 伴随文件,以及新鲜当前 generation 的可比较 Session 输出;保留历史输入的场景写入单独的 writer 输出比较基准,而非规范当前格式 replay 代际。Headless 与 SDK 场景可通过本地 `runtime.cordis.yml` patch 添加执行 fixture。每个组合 owner 把 replay patch 放在 live patch 旁;顶层 `snapshots/` 拥有 Session 驱动场景,其他预期输出留在其 package owner 旁。[`dsh-llm-replay`](../llm-replay/README.zh.md) 提供通过 `DSH_SNAPSHOT_*` 环境值选择的已记录流。 diff --git a/packages/test-support/session-snapshot/src/launcher.ts b/packages/test-support/session-snapshot/src/launcher.ts index a99ecac2c0..c0b3faa8fe 100644 --- a/packages/test-support/session-snapshot/src/launcher.ts +++ b/packages/test-support/session-snapshot/src/launcher.ts @@ -407,6 +407,7 @@ function linkProfilePackage(source: string, cwd: string, profile: string, packag /** * Copy one authored patch into the launch cwd with relative plugin names made absolute. + * Scoped insertions and inserted preset definitions retain package lookup and nested module paths. * @param source - authored profile patch path. * @param cwd - isolated process cwd whose profile receives package links. * @param profile - profile whose local package lookup receives the test links. @@ -433,6 +434,10 @@ export function materializeProfilePatch( if (entry.group === true && Array.isArray(entry.config)) { for (const child of entry.config as ProfilePatchEntry[]) visitEntry(child) } + const config = entry.config as { plugins?: unknown } | undefined + if (entry.name === '@deepseek-ai/dsh-agent-preset' && Array.isArray(config?.plugins)) { + for (const child of config.plugins as ProfilePatchEntry[]) visitEntry(child) + } } for (const patch of patches) { if (typeof patch.name === 'string') patch.name = resolveName(patch.name) diff --git a/packages/test-support/session-snapshot/tests/profile-patches.spec.ts b/packages/test-support/session-snapshot/tests/profile-patches.spec.ts new file mode 100644 index 0000000000..98f90f98a4 --- /dev/null +++ b/packages/test-support/session-snapshot/tests/profile-patches.spec.ts @@ -0,0 +1,72 @@ +/** Materialized profile patches retain scoped declaration paths and package ownership. */ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { load } from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { expect, it, onTestFinished } from 'vitest' +import { materializeProfilePatch } from '../src/launcher.ts' + +it('discovers relocated packages and relative modules in scoped and complete preset insertions', () => { + const dir = mkdtempSync(join(tmpdir(), 'snapshot-preset-patch-')) + onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) }) + const sourceDir = join(dir, 'authored') + const runDir = join(dir, 'run') + const targetDir = join(runDir, 'patches') + mkdirSync(sourceDir) + mkdirSync(targetDir, { recursive: true }) + const packages = ['@deepseek-ai/dsh-experimental-tool-terminal', '@deepseek-ai/dsh-agent-preset'] + for (const name of packages) { + const packageDir = join(sourceDir, 'node_modules', name) + mkdirSync(packageDir, { recursive: true }) + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version: '1.0.0' })) + } + const source = join(sourceDir, 'cordis.patch.yml') + writeFileSync(source, [ + '- preset: preset-ptc', + ' insert:', + ' - id: scoped-group', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: relocated-terminal', + " name: '@deepseek-ai/dsh-experimental-tool-terminal'", + ' - id: relative', + ' name: ./relative.mjs', + ' disabled: !!js process.platform === "win32"', + '- insert:', + ' - id: preset-added', + " name: '@deepseek-ai/dsh-agent-preset'", + ' config:', + ' id: added', + ' plugins:', + ' - id: nested', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: child', + ' name: ../child.mjs', + ' - id: terminal', + " name: '@deepseek-ai/dsh-experimental-tool-terminal'", + '- preset: preset-ptc', + ' id: relative', + ' disabled: true', + '', + ].join('\n')) + const output = materializeProfilePatch(source, runDir, 'web', targetDir, 0) + expect(load(readFileSync(output, 'utf8'), { schema: entryListSchema })).toMatchObject([ + { preset: 'preset-ptc', insert: [{ config: [ + { name: packages[0] }, + { name: pathToFileURL(join(sourceDir, 'relative.mjs')).href, disabled: { __jsExpr: 'process.platform === "win32"' } }, + ] }] }, + { insert: [{ config: { plugins: [{ config: [ + { name: pathToFileURL(join(dir, 'child.mjs')).href }, { name: packages[0] }, + ] }] } }] }, + { preset: 'preset-ptc', id: 'relative', disabled: true }, + ]) + for (const name of packages) { + expect(realpathSync(join(runDir, '.dsh', 'profiles', 'web', 'node_modules', name))) + .toBe(realpathSync(join(sourceDir, 'node_modules', name))) + } +}) diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index b1970e4a12..d7c451f851 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -4,6 +4,7 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts' import { readPluginMeta } from '../packages/boot/app-boot/src/package-meta.ts' import { OPTIONAL_BUNDLES, PROFILE_TEMPLATES, bundlePatchPaths, composeEntries } from '../packages/boot/app-boot/src/profile.ts' @@ -27,6 +28,26 @@ function bundle(name: string): { dir: string; patches: ReturnType loadOverlayPatches('test', path)) } } +const lightweight = [ + '@deepseek-ai/dsh-session-search', + '@deepseek-ai/dsh-string-editor', + '@deepseek-ai/dsh-tmux-location', + '@deepseek-ai/dsh-experimental-ralph-bundle', + '@deepseek-ai/dsh-experimental-terminal-bundle', + '@deepseek-ai/dsh-experimental-badge-skill-bundle', + '@deepseek-ai/dsh-experimental-session-titles-bundle', +] + +function presetRows(entries: EntryOptions[], id: string): EntryOptions[] { + const preset = entries.find(entry => entry.id === id) + if (preset === undefined) throw new Error(`missing preset ${id}`) + return (preset.config as { plugins: EntryOptions[] }).plugins +} + +function flattenRows(entries: EntryOptions[]): EntryOptions[] { + return entries.flatMap(entry => [entry, ...entry.group && Array.isArray(entry.config) ? flattenRows(entry.config as EntryOptions[]) : []]) +} + describe('optional bundles', () => { const shipped = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'].map(name => bundle(name).patches) @@ -42,12 +63,15 @@ describe('optional bundles', () => { const { patches } = bundle(name) const warnings: string[] = [] const composed = composeEntries([...shipped, patches], message => warnings.push(message)) - const ids = new Set(composed.map(entry => entry.id)) expect(warnings).toEqual([]) - // Inserted rows carry stable ids at the profile root, so a later profile patch can configure or disable them. - for (const row of patches.flatMap(patch => patch.insert ?? [])) { - expect(typeof row.id).toBe('string') - expect(ids.has(row.id)).toBe(true) + // Inserted ids remain addressable inside their selected profile or preset tree. + for (const patch of patches) { + const rows = typeof patch.preset === 'string' ? presetRows(composed, patch.preset) : composed + const ids = new Set(flattenRows(rows).map(entry => entry.id)) + for (const row of patch.insert ?? []) { + expect(typeof row.id).toBe('string') + expect(ids.has(row.id)).toBe(true) + } } // One top-level row per id: a duplicate declaration leaves the Loader with the last one, silently // replacing the layer that declared the id first. @@ -57,7 +81,7 @@ describe('optional bundles', () => { // row, and the override keeps the package the shipped layer declared on it. const shippedComposed = composeEntries([...shipped]) for (const patch of patches) { - if (patch.insert !== undefined || typeof patch.id !== 'string') continue + if (patch.insert !== undefined || typeof patch.id !== 'string' || patch.preset !== undefined) continue const matches = composed.filter(entry => entry.id === patch.id) expect(matches).toHaveLength(1) expect(matches[0]?.name).toBe(shippedComposed.find(entry => entry.id === patch.id)?.name) @@ -75,6 +99,51 @@ describe('optional bundles', () => { } }) + it('ships all seven lightweight bundles without selecting them in a default template', () => { + const defaults = Object.values(PROFILE_TEMPLATES).flatMap(template => template.bundles) + for (const name of lightweight) { + expect(OPTIONAL_BUNDLES).toContain(name) + expect(defaults).not.toContain(name) + } + }) + + it('composes every lightweight selection without changing minimal or adding host tools', () => { + const baseline = composeEntries(shipped) + const layers = lightweight.map(name => bundle(name).patches) + for (let mask = 0; mask < 2 ** lightweight.length; mask += 1) { + const selected = layers.filter((_layer, index) => (mask & 2 ** index) !== 0) + const warnings: string[] = [] + const composed = composeEntries([...shipped, ...selected], warning => warnings.push(warning)) + expect(warnings).toEqual([]) + expect(presetRows(composed, 'preset-minimal')).toEqual(presetRows(baseline, 'preset-minimal')) + const addedHost = composed.filter(row => !baseline.some(existing => existing.id === row.id)) + expect(addedHost.every(row => ['optional-skill-badge', 'optional-session-title-all-prompts'].includes(row.id ?? ''))).toBe(true) + for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) { + const rows = flattenRows(presetRows(composed, id)) + const ids = rows.map(row => row.id) + expect(new Set(ids).size).toBe(ids.length) + } + } + }) + + it('contributes tools inside all full presets with independently isolated services', () => { + const composed = composeEntries([...shipped, ...lightweight.map(name => bundle(name).patches)]) + for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) { + const roots = presetRows(composed, id) + const names = flattenRows(roots).map(row => row.name) + for (const name of [ + '@deepseek-ai/dsh-tool-session-query', '@deepseek-ai/dsh-tool-str-replace-editor', '@deepseek-ai/dsh-tmux-context', + '@deepseek-ai/dsh-experimental-tool-ralph', '@deepseek-ai/dsh-experimental-tool-terminal', + ]) expect(names).toContain(name) + expect(roots.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true }) + expect(roots.find(row => row.id === 'optional-persistent-terminals')?.isolate).toEqual({ terminals: true }) + } + expect(composed.find(row => row.id === 'session-query-sqlite')?.config).toEqual({ path: ':memory:', openAt: 'first-search' }) + expect(composed.find(row => row.id === 'session-title-llm')?.disabled).toBe(true) + expect(composed.find(row => row.id === 'optional-session-title-all-prompts')?.name) + .toBe('@deepseek-ai/dsh-experimental-session-title-all-prompts-llm') + }) + it('delivers the Schedule service and task page without a Host clock row', () => { const composed = composeEntries(shipped) // The delivered composition carries the Host Schedule service and its task diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index b11bcedeb0..1c7f0a9229 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -138,6 +138,31 @@ describe('default product isolation', () => { expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`optional bundle ${layer} must not be a default bundle`) }) + it.each(['dependencies', 'source', 'patch'])( + 'checks a nonexperimental optional bundle for experimental %s', (kind) => { + const root = fixture() + const layer = '@deepseek-ai/dsh-optional-layer' + write(root, 'packages/bundle/optional-layer/package.json', { + name: layer, icon: './icon.svg', exports: { './locale/*.json': './locale/*.json' }, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }) + write(root, 'packages/bundle/optional-layer/src/index.ts', 'export {}') + write(root, 'packages/bundle/optional-layer/cordis.patch.yml', []) + manifest(root, 'apps/cli/package.json', { dependencies: { [core]: 'workspace:*', [layer]: 'workspace:*' } }) + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\nexport const OPTIONAL_BUNDLES = ['${layer}']\n`) + expect(verifyDefaultProductIsolation(root).failures).toEqual([]) + if (kind === 'dependencies') { + manifest(root, 'packages/bundle/optional-layer/package.json', { dependencies: { [experimental]: 'workspace:*' } }) + } else if (kind === 'source') { + write(root, 'packages/bundle/optional-layer/src/index.ts', `import '${experimental}'`) + } else { + write(root, 'packages/bundle/optional-layer/cordis.patch.yml', [{ preset: 'preset-standard', insert: [{ name: experimental }] }]) + } + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain('must not include experimental packages') + }, + ) + it('requires each optional bundle to be a runtime dependency that declares a bundle patch, an icon, and locale metadata', () => { const root = fixture() write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index e2305d9756..0120368412 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -293,7 +293,8 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes const { manifest } = pkg for (const section of RUNTIME_SECTIONS) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { - if (pkg === cli && section === 'dependencies' && optionalBundles.has(name)) continue + const target = packages.get(name) + if (pkg === cli && section === 'dependencies' && optionalBundles.has(name) && target !== undefined && isExperimental(target)) continue dependency(name, range, pkg, `${manifest.name} ${section}`) } } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 99c96f919f..f38a18bfba 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -175,6 +175,13 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/host/open-in-app': { kind: 'none', reason: 'Host routes opening desktop applications for a human; registers nothing model-facing.' }, 'packages/client/ui-open-in-app': { kind: 'none', reason: 'Browser-side split button opening the workspace directory for a human; registers nothing model-facing.' }, + 'packages/bundle/session-search': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/bundle/string-editor': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/bundle/tmux-location': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/experimental/ralph-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/experimental/terminal-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/experimental/badge-skill-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, + 'packages/experimental/session-titles-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9d684ddc2d..a727cb716f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,13 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-session-search": ["./packages/bundle/session-search/src/index.ts"], + "@deepseek-ai/dsh-string-editor": ["./packages/bundle/string-editor/src/index.ts"], + "@deepseek-ai/dsh-tmux-location": ["./packages/bundle/tmux-location/src/index.ts"], + "@deepseek-ai/dsh-experimental-ralph-bundle": ["./packages/experimental/ralph-bundle/src/index.ts"], + "@deepseek-ai/dsh-experimental-terminal-bundle": ["./packages/experimental/terminal-bundle/src/index.ts"], + "@deepseek-ai/dsh-experimental-badge-skill-bundle": ["./packages/experimental/badge-skill-bundle/src/index.ts"], + "@deepseek-ai/dsh-experimental-session-titles-bundle": ["./packages/experimental/session-titles-bundle/src/index.ts"], "@deepseek-ai/dsh-client-product-analytics": ["./packages/client/product-analytics/src/index.ts"], "@deepseek-ai/dsh-client-product-analytics/types": ["./packages/client/product-analytics/src/events.ts"], "@deepseek-ai/dsh-client-product-analytics/client": ["./packages/client/product-analytics/src/client/index.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 359e8761a6..eec3f1741a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -118,6 +118,7 @@ "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/ptc-round.e2e.ts", "apps/web/tests/ptc-escalation.e2e.ts", + "apps/web/tests/optional-string-editor.e2e.ts", "apps/web/tests/present.e2e.ts", "apps/web/tests/present-svg.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", @@ -216,6 +217,13 @@ "references": [ { "path": "./packages/experimental/client-ui-cot-translation/tsconfig.host.json" }, { "path": "./packages/experimental/cot-translation-bundle" }, + { "path": "./packages/bundle/session-search" }, + { "path": "./packages/bundle/string-editor" }, + { "path": "./packages/bundle/tmux-location" }, + { "path": "./packages/experimental/ralph-bundle" }, + { "path": "./packages/experimental/terminal-bundle" }, + { "path": "./packages/experimental/badge-skill-bundle" }, + { "path": "./packages/experimental/session-titles-bundle" }, { "path": "./packages/experimental/translator" }, { "path": "./packages/client/product-analytics/tsconfig.host.json" }, { "path": "./packages/telemetry/otel" },