fix(workspace-context): require explicit byte budgets
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 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_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(['', '.', '..'])
|
||||
@@ -12,8 +17,8 @@ export interface Config {
|
||||
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
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
maxBytes: number
|
||||
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
@@ -21,28 +26,45 @@ export interface Config {
|
||||
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),
|
||||
maxBytes: z.number().required(),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Fully defaulted configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig {
|
||||
/** Normalized instruction discovery configuration. */
|
||||
export interface ResolvedDiscoveryConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
maxBytes: number
|
||||
instructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/** Normalized configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
|
||||
maxBytes: 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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/prompt/workspace-context/src/digest.ts
Normal file
16
packages/prompt/workspace-context/src/digest.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Content identity for workspace instruction caching and 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')
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Instruction-file discovery, provider reads, and content-aware caching.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/files
|
||||
*/
|
||||
|
||||
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 { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
@@ -18,10 +25,10 @@ export interface LoadedInstructionFile extends InstructionFile {
|
||||
|
||||
interface FileSignature {
|
||||
version: string
|
||||
size: number | undefined
|
||||
}
|
||||
|
||||
interface CachedContent extends FileSignature {
|
||||
sha1: string
|
||||
content: string
|
||||
}
|
||||
|
||||
@@ -30,7 +37,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
}
|
||||
|
||||
/** Provider-signature-keyed content cache shared across plugin hooks. */
|
||||
/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */
|
||||
export type InstructionContentCache = Map<string, CachedContent>
|
||||
|
||||
interface DiscoverOptions {
|
||||
@@ -41,7 +48,7 @@ interface DiscoverOptions {
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes?: number
|
||||
maxBytes: number
|
||||
cache?: InstructionContentCache
|
||||
}
|
||||
|
||||
@@ -61,7 +68,7 @@ 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 }
|
||||
return { version: String(info.mtimeMs) }
|
||||
} catch {
|
||||
// Candidates can disappear while discovery is in progress.
|
||||
return undefined
|
||||
@@ -78,7 +85,7 @@ async function fsStatFile(
|
||||
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 }
|
||||
return { version: info.version, target }
|
||||
} catch {
|
||||
// Provider absence and discovery races are both non-fatal.
|
||||
return undefined
|
||||
@@ -204,7 +211,7 @@ async function discoverInstructionFiles(
|
||||
options: DiscoverOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveConfig(options)
|
||||
const config = resolveDiscoveryConfig(options)
|
||||
const files: DiscoveredInstructionFile[] = []
|
||||
const seen = new Set<string>()
|
||||
const addFile = (file: DiscoveredInstructionFile): void => {
|
||||
@@ -250,15 +257,14 @@ async function readCached(
|
||||
): 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 })
|
||||
const sha1 = instructionContentSha1(content)
|
||||
const cached = cache.get(path)
|
||||
if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content
|
||||
cache.set(path, { ...signature, sha1, content })
|
||||
return content
|
||||
} catch {
|
||||
// A file may disappear or become unreadable after its metadata probe.
|
||||
@@ -345,7 +351,7 @@ export async function loadScopeInstruction(
|
||||
const discovered: DiscoveredInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
signature: { version: info.version, size: info.size },
|
||||
signature: { version: info.version },
|
||||
target,
|
||||
}
|
||||
const content = await readCached(discovered, cache, fileSystem)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
@@ -15,7 +21,7 @@ export interface TruncatedInstruction {
|
||||
includedBytes: number
|
||||
}
|
||||
|
||||
/** Bounded model-facing text plus omitted and truncated source records. */
|
||||
/** Model-facing text plus omitted and truncated source records. */
|
||||
export interface RenderedWorkspaceContext {
|
||||
text: string
|
||||
omitted: InstructionFile[]
|
||||
@@ -232,7 +238,7 @@ function renderInstructionContext(
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget.
|
||||
* @param options - required rendering byte budget.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderWorkspaceContext(
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
/**
|
||||
* 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 { 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 { instructionContentSha1 } from './digest.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
@@ -38,10 +44,6 @@ export interface WorkspaceHookContext extends HookContext {
|
||||
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,
|
||||
@@ -154,7 +156,7 @@ export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest: digest(file.content),
|
||||
digest: instructionContentSha1(file.content),
|
||||
}
|
||||
return [change.scope, change]
|
||||
}))
|
||||
@@ -181,7 +183,7 @@ function relativeScope(projectRoot: string, dir: string): string {
|
||||
* 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 cache - shared provider-version and content-digest 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.
|
||||
@@ -245,7 +247,7 @@ export async function reconcileInstructionContext(
|
||||
}
|
||||
continue
|
||||
}
|
||||
const currentDigest = digest(file.content)
|
||||
const currentDigest = instructionContentSha1(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
|
||||
@@ -275,7 +277,7 @@ export async function reconcileInstructionContext(
|
||||
* @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 cache - shared provider-version and content-digest cache.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
|
||||
Reference in New Issue
Block a user