opencode-muse 0.1.0 → 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 +14 -3
- package/dist/model/language-model.js +24 -16
- package/dist/model/stream.d.ts +1 -1
- package/dist/model/stream.js +5 -3
- package/dist/msp/notification.d.ts +9 -0
- package/dist/msp/notification.js +26 -0
- package/dist/session/registry.js +67 -14
- package/package.json +1 -1
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.
|
|
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
|
|
@@ -117,5 +127,6 @@ setup placeholder rather than advertising a model that will fail on use.
|
|
|
117
127
|
|
|
118
128
|
## Related projects
|
|
119
129
|
|
|
120
|
-
- [opencode-muse-auth](https://github.com/TheStreamCode/opencode-muse-auth)
|
|
121
|
-
- [
|
|
130
|
+
- [opencode-muse-auth](https://github.com/TheStreamCode/opencode-muse-auth) is another credential-extraction/direct-API approach; see its [issue #12](https://github.com/TheStreamCode/opencode-muse-auth/issues/12) for its discussion of subscription-term concerns.
|
|
131
|
+
- [opencode-muse-code](https://github.com/swalker326/opencode-muse-code) is an unofficial direct-API alternative. It reads the Muse CLI login, requests an API key from Meta, and sends model requests to Meta's API from OpenCode instead of routing inference through `muse serve` and MSP. Its subscription eligibility is separate from this plugin's CLI-mediated approach and has not been established by this project.
|
|
132
|
+
- [Official Muse Code documentation](https://dev.meta.ai/docs/quickstart#agent-setup) describes using Muse models in OpenCode through the Meta API.
|
|
@@ -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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
231
|
-
if (
|
|
238
|
+
const replayKey = notificationCursorKey(replay);
|
|
239
|
+
if (replayKey && seenNotifications.has(replayKey))
|
|
232
240
|
continue;
|
|
233
|
-
if (
|
|
234
|
-
|
|
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
|
|
243
|
-
if (
|
|
250
|
+
const notificationKey = notificationCursorKey(notification);
|
|
251
|
+
if (notificationKey && seenNotifications.has(notificationKey)) {
|
|
244
252
|
next = await subscription.next();
|
|
245
253
|
continue;
|
|
246
254
|
}
|
|
247
|
-
if (
|
|
248
|
-
|
|
255
|
+
if (notificationKey)
|
|
256
|
+
seenNotifications.add(notificationKey);
|
|
249
257
|
for await (const output of processNotification(notification))
|
|
250
258
|
yield output;
|
|
251
259
|
if (terminalSeen)
|
package/dist/model/stream.d.ts
CHANGED
|
@@ -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
|
|
17
|
+
private readonly seenUsageNotifications;
|
|
18
18
|
private readonly textParts;
|
|
19
19
|
private terminalUsage;
|
|
20
20
|
private callUsage;
|
package/dist/model/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
172
|
+
const key = notificationCursorKey({ method: "session/tokenUsage", params });
|
|
173
|
+
if (!key || this.seenUsageNotifications.has(key))
|
|
172
174
|
return;
|
|
173
|
-
this.
|
|
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/dist/session/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { closeSync, chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { closeSync, chmodSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readlinkSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join,
|
|
4
|
+
import { dirname, isAbsolute, join, posix, win32 } from "node:path";
|
|
5
5
|
import { RegistryCorruptError, RegistryStorageError, RegistryValidationError, RegistryWorkspaceMismatchError, } from "../errors.js";
|
|
6
6
|
const REGISTRY_VERSION = 1;
|
|
7
7
|
const MAX_ENTRY_BYTES = 1_048_576;
|
|
@@ -9,29 +9,82 @@ const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
|
9
9
|
const UUID_V7_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
10
10
|
const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:@+_-]{1,512}$/;
|
|
11
11
|
const METHOD_PATTERN = /^[a-z][A-Za-z0-9]*(?:\/[A-Za-z][A-Za-z0-9]*)+$/;
|
|
12
|
+
const MAX_PATH_SYMLINKS = 40;
|
|
13
|
+
/** Resolve existing path components while retaining a normalized not-yet-created suffix. */
|
|
14
|
+
function canonicalPath(path, platform) {
|
|
15
|
+
const pathApi = platform === "win32" ? win32 : posix;
|
|
16
|
+
if (platform !== process.platform)
|
|
17
|
+
return pathApi.resolve(path);
|
|
18
|
+
const absolute = pathApi.resolve(path);
|
|
19
|
+
const root = pathApi.parse(absolute).root;
|
|
20
|
+
let current = root;
|
|
21
|
+
let remaining = absolute.slice(root.length).split(pathApi.sep).filter(Boolean);
|
|
22
|
+
let followedSymlinks = 0;
|
|
23
|
+
while (remaining.length > 0) {
|
|
24
|
+
const [component, ...rest] = remaining;
|
|
25
|
+
const candidate = pathApi.join(current, component);
|
|
26
|
+
let stats;
|
|
27
|
+
try {
|
|
28
|
+
stats = lstatSync(candidate);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
// A missing component ends filesystem traversal; normalize the remaining suffix lexically.
|
|
32
|
+
if (isMissingFile(error))
|
|
33
|
+
return pathApi.resolve(current, ...remaining);
|
|
34
|
+
throw new RegistryValidationError();
|
|
35
|
+
}
|
|
36
|
+
if (stats.isSymbolicLink()) {
|
|
37
|
+
followedSymlinks += 1;
|
|
38
|
+
if (followedSymlinks > MAX_PATH_SYMLINKS)
|
|
39
|
+
throw new RegistryValidationError();
|
|
40
|
+
let target;
|
|
41
|
+
try {
|
|
42
|
+
target = readlinkSync(candidate);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new RegistryValidationError();
|
|
46
|
+
}
|
|
47
|
+
const absoluteTarget = pathApi.isAbsolute(target)
|
|
48
|
+
? pathApi.resolve(target)
|
|
49
|
+
: pathApi.resolve(current, target);
|
|
50
|
+
const targetRoot = pathApi.parse(absoluteTarget).root;
|
|
51
|
+
current = targetRoot;
|
|
52
|
+
remaining = [
|
|
53
|
+
...absoluteTarget.slice(targetRoot.length).split(pathApi.sep).filter(Boolean),
|
|
54
|
+
...rest,
|
|
55
|
+
];
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
current = realpathSync(candidate);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new RegistryValidationError();
|
|
63
|
+
}
|
|
64
|
+
remaining = rest;
|
|
65
|
+
}
|
|
66
|
+
return pathApi.resolve(current);
|
|
67
|
+
}
|
|
12
68
|
/** Refuse a caller-selected state root inside Muse's CLI-owned config directory. */
|
|
13
69
|
export function assertSafeStateDirectory(path, options = {}) {
|
|
14
70
|
const env = options.env ?? process.env;
|
|
15
71
|
const homeDirectory = options.homeDirectory ?? homedir();
|
|
16
72
|
const platform = options.platform ?? process.platform;
|
|
17
73
|
const windows = platform === "win32";
|
|
18
|
-
const
|
|
19
|
-
const pathResolve = windows ? win32.resolve : resolve;
|
|
20
|
-
const pathRelative = windows ? win32.relative : relative;
|
|
21
|
-
const pathSeparator = windows ? win32.sep : sep;
|
|
22
|
-
const pathIsAbsolute = windows ? win32.isAbsolute : isAbsolute;
|
|
74
|
+
const pathApi = windows ? win32 : posix;
|
|
23
75
|
const configuredHome = env.XDG_CONFIG_HOME;
|
|
24
|
-
const configHome = requiredString(configuredHome) &&
|
|
76
|
+
const configHome = requiredString(configuredHome) && pathApi.isAbsolute(configuredHome)
|
|
25
77
|
? configuredHome
|
|
26
|
-
:
|
|
27
|
-
const museConfigDirectory =
|
|
28
|
-
const
|
|
78
|
+
: pathApi.join(homeDirectory, ".config");
|
|
79
|
+
const museConfigDirectory = canonicalPath(pathApi.join(configHome, "muse"), platform);
|
|
80
|
+
const canonicalStateDirectory = canonicalPath(path, platform);
|
|
81
|
+
const relativePath = pathApi.relative(museConfigDirectory, canonicalStateDirectory);
|
|
29
82
|
const outsideMuseConfig = relativePath === ".."
|
|
30
|
-
|| relativePath.startsWith(`..${
|
|
31
|
-
||
|
|
83
|
+
|| relativePath.startsWith(`..${pathApi.sep}`)
|
|
84
|
+
|| pathApi.isAbsolute(relativePath);
|
|
32
85
|
if (!outsideMuseConfig)
|
|
33
86
|
throw new RegistryValidationError();
|
|
34
|
-
return
|
|
87
|
+
return canonicalStateDirectory;
|
|
35
88
|
}
|
|
36
89
|
function isRecord(value) {
|
|
37
90
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|