diff --git a/packages/README.md b/packages/README.md index 536379f5e9..f3105ebf0e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -53,8 +53,8 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-project-instructions, dsh-agent-loop (the providerless spine, as one bundle plugin) -dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) -dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) +dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session, dsh-project-instructions (stdio chat APP + bin) +dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl, dsh-project-instructions (ACP server APP + bin) ``` The rule: **extension** plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). diff --git a/packages/prompt/project-instructions/package.json b/packages/prompt/project-instructions/package.json index e617dd1565..8ec27f2f1d 100644 --- a/packages/prompt/project-instructions/package.json +++ b/packages/prompt/project-instructions/package.json @@ -31,6 +31,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/prompt/project-instructions/src/index.ts b/packages/prompt/project-instructions/src/index.ts index aec2a40131..336bda2324 100644 --- a/packages/prompt/project-instructions/src/index.ts +++ b/packages/prompt/project-instructions/src/index.ts @@ -6,13 +6,13 @@ * @module @deepseek-ai/dsh-project-instructions */ -import { readFile, stat } from 'node:fs/promises' +import { lstat, readFile, stat } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, expandHomePath } from '@deepseek-ai/dsh-paths' +import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, resolveDshHome } from '@deepseek-ai/dsh-paths' export const name = 'project-instructions' @@ -35,7 +35,7 @@ export interface Config { } export const Config: z = z.object({ - dshHome: z.string().default(defaultDshHome()), + dshHome: z.string(), projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES), enableClaudeFallback: z.boolean().default(true), @@ -98,7 +98,7 @@ interface LoadOptions extends DiscoverOptions { function resolveConfig(config: Config): ResolvedConfig { return { - dshHome: resolve(expandHomePath(config.dshHome ?? defaultDshHome())), + dshHome: resolveDshHome(config.dshHome), projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES, enableClaudeFallback: config.enableClaudeFallback ?? true, @@ -110,12 +110,16 @@ function byteLength(value: string): number { } function truncateUtf8(value: string, maxBytes: number): string { - return Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') + let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') + while (byteLength(truncated) > maxBytes) { + truncated = truncated.slice(0, -1) + } + return truncated } async function statFile(path: string): Promise { try { - const info = await stat(path) + const info = await lstat(path) if (!info.isFile()) return undefined return { mtimeMs: info.mtimeMs, size: info.size } } catch { @@ -234,7 +238,7 @@ async function readCached(path: string, signature: FileSignature, cache: Instruc export async function loadBaselineInstructions(options: LoadOptions): Promise { const config = resolveConfig(options) - if (config.baselineMaxBytes === 0) return undefined + if (config.baselineMaxBytes <= 0 || !Number.isFinite(config.baselineMaxBytes)) return undefined const cache = options.cache ?? new Map() const discovered = await discoverInstructionFiles(options) const loaded: LoadedInstructionFile[] = [] @@ -311,21 +315,26 @@ function truncateToFit( } export function renderProjectInstructions(files: LoadedInstructionFile[], options: { maxBytes: number }): RenderedProjectInstructions { - if (options.maxBytes <= 0) return { text: '', omitted: files, truncated: [] } + if (options.maxBytes <= 0 || !Number.isFinite(options.maxBytes)) return { text: '', omitted: files, truncated: [] } const fullText = buildInstructionText(files, options.maxBytes, [], []) if (byteLength(fullText) <= options.maxBytes) { return { text: fullText, omitted: [], truncated: [] } } + for (let start = 1; start < files.length; start += 1) { + const included = files.slice(start) + const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) + const suffixText = buildInstructionText(included, options.maxBytes, omitted, []) + if (byteLength(suffixText) <= options.maxBytes) { + return { text: suffixText, omitted, truncated: [] } + } + } + const mostSpecific = files.at(-1) /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) - const mostSpecificOnly = buildInstructionText([mostSpecific], options.maxBytes, omitted, []) - if (byteLength(mostSpecificOnly) <= options.maxBytes) { - return { text: mostSpecificOnly, omitted, truncated: [] } - } for (const intro of [WORKSPACE_CONTEXT_INTRO, COMPACT_WORKSPACE_CONTEXT_INTRO]) { const truncatedFile = truncateToFit(mostSpecific, [], options.maxBytes, omitted, intro) @@ -360,7 +369,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) const cache: InstructionContentCache = new Map() ctx.on('agent/request', async (agent: Agent, _turn: number, _step: number, request: GenerateOptions, next) => { - if (resolved.baselineMaxBytes === 0) return next() + if (resolved.baselineMaxBytes <= 0 || !Number.isFinite(resolved.baselineMaxBytes)) return next() /* v8 ignore next -- stdio compatibility fallback; tests avoid process.chdir() because cwd is process-global. */ const cwd = agent.session.header.cwd ?? process.cwd() const instructions = await loadBaselineInstructions({ diff --git a/packages/prompt/project-instructions/tests/project-instructions.spec.ts b/packages/prompt/project-instructions/tests/project-instructions.spec.ts index 376ff27c63..79f1ccb293 100644 --- a/packages/prompt/project-instructions/tests/project-instructions.spec.ts +++ b/packages/prompt/project-instructions/tests/project-instructions.spec.ts @@ -1,8 +1,10 @@ -import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import * as projectInstructions from '@deepseek-ai/dsh-project-instructions' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -148,6 +150,27 @@ describe('project instruction discovery', () => { } }) + it('rejects symlinked instruction files instead of following repository-controlled links', async () => { + const root = await tempRepo() + const home = await tempRepo() + const outside = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(outside, 'secret.txt'), 'outside secret') + await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) + + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) + const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home }) + + expect(files).toEqual([]) + expect(loaded).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + await rm(outside, { recursive: true, force: true }) + } + }) + it('disables baseline loading when the byte budget is zero', async () => { const root = await tempRepo() const home = await tempRepo() @@ -195,6 +218,23 @@ describe('project instruction discovery', () => { } }) + it('honors DSH_HOME when dshHome is not configured explicitly', async () => { + const root = await tempRepo() + const envHome = await tempRepo() + try { + await write(join(envHome, 'AGENTS.md'), 'env global rule') + vi.stubEnv('DSH_HOME', envHome) + + const files = await discoverBaselineInstructionFiles({ cwd: root }) + + expect(files).toEqual([{ absolutePath: join(envHome, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }]) + } finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + await rm(envHome, { recursive: true, force: true }) + } + }) + it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { const root = await tempRepo() const home = await tempRepo() @@ -322,6 +362,21 @@ describe('project instruction rendering', () => { expect(rendered.truncated).toEqual([]) }) + it('keeps the longest most-specific suffix that fits under the byte budget', () => { + const rendered = renderProjectInstructions([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) }, + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rule' }, + { absolutePath: '/repo/pkg/app/AGENTS.md', displayPath: 'pkg/app/AGENTS.md', content: 'app rule' }, + ], { maxBytes: 760 }) + + expect(rendered.text).toContain('omitted AGENTS.md') + expect(rendered.text).toContain('## pkg/AGENTS.md\n\npackage rule') + expect(rendered.text).toContain('## pkg/app/AGENTS.md\n\napp rule') + expect(rendered.text).not.toContain('root root') + expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md']) + expect(rendered.truncated).toEqual([]) + }) + it('truncates a single oversized file to the largest content slice that fits', () => { const rendered = renderProjectInstructions([ { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'x'.repeat(1000) }, @@ -366,6 +421,14 @@ describe('project instruction rendering', () => { expect(rendered.truncated).toEqual([{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }]) expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20) }) + + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { + const rendered = renderProjectInstructions([ + { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 53 }) + + expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(53) + }) }) describe('project instruction request injection', () => { @@ -492,6 +555,28 @@ describe('project instruction request injection', () => { } }) + it('does not inject an empty workspace-context message when baselineMaxBytes is negative', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home, baselineMaxBytes: -1 }) + + const request: GenerateOptions = { + model: 'mock', + messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }], + } + const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request) + + expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('leaves the request unchanged when no instruction files are present', async () => { const root = await tempRepo() const home = await tempRepo() @@ -527,7 +612,7 @@ describe('project instruction request injection', () => { } }) - it('reuses the discovery stat signature when reading cached content', async () => { + it('reuses the discovery lstat signature when reading cached content', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -540,9 +625,9 @@ describe('project instruction request injection', () => { const actual = await importOriginal() return { ...actual, - stat: async (path: string) => { + lstat: async (path: string) => { observedStats.set(path, (observedStats.get(path) ?? 0) + 1) - return actual.stat(path) + return actual.lstat(path) }, } }) @@ -562,3 +647,17 @@ describe('project instruction request injection', () => { } }) }) + +describe('project instruction plugin export shape', () => { + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { + expect('default' in projectInstructions).toBe(false) + expect(typeof projectInstructions.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(projectInstructions) as Record + expect(unwrapped).toBe(projectInstructions) + expect(unwrapped.name).toBe('project-instructions') + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index b010200a36..79bf1bddf7 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -5,7 +5,7 @@ */ import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' /** Directory name for the default DeepSeek Harness home under the OS home. */ export const DSH_HOME_DIR_NAME = '.dsh' @@ -13,6 +13,9 @@ export const DSH_HOME_DIR_NAME = '.dsh' /** Stable user-facing display form for the default DeepSeek Harness home. */ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` +/** Environment variable that overrides the default DeepSeek Harness home. */ +export const DSH_HOME_ENV = 'DSH_HOME' + /** Resolve the default DeepSeek Harness home using Node's platform path rules. */ export function defaultDshHome(): string { return join(homedir(), DSH_HOME_DIR_NAME) @@ -24,3 +27,9 @@ export function expandHomePath(path: string): string { if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2)) return path } + +/** Resolve an explicitly configured, env-selected, or default DSH home path. */ +export function resolveDshHome(configured?: string, env: Record = process.env): string { + const selected = configured ?? env[DSH_HOME_ENV] ?? defaultDshHome() + return resolve(expandHomePath(selected)) +} diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 7b96a4f269..97e91a556e 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -6,6 +6,7 @@ import { DSH_HOME_DIR_NAME, defaultDshHome, expandHomePath, + resolveDshHome, } from '@deepseek-ai/dsh-paths' describe('dsh path helpers', () => { @@ -22,4 +23,12 @@ describe('dsh path helpers', () => { expect(expandHomePath('/tmp/.dsh')).toBe('/tmp/.dsh') expect(expandHomePath('~other/.dsh')).toBe('~other/.dsh') }) + + it('resolves explicit DSH home before environment and default locations', () => { + const envHome = join(homedir(), 'env-dsh') + + expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome) + expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe('/tmp/explicit-dsh') + expect(resolveDshHome(undefined, {})).toBe(defaultDshHome()) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9b628677e..58a494b76d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -324,7 +327,7 @@ importers: version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/session-persistence/session-persistence: devDependencies: @@ -3471,6 +3474,14 @@ snapshots: cosmokit: 1.8.1 js-yaml: 4.2.0 + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6)': + dependencies: + '@cordisjs/plugin-loader': link:vendor/loader + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + cosmokit: 1.8.1 + js-yaml: 4.2.0 + optional: true + '@cordisjs/plugin-loader@1.0.0-rc.4(cordis@4.0.0-rc.6)': dependencies: cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -4289,6 +4300,14 @@ snapshots: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-loader': link:vendor/loader + cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): dependencies: '@standard-schema/spec': 1.1.0