opencode-ufr 0.2.9 → 0.2.10
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/README.md +55 -62
- package/package.json +14 -6
- package/skills/pdf2md/SKILL.md +32 -0
- package/src/client/gateway.ts +101 -0
- package/src/client/pdf2md.ts +747 -0
- package/src/daemon/catalog-source.ts +9 -0
- package/src/daemon/daemon.ts +70 -18
- package/src/daemon/keypool.ts +12 -7
- package/src/daemon/main.ts +7 -1
- package/src/daemon/probe-all.ts +60 -38
- package/src/daemon/router.ts +215 -17
- package/src/daemon/server.ts +33 -1
- package/src/daemon/stats.ts +13 -1
- package/src/daemon/upstream.ts +14 -3
- package/src/daemon/vpn/fortinet.ts +42 -29
- package/src/daemon/vpn/manager.ts +50 -16
- package/src/daemon/vpn/proxy.ts +3 -1
- package/src/plugin/connect.ts +4 -4
- package/src/plugin/ensure-daemon.ts +48 -19
- package/src/plugin/index.ts +121 -46
- package/src/plugin/tui.tsx +77 -0
- package/src/shared/config.ts +53 -4
- package/src/shared/connect.ts +3 -4
- package/src/shared/daemon-client.ts +29 -0
- package/src/shared/errors.ts +1 -1
- package/src/shared/fs.ts +8 -3
- package/src/shared/keys.ts +65 -0
- package/src/shared/models-file.ts +1 -1
- package/src/shared/secrets.ts +2 -6
- package/bin/ufr.ts +0 -4
- package/src/cli/catalog.ts +0 -44
- package/src/cli/connect.ts +0 -146
- package/src/cli/context-probe.ts +0 -96
- package/src/cli/daemon-client.ts +0 -19
- package/src/cli/index.ts +0 -105
- package/src/cli/io.ts +0 -98
- package/src/cli/keys.ts +0 -130
- package/src/cli/stats.ts +0 -37
- package/src/cli/status.ts +0 -48
- package/src/cli/stop.ts +0 -8
- package/src/cli/ufr-check.ts +0 -43
|
@@ -10,13 +10,11 @@ import { TcpStack } from "./stack"
|
|
|
10
10
|
import { startProxy, type ProxyHandle } from "./proxy"
|
|
11
11
|
import {
|
|
12
12
|
authenticate,
|
|
13
|
-
fetchTunnelConfig,
|
|
14
13
|
openTunnel,
|
|
15
14
|
VpnChallengeError,
|
|
16
15
|
type TunnelHandle,
|
|
17
16
|
} from "./fortinet"
|
|
18
17
|
import type { Transport } from "../transport"
|
|
19
|
-
import { isVpnPage } from "../../shared/vpn"
|
|
20
18
|
|
|
21
19
|
export type VpnState = {
|
|
22
20
|
mode: "off" | "connecting" | "up" | "failed"
|
|
@@ -78,8 +76,12 @@ export class VpnManager {
|
|
|
78
76
|
name: "vpn",
|
|
79
77
|
fetch: async (url, init) => {
|
|
80
78
|
const path = await untilAborted(this.ensurePath(), init?.signal)
|
|
81
|
-
if (path === "vpn"
|
|
82
|
-
|
|
79
|
+
if (path === "vpn") {
|
|
80
|
+
const proxy = this.proxy
|
|
81
|
+
// ensurePath answered "vpn" but teardown nulled the proxy meanwhile:
|
|
82
|
+
// fail fast instead of silently falling through to a direct fetch
|
|
83
|
+
if (!proxy) throw new Error("the vpn tunnel went down while the request was starting — retry")
|
|
84
|
+
return await fetch(url, { ...init, proxy: `http://127.0.0.1:${proxy.port}` } as RequestInit)
|
|
83
85
|
}
|
|
84
86
|
return await f(url, init)
|
|
85
87
|
},
|
|
@@ -115,7 +117,7 @@ export class VpnManager {
|
|
|
115
117
|
}
|
|
116
118
|
// 2. tunnel needed — do we have a login?
|
|
117
119
|
if (!this.o.credentials) {
|
|
118
|
-
this.setState("failed", "off campus and no uni login stored —
|
|
120
|
+
this.setState("failed", "off campus and no uni login stored — add the uni login in opencode's /connect (Uni Freiburg)")
|
|
119
121
|
return "failed"
|
|
120
122
|
}
|
|
121
123
|
// 3. bring the tunnel up
|
|
@@ -123,6 +125,7 @@ export class VpnManager {
|
|
|
123
125
|
await this.open()
|
|
124
126
|
return "vpn"
|
|
125
127
|
} catch (e) {
|
|
128
|
+
if (this.stopped) return "failed" // shutting down: don't overwrite the stopped state
|
|
126
129
|
const msg = (e as Error).message
|
|
127
130
|
this.setState("failed", msg)
|
|
128
131
|
this.scheduleReconnect(msg)
|
|
@@ -144,11 +147,7 @@ export class VpnManager {
|
|
|
144
147
|
this.directOk = true
|
|
145
148
|
return "direct"
|
|
146
149
|
}
|
|
147
|
-
|
|
148
|
-
const html = await res.text().catch(() => "")
|
|
149
|
-
this.directOk = false
|
|
150
|
-
return isVpnPage(html) ? "tunnel-needed" : "tunnel-needed"
|
|
151
|
-
}
|
|
150
|
+
// HTML or anything unexpected: the tunnel is the only path left
|
|
152
151
|
this.directOk = false
|
|
153
152
|
return "tunnel-needed"
|
|
154
153
|
} catch {
|
|
@@ -172,7 +171,8 @@ export class VpnManager {
|
|
|
172
171
|
}).catch((e) => {
|
|
173
172
|
throw signal.aborted ? new Error(`the uni VPN gateway did not answer within ${Math.ceil(loginMs / 1000)} s`) : e
|
|
174
173
|
})
|
|
175
|
-
|
|
174
|
+
// fresh per attempt: the DNS answer can change with the route
|
|
175
|
+
this.remoteIp = await resolveIp(this.o.upstreamHost)
|
|
176
176
|
|
|
177
177
|
this.setState("connecting", "negotiating PPP")
|
|
178
178
|
let sendIp: (datagram: Uint8Array) => void = () => {}
|
|
@@ -185,13 +185,23 @@ export class VpnManager {
|
|
|
185
185
|
sendIp = (datagram) => ppp.sendIp(datagram)
|
|
186
186
|
},
|
|
187
187
|
onEstablished: (cfg) => {
|
|
188
|
+
if (this.stopped) return // stop() mid-connect: the tunnel is closed below; don't start the proxy
|
|
189
|
+
// parseTunnelConfig validates innerIp; this is a non-throwing assertion so a
|
|
190
|
+
// malformed value can never surface as a "tunnel framing error"
|
|
191
|
+
let local: Ipv4
|
|
192
|
+
try {
|
|
193
|
+
local = ipv4Parse(cfg.innerIp)
|
|
194
|
+
} catch {
|
|
195
|
+
this.o.log(`tunnel established with an invalid inner ip ("${cfg.innerIp}") — ignored`)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
188
198
|
if (!stackRef) {
|
|
189
|
-
stackRef = new TcpStack({ local
|
|
199
|
+
stackRef = new TcpStack({ local, sink: (pkt) => sendIp(pkt), opts: { mss: Math.min(cfg.mru - 40, 1360) } }, this.o.log)
|
|
190
200
|
this.stack = stackRef
|
|
191
201
|
this.proxy = startProxy({ stack: stackRef, remoteIp: this.remoteIp!, log: this.o.log })
|
|
192
202
|
this.o.log(`proxy listening on 127.0.0.1:${this.proxy.port}`)
|
|
193
203
|
} else {
|
|
194
|
-
stackRef.setLocal(
|
|
204
|
+
stackRef.setLocal(local)
|
|
195
205
|
}
|
|
196
206
|
this.reconnectAttempt = 0
|
|
197
207
|
this.setState("up", `tunnel up (inner ip ${cfg.innerIp})`, cfg.innerIp, this.proxy?.port)
|
|
@@ -204,11 +214,21 @@ export class VpnManager {
|
|
|
204
214
|
},
|
|
205
215
|
onIp: (datagram) => stackRef?.receive(datagram),
|
|
206
216
|
})
|
|
217
|
+
if (this.stopped) {
|
|
218
|
+
// stop() ran while the connect was in flight: the tunnel came up after the
|
|
219
|
+
// teardown — close it instead of leaking a live session
|
|
220
|
+
await tunnel.close()
|
|
221
|
+
throw new Error("stopped")
|
|
222
|
+
}
|
|
207
223
|
this.tunnel = tunnel
|
|
208
224
|
|
|
209
225
|
// wait for the XML phase + PPP/ICCP negotiation to finish
|
|
210
226
|
const deadline = Date.now() + ESTABLISH_TIMEOUT_MS
|
|
211
227
|
while (this.state.mode !== "up") {
|
|
228
|
+
if (this.stopped) {
|
|
229
|
+
await tunnel.close()
|
|
230
|
+
throw new Error("stopped")
|
|
231
|
+
}
|
|
212
232
|
if (Date.now() > deadline) {
|
|
213
233
|
await tunnel.close()
|
|
214
234
|
throw new Error("the tunnel did not come up within 30 s")
|
|
@@ -271,8 +291,22 @@ function untilAborted<T>(p: Promise<T>, signal?: AbortSignal | null): Promise<T>
|
|
|
271
291
|
})
|
|
272
292
|
}
|
|
273
293
|
|
|
274
|
-
|
|
294
|
+
const RESOLVE_TIMEOUT_MS = 10_000
|
|
295
|
+
|
|
296
|
+
/** Resolve the upstream host to an IPv4, with a deadline (a hung resolver must not stall the connect forever). */
|
|
297
|
+
async function resolveIp(host: string, timeoutMs = RESOLVE_TIMEOUT_MS): Promise<Ipv4> {
|
|
275
298
|
const { lookup } = await import("node:dns/promises")
|
|
276
|
-
|
|
277
|
-
|
|
299
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
300
|
+
try {
|
|
301
|
+
const res = await Promise.race([
|
|
302
|
+
lookup(host, { family: 4 }),
|
|
303
|
+
new Promise<never>((_, reject) => {
|
|
304
|
+
timer = setTimeout(() => reject(new Error(`DNS lookup for ${host} did not answer within ${Math.ceil(timeoutMs / 1000)} s`)), timeoutMs)
|
|
305
|
+
timer.unref?.()
|
|
306
|
+
}),
|
|
307
|
+
])
|
|
308
|
+
return ipv4Parse(res.address)
|
|
309
|
+
} finally {
|
|
310
|
+
if (timer) clearTimeout(timer)
|
|
311
|
+
}
|
|
278
312
|
}
|
package/src/daemon/vpn/proxy.ts
CHANGED
|
@@ -57,7 +57,9 @@ export function startProxy(o: {
|
|
|
57
57
|
return
|
|
58
58
|
}
|
|
59
59
|
const port = Number(m[2])
|
|
60
|
-
|
|
60
|
+
const host = m[1]!.toLowerCase()
|
|
61
|
+
// exact match or a subdomain — a bare endsWith would let evil-uni-freiburg.de through
|
|
62
|
+
if (!allowedPorts.includes(port) || (host !== suffix && !host.endsWith(`.${suffix}`))) {
|
|
61
63
|
// the tunnel only carries traffic to the uni host
|
|
62
64
|
log(`proxy: refusing CONNECT ${m[1]}:${port}`)
|
|
63
65
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
|
package/src/plugin/connect.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* The /connect integration: opencode's built-in provider-connect panel lists
|
|
3
3
|
* the "unifreiburg" integration; this plugin replaces its key method with one
|
|
4
4
|
* that shows the optional uni-login form (login + password) alongside the API
|
|
5
|
-
* key
|
|
5
|
+
* key. When the user submits, opencode stores a Credential with the form
|
|
6
6
|
* answers; this module watches for it and applies it to the keyring + config,
|
|
7
7
|
* then restarts the gateway so the keys go live.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { applyConnect, splitKeys } from "../shared/connect"
|
|
11
11
|
import type { Paths } from "../shared/paths"
|
|
12
|
-
import { applyKeyChange, disconnectAll } from "../
|
|
12
|
+
import { applyKeyChange, disconnectAll } from "../shared/keys"
|
|
13
13
|
|
|
14
14
|
export const UFR_INTEGRATION_ID = "unifreiburg"
|
|
15
15
|
|
|
@@ -56,14 +56,14 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Replace the key method of the unifreiburg integration with ours: the
|
|
59
|
-
// standard API-key
|
|
59
|
+
// standard API-key prompt plus the optional uni-login form.
|
|
60
60
|
await ctx.integration.transform((editor: any) => {
|
|
61
61
|
if (typeof editor?.method?.update !== "function") return
|
|
62
62
|
editor.method.update({
|
|
63
63
|
integrationID: UFR_INTEGRATION_ID,
|
|
64
64
|
method: {
|
|
65
65
|
type: "key",
|
|
66
|
-
label: "API
|
|
66
|
+
label: "API key",
|
|
67
67
|
form: [
|
|
68
68
|
{
|
|
69
69
|
key: "login",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
2
|
import { mkdirSync } from "node:fs"
|
|
3
|
-
import {
|
|
3
|
+
import { readText } from "../shared/fs"
|
|
4
|
+
import { readDaemonInfo, type DaemonInfo } from "../shared/daemon-client"
|
|
4
5
|
import type { Paths } from "../shared/paths"
|
|
5
6
|
|
|
6
|
-
export type
|
|
7
|
+
export type { DaemonInfo }
|
|
7
8
|
export type Conn = { port: number; token: string }
|
|
8
9
|
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
|
|
9
10
|
|
|
@@ -18,11 +19,6 @@ export function isNewer(a: string, b: string): boolean {
|
|
|
18
19
|
return false
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export async function readDaemonInfo(paths: Paths): Promise<DaemonInfo | null> {
|
|
22
|
-
const j = (await readJson(paths.daemonFile)) as Partial<DaemonInfo> | null
|
|
23
|
-
return j && typeof j.port === "number" && typeof j.version === "string" ? { port: j.port, pid: j.pid ?? 0, version: j.version } : null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
async function readToken(paths: Paths): Promise<string | null> {
|
|
27
23
|
return (await readText(paths.tokenFile))?.trim() || null
|
|
28
24
|
}
|
|
@@ -31,7 +27,7 @@ async function readToken(paths: Paths): Promise<string | null> {
|
|
|
31
27
|
export async function ensureDaemon(o: {
|
|
32
28
|
paths: Paths
|
|
33
29
|
version: string
|
|
34
|
-
spawn: () => void
|
|
30
|
+
spawn: (onError: (e: Error) => void) => void
|
|
35
31
|
fetch?: FetchLike
|
|
36
32
|
sleep?: (ms: number) => Promise<void>
|
|
37
33
|
timeoutMs?: number
|
|
@@ -40,10 +36,13 @@ export async function ensureDaemon(o: {
|
|
|
40
36
|
const f: FetchLike = o.fetch ?? ((u, i) => fetch(u, i))
|
|
41
37
|
const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms))
|
|
42
38
|
const now = o.now ?? Date.now
|
|
43
|
-
const health = async (port: number) => {
|
|
39
|
+
const health = async (port: number): Promise<{ version: string } | null> => {
|
|
44
40
|
try {
|
|
45
41
|
const r = await f(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) })
|
|
46
|
-
|
|
42
|
+
if (!r.ok) return null
|
|
43
|
+
const h = (await r.json()) as { version?: unknown }
|
|
44
|
+
// Only reuse a daemon that identifies itself — a 200 without a version could be anything on that port.
|
|
45
|
+
return typeof h.version === "string" ? { version: h.version } : null
|
|
47
46
|
} catch {
|
|
48
47
|
return null
|
|
49
48
|
}
|
|
@@ -59,27 +58,56 @@ export async function ensureDaemon(o: {
|
|
|
59
58
|
const h = await health(info.port)
|
|
60
59
|
const token = await readToken(o.paths)
|
|
61
60
|
if (h && token) {
|
|
62
|
-
if (!
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
if (!isNewer(o.version, h.version)) return { port: info.port, token }
|
|
62
|
+
const res = await f(`http://127.0.0.1:${info.port}/v1/_status`, authed(token)).catch(() => null)
|
|
63
|
+
if (res !== null && !res.ok && (res.status === 401 || res.status === 403)) {
|
|
64
|
+
// The stored token is rejected — handing the conn back would only serve
|
|
65
|
+
// 401s, and this daemon can be neither stopped nor replaced without a
|
|
66
|
+
// valid token. Fail with the reason instead of pretending success.
|
|
67
|
+
throw new Error(
|
|
68
|
+
"the running opencode-ufr gateway rejects the stored token — restart it or remove the opencode-ufr state directory",
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
// Any other non-ok answer (a 503 while the daemon restarts, say) is not a
|
|
72
|
+
// token rejection: it lands in the couldn't-verify path below.
|
|
73
|
+
const st = res !== null ? ((await res.json().catch(() => null)) as { inFlight?: unknown } | null) : null
|
|
74
|
+
const inFlight = st !== null && typeof st.inFlight === "number" ? st.inFlight : null
|
|
75
|
+
if (inFlight === null) {
|
|
76
|
+
// Couldn't verify (transport hiccup on the probe, or a non-ok _status
|
|
77
|
+
// above) — not confirmed idle: fall through to the spawn path below
|
|
78
|
+
// instead of trusting the guess. Spawning while the old daemon still
|
|
79
|
+
// holds the lock is safe: the child cannot take over and exits quietly
|
|
80
|
+
// (no error event — only a failed exec fires one), and the health poll
|
|
81
|
+
// below keeps answering with the running daemon's conn either way.
|
|
82
|
+
} else if (inFlight > 0) {
|
|
83
|
+
return { port: info.port, token } // upgrade later, never mid-request
|
|
84
|
+
} else {
|
|
85
|
+
await f(`http://127.0.0.1:${info.port}/v1/_shutdown`, authed(token, "POST")).catch(() => null)
|
|
86
|
+
for (let i = 0; i < 25 && (await health(info.port)); i++) await sleep(200)
|
|
87
|
+
}
|
|
69
88
|
}
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
|
|
91
|
+
const spawnError: { message: string | null } = { message: null }
|
|
92
|
+
o.spawn((e) => {
|
|
93
|
+
spawnError.message = e.message
|
|
94
|
+
})
|
|
73
95
|
// Startup can take up to ~35 s: two catalog fetches (models.json, UFR /api/models)
|
|
74
96
|
// with 15 s and 20 s timeouts before /health answers.
|
|
75
97
|
const timeoutMs = o.timeoutMs ?? 40_000
|
|
76
98
|
const deadline = now() + timeoutMs
|
|
77
99
|
while (now() < deadline) {
|
|
100
|
+
if (spawnError.message !== null) {
|
|
101
|
+
throw new Error(`the opencode-ufr gateway failed to start (${spawnError.message}) — see daemon.log`)
|
|
102
|
+
}
|
|
78
103
|
await sleep(200)
|
|
79
104
|
const next = await readDaemonInfo(o.paths)
|
|
80
105
|
const token = await readToken(o.paths)
|
|
81
106
|
if (next && token && (await health(next.port))) return { port: next.port, token }
|
|
82
107
|
}
|
|
108
|
+
if (spawnError.message !== null) {
|
|
109
|
+
throw new Error(`the opencode-ufr gateway failed to start (${spawnError.message}) — see daemon.log`)
|
|
110
|
+
}
|
|
83
111
|
throw new Error(`the opencode-ufr gateway did not start within ${Math.round(timeoutMs / 1000)} s — see daemon.log`)
|
|
84
112
|
}
|
|
85
113
|
|
|
@@ -88,7 +116,7 @@ export async function ensureDaemon(o: {
|
|
|
88
116
|
* It runs in `cwd` (the state directory), never in the spawning project: Bun auto-loads
|
|
89
117
|
* .env / bunfig.toml from the working directory, and on Windows the folder would stay locked.
|
|
90
118
|
*/
|
|
91
|
-
export function spawnDaemon(entry: string, cwd: string): void {
|
|
119
|
+
export function spawnDaemon(entry: string, cwd: string, onError?: (e: Error) => void): void {
|
|
92
120
|
mkdirSync(cwd, { recursive: true })
|
|
93
121
|
const child = spawn(process.execPath, [entry], {
|
|
94
122
|
cwd,
|
|
@@ -97,5 +125,6 @@ export function spawnDaemon(entry: string, cwd: string): void {
|
|
|
97
125
|
windowsHide: true,
|
|
98
126
|
env: { ...process.env, BUN_BE_BUN: "1" },
|
|
99
127
|
})
|
|
128
|
+
if (onError) child.on("error", (e) => onError(e instanceof Error ? e : new Error(String(e))))
|
|
100
129
|
child.unref()
|
|
101
130
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url"
|
|
2
2
|
import type { OpencodeStamp } from "../daemon/catalog"
|
|
3
|
-
import { daemonRequest } from "../
|
|
3
|
+
import { daemonRequest } from "../shared/daemon-client"
|
|
4
|
+
import { disconnectAll } from "../shared/keys"
|
|
4
5
|
import { type Paths, resolvePaths } from "../shared/paths"
|
|
5
|
-
import { KeyringStore } from "../shared/secrets"
|
|
6
|
+
import { KeyringStore, type SecretStore } from "../shared/secrets"
|
|
6
7
|
import { VERSION } from "../shared/version"
|
|
7
8
|
import { type Conn, ensureDaemon, spawnDaemon } from "./ensure-daemon"
|
|
8
9
|
import { registerConnect, UFR_INTEGRATION_ID } from "./connect"
|
|
9
10
|
import { toModelInfo } from "./model-info"
|
|
10
11
|
|
|
11
12
|
const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
|
|
13
|
+
const PDF2MD_SCRIPT = fileURLToPath(new URL("../client/pdf2md.ts", import.meta.url))
|
|
14
|
+
const PDF2MD_SKILL = fileURLToPath(new URL("../../skills/pdf2md/SKILL.md", import.meta.url))
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Registers the pdf2md skill with opencode (V2 plugins can add skills via a
|
|
18
|
+
* transform). The SKILL.md ships with the package; the script path is baked in
|
|
19
|
+
* at registration time because it depends on where npm cached the package.
|
|
20
|
+
*/
|
|
21
|
+
async function registerSkill(ctx: any): Promise<void> {
|
|
22
|
+
if (typeof ctx?.skill?.transform !== "function") return // older opencode — no skills
|
|
23
|
+
try {
|
|
24
|
+
const raw = await Bun.file(PDF2MD_SKILL).text()
|
|
25
|
+
const content = raw.replace("{{PDF2MD_SCRIPT}}", PDF2MD_SCRIPT)
|
|
26
|
+
await ctx.skill.transform((editor: any) => {
|
|
27
|
+
editor.add({
|
|
28
|
+
id: "ufr-pdf2md",
|
|
29
|
+
name: "PDF to Markdown",
|
|
30
|
+
description:
|
|
31
|
+
"Convert a PDF to clean Markdown using UFR vision models — OCR with tables, math, circuit diagrams, refinement and cross-page table merging, all rate-limit-safe through the local gateway.",
|
|
32
|
+
path: PDF2MD_SKILL,
|
|
33
|
+
content,
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
log("registered skill ufr-pdf2md")
|
|
37
|
+
} catch (e) {
|
|
38
|
+
log(`skill registration failed: ${(e as Error).message}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
12
41
|
|
|
13
42
|
// opencode takes a request's key from the provider's integration (default: the one with the provider's
|
|
14
43
|
// id), and a key credential there replaces settings.apiKey. The /connect credential on "unifreiburg"
|
|
@@ -20,7 +49,11 @@ function log(msg: string): void {
|
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
async function connect(paths: Paths): Promise<Conn> {
|
|
23
|
-
return ensureDaemon({
|
|
52
|
+
return ensureDaemon({
|
|
53
|
+
paths,
|
|
54
|
+
version: VERSION,
|
|
55
|
+
spawn: (onError) => spawnDaemon(DAEMON_ENTRY, paths.stateDir, onError),
|
|
56
|
+
})
|
|
24
57
|
}
|
|
25
58
|
|
|
26
59
|
/** Whether this opencode exposes the integration API (needed to see connection state). */
|
|
@@ -39,18 +72,22 @@ async function registerProvider(
|
|
|
39
72
|
try {
|
|
40
73
|
conn = await connect(paths)
|
|
41
74
|
} catch (e) {
|
|
42
|
-
log(`${(e as Error).message} —
|
|
75
|
+
log(`${(e as Error).message} — see daemon.log in the opencode-ufr state directory`)
|
|
43
76
|
return false
|
|
44
77
|
}
|
|
45
78
|
const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
|
|
46
79
|
headers: { Authorization: `Bearer ${conn.token}` },
|
|
47
80
|
signal: AbortSignal.timeout(10_000),
|
|
48
81
|
})
|
|
49
|
-
.then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data :
|
|
50
|
-
.catch(() =>
|
|
82
|
+
.then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : null))
|
|
83
|
+
.catch(() => null)
|
|
84
|
+
if (list === null) {
|
|
85
|
+
log("gateway unreachable — retrying")
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
51
88
|
if (list.length === 0) {
|
|
52
89
|
// Never register an empty provider: it breaks the whole model picker.
|
|
53
|
-
log("no models available —
|
|
90
|
+
log("no models available — connect to the uni VPN off campus, then restart opencode")
|
|
54
91
|
return false
|
|
55
92
|
}
|
|
56
93
|
if (typeof ctx?.provider?.transform !== "function") {
|
|
@@ -92,50 +129,88 @@ export async function heartbeat(ctx: any, paths: Paths): Promise<void> {
|
|
|
92
129
|
if (!ok) await connect(paths).catch((e) => log(`gateway restart failed: ${(e as Error).message}`))
|
|
93
130
|
}
|
|
94
131
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The plugin entry point. `inject.secrets` exists for tests only — production
|
|
134
|
+
* always uses the OS keyring.
|
|
135
|
+
*/
|
|
136
|
+
export async function setupPlugin(ctx: any, inject: { secrets?: SecretStore } = {}): Promise<() => void> {
|
|
137
|
+
const options = (ctx?.options ?? {}) as Record<string, unknown>
|
|
138
|
+
const providerId = typeof options.providerId === "string" ? options.providerId : "unifreiburg"
|
|
139
|
+
const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
|
|
140
|
+
const paths = resolvePaths()
|
|
141
|
+
const secrets = inject.secrets ?? new KeyringStore()
|
|
102
142
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
143
|
+
let registered = false
|
|
144
|
+
const registerProviderFn = async (): Promise<boolean> => {
|
|
145
|
+
if (registered) return true
|
|
146
|
+
registered = await registerProvider(ctx, providerId, providerName, paths)
|
|
147
|
+
return registered
|
|
148
|
+
}
|
|
109
149
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
150
|
+
// /connect integration first: it is the only way to connect when no
|
|
151
|
+
// provider is registered yet, so it must exist even while disconnected.
|
|
152
|
+
const stopConnect = await registerConnect({
|
|
153
|
+
ctx,
|
|
154
|
+
paths,
|
|
155
|
+
secrets,
|
|
156
|
+
log,
|
|
157
|
+
register: registerProviderFn,
|
|
158
|
+
providerRegistered: false,
|
|
159
|
+
}).catch((e) => {
|
|
160
|
+
log(`/connect integration failed: ${(e as Error).message}`)
|
|
161
|
+
return { stop: () => {}, applyNow: async () => false }
|
|
162
|
+
})
|
|
123
163
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
// The provider is registered only while the integration is connected:
|
|
165
|
+
// removing it in opencode must remove the models too.
|
|
166
|
+
if (!hasIntegration(ctx)) {
|
|
167
|
+
await registerProviderFn() // old opencode without the integration API — register as before
|
|
168
|
+
} else {
|
|
169
|
+
const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
|
|
170
|
+
if (connection) {
|
|
171
|
+
// Registering can block ~80 s (daemon boot + watcher retries) — never
|
|
172
|
+
// stall opencode startup: run the first registration in the background;
|
|
173
|
+
// the /connect watcher (polling every few seconds) completes it if needed.
|
|
174
|
+
log("unifreiburg connected — registering the provider in the background")
|
|
175
|
+
void registerProviderFn().catch((e) => log(`provider registration failed: ${(e as Error).message}`))
|
|
128
176
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
177
|
+
// A removal while opencode was closed leaves the keyring untouched (the
|
|
178
|
+
// watcher only sees the transition while opencode runs) — wipe it here.
|
|
179
|
+
// A failing loadConfig (a broken config.json) must not reject setupPlugin:
|
|
180
|
+
// the plugin still has to load so /connect can repair the setup.
|
|
181
|
+
const r = await disconnectAll({ paths, secrets, fetch: (u, i) => fetch(u, i), log }).catch((e) => {
|
|
182
|
+
log(`cleanup of the removed connection failed: ${(e as Error).message}`)
|
|
183
|
+
return { removed: [], loginRemoved: false }
|
|
184
|
+
})
|
|
185
|
+
if (r.removed.length > 0 || r.loginRemoved) {
|
|
186
|
+
log(`unifreiburg was removed in opencode — wiped what was left: ${r.removed.join(", ")}${r.loginRemoved ? " + uni login" : ""}`)
|
|
187
|
+
}
|
|
188
|
+
log(`${providerId} not connected — open /connect in opencode to set it up`)
|
|
132
189
|
}
|
|
190
|
+
}
|
|
133
191
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
// The pdf2md skill is independent of the connection state — register it always.
|
|
193
|
+
await registerSkill(ctx)
|
|
194
|
+
|
|
195
|
+
// One heartbeat at a time: a slow one (daemon boot) must not pile up or race the /connect watcher.
|
|
196
|
+
let beating = false
|
|
197
|
+
const beat = setInterval(() => {
|
|
198
|
+
if (beating) return
|
|
199
|
+
beating = true
|
|
200
|
+
void heartbeat(ctx, paths).finally(() => {
|
|
201
|
+
beating = false
|
|
202
|
+
})
|
|
203
|
+
}, 60_000)
|
|
204
|
+
;(beat as { unref?: () => void }).unref?.()
|
|
205
|
+
return () => {
|
|
206
|
+
clearInterval(beat)
|
|
207
|
+
stopConnect.stop()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default {
|
|
212
|
+
id: "opencode-ufr",
|
|
213
|
+
async setup(ctx: any) {
|
|
214
|
+
return setupPlugin(ctx)
|
|
140
215
|
},
|
|
141
216
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI contribution: live gateway throughput in opencode's sidebar. Loaded by
|
|
3
|
+
* opencode's CLI because package.json exports "./tui" — the server plugin
|
|
4
|
+
* (index.ts) needs no changes for this to load.
|
|
5
|
+
*
|
|
6
|
+
* Polls GET /v1/_status every second and appends one line to the right
|
|
7
|
+
* panel: req/s and tok/s over the gateway's rolling 60 s window. Status
|
|
8
|
+
* polls don't reset the daemon's idle timer (see server.ts), so leaving the
|
|
9
|
+
* panel open never keeps the gateway alive.
|
|
10
|
+
*/
|
|
11
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
12
|
+
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
|
13
|
+
import { daemonRequest } from "../shared/daemon-client"
|
|
14
|
+
import { resolvePaths, type Paths } from "../shared/paths"
|
|
15
|
+
|
|
16
|
+
/** The rates block of GET /v1/_status (see StatusJson in daemon.ts). */
|
|
17
|
+
type Rates = {
|
|
18
|
+
windowMs: number
|
|
19
|
+
requests: number
|
|
20
|
+
reqPerSec: number
|
|
21
|
+
tokensInPerSec: number
|
|
22
|
+
tokensOutPerSec: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const POLL_MS = 1_000
|
|
26
|
+
|
|
27
|
+
/** 6743 → "6.7k" — prompt traffic through opencode dwarfs output. */
|
|
28
|
+
function fmtTokens(v: number): string {
|
|
29
|
+
return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function RatesLine(props: { paths: Paths }) {
|
|
33
|
+
const [rates, setRates] = createSignal<Rates | null>(null)
|
|
34
|
+
let busy = false
|
|
35
|
+
const poll = async () => {
|
|
36
|
+
if (busy) return
|
|
37
|
+
busy = true
|
|
38
|
+
try {
|
|
39
|
+
const res = await daemonRequest(props.paths, (u, i) => fetch(u, i), "/v1/_status")
|
|
40
|
+
if (!res?.ok) {
|
|
41
|
+
setRates(null) // daemon down, restarting or new token — the line hides
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const j = (await res.json()) as { rates?: Rates }
|
|
45
|
+
setRates(j.rates ?? null)
|
|
46
|
+
} finally {
|
|
47
|
+
busy = false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
51
|
+
onMount(() => {
|
|
52
|
+
void poll()
|
|
53
|
+
timer = setInterval(() => void poll(), POLL_MS)
|
|
54
|
+
})
|
|
55
|
+
onCleanup(() => clearInterval(timer))
|
|
56
|
+
const label = () => {
|
|
57
|
+
const r = rates()
|
|
58
|
+
if (!r) return null
|
|
59
|
+
return `UFR ${r.reqPerSec} req/s · ${fmtTokens(r.tokensOutPerSec)} tok/s out · ${fmtTokens(r.tokensInPerSec)} tok/s in`
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<Show when={label()}>
|
|
63
|
+
<text>{label()}</text>
|
|
64
|
+
</Show>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default Plugin.define({
|
|
69
|
+
id: "opencode-ufr.stats",
|
|
70
|
+
setup(context) {
|
|
71
|
+
const paths = resolvePaths()
|
|
72
|
+
return context.ui.slot({
|
|
73
|
+
append: "sidebar.content",
|
|
74
|
+
render: () => <RatesLine paths={paths} />,
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
})
|