events.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. /**
  2. * Append helpers for the log-only `hook/*` session events — the durable record
  3. * that a hook ran and what it decided. Thin wrappers over `session.append` so a
  4. * bridge does not hand-build the payloads (and so the `turn`-enclosure +
  5. * invoked/result pairing stay consistent across both bridges).
  6. *
  7. * `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no
  8. * `surfaceOp` and append with no surface intent — but, like every event, they
  9. * must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed
  10. * event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/
  11. * `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the
  12. * exception (its injected `context/message` is the durable evidence instead), so
  13. * a bridge does NOT write `hook/*` for session-start — see the hooks RFC.
  14. *
  15. * @module @deepseek-ai/dsh-hook-protocol/events
  16. */
  17. import type { Session } from '@deepseek-ai/dsh-session'
  18. import type { HookDialect, HookOutput } from './types.ts'
  19. /** What identifies a hook invocation across its invoked/result pair. */
  20. export interface HookInvocation {
  21. /** The open turn the invocation lives inside. */
  22. turn: number
  23. /** The hook point (`PreToolUse`, `Stop`, …). */
  24. point: string
  25. /** The bridge dialect that ran it. */
  26. dialect: HookDialect
  27. /** A stable id correlating the invoked event with its result. */
  28. handlerId: string
  29. /** The matcher-group pattern that selected it (absent for match-all). */
  30. matcher?: string
  31. }
  32. /** The decided outcome half of the pair. */
  33. export interface HookResultRecord {
  34. turn: number
  35. point: string
  36. handlerId: string
  37. /**
  38. * The decoded outcome the run produced. {@link appendHookResult} derives the
  39. * durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
  40. * event's semantics live here, in the lib that declares it, not per-bridge.
  41. */
  42. output: HookOutput
  43. /**
  44. * Character cap for the derived `stderrSummary`. The bound is the bridge's
  45. * to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
  46. * {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
  47. */
  48. stderrSummaryMaxChars: number
  49. /** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
  50. durationMs: number
  51. }
  52. /**
  53. * The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
  54. * (both bridges' config default). It lives here, once, next to the truncation
  55. * rule it bounds, so the bridges cannot drift apart on the shared event's
  56. * default cap.
  57. */
  58. export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
  59. /**
  60. * Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
  61. * `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
  62. * bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
  63. * the config default and passes it in.
  64. * @param stderr - the hook's raw captured stderr.
  65. * @param maxChars - the character cap for the summary (the bridge's config value).
  66. * @returns the trimmed, capped summary, or `undefined` when stderr is blank.
  67. */
  68. export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
  69. const t = stderr.trim()
  70. if (t.length === 0) return undefined
  71. return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
  72. }
  73. /**
  74. * Append a `hook/invoked` provenance event to `session`.
  75. * @param session - the session whose open turn records the event.
  76. * @param invocation - the invocation identity; an absent `matcher` is omitted from the payload.
  77. */
  78. export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
  79. session.append('hook/invoked', {
  80. turn: invocation.turn,
  81. point: invocation.point,
  82. dialect: invocation.dialect,
  83. handlerId: invocation.handlerId,
  84. ...invocation.matcher !== undefined ? { matcher: invocation.matcher } : {},
  85. })
  86. }
  87. /**
  88. * Append a `hook/result` outcome event to `session` (pairs with a prior
  89. * `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
  90. * parsed decision, else `'stop'` when it asked to halt (`continue: false`),
  91. * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
  92. * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
  93. * is omitted when the hook never ran.
  94. * @param session - the session whose open turn records the event.
  95. * @param record - the outcome to record: the decoded output plus the summary cap and duration.
  96. */
  97. export function appendHookResult(session: Session, record: HookResultRecord): void {
  98. const { output } = record
  99. const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
  100. session.append('hook/result', {
  101. turn: record.turn,
  102. point: record.point,
  103. handlerId: record.handlerId,
  104. decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
  105. ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
  106. ...stderrSummary !== undefined ? { stderrSummary } : {},
  107. durationMs: record.durationMs,
  108. })
  109. }