@semanticist14/clco 0.1.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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/clco +27 -0
- package/bun.lock +39 -0
- package/install.sh +161 -0
- package/package.json +35 -0
- package/scripts/mock-upstream.ts +129 -0
- package/scripts/write-launcher.sh +47 -0
- package/src/api.ts +90 -0
- package/src/auth.ts +130 -0
- package/src/blocks.ts +198 -0
- package/src/browsermcp.ts +430 -0
- package/src/catalog.ts +147 -0
- package/src/claudehome.ts +269 -0
- package/src/cli.ts +889 -0
- package/src/config.ts +164 -0
- package/src/responses.ts +435 -0
- package/src/route.ts +52 -0
- package/src/server.ts +641 -0
- package/src/setup.ts +218 -0
- package/src/spawn.ts +453 -0
- package/src/stream.ts +235 -0
- package/src/tls.ts +234 -0
- package/src/token.ts +298 -0
- package/src/tokens.ts +55 -0
- package/src/translate.ts +384 -0
- package/src/wire.ts +149 -0
- package/uninstall.sh +42 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Minimal mock Copilot upstream for end-to-end testing without GitHub auth.
|
|
2
|
+
// Usage: bun run scripts/mock-upstream.ts [port] then
|
|
3
|
+
// CLCO_UPSTREAM=http://127.0.0.1:<port> clco ...
|
|
4
|
+
|
|
5
|
+
const port = Number(process.argv[2] ?? 9099)
|
|
6
|
+
|
|
7
|
+
Bun.serve({
|
|
8
|
+
hostname: "127.0.0.1",
|
|
9
|
+
port,
|
|
10
|
+
fetch: async (req) => {
|
|
11
|
+
const url = new URL(req.url)
|
|
12
|
+
if (url.pathname === "/models") {
|
|
13
|
+
const native = ["/v1/messages", "/chat/completions"]
|
|
14
|
+
return Response.json({
|
|
15
|
+
data: [
|
|
16
|
+
// Claude-style models route through the native Anthropic endpoint,
|
|
17
|
+
// exactly as real Copilot declares them.
|
|
18
|
+
{ id: "mock-opus", supported_endpoints: native },
|
|
19
|
+
{ id: "mock-sonnet", supported_endpoints: native },
|
|
20
|
+
{ id: "mock-haiku", supported_endpoints: native },
|
|
21
|
+
// Translation-path model, for exercising the chat dialect.
|
|
22
|
+
{ id: "mock-chat", supported_endpoints: ["/chat/completions"] },
|
|
23
|
+
].map((m) => ({
|
|
24
|
+
...m,
|
|
25
|
+
// Matches production: GitHub returns false for every model.
|
|
26
|
+
model_picker_enabled: false,
|
|
27
|
+
policy: { state: "enabled" },
|
|
28
|
+
capabilities: {
|
|
29
|
+
type: "chat",
|
|
30
|
+
family: m.id,
|
|
31
|
+
limits: { max_prompt_tokens: 200000, max_context_window_tokens: 264000 },
|
|
32
|
+
supports: { reasoning_effort: ["low", "medium", "high", "xhigh", "max"] },
|
|
33
|
+
},
|
|
34
|
+
})),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (url.pathname === "/v1/messages") {
|
|
39
|
+
const body = (await req.json()) as { stream?: boolean; model: string }
|
|
40
|
+
const text = "Mock native response: OK"
|
|
41
|
+
if (body.stream) {
|
|
42
|
+
return new Response(
|
|
43
|
+
[
|
|
44
|
+
`event: message_start\ndata: ${JSON.stringify({
|
|
45
|
+
type: "message_start",
|
|
46
|
+
message: {
|
|
47
|
+
id: "msg_mock",
|
|
48
|
+
type: "message",
|
|
49
|
+
role: "assistant",
|
|
50
|
+
content: [],
|
|
51
|
+
model: body.model,
|
|
52
|
+
stop_reason: null,
|
|
53
|
+
stop_sequence: null,
|
|
54
|
+
usage: { input_tokens: 5, output_tokens: 0 },
|
|
55
|
+
},
|
|
56
|
+
})}\n\n`,
|
|
57
|
+
`event: content_block_start\ndata: ${JSON.stringify({
|
|
58
|
+
type: "content_block_start",
|
|
59
|
+
index: 0,
|
|
60
|
+
content_block: { type: "text", text: "" },
|
|
61
|
+
})}\n\n`,
|
|
62
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
63
|
+
type: "content_block_delta",
|
|
64
|
+
index: 0,
|
|
65
|
+
delta: { type: "text_delta", text },
|
|
66
|
+
})}\n\n`,
|
|
67
|
+
`event: content_block_stop\ndata: ${JSON.stringify({
|
|
68
|
+
type: "content_block_stop",
|
|
69
|
+
index: 0,
|
|
70
|
+
})}\n\n`,
|
|
71
|
+
`event: message_delta\ndata: ${JSON.stringify({
|
|
72
|
+
type: "message_delta",
|
|
73
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
74
|
+
usage: { output_tokens: 6 },
|
|
75
|
+
})}\n\n`,
|
|
76
|
+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
|
77
|
+
].join(""),
|
|
78
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return Response.json({
|
|
82
|
+
id: "msg_mock",
|
|
83
|
+
type: "message",
|
|
84
|
+
role: "assistant",
|
|
85
|
+
model: body.model,
|
|
86
|
+
content: [{ type: "text", text }],
|
|
87
|
+
stop_reason: "end_turn",
|
|
88
|
+
stop_sequence: null,
|
|
89
|
+
usage: { input_tokens: 5, output_tokens: 6 },
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (url.pathname !== "/chat/completions") {
|
|
94
|
+
return new Response("not found", { status: 404 })
|
|
95
|
+
}
|
|
96
|
+
const body = (await req.json()) as { stream?: boolean; model: string }
|
|
97
|
+
const chunk = (delta: unknown, finish: string | null = null) =>
|
|
98
|
+
`data: ${JSON.stringify({
|
|
99
|
+
id: "mock-1",
|
|
100
|
+
model: body.model,
|
|
101
|
+
choices: [{ index: 0, finish_reason: finish, delta }],
|
|
102
|
+
})}\n\n`
|
|
103
|
+
|
|
104
|
+
if (body.stream) {
|
|
105
|
+
return new Response(
|
|
106
|
+
[
|
|
107
|
+
chunk({ role: "assistant", content: "Mock upstream response: OK" }),
|
|
108
|
+
chunk({}, "stop"),
|
|
109
|
+
"data: [DONE]\n\n",
|
|
110
|
+
].join(""),
|
|
111
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return Response.json({
|
|
115
|
+
id: "mock-1",
|
|
116
|
+
model: body.model,
|
|
117
|
+
choices: [
|
|
118
|
+
{
|
|
119
|
+
index: 0,
|
|
120
|
+
finish_reason: "stop",
|
|
121
|
+
message: { role: "assistant", content: "Mock upstream response: OK" },
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
usage: { prompt_tokens: 5, completion_tokens: 3 },
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
console.log(`mock upstream on http://127.0.0.1:${port}`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Writes the clco launcher. Lives here rather than inline in install.sh so
|
|
3
|
+
# `clco update` can refresh it too.
|
|
4
|
+
#
|
|
5
|
+
# Usage: write-launcher.sh <app-dir> <bin-dir> [bun-path]
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
CLCO_DIR="${1:?app dir required}"
|
|
9
|
+
BIN_DIR="${2:?bin dir required}"
|
|
10
|
+
BUN_BIN="${3:-$(command -v bun)}"
|
|
11
|
+
[ -n "$BUN_BIN" ] || { echo "bun not found" >&2; exit 1; }
|
|
12
|
+
|
|
13
|
+
# Bump when the launcher body changes; clco compares it to warn about a stale
|
|
14
|
+
# launcher that predates a feature.
|
|
15
|
+
LAUNCHER_VERSION=4
|
|
16
|
+
|
|
17
|
+
mkdir -p "$BIN_DIR"
|
|
18
|
+
[ ! -d "$BIN_DIR/clco" ] || {
|
|
19
|
+
echo "$BIN_DIR/clco is a directory, not a launcher file" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
}
|
|
22
|
+
TMP_LAUNCHER="$(mktemp "$BIN_DIR/.clco-launcher.XXXXXX")"
|
|
23
|
+
trap 'rm -f "$TMP_LAUNCHER"' EXIT
|
|
24
|
+
{
|
|
25
|
+
printf '%s\n' '#!/usr/bin/env bash'
|
|
26
|
+
printf '# clco launcher v%s (generated by scripts/write-launcher.sh)\n' "$LAUNCHER_VERSION"
|
|
27
|
+
printf 'CLCO_APP_DIR=%q\n' "$CLCO_DIR"
|
|
28
|
+
printf 'CLCO_BIN_DIR=%q\n' "$BIN_DIR"
|
|
29
|
+
printf 'CLCO_BUN_BIN=%q\n' "$BUN_BIN"
|
|
30
|
+
printf 'CLCO_LAUNCHER_VERSION=%s\n' "$LAUNCHER_VERSION"
|
|
31
|
+
printf '%s\n' 'export CLCO_APP_DIR CLCO_BIN_DIR CLCO_BUN_BIN CLCO_LAUNCHER_VERSION'
|
|
32
|
+
printf '%s\n' '' \
|
|
33
|
+
'# Corporate TLS: clco adds a CA to the OS trust store itself, per request, via' \
|
|
34
|
+
'# CLCO_CA_BUNDLE — see src/tls.ts. Nothing to set here; NODE_USE_SYSTEM_CA is' \
|
|
35
|
+
'# a no-op on bun, whose default trust set already merges the system roots.' \
|
|
36
|
+
'' \
|
|
37
|
+
'# bun strips a leading "--" before scripts see it — translate it into a' \
|
|
38
|
+
'# sentinel that cli.ts understands as "everything after belongs to claude".' \
|
|
39
|
+
'if [ "${1:-}" = "--" ]; then' \
|
|
40
|
+
' shift' \
|
|
41
|
+
' set -- "__clco_passthrough__" "$@"' \
|
|
42
|
+
'fi' \
|
|
43
|
+
'exec "$CLCO_BUN_BIN" run --no-install "$CLCO_APP_DIR/src/cli.ts" "$@"'
|
|
44
|
+
} > "$TMP_LAUNCHER"
|
|
45
|
+
chmod +x "$TMP_LAUNCHER"
|
|
46
|
+
mv -f "$TMP_LAUNCHER" "$BIN_DIR/clco"
|
|
47
|
+
trap - EXIT
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Upstream endpoints, OAuth constants, and client identity headers.
|
|
2
|
+
// The editor identity mirrors an official Copilot Chat client, which is what
|
|
3
|
+
// the Copilot backend expects on api.githubcopilot.com.
|
|
4
|
+
|
|
5
|
+
import { caBundle } from "./tls"
|
|
6
|
+
|
|
7
|
+
// Every outbound HTTPS request clco makes goes through here so a corporate CA
|
|
8
|
+
// configured via CLCO_CA_BUNDLE applies uniformly. Per-request `tls.ca` needs
|
|
9
|
+
// no process-level env, so it works regardless of how clco was launched.
|
|
10
|
+
export function copilotFetch(
|
|
11
|
+
url: string,
|
|
12
|
+
init?: RequestInit,
|
|
13
|
+
): Promise<Response> {
|
|
14
|
+
const ca = caBundle()
|
|
15
|
+
return fetch(url, ca ? { ...init, tls: { ca } } : init)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const GITHUB_BASE_URL = "https://github.com"
|
|
19
|
+
export const GITHUB_API_BASE_URL = "https://api.github.com"
|
|
20
|
+
export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
|
21
|
+
export const GITHUB_APP_SCOPES = "read:user"
|
|
22
|
+
export const GITHUB_API_VERSION = "2025-04-01"
|
|
23
|
+
|
|
24
|
+
const COPILOT_CHAT_VERSION =
|
|
25
|
+
process.env.CLCO_CHAT_VERSION ?? "0.26.7"
|
|
26
|
+
const EDITOR_VERSION = process.env.CLCO_EDITOR_VERSION ?? "1.104.0"
|
|
27
|
+
|
|
28
|
+
// Test/dev hook: point the adapter at a mock upstream; GitHub auth is skipped.
|
|
29
|
+
// Must be a non-empty URL — an empty string is treated as unset everywhere
|
|
30
|
+
// (single source of truth via isMockMode/copilotBaseUrl).
|
|
31
|
+
export function mockUpstream(): string | undefined {
|
|
32
|
+
const value = process.env.CLCO_UPSTREAM?.trim()
|
|
33
|
+
return value ? value : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isMockMode(): boolean {
|
|
37
|
+
return mockUpstream() !== undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Business/Enterprise accounts get a different API host in the Copilot
|
|
41
|
+
// token response's endpoints.api field — official clients route there, so
|
|
42
|
+
// clco does too (set by token.ts after each token exchange).
|
|
43
|
+
let dynamicCopilotBase: string | null = null
|
|
44
|
+
|
|
45
|
+
export function setCopilotBase(url: string | null): void {
|
|
46
|
+
dynamicCopilotBase =
|
|
47
|
+
url && url.startsWith("https://") ? url.replace(/\/$/, "") : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function copilotBaseUrl(): string {
|
|
51
|
+
return mockUpstream() ?? dynamicCopilotBase ?? "https://api.githubcopilot.com"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function copilotRequestHeaders(
|
|
55
|
+
token: string,
|
|
56
|
+
opts?: {
|
|
57
|
+
agentInitiated?: boolean
|
|
58
|
+
vision?: boolean
|
|
59
|
+
accept?: string
|
|
60
|
+
},
|
|
61
|
+
): Record<string, string> {
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
authorization: `Bearer ${token}`,
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
accept: opts?.accept ?? "application/json",
|
|
66
|
+
"copilot-integration-id": "vscode-chat",
|
|
67
|
+
"editor-version": `vscode/${EDITOR_VERSION}`,
|
|
68
|
+
"editor-plugin-version": `copilot-chat/${COPILOT_CHAT_VERSION}`,
|
|
69
|
+
"user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
|
|
70
|
+
"openai-intent": "conversation-panel",
|
|
71
|
+
"x-github-api-version": GITHUB_API_VERSION,
|
|
72
|
+
"x-request-id": crypto.randomUUID(),
|
|
73
|
+
"x-vscode-user-agent-library-version": "electron-fetch",
|
|
74
|
+
"x-initiator": opts?.agentInitiated ? "agent" : "user",
|
|
75
|
+
}
|
|
76
|
+
if (opts?.vision) headers["copilot-vision-request"] = "true"
|
|
77
|
+
return headers
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function githubRequestHeaders(githubToken: string): Record<string, string> {
|
|
81
|
+
return {
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
accept: "application/json",
|
|
84
|
+
authorization: `token ${githubToken}`,
|
|
85
|
+
"editor-version": `vscode/${EDITOR_VERSION}`,
|
|
86
|
+
"editor-plugin-version": `copilot-chat/${COPILOT_CHAT_VERSION}`,
|
|
87
|
+
"user-agent": `GitHubCopilotChat/${COPILOT_CHAT_VERSION}`,
|
|
88
|
+
"x-github-api-version": GITHUB_API_VERSION,
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// GitHub OAuth device flow. The resulting user token (gho_...) is long-lived
|
|
2
|
+
// and stored via config.ts; it is exchanged for short-lived Copilot tokens in
|
|
3
|
+
// token.ts.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
GITHUB_API_BASE_URL,
|
|
7
|
+
GITHUB_APP_SCOPES,
|
|
8
|
+
GITHUB_BASE_URL,
|
|
9
|
+
GITHUB_CLIENT_ID,
|
|
10
|
+
copilotFetch,
|
|
11
|
+
githubRequestHeaders,
|
|
12
|
+
isMockMode,
|
|
13
|
+
} from "./api"
|
|
14
|
+
import { loadAuth, saveAuth } from "./config"
|
|
15
|
+
|
|
16
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
17
|
+
|
|
18
|
+
export interface GithubIdentity {
|
|
19
|
+
token: string
|
|
20
|
+
login?: string
|
|
21
|
+
fresh: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureGithubToken(): Promise<GithubIdentity> {
|
|
25
|
+
// Mock mode (CLCO_UPSTREAM set): no GitHub involved at all.
|
|
26
|
+
if (isMockMode()) {
|
|
27
|
+
return { token: "mock", fresh: false }
|
|
28
|
+
}
|
|
29
|
+
const saved = await loadAuth()
|
|
30
|
+
if (saved?.github_token) {
|
|
31
|
+
return { token: saved.github_token, login: saved.login, fresh: false }
|
|
32
|
+
}
|
|
33
|
+
const { token, login } = await runDeviceFlow()
|
|
34
|
+
return { token, login, fresh: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runDeviceFlow(): Promise<{ token: string; login?: string }> {
|
|
38
|
+
const res = await copilotFetch(`${GITHUB_BASE_URL}/login/device/code`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
client_id: GITHUB_CLIENT_ID,
|
|
43
|
+
scope: GITHUB_APP_SCOPES,
|
|
44
|
+
}),
|
|
45
|
+
signal: AbortSignal.timeout(15_000),
|
|
46
|
+
})
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new Error(`device code request failed: HTTP ${res.status}`)
|
|
49
|
+
}
|
|
50
|
+
const dc = (await res.json()) as {
|
|
51
|
+
device_code: string
|
|
52
|
+
user_code: string
|
|
53
|
+
verification_uri: string
|
|
54
|
+
expires_in: number
|
|
55
|
+
interval: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log("\nGitHub authentication required:")
|
|
59
|
+
console.log(` 1. Open in your browser: ${dc.verification_uri}`)
|
|
60
|
+
console.log(` 2. Enter the code: \x1b[1m${dc.user_code}\x1b[0m\n`)
|
|
61
|
+
|
|
62
|
+
if (process.platform === "darwin") {
|
|
63
|
+
const open = Bun.spawn(["open", dc.verification_uri], {
|
|
64
|
+
stdout: "ignore",
|
|
65
|
+
stderr: "ignore",
|
|
66
|
+
})
|
|
67
|
+
await open.exited.catch(() => {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let interval = (dc.interval + 1) * 1000
|
|
71
|
+
const deadline = Date.now() + dc.expires_in * 1000
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
await sleep(interval)
|
|
74
|
+
let body: {
|
|
75
|
+
access_token?: string
|
|
76
|
+
error?: string
|
|
77
|
+
error_description?: string
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const poll = await copilotFetch(`${GITHUB_BASE_URL}/login/oauth/access_token`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
accept: "application/json",
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
client_id: GITHUB_CLIENT_ID,
|
|
88
|
+
device_code: dc.device_code,
|
|
89
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
90
|
+
}),
|
|
91
|
+
signal: AbortSignal.timeout(15_000),
|
|
92
|
+
})
|
|
93
|
+
if (!poll.ok) {
|
|
94
|
+
// 4xx won't heal by waiting; 5xx might.
|
|
95
|
+
if (poll.status >= 400 && poll.status < 500) {
|
|
96
|
+
throw new Error(`device flow polling failed: HTTP ${poll.status}`)
|
|
97
|
+
}
|
|
98
|
+
console.error(`[clco] polling error HTTP ${poll.status} - retrying`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
body = (await poll.json()) as typeof body
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err instanceof Error && err.message.startsWith("device flow")) throw err
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (body.access_token) {
|
|
108
|
+
const token = body.access_token
|
|
109
|
+
let login: string | undefined
|
|
110
|
+
try {
|
|
111
|
+
const user = await copilotFetch(`${GITHUB_API_BASE_URL}/user`, {
|
|
112
|
+
headers: githubRequestHeaders(token),
|
|
113
|
+
})
|
|
114
|
+
if (user.ok) login = ((await user.json()) as { login?: string }).login
|
|
115
|
+
} catch {
|
|
116
|
+
// login display is best-effort
|
|
117
|
+
}
|
|
118
|
+
await saveAuth({ github_token: token, login })
|
|
119
|
+
return { token, login }
|
|
120
|
+
}
|
|
121
|
+
if (body.error === "slow_down") {
|
|
122
|
+
interval += 5000
|
|
123
|
+
} else if (body.error && body.error !== "authorization_pending") {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`device flow failed: ${body.error} ${body.error_description ?? ""}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new Error("device flow timed out")
|
|
130
|
+
}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The one place that decides what an Anthropic content block means.
|
|
2
|
+
//
|
|
3
|
+
// It used to be twenty `block.type === "..."` comparisons spread across
|
|
4
|
+
// translate.ts and responses.ts, each an if/else-if chain with no final else.
|
|
5
|
+
// A block type none of them listed therefore contributed nothing and said
|
|
6
|
+
// nothing: a PDF attachment (`document`) was dropped on the floor and the
|
|
7
|
+
// model answered confidently about a file it had never seen. Nothing in the
|
|
8
|
+
// type system could catch that, because every block arrives through
|
|
9
|
+
// `JSON.parse` and Claude Code adds block types on its own schedule.
|
|
10
|
+
//
|
|
11
|
+
// So the knowledge lives here, once, and the dispatch is exhaustive: adding a
|
|
12
|
+
// type to KNOWN_BLOCKS without handling it fails `tsc`, and anything NOT in
|
|
13
|
+
// KNOWN_BLOCKS becomes a visible placeholder rather than silence. The dialects
|
|
14
|
+
// then only decide how to render a classified block, never what one is.
|
|
15
|
+
|
|
16
|
+
export interface TextBlock {
|
|
17
|
+
type: "text"
|
|
18
|
+
text: string
|
|
19
|
+
/** Copilot's prompt-cache marker rides on the block Claude Code sends. */
|
|
20
|
+
cache_control?: { type: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ThinkingBlock {
|
|
24
|
+
type: "thinking"
|
|
25
|
+
thinking: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Thinking the upstream redacted; it carries no readable text. */
|
|
29
|
+
export interface RedactedThinkingBlock {
|
|
30
|
+
type: "redacted_thinking"
|
|
31
|
+
data: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ImageBlock {
|
|
35
|
+
type: "image"
|
|
36
|
+
source: { type: string; media_type: string; data: string }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A file attachment. No translated dialect has anywhere to put one. */
|
|
40
|
+
export interface DocumentBlock {
|
|
41
|
+
type: "document"
|
|
42
|
+
source: { type: string; media_type?: string; data?: string; url?: string }
|
|
43
|
+
title?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ToolUseBlock {
|
|
47
|
+
type: "tool_use"
|
|
48
|
+
id: string
|
|
49
|
+
name: string
|
|
50
|
+
input: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ToolResultBlock {
|
|
54
|
+
type: "tool_result"
|
|
55
|
+
tool_use_id: string
|
|
56
|
+
is_error?: boolean
|
|
57
|
+
content?: string | Array<TextBlock | ImageBlock | ThinkingBlock>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ContentBlock =
|
|
61
|
+
| TextBlock
|
|
62
|
+
| ThinkingBlock
|
|
63
|
+
| RedactedThinkingBlock
|
|
64
|
+
| ImageBlock
|
|
65
|
+
| DocumentBlock
|
|
66
|
+
| ToolUseBlock
|
|
67
|
+
| ToolResultBlock
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every type this file knows how to classify. The exhaustive switch below is
|
|
71
|
+
* checked against it, so extending this list without extending the switch is
|
|
72
|
+
* a compile error - which is the whole point of the file.
|
|
73
|
+
*/
|
|
74
|
+
export const KNOWN_BLOCKS = [
|
|
75
|
+
"text",
|
|
76
|
+
"thinking",
|
|
77
|
+
"redacted_thinking",
|
|
78
|
+
"image",
|
|
79
|
+
"document",
|
|
80
|
+
"tool_use",
|
|
81
|
+
"tool_result",
|
|
82
|
+
] as const
|
|
83
|
+
|
|
84
|
+
export type KnownBlockType = (typeof KNOWN_BLOCKS)[number]
|
|
85
|
+
|
|
86
|
+
const KNOWN = new Set<string>(KNOWN_BLOCKS)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A block as it arrives: off the wire via JSON.parse, so nothing about it is
|
|
90
|
+
* guaranteed. Taking `unknown` rather than a shaped type is deliberate - the
|
|
91
|
+
* whole failure this file fixes was code that trusted a shape the wire never
|
|
92
|
+
* promised.
|
|
93
|
+
*/
|
|
94
|
+
export type RawBlock = unknown
|
|
95
|
+
|
|
96
|
+
function field(block: unknown, key: string): unknown {
|
|
97
|
+
return typeof block === "object" && block !== null
|
|
98
|
+
? (block as Record<string, unknown>)[key]
|
|
99
|
+
: undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a dialect has to render. `unsupported` is never silence. */
|
|
103
|
+
export type Classified =
|
|
104
|
+
| { kind: "text"; text: string }
|
|
105
|
+
| { kind: "image"; block: ImageBlock }
|
|
106
|
+
| { kind: "tool_use"; block: ToolUseBlock }
|
|
107
|
+
| { kind: "tool_result"; block: ToolResultBlock }
|
|
108
|
+
/**
|
|
109
|
+
* Carried forward as text so the model is told something was there. A
|
|
110
|
+
* dropped block changes the answer; a named placeholder only shortens it,
|
|
111
|
+
* and the user can see which it was.
|
|
112
|
+
*/
|
|
113
|
+
| { kind: "unsupported"; blockType: string; text: string }
|
|
114
|
+
|
|
115
|
+
/** Wire strings are not guaranteed to be strings. */
|
|
116
|
+
function str(value: unknown): string {
|
|
117
|
+
return typeof value === "string" ? value : ""
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assertNever(value: never): never {
|
|
121
|
+
throw new Error(`unhandled content block: ${JSON.stringify(value)}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function placeholder(blockType: string, detail?: string): Classified {
|
|
125
|
+
return {
|
|
126
|
+
kind: "unsupported",
|
|
127
|
+
blockType,
|
|
128
|
+
text: `[clco: a ${blockType} block was not forwarded - this model's dialect has no equivalent${
|
|
129
|
+
detail ? `, ${detail}` : ""
|
|
130
|
+
}]`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Classify one block. Unknown types and known-but-unforwardable types both
|
|
136
|
+
* come back as `unsupported`, distinguishable by `blockType`.
|
|
137
|
+
*/
|
|
138
|
+
export function classifyBlock(block: RawBlock): Classified {
|
|
139
|
+
const raw = field(block, "type")
|
|
140
|
+
const type = typeof raw === "string" ? raw : "(missing type)"
|
|
141
|
+
if (!KNOWN.has(type)) return placeholder(type)
|
|
142
|
+
return classifyKnown(block, type as KnownBlockType)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `block` stays raw here rather than being asserted to ContentBlock: it came
|
|
146
|
+
// off the wire, so a field the interface calls required may simply be absent,
|
|
147
|
+
// and each case narrows only what it actually reads.
|
|
148
|
+
function classifyKnown(block: unknown, type: KnownBlockType): Classified {
|
|
149
|
+
switch (type) {
|
|
150
|
+
case "text":
|
|
151
|
+
return { kind: "text", text: str(field(block, "text")) }
|
|
152
|
+
// Reasoning text is ordinary text to a dialect that has no thinking
|
|
153
|
+
// channel; dropping it loses the assistant's own stated reasoning from
|
|
154
|
+
// the transcript it is about to continue.
|
|
155
|
+
case "thinking":
|
|
156
|
+
return { kind: "text", text: str(field(block, "thinking")) }
|
|
157
|
+
// Nothing readable to forward, and it is not a loss worth narrating on
|
|
158
|
+
// every turn - the upstream redacted it, not clco.
|
|
159
|
+
case "redacted_thinking":
|
|
160
|
+
return { kind: "text", text: "" }
|
|
161
|
+
case "image":
|
|
162
|
+
return { kind: "image", block: block as unknown as ImageBlock }
|
|
163
|
+
case "document": {
|
|
164
|
+
const title = str(field(block, "title"))
|
|
165
|
+
return placeholder(
|
|
166
|
+
"document",
|
|
167
|
+
title ? `titled ${JSON.stringify(title)}` : "no text extracted",
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
case "tool_use":
|
|
171
|
+
return { kind: "tool_use", block: block as unknown as ToolUseBlock }
|
|
172
|
+
case "tool_result":
|
|
173
|
+
return { kind: "tool_result", block: block as unknown as ToolResultBlock }
|
|
174
|
+
default:
|
|
175
|
+
return assertNever(type)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Classify a whole message body, with the string shorthand expanded. */
|
|
180
|
+
export function classifyContent(content: unknown): Classified[] {
|
|
181
|
+
if (content === undefined || content === null) return []
|
|
182
|
+
if (typeof content === "string") return [{ kind: "text", text: content }]
|
|
183
|
+
if (!Array.isArray(content)) return []
|
|
184
|
+
return content.map(classifyBlock)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The block types in a body that clco could not forward, for diagnostics. */
|
|
188
|
+
export function unsupportedTypes(blocks: Classified[]): string[] {
|
|
189
|
+
return [
|
|
190
|
+
...new Set(
|
|
191
|
+
blocks
|
|
192
|
+
.filter((b): b is Extract<Classified, { kind: "unsupported" }> =>
|
|
193
|
+
b.kind === "unsupported",
|
|
194
|
+
)
|
|
195
|
+
.map((b) => b.blockType),
|
|
196
|
+
),
|
|
197
|
+
]
|
|
198
|
+
}
|