| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- /**
- * Prompt journal: append every user-sent prompt to the session project's
- * journal file (`prompts.md` by default) before the step executes.
- *
- * The listener occupies the head of the `agent/pre-step` waterfall, journals
- * the claimed user messages, then delegates unchanged. Subagent sessions and
- * non-user sources (plugin context, tool results) are not journaled. Writes
- * go through the optional `ctx.fs` provider, so providerless products mount
- * this plugin as a no-op.
- *
- * @module dsh-prompt-journal
- */
- import type { Context } from '@deepseek-ai/cordis'
- import Schema from '@deepseek-ai/schemastery'
- import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
- import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
- import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
- import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
- /** Cordis plugin name used by loader diagnostics. */
- export const name = 'prompt-journal'
- /** The agent registry that owns pre-step dispatch scoping. */
- export const inject = ['agents']
- /** Journal target file and entry framing. Invalid values fail plugin load. */
- export interface Config {
- /** Journal file, relative to the session cwd (or absolute). */
- file: string
- /** Separator inserted between two journal entries. */
- separator: string
- }
- /** Schemastery validation for {@link Config}. */
- export const Config: Schema<Config> = Schema.object({
- file: Schema.string().default('prompts.md'),
- separator: Schema.string().default('\n\n---\n\n'),
- })
- /** Extract the visible prompt text of one message, or `undefined` when empty. */
- function promptText(message: UserMessage): string | undefined {
- const texts: string[] = []
- for (const block of message.content) {
- if (block.type === 'text' && block.text.trim().length > 0) texts.push(block.text)
- }
- const text = texts.join('\n').trim()
- return text.length === 0 ? undefined : text
- }
- /** Whether one agent's session belongs to the interactive user, not a delegation. */
- function isRootAgent(agent: Agent): boolean {
- return agent.session.header.origin !== 'subagent'
- }
- /**
- * Register the journaling pre-step listener for the lifetime of `ctx`.
- * @param ctx - plugin context; the listener is disposed with it.
- * @param config - journal target file and entry framing.
- * @throws when the configured file or separator is blank.
- */
- export function apply(ctx: Context, config: Config): void {
- const file = config.file
- const separator = config.separator
- if (file.trim().length === 0) {
- throw new TypeError(`prompt-journal: file must be a non-empty string, got ${JSON.stringify(file)}`)
- }
- if (separator.trim().length === 0) {
- throw new TypeError(`prompt-journal: separator must be a non-empty string, got ${JSON.stringify(separator)}`)
- }
- const logger = ctx.logger('prompt-journal')
- let warnedProviderless = false
- /** Read-modify-write chains keyed by journal target; unloading the plugin clears them. */
- const journalTails = new Map<string, Promise<void>>()
- ctx.effect(() => () => journalTails.clear())
- /**
- * Append one entry through a per-target serialized read-modify-write.
- * @param fs - filesystem provider backing the journal.
- * @param session - the owning session; its cwd is the journal's base and the
- * sandbox workspace boundary, and its logged mode rides the write policy.
- * @param entry - trimmed prompt text to append.
- * @param signal - the owning step's cancellation signal.
- */
- const appendEntry = async (
- fs: FileSystem,
- session: Session,
- entry: string,
- signal: AbortSignal,
- ): Promise<void> => {
- const target: FsTarget = await fs.resolve(
- file,
- session.header.cwd === undefined ? { signal } : { cwd: session.header.cwd, signal },
- )
- const previous = journalTails.get(target.targetKey) ?? Promise.resolve()
- const task = previous.then(async () => {
- const existing = await fs.stat(target, signal)
- const priorText = existing === undefined ? '' : (await fs.readText(target, signal)).trimEnd()
- // Three end states decide the join, so the rule line is never duplicated:
- // an empty file writes the bare entry; a file already ending on the
- // separator's rule line (e.g. a hand-added trailing '---') gets only the
- // separator's tail (the blank-line half); anything else gets the full
- // separator between the previous entry and this one.
- const rule = separator.trim()
- const lastLine = priorText.length === 0 ? '' : priorText.split(/\r?\n/).at(-1) ?? ''
- const endsOnRule = priorText.length > 0 && rule.length > 0 && lastLine.trim() === rule
- const join = priorText.length === 0
- ? ''
- : endsOnRule
- ? separator.slice(separator.lastIndexOf(rule) + rule.length)
- : separator
- const content = `${priorText}${join}${entry}\n`
- // Resolve the per-call sandbox policy from the SESSION, mirroring the
- // tool layer: a session's cwd is its workspace-write boundary. Without
- // this, a sandboxing fs provider falls back to the deployment root (the
- // process launch directory) and denies journaling a different project.
- const policyService: SandboxPolicyService | undefined = ctx.get('sandboxPolicy')
- const sandboxPolicy = policyService === undefined
- ? undefined
- : policyService.resolve({ session })
- await fs.writeText(target, content, undefined, signal, sandboxPolicy)
- })
- // Keep the serialization chain alive even when this append fails.
- journalTails.set(target.targetKey, task.then(() => {}, () => {}))
- await task
- }
- ctx.on('agent/pre-step', async (
- { agent, messages, signal },
- next,
- ): Promise<PreStepDecision> => {
- const fs = ctx.get('fs')
- if (fs === undefined) {
- if (!warnedProviderless) {
- warnedProviderless = true
- logger.warn('no fs provider is registered; prompts are not journaled')
- }
- return next()
- }
- if (isRootAgent(agent) && !signal.aborted) {
- for (const message of messages) {
- if (message.source.kind !== 'user') continue
- const entry = promptText(message)
- if (entry === undefined) continue
- try {
- await appendEntry(fs, agent.session, entry, signal)
- } catch (error: unknown) {
- // Journaling observes the step; it must never fail or delay recovery.
- logger.warn(`failed to journal a prompt: ${error instanceof Error ? error.message : String(error)}`)
- }
- }
- }
- return next()
- }, { prepend: true })
- }
|