@magpie-community/opencode-workbuddy-auth 0.2.2 → 0.2.4

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.
Files changed (3) hide show
  1. package/README.md +38 -15
  2. package/index.mjs +219 -4
  3. package/package.json +16 -4
package/README.md CHANGED
@@ -174,21 +174,33 @@ default one, as the app does.
174
174
 
175
175
  WorkBuddy refuses some agents' identity lines as an "unapproved channel"
176
176
  (11128), so those lines are reworded before a chat goes out. Since 0.1.27
177
- (#279) that includes DeepSeek Harness's opening line. A refusal of words
178
- not reworded yet is sent once more, since 0.1.29, with the system prompt
179
- moved into the first user message (inside `<instructions>` tags) and
180
- WorkBuddy's default system prompt in its place: WorkBuddy checks only the
181
- system and assistant messages, and the line it matched is then in neither.
182
- A system message later in the chat becomes a user message where it stands.
183
- The plugin remembers each refused system prompt while it is loaded, so that
184
- agent's next chats go moved at once. The move can't reach an assistant
185
- message, so words the model quotes back into one keep that chat refused
186
- until they are in the list reworded in every message (since 0.1.30, the "Web GUI whose URL the runtime
187
- context names" line, #291); otherwise only a new chat clears it. A refusal
188
- that stands after the move ends in a hint that names no one client
189
- (since 0.1.28): WorkBuddy refused words in this client's prompt; use it from
190
- Hermes, OpenCode or Pi, or add another provider to the group. Neither
191
- refusal rests the account.
177
+ (#279) that includes DeepSeek Harness's opening line. Since 0.2.3 a refusal
178
+ of words not reworded yet is answered first by saying the same line with one
179
+ more comma — a vendor matches such a line whole and exactly, so the comma
180
+ passes it while the line still names the harness (measured 2026-10-30; a word
181
+ added after the product name did not pass, the comma did). The comma is set in
182
+ the system and assistant messages only — the two WorkBuddy reads — so the
183
+ user's own words and tools' results go out as they were sent. A system prompt
184
+ whose comma passed is remembered while the plugin is loaded, as a moved one
185
+ is, and its next chats go with the comma already in before the first send:
186
+ otherwise every turn, and every step of a tool loop, would pay the refusal
187
+ once. Only a chat refused still is sent once more, since 0.1.29, with the
188
+ system prompt moved into the first user message (inside `<instructions>` tags)
189
+ and WorkBuddy's default system prompt in its place: WorkBuddy checks only the
190
+ system and assistant messages, and the line it matched is then in neither. A system message later
191
+ in the chat becomes a user message where it stands. The plugin remembers each
192
+ refused system prompt while it is loaded, so that agent's next chats go moved
193
+ at once. The move can't reach an assistant message, so words the model quotes
194
+ back into one keep that chat refused until they are in the list reworded in
195
+ every message (since 0.1.30, the "Web GUI whose URL the runtime context names"
196
+ line, #291); otherwise only a new chat clears it. A refusal that stands after
197
+ the move ends in a hint that names no one client (since 0.1.28): WorkBuddy
198
+ refused words in this client's prompt; use it from Hermes, OpenCode or Pi, or
199
+ add another provider to the group. Since 0.2.3 that hint is preceded by the
200
+ address of [REFUSED.md](REFUSED.md), which says what the check reads, how a
201
+ line is reworded to pass it, and how to turn such a line off in an agent that
202
+ has a switch for it — so the error carries its own fix, for the person who
203
+ reads it or the agent they paste it to. Neither refusal rests the account.
192
204
 
193
205
  The loader also translates OpenAI-compatible request spellings WorkBuddy
194
206
  doesn't take: `max_completion_tokens` becomes `max_tokens`, `developer`
@@ -227,6 +239,17 @@ serves; a model that refused it once gets it that way from then on, until
227
239
  the plugin restarts. A chat ending with the assistant's message (a prefill)
228
240
  is refused as before.
229
241
 
242
+ WorkBuddy's safety filter can refuse a reply it has begun:
243
+ `deepseek-v4.1-flash` reasoned, said "Sorry, I can't respond to this
244
+ question.", and ended with `finish_reason: "content_filter"` and no output
245
+ tokens, all under a 200 (#540). Since 0.2.4 that line is taken out of a
246
+ streamed chat ending `content_filter`, so the reply says nothing before the
247
+ filter's stop: magpie hands such a refusal to the group's next member
248
+ instead of passing it to the agent as an answer. Only text that is still
249
+ the filter's line is held, so any other reply's words go as they come; the
250
+ same words ending `stop`, or the filter cutting off a reply after other
251
+ words, go through as they came.
252
+
230
253
  ## Models
231
254
 
232
255
  When you are signed in, the models are the ones WorkBuddy's product config
package/index.mjs CHANGED
@@ -1008,6 +1008,79 @@ function systemAsUserOn(b) {
1008
1008
  return true
1009
1009
  }
1010
1010
 
1011
+ // OPENERS are the shapes in which an agent's opening says which harness it
1012
+ // runs in, as a vendor may have fingerprinted one. A vendor matches a refused
1013
+ // line whole and exactly — one extra space passes it, as FLAGGED's note above
1014
+ // has it — so the same line with one more comma passes while still saying
1015
+ // what it said. Measured 2026-10-30 against copilot.tencent.com, one account,
1016
+ // streamed, deepseek-v4.1-flash at max: DeepSeek Harness's opener answered
1017
+ // 400 code 11128 as written, and 200 with the comma, the harness's name still
1018
+ // in it; a suffix in its place (…Harness Desktop., …Harness CLI.) changed
1019
+ // nothing, so it is the line's shape the vendor knows, not the product alone.
1020
+ const OPENERS = [
1021
+ [/(\bYou are (?:an?|the)\s[^\n.,]{0,60}?\b(?:agent|assistant))\s+(powered by|running on|running in|built on|based on)\b/gi, "$1, $2"],
1022
+ ]
1023
+
1024
+ function opened(text) {
1025
+ for (const [re, to] of OPENERS) text = text.replace(re, to)
1026
+ return text
1027
+ }
1028
+
1029
+ // commaPaused is body with OPENERS' comma set in its system and assistant
1030
+ // messages — the two WorkBuddy reads (REFUSED.md). The user's messages and
1031
+ // tools' results are not among them: a comma in a message the vendor never
1032
+ // matches changes what someone said without changing what was refused, and
1033
+ // the user's own words come back altered. It is tried before systemAsUser,
1034
+ // which takes the system prompt out of the slot an agent's instructions are
1035
+ // read in. null when there was nothing to set a comma in.
1036
+ //
1037
+ // The two roles are read off the message's own role: a message with no role,
1038
+ // or one of another kind, is left as it is. roles, when given, collects the
1039
+ // roles the comma went into — the caller uses it to tell a comma that made a
1040
+ // chat pass in its system prompt from one that only set an assistant
1041
+ // message's (commaSystems).
1042
+ const PAUSED_ROLES = new Set(["system", "assistant"])
1043
+
1044
+ function commaPaused(body, roles) {
1045
+ const b = chatOf(body)
1046
+ return commaPausedOn(b, roles) ? JSON.stringify(b) : null
1047
+ }
1048
+
1049
+ // commaPausedOn makes commaPaused's change on a chat's object, in place; false
1050
+ // when there was nothing to set a comma in. commaPaused is this on a body, and
1051
+ // the first send of a system prompt whose comma passed is this too
1052
+ // (commaSystems), so the two can never drift apart.
1053
+ function commaPausedOn(b, roles) {
1054
+ const msgs = b?.messages
1055
+ if (!Array.isArray(msgs)) return false
1056
+ let changed = false
1057
+ for (const m of msgs) {
1058
+ if (!PAUSED_ROLES.has(m?.role)) continue
1059
+ let mine = false
1060
+ if (typeof m.content === "string") {
1061
+ const t = opened(m.content)
1062
+ if (t !== m.content) {
1063
+ m.content = t
1064
+ mine = true
1065
+ }
1066
+ } else if (Array.isArray(m.content)) {
1067
+ for (const p of m.content) {
1068
+ if (typeof p?.text !== "string") continue
1069
+ const t = opened(p.text)
1070
+ if (t === p.text) continue
1071
+ p.text = t
1072
+ mine = true
1073
+ }
1074
+ }
1075
+ // a message whose parts held no opening of their own is not changed, and
1076
+ // is not reported as one that was
1077
+ if (!mine) continue
1078
+ changed = true
1079
+ roles?.add(m.role)
1080
+ }
1081
+ return changed
1082
+ }
1083
+
1011
1084
  // normalizeToolChoice converts the object form emitted by OpenAI clients to
1012
1085
  // WorkBuddy's string form. It runs after fitted(), so a named choice follows a
1013
1086
  // tool name that was made safe for WorkBuddy.
@@ -1526,6 +1599,106 @@ function renamed(res, names) {
1526
1599
  return new Response(body, { status: res.status, statusText: res.statusText, headers })
1527
1600
  }
1528
1601
 
1602
+ // FILTER_SAYS is the text WorkBuddy's safety filter puts in a reply it ends
1603
+ // with finish_reason "content_filter": deepseek-v4.1-flash on
1604
+ // copilot.tencent.com reasoned, said this, and ended content_filter with
1605
+ // no output tokens counted (itspig, plugins #540).
1606
+ const FILTER_SAYS = ["Sorry, I can't respond to this question."]
1607
+
1608
+ // filterSaid tells whether text said so far may still be FILTER_SAYS.
1609
+ const filterSaid = (text) => {
1610
+ const t = text.replace(/’/g, "'").trimStart()
1611
+ return t !== "" && FILTER_SAYS.some((s) => s.startsWith(t))
1612
+ }
1613
+
1614
+ // unsaid is a streamed chat whose reply WorkBuddy's filter refused with its
1615
+ // text (FILTER_SAYS) and finish_reason content_filter, with the text taken
1616
+ // out: a reply that says nothing before content_filter is the refusal magpie
1617
+ // asks the group's next member for, where a 200 that said something is an
1618
+ // answer the agent fails on ("The model refused to complete the request",
1619
+ // plugins #540). Only text that is still the filter's is held, so any other
1620
+ // reply's first words go as they come, and one that only begins like it is
1621
+ // let through at its first word that isn't, or at its end.
1622
+ function unsaid(res) {
1623
+ if (!res.body || !/event-stream/i.test(res.headers.get("content-type") ?? "")) return res
1624
+ const headers = new Headers(res.headers)
1625
+ headers.delete("content-length")
1626
+ headers.delete("content-encoding")
1627
+ const dec = new TextDecoder()
1628
+ const enc = new TextEncoder()
1629
+ let rest = ""
1630
+ let open = true // the reply may still be the filter's
1631
+ let said = ""
1632
+ let held = [] // events held while it may be
1633
+ const chunkOf = (ev) => {
1634
+ const m = /^data:\s?(.*)$/m.exec(ev)
1635
+ if (!m || m[1] === "[DONE]") return null
1636
+ try {
1637
+ const v = JSON.parse(m[1])
1638
+ return v && typeof v === "object" ? v : null
1639
+ } catch {
1640
+ return null
1641
+ }
1642
+ }
1643
+ // without is ev with the filter's text taken out, or "" when nothing
1644
+ // else is in it
1645
+ const without = (ev) => {
1646
+ const v = chunkOf(ev)
1647
+ const c = v?.choices?.[0]
1648
+ if (!c?.delta || typeof c.delta.content !== "string") return ev
1649
+ delete c.delta.content
1650
+ const left = Object.keys(c.delta).filter((k) => k !== "role")
1651
+ if (!left.length && c.finish_reason == null && v.usage == null) return ""
1652
+ return `data: ${JSON.stringify(v)}`
1653
+ }
1654
+ const event = (ev) => {
1655
+ if (!open) return [ev]
1656
+ const c = chunkOf(ev)?.choices?.[0]
1657
+ const text = typeof c?.delta?.content === "string" ? c.delta.content : ""
1658
+ const finish = c?.finish_reason
1659
+ if (c?.delta?.tool_calls || (text && !filterSaid(said + text))) {
1660
+ open = false
1661
+ const out = [...held, ev]
1662
+ held = []
1663
+ return out
1664
+ }
1665
+ said += text
1666
+ if (finish === "content_filter" && said) {
1667
+ open = false
1668
+ const out = [...held, ev].map(without).filter(Boolean)
1669
+ held = []
1670
+ return out
1671
+ }
1672
+ if (finish) {
1673
+ open = false
1674
+ const out = [...held, ev]
1675
+ held = []
1676
+ return out
1677
+ }
1678
+ if (!said) return [ev] // reasoning, the role, before any text
1679
+ held.push(ev)
1680
+ return []
1681
+ }
1682
+ const body = res.body.pipeThrough(
1683
+ new TransformStream({
1684
+ transform(chunk, ctl) {
1685
+ rest = (rest + dec.decode(chunk, { stream: true })).replace(/\r\n/g, "\n")
1686
+ const evs = rest.split("\n\n")
1687
+ rest = evs.pop()
1688
+ const out = evs.flatMap(event)
1689
+ if (out.length) ctl.enqueue(enc.encode(out.map((e) => e + "\n\n").join("")))
1690
+ },
1691
+ flush(ctl) {
1692
+ rest += dec.decode()
1693
+ const out = held.map((e) => e + "\n\n").join("") + rest
1694
+ held = []
1695
+ if (out) ctl.enqueue(enc.encode(out))
1696
+ },
1697
+ }),
1698
+ )
1699
+ return new Response(body, { status: res.status, statusText: res.statusText, headers })
1700
+ }
1701
+
1529
1702
  // ---- signing in -------------------------------------------------------------
1530
1703
 
1531
1704
  // poll asks path until WorkBuddy answers with data, retrying the codes
@@ -3040,6 +3213,13 @@ function makePlugin(site) {
3040
3213
  // the system prompts this run WorkBuddy refused as an unapproved channel:
3041
3214
  // their next chats go with systemAsUser at once, not refused first
3042
3215
  const refusedSystems = new Set()
3216
+ // the system prompts this run WorkBuddy refused as written and took with
3217
+ // OPENERS' comma: their next chats go with the comma set before the first
3218
+ // send, as a moved one goes moved. Without it every turn of that agent —
3219
+ // and every step of its tool loop — pays a refused round trip first, which
3220
+ // is latency on each request and a pattern of channel refusals on the
3221
+ // account. Bounded as refusedSystems is.
3222
+ const commaSystems = new Set()
3043
3223
  return async ({ client, magpie }) => {
3044
3224
  // each account's list as last read, for FREE_ONLY: {at, list}
3045
3225
  const lists = new Map()
@@ -3290,6 +3470,10 @@ function makePlugin(site) {
3290
3470
  // words at once (see systemAsUser)
3291
3471
  const sysKey = native ? "" : systemKeyOf(chat)
3292
3472
  if (sysKey && refusedSystems.has(sysKey) && systemAsUserOn(chat)) changed = true
3473
+ // a system prompt whose comma passed once goes with the comma
3474
+ // set before the first send (see commaSystems), the way one
3475
+ // refused before goes moved above
3476
+ if (sysKey && commaSystems.has(sysKey) && commaPausedOn(chat)) changed = true
3293
3477
  // shaped is a body as such a model takes it. The first try's
3294
3478
  // is made on the chat already parsed, after the move above,
3295
3479
  // as shaped would make it of the body written; a resend with
@@ -3317,7 +3501,27 @@ function makePlugin(site) {
3317
3501
  else res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
3318
3502
  }
3319
3503
  if (res.status === 400 && !native) { let text = await res.text()
3320
- const moved = REFUSED.test(text) && sysKey && !refusedSystems.has(sysKey) ? systemAsUser(body) : null
3504
+ // the vendor may know a shape of the agent's opening that FLAGGED
3505
+ // doesn't: the same words said with one more comma pass it, and
3506
+ // that is tried before the system prompt is moved out of its slot.
3507
+ // The roles the comma went into are kept: one set in the system
3508
+ // prompt is what its next chats go with, one set in an assistant
3509
+ // message alone says nothing about the next chat's.
3510
+ const roles = new Set()
3511
+ const paused = REFUSED.test(text) ? commaPaused(body, roles) : null
3512
+ if (paused) {
3513
+ body = paused
3514
+ res = await send(shaped(body))
3515
+ if (res.status === 400) text = await res.text()
3516
+ // not refused this time: the comma is what passed the
3517
+ // vendor's check, so this system prompt's next chats go
3518
+ // with it set before the first send
3519
+ else if (sysKey && roles.has("system")) {
3520
+ commaSystems.add(sysKey)
3521
+ if (commaSystems.size > 256) commaSystems.delete(commaSystems.values().next().value)
3522
+ }
3523
+ }
3524
+ const moved = res.status === 400 && REFUSED.test(text) && sysKey && !refusedSystems.has(sysKey) ? systemAsUser(body) : null
3321
3525
  if (moved) {
3322
3526
  refusedSystems.add(sysKey)
3323
3527
  if (refusedSystems.size > 256) refusedSystems.delete(refusedSystems.values().next().value)
@@ -3335,7 +3539,7 @@ function makePlugin(site) {
3335
3539
  }
3336
3540
  if (ours.has(res)) return res
3337
3541
  // renewed here: a mark the account had comes off
3338
- return res.status >= 400 ? explained(res) : kept(native ? res : await renamed(res, names), renewed ? "renewed" : "kept")
3542
+ return res.status >= 400 ? explained(res) : kept(native ? res : unsaid(await renamed(res, names)), renewed ? "renewed" : "kept")
3339
3543
  },
3340
3544
  }
3341
3545
  },
@@ -3446,6 +3650,13 @@ function makePlugin(site) {
3446
3650
  // apart from WorkBuddy's own words.
3447
3651
  const REFUSED_HINT =
3448
3652
  "WorkBuddy refused words in this client's prompt (most often its system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group"
3653
+
3654
+ // REFUSED_DOC is where a refusal's fix is written down, for whoever holds the
3655
+ // error next: the person reading it, or the agent they paste it to. It goes
3656
+ // before the hint, never after, because magpie's Routing page tells the hint
3657
+ // from WorkBuddy's own words by the error ending in it.
3658
+ const REFUSED_DOC = "https://github.com/magpie-community/plugins/blob/main/packages/workbuddy/REFUSED.md"
3659
+
3449
3660
  const REFUSED = /unapproved channel|illegal api invocation/i
3450
3661
 
3451
3662
  // BAD_SIGNATURE is WorkBuddy's refusal of a thinking block whose signature it
@@ -3524,7 +3735,11 @@ async function explained(res) {
3524
3735
  const hint = isRefused ? REFUSED_HINT : isRestricted ? RESTRICTED_HINT : EXHAUSTED_HINT
3525
3736
  const type = isRefused || isRestricted ? "permission_error" : "insufficient_quota"
3526
3737
  const errCode = isRefused ? null : isRestricted ? RESTRICTED_CODE : 14018
3527
- msg = `${msg} — ${hint}`
3738
+ // a refusal says where its fix is written down, so whoever holds the error
3739
+ // — the person, or the agent they paste it to — can go and read it. It goes
3740
+ // before the hint, never after: the hint is the message's end.
3741
+ const where = isRefused ? `${REFUSED_DOC} — ` : ""
3742
+ msg = `${msg} — ${where}${hint}`
3528
3743
  const headers = new Headers(res.headers)
3529
3744
  headers.set("content-type", "application/json")
3530
3745
  headers.delete("content-length")
@@ -3537,4 +3752,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
3537
3752
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
3538
3753
 
3539
3754
  // for tests
3540
- export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, accountName, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
3755
+ export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, accountName, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unsaid, FILTER_SAYS, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, REFUSED_DOC, commaPaused, commaPausedOn, opened, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
package/package.json CHANGED
@@ -1,12 +1,24 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": "./index.mjs",
8
- "files": ["index.mjs", "README.md"],
9
- "keywords": ["opencode", "opencode-plugin", "workbuddy", "codebuddy"],
8
+ "files": [
9
+ "index.mjs",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "opencode",
14
+ "opencode-plugin",
15
+ "workbuddy",
16
+ "codebuddy"
17
+ ],
10
18
  "license": "MIT",
11
- "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/workbuddy" }
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/magpie-community/plugins.git",
22
+ "directory": "packages/workbuddy"
23
+ }
12
24
  }