diff --git a/AGENTS.md b/AGENTS.md index 0216e73..6735f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ It is the same trick `scripts/check.ts` already uses for the client bundle, at f - `src/google-bridge.ts` — the Google models, served through the plugin's own adapter on the real `llm` service. It is the ONLY module that imports pi at runtime, and its route is `opencode-google` so the `google` name stays free for a real deployment. - `src/opencode-paths.ts` — where an OpenCode installation keeps its files, ported from `~/dev/opencode`'s `global-roots.ts` (the directory) and `database-path.ts` (the filename). It takes `home`, `env` and `channel` as parameters and reads neither the environment nor the filesystem, which is what makes the Windows and Linux layouts testable from here. Two facts it encodes that are easy to get wrong: the directory is XDG on EVERY platform — macOS and Windows use `~/.local/share` too, and `os.homedir()` is the only platform-dependent call — and the FILENAME is not constant, being `$OPENCODE_DB`, else `opencode.db` on a stable channel, else `opencode-.db`. The database sits under `data` while the config sits under `config`, so one root cannot serve both. It is the foundation of the opt-in reader: `go-discovery.ts`'s `resolveOpencodeStoreKey` is what imports it, and nothing else does. - `src/opencode-store.ts` — reading the credentials an OpenCode installation already holds, through `node:sqlite`. Two rules shape it: nothing throws (a missing file, a foreign schema and `:memory:` all answer `undefined`), and a value never leaves the return value — no log line, no error message, no client bundle. `node:sqlite` is host-only, which is what keeps the module on the host side of the bundle rule. It is reached only through `resolveOpencodeStoreKey`, which is the last step of every key-source policy and is off unless `readOpencodeStore` is on. Its second reader, `inspectOpencodeStore`, answers the _other_ question — "is there a credential here, and whose is it?" — with a sanitized summary (email, org, kind, expiry) and never the token, which is what lets the settings card show the evidence beside the permission. -- **What the plugin page shows is `docs/detail.md`, not the README.** The page renders one document: the Markdown `meta.detail` in `locale/.json` names, else the whole `README.md`. This package declared nothing for years, so its page carried 461 lines of engineering notes above the settings card. It now declares a 19-line page per language, opening with `docs/media/hero.svg` — and the image is served from the package, so `files` has to ship it (the detail-image route reads package-relative paths). `test/plugin-detail.test.ts` holds the contract: a declared detail in every locale, a path that exists AND is shipped, an image that does the same, under 40 lines, and links to where the rest lives. +- **What the plugin page shows is `docs/detail.md`, not the README.** The page renders one document: the Markdown `meta.detail` in `locale/.json` names, else the whole `README.md`. This package declared nothing for years, so its page carried 461 lines of engineering notes above the settings card. It now declares a page per language that is **one image and nothing else** — a UI preview of the card and the meter (`docs/media/preview.svg`, `.zh.svg`) — which is the shape the upstream plugins use, because the header above the card already carries the title and the one-sentence description. The image is served from the package, so `files` has to ship it (the detail-image route reads package-relative paths). `test/plugin-detail.test.ts` holds the contract: a declared detail in every locale, a path that exists AND is shipped, an image that does the same, and a page that is the picture and nothing else. - `src/opencode-store-contract.ts` · `src/opencode-store-service.ts` — the inspection seam: the sanitized summary type both sides share, and the host service that produces it. The contract is pure data (no `node:`, no context), so the client bundle may import it; the service is the half that touches `node:sqlite` and the filesystem, and it rewrites the store path to `~`-relative before answering — a home directory is a fact about the machine, not about the plugin. `inspect` is declared through the `Object.defineProperty` marker rather than `@Remote`, for the reason `usage.ts` documents. - **The login stack is not in this tree.** It lives on the `v2` bookmark, and the map used to describe it here for years after it left — nine modules a reader could not find. What this tree carries is the API-key path only; a login grant is the other half, and reviving it is a days-scale port rather than a rebase (`v2` is 163 commits behind, and its `src/index.ts` exports a module the tree no longer has). diff --git a/docs/detail.md b/docs/detail.md index 7e2866e..d8406ee 100644 --- a/docs/detail.md +++ b/docs/detail.md @@ -1,18 +1 @@ -![OpenCode Patch — session affinity, gateway headers and live Go quota](./media/hero.svg) - -OpenCode's own CLI and this plugin make the same requests to the same gateway. The difference is what the gateway is told about them, and what the composer shows while they run: this patch restores the request identity the official CLI sends, keeps one session across turns, and puts the live Go quota beside the input box. - -## What you get - -| Surface | What it does | -| :-- | :-- | -| Gateway requests | Sends the official CLI's `User-Agent`, origin headers and workspace project tag, so Cloudflare's WAF checks pass | -| Sessions | One session id per conversation, derived and reused, so Zen and Go stop treating every turn as a stranger | -| Quota | The Go panel — 5-hour, weekly and monthly rings, the monthly allowance per model — and the Zen session spend | -| Routing | Models served on another protocol are routed to the endpoint that speaks it, instead of being refused | - -## Where the rest lives - -- The full reference — configuration, every control, the model tables — is in [README.md](https://github.com/viztor/dsh-opencode-patch#readme). -- The meter's numbers, and what each row means, are in [docs/quota-meter.md](https://github.com/viztor/dsh-opencode-patch/blob/main/docs/quota-meter.md). -- The endpoints, the headers and the measurements behind them are in [docs/redirect-planes.md](https://github.com/viztor/dsh-opencode-patch/blob/main/docs/redirect-planes.md). +![The settings card, and the Go meter above the composer](./media/preview.svg) diff --git a/docs/detail.zh-CN.md b/docs/detail.zh-CN.md index 272ec53..2f74987 100644 --- a/docs/detail.zh-CN.md +++ b/docs/detail.zh-CN.md @@ -1,18 +1 @@ -![OpenCode 补丁——会话保持、网关请求头与实时 Go 额度](./media/hero.svg) - -OpenCode 自己的 CLI 和这个插件向同一个网关发的是同一批请求,差别在于网关被告知了什么、以及运行的时候输入框旁边显示了什么:本补丁恢复官方 CLI 的请求身份、让同一段对话共用一个会话、并把实时 Go 额度放在输入框旁。 - -## 你能得到什么 - -| 界面 | 作用 | -| :-- | :-- | -| 网关请求 | 发送官方 CLI 的 `User-Agent`、来源请求头和工作区项目标识,使 Cloudflare WAF 校验通过 | -| 会话 | 每段对话一个会话 id,推导并复用,Zen 与 Go 不再把每一轮都当成陌生人 | -| 额度 | Go 面板——5 小时、每周、每月三个环,以及按模型的月度额度——和 Zen 的会话消耗 | -| 路由 | 走另一种协议的模型会被送到说那种协议的端点,而不是被拒绝 | - -## 其余内容在哪儿 - -- 完整参考——配置、每个控件、模型表——在 [README.zh-CN.md](https://github.com/viztor/dsh-opencode-patch/blob/main/README.zh-CN.md)。 -- 计量表上每个数字、每一行在说什么,在 [docs/quota-meter.zh-CN.md](https://github.com/viztor/dsh-opencode-patch/blob/main/docs/quota-meter.zh-CN.md)。 -- 端点、请求头以及它们背后的实测,在 [docs/redirect-planes.md](https://github.com/viztor/dsh-opencode-patch/blob/main/docs/redirect-planes.md)。 +![设置卡片,以及输入框上方的 Go 计量表](./media/preview.zh.svg) diff --git a/docs/media/preview.svg b/docs/media/preview.svg new file mode 100644 index 0000000..a1609c5 --- /dev/null +++ b/docs/media/preview.svg @@ -0,0 +1,51 @@ + + + + +OpenCode Patch + + +GATEWAY REQUESTS +Official User-Agent + +Origin headers + +Workspace project + +MODELS & FREE TIER +Enrich from catalog + +Core tool fallback + +QUOTA METER +Composer meter + + +Go usage +Healthy + +USED +LEFT + +5-hour +0% +100% + + +Weekly +3% +97% + + +Monthly +90% +10% + +Monthly allowance + +deepseek-v4.1-flash +Go $100 · Plus $200 + +refresh + + diff --git a/docs/media/preview.zh.svg b/docs/media/preview.zh.svg new file mode 100644 index 0000000..294576c --- /dev/null +++ b/docs/media/preview.zh.svg @@ -0,0 +1,51 @@ + + + + +OpenCode 补丁 + + +网关请求 +恢复官方 User-Agent + +注入来源请求头 + +附带工作区项目 + +模型与免费额度 +用官方目录补全 + +核心工具回退 + +配额计量 +输入区计量 + + +Go 用量 +状态正常 + +已用 +剩余 + +5 小时 +0% +100% + + +每周 +3% +97% + + +每月 +90% +10% + +月度额度 + +deepseek-v4.1-flash +Go $100 · Plus $200 + +刷新 + + diff --git a/package.json b/package.json index 85dd14a..e09dc99 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "LICENSE", "docs/detail.md", "docs/detail.zh-CN.md", - "docs/media/hero.svg" + "docs/media/hero.svg", + "docs/media/preview.svg", + "docs/media/preview.zh.svg" ], "type": "module", "main": "lib/index.mjs", diff --git a/test/docs-pairing.test.ts b/test/docs-pairing.test.ts index 5143e15..a011814 100644 --- a/test/docs-pairing.test.ts +++ b/test/docs-pairing.test.ts @@ -1,10 +1,11 @@ /** * The language pairs: one document each, two languages. * - * `README` and `docs/quota-meter` are the documents this repository publishes - * twice. Every count and every table in them is already held to the code — but - * nothing held the two LANGUAGES to each other, and that is the drift that - * actually happens: a row added to one language and not the other. + * `README`, `docs/quota-meter` and `docs/detail` are the documents this + * repository publishes twice. Every count and every table in them is already + * held to the code — but nothing held the two LANGUAGES to each other, and that + * is the drift that actually happens: a row added to one language and not the + * other. * * It had, when this check was first run, in both directions: * @@ -14,8 +15,12 @@ * * The check is deliberately about STRUCTURE, not prose. A translator may * rephrase, and the two languages are allowed their own idiom — but a section, a - * code block or a table row is a FACT, and a fact present in one language and - * absent from the other is invisible to whoever reads only one of them. + * code block, a table row or an image is a FACT, and a fact present in one + * language and absent from the other is invisible to whoever reads only one. + * + * No axis has a minimum. The page documents are ONE image line each, by design, + * and a floor of "at least one heading" was a rule about long pages dressed up + * as a rule about pairing. * * @module test/docs-pairing.test */ @@ -82,59 +87,76 @@ const tableRows = (text: string): number[] => { const listItems = (text: string): number => (text.match(/^(?:\d+\.|[-*]) /gmu) ?? []).length; +/** Every image a document embeds, in order, as written. */ +const images = (text: string): string[] => + [...text.matchAll(/!\[[^\]]*\]\(([^)]+)\)/gu)].map((match) => match[1] ?? ""); + describe("the documents published in two languages", () => { for (const base of PAIRS) { it(`${base}: the two languages carry the same sections`, () => { // A section added to one language is the drift a reader cannot see: they // read one language and never learn the other says more. - const en = headings(read(base, "")); - const zh = headings(read(base, ".zh-CN")); - expect(en.length).toBeGreaterThan(0); - expect(zh, `${base}.zh-CN`).toEqual(en); + expect(headings(read(base, ".zh-CN")), `${base}.zh-CN`).toEqual( + headings(read(base, "")) + ); }); it(`${base}: the two languages carry the same code blocks`, () => { // A command that exists in one language only is worse than a typo: the // reader following that language cannot run it. - // No minimum: a page with no commands is a legitimate page, and the - // pairing is what this asserts — not that every document has a fence. - const en = fences(read(base, "")); - const zh = fences(read(base, ".zh-CN")); - expect(zh, `${base}.zh-CN`).toBe(en); + expect(fences(read(base, ".zh-CN")), `${base}.zh-CN`).toBe( + fences(read(base, "")) + ); }); it(`${base}: every table has the same rows in both languages`, () => { // Row for row: a row may be rephrased, not removed. This is what found // the stale settings row and the missing `credentialOrg` row. - const en = tableRows(read(base, "")); - const zh = tableRows(read(base, ".zh-CN")); - expect(en.length).toBeGreaterThan(0); - expect(zh, `${base}.zh-CN`).toEqual(en); + expect(tableRows(read(base, ".zh-CN")), `${base}.zh-CN`).toEqual( + tableRows(read(base, "")) + ); }); it(`${base}: the two languages carry the same list items`, () => { // The other fact form. Counted rather than compared, because a list item // is prose with a marker and the prose is the translator's. - const en = listItems(read(base, "")); - const zh = listItems(read(base, ".zh-CN")); - expect(zh, `${base}.zh-CN`).toBe(en); + expect(listItems(read(base, ".zh-CN")), `${base}.zh-CN`).toBe( + listItems(read(base, "")) + ); + }); + + it(`${base}: the two languages show the same pictures`, () => { + // The detail pages are a single image each, and their two images are + // different FILES (a Chinese preview and an English one) — so what is + // compared is the count, and each file has to exist. + const en = images(read(base, "")); + const zh = images(read(base, ".zh-CN")); + expect(zh.length, `${base}.zh-CN`).toBe(en.length); + for (const image of [...en, ...zh]) { + if (/^[a-z]+:/iu.test(image)) { + continue; + } + expect(() => + readFileSync(join(process.cwd(), base, "..", image)) + ).not.toThrow(); + } }); } it("finds the pairs it claims to check", () => { // A loop over an empty list passes silently, which is how a gate stops being - // one. Each pair is read, and each read has to produce structure. + // one. Each pair is read, and each read has to produce SOMETHING — a + // heading, a table, a list item or a picture — because a document with none + // of those is not a document a pairing check can say anything about. for (const base of PAIRS) { for (const suffix of ["", ".zh-CN"]) { const text = read(base, suffix); - // A floor that proves the file was READ, not a length policy: the - // featured-image page is three short sections and 808 characters, and a - // threshold tuned to it would fail the next short document for no reason. - expect(text.length, `${base}${suffix}.md`).toBeGreaterThan(200); - expect(headings(text).length, `${base}${suffix}.md`).toBeGreaterThan(1); - expect(tableRows(text).length, `${base}${suffix}.md`).toBeGreaterThan( - 0 - ); + const markers = + headings(text).length + + tableRows(text).length + + listItems(text) + + images(text).length; + expect(markers, `${base}${suffix}.md`).toBeGreaterThan(0); } } }); diff --git a/test/plugin-detail.test.ts b/test/plugin-detail.test.ts index bab2aa3..454de3b 100644 --- a/test/plugin-detail.test.ts +++ b/test/plugin-detail.test.ts @@ -111,23 +111,29 @@ describe("the document the plugin page renders", () => { // "why do I have such a big readme here". for (const locale of LOCALES) { const detail = metaOf(locale).detail ?? ""; - const lines = read(detail).split("\n").length; - expect(lines, `${detail} is ${lines} lines`).toBeLessThanOrEqual(40); + const lines = read(detail) + .split("\n") + .filter((line) => line.trim() !== "").length; + expect( + lines, + `${detail} is ${lines} non-empty lines` + ).toBeLessThanOrEqual(3); } }); - it("names the pages a reader needs next", () => { - // A short page has to hand off, or it is just a shorter dead end: the full - // reference, the meter's numbers, and the endpoints behind them. - const en = read(metaOf("en").detail ?? ""); - for (const target of [ - "github.com/viztor/dsh-opencode-patch", - "docs/quota-meter.md", - "docs/redirect-planes.md", - ]) { - expect(en, `the English detail document must link ${target}`).toContain( - target - ); + it("is the picture and nothing else", () => { + // The upstream plugins' detail pages are ONE image line — the header above + // the card already carries the title and the one-sentence description, so a + // second copy of the prose is noise. The reference page this was modelled on + // is exactly `![alt](preview.svg)` and nothing else. + for (const locale of LOCALES) { + const detail = metaOf(locale).detail ?? ""; + const lines = read(detail) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + expect(lines, `${detail} must be one image line`).toHaveLength(1); + expect(lines[0]).toMatch(/^!\[[^\]]+\]\([^)]+\)$/u); } }); });