Adapt workspace context to session prefixes
This commit is contained in:
54
packages/prompt/workspace-context/src/config.ts
Normal file
54
packages/prompt/workspace-context/src/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import z from 'schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
|
||||
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
|
||||
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[]
|
||||
/** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */
|
||||
maxBytes?: 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().default(DEFAULT_MAX_BYTES),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Fully defaulted configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
maxBytes: number
|
||||
instructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
dshHome: resolveDshHome(config.dshHome),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
|
||||
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)
|
||||
))
|
||||
}
|
||||
360
packages/prompt/workspace-context/src/files.ts
Normal file
360
packages/prompt/workspace-context/src/files.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { lstat, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, 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
|
||||
}
|
||||
|
||||
interface FileSignature {
|
||||
version: string
|
||||
size: number | undefined
|
||||
}
|
||||
|
||||
interface CachedContent extends FileSignature {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
signature: FileSignature
|
||||
target?: FsTarget
|
||||
}
|
||||
|
||||
/** Provider-signature-keyed content cache shared across plugin hooks. */
|
||||
export type InstructionContentCache = Map<string, CachedContent>
|
||||
|
||||
interface DiscoverOptions {
|
||||
cwd: string
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes?: number
|
||||
cache?: InstructionContentCache
|
||||
}
|
||||
|
||||
/** 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: LoadedInstructionFile }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile()) return undefined
|
||||
return { version: `${info.mtimeMs}:${info.size}`, size: info.size }
|
||||
} catch {
|
||||
// Candidates can disappear while discovery is in progress.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function fsStatFile(
|
||||
path: string,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile['signature'] & { target: FsTarget } | undefined> {
|
||||
try {
|
||||
const pathInfo = await fileSystem.lstat(path)
|
||||
if (pathInfo?.type !== 'file') return undefined
|
||||
const target = await fileSystem.resolve(path)
|
||||
const info = await fileSystem.stat(target)
|
||||
if (info?.type !== 'file') return undefined
|
||||
return { version: info.version, size: info.size, target }
|
||||
} catch {
|
||||
// Provider absence and discovery races are both non-fatal.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(
|
||||
path: string,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<(DiscoveredInstructionFile['signature'] & { target?: FsTarget }) | undefined> {
|
||||
return fileSystem === undefined ? nodeStatFile(path) : fsStatFile(path, fileSystem)
|
||||
}
|
||||
|
||||
async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise<boolean> {
|
||||
if (fileSystem !== undefined) {
|
||||
try {
|
||||
const target = await fileSystem.resolve(path)
|
||||
return await fileSystem.stat(target) !== undefined
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
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.
|
||||
* @returns the discovered project root, or `cwd` when no marker exists.
|
||||
*/
|
||||
export async function findProjectRoot(
|
||||
cwd: string,
|
||||
markers: readonly string[],
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<string> {
|
||||
let current = resolve(cwd)
|
||||
for (;;) {
|
||||
for (const marker of markers) {
|
||||
if (await existsAsMarker(join(current, marker), fileSystem)) 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,
|
||||
): Promise<DiscoveredInstructionFile | undefined> {
|
||||
for (const candidate of instructionFileCandidates) {
|
||||
const path = join(dir, candidate)
|
||||
const fileSignature = await statFile(path, fileSystem)
|
||||
if (fileSignature !== undefined) {
|
||||
const { target, ...signature } = fileSignature
|
||||
return {
|
||||
absolutePath: path,
|
||||
displayPath: relativeDisplay(root, path),
|
||||
signature,
|
||||
...target === undefined ? {} : { target },
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function discoverInstructionFiles(
|
||||
options: DiscoverOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveConfig(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 userGlobalSignature = await statFile(userGlobal, fileSystem)
|
||||
if (userGlobalSignature !== undefined) {
|
||||
const { target, ...signature } = userGlobalSignature
|
||||
addFile({
|
||||
absolutePath: userGlobal,
|
||||
displayPath: userGlobalDisplayPath(config.dshHome),
|
||||
signature,
|
||||
...target === undefined ? {} : { target },
|
||||
})
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem)
|
||||
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 readCached(
|
||||
file: DiscoveredInstructionFile,
|
||||
cache: InstructionContentCache,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<string | undefined> {
|
||||
const path = file.absolutePath
|
||||
const { signature } = file
|
||||
const cached = cache.get(path)
|
||||
if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) {
|
||||
return cached.content
|
||||
}
|
||||
try {
|
||||
const content = fileSystem === undefined || file.target === undefined
|
||||
? await readFile(path, 'utf8')
|
||||
: await fileSystem.readText(file.target)
|
||||
cache.set(path, { ...signature, content })
|
||||
return content
|
||||
} catch {
|
||||
// A file may disappear or become unreadable after its metadata probe.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover, read, and render the baseline instruction chain.
|
||||
* @param options - discovery, byte-budget, and optional cache 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, byte-budget, and optional cache 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
|
||||
const cache = options.cache ?? new Map<string, CachedContent>()
|
||||
const discovered = await discoverInstructionFiles(options, fileSystem)
|
||||
const loaded: LoadedInstructionFile[] = []
|
||||
for (const file of discovered) {
|
||||
const content = await readCached(file, cache, fileSystem)
|
||||
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
|
||||
}
|
||||
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 cache - shared content cache.
|
||||
* @param fileSystem - provider used for no-follow probing and reading.
|
||||
* @returns present content, confirmed absence, or temporary unavailability.
|
||||
*/
|
||||
export async function loadScopeInstruction(
|
||||
scope: string,
|
||||
projectRoot: string,
|
||||
resolved: ResolvedConfig,
|
||||
cache: InstructionContentCache,
|
||||
fileSystem: FileSystem,
|
||||
): 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)
|
||||
} catch {
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (pathInfo === undefined || pathInfo.type !== 'file') continue
|
||||
let target: FsTarget
|
||||
let info: FsInfo | undefined
|
||||
try {
|
||||
target = await fileSystem.resolve(absolutePath)
|
||||
info = await fileSystem.stat(target)
|
||||
} catch {
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
const discovered: DiscoveredInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
signature: { version: info.version, size: info.size },
|
||||
target,
|
||||
}
|
||||
const content = await readCached(discovered, cache, fileSystem)
|
||||
if (content === undefined) return { kind: 'unavailable' }
|
||||
return { kind: 'present', file: { absolutePath, displayPath: discovered.displayPath, content } }
|
||||
}
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
function userGlobalDisplayPath(dshHome: string): string {
|
||||
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
||||
}
|
||||
117
packages/prompt/workspace-context/src/index.ts
Normal file
117
packages/prompt/workspace-context/src/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import {
|
||||
loadBaselineInstructionSet,
|
||||
type InstructionContentCache,
|
||||
} from './files.ts'
|
||||
import {
|
||||
baselineInstructionChanges,
|
||||
concatContext,
|
||||
dynamicInstructionContext,
|
||||
name,
|
||||
reconcileInstructionContext,
|
||||
workspaceContextMessage,
|
||||
type PendingInstructionChange,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
|
||||
export { Config, name }
|
||||
export {
|
||||
discoverBaselineInstructionFiles,
|
||||
loadBaselineInstructions,
|
||||
} from './files.ts'
|
||||
export type {
|
||||
InstructionContentCache,
|
||||
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 cache: InstructionContentCache = new Map()
|
||||
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
|
||||
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
|
||||
|
||||
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,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
cache,
|
||||
}, fileSystem)
|
||||
baselineInstructionStates.set(agent.session, baselineInstructionChanges(instructions?.included ?? []))
|
||||
|
||||
const update = await reconcileInstructionContext(
|
||||
agent,
|
||||
resolved,
|
||||
cache,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
fileSystem,
|
||||
{ includeBaselineScopes: false },
|
||||
)
|
||||
if (update !== undefined) {
|
||||
agent.inject(update.content, {
|
||||
source: update.source,
|
||||
envelope: update.envelope,
|
||||
meta: update.meta,
|
||||
})
|
||||
}
|
||||
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()
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return downstream
|
||||
const context = await dynamicInstructionContext(
|
||||
exec.agent,
|
||||
exec,
|
||||
result,
|
||||
resolved,
|
||||
cache,
|
||||
pendingNestedChanges,
|
||||
baselineInstructionStates,
|
||||
fileSystem,
|
||||
)
|
||||
if (context === undefined) return downstream
|
||||
const additionalContext = concatContext(context, downstream.additionalContext)
|
||||
if (downstream.kind === 'block') {
|
||||
return { kind: 'block', feedback: downstream.feedback, additionalContext }
|
||||
}
|
||||
return {
|
||||
kind: 'accept',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContext,
|
||||
}
|
||||
})
|
||||
}
|
||||
243
packages/prompt/workspace-context/src/render.ts
Normal file
243
packages/prompt/workspace-context/src/render.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** Bounded 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 {
|
||||
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,
|
||||
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 - 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)
|
||||
}
|
||||
301
packages/prompt/workspace-context/src/state.ts
Normal file
301
packages/prompt/workspace-context/src/state.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
findProjectRoot,
|
||||
loadScopeInstruction,
|
||||
relativeDisplay,
|
||||
type InstructionContentCache,
|
||||
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
|
||||
}
|
||||
|
||||
/** Plugin-owned raw context with required replay metadata. */
|
||||
export interface WorkspaceHookContext extends HookContext {
|
||||
envelope: 'raw'
|
||||
meta: JsonValue
|
||||
}
|
||||
|
||||
function digest(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
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 }] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve workspace state ownership while folding a downstream context contribution.
|
||||
* @param ours - workspace raw context and structured metadata.
|
||||
* @param theirs - optional downstream context with its own envelope semantics.
|
||||
* @returns one workspace-owned context containing both model-visible contributions.
|
||||
*/
|
||||
export function concatContext(ours: WorkspaceHookContext, theirs: HookContext | undefined): WorkspaceHookContext {
|
||||
if (theirs === undefined) return ours
|
||||
return {
|
||||
...ours,
|
||||
content: [
|
||||
...ours.content,
|
||||
...renderContextContent(theirs.content, theirs.source, theirs.envelope),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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.digest === b.digest
|
||||
}
|
||||
|
||||
function visibleInstructionChanges(
|
||||
agent: Agent,
|
||||
pending: Map<string, PendingInstructionChange>,
|
||||
): Map<string, WorkspaceInstructionChange> {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes.map(node => node.seq))
|
||||
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 scope/path/digest comparison state.
|
||||
* @param files - baseline files that survived rendering.
|
||||
* @returns latest baseline state keyed by logical scope.
|
||||
*/
|
||||
export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<string, WorkspaceInstructionChange> {
|
||||
return new Map(files.map((file) => {
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest: digest(file.content),
|
||||
}
|
||||
return [change.scope, change]
|
||||
}))
|
||||
}
|
||||
|
||||
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 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 cache - shared provider-signature content cache.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @param baselineBySession - frozen baseline comparison state per session.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @param options - touched path and whether baseline scopes should be checked.
|
||||
* @returns a structured context update, or undefined when state is unchanged/unavailable.
|
||||
*/
|
||||
export async function reconcileInstructionContext(
|
||||
agent: Agent,
|
||||
resolved: ResolvedConfig,
|
||||
cache: InstructionContentCache,
|
||||
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
fileSystem: FileSystem,
|
||||
options: { touchedPath?: string; includeBaselineScopes: boolean },
|
||||
): Promise<WorkspaceHookContext | 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()
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem)
|
||||
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 current = new Map<string, LoadedInstructionFile>()
|
||||
const unavailable = new Set<string>()
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
for (const scope of scopes) {
|
||||
const probe = await loadScopeInstruction(scope, projectRoot, resolved, cache, fileSystem)
|
||||
if (probe.kind === 'unavailable') {
|
||||
unavailable.add(scope)
|
||||
continue
|
||||
}
|
||||
if (probe.kind === 'absent') continue
|
||||
const { file } = probe
|
||||
if (seenAbsolutePaths.has(file.absolutePath)) continue
|
||||
seenAbsolutePaths.add(file.absolutePath)
|
||||
current.set(scope, file)
|
||||
}
|
||||
|
||||
const items: ChangeRenderItem[] = []
|
||||
for (const scope of scopes) {
|
||||
if (unavailable.has(scope)) continue
|
||||
const previous = effective.get(scope)
|
||||
const file = current.get(scope)
|
||||
if (file === undefined) {
|
||||
if (previous !== undefined && previous.action !== 'remove') {
|
||||
items.push({
|
||||
change: { action: 'remove', scope, path: previous.path },
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
const currentDigest = digest(file.content)
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
? previous.path
|
||||
: undefined
|
||||
items.push({
|
||||
change: {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
},
|
||||
file,
|
||||
})
|
||||
}
|
||||
if (items.length === 0) return undefined
|
||||
const rendered = renderInstructionChanges(items, resolved.maxBytes)
|
||||
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
|
||||
for (const change of rendered.changes) pending.set(change.scope, { change, afterSeq: session.seq })
|
||||
return workspaceContextHook(rendered.text, 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 cache - shared provider-signature content cache.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
* @returns a structured context update, or undefined for irrelevant/failed/unchanged calls.
|
||||
*/
|
||||
export async function dynamicInstructionContext(
|
||||
agent: Agent | undefined,
|
||||
exec: ToolExecution,
|
||||
result: ToolExecutionResult,
|
||||
resolved: ResolvedConfig,
|
||||
cache: InstructionContentCache,
|
||||
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
|
||||
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
|
||||
fileSystem: FileSystem,
|
||||
): Promise<WorkspaceHookContext | undefined> {
|
||||
if (agent === undefined || result.isError) return undefined
|
||||
const touchedPath = filePathFromExecution(exec)
|
||||
if (touchedPath === undefined) return undefined
|
||||
return reconcileInstructionContext(
|
||||
agent, resolved, cache, pendingNestedChanges, baselineInstructionStates, fileSystem,
|
||||
{ touchedPath, includeBaselineScopes: baselineInstructionStates.has(agent.session) },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user