copilot-reverse 0.1.0 → 0.2.0

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/dist/cli/index.js CHANGED
@@ -21,6 +21,7 @@ import { applyCodexToml } from "../tui/setup/codex-toml.js";
21
21
  import { claudeCopilotReverseEnv } from "../tui/setup/clients.js";
22
22
  import { dataDir } from "../shared/paths.js";
23
23
  import { defaultConfig } from "../shared/config.js";
24
+ import { APP_VERSION } from "../version.js";
24
25
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
25
26
  const DEFAULT_MODEL = "gpt-4o"; // a valid Copilot model id; pass-through routing uses it as-is
26
27
  // Conservative context budget that drives the assistant's auto-compaction. Sized below the
@@ -70,7 +71,7 @@ async function launchTui() {
70
71
  const registry = buildRegistry({ client, quit }, endpoint, {
71
72
  dashboardUrl: `http://${cfg.bindHost}:${cfg.supervisorPort}/`,
72
73
  reportRepo: cfg.reportRepo,
73
- appVersion: "0.1.0",
74
+ appVersion: APP_VERSION,
74
75
  platform: `${process.platform} node-${process.version}`,
75
76
  resetClient,
76
77
  // Re-run device-code login, then restart the worker so it picks up the new token.
@@ -145,7 +146,7 @@ async function launchTui() {
145
146
  }));
146
147
  }
147
148
  const program = new Command();
148
- program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version("0.1.0");
149
+ program.name("copilot-reverse").description("copilot-reverse: interactive Copilot proxy").version(APP_VERSION);
149
150
  program.command("login").description("GitHub device-code login").action(() => runDeviceLogin(dataDir()));
150
151
  program.action(() => { void launchTui(); });
151
152
  program.parseAsync(process.argv);
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ // Opening sentinels that switch the parser into capture mode. The `antml:` namespaced variants are
3
+ // the un-stripped originals; the bare forms are what survives when the namespace prefix is dropped.
4
+ const TRIGGER_RE = /<(?:antml:)?(?:function_calls>|invoke\b)/;
5
+ // Longest suffix of `s` that is a proper prefix of a trigger token — text we must hold back because
6
+ // it might be the front of a sentinel split across chunk boundaries (e.g. "…<inv" then "oke name=").
7
+ const PREFIX_TOKENS = ["<function_calls>", "<function_calls>", "<invoke", "<invoke"];
8
+ function heldBackLen(s) {
9
+ let max = 0;
10
+ for (const t of PREFIX_TOKENS) {
11
+ for (let k = Math.min(s.length, t.length - 1); k > 0; k--) {
12
+ if (s.endsWith(t.slice(0, k))) {
13
+ if (k > max)
14
+ max = k;
15
+ break;
16
+ }
17
+ }
18
+ }
19
+ return max;
20
+ }
21
+ // Index just past a `</tag>` (or `</tag>`) close, or -1 if not yet present in `s`.
22
+ function closeIndex(s, tag) {
23
+ const m = new RegExp(`</(?:antml:)?${tag}>`).exec(s);
24
+ return m ? m.index + m[0].length : -1;
25
+ }
26
+ // A scalar parameter value is raw text in the XML; recover its intended type by trying JSON, so
27
+ // `42`/`true`/`{"a":1}` become real values while a bare command string stays a string.
28
+ function coerce(raw) {
29
+ const v = raw.replace(/^\n/, "").replace(/\n$/, "");
30
+ try {
31
+ return JSON.parse(v);
32
+ }
33
+ catch {
34
+ return v;
35
+ }
36
+ }
37
+ function parseInvokes(block) {
38
+ const tools = [];
39
+ const invokeRe = /<(?:antml:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?invoke>/g;
40
+ for (let m = invokeRe.exec(block); m; m = invokeRe.exec(block)) {
41
+ const [, name, body] = m;
42
+ const input = {};
43
+ const paramRe = /<(?:antml:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?parameter>/g;
44
+ for (let p = paramRe.exec(body); p; p = paramRe.exec(body))
45
+ input[p[1]] = coerce(p[2]);
46
+ tools.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`, name, input });
47
+ }
48
+ return tools;
49
+ }
50
+ export class ToolCallExtractor {
51
+ buf = "";
52
+ capturing = false;
53
+ feed(chunk) {
54
+ this.buf += chunk;
55
+ const events = [];
56
+ for (;;) {
57
+ if (!this.capturing) {
58
+ const m = TRIGGER_RE.exec(this.buf);
59
+ if (!m) {
60
+ // No trigger; emit everything except a possible partial-sentinel tail.
61
+ const keep = heldBackLen(this.buf);
62
+ const emit = this.buf.slice(0, this.buf.length - keep);
63
+ if (emit)
64
+ events.push({ kind: "text", text: emit });
65
+ this.buf = keep ? this.buf.slice(this.buf.length - keep) : "";
66
+ return events;
67
+ }
68
+ if (m.index > 0)
69
+ events.push({ kind: "text", text: this.buf.slice(0, m.index) });
70
+ this.buf = this.buf.slice(m.index);
71
+ this.capturing = true;
72
+ }
73
+ const isWrapper = /^<(?:antml:)?function_calls>/.test(this.buf);
74
+ const end = closeIndex(this.buf, isWrapper ? "function_calls" : "invoke");
75
+ if (end < 0)
76
+ return events; // incomplete block — wait for more data
77
+ const block = this.buf.slice(0, end);
78
+ for (const tool of parseInvokes(block))
79
+ events.push({ kind: "tool", tool });
80
+ this.buf = this.buf.slice(end);
81
+ this.capturing = false; // a following <invoke> re-triggers via the passthrough branch
82
+ }
83
+ }
84
+ // Stream ended. Anything still buffered is an incomplete block we couldn't parse — emit it as
85
+ // text so nothing is silently dropped.
86
+ flush() {
87
+ const out = this.buf ? [{ kind: "text", text: this.buf }] : [];
88
+ this.buf = "";
89
+ this.capturing = false;
90
+ return out;
91
+ }
92
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ToolCallExtractor } from "../../core/tool-xml.js";
2
3
  const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
3
4
  // Canonical messages -> OpenAI wire messages (Copilot is OpenAI-shaped).
4
5
  function toWireMessages(messages) {
@@ -88,6 +89,27 @@ export class CopilotAdapter {
88
89
  let finishReason = "stop";
89
90
  let usage;
90
91
  const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
92
+ // Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
93
+ // long/tool-heavy turns). When the request has tools, route assistant text through an extractor
94
+ // that recovers those blocks into structured tool calls; otherwise text passes straight through.
95
+ const extractor = req.tools?.length ? new ToolCallExtractor() : undefined;
96
+ let extractedTool = false;
97
+ let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
98
+ const toChunks = (events) => {
99
+ const out = [];
100
+ for (const ev of events) {
101
+ if (ev.kind === "text") {
102
+ if (ev.text)
103
+ out.push({ kind: "text", delta: ev.text, done: false });
104
+ continue;
105
+ }
106
+ const index = extIdx++;
107
+ extractedTool = true;
108
+ out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
109
+ out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
110
+ }
111
+ return out;
112
+ };
91
113
  for (;;) {
92
114
  const { value, done } = await reader.read();
93
115
  if (done)
@@ -101,6 +123,11 @@ export class CopilotAdapter {
101
123
  continue;
102
124
  const payload = line.slice(6).trim();
103
125
  if (payload === "[DONE]") {
126
+ if (extractor)
127
+ for (const ch of toChunks(extractor.flush()))
128
+ yield ch;
129
+ if (extractedTool && finishReason === "stop")
130
+ finishReason = "tool_use";
104
131
  yield { kind: "done", done: true, finishReason, usage };
105
132
  return;
106
133
  }
@@ -122,8 +149,14 @@ export class CopilotAdapter {
122
149
  const delta = choice.delta;
123
150
  if (!delta)
124
151
  continue;
125
- if (delta.content)
126
- yield { kind: "text", delta: delta.content, done: false };
152
+ if (delta.content) {
153
+ if (extractor) {
154
+ for (const ch of toChunks(extractor.feed(delta.content)))
155
+ yield ch;
156
+ }
157
+ else
158
+ yield { kind: "text", delta: delta.content, done: false };
159
+ }
127
160
  for (const tc of delta.tool_calls ?? []) {
128
161
  const idx = tc.index ?? 0;
129
162
  if (!startedTools.has(idx) && tc.function?.name) {
@@ -135,6 +168,11 @@ export class CopilotAdapter {
135
168
  }
136
169
  }
137
170
  }
171
+ if (extractor)
172
+ for (const ch of toChunks(extractor.flush()))
173
+ yield ch;
174
+ if (extractedTool && finishReason === "stop")
175
+ finishReason = "tool_use";
138
176
  yield { kind: "done", done: true, finishReason, usage };
139
177
  }
140
178
  }
@@ -0,0 +1,2 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
+ export const APP_VERSION = "0.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,12 +32,14 @@
32
32
  "llm"
33
33
  ],
34
34
  "scripts": {
35
+ "prebuild": "node scripts/gen-version.mjs",
35
36
  "build": "tsc -p tsconfig.json",
36
37
  "test": "vitest run",
37
38
  "test:coverage": "vitest run --coverage",
38
39
  "test:e2e": "vitest run e2e/copilot-reverse.e2e.test.ts tests/e2e",
39
40
  "test:integration": "vitest run --config vitest.integration.config.ts",
40
41
  "dev": "tsx src/cli/index.ts",
42
+ "changeset": "node scripts/changesets.mjs new",
41
43
  "prepublishOnly": "npm run build && npm run test"
42
44
  },
43
45
  "engines": {