index.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  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 'cordis'
  6. import { ManualCompactionError } from '@deepseek-ai/dsh-compact'
  7. import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
  8. export const name = 'command-compact'
  9. export const inject = ['commands', 'compact']
  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 'changed':
  26. return {
  27. kind: 'error',
  28. text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
  29. }
  30. case 'summary':
  31. return {
  32. kind: 'error',
  33. text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
  34. }
  35. case 'commit':
  36. return {
  37. kind: 'error',
  38. text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
  39. }
  40. case 'persistence':
  41. return {
  42. kind: 'error',
  43. text: 'Compaction finished, but the session could not be saved.',
  44. }
  45. /* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */
  46. default: return assertNever(error.code)
  47. }
  48. }
  49. /** Execute one argument-free manual compaction request. */
  50. async function executeCompact(
  51. ctx: Context,
  52. invocation: CommandInvocation,
  53. ): Promise<CommandResult> {
  54. if (invocation.rawInput.trim().length > 0) {
  55. return { kind: 'error', text: USAGE }
  56. }
  57. try {
  58. const result = await ctx.compact.compactNow(invocation.agent, invocation.signal)
  59. if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
  60. return {
  61. kind: 'success',
  62. text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
  63. }
  64. } catch (error: unknown) {
  65. if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }
  66. if (error instanceof ManualCompactionError) return expectedFailure(error)
  67. throw error
  68. }
  69. }
  70. /**
  71. * Register `/compact` for every composed human-command adapter.
  72. * @param ctx - context carrying the command registry and the compaction seam.
  73. */
  74. export function apply(ctx: Context): void {
  75. const active = new Set<Promise<CommandResult>>()
  76. const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
  77. const operation = executeCompact(ctx, invocation)
  78. active.add(operation)
  79. const retire = (): void => { active.delete(operation) }
  80. // Both branches retire without rethrowing, so the derived observer promise
  81. // cannot become an unhandled mirror of an expected handler rejection.
  82. void operation.then(retire, retire)
  83. return operation
  84. }
  85. ctx.effect(function* () {
  86. // Yield drain before registration: composite teardown is LIFO, so no new
  87. // invocation can enter while already-started handler promises quiesce.
  88. yield async () => { await Promise.allSettled(active) }
  89. yield ctx.commands.register({
  90. name: 'compact',
  91. description: 'Compact older conversation history',
  92. handler,
  93. })
  94. }, 'command-compact lifecycle')
  95. }