diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index a54f90b2b..aeffbf840 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -27,12 +27,58 @@ import { handleUsageCommand, } from "./commands-session.js"; import { handlePluginCommand } from "./commands-plugin.js"; +import { updateSessionStoreEntry } from "../../config/sessions/store.js"; +import type { RecentCommandEntry } from "../../config/sessions/types.js"; +import type { ReplyPayload } from "../types.js"; import type { CommandHandler, CommandHandlerResult, HandleCommandsParams, } from "./commands-types.js"; +const MAX_RECENT_COMMANDS = 5; + +/** + * Summarize a command reply for agent context injection. + * Truncates to first 200 chars or first 3 lines. + */ +function summarizeCommandReply(reply: ReplyPayload): string { + const text = reply.text || ""; + const lines = text.split("\n").slice(0, 3); + const summary = lines.join(" | ").slice(0, 200); + return summary + (text.length > 200 ? "..." : ""); +} + +/** + * Store a recent command output in the session entry for agent visibility. + */ +async function storeRecentCommand(params: { + storePath?: string; + sessionKey: string; + cmd: string; + summary: string; +}): Promise { + if (!params.storePath) return; + + const entry: RecentCommandEntry = { + cmd: params.cmd, + summary: params.summary, + ts: Date.now(), + }; + + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async (existing) => { + const current = existing.recentCommands ?? []; + const updated = [...current, entry].slice(-MAX_RECENT_COMMANDS); + return { recentCommands: updated }; + }, + }).catch((err) => { + logVerbose(`Failed to store recent command: ${err}`); + }); +} + const HANDLERS: CommandHandler[] = [ // Plugin commands are processed first, before built-in commands handlePluginCommand, @@ -110,7 +156,22 @@ export async function handleCommands(params: HandleCommandsParams): Promise r.text).join(" | ") : reply.text || ""; + const lines = text.split("\n").slice(0, 3); + const summary = lines.join(" | ").slice(0, 200); + return summary + (text.length > 200 ? "..." : ""); +} + +/** + * Store a recent command output in the session entry for agent visibility. + */ +async function captureCommandForContext(params: { + cfg: MoltbotConfig; + storePath?: string; + sessionKey: string; + cmd: string; + reply: ReplyPayload | ReplyPayload[] | undefined; +}): Promise { + if (!params.storePath) return; + if (params.cfg.commands?.injectToContext === false) return; + + const entry: RecentCommandEntry = { + cmd: params.cmd, + summary: summarizeReplyForContext(params.reply), + ts: Date.now(), + }; + + await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async (existing) => { + const current = existing.recentCommands ?? []; + const updated = [...current, entry].slice(-MAX_RECENT_COMMANDS); + return { recentCommands: updated }; + }, + }).catch((err) => { + logVerbose(`Failed to capture command for context: ${err}`); + }); +} + export async function resolveReplyDirectives(params: { ctx: MsgContext; cfg: MoltbotConfig; @@ -439,6 +487,18 @@ export async function resolveReplyDirectives(params: { typing, }); if (applyResult.kind === "reply") { + // Capture the command output for agent context (e.g., /model, /models responses) + // Must await to ensure capture completes before the reply is sent + const cmdMatch = command.commandBodyNormalized.match(/^\/(\w+)/); + if (cmdMatch && applyResult.reply) { + await captureCommandForContext({ + cfg, + storePath, + sessionKey, + cmd: cmdMatch[0], + reply: applyResult.reply, + }); + } return { kind: "reply", reply: applyResult.reply }; } directives = applyResult.directives; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index ba96023ce..b4f3eaaa1 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -14,6 +14,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import type { RecentCommandEntry } from "../../config/sessions/types.js"; import { logVerbose } from "../../globals.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; import { normalizeMainKey } from "../../routing/session-key.js"; @@ -50,6 +51,23 @@ type ExecOverrides = Pick { + if (store[sessionKey]) { + store[sessionKey] = { ...store[sessionKey], recentCommands: [] }; + } + return store; + }).catch(() => {}); + } + const extraSystemPrompt = [groupIntro, groupSystemPrompt, recentCommandsContext] + .filter(Boolean) + .join("\n\n"); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; // Use CommandBody/RawBody for bare reset detection (clean message without structural context). const rawBodyTrimmed = (ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim(); diff --git a/src/config/schema.ts b/src/config/schema.ts index 28c994f3d..704800efb 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -277,6 +277,7 @@ const FIELD_LABELS: Record = { "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", "commands.useAccessGroups": "Use Access Groups", + "commands.injectToContext": "Inject Commands to Context", "ui.seamColor": "Accent Color", "ui.assistant.name": "Assistant Name", "ui.assistant.avatar": "Assistant Avatar", @@ -593,6 +594,8 @@ const FIELD_HELP: Record = { "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.injectToContext": + "Inject native command outputs (like /models, /model) into agent context so the agent sees what commands the user ran (default: true).", "session.dmScope": 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..7b4e6c6b9 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -23,6 +23,18 @@ export type SessionOrigin = { threadId?: string | number; }; +/** + * Entry for recent native command outputs (for agent context injection). + */ +export type RecentCommandEntry = { + /** The command that was executed (e.g., "/models") */ + cmd: string; + /** Summarized output of the command */ + summary: string; + /** Timestamp (ms) when the command was executed */ + ts: number; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -94,6 +106,11 @@ export type SessionEntry = { lastThreadId?: string | number; skillsSnapshot?: SessionSkillSnapshot; systemPromptReport?: SessionSystemPromptReport; + /** + * Recent native command outputs for agent context injection. + * Cleared after being injected into the agent's context. + */ + recentCommands?: RecentCommandEntry[]; }; export function mergeSessionEntry( diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 37ef4e942..25721f0c3 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -107,6 +107,8 @@ export type CommandsConfig = { restart?: boolean; /** Enforce access-group allowlists/policies for commands (default: true). */ useAccessGroups?: boolean; + /** Inject native command outputs into agent context (default: true). */ + injectToContext?: boolean; }; export type ProviderCommandsConfig = { diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 4412f5515..dbc3bed60 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -112,7 +112,9 @@ export const CommandsSchema = z debug: z.boolean().optional(), restart: z.boolean().optional(), useAccessGroups: z.boolean().optional(), + /** Inject native command outputs into agent context. Default: true */ + injectToContext: z.boolean().optional().default(true), }) .strict() .optional() - .default({ native: "auto", nativeSkills: "auto" }); + .default({ native: "auto", nativeSkills: "auto", injectToContext: true });