Merge current master into debug-pangwenjie

# Conflicts:
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/feature/2026-06-30-interception-seams.md
#	docs/tool-execution-pipeline.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/README.md
#	packages/core/tools/src/index.ts
#	scripts/gen-doc-graphs.ts
This commit is contained in:
Tianyi Cui
2026-07-18 13:39:12 +08:00
701 changed files with 38518 additions and 8779 deletions

View File

@@ -0,0 +1,82 @@
/**
* Configuration normalization for workspace instruction discovery and rendering.
*
* @module @deepseek-ai/dsh-workspace-context/config
*/
import z from 'schemastery'
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_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
/** User-facing workspace instruction loader configuration. */
export interface Config {
/** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Directory entries that identify the project root while walking upward from the session cwd. */
projectRootMarkers?: string[]
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
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. */
instructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
dshHome: z.string(),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
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]),
})
/** Normalized instruction discovery configuration. */
export interface ResolvedDiscoveryConfig {
dshHome: string
projectRootMarkers: string[]
instructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxBytes: number
maxSourceBytes: number
}
/**
* Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration.
* @returns normalized runtime configuration.
*/
export function resolveConfig(config: Config): ResolvedConfig {
return {
...resolveDiscoveryConfig(config),
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
}
}
/**
* Resolve the subset of configuration used before instruction content is rendered.
* @param config - optional discovery controls.
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}

View File

@@ -0,0 +1,16 @@
/**
* Content identity for workspace instruction duplicate suppression.
*
* @module @deepseek-ai/dsh-workspace-context/digest
*/
import { createHash } from 'node:crypto'
/**
* Compute the content identity used across instruction loading and session state.
* @param content - exact UTF-8 instruction text.
* @returns lowercase SHA-1 digest in hexadecimal form.
*/
export function instructionContentSha1(content: string): string {
return createHash('sha1').update(content).digest('hex')
}

View File

@@ -0,0 +1,473 @@
/**
* Instruction-file discovery and bounded, abort-aware provider reads.
*
* @module @deepseek-ai/dsh-workspace-context/files
*/
import { createReadStream } from 'node:fs'
import { lstat, 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 { assertNever } from '@deepseek-ai/dsh-llm'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
absolutePath: string
displayPath: string
}
/** An instruction file whose UTF-8 content was read successfully. */
export interface LoadedInstructionFile extends InstructionFile {
content: string
/** Provider freshness token when the file was loaded through `ctx.fs`. */
version?: FsVersion
}
interface DiscoveredInstructionFile extends InstructionFile {
target?: FsTarget
size?: number
version?: FsVersion
}
/** Provider metadata for a winning scope candidate before its content is read. */
export interface ProbedInstructionFile extends InstructionFile {
target: FsTarget
version: FsVersion
size?: number
}
interface DiscoverOptions {
cwd: string
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
signal?: AbortSignal
}
interface LoadOptions extends DiscoverOptions {
maxBytes: number
maxSourceBytes?: number
}
/** Rendered baseline plus the files that survived byte budgeting. */
export interface RenderedInstructionSet {
rendered: RenderedWorkspaceContext
included: LoadedInstructionFile[]
}
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
export type ScopeInstructionProbe =
| { kind: 'present'; file: ProbedInstructionFile }
| { kind: 'absent' }
| { kind: 'unavailable' }
interface StatFileInfo {
target?: FsTarget
size?: number
version?: FsVersion
}
type StatFileProbe =
| { kind: 'present'; info: StatFileInfo }
| { kind: 'absent' }
| { kind: 'unavailable' }
function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
return signal === undefined ? undefined : { signal }
}
function isMissingPathError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
}
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
signal?.throwIfAborted()
if (!info.isFile()) return { kind: 'absent' }
return { kind: 'present', info: { size: info.size } }
} catch (error: unknown) {
signal?.throwIfAborted()
return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' }
}
}
async function fsStatFile(
path: string,
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' }
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' }
return {
kind: 'present',
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
}
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
}
async function statFile(
path: string,
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<StatFileProbe> {
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
}
async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise<boolean> {
if (fileSystem !== undefined) {
try {
const target = await fileSystem.resolve(path, signalOptions(signal))
return await fileSystem.stat(target, signal) !== undefined
} catch {
signal?.throwIfAborted()
// TODO(root-marker-unavailable): preserve provider failure separately from
// absence and stop discovery; continuing upward can cross into an ancestor project.
return false
}
}
try {
signal?.throwIfAborted()
await stat(path)
signal?.throwIfAborted()
return true
} catch {
signal?.throwIfAborted()
return false
}
}
/**
* Walk upward to the first directory containing a configured root marker.
* @param cwd - absolute session working directory where the walk begins.
* @param markers - child names that identify a project root.
* @param fileSystem - optional provider used instead of host filesystem probes.
* @param signal - cancellation for provider and host probes.
* @returns the discovered project root, or `cwd` when no marker exists.
*/
export async function findProjectRoot(
cwd: string,
markers: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<string> {
let current = resolve(cwd)
for (;;) {
for (const marker of markers) {
if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current
}
const parent = dirname(current)
if (parent === current) return resolve(cwd)
current = parent
}
}
/**
* Build the inclusive root-to-cwd directory chain.
* @param root - root directory expected to contain or equal `cwd`.
* @param cwd - most-specific directory in the chain.
* @returns directories ordered from broadest to most specific.
*/
export function ancestorChain(root: string, cwd: string): string[] {
const chain: string[] = []
let current = resolve(cwd)
const resolvedRoot = resolve(root)
while (current !== resolvedRoot) {
chain.push(current)
const parent = dirname(current)
/* v8 ignore next -- discovery always supplies cwd or an ancestor root. */
if (parent === current) break
current = parent
}
chain.push(resolvedRoot)
return chain.reverse()
}
/**
* Find descendant directories crossed between a cwd and a touched file.
* @param root - session cwd that bounds nested discovery.
* @param touchedPath - absolute path or path relative to `root`.
* @returns descendant directories from shallowest through the touched file's parent.
*/
export function descendantDirsBetween(root: string, touchedPath: string): string[] {
const resolvedRoot = resolve(root)
const targetPath = isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath)
const targetDir = dirname(targetPath)
const rel = relative(resolvedRoot, targetDir)
if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return []
return ancestorChain(resolvedRoot, targetDir).slice(1)
}
/**
* Convert an absolute instruction path to its project-root-relative display form.
* @param root - project root used as the display base.
* @param path - absolute path to display.
* @returns the root-relative path.
*/
export function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
async function firstExistingInstructionFile(
dir: string,
root: string,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
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':
continue
case 'unavailable':
return undefined
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
return assertNever(probe, 'StatFileProbe')
}
}
return undefined
}
async function discoverInstructionFiles(
options: DiscoverOptions,
fileSystem?: FileSystem,
): Promise<DiscoveredInstructionFile[]> {
const config = resolveDiscoveryConfig(options)
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 userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
switch (userGlobalProbe.kind) {
case 'present':
addFile({
absolutePath: userGlobal,
displayPath: userGlobalDisplayPath(config.dshHome),
...userGlobalProbe.info,
})
break
case 'absent':
case 'unavailable':
break
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
assertNever(userGlobalProbe, 'StatFileProbe')
}
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)
}
return files
}
/**
* Discover host-visible user-global and root-to-cwd instruction candidates.
* @param options - cwd, home, root marker, and candidate configuration.
* @returns de-duplicated instruction paths in model precedence order.
*/
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
}
async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable<string> {
const stream = createReadStream(path, { encoding: 'utf8', signal })
for await (const chunk of stream) yield String(chunk)
}
async function readBounded(
file: DiscoveredInstructionFile,
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<string | undefined> {
// TODO(total-instruction-read-bound): enforce an aggregate source budget
// across a complete baseline or reconciliation batch; the render budget is
// applied only after every accepted file has been read under this per-file cap.
signal?.throwIfAborted()
if (file.size !== undefined && file.size > maxSourceBytes) return undefined
try {
const chunks = fileSystem === undefined || file.target === undefined
? nodeTextChunks(file.absolutePath, signal)
: await fileSystem.streamText(file.target, signal)
const parts: string[] = []
let bytes = 0
for await (const chunk of chunks) {
signal?.throwIfAborted()
bytes += Buffer.byteLength(chunk, 'utf8')
if (bytes > maxSourceBytes) return undefined
parts.push(chunk)
}
signal?.throwIfAborted()
return parts.join('')
} catch {
signal?.throwIfAborted()
// A file may disappear or become unreadable after its metadata probe.
return undefined
}
}
/**
* Discover, read, and render the baseline instruction chain.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered baseline context, or undefined when nothing can be loaded.
*/
export async function loadBaselineInstructions(
options: LoadOptions,
fileSystem?: FileSystem,
): Promise<RenderedWorkspaceContext | undefined> {
return (await loadBaselineInstructionSet(options, fileSystem))?.rendered
}
/**
* Load a baseline together with the files retained after rendering.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered context and retained files, or undefined when empty or disabled.
*/
export async function loadBaselineInstructionSet(
options: LoadOptions,
fileSystem?: FileSystem,
): Promise<RenderedInstructionSet | undefined> {
const config = resolveConfig(options)
if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined
if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined
const discovered = await discoverInstructionFiles(options, fileSystem)
const loaded: LoadedInstructionFile[] = []
for (const file of discovered) {
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
if (content !== undefined) {
loaded.push({
absolutePath: file.absolutePath,
displayPath: file.displayPath,
content,
...file.version === undefined ? {} : { version: file.version },
})
}
}
if (loaded.length === 0) return undefined
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: loaded.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.
* @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 signal - cancellation for provider probes.
* @returns present metadata, confirmed absence, or temporary unavailability.
*/
export async function probeScopeInstruction(
scope: string,
projectRoot: string,
resolved: ResolvedConfig,
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
? 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 }
}
return { kind: 'absent' }
}
/**
* Read one already-probed scope candidate under the configured source cap.
* @param file - winning provider candidate and its metadata snapshot.
* @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
* @param fileSystem - provider used for the streaming read.
* @param signal - cancellation for provider streaming.
* @returns loaded content with the probed version, or undefined when unavailable.
*/
export async function readScopeInstruction(
file: ProbedInstructionFile,
maxSourceBytes: number,
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<LoadedInstructionFile | undefined> {
const content = await readBounded(file, maxSourceBytes, fileSystem, signal)
if (content === undefined) return undefined
return {
absolutePath: file.absolutePath,
displayPath: file.displayPath,
content,
version: file.version,
}
}
function userGlobalDisplayPath(dshHome: string): string {
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
}

View File

@@ -0,0 +1,173 @@
/**
* Workspace instruction loader for AGENTS.md-compatible files.
*
* Baseline instructions are frozen into `agent/session-prefix`; successful fs
* tool touches reconcile nested, changed, and removed instructions through
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
*
* @module @deepseek-ai/dsh-workspace-context
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
commitPendingInstructionContexts,
dynamicInstructionContext,
name,
observeInstructionSessionEvent,
reconcileInstructionContext,
retainedInstructionVersionUpdates,
rollbackPendingInstructionChanges,
workspaceContextMessage,
type InstructionVersionCache,
type InstructionVersionUpdate,
type PendingInstructionChange,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
export { Config, name }
export {
discoverBaselineInstructionFiles,
loadBaselineInstructions,
} from './files.ts'
export type {
InstructionFile,
LoadedInstructionFile,
} from './files.ts'
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
changes: WorkspaceInstructionChange[]
versionUpdates: InstructionVersionUpdate[]
}>()
ctx.on('session/event', (session, event) => {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
})
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
const rest = await next()
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return rest
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
baselineInstructionStates.set(agent.session, baseline.changes)
instructionVersions.set(agent.session, baseline.versions)
const update = await reconcileInstructionContext(
agent,
resolved,
pendingNestedChanges,
baselineInstructionStates,
instructionVersions,
fileSystem,
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
agent.inject(update.context.content, {
source: update.context.source,
envelope: update.context.envelope,
meta: update.context.meta,
})
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
return [workspaceContextMessage(instructions.rendered.text), ...rest]
})
ctx.on('tools/post-execute', async (
exec: ToolExecution,
result: ToolExecutionResult,
next,
): Promise<PostToolDecision> => {
const downstream = await next()
// A downstream listener/policy blocked this call: the registry turns it
// into a final `isError` result, so treat it like a failed fs touch and
// load nothing. Reconciling here would surface workspace instructions from
// a call the pipeline rejected, violating the "successful fs tool touches"
// contract, and would advance the nested/baseline tracking state off a
// touch that never really happened.
if (downstream.kind === 'block') return downstream
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return downstream
const update = await dynamicInstructionContext(
exec.agent,
exec,
result,
resolved,
pendingNestedChanges,
baselineInstructionStates,
instructionVersions,
fileSystem,
)
if (update === undefined) return downstream
pendingVersionUpdates.set(exec.token, update.versionUpdates)
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
}
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
pendingVersionUpdates.delete(exec.token)
if (exec.parent !== undefined) {
if (exec.agent === undefined) return
// Child contexts participate in duplicate suppression within one composite
// run, but remain provisional until the parent reaches its final policy.
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
if (changes.length === 0) return
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
const staged = pendingByParent.get(exec.parent)
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
else {
staged.changes.push(...changes)
staged.versionUpdates.push(...versionUpdates)
}
return
}
// The parent result is authoritative: remove every provisional child change,
// then commit only contexts that survived outer post-execute policy.
const staged = pendingByParent.get(exec.token)
if (staged !== undefined) {
pendingByParent.delete(exec.token)
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
}
if (exec.agent === undefined) return
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
const stagedVersionUpdates = staged?.versionUpdates ?? []
const versionUpdates = retainedInstructionVersionUpdates(
[...stagedVersionUpdates, ...ownVersionUpdates],
committed,
)
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
})
}

View File

@@ -0,0 +1,255 @@
/**
* Model-facing workspace instruction rendering within an explicit byte budget.
*
* @module @deepseek-ai/dsh-workspace-context/render
*/
import { dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
+ 'They do not override system, developer, or direct user instructions.'
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
/** Byte-accounting record for one truncated instruction file. */
export interface TruncatedInstruction {
displayPath: string
originalBytes: number
includedBytes: number
}
/** Model-facing text plus omitted and truncated source records. */
export interface RenderedWorkspaceContext {
text: string
omitted: InstructionFile[]
truncated: TruncatedInstruction[]
}
/** Structured dynamic state persisted outside model-visible prompt prose. */
export interface WorkspaceInstructionChange {
action: 'set' | 'replace' | 'remove'
scope: string
path: string
previousPath?: string
digest?: string
}
/** One state transition paired with the content used to render it. */
export interface ChangeRenderItem {
change: WorkspaceInstructionChange
file: LoadedInstructionFile
}
interface RenderStyle {
intro: string
section(file: LoadedInstructionFile): string
}
function byteLength(value: string): number {
return Buffer.byteLength(value, 'utf8')
}
function truncateUtf8(value: string, maxBytes: number): string {
let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
while (byteLength(truncated) > maxBytes) {
truncated = truncated.slice(0, -1)
}
return truncated
}
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.
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
}
function sectionText(file: LoadedInstructionFile): string {
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
}
/**
* 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'
return dirname(displayPath)
}
function additionalSectionText(file: LoadedInstructionFile): string {
const scope = scopeForDisplayPath(file.displayPath)
return [
`Additional instructions from: ${file.displayPath}`,
'',
`These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`,
'',
escapeInstructionContent(file.content),
].join('\n')
}
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
function changedSectionText(item: ChangeRenderItem): string {
const { change, file } = item
if (change.action === 'set') return additionalSectionText(file)
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,
'',
escapeInstructionContent(file.content),
].join('\n')
}
/**
* Render one reconciliation batch and retain only transitions that fit.
* @param items - ordered state transitions and current file contents.
* @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
* @returns bounded prompt text and the transitions actually represented by it.
*/
export function renderInstructionChanges(
items: ChangeRenderItem[],
maxBytes: number,
): { text: string; changes: WorkspaceInstructionChange[] } {
const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
const style: RenderStyle = {
intro: '',
section(file) {
const item = byAbsolutePath.get(file.absolutePath)
/* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
return item === undefined ? '' : changedSectionText({ ...item, file })
},
}
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return {
text: rendered.text,
// TODO(rendered-change-proof): retain a transition only when its semantic
// notice survived rendering; a tiny compact budget can currently return
// unrelated notice text while still committing the full state transition.
changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change),
}
}
function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string {
if (omitted.length === 0 && truncated.length === 0) return ''
const parts: string[] = []
if (omitted.length > 0) {
parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`)
}
if (truncated.length > 0) {
parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`)
}
return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}`
}
function buildInstructionText(
files: LoadedInstructionFile[],
maxBytes: number,
omitted: InstructionFile[],
truncated: TruncatedInstruction[],
style: RenderStyle,
): string {
const marker = markerText(maxBytes, omitted, truncated)
const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n')
}
function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile {
return { ...file, content: truncateUtf8(file.content, includedBytes) }
}
function truncateToFit(
file: LoadedInstructionFile,
includedFiles: LoadedInstructionFile[],
maxBytes: number,
omitted: InstructionFile[],
style: RenderStyle,
): LoadedInstructionFile {
const originalBytes = byteLength(file.content)
let low = 0
let high = originalBytes
let best = withTruncatedContent(file, 0)
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const candidate = withTruncatedContent(file, mid)
const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }]
const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)
if (byteLength(text) <= maxBytes) {
best = candidate
low = mid + 1
} else {
high = mid - 1
}
}
return best
}
function renderInstructionContext(
files: LoadedInstructionFile[],
maxBytes: number,
style: RenderStyle,
): RenderedWorkspaceContext {
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
const fullText = buildInstructionText(files, maxBytes, [], [], style)
if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] }
for (let start = 1; start < files.length; start += 1) {
const included = files.slice(start)
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] }
}
const mostSpecific = files.at(-1)
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] }
const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
const truncated = [{
displayPath: mostSpecific.displayPath,
originalBytes: byteLength(mostSpecific.content),
includedBytes: byteLength(truncatedFile.content),
}]
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
if (byteLength(text) <= maxBytes) return { text, omitted, truncated }
}
const truncated = [{
displayPath: mostSpecific.displayPath,
originalBytes: byteLength(mostSpecific.content),
includedBytes: 0,
}]
const compactNotice = markerText(maxBytes, omitted, truncated)
const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n')
if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated }
const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
return { text, omitted, truncated }
}
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
* @param options - required rendering byte budget.
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext(
files: LoadedInstructionFile[],
options: { maxBytes: number },
): RenderedWorkspaceContext {
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
}

View File

@@ -0,0 +1,507 @@
/**
* Session-visible workspace instruction state and dynamic reconciliation.
*
* @module @deepseek-ai/dsh-workspace-context/state
*/
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
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 {
ancestorChain,
descendantDirsBetween,
findProjectRoot,
probeScopeInstruction,
readScopeInstruction,
relativeDisplay,
type LoadedInstructionFile,
} from './files.ts'
import {
renderInstructionChanges,
scopeForDisplayPath,
type ChangeRenderItem,
type WorkspaceInstructionChange,
} from './render.ts'
export const name = 'workspace-context'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
afterSeq: number
step?: { turn: number; step: number }
}
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
export interface InstructionVersionState {
path: string
version: FsVersion
digest: string
}
/** Session-isolated fast-path state keyed by logical instruction scope. */
export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVersionState>>
/** A cache transition coupled to the model-visible change that authorizes it. */
export interface InstructionVersionUpdate {
change: WorkspaceInstructionChange
state?: InstructionVersionState
}
/** Rendered reconciliation plus cache transitions awaiting final policy. */
export interface ReconciledInstructionContext {
context: WorkspaceHookContext
versionUpdates: InstructionVersionUpdate[]
}
/** Plugin-owned raw context with required replay metadata. */
export interface WorkspaceHookContext extends HookContext {
envelope: 'raw'
meta: JsonValue
}
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({
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 }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta }
}
/**
* Build the request-prefix message for a rendered baseline.
* @param text - complete plugin-owned system-reminder text.
* @returns a user-role prefix message.
*/
export function workspaceContextMessage(text: string): Message {
return { role: 'user', content: [{ type: 'text', text }] }
}
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
const filePath = exec.arguments.file_path.trim()
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'plugin'
&& 'plugin' in source && source.plugin === name
}
function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
const changes: WorkspaceInstructionChange[] = []
for (const value of meta.changes) {
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 } : {},
})
}
return changes
}
function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
return a.action === b.action
&& a.scope === b.scope
&& a.path === b.path
&& a.previousPath === b.previousPath
&& a.digest === b.digest
}
function visibleInstructionChanges(
agent: Agent,
pending: Map<string, PendingInstructionChange>,
): Map<string, WorkspaceInstructionChange> {
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
}
}
for (const { change } of pending.values()) visible.set(change.scope, change)
return visible
}
/**
* Convert retained baseline files into comparison and metadata-cache state.
* @param files - baseline files that survived rendering.
* @returns latest baseline changes and provider versions keyed by logical scope.
*/
export function baselineInstructionState(files: LoadedInstructionFile[]): {
changes: Map<string, WorkspaceInstructionChange>
versions: Map<string, InstructionVersionState>
} {
const changes = new Map<string, WorkspaceInstructionChange>()
const versions = new Map<string, InstructionVersionState>()
for (const file of files) {
const digest = instructionContentSha1(file.content)
const change: WorkspaceInstructionChange = {
action: 'set',
scope: scopeForDisplayPath(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 })
}
}
return { changes, versions }
}
function versionStatesFor(session: Session, cache: InstructionVersionCache): Map<string, InstructionVersionState> {
let states = cache.get(session)
if (states === undefined) {
states = new Map()
cache.set(session, states)
}
return states
}
/**
* Keep only cache updates whose model-visible changes survived final policy.
* @param updates - proposed updates from one or more reconciliations.
* @param committedChanges - transitions retained on the authoritative result.
* @returns updates authorized by an exact retained transition.
*/
export function retainedInstructionVersionUpdates(
updates: readonly InstructionVersionUpdate[],
committedChanges: readonly WorkspaceInstructionChange[],
): InstructionVersionUpdate[] {
return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change)))
}
/**
* Apply authorized metadata-cache transitions without retaining instruction prose.
* @param session - owning session.
* @param updates - ordered set/delete transitions.
* @param cache - session-isolated metadata cache.
*/
export function applyInstructionVersionUpdates(
session: Session,
updates: readonly InstructionVersionUpdate[],
cache: InstructionVersionCache,
): void {
if (updates.length === 0) return
const states = versionStatesFor(session, cache)
for (const update of updates) {
if (update.state === undefined) states.delete(update.change.scope)
else states.set(update.change.scope, update.state)
}
if (states.size === 0) cache.delete(session)
}
function pendingChangesFor(
session: object,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): Map<string, PendingInstructionChange> {
let pending = pendingBySession.get(session)
if (pending === undefined) {
pending = new Map()
pendingBySession.set(session, pending)
}
return pending
}
function openStep(session: Session): { turn: number; step: number } | undefined {
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
return boundary?.type === 'step/start' ? boundary.data : undefined
}
function invalidateInstructionVersions(
session: Session,
scopes: readonly string[],
cache: InstructionVersionCache,
): void {
const states = cache.get(session)
if (states === undefined) return
for (const scope of scopes) states.delete(scope)
if (states.size === 0) cache.delete(session)
}
/**
* Settle provisional tool-result state against durable session events.
* A matching context event confirms the transition. If its owning step closes
* first, both duplicate suppression and the metadata fast path are re-armed for
* the next successful touch.
* @param session - session whose append-only log emitted `event`.
* @param event - newly committed session event.
* @param pendingBySession - provisional transitions awaiting log confirmation.
* @param versionCache - metadata fast path coupled to those transitions.
*/
export function observeInstructionSessionEvent(
session: Session,
event: SessionEvent,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
): void {
const pending = pendingBySession.get(session)
if (pending === undefined) return
switch (event.type) {
case 'context/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
}
if (pending.size === 0) pendingBySession.delete(session)
return
}
case 'step/end': {
const discardedScopes: string[] = []
for (const [scope, waiting] of pending) {
const step = waiting.step
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
pending.delete(scope)
discardedScopes.push(scope)
}
if (pending.size === 0) pendingBySession.delete(session)
invalidateInstructionVersions(session, discardedScopes, versionCache)
return
}
default:
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
return
}
}
/**
* Commit only workspace contexts that survived the complete tool pipeline.
* The observe-only `tools/result` notification calls this before the loop can
* append the returned contexts, closing that short pending window without
* trusting an intermediate post-execute decision.
* @param agent - session that will receive the final result contexts.
* @param contexts - immutable contexts on the authoritative top-level result.
* @param pendingBySession - per-session pending transition maps.
* @returns transitions committed into the short pending window.
*/
export function commitPendingInstructionContexts(
agent: Agent,
contexts: readonly HookContext[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
pending.set(change.scope, {
change,
afterSeq: agent.session.seq,
...step === undefined ? {} : { step },
})
committed.push(change)
}
}
return committed
}
/**
* Roll back parent-token state when an enclosing tool result discards deferred
* contexts. A newer transition for the same scope is left intact.
* @param agent - session whose pending state was staged.
* @param changes - exact staged transitions to remove when still current.
* @param pendingBySession - per-session pending transition maps.
*/
export function rollbackPendingInstructionChanges(
agent: Agent,
changes: readonly WorkspaceInstructionChange[],
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): void {
const pending = pendingBySession.get(agent.session)
if (pending === undefined) return
for (const change of changes) {
const current = pending.get(change.scope)
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
}
if (pending.size === 0) pendingBySession.delete(agent.session)
}
function relativeScope(projectRoot: string, dir: string): string {
const scope = relativeDisplay(projectRoot, dir)
return scope.length === 0 ? '.' : scope
}
/**
* Compare visible/pending state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param pendingBySession - short pending window before returned context is logged.
* @param baselineBySession - frozen baseline comparison state per session.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @param options - touched path and whether baseline scopes should be checked.
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
*/
export async function reconcileInstructionContext(
agent: Agent,
resolved: ResolvedConfig,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
const visible = visibleInstructionChanges(agent, pending)
const effective = new Map(baselineBySession.get(session) ?? [])
for (const [scope, change] of visible) effective.set(scope, change)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
// 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))
}
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))
}
const versions = versionStatesFor(session, versionCache)
const seenAbsolutePaths = new Set<string>()
const items: ChangeRenderItem[] = []
const versionUpdates: InstructionVersionUpdate[] = []
for (const scope of scopes) {
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
}
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
items.push({
change,
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
})
versionUpdates.push({ change })
continue
}
const { file: probedFile } = probe
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
seenAbsolutePaths.add(probedFile.absolutePath)
const cached = versions.get(scope)
if (
cached !== undefined
&& cached.path === probedFile.displayPath
&& cached.version === probedFile.version
&& previous !== undefined
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) continue
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
}
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 })
versionUpdates.push({ change, state: nextVersion })
}
if (items.length === 0) return undefined
const rendered = renderInstructionChanges(items, resolved.maxBytes)
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
return {
context: workspaceContextHook(rendered.text, rendered.changes),
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
}
}
/**
* Validate a successful structured file touch and reconcile its applicable scopes.
* @param agent - optional agent attached to the tool execution.
* @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
*/
export async function dynamicInstructionContext(
agent: Agent | undefined,
exec: ToolExecution,
result: ToolExecutionResult,
resolved: ResolvedConfig,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
): Promise<ReconciledInstructionContext | undefined> {
if (agent === undefined || result.isError) return undefined
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineInstructionStates.has(agent.session),
...exec.signal === undefined ? {} : { signal: exec.signal },
},
)
}