@xtruder/oc-plugin-convergence-engine 0.1.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 +57 -0
- package/prompts.ts +164 -0
- package/server.ts +15 -0
- package/tui.tsx +784 -0
- package/types.ts +44 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@xtruder/oc-plugin-convergence-engine",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Convergence engine for OpenCode - automatically verifies and continues LLM work until tasks are truly complete",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server": {
|
|
10
|
+
"import": "./server.ts",
|
|
11
|
+
"config": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"maxRounds": 5,
|
|
14
|
+
"autoStart": false
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"./tui": {
|
|
18
|
+
"import": "./tui.tsx",
|
|
19
|
+
"config": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"sidebar": true,
|
|
22
|
+
"maxRounds": 5
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"server.ts",
|
|
28
|
+
"tui.tsx",
|
|
29
|
+
"prompts.ts",
|
|
30
|
+
"types.ts"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"opencode": ">=1.3.14"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@opencode-ai/plugin": "*",
|
|
37
|
+
"@opentui/core": "*",
|
|
38
|
+
"@opentui/solid": "*",
|
|
39
|
+
"solid-js": "*"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@opencode-ai/plugin": { "optional": true },
|
|
43
|
+
"@opentui/core": { "optional": true },
|
|
44
|
+
"@opentui/solid": { "optional": true },
|
|
45
|
+
"solid-js": { "optional": true }
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@opencode-ai/plugin": "^1.3.0",
|
|
49
|
+
"@opentui/core": "*",
|
|
50
|
+
"@opentui/solid": "^0.1.96",
|
|
51
|
+
"bun-types": "^1.3.11",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/prompts.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial verification prompt sent to a SEPARATE verifier session.
|
|
3
|
+
* The verifier receives an LLM-generated summary (from a forked session)
|
|
4
|
+
* and must actively look for problems rather than confirm success.
|
|
5
|
+
*/
|
|
6
|
+
export const VERIFIER_SYSTEM_PROMPT = `You are an independent code auditor and task verifier. Your job is to critically evaluate whether a task has been completed correctly and thoroughly.
|
|
7
|
+
|
|
8
|
+
You must be SKEPTICAL by default. Assume the work is incomplete until you can confirm otherwise through concrete evidence. Do not take the original assistant's claims at face value.
|
|
9
|
+
|
|
10
|
+
Your verification process:
|
|
11
|
+
1. Read the original task requirements carefully
|
|
12
|
+
2. Examine every file that was created or modified
|
|
13
|
+
3. Look for: missing requirements, incomplete implementations, errors, edge cases not handled, tests not written, build/lint issues
|
|
14
|
+
4. Run any available verification commands (tests, type checks, builds) to confirm correctness
|
|
15
|
+
5. Check that the output matches what was actually requested, not just what's close enough
|
|
16
|
+
|
|
17
|
+
IMPORTANT: Use the TodoWrite tool to track your verification progress. Create a todo for each verification step you plan to perform and update their status as you work through them. This gives visibility into what you are checking and how far along you are.
|
|
18
|
+
|
|
19
|
+
You are NOT the assistant that did the work. You are the reviewer. Be thorough and honest.`
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Prompt sent to a FORKED copy of the worker session to generate
|
|
23
|
+
* a structured summary of the work done. The fork has full conversation
|
|
24
|
+
* context, so the LLM can produce a proper summary.
|
|
25
|
+
*
|
|
26
|
+
* On round 1, produces a full summary. On subsequent rounds, receives the
|
|
27
|
+
* previous summary and only outputs what changed since then.
|
|
28
|
+
*/
|
|
29
|
+
export function buildSummarizePrompt(previousSummary?: string): string {
|
|
30
|
+
if (!previousSummary) {
|
|
31
|
+
return `Summarize everything that happened in this session for an independent code reviewer. Be factual and specific. Include:
|
|
32
|
+
|
|
33
|
+
1. **Original task**: What was the user's request?
|
|
34
|
+
2. **Actions taken**: What files were created, modified, or deleted? What commands were run?
|
|
35
|
+
3. **Current state**: What is the current state of the work? What exists on disk now?
|
|
36
|
+
4. **Open issues**: Were there any errors, failed tool calls, or unresolved problems?
|
|
37
|
+
5. **Completion claim**: Did you indicate the task was finished, or were you still working?
|
|
38
|
+
|
|
39
|
+
Be honest and include anything that went wrong or was left incomplete. Do not editorialize or justify -- just report the facts.`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `An independent code reviewer already has the following summary of this session from a previous verification round:
|
|
43
|
+
|
|
44
|
+
<previous-summary>
|
|
45
|
+
${previousSummary}
|
|
46
|
+
</previous-summary>
|
|
47
|
+
|
|
48
|
+
Your job is to produce a **delta summary** -- only describe what changed SINCE that summary was written. Focus on:
|
|
49
|
+
|
|
50
|
+
1. **New actions**: What files were created, modified, or deleted since the previous summary? What new commands were run?
|
|
51
|
+
2. **Fixes applied**: What issues from the previous round were addressed, and how?
|
|
52
|
+
3. **Remaining issues**: Are there any new errors, failed tool calls, or unresolved problems?
|
|
53
|
+
4. **Current state**: Has the overall state changed? Is the work now complete?
|
|
54
|
+
|
|
55
|
+
Do NOT repeat information already in the previous summary. Only report new or changed facts. Be concise and factual.`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The prompt sent to the verifier session for each verification round.
|
|
60
|
+
* Receives an LLM-generated summary from the forked session.
|
|
61
|
+
*/
|
|
62
|
+
export function buildVerificationPrompt(input: {
|
|
63
|
+
summary: string
|
|
64
|
+
hasFailedTools: boolean
|
|
65
|
+
round: number
|
|
66
|
+
customInstructions?: string
|
|
67
|
+
isDelta?: boolean
|
|
68
|
+
}): string {
|
|
69
|
+
const parts: string[] = []
|
|
70
|
+
|
|
71
|
+
parts.push(`## Verification Round ${input.round}`)
|
|
72
|
+
parts.push("")
|
|
73
|
+
if (input.isDelta) {
|
|
74
|
+
parts.push(`### Changes Since Last Round`)
|
|
75
|
+
parts.push(`The following describes only what changed since the previous verification round:`)
|
|
76
|
+
} else {
|
|
77
|
+
parts.push(`### Session Summary`)
|
|
78
|
+
}
|
|
79
|
+
parts.push(input.summary)
|
|
80
|
+
parts.push("")
|
|
81
|
+
|
|
82
|
+
if (input.hasFailedTools) {
|
|
83
|
+
parts.push(
|
|
84
|
+
`**WARNING**: Some tool calls failed during the previous round. Pay special attention to whether the failures were recovered from.`,
|
|
85
|
+
)
|
|
86
|
+
parts.push("")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (input.customInstructions) {
|
|
90
|
+
parts.push(`### Additional Verification Criteria`)
|
|
91
|
+
parts.push(input.customInstructions)
|
|
92
|
+
parts.push("")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
parts.push(`### Your Task`)
|
|
96
|
+
parts.push(
|
|
97
|
+
`Verify whether the original task has been fully and correctly completed. Be adversarial -- actively look for problems, missing pieces, and things that could be wrong.`,
|
|
98
|
+
)
|
|
99
|
+
parts.push("")
|
|
100
|
+
parts.push(`Specifically check:`)
|
|
101
|
+
parts.push(`1. Are ALL requirements from the original task addressed?`)
|
|
102
|
+
parts.push(
|
|
103
|
+
`2. Were any files that should have been created/modified missed?`,
|
|
104
|
+
)
|
|
105
|
+
parts.push(`3. Are there syntax errors, type errors, or broken imports?`)
|
|
106
|
+
parts.push(
|
|
107
|
+
`4. If tests were expected, do they exist and would they pass?`,
|
|
108
|
+
)
|
|
109
|
+
parts.push(`5. Are there any obvious bugs or logic errors?`)
|
|
110
|
+
parts.push(
|
|
111
|
+
`6. Is the implementation complete or are there TODOs/placeholders left?`,
|
|
112
|
+
)
|
|
113
|
+
parts.push("")
|
|
114
|
+
parts.push(
|
|
115
|
+
`After your analysis, you MUST end your response with exactly one of:`,
|
|
116
|
+
)
|
|
117
|
+
parts.push("")
|
|
118
|
+
parts.push(
|
|
119
|
+
`[CONVERGENCE:COMPLETE] - ONLY if you found zero issues after thorough review`,
|
|
120
|
+
)
|
|
121
|
+
parts.push(
|
|
122
|
+
`[CONVERGENCE:ISSUES] - If you found ANY issues, followed by a numbered list of specific problems that need fixing`,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return parts.join("\n")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Prompt sent back to the ORIGINAL session when the verifier finds issues.
|
|
130
|
+
* Tells the worker to fix the specific problems identified.
|
|
131
|
+
*/
|
|
132
|
+
export function buildFixPrompt(issues: string): string {
|
|
133
|
+
return `The convergence engine's independent verifier found the following issues with your work. Fix all of them:
|
|
134
|
+
|
|
135
|
+
${issues}
|
|
136
|
+
|
|
137
|
+
After fixing everything, confirm what you fixed.`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse the verifier's response. The verifier uses [CONVERGENCE:COMPLETE]
|
|
142
|
+
* or [CONVERGENCE:ISSUES] markers.
|
|
143
|
+
*/
|
|
144
|
+
export function parseVerifierResponse(text: string): {
|
|
145
|
+
complete: boolean
|
|
146
|
+
issues: string
|
|
147
|
+
} {
|
|
148
|
+
const completeMatch = text.includes("[CONVERGENCE:COMPLETE]")
|
|
149
|
+
const issuesMatch = text.includes("[CONVERGENCE:ISSUES]")
|
|
150
|
+
|
|
151
|
+
if (completeMatch && !issuesMatch) {
|
|
152
|
+
return { complete: true, issues: "" }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Extract everything after [CONVERGENCE:ISSUES]
|
|
156
|
+
const issuesIdx = text.indexOf("[CONVERGENCE:ISSUES]")
|
|
157
|
+
if (issuesIdx !== -1) {
|
|
158
|
+
const issues = text.slice(issuesIdx + "[CONVERGENCE:ISSUES]".length).trim()
|
|
159
|
+
return { complete: false, issues }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// No marker found -- treat as incomplete with the whole response as context
|
|
163
|
+
return { complete: false, issues: text }
|
|
164
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Plugin, PluginModule } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server plugin stub. The convergence engine runs entirely in the TUI plugin.
|
|
5
|
+
* This file exists to satisfy the package.json exports for ./server.
|
|
6
|
+
*/
|
|
7
|
+
const server: Plugin = async () => {
|
|
8
|
+
return {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The convergence engine server plugin module (stub -- logic is in TUI). */
|
|
12
|
+
export default {
|
|
13
|
+
id: "convergence-engine",
|
|
14
|
+
server,
|
|
15
|
+
} satisfies PluginModule & { id: string }
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createSignal, Show, For } from "solid-js"
|
|
4
|
+
import { readFileSync } from "node:fs"
|
|
5
|
+
import { resolve } from "node:path"
|
|
6
|
+
import { TextAttributes } from "@opentui/core"
|
|
7
|
+
import type { ConvergenceKVData, VerifierIssue, ConvergenceOptions } from "./types"
|
|
8
|
+
import { DEFAULT_OPTIONS } from "./types"
|
|
9
|
+
import {
|
|
10
|
+
VERIFIER_SYSTEM_PROMPT,
|
|
11
|
+
buildSummarizePrompt,
|
|
12
|
+
buildVerificationPrompt,
|
|
13
|
+
buildFixPrompt,
|
|
14
|
+
parseVerifierResponse,
|
|
15
|
+
} from "./prompts"
|
|
16
|
+
|
|
17
|
+
const KV_PREFIX = "convergence:"
|
|
18
|
+
|
|
19
|
+
/** Minimal type for message objects returned by the SDK. */
|
|
20
|
+
interface SessionMessage {
|
|
21
|
+
info: { role: "user" | "assistant" }
|
|
22
|
+
parts: Array<{ type: string; text?: string; status?: string }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ConvergenceDisplayState {
|
|
26
|
+
active: boolean
|
|
27
|
+
round: number
|
|
28
|
+
maxRounds: number
|
|
29
|
+
status: string
|
|
30
|
+
verifierSessionID?: string
|
|
31
|
+
issues: VerifierIssue[]
|
|
32
|
+
startedAt: number
|
|
33
|
+
processing: boolean
|
|
34
|
+
lastSummary?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse issue lines from the verifier's response. */
|
|
38
|
+
function parseIssueLines(issuesText: string): string[] {
|
|
39
|
+
const lines = issuesText.split("\n").filter((l) => l.trim())
|
|
40
|
+
const issues: string[] = []
|
|
41
|
+
let current = ""
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (/^\d+[\.\)]\s/.test(line.trim())) {
|
|
44
|
+
if (current) issues.push(current.trim())
|
|
45
|
+
current = line.trim().replace(/^\d+[\.\)]\s*/, "")
|
|
46
|
+
} else {
|
|
47
|
+
current += " " + line.trim()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (current) issues.push(current.trim())
|
|
51
|
+
return issues.length > 0 ? issues : [issuesText.trim()]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
55
|
+
const opts: ConvergenceOptions = {
|
|
56
|
+
...DEFAULT_OPTIONS,
|
|
57
|
+
...(options ?? {}),
|
|
58
|
+
} as ConvergenceOptions
|
|
59
|
+
|
|
60
|
+
const [state, setState] = createSignal<ConvergenceDisplayState | null>(null)
|
|
61
|
+
const [dialogOpen, setDialogOpen] = createSignal(false)
|
|
62
|
+
|
|
63
|
+
// Session todos from the coding session (tracked via todo.updated events)
|
|
64
|
+
interface SessionTodo { content: string; status: string; priority: string }
|
|
65
|
+
const [sessionTodos, setSessionTodos] = createSignal<SessionTodo[]>([])
|
|
66
|
+
|
|
67
|
+
// Track managed session IDs (forks, verifiers) to ignore their idle events
|
|
68
|
+
const managedSessions = new Set<string>()
|
|
69
|
+
|
|
70
|
+
// Abort controller for the current verification run
|
|
71
|
+
const [currentAbort, setCurrentAbort] = createSignal<AbortController | null>(null)
|
|
72
|
+
|
|
73
|
+
// Track whether the coding session completed normally (went busy then idle)
|
|
74
|
+
// vs was aborted. We only trigger verification after normal completion.
|
|
75
|
+
const [sessionWentBusy, setSessionWentBusy] = createSignal(false)
|
|
76
|
+
const [sessionAborted, setSessionAborted] = createSignal(false)
|
|
77
|
+
|
|
78
|
+
// Idle event resolvers for promptAndWait
|
|
79
|
+
const idleResolvers = new Map<string, () => void>()
|
|
80
|
+
|
|
81
|
+
/** Abort any in-flight verification/summarization and clean up. */
|
|
82
|
+
function abortVerification(): void {
|
|
83
|
+
const ac = currentAbort()
|
|
84
|
+
if (ac) {
|
|
85
|
+
ac.abort()
|
|
86
|
+
setCurrentAbort(null)
|
|
87
|
+
}
|
|
88
|
+
// Abort running LLM in all managed sessions (forks + verifier)
|
|
89
|
+
for (const sid of managedSessions) {
|
|
90
|
+
api.client.session.abort({ sessionID: sid }).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
idleResolvers.clear()
|
|
93
|
+
setSessionTodos([])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Load custom verification instructions
|
|
97
|
+
let customVerificationMd: string | undefined
|
|
98
|
+
try {
|
|
99
|
+
const dir = api.state.path.directory
|
|
100
|
+
customVerificationMd = readFileSync(resolve(dir, ".opencode", "verification.md"), "utf-8")
|
|
101
|
+
} catch { /* no custom instructions */ }
|
|
102
|
+
|
|
103
|
+
// --- KV helpers ---
|
|
104
|
+
|
|
105
|
+
function getKV(sessionID: string): ConvergenceKVData | undefined {
|
|
106
|
+
if (!api.kv.ready) return undefined
|
|
107
|
+
return api.kv.get<ConvergenceKVData | undefined>(`${KV_PREFIX}${sessionID}`, undefined)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setKV(sessionID: string, data: ConvergenceKVData): void {
|
|
111
|
+
if (!api.kv.ready) return
|
|
112
|
+
api.kv.set(`${KV_PREFIX}${sessionID}`, data)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clearKV(sessionID: string): void {
|
|
116
|
+
if (!api.kv.ready) return
|
|
117
|
+
api.kv.set(`${KV_PREFIX}${sessionID}`, undefined)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Dialog ---
|
|
121
|
+
|
|
122
|
+
function showVerifierDialog(): void {
|
|
123
|
+
// Always re-render the dialog to reflect status changes.
|
|
124
|
+
// If user dismissed with Esc (dialogOpen=false), reopen it.
|
|
125
|
+
setDialogOpen(true)
|
|
126
|
+
|
|
127
|
+
api.ui.dialog.replace(
|
|
128
|
+
() => {
|
|
129
|
+
const s = state()
|
|
130
|
+
if (!s) return <text>No convergence data</text>
|
|
131
|
+
|
|
132
|
+
const statusLabel = (): string => {
|
|
133
|
+
switch (s.status) {
|
|
134
|
+
case "summarizing": return "Summarizing session..."
|
|
135
|
+
case "verifying": return "Verifier reviewing your work..."
|
|
136
|
+
case "sending-fix": return "Sending fixes..."
|
|
137
|
+
default: return "Starting verification..."
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const unresolvedIssues = (): VerifierIssue[] =>
|
|
142
|
+
s.issues.filter((i) => !i.resolved)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
|
|
146
|
+
<text attributes={TextAttributes.BOLD} fg={api.theme.current.accent}>
|
|
147
|
+
{"~ Convergence Engine ~"}
|
|
148
|
+
</text>
|
|
149
|
+
<text fg={api.theme.current.warning ?? api.theme.current.accent}>
|
|
150
|
+
{` ${statusLabel()}`}
|
|
151
|
+
</text>
|
|
152
|
+
<text fg={api.theme.current.textMuted}>
|
|
153
|
+
{` Round ${s.round}/${s.maxRounds}`}
|
|
154
|
+
</text>
|
|
155
|
+
<Show when={unresolvedIssues().length > 0}>
|
|
156
|
+
<box marginTop={1} flexDirection="column">
|
|
157
|
+
<text attributes={TextAttributes.BOLD} fg={api.theme.current.error ?? api.theme.current.accent}>
|
|
158
|
+
{` Issues from previous round (${unresolvedIssues().length}):`}
|
|
159
|
+
</text>
|
|
160
|
+
<For each={unresolvedIssues().slice(0, 10)}>
|
|
161
|
+
{(issue) => (
|
|
162
|
+
<text fg={api.theme.current.text}>
|
|
163
|
+
{` [ ] ${issue.text.slice(0, 70)}`}
|
|
164
|
+
</text>
|
|
165
|
+
)}
|
|
166
|
+
</For>
|
|
167
|
+
</box>
|
|
168
|
+
</Show>
|
|
169
|
+
<Show when={sessionTodos().length > 0}>
|
|
170
|
+
{(() => {
|
|
171
|
+
const MAX_VISIBLE = 8
|
|
172
|
+
const todos = sessionTodos()
|
|
173
|
+
const completed = todos.filter((t) => t.status === "completed").length
|
|
174
|
+
const active = todos.filter((t) => t.status === "in_progress" || t.status === "pending")
|
|
175
|
+
const done = todos.filter((t) => t.status === "completed" || t.status === "cancelled")
|
|
176
|
+
// Show active todos first, then fill remaining slots with recent completed ones
|
|
177
|
+
const visible = [
|
|
178
|
+
...active,
|
|
179
|
+
...done.slice(-(MAX_VISIBLE - Math.min(active.length, MAX_VISIBLE))),
|
|
180
|
+
].slice(0, MAX_VISIBLE)
|
|
181
|
+
return (
|
|
182
|
+
<box marginTop={1} flexDirection="column">
|
|
183
|
+
<text fg={api.theme.current.accent}>
|
|
184
|
+
{` Verification tasks (${completed}/${todos.length} done):`}
|
|
185
|
+
</text>
|
|
186
|
+
<For each={visible}>
|
|
187
|
+
{(todo) => {
|
|
188
|
+
const icon = todo.status === "completed" ? "x"
|
|
189
|
+
: todo.status === "in_progress" ? "~"
|
|
190
|
+
: todo.status === "cancelled" ? "-"
|
|
191
|
+
: " "
|
|
192
|
+
const color = todo.status === "completed" ? api.theme.current.textMuted
|
|
193
|
+
: todo.status === "in_progress" ? (api.theme.current.warning ?? api.theme.current.accent)
|
|
194
|
+
: api.theme.current.text
|
|
195
|
+
return (
|
|
196
|
+
<text fg={color}>
|
|
197
|
+
{` [${icon}] ${todo.content.slice(0, 65)}`}
|
|
198
|
+
</text>
|
|
199
|
+
)
|
|
200
|
+
}}
|
|
201
|
+
</For>
|
|
202
|
+
<Show when={todos.length > MAX_VISIBLE && active.length > MAX_VISIBLE}>
|
|
203
|
+
<text fg={api.theme.current.textMuted}>
|
|
204
|
+
{` ... and ${active.length - MAX_VISIBLE} more pending`}
|
|
205
|
+
</text>
|
|
206
|
+
</Show>
|
|
207
|
+
</box>
|
|
208
|
+
)
|
|
209
|
+
})()}
|
|
210
|
+
</Show>
|
|
211
|
+
<text fg={api.theme.current.textMuted} marginTop={1}>
|
|
212
|
+
{" Press Esc to dismiss (verification continues)."}
|
|
213
|
+
</text>
|
|
214
|
+
</box>
|
|
215
|
+
)
|
|
216
|
+
},
|
|
217
|
+
() => setDialogOpen(false),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function hideVerifierDialog(): void {
|
|
222
|
+
api.ui.dialog.clear()
|
|
223
|
+
setDialogOpen(false)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function currentSessionID(): string | undefined {
|
|
227
|
+
const route = api.route.current
|
|
228
|
+
if (route.name === "session") {
|
|
229
|
+
return (route.params as Record<string, string>)?.sessionID
|
|
230
|
+
}
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function updateState(partial: Partial<ConvergenceDisplayState>): void {
|
|
235
|
+
const s = state()
|
|
236
|
+
if (!s) return
|
|
237
|
+
const updated = { ...s, ...partial }
|
|
238
|
+
setState(updated)
|
|
239
|
+
// Re-render dialog when status changes during active verification
|
|
240
|
+
if (partial.status && updated.processing) {
|
|
241
|
+
showVerifierDialog()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- promptAndWait: send prompt, wait for session.idle, read response ---
|
|
246
|
+
|
|
247
|
+
async function promptAndWait(
|
|
248
|
+
targetSessionID: string,
|
|
249
|
+
body: Record<string, unknown>,
|
|
250
|
+
ac: AbortController,
|
|
251
|
+
timeoutMs = 600_000,
|
|
252
|
+
): Promise<string> {
|
|
253
|
+
idleResolvers.delete(targetSessionID)
|
|
254
|
+
|
|
255
|
+
const idlePromise = new Promise<void>((resolve, reject) => {
|
|
256
|
+
idleResolvers.set(targetSessionID, resolve)
|
|
257
|
+
ac.signal.addEventListener("abort", () => {
|
|
258
|
+
api.client.session.abort({ sessionID: targetSessionID }).catch(() => {})
|
|
259
|
+
reject(new Error("verification cancelled"))
|
|
260
|
+
}, { once: true })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
await api.client.session.promptAsync({
|
|
264
|
+
sessionID: targetSessionID,
|
|
265
|
+
...(body as Record<string, unknown>),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
269
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
270
|
+
timer = setTimeout(() => reject(new Error("promptAndWait timed out")), timeoutMs)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
await Promise.race([idlePromise, timeoutPromise])
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(timer)
|
|
277
|
+
idleResolvers.delete(targetSessionID)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const messagesResp = await api.client.session.messages({
|
|
281
|
+
sessionID: targetSessionID,
|
|
282
|
+
limit: 5,
|
|
283
|
+
})
|
|
284
|
+
const messages = (messagesResp.data ?? []) as Array<SessionMessage>
|
|
285
|
+
const lastAssistant = [...messages]
|
|
286
|
+
.reverse()
|
|
287
|
+
.find((m) => m.info?.role === "assistant")
|
|
288
|
+
|
|
289
|
+
if (!lastAssistant) return ""
|
|
290
|
+
return lastAssistant.parts
|
|
291
|
+
.filter((p) => p.type === "text")
|
|
292
|
+
.map((p) => p.text ?? "")
|
|
293
|
+
.join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- Verification flow ---
|
|
297
|
+
|
|
298
|
+
async function runVerification(sessionID: string): Promise<void> {
|
|
299
|
+
const s = state()
|
|
300
|
+
if (!s || !s.active || s.processing) return
|
|
301
|
+
|
|
302
|
+
const round = s.round + 1
|
|
303
|
+
if (round > s.maxRounds) {
|
|
304
|
+
setState(null)
|
|
305
|
+
hideVerifierDialog()
|
|
306
|
+
api.ui.toast({ message: `Max rounds (${s.maxRounds}) reached`, variant: "warning" })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const ac = new AbortController()
|
|
311
|
+
setCurrentAbort(ac)
|
|
312
|
+
let vsID: string | undefined = s.verifierSessionID
|
|
313
|
+
let cancelled = false
|
|
314
|
+
|
|
315
|
+
updateState({ round, status: "summarizing", processing: true })
|
|
316
|
+
setSessionTodos([])
|
|
317
|
+
showVerifierDialog()
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
// Step 1: Fork and summarize
|
|
321
|
+
const forkResp = await api.client.session.fork({ sessionID })
|
|
322
|
+
const forkID = (forkResp.data as { id?: string })?.id
|
|
323
|
+
if (!forkID) {
|
|
324
|
+
setState(null)
|
|
325
|
+
hideVerifierDialog()
|
|
326
|
+
api.ui.toast({ message: "Convergence error: Failed to fork session", variant: "error" })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
managedSessions.add(forkID)
|
|
330
|
+
|
|
331
|
+
let summaryText: string
|
|
332
|
+
try {
|
|
333
|
+
const summarizePrompt = buildSummarizePrompt(s.lastSummary)
|
|
334
|
+
summaryText = await promptAndWait(forkID, {
|
|
335
|
+
parts: [{ type: "text" as const, text: summarizePrompt, synthetic: true }],
|
|
336
|
+
}, ac)
|
|
337
|
+
} finally {
|
|
338
|
+
managedSessions.delete(forkID)
|
|
339
|
+
try { await api.client.session.delete({ sessionID: forkID }) } catch { /* ok */ }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (ac.signal.aborted) { cancelled = true; return }
|
|
343
|
+
if (!summaryText) summaryText = "(summary generation produced no output)"
|
|
344
|
+
|
|
345
|
+
// Accumulate summaries so subsequent rounds retain full context
|
|
346
|
+
const cumulativeSummary = s.lastSummary
|
|
347
|
+
? `${s.lastSummary}\n\n--- Round ${round} Delta ---\n${summaryText}`
|
|
348
|
+
: summaryText
|
|
349
|
+
updateState({ lastSummary: cumulativeSummary })
|
|
350
|
+
|
|
351
|
+
// Step 2: Create or reuse verifier session
|
|
352
|
+
if (!vsID) {
|
|
353
|
+
let codingTitle = "session"
|
|
354
|
+
try {
|
|
355
|
+
const sessionResp = await api.client.session.get({ sessionID })
|
|
356
|
+
codingTitle = (sessionResp.data as { title?: string })?.title ?? "session"
|
|
357
|
+
} catch { /* use default */ }
|
|
358
|
+
|
|
359
|
+
const verifierResp = await api.client.session.create({
|
|
360
|
+
title: `[Verifier] ${codingTitle}`,
|
|
361
|
+
})
|
|
362
|
+
vsID = (verifierResp.data as { id?: string })?.id
|
|
363
|
+
if (!vsID) {
|
|
364
|
+
setState(null)
|
|
365
|
+
hideVerifierDialog()
|
|
366
|
+
api.ui.toast({ message: "Convergence error: Failed to create verifier session", variant: "error" })
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
updateState({ verifierSessionID: vsID })
|
|
370
|
+
}
|
|
371
|
+
managedSessions.add(vsID)
|
|
372
|
+
|
|
373
|
+
// Step 3: Send to verifier
|
|
374
|
+
updateState({ status: "verifying" })
|
|
375
|
+
|
|
376
|
+
const messagesResp = await api.client.session.messages({
|
|
377
|
+
sessionID,
|
|
378
|
+
limit: 3,
|
|
379
|
+
})
|
|
380
|
+
const recentMessages = (messagesResp.data ?? []) as Array<SessionMessage>
|
|
381
|
+
const lastAssistant = [...recentMessages]
|
|
382
|
+
.reverse()
|
|
383
|
+
.find((m) => m.info.role === "assistant")
|
|
384
|
+
const hasFailedTools =
|
|
385
|
+
lastAssistant?.parts?.some(
|
|
386
|
+
(p) => p.type === "tool" && p.status === "error",
|
|
387
|
+
) ?? false
|
|
388
|
+
|
|
389
|
+
const verificationPrompt = buildVerificationPrompt({
|
|
390
|
+
summary: summaryText,
|
|
391
|
+
hasFailedTools,
|
|
392
|
+
round,
|
|
393
|
+
customInstructions: customVerificationMd,
|
|
394
|
+
isDelta: !!s.lastSummary,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// Only send the system prompt on the first round; the verifier session
|
|
398
|
+
// is persistent and retains it across subsequent messages
|
|
399
|
+
const verifierBody: Record<string, unknown> = {
|
|
400
|
+
parts: [{ type: "text" as const, text: verificationPrompt }],
|
|
401
|
+
}
|
|
402
|
+
if (!s.lastSummary) {
|
|
403
|
+
verifierBody.system = VERIFIER_SYSTEM_PROMPT
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const responseText = await promptAndWait(vsID, verifierBody, ac)
|
|
407
|
+
|
|
408
|
+
if (ac.signal.aborted) { cancelled = true; return }
|
|
409
|
+
|
|
410
|
+
if (!responseText) {
|
|
411
|
+
setState(null)
|
|
412
|
+
hideVerifierDialog()
|
|
413
|
+
api.ui.toast({ message: "Convergence error: Verifier returned empty response", variant: "error" })
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const result = parseVerifierResponse(responseText)
|
|
418
|
+
|
|
419
|
+
if (result.complete) {
|
|
420
|
+
const resolved = s.issues.map((i) => ({ ...i, resolved: true }))
|
|
421
|
+
if (vsID) {
|
|
422
|
+
setKV(sessionID, { verifierSessionID: vsID, round, status: "converged", issues: resolved, lastSummary: state()?.lastSummary })
|
|
423
|
+
}
|
|
424
|
+
// Reset round counter for the next convergence run but keep active
|
|
425
|
+
updateState({ round: 0, status: "idle", processing: false, issues: resolved, lastSummary: undefined })
|
|
426
|
+
hideVerifierDialog()
|
|
427
|
+
api.ui.toast({ message: `Convergence achieved after ${round} round${round > 1 ? "s" : ""}!`, variant: "success" })
|
|
428
|
+
} else {
|
|
429
|
+
// Verifier found issues
|
|
430
|
+
const issueLines = parseIssueLines(result.issues)
|
|
431
|
+
const newIssues: VerifierIssue[] = issueLines.map((text) => ({ round, text, resolved: false }))
|
|
432
|
+
const allIssues = [...s.issues, ...newIssues]
|
|
433
|
+
|
|
434
|
+
updateState({ status: "sending-fix", issues: allIssues })
|
|
435
|
+
|
|
436
|
+
if (vsID) {
|
|
437
|
+
setKV(sessionID, { verifierSessionID: vsID, round, status: "sending-fix", issues: allIssues, lastSummary: state()?.lastSummary })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const fixPrompt = buildFixPrompt(result.issues)
|
|
441
|
+
hideVerifierDialog()
|
|
442
|
+
|
|
443
|
+
const issueCount = issueLines.length
|
|
444
|
+
api.ui.toast({
|
|
445
|
+
message: `Verifier found ${issueCount} issue${issueCount > 1 ? "s" : ""} -- sending fixes`,
|
|
446
|
+
variant: "warning",
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// Send fix prompt -- triggers LLM response
|
|
450
|
+
await api.client.session.promptAsync({
|
|
451
|
+
sessionID,
|
|
452
|
+
parts: [{ type: "text" as const, text: fixPrompt, synthetic: true }],
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
updateState({ status: "waiting-for-fix", processing: false })
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// Only real errors reach here (network failures, unexpected exceptions)
|
|
459
|
+
// Cancellation is handled via the `cancelled` flag and early returns
|
|
460
|
+
if (ac.signal.aborted) {
|
|
461
|
+
cancelled = true
|
|
462
|
+
} else {
|
|
463
|
+
setState(null)
|
|
464
|
+
hideVerifierDialog()
|
|
465
|
+
api.ui.toast({ message: `Convergence error: ${String(err)}`, variant: "error" })
|
|
466
|
+
}
|
|
467
|
+
} finally {
|
|
468
|
+
setCurrentAbort(null)
|
|
469
|
+
if (vsID) managedSessions.delete(vsID)
|
|
470
|
+
if (cancelled) {
|
|
471
|
+
// Roll back round and reset to idle
|
|
472
|
+
updateState({ status: "idle", processing: false, round: Math.max(0, round - 1) })
|
|
473
|
+
hideVerifierDialog()
|
|
474
|
+
api.ui.toast({ message: "Verification cancelled", variant: "info" })
|
|
475
|
+
} else {
|
|
476
|
+
const s = state()
|
|
477
|
+
if (s?.processing) updateState({ processing: false })
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// --- Event handlers ---
|
|
483
|
+
|
|
484
|
+
// session.idle: resolve promptAndWait OR trigger verification
|
|
485
|
+
const offIdle = api.event.on("session.idle", async (event) => {
|
|
486
|
+
const sessionID = (event as { properties?: { sessionID?: string } }).properties?.sessionID
|
|
487
|
+
if (!sessionID) return
|
|
488
|
+
|
|
489
|
+
// Resolve promptAndWait for managed sessions (fork/verifier)
|
|
490
|
+
const resolver = idleResolvers.get(sessionID)
|
|
491
|
+
if (resolver) {
|
|
492
|
+
idleResolvers.delete(sessionID)
|
|
493
|
+
resolver()
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Ignore idle events from managed sessions
|
|
498
|
+
if (managedSessions.has(sessionID)) return
|
|
499
|
+
|
|
500
|
+
// Trigger verification for the current session
|
|
501
|
+
if (sessionID !== currentSessionID()) return
|
|
502
|
+
|
|
503
|
+
// Only trigger verification if the session completed normally (went busy
|
|
504
|
+
// then idle without being aborted). Reset tracking state either way.
|
|
505
|
+
const wasBusy = sessionWentBusy()
|
|
506
|
+
const wasAborted = sessionAborted()
|
|
507
|
+
setSessionWentBusy(false)
|
|
508
|
+
setSessionAborted(false)
|
|
509
|
+
|
|
510
|
+
if (!wasBusy || wasAborted) return
|
|
511
|
+
|
|
512
|
+
const s = state()
|
|
513
|
+
if (!s || !s.active || s.processing) return
|
|
514
|
+
|
|
515
|
+
await runVerification(sessionID)
|
|
516
|
+
})
|
|
517
|
+
api.lifecycle.onDispose(offIdle)
|
|
518
|
+
|
|
519
|
+
// session.status: track busy state for the coding session and cancel
|
|
520
|
+
// verification if the user submits a new prompt while it's running
|
|
521
|
+
const offStatus = api.event.on("session.status", (event) => {
|
|
522
|
+
const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties
|
|
523
|
+
if (props?.sessionID !== currentSessionID()) return
|
|
524
|
+
|
|
525
|
+
if (props?.status?.type === "busy") {
|
|
526
|
+
setSessionWentBusy(true)
|
|
527
|
+
setSessionAborted(false)
|
|
528
|
+
hideVerifierDialog()
|
|
529
|
+
|
|
530
|
+
const s = state()
|
|
531
|
+
if (s?.processing) {
|
|
532
|
+
abortVerification()
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
api.lifecycle.onDispose(offStatus)
|
|
537
|
+
|
|
538
|
+
// session.error: mark session as aborted so the next idle is skipped
|
|
539
|
+
const offError = api.event.on("session.error", (event) => {
|
|
540
|
+
const props = (event as { properties?: { sessionID?: string; error?: { name?: string; type?: string } } }).properties
|
|
541
|
+
if (!props?.sessionID || props.sessionID !== currentSessionID()) return
|
|
542
|
+
|
|
543
|
+
const errorName = props.error?.name ?? props.error?.type
|
|
544
|
+
if (errorName === "MessageAbortedError" || errorName === "message_aborted") {
|
|
545
|
+
setSessionAborted(true)
|
|
546
|
+
abortVerification()
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
api.lifecycle.onDispose(offError)
|
|
550
|
+
|
|
551
|
+
// todo.updated: track todos from managed sessions (fork/verifier)
|
|
552
|
+
const offTodo = api.event.on("todo.updated", (event) => {
|
|
553
|
+
const props = (event as { properties?: { sessionID?: string; todos?: SessionTodo[] } }).properties
|
|
554
|
+
if (!props?.sessionID) return
|
|
555
|
+
const s = state()
|
|
556
|
+
if (!s?.active || props.sessionID !== s.verifierSessionID) return
|
|
557
|
+
setSessionTodos(props.todos ?? [])
|
|
558
|
+
// Re-render dialog if it's open to show updated todos
|
|
559
|
+
if (dialogOpen()) showVerifierDialog()
|
|
560
|
+
})
|
|
561
|
+
api.lifecycle.onDispose(offTodo)
|
|
562
|
+
|
|
563
|
+
/** Fetch current todos for a session via the API. */
|
|
564
|
+
async function fetchSessionTodos(sessionID: string): Promise<void> {
|
|
565
|
+
try {
|
|
566
|
+
const resp = await api.client.session.todo({ sessionID })
|
|
567
|
+
setSessionTodos((resp.data ?? []) as SessionTodo[])
|
|
568
|
+
} catch {
|
|
569
|
+
setSessionTodos([])
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// --- Commands ---
|
|
574
|
+
|
|
575
|
+
api.command.register(() => [
|
|
576
|
+
{
|
|
577
|
+
title: "/converge - Toggle convergence engine",
|
|
578
|
+
value: "convergence-engine.converge",
|
|
579
|
+
category: "Convergence",
|
|
580
|
+
slash: { name: "converge" },
|
|
581
|
+
async onSelect() {
|
|
582
|
+
const sessionID = currentSessionID()
|
|
583
|
+
if (!sessionID) {
|
|
584
|
+
api.ui.toast({ message: "No active session", variant: "warning" })
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const current = state()
|
|
589
|
+
if (current?.active) {
|
|
590
|
+
abortVerification()
|
|
591
|
+
setState(null)
|
|
592
|
+
hideVerifierDialog()
|
|
593
|
+
api.ui.toast({ message: "Convergence engine stopped", variant: "info" })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const kvData = getKV(sessionID)
|
|
598
|
+
if (kvData?.verifierSessionID) {
|
|
599
|
+
const openCount = kvData.issues.filter((i: VerifierIssue) => !i.resolved).length
|
|
600
|
+
api.ui.dialog.replace(() => (
|
|
601
|
+
<api.ui.DialogSelect
|
|
602
|
+
title="Previous verifier session found"
|
|
603
|
+
options={[
|
|
604
|
+
{
|
|
605
|
+
title: "Resume convergence",
|
|
606
|
+
value: "resume",
|
|
607
|
+
description: `Reuse verifier session (${kvData.round} previous round${kvData.round > 1 ? "s" : ""}, ${openCount} open issue${openCount !== 1 ? "s" : ""})`,
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
title: "Start fresh",
|
|
611
|
+
value: "reset",
|
|
612
|
+
description: "Delete old verifier and start new convergence",
|
|
613
|
+
},
|
|
614
|
+
{ title: "Cancel", value: "cancel" },
|
|
615
|
+
]}
|
|
616
|
+
onSelect={async (option: { value: string }) => {
|
|
617
|
+
api.ui.dialog.clear()
|
|
618
|
+
if (option.value === "resume") {
|
|
619
|
+
managedSessions.add(kvData.verifierSessionID)
|
|
620
|
+
setState({
|
|
621
|
+
active: true, round: 0, maxRounds: opts.maxRounds,
|
|
622
|
+
status: "idle", verifierSessionID: kvData.verifierSessionID,
|
|
623
|
+
issues: kvData.issues, startedAt: Date.now(), processing: false,
|
|
624
|
+
lastSummary: kvData.lastSummary,
|
|
625
|
+
})
|
|
626
|
+
// Hydrate verifier todos from the resumed session
|
|
627
|
+
fetchSessionTodos(kvData.verifierSessionID)
|
|
628
|
+
api.ui.toast({ message: "Convergence resumed", variant: "success" })
|
|
629
|
+
} else if (option.value === "reset") {
|
|
630
|
+
try { await api.client.session.delete({ sessionID: kvData.verifierSessionID }) } catch { /* ok */ }
|
|
631
|
+
clearKV(sessionID)
|
|
632
|
+
setState({
|
|
633
|
+
active: true, round: 0, maxRounds: opts.maxRounds,
|
|
634
|
+
status: "idle", issues: [], startedAt: Date.now(), processing: false,
|
|
635
|
+
})
|
|
636
|
+
api.ui.toast({ message: "Convergence engine activated", variant: "success" })
|
|
637
|
+
}
|
|
638
|
+
}}
|
|
639
|
+
/>
|
|
640
|
+
))
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
setState({
|
|
645
|
+
active: true, round: 0, maxRounds: opts.maxRounds,
|
|
646
|
+
status: "idle", issues: [], startedAt: Date.now(), processing: false,
|
|
647
|
+
})
|
|
648
|
+
api.ui.toast({ message: "Convergence engine activated", variant: "success" })
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
title: "/converge-status - Show convergence progress",
|
|
653
|
+
value: "convergence-engine.status",
|
|
654
|
+
category: "Convergence",
|
|
655
|
+
slash: { name: "converge-status" },
|
|
656
|
+
onSelect() {
|
|
657
|
+
const s = state()
|
|
658
|
+
if (!s) {
|
|
659
|
+
api.ui.toast({ message: "Convergence not active", variant: "info" })
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
api.ui.dialog.replace(() => {
|
|
663
|
+
const unresolved = (): VerifierIssue[] => s.issues.filter((i) => !i.resolved)
|
|
664
|
+
const resolved = (): VerifierIssue[] => s.issues.filter((i) => i.resolved)
|
|
665
|
+
return (
|
|
666
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
|
|
667
|
+
<text attributes={TextAttributes.BOLD} fg={api.theme.current.accent}>{"~ Convergence Status ~"}</text>
|
|
668
|
+
<text fg={api.theme.current.text}>
|
|
669
|
+
{` Status: ${s.status} | Round ${s.round}/${s.maxRounds}`}
|
|
670
|
+
</text>
|
|
671
|
+
<Show when={s.verifierSessionID}>
|
|
672
|
+
<text fg={api.theme.current.textMuted}>{` Verifier: ${s.verifierSessionID}`}</text>
|
|
673
|
+
</Show>
|
|
674
|
+
<Show when={unresolved().length > 0}>
|
|
675
|
+
<box marginTop={1} flexDirection="column">
|
|
676
|
+
<text attributes={TextAttributes.BOLD} fg={api.theme.current.error ?? api.theme.current.accent}>
|
|
677
|
+
{` Open (${unresolved().length}):`}
|
|
678
|
+
</text>
|
|
679
|
+
<For each={unresolved()}>
|
|
680
|
+
{(issue) => (
|
|
681
|
+
<text fg={api.theme.current.text}>{` [ ] R${issue.round}: ${issue.text.slice(0, 70)}`}</text>
|
|
682
|
+
)}
|
|
683
|
+
</For>
|
|
684
|
+
</box>
|
|
685
|
+
</Show>
|
|
686
|
+
<Show when={resolved().length > 0}>
|
|
687
|
+
<box marginTop={1} flexDirection="column">
|
|
688
|
+
<text fg={api.theme.current.textMuted}>{` Resolved (${resolved().length}):`}</text>
|
|
689
|
+
<For each={resolved().slice(-5)}>
|
|
690
|
+
{(issue) => (
|
|
691
|
+
<text fg={api.theme.current.textMuted}>{` [x] R${issue.round}: ${issue.text.slice(0, 70)}`}</text>
|
|
692
|
+
)}
|
|
693
|
+
</For>
|
|
694
|
+
</box>
|
|
695
|
+
</Show>
|
|
696
|
+
</box>
|
|
697
|
+
)
|
|
698
|
+
})
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
title: "/converge-reset - Reset verifier session",
|
|
703
|
+
value: "convergence-engine.reset",
|
|
704
|
+
category: "Convergence",
|
|
705
|
+
slash: { name: "converge-reset" },
|
|
706
|
+
async onSelect() {
|
|
707
|
+
const sessionID = currentSessionID()
|
|
708
|
+
if (!sessionID) {
|
|
709
|
+
api.ui.toast({ message: "No active session", variant: "warning" })
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
abortVerification()
|
|
713
|
+
const s = state()
|
|
714
|
+
if (s?.verifierSessionID) {
|
|
715
|
+
try { await api.client.session.delete({ sessionID: s.verifierSessionID }) } catch { /* ok */ }
|
|
716
|
+
}
|
|
717
|
+
const kvData = getKV(sessionID)
|
|
718
|
+
if (kvData?.verifierSessionID && kvData.verifierSessionID !== s?.verifierSessionID) {
|
|
719
|
+
try { await api.client.session.delete({ sessionID: kvData.verifierSessionID }) } catch { /* ok */ }
|
|
720
|
+
}
|
|
721
|
+
clearKV(sessionID)
|
|
722
|
+
setState(null)
|
|
723
|
+
hideVerifierDialog()
|
|
724
|
+
api.ui.toast({ message: "Verifier session reset", variant: "info" })
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
])
|
|
728
|
+
|
|
729
|
+
// --- Sidebar: convergence status ---
|
|
730
|
+
|
|
731
|
+
api.slots.register({
|
|
732
|
+
order: 90,
|
|
733
|
+
slots: {
|
|
734
|
+
sidebar_content(ctx) {
|
|
735
|
+
const statusColor = (): string => {
|
|
736
|
+
const s = state()
|
|
737
|
+
switch (s?.status) {
|
|
738
|
+
case "summarizing": case "verifying": case "sending-fix": case "waiting-for-fix":
|
|
739
|
+
return ctx.theme.current.warning.toString() ?? ctx.theme.current.accent.toString()
|
|
740
|
+
default: return ctx.theme.current.text.toString()
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const label = (): string => {
|
|
744
|
+
const s = state()
|
|
745
|
+
if (!s) return ""
|
|
746
|
+
const icon = (() => {
|
|
747
|
+
switch (s.status) {
|
|
748
|
+
case "summarizing": case "verifying": return "~"
|
|
749
|
+
case "sending-fix": case "waiting-for-fix": return ">"
|
|
750
|
+
default: return "-"
|
|
751
|
+
}
|
|
752
|
+
})()
|
|
753
|
+
const unresolvedCount = s.issues.filter((i) => !i.resolved).length
|
|
754
|
+
const parts = [`[${icon}] Convergence R${s.round}/${s.maxRounds}`]
|
|
755
|
+
if (unresolvedCount > 0) parts.push(`${unresolvedCount} issues`)
|
|
756
|
+
const todos = sessionTodos()
|
|
757
|
+
if (todos.length > 0) {
|
|
758
|
+
const done = todos.filter((t) => t.status === "completed").length
|
|
759
|
+
const inProgress = todos.find((t) => t.status === "in_progress")
|
|
760
|
+
if (inProgress) {
|
|
761
|
+
parts.push(inProgress.content.slice(0, 30))
|
|
762
|
+
} else {
|
|
763
|
+
parts.push(`${done}/${todos.length} tasks`)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return parts.join(" | ")
|
|
767
|
+
}
|
|
768
|
+
return (
|
|
769
|
+
<Show when={state()}>
|
|
770
|
+
<box paddingLeft={1}>
|
|
771
|
+
<text fg={statusColor()}>{label()}</text>
|
|
772
|
+
</box>
|
|
773
|
+
</Show>
|
|
774
|
+
)
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The convergence engine TUI plugin module. */
|
|
781
|
+
export default {
|
|
782
|
+
id: "convergence-engine",
|
|
783
|
+
tui,
|
|
784
|
+
} satisfies TuiPluginModule & { id: string }
|
package/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Possible status values for the convergence engine state machine. */
|
|
2
|
+
export type ConvergenceStatus =
|
|
3
|
+
| "idle"
|
|
4
|
+
| "summarizing"
|
|
5
|
+
| "verifying"
|
|
6
|
+
| "sending-fix"
|
|
7
|
+
| "waiting-for-fix"
|
|
8
|
+
| "converged"
|
|
9
|
+
| "max-rounds"
|
|
10
|
+
| "error"
|
|
11
|
+
|
|
12
|
+
/** A single issue found by the verifier during a verification round. */
|
|
13
|
+
export interface VerifierIssue {
|
|
14
|
+
/** The round in which this issue was found. */
|
|
15
|
+
round: number
|
|
16
|
+
/** Human-readable description of the issue. */
|
|
17
|
+
text: string
|
|
18
|
+
/** Whether this issue has been resolved in a subsequent round. */
|
|
19
|
+
resolved: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Configuration options for the convergence engine. */
|
|
23
|
+
export interface ConvergenceOptions {
|
|
24
|
+
enabled: boolean
|
|
25
|
+
maxRounds: number
|
|
26
|
+
autoStart: boolean
|
|
27
|
+
verificationPrompt?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Default convergence options used when no user overrides are provided. */
|
|
31
|
+
export const DEFAULT_OPTIONS: ConvergenceOptions = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
maxRounds: 5,
|
|
34
|
+
autoStart: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shape of the data stored in TUI KV for a convergence mapping. */
|
|
38
|
+
export interface ConvergenceKVData {
|
|
39
|
+
verifierSessionID: string
|
|
40
|
+
round: number
|
|
41
|
+
status: ConvergenceStatus
|
|
42
|
+
issues: VerifierIssue[]
|
|
43
|
+
lastSummary?: string
|
|
44
|
+
}
|