render.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. /**
  2. * Model-facing workspace instruction rendering within an explicit byte budget.
  3. *
  4. * @module @deepseek-ai/dsh-agent-instructions/render
  5. */
  6. import { basename, dirname } from 'node:path'
  7. import type { InstructionFile, LoadedInstructionFile } from './files.ts'
  8. const SYSTEM_REMINDER_OPEN = '<system-reminder>'
  9. const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
  10. const AGENT_INSTRUCTIONS_INTRO = 'The following workspace instructions may be relevant to your work. '
  11. + 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
  12. + 'They do not override system, developer, or direct user instructions.'
  13. const REPLACEMENT_AGENT_INSTRUCTIONS_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
  14. + AGENT_INSTRUCTIONS_INTRO
  15. const EMPTY_REPLACEMENT_AGENT_INSTRUCTIONS_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
  16. + 'No workspace instructions are currently active.'
  17. const COMPACT_AGENT_INSTRUCTIONS_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
  18. /** Byte-accounting record for one truncated instruction file. */
  19. export interface TruncatedInstruction {
  20. displayPath: string
  21. originalBytes: number
  22. includedBytes: number
  23. }
  24. /** Model-facing text plus omitted and truncated source records. */
  25. export interface RenderedAgentInstructions {
  26. text: string
  27. omitted: InstructionFile[]
  28. truncated: TruncatedInstruction[]
  29. }
  30. interface RenderedInstructionContext extends RenderedAgentInstructions {
  31. /**
  32. * Original files semantically represented by rendered section text. This is
  33. * not the complement of `omitted`: a truncated file may be represented here
  34. * and in `truncated`, while a notice-only file appears in neither. A genuinely
  35. * empty file counts when its heading survives because that heading conveys
  36. * that the instruction exists and has no content.
  37. */
  38. represented: LoadedInstructionFile[]
  39. }
  40. /** Structured dynamic state persisted outside model-visible prompt prose. */
  41. export interface AgentInstructionChange {
  42. action: 'set' | 'replace' | 'remove'
  43. scope: string
  44. path: string
  45. digest?: string
  46. }
  47. /** One state transition paired with the content used to render it. */
  48. export interface ChangeRenderItem {
  49. change: AgentInstructionChange
  50. file: LoadedInstructionFile
  51. }
  52. interface RenderStyle {
  53. intro: string
  54. section(file: LoadedInstructionFile): string
  55. }
  56. function byteLength(value: string): number {
  57. return Buffer.byteLength(value, 'utf8')
  58. }
  59. function truncateUtf8(value: string, maxBytes: number): string {
  60. const bytes = Buffer.from(value, 'utf8')
  61. if (bytes.length <= maxBytes) return value
  62. let end = Math.max(0, Math.trunc(maxBytes))
  63. // If the first excluded byte is a UTF-8 continuation byte, the budget cut
  64. // through that code point. Back up to its lead byte and exclude it too.
  65. while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) {
  66. end -= 1
  67. }
  68. return bytes.subarray(0, end).toString('utf8')
  69. }
  70. function escapeInstructionFrameBody(body: string): string {
  71. return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
  72. }
  73. function sectionText(file: LoadedInstructionFile): string {
  74. return `Instructions from: ${file.displayPath}\n\n${file.content}`
  75. }
  76. /** Directory component that identifies the single user-global instruction scope. */
  77. export const USER_GLOBAL_DIRECTORY = 'user-global'
  78. /**
  79. * File name of the single user-global instruction file under `$DSH_HOME`.
  80. * Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
  81. * candidate component) both key on this name, so it lives in one place: were the
  82. * two to disagree, the user-global instruction would load but never reconcile.
  83. */
  84. export const USER_GLOBAL_FILE = 'AGENTS.md'
  85. /**
  86. * Derive the logical instruction scope from a model-facing path.
  87. * @param displayPath - project-relative or user-global instruction path.
  88. * @returns `user-global`, `.`, or the containing project-relative directory.
  89. */
  90. export function scopeForDisplayPath(displayPath: string): string {
  91. if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
  92. return dirname(displayPath)
  93. }
  94. const SCOPE_SEPARATOR = '\u0000'
  95. /**
  96. * Compose the reconciliation key for one instruction candidate file.
  97. * Each loaded candidate is tracked independently, so the key pairs the logical
  98. * directory with the exact candidate file name behind a NUL separator that no
  99. * directory path or file name can contain. Distinct candidates in one directory
  100. * (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
  101. * never collide in the scope-keyed state maps.
  102. * @param directory - `user-global`, `.`, or a project-relative directory.
  103. * @param candidateName - instruction file name within that directory.
  104. * @returns the per-candidate logical scope key.
  105. */
  106. export function candidateScopeKey(directory: string, candidateName: string): string {
  107. return `${directory}${SCOPE_SEPARATOR}${candidateName}`
  108. }
  109. /**
  110. * Derive the per-candidate scope key for a loaded instruction file.
  111. * @param displayPath - project-relative or user-global instruction path.
  112. * @returns the scope key pairing the file's directory with its name.
  113. */
  114. export function instructionScopeKey(displayPath: string): string {
  115. return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
  116. }
  117. /**
  118. * Recover the directory and candidate name that {@link candidateScopeKey} encoded.
  119. * @param scope - a per-candidate scope key.
  120. * @returns the directory scope and the candidate file name within it.
  121. */
  122. export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
  123. const separator = scope.indexOf(SCOPE_SEPARATOR)
  124. /* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
  125. if (separator < 0) return { directory: scope, candidateName: '' }
  126. return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
  127. }
  128. function additionalSectionText(file: LoadedInstructionFile): string {
  129. const scope = scopeForDisplayPath(file.displayPath)
  130. return [
  131. `Additional instructions from: ${file.displayPath}`,
  132. '',
  133. `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.`,
  134. '',
  135. file.content,
  136. ].join('\n')
  137. }
  138. const BASELINE_RENDER_STYLE: RenderStyle = { intro: AGENT_INSTRUCTIONS_INTRO, section: sectionText }
  139. function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
  140. if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
  141. return {
  142. ...BASELINE_RENDER_STYLE,
  143. intro: files.length === 0
  144. ? EMPTY_REPLACEMENT_AGENT_INSTRUCTIONS_INTRO
  145. : REPLACEMENT_AGENT_INSTRUCTIONS_INTRO,
  146. }
  147. }
  148. function changedSectionText(item: ChangeRenderItem): string {
  149. const { change, file } = item
  150. if (change.action === 'set') return additionalSectionText(file)
  151. if (change.action === 'remove') {
  152. return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
  153. }
  154. return [
  155. `Updated instructions from: ${change.path}`,
  156. '',
  157. 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
  158. '',
  159. file.content,
  160. ].join('\n')
  161. }
  162. /**
  163. * Render one reconciliation batch and retain only transitions that fit.
  164. * @param items - ordered state transitions and current file contents.
  165. * @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
  166. * @returns bounded prompt text and the transitions actually represented by it.
  167. */
  168. export function renderInstructionChanges(
  169. items: ChangeRenderItem[],
  170. maxBytes: number,
  171. ): { text: string; changes: AgentInstructionChange[] } {
  172. const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
  173. const style: RenderStyle = {
  174. intro: '',
  175. section(file) {
  176. const item = byAbsolutePath.get(file.absolutePath)
  177. /* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
  178. return item === undefined ? '' : changedSectionText({ ...item, file })
  179. },
  180. }
  181. const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
  182. const represented = new Set(rendered.represented.map(file => file.absolutePath))
  183. return {
  184. text: rendered.text,
  185. changes: items
  186. .filter(item => represented.has(item.file.absolutePath))
  187. .map(item => item.change),
  188. }
  189. }
  190. function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string {
  191. if (omitted.length === 0 && truncated.length === 0) return ''
  192. const parts: string[] = []
  193. if (omitted.length > 0) {
  194. parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`)
  195. }
  196. if (truncated.length > 0) {
  197. parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`)
  198. }
  199. return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}`
  200. }
  201. function buildInstructionText(
  202. files: LoadedInstructionFile[],
  203. maxBytes: number,
  204. omitted: InstructionFile[],
  205. truncated: TruncatedInstruction[],
  206. style: RenderStyle,
  207. ): string {
  208. const marker = markerText(maxBytes, omitted, truncated)
  209. const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
  210. // Caller-owned framing: the plugin bakes the complete `<system-reminder>`
  211. // frame into the message content. The session surface projects context
  212. // verbatim and does not wrap it, so any framing must live here in the
  213. // producer's content (the pattern a future `meta`-driven renderer would
  214. // generalize — see the deferred note in
  215. // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md).
  216. return [SYSTEM_REMINDER_OPEN, escapeInstructionFrameBody(body.join('\n\n')), SYSTEM_REMINDER_CLOSE].join('\n')
  217. }
  218. function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile {
  219. return { ...file, content: truncateUtf8(file.content, includedBytes) }
  220. }
  221. function truncateToFit(
  222. file: LoadedInstructionFile,
  223. includedFiles: LoadedInstructionFile[],
  224. maxBytes: number,
  225. omitted: InstructionFile[],
  226. style: RenderStyle,
  227. ): LoadedInstructionFile {
  228. const originalBytes = byteLength(file.content)
  229. let low = 0
  230. let high = originalBytes
  231. let best = withTruncatedContent(file, 0)
  232. while (low <= high) {
  233. const mid = Math.floor((low + high) / 2)
  234. const candidate = withTruncatedContent(file, mid)
  235. const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }]
  236. const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)
  237. if (byteLength(text) <= maxBytes) {
  238. best = candidate
  239. low = mid + 1
  240. } else {
  241. high = mid - 1
  242. }
  243. }
  244. return best
  245. }
  246. function renderInstructionContext(
  247. files: LoadedInstructionFile[],
  248. maxBytes: number,
  249. style: RenderStyle,
  250. ): RenderedInstructionContext {
  251. if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
  252. return { text: '', omitted: files, truncated: [], represented: [] }
  253. }
  254. const fullText = buildInstructionText(files, maxBytes, [], [], style)
  255. if (byteLength(fullText) <= maxBytes) {
  256. return { text: fullText, omitted: [], truncated: [], represented: files }
  257. }
  258. for (let start = 1; start < files.length; start += 1) {
  259. const included = files.slice(start)
  260. const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
  261. const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
  262. if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included }
  263. }
  264. const mostSpecific = files.at(-1)
  265. /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
  266. if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] }
  267. const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
  268. const originalBytes = byteLength(mostSpecific.content)
  269. for (const candidateStyle of [style, { ...style, intro: COMPACT_AGENT_INSTRUCTIONS_INTRO }]) {
  270. const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
  271. const includedBytes = byteLength(truncatedFile.content)
  272. const truncated = [{
  273. displayPath: mostSpecific.displayPath,
  274. originalBytes,
  275. includedBytes,
  276. }]
  277. const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
  278. if (byteLength(text) <= maxBytes) {
  279. const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
  280. return { text, omitted, truncated, represented }
  281. }
  282. }
  283. const truncated = [{
  284. displayPath: mostSpecific.displayPath,
  285. originalBytes,
  286. includedBytes: 0,
  287. }]
  288. const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated))
  289. const compactWithHeading = escapeInstructionFrameBody(
  290. [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
  291. )
  292. if (byteLength(compactWithHeading) <= maxBytes) {
  293. const represented = originalBytes === 0 ? [mostSpecific] : []
  294. return { text: compactWithHeading, omitted, truncated, represented }
  295. }
  296. const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
  297. return { text, omitted, truncated, represented: [] }
  298. }
  299. /**
  300. * Render a baseline together with the exact source files semantically represented in it.
  301. * @param files - loaded files ordered from broadest to most specific.
  302. * @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
  303. * @returns bounded public rendering plus files with surviving content, including genuinely empty files.
  304. * @internal
  305. */
  306. export function renderAgentInstructionSet(
  307. files: LoadedInstructionFile[],
  308. options: { maxBytes: number; replacePreviousBaseline?: boolean },
  309. ): { rendered: RenderedAgentInstructions; included: LoadedInstructionFile[] } {
  310. const style = baselineRenderStyle(files, options.replacePreviousBaseline)
  311. const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
  312. return { rendered, included: represented }
  313. }
  314. /**
  315. * Render the baseline instruction chain with deterministic precedence budgeting.
  316. * @param files - loaded files ordered from broadest to most specific.
  317. * @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
  318. * @returns bounded baseline prompt text and budget diagnostics.
  319. */
  320. export function renderAgentInstructions(
  321. files: LoadedInstructionFile[],
  322. options: { maxBytes: number; replacePreviousBaseline?: boolean },
  323. ): RenderedAgentInstructions {
  324. return renderAgentInstructionSet(files, options).rendered
  325. }