index.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /**
  2. * Human-facing `/compact` command over the backend-independent compaction seam.
  3. * @module @deepseek-ai/dsh-command-compact
  4. */
  5. import type { Context } from '@deepseek-ai/cordis'
  6. import { ManualCompactionError } from '@deepseek-ai/dsh-compaction'
  7. import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
  8. export const name = 'command-compact'
  9. export const inject = ['commands', 'compaction']
  10. const USAGE = 'Usage: /compact (no arguments)'
  11. /** Fail loudly if a locally closed union gains an unhandled member. */
  12. /* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
  13. function assertNever(value: never): never {
  14. throw new TypeError(`unknown manual compaction error code: ${String(value)}`)
  15. }
  16. /* v8 ignore stop */
  17. /** Convert expected capability failures into concise human-only outcomes. */
  18. function expectedFailure(error: ManualCompactionError): CommandResult {
  19. switch (error.code) {
  20. case 'busy':
  21. return {
  22. kind: 'error',
  23. text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
  24. }
  25. case 'cancelled':
  26. return { kind: 'error', text: 'Compaction cancelled.' }
  27. case 'changed':
  28. return {
  29. kind: 'error',
  30. text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
  31. }
  32. case 'summary':
  33. return {
  34. kind: 'error',
  35. text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
  36. }
  37. case 'commit':
  38. return {
  39. kind: 'error',
  40. text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
  41. }
  42. case 'persistence':
  43. return {
  44. kind: 'error',
  45. text: 'Compaction finished, but the session could not be saved.',
  46. }
  47. /* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */
  48. default: return assertNever(error.code)
  49. }
  50. }
  51. /** Execute one argument-free manual compaction request. */
  52. async function executeCompact(
  53. ctx: Context,
  54. invocation: CommandInvocation,
  55. ): Promise<CommandResult> {
  56. if (invocation.rawInput.trim().length > 0) {
  57. return { kind: 'error', text: USAGE }
  58. }
  59. try {
  60. const result = await ctx.compaction.compactNow(invocation.agent, invocation.signal, invocation.commandId)
  61. if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
  62. return {
  63. kind: 'success',
  64. text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
  65. sourceEventSeq: result.summarySeq,
  66. }
  67. } catch (error: unknown) {
  68. if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }
  69. if (error instanceof ManualCompactionError) return expectedFailure(error)
  70. throw error
  71. }
  72. }
  73. /**
  74. * Register `/compact` for every composed human-command adapter.
  75. * @param ctx - context carrying the command registry and the compaction seam.
  76. */
  77. export function apply(ctx: Context): void {
  78. const active = new Set<Promise<CommandResult>>()
  79. const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
  80. const operation = executeCompact(ctx, invocation)
  81. active.add(operation)
  82. const retire = (): void => { active.delete(operation) }
  83. // Both branches retire without rethrowing, so the derived observer promise
  84. // cannot become an unhandled mirror of an expected handler rejection.
  85. void operation.then(retire, retire)
  86. return operation
  87. }
  88. ctx.effect(function* () {
  89. // Yield drain before registration: composite teardown is LIFO, so no new
  90. // invocation can enter while already-started handler promises quiesce.
  91. yield async () => { await Promise.allSettled(active) }
  92. yield ctx.commands.register({
  93. name: 'compact',
  94. description: 'Compact older conversation history',
  95. handler,
  96. })
  97. }, 'command-compact lifecycle')
  98. }