feat(workspace-context): load all instruction candidates, dedup by trimmed content, follow symlinks

Squashes feat/instruction-load-all-dedup and feat/allow-instruction-symlink.
This commit is contained in:
Turtle
2026-07-22 10:55:19 +08:00
parent dd55b2cc62
commit 87899ae161
21 changed files with 685 additions and 343 deletions

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/render
*/
import { dirname } from 'node:path'
import { basename, dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
@@ -33,7 +33,6 @@ export interface WorkspaceInstructionChange {
action: 'set' | 'replace' | 'remove'
scope: string
path: string
previousPath?: string
digest?: string
}
@@ -62,8 +61,8 @@ function truncateUtf8(value: string, maxBytes: number): string {
function escapeInstructionContent(content: string): string {
// TODO(instruction-frame-paths): apply the same delimiter neutralization to
// every interpolated path, scope, and previous path; repository-controlled
// names can otherwise close the plugin-owned system-reminder frame.
// every interpolated path and scope; repository-controlled names can
// otherwise close the plugin-owned system-reminder frame.
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
}
@@ -71,43 +70,63 @@ function sectionText(file: LoadedInstructionFile): string {
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
}
/** Directory component that identifies the single user-global instruction scope. */
export const USER_GLOBAL_DIRECTORY = 'user-global'
/**
* File name of the single user-global instruction file under `$DSH_HOME`.
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
* candidate component) both key on this name, so it lives in one place: were the
* two to disagree, the user-global instruction would load but never reconcile.
*/
export const USER_GLOBAL_FILE = 'AGENTS.md'
/**
* Derive the logical instruction scope from a model-facing path.
* @param displayPath - project-relative or user-global instruction path.
* @returns `user-global`, `.`, or the containing project-relative directory.
*/
export function scopeForDisplayPath(displayPath: string): string {
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
return dirname(displayPath)
}
/** Instruction tier: the native base file or the additive local overlay. */
export type InstructionTier = 'base' | 'local'
const LOCAL_SCOPE_SUFFIX = '\u0000local'
const SCOPE_SEPARATOR = '\u0000'
/**
* Compose the reconciliation key for a directory scope and instruction tier.
* The base tier keeps the human-readable directory; the local overlay appends a
* NUL-delimited marker that no directory path can contain, so a directory's base
* and local files never collide in the scope-keyed state maps.
* Compose the reconciliation key for one instruction candidate file.
* Each loaded candidate is tracked independently, so the key pairs the logical
* directory with the exact candidate file name behind a NUL separator that no
* directory path or file name can contain. Distinct candidates in one directory
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
* never collide in the scope-keyed state maps.
* @param directory - `user-global`, `.`, or a project-relative directory.
* @param tier - base file or additive local overlay.
* @returns the collision-free logical scope key.
* @param candidateName - instruction file name within that directory.
* @returns the per-candidate logical scope key.
*/
export function scopeKey(directory: string, tier: InstructionTier): string {
return tier === 'local' ? `${directory}${LOCAL_SCOPE_SUFFIX}` : directory
export function candidateScopeKey(directory: string, candidateName: string): string {
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
}
/**
* Recover the directory and tier that {@link scopeKey} encoded.
* @param scope - a base or local scope key.
* @returns the directory scope and its instruction tier.
* Derive the per-candidate scope key for a loaded instruction file.
* @param displayPath - project-relative or user-global instruction path.
* @returns the scope key pairing the file's directory with its name.
*/
export function decodeScopeKey(scope: string): { directory: string; tier: InstructionTier } {
return scope.endsWith(LOCAL_SCOPE_SUFFIX)
? { directory: scope.slice(0, -LOCAL_SCOPE_SUFFIX.length), tier: 'local' }
: { directory: scope, tier: 'base' }
export function instructionScopeKey(displayPath: string): string {
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
}
/**
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
* @param scope - a per-candidate scope key.
* @returns the directory scope and the candidate file name within it.
*/
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
const separator = scope.indexOf(SCOPE_SEPARATOR)
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
if (separator < 0) return { directory: scope, candidateName: '' }
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
}
function additionalSectionText(file: LoadedInstructionFile): string {
@@ -129,13 +148,10 @@ function changedSectionText(item: ChangeRenderItem): string {
if (change.action === 'remove') {
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
}
const description = change.previousPath === undefined
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${scopeForDisplayPath(change.path)}\` instead.`
return [
`Updated instructions from: ${change.path}`,
'',
description,
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
escapeInstructionContent(file.content),
].join('\n')