@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 +2 -2
- package/dist/index.js +204 -3
- package/dist/rpc.js +23 -0
- package/dist/tui.js +158 -151
- 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,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
|
-
|
|
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 _$
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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:
|
|
179
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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$
|
|
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$
|
|
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
|
-
_$
|
|
395
|
-
_$insert(_el$
|
|
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.
|
|
413
|
+
var _c$ = _$memo(() => !!props.recap.next);
|
|
399
414
|
return () => _c$() ? (() => {
|
|
400
|
-
var _el$
|
|
401
|
-
_$
|
|
402
|
-
_$effect((_$p) => _$setProp(_el$
|
|
403
|
-
return _el$
|
|
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
|
|
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$
|
|
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
|
-
|
|
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
|
-
}
|
|
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$, _$
|
|
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.
|
|
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",
|