codec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import {
  2. SessionFormatError,
  3. sessionFormatCount,
  4. sessionFormatSafeInteger,
  5. snapshotSessionFormatJson,
  6. } from '@deepseek-ai/dsh-session-format'
  7. import type {
  8. SessionFormatArtifactDecoder,
  9. SessionFormatCodec,
  10. SessionFormatEvent,
  11. SessionFormatEventRun,
  12. SessionFormatHeader,
  13. SessionFormatJsonObject,
  14. SessionFormatJsonValue,
  15. SessionFormatMigrationContext,
  16. SessionFormatRecovery,
  17. } from '@deepseek-ai/dsh-session-format'
  18. import { assertReleasedSessionFormatHeader } from './validation.ts'
  19. import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts'
  20. const PHYSICAL_HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'delegationDepth'] as const
  21. const PHYSICAL_HEADER_OPTIONAL = ['cwd', 'parentSession', 'seedLength', 'origin', 'agentPreset'] as const
  22. const PACKED_TAGS = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
  23. /** A released packed Assistant row retained until v1-to-v2 embeds its compact stream. */
  24. export interface ReleasedAssistantChunkRun extends SessionFormatEventRun {
  25. readonly runType: 'released-assistant-chunks'
  26. readonly turn: number
  27. readonly step: number
  28. readonly lastSeq: number
  29. readonly lastTime: number
  30. readonly stream: SessionFormatJsonObject
  31. }
  32. /**
  33. * Test whether a compact migration item is a released packed Assistant row.
  34. * @param run - compact migration item to classify.
  35. * @returns whether the item carries the released Assistant chunk representation.
  36. */
  37. export function isReleasedAssistantChunkRun(run: SessionFormatEventRun): run is ReleasedAssistantChunkRun {
  38. return run.runType === 'released-assistant-chunks'
  39. }
  40. /** Frozen physical JSON codec for the released v0 layout. */
  41. export const releasedV0SessionFormatCodec = createReleasedCodec(0)
  42. /** Frozen physical JSON codec for the shared-layout released v1 format. */
  43. export const releasedV1SessionFormatCodec = createReleasedCodec(1)
  44. function createReleasedCodec(version: 0 | 1) {
  45. return Object.freeze({
  46. version,
  47. decodeHeader: (value: unknown) => decodeHeader(value, version),
  48. createDecoder(headerValue: unknown, recovery: SessionFormatRecovery) {
  49. const physical = decodePhysicalHeader(headerValue, version)
  50. const scanner = scanRows(recovery === 'recoverable')
  51. return {
  52. header: physical.header,
  53. headerInheritedEventCount: physical.inheritedEventCount,
  54. decodeRow: (rowValue, context) => { scanner.decodeRow(rowValue, context) },
  55. finish(_context) {
  56. scanner.finish(physical.inheritedEventCount)
  57. return physical.inheritedEventCount
  58. },
  59. } satisfies SessionFormatArtifactDecoder
  60. },
  61. } satisfies SessionFormatCodec)
  62. }
  63. function scanRows(
  64. recoverable: boolean,
  65. ): {
  66. decodeRow(
  67. rowValue: unknown,
  68. context: SessionFormatMigrationContext,
  69. ): void
  70. finish(inheritedEventCount: number): void
  71. } {
  72. let rowIndex = 0
  73. let eventCount = 0
  74. let issue: SessionFormatError | undefined
  75. return {
  76. decodeRow(rowValue, context) {
  77. const currentRow = rowIndex
  78. rowIndex += 1
  79. let packed = false
  80. let decoded: SessionFormatEvent | ReleasedAssistantChunkRun
  81. try {
  82. const record = releasedV0Record(rowValue, `released Session row ${currentRow}`)
  83. const type = record['type']
  84. if (typeof type === 'string' && PACKED_TAGS.has(type)) {
  85. packed = true
  86. decoded = decodePackedRun(record, type, currentRow)
  87. } else {
  88. decoded = decodeEvent(record, currentRow)
  89. }
  90. } catch (error: unknown) {
  91. const current = error instanceof SessionFormatError
  92. ? error
  93. : new SessionFormatError(`released Session row ${currentRow} is malformed`, { cause: error })
  94. if (!recoverable) throw current
  95. issue ??= current
  96. return
  97. }
  98. if (issue !== undefined) {
  99. if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw issue
  100. return
  101. }
  102. const seq = packed
  103. ? (decoded as ReleasedAssistantChunkRun).firstSeq
  104. : (decoded as SessionFormatEvent).seq
  105. if (seq !== eventCount) {
  106. const gap = new SessionFormatError(
  107. `released Session row ${currentRow} has seq gap (expected ${eventCount}, got ${seq})`,
  108. )
  109. if (!recoverable) throw gap
  110. issue = gap
  111. if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw gap
  112. return
  113. }
  114. if (packed) {
  115. const run = decoded as ReleasedAssistantChunkRun
  116. eventCount += run.eventCount
  117. context.emitRun(run)
  118. } else {
  119. eventCount += 1
  120. context.emitEvent(decoded as SessionFormatEvent)
  121. }
  122. },
  123. finish(inheritedEventCount) {
  124. if (inheritedEventCount > eventCount) {
  125. throw new SessionFormatError('Session inheritedEventCount exceeds its event count')
  126. }
  127. },
  128. }
  129. }
  130. function decodeHeader(value: unknown, version: 0 | 1): SessionFormatHeader {
  131. return decodePhysicalHeader(value, version).header
  132. }
  133. function decodePhysicalHeader(
  134. value: unknown,
  135. version: 0 | 1,
  136. ): { header: SessionFormatHeader; inheritedEventCount: number } {
  137. const source = snapshotSessionFormatJson(value, `released v${version} physical header`)
  138. const record = releasedV0Record(source, `released v${version} physical header`)
  139. assertReleasedV0Keys(
  140. record,
  141. PHYSICAL_HEADER_REQUIRED,
  142. PHYSICAL_HEADER_OPTIONAL,
  143. `released v${version} physical header`,
  144. )
  145. if (record['type'] !== 'session' || record['version'] !== version) {
  146. throw new SessionFormatError(`expected released v${version} physical Session header`)
  147. }
  148. if (typeof record['id'] !== 'string') throw new SessionFormatError(`released v${version} header id must be a string`)
  149. const createdAt = sessionFormatCount(record['createdAt'], `released v${version} header createdAt`)
  150. const delegationDepth = sessionFormatCount(
  151. record['delegationDepth'],
  152. `released v${version} header delegationDepth`,
  153. )
  154. const seedLength = record['seedLength'] === undefined
  155. ? 0
  156. : sessionFormatCount(record['seedLength'], `released v${version} header seedLength`)
  157. for (const key of ['cwd', 'parentSession', 'agentPreset'] as const) {
  158. if (record[key] !== undefined && typeof record[key] !== 'string') {
  159. throw new SessionFormatError(`released v${version} header ${key} must be a string`)
  160. }
  161. }
  162. if (record['origin'] !== undefined && record['origin'] !== 'subagent') {
  163. throw new SessionFormatError(`released v${version} header origin must be "subagent"`)
  164. }
  165. const header = {
  166. version,
  167. id: record['id'],
  168. createdAt,
  169. ...(record['cwd'] === undefined ? {} : { cwd: record['cwd'] }),
  170. ...(record['parentSession'] === undefined ? {} : { parentSession: record['parentSession'] }),
  171. isSeeded: record['seedLength'] !== undefined,
  172. ...(record['origin'] === undefined ? {} : { origin: record['origin'] }),
  173. delegationDepth,
  174. ...(record['agentPreset'] === undefined ? {} : { agentPreset: record['agentPreset'] }),
  175. } as SessionFormatHeader
  176. assertReleasedSessionFormatHeader(header, version)
  177. return { header, inheritedEventCount: seedLength }
  178. }
  179. function decodeEvent(
  180. record: Record<string, SessionFormatJsonValue>,
  181. rowIndex: number,
  182. ): SessionFormatEvent {
  183. if (record['sourceEventSeqs'] !== undefined) {
  184. const seq = sessionFormatCount(record['seq'], `released Session row ${rowIndex} seq`)
  185. return {
  186. ...record,
  187. sourceEventSeqs: decodeSeqRanges(record['sourceEventSeqs'], seq),
  188. } as unknown as SessionFormatEvent
  189. }
  190. return record as unknown as SessionFormatEvent
  191. }
  192. function decodePackedRun(
  193. row: Record<string, SessionFormatJsonValue>,
  194. type: string,
  195. rowIndex: number,
  196. ): ReleasedAssistantChunkRun {
  197. const label = `released ${type} row ${rowIndex}`
  198. assertReleasedV0Keys(row, ['type', 'seq0', 'time0', 'data'], [], label)
  199. const seq0 = sessionFormatCount(row['seq0'], `${label} seq0`)
  200. const time0 = sessionFormatSafeInteger(row['time0'], `${label} time0`)
  201. const data = releasedV0Record(row['data'], `${label} data`)
  202. const isTool = type === 'tool-call-chunks'
  203. assertReleasedV0Keys(
  204. data,
  205. isTool ? ['turn', 'step', 'index', 'id', 'dt', 'args'] : ['turn', 'step', 'index', 'dt', 'texts'],
  206. isTool ? ['name'] : [],
  207. `${label} data`,
  208. )
  209. const payload = data[isTool ? 'args' : 'texts']
  210. if (!Array.isArray(payload) || payload.length === 0 || payload.some(member => typeof member !== 'string')) {
  211. throw new SessionFormatError(`${label} payload must be a non-empty string array`)
  212. }
  213. const gaps = data['dt']
  214. if (!Array.isArray(gaps) || gaps.length !== payload.length - 1) {
  215. throw new SessionFormatError(`${label} dt length must match its payload`)
  216. }
  217. let lastTime = time0
  218. for (const gap of gaps) {
  219. const validGap = sessionFormatSafeInteger(gap, `${label} dt member`)
  220. lastTime = sessionFormatSafeInteger(lastTime + validGap, `${label} member time`)
  221. }
  222. const turn = sessionFormatCount(data['turn'], `${label} turn`)
  223. const step = sessionFormatCount(data['step'], `${label} step`)
  224. const chunkIndex = sessionFormatCount(data['index'], `${label} index`)
  225. if (isTool && (typeof data['id'] !== 'string'
  226. || data['id'].length === 0
  227. || (data['name'] !== undefined && typeof data['name'] !== 'string'))) {
  228. throw new SessionFormatError(`${label} id and optional name must be strings`)
  229. }
  230. const lastSeq = sessionFormatCount(seq0 + payload.length - 1, `${label} final seq`)
  231. const stream = (type === 'tool-call-chunks'
  232. ? {
  233. type,
  234. time0,
  235. index: chunkIndex,
  236. dt: gaps,
  237. id: data['id'],
  238. ...(data['name'] === undefined ? {} : { name: data['name'] }),
  239. args: payload,
  240. }
  241. : { type, time0, index: chunkIndex, dt: gaps, texts: payload }) as SessionFormatJsonObject
  242. const run: ReleasedAssistantChunkRun = {
  243. runType: 'released-assistant-chunks',
  244. firstSeq: seq0,
  245. eventCount: payload.length,
  246. turn,
  247. step,
  248. lastSeq,
  249. lastTime,
  250. stream,
  251. expand: () => expandAssistantChunkRun(run),
  252. }
  253. return run
  254. }
  255. function* expandAssistantChunkRun(run: ReleasedAssistantChunkRun): Iterable<SessionFormatEvent> {
  256. const stream = run.stream
  257. const gaps = stream['dt'] as readonly number[]
  258. const members = stream['type'] === 'tool-call-chunks'
  259. ? stream['args'] as readonly string[]
  260. : stream['texts'] as readonly string[]
  261. let time = run.stream['time0'] as number
  262. for (let index = 0; index < members.length; index += 1) {
  263. if (index > 0) time += gaps[index - 1] as number
  264. const member = members[index] as string
  265. const chunk = stream['type'] === 'text-chunks'
  266. ? { type: 'text-delta', index: stream['index'], text: member }
  267. : stream['type'] === 'reasoning-chunks'
  268. ? { type: 'reasoning-delta', index: stream['index'], text: member }
  269. : {
  270. type: 'tool-call-delta',
  271. index: stream['index'],
  272. id: stream['id'],
  273. ...(stream['name'] === undefined ? {} : { name: stream['name'] }),
  274. argumentsDelta: member,
  275. }
  276. yield {
  277. type: 'assistant/chunk',
  278. seq: run.firstSeq + index,
  279. time,
  280. data: { turn: run.turn, step: run.step, chunk },
  281. } as SessionFormatEvent
  282. }
  283. }
  284. function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly SessionFormatJsonValue[] {
  285. if (!Array.isArray(value)) throw new SessionFormatError('sourceEventSeqs must be an array')
  286. const output: number[] = []
  287. let hasRange = false
  288. for (const entry of value) {
  289. if (typeof entry === 'number') {
  290. if (output.length >= maxEntries) throw new SessionFormatError('sourceEventSeqs exceeds its event seq')
  291. output.push(sessionFormatCount(entry, 'sourceEventSeqs member'))
  292. continue
  293. }
  294. if (!Array.isArray(entry) || entry.length !== 2) {
  295. throw new SessionFormatError('sourceEventSeqs range must be a [start, end] pair')
  296. }
  297. const start = sessionFormatCount(entry[0], 'sourceEventSeqs range start')
  298. const end = sessionFormatCount(entry[1], 'sourceEventSeqs range end')
  299. if (end < start || end - start + 1 > maxEntries - output.length) {
  300. throw new SessionFormatError('sourceEventSeqs range exceeds its event seq')
  301. }
  302. for (let seq = start; seq <= end; seq += 1) output.push(seq)
  303. hasRange = true
  304. }
  305. if (hasRange && output.some((member, index) => index > 0 && member <= (output[index - 1] as number))) {
  306. throw new SessionFormatError('sourceEventSeqs ranges must be strictly increasing')
  307. }
  308. return output
  309. }