@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 +2 -2
- package/dist/index.js +201 -3
- package/dist/rpc.js +23 -0
- package/dist/tui.js +37 -21
- package/package.json +3 -2
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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.
|
|
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",
|