opencode-muse 0.1.1 → 0.1.2

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
@@ -16,7 +16,7 @@ Add the published package to your project-level `opencode.jsonc` (or
16
16
  ```jsonc
17
17
  {
18
18
  "$schema": "https://opencode.ai/config.json",
19
- "plugins": ["opencode-muse@0.1.1"],
19
+ "plugins": ["opencode-muse@0.1.2"],
20
20
  }
21
21
  ```
22
22
 
@@ -104,6 +104,16 @@ setup placeholder rather than advertising a model that will fail on use.
104
104
  tested. The plugin also allows only one active writer per workspace, so a
105
105
  new turn may be reported as busy while another is still finishing.
106
106
 
107
+ ## Troubleshooting
108
+
109
+ - **"Muse Spark 1.3 Contributor" never connects after a model switch:** that
110
+ entry is a retired OpenCode-gateway model (`opencode/muse-spark-1.3-contributor-free`),
111
+ not this plugin's model. It no longer resolves anywhere, so OpenCode keeps
112
+ retrying and the session appears stuck on "connecting". Pick the model under
113
+ provider **Muse Code** — `muse/muse-profile-tbh-model-muse-spark-1.3` —
114
+ instead of any historical "contributor-free" entry in recent-model lists. See
115
+ [#26](https://github.com/pgquiles/opencode-muse/issues/26).
116
+
107
117
  ## Credits and references
108
118
 
109
119
  - [opencode-claude](https://github.com/openchamber/opencode-claude) — a
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MspCommandRejectedError, MuseBridgeError, MuseTranscriptDivergedError, MuseUnsupportedInputError, MuseUnsupportedRequestError, MspProtocolError, MspTransportError } from "../errors.js";
3
+ import { notificationCursorKey } from "../msp/notification.js";
3
4
  import { MUSE_CONTEXT_HEADERS, readRequestContext } from "./context.js";
4
5
  import { classifyMetaRequest, makeCompactionDigest, makeLocalTitle } from "./meta.js";
5
6
  import { promptContainsCompactionFingerprint } from "../session/fingerprint.js";
@@ -93,7 +94,7 @@ function eventTurnId(notification, itemTurnIds) {
93
94
  async function* pageGapNotifications(host, sessionId, gap) {
94
95
  let cursor = gap.after;
95
96
  const requestedCursors = new Set([gap.after]);
96
- const eventCursors = new Set();
97
+ const eventKeys = new Set();
97
98
  for (let pageNumber = 0; pageNumber < 100; pageNumber += 1) {
98
99
  const requestCursor = cursor;
99
100
  const page = await host.request("view/page", {
@@ -104,6 +105,7 @@ async function* pageGapNotifications(host, sessionId, gap) {
104
105
  });
105
106
  if (!Array.isArray(page.events))
106
107
  throw new MspProtocolError("view/page");
108
+ let boundaryReached = false;
107
109
  for (const event of page.events) {
108
110
  if (typeof event !== "object" || event === null)
109
111
  throw new MspProtocolError("view/page");
@@ -112,14 +114,20 @@ async function* pageGapNotifications(host, sessionId, gap) {
112
114
  if (typeof record.method !== "string" || !params || params.sessionId !== sessionId || typeof params.viewCursor !== "string") {
113
115
  throw new MspProtocolError("view/page");
114
116
  }
115
- if (params.viewCursor === gap.next)
116
- return;
117
- if (eventCursors.has(params.viewCursor))
118
- throw new MspProtocolError("view/page");
119
- eventCursors.add(params.viewCursor);
117
+ const atBoundary = params.viewCursor === gap.next;
118
+ if (atBoundary)
119
+ boundaryReached = true;
120
120
  cursor = params.viewCursor;
121
- yield { jsonrpc: "2.0", method: record.method, params };
121
+ const notification = { jsonrpc: "2.0", method: record.method, params };
122
+ const key = notificationCursorKey(notification);
123
+ if (key && eventKeys.has(key))
124
+ throw new MspProtocolError("view/page");
125
+ if (key)
126
+ eventKeys.add(key);
127
+ yield notification;
122
128
  }
129
+ if (boundaryReached)
130
+ return;
123
131
  if (page.nextCursor === null || typeof page.nextCursor !== "string"
124
132
  || page.nextCursor === requestCursor || requestedCursors.has(page.nextCursor)) {
125
133
  throw new MspProtocolError("view/page");
@@ -196,7 +204,7 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
196
204
  const events = {
197
205
  [Symbol.asyncIterator]: async function* () {
198
206
  const itemTurnIds = new Map();
199
- const seenCursors = new Set();
207
+ const seenNotifications = new Set();
200
208
  const processNotification = async function* (notification) {
201
209
  const params = notification.params ?? {};
202
210
  if (params.sessionId !== session.sessionId)
@@ -227,11 +235,11 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
227
235
  if (typeof params.after !== "string" || typeof params.next !== "string")
228
236
  throw new MspProtocolError("view/gap");
229
237
  for await (const replay of pageGapNotifications(session.host, session.sessionId, { after: params.after, next: params.next })) {
230
- const replayCursor = replay.params?.viewCursor;
231
- if (typeof replayCursor === "string" && seenCursors.has(replayCursor))
238
+ const replayKey = notificationCursorKey(replay);
239
+ if (replayKey && seenNotifications.has(replayKey))
232
240
  continue;
233
- if (typeof replayCursor === "string")
234
- seenCursors.add(replayCursor);
241
+ if (replayKey)
242
+ seenNotifications.add(replayKey);
235
243
  for await (const output of processNotification(replay))
236
244
  yield output;
237
245
  if (terminalSeen)
@@ -239,13 +247,13 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
239
247
  }
240
248
  }
241
249
  else {
242
- const viewCursor = params.viewCursor;
243
- if (typeof viewCursor === "string" && seenCursors.has(viewCursor)) {
250
+ const notificationKey = notificationCursorKey(notification);
251
+ if (notificationKey && seenNotifications.has(notificationKey)) {
244
252
  next = await subscription.next();
245
253
  continue;
246
254
  }
247
- if (typeof viewCursor === "string")
248
- seenCursors.add(viewCursor);
255
+ if (notificationKey)
256
+ seenNotifications.add(notificationKey);
249
257
  for await (const output of processNotification(notification))
250
258
  yield output;
251
259
  if (terminalSeen)
@@ -14,7 +14,7 @@ export declare class MuseStreamMapper {
14
14
  private readonly reasoningStarted;
15
15
  private readonly itemDeltaCounts;
16
16
  private readonly itemText;
17
- private readonly seenUsageCursors;
17
+ private readonly seenUsageNotifications;
18
18
  private readonly textParts;
19
19
  private terminalUsage;
20
20
  private callUsage;
@@ -1,4 +1,5 @@
1
1
  import { MspProtocolError, MuseTurnFailedError, MuseTurnUnqueuedError, } from "../errors.js";
2
+ import { notificationCursorKey } from "../msp/notification.js";
2
3
  function isNumber(value) {
3
4
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
4
5
  }
@@ -17,7 +18,7 @@ export class MuseStreamMapper {
17
18
  reasoningStarted = new Set();
18
19
  itemDeltaCounts = new Map();
19
20
  itemText = new Map();
20
- seenUsageCursors = new Set();
21
+ seenUsageNotifications = new Set();
21
22
  textParts = [];
22
23
  terminalUsage;
23
24
  callUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0 };
@@ -168,9 +169,10 @@ export class MuseStreamMapper {
168
169
  tokenUsageEvent(params) {
169
170
  if (params.turnId !== this.turnId || typeof params.viewCursor !== "string")
170
171
  return;
171
- if (this.seenUsageCursors.has(params.viewCursor))
172
+ const key = notificationCursorKey({ method: "session/tokenUsage", params });
173
+ if (!key || this.seenUsageNotifications.has(key))
172
174
  return;
173
- this.seenUsageCursors.add(params.viewCursor);
175
+ this.seenUsageNotifications.add(key);
174
176
  const raw = params.usage;
175
177
  if (!raw)
176
178
  return;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stable identity for exact notification replay suppression.
3
+ * A view cursor may be shared by multiple distinct notification methods or
4
+ * payloads, so the cursor alone is not an event identity.
5
+ */
6
+ export declare function notificationCursorKey(notification: {
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }): string | undefined;
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ function stableJson(value) {
3
+ if (value === null || typeof value !== "object")
4
+ return JSON.stringify(value) ?? "null";
5
+ if (Array.isArray(value))
6
+ return `[${value.map(stableJson).join(",")}]`;
7
+ const entries = Object.entries(value)
8
+ .filter(([, child]) => child !== undefined)
9
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
10
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
11
+ }
12
+ /**
13
+ * Stable identity for exact notification replay suppression.
14
+ * A view cursor may be shared by multiple distinct notification methods or
15
+ * payloads, so the cursor alone is not an event identity.
16
+ */
17
+ export function notificationCursorKey(notification) {
18
+ const params = notification.params ?? {};
19
+ const cursor = params.viewCursor;
20
+ if (typeof cursor !== "string")
21
+ return undefined;
22
+ const digest = createHash("sha256")
23
+ .update(stableJson({ method: notification.method, params }))
24
+ .digest("hex");
25
+ return `${cursor}:${digest}`;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-muse",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin backed by the official Muse Code CLI and MSP",
5
5
  "license": "MIT",
6
6
  "type": "module",