/** * The two surface projections the loop owns: the rendered system prompt and * the dynamic runtime-context snapshot. * * ## Why this is a copy and not an import * * `dsh-agent-loop` does not export either class (its entry exports only * `AgentLoop`, the constants and `turnBoundaryProjectionDefinition`, * dsh-agent-loop/lib/index.js:2023). They are nevertheless required: without * `SystemPromptProjection` the prompt would never reach surface node 0, and * without `RuntimeContextProjection` every step would append a duplicate * snapshot. Both are reproduced from * `dsh-agent-loop/lib/types/index.js:211-351` (0.2.1-rc.2, MIT) unchanged. * * ★ `SOURCE = 'runtime-context'` is load-bearing and must not be renamed: the * stock loop also owns snapshots under that `source.kind`, so a session that * switches between the stock loop and this one (a profile patch applied * mid-life) must still recognise the previous process's snapshot as its own. * A different kind would leave two snapshots in the surface, both claiming to * supersede the other. * * @module dsh-memory/lib/loop/runtime-context */ import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' const SOURCE = 'runtime-context' const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' /** @param {object} message derived message */ function isOwned(message) { return message.source.kind === SOURCE } /** @param {object} message derived message */ function textOf(message) { const [block] = message.content return message.content.length === 1 && block?.type === 'text' ? block.text : undefined } /** Committed events from the newest backward; the restore scans stop at the first match. */ function eventsNewestFirst(session) { return session.snapshotEvents().toReversed() } /** * Decides how a rendered system prompt reaches the surface without owning the * commit. The first prompt, even empty, reserves surface node 0. */ export class SystemPromptProjection { /** @param {object} session */ constructor(session) { this.session = session } /** The surviving `system/message` nodes in surface order. */ systemNodes() { const nodes = [] for (const seq of this.session.surface.nodes) { const event = this.session.eventAt(seq) if (event?.type !== 'system/message') continue const text = event.data.message.content.length === 0 ? '' : textOf(event.data.message) nodes.push({ seq, text }) } return nodes } /** * Reconcile effective text and retained nodes with the prepared route and series. * @param {string} rendered the fully rendered system prompt; `''` when none is active * @param {{inHistory: boolean, startsSeries: boolean}} input route capability and series facts */ project(rendered, input) { const nodes = this.systemNodes() const head = nodes[0] if (head === undefined) return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }] const latest = nodes.findLast((node) => node.text !== '') ?? head if (!input.inHistory || input.startsSeries || rendered.length === 0) { const updates = nodes.slice(1).filter((node) => node.text !== '').map((node) => this.replace(node.seq, '')) if (head.text !== rendered) updates.push(this.replace(head.seq, rendered)) return updates } if (latest.text === rendered) return [] return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }] } /** * @param {number} seq surface node to replace * @param {string} text replacement text */ replace(seq, text) { return { message: createSystemMessage(text), intent: { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] }, } } } /** Tracks the last retained runtime-context snapshot without owning its commit. */ export class RuntimeContextProjection { /** `undefined` means no snapshot ever existed; `null` means none is retained. */ retained /** * Restore projection state once, then follow authoritative session events. * @param {object} ctx agent-scoped event context * @param {object} session session receiving projected messages */ constructor(ctx, session) { const surface = new Set(session.surface.nodes) for (const event of eventsNewestFirst(session)) { if (event.type !== 'user/message' || !isOwned(event.data)) continue this.retained ??= null if (surface.has(event.seq)) { this.retained = { seq: event.seq, text: textOf(event.data) } break } } ctx.on('session/event', (subject, event) => { if (subject !== session) return if (event.type === 'user/message' && isOwned(event.data)) { this.retained = { seq: event.seq, text: textOf(event.data) } } else if (this.retained && isReplacementSurfaceEvent(event) && event.sourceEventSeqs?.includes(this.retained.seq) === true) { this.retained = null } }) } /** * Create an uncommitted snapshot only when the retained value differs. * @param {string} current fully rendered dynamic context * @param {Array<{name: string, text: string}>} sections named contributions of the snapshot * @returns {object|undefined} a candidate user message, or undefined when no update is needed */ project(current, sections) { if (this.retained === undefined && current.length === 0) return const snapshot = current.length === 0 ? CLEARED : current if (this.retained?.text === snapshot) return return createUserMessage({ content: [{ type: 'text', text: snapshot }], source: sections.length === 0 ? { kind: SOURCE } : { kind: SOURCE, form: 'snapshot', sections }, }) } }