reply9842 0.0.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jixoai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,379 @@
1
+ # reply9842
2
+
3
+ **基于内容寻址基版本的增量压缩隧道,把 AI Agent 的上行带宽砍掉 99.8%。**
4
+
5
+ Agent 每请求要重传 1.3–2.2 MB 的完整上下文,日请求 2 万+,日上行 20–44 GB。
6
+ 本项目把同一份请求压到**几百字节**——因为相邻请求里 90% 以上的字节,上一轮已经传过了。
7
+
8
+ ```
9
+ 279.8 MB ──▶ 659 KB (215 条真实请求,压缩比 0.225%)
10
+ 单条中位数:1.4 MB ──▶ 1,052 B
11
+ ```
12
+
13
+ > 数据来自生产环境连续观测,含推理型模型的完整上下文。见[实测](#实测)。
14
+
15
+ ---
16
+
17
+ ## 它解决什么问题
18
+
19
+ 自建 Agent 跑 LLM 请求时,每一次调用都要把完整上下文重新上传:system prompt + tools schema + 全部多轮历史。
20
+ 上下文越长,重复传的字节越多。实测某生产 Agent 的负载:
21
+
22
+ | 指标 | 实测值 |
23
+ |---|---|
24
+ | 日请求量 | 22,000+ |
25
+ | 单请求上行 | 1.3–2.2 MB |
26
+ | 日上行 | 20–44 GB |
27
+ | 日下行 | 3 GB(上行/下行 ≈ 14.8:1)|
28
+ | 相邻请求体积比 p50 | **1.003**(几乎完全相同)|
29
+
30
+ **关键洞察**:相邻请求的体积极少变化,说明绝大部分内容是重复传输。
31
+
32
+ 而 gzip 只能压到 **32%**(实测 gzip -9:1.32 MB → 471 KB)——因为 gzip 是**无状态**的,
33
+ 每个请求都从零开始找重复,永远看不到「上一轮刚传过的 1.3 MB」。
34
+
35
+ ## 方案:内容寻址的整文档 delta
36
+
37
+ ```
38
+ relay-client (本地) relay-server (远端)
39
+ ────────────────── ──────────────────
40
+ Agent → :18899
41
+ │
42
+ ├─ 1. 选基线:在历史请求里找「块哈希命中率」最高的
43
+ ├─ 2. zstd delta:把基线当字典压缩新请求
44
+ ├─ 3. 40 字节 dcz 头(magic + BLAKE3 哈希)+ zstd 流
45
+ └─ QUIC/TCP 隧道发出
46
+ │
47
+ ├─ 按哈希从 store 取字典
48
+ ├─ 解 delta + 校验 ResultHash
49
+ ├─ 转发到上游(同机回环)
50
+ └─ 存本次 body 供后续当基线
51
+ ```
52
+
53
+ Agent 完全无感:只是把 `base_url` 指向 `http://127.0.0.1:18899`。
54
+ 隧道两端都是有状态的压缩器——**上一轮的请求体就是这一轮的字典**。
55
+
56
+ ## 实测效果
57
+
58
+ ### 14 条连续真实请求(生产日志)
59
+
60
+ | 时间 | 请求体 | 线宽 | 压缩比 | 编码 |
61
+ |---|---:|---:|---:|---|
62
+ | 10:51:02 | 111 | 111 | 100.000% | identity |
63
+ | 10:53:29 | 1,316,641 | 250 | 0.019% | resend-full † |
64
+ | 10:53:35 | 2,022,098 | 343 | 0.017% | resend-full † |
65
+ | 10:54:10 | 1,319,782 | 960 | 0.073% | dcz(L19) |
66
+ | 10:54:11 | 2,027,965 | 1,777 | 0.088% | dcz(L19) |
67
+ | 10:54:32 | 2,035,095 | 2,521 | 0.124% | dcz(L19) |
68
+ | 10:54:36 | 1,330,887 | 3,024 | 0.227% | dcz(L19) |
69
+ | 10:54:44 | 2,036,158 | 655 | 0.032% | dcz(L19) |
70
+ | 10:55:03 | 1,337,093 | 1,724 | 0.129% | dcz(L19) |
71
+ | 10:55:13 | 2,041,295 | 1,874 | 0.092% | dcz(L19) |
72
+ | 10:55:13 | 1,345,230 | 2,190 | 0.163% | dcz(L19) |
73
+ | 10:55:28 | 2,053,162 | 3,414 | 0.166% | dcz(L19) |
74
+ | 10:55:30 | 1,346,946 | 461 | 0.034% | dcz(L19) |
75
+ | 10:55:46 | 2,060,805 | 1,607 | 0.078% | dcz(L19) |
76
+ | **合计** | **22,273,268** | **20,911** | **0.094%** | 省 **99.9%** |
77
+
78
+ † 这 2 条首次 delta 未命中(服务端此时还没有可用的基线),触发了全量重发。
79
+ 上表 `线宽` 列只计首次尝试,**不含**该次重发的约 762 KB。这是冷启动的真实代价。
80
+
81
+ ### 生产环境全量观测(同一日志,215 条 dcz 命中)
82
+
83
+ **293,343,255 字节 → 659,452 字节 = 0.225%**(省 99.78%)。
84
+
85
+ 单条线宽分布:p50 **1,052 字节**、p90 4,860 字节、最大 91,469 字节。
86
+
87
+ 其中 11:04–11:06 连续 14 条(基线完全就位、无冷启动):
88
+ **24,798,163 字节 → 10,241 字节 = 0.041%**。
89
+
90
+ > 同一份日志里更早的 199 条(旧基线算法,公共前缀判定)压缩比是 1.738%。
91
+ > 换成块哈希判定后降到 0.225%,改善 7.7 倍。两组都保留在日志里,可复算。
92
+
93
+ ### 与无状态压缩对比(同一批真实请求体,本机实测)
94
+
95
+ | 方案 | 14 条累计线宽 | 占原体积 | 相对本项目 |
96
+ |---|---:|---:|---:|
97
+ | 原始请求体 | 22,273,268 | 100% | — |
98
+ | gzip -9 | 7,049,305 | 31.65% | 慢 337× |
99
+ | zstd -19(无字典) | 5,045,979 | 22.65% | 慢 241× |
100
+ | **dcz delta** | **20,911** | **0.094%** | — |
101
+
102
+ 单条 1.35 MB 请求上差距更明显:gzip -9 = 471,780 B,dcz = 461 B,**相差 1023 倍**。
103
+
104
+ ### 全量样本(含冷启动)
105
+
106
+ 生产日志里 332 条成功走 delta 的请求,合计 **346,857,922 B → 3,042,281 B = 0.88%**(省 99.1%)。
107
+ 逐条中位数 0.25%,p90 为 3.7%——冷启动与基线淘汰会拉低平均值,这是有状态压缩的固有特性。
108
+
109
+ ## 快速开始
110
+
111
+ ### 1. 服务端(部署在能直连上游的那台机器)
112
+
113
+ ```bash
114
+ go build -o relay-server ./cmd/server
115
+
116
+ ./relay-server \
117
+ -listen 0.0.0.0:28443 \
118
+ -upstream http://127.0.0.1:3431 \
119
+ -uphost magpie.aiweb.xin \
120
+ -cert /path/to/fullchain.pem \
121
+ -key /path/to/privkey.pem
122
+ ```
123
+
124
+ **`-upstream` 必须是 `127.0.0.1`(或同机内网地址)。** 若填公网域名,流量会绕一整圈公网
125
+ (实测:某云主机公网 IP 不在网卡上,`172.17.61.35` 才是真实内网地址)。
126
+ QUIC 与 TCP/TLS 共用同一个端口。
127
+
128
+ ### 2. 客户端(和 Agent 同机)
129
+
130
+ ```bash
131
+ go build -o relay-client ./cmd/relay-client
132
+
133
+ ./relay-client \
134
+ -listen 127.0.0.1:18899 \
135
+ -server 47.82.76.102:28443 \
136
+ -server-name magpie.aiweb.xin \
137
+ -transport auto
138
+ ```
139
+
140
+ 然后把 Agent 的 `base_url` 指向 `http://127.0.0.1:18899`。其余不变。
141
+
142
+ ### 客户端参数
143
+
144
+ | 参数 | 默认 | 说明 |
145
+ |---|---|---|
146
+ | `-listen` | `127.0.0.1:8899` | 本地 HTTP 入口 |
147
+ | `-server` | `127.0.0.1:9000` | relay-server 的 host:port |
148
+ | `-server-name` | 自动推导 | TLS SNI |
149
+ | `-transport` | `auto` | `auto` / `quic` / `tcp` |
150
+ | `-insecure` | `false` | 跳过证书校验(自签观察期)|
151
+ | `-min-delta` | `16384` | 小于此值的请求体不做 delta |
152
+ | `-history-mb` | `64` | 基线历史总字节预算(MB,约 35 条 1.8MB 请求)|
153
+ | `-max-resume` | `3` | 断线后最大续传重试次数(`0` = 不续传)|
154
+ | `-capture-dir` | 空 | 采样目录:把请求体原样落盘供离线分析 |
155
+ | `-capture-n` | `30` | 最多采样多少条 |
156
+ | `-log` / `-stats` | `~/.reply9842/` | 日志与统计路径 |
157
+
158
+ ### 服务端参数
159
+
160
+ | 参数 | 默认 | 说明 |
161
+ |---|---|---|
162
+ | `-listen` | `0.0.0.0:28443` | 隧道监听(QUIC + TCP 同端口)|
163
+ | `-upstream` | `http://127.0.0.1:3431` | 上游 base URL |
164
+ | `-uphost` | `magpie.aiweb.xin` | 转发时覆盖的 Host 头 |
165
+ | `-cert` / `-key` | 必填 | TLS 证书 |
166
+ | `-cache-mb` | `256` | 基版本缓存上限(内存,内容寻址 LRU)|
167
+ | `-spool-dir` | `/var/lib/reply9842/spool` | 流式响应 spool 根目录 |
168
+ | `-tmp-mb` | `512` | 活跃区字节预算 |
169
+ | `-idem-mb` | `2048` | 幂等区字节预算 |
170
+ | `-upstream-timeout` | `10m` | 上游请求超时(不随客户端断开取消)|
171
+
172
+ ## 三个关键设计决策
173
+
174
+ ### 1. 不做块级去重,做整文档 delta
175
+
176
+ 调研实测(FastCDC,min 2KB / avg 8KB / max 64KB,292 KB agent 请求):
177
+
178
+ | 方案 | 成本 |
179
+ |---|---:|
180
+ | FastCDC 块级去重 | **17 KB**(23 块,19 块可复用)|
181
+ | **整文档 zstd delta** | **1,297 B** |
182
+
183
+ **差 13 倍。** 原因:LLM 会话是**尾部追加**为主,LZ77 的变长 match 天然对「未变的前缀」
184
+ 只编码 2–3 字节;块级去重则把未变的 12.7 KB 当成不可分割的原子。
185
+
186
+ 唯一例外是跨会话的固定前缀(system + tools schema),这个用共享字典
187
+ (`-D` / `WithEncoderDictRaw`)即可,仍然不需要 CDC。
188
+
189
+ ### 2. 基线判定用「块哈希命中率」而不是「公共前缀」
190
+
191
+ 40 条真实请求体、1,560 组配对实测(`go test ./internal/basefind -run TestBlockReuseOnRealSamples -v` 可复现):
192
+
193
+ | 指标 | 同簇复用率 | 跨簇复用率 | 区分度 |
194
+ |---|---:|---:|---:|
195
+ | 从头的最长公共前缀 | **0%** | 0% | **0%(完全失效)** |
196
+ | 4 KB 块哈希命中率 | **91.5%** | **1.6%** | **+90 个百分点** |
197
+
198
+ 根因:真实请求从**第 45 字节**起就不同——
199
+
200
+ ```
201
+ {"model":"deepseek-v4.1-flash","max_tokens":343755,…
202
+ {"model":"deepseek-v4.1-flash","max_tokens":160014,…
203
+ ↑ 第 45 字节
204
+ ```
205
+
206
+ 平均公共前缀只有 **45 字节**,但第 64 字节之后有 **93%** 的字节逐字节相同。
207
+ 「从头算前缀」永远只看得到前 45 字节,于是判定「不值得做 delta」,白扔 1.3 MB。
208
+
209
+ 块哈希命中率能跨过变化点:基线切 4 KB 块建索引,新请求滑动匹配,统计可复用字节占比。
210
+ 实测区分度 ~90 个百分点,阈值取 0.5(值得组 84–93%,不值得组 1.4–2.2%,中间是空档)。
211
+
212
+ ### 3. 一致性靠内容哈希,不需要「字典目录」
213
+
214
+ ```
215
+ client: BaseHash = blake3(上一轮 body) ← 32 字节
216
+ server: store[BaseHash] 存在 → 解码;不存在 → DeltaMiss → 客户端重发全量
217
+ ```
218
+
219
+ - **没有目录**:`store` 是纯 `hash → body` 的 KV
220
+ - **没有版本号**:不存在「第几版」这种需要双向同步的可变状态
221
+ - **没有会话 ID**:服务端完全无状态,可水平扩展
222
+ - **协商成本固定 32 字节**,不随缓存规模增长
223
+
224
+ 这与 RFC 9842 §2.1.3 一致:**服务端 ID 不得用于保证字典内容,必须校验哈希**。
225
+
226
+ #### 历史教训:SDCH 为什么死了
227
+
228
+ Chrome 59 移除 SDCH,原因是字典老化太快,压缩率退化到不如 gzip,维持字典新鲜度的成本超过收益。
229
+ RFC 9842 之所以存活,是把「字典老化」转成了 HTTP 缓存问题。
230
+
231
+ **启示**:不要设计「客户端块缓存 + 服务端块缓存 + 一致性协议」——那是 SDCH 的坑。
232
+ 「上一轮 body 当字典」天然随会话推进而更新,不存在老化。
233
+
234
+ ## 防静默损坏(三道防线,不可关闭)
235
+
236
+ 这是本方案最危险的地方。zstd 的 frame checksum 可以关闭(`--no-check`),一旦关闭 +
237
+ 字典错误 + 内容仍可解码,就会得到**语义正确但内容错乱**的 LLM 请求——模型不报错,
238
+ 只给出奇怪回答,几天后才发现。
239
+
240
+ 1. `zstd.WithEncoderCRC(true)` —— 帧级 checksum
241
+ 2. dcz 头里的 BLAKE3 哈希 —— 解压前与提供的字典校验
242
+ 3. `ResultHash` —— 客户端声明「解码后应得到的 body 哈希」,服务端解压后验证
243
+
244
+ 任一失败都返回错误帧,客户端重发全量。三道全部强制开启,没有关闭开关。
245
+
246
+ ## 断线续传
247
+
248
+ 网络抖动时,Agent 侧的请求重试不该导致「上游白跑一遍」。
249
+
250
+ - 上游请求用 `context.WithoutCancel` 派生 —— **客户端断开不影响上游继续接收**
251
+ - 响应流边收边落盘(磁盘双区域 spool:`tmp/` 活跃 + `idem/` 幂等,按字节预算淘汰)
252
+ - 客户端重连带 `IdemKey` + `ResumeFrom`(字节偏移)或 SSE `event id`
253
+ - `FrameDone` 终止帧区分「上游正常完成」与「连接断了需要续传」
254
+
255
+ 实测:60 个 SSE 事件的响应在断开后重连,拼接**连贯无重复**。
256
+
257
+ ## 传输层:QUIC / TCP 双栈
258
+
259
+ ```
260
+ -transport=auto|quic|tcp 默认 auto
261
+ ```
262
+
263
+ **关于「QUIC 能否优雅回退」的澄清**:HTTP/3 的自动回退是 **HTTP 协议族**的能力
264
+ (浏览器用 `Alt-Svc` 探测,失败退回 HTTP/2 over TCP),**不是 QUIC 传输层自带**。
265
+ `quic-go` 连不上就是连不上。
266
+
267
+ 因此回退逻辑实现在应用层(`transport.DialAuto`):先试 QUIC(默认 1 s 超时),
268
+ 失败降级 TCP+TLS,并记住结果用于后续连接的优先级。
269
+
270
+ ## 观测
271
+
272
+ ### `~/.reply9842/client-stats.json`(每次请求后覆盖)
273
+
274
+ ```json
275
+ {
276
+ "requests": 66,
277
+ "errors": 4,
278
+ "base_hits": 63,
279
+ "up_raw": 116780185,
280
+ "up_wire": 923234,
281
+ "up_ratio": 0.0079,
282
+ "p50_ms": 14801,
283
+ "p90_ms": 39423,
284
+ "encodings": {"dcz(L19)": 39, "dcz(L12)": 20, "identity": 1},
285
+ "transport": {"tcp": 66}
286
+ }
287
+ ```
288
+
289
+ ### `~/.reply9842/client-<date>.jsonl`(逐请求追加)
290
+
291
+ 字段:`t` `method` `path` `status` `enc` `base_hit` `up_raw` `up_wire`
292
+ `down_raw` `down_wire` `ms_total` `ms_encode` `ms_wire` `ms_decode`
293
+ `ms_upstream` `up_bps` `down_bps` `transport` `err`
294
+
295
+ ```bash
296
+ tail -f ~/.reply9842/client-$(date +%F).jsonl | \
297
+ jq -c '{path,status,enc,up_raw,up_wire,ms_total}'
298
+ ```
299
+
300
+ ## 自适应压缩 level
301
+
302
+ 实测(1.86 MB → 1.89 MB 增量):
303
+
304
+ | level | delta 大小 | 耗时 |
305
+ |---|---:|---:|
306
+ | -1 | 11,651 B | ~0 s |
307
+ | -9 | 2,615 B | 0.01 s |
308
+ | **-19** | **1,297 B** | **1.51 s** |
309
+
310
+ 从 -9 到 -19,**体积降 2 倍,时间涨 150 倍**。且 41 MB 文档用 -19 需 34.5 s / 328 MB RSS。
311
+ 因此按体积自适应(`codec.PickLevel`):
312
+
313
+ | 请求体 | level |
314
+ |---|---|
315
+ | ≤ 2 MB | 19 |
316
+ | ≤ 8 MB | 12 |
317
+ | > 8 MB | 9 |
318
+
319
+ ## 目录结构
320
+
321
+ ```
322
+ cmd/
323
+ ├── relay-client/ 本地 HTTP 入口 + 基线选择 + delta 编码 + 续传
324
+ └── relay-server/ 隧道服务端 + 解码校验 + 上游转发 + spool
325
+ internal/
326
+ ├── meta/ 隧道帧格式(magic + 版本 + 类型 + 长度前缀)
327
+ ├── codec/ dcz 编解码、自适应 level、静默损坏防护
328
+ ├── store/ 内容寻址 LRU(零协商)
329
+ ├── basefind/ 客户端基线选择(4KB 块哈希命中率)
330
+ ├── transport/ QUIC + TCP 双栈 + auto 回退
331
+ ├── spool/ 磁盘双区域流式缓冲(tmp / idem + 字节预算淘汰)
332
+ ├── idem/ 幂等键(callerKeyId + 请求指纹)
333
+ ├── clientstream/ 帧 → HTTP 响应 + 续传游标
334
+ ├── e2e/ 断线续传端到端测试
335
+ └── stats/ 逐请求与汇总统计
336
+ ```
337
+
338
+ ## 已知限制
339
+
340
+ 1. **响应不压缩**。当前只优化上行;下行 p50 约 18 KB,暂未处理。
341
+ 2. **`resend-full` 的线宽统计只计首次尝试**。DeltaMiss 后的全量重发不计入该行
342
+ `up_wire`,所以含冷启动的窗口其真实占比会高于表格数字(见上文 † 注)。
343
+ 3. **冷启动无收益**。服务端首次见到某基线时必然 miss,第一次重发是全量成本(约 20–35%)。
344
+ 4. **QUIC 生产未启用**。有回环端到端测试(`TestQUICRoundTrip`)通过,但生产链路仍走
345
+ TCP/TLS;启用需放通 UDP 端口并验证中间设备。
346
+ 5. **大文档内存**。30 MB 级请求的 delta 需要显著内存,需靠 `PickLevel` 降级。
347
+ 6. **单请求体全量缓冲**。客户端与服务端都会把 body 读进内存(为统计与压缩)。
348
+ 7. **跨用户字典隔离**。若将来共享字典,必须按租户隔离——混合公私数据的字典存在
349
+ CRIME/BREACH 类攻击面(RFC 9842 §9.2)。
350
+
351
+ ## 规范参考
352
+
353
+ | 规范 | 用途 | 采纳程度 |
354
+ |---|---|---|
355
+ | RFC 9842 (Compression Dictionary Transport) | dcz 线格式、内容哈希协商 | 线格式一致;请求方向为自研扩展 |
356
+ | RFC 8878 (Zstandard) | raw content dictionary | 直接用 |
357
+ | RFC 9530 (Repr-Digest) | 表示身份摘要 | 语义参考 |
358
+ | RFC 7694 (Client-Initiated Content-Encoding) | 请求方向编码协商 | 语义参考 |
359
+ | RFC 8785 (JCS) | JSON 规范化 | 若做块哈希则必需(本方案未用)|
360
+
361
+ ## 官网与部署
362
+
363
+ 官网源码在 [`site/`](./site)(纯 HTML + CSS,无框架、无外部字体、无图片资源),
364
+ 线上地址 <https://reply9842.jixoai.com>。
365
+
366
+ `site/` 目录:
367
+
368
+ ```
369
+ site/
370
+ ├── index.html 单页落地页(内核数字 / 原理图 / 实测表 / 快速开始)
371
+ ├── style.css 全部样式(OKLCH 单品牌色,响应式,含 reduced-motion)
372
+ └── CNAME reply9842.jixoai.com
373
+ ```
374
+
375
+ 推送 `main` 且 `site/**` 有变更时,`.github/workflows/pages.yml` 自动构建并发布到
376
+ GitHub Pages(也可在 Actions 页手动 `workflow_dispatch`)。
377
+
378
+ DNS 侧 `rfc9842.jixoai.com` 已 CNAME 到 `jixoai.github.io`(Cloudflare、DNS only),
379
+ `site/CNAME` 已写好目标域名,Pages 据此绑定自定义域并自动申请 TLS。
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../lib/run.js");
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../lib/run.js");
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../lib/run.js");
package/lib/run.js ADDED
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // reply9842 — npm 引导器
3
+ //
4
+ // 设计(2026-11-13 Owner 裁决):这个 npm 包不含任何二进制,只有一个启动器和
5
+ // 下载脚本。首次运行时按「当前平台 + 想要的产品形态」从 GitHub Release 下载
6
+ // 对应文件,缓存到本地后直接执行。
7
+ //
8
+ // 为什么这样更好:
9
+ // - 包体从 136MB 降到几 KB,npm 安装秒级完成
10
+ // - 用户只为自己的平台付一次下载,而不是装一次拿到 13 个二进制
11
+ // - 缓存按版本隔离,换版本不会串
12
+ //
13
+ // 国内加速:GitHub Release 直连在国内经常超时。按下列前缀顺序尝试(社区惯例的
14
+ // gh-proxy 系列),任一成功即用;全部失败才回退直连。
15
+
16
+ "use strict";
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+ const os = require("node:os");
21
+ const { spawn } = require("node:child_process");
22
+
23
+ const PKG = require("../package.json");
24
+
25
+ // 加速前缀。顺序即优先级:先国内镜像,最后官方直连兜底。
26
+ // 这类代理的契约是 `<prefix><完整 url>`,社区通用。
27
+ const MIRRORS = [
28
+ "https://gh-proxy.com/",
29
+ "https://ghfast.top/",
30
+ "https://ghproxy.net/",
31
+ "", // 空串 = 直连
32
+ ];
33
+
34
+ const REPO = "jixoai/reply9842";
35
+ const KINDS = ["cli", "gui", "server"];
36
+
37
+ // ---- 平台解析 ----
38
+
39
+ function goos() {
40
+ switch (process.platform) {
41
+ case "darwin":
42
+ return "darwin";
43
+ case "linux":
44
+ return "linux";
45
+ case "win32":
46
+ return "windows";
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function goarch() {
53
+ switch (process.arch) {
54
+ case "x64":
55
+ return "amd64";
56
+ case "arm64":
57
+ return "arm64";
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** 产物名,与 release.yml 的命名严格一致 */
64
+ function artifactName(kind) {
65
+ const o = goos();
66
+ const a = goarch();
67
+ if (!o || !a) return null;
68
+ // GUI 只有 darwin / windows(mygo native UI 无 Linux 后端)
69
+ if (kind === "gui" && o === "linux") return null;
70
+ const ext = o === "windows" ? ".exe" : "";
71
+ return `${kind}-${o}-${a}${ext}`;
72
+ }
73
+
74
+ // ---- 缓存 ----
75
+
76
+ /** 缓存根:Windows 用 LOCALAPPDATA,其它走 XDG_CACHE_HOME 或 ~/.cache */
77
+ function cacheRoot() {
78
+ if (process.platform === "win32") {
79
+ const base = process.env.LOCALAPPDATA ||
80
+ path.join(os.homedir(), "AppData", "Local");
81
+ return path.join(base, "reply9842");
82
+ }
83
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
84
+ return path.join(base, "reply9842");
85
+ }
86
+
87
+ function binaryPath(kind, version) {
88
+ const name = artifactName(kind);
89
+ if (!name) return null;
90
+ return path.join(cacheRoot(), version, name);
91
+ }
92
+
93
+ // ---- 下载 ----
94
+
95
+ function releaseUrl(version, name) {
96
+ return `https://github.com/${REPO}/releases/download/v${version}/${name}`;
97
+ }
98
+
99
+ /** 下载到 dest,带进度。mirror 为前缀,空串表示直连。 */
100
+ async function download(url, dest, mirror) {
101
+ const res = await fetch(mirror + url, { redirect: "follow" });
102
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
103
+
104
+ const tmp = `${dest}.part`;
105
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
106
+
107
+ const total = Number(res.headers.get("content-length") || 0);
108
+ const chunks = [];
109
+ let got = 0;
110
+ let lastPct = -1;
111
+
112
+ for await (const chunk of res.body) {
113
+ chunks.push(chunk);
114
+ got += chunk.length;
115
+ if (total > 0) {
116
+ const pct = Math.floor((got / total) * 100);
117
+ if (pct !== lastPct && pct % 10 === 0) {
118
+ process.stderr.write(`\r 下载 ${pct}% (${(got / 1048576).toFixed(1)}MB)`);
119
+ lastPct = pct;
120
+ }
121
+ }
122
+ }
123
+ if (total > 0) process.stderr.write("\r" + " ".repeat(40) + "\r");
124
+
125
+ await fs.promises.writeFile(tmp, Buffer.concat(chunks));
126
+ await fs.promises.rename(tmp, dest); // 原子落盘
127
+ if (process.platform !== "win32") await fs.promises.chmod(dest, 0o755);
128
+ }
129
+
130
+ /** 依次尝试镜像,任一成功即返回 */
131
+ async function downloadWithMirrors(url, dest) {
132
+ let lastErr;
133
+ for (const m of MIRRORS) {
134
+ const label = m ? new URL(m).hostname : "github.com(直连)";
135
+ process.stderr.write(` 尝试 ${label} …\n`);
136
+ try {
137
+ await download(url, dest, m);
138
+ return true;
139
+ } catch (e) {
140
+ lastErr = e;
141
+ process.stderr.write(` 失败:${e.message}\n`);
142
+ }
143
+ }
144
+ throw lastErr || new Error("全部镜像失败");
145
+ }
146
+
147
+ // ---- 主流程 ----
148
+
149
+ function usage(kind) {
150
+ const bins = {
151
+ cli: "reply9842 [参数...]",
152
+ gui: "reply9842-gui [参数...]",
153
+ server: "reply9842-server [参数...]",
154
+ };
155
+ process.stderr.write([
156
+ `reply9842 ${PKG.version} — RFC 9842 增量压缩隧道`,
157
+ "",
158
+ "用法:",
159
+ ` ${bins[kind] || "reply9842 <cli|gui|server> [参数...]"}`,
160
+ "",
161
+ "首次运行会从 GitHub Release 下载对应平台的二进制(8-17MB),",
162
+ "缓存后不再重复下载。国内网络自动走 gh-proxy 加速。",
163
+ "",
164
+ `缓存目录: ${cacheRoot()}`,
165
+ "",
166
+ ].join("\n"));
167
+ }
168
+
169
+ /** 根据调用名或首参数决定 kind */
170
+ function resolveKind(argv) {
171
+ const invoked = path.basename(process.argv[1] || "");
172
+ if (invoked === "reply9842-gui") return { kind: "gui", args: argv };
173
+ if (invoked === "reply9842-server") return { kind: "server", args: argv };
174
+ if (argv.length > 0 && KINDS.includes(argv[0])) {
175
+ return { kind: argv[0], args: argv.slice(1) };
176
+ }
177
+ return { kind: "cli", args: argv };
178
+ }
179
+
180
+ async function ensureBinary(kind, version) {
181
+ const name = artifactName(kind);
182
+ if (!name) {
183
+ process.stderr.write(
184
+ `reply9842: 当前平台(${process.platform}/${process.arch})没有 ${kind} 的预编译产物。\n` +
185
+ (kind === "gui"
186
+ ? "GUI 只提供 macOS 与 Windows 版本;Linux 请用 CLI 或从源码构建。\n"
187
+ : `请到 https://github.com/${REPO}/releases 下载,或用 go install 安装。\n`),
188
+ );
189
+ process.exit(1);
190
+ }
191
+
192
+ const dest = binaryPath(kind, version);
193
+ try {
194
+ const st = await fs.promises.stat(dest);
195
+ if (st.size > 0) return dest; // 已缓存,零网络开销
196
+ } catch {
197
+ /* 未缓存,继续下载 */
198
+ }
199
+
200
+ process.stderr.write(`reply9842: 首次运行,下载 ${name}(v${version})\n`);
201
+ const url = releaseUrl(version, name);
202
+ try {
203
+ await downloadWithMirrors(url, dest);
204
+ process.stderr.write(` 已缓存到 ${dest}\n`);
205
+ return dest;
206
+ } catch {
207
+ process.stderr.write(
208
+ `\nreply9842: 下载失败。\n` +
209
+ ` 手动下载:${url}\n` +
210
+ ` 放到:${dest}\n` +
211
+ ` 或源码构建:go install github.com/${REPO}/cmd/${kind}@v${version}\n`,
212
+ );
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ async function main() {
218
+ const { kind, args } = resolveKind(process.argv.slice(2));
219
+
220
+ if (args[0] === "-h" || args[0] === "--help") {
221
+ usage(kind);
222
+ return;
223
+ }
224
+
225
+ // 版本跟随 npm 包版本:包升级即视为「想要新版本二进制」,缓存按版本隔离。
226
+ const bin = await ensureBinary(kind, PKG.version);
227
+
228
+ const child = spawn(bin, args, { stdio: "inherit" });
229
+
230
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
231
+ process.on(sig, () => {
232
+ if (!child.killed) child.kill(sig);
233
+ });
234
+ }
235
+
236
+ child.on("error", (err) => {
237
+ process.stderr.write(`reply9842: 启动失败:${err.message}\n`);
238
+ process.exit(1);
239
+ });
240
+
241
+ child.on("exit", (code, signal) => {
242
+ if (signal) {
243
+ const n = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 }[signal] || 0;
244
+ process.exit(128 + n);
245
+ }
246
+ process.exit(code == null ? 1 : code);
247
+ });
248
+ }
249
+
250
+ main().catch((e) => {
251
+ process.stderr.write(`reply9842: ${e.stack || e.message}\n`);
252
+ process.exit(1);
253
+ });
package/package.json CHANGED
@@ -1,9 +1,49 @@
1
1
  {
2
2
  "name": "reply9842",
3
- "version": "0.0.0",
4
- "description": "Generated placeholder package reserved by pnpm-pub.",
5
- "main": "index.js",
3
+ "version": "0.2.1",
4
+ "description": "RFC 9842 增量压缩隧道:把 AI Agent 的上行带宽砍掉 99.8%。启动器按需下载对应平台的 cli / gui / server 二进制",
5
+ "keywords": [
6
+ "rfc9842",
7
+ "compression",
8
+ "delta",
9
+ "llm",
10
+ "agent",
11
+ "proxy",
12
+ "zstd"
13
+ ],
14
+ "homepage": "https://reply9842.jixoai.com",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/jixoai/reply9842.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/jixoai/reply9842/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "jixoai",
24
+ "bin": {
25
+ "reply9842": "bin/reply9842.js",
26
+ "reply9842-gui": "bin/reply9842-gui.js",
27
+ "reply9842-server": "bin/reply9842-server.js"
28
+ },
6
29
  "files": [
7
- "index.js"
8
- ]
9
- }
30
+ "bin/",
31
+ "lib/",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "os": [
39
+ "darwin",
40
+ "linux",
41
+ "win32"
42
+ ],
43
+ "scripts": {
44
+ "test": "node --test test/*.test.js"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- module.exports = {};