index.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. /**
  2. * Basic compaction backend. It estimates request pressure, retains a recent
  3. * tool-balanced surface tail, summarizes the older head through a one-shot model
  4. * call, and replaces that head with one checkpoint. Auto-compaction runs before
  5. * every step so a growing turn can compact its earlier closed steps.
  6. * @module @deepseek-ai/dsh-compact-basic
  7. */
  8. import { Context } from 'cordis'
  9. import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
  10. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  11. import { BlockAssembler } from '@deepseek-ai/dsh-llm'
  12. import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  13. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  14. import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
  15. import type { Agent } from '@deepseek-ai/dsh-agent'
  16. import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
  17. import { resolveConfig } from './types.ts'
  18. export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
  19. export { resolveConfig } from './types.ts'
  20. /** Per-block structural overhead for JSON framing / type tag. */
  21. const BLOCK_OVERHEAD = 4
  22. /** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
  23. const ROLE_OVERHEAD = 4
  24. /** Tags wrapping the structured summary inside the landed checkpoint node. */
  25. const SUMMARY_OPEN_TAG = '<compacted-summary>'
  26. const SUMMARY_CLOSE_TAG = '</compacted-summary>'
  27. /**
  28. * Fixed summary structure for resumable checkpoints. A tagged prior checkpoint
  29. * is merged with newer history instead of copied forward verbatim.
  30. */
  31. const SUMMARIZE_SYSTEM_PROMPT = [
  32. 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
  33. '',
  34. 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
  35. '',
  36. '## Primary Request and Intent',
  37. "- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
  38. '',
  39. '## Key Technical Concepts',
  40. '- [technologies, frameworks, patterns, and conventions in play]',
  41. '',
  42. '## Files and Code',
  43. '- [exact path: why it matters, key changes or snippets]',
  44. '',
  45. '## Errors and Fixes',
  46. '- [error: how it was resolved, plus any related user feedback]',
  47. '',
  48. '## Pending Tasks',
  49. '- [explicitly requested work not yet completed]',
  50. '',
  51. '## Current Work',
  52. '- [precisely what was in progress at this checkpoint]',
  53. '',
  54. '## Next Step',
  55. '- [the single next action, directly in line with the most recent request, or "(none)"]',
  56. '',
  57. '## Critical Context',
  58. '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
  59. '',
  60. 'Rules:',
  61. '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
  62. '- Capture user feedback and explicit instructions faithfully, especially corrections.',
  63. '- Do NOT mention this summarization process or that the context was compacted.',
  64. `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
  65. ].join('\n')
  66. /** Framing that makes a landed summary established context rather than a new request. */
  67. const CHECKPOINT_PREAMBLE =
  68. 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
  69. /**
  70. * Map a terminal summary failure to an error. A max-token finish is rejected
  71. * because committing an incomplete checkpoint would shadow the full history.
  72. */
  73. function finishError(finish: FinishReason): Error | undefined {
  74. switch (finish.kind) {
  75. case 'error': {
  76. const error = new Error(finish.message) as Error & { code?: string }
  77. if (finish.code !== undefined) error.code = finish.code
  78. return error
  79. }
  80. case 'aborted': {
  81. const error = new Error('summarization stream aborted') as Error & { code?: string }
  82. error.code = 'ABORTED'
  83. return error
  84. }
  85. case 'max-tokens': {
  86. const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
  87. error.code = 'MAX_TOKENS'
  88. return error
  89. }
  90. default:
  91. return undefined
  92. }
  93. }
  94. /**
  95. * Basic, dependency-light compaction backend: estimates the surface's token
  96. * footprint, summarizes the stale prefix through the model, and shadows it
  97. * behind a durable checkpoint. Every threshold/budget knob is required config
  98. * ({@link BasicCompactConfig}); the estimator's text density is the
  99. * `charsPerToken` knob.
  100. */
  101. export class BasicCompactService extends CompactService {
  102. static inject = ['llm']
  103. /** Resolved configuration (`auto` defaulted). */
  104. readonly config: ResolvedConfig
  105. constructor(ctx: Context, config: BasicCompactConfig) {
  106. super(ctx)
  107. this.config = resolveConfig(config)
  108. if (this.config.auto) {
  109. // Check before every step so a single growing turn can compact earlier closed steps.
  110. // This serial pre-step seam mutates the surface outside the pending step.
  111. ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
  112. try {
  113. const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
  114. if (result) {
  115. const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
  116. ctx.logger.info(
  117. `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
  118. `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
  119. `~${result.shadowedTokenCount} tokens) ` +
  120. `→ ${after} estimated tokens after compaction`,
  121. )
  122. }
  123. } catch (error: unknown) {
  124. // A failed compaction must not prevent the model call — the surface is
  125. // untouched on failure, so the loop derives the full history and the
  126. // call proceeds.
  127. const msg = error instanceof Error ? error.message : String(error)
  128. ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
  129. }
  130. })
  131. }
  132. }
  133. // ---- Token estimation (overridable hooks) ----
  134. // TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
  135. // count — a real tokenizer, or the provider's post-response `usage` (input
  136. // tokens) fed back as a correction — so threshold decisions match the
  137. // model's actual budget.
  138. /**
  139. * Estimate the token count of content blocks — chars divided by the
  140. * `charsPerToken` config, with per-block overhead. Override in a subclass to
  141. * plug in a real tokenizer.
  142. *
  143. * @param blocks - the blocks to estimate; `tool-result` blocks recurse into
  144. * their nested content, and unknown (merge-extended) types fall back to
  145. * their JSON-stringified length.
  146. * @returns the estimated token count.
  147. */
  148. estimateContentTokens(blocks: readonly ContentBlock[]): number {
  149. const { charsPerToken } = this.config
  150. let tokens = 0
  151. for (const block of blocks) {
  152. switch (block.type) {
  153. case 'text':
  154. case 'reasoning':
  155. tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
  156. break
  157. case 'tool-call':
  158. tokens += Math.ceil(block.name.length / charsPerToken)
  159. + Math.ceil(block.arguments.length / charsPerToken)
  160. + BLOCK_OVERHEAD
  161. break
  162. case 'tool-result':
  163. tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
  164. break
  165. default:
  166. // Unknown block types (merge-extensible ContentBlockMap):
  167. // estimate conservatively via JSON stringify.
  168. tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
  169. }
  170. }
  171. return tokens
  172. }
  173. /**
  174. * Estimate token count for a single session event. Returns 0 for non-message
  175. * event types (boundaries, chunks, usage, errors, compact markers).
  176. *
  177. * @param event - any session event; only the message-bearing types carry
  178. * content to count.
  179. * @returns the estimated token count of the event's content, or 0 for a
  180. * non-message event.
  181. */
  182. estimateEventTokens(event: SessionEvent): number {
  183. switch (event.type) {
  184. case 'user/message':
  185. case 'assistant/message':
  186. case 'context/message':
  187. case 'steering/message':
  188. case 'tool/result':
  189. return this.estimateContentTokens(event.data.content)
  190. default:
  191. return 0
  192. }
  193. }
  194. /**
  195. * Estimate total tokens across a list of messages plus optional system prompt.
  196. *
  197. * @param messages - the derived conversation messages; each adds a fixed
  198. * role-framing overhead on top of its content estimate.
  199. * @param systemPrompt - counted at chars / `charsPerToken` when provided.
  200. * @returns the estimated token footprint of the whole request.
  201. */
  202. estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
  203. let total = 0
  204. for (const msg of messages) {
  205. total += this.estimateContentTokens(msg.content)
  206. total += ROLE_OVERHEAD
  207. }
  208. if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
  209. return total
  210. }
  211. /**
  212. * Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent
  213. * step or `agent/request` dispatch. Failure finishes and truncated summaries
  214. * reject; the signal is forwarded and only text reaches the checkpoint.
  215. *
  216. * @param text - plain-text rendering of the conversation region to condense.
  217. * @param agent - supplies the fallback model and the session id stamped on
  218. * the call; throws when neither it nor the config names a model.
  219. * @param signal - optional abort signal, forwarded into the model call.
  220. * @returns the text-only summary blocks plus the call envelope used
  221. * (`model`, and `maxTokens` when the summarizer has a cap).
  222. */
  223. async summarize(
  224. text: string, agent: Agent, signal?: AbortSignal,
  225. ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
  226. const assembler = new BlockAssembler()
  227. const options: GenerateOptions = {
  228. model: this.config.summarizationModel || agent.options.model || '',
  229. messages: [{
  230. role: 'user',
  231. content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
  232. }],
  233. system: SUMMARIZE_SYSTEM_PROMPT,
  234. maxTokens: this.config.maxTokens,
  235. sessionId: agent.session.id,
  236. }
  237. // exactOptionalPropertyTypes: only set `signal` when present — assigning
  238. // `undefined` to an optional `signal?: AbortSignal` is a type error.
  239. if (signal) options.signal = signal
  240. if (!options.model) {
  241. throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
  242. }
  243. for await (const chunk of this.ctx.llm.stream(options)) {
  244. assembler.push(chunk)
  245. }
  246. const error = finishError(assembler.finish)
  247. if (error) throw error
  248. const summary = this._textOnly(assembler.message().content)
  249. if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
  250. throw new Error('summarization produced no text summary content')
  251. }
  252. // config.maxTokens is required and validated positive, so this backend's
  253. // envelope always carries the cap; the return type's optionality exists
  254. // for overriding subclasses whose summarizer has none.
  255. return { summary, model: options.model, maxTokens: this.config.maxTokens }
  256. }
  257. // ---- Core API (implements the abstract contract) ----
  258. /**
  259. * The sole pressure gate: count the next request's prefix, derived history,
  260. * and system prompt. Above threshold, retain a recent tool-balanced tail and
  261. * compact the head, reconsolidating any prior automatic checkpoint. Returns
  262. * `null` when no safe or necessary range exists.
  263. */
  264. override async compactIfNeeded(
  265. agent: Agent,
  266. fullSystemPrompt: string,
  267. sessionPrefix: readonly Message[],
  268. signal: AbortSignal,
  269. ): Promise<CompactionResult | null> {
  270. const session = agent.session
  271. const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
  272. let result: CompactionResult | null = null
  273. for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
  274. const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
  275. if (totalTokens < threshold) return result
  276. const range = this._compactableRange(session)
  277. if (range === null) {
  278. /* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
  279. if (result === null) return null
  280. /* v8 ignore next -- paired with the ignored defensive branch above. */
  281. break
  282. }
  283. result = await this.compactRegion(session, range.start, range.end, agent, signal)
  284. }
  285. const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
  286. if (totalTokens < threshold) return result
  287. throw new Error(
  288. `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
  289. + `(${totalTokens} estimated tokens >= threshold ${threshold})`,
  290. )
  291. }
  292. /**
  293. * Estimated token pressure of the NEXT request: the session prefix
  294. * (`EpochHeader.messagePrefix` — request-only messages the loop sends in
  295. * front of the derived history, composed before the pre-step seam and
  296. * handed to the gate), the derived history, and the system prompt.
  297. * @param session - the session whose next request is being estimated.
  298. * @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
  299. * @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
  300. * @returns the estimated token total the next request will carry.
  301. */
  302. estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
  303. return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
  304. }
  305. override async compactRegion(
  306. session: Session,
  307. start: number,
  308. end: number,
  309. agent: Agent,
  310. signal?: AbortSignal,
  311. ): Promise<CompactionResult> {
  312. // Resolve by surface position: a newer replacement seq may occupy an older slot.
  313. const nodes = session.surface.nodes
  314. const startIdx = nodes.findIndex(n => n.seq === start)
  315. const endIdx = nodes.findIndex(n => n.seq === end)
  316. if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
  317. if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
  318. if (startIdx > endIdx) {
  319. throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
  320. }
  321. // Both range edges must preserve assistant tool-call/result pairing.
  322. const events = session.events
  323. if (!isToolPairingBalanced(nodes, events, start)) {
  324. throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
  325. }
  326. // The cut after `end` is named by `end`'s surface successor, or `null` when
  327. // `end` is the tail.
  328. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  329. const afterEnd: number | null = nodes[endIdx]!.next
  330. if (!isToolPairingBalanced(nodes, events, afterEnd)) {
  331. throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
  332. }
  333. if (this._isCompactionInProgress(session)) {
  334. throw new Error('compaction already in progress')
  335. }
  336. // Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
  337. // the session-log contract rejects any plugin event appended outside an open turn.
  338. const openTurn = this._openTurn(session)
  339. if (openTurn === null) {
  340. throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
  341. }
  342. // Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
  343. // shadowed range is positional, so this is the set the replace op covers.
  344. const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
  345. // --- Acquire lock ---
  346. const startEvent = session.append('compact/start', { turn: openTurn })
  347. try {
  348. // --- Extract text and summarize ---
  349. const text = renderTranscript(session.events, shadowedSeqs)
  350. const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
  351. // Estimate token count of the shadowed content for provenance.
  352. let shadowedTokenCount = 0
  353. for (const seq of shadowedSeqs) {
  354. // seq comes from a surface node — always a valid log index by construction.
  355. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  356. shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
  357. }
  358. const framedSummary = this._frameSummary(summary)
  359. const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
  360. if (framedSummaryTokenCount >= shadowedTokenCount) {
  361. throw new Error(
  362. `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
  363. )
  364. }
  365. // --- Provenance record (log-only) ---
  366. const summaryEvent = session.append('compact/summary', {
  367. summary,
  368. shadowedRange: { start, end },
  369. shadowedSeqs,
  370. shadowedTokenCount,
  371. model,
  372. ...maxTokens !== undefined ? { maxTokens } : {},
  373. })
  374. // --- Surface replacement --- The user/message directly shadows all compacted surface
  375. // nodes with a single replace op.
  376. session.append('user/message', {
  377. content: framedSummary,
  378. source: { kind: 'plugin', plugin: 'compact' },
  379. }, {
  380. surfaceOp: { op: 'replace', start, end },
  381. sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
  382. })
  383. // --- Release lock (log-only) ---
  384. // Appended LAST so the lock brackets the WHOLE operation: a crash between
  385. // compact/start and here leaves a detectable orphaned lock (a compact/start
  386. // with no matching compact/end) rather than a compact/end that falsely
  387. // claims compaction finished before the surface replacement landed.
  388. const endEvent = session.append('compact/end', { turn: openTurn })
  389. return {
  390. startSeq: startEvent.seq,
  391. summarySeq: summaryEvent.seq,
  392. endSeq: endEvent.seq,
  393. summary,
  394. shadowedRange: { start, end },
  395. shadowedSeqs,
  396. shadowedTokenCount,
  397. }
  398. } catch (error: unknown) {
  399. // Always release the lock — append compact/end with the error so a
  400. // wedged lock is impossible.
  401. const msg = error instanceof Error ? error.message : String(error)
  402. session.append('compact/end', { turn: openTurn, error: msg })
  403. throw error
  404. }
  405. }
  406. // ---- Internal helpers ----
  407. /**
  408. * Frame the raw summary blocks into the content that lands on the surface:
  409. * a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
  410. * fresh user request) followed by the summary wrapped in
  411. * {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
  412. * checkpoint detectable in the transcript on the next compaction cycle, which
  413. * triggers the merge rule in the summarization prompt. The raw, unframed
  414. * `summary` is preserved separately on the `compact/summary` provenance event.
  415. */
  416. private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
  417. return [
  418. { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
  419. ...summary,
  420. { type: 'text', text: SUMMARY_CLOSE_TAG },
  421. ]
  422. }
  423. /**
  424. * Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
  425. * (no later `compact/end`) WITHIN the current turn.
  426. */
  427. private _isCompactionInProgress(session: Session): boolean {
  428. const events = session.events
  429. for (let i = events.length - 1; i >= 0; i--) {
  430. // Index bounded by i >= 0 and i < events.length — never undefined.
  431. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  432. const e = events[i]!
  433. if (e.type === 'compact/start') return true
  434. if (e.type === 'compact/end') break
  435. // A turn/end bounds the scan: anything before it belongs to a prior
  436. // (closed) turn and cannot be an in-progress compaction of THIS turn.
  437. if (e.type === 'turn/end') break
  438. }
  439. return false
  440. }
  441. /** Resolve the next head-anchored compactable surface range, or `null`. */
  442. private _compactableRange(session: Session): { start: number; end: number } | null {
  443. const nodes = session.surface.nodes
  444. if (nodes.length === 0) return null
  445. const events = session.events
  446. const retainBudget = this.config.retainTokens
  447. // Walk tail→head summing per-node token estimates. `keepFromIdx` is the
  448. // index of the OLDEST node we retain verbatim; everything strictly older
  449. // (`[0, keepFromIdx - 1]`) is the compactable range.
  450. let accumulated = 0
  451. let keepFromIdx = nodes.length // nothing retained yet
  452. for (let i = nodes.length - 1; i >= 0; i--) {
  453. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  454. const node = nodes[i]!
  455. const event = events[node.seq]
  456. /* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
  457. if (event) accumulated += this.estimateEventTokens(event)
  458. keepFromIdx = i
  459. if (accumulated >= retainBudget) break
  460. }
  461. // The whole surface fits the retain budget — nothing to compact.
  462. if (keepFromIdx === 0) return null
  463. // Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
  464. // unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
  465. // retained side head-ward until the cut is balanced, so the compacted range ends without
  466. // splitting an assistant↔result pair.
  467. while (keepFromIdx > 0) {
  468. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  469. if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
  470. keepFromIdx -= 1
  471. }
  472. if (keepFromIdx === 0) return null
  473. // The compacted range is [head … keepFromIdx - 1], anchored at the head.
  474. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  475. const firstSeq = nodes[0]!.seq
  476. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  477. const cutoffSeq = nodes[keepFromIdx - 1]!.seq
  478. return { start: firstSeq, end: cutoffSeq }
  479. }
  480. /** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
  481. private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
  482. return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
  483. }
  484. /**
  485. * The turn number of the currently OPEN turn — a `turn/start` not yet
  486. * followed by its `turn/end` — or `null` if the session has no open turn.
  487. *
  488. * Compaction's events must be enclosed in a turn, so scanning back from the
  489. * tail: a `turn/start` means that turn is open (return it); a `turn/end` means
  490. * the most recent turn already closed (return null). The whole compaction
  491. * sequence (compact/start … compact/end) is stamped with this turn.
  492. */
  493. private _openTurn(session: Session): number | null {
  494. for (let i = session.events.length - 1; i >= 0; i--) {
  495. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  496. const e = session.events[i]!
  497. if (e.type === 'turn/start') return e.data.turn
  498. if (e.type === 'turn/end') return null
  499. }
  500. return null
  501. }
  502. }
  503. export default BasicCompactService