@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,190 @@
1
+ import { CONFIG_DEFAULTS } from "./config";
2
+ import type { Config } from "./config";
3
+ import { resolveModel } from "./catalog";
4
+ import { invokeAgent, summariseContext } from "./agents";
5
+ import type { PipelineDeps, PipelineResult, StageNotifier, Usage } from "./types";
6
+
7
+ // A correction is consistent when every claimed fix actually appears in the
8
+ // corrected text. A model that lists a fix for text it then dropped has
9
+ // rewritten or extracted a subset rather than applying discrete fixes — the
10
+ // correction should be discarded and the original kept.
11
+ function correctionIsConsistent(
12
+ corrected: string,
13
+ mistakes: Array<{ correction?: string }>,
14
+ ): boolean {
15
+ for (const m of mistakes) {
16
+ const fix = m.correction;
17
+ if (!fix) continue;
18
+ if (!corrected.toLowerCase().includes(fix.toLowerCase())) {
19
+ return false;
20
+ }
21
+ }
22
+ return true;
23
+ }
24
+
25
+ export async function runPipeline(
26
+ deps: PipelineDeps,
27
+ text: string,
28
+ sessionID: string,
29
+ config: Config,
30
+ notify: StageNotifier,
31
+ currentMessageID?: string,
32
+ ): Promise<PipelineResult> {
33
+ const { toast, logWarn } = deps;
34
+
35
+ let working = text;
36
+ let corrected: string | null = null;
37
+ let detectedLanguage: string | null = null;
38
+ let mistakes: Array<{ type: string; original: string; correction: string }> = [];
39
+ const totalUsage: Usage = {
40
+ cost: 0,
41
+ inputTokens: 0,
42
+ outputTokens: 0,
43
+ cacheWriteTokens: 0,
44
+ cacheReadTokens: 0,
45
+ };
46
+
47
+ function addUsage(u: Usage) {
48
+ totalUsage.cost += u.cost;
49
+ totalUsage.inputTokens += u.inputTokens;
50
+ totalUsage.outputTokens += u.outputTokens;
51
+ totalUsage.cacheWriteTokens += u.cacheWriteTokens;
52
+ totalUsage.cacheReadTokens += u.cacheReadTokens;
53
+ }
54
+
55
+ const { correction, translation, enhancement } = config;
56
+ const anyStage = correction || translation || enhancement;
57
+ if (!anyStage) {
58
+ return {
59
+ result: text,
60
+ corrected: null,
61
+ detectedLanguage: null,
62
+ mistakes: [],
63
+ contextSummary: "",
64
+ usage: totalUsage,
65
+ };
66
+ }
67
+
68
+ const skipCorrection = enhancement && !translation && correction;
69
+ const correctionOnlyForLanguage = translation && !correction;
70
+
71
+ // :::: Correction //
72
+ if (!skipCorrection && (correction || correctionOnlyForLanguage)) {
73
+ notify("correction", "starting");
74
+ const model = await resolveModel(config.correction_model, CONFIG_DEFAULTS.correction_model);
75
+ const correctionResult = await invokeAgent(deps, "prompt-correction", working, model);
76
+ addUsage(correctionResult.usage);
77
+ const raw = correctionResult.text;
78
+
79
+ let correctionFailed = false;
80
+ let correctionError = "";
81
+ try {
82
+ const fenceMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
83
+ const cleaned = fenceMatch ? fenceMatch[1].trim() : raw.trim();
84
+ const parsed = JSON.parse(cleaned);
85
+ if (parsed.corrected && typeof parsed.corrected === "string") {
86
+ if (correctionOnlyForLanguage) {
87
+ detectedLanguage = parsed.language || null;
88
+ } else {
89
+ const candidateMistakes = Array.isArray(parsed.mistakes) ? parsed.mistakes : [];
90
+ // Guard: every claimed fix must appear in the corrected text; otherwise
91
+ // the model rewrote/extracted a subset instead of applying discrete fixes
92
+ // — discard and keep the original prompt.
93
+ if (!correctionIsConsistent(parsed.corrected, candidateMistakes)) {
94
+ correctionFailed = true;
95
+ correctionError = "discarded: dropped a claimed fix";
96
+ void logWarn("correction discarded — output dropped a claimed fix; keeping original");
97
+ } else {
98
+ working = parsed.corrected;
99
+ corrected = parsed.corrected;
100
+ detectedLanguage = parsed.language || null;
101
+ mistakes = candidateMistakes;
102
+ }
103
+ }
104
+ } else {
105
+ correctionFailed = true;
106
+ correctionError = "missing corrected field";
107
+ void logWarn(`correction agent returned JSON without "corrected" field`);
108
+ }
109
+ } catch {
110
+ correctionFailed = true;
111
+ correctionError = "non-JSON response";
112
+ void logWarn(`correction agent returned non-JSON`, raw?.substring(0, 200));
113
+ }
114
+ if (correctionFailed) {
115
+ notify("correction", "error", correctionError);
116
+ const discarded = correctionError.startsWith("discarded");
117
+ await toast(
118
+ discarded ? "Correction discarded — keeping original" : "Correction failed — using original prompt",
119
+ discarded ? "info" : "error",
120
+ 3000,
121
+ );
122
+ } else {
123
+ notify("correction", "complete");
124
+ }
125
+ } else {
126
+ notify("correction", "skipped");
127
+ }
128
+
129
+ // :::: Translation //
130
+ if (translation) {
131
+ if (detectedLanguage === "en") {
132
+ notify("translation", "skipped");
133
+ } else {
134
+ notify("translation", "starting");
135
+ const model = await resolveModel(config.translation_model, CONFIG_DEFAULTS.translation_model);
136
+ const translationResult = await invokeAgent(deps, "prompt-translation", working, model);
137
+ addUsage(translationResult.usage);
138
+ if (translationResult.text?.trim()) {
139
+ working = translationResult.text;
140
+ }
141
+ notify("translation", "complete");
142
+ }
143
+ } else {
144
+ notify("translation", "skipped");
145
+ }
146
+
147
+ // :::: Context summarisation (only with enhancement) //
148
+ let contextSummary = "";
149
+ if (enhancement) {
150
+ notify("context", "starting");
151
+ const ctxResult = await summariseContext(deps, sessionID, currentMessageID ?? "", config);
152
+ contextSummary = ctxResult.summary;
153
+ addUsage(ctxResult.usage);
154
+ deps.sessionContexts.set(sessionID, {
155
+ summary: ctxResult.summary,
156
+ lastMessageID: ctxResult.lastMessageID,
157
+ messageCount: ctxResult.messageCount,
158
+ });
159
+ notify("context", "complete");
160
+ }
161
+
162
+ // :::: Enhancement //
163
+ if (enhancement) {
164
+ notify("enhancement", "starting");
165
+ let enhanceInput = "";
166
+ if (contextSummary) {
167
+ enhanceInput += `Conversation context: ${contextSummary}\n\n`;
168
+ }
169
+ enhanceInput += working;
170
+
171
+ const model = await resolveModel(config.enhancement_model, CONFIG_DEFAULTS.enhancement_model);
172
+ const enhancementResult = await invokeAgent(deps, "prompt-enhancement", enhanceInput, model);
173
+ addUsage(enhancementResult.usage);
174
+ if (enhancementResult.text?.trim()) {
175
+ working = enhancementResult.text;
176
+ }
177
+ notify("enhancement", "complete");
178
+ } else {
179
+ notify("enhancement", "skipped");
180
+ }
181
+
182
+ return {
183
+ result: working,
184
+ corrected,
185
+ detectedLanguage,
186
+ mistakes,
187
+ contextSummary,
188
+ usage: totalUsage,
189
+ };
190
+ }
@@ -0,0 +1,21 @@
1
+ import { appendFileSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { AuditEntry, PipelineState } from "./types";
4
+
5
+ export function writeAudit(auditPath: string, entry: AuditEntry): void {
6
+ mkdirSync(dirname(auditPath), { recursive: true });
7
+ appendFileSync(auditPath, `${JSON.stringify(entry)}\n`);
8
+ }
9
+
10
+ export function writeState(statePath: string, state: PipelineState): void {
11
+ mkdirSync(dirname(statePath), { recursive: true });
12
+ writeFileSync(statePath, `${JSON.stringify(state)}\n`);
13
+ }
14
+
15
+ export function clearState(statePath: string): void {
16
+ try {
17
+ unlinkSync(statePath);
18
+ } catch {
19
+ // best effort
20
+ }
21
+ }
@@ -0,0 +1,42 @@
1
+ import { findModelEntry, MODEL_DEFAULTS, TIER_ALIASES, TIER_CYCLE } from "../catalog";
2
+ import { formatContext, formatCost } from "../format";
3
+ import type { ModelEntry } from "../types";
4
+
5
+ type ModelTiers = { fast: ModelEntry[]; capable: ModelEntry[]; powerful: ModelEntry[] };
6
+
7
+ export function formatModelDisplay(value: string, tiers: ModelTiers | null, key: string): string {
8
+ const isDefault = value === MODEL_DEFAULTS[key];
9
+ if (isDefault) return `${value} (inherits session model)`;
10
+
11
+ // Legacy alias: haiku → fast, sonnet → capable, opus → powerful
12
+ const alias = TIER_ALIASES[value];
13
+ if (alias) {
14
+ if (!tiers) return `${value} ≡ ${alias}`;
15
+ const tierModels = tiers[alias as "fast" | "capable" | "powerful"];
16
+ const best = tierModels?.[0];
17
+ return `${value} ≡ ${alias} ${
18
+ best ? `(${best.id} ${formatCost(best.cost)} ctx:${formatContext(best.context)})` : ""
19
+ }`;
20
+ }
21
+
22
+ // Tier name: fast/capable/powerful
23
+ if (TIER_CYCLE.includes(value)) {
24
+ if (!tiers) return value;
25
+ const tierModels = tiers[value as "fast" | "capable" | "powerful"];
26
+ const best = tierModels?.[0];
27
+ return `${value} ${
28
+ best ? `(${best.id} ${formatCost(best.cost)} ctx:${formatContext(best.context)})` : ""
29
+ }`;
30
+ }
31
+
32
+ // Explicit provider/model
33
+ if (tiers) {
34
+ const entry = findModelEntry(value, tiers);
35
+ if (entry) {
36
+ return `${value} (${entry.tier} ${formatCost(entry.cost)} ctx:${formatContext(
37
+ entry.context,
38
+ )})`;
39
+ }
40
+ }
41
+ return value;
42
+ }
@@ -0,0 +1,310 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
6
+ import { createSignal } from "solid-js";
7
+ import { CONFIG_PATH, MODEL_FIELDS, parseConfig, updateConfig } from "../config";
8
+ import type { Config } from "../config";
9
+ import { getModelTiers, resolveTier, TIER_ALIASES, TIER_CYCLE } from "../catalog";
10
+ import type { ModelEntry } from "../types";
11
+ import { formatModelDisplay } from "./format";
12
+ import { SelectView, type SelectOption } from "./select-view";
13
+
14
+ export interface RouteProps {
15
+ api: TuiPluginApi;
16
+ goBack: () => void;
17
+ }
18
+
19
+ function auditPath(api: TuiPluginApi): string {
20
+ return join(api.state.path.directory, ".opencode", "better-prompt", "audit.json");
21
+ }
22
+
23
+ // :::: /better-prompt:toggle :::: ///////////////////////////
24
+
25
+ export function ToggleRoute(props: RouteProps) {
26
+ const TOGGLE_KEYS = [
27
+ "enabled",
28
+ "correction",
29
+ "translation",
30
+ "enhancement",
31
+ "audit",
32
+ "verbose",
33
+ ] as const;
34
+
35
+ const buildStages = (): SelectOption[] => {
36
+ const config = parseConfig(CONFIG_PATH);
37
+ return TOGGLE_KEYS.map((stage) => ({
38
+ title: stage,
39
+ description: `Currently: ${
40
+ (config as unknown as Record<string, unknown>)[stage] ? "ON" : "OFF"
41
+ }`,
42
+ value: stage,
43
+ }));
44
+ };
45
+
46
+ const [stages, setStages] = createSignal<SelectOption[]>(buildStages());
47
+
48
+ return (
49
+ <SelectView
50
+ title="Better Prompt: Toggle Stage"
51
+ options={() => stages()}
52
+ onSelect={(opt) => {
53
+ const current = parseConfig(CONFIG_PATH);
54
+ const newVal = !(current as unknown as Record<string, unknown>)[opt.value];
55
+ updateConfig(CONFIG_PATH, { [opt.value]: newVal });
56
+ props.api.ui.toast({
57
+ variant: "success",
58
+ message: `${opt.value} is now ${newVal ? "ON" : "OFF"}`,
59
+ });
60
+ setStages(buildStages());
61
+ }}
62
+ onBack={props.goBack}
63
+ />
64
+ );
65
+ }
66
+
67
+ // :::: /better-prompt:config :::: ///////////////////////////
68
+
69
+ export function ConfigRoute(props: RouteProps) {
70
+ const [configData, setConfigData] = createSignal(parseConfig(CONFIG_PATH));
71
+ const [tiersData, setTiersData] = createSignal<{
72
+ fast: ModelEntry[];
73
+ capable: ModelEntry[];
74
+ powerful: ModelEntry[];
75
+ } | null>(null);
76
+ const [cycleIndex, setCycleIndex] = createSignal<Record<string, number>>({});
77
+
78
+ // Load model tiers asynchronously
79
+ getModelTiers()
80
+ .then(setTiersData)
81
+ .catch(() => {
82
+ props.api.ui.toast({
83
+ variant: "error",
84
+ message: "Could not load model tiers",
85
+ });
86
+ });
87
+
88
+ const refreshConfig = () => {
89
+ setConfigData(parseConfig(CONFIG_PATH));
90
+ };
91
+
92
+ const options = () => {
93
+ const config = configData();
94
+ return Object.entries(config).map(([k, v]) => {
95
+ const isModel = (MODEL_FIELDS as readonly string[]).includes(k);
96
+
97
+ if (isModel) {
98
+ const display = formatModelDisplay(String(v), tiersData(), k);
99
+ return {
100
+ title: k,
101
+ description: display,
102
+ value: k,
103
+ };
104
+ }
105
+
106
+ const isBool = typeof v === "boolean";
107
+ return {
108
+ title: k,
109
+ description: `${v}${isBool ? " (select to toggle)" : ""}`,
110
+ value: k,
111
+ };
112
+ });
113
+ };
114
+
115
+ const handleSelect = (opt: SelectOption) => {
116
+ const key = opt.value as keyof Config;
117
+ const config = configData();
118
+ const currentVal = config[key];
119
+
120
+ if (typeof currentVal === "boolean") {
121
+ updateConfig(CONFIG_PATH, { [key]: !currentVal });
122
+ props.api.ui.toast({
123
+ variant: "success",
124
+ message: `${key}: ${currentVal} -> ${!currentVal}`,
125
+ });
126
+ refreshConfig();
127
+ return;
128
+ }
129
+
130
+ // Model field: cycle tier on Enter
131
+ if ((MODEL_FIELDS as readonly string[]).includes(key)) {
132
+ const current = String(currentVal);
133
+ const resolvedTier = resolveTier(current, tiersData());
134
+ const currentTierName = resolvedTier || TIER_ALIASES[current] || current;
135
+
136
+ const currentIdx = TIER_CYCLE.indexOf(currentTierName);
137
+ const nextIdx = currentIdx >= 0 ? (currentIdx + 1) % TIER_CYCLE.length : 0;
138
+ const next = TIER_CYCLE[nextIdx];
139
+
140
+ updateConfig(CONFIG_PATH, { [key]: next });
141
+ setCycleIndex({ ...cycleIndex(), [key]: 0 });
142
+ props.api.ui.toast({ variant: "success", message: `${key}: ${next}` });
143
+ refreshConfig();
144
+ }
145
+ };
146
+
147
+ const handleAltSelect = (opt: SelectOption) => {
148
+ const key = opt.value as keyof Config;
149
+ if (!(MODEL_FIELDS as readonly string[]).includes(key)) return;
150
+
151
+ const tierData = tiersData();
152
+ if (!tierData) {
153
+ props.api.ui.toast({
154
+ variant: "error",
155
+ message: "Model data not loaded yet",
156
+ });
157
+ return;
158
+ }
159
+
160
+ const config = configData();
161
+ const currentVal = String(config[key]);
162
+ const resolvedTier = resolveTier(currentVal, tierData) || "fast";
163
+ const modelsInTier = tierData[resolvedTier as "fast" | "capable" | "powerful"];
164
+ if (!modelsInTier || modelsInTier.length === 0) {
165
+ props.api.ui.toast({
166
+ variant: "error",
167
+ message: `No models available for tier: ${resolvedTier}`,
168
+ });
169
+ return;
170
+ }
171
+
172
+ const currentIdx = (cycleIndex()[key] || 0) % modelsInTier.length;
173
+ const picked = modelsInTier[currentIdx];
174
+ updateConfig(CONFIG_PATH, { [key]: picked.id });
175
+ setCycleIndex({ ...cycleIndex(), [key]: currentIdx + 1 });
176
+ props.api.ui.toast({
177
+ variant: "success",
178
+ message: `${key}: ${picked.id} (${resolvedTier})`,
179
+ });
180
+ refreshConfig();
181
+ };
182
+
183
+ return (
184
+ <SelectView
185
+ title="Better Prompt Configuration"
186
+ options={options}
187
+ onSelect={handleSelect}
188
+ onAltSelect={handleAltSelect}
189
+ onBack={props.goBack}
190
+ />
191
+ );
192
+ }
193
+
194
+ // :::: /better-prompt:audit :::: ////////////////////////////
195
+
196
+ export function AuditRoute(props: RouteProps) {
197
+ const path = auditPath(props.api);
198
+
199
+ if (!existsSync(path)) {
200
+ return (
201
+ <box border paddingTop={0} paddingBottom={0} paddingLeft={1} paddingRight={1}>
202
+ <text>No audit data available.</text>
203
+ </box>
204
+ );
205
+ }
206
+
207
+ const allLines = readFileSync(path, "utf8")
208
+ .trim()
209
+ .split("\n")
210
+ .filter((l: string) => l.trim());
211
+
212
+ if (allLines.length === 0) {
213
+ return (
214
+ <box border paddingTop={0} paddingBottom={0} paddingLeft={1} paddingRight={1}>
215
+ <text>Audit trail is empty.</text>
216
+ </box>
217
+ );
218
+ }
219
+
220
+ const recent = allLines.slice(-10);
221
+ const auditOptions: SelectOption[] = [];
222
+
223
+ for (let i = 0; i < recent.length; i++) {
224
+ try {
225
+ const entry = JSON.parse(recent[i]);
226
+ const num = allLines.length - recent.length + i + 1;
227
+ const mistakes = entry.mistakes?.length ?? 0;
228
+ const parts: string[] = [entry.prompt.substring(0, 60)];
229
+ if (entry.language) parts.push(`lang: ${entry.language}`);
230
+ if (mistakes > 0) {
231
+ parts.push(`${mistakes} mistake${mistakes > 1 ? "s" : ""}`);
232
+ }
233
+ auditOptions.push({
234
+ title: `Entry #${num}`,
235
+ description: parts.join(" | "),
236
+ value: String(num),
237
+ });
238
+ } catch {
239
+ // skip malformed entries
240
+ }
241
+ }
242
+
243
+ auditOptions.push({
244
+ title: "Clear audit trail",
245
+ description: `${allLines.length} entries total`,
246
+ value: "clear",
247
+ });
248
+
249
+ return (
250
+ <SelectView
251
+ title="Audit Trail"
252
+ options={() => auditOptions}
253
+ onSelect={(opt) => {
254
+ if (opt.value === "clear") {
255
+ writeFileSync(path, "");
256
+ props.api.ui.toast({
257
+ variant: "success",
258
+ message: "Audit trail cleared.",
259
+ });
260
+ props.goBack();
261
+ return;
262
+ }
263
+ const num = parseInt(opt.value, 10);
264
+ const line = allLines[num - 1];
265
+ try {
266
+ const entry = JSON.parse(line);
267
+ const details: string[] = [`#${num} ${entry.date}`, `Original: "${entry.prompt}"`];
268
+ if (entry.language) details.push(`Language: ${entry.language}`);
269
+ if (entry.corrected) {
270
+ details.push(`Corrected: "${entry.corrected}"`);
271
+ }
272
+ if (entry.enhanced) details.push(`Enhanced: "${entry.enhanced}"`);
273
+ if (entry.mistakes?.length > 0) {
274
+ details.push(
275
+ "Mistakes: " +
276
+ entry.mistakes
277
+ .map(
278
+ (m: { type: string; original: string; correction: string }) =>
279
+ `[${m.type}] "${m.original}" -> "${m.correction}"`,
280
+ )
281
+ .join("; "),
282
+ );
283
+ }
284
+ if (entry.models) {
285
+ const used: string[] = [];
286
+ if (entry.models.correction) {
287
+ used.push(`correction=${entry.models.correction}`);
288
+ }
289
+ if (entry.models.translation) {
290
+ used.push(`translation=${entry.models.translation}`);
291
+ }
292
+ if (entry.models.enhancement) {
293
+ used.push(`enhancement=${entry.models.enhancement}`);
294
+ }
295
+ if (used.length) details.push(`Models: ${used.join(", ")}`);
296
+ }
297
+ props.api.ui.toast({
298
+ variant: "info",
299
+ message: details.join("\n"),
300
+ duration: 8000,
301
+ });
302
+ } catch {
303
+ // skip
304
+ }
305
+ props.goBack();
306
+ }}
307
+ onBack={props.goBack}
308
+ />
309
+ );
310
+ }
@@ -0,0 +1,58 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import { createSignal } from "solid-js";
5
+
6
+ export interface SelectOption {
7
+ title: string;
8
+ description: string;
9
+ value: string;
10
+ }
11
+
12
+ export function SelectView(props: {
13
+ title: string;
14
+ options: () => SelectOption[];
15
+ onSelect: (opt: SelectOption) => void;
16
+ onAltSelect?: (opt: SelectOption) => void;
17
+ onBack: () => void;
18
+ }) {
19
+ const [idx, setIdx] = createSignal(0);
20
+
21
+ useKeyboard((key: { name: string }) => {
22
+ const opts = props.options();
23
+ const max = opts.length - 1;
24
+ if (key.name === "up" || key.name === "k") {
25
+ setIdx((i: number) => Math.max(0, i - 1));
26
+ }
27
+ if (key.name === "down" || key.name === "j") {
28
+ setIdx((i: number) => Math.min(max, i + 1));
29
+ }
30
+ if (key.name === "return") {
31
+ const opt = opts[idx()];
32
+ if (opt) props.onSelect(opt);
33
+ }
34
+ if (key.name === "space") {
35
+ if (props.onAltSelect) {
36
+ const opt = opts[idx()];
37
+ if (opt) props.onAltSelect(opt);
38
+ }
39
+ }
40
+ if (key.name === "escape") {
41
+ props.onBack();
42
+ }
43
+ });
44
+
45
+ return (
46
+ <box border paddingTop={0} paddingBottom={0} paddingLeft={1} paddingRight={1}>
47
+ <text>
48
+ <strong>{props.title}</strong>
49
+ </text>
50
+ {props.options().map((opt: SelectOption, i: number) => (
51
+ <text fg={idx() === i ? "#ffffff" : "#888888"}>
52
+ {`${idx() === i ? "> " : " "}${opt.title} ${opt.description}`}
53
+ </text>
54
+ ))}
55
+ <text fg="#555555">Up/Dn Navigate Enter Cycle Tier Space Pick Model Esc Back</text>
56
+ </box>
57
+ );
58
+ }