fix: tighten project instruction path handling

This commit is contained in:
Yichen Jiang
2026-06-29 21:42:23 +08:00
parent 234ffe4604
commit 21ae734839
20 changed files with 247 additions and 30 deletions
+2
View File
@@ -97,6 +97,8 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/.
util/ low-level zero-dependency utilities shared across groups util/ low-level zero-dependency utilities shared across groups
brand/ type-only Branded<B> nominal-typing primitive (no runtime brand/ type-only Branded<B> nominal-typing primitive (no runtime
code, no harness deps; owns the brand for cross-boundary ids) code, no harness deps; owns the brand for cross-boundary ids)
paths/ shared filesystem path constants and helpers for harness
user data such as the default DSH home
examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a
THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter, THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter,
a bash executor) and loads ONE app package (dsh-stdio-agent or a bash executor) and loads ONE app package (dsh-stdio-agent or
+2 -2
View File
@@ -22,7 +22,7 @@ For a catalog of the **data structures** this architecture moves around — the
│ future plugins: hooks, compaction, sandbox, UI, MCP… │ │ future plugins: hooks, compaction, sandbox, UI, MCP… │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │ │ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │
│ @deepseek-ai/dsh-project-instructions (AGENTS.md loader) │ │ @deepseek-ai/dsh-project-instructions (AGENTS.md loader) │
│ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-bash-local (bash impl) │
│ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │
│ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│
@@ -97,7 +97,7 @@ Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry =
Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall. Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall.
Prompt/context extension plugins that shape model inputs without owning a core service live under `packages/prompt/`. `dsh-project-instructions` is the reference case: it is semantically prompt/context assembly, but it uses the per-agent `agent/request` seam instead of a global `ctx.systemPrompt.section()` so concurrent sessions with different cwd values stay isolated. Prompt/context extension plugins that shape model inputs without owning a core service live under `packages/prompt/`. `dsh-project-instructions` is the reference case: it is semantically prompt/context assembly, but it uses the per-agent `agent/request` seam instead of a global `ctx.systemPrompt.section()` so concurrent sessions with different cwd values stay isolated. Shared filesystem path conventions such as the default DSH home live in the low-level `dsh-paths` utility package rather than in the prompt plugin.
Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text. Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text.
+3 -1
View File
@@ -28,6 +28,7 @@ graph TD
invariants --> session invariants --> session
project-instructions --> agent project-instructions --> agent
project-instructions --> llm project-instructions --> llm
project-instructions --> paths
session-persistence-jsonl --> session session-persistence-jsonl --> session
session-persistence-jsonl --> session-persistence session-persistence-jsonl --> session-persistence
session-persistence-sqlite --> session session-persistence-sqlite --> session
@@ -100,6 +101,7 @@ graph TD
| Package | Depends on | | Package | Depends on |
| --- | --- | | --- | --- |
| `brand` | — | | `brand` | — |
| `paths` | — |
| `bash` | `brand` | | `bash` | `brand` |
| `llm` | `brand` | | `llm` | `brand` |
| `bash-local` | `bash` | | `bash-local` | `bash` |
@@ -112,7 +114,7 @@ graph TD
| `llm-replay` | `llm`, `session` | | `llm-replay` | `llm`, `session` |
| `session-persistence` | `session` | | `session-persistence` | `session` |
| `invariants` | `agent`, `llm`, `session` | | `invariants` | `agent`, `llm`, `session` |
| `project-instructions` | `agent`, `llm` | | `project-instructions` | `agent`, `llm`, `paths` |
| `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-jsonl` | `session`, `session-persistence` |
| `session-persistence-sqlite` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` |
| `tools` | `agent`, `llm`, `system-prompt` | | `tools` | `agent`, `llm`, `system-prompt` |
@@ -12,7 +12,7 @@ The non-obvious constraint is multi-session cwd. `dsh-system-prompt` sections ar
## Proposal ## Proposal
Add a new plugin package `packages/prompt/project-instructions` (`@deepseek-ai/dsh-project-instructions`). It is a single-purpose prompt/context extension plugin, not an interface/implementation/consumer capability seam: there is no swappable backend, only filesystem discovery plus per-request context injection. It depends on interface packages only (`dsh-agent` and `dsh-llm`) and consumes the existing `agent/request` waterfall. Add a new plugin package `packages/prompt/project-instructions` (`@deepseek-ai/dsh-project-instructions`). It is a single-purpose prompt/context extension plugin, not an interface/implementation/consumer capability seam: there is no swappable backend, only filesystem discovery plus per-request context injection. It depends on interface packages (`dsh-agent` and `dsh-llm`) plus the low-level `dsh-paths` utility for the shared DSH home convention, and consumes the existing `agent/request` waterfall.
The plugin is loaded by `@deepseek-ai/dsh-agent-core` so both product front doors (`dsh-stdio-agent` and `dsh-acp-agent`) get instruction-file behavior by default. The bundle and both app packages expose `projectInstructions` config, so apps may set `projectInstructions: false` or `baselineMaxBytes: 0` when they need a hermetic prompt. The default product behavior matches user expectations for coding agents. The plugin is loaded by `@deepseek-ai/dsh-agent-core` so both product front doors (`dsh-stdio-agent` and `dsh-acp-agent`) get instruction-file behavior by default. The bundle and both app packages expose `projectInstructions` config, so apps may set `projectInstructions: false` or `baselineMaxBytes: 0` when they need a hermetic prompt. The default product behavior matches user expectations for coding agents.
@@ -28,7 +28,7 @@ The first cut intentionally does not load lowercase variants (`agents.md`, `clau
User-global harness instructions live at `$DSH_HOME/AGENTS.md`, where `$DSH_HOME` defaults to `~/.dsh` when unset. This mirrors Codex's `~/.codex` and Claude Code's `~/.claude` convention without inventing a repo-local home. The user-global file loads before project files so project-specific instructions appear later and can override broad preferences in the model-readable order. User-global harness instructions live at `$DSH_HOME/AGENTS.md`, where `$DSH_HOME` defaults to `~/.dsh` when unset. This mirrors Codex's `~/.codex` and Claude Code's `~/.claude` convention without inventing a repo-local home. The user-global file loads before project files so project-specific instructions appear later and can override broad preferences in the model-readable order.
`$DSH_HOME` is a filesystem location only; this RFC does not introduce a broader config service. If a future config package owns the harness data directory, it should preserve this default and move the path resolution there. `$DSH_HOME` is a filesystem location only; this RFC does not introduce a broader config service. The default `.dsh` directory name and tilde expansion live in the small `dsh-paths` utility package so future features can share the same convention without depending on this prompt plugin. If a future config package owns the harness data directory, it should preserve this default and consume or supersede that helper deliberately.
### Project baseline discovery ### Project baseline discovery
+5
View File
@@ -21,6 +21,11 @@
"project": ["src/**/*.ts"], "project": ["src/**/*.ts"],
"ignoreDependencies": ["cordis"] "ignoreDependencies": ["cordis"]
}, },
"packages/util/paths": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/llm/llm-deepseek": { "packages/llm/llm-deepseek": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"] "project": ["src/**/*.ts", "tests/**/*.ts"]
+3 -1
View File
@@ -25,6 +25,7 @@ The split is the point: a package's group says whether it is part of the product
``` ```
dsh-brand (no harness deps — type-only Branded<B> primitive) dsh-brand (no harness deps — type-only Branded<B> primitive)
dsh-paths (no harness deps — shared filesystem path helpers)
dsh-llm ← dsh-brand (vocabulary; brands CallId) dsh-llm ← dsh-brand (vocabulary; brands CallId)
dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken) dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken)
dsh-session ← dsh-llm, dsh-brand dsh-session ← dsh-llm, dsh-brand
@@ -32,7 +33,7 @@ dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred) dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-project-instructions ← dsh-agent, dsh-llm (AGENTS.md/CLAUDE.md workspace context loader) dsh-project-instructions ← dsh-agent, dsh-llm, dsh-paths (AGENTS.md/CLAUDE.md workspace context loader)
dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
@@ -89,6 +90,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | | `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | | `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
| `paths/` | `util` | Shared filesystem path constants and helpers for harness user data | (none) |
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
@@ -6,7 +6,7 @@ Project instruction file loader for the harness. It discovers `AGENTS.md` with `
The plugin listens on the `agent/request` waterfall. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory: `AGENTS.md` wins, `CLAUDE.md` is a compatibility fallback. The plugin listens on the `agent/request` waterfall. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory: `AGENTS.md` wins, `CLAUDE.md` is a compatibility fallback.
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance. User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
The loaded files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules. The loaded files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
@@ -27,7 +27,7 @@ export interface Config {
The renderer keeps full text until the configured byte budget is exceeded. When it must trim, it preserves more-specific files first, drops whole less-specific files before truncating a more-specific file, and emits an HTML comment naming omitted and truncated files with byte counts. The renderer keeps full text until the configured byte budget is exceeded. When it must trim, it preserves more-specific files first, drops whole less-specific files before truncating a more-specific file, and emits an HTML comment naming omitted and truncated files with byte counts.
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read. Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read. The discovery pass carries the file signature forward to the read pass, so a cache hit does not stat the same instruction file twice in one request.
## Non-goals ## Non-goals
@@ -24,6 +24,7 @@
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.6" "cordis": "^4.0.0-rc.6"
}, },
"dependencies": { "dependencies": {
@@ -34,6 +35,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
@@ -7,12 +7,12 @@
*/ */
import { readFile, stat } from 'node:fs/promises' import { readFile, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path' import { dirname, join, relative, resolve } from 'node:path'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, expandHomePath } from '@deepseek-ai/dsh-paths'
export const name = 'project-instructions' export const name = 'project-instructions'
@@ -35,7 +35,7 @@ export interface Config {
} }
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
dshHome: z.string().default(join(homedir(), '.dsh')), dshHome: z.string().default(defaultDshHome()),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES), baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES),
enableClaudeFallback: z.boolean().default(true), enableClaudeFallback: z.boolean().default(true),
@@ -46,6 +46,10 @@ export interface InstructionFile {
displayPath: string displayPath: string
} }
interface DiscoveredInstructionFile extends InstructionFile {
signature: FileSignature
}
export interface LoadedInstructionFile extends InstructionFile { export interface LoadedInstructionFile extends InstructionFile {
content: string content: string
} }
@@ -94,7 +98,7 @@ interface LoadOptions extends DiscoverOptions {
function resolveConfig(config: Config): ResolvedConfig { function resolveConfig(config: Config): ResolvedConfig {
return { return {
dshHome: resolve(config.dshHome ?? join(homedir(), '.dsh')), dshHome: resolve(expandHomePath(config.dshHome ?? defaultDshHome())),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES, baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES,
enableClaudeFallback: config.enableClaudeFallback ?? true, enableClaudeFallback: config.enableClaudeFallback ?? true,
@@ -162,15 +166,17 @@ async function firstExistingInstructionFile(
dir: string, dir: string,
root: string, root: string,
enableClaudeFallback: boolean, enableClaudeFallback: boolean,
): Promise<InstructionFile | undefined> { ): Promise<DiscoveredInstructionFile | undefined> {
const agentsPath = join(dir, 'AGENTS.md') const agentsPath = join(dir, 'AGENTS.md')
if (await statFile(agentsPath)) { const agentsSignature = await statFile(agentsPath)
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath) } if (agentsSignature !== undefined) {
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath), signature: agentsSignature }
} }
if (!enableClaudeFallback) return undefined if (!enableClaudeFallback) return undefined
const claudePath = join(dir, 'CLAUDE.md') const claudePath = join(dir, 'CLAUDE.md')
if (await statFile(claudePath)) { const claudeSignature = await statFile(claudePath)
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath) } if (claudeSignature !== undefined) {
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath), signature: claudeSignature }
} }
return undefined return undefined
} }
@@ -179,29 +185,38 @@ function relativeDisplay(root: string, path: string): string {
return relative(root, path) return relative(root, path)
} }
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> { async function discoverInstructionFiles(options: DiscoverOptions): Promise<DiscoveredInstructionFile[]> {
const config = resolveConfig(options) const config = resolveConfig(options)
const files: InstructionFile[] = [] const files: DiscoveredInstructionFile[] = []
const seen = new Set<string>()
const addFile = (file: DiscoveredInstructionFile): void => {
if (seen.has(file.absolutePath)) return
seen.add(file.absolutePath)
files.push(file)
}
const userGlobal = join(config.dshHome, 'AGENTS.md') const userGlobal = join(config.dshHome, 'AGENTS.md')
if (await statFile(userGlobal)) { const userGlobalSignature = await statFile(userGlobal)
const defaultDshHome = resolve(join(homedir(), '.dsh')) if (userGlobalSignature !== undefined) {
const displayPath = config.dshHome === defaultDshHome ? '~/.dsh/AGENTS.md' : '$DSH_HOME/AGENTS.md' const defaultHome = resolve(defaultDshHome())
files.push({ absolutePath: userGlobal, displayPath }) const displayPath = config.dshHome === defaultHome ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
addFile({ absolutePath: userGlobal, displayPath, signature: userGlobalSignature })
} }
const cwd = resolve(options.cwd) const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers) const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers)
for (const dir of ancestorChain(projectRoot, cwd)) { for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback) const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback)
if (file !== undefined) files.push(file) if (file !== undefined) addFile(file)
} }
return files return files
} }
async function readCached(path: string, cache: InstructionContentCache): Promise<string | undefined> { export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
const signature = await statFile(path) return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
/* v8 ignore next -- race-only path: file existed during discovery but vanished before the read-side stat. */ }
if (signature === undefined) return undefined
async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise<string | undefined> {
const cached = cache.get(path) const cached = cache.get(path)
if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) { if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) {
return cached.content return cached.content
@@ -221,11 +236,11 @@ export async function loadBaselineInstructions(options: LoadOptions): Promise<Re
const config = resolveConfig(options) const config = resolveConfig(options)
if (config.baselineMaxBytes === 0) return undefined if (config.baselineMaxBytes === 0) return undefined
const cache = options.cache ?? new Map<string, CachedContent>() const cache = options.cache ?? new Map<string, CachedContent>()
const discovered = await discoverBaselineInstructionFiles(options) const discovered = await discoverInstructionFiles(options)
const loaded: LoadedInstructionFile[] = [] const loaded: LoadedInstructionFile[] = []
for (const file of discovered) { for (const file of discovered) {
const content = await readCached(file.absolutePath, cache) const content = await readCached(file.absolutePath, file.signature, cache)
if (content !== undefined) loaded.push({ ...file, content }) if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
} }
if (loaded.length === 0) return undefined if (loaded.length === 0) return undefined
return renderProjectInstructions(loaded, { maxBytes: config.baselineMaxBytes }) return renderProjectInstructions(loaded, { maxBytes: config.baselineMaxBytes })
@@ -215,6 +215,40 @@ describe('project instruction discovery', () => {
} }
}) })
it('expands a configured ~/.dsh home to the operating-system home directory', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await write(join(home, '.dsh/AGENTS.md'), 'global tilde rule')
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
const isolated = await import('@deepseek-ai/dsh-project-instructions')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' })
expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }])
} finally {
vi.doUnmock('node:os')
vi.resetModules()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('deduplicates user-global instructions when dshHome points at the project root', async () => {
const root = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'same file')
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: root })
expect(files).toEqual([{ absolutePath: join(root, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('ignores instruction candidates that are directories', async () => { it('ignores instruction candidates that are directories', async () => {
const root = await tempRepo() const root = await tempRepo()
const home = await tempRepo() const home = await tempRepo()
@@ -492,4 +526,39 @@ describe('project instruction request injection', () => {
await rm(home, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })
} }
}) })
it('reuses the discovery stat signature when reading cached content', 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 observedStats = new Map<string, number>()
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
stat: async (path: string) => {
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
return actual.stat(path)
},
}
})
const isolated = await import('@deepseek-ai/dsh-project-instructions')
const cache: InstructionContentCache = new Map()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
observedStats.clear()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
}) })
@@ -19,6 +19,9 @@
}, },
{ {
"path": "../../core/agent" "path": "../../core/agent"
},
{
"path": "../../util/paths"
} }
] ]
} }
+1
View File
@@ -5,5 +5,6 @@ Zero-dependency primitives shared across the other groups. A package lands here
| Package | Role | | Package | Role |
|---|---| |---|---|
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | | `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
| `paths/` | Shared filesystem path constants and helpers for harness user data |
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. `dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
+13
View File
@@ -0,0 +1,13 @@
# dsh-paths
Shared filesystem path helpers for DeepSeek Harness user data.
## DSH home
`DSH_HOME_DIR_NAME` owns the default user-data directory name: `.dsh`.
`defaultDshHome()` returns the default DeepSeek Harness home by joining the operating-system home directory with `.dsh`, using Node's platform path rules.
`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched.
This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@deepseek-ai/dsh-paths",
"description": "Shared filesystem path helpers for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared filesystem path helpers for DeepSeek Harness user data.
*
* @module @deepseek-ai/dsh-paths
*/
import { homedir } from 'node:os'
import { join } from 'node:path'
/** Directory name for the default DeepSeek Harness home under the OS home. */
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}`
/** Resolve the default DeepSeek Harness home using Node's platform path rules. */
export function defaultDshHome(): string {
return join(homedir(), DSH_HOME_DIR_NAME)
}
/** Expand `~`, `~/...`, and Windows-style `~\...` prefixes against the OS home. */
export function expandHomePath(path: string): string {
if (path === '~') return homedir()
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
return path
}
+25
View File
@@ -0,0 +1,25 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_DSH_HOME_DISPLAY,
DSH_HOME_DIR_NAME,
defaultDshHome,
expandHomePath,
} from '@deepseek-ai/dsh-paths'
describe('dsh path helpers', () => {
it('owns the shared default DSH home directory name', () => {
expect(DSH_HOME_DIR_NAME).toBe('.dsh')
expect(DEFAULT_DSH_HOME_DISPLAY).toBe('~/.dsh')
expect(defaultDshHome()).toBe(join(homedir(), '.dsh'))
})
it('expands tilde paths without changing non-tilde paths', () => {
expect(expandHomePath('~')).toBe(homedir())
expect(expandHomePath('~/.dsh')).toBe(join(homedir(), '.dsh'))
expect(expandHomePath('~\\.dsh')).toBe(join(homedir(), '.dsh'))
expect(expandHomePath('/tmp/.dsh')).toBe('/tmp/.dsh')
expect(expandHomePath('~other/.dsh')).toBe('~other/.dsh')
})
})
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}
+9
View File
@@ -310,6 +310,9 @@ importers:
'@deepseek-ai/dsh-llm-deepseek': '@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^ specifier: workspace:^
version: link:../../llm/llm-deepseek version: link:../../llm/llm-deepseek
'@deepseek-ai/dsh-paths':
specifier: workspace:^
version: link:../../util/paths
'@deepseek-ai/dsh-session': '@deepseek-ai/dsh-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/session version: link:../../core/session
@@ -743,6 +746,12 @@ importers:
specifier: ^4.0.0-rc.6 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@1.0.0-rc.4)
packages/util/paths:
devDependencies:
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)
vendor/cordis: vendor/cordis:
dependencies: dependencies:
'@cordisjs/plugin-include': '@cordisjs/plugin-include':
+1
View File
@@ -11,6 +11,7 @@
{ "path": "./vendor/hmr" }, { "path": "./vendor/hmr" },
{ "path": "./vendor/logger-console" }, { "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" }, { "path": "./packages/util/brand" },
{ "path": "./packages/util/paths" },
{ "path": "./packages/llm/llm" }, { "path": "./packages/llm/llm" },
{ "path": "./packages/core/session" }, { "path": "./packages/core/session" },
{ "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence" },
+1
View File
@@ -22,6 +22,7 @@
{ "path": "./vendor/hmr" }, { "path": "./vendor/hmr" },
{ "path": "./vendor/logger-console" }, { "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" }, { "path": "./packages/util/brand" },
{ "path": "./packages/util/paths" },
{ "path": "./packages/llm/llm" }, { "path": "./packages/llm/llm" },
{ "path": "./packages/core/session" }, { "path": "./packages/core/session" },
{ "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence" },