Merge refreshed schema DSL into canonical tool output

# Conflicts:
#	docs/config-catalog.md
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/tui.snapshot.ts
This commit is contained in:
Tianyi Cui
2026-07-22 21:31:16 +08:00
390 files changed
+16442 -2975

No files matched your search

@@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
@@ -22,8 +23,16 @@ export interface Config {
maxBytes: number
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
maxSourceBytes?: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
/**
* Ordered same-directory project candidates; every existing file loads, with
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
*/
instructionFileCandidates?: string[]
/**
* Ordered same-directory local-overlay candidates loaded after the base files
* under the same per-directory trimmed-content dedup; empty disables the overlay.
*/
localInstructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
@@ -32,6 +41,7 @@ export const Config: z<Config> = z.object({
maxBytes: z.number().required(),
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
})
/** Normalized instruction discovery configuration. */
@@ -39,6 +49,7 @@ export interface ResolvedDiscoveryConfig {
dshHome: string
projectRootMarkers: string[]
instructionFileCandidates: string[]
localInstructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
@@ -66,17 +77,24 @@ export function resolveConfig(config: Config): ResolvedConfig {
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
instructionFileCandidates: resolveInstructionFileCandidates(
config.instructionFileCandidates,
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
),
localInstructionFileCandidates: resolveInstructionFileCandidates(
config.localInstructionFileCandidates,
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
return (candidates ?? [...fallback]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}
@@ -14,3 +14,15 @@ import { createHash } from 'node:crypto'
export function instructionContentSha1(content: string): string {
return createHash('sha1').update(content).digest('hex')
}
/**
* Compute the whitespace-insensitive identity used for per-directory duplicate
* suppression. Leading and trailing whitespace is trimmed before hashing so a
* symlinked or byte-copied sibling that differs only by surrounding whitespace
* still collapses to a single rendered file.
* @param content - exact UTF-8 instruction text.
* @returns SHA-1 digest of the trimmed content.
*/
export function trimmedInstructionDigest(content: string): string {
return instructionContentSha1(content.trim())
}
+91 -73
View File
@@ -5,13 +5,14 @@
*/
import { createReadStream } from 'node:fs'
import { lstat, stat } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
import { trimmedInstructionDigest } from './digest.ts'
import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
@@ -32,7 +33,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
version?: FsVersion
}
/** Provider metadata for a winning scope candidate before its content is read. */
/** Provider metadata for a probed scope candidate before its content is read. */
export interface ProbedInstructionFile extends InstructionFile {
target: FsTarget
version: FsVersion
@@ -44,6 +45,7 @@ interface DiscoverOptions {
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
signal?: AbortSignal
}
@@ -86,7 +88,9 @@ function isMissingPathError(error: unknown): boolean {
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
// stat (not lstat) follows a final-component symlink so a link to a regular
// file loads; a broken link surfaces as ENOENT and is treated as absent below.
const info = await stat(path)
signal?.throwIfAborted()
if (!info.isFile()) return { kind: 'absent' }
return { kind: 'present', info: { size: info.size } }
@@ -101,25 +105,15 @@ async function fsStatFile(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<StatFileProbe> {
// TODO(instruction-symlink-race): replace this lstat -> resolve -> read
// protocol, including probeScopeInstruction below, with a provider-owned
// atomic no-follow read so the final component cannot change after validation.
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(path, undefined, signal)
signal?.throwIfAborted()
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo?.type !== 'file') return { kind: 'absent' }
// resolve() follows a final-component symlink to its target's stable identity;
// stat then classifies that target. A link to a regular file loads, while a
// missing path or non-file target (including a link to a directory) is absent.
try {
const target = await fileSystem.resolve(path, signalOptions(signal))
signal?.throwIfAborted()
const info = await fileSystem.stat(target, signal)
signal?.throwIfAborted()
if (info?.type !== 'file') return { kind: 'unavailable' }
if (info?.type !== 'file') return { kind: 'absent' }
return {
kind: 'present',
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
@@ -232,33 +226,32 @@ export function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
async function firstExistingInstructionFile(
async function allExistingInstructionFiles(
dir: string,
root: string,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
): Promise<DiscoveredInstructionFile[]> {
const found: DiscoveredInstructionFile[] = []
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const probe = await statFile(path, fileSystem, signal)
switch (probe.kind) {
case 'present':
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
...probe.info,
}
case 'absent':
found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
continue
// A missing candidate is skipped; a transient provider failure skips only
// that candidate so the remaining independent candidates still load.
case 'absent':
case 'unavailable':
return undefined
continue
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
return assertNever(probe, 'StatFileProbe')
assertNever(probe, 'StatFileProbe')
}
}
return undefined
return found
}
async function discoverInstructionFiles(
@@ -274,7 +267,7 @@ async function discoverInstructionFiles(
files.push(file)
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
switch (userGlobalProbe.kind) {
case 'present':
@@ -295,16 +288,21 @@ async function discoverInstructionFiles(
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
if (file !== undefined) addFile(file)
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
addFile(file)
}
}
}
return files
}
/**
* Discover host-visible user-global and root-to-cwd instruction candidates.
* All present candidates in each directory are returned; trimmed-content
* duplicates are collapsed later, once content is read.
* @param options - cwd, home, root marker, and candidate configuration.
* @returns de-duplicated instruction paths in model precedence order.
* @returns path-deduplicated instruction candidates in model precedence order.
*/
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
@@ -316,7 +314,7 @@ async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterabl
}
async function readBounded(
file: DiscoveredInstructionFile,
file: { absolutePath: string; target?: FsTarget; size?: number },
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
@@ -347,6 +345,33 @@ async function readBounded(
}
}
/**
* Drop later candidates whose trimmed content duplicates an earlier sibling in
* the same directory. Different directories never collapse even when identical;
* within one directory the earliest candidate in discovery order is kept and its
* original bytes are rendered. A candidate that symlinks a sibling resolves to
* the same content and collapses here like any byte-identical real file.
* @param files - loaded files in discovery order.
* @returns the retained files in the same order.
*/
export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
const keptDigestsByDir = new Map<string, Set<string>>()
const kept: LoadedInstructionFile[] = []
for (const file of files) {
const dir = dirname(file.displayPath)
let digests = keptDigestsByDir.get(dir)
if (digests === undefined) {
digests = new Set()
keptDigestsByDir.set(dir, digests)
}
const digest = trimmedInstructionDigest(file.content)
if (digests.has(digest)) continue
digests.add(digest)
kept.push(file)
}
return kept
}
/**
* Discover, read, and render the baseline instruction chain.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
@@ -386,18 +411,19 @@ export async function loadBaselineInstructionSet(
})
}
}
if (loaded.length === 0) return undefined
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) }
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
}
/**
* Probe the current first-winning instruction candidate for one logical scope.
* @param scope - `user-global`, `.`, or a project-relative directory.
* Probe the current provider metadata for one per-candidate instruction scope.
* @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
* @param projectRoot - project root used to resolve and display project scopes.
* @param resolved - normalized plugin configuration.
* @param fileSystem - provider used for no-follow probing.
* @param fileSystem - provider used to resolve and stat scope candidates.
* @param signal - cancellation for provider probes.
* @returns present metadata, confirmed absence, or temporary unavailability.
*/
@@ -408,40 +434,32 @@ export async function probeScopeInstruction(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
const { directory, candidateName } = decodeScopeKey(scope)
const dir = directory === USER_GLOBAL_DIRECTORY
? resolved.dshHome
: scope === '.' ? projectRoot : join(projectRoot, scope)
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
for (const candidate of candidates) {
const absolutePath = join(dir, candidate)
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(absolutePath, undefined, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo === undefined || pathInfo.type !== 'file') continue
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (info?.type !== 'file') return { kind: 'unavailable' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
: directory === '.' ? projectRoot : join(projectRoot, directory)
const absolutePath = join(dir, candidateName)
// resolve() follows a final-component symlink; stat then classifies the target.
// A non-file target (missing, or a link to a directory) is a confirmed absence;
// only a provider exception is reported as unavailable.
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
return { kind: 'absent' }
if (info?.type !== 'file') return { kind: 'absent' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
}
/**
@@ -74,6 +74,7 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
@@ -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,16 +70,65 @@ 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)
}
const SCOPE_SEPARATOR = '\u0000'
/**
* 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 candidateName - instruction file name within that directory.
* @returns the per-candidate logical scope key.
*/
export function candidateScopeKey(directory: string, candidateName: string): string {
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
}
/**
* 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 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 {
const scope = scopeForDisplayPath(file.displayPath)
return [
@@ -100,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 \`${change.scope}\` 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')
+82 -29
View File
@@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
import {
ancestorChain,
descendantDirsBetween,
@@ -21,8 +21,12 @@ import {
type LoadedInstructionFile,
} from './files.ts'
import {
candidateScopeKey,
decodeScopeKey,
instructionScopeKey,
renderInstructionChanges,
scopeForDisplayPath,
USER_GLOBAL_DIRECTORY,
USER_GLOBAL_FILE,
type ChangeRenderItem,
type WorkspaceInstructionChange,
} from './render.ts'
@@ -44,6 +48,11 @@ export interface InstructionVersionState {
path: string
version: FsVersion
digest: string
/**
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
* per-directory duplicates on the metadata fast path without re-reading a sibling.
*/
trimmedDigest: string
}
/** Session-isolated fast-path state keyed by logical instruction scope. */
@@ -71,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
action: change.action,
scope: change.scope,
path: change.path,
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
@@ -112,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
if (value.digest !== undefined && typeof value.digest !== 'string') continue
changes.push({
action: value.action,
scope: value.scope,
path: value.path,
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
...value.digest !== undefined ? { digest: value.digest } : {},
})
}
@@ -129,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
return a.action === b.action
&& a.scope === b.scope
&& a.path === b.path
&& a.previousPath === b.previousPath
&& a.digest === b.digest
}
@@ -169,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
const digest = instructionContentSha1(file.content)
const change: WorkspaceInstructionChange = {
action: 'set',
scope: scopeForDisplayPath(file.displayPath),
scope: instructionScopeKey(file.displayPath),
path: file.displayPath,
digest,
}
changes.set(change.scope, change)
if (file.version !== undefined) {
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
versions.set(change.scope, {
path: file.displayPath,
version: file.version,
digest,
trimmedDigest: trimmedInstructionDigest(file.content),
})
}
}
return { changes, versions }
@@ -391,34 +401,67 @@ export async function reconcileInstructionContext(
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
if (options.includeBaselineScopes) {
scopes.add('user-global')
for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
const addDirScopes = (directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
}
const addProjectScopes = (dir: string): void => {
addDirScopes(relativeScope(projectRoot, dir))
}
if (options.includeBaselineScopes) {
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
}
for (const scope of effective.keys()) {
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(directory)
}
for (const scope of effective.keys()) scopes.add(scope)
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
}
const versions = versionStatesFor(session, versionCache)
const seenAbsolutePaths = new Set<string>()
// Per-directory trimmed-content identities kept so far this pass, iterated in
// candidate order (base before local); a later sibling matching an earlier one
// is a duplicate and is dropped or removed rather than rendered twice.
const keptTrimmedByDir = new Map<string, Set<string>>()
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
let digests = keptTrimmedByDir.get(directory)
if (digests === undefined) {
digests = new Set()
keptTrimmedByDir.set(directory, digests)
}
if (digests.has(digest)) return true
digests.add(digest)
return false
}
const items: ChangeRenderItem[] = []
const versionUpdates: InstructionVersionUpdate[] = []
const pushRemoval = (scope: string, path: string): void => {
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
for (const scope of scopes) {
const { directory } = decodeScopeKey(scope)
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') continue
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') {
versions.delete(scope)
continue
if (probe.kind === 'unavailable') {
// Last-good-state: the candidate stays effective, so its cached trimmed
// digest must keep occupying the directory's dedup slot — otherwise an
// identical later sibling would be emitted as a duplicate `set` until the
// next successful reconciliation removed it again.
const cached = versions.get(scope)
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
registerKeptTrimmed(directory, cached.trimmedDigest)
}
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
items.push({
change,
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
})
versionUpdates.push({ change })
continue
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
@@ -433,29 +476,39 @@ export async function reconcileInstructionContext(
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) continue
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
? previous.path
: undefined
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
...previousPath === undefined ? {} : { previousPath },
digest: currentDigest,
}
items.push({ change, file })