openrecall 0.2.2 → 0.4.0
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.
- package/package.json +8 -2
- package/src/agent.ts +97 -11
- package/src/dcp/auth.ts +37 -0
- package/src/dcp/commands/context.ts +265 -0
- package/src/dcp/commands/help.ts +73 -0
- package/src/dcp/commands/manual.ts +131 -0
- package/src/dcp/commands/stats.ts +73 -0
- package/src/dcp/commands/sweep.ts +263 -0
- package/src/dcp/config.ts +981 -0
- package/src/dcp/hooks.ts +224 -0
- package/src/dcp/index.ts +123 -0
- package/src/dcp/logger.ts +211 -0
- package/src/dcp/messages/index.ts +2 -0
- package/src/dcp/messages/inject.ts +316 -0
- package/src/dcp/messages/prune.ts +217 -0
- package/src/dcp/messages/utils.ts +269 -0
- package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
- package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
- package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
- package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
- package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
- package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
- package/src/dcp/prompts/index.ts +59 -0
- package/src/dcp/protected-file-patterns.ts +113 -0
- package/src/dcp/shared-utils.ts +26 -0
- package/src/dcp/state/index.ts +3 -0
- package/src/dcp/state/persistence.ts +196 -0
- package/src/dcp/state/state.ts +143 -0
- package/src/dcp/state/tool-cache.ts +112 -0
- package/src/dcp/state/types.ts +55 -0
- package/src/dcp/state/utils.ts +55 -0
- package/src/dcp/strategies/deduplication.ts +123 -0
- package/src/dcp/strategies/index.ts +4 -0
- package/src/dcp/strategies/purge-errors.ts +84 -0
- package/src/dcp/strategies/supersede-writes.ts +115 -0
- package/src/dcp/strategies/utils.ts +135 -0
- package/src/dcp/tools/compress.ts +218 -0
- package/src/dcp/tools/distill.ts +60 -0
- package/src/dcp/tools/index.ts +4 -0
- package/src/dcp/tools/prune-shared.ts +174 -0
- package/src/dcp/tools/prune.ts +36 -0
- package/src/dcp/tools/types.ts +11 -0
- package/src/dcp/tools/utils.ts +244 -0
- package/src/dcp/ui/notification.ts +273 -0
- package/src/dcp/ui/utils.ts +133 -0
- package/src/index.ts +101 -49
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import type { PruneToolContext } from "./types"
|
|
3
|
+
import { executePruneOperation } from "./prune-shared"
|
|
4
|
+
import type { PruneReason } from "../ui/notification"
|
|
5
|
+
import { loadPrompt } from "../prompts"
|
|
6
|
+
|
|
7
|
+
const DISTILL_TOOL_DESCRIPTION = loadPrompt("distill-tool-spec")
|
|
8
|
+
|
|
9
|
+
export function createDistillTool(ctx: PruneToolContext): ReturnType<typeof tool> {
|
|
10
|
+
return tool({
|
|
11
|
+
description: DISTILL_TOOL_DESCRIPTION,
|
|
12
|
+
args: {
|
|
13
|
+
targets: tool.schema
|
|
14
|
+
.array(
|
|
15
|
+
tool.schema.object({
|
|
16
|
+
id: tool.schema
|
|
17
|
+
.string()
|
|
18
|
+
.describe("Numeric ID from the <prunable-tools> list"),
|
|
19
|
+
distillation: tool.schema
|
|
20
|
+
.string()
|
|
21
|
+
.describe("Complete technical distillation for this tool output"),
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
.describe("Tool outputs to distill, each pairing an ID with its distillation"),
|
|
25
|
+
},
|
|
26
|
+
async execute(args, toolCtx) {
|
|
27
|
+
if (!args.targets || !Array.isArray(args.targets) || args.targets.length === 0) {
|
|
28
|
+
ctx.logger.debug("Distill tool called without targets: " + JSON.stringify(args))
|
|
29
|
+
throw new Error("Missing targets. Provide at least one { id, distillation } entry.")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const target of args.targets) {
|
|
33
|
+
if (!target.id || typeof target.id !== "string" || target.id.trim() === "") {
|
|
34
|
+
ctx.logger.debug("Distill target missing id: " + JSON.stringify(target))
|
|
35
|
+
throw new Error(
|
|
36
|
+
"Each target must have an id (numeric string from <prunable-tools>).",
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (!target.distillation || typeof target.distillation !== "string") {
|
|
40
|
+
ctx.logger.debug(
|
|
41
|
+
"Distill target missing distillation: " + JSON.stringify(target),
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Each target must have a distillation string.")
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ids = args.targets.map((t) => t.id)
|
|
48
|
+
const distillations = args.targets.map((t) => t.distillation)
|
|
49
|
+
|
|
50
|
+
return executePruneOperation(
|
|
51
|
+
ctx,
|
|
52
|
+
toolCtx,
|
|
53
|
+
ids,
|
|
54
|
+
"extraction" as PruneReason,
|
|
55
|
+
"Distill",
|
|
56
|
+
distillations,
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { SessionState, ToolParameterEntry, WithParts } from "../state"
|
|
2
|
+
import type { PluginConfig } from "../config"
|
|
3
|
+
import type { Logger } from "../logger"
|
|
4
|
+
import type { PruneToolContext } from "./types"
|
|
5
|
+
import { syncToolCache } from "../state/tool-cache"
|
|
6
|
+
import type { PruneReason } from "../ui/notification"
|
|
7
|
+
import { sendUnifiedNotification } from "../ui/notification"
|
|
8
|
+
import { formatPruningResultForTool } from "../ui/utils"
|
|
9
|
+
import { ensureSessionInitialized } from "../state"
|
|
10
|
+
import { saveSessionState } from "../state/persistence"
|
|
11
|
+
import { getTotalToolTokens, getCurrentParams } from "../strategies/utils"
|
|
12
|
+
import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
|
|
13
|
+
import { buildToolIdList } from "../messages/utils"
|
|
14
|
+
|
|
15
|
+
// Shared logic for executing prune operations.
|
|
16
|
+
export async function executePruneOperation(
|
|
17
|
+
ctx: PruneToolContext,
|
|
18
|
+
toolCtx: { sessionID: string },
|
|
19
|
+
ids: string[],
|
|
20
|
+
reason: PruneReason,
|
|
21
|
+
toolName: string,
|
|
22
|
+
distillation?: string[],
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const { client, state, logger, config, workingDirectory } = ctx
|
|
25
|
+
const sessionId = toolCtx.sessionID
|
|
26
|
+
|
|
27
|
+
logger.info(`${toolName} tool invoked`)
|
|
28
|
+
logger.info(JSON.stringify(reason ? { ids, reason } : { ids }))
|
|
29
|
+
|
|
30
|
+
if (!ids || ids.length === 0) {
|
|
31
|
+
logger.debug(`${toolName} tool called but ids is empty or undefined`)
|
|
32
|
+
throw new Error(
|
|
33
|
+
`No IDs provided. Check the <prunable-tools> list for available IDs to ${toolName.toLowerCase()}.`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const numericToolIds: number[] = ids
|
|
38
|
+
.map((id) => parseInt(id, 10))
|
|
39
|
+
.filter((n): n is number => !isNaN(n))
|
|
40
|
+
|
|
41
|
+
if (numericToolIds.length === 0) {
|
|
42
|
+
logger.debug(`No numeric tool IDs provided for ${toolName}: ` + JSON.stringify(ids))
|
|
43
|
+
throw new Error("No numeric IDs provided. Format: ids: [id1, id2, ...]")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fetch messages to calculate tokens and find current agent
|
|
47
|
+
const messagesResponse = await client.session.messages({
|
|
48
|
+
path: { id: sessionId },
|
|
49
|
+
})
|
|
50
|
+
const messages: WithParts[] = messagesResponse.data || messagesResponse
|
|
51
|
+
|
|
52
|
+
// These 3 are probably not needed as they should always be set in the message
|
|
53
|
+
// transform handler, but in case something causes state to reset, this is a safety net
|
|
54
|
+
await ensureSessionInitialized(
|
|
55
|
+
ctx.client,
|
|
56
|
+
state,
|
|
57
|
+
sessionId,
|
|
58
|
+
logger,
|
|
59
|
+
messages,
|
|
60
|
+
config.manualMode.enabled,
|
|
61
|
+
)
|
|
62
|
+
syncToolCache(state, config, logger, messages)
|
|
63
|
+
buildToolIdList(state, messages, logger)
|
|
64
|
+
|
|
65
|
+
const currentParams = getCurrentParams(state, messages, logger)
|
|
66
|
+
|
|
67
|
+
const toolIdList = state.toolIdList
|
|
68
|
+
|
|
69
|
+
const validNumericIds: number[] = []
|
|
70
|
+
const skippedIds: string[] = []
|
|
71
|
+
|
|
72
|
+
// Validate and filter IDs
|
|
73
|
+
for (const index of numericToolIds) {
|
|
74
|
+
// Validate that index is within bounds
|
|
75
|
+
if (index < 0 || index >= toolIdList.length) {
|
|
76
|
+
logger.debug(`Rejecting prune request - index out of bounds: ${index}`)
|
|
77
|
+
skippedIds.push(index.toString())
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const id = toolIdList[index]!
|
|
82
|
+
const metadata = state.toolParameters.get(id)
|
|
83
|
+
|
|
84
|
+
// Validate that all IDs exist in cache and aren't protected
|
|
85
|
+
// (rejects hallucinated IDs and turn-protected tools not shown in <prunable-tools>)
|
|
86
|
+
if (!metadata) {
|
|
87
|
+
logger.debug(
|
|
88
|
+
"Rejecting prune request - ID not in cache (turn-protected or hallucinated)",
|
|
89
|
+
{ index, id },
|
|
90
|
+
)
|
|
91
|
+
skippedIds.push(index.toString())
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const allProtectedTools = config.tools.settings.protectedTools
|
|
96
|
+
if (allProtectedTools.includes(metadata.tool)) {
|
|
97
|
+
logger.debug("Rejecting prune request - protected tool", {
|
|
98
|
+
index,
|
|
99
|
+
id,
|
|
100
|
+
tool: metadata.tool,
|
|
101
|
+
})
|
|
102
|
+
skippedIds.push(index.toString())
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
|
|
107
|
+
if (isProtected(filePaths, config.protectedFilePatterns)) {
|
|
108
|
+
logger.debug("Rejecting prune request - protected file path", {
|
|
109
|
+
index,
|
|
110
|
+
id,
|
|
111
|
+
tool: metadata.tool,
|
|
112
|
+
filePaths,
|
|
113
|
+
})
|
|
114
|
+
skippedIds.push(index.toString())
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
validNumericIds.push(index)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (validNumericIds.length === 0) {
|
|
122
|
+
const errorMsg =
|
|
123
|
+
skippedIds.length > 0
|
|
124
|
+
? `Invalid IDs provided: [${skippedIds.join(", ")}]. Only use numeric IDs from the <prunable-tools> list.`
|
|
125
|
+
: `No valid IDs provided to ${toolName.toLowerCase()}.`
|
|
126
|
+
throw new Error(errorMsg)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pruneToolIds: string[] = validNumericIds.map((index) => toolIdList[index]!)
|
|
130
|
+
for (const id of pruneToolIds) {
|
|
131
|
+
const entry = state.toolParameters.get(id)
|
|
132
|
+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toolMetadata = new Map<string, ToolParameterEntry>()
|
|
136
|
+
for (const id of pruneToolIds) {
|
|
137
|
+
const toolParameters = state.toolParameters.get(id)
|
|
138
|
+
if (toolParameters) {
|
|
139
|
+
toolMetadata.set(id, toolParameters)
|
|
140
|
+
} else {
|
|
141
|
+
logger.debug("No metadata found for ID", { id })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
state.stats.pruneTokenCounter += getTotalToolTokens(state, pruneToolIds)
|
|
146
|
+
|
|
147
|
+
await sendUnifiedNotification(
|
|
148
|
+
client,
|
|
149
|
+
logger,
|
|
150
|
+
config,
|
|
151
|
+
state,
|
|
152
|
+
sessionId,
|
|
153
|
+
pruneToolIds,
|
|
154
|
+
toolMetadata,
|
|
155
|
+
reason,
|
|
156
|
+
currentParams,
|
|
157
|
+
workingDirectory,
|
|
158
|
+
distillation,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
state.stats.totalPruneTokens += state.stats.pruneTokenCounter
|
|
162
|
+
state.stats.pruneTokenCounter = 0
|
|
163
|
+
state.nudgeCounter = 0
|
|
164
|
+
|
|
165
|
+
saveSessionState(state, logger).catch((err) =>
|
|
166
|
+
logger.error("Failed to persist state", { error: err.message }),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
let result = formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory)
|
|
170
|
+
if (skippedIds.length > 0) {
|
|
171
|
+
result += `\n\nNote: ${skippedIds.length} IDs were skipped (invalid, protected, or missing metadata): ${skippedIds.join(", ")}`
|
|
172
|
+
}
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import type { PruneToolContext } from "./types"
|
|
3
|
+
import { executePruneOperation } from "./prune-shared"
|
|
4
|
+
import type { PruneReason } from "../ui/notification"
|
|
5
|
+
import { loadPrompt } from "../prompts"
|
|
6
|
+
|
|
7
|
+
const PRUNE_TOOL_DESCRIPTION = loadPrompt("prune-tool-spec")
|
|
8
|
+
|
|
9
|
+
export function createPruneTool(ctx: PruneToolContext): ReturnType<typeof tool> {
|
|
10
|
+
return tool({
|
|
11
|
+
description: PRUNE_TOOL_DESCRIPTION,
|
|
12
|
+
args: {
|
|
13
|
+
ids: tool.schema
|
|
14
|
+
.array(tool.schema.string())
|
|
15
|
+
.describe("Numeric IDs as strings from the <prunable-tools> list to prune"),
|
|
16
|
+
},
|
|
17
|
+
async execute(args, toolCtx) {
|
|
18
|
+
if (!args.ids || !Array.isArray(args.ids) || args.ids.length === 0) {
|
|
19
|
+
ctx.logger.debug("Prune tool called without ids: " + JSON.stringify(args))
|
|
20
|
+
throw new Error("Missing ids. You must provide at least one ID to prune.")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!args.ids.every((id) => typeof id === "string" && id.trim() !== "")) {
|
|
24
|
+
ctx.logger.debug("Prune tool called with invalid ids: " + JSON.stringify(args))
|
|
25
|
+
throw new Error(
|
|
26
|
+
'Invalid ids. All IDs must be numeric strings (e.g., "1", "23") from the <prunable-tools> list.',
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const numericIds = args.ids
|
|
31
|
+
const reason = "noise"
|
|
32
|
+
|
|
33
|
+
return executePruneOperation(ctx, toolCtx, numericIds, reason, "Prune")
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SessionState } from "../state"
|
|
2
|
+
import type { PluginConfig } from "../config"
|
|
3
|
+
import type { Logger } from "../logger"
|
|
4
|
+
|
|
5
|
+
export interface PruneToolContext {
|
|
6
|
+
client: any
|
|
7
|
+
state: SessionState
|
|
8
|
+
logger: Logger
|
|
9
|
+
config: PluginConfig
|
|
10
|
+
workingDirectory: string
|
|
11
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { partial_ratio } from "fuzzball"
|
|
2
|
+
import type { WithParts } from "../state"
|
|
3
|
+
import type { Logger } from "../logger"
|
|
4
|
+
import { isIgnoredUserMessage } from "../messages/utils"
|
|
5
|
+
|
|
6
|
+
export interface FuzzyConfig {
|
|
7
|
+
minScore: number
|
|
8
|
+
minGap: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_FUZZY_CONFIG: FuzzyConfig = {
|
|
12
|
+
minScore: 95,
|
|
13
|
+
minGap: 15,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface MatchResult {
|
|
17
|
+
messageId: string
|
|
18
|
+
messageIndex: number
|
|
19
|
+
score: number
|
|
20
|
+
matchType: "exact" | "fuzzy"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractMessageContent(msg: WithParts): string {
|
|
24
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
25
|
+
let content = ""
|
|
26
|
+
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
const p = part as Record<string, unknown>
|
|
29
|
+
if ((part as any).ignored) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
switch (part.type) {
|
|
34
|
+
case "text":
|
|
35
|
+
case "reasoning":
|
|
36
|
+
if (typeof p.text === "string") {
|
|
37
|
+
content += " " + p.text
|
|
38
|
+
}
|
|
39
|
+
break
|
|
40
|
+
|
|
41
|
+
case "tool": {
|
|
42
|
+
const state = p.state as Record<string, unknown> | undefined
|
|
43
|
+
if (!state) break
|
|
44
|
+
|
|
45
|
+
// Include tool output (completed or error)
|
|
46
|
+
if (state.status === "completed" && typeof state.output === "string") {
|
|
47
|
+
content += " " + state.output
|
|
48
|
+
} else if (state.status === "error" && typeof state.error === "string") {
|
|
49
|
+
content += " " + state.error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Include tool input
|
|
53
|
+
if (state.input) {
|
|
54
|
+
content +=
|
|
55
|
+
" " +
|
|
56
|
+
(typeof state.input === "string"
|
|
57
|
+
? state.input
|
|
58
|
+
: JSON.stringify(state.input))
|
|
59
|
+
}
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case "compaction":
|
|
64
|
+
if (typeof p.summary === "string") {
|
|
65
|
+
content += " " + p.summary
|
|
66
|
+
}
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
case "subtask":
|
|
70
|
+
if (typeof p.summary === "string") {
|
|
71
|
+
content += " " + p.summary
|
|
72
|
+
}
|
|
73
|
+
if (typeof p.result === "string") {
|
|
74
|
+
content += " " + p.result
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return content
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findExactMatches(messages: WithParts[], searchString: string): MatchResult[] {
|
|
84
|
+
const matches: MatchResult[] = []
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < messages.length; i++) {
|
|
87
|
+
const msg = messages[i]!
|
|
88
|
+
if (isIgnoredUserMessage(msg)) {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const content = extractMessageContent(msg)
|
|
92
|
+
if (content.includes(searchString)) {
|
|
93
|
+
matches.push({
|
|
94
|
+
messageId: msg.info.id,
|
|
95
|
+
messageIndex: i,
|
|
96
|
+
score: 100,
|
|
97
|
+
matchType: "exact",
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return matches
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findFuzzyMatches(
|
|
106
|
+
messages: WithParts[],
|
|
107
|
+
searchString: string,
|
|
108
|
+
minScore: number,
|
|
109
|
+
): MatchResult[] {
|
|
110
|
+
const matches: MatchResult[] = []
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < messages.length; i++) {
|
|
113
|
+
const msg = messages[i]!
|
|
114
|
+
if (isIgnoredUserMessage(msg)) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const content = extractMessageContent(msg)
|
|
118
|
+
const score = partial_ratio(searchString, content)
|
|
119
|
+
if (score >= minScore) {
|
|
120
|
+
matches.push({
|
|
121
|
+
messageId: msg.info.id,
|
|
122
|
+
messageIndex: i,
|
|
123
|
+
score,
|
|
124
|
+
matchType: "fuzzy",
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return matches
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function findStringInMessages(
|
|
133
|
+
messages: WithParts[],
|
|
134
|
+
searchString: string,
|
|
135
|
+
logger: Logger,
|
|
136
|
+
stringType: "startString" | "endString",
|
|
137
|
+
fuzzyConfig: FuzzyConfig = DEFAULT_FUZZY_CONFIG,
|
|
138
|
+
): { messageId: string; messageIndex: number } {
|
|
139
|
+
const searchableMessages = messages.length > 1 ? messages.slice(0, -1) : messages
|
|
140
|
+
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined
|
|
141
|
+
|
|
142
|
+
const exactMatches = findExactMatches(searchableMessages, searchString)
|
|
143
|
+
|
|
144
|
+
if (exactMatches.length === 1) {
|
|
145
|
+
return { messageId: exactMatches[0]!.messageId, messageIndex: exactMatches[0]!.messageIndex }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (exactMatches.length > 1) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Found multiple matches for ${stringType}. ` +
|
|
151
|
+
`Provide more surrounding context to uniquely identify the intended match.`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const fuzzyMatches = findFuzzyMatches(searchableMessages, searchString, fuzzyConfig.minScore)
|
|
156
|
+
|
|
157
|
+
if (fuzzyMatches.length === 0) {
|
|
158
|
+
if (lastMessage && !isIgnoredUserMessage(lastMessage)) {
|
|
159
|
+
const lastMsgContent = extractMessageContent(lastMessage)
|
|
160
|
+
const lastMsgIndex = messages.length - 1
|
|
161
|
+
if (lastMsgContent.includes(searchString)) {
|
|
162
|
+
// logger.info(
|
|
163
|
+
// `${stringType} found in last message (last resort) at index ${lastMsgIndex}`,
|
|
164
|
+
// )
|
|
165
|
+
return {
|
|
166
|
+
messageId: lastMessage.info.id,
|
|
167
|
+
messageIndex: lastMsgIndex,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${stringType} not found in conversation. ` +
|
|
174
|
+
`Make sure the string exists and is spelled exactly as it appears.`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fuzzyMatches.sort((a, b) => b.score - a.score)
|
|
179
|
+
|
|
180
|
+
const best = fuzzyMatches[0]!
|
|
181
|
+
const secondBest = fuzzyMatches[1]
|
|
182
|
+
|
|
183
|
+
// Log fuzzy match candidates
|
|
184
|
+
// logger.info(
|
|
185
|
+
// `Fuzzy match for ${stringType}: best=${best.score}% (msg ${best.messageIndex})` +
|
|
186
|
+
// (secondBest
|
|
187
|
+
// ? `, secondBest=${secondBest.score}% (msg ${secondBest.messageIndex})`
|
|
188
|
+
// : ""),
|
|
189
|
+
// )
|
|
190
|
+
|
|
191
|
+
// Check confidence gap - best must be significantly better than second best
|
|
192
|
+
if (secondBest && best.score - secondBest.score < fuzzyConfig.minGap) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Found multiple matches for ${stringType}. ` +
|
|
195
|
+
`Provide more unique surrounding context to disambiguate.`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
logger.info(
|
|
200
|
+
`Fuzzy matched ${stringType} with ${best.score}% confidence at message index ${best.messageIndex}`,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return { messageId: best.messageId, messageIndex: best.messageIndex }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function collectToolIdsInRange(
|
|
207
|
+
messages: WithParts[],
|
|
208
|
+
startIndex: number,
|
|
209
|
+
endIndex: number,
|
|
210
|
+
): string[] {
|
|
211
|
+
const toolIds: string[] = []
|
|
212
|
+
|
|
213
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
214
|
+
const msg = messages[i]!
|
|
215
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
216
|
+
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
if (part.type === "tool" && part.callID) {
|
|
219
|
+
if (!toolIds.includes(part.callID)) {
|
|
220
|
+
toolIds.push(part.callID)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return toolIds
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function collectMessageIdsInRange(
|
|
230
|
+
messages: WithParts[],
|
|
231
|
+
startIndex: number,
|
|
232
|
+
endIndex: number,
|
|
233
|
+
): string[] {
|
|
234
|
+
const messageIds: string[] = []
|
|
235
|
+
|
|
236
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
237
|
+
const msgId = messages[i]!.info.id
|
|
238
|
+
if (!messageIds.includes(msgId)) {
|
|
239
|
+
messageIds.push(msgId)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return messageIds
|
|
244
|
+
}
|