Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,13 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
actual: typeof config.turnProtection.turns,
})
}
if (typeof config.turnProtection.turns === "number" && config.turnProtection.turns < 1) {
errors.push({
key: "turnProtection.turns",
expected: "positive number (>= 1)",
actual: `${config.turnProtection.turns}`,
})
}
}

// Commands validator
Expand Down Expand Up @@ -326,6 +333,16 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
actual: typeof tools.settings.nudgeFrequency,
})
}
if (
typeof tools.settings.nudgeFrequency === "number" &&
tools.settings.nudgeFrequency < 1
) {
errors.push({
key: "tools.settings.nudgeFrequency",
expected: "positive number (>= 1)",
actual: `${tools.settings.nudgeFrequency} (will be clamped to 1)`,
})
}
if (
tools.settings.protectedTools !== undefined &&
!Array.isArray(tools.settings.protectedTools)
Expand Down Expand Up @@ -497,6 +514,17 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
actual: typeof strategies.purgeErrors.turns,
})
}
// Warn if turns is 0 or negative - will be clamped to 1
if (
typeof strategies.purgeErrors.turns === "number" &&
strategies.purgeErrors.turns < 1
) {
errors.push({
key: "strategies.purgeErrors.turns",
expected: "positive number (>= 1)",
actual: `${strategies.purgeErrors.turns} (will be clamped to 1)`,
})
}
if (
strategies.purgeErrors.protectedTools !== undefined &&
!Array.isArray(strategies.purgeErrors.protectedTools)
Expand Down
7 changes: 5 additions & 2 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { SessionState, WithParts } from "./state"
import type { Logger } from "./logger"
import type { PluginConfig } from "./config"
import { assignMessageRefs } from "./message-ids"
import { syncToolCache } from "./state/tool-cache"
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
import { prune, insertPruneToolContext } from "./messages"
import { prune, insertPruneToolContext, insertMessageIdContext } from "./messages"
import { buildToolIdList, isIgnoredUserMessage } from "./messages/utils"
import { checkSession } from "./state"
import { renderSystemPrompt } from "./prompts"
Expand All @@ -13,7 +14,6 @@ import { handleHelpCommand } from "./commands/help"
import { handleSweepCommand } from "./commands/sweep"
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
import { ensureSessionInitialized } from "./state/state"
import { getCurrentParams } from "./strategies/utils"

const INTERNAL_AGENT_SIGNATURES = [
"You are a title generator",
Expand Down Expand Up @@ -109,6 +109,8 @@ export function createChatMessageTransformHandler(
return
}

assignMessageRefs(state, output.messages)

syncToolCache(state, config, logger, output.messages)
buildToolIdList(state, output.messages, logger)

Expand All @@ -118,6 +120,7 @@ export function createChatMessageTransformHandler(

prune(state, logger, config, output.messages)
insertPruneToolContext(state, config, logger, output.messages)
insertMessageIdContext(state, output.messages)

applyPendingManualTriggerPrompt(state, output.messages, logger)

Expand Down
133 changes: 133 additions & 0 deletions lib/message-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { SessionState, WithParts } from "./state"

const MESSAGE_REF_REGEX = /^m(\d{4})$/
const BLOCK_REF_REGEX = /^b([1-9]\d*)$/
const MESSAGE_ID_TAG_NAME = "dcp-message-id"

const MESSAGE_REF_WIDTH = 4
const MESSAGE_REF_MIN_INDEX = 0
export const MESSAGE_REF_MAX_INDEX = 9999

export type ParsedBoundaryId =
| {
kind: "message"
ref: string
index: number
}
| {
kind: "compressed-block"
ref: string
blockId: number
}

export function formatMessageRef(index: number): string {
if (
!Number.isInteger(index) ||
index < MESSAGE_REF_MIN_INDEX ||
index > MESSAGE_REF_MAX_INDEX
) {
throw new Error(
`Message ID index out of bounds: ${index}. Supported range is 0-${MESSAGE_REF_MAX_INDEX}.`,
)
}
return `m${index.toString().padStart(MESSAGE_REF_WIDTH, "0")}`
}

export function formatBlockRef(blockId: number): string {
if (!Number.isInteger(blockId) || blockId < 1) {
throw new Error(`Invalid block ID: ${blockId}`)
}
return `b${blockId}`
}

export function parseMessageRef(ref: string): number | null {
const normalized = ref.trim().toLowerCase()
const match = normalized.match(MESSAGE_REF_REGEX)
if (!match) {
return null
}
const index = Number.parseInt(match[1], 10)
return Number.isInteger(index) ? index : null
}

export function parseBlockRef(ref: string): number | null {
const normalized = ref.trim().toLowerCase()
const match = normalized.match(BLOCK_REF_REGEX)
if (!match) {
return null
}
const id = Number.parseInt(match[1], 10)
return Number.isInteger(id) ? id : null
}

export function parseBoundaryId(id: string): ParsedBoundaryId | null {
const normalized = id.trim().toLowerCase()
const messageIndex = parseMessageRef(normalized)
if (messageIndex !== null) {
return {
kind: "message",
ref: formatMessageRef(messageIndex),
index: messageIndex,
}
}

const blockId = parseBlockRef(normalized)
if (blockId !== null) {
return {
kind: "compressed-block",
ref: formatBlockRef(blockId),
blockId,
}
}

return null
}

export function formatMessageIdTag(ref: string): string {
return `<${MESSAGE_ID_TAG_NAME}>${ref}</${MESSAGE_ID_TAG_NAME}>`
}

export function assignMessageRefs(state: SessionState, messages: WithParts[]): number {
let assigned = 0

for (const message of messages) {
const rawMessageId = message.info.id
if (typeof rawMessageId !== "string" || rawMessageId.length === 0) {
continue
}

const existingRef = state.messageIds.byRawId.get(rawMessageId)
if (existingRef) {
if (state.messageIds.byRef.get(existingRef) !== rawMessageId) {
state.messageIds.byRef.set(existingRef, rawMessageId)
}
continue
}

const ref = allocateNextMessageRef(state)
state.messageIds.byRawId.set(rawMessageId, ref)
state.messageIds.byRef.set(ref, rawMessageId)
assigned++
}

return assigned
}

function allocateNextMessageRef(state: SessionState): string {
let candidate = Number.isInteger(state.messageIds.nextRef)
? Math.max(MESSAGE_REF_MIN_INDEX, state.messageIds.nextRef)
: MESSAGE_REF_MIN_INDEX

while (candidate <= MESSAGE_REF_MAX_INDEX) {
const ref = formatMessageRef(candidate)
if (!state.messageIds.byRef.has(ref)) {
state.messageIds.nextRef = candidate + 1
return ref
}
candidate++
}

throw new Error(
`Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`,
)
}
1 change: 1 addition & 0 deletions lib/messages/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { prune } from "./prune"
export { insertPruneToolContext } from "./inject"
export { insertMessageIdContext } from "./inject"
60 changes: 53 additions & 7 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import type { SessionState, WithParts } from "../state"
import type { Logger } from "../logger"
import type { PluginConfig } from "../config"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { formatMessageIdTag } from "../message-ids"
import { renderNudge, renderCompressNudge } from "../prompts"
import {
extractParameterKey,
createSyntheticTextPart,
createSyntheticToolPart,
isIgnoredUserMessage,
appendMessageIdTagToToolOutput,
findLastToolPart,
} from "./utils"
import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
import { getLastUserMessage, isMessageCompacted } from "../shared-utils"
Expand Down Expand Up @@ -38,7 +41,7 @@ ${content}
export const wrapCompressContext = (messageCount: number): string => `<compress-context>
Compress available. Conversation: ${messageCount} messages.
Compress collapses completed task sequences or exploration phases into summaries.
Uses text boundaries [startString, endString, topic, summary].
Uses ID boundaries [startId, endId, topic, summary].
</compress-context>`

export const wrapCooldownMessage = (flags: {
Expand Down Expand Up @@ -274,7 +277,7 @@ export const insertPruneToolContext = (
contentParts.push(renderCompressNudge())
} else if (
config.tools.settings.nudgeEnabled &&
state.nudgeCounter >= config.tools.settings.nudgeFrequency
state.nudgeCounter >= Math.max(1, config.tools.settings.nudgeFrequency)
) {
logger.info("Inserting prune nudge message")
contentParts.push(getNudgeString(config))
Expand All @@ -291,8 +294,6 @@ export const insertPruneToolContext = (
return
}

const userInfo = lastUserMessage.info as UserMessage

const lastNonIgnoredMessage = messages.findLast(
(msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)),
)
Expand All @@ -306,11 +307,56 @@ export const insertPruneToolContext = (
// For all other cases, append a synthetic tool part to the last message which works
// across all models without disrupting their behavior.
if (lastNonIgnoredMessage.info.role === "user") {
const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent)
const textPart = createSyntheticTextPart(
lastNonIgnoredMessage,
combinedContent,
`${lastNonIgnoredMessage.info.id}:context`,
)
lastNonIgnoredMessage.parts.push(textPart)
} else {
const modelID = userInfo.model?.modelID || ""
const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID)
const toolPart = createSyntheticToolPart(
lastNonIgnoredMessage,
combinedContent,
modelId ?? "",
`${lastNonIgnoredMessage.info.id}:context`,
)
lastNonIgnoredMessage.parts.push(toolPart)
}
}

export const insertMessageIdContext = (state: SessionState, messages: WithParts[]): void => {
const lastUserMessage = getLastUserMessage(messages)
const toolModelId = lastUserMessage
? ((lastUserMessage.info as UserMessage).model.modelID ?? "")
: ""

for (const message of messages) {
if (message.info.role === "user" && isIgnoredUserMessage(message)) {
continue
}

const messageRef = state.messageIds.byRawId.get(message.info.id)
if (!messageRef) {
continue
}

const tag = formatMessageIdTag(messageRef)
const messageIdSeed = `${message.info.id}:message-id:${messageRef}`

if (message.info.role === "user") {
message.parts.push(createSyntheticTextPart(message, tag, messageIdSeed))
continue
}

if (message.info.role !== "assistant") {
continue
}

const lastToolPart = findLastToolPart(message)
if (lastToolPart && appendMessageIdTagToToolOutput(lastToolPart, tag)) {
continue
}

message.parts.push(createSyntheticToolPart(message, tag, toolModelId, messageIdSeed))
}
}
17 changes: 12 additions & 5 deletions lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const PRUNED_TOOL_OUTPUT_REPLACEMENT =
"[Output removed to save context - information superseded or no longer needed]"
const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]"
const PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]"
const PRUNED_COMPRESS_INPUT_REPLACEMENT =
"[compress content removed - topic retained for reference]"
const PRUNED_COMPRESS_SUMMARY_REPLACEMENT =
"[summary removed to save context - see injected compressed block]"

export const prune = (
state: SessionState,
Expand Down Expand Up @@ -109,8 +109,9 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
continue
}
if (part.tool === "compress" && part.state.status === "completed") {
if (part.state.input?.content !== undefined) {
part.state.input.content = PRUNED_COMPRESS_INPUT_REPLACEMENT
const content = part.state.input?.content
if (content && typeof content === "object" && "summary" in content) {
content.summary = PRUNED_COMPRESS_SUMMARY_REPLACEMENT
}
continue
}
Expand Down Expand Up @@ -187,8 +188,14 @@ const filterCompressedRanges = (
if (userMessage) {
const userInfo = userMessage.info as UserMessage
const summaryContent = summary.summary
const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`
result.push(
createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant),
createSyntheticUserMessage(
userMessage,
summaryContent,
userInfo.variant,
summarySeed,
),
)

logger.info("Injected compress summary", {
Expand Down
Loading