@azatakmyradov/opencode-recap-plugin 0.1.2 → 0.1.4

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 CHANGED
@@ -23,11 +23,11 @@ Use an exact package version for a reproducible install that does not update.
23
23
 
24
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
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.
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 the TUI's wait for a pending recap.
27
27
 
28
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
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.
30
+ Each open TUI instance handles session events on its own. The server shares generation requests for the same session, terminal event, and model, and retains up to 128 completed results until the plugin reloads. New input cancels that TUI's wait. The server cancels the model request when no clients are waiting for it.
31
31
 
32
32
  ## Model
33
33
 
package/dist/index.js CHANGED
@@ -1,10 +1,211 @@
1
1
  // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var RecapRpc = Rpc.define({
6
+ id: "recap",
7
+ methods: {
8
+ generate: {
9
+ input: z.object({
10
+ sessionID: z.string().min(1),
11
+ eventID: z.string().min(1),
12
+ transcript: z.string(),
13
+ model: z.object({ providerID: z.string(), id: z.string(), variant: z.string().optional() })
14
+ }),
15
+ output: z.object({ recap: z.string(), next: z.string() }),
16
+ errors: { generation_failed: z.object({}) }
17
+ }
18
+ },
19
+ events: {}
20
+ });
21
+
22
+ // src/index.ts
23
+ import { Effect as Effect2 } from "effect";
24
+ import { Plugin } from "@opencode-ai/plugin";
25
+
26
+ // src/core/cache.ts
27
+ function generationCache(limit = 128) {
28
+ const active = new Map;
29
+ const completed = new Map;
30
+ return (key, generate, signal) => {
31
+ if (signal?.aborted)
32
+ return Promise.reject(signal.reason);
33
+ if (completed.has(key))
34
+ return Promise.resolve(completed.get(key));
35
+ let pending = active.get(key);
36
+ if (!pending) {
37
+ const controller = new AbortController;
38
+ const request = Promise.resolve().then(() => {
39
+ controller.signal.throwIfAborted();
40
+ return generate(controller.signal);
41
+ }).then((result) => {
42
+ if (!controller.signal.aborted)
43
+ completed.set(key, result);
44
+ while (completed.size > limit)
45
+ completed.delete(completed.keys().next().value);
46
+ return result;
47
+ }).finally(() => {
48
+ if (active.get(key)?.controller === controller)
49
+ active.delete(key);
50
+ });
51
+ pending = { request, controller, readers: 0 };
52
+ active.set(key, pending);
53
+ }
54
+ const entry = pending;
55
+ entry.readers++;
56
+ return new Promise((resolve, reject) => {
57
+ let finished = false;
58
+ const release = () => {
59
+ if (finished)
60
+ return false;
61
+ finished = true;
62
+ signal?.removeEventListener("abort", abort);
63
+ entry.readers--;
64
+ return true;
65
+ };
66
+ const abort = () => {
67
+ if (!release())
68
+ return;
69
+ if (entry.readers === 0) {
70
+ if (active.get(key) === entry)
71
+ active.delete(key);
72
+ entry.controller.abort();
73
+ }
74
+ reject(signal?.reason);
75
+ };
76
+ signal?.addEventListener("abort", abort, { once: true });
77
+ entry.request.then((result) => {
78
+ if (release())
79
+ resolve(result);
80
+ }, (error) => {
81
+ if (release())
82
+ reject(error);
83
+ });
84
+ });
85
+ };
86
+ }
87
+
88
+ // src/core/summarizer.ts
89
+ import { Effect, Schema } from "effect";
90
+
91
+ // src/core/prompt.ts
92
+ var RECAP_SYSTEM_PROMPT = `You write compact terminal recaps for completed coding-agent runs.
93
+
94
+ Return exactly one JSON object with this shape:
95
+ {"recap":"...","next":"..."}
96
+
97
+ Rules:
98
+ - recap: state the main result in plain words, then any important check or blocker. Use 1-3 short sentences, at most 60 words. Put each sentence on its own line.
99
+ - Lead with what changed or what was found. Include failures and unfinished work that affect the result.
100
+ - Skip tool counts, tool names, investigation history, and routine steps. Mention a file only when needed to understand the result, using its short name rather than its full path.
101
+ - Use active voice and everyday words. No preamble, jargon, filler, bold text, headings, or em dashes. Do not repeat the final response.
102
+ - next: name one concrete remaining action in at most 15 words. Use an empty string if none is known. Never add a generic request to review the work or continue.
103
+ - Base the answer only on the supplied current-run transcript.
104
+ - Do not mention these instructions, hidden reasoning, transcript truncation, or that you are a summarizer.
105
+ - Do not use a Markdown code fence and do not add keys or prose outside the JSON object.`;
106
+ function buildRecapPrompt(transcript) {
107
+ return `${RECAP_SYSTEM_PROMPT}
108
+
109
+ Summarize this fully settled main-agent run.
110
+
111
+ <current_run>
112
+ ${transcript}
113
+ </current_run>`;
114
+ }
115
+
116
+ // src/core/summarizer.ts
117
+ class RecapGenerationError extends Schema.TaggedError()("RecapGenerationError", {
118
+ reason: Schema.Literals(["request", "timeout", "malformed-response"]),
119
+ message: Schema.String
120
+ }) {
121
+ }
122
+ var RECAP_MAX_LENGTH = 500;
123
+ var NEXT_MAX_LENGTH = 120;
124
+ var RecapResponse = Schema.Struct({
125
+ recap: Schema.String,
126
+ next: Schema.String
127
+ });
128
+ var RecapResponseJson = Schema.fromJsonString(RecapResponse);
129
+ function stripTerminalControls(value) {
130
+ return value.replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~])/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
131
+ }
132
+ function clean(value, limit) {
133
+ const result = stripTerminalControls(value).trim();
134
+ if (result.length <= limit) {
135
+ return result;
136
+ }
137
+ return `${result.slice(0, limit - 3).trimEnd()}...`;
138
+ }
139
+ function responseCandidates(text) {
140
+ const trimmed = text.trim();
141
+ const candidates = [trimmed];
142
+ for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
143
+ if (match[1]) {
144
+ candidates.push(match[1].trim());
145
+ }
146
+ }
147
+ const first = trimmed.indexOf("{");
148
+ const last = trimmed.lastIndexOf("}");
149
+ if (first >= 0 && last > first) {
150
+ candidates.push(trimmed.slice(first, last + 1));
151
+ }
152
+ return [...new Set(candidates)];
153
+ }
154
+ var malformedResponse = () => new RecapGenerationError({
155
+ reason: "malformed-response",
156
+ message: "The recap model did not return valid recap JSON."
157
+ });
158
+ var parseRecapResponse = Effect.fn("parseRecapResponse")(function* (text) {
159
+ const decoded = yield* Effect.firstSuccessOf(responseCandidates(text).map((candidate) => Schema.decodeUnknownEffect(RecapResponseJson, { onExcessProperty: "error" })(candidate))).pipe(Effect.mapError(malformedResponse));
160
+ const recap = clean(decoded.recap, RECAP_MAX_LENGTH);
161
+ const next = clean(decoded.next.replace(/^next\s*:\s*/i, ""), NEXT_MAX_LENGTH);
162
+ if (!recap) {
163
+ return yield* malformedResponse();
164
+ }
165
+ return { recap, next };
166
+ });
167
+ var summarizeRun = Effect.fn("summarizeRun")(function* (options) {
168
+ const text = yield* options.generate({
169
+ prompt: buildRecapPrompt(options.transcript),
170
+ model: options.model
171
+ }).pipe(Effect.timeout(options.timeoutMs ?? 45000), Effect.mapError((error) => error._tag === "TimeoutError" ? new RecapGenerationError({
172
+ reason: "timeout",
173
+ message: "The recap model request timed out."
174
+ }) : error));
175
+ return yield* parseRecapResponse(text);
176
+ });
177
+
2
178
  // src/index.ts
3
- import { Effect } from "effect";
4
- import { Plugin } from "@opencode-ai/plugin/effect";
5
179
  var src_default = Plugin.define({
6
180
  id: "recap",
7
- effect: () => Effect.void
181
+ async setup(ctx) {
182
+ const cached = generationCache();
183
+ const controller = new AbortController;
184
+ await ctx.rpc.register(RecapRpc, {
185
+ async generate(input, call) {
186
+ const key = JSON.stringify([
187
+ input.sessionID,
188
+ input.eventID,
189
+ input.model.providerID,
190
+ input.model.id,
191
+ input.model.variant
192
+ ]);
193
+ try {
194
+ return await cached(key, (signal) => Effect2.runPromise(summarizeRun({
195
+ transcript: input.transcript,
196
+ model: input.model,
197
+ generate: (request) => Effect2.tryPromise({
198
+ try: (signal2) => ctx.generate.text(request, { signal: signal2 }).then((result) => result.text),
199
+ catch: (error) => new RecapGenerationError({ reason: "request", message: String(error) })
200
+ })
201
+ }), { signal: AbortSignal.any([signal, controller.signal]) }), call.signal);
202
+ } catch (error) {
203
+ throw call.error("generation_failed", String(error), {});
204
+ }
205
+ }
206
+ });
207
+ return () => controller.abort();
208
+ }
8
209
  });
9
210
  export {
10
211
  src_default as default
package/dist/rpc.js ADDED
@@ -0,0 +1,23 @@
1
+ // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var RecapRpc = Rpc.define({
6
+ id: "recap",
7
+ methods: {
8
+ generate: {
9
+ input: z.object({
10
+ sessionID: z.string().min(1),
11
+ eventID: z.string().min(1),
12
+ transcript: z.string(),
13
+ model: z.object({ providerID: z.string(), id: z.string(), variant: z.string().optional() })
14
+ }),
15
+ output: z.object({ recap: z.string(), next: z.string() }),
16
+ errors: { generation_failed: z.object({}) }
17
+ }
18
+ },
19
+ events: {}
20
+ });
21
+ export {
22
+ RecapRpc
23
+ };
package/dist/tui.js CHANGED
@@ -1,8 +1,28 @@
1
1
  // @bun
2
+ // src/rpc.ts
3
+ import { Rpc } from "@opencode-ai/plugin/rpc";
4
+ import { z } from "zod";
5
+ var RecapRpc = Rpc.define({
6
+ id: "recap",
7
+ methods: {
8
+ generate: {
9
+ input: z.object({
10
+ sessionID: z.string().min(1),
11
+ eventID: z.string().min(1),
12
+ transcript: z.string(),
13
+ model: z.object({ providerID: z.string(), id: z.string(), variant: z.string().optional() })
14
+ }),
15
+ output: z.object({ recap: z.string(), next: z.string() }),
16
+ errors: { generation_failed: z.object({}) }
17
+ }
18
+ },
19
+ events: {}
20
+ });
21
+
2
22
  // src/tui.tsx
3
23
  import { setProp as _$setProp2 } from "@opentui/solid";
4
24
  import { effect as _$effect2 } from "@opentui/solid";
5
- import { createTextNode as _$createTextNode2 } from "@opentui/solid";
25
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
6
26
  import { insertNode as _$insertNode2 } from "@opentui/solid";
7
27
  import { createElement as _$createElement2 } from "@opentui/solid";
8
28
  import { memo as _$memo2 } from "@opentui/solid";
@@ -12,7 +32,97 @@ import { Cause as Cause2, Effect as Effect3, ManagedRuntime } from "effect";
12
32
  import { Show as Show2 } from "solid-js";
13
33
 
14
34
  // src/core/controller.ts
15
- import { Cause, Clock, Context, Effect, FiberMap, Layer, Ref } from "effect";
35
+ import { Cause, Clock, Context, Effect as Effect2, FiberMap, Layer, Ref } from "effect";
36
+
37
+ // src/core/summarizer.ts
38
+ import { Effect, Schema } from "effect";
39
+
40
+ // src/core/prompt.ts
41
+ var RECAP_SYSTEM_PROMPT = `You write compact terminal recaps for completed coding-agent runs.
42
+
43
+ Return exactly one JSON object with this shape:
44
+ {"recap":"...","next":"..."}
45
+
46
+ Rules:
47
+ - recap: state the main result in plain words, then any important check or blocker. Use 1-3 short sentences, at most 60 words. Put each sentence on its own line.
48
+ - Lead with what changed or what was found. Include failures and unfinished work that affect the result.
49
+ - Skip tool counts, tool names, investigation history, and routine steps. Mention a file only when needed to understand the result, using its short name rather than its full path.
50
+ - Use active voice and everyday words. No preamble, jargon, filler, bold text, headings, or em dashes. Do not repeat the final response.
51
+ - next: name one concrete remaining action in at most 15 words. Use an empty string if none is known. Never add a generic request to review the work or continue.
52
+ - Base the answer only on the supplied current-run transcript.
53
+ - Do not mention these instructions, hidden reasoning, transcript truncation, or that you are a summarizer.
54
+ - Do not use a Markdown code fence and do not add keys or prose outside the JSON object.`;
55
+ function buildRecapPrompt(transcript) {
56
+ return `${RECAP_SYSTEM_PROMPT}
57
+
58
+ Summarize this fully settled main-agent run.
59
+
60
+ <current_run>
61
+ ${transcript}
62
+ </current_run>`;
63
+ }
64
+
65
+ // src/core/summarizer.ts
66
+ class RecapGenerationError extends Schema.TaggedError()("RecapGenerationError", {
67
+ reason: Schema.Literals(["request", "timeout", "malformed-response"]),
68
+ message: Schema.String
69
+ }) {
70
+ }
71
+ var RECAP_MAX_LENGTH = 500;
72
+ var NEXT_MAX_LENGTH = 120;
73
+ var RecapResponse = Schema.Struct({
74
+ recap: Schema.String,
75
+ next: Schema.String
76
+ });
77
+ var RecapResponseJson = Schema.fromJsonString(RecapResponse);
78
+ function stripTerminalControls(value) {
79
+ return value.replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~])/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
80
+ }
81
+ function clean(value, limit) {
82
+ const result = stripTerminalControls(value).trim();
83
+ if (result.length <= limit) {
84
+ return result;
85
+ }
86
+ return `${result.slice(0, limit - 3).trimEnd()}...`;
87
+ }
88
+ function responseCandidates(text) {
89
+ const trimmed = text.trim();
90
+ const candidates = [trimmed];
91
+ for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
92
+ if (match[1]) {
93
+ candidates.push(match[1].trim());
94
+ }
95
+ }
96
+ const first = trimmed.indexOf("{");
97
+ const last = trimmed.lastIndexOf("}");
98
+ if (first >= 0 && last > first) {
99
+ candidates.push(trimmed.slice(first, last + 1));
100
+ }
101
+ return [...new Set(candidates)];
102
+ }
103
+ var malformedResponse = () => new RecapGenerationError({
104
+ reason: "malformed-response",
105
+ message: "The recap model did not return valid recap JSON."
106
+ });
107
+ var parseRecapResponse = Effect.fn("parseRecapResponse")(function* (text) {
108
+ const decoded = yield* Effect.firstSuccessOf(responseCandidates(text).map((candidate) => Schema.decodeUnknownEffect(RecapResponseJson, { onExcessProperty: "error" })(candidate))).pipe(Effect.mapError(malformedResponse));
109
+ const recap = clean(decoded.recap, RECAP_MAX_LENGTH);
110
+ const next = clean(decoded.next.replace(/^next\s*:\s*/i, ""), NEXT_MAX_LENGTH);
111
+ if (!recap) {
112
+ return yield* malformedResponse();
113
+ }
114
+ return { recap, next };
115
+ });
116
+ var summarizeRun = Effect.fn("summarizeRun")(function* (options) {
117
+ const text = yield* options.generate({
118
+ prompt: buildRecapPrompt(options.transcript),
119
+ model: options.model
120
+ }).pipe(Effect.timeout(options.timeoutMs ?? 45000), Effect.mapError((error) => error._tag === "TimeoutError" ? new RecapGenerationError({
121
+ reason: "timeout",
122
+ message: "The recap model request timed out."
123
+ }) : error));
124
+ return yield* parseRecapResponse(text);
125
+ });
16
126
 
17
127
  // src/core/transcript.ts
18
128
  var TOOL_ARGUMENT_MAX_BYTES = 2000;
@@ -152,31 +262,24 @@ function serializeRunTranscript(messages, detail, maxBytes = TRANSCRIPT_MAX_BYTE
152
262
  return `${head}${marker}${tail}`;
153
263
  }
154
264
  function buildFallbackRecap(messages, outcome = "completed") {
155
- const tools = [];
156
265
  let final = "";
157
266
  for (const message of messages) {
158
267
  if (message.type !== "assistant") {
159
268
  continue;
160
269
  }
161
270
  for (const part of message.content) {
162
- if (part.type === "tool") {
163
- tools.push(part.name);
164
- }
165
271
  if (part.type === "text" && part.text.trim()) {
166
272
  final = redactSecrets(part.text.trim());
167
273
  }
168
274
  }
169
275
  }
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")}` : "";
276
+ const excerpt = stripTerminalControls(final).split(/\n\s*\n/)[0].replace(/\*\*|`/g, "").trim();
277
+ const result = excerpt.length <= 360 ? excerpt : `${excerpt.slice(0, 357).replace(/\s+\S*$/, "").trimEnd()}...`;
278
+ const status = `Run ${outcome}.`;
177
279
  return {
178
- recap: `The main-agent run ${outcome}.${activity}${result}`.trim(),
179
- next: "Review the completed work above and continue if anything remains."
280
+ recap: result ? outcome === "completed" || outcome === "succeeded" ? result : `${status}
281
+ ${result}` : status,
282
+ next: ""
180
283
  };
181
284
  }
182
285
  function selectRunMessages(messages, baseline) {
@@ -186,13 +289,13 @@ function selectRunMessages(messages, baseline) {
186
289
  // src/core/controller.ts
187
290
  class RecapControllerService extends Context.Service()("opencode-recap-plugin/RecapController") {
188
291
  }
189
- var createRecapController = Effect.fn("createRecapController")(function* (deps) {
292
+ var createRecapController = Effect2.fn("createRecapController")(function* (deps) {
190
293
  const state = yield* Ref.make({
191
294
  inboxBaselines: new Map,
192
295
  runs: new Map
193
296
  });
194
297
  const active = yield* FiberMap.make();
195
- const invalidate = Effect.fn("RecapController.invalidate")(function* (sessionID) {
298
+ const invalidate = Effect2.fn("RecapController.invalidate")(function* (sessionID) {
196
299
  yield* FiberMap.remove(active, sessionID);
197
300
  yield* Ref.modify(state, (current) => {
198
301
  const runs = new Map(current.runs);
@@ -201,7 +304,7 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
201
304
  });
202
305
  yield* deps.persist(undefined, sessionID);
203
306
  });
204
- const inbox = Effect.fn("RecapController.inbox")(function* (sessionID, user) {
307
+ const inbox = Effect2.fn("RecapController.inbox")(function* (sessionID, user) {
205
308
  if (!user || deps.session(sessionID)?.parentID) {
206
309
  return;
207
310
  }
@@ -216,7 +319,7 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
216
319
  yield* FiberMap.remove(active, sessionID);
217
320
  yield* deps.persist(undefined, sessionID);
218
321
  });
219
- const started = Effect.fn("RecapController.started")(function* (sessionID) {
322
+ const started = Effect2.fn("RecapController.started")(function* (sessionID) {
220
323
  if (deps.session(sessionID)?.parentID) {
221
324
  return;
222
325
  }
@@ -233,7 +336,7 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
233
336
  });
234
337
  yield* deps.persist(undefined, sessionID);
235
338
  });
236
- const terminal = Effect.fn("RecapController.terminal")(function* (input) {
339
+ const terminal = Effect2.fn("RecapController.terminal")(function* (input) {
237
340
  const baseline = yield* Ref.modify(state, (current) => {
238
341
  const runs = new Map(current.runs);
239
342
  const boundary = runs.get(input.sessionID);
@@ -243,7 +346,7 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
243
346
  if (!baseline) {
244
347
  return;
245
348
  }
246
- const generation = Effect.gen(function* () {
349
+ const generation = Effect2.gen(function* () {
247
350
  yield* deps.syncMessages(input.sessionID);
248
351
  const messages = selectRunMessages(deps.messages(input.sessionID), baseline);
249
352
  if (messages.length === 0) {
@@ -251,9 +354,11 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
251
354
  }
252
355
  const model = deps.model();
253
356
  const result = yield* deps.generate({
357
+ sessionID: input.sessionID,
358
+ eventID: input.eventID,
254
359
  transcript: serializeRunTranscript(messages, input.detail),
255
360
  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({
361
+ }).pipe(Effect2.map((recap) => ({ recap, fallback: false })), Effect2.catch((error) => deps.warning(`The recap model failed; showing a local fallback. ${error.message}`).pipe(Effect2.as({
257
362
  recap: buildFallbackRecap(messages, input.outcome),
258
363
  fallback: true
259
364
  }))));
@@ -269,7 +374,7 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
269
374
  created
270
375
  }, input.sessionID);
271
376
  });
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)));
377
+ const trackedGeneration = deps.running(input.sessionID, true).pipe(Effect2.andThen(generation), Effect2.ensuring(deps.running(input.sessionID, false)), Effect2.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect2.failCause(cause) : deps.unexpected(cause)));
273
378
  yield* FiberMap.run(active, input.sessionID, trackedGeneration, {
274
379
  startImmediately: true
275
380
  });
@@ -280,93 +385,6 @@ function recapControllerLayer(deps) {
280
385
  return Layer.effect(RecapControllerService, createRecapController(deps));
281
386
  }
282
387
 
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
388
  // src/tui/inline.tsx
371
389
  import { createComponent as _$createComponent } from "@opentui/solid";
372
390
  import { isRenderable } from "@opentui/core";
@@ -375,44 +393,39 @@ import { createSignal, onCleanup, onMount, Show } from "solid-js";
375
393
 
376
394
  // src/tui/card.tsx
377
395
  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
396
  import { insertNode as _$insertNode } from "@opentui/solid";
397
+ import { insert as _$insert } from "@opentui/solid";
398
+ import { memo as _$memo } from "@opentui/solid";
382
399
  import { setProp as _$setProp } from "@opentui/solid";
383
400
  import { createElement as _$createElement } from "@opentui/solid";
384
401
  function RecapCard(props) {
385
402
  return (() => {
386
- var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text");
403
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"), _el$4 = _$createElement("text");
387
404
  _$insertNode(_el$, _el$2);
388
- _$insertNode(_el$, _el$5);
389
- _$insertNode(_el$, _el$6);
405
+ _$insertNode(_el$, _el$4);
390
406
  _$setProp(_el$, "flexDirection", "column");
391
407
  _$setProp(_el$, "marginTop", 1);
392
408
  _$setProp(_el$, "paddingLeft", 3);
393
409
  _$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}`);
410
+ _$insert(_el$3, () => props.recap.fallback ? "Recap \xB7 excerpt" : "Recap");
411
+ _$insert(_el$4, () => props.recap.recap);
397
412
  _$insert(_el$, (() => {
398
- var _c$ = _$memo(() => !!props.recap.fallback);
413
+ var _c$ = _$memo(() => !!props.recap.next);
399
414
  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.subdued, _$p));
403
- return _el$7;
415
+ var _el$5 = _$createElement("text");
416
+ _$insert(_el$5, () => `Next: ${props.recap.next}`);
417
+ _$effect((_$p) => _$setProp(_el$5, "fg", props.theme.text.subdued, _$p));
418
+ return _el$5;
404
419
  })() : null;
405
420
  })(), null);
406
421
  _$effect((_p$) => {
407
- var _v$ = props.theme.text.subdued, _v$2 = props.theme.text.default, _v$3 = props.theme.text.subdued;
422
+ var _v$ = props.theme.text.subdued, _v$2 = props.theme.text.default;
408
423
  _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));
424
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
411
425
  return _p$;
412
426
  }, {
413
427
  e: undefined,
414
- t: undefined,
415
- a: undefined
428
+ t: undefined
416
429
  });
417
430
  return _el$;
418
431
  })();
@@ -526,6 +539,7 @@ function errorMessage(error) {
526
539
  var tui_default = Plugin.define({
527
540
  id: "recap",
528
541
  setup(context) {
542
+ const rpc = context.client.rpc(RecapRpc);
529
543
  const [state, updateState] = context.storage.store("state", {
530
544
  initial: {
531
545
  model: DEFAULT_MODEL,
@@ -598,27 +612,20 @@ var tui_default = Plugin.define({
598
612
  ...state.model
599
613
  };
600
614
  },
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
- }
615
+ generate(input) {
616
+ const location = context.location ?? context.data.location.default();
617
+ return Effect3.tryPromise({
618
+ try: (signal) => rpc.generate(input, {
619
+ signal,
620
+ location: {
621
+ directory: location.directory,
622
+ workspace: location.workspaceID
623
+ }
624
+ }),
625
+ catch: (error) => new RecapGenerationError({
626
+ reason: "request",
627
+ message: `The recap model request failed. ${errorMessage(error)}`
628
+ })
622
629
  });
623
630
  },
624
631
  persist(recap, sessionID) {
@@ -765,7 +772,7 @@ ${event.data.reason}`
765
772
  },
766
773
  get children() {
767
774
  var _el$ = _$createElement2("text");
768
- _$insertNode2(_el$, _$createTextNode2(`\u25CF generating run recap...`));
775
+ _$insertNode2(_el$, _$createTextNode(`\u25CF generating run recap...`));
769
776
  _$effect2((_$p) => _$setProp2(_el$, "fg", context.theme.text.status.running, _$p));
770
777
  return _el$;
771
778
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azatakmyradov/opencode-recap-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run recaps and suggested next steps for the OpenCode V2 TUI",
5
5
  "keywords": [
6
6
  "opencode",
@@ -37,7 +37,8 @@
37
37
  "dependencies": {
38
38
  "@opencode-ai/client": "0.0.0-beta-18721",
39
39
  "@opencode-ai/plugin": "0.0.0-beta-18721",
40
- "effect": "4.0.0-rc.112"
40
+ "effect": "4.0.0-rc.112",
41
+ "zod": "4.1.8"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "@opentui/core": ">=0.5.9",