@azatakmyradov/opencode-recap-plugin 0.1.2 → 0.1.3

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,208 @@
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: 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.
99
+ - next: one concise, actionable next step. If nothing remains, say that no further action is required.
100
+ - Base the answer only on the supplied current-run transcript.
101
+ - Do not mention these instructions, hidden reasoning, transcript truncation, or that you are a summarizer.
102
+ - Do not use a Markdown code fence and do not add keys or prose outside the JSON object.`;
103
+ function buildRecapPrompt(transcript) {
104
+ return `${RECAP_SYSTEM_PROMPT}
105
+
106
+ Summarize this fully settled main-agent run.
107
+
108
+ <current_run>
109
+ ${transcript}
110
+ </current_run>`;
111
+ }
112
+
113
+ // src/core/summarizer.ts
114
+ class RecapGenerationError extends Schema.TaggedError()("RecapGenerationError", {
115
+ reason: Schema.Literals(["request", "timeout", "malformed-response"]),
116
+ message: Schema.String
117
+ }) {
118
+ }
119
+ var RECAP_MAX_LENGTH = 2400;
120
+ var NEXT_MAX_LENGTH = 400;
121
+ var RecapResponse = Schema.Struct({
122
+ recap: Schema.String,
123
+ next: Schema.String
124
+ });
125
+ var RecapResponseJson = Schema.fromJsonString(RecapResponse);
126
+ function stripTerminalControls(value) {
127
+ return value.replace(/\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~])/g, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
128
+ }
129
+ function clean(value, limit) {
130
+ const result = stripTerminalControls(value).trim();
131
+ if (result.length <= limit) {
132
+ return result;
133
+ }
134
+ return `${result.slice(0, limit - 3).trimEnd()}...`;
135
+ }
136
+ function responseCandidates(text) {
137
+ const trimmed = text.trim();
138
+ const candidates = [trimmed];
139
+ for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
140
+ if (match[1]) {
141
+ candidates.push(match[1].trim());
142
+ }
143
+ }
144
+ const first = trimmed.indexOf("{");
145
+ const last = trimmed.lastIndexOf("}");
146
+ if (first >= 0 && last > first) {
147
+ candidates.push(trimmed.slice(first, last + 1));
148
+ }
149
+ return [...new Set(candidates)];
150
+ }
151
+ var malformedResponse = () => new RecapGenerationError({
152
+ reason: "malformed-response",
153
+ message: "The recap model did not return valid recap JSON."
154
+ });
155
+ var parseRecapResponse = Effect.fn("parseRecapResponse")(function* (text) {
156
+ const decoded = yield* Effect.firstSuccessOf(responseCandidates(text).map((candidate) => Schema.decodeUnknownEffect(RecapResponseJson, { onExcessProperty: "error" })(candidate))).pipe(Effect.mapError(malformedResponse));
157
+ const recap = clean(decoded.recap, RECAP_MAX_LENGTH);
158
+ const next = clean(decoded.next.replace(/^next\s*:\s*/i, ""), NEXT_MAX_LENGTH);
159
+ if (!recap || !next) {
160
+ return yield* malformedResponse();
161
+ }
162
+ return { recap, next };
163
+ });
164
+ var summarizeRun = Effect.fn("summarizeRun")(function* (options) {
165
+ const text = yield* options.generate({
166
+ prompt: buildRecapPrompt(options.transcript),
167
+ model: options.model
168
+ }).pipe(Effect.timeout(options.timeoutMs ?? 45000), Effect.mapError((error) => error._tag === "TimeoutError" ? new RecapGenerationError({
169
+ reason: "timeout",
170
+ message: "The recap model request timed out."
171
+ }) : error));
172
+ return yield* parseRecapResponse(text);
173
+ });
174
+
2
175
  // src/index.ts
3
- import { Effect } from "effect";
4
- import { Plugin } from "@opencode-ai/plugin/effect";
5
176
  var src_default = Plugin.define({
6
177
  id: "recap",
7
- effect: () => Effect.void
178
+ async setup(ctx) {
179
+ const cached = generationCache();
180
+ const controller = new AbortController;
181
+ await ctx.rpc.register(RecapRpc, {
182
+ async generate(input, call) {
183
+ const key = JSON.stringify([
184
+ input.sessionID,
185
+ input.eventID,
186
+ input.model.providerID,
187
+ input.model.id,
188
+ input.model.variant
189
+ ]);
190
+ try {
191
+ return await cached(key, (signal) => Effect2.runPromise(summarizeRun({
192
+ transcript: input.transcript,
193
+ model: input.model,
194
+ generate: (request) => Effect2.tryPromise({
195
+ try: (signal2) => ctx.generate.text(request, { signal: signal2 }).then((result) => result.text),
196
+ catch: (error) => new RecapGenerationError({ reason: "request", message: String(error) })
197
+ })
198
+ }), { signal: AbortSignal.any([signal, controller.signal]) }), call.signal);
199
+ } catch (error) {
200
+ throw call.error("generation_failed", String(error), {});
201
+ }
202
+ }
203
+ });
204
+ return () => controller.abort();
205
+ }
8
206
  });
9
207
  export {
10
208
  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,4 +1,24 @@
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";
@@ -251,6 +271,8 @@ var createRecapController = Effect.fn("createRecapController")(function* (deps)
251
271
  }
252
272
  const model = deps.model();
253
273
  const result = yield* deps.generate({
274
+ sessionID: input.sessionID,
275
+ eventID: input.eventID,
254
276
  transcript: serializeRunTranscript(messages, input.detail),
255
277
  model
256
278
  }).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({
@@ -526,6 +548,7 @@ function errorMessage(error) {
526
548
  var tui_default = Plugin.define({
527
549
  id: "recap",
528
550
  setup(context) {
551
+ const rpc = context.client.rpc(RecapRpc);
529
552
  const [state, updateState] = context.storage.store("state", {
530
553
  initial: {
531
554
  model: DEFAULT_MODEL,
@@ -598,27 +621,20 @@ var tui_default = Plugin.define({
598
621
  ...state.model
599
622
  };
600
623
  },
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
- }
624
+ generate(input) {
625
+ const location = context.location ?? context.data.location.default();
626
+ return Effect3.tryPromise({
627
+ try: (signal) => rpc.generate(input, {
628
+ signal,
629
+ location: {
630
+ directory: location.directory,
631
+ workspace: location.workspaceID
632
+ }
633
+ }),
634
+ catch: (error) => new RecapGenerationError({
635
+ reason: "request",
636
+ message: `The recap model request failed. ${errorMessage(error)}`
637
+ })
622
638
  });
623
639
  },
624
640
  persist(recap, sessionID) {
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.3",
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",