2026-02-13 19:54:22 -04:00
|
|
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
2026-01-22 09:58:07 +00:00
|
|
|
import {
|
|
|
|
|
createAgentSession,
|
|
|
|
|
estimateTokens,
|
|
|
|
|
SessionManager,
|
|
|
|
|
SettingsManager,
|
|
|
|
|
} from "@mariozechner/pi-coding-agent";
|
2026-02-01 10:03:47 +09:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import os from "node:os";
|
2026-01-14 01:08:15 +00:00
|
|
|
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
2026-01-30 03:15:10 +01:00
|
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
|
|
|
|
import type { EmbeddedPiCompactResult } from "./types.js";
|
|
|
|
|
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
|
|
|
|
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
|
|
|
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
|
|
|
|
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
2026-01-16 20:16:35 +00:00
|
|
|
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
2026-01-25 03:20:09 +00:00
|
|
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
|
|
|
|
import { resolveUserPath } from "../../utils.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
|
|
|
|
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
2026-01-30 03:15:10 +01:00
|
|
|
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { resolveSessionAgentIds } from "../agent-scope.js";
|
2026-01-18 06:07:20 +00:00
|
|
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
|
|
|
|
|
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { resolveOpenClawDocsPath } from "../docs-path.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
2026-01-30 03:15:10 +01:00
|
|
|
import { ensureOpenClawModelsJson } from "../models-config.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import {
|
|
|
|
|
ensureSessionHeader,
|
|
|
|
|
validateAnthropicTurns,
|
|
|
|
|
validateGeminiTurns,
|
|
|
|
|
} from "../pi-embedded-helpers.js";
|
|
|
|
|
import {
|
|
|
|
|
ensurePiCompactionReserveTokens,
|
|
|
|
|
resolveCompactionReserveTokensFloor,
|
|
|
|
|
} from "../pi-settings.js";
|
2026-01-30 03:15:10 +01:00
|
|
|
import { createOpenClawCodingTools } from "../pi-tools.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { resolveSandboxContext } from "../sandbox.js";
|
2026-02-03 05:17:42 +08:00
|
|
|
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
2026-02-12 07:42:05 +08:00
|
|
|
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { acquireSessionWriteLock } from "../session-write-lock.js";
|
2026-02-07 11:32:31 -06:00
|
|
|
import { detectRuntimeShell } from "../shell-utils.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import {
|
|
|
|
|
applySkillEnvOverrides,
|
|
|
|
|
applySkillEnvOverridesFromSnapshot,
|
|
|
|
|
loadWorkspaceSkillEntries,
|
|
|
|
|
resolveSkillsPromptForRun,
|
|
|
|
|
type SkillSnapshot,
|
|
|
|
|
} from "../skills.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
2026-02-15 06:54:12 +08:00
|
|
|
import { compactWithSafetyTimeout } from "./compaction-safety-timeout.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
2026-01-16 06:57:25 +00:00
|
|
|
import {
|
|
|
|
|
logToolSchemasForGoogle,
|
|
|
|
|
sanitizeSessionHistory,
|
|
|
|
|
sanitizeToolsForGoogle,
|
|
|
|
|
} from "./google.js";
|
2026-01-14 14:31:43 +00:00
|
|
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
|
|
|
|
import { log } from "./logger.js";
|
|
|
|
|
import { buildModelAliasLines, resolveModel } from "./model.js";
|
|
|
|
|
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
|
2026-01-14 14:31:43 +00:00
|
|
|
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
2026-02-01 15:06:42 -08:00
|
|
|
import {
|
|
|
|
|
applySystemPromptOverrideToSession,
|
|
|
|
|
buildEmbeddedSystemPrompt,
|
|
|
|
|
createSystemPromptOverride,
|
|
|
|
|
} from "./system-prompt.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { splitSdkTools } from "./tool-split.js";
|
2026-02-13 17:34:04 -08:00
|
|
|
import { describeUnknownError, mapThinkingLevel } from "./utils.js";
|
2026-02-14 06:35:43 +11:00
|
|
|
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-24 19:09:24 -03:00
|
|
|
export type CompactEmbeddedPiSessionParams = {
|
2026-01-14 01:08:15 +00:00
|
|
|
sessionId: string;
|
2026-02-13 19:54:22 -04:00
|
|
|
runId?: string;
|
2026-01-14 01:08:15 +00:00
|
|
|
sessionKey?: string;
|
|
|
|
|
messageChannel?: string;
|
|
|
|
|
messageProvider?: string;
|
|
|
|
|
agentAccountId?: string;
|
2026-01-24 22:23:49 +00:00
|
|
|
authProfileId?: string;
|
2026-01-24 05:49:23 +00:00
|
|
|
/** Group id for channel-level tool policy resolution. */
|
|
|
|
|
groupId?: string | null;
|
|
|
|
|
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
|
|
|
|
groupChannel?: string | null;
|
|
|
|
|
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
|
|
|
|
groupSpace?: string | null;
|
|
|
|
|
/** Parent session key for subagent policy inheritance. */
|
|
|
|
|
spawnedBy?: string | null;
|
2026-02-04 19:49:36 -05:00
|
|
|
/** Whether the sender is an owner (required for owner-only tools). */
|
|
|
|
|
senderIsOwner?: boolean;
|
2026-01-14 01:08:15 +00:00
|
|
|
sessionFile: string;
|
|
|
|
|
workspaceDir: string;
|
|
|
|
|
agentDir?: string;
|
2026-01-30 03:15:10 +01:00
|
|
|
config?: OpenClawConfig;
|
2026-01-14 01:08:15 +00:00
|
|
|
skillsSnapshot?: SkillSnapshot;
|
|
|
|
|
provider?: string;
|
|
|
|
|
model?: string;
|
|
|
|
|
thinkLevel?: ThinkLevel;
|
|
|
|
|
reasoningLevel?: ReasoningLevel;
|
|
|
|
|
bashElevated?: ExecElevatedDefaults;
|
|
|
|
|
customInstructions?: string;
|
2026-02-15 06:54:12 +08:00
|
|
|
trigger?: "overflow" | "manual";
|
2026-02-13 19:54:22 -04:00
|
|
|
diagId?: string;
|
|
|
|
|
attempt?: number;
|
|
|
|
|
maxAttempts?: number;
|
2026-01-14 01:08:15 +00:00
|
|
|
lane?: string;
|
|
|
|
|
enqueue?: typeof enqueueCommand;
|
|
|
|
|
extraSystemPrompt?: string;
|
|
|
|
|
ownerNumbers?: string[];
|
2026-01-24 19:09:24 -03:00
|
|
|
};
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-13 19:54:22 -04:00
|
|
|
type CompactionMessageMetrics = {
|
|
|
|
|
messages: number;
|
|
|
|
|
historyTextChars: number;
|
|
|
|
|
toolResultChars: number;
|
|
|
|
|
estTokens?: number;
|
|
|
|
|
contributors: Array<{ role: string; chars: number; tool?: string }>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function createCompactionDiagId(): string {
|
|
|
|
|
return `cmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMessageTextChars(msg: AgentMessage): number {
|
|
|
|
|
const content = (msg as { content?: unknown }).content;
|
|
|
|
|
if (typeof content === "string") {
|
|
|
|
|
return content.length;
|
|
|
|
|
}
|
|
|
|
|
if (!Array.isArray(content)) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
let total = 0;
|
|
|
|
|
for (const block of content) {
|
|
|
|
|
if (!block || typeof block !== "object") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const text = (block as { text?: unknown }).text;
|
|
|
|
|
if (typeof text === "string") {
|
|
|
|
|
total += text.length;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveMessageToolLabel(msg: AgentMessage): string | undefined {
|
|
|
|
|
const candidate =
|
|
|
|
|
(msg as { toolName?: unknown }).toolName ??
|
|
|
|
|
(msg as { name?: unknown }).name ??
|
|
|
|
|
(msg as { tool?: unknown }).tool;
|
|
|
|
|
return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function summarizeCompactionMessages(messages: AgentMessage[]): CompactionMessageMetrics {
|
|
|
|
|
let historyTextChars = 0;
|
|
|
|
|
let toolResultChars = 0;
|
|
|
|
|
const contributors: Array<{ role: string; chars: number; tool?: string }> = [];
|
|
|
|
|
let estTokens = 0;
|
|
|
|
|
let tokenEstimationFailed = false;
|
|
|
|
|
|
|
|
|
|
for (const msg of messages) {
|
|
|
|
|
const role = typeof msg.role === "string" ? msg.role : "unknown";
|
|
|
|
|
const chars = getMessageTextChars(msg);
|
|
|
|
|
historyTextChars += chars;
|
|
|
|
|
if (role === "toolResult") {
|
|
|
|
|
toolResultChars += chars;
|
|
|
|
|
}
|
|
|
|
|
contributors.push({ role, chars, tool: resolveMessageToolLabel(msg) });
|
|
|
|
|
if (!tokenEstimationFailed) {
|
|
|
|
|
try {
|
|
|
|
|
estTokens += estimateTokens(msg);
|
|
|
|
|
} catch {
|
|
|
|
|
tokenEstimationFailed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
messages: messages.length,
|
|
|
|
|
historyTextChars,
|
|
|
|
|
toolResultChars,
|
|
|
|
|
estTokens: tokenEstimationFailed ? undefined : estTokens,
|
|
|
|
|
contributors: contributors.toSorted((a, b) => b.chars - a.chars).slice(0, 3),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function classifyCompactionReason(reason?: string): string {
|
|
|
|
|
const text = (reason ?? "").trim().toLowerCase();
|
|
|
|
|
if (!text) {
|
|
|
|
|
return "unknown";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("nothing to compact")) {
|
|
|
|
|
return "no_compactable_entries";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("below threshold")) {
|
|
|
|
|
return "below_threshold";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("already compacted")) {
|
|
|
|
|
return "already_compacted_recently";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("guard")) {
|
|
|
|
|
return "guard_blocked";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("summary")) {
|
|
|
|
|
return "summary_failed";
|
|
|
|
|
}
|
|
|
|
|
if (text.includes("timed out") || text.includes("timeout")) {
|
|
|
|
|
return "timeout";
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
text.includes("400") ||
|
|
|
|
|
text.includes("401") ||
|
|
|
|
|
text.includes("403") ||
|
|
|
|
|
text.includes("429")
|
|
|
|
|
) {
|
|
|
|
|
return "provider_error_4xx";
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
text.includes("500") ||
|
|
|
|
|
text.includes("502") ||
|
|
|
|
|
text.includes("503") ||
|
|
|
|
|
text.includes("504")
|
|
|
|
|
) {
|
|
|
|
|
return "provider_error_5xx";
|
|
|
|
|
}
|
|
|
|
|
return "unknown";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 19:09:24 -03:00
|
|
|
/**
|
|
|
|
|
* Core compaction logic without lane queueing.
|
|
|
|
|
* Use this when already inside a session/global lane to avoid deadlocks.
|
|
|
|
|
*/
|
|
|
|
|
export async function compactEmbeddedPiSessionDirect(
|
|
|
|
|
params: CompactEmbeddedPiSessionParams,
|
|
|
|
|
): Promise<EmbeddedPiCompactResult> {
|
2026-02-13 19:54:22 -04:00
|
|
|
const startedAt = Date.now();
|
|
|
|
|
const diagId = params.diagId?.trim() || createCompactionDiagId();
|
|
|
|
|
const trigger = params.trigger ?? "manual";
|
|
|
|
|
const attempt = params.attempt ?? 1;
|
|
|
|
|
const maxAttempts = params.maxAttempts ?? 1;
|
|
|
|
|
const runId = params.runId ?? params.sessionId;
|
2026-01-24 19:09:24 -03:00
|
|
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
|
|
|
|
const prevCwd = process.cwd();
|
|
|
|
|
|
|
|
|
|
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
|
|
|
|
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
2026-01-30 03:15:10 +01:00
|
|
|
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
|
|
|
|
|
await ensureOpenClawModelsJson(params.config, agentDir);
|
2026-01-24 19:09:24 -03:00
|
|
|
const { model, error, authStorage, modelRegistry } = resolveModel(
|
|
|
|
|
provider,
|
|
|
|
|
modelId,
|
|
|
|
|
agentDir,
|
|
|
|
|
params.config,
|
|
|
|
|
);
|
|
|
|
|
if (!model) {
|
2026-02-13 19:54:22 -04:00
|
|
|
const reason = error ?? `Unknown model: ${provider}/${modelId}`;
|
|
|
|
|
log.warn(
|
|
|
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
|
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
|
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
|
|
|
|
|
`durationMs=${Date.now() - startedAt}`,
|
|
|
|
|
);
|
2026-01-24 19:09:24 -03:00
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
compacted: false,
|
2026-02-13 19:54:22 -04:00
|
|
|
reason,
|
2026-01-24 19:09:24 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const apiKeyInfo = await getApiKeyForModel({
|
|
|
|
|
model,
|
|
|
|
|
cfg: params.config,
|
2026-01-24 22:23:49 +00:00
|
|
|
profileId: params.authProfileId,
|
2026-01-24 19:09:24 -03:00
|
|
|
agentDir,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!apiKeyInfo.apiKey) {
|
|
|
|
|
if (apiKeyInfo.mode !== "aws-sdk") {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
|
|
|
|
);
|
2026-01-14 01:08:15 +00:00
|
|
|
}
|
2026-01-24 19:09:24 -03:00
|
|
|
} else if (model.provider === "github-copilot") {
|
|
|
|
|
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
|
|
|
|
|
const copilotToken = await resolveCopilotApiToken({
|
|
|
|
|
githubToken: apiKeyInfo.apiKey,
|
|
|
|
|
});
|
|
|
|
|
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
|
|
|
|
} else {
|
|
|
|
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-02-13 19:54:22 -04:00
|
|
|
const reason = describeUnknownError(err);
|
|
|
|
|
log.warn(
|
|
|
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
|
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
|
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
|
|
|
|
|
`durationMs=${Date.now() - startedAt}`,
|
|
|
|
|
);
|
2026-01-24 19:09:24 -03:00
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
compacted: false,
|
2026-02-13 19:54:22 -04:00
|
|
|
reason,
|
2026-01-24 19:09:24 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
|
|
|
|
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
|
|
|
|
const sandbox = await resolveSandboxContext({
|
|
|
|
|
config: params.config,
|
|
|
|
|
sessionKey: sandboxSessionKey,
|
|
|
|
|
workspaceDir: resolvedWorkspace,
|
|
|
|
|
});
|
|
|
|
|
const effectiveWorkspace = sandbox?.enabled
|
|
|
|
|
? sandbox.workspaceAccess === "rw"
|
|
|
|
|
? resolvedWorkspace
|
|
|
|
|
: sandbox.workspaceDir
|
|
|
|
|
: resolvedWorkspace;
|
|
|
|
|
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
|
|
|
|
await ensureSessionHeader({
|
|
|
|
|
sessionFile: params.sessionFile,
|
|
|
|
|
sessionId: params.sessionId,
|
|
|
|
|
cwd: effectiveWorkspace,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let restoreSkillEnv: (() => void) | undefined;
|
|
|
|
|
process.chdir(effectiveWorkspace);
|
|
|
|
|
try {
|
|
|
|
|
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
|
|
|
|
const skillEntries = shouldLoadSkillEntries
|
|
|
|
|
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
|
|
|
|
: [];
|
|
|
|
|
restoreSkillEnv = params.skillsSnapshot
|
|
|
|
|
? applySkillEnvOverridesFromSnapshot({
|
|
|
|
|
snapshot: params.skillsSnapshot,
|
|
|
|
|
config: params.config,
|
|
|
|
|
})
|
|
|
|
|
: applySkillEnvOverrides({
|
|
|
|
|
skills: skillEntries ?? [],
|
|
|
|
|
config: params.config,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
2026-01-24 19:09:24 -03:00
|
|
|
const skillsPrompt = resolveSkillsPromptForRun({
|
|
|
|
|
skillsSnapshot: params.skillsSnapshot,
|
|
|
|
|
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
|
|
|
|
config: params.config,
|
|
|
|
|
workspaceDir: effectiveWorkspace,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-24 19:09:24 -03:00
|
|
|
const sessionLabel = params.sessionKey ?? params.sessionId;
|
|
|
|
|
const { contextFiles } = await resolveBootstrapContextForRun({
|
|
|
|
|
workspaceDir: effectiveWorkspace,
|
|
|
|
|
config: params.config,
|
|
|
|
|
sessionKey: params.sessionKey,
|
|
|
|
|
sessionId: params.sessionId,
|
|
|
|
|
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
|
|
|
|
});
|
|
|
|
|
const runAbortController = new AbortController();
|
2026-01-30 03:15:10 +01:00
|
|
|
const toolsRaw = createOpenClawCodingTools({
|
2026-01-24 19:09:24 -03:00
|
|
|
exec: {
|
|
|
|
|
elevated: params.bashElevated,
|
|
|
|
|
},
|
|
|
|
|
sandbox,
|
|
|
|
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
|
|
|
|
agentAccountId: params.agentAccountId,
|
|
|
|
|
sessionKey: params.sessionKey ?? params.sessionId,
|
|
|
|
|
groupId: params.groupId,
|
|
|
|
|
groupChannel: params.groupChannel,
|
|
|
|
|
groupSpace: params.groupSpace,
|
|
|
|
|
spawnedBy: params.spawnedBy,
|
2026-02-04 19:49:36 -05:00
|
|
|
senderIsOwner: params.senderIsOwner,
|
2026-01-24 19:09:24 -03:00
|
|
|
agentDir,
|
|
|
|
|
workspaceDir: effectiveWorkspace,
|
|
|
|
|
config: params.config,
|
|
|
|
|
abortSignal: runAbortController.signal,
|
|
|
|
|
modelProvider: model.provider,
|
|
|
|
|
modelId,
|
|
|
|
|
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
|
|
|
|
});
|
|
|
|
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
|
|
|
|
logToolSchemasForGoogle({ tools, provider });
|
|
|
|
|
const machineName = await getMachineDisplayName();
|
|
|
|
|
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
|
|
|
|
let runtimeCapabilities = runtimeChannel
|
|
|
|
|
? (resolveChannelCapabilities({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
channel: runtimeChannel,
|
|
|
|
|
accountId: params.agentAccountId,
|
|
|
|
|
}) ?? [])
|
|
|
|
|
: undefined;
|
|
|
|
|
if (runtimeChannel === "telegram" && params.config) {
|
|
|
|
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
accountId: params.agentAccountId ?? undefined,
|
|
|
|
|
});
|
|
|
|
|
if (inlineButtonsScope !== "off") {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!runtimeCapabilities) {
|
|
|
|
|
runtimeCapabilities = [];
|
|
|
|
|
}
|
2026-01-24 19:09:24 -03:00
|
|
|
if (
|
|
|
|
|
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
|
|
|
|
|
) {
|
|
|
|
|
runtimeCapabilities.push("inlineButtons");
|
2026-01-14 01:08:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-24 19:09:24 -03:00
|
|
|
}
|
2026-01-25 03:20:09 +00:00
|
|
|
const reactionGuidance =
|
|
|
|
|
runtimeChannel && params.config
|
|
|
|
|
? (() => {
|
|
|
|
|
if (runtimeChannel === "telegram") {
|
|
|
|
|
const resolved = resolveTelegramReactionLevel({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
accountId: params.agentAccountId ?? undefined,
|
|
|
|
|
});
|
|
|
|
|
const level = resolved.agentReactionGuidance;
|
|
|
|
|
return level ? { level, channel: "Telegram" } : undefined;
|
|
|
|
|
}
|
|
|
|
|
if (runtimeChannel === "signal") {
|
|
|
|
|
const resolved = resolveSignalReactionLevel({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
accountId: params.agentAccountId ?? undefined,
|
|
|
|
|
});
|
|
|
|
|
const level = resolved.agentReactionGuidance;
|
|
|
|
|
return level ? { level, channel: "Signal" } : undefined;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
})()
|
|
|
|
|
: undefined;
|
2026-01-24 19:09:24 -03:00
|
|
|
// Resolve channel-specific message actions for system prompt
|
|
|
|
|
const channelActions = runtimeChannel
|
|
|
|
|
? listChannelSupportedActions({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
channel: runtimeChannel,
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
|
|
|
|
const messageToolHints = runtimeChannel
|
|
|
|
|
? resolveChannelMessageToolHints({
|
|
|
|
|
cfg: params.config,
|
|
|
|
|
channel: runtimeChannel,
|
|
|
|
|
accountId: params.agentAccountId,
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-24 19:09:24 -03:00
|
|
|
const runtimeInfo = {
|
|
|
|
|
host: machineName,
|
|
|
|
|
os: `${os.type()} ${os.release()}`,
|
|
|
|
|
arch: os.arch(),
|
|
|
|
|
node: process.version,
|
|
|
|
|
model: `${provider}/${modelId}`,
|
2026-02-07 11:32:31 -06:00
|
|
|
shell: detectRuntimeShell(),
|
2026-01-24 19:09:24 -03:00
|
|
|
channel: runtimeChannel,
|
|
|
|
|
capabilities: runtimeCapabilities,
|
|
|
|
|
channelActions,
|
|
|
|
|
};
|
|
|
|
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
|
|
|
|
const reasoningTagHint = isReasoningTagProvider(provider);
|
|
|
|
|
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
|
|
|
|
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
|
|
|
|
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
|
|
|
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
|
|
|
|
sessionKey: params.sessionKey,
|
|
|
|
|
config: params.config,
|
|
|
|
|
});
|
|
|
|
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
|
|
|
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
2026-01-30 03:15:10 +01:00
|
|
|
const docsPath = await resolveOpenClawDocsPath({
|
2026-01-24 19:09:24 -03:00
|
|
|
workspaceDir: effectiveWorkspace,
|
|
|
|
|
argv1: process.argv[1],
|
|
|
|
|
cwd: process.cwd(),
|
|
|
|
|
moduleUrl: import.meta.url,
|
|
|
|
|
});
|
|
|
|
|
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
|
|
|
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
|
|
|
|
workspaceDir: effectiveWorkspace,
|
|
|
|
|
defaultThinkLevel: params.thinkLevel,
|
|
|
|
|
reasoningLevel: params.reasoningLevel ?? "off",
|
|
|
|
|
extraSystemPrompt: params.extraSystemPrompt,
|
|
|
|
|
ownerNumbers: params.ownerNumbers,
|
|
|
|
|
reasoningTagHint,
|
|
|
|
|
heartbeatPrompt: isDefaultAgent
|
|
|
|
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
|
|
|
|
: undefined,
|
|
|
|
|
skillsPrompt,
|
|
|
|
|
docsPath: docsPath ?? undefined,
|
|
|
|
|
ttsHint,
|
|
|
|
|
promptMode,
|
|
|
|
|
runtimeInfo,
|
2026-01-25 03:20:09 +00:00
|
|
|
reactionGuidance,
|
2026-01-24 19:09:24 -03:00
|
|
|
messageToolHints,
|
|
|
|
|
sandboxInfo,
|
|
|
|
|
tools,
|
|
|
|
|
modelAliasLines: buildModelAliasLines(params.config),
|
|
|
|
|
userTimezone,
|
|
|
|
|
userTime,
|
|
|
|
|
userTimeFormat,
|
|
|
|
|
contextFiles,
|
2026-01-27 21:57:15 -08:00
|
|
|
memoryCitationsMode: params.config?.memory?.citations,
|
2026-01-24 19:09:24 -03:00
|
|
|
});
|
2026-02-01 15:06:42 -08:00
|
|
|
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
|
2026-01-24 19:09:24 -03:00
|
|
|
|
|
|
|
|
const sessionLock = await acquireSessionWriteLock({
|
|
|
|
|
sessionFile: params.sessionFile,
|
|
|
|
|
});
|
|
|
|
|
try {
|
2026-02-03 05:17:42 +08:00
|
|
|
await repairSessionFileIfNeeded({
|
|
|
|
|
sessionFile: params.sessionFile,
|
|
|
|
|
warn: (message) => log.warn(message),
|
|
|
|
|
});
|
2026-01-24 19:09:24 -03:00
|
|
|
await prewarmSessionFile(params.sessionFile);
|
|
|
|
|
const transcriptPolicy = resolveTranscriptPolicy({
|
|
|
|
|
modelApi: model.api,
|
|
|
|
|
provider,
|
|
|
|
|
modelId,
|
|
|
|
|
});
|
|
|
|
|
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
|
|
|
|
agentId: sessionAgentId,
|
|
|
|
|
sessionKey: params.sessionKey,
|
|
|
|
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
2026-01-24 19:09:24 -03:00
|
|
|
trackSessionManagerAccess(params.sessionFile);
|
|
|
|
|
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
|
|
|
|
ensurePiCompactionReserveTokens({
|
|
|
|
|
settingsManager,
|
|
|
|
|
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
|
|
|
|
|
});
|
2026-02-02 01:52:24 +01:00
|
|
|
// Call for side effects (sets compaction/pruning runtime state)
|
|
|
|
|
buildEmbeddedExtensionPaths({
|
2026-01-24 19:09:24 -03:00
|
|
|
cfg: params.config,
|
|
|
|
|
sessionManager,
|
|
|
|
|
provider,
|
|
|
|
|
modelId,
|
|
|
|
|
model,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-24 19:09:24 -03:00
|
|
|
const { builtInTools, customTools } = splitSdkTools({
|
|
|
|
|
tools,
|
|
|
|
|
sandboxEnabled: !!sandbox?.enabled,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-31 06:22:19 +00:00
|
|
|
const { session } = await createAgentSession({
|
2026-01-24 19:09:24 -03:00
|
|
|
cwd: resolvedWorkspace,
|
|
|
|
|
agentDir,
|
|
|
|
|
authStorage,
|
|
|
|
|
modelRegistry,
|
|
|
|
|
model,
|
|
|
|
|
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
|
|
|
|
tools: builtInTools,
|
|
|
|
|
customTools,
|
|
|
|
|
sessionManager,
|
|
|
|
|
settingsManager,
|
2026-01-31 06:22:19 +00:00
|
|
|
});
|
2026-02-02 02:04:50 -08:00
|
|
|
applySystemPromptOverrideToSession(session, systemPromptOverride());
|
2026-01-24 19:09:24 -03:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const prior = await sanitizeSessionHistory({
|
|
|
|
|
messages: session.messages,
|
|
|
|
|
modelApi: model.api,
|
2026-01-14 01:08:15 +00:00
|
|
|
modelId,
|
2026-01-24 19:09:24 -03:00
|
|
|
provider,
|
|
|
|
|
sessionManager,
|
|
|
|
|
sessionId: params.sessionId,
|
|
|
|
|
policy: transcriptPolicy,
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
2026-01-24 19:09:24 -03:00
|
|
|
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
|
|
|
|
? validateGeminiTurns(prior)
|
|
|
|
|
: prior;
|
|
|
|
|
const validated = transcriptPolicy.validateAnthropicTurns
|
|
|
|
|
? validateAnthropicTurns(validatedGemini)
|
|
|
|
|
: validatedGemini;
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
// Capture full message history BEFORE limiting — plugins need the complete conversation
|
|
|
|
|
const preCompactionMessages = [...session.messages];
|
2026-02-12 07:42:05 +08:00
|
|
|
const truncated = limitHistoryTurns(
|
2026-01-24 19:09:24 -03:00
|
|
|
validated,
|
|
|
|
|
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
2026-01-14 01:08:15 +00:00
|
|
|
);
|
2026-02-12 07:42:05 +08:00
|
|
|
// Re-run tool_use/tool_result pairing repair after truncation, since
|
|
|
|
|
// limitHistoryTurns can orphan tool_result blocks by removing the
|
|
|
|
|
// assistant message that contained the matching tool_use.
|
|
|
|
|
const limited = transcriptPolicy.repairToolUseResultPairing
|
|
|
|
|
? sanitizeToolUseResultPairing(truncated)
|
|
|
|
|
: truncated;
|
2026-01-24 19:09:24 -03:00
|
|
|
if (limited.length > 0) {
|
|
|
|
|
session.agent.replaceMessages(limited);
|
2026-01-16 20:16:35 +00:00
|
|
|
}
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
// Run before_compaction hooks (fire-and-forget).
|
|
|
|
|
// The session JSONL already contains all messages on disk, so plugins
|
|
|
|
|
// can read sessionFile asynchronously and process in parallel with
|
|
|
|
|
// the compaction LLM call — no need to block or wait for after_compaction.
|
|
|
|
|
const hookRunner = getGlobalHookRunner();
|
|
|
|
|
const hookCtx = {
|
|
|
|
|
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
|
|
|
|
sessionKey: params.sessionKey,
|
|
|
|
|
sessionId: params.sessionId,
|
|
|
|
|
workspaceDir: params.workspaceDir,
|
|
|
|
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
|
|
|
|
};
|
|
|
|
|
if (hookRunner?.hasHooks("before_compaction")) {
|
|
|
|
|
hookRunner
|
|
|
|
|
.runBeforeCompaction(
|
|
|
|
|
{
|
|
|
|
|
messageCount: preCompactionMessages.length,
|
|
|
|
|
compactingCount: limited.length,
|
|
|
|
|
messages: preCompactionMessages,
|
|
|
|
|
sessionFile: params.sessionFile,
|
|
|
|
|
},
|
|
|
|
|
hookCtx,
|
|
|
|
|
)
|
|
|
|
|
.catch((hookErr: unknown) => {
|
|
|
|
|
log.warn(`before_compaction hook failed: ${String(hookErr)}`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 19:54:22 -04:00
|
|
|
const diagEnabled = log.isEnabled("debug");
|
|
|
|
|
const preMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
|
|
|
|
|
if (diagEnabled && preMetrics) {
|
|
|
|
|
log.debug(
|
|
|
|
|
`[compaction-diag] start runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
|
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
|
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} ` +
|
|
|
|
|
`pre.messages=${preMetrics.messages} pre.historyTextChars=${preMetrics.historyTextChars} ` +
|
|
|
|
|
`pre.toolResultChars=${preMetrics.toolResultChars} pre.estTokens=${preMetrics.estTokens ?? "unknown"}`,
|
|
|
|
|
);
|
|
|
|
|
log.debug(
|
|
|
|
|
`[compaction-diag] contributors diagId=${diagId} top=${JSON.stringify(preMetrics.contributors)}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const compactStartedAt = Date.now();
|
2026-02-15 06:54:12 +08:00
|
|
|
const result = await compactWithSafetyTimeout(() =>
|
|
|
|
|
session.compact(params.customInstructions),
|
|
|
|
|
);
|
2026-01-24 19:09:24 -03:00
|
|
|
// Estimate tokens after compaction by summing token estimates for remaining messages
|
|
|
|
|
let tokensAfter: number | undefined;
|
2026-01-14 01:08:15 +00:00
|
|
|
try {
|
2026-01-24 19:09:24 -03:00
|
|
|
tokensAfter = 0;
|
|
|
|
|
for (const message of session.messages) {
|
|
|
|
|
tokensAfter += estimateTokens(message);
|
2026-01-14 01:08:15 +00:00
|
|
|
}
|
2026-01-24 19:09:24 -03:00
|
|
|
// Sanity check: tokensAfter should be less than tokensBefore
|
|
|
|
|
if (tokensAfter > result.tokensBefore) {
|
|
|
|
|
tokensAfter = undefined; // Don't trust the estimate
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// If estimation fails, leave tokensAfter undefined
|
|
|
|
|
tokensAfter = undefined;
|
2026-01-14 01:08:15 +00:00
|
|
|
}
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
// Run after_compaction hooks (fire-and-forget).
|
|
|
|
|
// Also includes sessionFile for plugins that only need to act after
|
|
|
|
|
// compaction completes (e.g. analytics, cleanup).
|
|
|
|
|
if (hookRunner?.hasHooks("after_compaction")) {
|
|
|
|
|
hookRunner
|
|
|
|
|
.runAfterCompaction(
|
|
|
|
|
{
|
|
|
|
|
messageCount: session.messages.length,
|
|
|
|
|
tokenCount: tokensAfter,
|
|
|
|
|
compactedCount: limited.length - session.messages.length,
|
|
|
|
|
sessionFile: params.sessionFile,
|
|
|
|
|
},
|
|
|
|
|
hookCtx,
|
|
|
|
|
)
|
|
|
|
|
.catch((hookErr) => {
|
|
|
|
|
log.warn(`after_compaction hook failed: ${hookErr}`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 19:54:22 -04:00
|
|
|
const postMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
|
|
|
|
|
if (diagEnabled && preMetrics && postMetrics) {
|
|
|
|
|
log.debug(
|
|
|
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
|
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
|
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=compacted reason=none ` +
|
|
|
|
|
`durationMs=${Date.now() - compactStartedAt} retrying=false ` +
|
|
|
|
|
`post.messages=${postMetrics.messages} post.historyTextChars=${postMetrics.historyTextChars} ` +
|
|
|
|
|
`post.toolResultChars=${postMetrics.toolResultChars} post.estTokens=${postMetrics.estTokens ?? "unknown"} ` +
|
|
|
|
|
`delta.messages=${postMetrics.messages - preMetrics.messages} ` +
|
|
|
|
|
`delta.historyTextChars=${postMetrics.historyTextChars - preMetrics.historyTextChars} ` +
|
|
|
|
|
`delta.toolResultChars=${postMetrics.toolResultChars - preMetrics.toolResultChars} ` +
|
|
|
|
|
`delta.estTokens=${typeof preMetrics.estTokens === "number" && typeof postMetrics.estTokens === "number" ? postMetrics.estTokens - preMetrics.estTokens : "unknown"}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
return {
|
2026-01-24 19:09:24 -03:00
|
|
|
ok: true,
|
|
|
|
|
compacted: true,
|
|
|
|
|
result: {
|
|
|
|
|
summary: result.summary,
|
|
|
|
|
firstKeptEntryId: result.firstKeptEntryId,
|
|
|
|
|
tokensBefore: result.tokensBefore,
|
|
|
|
|
tokensAfter,
|
|
|
|
|
details: result.details,
|
|
|
|
|
},
|
2026-01-14 01:08:15 +00:00
|
|
|
};
|
|
|
|
|
} finally {
|
2026-02-14 06:35:43 +11:00
|
|
|
await flushPendingToolResultsAfterIdle({
|
|
|
|
|
agent: session?.agent,
|
|
|
|
|
sessionManager,
|
|
|
|
|
});
|
2026-01-24 19:09:24 -03:00
|
|
|
session.dispose();
|
2026-01-14 01:08:15 +00:00
|
|
|
}
|
2026-01-24 19:09:24 -03:00
|
|
|
} finally {
|
|
|
|
|
await sessionLock.release();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-02-13 19:54:22 -04:00
|
|
|
const reason = describeUnknownError(err);
|
|
|
|
|
log.warn(
|
|
|
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
|
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
|
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
|
|
|
|
|
`durationMs=${Date.now() - startedAt}`,
|
|
|
|
|
);
|
2026-01-24 19:09:24 -03:00
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
compacted: false,
|
2026-02-13 19:54:22 -04:00
|
|
|
reason,
|
2026-01-24 19:09:24 -03:00
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
restoreSkillEnv?.();
|
|
|
|
|
process.chdir(prevCwd);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compacts a session with lane queueing (session lane + global lane).
|
|
|
|
|
* Use this from outside a lane context. If already inside a lane, use
|
|
|
|
|
* `compactEmbeddedPiSessionDirect` to avoid deadlocks.
|
|
|
|
|
*/
|
|
|
|
|
export async function compactEmbeddedPiSession(
|
|
|
|
|
params: CompactEmbeddedPiSessionParams,
|
|
|
|
|
): Promise<EmbeddedPiCompactResult> {
|
|
|
|
|
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
|
|
|
|
const globalLane = resolveGlobalLane(params.lane);
|
|
|
|
|
const enqueueGlobal =
|
|
|
|
|
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
|
|
|
|
return enqueueCommandInLane(sessionLane, () =>
|
|
|
|
|
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
|
2026-01-14 01:08:15 +00:00
|
|
|
);
|
|
|
|
|
}
|