Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ DEEPSEEK_CONTEXT_ROLLOVER=0
# RATE_LIMIT_PER_MINUTE=30 # per-client-IP limit on /v1 endpoints

# --- TOOL / TOOLCALL ---
# Optional raw per-chat traces (prompts, tool calls/results and replies) in ./llog/.
# Optional raw per-chat traces (prompts, tool calls/results and replies) in ./logs/.
# Files can contain private project data. Disabled by default.
TOOLCALL_LOG=0

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.12"]
python-version: ["3.10", "3.12"]
env:
SERVER_INTERACTIVE_LOGIN: "0"
SESSION_REFRESH_ENABLED: "0"
Expand Down
14 changes: 7 additions & 7 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,13 +577,13 @@ With `TOOLCALL_FORMAT_REPAIR=1` (off by default), an erroneous DeepSeek reply ge
one format correction in the same conversation. A second correction is never
requested. Verified offline.

In compact browser mode, an agent's tool result (for example the option you picked)
is sent as `Tool result (name id="..."): <result>`. IDs retain result binding;
multiple calls to the same tool also receive an exact call mapping. Complete schemas
are normally omitted, but the current `tool_choice` follows the result even in
`none` mode. `auto` explicitly replaces an earlier per-reply selection; task
constraints and schema validation remain enforced. An authorized catalog upgrade
sends complete schemas once.
A tool-result tail into an existing chat uses mode `none` (current `tool_choice`
policy only) and is sent as `Tool result (name): <result>`; `id` is attached only
when several results share a tool name. Complete schemas are omitted on that
tail; an authorized catalog upgrade sends complete schemas once
(`tool_instructions=full`). Automatic `auto` policy text is included in
`full`/`medium` only; `light`/`none` continuations omit it. Task constraints and
schema validation remain enforced.
Verified offline.

For DeepSeek, disabling `TOOLCALL_FORMAT_REPAIR` prevents an extra format-correction generation;
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,14 +574,13 @@ DeepSeek без угадывания аргументов, невалидный
получает одну коррекцию формата в той же беседе. Вторая коррекция не
запрашивается. Проверено офлайн.

Результат инструмента, который возвращает агент (например выбранный вами вариант),
в компактном браузерном режиме уходит как `Tool result (имя id="..."): <результат>`.
ID сохраняется для привязки; несколько вызовов одного инструмента дополнительно
получают точное сопоставление вызовов. Полные схемы обычно не повторяются, но
текущий `tool_choice` передаётся после результата даже при режиме `none`.
`auto` явно снимает прежний выбор для отдельного ответа; ограничения задачи
и проверки схем сохраняются. При разрешённой смене каталога один раз отправляются
полные схемы.
Хвост с результатом инструмента в существующий чат уходит в режиме `none`
(только политика `tool_choice`) как `Tool result (имя): <результат>`; `id`
добавляется лишь когда несколько результатов делят одно имя инструмента.
Полные схемы на таком хвосте не повторяются; при разрешённой смене каталога
один раз отправляются полные схемы (`tool_instructions=full`). Текст про
автоматический `auto` идёт в режимах `full`/`medium`; в `light`/`none` на
продолжениях его нет. Ограничения задачи и проверки схем сохраняются.
Проверено офлайн.

Для DeepSeek отключение `TOOLCALL_FORMAT_REPAIR` запрещает дополнительную генерацию для
Expand Down
144 changes: 135 additions & 9 deletions browser/opencode-controller.user.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ==UserScript==
// @name OpenCode signed-in tab controller
// @namespace opencode-local-bridge
// @version 0.3.35
// @version 0.3.37
// @description Opt-in local jobs in your existing signed-in browser tab.
// @match https://chat.z.ai/*
// @match https://grok.com/*
Expand Down Expand Up @@ -195,15 +195,98 @@
label();
}

// Attached to the next finish so TOOLCALL_LOG sees what happened after emit.
let pendingLateRewrite = null;
let pendingDomCheck = null;
let domCheckToken = null;
async function finish(result) {
const job = pending;
retire();
if (!job) return;
finishing = true;
try { await rpc("/browser/result", "POST", { ...identity(job), result }); }
try {
const payload = { ...result };
if (pendingLateRewrite && typeof pendingLateRewrite === "object") {
payload.late_rewrite = pendingLateRewrite;
pendingLateRewrite = null;
}
if (pendingDomCheck && typeof pendingDomCheck === "object") {
payload.dom_check = pendingDomCheck;
pendingDomCheck = null;
}
await rpc("/browser/result", "POST", { ...identity(job), result: payload });
}
finally { finishing = false; }
}

// ---- DOM check (docs/BRIDGE_TRACE_STATS.ru.md): any site with answerSnapshot ----
// Rendering turns Markdown into DOM, so the comparison ignores everything but
// letters and digits; similarity is the word-multiset overlap in percent.
// Markdown round-trips (tables, links, list markers) leave a few percent of
// noise: >= 97% overlap with lengths within 5% still counts as the same answer.
// coverage is the share of emitted words present in the bubble: a rendered
// widget (a mermaid diagram) adds DOM text but keeps coverage near 100%.
const domWords = text => String(text || "").toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
const domCompare = (wire, dom) => {
const result = domOverlap(wire, dom);
const a = String(wire || "").length, b = String(dom || "").length;
const close = Math.abs(a - b) <= Math.max(a, b) * 0.05;
return { exact: result.match, match: result.match || (result.similarity >= 97 && close),
similarity: result.similarity, coverage: result.coverage };
};
const domOverlap = (wire, dom) => {
const a = domWords(wire), b = domWords(dom);
const counts = new Map();
for (const word of a) counts.set(word, (counts.get(word) || 0) + 1);
let common = 0;
for (const word of b) {
const left = counts.get(word) || 0;
if (left > 0) { common += 1; counts.set(word, left - 1); }
}
return {
match: a.join("") === b.join(""),
similarity: a.length + b.length ? Math.round(200 * common / (a.length + b.length)) : 100,
coverage: a.length ? Math.round(100 * common / a.length) : 100,
};
};
// After an emit, watch the newest answer bubble (snapshot() returns the
// rendered answers, oldest first) until the next job starts (or 15 s) and
// record metadata only: whether the final rendered text matches what the
// agent received, and how often the bubble changed meanwhile.
async function domCheck(wireText, snapshot) {
const token = {};
domCheckToken = token;
const startedAt = performance.now();
let last = null, changes = -1, changedAt = startedAt;
let ended = "timeout";
while (performance.now() - startedAt < 15000) {
if (domCheckToken !== token) { ended = "next_job"; break; }
let texts = [];
try { texts = snapshot() || []; } catch (_) { texts = []; }
const dom = texts.length ? String(texts[texts.length - 1] || "") : "";
if (dom !== last) { last = dom; changes += 1; changedAt = performance.now(); }
await new Promise(resolve => setTimeout(resolve, 250));
}
if (domCheckToken === token) domCheckToken = null;
const dom = last || "";
const result = {
found: !!dom,
...domCompare(wireText, dom),
changes: Math.max(0, changes),
stable_ms: Math.max(0, Math.min(10000000, Math.round(performance.now() - changedAt))),
wire_len: String(wireText || "").length,
dom_len: dom.length,
tool_wire: String(wireText || "").includes("```tool_calls"),
tool_dom: dom.includes("```tool_calls"),
ended,
};
pendingDomCheck = result;
if (!result.match) {
try { console.warn("[opencode] rendered answer differs from emitted wire", result); } catch (_) { /* ignore */ }
}
return result;
}

button.addEventListener("click", async () => {
if (TOKEN === "__BRIDGE_TOKEN__") { label("Сначала экспортируй приватные скрипты моста"); return; }
button.title = "";
Expand All @@ -216,7 +299,18 @@

const visible = element => !!element && element.getClientRects().length > 0
&& getComputedStyle(element).visibility === "visible" && !element.closest('[inert], [aria-hidden=true]');
const editor = () => Array.from(document.querySelectorAll(site.input)).find(visible);
// Some composers mark an ancestor aria-hidden while dialogs/tool chips are open;
// still require a laid-out, non-inert, non-display:none control. Opt in with
// site.editorIgnoreAriaHidden when the shell itself stays aria-hidden.
const editorVisible = element => {
if (!element || element.getClientRects().length === 0) return false;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none") return false;
if (element.closest("[inert]")) return false;
if (!site.editorIgnoreAriaHidden && element.closest("[aria-hidden=true]")) return false;
return true;
};
const editor = () => Array.from(document.querySelectorAll(site.input)).find(editorVisible);
const block = node => node.nodeType === Node.ELEMENT_NODE && /^(P|DIV|LI|PRE|BLOCKQUOTE)$/.test(node.tagName);
function editorText(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
Expand Down Expand Up @@ -347,17 +441,22 @@
".send-button-container, [class*='send-button-container']",
)).find(visible) || null;
const sendArmed = button => {
if (!button || button.disabled || button.getAttribute("aria-disabled") === "true"
|| button.classList.contains("disabled")) return false;
if (!button || button.disabled || button.getAttribute("aria-disabled") === "true") {
return false;
}
// Class "disabled" can linger after the attribute is removed; the attribute
// is the source of truth when site.sendIgnoreDisabledClass is set.
if (!site.sendIgnoreDisabledClass && button.classList.contains("disabled")) return false;
const wrap = button.closest(".chat-prompt-send-button, .send-button-container")
|| button.parentElement;
if (wrap) {
const style = getComputedStyle(wrap);
if (style.pointerEvents === "none" || Number(style.opacity) < 0.5) return false;
if (wrap.classList.contains("disabled")) return false;
if (!site.sendIgnoreDisabledClass && wrap.classList.contains("disabled")) return false;
}
const style = getComputedStyle(button);
if (style.pointerEvents === "none" || Number(style.opacity) < 0.5) return false;
if (style.pointerEvents === "none") return false;
if (!site.sendIgnoreDisabledClass && Number(style.opacity) < 0.5) return false;
return true;
};
const activateClick = element => {
Expand Down Expand Up @@ -603,6 +702,18 @@
document.addEventListener("opencode-local-response-v1", async event => {
let value;
try { value = JSON.parse(event.detail); } catch (_) { return; }
if (value?.late_rewrite && typeof value.late_rewrite === "object") {
const note = value.late_rewrite;
pendingLateRewrite = {
delay_ms: Number.isFinite(note.delay_ms) ? Math.max(0, Math.min(10000000, note.delay_ms | 0)) : 0,
tool_before: !!note.tool_before,
tool_after: !!note.tool_after,
};
try {
console.warn("[opencode] late rewrite after emit", pendingLateRewrite);
} catch (_) { /* ignore */ }
return;
}
if (!pending || value?.nonce !== pending.nonce) return;
if (value.observing === true) {
pending.sawCapture = true;
Expand Down Expand Up @@ -653,13 +764,19 @@
if (claudeChatgbotPage() && site.provider === "claude") {
reported = claudeCanonicalPath(reported);
}
if (typeof site.answerSnapshot === "function" && typeof value.answer_text === "string") {
void domCheck(value.answer_text, site.answerSnapshot);
}
await finish({ status: value.status, body: value.body, path: reported,
...(value.request_turn ? { request_turn: value.request_turn } : {}),
...(value.settle && typeof value.settle === "object" ? { settle: value.settle } : {}),
}).catch(() => label("OpenCode: ответ не принят"));
});

async function execute(job) {
if (!Number.isFinite(job.expires_in) || job.expires_in <= 0) throw new BridgeFailure("Invalid browser job deadline");
// The previous answer's DOM check ends before this prompt adds a new bubble.
domCheckToken = null;
const deadline = performance.now() + Math.min(job.expires_in, 1800) * 1000;
let input = editor();
// Leftover text from a previous failed bridge attempt matches this job's
Expand Down Expand Up @@ -693,7 +810,9 @@
return;
}
// The extension can run before the SPA hydrates its signed-in editor.
for (let attempt = 0; !input && enabled && attempt < 50; attempt++) {
// Some sites also remount the composer between turns / while tool UI is open.
const editorWait = Number.isFinite(site.editorWaitAttempts) ? site.editorWaitAttempts : 50;
for (let attempt = 0; !input && enabled && attempt < editorWait; attempt++) {
if (attempt % 5 === 0 && !(await rpc("/browser/check", "POST", identity(job))).active) {
throw new BridgeFailure("The browser job was cancelled");
}
Expand All @@ -703,7 +822,14 @@
if (!enabled || !sameRoute(location.pathname, target)) throw new BridgeFailure("The tab changed before submission");
if (!input) throw new BridgeFailure("The signed-in chat input is unavailable");
await clearLeftoverDraft(input, job.prompt);
if (input.disabled || input.getAttribute("aria-disabled") === "true") throw new BridgeFailure("The chat input is busy");
for (let attempt = 0; input && (input.disabled || input.getAttribute("aria-disabled") === "true")
&& attempt < editorWait; attempt++) {
await new Promise(resolve => setTimeout(resolve, 100));
input = editor() || input;
}
if (!input || input.disabled || input.getAttribute("aria-disabled") === "true") {
throw new BridgeFailure("The chat input is busy");
}
if (job.submitted) throw new BridgeFailure("The prompt was already submitted");
pending = { ...job, nonce: crypto.randomUUID(), deadline };
// Use strings across Safari's isolated/page worlds. Do not send upstream
Expand Down
7 changes: 5 additions & 2 deletions browser/opencode-observer.user.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ==UserScript==
// @name OpenCode browser response observer
// @namespace opencode-local-bridge
// @version 0.3.18
// @version 0.3.19
// @description Observe only the completion caused by an active local bridge job.
// @match https://chat.z.ai/*
// @match https://grok.com/*
Expand Down Expand Up @@ -253,7 +253,7 @@
const match = location.pathname.match(/^\/(?:u\/\d+\/)?app\/([\w-]+)$/);
return match ? "/app/" + match[1] : null;
};
const emitBody = (job, status, binary, conversationPath, requestTurn) => {
const emitBody = (job, status, binary, conversationPath, requestTurn, settle, answerText) => {
if (active?.nonce !== job.nonce) return;
let encoded = "";
for (let i = 0; i < binary.length; i += 16384) {
Expand All @@ -263,6 +263,9 @@
nonce: job.nonce, status, body: btoa(encoded),
...(typeof conversationPath === "string" ? { path: conversationPath } : {}),
...(requestTurn ? { request_turn: requestTurn } : {}),
...(settle && typeof settle === "object" ? { settle } : {}),
// Page-local only: the controller compares it with the rendered bubble.
...(typeof answerText === "string" ? { answer_text: answerText } : {}),
}) }));
};
const beginCapture = job => {
Expand Down
2 changes: 2 additions & 0 deletions chat_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class Reply:
bridge_notice: Optional[dict] = None
# Ordered text/tool_call parts for streaming; None keeps the legacy framing.
parts: Optional[list] = None
# Optional tab settle metadata (counts/flags only; never response text).
bridge_settle: Optional[dict] = None

def __str__(self) -> str:
return self.text
Expand Down
Loading
Loading