2025-11-25 02:16:54 +01:00
import crypto from "node:crypto" ;
2025-12-09 02:25:37 +01:00
2025-12-05 22:33:09 +01:00
import { lookupContextTokens } from "../agents/context.js" ;
2025-12-14 04:21:27 +00:00
import {
DEFAULT_CONTEXT_TOKENS ,
DEFAULT_MODEL ,
DEFAULT_PROVIDER ,
} from "../agents/defaults.js" ;
2025-12-23 23:45:20 +00:00
import { loadModelCatalog } from "../agents/model-catalog.js" ;
import {
buildAllowedModelSet ,
2025-12-26 23:26:14 +00:00
buildModelAliasIndex ,
2025-12-23 23:45:20 +00:00
modelKey ,
2025-12-26 00:16:29 +01:00
resolveConfiguredModelRef ,
2025-12-26 23:54:30 +00:00
resolveModelRefFromString ,
2025-12-23 23:45:20 +00:00
} from "../agents/model-selection.js" ;
2025-12-20 16:10:46 +01:00
import {
2025-12-26 13:35:44 +01:00
abortEmbeddedPiRun ,
2025-12-20 16:10:46 +01:00
queueEmbeddedPiMessage ,
2025-12-26 13:35:44 +01:00
resolveEmbeddedSessionLane ,
2025-12-20 16:10:46 +01:00
runEmbeddedPiAgent ,
} from "../agents/pi-embedded.js" ;
2025-12-20 12:29:45 +01:00
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js" ;
2025-12-14 03:14:51 +00:00
import {
DEFAULT_AGENT_WORKSPACE_DIR ,
ensureAgentWorkspace ,
} from "../agents/workspace.js" ;
2025-12-09 23:16:57 +01:00
import { type ClawdisConfig , loadConfig } from "../config/config.js" ;
2025-11-25 02:16:54 +01:00
import {
2025-11-26 00:53:53 +01:00
DEFAULT_IDLE_MINUTES ,
2025-12-22 20:36:29 +01:00
DEFAULT_RESET_TRIGGERS ,
2025-11-26 00:53:53 +01:00
loadSessionStore ,
2025-12-06 23:16:23 +01:00
resolveSessionKey ,
2025-12-17 11:29:04 +01:00
resolveSessionTranscriptPath ,
2025-11-26 00:53:53 +01:00
resolveStorePath ,
2025-12-02 20:09:51 +00:00
type SessionEntry ,
2025-11-26 00:53:53 +01:00
saveSessionStore ,
2025-11-25 02:16:54 +01:00
} from "../config/sessions.js" ;
2025-12-17 11:29:04 +01:00
import { logVerbose } from "../globals.js" ;
2025-12-09 02:25:37 +01:00
import { buildProviderSummary } from "../infra/provider-summary.js" ;
2025-12-09 18:00:01 +00:00
import { triggerClawdisRestart } from "../infra/restart.js" ;
2025-12-27 01:17:03 +00:00
import { enqueueSystemEvent } from "../infra/system-events.js" ;
2025-12-09 02:25:37 +01:00
import { drainSystemEvents } from "../infra/system-events.js" ;
2025-12-26 13:35:44 +01:00
import { clearCommandLane , getQueueSize } from "../process/command-queue.js" ;
2025-12-05 19:03:59 +00:00
import { defaultRuntime } from "../runtime.js" ;
2025-12-22 20:36:29 +01:00
import { normalizeE164 } from "../utils.js" ;
2025-12-07 18:49:55 +01:00
import { resolveHeartbeatSeconds } from "../web/reconnect.js" ;
import { getWebAuthAgeMs , webAuthExists } from "../web/session.js" ;
2025-12-22 20:36:29 +01:00
import {
normalizeGroupActivation ,
2025-12-22 20:45:22 +00:00
parseActivationCommand ,
2025-12-22 20:36:29 +01:00
} from "./group-activation.js" ;
2025-12-24 00:33:35 +00:00
import { extractModelDirective } from "./model.js" ;
2025-12-07 18:49:55 +01:00
import { buildStatusMessage } from "./status.js" ;
2025-12-17 11:29:04 +01:00
import type { MsgContext , TemplateContext } from "./templating.js" ;
2025-12-04 17:53:37 +00:00
import {
normalizeThinkLevel ,
normalizeVerboseLevel ,
type ThinkLevel ,
type VerboseLevel ,
} from "./thinking.js" ;
2025-12-22 20:45:22 +00:00
import { SILENT_REPLY_TOKEN } from "./tokens.js" ;
2025-12-04 18:02:51 +00:00
import { isAudio , transcribeInboundAudio } from "./transcription.js" ;
import type { GetReplyOptions , ReplyPayload } from "./types.js" ;
2025-11-26 02:34:43 +01:00
export type { GetReplyOptions , ReplyPayload } from "./types.js" ;
2025-11-25 04:58:31 +01:00
2025-12-02 20:09:51 +00:00
const ABORT_TRIGGERS = new Set ( [ "stop" , "esc" , "abort" , "wait" , "exit" ] ) ;
const ABORT_MEMORY = new Map < string , boolean > ( ) ;
2025-12-05 23:43:14 +00:00
const SYSTEM_MARK = "⚙️" ;
2025-12-02 20:09:51 +00:00
2025-12-20 13:04:55 +00:00
const BARE_SESSION_RESET_PROMPT =
2025-12-23 23:45:20 +00:00
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning." ;
2025-12-20 13:04:55 +00:00
2025-12-26 14:29:28 +01:00
type QueueMode = "queue" | "interrupt" ;
2025-12-26 13:35:44 +01:00
2025-12-05 22:28:36 +00:00
export function extractThinkDirective ( body? : string ) : {
2025-12-03 09:09:34 +00:00
cleaned : string ;
thinkLevel? : ThinkLevel ;
rawLevel? : string ;
hasDirective : boolean ;
2025-12-03 00:45:27 +00:00
} {
2025-12-03 09:09:34 +00:00
if ( ! body ) return { cleaned : "" , hasDirective : false } ;
2025-12-06 00:49:46 +01:00
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
2025-12-05 22:28:36 +00:00
const match = body . match (
/(?:^|\s)\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i ,
) ;
2025-12-03 09:09:34 +00:00
const thinkLevel = normalizeThinkLevel ( match ? . [ 1 ] ) ;
const cleaned = match
? body . replace ( match [ 0 ] , "" ) . replace ( /\s+/g , " " ) . trim ( )
: body . trim ( ) ;
return {
cleaned ,
thinkLevel ,
rawLevel : match?. [ 1 ] ,
hasDirective : ! ! match ,
} ;
2025-12-03 00:45:27 +00:00
}
2025-12-05 22:28:36 +00:00
export function extractVerboseDirective ( body? : string ) : {
2025-12-03 09:09:34 +00:00
cleaned : string ;
verboseLevel? : VerboseLevel ;
rawLevel? : string ;
hasDirective : boolean ;
2025-12-03 09:04:37 +00:00
} {
2025-12-03 09:09:34 +00:00
if ( ! body ) return { cleaned : "" , hasDirective : false } ;
2025-12-06 00:49:46 +01:00
const match = body . match (
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i ,
) ;
2025-12-03 09:09:34 +00:00
const verboseLevel = normalizeVerboseLevel ( match ? . [ 1 ] ) ;
const cleaned = match
? body . replace ( match [ 0 ] , "" ) . replace ( /\s+/g , " " ) . trim ( )
: body . trim ( ) ;
return {
cleaned ,
verboseLevel ,
rawLevel : match?. [ 1 ] ,
hasDirective : ! ! match ,
} ;
2025-12-03 09:04:37 +00:00
}
2025-12-26 13:35:44 +01:00
function normalizeQueueMode ( raw? : string ) : QueueMode | undefined {
if ( ! raw ) return undefined ;
const cleaned = raw . trim ( ) . toLowerCase ( ) ;
if ( cleaned === "queue" || cleaned === "queued" ) return "queue" ;
2025-12-26 14:38:37 +01:00
if (
cleaned === "interrupt" ||
cleaned === "interrupts" ||
cleaned === "abort"
)
2025-12-26 13:35:44 +01:00
return "interrupt" ;
return undefined ;
}
export function extractQueueDirective ( body? : string ) : {
cleaned : string ;
queueMode? : QueueMode ;
2025-12-26 14:24:53 +01:00
queueReset : boolean ;
2025-12-26 13:35:44 +01:00
rawMode? : string ;
hasDirective : boolean ;
} {
2025-12-26 14:24:53 +01:00
if ( ! body ) return { cleaned : "" , hasDirective : false , queueReset : false } ;
2025-12-26 13:35:44 +01:00
const match = body . match ( /(?:^|\s)\/queue(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i ) ;
2025-12-26 14:24:53 +01:00
const rawMode = match ? . [ 1 ] ;
const lowered = rawMode ? . trim ( ) . toLowerCase ( ) ;
2025-12-26 14:38:37 +01:00
const queueReset =
lowered === "default" || lowered === "reset" || lowered === "clear" ;
2025-12-26 14:24:53 +01:00
const queueMode = queueReset ? undefined : normalizeQueueMode ( rawMode ) ;
2025-12-26 13:35:44 +01:00
const cleaned = match
? body . replace ( match [ 0 ] , "" ) . replace ( /\s+/g , " " ) . trim ( )
: body . trim ( ) ;
return {
cleaned ,
queueMode ,
2025-12-26 14:24:53 +01:00
queueReset ,
rawMode ,
2025-12-26 13:35:44 +01:00
hasDirective : ! ! match ,
} ;
}
2025-12-02 20:09:51 +00:00
function isAbortTrigger ( text? : string ) : boolean {
if ( ! text ) return false ;
const normalized = text . trim ( ) . toLowerCase ( ) ;
return ABORT_TRIGGERS . has ( normalized ) ;
}
2025-12-03 14:32:58 +00:00
function stripStructuralPrefixes ( text : string ) : string {
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.
const marker = "[Current message - respond to this]" ;
const afterMarker = text . includes ( marker )
? text . slice ( text . indexOf ( marker ) + marker . length )
: text ;
return afterMarker
. replace ( /\[[^\]]+\]\s*/g , "" )
. replace ( /^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm , "" )
. replace ( /\s+/g , " " )
. trim ( ) ;
}
2025-12-04 02:29:32 +00:00
function stripMentions (
text : string ,
ctx : MsgContext ,
2025-12-09 18:00:01 +00:00
cfg : ClawdisConfig | undefined ,
2025-12-04 02:29:32 +00:00
) : string {
let result = text ;
2025-12-24 00:22:52 +00:00
const patterns = cfg ? . routing ? . groupChat ? . mentionPatterns ? ? [ ] ;
2025-12-04 02:29:32 +00:00
for ( const p of patterns ) {
try {
const re = new RegExp ( p , "gi" ) ;
result = result . replace ( re , " " ) ;
} catch {
// ignore invalid regex
}
}
const selfE164 = ( ctx . To ? ? "" ) . replace ( /^whatsapp:/ , "" ) ;
if ( selfE164 ) {
const esc = selfE164 . replace ( /[.*+?^${}()|[\]\\]/g , "\\$&" ) ;
result = result
. replace ( new RegExp ( esc , "gi" ) , " " )
. replace ( new RegExp ( ` @ ${ esc } ` , "gi" ) , " " ) ;
}
// Generic mention patterns like @123456789 or plain digits
result = result . replace ( /@[0-9+]{5,}/g , " " ) ;
2025-12-26 13:35:44 +01:00
// Discord-style mentions (<@123> or <@!123>)
result = result . replace ( /<@!?\d+>/g , " " ) ;
2025-12-04 02:29:32 +00:00
return result . replace ( /\s+/g , " " ) . trim ( ) ;
}
2025-12-26 13:35:44 +01:00
function defaultQueueModeForSurface ( surface? : string ) : QueueMode {
const normalized = surface ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "discord" ) return "queue" ;
if ( normalized === "webchat" ) return "queue" ;
return "interrupt" ;
}
function resolveQueueMode ( params : {
cfg : ClawdisConfig ;
surface? : string ;
sessionEntry? : SessionEntry ;
inlineMode? : QueueMode ;
} ) : QueueMode {
const surfaceKey = params . surface ? . trim ( ) . toLowerCase ( ) ;
const queueCfg = params . cfg . routing ? . queue ;
const surfaceMode =
surfaceKey && queueCfg ? . bySurface
? ( queueCfg . bySurface as Record < string , QueueMode | undefined > ) [
surfaceKey
]
: undefined ;
return (
params . inlineMode ? ?
params . sessionEntry ? . queueMode ? ?
surfaceMode ? ?
queueCfg ? . mode ? ?
defaultQueueModeForSurface ( surfaceKey )
) ;
}
2025-11-25 02:16:54 +01:00
export async function getReplyFromConfig (
2025-11-26 00:53:53 +01:00
ctx : MsgContext ,
opts? : GetReplyOptions ,
2025-12-09 18:00:01 +00:00
configOverride? : ClawdisConfig ,
2025-12-03 00:35:57 +00:00
) : Promise < ReplyPayload | ReplyPayload [ ] | undefined > {
2025-11-26 00:53:53 +01:00
const cfg = configOverride ? ? loadConfig ( ) ;
2025-12-23 23:45:20 +00:00
const workspaceDirRaw = cfg . agent ? . workspace ? ? DEFAULT_AGENT_WORKSPACE_DIR ;
const agentCfg = cfg . agent ;
2025-12-24 00:22:52 +00:00
const sessionCfg = cfg . session ;
2025-12-17 11:29:04 +01:00
2025-12-26 01:13:13 +01:00
const mainModel = resolveConfiguredModelRef ( {
cfg ,
defaultProvider : DEFAULT_PROVIDER ,
defaultModel : DEFAULT_MODEL ,
} ) ;
const defaultProvider = mainModel . provider ;
const defaultModel = mainModel . model ;
2025-12-26 23:26:14 +00:00
const aliasIndex = buildModelAliasIndex ( { cfg , defaultProvider } ) ;
2025-12-23 23:45:20 +00:00
let provider = defaultProvider ;
let model = defaultModel ;
2025-12-26 01:13:13 +01:00
if ( opts ? . isHeartbeat ) {
const heartbeatRaw = agentCfg ? . heartbeat ? . model ? . trim ( ) ? ? "" ;
const heartbeatRef = heartbeatRaw
2025-12-26 23:26:14 +00:00
? resolveModelRefFromString ( {
raw : heartbeatRaw ,
defaultProvider ,
aliasIndex ,
} )
2025-12-26 01:13:13 +01:00
: null ;
if ( heartbeatRef ) {
2025-12-26 23:26:14 +00:00
provider = heartbeatRef . ref . provider ;
model = heartbeatRef . ref . model ;
2025-12-26 01:13:13 +01:00
}
}
2025-12-23 23:45:20 +00:00
let contextTokens =
2025-12-17 11:29:04 +01:00
agentCfg ? . contextTokens ? ?
lookupContextTokens ( model ) ? ?
DEFAULT_CONTEXT_TOKENS ;
// Bootstrap the workspace and the required files (AGENTS.md, SOUL.md, TOOLS.md).
const workspace = await ensureAgentWorkspace ( {
dir : workspaceDirRaw ,
ensureBootstrapFiles : true ,
} ) ;
const workspaceDir = workspace . dir ;
const timeoutSeconds = Math . max ( agentCfg ? . timeoutSeconds ? ? 600 , 1 ) ;
2025-11-26 00:53:53 +01:00
const timeoutMs = timeoutSeconds * 1000 ;
let started = false ;
const triggerTyping = async ( ) = > {
await opts ? . onReplyStart ? . ( ) ;
} ;
const onReplyStart = async ( ) = > {
if ( started ) return ;
started = true ;
await triggerTyping ( ) ;
} ;
let typingTimer : NodeJS.Timeout | undefined ;
2025-12-23 15:03:05 +00:00
const configuredTypingSeconds =
agentCfg ? . typingIntervalSeconds ? ? sessionCfg ? . typingIntervalSeconds ;
const typingIntervalSeconds =
2025-12-24 00:33:35 +00:00
typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6 ;
2025-12-23 15:03:05 +00:00
const typingIntervalMs = typingIntervalSeconds * 1000 ;
2025-11-26 00:53:53 +01:00
const cleanupTyping = ( ) = > {
if ( typingTimer ) {
clearInterval ( typingTimer ) ;
typingTimer = undefined ;
}
} ;
const startTypingLoop = async ( ) = > {
if ( ! opts ? . onReplyStart ) return ;
if ( typingIntervalMs <= 0 ) return ;
if ( typingTimer ) return ;
2025-12-23 13:55:01 +00:00
await onReplyStart ( ) ;
2025-11-26 00:53:53 +01:00
typingTimer = setInterval ( ( ) = > {
void triggerTyping ( ) ;
} , typingIntervalMs ) ;
} ;
2025-12-23 13:55:01 +00:00
const startTypingOnText = async ( text? : string ) = > {
const trimmed = text ? . trim ( ) ;
if ( ! trimmed ) return ;
if ( trimmed === SILENT_REPLY_TOKEN ) return ;
await startTypingLoop ( ) ;
} ;
2025-11-26 00:53:53 +01:00
let transcribedText : string | undefined ;
// Optional audio transcription before templating/session handling.
2025-12-24 00:22:52 +00:00
if ( cfg . routing ? . transcribeAudio && isAudio ( ctx . MediaType ) ) {
2025-11-26 00:53:53 +01:00
const transcribed = await transcribeInboundAudio ( cfg , ctx , defaultRuntime ) ;
if ( transcribed ? . text ) {
transcribedText = transcribed . text ;
ctx . Body = transcribed . text ;
ctx . Transcript = transcribed . text ;
logVerbose ( "Replaced Body with audio transcript for reply flow" ) ;
}
}
// Optional session handling (conversation reuse + /new resets)
2025-12-06 23:16:23 +01:00
const mainKey = sessionCfg ? . mainKey ? ? "main" ;
2025-11-26 00:53:53 +01:00
const resetTriggers = sessionCfg ? . resetTriggers ? . length
? sessionCfg . resetTriggers
2025-12-22 20:36:29 +01:00
: DEFAULT_RESET_TRIGGERS ;
2025-11-26 00:53:53 +01:00
const idleMinutes = Math . max (
sessionCfg ? . idleMinutes ? ? DEFAULT_IDLE_MINUTES ,
1 ,
) ;
const sessionScope = sessionCfg ? . scope ? ? "per-sender" ;
2025-12-06 00:49:46 +01:00
const storePath = resolveStorePath ( sessionCfg ? . store ) ;
2025-11-26 00:53:53 +01:00
let sessionStore : ReturnType < typeof loadSessionStore > | undefined ;
let sessionKey : string | undefined ;
2025-12-02 20:09:51 +00:00
let sessionEntry : SessionEntry | undefined ;
2025-11-26 00:53:53 +01:00
let sessionId : string | undefined ;
let isNewSession = false ;
let bodyStripped : string | undefined ;
let systemSent = false ;
2025-12-02 20:09:51 +00:00
let abortedLastRun = false ;
2025-11-26 00:53:53 +01:00
2025-12-03 08:45:23 +00:00
let persistedThinking : string | undefined ;
2025-12-03 09:04:37 +00:00
let persistedVerbose : string | undefined ;
2025-12-23 23:45:20 +00:00
let persistedModelOverride : string | undefined ;
let persistedProviderOverride : string | undefined ;
2025-12-03 08:45:23 +00:00
2025-12-23 13:20:11 +00:00
const isGroup =
typeof ctx . From === "string" &&
( ctx . From . includes ( "@g.us" ) || ctx . From . startsWith ( "group:" ) ) ;
2025-12-06 00:49:46 +01:00
const triggerBodyNormalized = stripStructuralPrefixes ( ctx . Body ? ? "" )
2025-12-05 21:29:41 +00:00
. trim ( )
. toLowerCase ( ) ;
2025-12-17 11:29:04 +01:00
const rawBody = ctx . Body ? ? "" ;
const trimmedBody = rawBody . trim ( ) ;
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
2025-12-23 13:20:11 +00:00
const strippedForReset = isGroup
? stripMentions ( triggerBodyNormalized , ctx , cfg )
: triggerBodyNormalized ;
2025-12-17 11:29:04 +01:00
for ( const trigger of resetTriggers ) {
if ( ! trigger ) continue ;
if ( trimmedBody === trigger || strippedForReset === trigger ) {
isNewSession = true ;
bodyStripped = "" ;
break ;
2025-11-26 00:53:53 +01:00
}
2025-12-17 11:29:04 +01:00
const triggerPrefix = ` ${ trigger } ` ;
if (
trimmedBody . startsWith ( triggerPrefix ) ||
strippedForReset . startsWith ( triggerPrefix )
) {
2025-11-26 00:53:53 +01:00
isNewSession = true ;
2025-12-17 11:29:04 +01:00
bodyStripped = strippedForReset . slice ( trigger . length ) . trimStart ( ) ;
break ;
2025-11-26 00:53:53 +01:00
}
2025-12-17 11:29:04 +01:00
}
2025-11-26 00:53:53 +01:00
2025-12-17 11:29:04 +01:00
sessionKey = resolveSessionKey ( sessionScope , ctx , mainKey ) ;
sessionStore = loadSessionStore ( storePath ) ;
const entry = sessionStore [ sessionKey ] ;
const idleMs = idleMinutes * 60 _000 ;
const freshEntry = entry && Date . now ( ) - entry . updatedAt <= idleMs ;
if ( ! isNewSession && freshEntry ) {
sessionId = entry . sessionId ;
systemSent = entry . systemSent ? ? false ;
abortedLastRun = entry . abortedLastRun ? ? false ;
persistedThinking = entry . thinkingLevel ;
persistedVerbose = entry . verboseLevel ;
2025-12-23 23:45:20 +00:00
persistedModelOverride = entry . modelOverride ;
persistedProviderOverride = entry . providerOverride ;
2025-12-17 11:29:04 +01:00
} else {
sessionId = crypto . randomUUID ( ) ;
isNewSession = true ;
systemSent = false ;
abortedLastRun = false ;
2025-11-26 00:53:53 +01:00
}
2025-12-17 11:29:04 +01:00
const baseEntry = ! isNewSession && freshEntry ? entry : undefined ;
sessionEntry = {
. . . baseEntry ,
sessionId ,
updatedAt : Date.now ( ) ,
systemSent ,
abortedLastRun ,
// Persist previously stored thinking/verbose levels when present.
thinkingLevel : persistedThinking ? ? baseEntry ? . thinkingLevel ,
verboseLevel : persistedVerbose ? ? baseEntry ? . verboseLevel ,
2025-12-23 23:45:20 +00:00
modelOverride : persistedModelOverride ? ? baseEntry ? . modelOverride ,
providerOverride : persistedProviderOverride ? ? baseEntry ? . providerOverride ,
2025-12-26 13:35:44 +01:00
queueMode : baseEntry?.queueMode ,
2025-12-17 11:29:04 +01:00
} ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
2025-11-26 00:53:53 +01:00
const sessionCtx : TemplateContext = {
. . . ctx ,
BodyStripped : bodyStripped ? ? ctx . Body ,
SessionId : sessionId ,
IsNewSession : isNewSession ? "true" : "false" ,
} ;
2025-12-03 09:09:34 +00:00
const {
2025-12-06 00:49:46 +01:00
cleaned : thinkCleaned ,
2025-12-03 09:09:34 +00:00
thinkLevel : inlineThink ,
rawLevel : rawThinkLevel ,
hasDirective : hasThinkDirective ,
2025-12-06 00:49:46 +01:00
} = extractThinkDirective ( sessionCtx . BodyStripped ? ? sessionCtx . Body ? ? "" ) ;
2025-12-03 09:09:34 +00:00
const {
2025-12-06 00:49:46 +01:00
cleaned : verboseCleaned ,
2025-12-03 09:09:34 +00:00
verboseLevel : inlineVerbose ,
rawLevel : rawVerboseLevel ,
hasDirective : hasVerboseDirective ,
2025-12-06 00:49:46 +01:00
} = extractVerboseDirective ( thinkCleaned ) ;
2025-12-23 23:45:20 +00:00
const {
cleaned : modelCleaned ,
rawModel : rawModelDirective ,
hasDirective : hasModelDirective ,
} = extractModelDirective ( verboseCleaned ) ;
2025-12-26 13:35:44 +01:00
const {
cleaned : queueCleaned ,
queueMode : inlineQueueMode ,
2025-12-26 14:24:53 +01:00
queueReset : inlineQueueReset ,
2025-12-26 13:35:44 +01:00
rawMode : rawQueueMode ,
hasDirective : hasQueueDirective ,
} = extractQueueDirective ( modelCleaned ) ;
sessionCtx . Body = queueCleaned ;
sessionCtx . BodyStripped = queueCleaned ;
2025-12-03 09:09:34 +00:00
2025-12-23 12:53:30 +00:00
const defaultGroupActivation = ( ) = > {
2025-12-24 00:22:52 +00:00
const requireMention = cfg . routing ? . groupChat ? . requireMention ;
2025-12-23 12:53:30 +00:00
return requireMention === false ? "always" : "mention" ;
} ;
2025-12-03 09:09:34 +00:00
let resolvedThinkLevel =
inlineThink ? ?
( sessionEntry ? . thinkingLevel as ThinkLevel | undefined ) ? ?
2025-12-17 11:29:04 +01:00
( agentCfg ? . thinkingDefault as ThinkLevel | undefined ) ;
2025-12-03 09:09:34 +00:00
const resolvedVerboseLevel =
inlineVerbose ? ?
( sessionEntry ? . verboseLevel as VerboseLevel | undefined ) ? ?
2025-12-17 11:29:04 +01:00
( agentCfg ? . verboseDefault as VerboseLevel | undefined ) ;
2025-12-20 13:52:04 +00:00
const shouldEmitToolResult = ( ) = > {
if ( ! sessionKey || ! storePath ) {
return resolvedVerboseLevel === "on" ;
}
try {
const store = loadSessionStore ( storePath ) ;
const entry = store [ sessionKey ] ;
const current = normalizeVerboseLevel ( entry ? . verboseLevel ) ;
if ( current ) return current === "on" ;
} catch {
// ignore store read failures
}
return resolvedVerboseLevel === "on" ;
} ;
2025-12-03 09:09:34 +00:00
2025-12-23 23:45:20 +00:00
const hasAllowlist = ( agentCfg ? . allowedModels ? . length ? ? 0 ) > 0 ;
const hasStoredOverride = Boolean (
sessionEntry ? . modelOverride || sessionEntry ? . providerOverride ,
) ;
2025-12-24 00:33:35 +00:00
const needsModelCatalog =
hasModelDirective || hasAllowlist || hasStoredOverride ;
2025-12-23 23:45:20 +00:00
let allowedModelKeys = new Set < string > ( ) ;
let allowedModelCatalog : Awaited < ReturnType < typeof loadModelCatalog > > = [ ] ;
let resetModelOverride = false ;
if ( needsModelCatalog ) {
const catalog = await loadModelCatalog ( { config : cfg } ) ;
const allowed = buildAllowedModelSet ( {
cfg ,
catalog ,
defaultProvider ,
} ) ;
allowedModelCatalog = allowed . allowedCatalog ;
allowedModelKeys = allowed . allowedKeys ;
}
if ( sessionEntry && sessionStore && sessionKey && hasStoredOverride ) {
const overrideProvider =
sessionEntry . providerOverride ? . trim ( ) || defaultProvider ;
const overrideModel = sessionEntry . modelOverride ? . trim ( ) ;
if ( overrideModel ) {
const key = modelKey ( overrideProvider , overrideModel ) ;
if ( allowedModelKeys . size > 0 && ! allowedModelKeys . has ( key ) ) {
delete sessionEntry . providerOverride ;
delete sessionEntry . modelOverride ;
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
resetModelOverride = true ;
}
}
}
const storedProviderOverride = sessionEntry ? . providerOverride ? . trim ( ) ;
const storedModelOverride = sessionEntry ? . modelOverride ? . trim ( ) ;
if ( storedModelOverride ) {
const candidateProvider = storedProviderOverride || defaultProvider ;
const key = modelKey ( candidateProvider , storedModelOverride ) ;
if ( allowedModelKeys . size === 0 || allowedModelKeys . has ( key ) ) {
provider = candidateProvider ;
model = storedModelOverride ;
}
}
contextTokens =
agentCfg ? . contextTokens ? ?
lookupContextTokens ( model ) ? ?
DEFAULT_CONTEXT_TOKENS ;
2025-12-04 02:29:32 +00:00
2025-12-27 01:17:03 +00:00
const initialModelLabel = ` ${ provider } / ${ model } ` ;
const formatModelSwitchEvent = ( label : string , alias? : string ) = >
alias ? ` Model switched to ${ alias } ( ${ label } ). ` : ` Model switched to ${ label } . ` ;
2025-12-03 09:09:34 +00:00
const directiveOnly = ( ( ) = > {
2025-12-26 13:35:44 +01:00
if (
! hasThinkDirective &&
! hasVerboseDirective &&
! hasModelDirective &&
! hasQueueDirective
)
2025-12-23 23:45:20 +00:00
return false ;
2025-12-26 13:35:44 +01:00
const stripped = stripStructuralPrefixes ( queueCleaned ? ? "" ) ;
2025-12-04 02:29:32 +00:00
const noMentions = isGroup ? stripMentions ( stripped , ctx , cfg ) : stripped ;
return noMentions . length === 0 ;
2025-12-03 09:09:34 +00:00
} ) ( ) ;
2025-12-23 23:45:20 +00:00
if ( directiveOnly ) {
if ( hasModelDirective && ! rawModelDirective ) {
if ( allowedModelCatalog . length === 0 ) {
cleanupTyping ( ) ;
return { text : "No models available." } ;
}
const current = ` ${ provider } / ${ model } ` ;
const defaultLabel = ` ${ defaultProvider } / ${ defaultModel } ` ;
const header =
current === defaultLabel
? ` Models (current: ${ current } ): `
: ` Models (current: ${ current } , default: ${ defaultLabel } ): ` ;
const lines = [ header ] ;
if ( resetModelOverride ) {
lines . push ( ` (previous selection reset to default) ` ) ;
}
for ( const entry of allowedModelCatalog ) {
const label = ` ${ entry . provider } / ${ entry . id } ` ;
2025-12-26 23:26:14 +00:00
const aliases = aliasIndex . byKey . get ( label ) ;
const aliasSuffix =
2025-12-26 23:54:30 +00:00
aliases && aliases . length > 0
? ` (alias: ${ aliases . join ( ", " ) } ) `
: "" ;
2025-12-24 00:33:35 +00:00
const suffix =
entry . name && entry . name !== entry . id ? ` — ${ entry . name } ` : "" ;
2025-12-26 23:26:14 +00:00
lines . push ( ` - ${ label } ${ aliasSuffix } ${ suffix } ` ) ;
2025-12-23 23:45:20 +00:00
}
cleanupTyping ( ) ;
return { text : lines.join ( "\n" ) } ;
}
if ( hasThinkDirective && ! inlineThink ) {
2025-12-03 09:09:34 +00:00
cleanupTyping ( ) ;
return {
2025-12-06 00:49:46 +01:00
text : ` Unrecognized thinking level " ${ rawThinkLevel ? ? "" } ". Valid levels: off, minimal, low, medium, high. ` ,
2025-12-03 09:09:34 +00:00
} ;
}
2025-12-23 23:45:20 +00:00
if ( hasVerboseDirective && ! inlineVerbose ) {
2025-12-03 09:09:34 +00:00
cleanupTyping ( ) ;
return {
2025-12-06 00:49:46 +01:00
text : ` Unrecognized verbose level " ${ rawVerboseLevel ? ? "" } ". Valid levels: off, on. ` ,
2025-12-03 09:09:34 +00:00
} ;
}
2025-12-26 14:24:53 +01:00
if ( hasQueueDirective && ! inlineQueueMode && ! inlineQueueReset ) {
2025-12-26 13:35:44 +01:00
cleanupTyping ( ) ;
return {
2025-12-26 14:29:28 +01:00
text : ` Unrecognized queue mode " ${ rawQueueMode ? ? "" } ". Valid modes: queue, interrupt. ` ,
2025-12-26 13:35:44 +01:00
} ;
}
2025-12-23 23:45:20 +00:00
let modelSelection :
2025-12-26 23:26:14 +00:00
| { provider : string ; model : string ; isDefault : boolean ; alias? : string }
2025-12-23 23:45:20 +00:00
| undefined ;
if ( hasModelDirective && rawModelDirective ) {
2025-12-26 23:26:14 +00:00
const resolved = resolveModelRefFromString ( {
raw : rawModelDirective ,
defaultProvider ,
aliasIndex ,
} ) ;
if ( ! resolved ) {
2025-12-23 23:45:20 +00:00
cleanupTyping ( ) ;
return {
text : ` Unrecognized model " ${ rawModelDirective } ". Use /model to list available models. ` ,
} ;
}
2025-12-26 23:26:14 +00:00
const key = modelKey ( resolved . ref . provider , resolved . ref . model ) ;
2025-12-23 23:45:20 +00:00
if ( allowedModelKeys . size > 0 && ! allowedModelKeys . has ( key ) ) {
cleanupTyping ( ) ;
return {
2025-12-26 23:26:14 +00:00
text : ` Model " ${ resolved . ref . provider } / ${ resolved . ref . model } " is not allowed. Use /model to list available models. ` ,
2025-12-23 23:45:20 +00:00
} ;
}
const isDefault =
2025-12-26 23:26:14 +00:00
resolved . ref . provider === defaultProvider &&
resolved . ref . model === defaultModel ;
modelSelection = {
provider : resolved.ref.provider ,
model : resolved.ref.model ,
isDefault ,
alias : resolved.alias ,
} ;
2025-12-27 01:17:03 +00:00
const nextLabel = ` ${ modelSelection . provider } / ${ modelSelection . model } ` ;
if ( nextLabel !== initialModelLabel ) {
enqueueSystemEvent ( formatModelSwitchEvent ( nextLabel , modelSelection . alias ) , {
contextKey : ` model: ${ nextLabel } ` ,
} ) ;
}
2025-12-23 23:45:20 +00:00
}
2025-12-03 09:09:34 +00:00
if ( sessionEntry && sessionStore && sessionKey ) {
2025-12-23 23:45:20 +00:00
if ( hasThinkDirective && inlineThink ) {
if ( inlineThink === "off" ) delete sessionEntry . thinkingLevel ;
else sessionEntry . thinkingLevel = inlineThink ;
}
if ( hasVerboseDirective && inlineVerbose ) {
if ( inlineVerbose === "off" ) delete sessionEntry . verboseLevel ;
else sessionEntry . verboseLevel = inlineVerbose ;
}
if ( modelSelection ) {
if ( modelSelection . isDefault ) {
delete sessionEntry . providerOverride ;
delete sessionEntry . modelOverride ;
} else {
sessionEntry . providerOverride = modelSelection . provider ;
sessionEntry . modelOverride = modelSelection . model ;
}
2025-12-03 09:09:34 +00:00
}
2025-12-26 14:24:53 +01:00
if ( hasQueueDirective && inlineQueueReset ) {
delete sessionEntry . queueMode ;
} else if ( hasQueueDirective && inlineQueueMode ) {
2025-12-26 13:35:44 +01:00
sessionEntry . queueMode = inlineQueueMode ;
}
2025-12-03 09:09:34 +00:00
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
2025-12-23 23:45:20 +00:00
const parts : string [ ] = [ ] ;
if ( hasThinkDirective && inlineThink ) {
parts . push (
inlineThink === "off"
? "Thinking disabled."
: ` Thinking level set to ${ inlineThink } . ` ,
) ;
}
if ( hasVerboseDirective && inlineVerbose ) {
parts . push (
inlineVerbose === "off"
? ` ${ SYSTEM_MARK } Verbose logging disabled. `
: ` ${ SYSTEM_MARK } Verbose logging enabled. ` ,
) ;
}
if ( modelSelection ) {
const label = ` ${ modelSelection . provider } / ${ modelSelection . model } ` ;
2025-12-26 23:26:14 +00:00
const labelWithAlias = modelSelection . alias
? ` ${ modelSelection . alias } ( ${ label } ) `
: label ;
2025-12-23 23:45:20 +00:00
parts . push (
modelSelection . isDefault
2025-12-26 23:26:14 +00:00
? ` Model reset to default ( ${ labelWithAlias } ). `
: ` Model set to ${ labelWithAlias } . ` ,
2025-12-23 23:45:20 +00:00
) ;
}
2025-12-26 13:35:44 +01:00
if ( hasQueueDirective && inlineQueueMode ) {
parts . push ( ` ${ SYSTEM_MARK } Queue mode set to ${ inlineQueueMode } . ` ) ;
2025-12-26 14:24:53 +01:00
} else if ( hasQueueDirective && inlineQueueReset ) {
parts . push ( ` ${ SYSTEM_MARK } Queue mode reset to default. ` ) ;
2025-12-26 13:35:44 +01:00
}
2025-12-23 23:45:20 +00:00
const ack = parts . join ( " " ) . trim ( ) ;
2025-12-03 09:09:34 +00:00
cleanupTyping ( ) ;
2025-12-23 23:45:20 +00:00
return { text : ack || "OK." } ;
2025-12-03 09:09:34 +00:00
}
2025-12-03 09:04:37 +00:00
2025-12-23 23:45:20 +00:00
// Persist inline think/verbose/model settings even when additional content follows.
2025-12-06 00:49:46 +01:00
if ( sessionEntry && sessionStore && sessionKey ) {
let updated = false ;
if ( hasThinkDirective && inlineThink ) {
if ( inlineThink === "off" ) {
delete sessionEntry . thinkingLevel ;
} else {
sessionEntry . thinkingLevel = inlineThink ;
2025-12-05 21:13:17 +00:00
}
2025-12-06 00:49:46 +01:00
updated = true ;
}
if ( hasVerboseDirective && inlineVerbose ) {
if ( inlineVerbose === "off" ) {
delete sessionEntry . verboseLevel ;
} else {
sessionEntry . verboseLevel = inlineVerbose ;
2025-12-05 21:13:17 +00:00
}
2025-12-06 00:49:46 +01:00
updated = true ;
}
2025-12-23 23:45:20 +00:00
if ( hasModelDirective && rawModelDirective ) {
2025-12-26 23:26:14 +00:00
const resolved = resolveModelRefFromString ( {
raw : rawModelDirective ,
defaultProvider ,
aliasIndex ,
} ) ;
if ( resolved ) {
const key = modelKey ( resolved . ref . provider , resolved . ref . model ) ;
2025-12-23 23:45:20 +00:00
if ( allowedModelKeys . size === 0 || allowedModelKeys . has ( key ) ) {
const isDefault =
2025-12-26 23:26:14 +00:00
resolved . ref . provider === defaultProvider &&
resolved . ref . model === defaultModel ;
2025-12-23 23:45:20 +00:00
if ( isDefault ) {
delete sessionEntry . providerOverride ;
delete sessionEntry . modelOverride ;
} else {
2025-12-26 23:26:14 +00:00
sessionEntry . providerOverride = resolved . ref . provider ;
sessionEntry . modelOverride = resolved . ref . model ;
2025-12-23 23:45:20 +00:00
}
2025-12-26 23:26:14 +00:00
provider = resolved . ref . provider ;
model = resolved . ref . model ;
2025-12-27 01:17:03 +00:00
const nextLabel = ` ${ provider } / ${ model } ` ;
if ( nextLabel !== initialModelLabel ) {
enqueueSystemEvent (
formatModelSwitchEvent ( nextLabel , resolved . alias ) ,
{ contextKey : ` model: ${ nextLabel } ` } ,
) ;
}
2025-12-23 23:45:20 +00:00
contextTokens =
agentCfg ? . contextTokens ? ?
lookupContextTokens ( model ) ? ?
DEFAULT_CONTEXT_TOKENS ;
updated = true ;
}
}
}
2025-12-26 14:24:53 +01:00
if ( hasQueueDirective && inlineQueueReset ) {
delete sessionEntry . queueMode ;
updated = true ;
}
2025-12-06 00:49:46 +01:00
if ( updated ) {
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
2025-12-05 21:13:17 +00:00
}
}
2025-12-26 14:24:53 +01:00
const perMessageQueueMode =
hasQueueDirective && ! inlineQueueReset ? inlineQueueMode : undefined ;
2025-12-05 21:13:17 +00:00
2025-11-26 00:53:53 +01:00
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
2025-12-24 00:22:52 +00:00
const configuredAllowFrom = cfg . routing ? . allowFrom ;
2025-11-29 04:50:56 +00:00
const from = ( ctx . From ? ? "" ) . replace ( /^whatsapp:/ , "" ) ;
const to = ( ctx . To ? ? "" ) . replace ( /^whatsapp:/ , "" ) ;
const isSamePhone = from && to && from === to ;
2025-12-12 00:50:40 +00:00
// If no config is present, default to self-only DM access.
const defaultAllowFrom =
( ! configuredAllowFrom || configuredAllowFrom . length === 0 ) && to
? [ to ]
: undefined ;
const allowFrom =
configuredAllowFrom && configuredAllowFrom . length > 0
? configuredAllowFrom
: defaultAllowFrom ;
2025-12-02 20:09:51 +00:00
const abortKey = sessionKey ? ? ( from || undefined ) ? ? ( to || undefined ) ;
2025-12-05 21:29:41 +00:00
const rawBodyNormalized = triggerBodyNormalized ;
2025-12-22 20:36:29 +01:00
const commandBodyNormalized = isGroup
? stripMentions ( rawBodyNormalized , ctx , cfg )
: rawBodyNormalized ;
const activationCommand = parseActivationCommand ( commandBodyNormalized ) ;
const senderE164 = normalizeE164 ( ctx . SenderE164 ? ? "" ) ;
const ownerCandidates = ( allowFrom ? ? [ ] ) . filter (
( entry ) = > entry && entry !== "*" ,
) ;
if ( ownerCandidates . length === 0 && to ) ownerCandidates . push ( to ) ;
const ownerList = ownerCandidates
. map ( ( entry ) = > normalizeE164 ( entry ) )
. filter ( ( entry ) : entry is string = > Boolean ( entry ) ) ;
const isOwnerSender =
Boolean ( senderE164 ) && ownerList . includes ( senderE164 ? ? "" ) ;
2025-12-02 20:09:51 +00:00
if ( ! sessionEntry && abortKey ) {
abortedLastRun = ABORT_MEMORY . get ( abortKey ) ? ? false ;
}
2025-11-29 04:50:56 +00:00
// Same-phone mode (self-messaging) is always allowed
if ( isSamePhone ) {
logVerbose ( ` Allowing same-phone mode: from === to ( ${ from } ) ` ) ;
2025-12-03 13:08:54 +00:00
} else if ( ! isGroup && Array . isArray ( allowFrom ) && allowFrom . length > 0 ) {
2025-11-26 12:43:48 -03:00
// Support "*" as wildcard to allow all senders
if ( ! allowFrom . includes ( "*" ) && ! allowFrom . includes ( from ) ) {
2025-11-26 00:53:53 +01:00
logVerbose (
` Skipping auto-reply: sender ${ from || "<unknown>" } not in allowFrom list ` ,
) ;
cleanupTyping ( ) ;
return undefined ;
}
}
2025-12-22 20:36:29 +01:00
if ( activationCommand . hasCommand ) {
if ( ! isGroup ) {
cleanupTyping ( ) ;
return { text : "⚙️ Group activation only applies to group chats." } ;
}
if ( ! isOwnerSender ) {
logVerbose (
` Ignoring /activation from non-owner in group: ${ senderE164 || "<unknown>" } ` ,
) ;
cleanupTyping ( ) ;
return undefined ;
}
if ( ! activationCommand . mode ) {
cleanupTyping ( ) ;
return { text : "⚙️ Usage: /activation mention|always" } ;
}
if ( sessionEntry && sessionStore && sessionKey ) {
sessionEntry . groupActivation = activationCommand . mode ;
2025-12-23 13:32:07 +00:00
sessionEntry . groupActivationNeedsSystemIntro = true ;
2025-12-22 20:36:29 +01:00
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
cleanupTyping ( ) ;
return {
text : ` ⚙️ Group activation set to ${ activationCommand . mode } . ` ,
} ;
}
2025-12-03 12:14:32 +00:00
if (
2025-12-22 20:36:29 +01:00
commandBodyNormalized === "/restart" ||
commandBodyNormalized === "restart" ||
commandBodyNormalized . startsWith ( "/restart " )
2025-12-03 12:14:32 +00:00
) {
2025-12-22 20:36:29 +01:00
if ( isGroup && ! isOwnerSender ) {
logVerbose (
` Ignoring /restart from non-owner in group: ${ senderE164 || "<unknown>" } ` ,
) ;
cleanupTyping ( ) ;
return undefined ;
}
2025-12-09 18:00:01 +00:00
triggerClawdisRestart ( ) ;
2025-12-03 12:14:32 +00:00
cleanupTyping ( ) ;
return {
2025-12-05 21:37:11 +00:00
text : "⚙️ Restarting clawdis via launchctl; give me a few seconds to come back online." ,
2025-12-03 12:14:32 +00:00
} ;
}
2025-12-07 16:53:19 +00:00
if (
2025-12-22 20:36:29 +01:00
commandBodyNormalized === "/status" ||
commandBodyNormalized === "status" ||
commandBodyNormalized . startsWith ( "/status " )
2025-12-07 16:53:19 +00:00
) {
2025-12-22 20:36:29 +01:00
if ( isGroup && ! isOwnerSender ) {
logVerbose (
` Ignoring /status from non-owner in group: ${ senderE164 || "<unknown>" } ` ,
) ;
cleanupTyping ( ) ;
return undefined ;
}
2025-12-07 16:53:19 +00:00
const webLinked = await webAuthExists ( ) ;
const webAuthAgeMs = getWebAuthAgeMs ( ) ;
const heartbeatSeconds = resolveHeartbeatSeconds ( cfg , undefined ) ;
const statusText = buildStatusMessage ( {
2025-12-17 11:29:04 +01:00
agent : {
model ,
contextTokens ,
thinkingDefault : agentCfg?.thinkingDefault ,
verboseDefault : agentCfg?.verboseDefault ,
} ,
workspaceDir ,
2025-12-07 16:53:19 +00:00
sessionEntry ,
sessionKey ,
sessionScope ,
storePath ,
resolvedThink : resolvedThinkLevel ,
resolvedVerbose : resolvedVerboseLevel ,
webLinked ,
webAuthAgeMs ,
heartbeatSeconds ,
} ) ;
cleanupTyping ( ) ;
return { text : statusText } ;
}
2025-12-17 11:29:04 +01:00
const abortRequested = isAbortTrigger ( rawBodyNormalized ) ;
2025-12-02 20:09:51 +00:00
if ( abortRequested ) {
if ( sessionEntry && sessionStore && sessionKey ) {
sessionEntry . abortedLastRun = true ;
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
} else if ( abortKey ) {
ABORT_MEMORY . set ( abortKey , true ) ;
}
cleanupTyping ( ) ;
2025-12-05 21:37:11 +00:00
return { text : "⚙️ Agent was aborted." } ;
2025-12-02 20:09:51 +00:00
}
2025-11-26 00:53:53 +01:00
const isFirstTurnInSession = isNewSession || ! systemSent ;
2025-12-23 14:17:18 +00:00
const isGroupChat = sessionCtx . ChatType === "group" ;
const wasMentioned = ctx . WasMentioned === true ;
const shouldEagerType = ! isGroupChat || wasMentioned ;
2025-12-23 13:32:07 +00:00
const shouldInjectGroupIntro =
2025-12-23 14:17:18 +00:00
isGroupChat &&
2025-12-23 13:32:07 +00:00
( isFirstTurnInSession || sessionEntry ? . groupActivationNeedsSystemIntro ) ;
2025-12-24 00:33:35 +00:00
const groupIntro = shouldInjectGroupIntro
? ( ( ) = > {
const activation =
normalizeGroupActivation ( sessionEntry ? . groupActivation ) ? ?
defaultGroupActivation ( ) ;
const subject = sessionCtx . GroupSubject ? . trim ( ) ;
const members = sessionCtx . GroupMembers ? . trim ( ) ;
2025-12-15 10:11:18 -06:00
const surface = sessionCtx . Surface ? . trim ( ) . toLowerCase ( ) ;
const surfaceLabel = ( ( ) = > {
if ( ! surface ) return "chat" ;
if ( surface === "whatsapp" ) return "WhatsApp" ;
if ( surface === "telegram" ) return "Telegram" ;
if ( surface === "discord" ) return "Discord" ;
if ( surface === "webchat" ) return "WebChat" ;
return ` ${ surface . at ( 0 ) ? . toUpperCase ( ) ? ? "" } ${ surface . slice ( 1 ) } ` ;
} ) ( ) ;
2025-12-24 00:33:35 +00:00
const subjectLine = subject
2025-12-15 10:11:18 -06:00
? ` You are replying inside the ${ surfaceLabel } group " ${ subject } ". `
: ` You are replying inside a ${ surfaceLabel } group chat. ` ;
2025-12-24 00:33:35 +00:00
const membersLine = members ? ` Group members: ${ members } . ` : undefined ;
const activationLine =
activation === "always"
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)." ;
const silenceLine =
activation === "always"
? ` If no response is needed, reply with exactly " ${ SILENT_REPLY_TOKEN } " (no other text) so Clawdis stays silent. `
: undefined ;
const cautionLine =
activation === "always"
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
2025-12-03 13:33:32 +00:00
: undefined ;
2025-12-24 00:33:35 +00:00
return [
subjectLine ,
membersLine ,
activationLine ,
silenceLine ,
cautionLine ,
]
. filter ( Boolean )
. join ( " " )
. concat ( " Address the specific sender noted in the message context." ) ;
} ) ( )
: "" ;
2025-11-26 00:53:53 +01:00
const baseBody = sessionCtx . BodyStripped ? ? sessionCtx . Body ? ? "" ;
2025-12-10 15:55:20 +00:00
const rawBodyTrimmed = ( ctx . Body ? ? "" ) . trim ( ) ;
2025-12-20 13:04:55 +00:00
const baseBodyTrimmedRaw = baseBody . trim ( ) ;
2025-12-10 15:55:20 +00:00
const isBareSessionReset =
2025-12-20 13:04:55 +00:00
isNewSession &&
baseBodyTrimmedRaw . length === 0 &&
rawBodyTrimmed . length > 0 ;
2025-12-20 13:31:28 +00:00
const baseBodyFinal = isBareSessionReset
? BARE_SESSION_RESET_PROMPT
: baseBody ;
2025-12-20 13:04:55 +00:00
const baseBodyTrimmed = baseBodyFinal . trim ( ) ;
2025-12-10 13:51:06 +00:00
// Bail early if the cleaned body is empty to avoid sending blank prompts to the agent.
// This can happen if an inbound platform delivers an empty text message or we strip everything out.
2025-12-10 15:55:20 +00:00
if ( ! baseBodyTrimmed ) {
2025-12-10 13:51:06 +00:00
await onReplyStart ( ) ;
logVerbose ( "Inbound body empty after normalization; skipping agent run" ) ;
cleanupTyping ( ) ;
return {
2025-12-10 15:55:20 +00:00
text : "I didn't receive any text in your message. Please resend or add a caption." ,
2025-12-10 13:51:06 +00:00
} ;
}
2025-12-17 11:29:04 +01:00
const abortedHint = abortedLastRun
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
: "" ;
2025-12-20 13:04:55 +00:00
let prefixedBodyBase = baseBodyFinal ;
2025-12-02 20:09:51 +00:00
if ( abortedHint ) {
prefixedBodyBase = ` ${ abortedHint } \ n \ n ${ prefixedBodyBase } ` ;
if ( sessionEntry && sessionStore && sessionKey ) {
sessionEntry . abortedLastRun = false ;
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
} else if ( abortKey ) {
ABORT_MEMORY . set ( abortKey , false ) ;
2025-11-26 00:53:53 +01:00
}
2025-12-02 20:09:51 +00:00
}
2025-12-09 02:25:37 +01:00
2025-12-17 08:31:23 +01:00
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
2025-12-09 02:25:37 +01:00
const isGroupSession =
typeof ctx . From === "string" &&
( ctx . From . includes ( "@g.us" ) || ctx . From . startsWith ( "group:" ) ) ;
const isMainSession =
! isGroupSession && sessionKey === ( sessionCfg ? . mainKey ? ? "main" ) ;
if ( isMainSession ) {
2025-12-17 08:31:23 +01:00
const compactSystemEvent = ( line : string ) : string | null = > {
const trimmed = line . trim ( ) ;
if ( ! trimmed ) return null ;
const lower = trimmed . toLowerCase ( ) ;
if ( lower . includes ( "reason periodic" ) ) return null ;
if ( lower . includes ( "heartbeat" ) ) return null ;
if ( trimmed . startsWith ( "Node:" ) ) {
// Drop the chatty "last input … ago" segment; keep connect/disconnect/launch reasons.
return trimmed . replace ( / · last input [^·]+/i , "" ) . trim ( ) ;
}
return trimmed ;
} ;
const systemLines : string [ ] = [ ] ;
const queued = drainSystemEvents ( ) ;
systemLines . push (
. . . queued . map ( compactSystemEvent ) . filter ( ( v ) : v is string = > Boolean ( v ) ) ,
) ;
2025-12-09 02:25:37 +01:00
if ( isNewSession ) {
const summary = await buildProviderSummary ( cfg ) ;
2025-12-17 08:31:23 +01:00
if ( summary . length > 0 ) systemLines . unshift ( . . . summary ) ;
}
if ( systemLines . length > 0 ) {
const block = systemLines . map ( ( l ) = > ` System: ${ l } ` ) . join ( "\n" ) ;
prefixedBodyBase = ` ${ block } \ n \ n ${ prefixedBodyBase } ` ;
2025-12-09 02:25:37 +01:00
}
}
2025-12-17 11:29:04 +01:00
if ( isFirstTurnInSession && sessionStore && sessionKey ) {
2025-12-02 20:09:51 +00:00
const current = sessionEntry ? ?
sessionStore [ sessionKey ] ? ? {
sessionId : sessionId ? ? crypto . randomUUID ( ) ,
updatedAt : Date.now ( ) ,
} ;
2025-12-20 12:22:15 +01:00
const skillSnapshot =
isFirstTurnInSession || ! current . skillsSnapshot
? buildWorkspaceSkillSnapshot ( workspaceDir , { config : cfg } )
: current . skillsSnapshot ;
2025-12-02 20:09:51 +00:00
sessionEntry = {
. . . current ,
sessionId : sessionId ? ? current . sessionId ? ? crypto . randomUUID ( ) ,
2025-11-26 00:53:53 +01:00
updatedAt : Date.now ( ) ,
systemSent : true ,
2025-12-20 12:22:15 +01:00
skillsSnapshot : skillSnapshot ,
2025-11-26 00:53:53 +01:00
} ;
2025-12-02 20:09:51 +00:00
sessionStore [ sessionKey ] = sessionEntry ;
2025-11-26 00:53:53 +01:00
await saveSessionStore ( storePath , sessionStore ) ;
2025-12-02 21:23:56 +00:00
systemSent = true ;
2025-11-26 00:53:53 +01:00
}
2025-12-20 12:22:15 +01:00
const skillsSnapshot =
sessionEntry ? . skillsSnapshot ? ?
( isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot ( workspaceDir , { config : cfg } ) ) ;
if (
skillsSnapshot &&
sessionStore &&
sessionKey &&
! isFirstTurnInSession &&
! sessionEntry ? . skillsSnapshot
) {
const current = sessionEntry ? ? {
sessionId : sessionId ? ? crypto . randomUUID ( ) ,
updatedAt : Date.now ( ) ,
} ;
sessionEntry = {
. . . current ,
sessionId : sessionId ? ? current . sessionId ? ? crypto . randomUUID ( ) ,
updatedAt : Date.now ( ) ,
skillsSnapshot ,
} ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
2025-12-17 11:29:04 +01:00
const prefixedBody = transcribedText
? [ prefixedBodyBase , ` Transcript: \ n ${ transcribedText } ` ]
. filter ( Boolean )
. join ( "\n\n" )
: prefixedBodyBase ;
2025-11-26 00:53:53 +01:00
const mediaNote = ctx . MediaPath ? . length
? ` [media attached: ${ ctx . MediaPath } ${ ctx . MediaType ? ` ( ${ ctx . MediaType } ) ` : "" } ${ ctx . MediaUrl ? ` | ${ ctx . MediaUrl } ` : "" } ] `
: undefined ;
2025-12-17 11:29:04 +01:00
const mediaReplyHint = mediaNote
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
: undefined ;
2025-12-03 08:45:23 +00:00
let commandBody = mediaNote
2025-11-26 00:53:53 +01:00
? [ mediaNote , mediaReplyHint , prefixedBody ? ? "" ]
. filter ( Boolean )
. join ( "\n" )
. trim ( )
: prefixedBody ;
2025-12-03 08:45:23 +00:00
// Fallback: if a stray leading level token remains, consume it
if ( ! resolvedThinkLevel && commandBody ) {
const parts = commandBody . split ( /\s+/ ) ;
const maybeLevel = normalizeThinkLevel ( parts [ 0 ] ) ;
if ( maybeLevel ) {
resolvedThinkLevel = maybeLevel ;
commandBody = parts . slice ( 1 ) . join ( " " ) . trim ( ) ;
}
}
2025-11-26 00:53:53 +01:00
2025-12-17 11:29:04 +01:00
const sessionIdFinal = sessionId ? ? crypto . randomUUID ( ) ;
const sessionFile = resolveSessionTranscriptPath ( sessionIdFinal ) ;
2025-12-20 16:10:46 +01:00
const queueBodyBase = transcribedText
? [ baseBodyFinal , ` Transcript: \ n ${ transcribedText } ` ]
. filter ( Boolean )
. join ( "\n\n" )
: baseBodyFinal ;
const queuedBody = mediaNote
2025-12-20 17:50:45 +01:00
? [ mediaNote , mediaReplyHint , queueBodyBase ]
. filter ( Boolean )
. join ( "\n" )
. trim ( )
2025-12-20 16:10:46 +01:00
: queueBodyBase ;
2025-12-26 13:35:44 +01:00
const resolvedQueueMode = resolveQueueMode ( {
cfg ,
surface : sessionCtx.Surface ,
sessionEntry ,
inlineMode : perMessageQueueMode ,
} ) ;
const sessionLaneKey = resolveEmbeddedSessionLane (
sessionKey ? ? sessionIdFinal ,
) ;
const laneSize = getQueueSize ( sessionLaneKey ) ;
if ( resolvedQueueMode === "interrupt" && laneSize > 0 ) {
const cleared = clearCommandLane ( sessionLaneKey ) ;
const aborted = abortEmbeddedPiRun ( sessionIdFinal ) ;
logVerbose (
` Interrupting ${ sessionLaneKey } (cleared ${ cleared } , aborted= ${ aborted } ) ` ,
) ;
}
if (
resolvedQueueMode === "queue" &&
queueEmbeddedPiMessage ( sessionIdFinal , queuedBody )
) {
2025-12-20 16:10:46 +01:00
if ( sessionEntry && sessionStore && sessionKey ) {
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
cleanupTyping ( ) ;
return undefined ;
}
2025-12-17 11:29:04 +01:00
try {
2025-12-23 15:03:05 +00:00
if ( shouldEagerType ) {
await startTypingLoop ( ) ;
}
2025-12-17 11:29:04 +01:00
const runId = crypto . randomUUID ( ) ;
2025-12-26 10:16:50 +01:00
let runResult : Awaited < ReturnType < typeof runEmbeddedPiAgent > > ;
try {
runResult = await runEmbeddedPiAgent ( {
sessionId : sessionIdFinal ,
sessionKey ,
sessionFile ,
workspaceDir ,
config : cfg ,
skillsSnapshot ,
prompt : commandBody ,
extraSystemPrompt : groupIntro || undefined ,
ownerNumbers : ownerList.length > 0 ? ownerList : undefined ,
2025-12-27 00:28:52 +00:00
enforceFinalTag : provider === "ollama" ? true : undefined ,
2025-12-26 10:16:50 +01:00
provider ,
model ,
thinkLevel : resolvedThinkLevel ,
verboseLevel : resolvedVerboseLevel ,
timeoutMs ,
runId ,
onPartialReply : opts?.onPartialReply
? async ( payload ) = > {
await startTypingOnText ( payload . text ) ;
await opts . onPartialReply ? . ( {
text : payload.text ,
mediaUrls : payload.mediaUrls ,
} ) ;
}
: undefined ,
shouldEmitToolResult ,
onToolResult : opts?.onToolResult
? async ( payload ) = > {
await startTypingOnText ( payload . text ) ;
await opts . onToolResult ? . ( {
text : payload.text ,
mediaUrls : payload.mediaUrls ,
} ) ;
}
: undefined ,
} ) ;
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
const isContextOverflow =
/context.*overflow|too large|context window/i . test ( message ) ;
defaultRuntime . error ( ` Embedded agent failed before reply: ${ message } ` ) ;
return {
text : isContextOverflow
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
: "⚠️ Agent failed. Check gateway logs." ,
} ;
}
2025-12-03 00:40:19 +00:00
2025-12-23 13:32:07 +00:00
if (
shouldInjectGroupIntro &&
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry . groupActivationNeedsSystemIntro
) {
sessionEntry . groupActivationNeedsSystemIntro = false ;
sessionEntry . updatedAt = Date . now ( ) ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
2025-12-17 11:29:04 +01:00
const payloadArray = runResult . payloads ? ? [ ] ;
if ( payloadArray . length === 0 ) return undefined ;
2025-12-23 13:55:01 +00:00
const shouldSignalTyping = payloadArray . some ( ( payload ) = > {
const trimmed = payload . text ? . trim ( ) ;
if ( trimmed && trimmed !== SILENT_REPLY_TOKEN ) return true ;
if ( payload . mediaUrl ) return true ;
if ( payload . mediaUrls && payload . mediaUrls . length > 0 ) return true ;
return false ;
} ) ;
if ( shouldSignalTyping ) {
2025-12-23 15:03:05 +00:00
await startTypingLoop ( ) ;
2025-12-23 13:55:01 +00:00
}
2025-12-17 11:29:04 +01:00
if ( sessionStore && sessionKey ) {
const usage = runResult . meta . agentMeta ? . usage ;
2025-12-26 00:50:46 +00:00
const modelUsed = runResult . meta . agentMeta ? . model ? ? defaultModel ;
2025-12-17 11:29:04 +01:00
const contextTokensUsed =
agentCfg ? . contextTokens ? ?
lookupContextTokens ( modelUsed ) ? ?
sessionEntry ? . contextTokens ? ?
DEFAULT_CONTEXT_TOKENS ;
if ( usage ) {
const entry = sessionEntry ? ? sessionStore [ sessionKey ] ;
if ( entry ) {
const input = usage . input ? ? 0 ;
const output = usage . output ? ? 0 ;
const promptTokens =
input + ( usage . cacheRead ? ? 0 ) + ( usage . cacheWrite ? ? 0 ) ;
2025-12-02 20:09:51 +00:00
sessionEntry = {
. . . entry ,
2025-12-17 11:29:04 +01:00
inputTokens : input ,
outputTokens : output ,
totalTokens :
promptTokens > 0 ? promptTokens : ( usage . total ? ? input ) ,
model : modelUsed ,
contextTokens : contextTokensUsed ? ? entry . contextTokens ,
2025-12-02 20:09:51 +00:00
updatedAt : Date.now ( ) ,
} ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
}
2025-12-17 11:29:04 +01:00
} else if ( modelUsed || contextTokensUsed ) {
const entry = sessionEntry ? ? sessionStore [ sessionKey ] ;
if ( entry ) {
sessionEntry = {
. . . entry ,
model : modelUsed ? ? entry . model ,
contextTokens : contextTokensUsed ? ? entry . contextTokens ,
} ;
sessionStore [ sessionKey ] = sessionEntry ;
await saveSessionStore ( storePath , sessionStore ) ;
2025-12-05 22:33:09 +01:00
}
2025-12-02 20:09:51 +00:00
}
2025-12-03 09:09:34 +00:00
}
2025-11-26 00:53:53 +01:00
2025-12-17 11:29:04 +01:00
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = payloadArray ;
if ( resolvedVerboseLevel === "on" && isNewSession ) {
finalPayloads = [
{ text : ` 🧭 New session: ${ sessionIdFinal } ` } ,
. . . payloadArray ,
] ;
}
return finalPayloads . length === 1 ? finalPayloads [ 0 ] : finalPayloads ;
} finally {
cleanupTyping ( ) ;
}
2025-11-25 02:16:54 +01:00
}