@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 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
+ }