presentation.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Model text rendering and generic tool-call presentation.
  3. *
  4. * @module @deepseek-ai/dsh-tool-session-query/presentation
  5. */
  6. import {
  7. extractSessionEventText,
  8. type SessionEventSearchHit,
  9. type SessionEventTraceObservation,
  10. type SessionEventWindow,
  11. type SessionLineageTrace,
  12. type SessionRecord,
  13. type SessionSearchHit,
  14. } from '@deepseek-ai/dsh-session-query'
  15. import type {
  16. SessionEvent,
  17. SessionId,
  18. } from '@deepseek-ai/dsh-session'
  19. import type { GenericCallView } from '@deepseek-ai/dsh-tools'
  20. import { workspaceAccess } from './workspace-access.ts'
  21. type TitleView = Awaited<ReturnType<typeof workspaceAccess.readTitle>>
  22. type CompleteTitleMap = Awaited<ReturnType<typeof workspaceAccess.readTitles>>
  23. type AuthorizedDescendants = ReturnType<typeof workspaceAccess.authorizeDescendants>
  24. interface SearchCollection<T> {
  25. readonly items: T[]
  26. readonly capped: boolean
  27. }
  28. interface SessionSearchCallArgs {
  29. readonly query: string
  30. }
  31. interface EventSearchCallArgs {
  32. readonly query: string
  33. }
  34. interface SessionTargetCallArgs {
  35. readonly session_id?: string
  36. }
  37. interface EventTargetCallArgs extends SessionTargetCallArgs {
  38. readonly seq: number
  39. }
  40. function formatSessionSearch(
  41. collected: SearchCollection<SessionSearchHit>,
  42. titles: CompleteTitleMap,
  43. authorizedParents: ReadonlySet<SessionId>,
  44. ): string {
  45. if (collected.items.length === 0) return formatEmptySessionSearch()
  46. const lines = [`Session search results (${collected.items.length}):`]
  47. for (const [index, hit] of collected.items.entries()) {
  48. const parent = hit.header.parentSession === undefined
  49. ? 'root'
  50. : authorizedParents.has(hit.header.parentSession)
  51. ? hit.header.parentSession
  52. : '[outside workspace]'
  53. const availability = [
  54. hit.live ? 'live' : undefined,
  55. hit.persisted ? 'persisted' : undefined,
  56. ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable'
  57. lines.push(
  58. '',
  59. `${index + 1}. Session ${hit.header.id} — ${workspaceAccess.titleText(titles.get(hit.header.id))}`,
  60. ` Created: ${formatTime(hit.header.createdAt)}`,
  61. ` Parent: ${parent}`,
  62. ` Availability: ${availability}`,
  63. ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`,
  64. ` Snippet: ${hit.bestMatch.snippet}`,
  65. )
  66. }
  67. if (collected.capped) {
  68. lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.')
  69. }
  70. return lines.join('\n')
  71. }
  72. function formatEmptySessionSearch(): string {
  73. return 'No prior session matches found.'
  74. }
  75. function formatEventSearch(
  76. sessionId: SessionId,
  77. title: TitleView,
  78. collected: SearchCollection<SessionEventSearchHit>,
  79. ): string {
  80. const lines = [`Session ${sessionId} — ${workspaceAccess.titleText(title)}`]
  81. if (collected.items.length === 0) {
  82. lines.push('', 'No prior event matches found.')
  83. return lines.join('\n')
  84. }
  85. lines.push('', `Event search results (${collected.items.length}):`)
  86. for (const [index, hit] of collected.items.entries()) {
  87. lines.push(
  88. `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`,
  89. ` Snippet: ${hit.snippet}`,
  90. )
  91. }
  92. if (collected.capped) {
  93. lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.')
  94. }
  95. return lines.join('\n')
  96. }
  97. function formatSessionTrace(
  98. trace: SessionLineageTrace,
  99. ancestors: readonly SessionRecord[],
  100. ancestorBoundary: boolean,
  101. descendants: AuthorizedDescendants,
  102. titles: CompleteTitleMap,
  103. ): string {
  104. const lines = [
  105. `Session ${trace.target.header.id} — ${workspaceAccess.titleText(titles.get(trace.target.header.id))}`,
  106. `Created: ${formatTime(trace.target.header.createdAt)}`,
  107. `Availability: ${availabilityText(trace.target)}`,
  108. '',
  109. 'Ancestors (nearest first):',
  110. ]
  111. if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)')
  112. for (const record of ancestors) {
  113. lines.push(`- ${record.header.id} — ${workspaceAccess.titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`)
  114. }
  115. if (ancestorBoundary) lines.push('- [outside workspace boundary]')
  116. lines.push('', 'Descendants:')
  117. if (descendants.length === 0) lines.push('- none')
  118. else renderDescendants(lines, descendants, titles)
  119. return lines.join('\n')
  120. }
  121. function renderDescendants(
  122. lines: string[],
  123. nodes: AuthorizedDescendants,
  124. titles: CompleteTitleMap,
  125. ): void {
  126. for (const { node, depth } of workspaceAccess.visitDescendants(nodes)) {
  127. const indent = ' '.repeat(depth)
  128. if (node === null) {
  129. lines.push(`${indent}- [outside workspace subtree]`)
  130. continue
  131. }
  132. const id = node.record.header.id
  133. lines.push(`${indent}- ${id} — ${workspaceAccess.titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`)
  134. }
  135. }
  136. function formatEventTrace(
  137. sessionId: SessionId,
  138. title: TitleView,
  139. trace: SessionEventTraceObservation,
  140. ): string {
  141. return [
  142. `Session ${sessionId} — ${workspaceAccess.titleText(title)}`,
  143. `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`,
  144. `Replaced by: ${trace.replacedBy ?? 'none'}`,
  145. `Replacement chain: ${seqList(trace.replacementChain)}`,
  146. `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`,
  147. `Events cited directly as sources: ${seqList(trace.sourceEventSeqs)}`,
  148. `Direct derived events: ${seqList(trace.derivedEventSeqs)}`,
  149. ].join('\n')
  150. }
  151. function formatEventRead(
  152. sessionId: SessionId,
  153. title: TitleView,
  154. window: SessionEventWindow,
  155. ): string {
  156. const before = window.events.filter(event => event.seq < window.target.seq)
  157. const after = window.events.filter(event => event.seq > window.target.seq)
  158. const lines = [
  159. `Session ${sessionId} — ${workspaceAccess.titleText(title)}`,
  160. `Target event seq ${window.target.seq}:`,
  161. '```json',
  162. JSON.stringify(window.target, null, 2),
  163. '```',
  164. ]
  165. if (before.length > 0) {
  166. lines.push('', 'Before:')
  167. for (const event of before) lines.push(formatNeighbor(event))
  168. }
  169. if (after.length > 0) {
  170. lines.push('', 'After:')
  171. for (const event of after) lines.push(formatNeighbor(event))
  172. }
  173. return lines.join('\n')
  174. }
  175. function formatNeighbor(event: SessionEvent): string {
  176. const text = extractSessionEventText(event)
  177. return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}`
  178. + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`)
  179. }
  180. function availabilityText(record: SessionRecord): string {
  181. return [
  182. record.live ? 'live' : undefined,
  183. record.persisted ? 'persisted' : undefined,
  184. ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable'
  185. }
  186. function seqList(values: readonly number[]): string {
  187. return values.length === 0 ? 'none' : values.join(', ')
  188. }
  189. function formatTime(value: number): string {
  190. return new Date(value).toISOString()
  191. }
  192. function presentSessionSearchCall(args: SessionSearchCallArgs): GenericCallView {
  193. return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query }
  194. }
  195. function presentEventSearchCall(args: EventSearchCallArgs): GenericCallView {
  196. return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query }
  197. }
  198. function presentSessionTraceCall(args: SessionTargetCallArgs): GenericCallView {
  199. return {
  200. card: 'generic',
  201. kind: 'read',
  202. title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`,
  203. ...args.session_id === undefined ? {} : { rawInput: args.session_id },
  204. }
  205. }
  206. function presentEventTargetCall(
  207. action: string,
  208. args: EventTargetCallArgs,
  209. ): GenericCallView {
  210. return {
  211. card: 'generic',
  212. kind: 'read',
  213. title: `${action} ${args.seq}`,
  214. rawInput: {
  215. ...args.session_id === undefined ? {} : { session_id: args.session_id },
  216. seq: args.seq,
  217. },
  218. }
  219. }
  220. /** Text output and call-card presentation for every session-query tool. */
  221. export const presentation = {
  222. formatSessionSearch,
  223. formatEmptySessionSearch,
  224. formatEventSearch,
  225. formatSessionTrace,
  226. formatEventTrace,
  227. formatEventRead,
  228. presentSessionSearchCall,
  229. presentEventSearchCall,
  230. presentSessionTraceCall,
  231. presentEventTargetCall,
  232. }