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 +3 -2
- package/dist/core/tool-xml.js +92 -0
- package/dist/providers/copilot/adapter.js +40 -2
- package/dist/version.js +2 -0
- package/package.json +3 -1
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:
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-reverse",
|
|
3
|
-
"version": "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": {
|