Skip to content

Commit dd07886

Browse files
authored
Merge pull request #395 from Opencode-DCP/dev
merge dev into master
2 parents a49cf11 + d781339 commit dd07886

File tree

19 files changed

+1079
-467
lines changed

19 files changed

+1079
-467
lines changed

lib/config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,13 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
243243
actual: typeof config.turnProtection.turns,
244244
})
245245
}
246+
if (typeof config.turnProtection.turns === "number" && config.turnProtection.turns < 1) {
247+
errors.push({
248+
key: "turnProtection.turns",
249+
expected: "positive number (>= 1)",
250+
actual: `${config.turnProtection.turns}`,
251+
})
252+
}
246253
}
247254

248255
// Commands validator
@@ -326,6 +333,16 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
326333
actual: typeof tools.settings.nudgeFrequency,
327334
})
328335
}
336+
if (
337+
typeof tools.settings.nudgeFrequency === "number" &&
338+
tools.settings.nudgeFrequency < 1
339+
) {
340+
errors.push({
341+
key: "tools.settings.nudgeFrequency",
342+
expected: "positive number (>= 1)",
343+
actual: `${tools.settings.nudgeFrequency} (will be clamped to 1)`,
344+
})
345+
}
329346
if (
330347
tools.settings.protectedTools !== undefined &&
331348
!Array.isArray(tools.settings.protectedTools)
@@ -497,6 +514,17 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
497514
actual: typeof strategies.purgeErrors.turns,
498515
})
499516
}
517+
// Warn if turns is 0 or negative - will be clamped to 1
518+
if (
519+
typeof strategies.purgeErrors.turns === "number" &&
520+
strategies.purgeErrors.turns < 1
521+
) {
522+
errors.push({
523+
key: "strategies.purgeErrors.turns",
524+
expected: "positive number (>= 1)",
525+
actual: `${strategies.purgeErrors.turns} (will be clamped to 1)`,
526+
})
527+
}
500528
if (
501529
strategies.purgeErrors.protectedTools !== undefined &&
502530
!Array.isArray(strategies.purgeErrors.protectedTools)

lib/hooks.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { SessionState, WithParts } from "./state"
22
import type { Logger } from "./logger"
33
import type { PluginConfig } from "./config"
4+
import { assignMessageRefs } from "./message-ids"
45
import { syncToolCache } from "./state/tool-cache"
56
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
6-
import { prune, insertPruneToolContext } from "./messages"
7+
import { prune, insertPruneToolContext, insertMessageIdContext } from "./messages"
78
import { buildToolIdList, isIgnoredUserMessage } from "./messages/utils"
89
import { checkSession } from "./state"
910
import { renderSystemPrompt } from "./prompts"
@@ -13,7 +14,6 @@ import { handleHelpCommand } from "./commands/help"
1314
import { handleSweepCommand } from "./commands/sweep"
1415
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
1516
import { ensureSessionInitialized } from "./state/state"
16-
import { getCurrentParams } from "./strategies/utils"
1717

1818
const INTERNAL_AGENT_SIGNATURES = [
1919
"You are a title generator",
@@ -109,6 +109,8 @@ export function createChatMessageTransformHandler(
109109
return
110110
}
111111

112+
assignMessageRefs(state, output.messages)
113+
112114
syncToolCache(state, config, logger, output.messages)
113115
buildToolIdList(state, output.messages, logger)
114116

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

119121
prune(state, logger, config, output.messages)
120122
insertPruneToolContext(state, config, logger, output.messages)
123+
insertMessageIdContext(state, output.messages)
121124

122125
applyPendingManualTriggerPrompt(state, output.messages, logger)
123126

lib/message-ids.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { SessionState, WithParts } from "./state"
2+
3+
const MESSAGE_REF_REGEX = /^m(\d{4})$/
4+
const BLOCK_REF_REGEX = /^b([1-9]\d*)$/
5+
const MESSAGE_ID_TAG_NAME = "dcp-message-id"
6+
7+
const MESSAGE_REF_WIDTH = 4
8+
const MESSAGE_REF_MIN_INDEX = 0
9+
export const MESSAGE_REF_MAX_INDEX = 9999
10+
11+
export type ParsedBoundaryId =
12+
| {
13+
kind: "message"
14+
ref: string
15+
index: number
16+
}
17+
| {
18+
kind: "compressed-block"
19+
ref: string
20+
blockId: number
21+
}
22+
23+
export function formatMessageRef(index: number): string {
24+
if (
25+
!Number.isInteger(index) ||
26+
index < MESSAGE_REF_MIN_INDEX ||
27+
index > MESSAGE_REF_MAX_INDEX
28+
) {
29+
throw new Error(
30+
`Message ID index out of bounds: ${index}. Supported range is 0-${MESSAGE_REF_MAX_INDEX}.`,
31+
)
32+
}
33+
return `m${index.toString().padStart(MESSAGE_REF_WIDTH, "0")}`
34+
}
35+
36+
export function formatBlockRef(blockId: number): string {
37+
if (!Number.isInteger(blockId) || blockId < 1) {
38+
throw new Error(`Invalid block ID: ${blockId}`)
39+
}
40+
return `b${blockId}`
41+
}
42+
43+
export function parseMessageRef(ref: string): number | null {
44+
const normalized = ref.trim().toLowerCase()
45+
const match = normalized.match(MESSAGE_REF_REGEX)
46+
if (!match) {
47+
return null
48+
}
49+
const index = Number.parseInt(match[1], 10)
50+
return Number.isInteger(index) ? index : null
51+
}
52+
53+
export function parseBlockRef(ref: string): number | null {
54+
const normalized = ref.trim().toLowerCase()
55+
const match = normalized.match(BLOCK_REF_REGEX)
56+
if (!match) {
57+
return null
58+
}
59+
const id = Number.parseInt(match[1], 10)
60+
return Number.isInteger(id) ? id : null
61+
}
62+
63+
export function parseBoundaryId(id: string): ParsedBoundaryId | null {
64+
const normalized = id.trim().toLowerCase()
65+
const messageIndex = parseMessageRef(normalized)
66+
if (messageIndex !== null) {
67+
return {
68+
kind: "message",
69+
ref: formatMessageRef(messageIndex),
70+
index: messageIndex,
71+
}
72+
}
73+
74+
const blockId = parseBlockRef(normalized)
75+
if (blockId !== null) {
76+
return {
77+
kind: "compressed-block",
78+
ref: formatBlockRef(blockId),
79+
blockId,
80+
}
81+
}
82+
83+
return null
84+
}
85+
86+
export function formatMessageIdTag(ref: string): string {
87+
return `<${MESSAGE_ID_TAG_NAME}>${ref}</${MESSAGE_ID_TAG_NAME}>`
88+
}
89+
90+
export function assignMessageRefs(state: SessionState, messages: WithParts[]): number {
91+
let assigned = 0
92+
93+
for (const message of messages) {
94+
const rawMessageId = message.info.id
95+
if (typeof rawMessageId !== "string" || rawMessageId.length === 0) {
96+
continue
97+
}
98+
99+
const existingRef = state.messageIds.byRawId.get(rawMessageId)
100+
if (existingRef) {
101+
if (state.messageIds.byRef.get(existingRef) !== rawMessageId) {
102+
state.messageIds.byRef.set(existingRef, rawMessageId)
103+
}
104+
continue
105+
}
106+
107+
const ref = allocateNextMessageRef(state)
108+
state.messageIds.byRawId.set(rawMessageId, ref)
109+
state.messageIds.byRef.set(ref, rawMessageId)
110+
assigned++
111+
}
112+
113+
return assigned
114+
}
115+
116+
function allocateNextMessageRef(state: SessionState): string {
117+
let candidate = Number.isInteger(state.messageIds.nextRef)
118+
? Math.max(MESSAGE_REF_MIN_INDEX, state.messageIds.nextRef)
119+
: MESSAGE_REF_MIN_INDEX
120+
121+
while (candidate <= MESSAGE_REF_MAX_INDEX) {
122+
const ref = formatMessageRef(candidate)
123+
if (!state.messageIds.byRef.has(ref)) {
124+
state.messageIds.nextRef = candidate + 1
125+
return ref
126+
}
127+
candidate++
128+
}
129+
130+
throw new Error(
131+
`Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`,
132+
)
133+
}

lib/messages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { prune } from "./prune"
22
export { insertPruneToolContext } from "./inject"
3+
export { insertMessageIdContext } from "./inject"

lib/messages/inject.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import type { SessionState, WithParts } from "../state"
22
import type { Logger } from "../logger"
33
import type { PluginConfig } from "../config"
44
import type { UserMessage } from "@opencode-ai/sdk/v2"
5+
import { formatMessageIdTag } from "../message-ids"
56
import { renderNudge, renderCompressNudge } from "../prompts"
67
import {
78
extractParameterKey,
89
createSyntheticTextPart,
910
createSyntheticToolPart,
1011
isIgnoredUserMessage,
12+
appendMessageIdTagToToolOutput,
13+
findLastToolPart,
1114
} from "./utils"
1215
import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
1316
import { getLastUserMessage, isMessageCompacted } from "../shared-utils"
@@ -38,7 +41,7 @@ ${content}
3841
export const wrapCompressContext = (messageCount: number): string => `<compress-context>
3942
Compress available. Conversation: ${messageCount} messages.
4043
Compress collapses completed task sequences or exploration phases into summaries.
41-
Uses text boundaries [startString, endString, topic, summary].
44+
Uses ID boundaries [startId, endId, topic, summary].
4245
</compress-context>`
4346

4447
export const wrapCooldownMessage = (flags: {
@@ -274,7 +277,7 @@ export const insertPruneToolContext = (
274277
contentParts.push(renderCompressNudge())
275278
} else if (
276279
config.tools.settings.nudgeEnabled &&
277-
state.nudgeCounter >= config.tools.settings.nudgeFrequency
280+
state.nudgeCounter >= Math.max(1, config.tools.settings.nudgeFrequency)
278281
) {
279282
logger.info("Inserting prune nudge message")
280283
contentParts.push(getNudgeString(config))
@@ -291,8 +294,6 @@ export const insertPruneToolContext = (
291294
return
292295
}
293296

294-
const userInfo = lastUserMessage.info as UserMessage
295-
296297
const lastNonIgnoredMessage = messages.findLast(
297298
(msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)),
298299
)
@@ -306,11 +307,56 @@ export const insertPruneToolContext = (
306307
// For all other cases, append a synthetic tool part to the last message which works
307308
// across all models without disrupting their behavior.
308309
if (lastNonIgnoredMessage.info.role === "user") {
309-
const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent)
310+
const textPart = createSyntheticTextPart(
311+
lastNonIgnoredMessage,
312+
combinedContent,
313+
`${lastNonIgnoredMessage.info.id}:context`,
314+
)
310315
lastNonIgnoredMessage.parts.push(textPart)
311316
} else {
312-
const modelID = userInfo.model?.modelID || ""
313-
const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID)
317+
const toolPart = createSyntheticToolPart(
318+
lastNonIgnoredMessage,
319+
combinedContent,
320+
modelId ?? "",
321+
`${lastNonIgnoredMessage.info.id}:context`,
322+
)
314323
lastNonIgnoredMessage.parts.push(toolPart)
315324
}
316325
}
326+
327+
export const insertMessageIdContext = (state: SessionState, messages: WithParts[]): void => {
328+
const lastUserMessage = getLastUserMessage(messages)
329+
const toolModelId = lastUserMessage
330+
? ((lastUserMessage.info as UserMessage).model.modelID ?? "")
331+
: ""
332+
333+
for (const message of messages) {
334+
if (message.info.role === "user" && isIgnoredUserMessage(message)) {
335+
continue
336+
}
337+
338+
const messageRef = state.messageIds.byRawId.get(message.info.id)
339+
if (!messageRef) {
340+
continue
341+
}
342+
343+
const tag = formatMessageIdTag(messageRef)
344+
const messageIdSeed = `${message.info.id}:message-id:${messageRef}`
345+
346+
if (message.info.role === "user") {
347+
message.parts.push(createSyntheticTextPart(message, tag, messageIdSeed))
348+
continue
349+
}
350+
351+
if (message.info.role !== "assistant") {
352+
continue
353+
}
354+
355+
const lastToolPart = findLastToolPart(message)
356+
if (lastToolPart && appendMessageIdTagToToolOutput(lastToolPart, tag)) {
357+
continue
358+
}
359+
360+
message.parts.push(createSyntheticToolPart(message, tag, toolModelId, messageIdSeed))
361+
}
362+
}

lib/messages/prune.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const PRUNED_TOOL_OUTPUT_REPLACEMENT =
99
"[Output removed to save context - information superseded or no longer needed]"
1010
const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]"
1111
const PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]"
12-
const PRUNED_COMPRESS_INPUT_REPLACEMENT =
13-
"[compress content removed - topic retained for reference]"
12+
const PRUNED_COMPRESS_SUMMARY_REPLACEMENT =
13+
"[summary removed to save context - see injected compressed block]"
1414

1515
export const prune = (
1616
state: SessionState,
@@ -109,8 +109,9 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
109109
continue
110110
}
111111
if (part.tool === "compress" && part.state.status === "completed") {
112-
if (part.state.input?.content !== undefined) {
113-
part.state.input.content = PRUNED_COMPRESS_INPUT_REPLACEMENT
112+
const content = part.state.input?.content
113+
if (content && typeof content === "object" && "summary" in content) {
114+
content.summary = PRUNED_COMPRESS_SUMMARY_REPLACEMENT
114115
}
115116
continue
116117
}
@@ -187,8 +188,14 @@ const filterCompressedRanges = (
187188
if (userMessage) {
188189
const userInfo = userMessage.info as UserMessage
189190
const summaryContent = summary.summary
191+
const summarySeed = `${summary.blockId}:${summary.anchorMessageId}`
190192
result.push(
191-
createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant),
193+
createSyntheticUserMessage(
194+
userMessage,
195+
summaryContent,
196+
userInfo.variant,
197+
summarySeed,
198+
),
192199
)
193200

194201
logger.info("Injected compress summary", {

0 commit comments

Comments
 (0)