pi-freeflow 1.12.1 → 1.13.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/CHANGELOG.md +6 -0
- package/README.md +160 -185
- package/package.json +1 -1
- package/src/commands.ts +235 -1
- package/src/relay-state.ts +364 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 39a06b9: Carry a relay pool between machines: `/freeflow export` saves the relays to a file and `/freeflow import` loads them back, adding to the current pool by default or swapping it whole on confirmation.
|
|
8
|
+
|
|
3
9
|
## 1.12.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# pi-freeflow
|
|
1
|
+
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 26 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
|
|
4
4
|
|
|
5
|
-
Thin by design: model list
|
|
5
|
+
Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/pi-freeflow)
|
|
8
8
|
[](https://www.npmjs.com/package/pi-freeflow)
|
|
@@ -10,120 +10,22 @@ Thin by design: model list + dumb relay + log. Host `pi-ai` owns thinking, norma
|
|
|
10
10
|
[](https://github.com/badlogic/pi-ai)
|
|
11
11
|
[](https://github.com/coder/oh-my-pi)
|
|
12
12
|
|
|
13
|
-
Join devs bypassing rate limits with their own relay pools. BYO, add as many as you need.
|
|
14
|
-
|
|
15
13
|
---
|
|
16
14
|
|
|
17
|
-
###
|
|
18
|
-
|
|
19
|
-
| Feature | Description | Value | Cost |
|
|
20
|
-
| :--- | :--- | :--- | :--- |
|
|
21
|
-
| **26 Curated Free Models** | 7 OpenCode Zen + 19 KiloCode Gateway models, up to 1M context & 512K output | Ceiling Unlocked | **$0** |
|
|
22
|
-
| **BYO Relay Pool** | Round-robin load balancing across your Cloudflare Workers & Vercel Edges | Zero Rate Limits | **$0** (your free tiers) |
|
|
23
|
-
| **Adaptive Health & Error Detection** | Auto-cooldown on 429 rate limits, 504 timeouts, and socket drops | 0ms Wasted Latency | **$0** |
|
|
24
|
-
| **Stream Truncation Resilience** | Stateful SSE terminal tracking (`response.failed` / `response.incomplete` injection) | Zero Host Crashes | **$0** |
|
|
25
|
-
| **Smart Model Aliasing** | Clean slash-free & colon-free CLI model names compatible with thinking selectors | DX Optimized | **$0** |
|
|
26
|
-
| **Auto-Enabled on Session** | Relay stays enabled in `auto` mode on session start and model switch | Zero Friction | **$0** |
|
|
27
|
-
| **Interactive CLI Management** | 10+ `/freeflow` subcommands (`status`, `list`, `use`, `add`, `label`, `remove`, `deploy`, `logs`, `debug`) | Full Control | **$0** |
|
|
28
|
-
| **Dumb Proxy That Never Breaks** | `127.0.0.1:28180`, host-normalized, pathname-guarded `/v1/models` | 100% Uptime | **$0** |
|
|
29
|
-
| **Observable Real Logs** | `~/.pi/agent/pi-freeflow.log`, 10MB auto-rotation, real-time debug toggle | Observable | **$0** |
|
|
15
|
+
### What you get
|
|
30
16
|
|
|
31
|
-
|
|
17
|
+
| Feature | What it does | Cost |
|
|
18
|
+
| :--- | :--- | :--- |
|
|
19
|
+
| **26 free models** | 7 from OpenCode Zen, 19 from KiloCode Gateway, context windows up to 1M. Full list below. | **$0** |
|
|
20
|
+
| **Relay pool** | Route requests through your own Cloudflare Workers and Vercel Edge relays. Requests rotate across the pool. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
|
|
21
|
+
| **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
|
|
22
|
+
| **Short model names** | Every model has a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`. | **$0** |
|
|
23
|
+
| **Shared local proxy** | One daemon on `127.0.0.1:28180` serves every session on the machine, so parallel subagents reuse it instead of opening their own connections. | **$0** |
|
|
24
|
+
| **Logs you can read** | `~/.pi/agent/pi-freeflow.log`, rotated at 10MB. Tail it with `/freeflow logs`. | **$0** |
|
|
32
25
|
|
|
33
26
|
---
|
|
34
27
|
|
|
35
|
-
###
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
/model → freeflow → pick
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
#### OpenCode Zen (7 Models), Responses & Chat API
|
|
42
|
-
Optimized for deep reasoning, long-horizon coding & autonomous agentic workflows.
|
|
43
|
-
|
|
44
|
-
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
45
|
-
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
46
|
-
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
47
|
-
| `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
48
|
-
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
49
|
-
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
50
|
-
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
51
|
-
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
52
|
-
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
53
|
-
|
|
54
|
-
#### KiloCode Gateway (19 Models), OpenRouter Compatible
|
|
55
|
-
Keyless access with `Bearer kilo-free`. Clean slash-free and colon-free CLI aliases supported.
|
|
56
|
-
|
|
57
|
-
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
58
|
-
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
59
|
-
| `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | **512K** (512.000) | **512K** (512.000) | `minimal…xhigh`\* | ✅ |
|
|
60
|
-
| `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
61
|
-
| `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **256K** (256.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
62
|
-
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
63
|
-
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
64
|
-
| `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
65
|
-
| `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
|
|
66
|
-
| `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
67
|
-
| `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
68
|
-
| `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **65K** (65.536) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
69
|
-
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
70
|
-
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
71
|
-
| `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
|
|
72
|
-
| `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
73
|
-
| `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
74
|
-
| `nex-n2.5-pro` (`nex-agi/nex-n2.5-pro:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
|
|
75
|
-
| `nex-n2.5-mini` (`nex-agi/nex-n2.5-mini:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
|
|
76
|
-
| `ling-3.0-flash-vl` (`inclusionai/ling-3.0-flash-vl:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ✅ |
|
|
77
|
-
| `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
78
|
-
|
|
79
|
-
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
### How It Works: BYO Relays, Zero Rate Limits
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
You → 127.0.0.1:28180 (dumb proxy, host-normalized) → x-relay-target → N egress IPs (your pool) → opencode.ai / api.kilo.ai
|
|
87
|
-
↑ host already normalized thinking → proxy just forwards
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
1. **Per-Request Round-Robin**: 10 parallel subagents hit N different egress IPs (your pool size). No thundering herd.
|
|
91
|
-
2. **Adaptive Health & Error Cooldown**: Relays hitting 429, 504, or socket disconnects enter temporary cooldown (30-90s) and automatically move behind healthy candidates. Healthy relays handle traffic with 0ms wasted delay.
|
|
92
|
-
3. **Seamless 429 Roll**: `429 / 408 / 502 / 503 / 504 / 520-530` → instant roll to next relay, never 429 to agent.
|
|
93
|
-
4. **Stream Truncation Resilience**: Stateful SSE terminal tracking prevents fatal unhandled stream closed errors when connections drop.
|
|
94
|
-
5. **Direct Fallback Safety Net**: If all relays in the pool are exhausted, transparent direct fetch to upstream.
|
|
95
|
-
6. **Zero Subagent Connect Errors**: 24h `DISK_CACHE_ONLY` model catalog avoids subagents hammering remote catalogs.
|
|
96
|
-
|
|
97
|
-
You bring the relays (free tiers). We bring the rolling.
|
|
98
|
-
|
|
99
|
-
---
|
|
100
|
-
|
|
101
|
-
### Interactive Commands Reference (`/freeflow`)
|
|
102
|
-
|
|
103
|
-
Manage your relay pool directly from the OMP / Pi terminal:
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
/freeflow status # View active relay, pool status, and candidates
|
|
107
|
-
/freeflow list # List all relays with real-time health badges (✓ / ⚠️ [cooling])
|
|
108
|
-
/freeflow use <url|index|label> # Switch active relay
|
|
109
|
-
/freeflow url <url> # Set the active relay URL directly
|
|
110
|
-
/freeflow add <url> [label] # Add new relay to the pool
|
|
111
|
-
/freeflow label <index|url> <name># Assign a friendly label to a relay
|
|
112
|
-
/freeflow remove <index|url|label># Remove a relay from the pool
|
|
113
|
-
/freeflow test <index|url|label> # Probe a relay for reachability (HTTP 200 + latency)
|
|
114
|
-
/freeflow on | off | auto # Toggle relay mode (auto = enabled for freeflow)
|
|
115
|
-
/freeflow deploy <platform> # Guided relay deploy: vercel|cloudflare|deno — token in-memory, auto-adds (Vercel 1M/mo recommended)
|
|
116
|
-
/freeflow logs [lines] # Inspect recent proxy logs
|
|
117
|
-
/freeflow trace [req-id] # Tail logs filtered by request correlation ID
|
|
118
|
-
/freeflow refresh # Reload the model catalog from live upstreams
|
|
119
|
-
/freeflow update # Check for and install a package update
|
|
120
|
-
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
121
|
-
/freeflow kill # Stop the shared proxy daemon now (restarts on next use)
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
### Quick Start in 30 Seconds
|
|
28
|
+
### Install
|
|
127
29
|
|
|
128
30
|
**On Oh My Pi (OMP):**
|
|
129
31
|
```bash
|
|
@@ -137,69 +39,61 @@ omp plugin link /path/to/pi-freeflow
|
|
|
137
39
|
pi install npm:pi-freeflow
|
|
138
40
|
```
|
|
139
41
|
|
|
140
|
-
> Both commands fetch the
|
|
141
|
-
> is an extension loaded by the host, not a standalone CLI. It works on
|
|
142
|
-
> and Pi only
|
|
42
|
+
> Both commands fetch the same npm package from the registry. pi-freeflow
|
|
43
|
+
> is an extension loaded by the host, not a standalone CLI. It works on OMP
|
|
44
|
+
> and Pi only (they share the extension API).
|
|
143
45
|
|
|
144
|
-
|
|
46
|
+
### Pick a model
|
|
145
47
|
|
|
146
|
-
**OMP
|
|
48
|
+
**OMP, interactive:**
|
|
147
49
|
```bash
|
|
148
50
|
omp
|
|
149
51
|
/model → freeflow → muse-spark-1.2-contributor-free (1M) → max
|
|
150
52
|
```
|
|
151
53
|
|
|
152
|
-
**OMP
|
|
54
|
+
**OMP, one shot:**
|
|
153
55
|
```bash
|
|
154
56
|
omp -p --model freeflow/muse-spark-1.2-contributor-free "build me a SaaS"
|
|
155
57
|
omp -p --model freeflow/step-3.7-flash:high "solve this bug" # alias + thinking level
|
|
156
58
|
```
|
|
157
59
|
|
|
158
|
-
**Pi
|
|
60
|
+
**Pi, interactive:**
|
|
159
61
|
```bash
|
|
160
62
|
pi
|
|
161
63
|
/model → freeflow → pick
|
|
162
64
|
```
|
|
163
65
|
|
|
164
|
-
**Pi
|
|
66
|
+
**Pi, one shot:**
|
|
165
67
|
```bash
|
|
166
68
|
pi -p --model freeflow/step-3.7-flash:high "solve this bug"
|
|
167
69
|
```
|
|
168
70
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
#### 3. Manage Your Relay Pool (OMP & Pi both)
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
/freeflow status # active relay, pool status, candidates
|
|
177
|
-
/freeflow list # relays with health badges
|
|
178
|
-
/freeflow add <url> [label]
|
|
179
|
-
/freeflow deploy # guided deploy: vercel|cloudflare|deno
|
|
180
|
-
/freeflow logs [n] # tail proxy logs
|
|
181
|
-
```
|
|
71
|
+
Model IDs accept a full canonical ID, a short alias (see the tables below),
|
|
72
|
+
and an optional `:effort` suffix (`:minimal` through `:xhigh`, `:max` where
|
|
73
|
+
supported). The host resolves the rest.
|
|
182
74
|
|
|
183
|
-
|
|
184
|
-
registers the same `/freeflow` command set in both hosts.
|
|
185
|
-
#### 4. Add Your Free Relays (Scale Infinitely)
|
|
75
|
+
### Add relays
|
|
186
76
|
|
|
187
|
-
|
|
77
|
+
The default setup talks to upstream directly. Add relays when shared IPs start
|
|
78
|
+
hitting rate limits. The fastest path is guided deploy: run
|
|
79
|
+
`/freeflow deploy cloudflare` (or `deno`, `vercel`), paste your platform token
|
|
80
|
+
once when asked, and the relay is created and added to your pool. The token
|
|
81
|
+
stays in memory and is never written to disk.
|
|
188
82
|
|
|
189
|
-
|
|
83
|
+
Manual fallback per platform:
|
|
190
84
|
|
|
191
|
-
**Option A: Cloudflare Workers
|
|
85
|
+
**Option A: Cloudflare Workers, auto deploy**
|
|
192
86
|
```bash
|
|
193
87
|
/freeflow deploy cloudflare # prompts token in-memory, auto-adds to pool
|
|
194
88
|
```
|
|
195
|
-
|
|
89
|
+
Manual fallback: `dash.cloudflare.com` → Workers → Create → Deploy → Edit code → paste the canonical worker source (see below) → Deploy → `/freeflow add https://your.workers.dev cf-worker-1`
|
|
196
90
|
|
|
197
|
-
**Option B: Vercel Edge Relay
|
|
91
|
+
**Option B: Vercel Edge Relay, auto deploy**
|
|
198
92
|
```bash
|
|
199
93
|
/freeflow deploy vercel # prompts token in-memory, auto-adds to pool
|
|
200
94
|
# or shorthand: /freeflow deploy
|
|
201
95
|
```
|
|
202
|
-
|
|
96
|
+
Manual fallback: push 2 files (`api/relay.js` + `vercel.json`) to GitHub, then Import on `vercel.com`, then `/freeflow add https://your.vercel.app vercel-relay-1`
|
|
203
97
|
|
|
204
98
|
For `api/relay.js`, use the canonical worker source (see below); `vercel.json` stays:
|
|
205
99
|
|
|
@@ -207,20 +101,20 @@ For `api/relay.js`, use the canonical worker source (see below); `vercel.json` s
|
|
|
207
101
|
{ "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }
|
|
208
102
|
```
|
|
209
103
|
|
|
210
|
-
**Option C: Deno Deploy
|
|
104
|
+
**Option C: Deno Deploy, auto deploy**
|
|
211
105
|
```bash
|
|
212
106
|
/freeflow deploy deno # prompts token in-memory, auto-adds to pool
|
|
213
107
|
```
|
|
214
|
-
|
|
108
|
+
Manual fallback: `dash.deno.com` → New Project → Playground → paste the canonical worker source (see below) → Deploy → `/freeflow add https://your-project.deno.dev deno-relay-1`
|
|
215
109
|
|
|
216
110
|
**Canonical worker source (all platforms)**
|
|
217
111
|
|
|
218
|
-
The relay worker template is generated per deployment by `/freeflow deploy
|
|
112
|
+
The relay worker template is generated per deployment by `/freeflow deploy`, with one shared core adapted for Vercel, Cloudflare, and Deno. Deployed relays accept requests from any pi-freeflow user by default, while only forwarding to an allowlist (`https://opencode.ai`, `https://api.kilo.ai`) with private-host and path checks.
|
|
219
113
|
|
|
220
114
|
```js
|
|
221
115
|
// Minimal Cloudflare illustration. Prefer /freeflow deploy: the generated
|
|
222
|
-
// worker
|
|
223
|
-
//
|
|
116
|
+
// worker is the hardened source for all three platforms.
|
|
117
|
+
// This example omits the private-host guard, path validation, and auth.
|
|
224
118
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
225
119
|
export default {
|
|
226
120
|
async fetch(req) {
|
|
@@ -238,53 +132,141 @@ export default {
|
|
|
238
132
|
|
|
239
133
|
**Verify your pool:**
|
|
240
134
|
```bash
|
|
241
|
-
/freeflow status # relay
|
|
242
|
-
/freeflow list #
|
|
135
|
+
/freeflow status # active relay, pool status, candidates
|
|
136
|
+
/freeflow list # every relay with health status
|
|
243
137
|
/freeflow logs # tail -25
|
|
244
|
-
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Carry your pool to another machine
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
/freeflow export # save pool to ./freeflow-relays.json
|
|
144
|
+
/freeflow export backup/team.json # save to a path you choose
|
|
145
|
+
/freeflow import backup/team.json # add its relays to this machine (merge)
|
|
146
|
+
/freeflow import backup/team.json --replace --dry-run # preview a full swap first
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Merge is the default and never deletes anything. Replace swaps the whole pool
|
|
150
|
+
and always asks first. Passwords stay out of the file unless you pass
|
|
151
|
+
`--include-secrets`; a file without passwords still imports, and you re-enter
|
|
152
|
+
each password once afterwards.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
### Commands reference (`/freeflow`)
|
|
157
|
+
|
|
158
|
+
The same command set works identically in OMP and Pi:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
/freeflow status # View active relay, pool status, and candidates
|
|
162
|
+
/freeflow list # List all relays with real-time health badges
|
|
163
|
+
/freeflow use <url|index|label> # Switch active relay
|
|
164
|
+
/freeflow url <url> # Set the active relay URL directly
|
|
165
|
+
/freeflow add <url> [label] # Add new relay to the pool
|
|
166
|
+
/freeflow label <index|url> <name># Assign a friendly label to a relay
|
|
167
|
+
/freeflow remove <index|url|label># Remove a relay from the pool
|
|
168
|
+
/freeflow test <index|url|label> # Probe a relay for reachability and latency
|
|
169
|
+
/freeflow on | off | auto # Toggle relay mode (auto = enabled for freeflow)
|
|
170
|
+
/freeflow deploy <platform> # Guided relay deploy: vercel|cloudflare|deno, token in-memory, auto-adds
|
|
171
|
+
/freeflow logs [lines] # Inspect recent proxy logs
|
|
172
|
+
/freeflow trace [req-id] # Tail logs filtered by request correlation ID
|
|
173
|
+
/freeflow refresh # Reload the model catalog from live upstreams
|
|
174
|
+
/freeflow update # Check for and install a package update
|
|
175
|
+
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
176
|
+
/freeflow kill # Stop the shared proxy daemon now (restarts on next use)
|
|
177
|
+
/freeflow export [path] [--include-secrets] # Save the relay pool to a file (default freeflow-relays.json; passwords left out unless asked)
|
|
178
|
+
/freeflow import <path> [--merge|--replace] [--dry-run] # Load a relay pool from a file (merge is default; replace asks first; dry-run previews only)
|
|
245
179
|
```
|
|
246
180
|
|
|
247
181
|
---
|
|
248
182
|
|
|
249
|
-
###
|
|
183
|
+
### 26 models, one command
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
/model → freeflow → pick
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### OpenCode Zen (7 models), Responses and Chat API
|
|
190
|
+
|
|
191
|
+
Good defaults for long coding sessions and agentic work.
|
|
192
|
+
|
|
193
|
+
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
194
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
195
|
+
| `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
196
|
+
| `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
|
|
197
|
+
| `mimo-v2.5-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
|
|
198
|
+
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
|
|
199
|
+
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
|
|
200
|
+
| `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
|
|
201
|
+
| `ling-3.0-flash-fin-free` | Inclusion AI | **262K** (262.144) | **131K** (131.072) | `minimal … xhigh` | ❌ |
|
|
202
|
+
|
|
203
|
+
#### KiloCode Gateway (19 models), OpenRouter compatible
|
|
204
|
+
|
|
205
|
+
Keyless access. Short aliases work for every row (the full ID is in parentheses).
|
|
206
|
+
|
|
207
|
+
| Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
|
|
208
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
209
|
+
| `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | **512K** (512.000) | **512K** (512.000) | `minimal…xhigh`\* | ✅ |
|
|
210
|
+
| `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
211
|
+
| `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **256K** (256.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
|
|
212
|
+
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
|
|
213
|
+
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
214
|
+
| `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
|
|
215
|
+
| `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
|
|
216
|
+
| `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
217
|
+
| `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
218
|
+
| `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **65K** (65.536) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
219
|
+
| `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
|
|
220
|
+
| `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
|
|
221
|
+
| `content-safety` (`nvidia/...:free`) | NVIDIA | **128K** (128.000) | **8K** (8.192) | ❌ *(non-thinking)* | ✅ |
|
|
222
|
+
| `ling-3.0-flash-fin` (`inclusionai/ling-3.0-flash-fin:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
223
|
+
| `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
|
|
224
|
+
| `nex-n2.5-pro` (`nex-agi/nex-n2.5-pro:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
|
|
225
|
+
| `nex-n2.5-mini` (`nex-agi/nex-n2.5-mini:free`) | Nex AGI | **262K** (262.144) | **235K** (235.929) | `minimal…xhigh`\* | ✅ |
|
|
226
|
+
| `ling-3.0-flash-vl` (`inclusionai/ling-3.0-flash-vl:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ✅ |
|
|
227
|
+
| `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
|
|
228
|
+
|
|
229
|
+
\* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Logs and debugging
|
|
250
234
|
|
|
251
235
|
```bash
|
|
252
236
|
/freeflow logs
|
|
253
237
|
cat ~/.pi/agent/pi-freeflow.log | tail -n 50
|
|
254
238
|
|
|
255
|
-
#
|
|
239
|
+
# full debug logging is on by default; `off` restores info level
|
|
256
240
|
/freeflow debug on
|
|
257
241
|
```
|
|
258
242
|
|
|
259
|
-
Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
260
|
-
|
|
261
243
|
---
|
|
262
244
|
|
|
263
245
|
### Design
|
|
264
246
|
|
|
265
|
-
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step
|
|
247
|
+
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step and zero runtime dependencies. Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
266
248
|
|
|
267
|
-
|
|
249
|
+
About 19k lines including tests. The full suite (sandboxed, network-mocked) and typecheck pass before every release. See CHANGELOG.md.
|
|
268
250
|
|
|
269
251
|
---
|
|
270
252
|
|
|
271
253
|
### FAQ
|
|
272
254
|
|
|
273
255
|
**Do I need API keys?**
|
|
274
|
-
No. Kilo uses
|
|
256
|
+
No. Kilo uses a shared free credential and OpenCode free models need no header. You never paste a key.
|
|
275
257
|
|
|
276
|
-
**What if all relays
|
|
277
|
-
|
|
258
|
+
**What if all relays hit rate limits?**
|
|
259
|
+
The proxy tries direct upstream. If that is also rate-limited, the host shows the limit. That number is the shared upstream cap; without relays you would hit the same wall sooner.
|
|
278
260
|
|
|
279
|
-
**Can I use without relays?**
|
|
280
|
-
Yes. `/freeflow off`
|
|
261
|
+
**Can I use it without relays?**
|
|
262
|
+
Yes. `/freeflow off` talks direct. Add relays later when you need them.
|
|
281
263
|
|
|
282
264
|
**What happens when I update to a new version?**
|
|
283
265
|
The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
|
|
284
|
-
detects a stale daemon (
|
|
285
|
-
kill
|
|
266
|
+
detects a stale daemon (an older version still running) and replaces it automatically, with no manual
|
|
267
|
+
kill and no restart of other sessions. Replacement only happens when the running daemon is
|
|
286
268
|
idle: sessions with in-flight requests are never interrupted (busy or newer daemons are reused
|
|
287
|
-
with a log note instead). If a daemon cannot be replaced (
|
|
269
|
+
with a log note instead). If a daemon cannot be replaced (for example the port is held by an unrelated process),
|
|
288
270
|
it falls back to reusing it with a warning. To disable replacement entirely, set the no-kill env
|
|
289
271
|
to `1` before starting a session.
|
|
290
272
|
|
|
@@ -293,41 +275,35 @@ Nothing visible to your other sessions. The proxy daemon is a separate backgroun
|
|
|
293
275
|
process shared by every OMP/Pi session on the machine. Closing one session just
|
|
294
276
|
unregisters it; the daemon keeps serving the rest and retires itself automatically
|
|
295
277
|
once the last client disconnects and no client re-attaches within a short grace window.
|
|
296
|
-
To stop it manually, run `/freeflow kill
|
|
278
|
+
To stop it manually, run `/freeflow kill`. The next freeflow use starts it again.
|
|
297
279
|
|
|
298
|
-
**Why did my session stop with "reasoning
|
|
299
|
-
|
|
280
|
+
**Why did my long session stop with a "reasoning was not issued to this caller" error?**
|
|
281
|
+
The upstream backend signs each thinking block so only the backend that produced it can read it back. When a later turn reaches a different backend, the old blocks get rejected and the session stalls. pi-freeflow keeps each conversation on the relay that produced its thinking while that relay is healthy. When the relay has to change anyway (rate limit, relay removed, direct-mode switch), that turn is sent without the old thinking blocks so the new backend accepts it, and only the rejected blocks are dropped afterwards. Messages, tool calls, and tool results are always kept.
|
|
300
282
|
|
|
301
|
-
**Where's the normalizer?**
|
|
302
|
-
Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
|
|
303
|
-
|
|
304
|
-
**Why is context free?**
|
|
305
|
-
We use OpenCode Zen & Kilo free tiers. You pay only with your own Cloudflare/Vercel free tiers for egress.
|
|
306
283
|
**Why is it installed via npm?**
|
|
307
|
-
The npm package is the
|
|
284
|
+
The npm package is the distribution channel only. Both hosts resolve it internally:
|
|
308
285
|
`omp plugin install pi-freeflow` and `pi install npm:pi-freeflow` install the same
|
|
309
|
-
package from the npm registry. pi-freeflow is an
|
|
310
|
-
|
|
286
|
+
package from the npm registry. pi-freeflow is an extension, not a standalone CLI.
|
|
287
|
+
The host (OMP or Pi) loads and runs it. A plain `npm install` just downloads the
|
|
311
288
|
files; it is not a supported way to run the extension.
|
|
312
289
|
|
|
313
290
|
**Which hosts can use it?**
|
|
314
|
-
Oh My Pi (OMP) and Pi only. They share the same extension API
|
|
315
|
-
|
|
316
|
-
package serves both. Other AI agents (OpenCode, KiloCode, Cursor, ...) have their
|
|
291
|
+
Oh My Pi (OMP) and Pi only. They share the same extension API, so one
|
|
292
|
+
package serves both. Other AI agents (OpenCode, KiloCode, Cursor, and similar) have their
|
|
317
293
|
own plugin systems and do not load this extension.
|
|
318
294
|
|
|
319
295
|
---
|
|
320
296
|
|
|
321
297
|
### Contributing
|
|
322
298
|
|
|
323
|
-
Contributions welcome
|
|
299
|
+
Contributions welcome: bug fixes, new relay platforms, model additions, docs improvements.
|
|
324
300
|
|
|
325
301
|
#### Prerequisites
|
|
326
302
|
|
|
327
303
|
- **Node.js ≥ 22.19.0** (uses `--experimental-strip-types`, no build step)
|
|
328
304
|
- **pnpm** (package manager)
|
|
329
305
|
|
|
330
|
-
#### Setup
|
|
306
|
+
#### Setup and verify
|
|
331
307
|
|
|
332
308
|
```bash
|
|
333
309
|
git clone https://github.com/trefeon/pi-freeflow
|
|
@@ -335,12 +311,12 @@ cd pi-freeflow
|
|
|
335
311
|
pnpm install
|
|
336
312
|
|
|
337
313
|
# run all three before opening a PR
|
|
338
|
-
pnpm test # full suite; sandboxed
|
|
314
|
+
pnpm test # full suite; sandboxed and network-mocked
|
|
339
315
|
pnpm typecheck # tsc --noEmit, must pass clean
|
|
340
316
|
pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
341
317
|
```
|
|
342
318
|
|
|
343
|
-
#### Project
|
|
319
|
+
#### Project structure
|
|
344
320
|
|
|
345
321
|
```
|
|
346
322
|
src/
|
|
@@ -348,9 +324,9 @@ src/
|
|
|
348
324
|
├── models.ts # 26-model catalog definitions
|
|
349
325
|
├── catalog.ts # model catalog cache (24h disk)
|
|
350
326
|
├── proxy.ts # local proxy server (127.0.0.1:28180)
|
|
351
|
-
├── relay.ts # relay selection
|
|
327
|
+
├── relay.ts # relay selection and round-robin
|
|
352
328
|
├── relay-state.ts # relay pool state, health tracking
|
|
353
|
-
├── stream-pipe.ts # SSE stream piping
|
|
329
|
+
├── stream-pipe.ts # SSE stream piping and truncation resilience
|
|
354
330
|
├── commands.ts # /freeflow CLI subcommands
|
|
355
331
|
├── deploy.ts # guided relay deploy (vercel/cloudflare/deno)
|
|
356
332
|
├── config.ts # constants, whitelists, paths, and runtime settings
|
|
@@ -369,7 +345,7 @@ test/
|
|
|
369
345
|
- **Keep model IDs clean.** Slash-free, colon-free aliases for CLI compatibility. See existing patterns in `models.ts`.
|
|
370
346
|
- **One concern per PR.** Bug fix? One PR. New relay platform? Separate PR. Easier to review, faster to merge.
|
|
371
347
|
|
|
372
|
-
#### Reporting
|
|
348
|
+
#### Reporting issues
|
|
373
349
|
|
|
374
350
|
Found a bug or want a feature? [Open an issue](https://github.com/trefeon/pi-freeflow/issues) with:
|
|
375
351
|
- What happened vs what you expected
|
|
@@ -381,4 +357,3 @@ Found a bug or want a feature? [Open an issue](https://github.com/trefeon/pi-fre
|
|
|
381
357
|
### License
|
|
382
358
|
|
|
383
359
|
MIT © trefeon
|
|
384
|
-
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/commands.ts
CHANGED
|
@@ -36,10 +36,16 @@ import {
|
|
|
36
36
|
} from "./logger.ts";
|
|
37
37
|
import { probeRelay } from "./probe.ts";
|
|
38
38
|
import {
|
|
39
|
+
buildRelayExport,
|
|
39
40
|
ensureRelay,
|
|
41
|
+
EXPORT_DEFAULT_FILENAME,
|
|
42
|
+
EXPORT_MAX_BYTES,
|
|
40
43
|
findRelay,
|
|
41
44
|
getActiveRelayState,
|
|
42
45
|
getRelayHealth,
|
|
46
|
+
loadRelayState,
|
|
47
|
+
parseRelayImport,
|
|
48
|
+
planRelayImport,
|
|
43
49
|
removeRelay,
|
|
44
50
|
setActiveRelayState,
|
|
45
51
|
setRelayLabel,
|
|
@@ -327,7 +333,7 @@ export function createCommandSpec(
|
|
|
327
333
|
): Omit<RegisteredCommand, "name"> {
|
|
328
334
|
return {
|
|
329
335
|
description:
|
|
330
|
-
"Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup",
|
|
336
|
+
"Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | test <target> | export [path] [--include-secrets] | import <path> [--merge|--replace] [--dry-run] | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno | install-startup | uninstall-startup",
|
|
331
337
|
getArgumentCompletions: (prefix: string) =>
|
|
332
338
|
[
|
|
333
339
|
"auto",
|
|
@@ -344,6 +350,8 @@ export function createCommandSpec(
|
|
|
344
350
|
"label",
|
|
345
351
|
"rename",
|
|
346
352
|
"remove",
|
|
353
|
+
"export",
|
|
354
|
+
"import",
|
|
347
355
|
"test",
|
|
348
356
|
"url",
|
|
349
357
|
"deploy",
|
|
@@ -645,6 +653,183 @@ export function createCommandSpec(
|
|
|
645
653
|
persist();
|
|
646
654
|
ctx.ui.notify(`Removed: [${shortRelayLabel(match.url, relayState.relays)}] ${match.url}`, "info");
|
|
647
655
|
};
|
|
656
|
+
const classifyImportFailure = (filePath: string, raw: string): string => {
|
|
657
|
+
let parsed: unknown = null;
|
|
658
|
+
try {
|
|
659
|
+
parsed = JSON.parse(raw);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
return `Relay file ${filePath} is not valid JSON (${(e as Error).message}). Nothing changed.`;
|
|
662
|
+
}
|
|
663
|
+
const version = parsed !== null && typeof parsed === "object" && "version" in parsed
|
|
664
|
+
? parsed.version
|
|
665
|
+
: undefined;
|
|
666
|
+
if (typeof version !== "undefined" && version !== 1) {
|
|
667
|
+
return `Relay file ${filePath} uses version ${String(version)}, this version understands version 1. Update then try again. Nothing changed.`;
|
|
668
|
+
}
|
|
669
|
+
return `Relay file ${filePath} is not a relay export (missing relay list). Nothing changed.`;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const runExport = async (pathArg?: string, includeSecrets = false) => {
|
|
673
|
+
let state: RelayState;
|
|
674
|
+
try {
|
|
675
|
+
state = getActiveRelayState();
|
|
676
|
+
} catch {
|
|
677
|
+
state = loadRelayState();
|
|
678
|
+
}
|
|
679
|
+
const artifact = buildRelayExport(state, { includeSecrets });
|
|
680
|
+
const count = artifact.state.relays.length;
|
|
681
|
+
let target = pathArg ? path.resolve(pathArg) : path.resolve(EXPORT_DEFAULT_FILENAME);
|
|
682
|
+
if (!pathArg) {
|
|
683
|
+
const choice = await ctx.ui.select("Share relays (export file)", [
|
|
684
|
+
`Export to ${target}`,
|
|
685
|
+
"Choose another path…",
|
|
686
|
+
"Cancel",
|
|
687
|
+
]);
|
|
688
|
+
if (!choice || choice === "Cancel") return;
|
|
689
|
+
if (choice === "Choose another path…") {
|
|
690
|
+
const picked = (await ctx.ui.input("Export path:", target))?.trim();
|
|
691
|
+
if (!picked) return;
|
|
692
|
+
target = path.resolve(picked);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (fs.existsSync(target)) {
|
|
696
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
697
|
+
const ok = await ctx.ui.confirm(
|
|
698
|
+
"Export relays",
|
|
699
|
+
`File already exists: ${target}\nOverwrite it?`,
|
|
700
|
+
);
|
|
701
|
+
if (!ok) {
|
|
702
|
+
ctx.ui.notify("Export cancelled — existing file left alone", "warning");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
fs.writeFileSync(target, JSON.stringify(artifact, null, 2));
|
|
709
|
+
} catch (e) {
|
|
710
|
+
ctx.ui.notify(`Could not write relay file ${target}: ${(e as Error).message}.`, "error");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
updateStatusBar(ctx.ui);
|
|
714
|
+
flash();
|
|
715
|
+
ctx.ui.notify(
|
|
716
|
+
includeSecrets
|
|
717
|
+
? `Exported ${count} relay(s) to ${target} WITH passwords included — keep this file private.`
|
|
718
|
+
: `Exported ${count} relay(s) to ${target} (passwords stripped — file is safe to share).`,
|
|
719
|
+
"info",
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const runImport = async (
|
|
724
|
+
pathArg: string | undefined,
|
|
725
|
+
mode: "merge" | "replace",
|
|
726
|
+
dryRun: boolean,
|
|
727
|
+
) => {
|
|
728
|
+
const filePath = (pathArg || "").trim();
|
|
729
|
+
if (!filePath) {
|
|
730
|
+
ctx.ui.notify("Usage: /freeflow import <path> [--merge|--replace] [--dry-run] — path is required.", "warning");
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
let byteSize = 0;
|
|
734
|
+
try {
|
|
735
|
+
const st = fs.statSync(filePath);
|
|
736
|
+
if (!st.isFile()) {
|
|
737
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: not a regular file.`, "error");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
byteSize = st.size;
|
|
741
|
+
} catch (e) {
|
|
742
|
+
const code = e !== null && typeof e === "object" && "code" in e ? e.code : undefined;
|
|
743
|
+
if (code === "ENOENT") {
|
|
744
|
+
ctx.ui.notify(`Relay file not found: ${filePath} — check the path and try again.`, "warning");
|
|
745
|
+
} else {
|
|
746
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
747
|
+
}
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (byteSize > EXPORT_MAX_BYTES) {
|
|
751
|
+
ctx.ui.notify(`Relay file ${filePath} is too large (${byteSize} bytes; limit ${EXPORT_MAX_BYTES} bytes). Nothing changed.`, "warning");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
let raw: string;
|
|
755
|
+
try {
|
|
756
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
757
|
+
} catch (e) {
|
|
758
|
+
ctx.ui.notify(`Could not read relay file ${filePath}: ${(e as Error).message}.`, "error");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
let fragment: Pick<RelayState, "mode" | "enabled" | "url" | "relays" | "hideWidget">;
|
|
762
|
+
let parseSkipped: Array<{ index: number; url: string; reason: string }> = [];
|
|
763
|
+
try {
|
|
764
|
+
const parsed = parseRelayImport(raw);
|
|
765
|
+
fragment = parsed.fragment;
|
|
766
|
+
parseSkipped = parsed.skipped;
|
|
767
|
+
} catch {
|
|
768
|
+
ctx.ui.notify(classifyImportFailure(filePath, raw), "warning");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (!fragment || !Array.isArray(fragment.relays) || fragment.relays.length === 0) {
|
|
772
|
+
if (parseSkipped.length > 0) {
|
|
773
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (${parseSkipped.length} skipped: ${parseSkipped[0].reason}). Nothing changed.`, "warning");
|
|
774
|
+
} else {
|
|
775
|
+
ctx.ui.notify(`Relay file ${filePath} holds no usable relay addresses (0 skipped). Nothing changed.`, "warning");
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const plan = planRelayImport(loadRelayState(), fragment, { mode });
|
|
780
|
+
const skipped = [...parseSkipped];
|
|
781
|
+
for (const s of plan.skipped) {
|
|
782
|
+
if (!skipped.some((x) => x.index === s.index && x.url === s.url)) skipped.push(s);
|
|
783
|
+
}
|
|
784
|
+
const skippedCount = skipped.length;
|
|
785
|
+
const reasons = skippedCount > 0
|
|
786
|
+
? skipped.map((s) => s.reason).filter((r, i, arr) => arr.indexOf(r) === i).join("; ")
|
|
787
|
+
: "none";
|
|
788
|
+
const plannedActive = plan.next.url
|
|
789
|
+
? `[${shortRelayLabel(plan.next.url, plan.next.relays)}]`
|
|
790
|
+
: "none";
|
|
791
|
+
if (dryRun) {
|
|
792
|
+
if (mode === "replace") {
|
|
793
|
+
ctx.ui.notify(`DRY RUN — replace from ${filePath}: would install ${plan.added}, would remove ${plan.removed}, would skip ${skippedCount} (${reasons}). Active relay would be ${plannedActive}. Nothing changed.`, "info");
|
|
794
|
+
} else {
|
|
795
|
+
ctx.ui.notify(`DRY RUN — merge from ${filePath}: would add ${plan.added}, would update short name on ${plan.updated}, would skip ${skippedCount} (${reasons}). Active and mode unchanged. Nothing changed.`, "info");
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (mode === "replace") {
|
|
800
|
+
ctx.ui.notify(`Replace from ${filePath}: will install ${plan.added}, remove ${plan.removed}, skip ${skippedCount} (${reasons}). Active relay will be ${plannedActive}.`, "info");
|
|
801
|
+
if (typeof ctx.ui.confirm === "function") {
|
|
802
|
+
const ok = await ctx.ui.confirm(
|
|
803
|
+
"Replace all relays?",
|
|
804
|
+
`Install ${plan.added} relay(s) from ${filePath} and remove ${plan.removed} existing? This cannot be undone.`,
|
|
805
|
+
);
|
|
806
|
+
if (!ok) {
|
|
807
|
+
ctx.ui.notify("Replace cancelled — relay list unchanged.", "warning");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
applyRelayState((s) => planRelayImport(s, fragment, { mode }).next);
|
|
813
|
+
persist();
|
|
814
|
+
flash();
|
|
815
|
+
if (mode === "replace") {
|
|
816
|
+
const activeName = relayState.url
|
|
817
|
+
? `[${shortRelayLabel(relayState.url, relayState.relays)}]`
|
|
818
|
+
: "none";
|
|
819
|
+
ctx.ui.notify(`Replaced relay list from ${filePath}: installed ${plan.added}, skipped ${skippedCount}. Active relay is now ${activeName}.`, "info");
|
|
820
|
+
} else {
|
|
821
|
+
ctx.ui.notify(`Merge from ${filePath}: added ${plan.added}, updated short name on ${plan.updated}, skipped ${skippedCount}. Relay list now holds ${relayState.relays.length}.`, "info");
|
|
822
|
+
}
|
|
823
|
+
const withoutPassword = relayState.relays.filter((r) => !r.auth).length;
|
|
824
|
+
if (withoutPassword > 0) {
|
|
825
|
+
ctx.ui.notify(`${withoutPassword} relay(s) have no password — reconnect each one before use.`, "warning");
|
|
826
|
+
}
|
|
827
|
+
for (const s of skipped) {
|
|
828
|
+
const shown = s.url || `#${s.index + 1}`;
|
|
829
|
+
const reason = s.reason.endsWith(".") ? s.reason : `${s.reason}.`;
|
|
830
|
+
ctx.ui.notify(`Skipped ${shown}: ${reason}`, "warning");
|
|
831
|
+
}
|
|
832
|
+
};
|
|
648
833
|
|
|
649
834
|
if (sub === "auto") {
|
|
650
835
|
applyRelayState((s) => {
|
|
@@ -1102,6 +1287,47 @@ export function createCommandSpec(
|
|
|
1102
1287
|
} else {
|
|
1103
1288
|
ctx.ui.notify(`✗ ${shortRelayLabel(matched.url, relayState.relays)} failed: ${probe.error || `HTTP ${probe.status}`}`, "error");
|
|
1104
1289
|
}
|
|
1290
|
+
} else if (sub === "export") {
|
|
1291
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1292
|
+
let target: string | undefined;
|
|
1293
|
+
let includeSecrets = false;
|
|
1294
|
+
for (const t of tokens) {
|
|
1295
|
+
if (t === "--include-secrets") {
|
|
1296
|
+
includeSecrets = true;
|
|
1297
|
+
} else if (t.startsWith("--")) {
|
|
1298
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow export [path] [--include-secrets].`, "warning");
|
|
1299
|
+
return;
|
|
1300
|
+
} else if (target === undefined) {
|
|
1301
|
+
target = t;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
await runExport(target, includeSecrets);
|
|
1305
|
+
} else if (sub === "import") {
|
|
1306
|
+
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|
|
1307
|
+
let target: string | undefined;
|
|
1308
|
+
let sawMerge = false;
|
|
1309
|
+
let sawReplace = false;
|
|
1310
|
+
let dryRun = false;
|
|
1311
|
+
for (const t of tokens) {
|
|
1312
|
+
if (t === "--merge") {
|
|
1313
|
+
sawMerge = true;
|
|
1314
|
+
} else if (t === "--replace") {
|
|
1315
|
+
sawReplace = true;
|
|
1316
|
+
} else if (t === "--dry-run") {
|
|
1317
|
+
dryRun = true;
|
|
1318
|
+
} else if (t.startsWith("--")) {
|
|
1319
|
+
ctx.ui.notify(`Unknown flag ${t} — usage: /freeflow import <path> [--merge|--replace] [--dry-run].`, "warning");
|
|
1320
|
+
return;
|
|
1321
|
+
} else if (target === undefined) {
|
|
1322
|
+
target = t;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (sawMerge && sawReplace) {
|
|
1326
|
+
ctx.ui.notify("Both --merge and --replace given — showing a merge preview. Nothing changed.", "info");
|
|
1327
|
+
await runImport(target, "merge", true);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
await runImport(target, sawReplace ? "replace" : "merge", dryRun);
|
|
1105
1331
|
} else if (sub === "url") {
|
|
1106
1332
|
const input =
|
|
1107
1333
|
rest ||
|
|
@@ -1152,6 +1378,8 @@ export function createCommandSpec(
|
|
|
1152
1378
|
"Switch active relay…",
|
|
1153
1379
|
"Rename / Set relay short name…",
|
|
1154
1380
|
"Remove relay…",
|
|
1381
|
+
"Share relays (export file)…",
|
|
1382
|
+
"Load relays from file (import)…",
|
|
1155
1383
|
"List saved relays",
|
|
1156
1384
|
"Deploy Vercel relay…",
|
|
1157
1385
|
"Deploy Cloudflare relay…",
|
|
@@ -1170,6 +1398,12 @@ export function createCommandSpec(
|
|
|
1170
1398
|
await editRelayLabelMenu();
|
|
1171
1399
|
} else if (choice === "Remove relay…") {
|
|
1172
1400
|
await removeRelayMenu();
|
|
1401
|
+
} else if (choice === "Share relays (export file)…") {
|
|
1402
|
+
await runExport(undefined, false);
|
|
1403
|
+
} else if (choice === "Load relays from file (import)…") {
|
|
1404
|
+
const picked = (await ctx.ui.input("Relay file path:", EXPORT_DEFAULT_FILENAME))?.trim();
|
|
1405
|
+
if (!picked) return;
|
|
1406
|
+
await runImport(picked, "merge", false);
|
|
1173
1407
|
} else if (choice === "List saved relays") {
|
|
1174
1408
|
showList();
|
|
1175
1409
|
} else if (choice === "Deploy Vercel relay…") {
|
package/src/relay-state.ts
CHANGED
|
@@ -767,3 +767,367 @@ export function updateRelayStatusUi(targetUrl?: string): void {
|
|
|
767
767
|
const label = formatRelayStatusLabel(getActiveRelayState(), targetUrl);
|
|
768
768
|
activeStatusUi.setStatus("freeflow", label ?? undefined);
|
|
769
769
|
}
|
|
770
|
+
|
|
771
|
+
// ── Relay export/import codec (relay-pool portability) ─────────────────────
|
|
772
|
+
// Pure in-memory codec for `/freeflow export` + `/freeflow import`. These
|
|
773
|
+
// functions never touch disk, never prompt, and never validate the live pool:
|
|
774
|
+
// the command layer parses 100% in memory, then commits via one CAS write.
|
|
775
|
+
|
|
776
|
+
/** Envelope marker identifying a pi-freeflow relay export file. */
|
|
777
|
+
export const EXPORT_KIND = "pi-freeflow/relay-export" as const;
|
|
778
|
+
/** Current relay export file version. Bump on any envelope breaking change. */
|
|
779
|
+
export const EXPORT_VERSION = 1;
|
|
780
|
+
/** Default file name used by `/freeflow export` when no path is given. */
|
|
781
|
+
export const EXPORT_DEFAULT_FILENAME = "freeflow-relays.json";
|
|
782
|
+
/** Hard cap on accepted import file size (1 MiB). */
|
|
783
|
+
export const EXPORT_MAX_BYTES = 1_048_576;
|
|
784
|
+
|
|
785
|
+
/** A parsed relay export file envelope. */
|
|
786
|
+
export interface RelayExportArtifact {
|
|
787
|
+
kind: "pi-freeflow/relay-export";
|
|
788
|
+
version: 1;
|
|
789
|
+
exportedAt: string;
|
|
790
|
+
state: {
|
|
791
|
+
mode?: RelayMode;
|
|
792
|
+
enabled: boolean;
|
|
793
|
+
url: string;
|
|
794
|
+
relays: KnownRelay[];
|
|
795
|
+
hideWidget?: boolean;
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** One import candidate entry that was skipped instead of aborting the file. */
|
|
800
|
+
export interface RelayImportSkipped {
|
|
801
|
+
index: number;
|
|
802
|
+
url: string;
|
|
803
|
+
reason: string;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** RelayState subset carried by an import file (pool scope only). */
|
|
807
|
+
export type RelayImportFragment = Pick<
|
|
808
|
+
RelayState,
|
|
809
|
+
"mode" | "enabled" | "url" | "relays" | "hideWidget"
|
|
810
|
+
>;
|
|
811
|
+
|
|
812
|
+
/** Result of planning an import against the current pool. */
|
|
813
|
+
export interface RelayImportPlanResult {
|
|
814
|
+
next: RelayState;
|
|
815
|
+
added: number;
|
|
816
|
+
updated: number;
|
|
817
|
+
removed: number;
|
|
818
|
+
skipped: RelayImportSkipped[];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Alias kept for the cross-slice contract name. */
|
|
822
|
+
export type PlanResult = RelayImportPlanResult;
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Build an export artifact from live relay state. Passwords are stripped by
|
|
826
|
+
* default (the key is absent, not null); pass `{ includeSecrets: true }` to
|
|
827
|
+
* carry them verbatim.
|
|
828
|
+
*/
|
|
829
|
+
export function buildRelayExport(
|
|
830
|
+
state: RelayState,
|
|
831
|
+
opts?: { includeSecrets?: boolean },
|
|
832
|
+
): RelayExportArtifact {
|
|
833
|
+
const includeSecrets = opts?.includeSecrets === true;
|
|
834
|
+
return {
|
|
835
|
+
kind: EXPORT_KIND,
|
|
836
|
+
version: EXPORT_VERSION,
|
|
837
|
+
exportedAt: new Date().toISOString(),
|
|
838
|
+
state: {
|
|
839
|
+
mode: state.mode,
|
|
840
|
+
enabled: state.enabled,
|
|
841
|
+
url: state.url,
|
|
842
|
+
relays: state.relays.map((r) => {
|
|
843
|
+
const out: KnownRelay = { url: r.url };
|
|
844
|
+
if (r.label !== undefined) out.label = r.label;
|
|
845
|
+
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
846
|
+
if (
|
|
847
|
+
includeSecrets &&
|
|
848
|
+
typeof r.auth === "string" &&
|
|
849
|
+
r.auth.length > 0
|
|
850
|
+
) {
|
|
851
|
+
out.auth = r.auth;
|
|
852
|
+
}
|
|
853
|
+
return out;
|
|
854
|
+
}),
|
|
855
|
+
hideWidget: state.hideWidget,
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Parse raw import file text into a relay fragment. Throws on envelope
|
|
862
|
+
* failure (oversize, bad JSON, wrong kind/version, missing pool); per-entry
|
|
863
|
+
* problems fail closed to `skipped` so one bad relay never wipes the pool.
|
|
864
|
+
*/
|
|
865
|
+
export function parseRelayImport(raw: string): {
|
|
866
|
+
fragment: RelayImportFragment;
|
|
867
|
+
skipped: RelayImportSkipped[];
|
|
868
|
+
warnings: string[];
|
|
869
|
+
} {
|
|
870
|
+
if (raw.length > EXPORT_MAX_BYTES) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`Relay import file too large (${raw.length} bytes, limit ${EXPORT_MAX_BYTES})`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
let doc: unknown;
|
|
876
|
+
try {
|
|
877
|
+
doc = JSON.parse(raw);
|
|
878
|
+
} catch {
|
|
879
|
+
throw new Error("Relay import file is not valid JSON");
|
|
880
|
+
}
|
|
881
|
+
const envelope =
|
|
882
|
+
typeof doc === "object" && doc !== null
|
|
883
|
+
? (doc as Record<string, unknown>)
|
|
884
|
+
: null;
|
|
885
|
+
if (envelope?.kind !== EXPORT_KIND) {
|
|
886
|
+
throw new Error("File is not a relay export (bad kind marker)");
|
|
887
|
+
}
|
|
888
|
+
if (
|
|
889
|
+
typeof envelope.version !== "number" ||
|
|
890
|
+
!Number.isInteger(envelope.version) ||
|
|
891
|
+
envelope.version !== EXPORT_VERSION
|
|
892
|
+
) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`Unsupported relay export version (${String(envelope.version)}); this build reads version ${EXPORT_VERSION}`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
const rawState =
|
|
898
|
+
typeof envelope.state === "object" && envelope.state !== null
|
|
899
|
+
? (envelope.state as Record<string, unknown>)
|
|
900
|
+
: null;
|
|
901
|
+
if (!rawState || !Array.isArray(rawState.relays)) {
|
|
902
|
+
throw new Error("File is not a relay export (state pool is missing)");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const warnings: string[] = [];
|
|
906
|
+
const skipped: RelayImportSkipped[] = [];
|
|
907
|
+
|
|
908
|
+
const rawMode = rawState.mode;
|
|
909
|
+
const mode: RelayMode =
|
|
910
|
+
rawMode === "on" || rawMode === "off" || rawMode === "auto"
|
|
911
|
+
? rawMode
|
|
912
|
+
: "auto";
|
|
913
|
+
if (rawMode !== "on" && rawMode !== "off" && rawMode !== "auto") {
|
|
914
|
+
warnings.push("Unknown relay mode in file; using automatic mode.");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const rawEnabled = rawState.enabled;
|
|
918
|
+
const enabled =
|
|
919
|
+
typeof rawEnabled === "boolean"
|
|
920
|
+
? rawEnabled
|
|
921
|
+
: mode === "on"
|
|
922
|
+
? true
|
|
923
|
+
: mode === "off"
|
|
924
|
+
? false
|
|
925
|
+
: (rawState.relays as unknown[]).length > 0;
|
|
926
|
+
|
|
927
|
+
const rawUrl = rawState.url;
|
|
928
|
+
let url = "";
|
|
929
|
+
if (typeof rawUrl === "string" && rawUrl.trim()) {
|
|
930
|
+
const trimmed = rawUrl.trim();
|
|
931
|
+
if (validateRelayUrl(trimmed).ok) {
|
|
932
|
+
url = trimmed;
|
|
933
|
+
} else {
|
|
934
|
+
warnings.push("Active relay address in file is not usable; ignoring it.");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const hideWidget = rawState.hideWidget === true;
|
|
939
|
+
const relays: KnownRelay[] = [];
|
|
940
|
+
const seen = new Set<string>();
|
|
941
|
+
const entries = rawState.relays as unknown[];
|
|
942
|
+
for (let index = 0; index < entries.length; index++) {
|
|
943
|
+
const entry = entries[index];
|
|
944
|
+
if (typeof entry !== "object" || entry === null) {
|
|
945
|
+
skipped.push({ index, url: "", reason: "entry is not an object" });
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const record = entry as Record<string, unknown>;
|
|
949
|
+
const trimmedUrl =
|
|
950
|
+
typeof record.url === "string" ? record.url.trim() : "";
|
|
951
|
+
if (!trimmedUrl) {
|
|
952
|
+
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const check = validateRelayUrl(trimmedUrl);
|
|
956
|
+
if (!check.ok) {
|
|
957
|
+
skipped.push({ index, url: trimmedUrl, reason: check.reason });
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
// Exact trimmed-address dedupe (first wins), mirroring ensureRelay.
|
|
961
|
+
if (seen.has(trimmedUrl)) {
|
|
962
|
+
skipped.push({ index, url: trimmedUrl, reason: "duplicate relay address" });
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
seen.add(trimmedUrl);
|
|
966
|
+
const candidate: KnownRelay = { url: trimmedUrl };
|
|
967
|
+
if (typeof record.label === "string") {
|
|
968
|
+
const cleanLabel = record.label.trim();
|
|
969
|
+
if (cleanLabel && cleanLabel !== "manual") {
|
|
970
|
+
if (cleanLabel.length > 64) {
|
|
971
|
+
candidate.label = cleanLabel.slice(0, 64);
|
|
972
|
+
warnings.push(
|
|
973
|
+
`Short name for ${trimmedUrl} truncated to 64 characters.`,
|
|
974
|
+
);
|
|
975
|
+
} else {
|
|
976
|
+
candidate.label = cleanLabel;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (
|
|
981
|
+
typeof record.addedAt === "string" &&
|
|
982
|
+
Number.isNaN(Date.parse(record.addedAt)) === false
|
|
983
|
+
) {
|
|
984
|
+
candidate.addedAt = record.addedAt;
|
|
985
|
+
}
|
|
986
|
+
if (
|
|
987
|
+
typeof record.auth === "string" &&
|
|
988
|
+
record.auth.length > 0
|
|
989
|
+
) {
|
|
990
|
+
candidate.auth = record.auth;
|
|
991
|
+
} else if (record.auth !== undefined) {
|
|
992
|
+
warnings.push(`Password for ${trimmedUrl} is not usable; ignoring it.`);
|
|
993
|
+
}
|
|
994
|
+
relays.push(candidate);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
fragment: { mode, enabled, url, relays, hideWidget },
|
|
999
|
+
skipped,
|
|
1000
|
+
warnings,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function copyRelayEntry(r: KnownRelay): KnownRelay {
|
|
1005
|
+
const out: KnownRelay = { url: r.url };
|
|
1006
|
+
if (r.label !== undefined) out.label = r.label;
|
|
1007
|
+
if (r.addedAt !== undefined) out.addedAt = r.addedAt;
|
|
1008
|
+
if (r.auth !== undefined) out.auth = r.auth;
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Plan an import against the current pool without touching disk or prompting.
|
|
1014
|
+
* Merge folds the file pool into the current one via ensureRelay semantics and
|
|
1015
|
+
* keeps the current mode/switch/widget; replace installs the file pool fresh.
|
|
1016
|
+
*/
|
|
1017
|
+
export function planRelayImport(
|
|
1018
|
+
current: RelayState,
|
|
1019
|
+
fragment: RelayImportFragment,
|
|
1020
|
+
opts: { mode: "merge" | "replace" },
|
|
1021
|
+
): RelayImportPlanResult {
|
|
1022
|
+
if (opts.mode === "replace") {
|
|
1023
|
+
const installed: KnownRelay[] = [];
|
|
1024
|
+
const seen = new Set<string>();
|
|
1025
|
+
const skipped: RelayImportSkipped[] = [];
|
|
1026
|
+
const source = fragment.relays ?? [];
|
|
1027
|
+
for (let index = 0; index < source.length; index++) {
|
|
1028
|
+
const entry = source[index];
|
|
1029
|
+
const trimmedUrl =
|
|
1030
|
+
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1031
|
+
if (!trimmedUrl || !validateRelayUrl(trimmedUrl).ok) {
|
|
1032
|
+
skipped.push({
|
|
1033
|
+
index,
|
|
1034
|
+
url: trimmedUrl,
|
|
1035
|
+
reason: "relay address is not usable",
|
|
1036
|
+
});
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (seen.has(trimmedUrl)) {
|
|
1040
|
+
skipped.push({
|
|
1041
|
+
index,
|
|
1042
|
+
url: trimmedUrl,
|
|
1043
|
+
reason: "duplicate relay address",
|
|
1044
|
+
});
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
seen.add(trimmedUrl);
|
|
1048
|
+
installed.push(copyRelayEntry({ ...entry, url: trimmedUrl }));
|
|
1049
|
+
}
|
|
1050
|
+
// Empty file pool installs nothing: fail closed here (not just in the
|
|
1051
|
+
// command handler) so no caller can turn zero usable addresses into a wipe.
|
|
1052
|
+
if (installed.length === 0) {
|
|
1053
|
+
return {
|
|
1054
|
+
next: { ...current, relays: current.relays.map(copyRelayEntry) },
|
|
1055
|
+
added: 0,
|
|
1056
|
+
updated: 0,
|
|
1057
|
+
removed: 0,
|
|
1058
|
+
skipped,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
const installedUrls = new Set(installed.map((r) => r.url));
|
|
1062
|
+
const active =
|
|
1063
|
+
fragment.url && installedUrls.has(fragment.url)
|
|
1064
|
+
? fragment.url
|
|
1065
|
+
: (installed[0]?.url ?? "");
|
|
1066
|
+
return {
|
|
1067
|
+
next: {
|
|
1068
|
+
mode: fragment.mode ?? "auto",
|
|
1069
|
+
enabled: fragment.enabled,
|
|
1070
|
+
url: active,
|
|
1071
|
+
relays: installed,
|
|
1072
|
+
hideWidget: fragment.hideWidget === true,
|
|
1073
|
+
},
|
|
1074
|
+
added: installed.length,
|
|
1075
|
+
updated: 0,
|
|
1076
|
+
removed: current.relays.length - installed.length,
|
|
1077
|
+
skipped,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const next: RelayState = {
|
|
1082
|
+
...current,
|
|
1083
|
+
relays: current.relays.map(copyRelayEntry),
|
|
1084
|
+
};
|
|
1085
|
+
let added = 0;
|
|
1086
|
+
let updated = 0;
|
|
1087
|
+
const skipped: RelayImportSkipped[] = [];
|
|
1088
|
+
const source = fragment.relays ?? [];
|
|
1089
|
+
for (let index = 0; index < source.length; index++) {
|
|
1090
|
+
const entry = source[index];
|
|
1091
|
+
const trimmedUrl =
|
|
1092
|
+
typeof entry?.url === "string" ? entry.url.trim() : "";
|
|
1093
|
+
if (!trimmedUrl) {
|
|
1094
|
+
skipped.push({ index, url: "", reason: "missing relay address" });
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
let relay: KnownRelay | undefined;
|
|
1098
|
+
try {
|
|
1099
|
+
const already = next.relays.some((r) => r.url === trimmedUrl);
|
|
1100
|
+
relay = ensureRelay(next, trimmedUrl, entry.label);
|
|
1101
|
+
if (already) {
|
|
1102
|
+
updated += 1;
|
|
1103
|
+
} else {
|
|
1104
|
+
added += 1;
|
|
1105
|
+
}
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
skipped.push({
|
|
1108
|
+
index,
|
|
1109
|
+
url: trimmedUrl,
|
|
1110
|
+
reason: err instanceof Error ? err.message : "relay address rejected",
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
// File password overwrites; an absent file password keeps the stored one.
|
|
1115
|
+
if (typeof entry.auth === "string" && entry.auth.length > 0) {
|
|
1116
|
+
relay.auth = entry.auth;
|
|
1117
|
+
}
|
|
1118
|
+
if (
|
|
1119
|
+
typeof entry.addedAt === "string" &&
|
|
1120
|
+
Number.isNaN(Date.parse(entry.addedAt)) === false
|
|
1121
|
+
) {
|
|
1122
|
+
relay.addedAt = entry.addedAt;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const mergedUrls = new Set(next.relays.map((r) => r.url));
|
|
1126
|
+
if (!mergedUrls.has(next.url)) {
|
|
1127
|
+
next.url =
|
|
1128
|
+
fragment.url && mergedUrls.has(fragment.url)
|
|
1129
|
+
? fragment.url
|
|
1130
|
+
: (next.relays[0]?.url ?? "");
|
|
1131
|
+
}
|
|
1132
|
+
return { next, added, updated, removed: 0, skipped };
|
|
1133
|
+
}
|