opencode-next-prompt 0.0.7 → 0.0.8
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 +12 -3
- package/dist/index.js +147 -16
- package/dist/predict.d.ts +9 -1
- package/dist/predict.js +59 -47
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -28,7 +28,10 @@ Restart opencode. The package and its dependencies are installed automatically.
|
|
|
28
28
|
{
|
|
29
29
|
"plugin": [["opencode-next-prompt", {
|
|
30
30
|
"acceptKey": "down",
|
|
31
|
-
"timeoutMs": 20000
|
|
31
|
+
"timeoutMs": 20000,
|
|
32
|
+
"model": "my-provider/fast-model",
|
|
33
|
+
"disableTools": true,
|
|
34
|
+
"includeToolContext": false
|
|
32
35
|
}]]
|
|
33
36
|
}
|
|
34
37
|
```
|
|
@@ -36,11 +39,17 @@ Restart opencode. The package and its dependencies are installed automatically.
|
|
|
36
39
|
| Key | Default | Description |
|
|
37
40
|
|---|---|---|
|
|
38
41
|
| `acceptKey` | `down` | Key to accept the suggestion (only effective while the input is empty) |
|
|
39
|
-
| `timeoutMs` | `20000` | Per-prediction timeout; the
|
|
42
|
+
| `timeoutMs` | `20000` | Per-prediction timeout; the timed-out call is aborted and retried once |
|
|
43
|
+
| `model` | unset | Fast model for predictions as `provider/model`; a bare model id uses the session's provider. Defaults to the current session's model. Unknown providers/models are rejected with an error toast and a log entry |
|
|
44
|
+
| `variant` | auto | Model variant (reasoning effort) used for predictions. By default the lowest variant the model supports is picked automatically (e.g. `low`); models without variants are left untouched. Set it explicitly (e.g. `"high"`) to override, or `"default"` to disable |
|
|
45
|
+
| `disableTools` | `true` | Denies all tools in the background predictor session (predictions never run code or search) |
|
|
46
|
+
| `includeToolContext` | `false` | Include summarized tool calls and outputs in the prediction context (more context, more tokens) |
|
|
40
47
|
|
|
41
48
|
## How it works
|
|
42
49
|
|
|
43
|
-
After each reply, the plugin predicts your next input in a background session
|
|
50
|
+
After each reply, the plugin predicts your next input in a fresh background session built from the recent conversation (the last few turns plus the original goal). The suggestion is written into the input placeholder, so what you see is exactly what gets accepted. The main conversation is never modified.
|
|
51
|
+
|
|
52
|
+
Each suggestion costs one extra model call, and the background session is created and removed per prediction, so the model never sees its own previous predictions (which would bias the next one). Failed or timed-out predictions are retried once. A suggestion disappears when you type or when the session changes.
|
|
44
53
|
|
|
45
54
|
## License
|
|
46
55
|
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,10 @@ import { renderSuggestionPlaceholder } from "./ui.js";
|
|
|
5
5
|
const DEFAULT_OPTIONS = {
|
|
6
6
|
acceptKey: "down",
|
|
7
7
|
timeoutMs: 20_000,
|
|
8
|
+
disableTools: true,
|
|
9
|
+
includeToolContext: false,
|
|
8
10
|
};
|
|
11
|
+
const RETRY_DELAY_MS = 800;
|
|
9
12
|
const opencodeNextPrompt = async (api, rawOptions) => {
|
|
10
13
|
const options = { ...DEFAULT_OPTIONS, ...(rawOptions ?? {}) };
|
|
11
14
|
const store = createSuggestionStore();
|
|
@@ -33,7 +36,7 @@ const opencodeNextPrompt = async (api, rawOptions) => {
|
|
|
33
36
|
if (part.type === "text" && !part.synthetic && !part.ignored) {
|
|
34
37
|
return part.text.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
|
|
35
38
|
}
|
|
36
|
-
return summarizeToolPart(part);
|
|
39
|
+
return options.includeToolContext ? summarizeToolPart(part) : "";
|
|
37
40
|
})
|
|
38
41
|
.join("\n")
|
|
39
42
|
.trim();
|
|
@@ -50,31 +53,84 @@ const opencodeNextPrompt = async (api, rawOptions) => {
|
|
|
50
53
|
}
|
|
51
54
|
return undefined;
|
|
52
55
|
};
|
|
53
|
-
let
|
|
54
|
-
|
|
56
|
+
let inFlightUserMessageID;
|
|
57
|
+
let completedUserMessageID;
|
|
58
|
+
let retriedUserMessageID;
|
|
59
|
+
let reportedModelIssue;
|
|
60
|
+
const predictor = createPredictor(api.client, api.state.path.directory, {
|
|
61
|
+
timeoutMs: options.timeoutMs,
|
|
62
|
+
model: options.model,
|
|
63
|
+
disableTools: options.disableTools,
|
|
64
|
+
});
|
|
55
65
|
const maybePredict = async () => {
|
|
66
|
+
if (api.lifecycle.signal.aborted)
|
|
67
|
+
return;
|
|
56
68
|
const sessionID = currentSessionID();
|
|
57
69
|
if (!sessionID)
|
|
58
70
|
return;
|
|
71
|
+
if (api.state.session.status(sessionID)?.type !== "idle")
|
|
72
|
+
return;
|
|
59
73
|
const userMessageID = lastUserMessageID(sessionID);
|
|
60
|
-
if (userMessageID
|
|
74
|
+
if (!userMessageID)
|
|
75
|
+
return;
|
|
76
|
+
if (userMessageID === completedUserMessageID || userMessageID === inFlightUserMessageID)
|
|
61
77
|
return;
|
|
78
|
+
const session = api.state.session.get(sessionID);
|
|
79
|
+
const modelRef = resolveModelRef(options.model, session?.model, api.state.provider);
|
|
80
|
+
const modelIssue = checkModelConfig(options.model, modelRef, api.state.provider);
|
|
81
|
+
if (modelIssue) {
|
|
82
|
+
if (reportedModelIssue !== modelIssue) {
|
|
83
|
+
reportedModelIssue = modelIssue;
|
|
84
|
+
log(api, "prediction skipped", modelIssue);
|
|
85
|
+
api.ui.toast({ variant: "error", title: "next-prompt", message: modelIssue, duration: 8000 });
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
62
89
|
const turns = conversationTurns(sessionID);
|
|
63
90
|
if (turns.length === 0)
|
|
64
91
|
return;
|
|
65
92
|
const generation = store.beginPredict(sessionID);
|
|
66
93
|
if (generation === null)
|
|
67
94
|
return;
|
|
68
|
-
|
|
69
|
-
const
|
|
95
|
+
inFlightUserMessageID = userMessageID;
|
|
96
|
+
const variant = resolveVariant(options.variant, modelRef);
|
|
70
97
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
98
|
+
const result = await predictor.predict(session?.model, turns, variant);
|
|
99
|
+
if (inFlightUserMessageID !== userMessageID)
|
|
100
|
+
return;
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
completedUserMessageID = userMessageID;
|
|
103
|
+
store.commit(generation, sessionID, result.text);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
store.discard();
|
|
107
|
+
scheduleRetry(userMessageID);
|
|
108
|
+
}
|
|
73
109
|
}
|
|
74
110
|
catch (error) {
|
|
75
|
-
log(api, "prediction failed", error);
|
|
111
|
+
log(api, "prediction failed", errorMessage(error));
|
|
112
|
+
if (inFlightUserMessageID !== userMessageID)
|
|
113
|
+
return;
|
|
76
114
|
store.discard();
|
|
115
|
+
scheduleRetry(userMessageID);
|
|
77
116
|
}
|
|
117
|
+
finally {
|
|
118
|
+
if (inFlightUserMessageID === userMessageID)
|
|
119
|
+
inFlightUserMessageID = undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const scheduleRetry = (userMessageID) => {
|
|
123
|
+
if (retriedUserMessageID === userMessageID)
|
|
124
|
+
return;
|
|
125
|
+
retriedUserMessageID = userMessageID;
|
|
126
|
+
setTimeout(() => void maybePredict(), RETRY_DELAY_MS);
|
|
127
|
+
};
|
|
128
|
+
const invalidate = () => {
|
|
129
|
+
store.discard();
|
|
130
|
+
inFlightUserMessageID = undefined;
|
|
131
|
+
completedUserMessageID = undefined;
|
|
132
|
+
retriedUserMessageID = undefined;
|
|
133
|
+
void predictor.dispose();
|
|
78
134
|
};
|
|
79
135
|
const accept = async () => {
|
|
80
136
|
const current = store.read();
|
|
@@ -84,7 +140,7 @@ const opencodeNextPrompt = async (api, rawOptions) => {
|
|
|
84
140
|
await api.client.tui.appendPrompt({ text: current.text });
|
|
85
141
|
}
|
|
86
142
|
catch (error) {
|
|
87
|
-
log(api, "append prompt failed", error);
|
|
143
|
+
log(api, "append prompt failed", errorMessage(error));
|
|
88
144
|
}
|
|
89
145
|
};
|
|
90
146
|
api.event.on("session.status", (event) => {
|
|
@@ -99,12 +155,19 @@ const opencodeNextPrompt = async (api, rawOptions) => {
|
|
|
99
155
|
void maybePredict();
|
|
100
156
|
}
|
|
101
157
|
});
|
|
102
|
-
api.event.on("tui.session.select", (
|
|
103
|
-
|
|
104
|
-
|
|
158
|
+
api.event.on("tui.session.select", () => invalidate());
|
|
159
|
+
api.event.on("session.compacted", () => invalidate());
|
|
160
|
+
api.event.on("session.deleted", (event) => {
|
|
161
|
+
if (event.properties.sessionID === currentSessionID())
|
|
162
|
+
invalidate();
|
|
105
163
|
});
|
|
106
|
-
api.event.on("
|
|
107
|
-
|
|
164
|
+
api.event.on("message.removed", (event) => {
|
|
165
|
+
if (event.properties.sessionID === currentSessionID())
|
|
166
|
+
invalidate();
|
|
167
|
+
});
|
|
168
|
+
api.event.on("message.part.removed", (event) => {
|
|
169
|
+
if (event.properties.sessionID === currentSessionID())
|
|
170
|
+
invalidate();
|
|
108
171
|
});
|
|
109
172
|
api.lifecycle.onDispose(() => {
|
|
110
173
|
void predictor.dispose();
|
|
@@ -129,9 +192,77 @@ function log(api, message, extra) {
|
|
|
129
192
|
service: "opencode-next-prompt",
|
|
130
193
|
level: "info",
|
|
131
194
|
message,
|
|
132
|
-
extra: { data:
|
|
195
|
+
extra: { data: extra },
|
|
133
196
|
});
|
|
134
197
|
}
|
|
198
|
+
function resolveModelRef(spec, sessionModel, providers) {
|
|
199
|
+
const slash = spec?.indexOf("/") ?? -1;
|
|
200
|
+
if (spec && slash > 0 && slash < spec.length - 1) {
|
|
201
|
+
const providerID = spec.slice(0, slash);
|
|
202
|
+
const modelID = spec.slice(slash + 1);
|
|
203
|
+
return { providerID, modelID, model: findModel(providerID, modelID, providers) };
|
|
204
|
+
}
|
|
205
|
+
const providerID = sessionModel?.providerID;
|
|
206
|
+
const modelID = spec ?? sessionModel?.id;
|
|
207
|
+
if (!providerID || !modelID)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { providerID, modelID, model: findModel(providerID, modelID, providers) };
|
|
210
|
+
}
|
|
211
|
+
function findModel(providerID, modelID, providers) {
|
|
212
|
+
return providers.find((item) => item.id === providerID)?.models[modelID];
|
|
213
|
+
}
|
|
214
|
+
function checkModelConfig(spec, ref, providers) {
|
|
215
|
+
if (!spec || !ref)
|
|
216
|
+
return undefined;
|
|
217
|
+
const provider = providers.find((item) => item.id === ref.providerID);
|
|
218
|
+
if (!provider) {
|
|
219
|
+
const available = providers.map((item) => item.id).join(", ");
|
|
220
|
+
return `model config error: provider "${ref.providerID}" not found${available ? ` (available providers: ${available})` : ""}`;
|
|
221
|
+
}
|
|
222
|
+
if (!ref.model) {
|
|
223
|
+
const available = Object.keys(provider.models).slice(0, 10).join(", ");
|
|
224
|
+
return `model config error: model "${ref.modelID}" not found on provider "${ref.providerID}"${available ? ` (available models: ${available}${Object.keys(provider.models).length > 10 ? ", ..." : ""})` : ""}`;
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const VARIANT_ORDER = ["none", "low", "min", "medium", "high", "xhigh", "max"];
|
|
229
|
+
function resolveVariant(configVariant, ref) {
|
|
230
|
+
if (configVariant === "default")
|
|
231
|
+
return undefined;
|
|
232
|
+
if (configVariant)
|
|
233
|
+
return configVariant;
|
|
234
|
+
const variants = ref?.model?.variants;
|
|
235
|
+
if (!variants)
|
|
236
|
+
return undefined;
|
|
237
|
+
const names = Object.keys(variants);
|
|
238
|
+
if (names.length === 0)
|
|
239
|
+
return undefined;
|
|
240
|
+
const low = names.find((name) => name.toLowerCase() === "low");
|
|
241
|
+
if (low)
|
|
242
|
+
return low;
|
|
243
|
+
return names.toSorted((a, b) => variantRank(a) - variantRank(b))[0];
|
|
244
|
+
}
|
|
245
|
+
function variantRank(name) {
|
|
246
|
+
const index = VARIANT_ORDER.indexOf(name.toLowerCase());
|
|
247
|
+
return index === -1 ? VARIANT_ORDER.length : index;
|
|
248
|
+
}
|
|
249
|
+
function errorMessage(error) {
|
|
250
|
+
if (error instanceof Error)
|
|
251
|
+
return error.message;
|
|
252
|
+
if (typeof error === "string")
|
|
253
|
+
return error;
|
|
254
|
+
if (error && typeof error === "object") {
|
|
255
|
+
const candidate = error.message;
|
|
256
|
+
if (typeof candidate === "string")
|
|
257
|
+
return candidate;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
return JSON.stringify(error);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return String(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
135
266
|
const TOOL_SUMMARY_CHARS = 150;
|
|
136
267
|
function summarizeToolPart(part) {
|
|
137
268
|
if (part.type !== "tool" || !part.state || part.state.status === "running")
|
package/dist/predict.d.ts
CHANGED
|
@@ -6,12 +6,20 @@ export type ConversationTurn = {
|
|
|
6
6
|
};
|
|
7
7
|
export type PredictOptions = {
|
|
8
8
|
timeoutMs: number;
|
|
9
|
+
model?: string;
|
|
10
|
+
disableTools?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type PredictResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
text: string | null;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
9
17
|
};
|
|
10
18
|
export type Predictor = {
|
|
11
19
|
predict(model: {
|
|
12
20
|
providerID: string;
|
|
13
21
|
id: string;
|
|
14
|
-
} | undefined, turns: ConversationTurn[]): Promise<
|
|
22
|
+
} | undefined, turns: ConversationTurn[], variant?: string): Promise<PredictResult>;
|
|
15
23
|
dispose(): Promise<void>;
|
|
16
24
|
};
|
|
17
25
|
export declare function createPredictor(client: OpencodeClient, directory: string | undefined, options: PredictOptions): Predictor;
|
package/dist/predict.js
CHANGED
|
@@ -13,69 +13,78 @@ If nothing plausible remains to say, reply with exactly: NO_SUGGESTION
|
|
|
13
13
|
Conversation (oldest first):
|
|
14
14
|
`;
|
|
15
15
|
const MAX_PREDICTION_CHARS = 170;
|
|
16
|
+
const MAX_TURNS = 8;
|
|
17
|
+
const MAX_TURN_CHARS = 800;
|
|
18
|
+
const DENY_ALL_TOOLS = [
|
|
19
|
+
{ permission: "*", pattern: "*", action: "deny" },
|
|
20
|
+
];
|
|
16
21
|
export function createPredictor(client, directory, options) {
|
|
17
22
|
let sessionID;
|
|
18
|
-
let predictedTurns = 0;
|
|
19
|
-
let predictedLastMessageID;
|
|
20
|
-
let accumulatedChars = 0;
|
|
21
23
|
const deleteSession = async () => {
|
|
22
24
|
if (sessionID) {
|
|
23
|
-
|
|
25
|
+
const current = sessionID;
|
|
24
26
|
sessionID = undefined;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
accumulatedChars = 0;
|
|
27
|
+
await client.session.abort({ sessionID: current, directory }).catch(() => { });
|
|
28
|
+
await client.session.delete({ sessionID: current, directory }).catch(() => { });
|
|
28
29
|
}
|
|
29
30
|
};
|
|
30
31
|
return {
|
|
31
|
-
async predict(model, turns) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
async predict(model, turns, variant) {
|
|
33
|
+
if (turns.length === 0)
|
|
34
|
+
return { ok: true, text: null };
|
|
35
|
+
const input = PROMPT + renderConversation(turns);
|
|
36
|
+
const ref = resolveModel(options.model, model);
|
|
37
|
+
const created = await client.session.create({
|
|
38
|
+
directory,
|
|
39
|
+
title: TITLE,
|
|
40
|
+
model: ref ? { id: ref.id, providerID: ref.providerID, ...(variant ? { variant } : {}) } : undefined,
|
|
41
|
+
permission: options.disableTools === false ? undefined : DENY_ALL_TOOLS,
|
|
42
|
+
});
|
|
43
|
+
if (created.error)
|
|
44
|
+
throw created.error;
|
|
45
|
+
sessionID = created.data.id;
|
|
46
|
+
const currentSessionID = sessionID;
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
const result = await withTimeout(client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: input }] }), options.timeoutMs, () => void client.session.abort({ sessionID: currentSessionID, directory }).catch(() => { }));
|
|
50
|
+
if (result.error)
|
|
51
|
+
throw result.error;
|
|
52
|
+
text = parsePrediction(result.data.parts);
|
|
35
53
|
}
|
|
36
|
-
|
|
37
|
-
if (delta.length === 0)
|
|
38
|
-
return null;
|
|
39
|
-
let input = PROMPT + renderConversation(delta);
|
|
40
|
-
if (sessionID && accumulatedChars + input.length > MAX_ACCUMULATED_CHARS) {
|
|
54
|
+
catch (error) {
|
|
41
55
|
await deleteSession();
|
|
42
|
-
|
|
43
|
-
input = PROMPT + renderConversation(delta);
|
|
44
|
-
}
|
|
45
|
-
if (!sessionID) {
|
|
46
|
-
const created = await client.session.create({ directory, title: TITLE, model });
|
|
47
|
-
if (created.error)
|
|
48
|
-
throw created.error;
|
|
49
|
-
sessionID = created.data.id;
|
|
56
|
+
throw error;
|
|
50
57
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
throw result.error;
|
|
54
|
-
predictedTurns = turns.length;
|
|
55
|
-
predictedLastMessageID = turns.at(-1)?.id;
|
|
56
|
-
accumulatedChars += input.length;
|
|
57
|
-
return parsePrediction(result.data.parts);
|
|
58
|
+
await deleteSession();
|
|
59
|
+
return { ok: true, text };
|
|
58
60
|
},
|
|
59
61
|
async dispose() {
|
|
60
62
|
await deleteSession();
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
function resolveModel(spec, sessionModel) {
|
|
67
|
+
if (!spec)
|
|
68
|
+
return sessionModel;
|
|
69
|
+
const slash = spec.indexOf("/");
|
|
70
|
+
if (slash > 0 && slash < spec.length - 1) {
|
|
71
|
+
return { providerID: spec.slice(0, slash), id: spec.slice(slash + 1) };
|
|
72
|
+
}
|
|
73
|
+
return sessionModel ? { providerID: sessionModel.providerID, id: spec } : undefined;
|
|
74
|
+
}
|
|
66
75
|
function renderConversation(turns) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
if (turns.length === 0)
|
|
77
|
+
return "";
|
|
78
|
+
const recent = turns.slice(-MAX_TURNS);
|
|
79
|
+
const lines = recent.map((turn) => `${turn.role}: ${truncate(turn.text, MAX_TURN_CHARS)}`);
|
|
80
|
+
const first = turns[0];
|
|
81
|
+
if (turns.length > MAX_TURNS && first?.role === "user" && first.id !== recent[0]?.id) {
|
|
82
|
+
lines.unshift(`user: [original goal] ${truncate(first.text, MAX_TURN_CHARS)}`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
function truncate(text, max) {
|
|
87
|
+
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
79
88
|
}
|
|
80
89
|
function parsePrediction(parts) {
|
|
81
90
|
const line = parts
|
|
@@ -96,10 +105,13 @@ function parsePrediction(parts) {
|
|
|
96
105
|
return null;
|
|
97
106
|
return text.length > MAX_PREDICTION_CHARS ? text.slice(0, MAX_PREDICTION_CHARS - 3) + "..." : text;
|
|
98
107
|
}
|
|
99
|
-
function withTimeout(promise, ms) {
|
|
108
|
+
function withTimeout(promise, ms, onTimeout) {
|
|
100
109
|
let timer;
|
|
101
110
|
const timeout = new Promise((_, reject) => {
|
|
102
|
-
timer = setTimeout(() =>
|
|
111
|
+
timer = setTimeout(() => {
|
|
112
|
+
onTimeout();
|
|
113
|
+
reject(new Error(`prediction timed out after ${ms}ms`));
|
|
114
|
+
}, ms);
|
|
103
115
|
});
|
|
104
116
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
105
117
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-next-prompt",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Predicts your next message after the assistant replies in OpenCode \u2014 accept it with the
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"description": "Predicts your next message after the assistant replies in OpenCode \u2014 accept it with the down arrow",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./tui": {
|