feat(config): add experimental config for gating unstable features (#110)
* feat(anthropic-auto-compact): add aggressive truncation and empty message recovery Add truncateUntilTargetTokens method, empty content recovery mechanism, and emptyContentAttemptBySession tracking for robust message handling. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(session-recovery): add auto-resume and recovery callbacks Implement ResumeConfig, resumeSession() method, and callback support for enhanced session recovery and resume functionality. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config): add experimental config schema for gating unstable features This adds a new 'experimental' config field to the OhMyOpenCode schema that enables fine-grained control over unstable/experimental features: - aggressive_truncation: Enables aggressive token truncation in anthropic-auto-compact hook for more aggressive token limit handling - empty_message_recovery: Enables empty message recovery mechanism in anthropic-auto-compact hook for fixing truncation-induced empty message errors - auto_resume: Enables automatic session resume after recovery in session-recovery hook for seamless recovery experience The experimental config is optional and all experimental features are disabled by default, ensuring backward compatibility while allowing early adopters to opt-in to cutting-edge features. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -6,6 +6,7 @@ export {
|
|||||||
AgentNameSchema,
|
AgentNameSchema,
|
||||||
HookNameSchema,
|
HookNameSchema,
|
||||||
OmoAgentConfigSchema,
|
OmoAgentConfigSchema,
|
||||||
|
ExperimentalConfigSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -16,4 +17,5 @@ export type {
|
|||||||
AgentName,
|
AgentName,
|
||||||
HookName,
|
HookName,
|
||||||
OmoAgentConfig,
|
OmoAgentConfig,
|
||||||
|
ExperimentalConfig,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ export const OmoAgentConfigSchema = z.object({
|
|||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ExperimentalConfigSchema = z.object({
|
||||||
|
aggressive_truncation: z.boolean().optional(),
|
||||||
|
empty_message_recovery: z.boolean().optional(),
|
||||||
|
auto_resume: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const OhMyOpenCodeConfigSchema = z.object({
|
export const OhMyOpenCodeConfigSchema = z.object({
|
||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||||
@@ -115,6 +121,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||||
google_auth: z.boolean().optional(),
|
google_auth: z.boolean().optional(),
|
||||||
omo_agent: OmoAgentConfigSchema.optional(),
|
omo_agent: OmoAgentConfigSchema.optional(),
|
||||||
|
experimental: ExperimentalConfigSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||||
@@ -123,5 +130,6 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
|||||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema>
|
export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema>
|
||||||
|
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||||
|
|
||||||
export { McpNameSchema, type McpName } from "../mcp/types"
|
export { McpNameSchema, type McpName } from "../mcp/types"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
|
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
|
||||||
|
import type { ExperimentalConfig } from "../../config"
|
||||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
|
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
|
||||||
import { findLargestToolResult, truncateToolResult } from "./storage"
|
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
|
||||||
|
import { findEmptyMessages, injectTextPart } from "../session-recovery/storage"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type Client = {
|
type Client = {
|
||||||
session: {
|
session: {
|
||||||
@@ -151,24 +154,151 @@ function clearSessionState(autoCompactState: AutoCompactState, sessionID: string
|
|||||||
autoCompactState.retryStateBySession.delete(sessionID)
|
autoCompactState.retryStateBySession.delete(sessionID)
|
||||||
autoCompactState.fallbackStateBySession.delete(sessionID)
|
autoCompactState.fallbackStateBySession.delete(sessionID)
|
||||||
autoCompactState.truncateStateBySession.delete(sessionID)
|
autoCompactState.truncateStateBySession.delete(sessionID)
|
||||||
|
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
|
||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrCreateEmptyContentAttempt(
|
||||||
|
autoCompactState: AutoCompactState,
|
||||||
|
sessionID: string
|
||||||
|
): number {
|
||||||
|
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixEmptyMessages(
|
||||||
|
sessionID: string,
|
||||||
|
autoCompactState: AutoCompactState,
|
||||||
|
client: Client
|
||||||
|
): Promise<boolean> {
|
||||||
|
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
||||||
|
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
|
||||||
|
|
||||||
|
const emptyMessageIds = findEmptyMessages(sessionID)
|
||||||
|
if (emptyMessageIds.length === 0) {
|
||||||
|
await client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Empty Content Error",
|
||||||
|
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||||
|
variant: "error",
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixed = false
|
||||||
|
for (const messageID of emptyMessageIds) {
|
||||||
|
const success = injectTextPart(sessionID, messageID, "[user interrupted]")
|
||||||
|
if (success) fixed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixed) {
|
||||||
|
await client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Session Recovery",
|
||||||
|
message: `Fixed ${emptyMessageIds.length} empty messages. Retrying...`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixed
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeCompact(
|
export async function executeCompact(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
msg: Record<string, unknown>,
|
msg: Record<string, unknown>,
|
||||||
autoCompactState: AutoCompactState,
|
autoCompactState: AutoCompactState,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
directory: string
|
directory: string,
|
||||||
|
experimental?: ExperimentalConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
autoCompactState.compactionInProgress.add(sessionID)
|
autoCompactState.compactionInProgress.add(sessionID)
|
||||||
|
|
||||||
|
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
|
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
|
||||||
|
|
||||||
|
if (
|
||||||
|
experimental?.aggressive_truncation &&
|
||||||
|
errorData?.currentTokens &&
|
||||||
|
errorData?.maxTokens &&
|
||||||
|
errorData.currentTokens > errorData.maxTokens &&
|
||||||
|
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||||
|
) {
|
||||||
|
log("[auto-compact] aggressive truncation triggered (experimental)", {
|
||||||
|
currentTokens: errorData.currentTokens,
|
||||||
|
maxTokens: errorData.maxTokens,
|
||||||
|
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||||
|
})
|
||||||
|
|
||||||
|
const aggressiveResult = truncateUntilTargetTokens(
|
||||||
|
sessionID,
|
||||||
|
errorData.currentTokens,
|
||||||
|
errorData.maxTokens,
|
||||||
|
TRUNCATE_CONFIG.targetTokenRatio,
|
||||||
|
TRUNCATE_CONFIG.charsPerToken
|
||||||
|
)
|
||||||
|
|
||||||
|
if (aggressiveResult.truncatedCount > 0) {
|
||||||
|
truncateState.truncateAttempt += aggressiveResult.truncatedCount
|
||||||
|
|
||||||
|
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
|
||||||
|
const statusMsg = aggressiveResult.sufficient
|
||||||
|
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||||
|
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`
|
||||||
|
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation",
|
||||||
|
message: `${statusMsg}: ${toolNames}`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 4000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
log("[auto-compact] aggressive truncation completed", aggressiveResult)
|
||||||
|
|
||||||
|
if (aggressiveResult.sufficient) {
|
||||||
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await (client as Client).session.prompt_async({
|
||||||
|
path: { sessionID },
|
||||||
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
|
query: { directory },
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Truncation Skipped",
|
||||||
|
message: "No tool outputs found to truncate.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipSummarize = false
|
||||||
|
|
||||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||||
const largest = findLargestToolResult(sessionID)
|
const largest = findLargestToolResult(sessionID)
|
||||||
|
|
||||||
@@ -203,12 +333,68 @@ export async function executeCompact(
|
|||||||
}, 500)
|
}, 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) {
|
||||||
|
skipSummarize = true
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Summarize Skipped",
|
||||||
|
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
} else if (!errorData?.currentTokens) {
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Truncation Skipped",
|
||||||
|
message: "No large tool outputs found.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
||||||
|
|
||||||
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
if (experimental?.empty_message_recovery && errorData?.errorType?.includes("non-empty content")) {
|
||||||
|
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
|
||||||
|
if (attempt < 3) {
|
||||||
|
const fixed = await fixEmptyMessages(sessionID, autoCompactState, client as Client)
|
||||||
|
if (fixed) {
|
||||||
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
|
setTimeout(() => {
|
||||||
|
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Recovery Failed",
|
||||||
|
message: "Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||||
|
variant: "error",
|
||||||
|
duration: 10000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - retryState.lastAttemptTime > 300000) {
|
||||||
|
retryState.attempt = 0
|
||||||
|
autoCompactState.fallbackStateBySession.delete(sessionID)
|
||||||
|
autoCompactState.truncateStateBySession.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||||
retryState.attempt++
|
retryState.attempt++
|
||||||
retryState.lastAttemptTime = Date.now()
|
retryState.lastAttemptTime = Date.now()
|
||||||
|
|
||||||
@@ -234,7 +420,7 @@ export async function executeCompact(
|
|||||||
query: { directory },
|
query: { directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
clearSessionState(autoCompactState, sessionID)
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -253,10 +439,21 @@ export async function executeCompact(
|
|||||||
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeCompact(sessionID, msg, autoCompactState, client, directory)
|
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
|
||||||
}, cappedDelay)
|
}, cappedDelay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Summarize Skipped",
|
||||||
|
message: "Missing providerID or modelID. Skipping to revert...",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +498,21 @@ export async function executeCompact(
|
|||||||
autoCompactState.compactionInProgress.delete(sessionID)
|
autoCompactState.compactionInProgress.delete(sessionID)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeCompact(sessionID, msg, autoCompactState, client, directory)
|
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return
|
return
|
||||||
} catch {}
|
} catch {}
|
||||||
|
} else {
|
||||||
|
await (client as Client).tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Revert Skipped",
|
||||||
|
message: "Could not find last message pair to revert.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||||
|
import type { ExperimentalConfig } from "../../config"
|
||||||
import { parseAnthropicTokenLimitError } from "./parser"
|
import { parseAnthropicTokenLimitError } from "./parser"
|
||||||
import { executeCompact, getLastAssistant } from "./executor"
|
import { executeCompact, getLastAssistant } from "./executor"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export interface AnthropicAutoCompactOptions {
|
||||||
|
experimental?: ExperimentalConfig
|
||||||
|
}
|
||||||
|
|
||||||
function createAutoCompactState(): AutoCompactState {
|
function createAutoCompactState(): AutoCompactState {
|
||||||
return {
|
return {
|
||||||
@@ -10,12 +16,14 @@ function createAutoCompactState(): AutoCompactState {
|
|||||||
retryStateBySession: new Map(),
|
retryStateBySession: new Map(),
|
||||||
fallbackStateBySession: new Map(),
|
fallbackStateBySession: new Map(),
|
||||||
truncateStateBySession: new Map(),
|
truncateStateBySession: new Map(),
|
||||||
|
emptyContentAttemptBySession: new Map(),
|
||||||
compactionInProgress: new Set<string>(),
|
compactionInProgress: new Set<string>(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: AnthropicAutoCompactOptions) {
|
||||||
const autoCompactState = createAutoCompactState()
|
const autoCompactState = createAutoCompactState()
|
||||||
|
const experimental = options?.experimental
|
||||||
|
|
||||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
@@ -28,6 +36,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
|||||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||||
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
|
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
|
||||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||||
|
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -35,9 +44,11 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
|||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||||
|
log("[auto-compact] parsed result", { parsed, hasError: !!props?.error })
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
autoCompactState.pendingCompact.add(sessionID)
|
autoCompactState.pendingCompact.add(sessionID)
|
||||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||||
@@ -67,7 +78,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
|||||||
{ providerID, modelID },
|
{ providerID, modelID },
|
||||||
autoCompactState,
|
autoCompactState,
|
||||||
ctx.client,
|
ctx.client,
|
||||||
ctx.directory
|
ctx.directory,
|
||||||
|
experimental
|
||||||
)
|
)
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -79,7 +91,9 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
|||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
|
|
||||||
if (sessionID && info?.role === "assistant" && info.error) {
|
if (sessionID && info?.role === "assistant" && info.error) {
|
||||||
|
log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||||
|
log("[auto-compact] message.updated parsed result", { parsed })
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
parsed.providerID = info.providerID as string | undefined
|
parsed.providerID = info.providerID as string | undefined
|
||||||
parsed.modelID = info.modelID as string | undefined
|
parsed.modelID = info.modelID as string | undefined
|
||||||
@@ -123,7 +137,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
|||||||
{ providerID, modelID },
|
{ providerID, modelID },
|
||||||
autoCompactState,
|
autoCompactState,
|
||||||
ctx.client,
|
ctx.client,
|
||||||
ctx.directory
|
ctx.directory,
|
||||||
|
experimental
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const TOKEN_LIMIT_KEYWORDS = [
|
|||||||
"token limit",
|
"token limit",
|
||||||
"context length",
|
"context length",
|
||||||
"too many tokens",
|
"too many tokens",
|
||||||
|
"non-empty content",
|
||||||
]
|
]
|
||||||
|
|
||||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||||
@@ -46,6 +47,13 @@ function isTokenLimitError(text: string): boolean {
|
|||||||
|
|
||||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||||
if (typeof err === "string") {
|
if (typeof err === "string") {
|
||||||
|
if (err.toLowerCase().includes("non-empty content")) {
|
||||||
|
return {
|
||||||
|
currentTokens: 0,
|
||||||
|
maxTokens: 0,
|
||||||
|
errorType: "non-empty content",
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isTokenLimitError(err)) {
|
if (isTokenLimitError(err)) {
|
||||||
const tokens = extractTokensFromMessage(err)
|
const tokens = extractTokensFromMessage(err)
|
||||||
return {
|
return {
|
||||||
@@ -142,6 +150,14 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (combinedText.toLowerCase().includes("non-empty content")) {
|
||||||
|
return {
|
||||||
|
currentTokens: 0,
|
||||||
|
maxTokens: 0,
|
||||||
|
errorType: "non-empty content",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isTokenLimitError(combinedText)) {
|
if (isTokenLimitError(combinedText)) {
|
||||||
return {
|
return {
|
||||||
currentTokens: 0,
|
currentTokens: 0,
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { xdgData } from "xdg-basedir"
|
import { xdgData } from "xdg-basedir"
|
||||||
|
|
||||||
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
let OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||||
|
|
||||||
|
// Fix for macOS where xdg-basedir points to ~/Library/Application Support
|
||||||
|
// but OpenCode (cli) uses ~/.local/share
|
||||||
|
if (process.platform === "darwin" && !existsSync(OPENCODE_STORAGE)) {
|
||||||
|
const localShare = join(homedir(), ".local", "share", "opencode", "storage")
|
||||||
|
if (existsSync(localShare)) {
|
||||||
|
OPENCODE_STORAGE = localShare
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||||
|
|
||||||
@@ -171,3 +182,76 @@ export function countTruncatedResults(sessionID: string): number {
|
|||||||
|
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AggressiveTruncateResult {
|
||||||
|
success: boolean
|
||||||
|
sufficient: boolean
|
||||||
|
truncatedCount: number
|
||||||
|
totalBytesRemoved: number
|
||||||
|
targetBytesToRemove: number
|
||||||
|
truncatedTools: Array<{ toolName: string; originalSize: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateUntilTargetTokens(
|
||||||
|
sessionID: string,
|
||||||
|
currentTokens: number,
|
||||||
|
maxTokens: number,
|
||||||
|
targetRatio: number = 0.8,
|
||||||
|
charsPerToken: number = 4
|
||||||
|
): AggressiveTruncateResult {
|
||||||
|
const targetTokens = Math.floor(maxTokens * targetRatio)
|
||||||
|
const tokensToReduce = currentTokens - targetTokens
|
||||||
|
const charsToReduce = tokensToReduce * charsPerToken
|
||||||
|
|
||||||
|
if (tokensToReduce <= 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sufficient: true,
|
||||||
|
truncatedCount: 0,
|
||||||
|
totalBytesRemoved: 0,
|
||||||
|
targetBytesToRemove: 0,
|
||||||
|
truncatedTools: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = findToolResultsBySize(sessionID)
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
sufficient: false,
|
||||||
|
truncatedCount: 0,
|
||||||
|
totalBytesRemoved: 0,
|
||||||
|
targetBytesToRemove: charsToReduce,
|
||||||
|
truncatedTools: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRemoved = 0
|
||||||
|
let truncatedCount = 0
|
||||||
|
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const truncateResult = truncateToolResult(result.partPath)
|
||||||
|
if (truncateResult.success) {
|
||||||
|
truncatedCount++
|
||||||
|
const removedSize = truncateResult.originalSize ?? result.outputSize
|
||||||
|
totalRemoved += removedSize
|
||||||
|
truncatedTools.push({
|
||||||
|
toolName: truncateResult.toolName ?? result.toolName,
|
||||||
|
originalSize: removedSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sufficient = totalRemoved >= charsToReduce
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: truncatedCount > 0,
|
||||||
|
sufficient,
|
||||||
|
truncatedCount,
|
||||||
|
totalBytesRemoved: totalRemoved,
|
||||||
|
targetBytesToRemove: charsToReduce,
|
||||||
|
truncatedTools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AutoCompactState {
|
|||||||
retryStateBySession: Map<string, RetryState>
|
retryStateBySession: Map<string, RetryState>
|
||||||
fallbackStateBySession: Map<string, FallbackState>
|
fallbackStateBySession: Map<string, FallbackState>
|
||||||
truncateStateBySession: Map<string, TruncateState>
|
truncateStateBySession: Map<string, TruncateState>
|
||||||
|
emptyContentAttemptBySession: Map<string, number>
|
||||||
compactionInProgress: Set<string>
|
compactionInProgress: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ export const FALLBACK_CONFIG = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const TRUNCATE_CONFIG = {
|
export const TRUNCATE_CONFIG = {
|
||||||
maxTruncateAttempts: 10,
|
maxTruncateAttempts: 20,
|
||||||
minOutputSizeToTruncate: 1000,
|
minOutputSizeToTruncate: 500,
|
||||||
|
targetTokenRatio: 0.5,
|
||||||
|
charsPerToken: 4,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||||
export { createSessionNotification } from "./session-notification";
|
export { createSessionNotification } from "./session-notification";
|
||||||
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
|
export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery";
|
||||||
export { createCommentCheckerHooks } from "./comment-checker";
|
export { createCommentCheckerHooks } from "./comment-checker";
|
||||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||||
export { createToolOutputTruncatorHook } from "./tool-output-truncator";
|
export { createToolOutputTruncatorHook } from "./tool-output-truncator";
|
||||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||||
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
||||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
export { createAnthropicAutoCompactHook, type AnthropicAutoCompactOptions } from "./anthropic-auto-compact";
|
||||||
export { createThinkModeHook } from "./think-mode";
|
export { createThinkModeHook } from "./think-mode";
|
||||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
export { createRulesInjectorHook } from "./rules-injector";
|
export { createRulesInjectorHook } from "./rules-injector";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import type { ExperimentalConfig } from "../../config"
|
||||||
import {
|
import {
|
||||||
findEmptyMessages,
|
findEmptyMessages,
|
||||||
findEmptyMessageByIndex,
|
findEmptyMessageByIndex,
|
||||||
@@ -14,7 +15,11 @@ import {
|
|||||||
replaceEmptyTextParts,
|
replaceEmptyTextParts,
|
||||||
stripThinkingParts,
|
stripThinkingParts,
|
||||||
} from "./storage"
|
} from "./storage"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData, ResumeConfig } from "./types"
|
||||||
|
|
||||||
|
export interface SessionRecoveryOptions {
|
||||||
|
experimental?: ExperimentalConfig
|
||||||
|
}
|
||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
@@ -22,7 +27,6 @@ type RecoveryErrorType =
|
|||||||
| "tool_result_missing"
|
| "tool_result_missing"
|
||||||
| "thinking_block_order"
|
| "thinking_block_order"
|
||||||
| "thinking_disabled_violation"
|
| "thinking_disabled_violation"
|
||||||
| "empty_content_message"
|
|
||||||
| null
|
| null
|
||||||
|
|
||||||
interface MessageInfo {
|
interface MessageInfo {
|
||||||
@@ -49,6 +53,41 @@ interface MessagePart {
|
|||||||
input?: Record<string, unknown>
|
input?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||||
|
|
||||||
|
function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].info?.role === "user") {
|
||||||
|
return messages[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
|
||||||
|
return {
|
||||||
|
sessionID,
|
||||||
|
agent: userMessage?.info?.agent,
|
||||||
|
model: userMessage?.info?.model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await client.session.prompt({
|
||||||
|
path: { id: config.sessionID },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
|
||||||
|
agent: config.agent,
|
||||||
|
model: config.model,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
if (!error) return ""
|
if (!error) return ""
|
||||||
if (typeof error === "string") return error.toLowerCase()
|
if (typeof error === "string") return error.toLowerCase()
|
||||||
@@ -104,15 +143,6 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
|||||||
return "thinking_disabled_violation"
|
return "thinking_disabled_violation"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
message.includes("non-empty content") ||
|
|
||||||
message.includes("must have non-empty content") ||
|
|
||||||
(message.includes("content") && message.includes("is empty")) ||
|
|
||||||
(message.includes("content field") && message.includes("empty"))
|
|
||||||
) {
|
|
||||||
return "empty_content_message"
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +316,9 @@ export interface SessionRecoveryHook {
|
|||||||
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
|
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
|
export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
|
||||||
const processingErrors = new Set<string>()
|
const processingErrors = new Set<string>()
|
||||||
|
const experimental = options?.experimental
|
||||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||||
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
|
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
|
||||||
|
|
||||||
@@ -338,13 +369,11 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
|
|||||||
tool_result_missing: "Tool Crash Recovery",
|
tool_result_missing: "Tool Crash Recovery",
|
||||||
thinking_block_order: "Thinking Block Recovery",
|
thinking_block_order: "Thinking Block Recovery",
|
||||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||||
empty_content_message: "Empty Message Recovery",
|
|
||||||
}
|
}
|
||||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||||
tool_result_missing: "Injecting cancelled tool results...",
|
tool_result_missing: "Injecting cancelled tool results...",
|
||||||
thinking_block_order: "Fixing message structure...",
|
thinking_block_order: "Fixing message structure...",
|
||||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||||
empty_content_message: "Fixing empty message...",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.client.tui
|
await ctx.client.tui
|
||||||
@@ -364,13 +393,21 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
|
|||||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||||
} else if (errorType === "thinking_block_order") {
|
} else if (errorType === "thinking_block_order") {
|
||||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||||
|
if (success && experimental?.auto_resume) {
|
||||||
|
const lastUser = findLastUserMessage(msgs ?? [])
|
||||||
|
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||||
|
await resumeSession(ctx.client, resumeConfig)
|
||||||
|
}
|
||||||
} else if (errorType === "thinking_disabled_violation") {
|
} else if (errorType === "thinking_disabled_violation") {
|
||||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||||
} else if (errorType === "empty_content_message") {
|
if (success && experimental?.auto_resume) {
|
||||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
const lastUser = findLastUserMessage(msgs ?? [])
|
||||||
|
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||||
|
await resumeSession(ctx.client, resumeConfig)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[session-recovery] Recovery failed:", err)
|
console.error("[session-recovery] Recovery failed:", err)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export interface MessageData {
|
|||||||
sessionID?: string
|
sessionID?: string
|
||||||
parentID?: string
|
parentID?: string
|
||||||
error?: unknown
|
error?: unknown
|
||||||
|
agent?: string
|
||||||
|
model?: {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
}
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
parts?: Array<{
|
parts?: Array<{
|
||||||
type: string
|
type: string
|
||||||
@@ -80,3 +87,12 @@ export interface MessageData {
|
|||||||
callID?: string
|
callID?: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResumeConfig {
|
||||||
|
sessionID: string
|
||||||
|
agent?: string
|
||||||
|
model?: {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
? createContextWindowMonitorHook(ctx)
|
? createContextWindowMonitorHook(ctx)
|
||||||
: null;
|
: null;
|
||||||
const sessionRecovery = isHookEnabled("session-recovery")
|
const sessionRecovery = isHookEnabled("session-recovery")
|
||||||
? createSessionRecoveryHook(ctx)
|
? createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })
|
||||||
: null;
|
: null;
|
||||||
const sessionNotification = isHookEnabled("session-notification")
|
const sessionNotification = isHookEnabled("session-notification")
|
||||||
? createSessionNotification(ctx)
|
? createSessionNotification(ctx)
|
||||||
@@ -212,7 +212,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||||
});
|
});
|
||||||
const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact")
|
const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact")
|
||||||
? createAnthropicAutoCompactHook(ctx)
|
? createAnthropicAutoCompactHook(ctx, { experimental: pluginConfig.experimental })
|
||||||
: null;
|
: null;
|
||||||
const rulesInjector = isHookEnabled("rules-injector")
|
const rulesInjector = isHookEnabled("rules-injector")
|
||||||
? createRulesInjectorHook(ctx)
|
? createRulesInjectorHook(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user