normalize.ts 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. /** Title text normalization and UTF-8-safe truncation. */
  2. /** Operating-system-command escape sequences, including unterminated tails. */
  3. const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
  4. /** Control-sequence-introducer escapes such as SGR color codes. */
  5. const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
  6. /** Remaining two-byte ESC control sequences. */
  7. const ESC_SEQUENCE = /\u001B[@-_]/gu
  8. /** Non-whitespace C0/C1 control characters. */
  9. const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
  10. /** Directional and invisible controls that can make a displayed title deceptive. */
  11. const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
  12. /** Reject an invalid public text limit. */
  13. function assertPositiveInteger(name: string, value: number): void {
  14. if (!Number.isInteger(value) || value <= 0) {
  15. throw new Error(`${name} must be a positive integer`)
  16. }
  17. }
  18. /** Remove controls and produce one trimmed, whitespace-normalized line. */
  19. function cleanTitleText(input: string): string {
  20. return input
  21. .replace(OSC_SEQUENCE, '')
  22. .replace(CSI_SEQUENCE, '')
  23. .replace(ESC_SEQUENCE, '')
  24. .replace(CONTROL_CHARACTER, '')
  25. .replace(DIRECTIONAL_CONTROL, '')
  26. .replace(/\s+/gu, ' ')
  27. .trim()
  28. }
  29. /**
  30. * Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
  31. * @param input - normalized title text.
  32. * @param maxBytes - positive UTF-8 byte budget.
  33. * @returns the longest leading code-point prefix within the budget.
  34. */
  35. export function truncateTitleUtf8(input: string, maxBytes: number): string {
  36. assertPositiveInteger('maxBytes', maxBytes)
  37. if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
  38. let used = 0
  39. let output = ''
  40. for (const character of input) {
  41. const bytes = Buffer.byteLength(character, 'utf8')
  42. if (used + bytes > maxBytes) break
  43. output += character
  44. used += bytes
  45. }
  46. return output
  47. }
  48. /**
  49. * Normalize one accepted session title and enforce its UTF-8 byte budget.
  50. * @param input - untrusted title text.
  51. * @param maxBytes - positive maximum encoded size.
  52. * @returns a terminal-safe one-line title, possibly empty after sanitization.
  53. */
  54. export function normalizeSessionTitle(input: string, maxBytes: number): string {
  55. return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd()
  56. }
  57. /**
  58. * Derive the deterministic first-prompt fallback.
  59. * @param input - text from the first eligible human message.
  60. * @param maxWords - positive whitespace-delimited word cap.
  61. * @param maxBytes - positive UTF-8 byte cap.
  62. * @returns the normalized leading words within both limits.
  63. */
  64. export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string {
  65. assertPositiveInteger('maxWords', maxWords)
  66. const words = cleanTitleText(input).split(' ').filter(Boolean).slice(0, maxWords)
  67. return truncateTitleUtf8(words.join(' '), maxBytes).trimEnd()
  68. }