feat(memory): land the loop ports without wiring them
The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.
Two packaging facts they bring with them:
files `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
`lib/loop.js` and `lib/loop/*.js` are now listed separately because
neither glob covers the other.
peer runtime-context.js imports @deepseek-ai/dsh-session, which the peer
table never declared. G2b's allowlist covered it, so the static guard
passed over a real declaration gap.
test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
This commit is contained in:
145
lib/loop/runtime-context.js
Normal file
145
lib/loop/runtime-context.js
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user