fix: tighten project instruction path handling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "lib/types"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
Generated
+9
@@ -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':
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user