/** * 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 = 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>() 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 => { 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 => { 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 }) }