index.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. /**
  2. * Prompt journal: append every user-sent prompt to the session project's
  3. * journal file (`prompts.md` by default) before the step executes.
  4. *
  5. * The listener occupies the head of the `agent/pre-step` waterfall, journals
  6. * the claimed user messages, then delegates unchanged. Subagent sessions and
  7. * non-user sources (plugin context, tool results) are not journaled. Writes
  8. * go through the optional `ctx.fs` provider, so providerless products mount
  9. * this plugin as a no-op.
  10. *
  11. * @module dsh-prompt-journal
  12. */
  13. import type { Context } from '@deepseek-ai/cordis'
  14. import Schema from '@deepseek-ai/schemastery'
  15. import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
  16. import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
  17. import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
  18. import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
  19. /** Cordis plugin name used by loader diagnostics. */
  20. export const name = 'prompt-journal'
  21. /** The agent registry that owns pre-step dispatch scoping. */
  22. export const inject = ['agents']
  23. /** Journal target file and entry framing. Invalid values fail plugin load. */
  24. export interface Config {
  25. /** Journal file, relative to the session cwd (or absolute). */
  26. file: string
  27. /** Separator inserted between two journal entries. */
  28. separator: string
  29. }
  30. /** Schemastery validation for {@link Config}. */
  31. export const Config: Schema<Config> = Schema.object({
  32. file: Schema.string().default('prompts.md'),
  33. separator: Schema.string().default('\n\n---\n\n'),
  34. })
  35. /** Extract the visible prompt text of one message, or `undefined` when empty. */
  36. function promptText(message: UserMessage): string | undefined {
  37. const texts: string[] = []
  38. for (const block of message.content) {
  39. if (block.type === 'text' && block.text.trim().length > 0) texts.push(block.text)
  40. }
  41. const text = texts.join('\n').trim()
  42. return text.length === 0 ? undefined : text
  43. }
  44. /** Whether one agent's session belongs to the interactive user, not a delegation. */
  45. function isRootAgent(agent: Agent): boolean {
  46. return agent.session.header.origin !== 'subagent'
  47. }
  48. /**
  49. * Register the journaling pre-step listener for the lifetime of `ctx`.
  50. * @param ctx - plugin context; the listener is disposed with it.
  51. * @param config - journal target file and entry framing.
  52. * @throws when the configured file or separator is blank.
  53. */
  54. export function apply(ctx: Context, config: Config): void {
  55. const file = config.file
  56. const separator = config.separator
  57. if (file.trim().length === 0) {
  58. throw new TypeError(`prompt-journal: file must be a non-empty string, got ${JSON.stringify(file)}`)
  59. }
  60. if (separator.trim().length === 0) {
  61. throw new TypeError(`prompt-journal: separator must be a non-empty string, got ${JSON.stringify(separator)}`)
  62. }
  63. const logger = ctx.logger('prompt-journal')
  64. let warnedProviderless = false
  65. /** Read-modify-write chains keyed by journal target; unloading the plugin clears them. */
  66. const journalTails = new Map<string, Promise<void>>()
  67. ctx.effect(() => () => journalTails.clear())
  68. /**
  69. * Append one entry through a per-target serialized read-modify-write.
  70. * @param fs - filesystem provider backing the journal.
  71. * @param session - the owning session; its cwd is the journal's base and the
  72. * sandbox workspace boundary, and its logged mode rides the write policy.
  73. * @param entry - trimmed prompt text to append.
  74. * @param signal - the owning step's cancellation signal.
  75. */
  76. const appendEntry = async (
  77. fs: FileSystem,
  78. session: Session,
  79. entry: string,
  80. signal: AbortSignal,
  81. ): Promise<void> => {
  82. const target: FsTarget = await fs.resolve(
  83. file,
  84. session.header.cwd === undefined ? { signal } : { cwd: session.header.cwd, signal },
  85. )
  86. const previous = journalTails.get(target.targetKey) ?? Promise.resolve()
  87. const task = previous.then(async () => {
  88. const existing = await fs.stat(target, signal)
  89. const priorText = existing === undefined ? '' : (await fs.readText(target, signal)).trimEnd()
  90. // Three end states decide the join, so the rule line is never duplicated:
  91. // an empty file writes the bare entry; a file already ending on the
  92. // separator's rule line (e.g. a hand-added trailing '---') gets only the
  93. // separator's tail (the blank-line half); anything else gets the full
  94. // separator between the previous entry and this one.
  95. const rule = separator.trim()
  96. const lastLine = priorText.length === 0 ? '' : priorText.split(/\r?\n/).at(-1) ?? ''
  97. const endsOnRule = priorText.length > 0 && rule.length > 0 && lastLine.trim() === rule
  98. const join = priorText.length === 0
  99. ? ''
  100. : endsOnRule
  101. ? separator.slice(separator.lastIndexOf(rule) + rule.length)
  102. : separator
  103. const content = `${priorText}${join}${entry}\n`
  104. // Resolve the per-call sandbox policy from the SESSION, mirroring the
  105. // tool layer: a session's cwd is its workspace-write boundary. Without
  106. // this, a sandboxing fs provider falls back to the deployment root (the
  107. // process launch directory) and denies journaling a different project.
  108. const policyService: SandboxPolicyService | undefined = ctx.get('sandboxPolicy')
  109. const sandboxPolicy = policyService === undefined
  110. ? undefined
  111. : policyService.resolve({ session })
  112. await fs.writeText(target, content, undefined, signal, sandboxPolicy)
  113. })
  114. // Keep the serialization chain alive even when this append fails.
  115. journalTails.set(target.targetKey, task.then(() => {}, () => {}))
  116. await task
  117. }
  118. ctx.on('agent/pre-step', async (
  119. { agent, messages, signal },
  120. next,
  121. ): Promise<PreStepDecision> => {
  122. const fs = ctx.get('fs')
  123. if (fs === undefined) {
  124. if (!warnedProviderless) {
  125. warnedProviderless = true
  126. logger.warn('no fs provider is registered; prompts are not journaled')
  127. }
  128. return next()
  129. }
  130. if (isRootAgent(agent) && !signal.aborted) {
  131. for (const message of messages) {
  132. if (message.source.kind !== 'user') continue
  133. const entry = promptText(message)
  134. if (entry === undefined) continue
  135. try {
  136. await appendEntry(fs, agent.session, entry, signal)
  137. } catch (error: unknown) {
  138. // Journaling observes the step; it must never fail or delay recovery.
  139. logger.warn(`failed to journal a prompt: ${error instanceof Error ? error.message : String(error)}`)
  140. }
  141. }
  142. }
  143. return next()
  144. }, { prepend: true })
  145. }