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 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 suggestion is dropped on timeout |
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 that reuses the conversation prefix — each prediction only processes the new content, so it stays fast regardless of conversation length. The suggestion is written into the input placeholder, so what you see is exactly what gets accepted. The main conversation is never modified.
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 predictedUserMessageID;
54
- const predictor = createPredictor(api.client, api.state.path.directory, options);
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 && userMessageID === predictedUserMessageID)
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
- predictedUserMessageID = userMessageID;
69
- const session = api.state.session.get(sessionID);
95
+ inFlightUserMessageID = userMessageID;
96
+ const variant = resolveVariant(options.variant, modelRef);
70
97
  try {
71
- const text = await predictor.predict(session?.model, turns);
72
- store.commit(generation, sessionID, text);
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", (event) => {
103
- store.discard();
104
- void predictor.dispose();
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("session.compacted", () => {
107
- void predictor.dispose();
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: JSON.stringify(extra) },
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<string | null>;
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
- await client.session.delete({ sessionID, directory }).catch(() => { });
25
+ const current = sessionID;
24
26
  sessionID = undefined;
25
- predictedTurns = 0;
26
- predictedLastMessageID = undefined;
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
- const expectedID = turns[predictedTurns - 1]?.id;
33
- if (predictedLastMessageID && expectedID && expectedID !== predictedLastMessageID) {
34
- await deleteSession();
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
- let delta = turns.slice(predictedTurns);
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
- delta = turns;
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
- const result = await withTimeout(client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: input }] }), options.timeoutMs);
52
- if (result.error)
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
- const MAX_ASSISTANT_MESSAGES = 10;
65
- const MAX_ACCUMULATED_CHARS = 80_000;
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
- const assistantCount = turns.reduce((count, turn) => count + (turn.role === "assistant" ? 1 : 0), 0);
68
- const skip = Math.max(0, assistantCount - MAX_ASSISTANT_MESSAGES);
69
- let skipped = 0;
70
- return turns
71
- .filter((turn) => {
72
- if (turn.role !== "assistant" || skipped >= skip)
73
- return true;
74
- skipped += 1;
75
- return false;
76
- })
77
- .map((turn) => `${turn.role}: ${turn.text}`)
78
- .join("\n");
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(() => reject(new Error(`prediction timed out after ${ms}ms`)), ms);
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.7",
4
- "description": "Predicts your next message after the assistant replies in OpenCode \u2014 accept it with the right arrow",
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": {