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:
2026-11-04 17:27:36 +08:00
parent f1b74a35ef
commit 5c6aaf53cf
6 changed files with 951 additions and 1 deletions

145
lib/loop/runtime-context.js Normal file
View 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 },
})
}
}