2026-07-12 12:09:35 +08:00
/**
* Model-facing workspace instruction rendering within an explicit byte budget.
*
* @module @deepseek-ai/dsh-workspace-context/render
*/
2026-07-22 10:55:19 +08:00
import { basename , dirname } from 'node:path'
2026-07-10 14:32:44 +08:00
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.'
2026-08-04 23:11:15 +08:00
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ WORKSPACE_CONTEXT_INTRO
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ 'No workspace instructions are currently active.'
2026-07-10 14:32:44 +08:00
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
}
2026-07-12 12:09:35 +08:00
/** Model-facing text plus omitted and truncated source records. */
2026-07-10 14:32:44 +08:00
export interface RenderedWorkspaceContext {
text : string
omitted : InstructionFile [ ]
truncated : TruncatedInstruction [ ]
}
2026-07-30 01:25:11 -07:00
interface RenderedInstructionContext extends RenderedWorkspaceContext {
2026-07-30 02:07:38 -07:00
/**
2026-07-30 03:57:57 -07:00
* Original files semantically represented by rendered section text. This is
* not the complement of `omitted`: a truncated file may be represented here
* and in `truncated`, while a notice-only file appears in neither. A genuinely
* empty file counts when its heading survives because that heading conveys
* that the instruction exists and has no content.
2026-07-30 02:07:38 -07:00
*/
represented : LoadedInstructionFile [ ]
}
2026-07-10 14:32:44 +08:00
/** Structured dynamic state persisted outside model-visible prompt prose. */
export interface WorkspaceInstructionChange {
action : 'set' | 'replace' | 'remove'
scope : string
path : 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 {
2026-07-30 02:57:08 -07:00
const bytes = Buffer . from ( value , 'utf8' )
if ( bytes . length <= maxBytes ) return value
let end = Math . max ( 0 , Math . trunc ( maxBytes ) )
// If the first excluded byte is a UTF-8 continuation byte, the budget cut
// through that code point. Back up to its lead byte and exclude it too.
while ( end > 0 && ( bytes . readUInt8 ( end ) & 0xc0 ) === 0x80 ) {
end -= 1
2026-07-10 14:32:44 +08:00
}
2026-07-30 02:57:08 -07:00
return bytes . subarray ( 0 , end ) . toString ( 'utf8' )
2026-07-10 14:32:44 +08:00
}
2026-07-28 03:10:45 -07:00
function escapeInstructionFrameBody ( body : string ) : string {
return body . replaceAll ( SYSTEM_REMINDER_CLOSE , '<\\/system-reminder>' )
2026-07-10 14:32:44 +08:00
}
function sectionText ( file : LoadedInstructionFile ) : string {
2026-07-28 03:10:45 -07:00
return ` Instructions from: ${ file . displayPath } \ n \ n ${ file . content } `
2026-07-10 14:32:44 +08:00
}
2026-07-22 10:55:19 +08:00
/** Directory component that identifies the single user-global instruction scope. */
export const USER_GLOBAL_DIRECTORY = 'user-global'
/**
* File name of the single user-global instruction file under `$DSH_HOME`.
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
* candidate component) both key on this name, so it lives in one place: were the
* two to disagree, the user-global instruction would load but never reconcile.
*/
export const USER_GLOBAL_FILE = 'AGENTS.md'
2026-07-10 14:32:44 +08:00
/**
* 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 {
2026-07-22 10:55:19 +08:00
if ( displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md' ) return USER_GLOBAL_DIRECTORY
2026-07-10 14:32:44 +08:00
return dirname ( displayPath )
}
2026-07-22 10:55:19 +08:00
const SCOPE_SEPARATOR = '\u0000'
2026-07-22 10:55:18 +08:00
/**
2026-07-22 10:55:19 +08:00
* Compose the reconciliation key for one instruction candidate file.
* Each loaded candidate is tracked independently, so the key pairs the logical
* directory with the exact candidate file name behind a NUL separator that no
* directory path or file name can contain. Distinct candidates in one directory
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
* never collide in the scope-keyed state maps.
2026-07-22 10:55:18 +08:00
* @param directory - `user-global`, `.`, or a project-relative directory.
2026-07-22 10:55:19 +08:00
* @param candidateName - instruction file name within that directory.
* @returns the per-candidate logical scope key.
*/
export function candidateScopeKey ( directory : string , candidateName : string ) : string {
return ` ${ directory } ${ SCOPE_SEPARATOR } ${ candidateName } `
}
/**
* Derive the per-candidate scope key for a loaded instruction file.
* @param displayPath - project-relative or user-global instruction path.
* @returns the scope key pairing the file's directory with its name.
2026-07-22 10:55:18 +08:00
*/
2026-07-22 10:55:19 +08:00
export function instructionScopeKey ( displayPath : string ) : string {
return candidateScopeKey ( scopeForDisplayPath ( displayPath ) , basename ( displayPath ) )
2026-07-22 10:55:18 +08:00
}
/**
2026-07-22 10:55:19 +08:00
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
* @param scope - a per-candidate scope key.
* @returns the directory scope and the candidate file name within it.
2026-07-22 10:55:18 +08:00
*/
2026-07-22 10:55:19 +08:00
export function decodeScopeKey ( scope : string ) : { directory : string ; candidateName : string } {
const separator = scope . indexOf ( SCOPE_SEPARATOR )
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
if ( separator < 0 ) return { directory : scope , candidateName : '' }
return { directory : scope.slice ( 0 , separator ) , candidateName : scope.slice ( separator + 1 ) }
2026-07-22 10:55:18 +08:00
}
2026-07-10 14:32:44 +08:00
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. ` ,
'' ,
2026-07-28 03:10:45 -07:00
file . content ,
2026-07-10 14:32:44 +08:00
] . join ( '\n' )
}
const BASELINE_RENDER_STYLE : RenderStyle = { intro : WORKSPACE_CONTEXT_INTRO , section : sectionText }
2026-08-10 00:49:16 -07:00
function baselineRenderStyle ( files : LoadedInstructionFile [ ] , replacePreviousBaseline : boolean | undefined ) : RenderStyle {
if ( replacePreviousBaseline !== true ) return BASELINE_RENDER_STYLE
return {
. . . BASELINE_RENDER_STYLE ,
intro : files.length === 0
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO ,
}
}
2026-07-10 14:32:44 +08:00
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. `
}
return [
` Updated instructions from: ${ change . path } ` ,
'' ,
2026-07-22 10:55:19 +08:00
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.' ,
2026-07-10 14:32:44 +08:00
'' ,
2026-07-28 03:10:45 -07:00
file . content ,
2026-07-10 14:32:44 +08:00
] . 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 )
2026-07-30 02:07:38 -07:00
const represented = new Set ( rendered . represented . map ( file = > file . absolutePath ) )
2026-07-10 14:32:44 +08:00
return {
text : rendered.text ,
2026-07-30 02:07:38 -07:00
changes : items
2026-07-30 02:57:08 -07:00
. filter ( item = > represented . has ( item . file . absolutePath ) )
2026-07-30 02:07:38 -07:00
. map ( item = > item . change ) ,
2026-07-10 14:32:44 +08:00
}
}
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 )
2026-07-22 10:55:17 +08:00
// Caller-owned framing: the plugin bakes the complete `<system-reminder>`
// frame into the message content. The session surface projects context
// verbatim and does not wrap it, so any framing must live here in the
// producer's content (the pattern a future `meta`-driven renderer would
// generalize — see the deferred note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md).
2026-07-28 03:10:45 -07:00
return [ SYSTEM_REMINDER_OPEN , escapeInstructionFrameBody ( body . join ( '\n\n' ) ) , SYSTEM_REMINDER_CLOSE ] . join ( '\n' )
2026-07-10 14:32:44 +08:00
}
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 ,
2026-07-30 01:25:11 -07:00
) : RenderedInstructionContext {
if ( maxBytes <= 0 || ! Number . isFinite ( maxBytes ) ) {
2026-07-30 02:07:38 -07:00
return { text : '' , omitted : files , truncated : [ ] , represented : [ ] }
2026-07-30 01:25:11 -07:00
}
2026-07-10 14:32:44 +08:00
const fullText = buildInstructionText ( files , maxBytes , [ ] , [ ] , style )
2026-07-30 01:25:11 -07:00
if ( byteLength ( fullText ) <= maxBytes ) {
2026-07-30 02:07:38 -07:00
return { text : fullText , omitted : [ ] , truncated : [ ] , represented : files }
2026-07-30 01:25:11 -07:00
}
2026-07-10 14:32:44 +08:00
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 )
2026-07-30 02:07:38 -07:00
if ( byteLength ( suffixText ) <= maxBytes ) return { text : suffixText , omitted , truncated : [ ] , represented : included }
2026-07-10 14:32:44 +08:00
}
const mostSpecific = files . at ( - 1 )
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
2026-07-30 02:07:38 -07:00
if ( mostSpecific === undefined ) return { text : '' , omitted : [ ] , truncated : [ ] , represented : [ ] }
2026-07-10 14:32:44 +08:00
const omitted = files . slice ( 0 , - 1 ) . map ( file = > ( { absolutePath : file.absolutePath , displayPath : file.displayPath } ) )
2026-07-30 02:57:08 -07:00
const originalBytes = byteLength ( mostSpecific . content )
2026-07-10 14:32:44 +08:00
for ( const candidateStyle of [ style , { . . . style , intro : COMPACT_WORKSPACE_CONTEXT_INTRO } ] ) {
const truncatedFile = truncateToFit ( mostSpecific , [ ] , maxBytes , omitted , candidateStyle )
2026-07-30 02:57:08 -07:00
const includedBytes = byteLength ( truncatedFile . content )
2026-07-10 14:32:44 +08:00
const truncated = [ {
displayPath : mostSpecific.displayPath ,
2026-07-30 02:57:08 -07:00
originalBytes ,
includedBytes ,
2026-07-10 14:32:44 +08:00
} ]
const text = buildInstructionText ( [ truncatedFile ] , maxBytes , omitted , truncated , candidateStyle )
2026-07-30 02:57:08 -07:00
if ( byteLength ( text ) <= maxBytes ) {
const represented = includedBytes > 0 || originalBytes === 0 ? [ mostSpecific ] : [ ]
return { text , omitted , truncated , represented }
}
2026-07-10 14:32:44 +08:00
}
const truncated = [ {
displayPath : mostSpecific.displayPath ,
2026-07-30 02:57:08 -07:00
originalBytes ,
2026-07-10 14:32:44 +08:00
includedBytes : 0 ,
} ]
2026-07-28 03:10:45 -07:00
const compactNotice = escapeInstructionFrameBody ( markerText ( maxBytes , omitted , truncated ) )
const compactWithHeading = escapeInstructionFrameBody (
[ compactNotice , style . section ( withTruncatedContent ( mostSpecific , 0 ) ) ] . join ( '\n\n' ) ,
)
2026-07-30 01:25:11 -07:00
if ( byteLength ( compactWithHeading ) <= maxBytes ) {
2026-07-30 02:57:08 -07:00
const represented = originalBytes === 0 ? [ mostSpecific ] : [ ]
return { text : compactWithHeading , omitted , truncated , represented }
2026-07-30 01:25:11 -07:00
}
2026-07-10 14:32:44 +08:00
const text = byteLength ( compactNotice ) <= maxBytes ? compactNotice : truncateUtf8 ( compactNotice , maxBytes )
2026-07-30 02:07:38 -07:00
return { text , omitted , truncated , represented : [ ] }
2026-07-30 01:25:11 -07:00
}
/**
* Render a baseline together with the exact source files semantically represented in it.
* @param files - loaded files ordered from broadest to most specific.
2026-08-10 00:49:16 -07:00
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
2026-07-30 02:33:33 -07:00
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
2026-07-30 01:25:11 -07:00
* @internal
*/
export function renderWorkspaceInstructionSet (
files : LoadedInstructionFile [ ] ,
2026-08-10 00:49:16 -07:00
options : { maxBytes : number ; replacePreviousBaseline? : boolean } ,
) : { rendered : RenderedWorkspaceContext ; included : LoadedInstructionFile [ ] } {
const style = baselineRenderStyle ( files , options . replacePreviousBaseline )
const { represented , . . . rendered } = renderInstructionContext ( files , options . maxBytes , style )
2026-07-30 02:57:08 -07:00
return { rendered , included : represented }
2026-07-10 14:32:44 +08:00
}
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
2026-08-04 23:11:15 +08:00
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
2026-07-10 14:32:44 +08:00
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext (
files : LoadedInstructionFile [ ] ,
2026-08-04 23:11:15 +08:00
options : { maxBytes : number ; replacePreviousBaseline? : boolean } ,
2026-07-10 14:32:44 +08:00
) : RenderedWorkspaceContext {
2026-07-30 01:25:11 -07:00
return renderWorkspaceInstructionSet ( files , options ) . rendered
2026-07-10 14:32:44 +08:00
}