@objctp/opencode-better-prompt 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { Plugin } from "@opencode-ai/plugin";
4
+ import type { Event, Part } from "@opencode-ai/sdk";
5
+ import { CONFIG_PATH, parseConfig } from "./better-prompt/config";
6
+ import { runPipeline } from "./better-prompt/pipeline";
7
+ import { SUB_AGENTS } from "./better-prompt/agents";
8
+ import { clearState, writeAudit, writeState } from "./better-prompt/state";
9
+ import type {
10
+ AuditEntry,
11
+ PipelineDeps,
12
+ PipelineResult,
13
+ PipelineState,
14
+ SessionContext,
15
+ StageNotifier,
16
+ StageState,
17
+ ToastVariant,
18
+ Usage,
19
+ } from "./better-prompt/types";
20
+
21
+ // :::: Plugin :::: ////////////////////////////////////////////
22
+
23
+ export const BetterPromptPlugin: Plugin = async (ctx) => {
24
+ const { client, directory } = ctx;
25
+
26
+ const AUDIT_DIR = join(directory, ".opencode", "better-prompt");
27
+ const AUDIT_PATH = join(AUDIT_DIR, "audit.json");
28
+ const STATE_PATH = join(homedir(), ".local", "state", "opencode", "better-prompt", "state.json");
29
+
30
+ // :::: Error/warn logging via opencode :::: ///////////////
31
+
32
+ async function logError(message: string, error?: unknown): Promise<void> {
33
+ try {
34
+ const errStr = error instanceof Error ? error.message : error ? String(error) : "";
35
+ await client.app.log({
36
+ body: {
37
+ service: "better-prompt",
38
+ level: "error",
39
+ message: errStr ? `${message} — ${errStr}` : message,
40
+ },
41
+ });
42
+ } catch {
43
+ // opencode log may be unavailable
44
+ }
45
+ }
46
+
47
+ async function logWarn(message: string, detail?: string): Promise<void> {
48
+ try {
49
+ await client.app.log({
50
+ body: {
51
+ service: "better-prompt",
52
+ level: "warn",
53
+ message: detail ? `${message} — ${detail}` : message,
54
+ },
55
+ });
56
+ } catch {
57
+ // opencode log may be unavailable
58
+ }
59
+ }
60
+
61
+ // :::: Toast :::: /////////////////////////////////////////
62
+
63
+ async function toast(
64
+ message: string,
65
+ variant: ToastVariant = "info",
66
+ duration = 4000,
67
+ ): Promise<void> {
68
+ try {
69
+ await client.tui.showToast({
70
+ body: { message: `[bp] ${message}`, variant, duration },
71
+ });
72
+ } catch {
73
+ // TUI may not be available in headless/CLI mode
74
+ }
75
+ }
76
+
77
+ // :::: Session state :::: /////////////////////////////////
78
+
79
+ const sessionCost = {
80
+ cost: 0,
81
+ inputTokens: 0,
82
+ outputTokens: 0,
83
+ cacheWriteTokens: 0,
84
+ cacheReadTokens: 0,
85
+ };
86
+
87
+ const sessionContexts = new Map<string, SessionContext>();
88
+
89
+ const deps: PipelineDeps = { client, logError, logWarn, toast, sessionContexts };
90
+
91
+ // :::: Hooks :::: /////////////////////////////////////////
92
+
93
+ return {
94
+ event: async ({ event }: { event: Event }) => {
95
+ if (event.type === "session.deleted") {
96
+ const props = event.properties as Record<string, unknown> | undefined;
97
+ const sid = (props?.sessionID ??
98
+ (props?.info as Record<string, unknown> | undefined)?.id) as string | undefined;
99
+ if (sid) {
100
+ sessionContexts.delete(sid);
101
+ clearState(STATE_PATH);
102
+ }
103
+ }
104
+ },
105
+
106
+ "chat.message": async (
107
+ input: { sessionID: string; agent?: string; messageID?: string },
108
+ output: { parts: Part[] },
109
+ ) => {
110
+ if (input.agent && SUB_AGENTS.has(input.agent)) return;
111
+
112
+ const textPart = output.parts?.find((p: Part) => p.type === "text" && "text" in p);
113
+ if (!textPart || !("text" in textPart)) return;
114
+
115
+ const originalText = textPart.text;
116
+ if (!originalText?.trim()) return;
117
+
118
+ const config = parseConfig(CONFIG_PATH);
119
+ if (!config.enabled) return;
120
+
121
+ // Show initial toast to fix "frozen" UX
122
+ await toast("Processing prompt...", "info", 15000);
123
+
124
+ // Stage tracker for timing and live sidebar updates
125
+ const stageTracker: Record<
126
+ string,
127
+ {
128
+ status: string;
129
+ startTime?: number;
130
+ durationMs: number | null;
131
+ error?: string;
132
+ }
133
+ > = {};
134
+
135
+ function buildStageStates(): PipelineState["stages"] {
136
+ const names = ["correction", "translation", "context", "enhancement"] as const;
137
+ const result = {} as PipelineState["stages"];
138
+ for (const s of names) {
139
+ const t = stageTracker[s];
140
+ result[s] = {
141
+ status: (t?.status as StageState["status"]) || "pending",
142
+ durationMs: t?.durationMs ?? null,
143
+ ...(t?.error && { error: t.error }),
144
+ };
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function writeRunningState() {
150
+ writeState(STATE_PATH, {
151
+ timestamp: new Date().toISOString(),
152
+ status: "running",
153
+ language: null,
154
+ mistakes: 0,
155
+ stages: buildStageStates(),
156
+ cost: 0,
157
+ inputTokens: 0,
158
+ outputTokens: 0,
159
+ cacheWriteTokens: 0,
160
+ cacheReadTokens: 0,
161
+ sessionCost: sessionCost.cost,
162
+ sessionInputTokens: sessionCost.inputTokens,
163
+ sessionOutputTokens: sessionCost.outputTokens,
164
+ });
165
+ }
166
+
167
+ const notify: StageNotifier = (stage, status, detail) => {
168
+ const now = Date.now();
169
+ if (status === "starting") {
170
+ stageTracker[stage] = {
171
+ status: "active",
172
+ startTime: now,
173
+ durationMs: null,
174
+ };
175
+ } else {
176
+ const prev = stageTracker[stage];
177
+ const durationMs = prev?.startTime ? now - prev.startTime : null;
178
+ stageTracker[stage] = {
179
+ status,
180
+ durationMs,
181
+ ...(detail && { error: detail }),
182
+ };
183
+ }
184
+ writeRunningState();
185
+ };
186
+
187
+ let result: string;
188
+ let corrected: string | null;
189
+ let detectedLanguage: string | null;
190
+ let mistakes: PipelineResult["mistakes"];
191
+ let usage: Usage = {
192
+ cost: 0,
193
+ inputTokens: 0,
194
+ outputTokens: 0,
195
+ cacheWriteTokens: 0,
196
+ cacheReadTokens: 0,
197
+ };
198
+
199
+ try {
200
+ const pipelineResult = await runPipeline(
201
+ deps,
202
+ originalText,
203
+ input.sessionID,
204
+ config,
205
+ notify,
206
+ input.messageID,
207
+ );
208
+ result = pipelineResult.result;
209
+ corrected = pipelineResult.corrected;
210
+ detectedLanguage = pipelineResult.detectedLanguage;
211
+ mistakes = pipelineResult.mistakes;
212
+ usage = pipelineResult.usage;
213
+ } catch (err) {
214
+ void logError("pipeline failed", err);
215
+ writeState(STATE_PATH, {
216
+ timestamp: new Date().toISOString(),
217
+ status: "error",
218
+ language: null,
219
+ mistakes: 0,
220
+ stages: buildStageStates(),
221
+ cost: 0,
222
+ inputTokens: 0,
223
+ outputTokens: 0,
224
+ cacheWriteTokens: 0,
225
+ cacheReadTokens: 0,
226
+ sessionCost: sessionCost.cost,
227
+ sessionInputTokens: sessionCost.inputTokens,
228
+ sessionOutputTokens: sessionCost.outputTokens,
229
+ });
230
+ await toast("Pipeline error — original prompt sent", "error", 5000);
231
+ return;
232
+ }
233
+
234
+ // Completion toast
235
+ const changed = result !== originalText;
236
+ await toast(changed ? "Prompt modified" : "No changes", changed ? "success" : "info", 3000);
237
+
238
+ // Replace text
239
+ textPart.text = result;
240
+
241
+ // Audit
242
+ if (config.audit) {
243
+ const mistakeNature = [...new Set(mistakes.map((m) => m.type))];
244
+ const entry: AuditEntry = {
245
+ date: new Date().toISOString(),
246
+ prompt: originalText,
247
+ language: detectedLanguage,
248
+ corrected: config.correction ? corrected : null,
249
+ enhanced: config.enhancement ? result : null,
250
+ "mistake-nature": mistakeNature,
251
+ mistakes,
252
+ models: {
253
+ correction: config.correction ? config.correction_model : null,
254
+ translation: config.translation ? config.translation_model : null,
255
+ enhancement: config.enhancement ? config.enhancement_model : null,
256
+ context: config.enhancement ? config.correction_model : null,
257
+ },
258
+ usage,
259
+ };
260
+ writeAudit(AUDIT_PATH, entry);
261
+ }
262
+
263
+ // Accumulate session cost
264
+ sessionCost.cost += usage.cost;
265
+ sessionCost.inputTokens += usage.inputTokens;
266
+ sessionCost.outputTokens += usage.outputTokens;
267
+ sessionCost.cacheWriteTokens += usage.cacheWriteTokens;
268
+ sessionCost.cacheReadTokens += usage.cacheReadTokens;
269
+
270
+ // Write final state for TUI sidebar
271
+ writeState(STATE_PATH, {
272
+ timestamp: new Date().toISOString(),
273
+ status: changed ? "modified" : "no_changes",
274
+ language: detectedLanguage,
275
+ mistakes: mistakes.length,
276
+ stages: buildStageStates(),
277
+ cost: usage.cost,
278
+ inputTokens: usage.inputTokens,
279
+ outputTokens: usage.outputTokens,
280
+ cacheWriteTokens: usage.cacheWriteTokens,
281
+ cacheReadTokens: usage.cacheReadTokens,
282
+ sessionCost: sessionCost.cost,
283
+ sessionInputTokens: sessionCost.inputTokens,
284
+ sessionOutputTokens: sessionCost.outputTokens,
285
+ preview: {
286
+ original: originalText,
287
+ processed: result,
288
+ mistakeDetails: mistakes,
289
+ },
290
+ });
291
+ },
292
+ };
293
+ };
294
+
295
+ export default {
296
+ id: "better-prompt",
297
+ server: BetterPromptPlugin,
298
+ };