@@ -235,7 +235,225 @@ export function createRuntimeRouteMiddleware(
235235export const runtimeRouteMiddleware : BeforeModelMiddleware =
236236 createRuntimeRouteMiddleware ( ) ;
237237
238- // ── Middleware 3: Reflection (after_model) ─────────────────
238+ // ── Middleware 3: Compaction (before_model) ────────────────
239+ //
240+ // Uses P5 auto-compaction to summarize old conversation entries when
241+ // the context approaches the model's token budget. Keeps recent
242+ // entries as-is; replaces old entries with a context_summary.
243+ // Degrades gracefully: if compaction is not needed or fails, the
244+ // messages pass through unchanged.
245+
246+ import {
247+ shouldCompact ,
248+ estimateTokens ,
249+ extractWorkingState ,
250+ pickBoundary ,
251+ type TurnEntry ,
252+ } from "@cubecloud/platform-core" ;
253+
254+ /** Default token budget if model info is unavailable. */
255+ const DEFAULT_TOKEN_BUDGET = 128_000 ;
256+
257+ export function createCompactionMiddleware (
258+ summaryFn ?: ( entries : TurnEntry [ ] ) => Promise < string > ,
259+ ) : BeforeModelMiddleware {
260+ return async ( ctx ) => {
261+ const { messages } = ctx ;
262+ if ( messages . length < 6 ) {
263+ return { messages, applied : false , label : "compaction:skip(too-short)" } ;
264+ }
265+
266+ // Convert ChatMessage[] to TurnEntry[] for the compaction module
267+ const history : TurnEntry [ ] = messages
268+ . filter ( ( m ) => m . content && m . role !== "system" )
269+ . map ( ( m ) => ( {
270+ role : m . role as TurnEntry [ "role" ] ,
271+ content : m . content ! ,
272+ timestamp : Date . now ( ) ,
273+ } ) ) ;
274+
275+ // Estimate budget from model name (rough heuristic)
276+ const budget = ctx . model . includes ( "128k" ) ? 128_000
277+ : ctx . model . includes ( "200k" ) ? 200_000
278+ : ctx . model . includes ( "1m" ) || ctx . model . includes ( "1000k" ) ? 1_000_000
279+ : DEFAULT_TOKEN_BUDGET ;
280+
281+ if ( ! shouldCompact ( history , budget ) ) {
282+ return { messages, applied : false , label : "compaction:skip(below-threshold)" } ;
283+ }
284+
285+ try {
286+ const boundary = pickBoundary ( history , budget ) ;
287+ if ( boundary <= 0 ) {
288+ return { messages, applied : false , label : "compaction:skip(no-boundary)" } ;
289+ }
290+
291+ const workingState = extractWorkingState ( history ) ;
292+ const oldEntries = history . slice ( 0 , boundary ) ;
293+ const recentEntries = history . slice ( boundary ) ;
294+
295+ // Build summary — use provided summaryFn or mechanical extraction
296+ let summary : string ;
297+ if ( summaryFn ) {
298+ summary = await summaryFn ( oldEntries ) ;
299+ } else {
300+ // Mechanical summary (no LLM call) — just list working state
301+ const parts : string [ ] = [ "[Context Summary]" ] ;
302+ if ( workingState . pendingTodos . length > 0 ) {
303+ parts . push ( `Pending: ${ workingState . pendingTodos . map ( ( t ) => `[ ] ${ t } ` ) . join ( ", " ) } ` ) ;
304+ }
305+ if ( workingState . activeFiles . length > 0 ) {
306+ parts . push ( `Files: ${ workingState . activeFiles . join ( ", " ) } ` ) ;
307+ }
308+ parts . push ( `Compacted ${ oldEntries . length } entries.` ) ;
309+ summary = parts . join ( "\n" ) ;
310+ }
311+
312+ // Rebuild messages: system + summary + recent entries
313+ const systemMsgs = messages . filter ( ( m ) => m . role === "system" ) ;
314+ const compacted : ChatMessage [ ] = [
315+ ...systemMsgs ,
316+ { role : "user" , content : summary } ,
317+ ...recentEntries . map ( ( e ) => ( {
318+ role : e . role as ChatMessage [ "role" ] ,
319+ content : e . content ,
320+ } ) ) ,
321+ ] ;
322+
323+ const tokensBefore = history . reduce ( ( s , e ) => s + estimateTokens ( e . content ) , 0 ) ;
324+ const tokensAfter = compacted . reduce ( ( s , m ) => s + estimateTokens ( m . content ?? "" ) , 0 ) ;
325+
326+ return {
327+ messages : compacted ,
328+ applied : true ,
329+ label : "compaction:applied" ,
330+ stats : {
331+ tokensBefore,
332+ tokensAfter,
333+ entriesCompacted : oldEntries . length ,
334+ } ,
335+ } ;
336+ } catch {
337+ return { messages, applied : false , label : "compaction:error" } ;
338+ }
339+ } ;
340+ }
341+
342+ // ── Middleware 4: Memory inject (before_model) ─────────────
343+ //
344+ // Uses P4 memory service to recall relevant facts and inject them
345+ // into the system prompt. This gives the agent persistent memory
346+ // across conversations without modifying the gateway.
347+ // Degrades gracefully: if no memories or service unavailable, passes through.
348+
349+ export function createMemoryInjectMiddleware (
350+ recallFn ?: ( ) => Array < { content : string ; label : string } > ,
351+ ) : BeforeModelMiddleware {
352+ return async ( ctx ) => {
353+ if ( ! recallFn ) {
354+ return { messages : ctx . messages , applied : false , label : "memory:skip(no-recall-fn)" } ;
355+ }
356+
357+ try {
358+ const memories = recallFn ( ) ;
359+ if ( memories . length === 0 ) {
360+ return { messages : ctx . messages , applied : false , label : "memory:skip(empty)" } ;
361+ }
362+
363+ // Build a memory context block to prepend to the system message
364+ const memoryBlock = memories
365+ . map ( ( m ) => `- ${ m . content } ` )
366+ . join ( "\n" ) ;
367+ const memoryPrefix = `[Persistent Memory]\n${ memoryBlock } \n[/Persistent Memory]\n\n` ;
368+
369+ const messages = ctx . messages . map ( ( m ) => {
370+ if ( m . role === "system" && m . content ) {
371+ return { ...m , content : memoryPrefix + m . content } ;
372+ }
373+ return m ;
374+ } ) ;
375+
376+ // If no system message, prepend one
377+ if ( ! messages . some ( ( m ) => m . role === "system" ) ) {
378+ messages . unshift ( { role : "system" , content : memoryPrefix . trim ( ) } ) ;
379+ }
380+
381+ return {
382+ messages,
383+ applied : true ,
384+ label : "memory:injected" ,
385+ stats : { memoryCount : memories . length } ,
386+ } ;
387+ } catch {
388+ return { messages : ctx . messages , applied : false , label : "memory:error" } ;
389+ }
390+ } ;
391+ }
392+
393+ // ── Middleware 5: Tool policy screen (before_model) ────────
394+ //
395+ // Uses P3 tool policy to screen the user's message for commands that
396+ // require approval or should be denied. Does NOT block the message —
397+ // just annotates the context so the after_model chain or the approval
398+ // inbox can act on it. Degrades gracefully.
399+
400+ import {
401+ createCommandPolicy ,
402+ scannableCommand ,
403+ type CommandPolicyRule ,
404+ } from "@cubecloud/platform-core" ;
405+
406+ /** Default tool policy rules for the desktop. */
407+ const DEFAULT_TOOL_POLICY_RULES : CommandPolicyRule [ ] = [
408+ // Deny destructive shell commands
409+ { pattern : / \b r m \s + - r f \s + \/ / i, decision : "deny" , label : "deny:rm-rf-root" } ,
410+ { pattern : / \b m k f s \. / i, decision : "deny" , label : "deny:mkfs" } ,
411+ { pattern : / \b d d \s + i f = .* o f = \/ d e v \/ / i, decision : "deny" , label : "deny:dd-to-device" } ,
412+ // Require approval for package installs
413+ { pattern : / \b n p m \s + i n s t a l l \b | \b p i p \s + i n s t a l l \b | \b a p t \s + i n s t a l l \b / i, decision : "require_approval" , label : "approve:package-install" } ,
414+ // Require approval for git push
415+ { pattern : / \b g i t \s + p u s h \b / i, decision : "require_approval" , label : "approve:git-push" } ,
416+ // Require approval for network operations
417+ { pattern : / \b c u r l \s + .* \| \s * ( b a s h | s h ) \b / i, decision : "require_approval" , label : "approve:curl-pipe" } ,
418+ ] ;
419+
420+ export function createToolPolicyMiddleware (
421+ customRules ?: CommandPolicyRule [ ] ,
422+ ) : BeforeModelMiddleware {
423+ const policy = createCommandPolicy ( customRules ?? DEFAULT_TOOL_POLICY_RULES ) ;
424+
425+ return async ( ctx ) => {
426+ const lastUserMsg = [ ...ctx . messages ] . reverse ( ) . find ( ( m ) => m . role === "user" && m . content ) ;
427+ if ( ! lastUserMsg ?. content ) {
428+ return { messages : ctx . messages , applied : false , label : "tool-policy:skip(no-user-msg)" } ;
429+ }
430+
431+ try {
432+ // Scan the user message (including embedded commands) for policy violations
433+ const scannable = scannableCommand ( lastUserMsg . content ) ;
434+ const result = policy . evaluate ( scannable ) ;
435+
436+ if ( result . decision === "allow" ) {
437+ return { messages : ctx . messages , applied : false , label : "tool-policy:allow" } ;
438+ }
439+
440+ // Annotate the context — don't block, just report
441+ return {
442+ messages : ctx . messages ,
443+ applied : true ,
444+ label : `tool-policy:${ result . decision } :${ result . label } ` ,
445+ stats : {
446+ decision : result . decision ,
447+ rule : result . label ,
448+ } ,
449+ } ;
450+ } catch {
451+ return { messages : ctx . messages , applied : false , label : "tool-policy:error" } ;
452+ }
453+ } ;
454+ }
455+
456+ // ── Middleware 6: Reflection (after_model) ─────────────────
239457//
240458// Runs a second LLM pass that critiques the response for accuracy,
241459// completeness, and coherence. Opt-in via the reflectionEnabled
@@ -374,12 +592,28 @@ export async function runAfterModelChain(
374592
375593/** Build the default before_model chain for the desktop.
376594 * When a harness registry is provided, the runtimeRoute middleware
377- * resolves the active provider and annotates the context. */
595+ * resolves the active provider and annotates the context.
596+ *
597+ * Chain order (each step degrades gracefully):
598+ * 1. tool-policy — screen user message for dangerous/approval-needing commands
599+ * 2. memory-inject — inject persistent memories into system prompt
600+ * 3. headroom — compress context via Headroom sidecar
601+ * 4. compaction — summarize old entries when near token budget
602+ * 5. runtime-route — resolve active runtime/provider
603+ */
378604export function createBeforeModelChain (
379605 registry ?: HarnessRegistry ,
606+ options ?: {
607+ memoryRecallFn ?: ( ) => Array < { content : string ; label : string } > ;
608+ compactionSummaryFn ?: ( entries : TurnEntry [ ] ) => Promise < string > ;
609+ toolPolicyRules ?: CommandPolicyRule [ ] ;
610+ } ,
380611) : BeforeModelMiddleware [ ] {
381612 return [
613+ createToolPolicyMiddleware ( options ?. toolPolicyRules ) ,
614+ createMemoryInjectMiddleware ( options ?. memoryRecallFn ) ,
382615 headroomCompressMiddleware ,
616+ createCompactionMiddleware ( options ?. compactionSummaryFn ) ,
383617 createRuntimeRouteMiddleware ( registry ) ,
384618 ] ;
385619}
0 commit comments