@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,168 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { SmartRouterRpc } from "../rpc.js"
3
+ import type { DecisionView, ProviderRow } from "../gui/viewmodel.js"
4
+
5
+ /**
6
+ * The terminal side of the router.
7
+ *
8
+ * Same view-model as the desktop panel, different renderer. The `main` command
9
+ * opens a decision inspector as a dialog; the footer slot shows the current state
10
+ * at a glance. Nothing here reimplements routing logic — it formats the same
11
+ * pure functions the panel uses, which is what keeps the two from disagreeing.
12
+ *
13
+ * Deliberately not a dashboard. A terminal user wants "what is it doing" and
14
+ * "why", answerable without leaving the prompt.
15
+ */
16
+
17
+ interface RouterStatus {
18
+ mode?: string
19
+ runtime?: string
20
+ current?: { providerID: string; id: string }
21
+ quota?: ReadonlyArray<unknown>
22
+ lastDecision?: unknown
23
+ }
24
+
25
+ const tick = (ok: boolean) => (ok ? "✓" : "·")
26
+
27
+ /** A compact decision report, sized for a dialog rather than a panel. */
28
+ export const formatDecision = (status: RouterStatus | undefined, view: DecisionView | undefined): string => {
29
+ if (!status) return "Smart Router: no response from the router."
30
+ const head = `${status.runtime ?? "unknown"} · ${status.mode ?? "unknown"} · ${
31
+ status.current ? `${status.current.providerID}/${status.current.id}` : "no model yet"
32
+ }`
33
+ if (!view) return `${head}\n\nNo decision yet in this session.`
34
+
35
+ const lines = [head, "", `Chosen ${view.chosen ?? "nothing eligible"}`]
36
+ lines.push(
37
+ ` ${view.taskKind} · tier ${view.taskTier} · ${Math.round(view.contextTokens / 1000)}k context`,
38
+ )
39
+ if (view.reasons.length > 0) {
40
+ lines.push("", "Why")
41
+ for (const reason of view.reasons.slice(0, 5)) lines.push(` • ${reason}`)
42
+ }
43
+ if (view.rows.length > 0) {
44
+ lines.push("", `Candidates (${view.eligibleCount} eligible, ${view.blockedCount} blocked)`)
45
+ for (const row of view.rows.slice(0, 10)) {
46
+ lines.push(` ${tick(row.verdict !== "blocked")} ${row.key}`)
47
+ lines.push(` ${row.reason}`)
48
+ }
49
+ }
50
+ if (view.warnings.length > 0) {
51
+ lines.push("", "Warnings")
52
+ for (const warning of view.warnings.slice(0, 3)) lines.push(` ! ${warning}`)
53
+ }
54
+ return lines.join("\n")
55
+ }
56
+
57
+ /** Providers, one line each, with the tightest window that binds. */
58
+ export const formatProviders = (providers: ReadonlyArray<ProviderRow>): string => {
59
+ if (providers.length === 0) return "No providers reported."
60
+ const lines: string[] = []
61
+ for (const provider of providers) {
62
+ const windows = provider.windows
63
+ .filter((window) => window.reported)
64
+ .map((window) => `${window.label} ${window.lowerBound ? "≥" : ""}${window.usedPct}%`)
65
+ lines.push(`${provider.label}${provider.exhausted ? " (exhausted)" : ""}`)
66
+ lines.push(` ${windows.length > 0 ? windows.join(" · ") : "no windows reported"} · ${provider.sourceLabel}`)
67
+ if (provider.caveat) lines.push(` ! ${provider.caveat}`)
68
+ }
69
+ return lines.join("\n")
70
+ }
71
+
72
+ export default Plugin.define({
73
+ /**
74
+ * Deliberately NOT nested under the server part's id.
75
+ *
76
+ * The GUI docs are explicit: if a plugin's id has another id nested under it,
77
+ * or nests under one, its GUI does not load. The TUI previously used
78
+ * "opencode-smart-router.tui", which nests under the server part's
79
+ * "opencode-smart-router" — so adding a TUI silently disabled the desktop
80
+ * panel, with no error beyond a generic request failure.
81
+ *
82
+ * A sibling id avoids the namespace collision entirely.
83
+ */
84
+ id: "opencode-smart-router-tui",
85
+ setup(context) {
86
+ // Footer: the state a user wants without opening anything.
87
+ const stop = context.data.on("session.execution.succeeded", () => {
88
+ void refresh()
89
+ })
90
+
91
+ const refresh = async () => {
92
+ try {
93
+ const result: any = await context.client.rpc(SmartRouterRpc).status({})
94
+ setFooter(`${result?.runtime ?? "?"} · ${result?.mode ?? "?"}`)
95
+ } catch {
96
+ setFooter("router unreachable")
97
+ }
98
+ }
99
+
100
+ let setFooter = (_text: string) => {}
101
+ context.ui.slot({
102
+ append: "prompt.footer.status",
103
+ render: () => {
104
+ const [text, set] = createTerminalSignal("router")
105
+ setFooter = set
106
+ void refresh()
107
+ return <text>{text()}</text>
108
+ },
109
+ })
110
+
111
+ context.keymap.layer(() => ({
112
+ mode: "global",
113
+ commands: [
114
+ {
115
+ id: "router.decision",
116
+ title: "Router: why this model",
117
+ slash: { name: "router-why" },
118
+ palette: true,
119
+ run: async () => {
120
+ try {
121
+ const result: any = await context.client.rpc(SmartRouterRpc).status({})
122
+ const { summariseDecision, toProviderRow } = await import("../gui/viewmodel.js")
123
+ const body = formatDecision(result, summariseDecision(result?.lastDecision))
124
+ await context.ui.dialog.alert({ title: "Smart Router", message: body })
125
+ } catch (cause) {
126
+ await context.ui.dialog.alert({
127
+ title: "Smart Router",
128
+ message: `Could not read router status: ${cause instanceof Error ? cause.message : String(cause)}`,
129
+ })
130
+ }
131
+ },
132
+ },
133
+ {
134
+ id: "router.providers",
135
+ title: "Router: providers and allowances",
136
+ slash: { name: "router-quota" },
137
+ palette: true,
138
+ run: async () => {
139
+ try {
140
+ const result: any = await context.client.rpc(SmartRouterRpc).status({})
141
+ const { toProviderRow } = await import("../gui/viewmodel.js")
142
+ const rows = ((result?.quota ?? []) as unknown[]).map((snapshot) => toProviderRow(snapshot as never))
143
+ await context.ui.dialog.alert({ title: "Providers", message: formatProviders(rows) })
144
+ } catch (cause) {
145
+ await context.ui.dialog.alert({
146
+ title: "Providers",
147
+ message: `Could not read provider status: ${cause instanceof Error ? cause.message : String(cause)}`,
148
+ })
149
+ }
150
+ },
151
+ },
152
+ ],
153
+ bindings: ["router.decision", "router.providers"],
154
+ }))
155
+
156
+ return () => stop()
157
+ },
158
+ })
159
+
160
+ /** A minimal reactive cell, so this file needs no Solid runtime. */
161
+ function createTerminalSignal(initial: string): [() => string, (next: string) => void] {
162
+ let value = initial
163
+ const listeners = new Set<(next: string) => void>()
164
+ return [() => value, (next: string) => {
165
+ value = next
166
+ for (const listener of listeners) listener(next)
167
+ }]
168
+ }