@azatakmyradov/opencode-recap-plugin 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/README.md +54 -0
- package/dist/index.js +12 -0
- package/dist/tui.js +786 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @azatakmyradov/opencode-recap-plugin
|
|
2
|
+
|
|
3
|
+
OpenCode V2 plugin that writes a recap and suggested next step after each root-session run. It places the recap after the run's last message without adding it to the session history or future model context.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install the plugin from npm:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
opencode2 plugin add @azatakmyradov/opencode-recap-plugin
|
|
11
|
+
opencode2 plugin list
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The server entrypoint enables the TUI entrypoint. An unversioned install starts with its cached version and checks npm for updates in the background. A downloaded update takes effect the next time the service starts. Restart it now with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
opencode2 service restart
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use an exact package version for a reproducible install that does not update.
|
|
21
|
+
|
|
22
|
+
## Recaps
|
|
23
|
+
|
|
24
|
+
The plugin generates a recap when a run succeeds, fails, or is interrupted. It ignores child sessions. While generation runs, the prompt footer shows `generating run recap...`.
|
|
25
|
+
|
|
26
|
+
The recap card contains a summary of up to 2,400 characters and a next step of up to 400 characters. OpenCode stores the latest recap for each root session outside its messages, so the card survives TUI restarts but never enters model context. New user input or a revert removes the old recap and cancels any recap still being generated.
|
|
27
|
+
|
|
28
|
+
The model request times out after 45 seconds. If the request fails, times out, or returns invalid data, the plugin shows a warning and builds a local fallback from tool names and the last assistant response.
|
|
29
|
+
|
|
30
|
+
Each open TUI instance handles session events on its own. If several instances are open, each one may request a recap for the same run.
|
|
31
|
+
|
|
32
|
+
## Model
|
|
33
|
+
|
|
34
|
+
Run `/recap-model` from the slash menu or choose "Choose recap model" from the command palette. The dialog lists enabled models and their declared variants. The selected model persists across TUI restarts.
|
|
35
|
+
|
|
36
|
+
The default is `openai-codex/gpt-5.6-luna#medium`.
|
|
37
|
+
|
|
38
|
+
## Transcript data
|
|
39
|
+
|
|
40
|
+
The plugin sends the selected provider a transcript of the current run. The transcript can contain user and assistant text, shell commands and output, arguments and textual results from completed or failed tools, and execution errors.
|
|
41
|
+
|
|
42
|
+
The transcript excludes reasoning, files, binary tool output, system and skill messages, and compaction records. Tool arguments and shell commands are capped at 2 KB each. Tool results and shell output are capped at 5 KB each. The full transcript is capped at 48 KB and keeps content from its beginning and end when truncated.
|
|
43
|
+
|
|
44
|
+
The plugin redacts common secret formats and values under keys such as `token`, `password`, and `apiKey`. This is a best-effort filter, not a security boundary. Do not send sensitive session data to a recap provider you do not trust.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
For local development, run `bun install`. Add `packages/recap/src/index.ts` to `opencode.jsonc` and `packages/recap/src/tui.tsx` to the global CLI plugin configuration. OpenCode cannot find the package-level `./tui` export when it loads the server source file directly, so you must add both entries.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bun run --filter @azatakmyradov/opencode-recap-plugin check
|
|
52
|
+
bun run --filter @azatakmyradov/opencode-recap-plugin test
|
|
53
|
+
bun run check
|
|
54
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { Plugin } from "@opencode-ai/plugin/effect";
|
|
5
|
+
var src_default = Plugin.define({
|
|
6
|
+
id: "recap",
|
|
7
|
+
tui: true,
|
|
8
|
+
effect: () => Effect.void
|
|
9
|
+
});
|
|
10
|
+
export {
|
|
11
|
+
src_default as default
|
|
12
|
+
};
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/tui.tsx
|
|
3
|
+
import { setProp as _$setProp2 } from "@opentui/solid";
|
|
4
|
+
import { effect as _$effect2 } from "@opentui/solid";
|
|
5
|
+
import { createTextNode as _$createTextNode2 } from "@opentui/solid";
|
|
6
|
+
import { insertNode as _$insertNode2 } from "@opentui/solid";
|
|
7
|
+
import { createElement as _$createElement2 } from "@opentui/solid";
|
|
8
|
+
import { memo as _$memo2 } from "@opentui/solid";
|
|
9
|
+
import { createComponent as _$createComponent2 } from "@opentui/solid";
|
|
10
|
+
import { Plugin } from "@opencode-ai/plugin/tui";
|
|
11
|
+
import { Cause as Cause2, Effect as Effect3, ManagedRuntime } from "effect";
|
|
12
|
+
import { Show as Show2 } from "solid-js";
|
|
13
|
+
|
|
14
|
+
// src/core/controller.ts
|
|
15
|
+
import { Cause, Clock, Context, Effect, FiberMap, Layer, Ref } from "effect";
|
|
16
|
+
|
|
17
|
+
// src/core/transcript.ts
|
|
18
|
+
var TOOL_ARGUMENT_MAX_BYTES = 2000;
|
|
19
|
+
var TOOL_RESULT_MAX_BYTES = 5000;
|
|
20
|
+
var TRANSCRIPT_MAX_BYTES = 48000;
|
|
21
|
+
var ARGUMENT_MAX_DEPTH = 6;
|
|
22
|
+
var ARGUMENT_MAX_ITEMS = 30;
|
|
23
|
+
var SECRET_KEY = /(?:api[_-]?key|access[_-]?key|authorization|cookie|credential|password|passwd|private[_-]?key|secret|token)/i;
|
|
24
|
+
function redactSecrets(text) {
|
|
25
|
+
return text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]").replace(/\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,})\b/g, "[REDACTED]").replace(/(["']?(?:api[_-]?key|access[_-]?key|authorization|cookie|credential|password|passwd|private[_-]?key|secret|token)["']?\s*[:=]\s*)(["']?)[^\s,;}]+\2/gi, "$1[REDACTED]").replace(/([?&](?:api[_-]?key|access[_-]?token|key|secret|token)=)[^&#\s]+/gi, "$1[REDACTED]");
|
|
26
|
+
}
|
|
27
|
+
function truncateUtf8(text, bytes) {
|
|
28
|
+
if (Buffer.byteLength(text) <= bytes) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
let low = 0;
|
|
32
|
+
let high = text.length;
|
|
33
|
+
while (low < high) {
|
|
34
|
+
const mid = Math.ceil((low + high) / 2);
|
|
35
|
+
if (Buffer.byteLength(text.slice(0, mid)) <= bytes) {
|
|
36
|
+
low = mid;
|
|
37
|
+
} else {
|
|
38
|
+
high = mid - 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const end = /[\uD800-\uDBFF]/.test(text.charAt(low - 1)) ? low - 1 : low;
|
|
42
|
+
return text.slice(0, end);
|
|
43
|
+
}
|
|
44
|
+
function capped(text, bytes, notice) {
|
|
45
|
+
if (Buffer.byteLength(text) <= bytes) {
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
const suffix = `
|
|
49
|
+
[${notice}]`;
|
|
50
|
+
return `${truncateUtf8(text, bytes - Buffer.byteLength(suffix))}${suffix}`;
|
|
51
|
+
}
|
|
52
|
+
function sanitizeArgument(value, depth = 0, seen = new WeakSet) {
|
|
53
|
+
if (depth >= ARGUMENT_MAX_DEPTH) {
|
|
54
|
+
return "[nested value omitted]";
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
return redactSecrets(value);
|
|
58
|
+
}
|
|
59
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
if (typeof value !== "object") {
|
|
63
|
+
return `[${typeof value} omitted]`;
|
|
64
|
+
}
|
|
65
|
+
if (seen.has(value)) {
|
|
66
|
+
return "[cyclic value omitted]";
|
|
67
|
+
}
|
|
68
|
+
seen.add(value);
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
const items = value.slice(0, ARGUMENT_MAX_ITEMS).map((item) => sanitizeArgument(item, depth + 1, seen));
|
|
71
|
+
if (value.length > ARGUMENT_MAX_ITEMS) {
|
|
72
|
+
items.push("[additional items omitted]");
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
77
|
+
key,
|
|
78
|
+
SECRET_KEY.test(key) ? "[REDACTED]" : sanitizeArgument(item, depth + 1, seen)
|
|
79
|
+
]));
|
|
80
|
+
}
|
|
81
|
+
function toolText(content) {
|
|
82
|
+
const text = content?.flatMap((part) => part.type === "text" && part.text ? [part.text] : []).join(`
|
|
83
|
+
`);
|
|
84
|
+
return redactSecrets(text ?? "");
|
|
85
|
+
}
|
|
86
|
+
function serializeMessage(message) {
|
|
87
|
+
if (message.type === "user") {
|
|
88
|
+
return message.text ? [`USER
|
|
89
|
+
${redactSecrets(message.text)}`] : [];
|
|
90
|
+
}
|
|
91
|
+
if (message.type === "shell") {
|
|
92
|
+
const command = capped(redactSecrets(message.command), TOOL_ARGUMENT_MAX_BYTES, "command capped");
|
|
93
|
+
const output = capped(redactSecrets(message.output?.output ?? ""), TOOL_RESULT_MAX_BYTES, "command output capped");
|
|
94
|
+
return [
|
|
95
|
+
`USER SHELL${message.exit === undefined ? "" : ` (exit ${message.exit})`}
|
|
96
|
+
${command}
|
|
97
|
+
${output}`
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
if (message.type !== "assistant") {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const sections = [];
|
|
104
|
+
const text = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join(`
|
|
105
|
+
`);
|
|
106
|
+
if (text) {
|
|
107
|
+
sections.push(`ASSISTANT
|
|
108
|
+
${redactSecrets(text)}`);
|
|
109
|
+
}
|
|
110
|
+
for (const part of message.content) {
|
|
111
|
+
if (part.type !== "tool" || part.state.status !== "completed" && part.state.status !== "error") {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
let args;
|
|
115
|
+
try {
|
|
116
|
+
args = JSON.stringify(sanitizeArgument(part.state.input), null, 2);
|
|
117
|
+
} catch {
|
|
118
|
+
args = "[tool arguments could not be serialized]";
|
|
119
|
+
}
|
|
120
|
+
sections.push(`TOOL CALL ${part.name}
|
|
121
|
+
${capped(redactSecrets(args), TOOL_ARGUMENT_MAX_BYTES, "tool arguments capped")}`);
|
|
122
|
+
const content = "content" in part.state ? part.state.content : undefined;
|
|
123
|
+
const result = capped(toolText(content), TOOL_RESULT_MAX_BYTES, "tool result capped");
|
|
124
|
+
sections.push(`TOOL RESULT ${part.name}${part.state.status === "error" ? " (error)" : ""}
|
|
125
|
+
${result || "(no text output)"}`);
|
|
126
|
+
}
|
|
127
|
+
return sections;
|
|
128
|
+
}
|
|
129
|
+
function serializeRunTranscript(messages, detail, maxBytes = TRANSCRIPT_MAX_BYTES) {
|
|
130
|
+
const sections = messages.flatMap(serializeMessage);
|
|
131
|
+
if (detail) {
|
|
132
|
+
sections.push(redactSecrets(detail));
|
|
133
|
+
}
|
|
134
|
+
const transcript = sections.join(`
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
`) || "(no textual run output)";
|
|
139
|
+
if (Buffer.byteLength(transcript) <= maxBytes) {
|
|
140
|
+
return transcript;
|
|
141
|
+
}
|
|
142
|
+
const marker = `
|
|
143
|
+
|
|
144
|
+
[... transcript capped; middle omitted ...]
|
|
145
|
+
|
|
146
|
+
`;
|
|
147
|
+
const remaining = maxBytes - Buffer.byteLength(marker);
|
|
148
|
+
const head = truncateUtf8(transcript, Math.floor(remaining * 0.58));
|
|
149
|
+
const chars = Array.from(transcript);
|
|
150
|
+
const reversedTail = truncateUtf8(chars.reverse().join(""), remaining - Buffer.byteLength(head));
|
|
151
|
+
const tail = Array.from(reversedTail).reverse().join("");
|
|
152
|
+
return `${head}${marker}${tail}`;
|
|
153
|
+
}
|
|
154
|
+
function buildFallbackRecap(messages, outcome = "completed") {
|
|
155
|
+
const tools = [];
|
|
156
|
+
let final = "";
|
|
157
|
+
for (const message of messages) {
|
|
158
|
+
if (message.type !== "assistant") {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const part of message.content) {
|
|
162
|
+
if (part.type === "tool") {
|
|
163
|
+
tools.push(part.name);
|
|
164
|
+
}
|
|
165
|
+
if (part.type === "text" && part.text.trim()) {
|
|
166
|
+
final = redactSecrets(part.text.trim());
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const names = [...new Set(tools)];
|
|
171
|
+
let activity = "";
|
|
172
|
+
if (names.length) {
|
|
173
|
+
const toolCallLabel = tools.length === 1 ? "tool call" : "tool calls";
|
|
174
|
+
activity = ` The run used ${tools.length} ${toolCallLabel} across ${names.join(", ")}.`;
|
|
175
|
+
}
|
|
176
|
+
const result = final ? ` ${capped(final.replace(/\s+/g, " "), 700, "final response capped")}` : "";
|
|
177
|
+
return {
|
|
178
|
+
recap: `The main-agent run ${outcome}.${activity}${result}`.trim(),
|
|
179
|
+
next: "Review the completed work above and continue if anything remains."
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function selectRunMessages(messages, baseline) {
|
|
183
|
+
return messages.filter((message) => !baseline.has(message.id));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/core/controller.ts
|
|
187
|
+
class RecapControllerService extends Context.Service()("opencode-recap-plugin/RecapController") {
|
|
188
|
+
}
|
|
189
|
+
var createRecapController = Effect.fn("createRecapController")(function* (deps) {
|
|
190
|
+
const state = yield* Ref.make({
|
|
191
|
+
inboxBaselines: new Map,
|
|
192
|
+
runs: new Map
|
|
193
|
+
});
|
|
194
|
+
const active = yield* FiberMap.make();
|
|
195
|
+
const invalidate = Effect.fn("RecapController.invalidate")(function* (sessionID) {
|
|
196
|
+
yield* FiberMap.remove(active, sessionID);
|
|
197
|
+
yield* Ref.modify(state, (current) => {
|
|
198
|
+
const runs = new Map(current.runs);
|
|
199
|
+
runs.delete(sessionID);
|
|
200
|
+
return [undefined, { ...current, runs }];
|
|
201
|
+
});
|
|
202
|
+
yield* deps.persist(undefined, sessionID);
|
|
203
|
+
});
|
|
204
|
+
const inbox = Effect.fn("RecapController.inbox")(function* (sessionID, user) {
|
|
205
|
+
if (!user || deps.session(sessionID)?.parentID) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const baseline = new Set(deps.messages(sessionID).map((message) => message.id));
|
|
209
|
+
yield* Ref.modify(state, (current) => {
|
|
210
|
+
const inboxBaselines = new Map(current.inboxBaselines);
|
|
211
|
+
const runs = new Map(current.runs);
|
|
212
|
+
inboxBaselines.set(sessionID, baseline);
|
|
213
|
+
runs.delete(sessionID);
|
|
214
|
+
return [undefined, { inboxBaselines, runs }];
|
|
215
|
+
});
|
|
216
|
+
yield* FiberMap.remove(active, sessionID);
|
|
217
|
+
yield* deps.persist(undefined, sessionID);
|
|
218
|
+
});
|
|
219
|
+
const started = Effect.fn("RecapController.started")(function* (sessionID) {
|
|
220
|
+
if (deps.session(sessionID)?.parentID) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
yield* FiberMap.remove(active, sessionID);
|
|
224
|
+
yield* deps.syncMessages(sessionID);
|
|
225
|
+
const currentMessages = deps.messages(sessionID);
|
|
226
|
+
yield* Ref.modify(state, (current) => {
|
|
227
|
+
const inboxBaselines = new Map(current.inboxBaselines);
|
|
228
|
+
const runs = new Map(current.runs);
|
|
229
|
+
const baseline = inboxBaselines.get(sessionID) ?? new Set(currentMessages.map((message) => message.id));
|
|
230
|
+
inboxBaselines.delete(sessionID);
|
|
231
|
+
runs.set(sessionID, baseline);
|
|
232
|
+
return [undefined, { inboxBaselines, runs }];
|
|
233
|
+
});
|
|
234
|
+
yield* deps.persist(undefined, sessionID);
|
|
235
|
+
});
|
|
236
|
+
const terminal = Effect.fn("RecapController.terminal")(function* (input) {
|
|
237
|
+
const baseline = yield* Ref.modify(state, (current) => {
|
|
238
|
+
const runs = new Map(current.runs);
|
|
239
|
+
const boundary = runs.get(input.sessionID);
|
|
240
|
+
runs.delete(input.sessionID);
|
|
241
|
+
return [boundary, { ...current, runs }];
|
|
242
|
+
});
|
|
243
|
+
if (!baseline) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const generation = Effect.gen(function* () {
|
|
247
|
+
yield* deps.syncMessages(input.sessionID);
|
|
248
|
+
const messages = selectRunMessages(deps.messages(input.sessionID), baseline);
|
|
249
|
+
if (messages.length === 0) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const model = deps.model();
|
|
253
|
+
const result = yield* deps.generate({
|
|
254
|
+
transcript: serializeRunTranscript(messages, input.detail),
|
|
255
|
+
model
|
|
256
|
+
}).pipe(Effect.map((recap) => ({ recap, fallback: false })), Effect.catch((error) => deps.warning(`The recap model failed; showing a local fallback. ${error.message}`).pipe(Effect.as({
|
|
257
|
+
recap: buildFallbackRecap(messages, input.outcome),
|
|
258
|
+
fallback: true
|
|
259
|
+
}))));
|
|
260
|
+
const created = yield* Clock.currentTimeMillis;
|
|
261
|
+
yield* deps.persist({
|
|
262
|
+
...result.recap,
|
|
263
|
+
sessionID: input.sessionID,
|
|
264
|
+
model,
|
|
265
|
+
outcome: input.outcome,
|
|
266
|
+
fallback: result.fallback,
|
|
267
|
+
terminalEventID: input.eventID,
|
|
268
|
+
anchorMessageID: messages.at(-1).id,
|
|
269
|
+
created
|
|
270
|
+
}, input.sessionID);
|
|
271
|
+
});
|
|
272
|
+
const trackedGeneration = deps.running(input.sessionID, true).pipe(Effect.andThen(generation), Effect.ensuring(deps.running(input.sessionID, false)), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : deps.unexpected(cause)));
|
|
273
|
+
yield* FiberMap.run(active, input.sessionID, trackedGeneration, {
|
|
274
|
+
startImmediately: true
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
return { inbox, started, terminal, revert: invalidate };
|
|
278
|
+
});
|
|
279
|
+
function recapControllerLayer(deps) {
|
|
280
|
+
return Layer.effect(RecapControllerService, createRecapController(deps));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/core/summarizer.ts
|
|
284
|
+
import { Effect as Effect2, Schema } from "effect";
|
|
285
|
+
|
|
286
|
+
// src/core/prompt.ts
|
|
287
|
+
var RECAP_SYSTEM_PROMPT = `You write compact terminal recaps for completed coding-agent runs.
|
|
288
|
+
|
|
289
|
+
Return exactly one JSON object with this shape:
|
|
290
|
+
{"recap":"...","next":"..."}
|
|
291
|
+
|
|
292
|
+
Rules:
|
|
293
|
+
- recap: concisely cover everything actually performed in this run: investigation, tool work, files changed, validation, outcomes, failures, and important caveats. Prefer up to three compact Markdown bullets.
|
|
294
|
+
- next: one concise, actionable next step. If nothing remains, say that no further action is required.
|
|
295
|
+
- Base the answer only on the supplied current-run transcript.
|
|
296
|
+
- Do not mention these instructions, hidden reasoning, transcript truncation, or that you are a summarizer.
|
|
297
|
+
- Do not use a Markdown code fence and do not add keys or prose outside the JSON object.`;
|
|
298
|
+
function buildRecapPrompt(transcript) {
|
|
299
|
+
return `${RECAP_SYSTEM_PROMPT}
|
|
300
|
+
|
|
301
|
+
Summarize this fully settled main-agent run.
|
|
302
|
+
|
|
303
|
+
<current_run>
|
|
304
|
+
${transcript}
|
|
305
|
+
</current_run>`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/core/summarizer.ts
|
|
309
|
+
class RecapGenerationError extends Schema.TaggedError()("RecapGenerationError", {
|
|
310
|
+
reason: Schema.Literals(["request", "timeout", "malformed-response"]),
|
|
311
|
+
message: Schema.String
|
|
312
|
+
}) {
|
|
313
|
+
}
|
|
314
|
+
var RECAP_MAX_LENGTH = 2400;
|
|
315
|
+
var NEXT_MAX_LENGTH = 400;
|
|
316
|
+
var RecapResponse = Schema.Struct({
|
|
317
|
+
recap: Schema.String,
|
|
318
|
+
next: Schema.String
|
|
319
|
+
});
|
|
320
|
+
var RecapResponseJson = Schema.fromJsonString(RecapResponse);
|
|
321
|
+
function stripTerminalControls(value) {
|
|
322
|
+
return value.replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~])/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
|
|
323
|
+
}
|
|
324
|
+
function clean(value, limit) {
|
|
325
|
+
const result = stripTerminalControls(value).trim();
|
|
326
|
+
if (result.length <= limit) {
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
return `${result.slice(0, limit - 3).trimEnd()}...`;
|
|
330
|
+
}
|
|
331
|
+
function responseCandidates(text) {
|
|
332
|
+
const trimmed = text.trim();
|
|
333
|
+
const candidates = [trimmed];
|
|
334
|
+
for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
|
335
|
+
if (match[1]) {
|
|
336
|
+
candidates.push(match[1].trim());
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const first = trimmed.indexOf("{");
|
|
340
|
+
const last = trimmed.lastIndexOf("}");
|
|
341
|
+
if (first >= 0 && last > first) {
|
|
342
|
+
candidates.push(trimmed.slice(first, last + 1));
|
|
343
|
+
}
|
|
344
|
+
return [...new Set(candidates)];
|
|
345
|
+
}
|
|
346
|
+
var malformedResponse = () => new RecapGenerationError({
|
|
347
|
+
reason: "malformed-response",
|
|
348
|
+
message: "The recap model did not return valid recap JSON."
|
|
349
|
+
});
|
|
350
|
+
var parseRecapResponse = Effect2.fn("parseRecapResponse")(function* (text) {
|
|
351
|
+
const decoded = yield* Effect2.firstSuccessOf(responseCandidates(text).map((candidate) => Schema.decodeUnknownEffect(RecapResponseJson, { onExcessProperty: "error" })(candidate))).pipe(Effect2.mapError(malformedResponse));
|
|
352
|
+
const recap = clean(decoded.recap, RECAP_MAX_LENGTH);
|
|
353
|
+
const next = clean(decoded.next.replace(/^next\s*:\s*/i, ""), NEXT_MAX_LENGTH);
|
|
354
|
+
if (!recap || !next) {
|
|
355
|
+
return yield* malformedResponse();
|
|
356
|
+
}
|
|
357
|
+
return { recap, next };
|
|
358
|
+
});
|
|
359
|
+
var summarizeRun = Effect2.fn("summarizeRun")(function* (options) {
|
|
360
|
+
const text = yield* options.generate({
|
|
361
|
+
prompt: buildRecapPrompt(options.transcript),
|
|
362
|
+
model: options.model
|
|
363
|
+
}).pipe(Effect2.timeout(options.timeoutMs ?? 45000), Effect2.mapError((error) => error._tag === "TimeoutError" ? new RecapGenerationError({
|
|
364
|
+
reason: "timeout",
|
|
365
|
+
message: "The recap model request timed out."
|
|
366
|
+
}) : error));
|
|
367
|
+
return yield* parseRecapResponse(text);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// src/tui/inline.tsx
|
|
371
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
372
|
+
import { isRenderable } from "@opentui/core";
|
|
373
|
+
import { Portal } from "@opentui/solid";
|
|
374
|
+
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
375
|
+
|
|
376
|
+
// src/tui/card.tsx
|
|
377
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
378
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
379
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
380
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
381
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
382
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
383
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
384
|
+
function RecapCard(props) {
|
|
385
|
+
return (() => {
|
|
386
|
+
var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text");
|
|
387
|
+
_$insertNode(_el$, _el$2);
|
|
388
|
+
_$insertNode(_el$, _el$5);
|
|
389
|
+
_$insertNode(_el$, _el$6);
|
|
390
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
391
|
+
_$setProp(_el$, "marginTop", 1);
|
|
392
|
+
_$setProp(_el$, "paddingLeft", 3);
|
|
393
|
+
_$insertNode(_el$2, _el$3);
|
|
394
|
+
_$insertNode(_el$3, _$createTextNode(`Summary:`));
|
|
395
|
+
_$insert(_el$5, () => props.recap.recap);
|
|
396
|
+
_$insert(_el$6, () => `Next: ${props.recap.next}`);
|
|
397
|
+
_$insert(_el$, (() => {
|
|
398
|
+
var _c$ = _$memo(() => !!props.recap.fallback);
|
|
399
|
+
return () => _c$() ? (() => {
|
|
400
|
+
var _el$7 = _$createElement("text");
|
|
401
|
+
_$insertNode(_el$7, _$createTextNode(`Local fallback`));
|
|
402
|
+
_$effect((_$p) => _$setProp(_el$7, "fg", props.theme.text.muted, _$p));
|
|
403
|
+
return _el$7;
|
|
404
|
+
})() : null;
|
|
405
|
+
})(), null);
|
|
406
|
+
_$effect((_p$) => {
|
|
407
|
+
var _v$ = props.theme.text.muted, _v$2 = props.theme.text.default, _v$3 = props.theme.text.muted;
|
|
408
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
|
|
409
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "fg", _v$2, _p$.t));
|
|
410
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$6, "fg", _v$3, _p$.a));
|
|
411
|
+
return _p$;
|
|
412
|
+
}, {
|
|
413
|
+
e: undefined,
|
|
414
|
+
t: undefined,
|
|
415
|
+
a: undefined
|
|
416
|
+
});
|
|
417
|
+
return _el$;
|
|
418
|
+
})();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/tui/inline.tsx
|
|
422
|
+
function assistantContentRowCount(content) {
|
|
423
|
+
let rows = 0;
|
|
424
|
+
let group;
|
|
425
|
+
for (const part of content) {
|
|
426
|
+
if ((part.type === "text" || part.type === "reasoning") && !part.text?.trim()) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let nextGroup;
|
|
430
|
+
if (part.type === "reasoning") {
|
|
431
|
+
nextGroup = "reasoning";
|
|
432
|
+
} else if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name?.toLowerCase() ?? "")) {
|
|
433
|
+
nextGroup = "exploration";
|
|
434
|
+
}
|
|
435
|
+
if (!nextGroup || nextGroup !== group) {
|
|
436
|
+
rows++;
|
|
437
|
+
}
|
|
438
|
+
group = nextGroup;
|
|
439
|
+
}
|
|
440
|
+
return rows;
|
|
441
|
+
}
|
|
442
|
+
function recapInsertionIndex(children, anchor, card, messageIDs, contentRows) {
|
|
443
|
+
const ordered = children.filter((child) => child !== card);
|
|
444
|
+
const anchorIndex = ordered.indexOf(anchor);
|
|
445
|
+
const cardIndex = children.indexOf(card);
|
|
446
|
+
if (anchorIndex < 0 || cardIndex < 0) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (contentRows !== undefined) {
|
|
450
|
+
return Math.min(anchorIndex + contentRows, ordered.length);
|
|
451
|
+
}
|
|
452
|
+
const nextBoundary = ordered.findIndex((child, index) => index > anchorIndex && (child.id === "session-navigation-slack" || messageIDs.has(child.id)));
|
|
453
|
+
return nextBoundary < 0 ? ordered.length : nextBoundary;
|
|
454
|
+
}
|
|
455
|
+
function InlineRecap(props) {
|
|
456
|
+
const [mount, setMount] = createSignal();
|
|
457
|
+
let card;
|
|
458
|
+
function placeCard(parent, anchor) {
|
|
459
|
+
if (!card || card.parent !== parent) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const children = parent.getChildren();
|
|
463
|
+
const cardIndex = children.indexOf(card);
|
|
464
|
+
const targetIndex = recapInsertionIndex(children, anchor, card, props.messageIDs, props.contentRows);
|
|
465
|
+
if (targetIndex === undefined || cardIndex === targetIndex) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
parent.remove(card);
|
|
469
|
+
parent.add(card, targetIndex);
|
|
470
|
+
}
|
|
471
|
+
function sync() {
|
|
472
|
+
const anchor = props.renderer.root.findDescendantById(props.recap.anchorMessageID);
|
|
473
|
+
const parent = isRenderable(anchor?.parent) ? anchor.parent : undefined;
|
|
474
|
+
if (parent !== mount()) {
|
|
475
|
+
setMount(parent);
|
|
476
|
+
}
|
|
477
|
+
if (parent && isRenderable(anchor)) {
|
|
478
|
+
placeCard(parent, anchor);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
onMount(() => {
|
|
482
|
+
props.renderer.on("frame", sync);
|
|
483
|
+
sync();
|
|
484
|
+
});
|
|
485
|
+
onCleanup(() => {
|
|
486
|
+
props.renderer.off("frame", sync);
|
|
487
|
+
card = undefined;
|
|
488
|
+
});
|
|
489
|
+
return _$createComponent(Show, {
|
|
490
|
+
get when() {
|
|
491
|
+
return mount();
|
|
492
|
+
},
|
|
493
|
+
keyed: true,
|
|
494
|
+
children: (parent) => _$createComponent(Portal, {
|
|
495
|
+
mount: parent,
|
|
496
|
+
ref: (element) => {
|
|
497
|
+
if (!isRenderable(element)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
card = element;
|
|
501
|
+
sync();
|
|
502
|
+
},
|
|
503
|
+
get children() {
|
|
504
|
+
return _$createComponent(RecapCard, {
|
|
505
|
+
get recap() {
|
|
506
|
+
return props.recap;
|
|
507
|
+
},
|
|
508
|
+
get theme() {
|
|
509
|
+
return props.theme;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/tui.tsx
|
|
518
|
+
var DEFAULT_MODEL = {
|
|
519
|
+
providerID: "openai-codex",
|
|
520
|
+
id: "gpt-5.6-luna",
|
|
521
|
+
variant: "medium"
|
|
522
|
+
};
|
|
523
|
+
function errorMessage(error) {
|
|
524
|
+
return error instanceof Error ? error.message : String(error);
|
|
525
|
+
}
|
|
526
|
+
var tui_default = Plugin.define({
|
|
527
|
+
id: "recap",
|
|
528
|
+
setup(context) {
|
|
529
|
+
const [state, updateState] = context.storage.store("state", {
|
|
530
|
+
initial: {
|
|
531
|
+
model: DEFAULT_MODEL,
|
|
532
|
+
recaps: {}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
const [runtime, updateRuntime] = context.storage.memory("runtime", {
|
|
536
|
+
initial: {
|
|
537
|
+
running: {}
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
const selectRecapModel = Effect3.fn("selectRecapModel")(function* () {
|
|
541
|
+
const location = context.location ?? context.data.location.default();
|
|
542
|
+
yield* Effect3.tryPromise(() => context.data.location.model.sync(location));
|
|
543
|
+
const models = yield* Effect3.sync(() => (context.data.location.model.list(location) ?? []).filter((model2) => model2.enabled));
|
|
544
|
+
const selected = yield* Effect3.tryPromise(() => context.ui.dialog.select({
|
|
545
|
+
title: "Recap model",
|
|
546
|
+
current: `${state.model.providerID}/${state.model.id}`,
|
|
547
|
+
options: models.map((model2) => ({
|
|
548
|
+
title: model2.name,
|
|
549
|
+
description: `${model2.providerID}/${model2.id}`,
|
|
550
|
+
value: `${model2.providerID}/${model2.id}`
|
|
551
|
+
}))
|
|
552
|
+
}));
|
|
553
|
+
if (!selected) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const model = models.find((item) => `${item.providerID}/${item.id}` === selected);
|
|
557
|
+
let variant;
|
|
558
|
+
if (model.variants.length) {
|
|
559
|
+
variant = yield* Effect3.tryPromise(() => context.ui.dialog.select({
|
|
560
|
+
title: "Recap model variant",
|
|
561
|
+
current: state.model.variant,
|
|
562
|
+
options: model.variants.map((item) => ({
|
|
563
|
+
title: item.id,
|
|
564
|
+
value: item.id
|
|
565
|
+
}))
|
|
566
|
+
}));
|
|
567
|
+
if (!variant) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
yield* Effect3.tryPromise(() => updateState((draft) => {
|
|
572
|
+
draft.model = {
|
|
573
|
+
providerID: model.providerID,
|
|
574
|
+
id: model.id,
|
|
575
|
+
...variant ? {
|
|
576
|
+
variant
|
|
577
|
+
} : {}
|
|
578
|
+
};
|
|
579
|
+
}));
|
|
580
|
+
yield* Effect3.sync(() => context.ui.toast.show({
|
|
581
|
+
title: "Recap",
|
|
582
|
+
message: `Recap model: ${model.providerID}/${model.id}${variant ? `#${variant}` : ""}`,
|
|
583
|
+
variant: "success"
|
|
584
|
+
}));
|
|
585
|
+
});
|
|
586
|
+
const controllerRuntime = ManagedRuntime.make(recapControllerLayer({
|
|
587
|
+
session(sessionID) {
|
|
588
|
+
return context.data.session.get(sessionID);
|
|
589
|
+
},
|
|
590
|
+
syncMessages(sessionID) {
|
|
591
|
+
return Effect3.tryPromise(() => context.data.session.message.sync(sessionID));
|
|
592
|
+
},
|
|
593
|
+
messages(sessionID) {
|
|
594
|
+
return context.data.session.message.list(sessionID);
|
|
595
|
+
},
|
|
596
|
+
model() {
|
|
597
|
+
return {
|
|
598
|
+
...state.model
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
generate({
|
|
602
|
+
transcript,
|
|
603
|
+
model
|
|
604
|
+
}) {
|
|
605
|
+
return summarizeRun({
|
|
606
|
+
transcript,
|
|
607
|
+
model,
|
|
608
|
+
generate(request) {
|
|
609
|
+
return Effect3.tryPromise({
|
|
610
|
+
try: (signal) => context.client.generate.text({
|
|
611
|
+
prompt: request.prompt,
|
|
612
|
+
model: request.model
|
|
613
|
+
}, {
|
|
614
|
+
signal
|
|
615
|
+
}).then((result) => result.text),
|
|
616
|
+
catch: (error) => new RecapGenerationError({
|
|
617
|
+
reason: "request",
|
|
618
|
+
message: `The recap model request failed. ${errorMessage(error)}`
|
|
619
|
+
})
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
persist(recap, sessionID) {
|
|
625
|
+
return Effect3.tryPromise(() => updateState((draft) => {
|
|
626
|
+
if (!recap) {
|
|
627
|
+
delete draft.recaps[sessionID];
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (draft.recaps[sessionID]?.terminalEventID !== recap.terminalEventID) {
|
|
631
|
+
draft.recaps[sessionID] = recap;
|
|
632
|
+
}
|
|
633
|
+
}));
|
|
634
|
+
},
|
|
635
|
+
running(sessionID, value) {
|
|
636
|
+
return Effect3.sync(() => updateRuntime((draft) => {
|
|
637
|
+
if (value) {
|
|
638
|
+
draft.running[sessionID] = true;
|
|
639
|
+
} else {
|
|
640
|
+
delete draft.running[sessionID];
|
|
641
|
+
}
|
|
642
|
+
}));
|
|
643
|
+
},
|
|
644
|
+
warning(message) {
|
|
645
|
+
return Effect3.sync(() => context.ui.toast.show({
|
|
646
|
+
title: "Recap",
|
|
647
|
+
message,
|
|
648
|
+
variant: "warning"
|
|
649
|
+
}));
|
|
650
|
+
},
|
|
651
|
+
unexpected: reportUnexpected
|
|
652
|
+
}));
|
|
653
|
+
function reportUnexpected(cause) {
|
|
654
|
+
return Effect3.sync(() => {
|
|
655
|
+
const message = errorMessage(Cause2.squash(cause));
|
|
656
|
+
console.error(`opencode-recap-plugin: ${message}`);
|
|
657
|
+
context.ui.toast.show({
|
|
658
|
+
title: "Recap",
|
|
659
|
+
message: `Recap failed unexpectedly. ${message}`,
|
|
660
|
+
variant: "warning"
|
|
661
|
+
});
|
|
662
|
+
}).pipe(Effect3.catchCause(() => Effect3.void));
|
|
663
|
+
}
|
|
664
|
+
function runEffect(effect) {
|
|
665
|
+
const handled = effect.pipe(Effect3.catchCause((cause) => Cause2.hasInterruptsOnly(cause) ? Effect3.void : reportUnexpected(cause)));
|
|
666
|
+
controllerRuntime.runPromise(handled).catch((error) => {
|
|
667
|
+
console.error(`opencode-recap-plugin: ${errorMessage(error)}`);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const stops = [context.data.on("session.inbox.enqueued", (event) => runEffect(RecapControllerService.use((controller) => controller.inbox(event.data.sessionID, event.data.item.type === "user")))), context.data.on("session.execution.started", (event) => runEffect(RecapControllerService.use((controller) => controller.started(event.data.sessionID)))), context.data.on("session.execution.succeeded", (event) => runEffect(RecapControllerService.use((controller) => controller.terminal({
|
|
671
|
+
sessionID: event.data.sessionID,
|
|
672
|
+
eventID: event.id,
|
|
673
|
+
outcome: "succeeded"
|
|
674
|
+
})))), context.data.on("session.execution.failed", (event) => runEffect(RecapControllerService.use((controller) => controller.terminal({
|
|
675
|
+
sessionID: event.data.sessionID,
|
|
676
|
+
eventID: event.id,
|
|
677
|
+
outcome: "failed",
|
|
678
|
+
detail: `EXECUTION FAILED
|
|
679
|
+
${event.data.error.type}: ${event.data.error.message}`
|
|
680
|
+
})))), context.data.on("session.execution.interrupted", (event) => runEffect(RecapControllerService.use((controller) => controller.terminal({
|
|
681
|
+
sessionID: event.data.sessionID,
|
|
682
|
+
eventID: event.id,
|
|
683
|
+
outcome: "interrupted",
|
|
684
|
+
detail: `EXECUTION INTERRUPTED
|
|
685
|
+
${event.data.reason}`
|
|
686
|
+
})))), context.data.on("session.revert.staged", (event) => runEffect(RecapControllerService.use((controller) => controller.revert(event.data.sessionID))))];
|
|
687
|
+
function AppExtensions() {
|
|
688
|
+
context.keymap.layer(() => ({
|
|
689
|
+
mode: "global",
|
|
690
|
+
priority: 10,
|
|
691
|
+
commands: [{
|
|
692
|
+
id: "recap.model",
|
|
693
|
+
title: "Choose recap model",
|
|
694
|
+
group: "Recap",
|
|
695
|
+
palette: true,
|
|
696
|
+
bind: false,
|
|
697
|
+
slash: {
|
|
698
|
+
name: "recap-model"
|
|
699
|
+
},
|
|
700
|
+
run: () => runEffect(selectRecapModel())
|
|
701
|
+
}]
|
|
702
|
+
}));
|
|
703
|
+
function currentSessionID() {
|
|
704
|
+
const route = context.ui.router.current();
|
|
705
|
+
return route.type === "session" ? route.sessionID : undefined;
|
|
706
|
+
}
|
|
707
|
+
return _$createComponent2(Show2, {
|
|
708
|
+
get when() {
|
|
709
|
+
return currentSessionID();
|
|
710
|
+
},
|
|
711
|
+
keyed: true,
|
|
712
|
+
children: (sessionID) => _$createComponent2(Show2, {
|
|
713
|
+
get when() {
|
|
714
|
+
return currentRecap(sessionID);
|
|
715
|
+
},
|
|
716
|
+
keyed: true,
|
|
717
|
+
children: (recap) => _$createComponent2(InlineRecap, {
|
|
718
|
+
recap,
|
|
719
|
+
get messageIDs() {
|
|
720
|
+
return new Set(context.data.session.message.list(sessionID).map((message) => message.id));
|
|
721
|
+
},
|
|
722
|
+
get contentRows() {
|
|
723
|
+
return anchorContentRowCount(sessionID, recap.anchorMessageID);
|
|
724
|
+
},
|
|
725
|
+
get renderer() {
|
|
726
|
+
return context.renderer;
|
|
727
|
+
},
|
|
728
|
+
get theme() {
|
|
729
|
+
return context.theme;
|
|
730
|
+
}
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function anchorContentRowCount(sessionID, anchorMessageID) {
|
|
736
|
+
const message = context.data.session.message.get(sessionID, anchorMessageID);
|
|
737
|
+
if (message?.type !== "assistant") {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
return assistantContentRowCount(message.content);
|
|
741
|
+
}
|
|
742
|
+
function currentRecap(sessionID) {
|
|
743
|
+
const recap = state.recaps[sessionID];
|
|
744
|
+
if (!recap || context.data.session.get(sessionID)?.revert) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const messages = context.data.session.message.list(sessionID);
|
|
748
|
+
const anchor = messages.findIndex((message) => message.id === recap.anchorMessageID);
|
|
749
|
+
if (anchor < 0 || messages.slice(anchor + 1).some((message) => message.type === "user")) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
return recap;
|
|
753
|
+
}
|
|
754
|
+
const removeApp = context.ui.slot({
|
|
755
|
+
append: "app",
|
|
756
|
+
render: AppExtensions
|
|
757
|
+
});
|
|
758
|
+
const removeStatus = context.ui.slot({
|
|
759
|
+
append: "prompt.footer.status",
|
|
760
|
+
render: ({
|
|
761
|
+
sessionID
|
|
762
|
+
}) => _$createComponent2(Show2, {
|
|
763
|
+
get when() {
|
|
764
|
+
return sessionID && runtime.running[sessionID];
|
|
765
|
+
},
|
|
766
|
+
get children() {
|
|
767
|
+
var _el$ = _$createElement2("text");
|
|
768
|
+
_$insertNode2(_el$, _$createTextNode2(`\u25CF generating run recap...`));
|
|
769
|
+
_$effect2((_$p) => _$setProp2(_el$, "fg", context.theme.text.status.running, _$p));
|
|
770
|
+
return _el$;
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
});
|
|
774
|
+
return () => {
|
|
775
|
+
for (const stop of stops) {
|
|
776
|
+
stop();
|
|
777
|
+
}
|
|
778
|
+
removeStatus();
|
|
779
|
+
removeApp();
|
|
780
|
+
return controllerRuntime.dispose();
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
export {
|
|
785
|
+
tui_default as default
|
|
786
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@azatakmyradov/opencode-recap-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run recaps and suggested next steps for the OpenCode V2 TUI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"recap",
|
|
9
|
+
"tui"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/azatakmyradov/opencode-plugins/tree/main/packages/recap",
|
|
12
|
+
"bugs": "https://github.com/azatakmyradov/opencode-plugins/issues",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/azatakmyradov/opencode-plugins.git",
|
|
17
|
+
"directory": "packages/recap"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/index.js",
|
|
26
|
+
"./tui": "./dist/tui.js"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun ../../scripts/build.ts .",
|
|
33
|
+
"check": "tsc --noEmit",
|
|
34
|
+
"prepack": "bun run build",
|
|
35
|
+
"test": "vp test run"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@opencode-ai/client": "0.0.0-beta-18414",
|
|
39
|
+
"@opencode-ai/plugin": "0.0.0-beta-18414",
|
|
40
|
+
"effect": "4.0.0-rc.111"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opentui/core": ">=0.5.8",
|
|
44
|
+
"@opentui/solid": ">=0.5.8",
|
|
45
|
+
"solid-js": ">=1.9.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@opentui/core": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@opentui/solid": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"solid-js": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|