output-json.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. /** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */
  2. import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
  3. /** Control characters with a two-byte short JSON escape instead of `\u00XX`. */
  4. const SHORT_ESCAPE_CODES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d])
  5. /** Serialized bytes contributed by one complete Unicode code point inside JSON quotes. */
  6. function serializedCharacterBytes(character: string): number {
  7. if (character.length === 2) return 4
  8. if (character === '"' || character === '\\') return 2
  9. const code = character.charCodeAt(0)
  10. if (code >= 0xd800 && code <= 0xdfff) return 6
  11. if (code < 0x20) return SHORT_ESCAPE_CODES.has(code) ? 2 : 6
  12. return Buffer.byteLength(character, 'utf8')
  13. }
  14. /**
  15. * Measure one JSON string without materializing its complete escaped form.
  16. * @param text - the candidate string.
  17. * @param maxBytes - largest serialized size the caller can admit.
  18. * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
  19. */
  20. export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined {
  21. if (maxBytes < 2) return undefined
  22. let bytes = 2
  23. for (const character of text) {
  24. bytes += serializedCharacterBytes(character)
  25. if (bytes > maxBytes) return undefined
  26. }
  27. return bytes
  28. }
  29. /**
  30. * Measure one lossless JSON value without allocating its serialized form.
  31. * @param value - already validated lossless JSON.
  32. * @param maxBytes - largest serialized size the caller can admit.
  33. * @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
  34. */
  35. export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined {
  36. type Task =
  37. | { kind: 'value'; value: CodeJsonValue }
  38. | { kind: 'array'; value: CodeJsonValue[]; index: number }
  39. | { kind: 'object'; value: Record<string, CodeJsonValue>; keys: string[]; index: number }
  40. let bytes = 0
  41. const add = (cost: number): boolean => {
  42. bytes += cost
  43. return bytes <= maxBytes
  44. }
  45. const tasks: Task[] = [{ kind: 'value', value }]
  46. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  47. if (task.kind === 'value') {
  48. const current = task.value
  49. if (current === null) {
  50. if (!add(4)) return undefined
  51. } else if (typeof current === 'string') {
  52. const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes)
  53. if (stringBytes === undefined) return undefined
  54. bytes += stringBytes
  55. } else if (typeof current === 'number') {
  56. if (!add(Buffer.byteLength(String(current), 'utf8'))) return undefined
  57. } else if (typeof current === 'boolean') {
  58. if (!add(current ? 4 : 5)) return undefined
  59. } else if (Array.isArray(current)) {
  60. if (!add(2)) return undefined
  61. if (current.length > 0) tasks.push({ kind: 'array', value: current, index: 0 })
  62. } else {
  63. if (!add(2)) return undefined
  64. const keys = Object.keys(current)
  65. if (keys.length > 0) tasks.push({ kind: 'object', value: current, keys, index: 0 })
  66. }
  67. continue
  68. }
  69. if (task.index > 0 && !add(1)) return undefined
  70. if (task.kind === 'array') {
  71. const item = task.value[task.index]
  72. if (item === undefined) return undefined
  73. if (task.index + 1 < task.value.length) tasks.push({ ...task, index: task.index + 1 })
  74. tasks.push({ kind: 'value', value: item })
  75. continue
  76. }
  77. const key = task.keys[task.index]
  78. /* v8 ignore next -- an object frame is created and advanced only for an existing Object.keys entry. */
  79. if (key === undefined) return undefined
  80. const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
  81. if (keyBytes === undefined) return undefined
  82. if (!add(keyBytes + 1)) return undefined
  83. const item = task.value[key]
  84. if (item === undefined) return undefined
  85. if (task.index + 1 < task.keys.length) tasks.push({ ...task, index: task.index + 1 })
  86. tasks.push({ kind: 'value', value: item })
  87. }
  88. return bytes
  89. }
  90. /**
  91. * Return the longest code-point-aligned prefix whose JSON string encoding,
  92. * including its surrounding quotes, fits `maxBytes`.
  93. *
  94. * @param text - the candidate string.
  95. * @param maxBytes - serialized JSON-string bytes available.
  96. * @returns the fitting prefix, or an empty string when even useful content cannot fit.
  97. */
  98. export function truncateJsonStringBytes(text: string, maxBytes: number): string {
  99. if (maxBytes < 2) return ''
  100. let bytes = 2
  101. let end = 0
  102. for (const character of text) {
  103. const cost = serializedCharacterBytes(character)
  104. if (bytes + cost > maxBytes) break
  105. bytes += cost
  106. end += character.length
  107. }
  108. return end === text.length ? text : text.slice(0, end)
  109. }