@tarquinen/opencode-dcp 3.2.4-beta0 → 3.2.5-beta0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,106 +0,0 @@
1
- import type { WithParts } from "../state"
2
- import { ensureSessionInitialized } from "../state"
3
- import { saveSessionState } from "../state/persistence"
4
- import { assignMessageRefs } from "../message-ids"
5
- import { isIgnoredUserMessage } from "../messages/query"
6
- import { deduplicate, purgeErrors } from "../strategies"
7
- import { getCurrentParams } from "../token-utils"
8
- import { sendCompressNotification } from "../ui/notification"
9
- import type { ToolContext } from "./types"
10
- import { buildSearchContext, fetchSessionMessages } from "./search"
11
- import type { SearchContext } from "./types"
12
- import { applyPendingCompressionDurations } from "./timing"
13
-
14
- interface RunContext {
15
- ask(input: {
16
- permission: string
17
- patterns: string[]
18
- always: string[]
19
- metadata: Record<string, unknown>
20
- }): Promise<void>
21
- metadata(input: { title: string }): void
22
- sessionID: string
23
- }
24
-
25
- export interface NotificationEntry {
26
- blockId: number
27
- runId: number
28
- summary: string
29
- summaryTokens: number
30
- }
31
-
32
- export interface PreparedSession {
33
- rawMessages: WithParts[]
34
- searchContext: SearchContext
35
- }
36
-
37
- export async function prepareSession(
38
- ctx: ToolContext,
39
- toolCtx: RunContext,
40
- title: string,
41
- ): Promise<PreparedSession> {
42
- if (ctx.state.manualMode && ctx.state.manualMode !== "compress-pending") {
43
- throw new Error(
44
- "Manual mode: compress blocked. Do not retry until `<compress triggered manually>` appears in user context.",
45
- )
46
- }
47
-
48
- await toolCtx.ask({
49
- permission: "compress",
50
- patterns: ["*"],
51
- always: ["*"],
52
- metadata: {},
53
- })
54
-
55
- toolCtx.metadata({ title })
56
-
57
- const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID)
58
-
59
- await ensureSessionInitialized(
60
- ctx.client,
61
- ctx.state,
62
- toolCtx.sessionID,
63
- ctx.logger,
64
- rawMessages,
65
- ctx.config.manualMode.enabled,
66
- )
67
-
68
- assignMessageRefs(ctx.state, rawMessages)
69
-
70
- deduplicate(ctx.state, ctx.logger, ctx.config, rawMessages)
71
- purgeErrors(ctx.state, ctx.logger, ctx.config, rawMessages)
72
-
73
- return {
74
- rawMessages,
75
- searchContext: buildSearchContext(ctx.state, rawMessages),
76
- }
77
- }
78
-
79
- export async function finalizeSession(
80
- ctx: ToolContext,
81
- toolCtx: RunContext,
82
- rawMessages: WithParts[],
83
- entries: NotificationEntry[],
84
- batchTopic: string | undefined,
85
- ): Promise<void> {
86
- ctx.state.manualMode = ctx.state.manualMode ? "active" : false
87
- applyPendingCompressionDurations(ctx.state)
88
- await saveSessionState(ctx.state, ctx.logger)
89
-
90
- const params = getCurrentParams(ctx.state, rawMessages, ctx.logger)
91
- const sessionMessageIds = rawMessages
92
- .filter((msg) => !isIgnoredUserMessage(msg))
93
- .map((msg) => msg.info.id)
94
-
95
- await sendCompressNotification(
96
- ctx.client,
97
- ctx.logger,
98
- ctx.config,
99
- ctx.state,
100
- toolCtx.sessionID,
101
- entries,
102
- batchTopic,
103
- sessionMessageIds,
104
- params,
105
- )
106
- }
@@ -1,154 +0,0 @@
1
- import type { SessionState } from "../state"
2
- import { isIgnoredUserMessage } from "../messages/query"
3
- import {
4
- getFilePathsFromParameters,
5
- isFilePathProtected,
6
- isToolNameProtected,
7
- } from "../protected-patterns"
8
- import {
9
- buildSubagentResultText,
10
- getSubAgentId,
11
- mergeSubagentResult,
12
- } from "../subagents/subagent-results"
13
- import { fetchSessionMessages } from "./search"
14
- import type { SearchContext, SelectionResolution } from "./types"
15
-
16
- export function appendProtectedUserMessages(
17
- summary: string,
18
- selection: SelectionResolution,
19
- searchContext: SearchContext,
20
- state: SessionState,
21
- enabled: boolean,
22
- ): string {
23
- if (!enabled) return summary
24
-
25
- const userTexts: string[] = []
26
-
27
- for (const messageId of selection.messageIds) {
28
- const existingCompressionEntry = state.prune.messages.byMessageId.get(messageId)
29
- if (existingCompressionEntry && existingCompressionEntry.activeBlockIds.length > 0) {
30
- continue
31
- }
32
-
33
- const message = searchContext.rawMessagesById.get(messageId)
34
- if (!message) continue
35
- if (message.info.role !== "user") continue
36
- if (isIgnoredUserMessage(message)) continue
37
-
38
- const parts = Array.isArray(message.parts) ? message.parts : []
39
- for (const part of parts) {
40
- if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
41
- userTexts.push(part.text)
42
- break
43
- }
44
- }
45
- }
46
-
47
- if (userTexts.length === 0) {
48
- return summary
49
- }
50
-
51
- const heading = "\n\nThe following user messages were sent in this conversation verbatim:"
52
- const body = userTexts.map((text) => `\n${text}`).join("")
53
- return summary + heading + body
54
- }
55
-
56
- export async function appendProtectedTools(
57
- client: any,
58
- state: SessionState,
59
- allowSubAgents: boolean,
60
- summary: string,
61
- selection: SelectionResolution,
62
- searchContext: SearchContext,
63
- protectedTools: string[],
64
- protectedFilePatterns: string[] = [],
65
- ): Promise<string> {
66
- const protectedOutputs: string[] = []
67
-
68
- for (const messageId of selection.messageIds) {
69
- const existingCompressionEntry = state.prune.messages.byMessageId.get(messageId)
70
- if (existingCompressionEntry && existingCompressionEntry.activeBlockIds.length > 0) {
71
- continue
72
- }
73
-
74
- const message = searchContext.rawMessagesById.get(messageId)
75
- if (!message) continue
76
-
77
- const parts = Array.isArray(message.parts) ? message.parts : []
78
- for (const part of parts) {
79
- if (part.type === "tool" && part.callID) {
80
- let isToolProtected = isToolNameProtected(part.tool, protectedTools)
81
-
82
- if (!isToolProtected && protectedFilePatterns.length > 0) {
83
- const filePaths = getFilePathsFromParameters(part.tool, part.state?.input)
84
- if (isFilePathProtected(filePaths, protectedFilePatterns)) {
85
- isToolProtected = true
86
- }
87
- }
88
-
89
- if (isToolProtected) {
90
- const title = `Tool: ${part.tool}`
91
- let output = ""
92
-
93
- if (part.state?.status === "completed" && part.state?.output) {
94
- output =
95
- typeof part.state.output === "string"
96
- ? part.state.output
97
- : JSON.stringify(part.state.output)
98
- }
99
-
100
- if (
101
- allowSubAgents &&
102
- part.tool === "task" &&
103
- part.state?.status === "completed" &&
104
- typeof part.state?.output === "string"
105
- ) {
106
- const cachedSubAgentResult = state.subAgentResultCache.get(part.callID)
107
-
108
- if (cachedSubAgentResult !== undefined) {
109
- if (cachedSubAgentResult) {
110
- output = mergeSubagentResult(
111
- part.state.output,
112
- cachedSubAgentResult,
113
- )
114
- }
115
- } else {
116
- const subAgentSessionId = getSubAgentId(part)
117
- if (subAgentSessionId) {
118
- let subAgentResultText = ""
119
- try {
120
- const subAgentMessages = await fetchSessionMessages(
121
- client,
122
- subAgentSessionId,
123
- )
124
- subAgentResultText = buildSubagentResultText(subAgentMessages)
125
- } catch {
126
- subAgentResultText = ""
127
- }
128
-
129
- if (subAgentResultText) {
130
- state.subAgentResultCache.set(part.callID, subAgentResultText)
131
- output = mergeSubagentResult(
132
- part.state.output,
133
- subAgentResultText,
134
- )
135
- }
136
- }
137
- }
138
- }
139
-
140
- if (output) {
141
- protectedOutputs.push(`\n### ${title}\n${output}`)
142
- }
143
- }
144
- }
145
- }
146
- }
147
-
148
- if (protectedOutputs.length === 0) {
149
- return summary
150
- }
151
-
152
- const heading = "\n\nThe following protected tools were used in this conversation as well:"
153
- return summary + heading + protectedOutputs.join("")
154
- }
@@ -1,308 +0,0 @@
1
- import type { CompressionBlock, SessionState } from "../state"
2
- import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search"
3
- import type {
4
- BoundaryReference,
5
- CompressRangeToolArgs,
6
- InjectedSummaryResult,
7
- ParsedBlockPlaceholder,
8
- ResolvedRangeCompression,
9
- SearchContext,
10
- } from "./types"
11
-
12
- const BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi
13
-
14
- export function validateArgs(args: CompressRangeToolArgs): void {
15
- if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
16
- throw new Error("topic is required and must be a non-empty string")
17
- }
18
-
19
- if (!Array.isArray(args.content) || args.content.length === 0) {
20
- throw new Error("content is required and must be a non-empty array")
21
- }
22
-
23
- for (let index = 0; index < args.content.length; index++) {
24
- const entry = args.content[index]
25
- const prefix = `content[${index}]`
26
-
27
- if (typeof entry?.startId !== "string" || entry.startId.trim().length === 0) {
28
- throw new Error(`${prefix}.startId is required and must be a non-empty string`)
29
- }
30
-
31
- if (typeof entry?.endId !== "string" || entry.endId.trim().length === 0) {
32
- throw new Error(`${prefix}.endId is required and must be a non-empty string`)
33
- }
34
-
35
- if (typeof entry?.summary !== "string" || entry.summary.trim().length === 0) {
36
- throw new Error(`${prefix}.summary is required and must be a non-empty string`)
37
- }
38
- }
39
- }
40
-
41
- export function resolveRanges(
42
- args: CompressRangeToolArgs,
43
- searchContext: SearchContext,
44
- state: SessionState,
45
- ): ResolvedRangeCompression[] {
46
- return args.content.map((entry, index) => {
47
- const normalizedEntry = {
48
- startId: entry.startId.trim(),
49
- endId: entry.endId.trim(),
50
- summary: entry.summary,
51
- }
52
-
53
- const { startReference, endReference } = resolveBoundaryIds(
54
- searchContext,
55
- state,
56
- normalizedEntry.startId,
57
- normalizedEntry.endId,
58
- )
59
- const selection = resolveSelection(searchContext, startReference, endReference)
60
-
61
- return {
62
- index,
63
- entry: normalizedEntry,
64
- selection,
65
- anchorMessageId: resolveAnchorMessageId(startReference),
66
- }
67
- })
68
- }
69
-
70
- export function validateNonOverlapping(plans: ResolvedRangeCompression[]): void {
71
- const sortedPlans = [...plans].sort(
72
- (left, right) =>
73
- left.selection.startReference.rawIndex - right.selection.startReference.rawIndex ||
74
- left.selection.endReference.rawIndex - right.selection.endReference.rawIndex ||
75
- left.index - right.index,
76
- )
77
-
78
- const issues: string[] = []
79
-
80
- for (let index = 1; index < sortedPlans.length; index++) {
81
- const previous = sortedPlans[index - 1]
82
- const current = sortedPlans[index]
83
- if (!previous || !current) {
84
- continue
85
- }
86
-
87
- if (current.selection.startReference.rawIndex > previous.selection.endReference.rawIndex) {
88
- continue
89
- }
90
-
91
- issues.push(
92
- `content[${previous.index}] (${previous.entry.startId}..${previous.entry.endId}) overlaps content[${current.index}] (${current.entry.startId}..${current.entry.endId}). Overlapping ranges cannot be compressed in the same batch.`,
93
- )
94
- }
95
-
96
- if (issues.length > 0) {
97
- throw new Error(
98
- issues.length === 1 ? issues[0] : issues.map((issue) => `- ${issue}`).join("\n"),
99
- )
100
- }
101
- }
102
-
103
- export function parseBlockPlaceholders(summary: string): ParsedBlockPlaceholder[] {
104
- const placeholders: ParsedBlockPlaceholder[] = []
105
- const regex = new RegExp(BLOCK_PLACEHOLDER_REGEX)
106
-
107
- let match: RegExpExecArray | null
108
- while ((match = regex.exec(summary)) !== null) {
109
- const full = match[0]
110
- const blockIdPart = match[1] || match[2]
111
- const parsed = Number.parseInt(blockIdPart, 10)
112
- if (!Number.isInteger(parsed)) {
113
- continue
114
- }
115
-
116
- placeholders.push({
117
- raw: full,
118
- blockId: parsed,
119
- startIndex: match.index,
120
- endIndex: match.index + full.length,
121
- })
122
- }
123
-
124
- return placeholders
125
- }
126
-
127
- export function validateSummaryPlaceholders(
128
- placeholders: ParsedBlockPlaceholder[],
129
- requiredBlockIds: number[],
130
- startReference: BoundaryReference,
131
- endReference: BoundaryReference,
132
- summaryByBlockId: Map<number, CompressionBlock>,
133
- ): number[] {
134
- const boundaryOptionalIds = new Set<number>()
135
- if (startReference.kind === "compressed-block") {
136
- if (startReference.blockId === undefined) {
137
- throw new Error("Failed to map boundary matches back to raw messages")
138
- }
139
- boundaryOptionalIds.add(startReference.blockId)
140
- }
141
- if (endReference.kind === "compressed-block") {
142
- if (endReference.blockId === undefined) {
143
- throw new Error("Failed to map boundary matches back to raw messages")
144
- }
145
- boundaryOptionalIds.add(endReference.blockId)
146
- }
147
-
148
- const strictRequiredIds = requiredBlockIds.filter((id) => !boundaryOptionalIds.has(id))
149
- const requiredSet = new Set(requiredBlockIds)
150
- const keptPlaceholderIds = new Set<number>()
151
- const validPlaceholders: ParsedBlockPlaceholder[] = []
152
-
153
- for (const placeholder of placeholders) {
154
- const isKnown = summaryByBlockId.has(placeholder.blockId)
155
- const isRequired = requiredSet.has(placeholder.blockId)
156
- const isDuplicate = keptPlaceholderIds.has(placeholder.blockId)
157
-
158
- if (isKnown && isRequired && !isDuplicate) {
159
- validPlaceholders.push(placeholder)
160
- keptPlaceholderIds.add(placeholder.blockId)
161
- }
162
- }
163
-
164
- placeholders.length = 0
165
- placeholders.push(...validPlaceholders)
166
-
167
- return strictRequiredIds.filter((id) => !keptPlaceholderIds.has(id))
168
- }
169
-
170
- export function injectBlockPlaceholders(
171
- summary: string,
172
- placeholders: ParsedBlockPlaceholder[],
173
- summaryByBlockId: Map<number, CompressionBlock>,
174
- startReference: BoundaryReference,
175
- endReference: BoundaryReference,
176
- ): InjectedSummaryResult {
177
- let cursor = 0
178
- let expanded = summary
179
- const consumed: number[] = []
180
- const consumedSeen = new Set<number>()
181
-
182
- if (placeholders.length > 0) {
183
- expanded = ""
184
- for (const placeholder of placeholders) {
185
- const target = summaryByBlockId.get(placeholder.blockId)
186
- if (!target) {
187
- throw new Error(`Compressed block not found: (b${placeholder.blockId})`)
188
- }
189
-
190
- expanded += summary.slice(cursor, placeholder.startIndex)
191
- expanded += restoreSummary(target.summary)
192
- cursor = placeholder.endIndex
193
-
194
- if (!consumedSeen.has(placeholder.blockId)) {
195
- consumedSeen.add(placeholder.blockId)
196
- consumed.push(placeholder.blockId)
197
- }
198
- }
199
-
200
- expanded += summary.slice(cursor)
201
- }
202
-
203
- expanded = injectBoundarySummary(
204
- expanded,
205
- startReference,
206
- "start",
207
- summaryByBlockId,
208
- consumed,
209
- consumedSeen,
210
- )
211
- expanded = injectBoundarySummary(
212
- expanded,
213
- endReference,
214
- "end",
215
- summaryByBlockId,
216
- consumed,
217
- consumedSeen,
218
- )
219
-
220
- return {
221
- expandedSummary: expanded,
222
- consumedBlockIds: consumed,
223
- }
224
- }
225
-
226
- export function appendMissingBlockSummaries(
227
- summary: string,
228
- missingBlockIds: number[],
229
- summaryByBlockId: Map<number, CompressionBlock>,
230
- consumedBlockIds: number[],
231
- ): InjectedSummaryResult {
232
- const consumedSeen = new Set<number>(consumedBlockIds)
233
- const consumed = [...consumedBlockIds]
234
-
235
- const missingSummaries: string[] = []
236
- for (const blockId of missingBlockIds) {
237
- if (consumedSeen.has(blockId)) {
238
- continue
239
- }
240
-
241
- const target = summaryByBlockId.get(blockId)
242
- if (!target) {
243
- throw new Error(`Compressed block not found: (b${blockId})`)
244
- }
245
-
246
- missingSummaries.push(`\n### (b${blockId})\n${restoreSummary(target.summary)}`)
247
- consumedSeen.add(blockId)
248
- consumed.push(blockId)
249
- }
250
-
251
- if (missingSummaries.length === 0) {
252
- return {
253
- expandedSummary: summary,
254
- consumedBlockIds: consumed,
255
- }
256
- }
257
-
258
- const heading =
259
- "\n\nThe following previously compressed summaries were also part of this conversation section:"
260
-
261
- return {
262
- expandedSummary: summary + heading + missingSummaries.join(""),
263
- consumedBlockIds: consumed,
264
- }
265
- }
266
-
267
- function restoreSummary(summary: string): string {
268
- const headerMatch = summary.match(/^\s*\[Compressed conversation(?: section)?(?: b\d+)?\]/i)
269
- if (!headerMatch) {
270
- return summary
271
- }
272
-
273
- const afterHeader = summary.slice(headerMatch[0].length)
274
- const withoutLeadingBreaks = afterHeader.replace(/^(?:\r?\n)+/, "")
275
- return withoutLeadingBreaks
276
- .replace(/(?:\r?\n)*<dcp-message-id>b\d+<\/dcp-message-id>\s*$/i, "")
277
- .replace(/(?:\r?\n)+$/, "")
278
- }
279
-
280
- function injectBoundarySummary(
281
- summary: string,
282
- reference: BoundaryReference,
283
- position: "start" | "end",
284
- summaryByBlockId: Map<number, CompressionBlock>,
285
- consumed: number[],
286
- consumedSeen: Set<number>,
287
- ): string {
288
- if (reference.kind !== "compressed-block" || reference.blockId === undefined) {
289
- return summary
290
- }
291
- if (consumedSeen.has(reference.blockId)) {
292
- return summary
293
- }
294
-
295
- const target = summaryByBlockId.get(reference.blockId)
296
- if (!target) {
297
- throw new Error(`Compressed block not found: (b${reference.blockId})`)
298
- }
299
-
300
- const injectedBody = restoreSummary(target.summary)
301
- const left = position === "start" ? injectedBody.trim() : summary.trim()
302
- const right = position === "start" ? summary.trim() : injectedBody.trim()
303
- const next = !left ? right : !right ? left : `${left}\n\n${right}`
304
-
305
- consumedSeen.add(reference.blockId)
306
- consumed.push(reference.blockId)
307
- return next
308
- }