projection.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /** Current-surface projection and byte-bounded rendering. */
  2. import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction'
  3. import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
  4. import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
  5. import { assertNever } from '@deepseek-ai/dsh-util-values'
  6. import { SessionSeq } from '@deepseek-ai/dsh-session'
  7. import type { OptionalSessionSeq, SessionId } from '@deepseek-ai/dsh-session'
  8. import { stringifyTagSafeJson } from './serialization.ts'
  9. import type { ReferencedConversationItem } from './types.ts'
  10. interface ProjectedItem extends ReferencedConversationItem {
  11. checkpoint: boolean
  12. originalText: string
  13. omittedBytes: number
  14. }
  15. /** Snapshot data serialized inside the untrusted prompt. */
  16. export interface ReferencedSessionData {
  17. sessionId: SessionId
  18. label: string
  19. cwd: string | null
  20. capturedThroughSeq: OptionalSessionSeq
  21. conversation: ReferencedConversationItem[]
  22. }
  23. /** Retention facts stored beside the durable context. */
  24. export interface ReferenceRetentionStats {
  25. compacted: boolean
  26. originalMessages: number
  27. retainedMessages: number
  28. omittedMessages: number
  29. omittedBytes: number
  30. truncated: boolean
  31. }
  32. /** Project current user/assistant conversation while excluding tools, reasoning, and injected context. */
  33. function projectSessionConversation(snapshot: SessionSurfaceSnapshot): ProjectedItem[] {
  34. const conversation: ProjectedItem[] = []
  35. for (const event of snapshot.events) {
  36. switch (event.type) {
  37. case 'user/message': {
  38. const checkpoint = isCompactCheckpointSource(event.data.source)
  39. if (!checkpoint && event.data.source.kind !== 'user') break
  40. const text = textContent(event.data.content)
  41. if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
  42. break
  43. }
  44. case 'assistant/message': {
  45. const text = textContent(event.data.message.content)
  46. if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 })
  47. break
  48. }
  49. case 'tool/result':
  50. break
  51. /* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
  52. default:
  53. assertNever(event, 'session-reference surface event')
  54. }
  55. }
  56. return conversation
  57. }
  58. /**
  59. * Fit one projected snapshot into an exact rendered JSON-object byte cap.
  60. * @param snapshot - current-surface source observation.
  61. * @param label - host-provided display label serialized with the source.
  62. * @param maxBytes - maximum UTF-8 bytes for the serialized data object.
  63. * @returns full projected data, retained preview and stats, or `undefined` when fixed data cannot fit.
  64. */
  65. export function retainReferencedSession(
  66. snapshot: SessionSurfaceSnapshot,
  67. label: string,
  68. maxBytes: number,
  69. ): { data: ReferencedSessionData; fullData: ReferencedSessionData; stats: ReferenceRetentionStats } | undefined {
  70. const original = projectSessionConversation(snapshot)
  71. const retained = original.map(item => ({ ...item }))
  72. let omittedMessages = 0
  73. let droppedOmittedBytes = 0
  74. const data = (): ReferencedSessionData => ({
  75. sessionId: snapshot.session.id,
  76. label,
  77. cwd: snapshot.session.cwd ?? null,
  78. capturedThroughSeq: snapshot.capturedThroughSeq === null
  79. ? null
  80. : SessionSeq(snapshot.capturedThroughSeq),
  81. conversation: retained.map(({ role, text }) => ({ role, text })),
  82. })
  83. const fullData = data()
  84. const size = (): number => Buffer.byteLength(stringifyTagSafeJson(data()), 'utf8')
  85. while (size() > maxBytes) {
  86. const newestIndex = retained.length - 1
  87. const dropIndex = retained.findIndex((item, index) => !item.checkpoint && index !== newestIndex)
  88. if (dropIndex < 0) break
  89. const removed = retained.splice(dropIndex, 1)[0]
  90. /* v8 ignore next 3 -- dropIndex came from this exact array and is non-negative. */
  91. if (removed === undefined) {
  92. throw new Error('session-reference retention selected a missing message')
  93. }
  94. omittedMessages += 1
  95. droppedOmittedBytes += Buffer.byteLength(removed.originalText, 'utf8')
  96. }
  97. while (size() > maxBytes) {
  98. let longestIndex = -1
  99. let longestBytes = 0
  100. for (const [index, item] of retained.entries()) {
  101. const bytes = Buffer.byteLength(item.text, 'utf8')
  102. if (bytes > longestBytes) {
  103. longestBytes = bytes
  104. longestIndex = index
  105. }
  106. }
  107. if (longestIndex < 0 || longestBytes === 0) return undefined
  108. const overflow = size() - maxBytes
  109. const target = Math.max(0, longestBytes - overflow)
  110. const item = retained[longestIndex]
  111. /* v8 ignore next 3 -- longestIndex was selected from this exact array's entries. */
  112. if (item === undefined) {
  113. throw new Error('session-reference retention selected a missing longest message')
  114. }
  115. const shortened = truncateWithNotice(item.originalText, target)
  116. /* v8 ignore next -- strictly lowering the byte target must change a complete-string retention result. */
  117. if (shortened.text === retained[longestIndex]?.text) return undefined
  118. retained[longestIndex] = { ...item, text: shortened.text, omittedBytes: shortened.omittedBytes }
  119. }
  120. const compacted = original.some(item => item.checkpoint)
  121. const retainedOmittedBytes = retained.reduce((sum, item) => sum + item.omittedBytes, 0)
  122. const omittedBytes = retainedOmittedBytes + droppedOmittedBytes
  123. return {
  124. data: data(),
  125. fullData,
  126. stats: {
  127. compacted,
  128. originalMessages: original.length,
  129. retainedMessages: retained.length,
  130. omittedMessages,
  131. omittedBytes,
  132. truncated: omittedMessages > 0 || omittedBytes > 0,
  133. },
  134. }
  135. }
  136. function textContent(content: readonly { type: string; text?: string }[]): string {
  137. return content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
  138. }
  139. function truncateWithNotice(text: string, maxOutputBytes: number): { text: string; omittedBytes: number } {
  140. /* v8 ignore next -- callers invoke this only with a target smaller than the selected original text. */
  141. if (Buffer.byteLength(text, 'utf8') <= maxOutputBytes) return { text, omittedBytes: 0 }
  142. let low = 0
  143. let high = maxOutputBytes
  144. let best = { text: '', omittedBytes: Buffer.byteLength(text, 'utf8') }
  145. while (low <= high) {
  146. const retainedBytes = Math.floor((low + high) / 2)
  147. const headBytes = Math.ceil(retainedBytes / 2)
  148. const tailBytes = Math.floor(retainedBytes / 2)
  149. const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
  150. retainer.push(text)
  151. const result = retainer.finish()
  152. // The complete source string was pushed before `finish()`, so omission is exact.
  153. /* v8 ignore next 3 -- complete-string TextRetainer input cannot report a lower bound. */
  154. if (result.omittedBytes.kind !== 'exact') {
  155. throw new Error('session-reference retention did not report exact omitted bytes')
  156. }
  157. const omitted = result.omittedBytes.count
  158. const candidate = `${result.text}\n[… omitted ${omitted} UTF-8 bytes …]`
  159. if (Buffer.byteLength(candidate, 'utf8') <= maxOutputBytes) {
  160. best = { text: candidate, omittedBytes: omitted }
  161. low = retainedBytes + 1
  162. } else {
  163. high = retainedBytes - 1
  164. }
  165. }
  166. return best
  167. }