From 21ae7348390f5454b9de4a4333a62f3f0b0db991 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 29 Jun 2026 21:42:23 +0800 Subject: [PATCH] fix: tighten project instruction path handling --- AGENTS.md | 2 + docs/architecture.md | 4 +- docs/module-graph.md | 4 +- .../2026-06-24-project-instruction-files.md | 4 +- knip.json | 5 ++ packages/README.md | 4 +- .../prompt/project-instructions/README.md | 4 +- .../prompt/project-instructions/package.json | 2 + .../prompt/project-instructions/src/index.ts | 59 ++++++++++------ .../tests/project-instructions.spec.ts | 69 +++++++++++++++++++ .../prompt/project-instructions/tsconfig.json | 3 + packages/util/README.md | 1 + packages/util/paths/README.md | 13 ++++ packages/util/paths/package.json | 30 ++++++++ packages/util/paths/src/index.ts | 26 +++++++ packages/util/paths/tests/paths.spec.ts | 25 +++++++ packages/util/paths/tsconfig.json | 11 +++ pnpm-lock.yaml | 9 +++ tsconfig.build.json | 1 + tsconfig.json | 1 + 20 files changed, 247 insertions(+), 30 deletions(-) create mode 100644 packages/util/paths/README.md create mode 100644 packages/util/paths/package.json create mode 100644 packages/util/paths/src/index.ts create mode 100644 packages/util/paths/tests/paths.spec.ts create mode 100644 packages/util/paths/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index bfaf5a8e6f..47e66f1924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,8 @@ packages/ Harness packages, grouped by role at packages///. util/ low-level zero-dependency utilities shared across groups brand/ type-only Branded nominal-typing primitive (no runtime 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 THIN leaf cordis.yml: it picks the swappable backends (an LLM adapter, a bash executor) and loads ONE app package (dsh-stdio-agent or diff --git a/docs/architecture.md b/docs/architecture.md index 85b69cb1f4..8ec07f494c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ For a catalog of the **data structures** this architecture moves around — the │ future plugins: hooks, compaction, sandbox, UI, MCP… │ ├─────────────────────────────────────────────────────────────┤ │ @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-tool-bash (bash tool schemas) │ │ @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. -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. diff --git a/docs/module-graph.md b/docs/module-graph.md index 3011204f7b..ab73c5817d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -28,6 +28,7 @@ graph TD invariants --> session project-instructions --> agent project-instructions --> llm + project-instructions --> paths session-persistence-jsonl --> session session-persistence-jsonl --> session-persistence session-persistence-sqlite --> session @@ -100,6 +101,7 @@ graph TD | Package | Depends on | | --- | --- | | `brand` | — | +| `paths` | — | | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | @@ -112,7 +114,7 @@ graph TD | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | -| `project-instructions` | `agent`, `llm` | +| `project-instructions` | `agent`, `llm`, `paths` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | | `tools` | `agent`, `llm`, `system-prompt` | diff --git a/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md b/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md index d0367d28b0..bf973dd00e 100644 --- a/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md +++ b/docs/rfc/implemented/feature/2026-06-24-project-instruction-files.md @@ -12,7 +12,7 @@ The non-obvious constraint is multi-session cwd. `dsh-system-prompt` sections ar ## 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. @@ -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. -`$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 diff --git a/knip.json b/knip.json index a2cfd91654..aa15395c94 100644 --- a/knip.json +++ b/knip.json @@ -21,6 +21,11 @@ "project": ["src/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/util/paths": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/llm/llm-deepseek": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index ad8b344460..1376a41bc0 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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 primitive) +dsh-paths (no harness deps — shared filesystem path helpers) dsh-llm ← dsh-brand (vocabulary; brands CallId) dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken) dsh-session ← dsh-llm, dsh-brand @@ -32,7 +33,7 @@ dsh-system-prompt ← dsh-llm dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred) 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-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) 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`) | | `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` 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). diff --git a/packages/prompt/project-instructions/README.md b/packages/prompt/project-instructions/README.md index 3e226f11b5..cca2afb656 100644 --- a/packages/prompt/project-instructions/README.md +++ b/packages/prompt/project-instructions/README.md @@ -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. -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. @@ -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. -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 diff --git a/packages/prompt/project-instructions/package.json b/packages/prompt/project-instructions/package.json index dddd3e7f0b..e617dd1565 100644 --- a/packages/prompt/project-instructions/package.json +++ b/packages/prompt/project-instructions/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -34,6 +35,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/prompt/project-instructions/src/index.ts b/packages/prompt/project-instructions/src/index.ts index c518363c45..aec2a40131 100644 --- a/packages/prompt/project-instructions/src/index.ts +++ b/packages/prompt/project-instructions/src/index.ts @@ -7,12 +7,12 @@ */ import { readFile, stat } from 'node:fs/promises' -import { homedir } from 'node:os' 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' export const name = 'project-instructions' @@ -35,7 +35,7 @@ export interface Config { } export const Config: z = z.object({ - dshHome: z.string().default(join(homedir(), '.dsh')), + dshHome: z.string().default(defaultDshHome()), projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES), enableClaudeFallback: z.boolean().default(true), @@ -46,6 +46,10 @@ export interface InstructionFile { displayPath: string } +interface DiscoveredInstructionFile extends InstructionFile { + signature: FileSignature +} + export interface LoadedInstructionFile extends InstructionFile { content: string } @@ -94,7 +98,7 @@ interface LoadOptions extends DiscoverOptions { function resolveConfig(config: Config): ResolvedConfig { return { - dshHome: resolve(config.dshHome ?? join(homedir(), '.dsh')), + dshHome: resolve(expandHomePath(config.dshHome ?? defaultDshHome())), projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES, enableClaudeFallback: config.enableClaudeFallback ?? true, @@ -162,15 +166,17 @@ async function firstExistingInstructionFile( dir: string, root: string, enableClaudeFallback: boolean, -): Promise { +): Promise { const agentsPath = join(dir, 'AGENTS.md') - if (await statFile(agentsPath)) { - return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath) } + const agentsSignature = await statFile(agentsPath) + if (agentsSignature !== undefined) { + return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath), signature: agentsSignature } } if (!enableClaudeFallback) return undefined const claudePath = join(dir, 'CLAUDE.md') - if (await statFile(claudePath)) { - return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath) } + const claudeSignature = await statFile(claudePath) + if (claudeSignature !== undefined) { + return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath), signature: claudeSignature } } return undefined } @@ -179,29 +185,38 @@ function relativeDisplay(root: string, path: string): string { return relative(root, path) } -export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise { +async function discoverInstructionFiles(options: DiscoverOptions): Promise { const config = resolveConfig(options) - const files: InstructionFile[] = [] + const files: DiscoveredInstructionFile[] = [] + const seen = new Set() + 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') - if (await statFile(userGlobal)) { - const defaultDshHome = resolve(join(homedir(), '.dsh')) - const displayPath = config.dshHome === defaultDshHome ? '~/.dsh/AGENTS.md' : '$DSH_HOME/AGENTS.md' - files.push({ absolutePath: userGlobal, displayPath }) + const userGlobalSignature = await statFile(userGlobal) + if (userGlobalSignature !== undefined) { + const defaultHome = resolve(defaultDshHome()) + 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 projectRoot = await findProjectRoot(cwd, config.projectRootMarkers) for (const dir of ancestorChain(projectRoot, cwd)) { const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback) - if (file !== undefined) files.push(file) + if (file !== undefined) addFile(file) } return files } -async function readCached(path: string, cache: InstructionContentCache): Promise { - const signature = await statFile(path) - /* v8 ignore next -- race-only path: file existed during discovery but vanished before the read-side stat. */ - if (signature === undefined) return undefined +export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise { + return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath })) +} + +async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise { const cached = cache.get(path) if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) { return cached.content @@ -221,11 +236,11 @@ export async function loadBaselineInstructions(options: LoadOptions): Promise() - const discovered = await discoverBaselineInstructionFiles(options) + const discovered = await discoverInstructionFiles(options) const loaded: LoadedInstructionFile[] = [] for (const file of discovered) { - const content = await readCached(file.absolutePath, cache) - if (content !== undefined) loaded.push({ ...file, content }) + const content = await readCached(file.absolutePath, file.signature, cache) + if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content }) } if (loaded.length === 0) return undefined return renderProjectInstructions(loaded, { maxBytes: config.baselineMaxBytes }) diff --git a/packages/prompt/project-instructions/tests/project-instructions.spec.ts b/packages/prompt/project-instructions/tests/project-instructions.spec.ts index 2ed5630bb2..376ff27c63 100644 --- a/packages/prompt/project-instructions/tests/project-instructions.spec.ts +++ b/packages/prompt/project-instructions/tests/project-instructions.spec.ts @@ -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 () => { const root = await tempRepo() const home = await tempRepo() @@ -492,4 +526,39 @@ describe('project instruction request injection', () => { 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() + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + 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 }) + } + }) }) diff --git a/packages/prompt/project-instructions/tsconfig.json b/packages/prompt/project-instructions/tsconfig.json index 3d8e442848..5ba191afce 100644 --- a/packages/prompt/project-instructions/tsconfig.json +++ b/packages/prompt/project-instructions/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../util/paths" } ] } diff --git a/packages/util/README.md b/packages/util/README.md index ae73c8125f..475808117d 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,5 +5,6 @@ Zero-dependency primitives shared across the other groups. A package lands here | Package | Role | |---|---| | `brand/` | The type-only `Branded` 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` 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`. diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md new file mode 100644 index 0000000000..432baae950 --- /dev/null +++ b/packages/util/paths/README.md @@ -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. diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json new file mode 100644 index 0000000000..b4f760afe9 --- /dev/null +++ b/packages/util/paths/package.json @@ -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" + } +} diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts new file mode 100644 index 0000000000..b010200a36 --- /dev/null +++ b/packages/util/paths/src/index.ts @@ -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 +} diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts new file mode 100644 index 0000000000..7b96a4f269 --- /dev/null +++ b/packages/util/paths/tests/paths.spec.ts @@ -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') + }) +}) diff --git a/packages/util/paths/tsconfig.json b/packages/util/paths/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/util/paths/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19b6cb77f0..777227e599 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -743,6 +746,12 @@ importers: 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) + 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: dependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.build.json b/tsconfig.build.json index 4538eefc45..5f04a7fea4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/session-persistence/session-persistence" }, diff --git a/tsconfig.json b/tsconfig.json index d8d91b690d..8e4f2dc51b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/session-persistence/session-persistence" },