render.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Model-facing workspace instruction rendering within an explicit byte budget.
  3. *
  4. * @module @deepseek-ai/dsh-workspace-context/render
  5. */
  6. import { 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 WORKSPACE_CONTEXT_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 COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
  14. /** Byte-accounting record for one truncated instruction file. */
  15. export interface TruncatedInstruction {
  16. displayPath: string
  17. originalBytes: number
  18. includedBytes: number
  19. }
  20. /** Model-facing text plus omitted and truncated source records. */
  21. export interface RenderedWorkspaceContext {
  22. text: string
  23. omitted: InstructionFile[]
  24. truncated: TruncatedInstruction[]
  25. }
  26. /** Structured dynamic state persisted outside model-visible prompt prose. */
  27. export interface WorkspaceInstructionChange {
  28. action: 'set' | 'replace' | 'remove'
  29. scope: string
  30. path: string
  31. previousPath?: string
  32. digest?: string
  33. }
  34. /** One state transition paired with the content used to render it. */
  35. export interface ChangeRenderItem {
  36. change: WorkspaceInstructionChange
  37. file: LoadedInstructionFile
  38. }
  39. interface RenderStyle {
  40. intro: string
  41. section(file: LoadedInstructionFile): string
  42. }
  43. function byteLength(value: string): number {
  44. return Buffer.byteLength(value, 'utf8')
  45. }
  46. function truncateUtf8(value: string, maxBytes: number): string {
  47. let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8')
  48. while (byteLength(truncated) > maxBytes) {
  49. truncated = truncated.slice(0, -1)
  50. }
  51. return truncated
  52. }
  53. function escapeInstructionContent(content: string): string {
  54. // TODO(instruction-frame-paths): apply the same delimiter neutralization to
  55. // every interpolated path, scope, and previous path; repository-controlled
  56. // names can otherwise close the plugin-owned system-reminder frame.
  57. return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
  58. }
  59. function sectionText(file: LoadedInstructionFile): string {
  60. return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
  61. }
  62. /**
  63. * Derive the logical instruction scope from a model-facing path.
  64. * @param displayPath - project-relative or user-global instruction path.
  65. * @returns `user-global`, `.`, or the containing project-relative directory.
  66. */
  67. export function scopeForDisplayPath(displayPath: string): string {
  68. if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
  69. return dirname(displayPath)
  70. }
  71. function additionalSectionText(file: LoadedInstructionFile): string {
  72. const scope = scopeForDisplayPath(file.displayPath)
  73. return [
  74. `Additional instructions from: ${file.displayPath}`,
  75. '',
  76. `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.`,
  77. '',
  78. escapeInstructionContent(file.content),
  79. ].join('\n')
  80. }
  81. const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
  82. function changedSectionText(item: ChangeRenderItem): string {
  83. const { change, file } = item
  84. if (change.action === 'set') return additionalSectionText(file)
  85. if (change.action === 'remove') {
  86. return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
  87. }
  88. const description = change.previousPath === undefined
  89. ? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
  90. : `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.`
  91. return [
  92. `Updated instructions from: ${change.path}`,
  93. '',
  94. description,
  95. '',
  96. escapeInstructionContent(file.content),
  97. ].join('\n')
  98. }
  99. /**
  100. * Render one reconciliation batch and retain only transitions that fit.
  101. * @param items - ordered state transitions and current file contents.
  102. * @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
  103. * @returns bounded prompt text and the transitions actually represented by it.
  104. */
  105. export function renderInstructionChanges(
  106. items: ChangeRenderItem[],
  107. maxBytes: number,
  108. ): { text: string; changes: WorkspaceInstructionChange[] } {
  109. const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
  110. const style: RenderStyle = {
  111. intro: '',
  112. section(file) {
  113. const item = byAbsolutePath.get(file.absolutePath)
  114. /* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
  115. return item === undefined ? '' : changedSectionText({ ...item, file })
  116. },
  117. }
  118. const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
  119. const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
  120. return {
  121. text: rendered.text,
  122. // TODO(rendered-change-proof): retain a transition only when its semantic
  123. // notice survived rendering; a tiny compact budget can currently return
  124. // unrelated notice text while still committing the full state transition.
  125. changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change),
  126. }
  127. }
  128. function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string {
  129. if (omitted.length === 0 && truncated.length === 0) return ''
  130. const parts: string[] = []
  131. if (omitted.length > 0) {
  132. parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`)
  133. }
  134. if (truncated.length > 0) {
  135. parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`)
  136. }
  137. return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}`
  138. }
  139. function buildInstructionText(
  140. files: LoadedInstructionFile[],
  141. maxBytes: number,
  142. omitted: InstructionFile[],
  143. truncated: TruncatedInstruction[],
  144. style: RenderStyle,
  145. ): string {
  146. const marker = markerText(maxBytes, omitted, truncated)
  147. const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
  148. return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n')
  149. }
  150. function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile {
  151. return { ...file, content: truncateUtf8(file.content, includedBytes) }
  152. }
  153. function truncateToFit(
  154. file: LoadedInstructionFile,
  155. includedFiles: LoadedInstructionFile[],
  156. maxBytes: number,
  157. omitted: InstructionFile[],
  158. style: RenderStyle,
  159. ): LoadedInstructionFile {
  160. const originalBytes = byteLength(file.content)
  161. let low = 0
  162. let high = originalBytes
  163. let best = withTruncatedContent(file, 0)
  164. while (low <= high) {
  165. const mid = Math.floor((low + high) / 2)
  166. const candidate = withTruncatedContent(file, mid)
  167. const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }]
  168. const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)
  169. if (byteLength(text) <= maxBytes) {
  170. best = candidate
  171. low = mid + 1
  172. } else {
  173. high = mid - 1
  174. }
  175. }
  176. return best
  177. }
  178. function renderInstructionContext(
  179. files: LoadedInstructionFile[],
  180. maxBytes: number,
  181. style: RenderStyle,
  182. ): RenderedWorkspaceContext {
  183. if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
  184. const fullText = buildInstructionText(files, maxBytes, [], [], style)
  185. if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] }
  186. for (let start = 1; start < files.length; start += 1) {
  187. const included = files.slice(start)
  188. const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
  189. const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
  190. if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] }
  191. }
  192. const mostSpecific = files.at(-1)
  193. /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
  194. if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] }
  195. const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
  196. for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
  197. const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle)
  198. const truncated = [{
  199. displayPath: mostSpecific.displayPath,
  200. originalBytes: byteLength(mostSpecific.content),
  201. includedBytes: byteLength(truncatedFile.content),
  202. }]
  203. const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
  204. if (byteLength(text) <= maxBytes) return { text, omitted, truncated }
  205. }
  206. const truncated = [{
  207. displayPath: mostSpecific.displayPath,
  208. originalBytes: byteLength(mostSpecific.content),
  209. includedBytes: 0,
  210. }]
  211. const compactNotice = markerText(maxBytes, omitted, truncated)
  212. const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n')
  213. if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated }
  214. const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes)
  215. return { text, omitted, truncated }
  216. }
  217. /**
  218. * Render the baseline instruction chain with deterministic precedence budgeting.
  219. * @param files - loaded files ordered from broadest to most specific.
  220. * @param options - required rendering byte budget.
  221. * @returns bounded baseline prompt text and budget diagnostics.
  222. */
  223. export function renderWorkspaceContext(
  224. files: LoadedInstructionFile[],
  225. options: { maxBytes: number },
  226. ): RenderedWorkspaceContext {
  227. return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
  228. }