surface.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. /**
  2. * Surface layer on top of the session event log: an ordered view of events
  3. * that produce LLM messages. The append-only log remains the source of truth.
  4. *
  5. * @module @deepseek-ai/dsh-session/surface
  6. */
  7. import { isDeepStrictEqual } from 'node:util'
  8. import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
  9. /** Runtime counterpart of the message-producing event union. */
  10. const SURFACE_EVENT_TYPES = new Set<string>([
  11. 'user/message',
  12. 'assistant/message',
  13. 'tool/result',
  14. 'context/message',
  15. 'steering/message',
  16. ])
  17. /**
  18. * Whether an event type can join the model-visible surface.
  19. * @param type - event type to test.
  20. * @returns true for one of the five message-producing event types.
  21. */
  22. export function isSurfaceEligibleType(type: string): boolean {
  23. return SURFACE_EVENT_TYPES.has(type)
  24. }
  25. /**
  26. * Narrow an event to a surface-eligible event carrying its required marker.
  27. * @param event - event to test.
  28. * @returns true when both the type and marker identify a surface event.
  29. */
  30. export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
  31. if (!SURFACE_EVENT_TYPES.has(event.type)) return false
  32. return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
  33. }
  34. /** One replacement operation observed while folding a session surface. */
  35. export interface SurfaceFoldReplacement {
  36. /** Seq of the event that replaced the prior surface range. */
  37. seq: number
  38. /** Declared inclusive start seq of the replaced surface range. */
  39. start: number
  40. /** Declared inclusive end seq of the replaced surface range. */
  41. end: number
  42. /** Actual surface entries removed by the operation, in surface order. */
  43. shadowedSeqs: number[]
  44. }
  45. /** Complete result of replaying the surface operations in a session log. */
  46. export interface SurfaceFoldResult {
  47. /** Current surface event sequences in model-visible order. */
  48. nodes: number[]
  49. /** Replacement operations in event order. */
  50. replacements: SurfaceFoldReplacement[]
  51. }
  52. /** Readonly live projection of the message-producing session events. */
  53. export interface SessionSurface {
  54. /** Current surface event sequences in model-visible order. */
  55. readonly nodes: readonly number[]
  56. /** Monotonic count of committed positional replacements. */
  57. readonly replaceGeneration: number
  58. }
  59. /** Mutable state shared by complete and incremental folds. */
  60. interface SurfaceFoldState {
  61. nodes: number[]
  62. replaceGeneration: number
  63. }
  64. /** A validated replacement transition that has not mutated fold state yet. */
  65. interface SurfaceReplacePlan extends SurfaceFoldReplacement {
  66. kind: 'replace'
  67. startIdx: number
  68. endIdx: number
  69. }
  70. /** One validated surface transition that has not mutated fold state yet. */
  71. type SurfacePlan =
  72. | { kind: 'append'; seq: number }
  73. | SurfaceReplacePlan
  74. /** Create an empty surface fold state. */
  75. function createFoldState(): SurfaceFoldState {
  76. return { nodes: [], replaceGeneration: 0 }
  77. }
  78. /** Whether a runtime value is a non-negative safe event sequence. */
  79. function isEventSeq(value: unknown): value is number {
  80. return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
  81. }
  82. /** Whether a runtime value is the exact positional-replacement shape. */
  83. function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
  84. const op = value as Record<string, unknown>
  85. return Object.keys(op).length === 3
  86. && Object.hasOwn(op, 'op')
  87. && Object.hasOwn(op, 'start')
  88. && Object.hasOwn(op, 'end')
  89. && op['op'] === 'replace'
  90. && isEventSeq(op['start'])
  91. && isEventSeq(op['end'])
  92. }
  93. /** Validate event-local surface eligibility and return its operation. */
  94. function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
  95. const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
  96. if (!isSurfaceEligibleType(event.type)) {
  97. if (raw.surfaceOp !== undefined) {
  98. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
  99. }
  100. if (raw.sourceEventSeqs !== undefined) {
  101. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
  102. }
  103. return
  104. }
  105. const op = raw.surfaceOp
  106. if (op === undefined) {
  107. throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
  108. }
  109. if (op === 'append') return op
  110. if (op === null || typeof op !== 'object' || Array.isArray(op)) {
  111. throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
  112. }
  113. if (!isReplaceOp(op)) {
  114. throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
  115. }
  116. return op
  117. }
  118. /** Validate provenance against prior log entries and the replacement range. */
  119. function assertProvenance(
  120. event: SessionEvent,
  121. shadowedSeqs: readonly number[],
  122. ): void {
  123. const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
  124. const sources = new Set<number>()
  125. if (raw !== undefined) {
  126. if (!Array.isArray(raw)) {
  127. throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
  128. }
  129. if (raw.length === 0 && event.type !== 'assistant/message') {
  130. throw new Error('sourceEventSeqs must not be empty except on assistant/message')
  131. }
  132. let nonEarlierSource: number | undefined
  133. for (const source of raw) {
  134. if (!isEventSeq(source)) {
  135. throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
  136. }
  137. sources.add(source)
  138. if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
  139. }
  140. if (sources.size !== raw.length) {
  141. throw new Error('sourceEventSeqs must not contain duplicates')
  142. }
  143. if (nonEarlierSource !== undefined) {
  144. throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
  145. }
  146. }
  147. const missing = shadowedSeqs.filter(seq => !sources.has(seq))
  148. if (missing.length > 0) {
  149. throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
  150. }
  151. }
  152. /** Locate one replacement range without mutating the current fold state. */
  153. function replacementRange(
  154. state: SurfaceFoldState,
  155. op: Extract<SurfaceOp, { op: 'replace' }>,
  156. ): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
  157. const startIdx = state.nodes.indexOf(op.start)
  158. if (startIdx === -1) {
  159. throw new Error(`surface replace: start seq ${op.start} not found in surface`)
  160. }
  161. const endIdx = state.nodes.indexOf(op.end)
  162. if (endIdx === -1) {
  163. throw new Error(`surface replace: end seq ${op.end} not found in surface`)
  164. }
  165. if (startIdx > endIdx) {
  166. throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
  167. }
  168. return {
  169. startIdx,
  170. endIdx,
  171. shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
  172. }
  173. }
  174. /** Restrict a tool-result replacement to one current result's content. */
  175. function assertToolResultRewrite(
  176. event: SessionEvent,
  177. shadowedSeqs: readonly number[],
  178. events: readonly SessionEvent[],
  179. ): void {
  180. if (event.type !== 'tool/result') return
  181. if (shadowedSeqs.length !== 1) {
  182. throw new Error('tool/result surface replacement must rewrite exactly one current node')
  183. }
  184. for (const originalSeq of shadowedSeqs) {
  185. const original = events[originalSeq]
  186. if (original?.type !== 'tool/result') {
  187. throw new Error('tool/result surface replacement must target a current tool/result')
  188. }
  189. const originalRest = { ...original.data } as Record<string, unknown>
  190. const replacementRest = { ...event.data } as Record<string, unknown>
  191. delete originalRest['content']
  192. delete replacementRest['content']
  193. if (!isDeepStrictEqual(originalRest, replacementRest)) {
  194. throw new Error('tool/result surface replacement may change only content')
  195. }
  196. }
  197. }
  198. /** Validate one event at its replay boundary and prepare its atomic fold transition. */
  199. function planSurfaceEvent(
  200. state: SurfaceFoldState,
  201. event: SessionEvent,
  202. expectedSeq: number,
  203. events: readonly SessionEvent[],
  204. ): SurfacePlan | undefined {
  205. if (event.seq !== expectedSeq) {
  206. throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
  207. }
  208. const surfaceOp = surfaceOpOf(event)
  209. if (surfaceOp === undefined) return
  210. if (surfaceOp === 'append') {
  211. assertProvenance(event, [])
  212. return { kind: 'append', seq: event.seq }
  213. }
  214. const range = replacementRange(state, surfaceOp)
  215. assertProvenance(event, range.shadowedSeqs)
  216. assertToolResultRewrite(event, range.shadowedSeqs, events)
  217. return {
  218. kind: 'replace',
  219. seq: event.seq,
  220. start: surfaceOp.start,
  221. end: surfaceOp.end,
  222. ...range,
  223. }
  224. }
  225. /** Apply one event and return replacement metadata only when one occurred. */
  226. function applySurfaceEvent(
  227. state: SurfaceFoldState,
  228. event: SessionEvent,
  229. expectedSeq: number,
  230. events: readonly SessionEvent[],
  231. ): SurfaceFoldReplacement | undefined {
  232. const plan = planSurfaceEvent(state, event, expectedSeq, events)
  233. if (plan?.kind === 'append') {
  234. state.nodes.push(plan.seq)
  235. } else if (plan?.kind === 'replace') {
  236. state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
  237. state.replaceGeneration += 1
  238. }
  239. if (plan?.kind !== 'replace') return
  240. return {
  241. seq: plan.seq,
  242. start: plan.start,
  243. end: plan.end,
  244. shadowedSeqs: plan.shadowedSeqs,
  245. }
  246. }
  247. /**
  248. * Replay a complete session log through the canonical surface fold.
  249. * @param events - session events in contiguous seq order.
  250. * @returns detached current sequences and replacement history.
  251. * @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
  252. */
  253. export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
  254. const state = createFoldState()
  255. const replacements: SurfaceFoldReplacement[] = []
  256. for (const [index, event] of events.entries()) {
  257. const replacement = applySurfaceEvent(state, event, index, events)
  258. if (replacement !== undefined) replacements.push(replacement)
  259. }
  260. return { nodes: [...state.nodes], replacements }
  261. }
  262. /** Incremental ordered surface view and append-boundary validator. */
  263. export class SurfaceManager implements SessionSurface {
  264. /** Shared transition state; replacement history is not retained. */
  265. private _state = createFoldState()
  266. /** Last processed seq; -1 folds a seeded log on first access. */
  267. private _lastProcessedSeq = -1
  268. constructor(private log: readonly SessionEvent[]) {}
  269. /**
  270. * Validate the next candidate without mutating the committed surface.
  271. * @param event - candidate event that has not entered the log yet.
  272. */
  273. validateNext(event: SessionEvent): void {
  274. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  275. planSurfaceEvent(this._state, event, this.log.length, this.log)
  276. }
  277. /** Monotonic count of folded positional replacements. */
  278. get replaceGeneration(): number {
  279. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  280. return this._state.replaceGeneration
  281. }
  282. /** Surface event sequences in model-visible order. */
  283. get nodes(): readonly number[] {
  284. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  285. return this._state.nodes
  286. }
  287. /** Fold events appended since the previous access. */
  288. private _processDelta(): void {
  289. for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
  290. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
  291. applySurfaceEvent(this._state, this.log[i]!, i, this.log)
  292. this._lastProcessedSeq = i
  293. }
  294. }
  295. }