@liwu1995/afu-context 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -68
- package/README.zh-CN.md +83 -46
- package/bin/afu.mjs +33 -9
- package/bin/host-autoload.py +328 -0
- package/bin/platform.mjs +7 -2
- package/bin/prebuilt.mjs +2 -2
- package/package.json +2 -6
- package/python/{afu_context_local-0.1.0-py3-none-any.whl → afu_context_local-0.1.2-py3-none-any.whl} +0 -0
- package/release.json +4 -4
- package/src/afu_host_integrations/dsh/dist/index.js +25 -3
- package/src/afu_host_integrations/shared/runtime-client/dist/host-worker.js +14 -4
- package/src/afu_host_integrations/shared/runtime-client/dist/index.js +24 -0
- package/src/afu_host_integrations/shared/runtime-client/dist/protocol.js +16 -3
package/README.md
CHANGED
|
@@ -1,110 +1,116 @@
|
|
|
1
|
-
# AFU Context
|
|
1
|
+
# AFU Context · Local asynchronous context compression
|
|
2
2
|
|
|
3
|
-
**English** · [简体中文](README.zh-CN.md)
|
|
3
|
+
**English** · [简体中文](README.zh-CN.md) · [npm](https://www.npmjs.com/package/@liwu1995/afu-context) · [Install](#install) · [Results](#results) · [Dashboard](#dashboard)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
In a long session, the same tool output can travel with every model request. AFU uses the host’s selected model to summarize completed history in the background. Once ready, the summary can replace that history on a later request while preserving newer tool calls and messages.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## How AFU compacts a session
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| When | What AFU does |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| A session reaches its threshold | Prepares a summary in the background while the session continues. |
|
|
12
|
+
| The host next needs the history | Checks that the covered prefix still matches before replacing it. |
|
|
13
|
+
| After replacement | Records a successful upstream response or a host commit so the dashboard can distinguish generation from application. |
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
<a id="install"></a>
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
npx @liwu1995/afu-context install # detects installed hosts; asks when multiple are found
|
|
15
|
-
# Non-interactive: npx @liwu1995/afu-context install claude
|
|
16
|
-
```
|
|
17
|
+
## Quick start
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
Install and sign in to your host first. You need npm and Node.js **22.19+ (22.x) or 24+**. Pick one row, run the install command in your terminal, then use the launch command.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
| Host | Integration | Launch | Validated compatibility |
|
|
21
|
+
| Host | System | Install | Launch after installation |
|
|
23
22
|
| --- | --- | --- | --- |
|
|
24
|
-
| Claude Code |
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
23
|
+
| <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install claude` | `claude` |
|
|
24
|
+
| Claude Code | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "@liwu1995/afu-context@0.1.2" install claude` | `claude` |
|
|
25
|
+
| <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex` | `codex` |
|
|
26
|
+
| <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install pi` | `pi` |
|
|
27
|
+
| <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
The macOS packages target Apple Silicon (ARM64), and the Linux packages target x64, and both include Python. On macOS, Claude Code and Codex were tested end to end with real models (install, chat with compaction, upgrade, remove, launchd autostart). On Linux, Claude Code was tested with a real model and Codex with a local fixture; user-systemd autostart was not tested. Start a new session for Claude Code or Codex. Pi loads the extension in new sessions. Replace `YOUR_PROFILE` with the DSH profile to integrate, such as `headless` or `web`. For web, you can also launch with `dsh web`. To remove just one profile, run `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 remove dsh --profile YOUR_PROFILE`.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
¹ On Windows, use PowerShell, Python **3.11+** and a Claude Code version with PowerShell hook support. Native Claude Code is implemented and awaits Windows testing. Native Codex is unsupported, and Pi and DSH are unverified on Windows.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
afu status
|
|
35
|
-
afu status claude --json
|
|
36
|
-
afu ui --open
|
|
37
|
-
```
|
|
33
|
+
The commands pin **0.1.2** from the public npm registry so installation does not depend on a mirror carrying the latest release. If npm reports `with a date before`, check `before` and `min-release-age`. This version was published at **PUBLISHED_AT**. Retry after a rolling cooldown expires. For a fixed cutoff, the effective policy must allow that publication date.
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
<details>
|
|
36
|
+
<summary>Linux manual startup and Pi / DSH launch options</summary>
|
|
40
37
|
|
|
41
|
-
|
|
38
|
+
On Linux without user systemd, append `--no-autostart` to the Claude Code or Codex install command. Run it again after a reboot to start the gateway.
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
For Pi **0.84.4**, pass `--provider` and `--model` on the Pi command line. DSH was tested with **0.1.2-rc.1** and Cordis **4.0.2**. If a web profile reports an incompatible HMR API, run `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 dsh --web-compat -- --profile web`.
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
afu remove claude # substitute codex, pi or dsh
|
|
47
|
-
```
|
|
42
|
+
</details>
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
<details>
|
|
45
|
+
<summary>Known issues and network notes</summary>
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
- npm prints `EBADENGINE` on Node.js 22.18 or older. The requirement is 22.19+ (22.x) or 24+; upgrade Node.js before installing.
|
|
48
|
+
- If the host reaches its model through a proxy, run the install command with the same `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` environment. The autostart service records these variables (and `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`) at install time; rerun the install after they change.
|
|
49
|
+
- Codex through AFU occasionally stalls for about three minutes before reconnecting. The cause is not yet confirmed.
|
|
50
|
+
- Removal restores your original file byte for byte when you made no other change. If you edited the file after installing, removal reverts only the AFU entries and keeps your edits.
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
- `AFU_RUNTIME_DIR`: isolated Python runtime cache.
|
|
55
|
-
- `AFU_STATE_DIR`: integration receipts and metadata logs.
|
|
56
|
-
- `afu install claude --context-window 200000`: explicit CC window; substitute Codex where appropriate. A changed build or explicit configuration creates a healthy new generation before switching future sessions; existing sessions keep their original endpoint.
|
|
57
|
-
- Pi/DSH follow the selected model's declared window. Default trigger ratio: 40% at 1M or above, 50% otherwise. Host metadata unavailable means AFU yields to native behavior.
|
|
58
|
-
- `--config-dir` and `--no-autostart` support disposable native sandbox tests; do not substitute hooks-only tests for production service-manager acceptance.
|
|
52
|
+
</details>
|
|
59
53
|
|
|
60
|
-
|
|
54
|
+
<a id="results"></a>
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
## Benchmark results
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
In a LongBench V2 replay of 39 history questions (20 agent histories and 19 dialogue histories), AFU’s asynchronous strategy answered **23 correctly** with an average context of **15.9k tokens**.
|
|
65
59
|
|
|
66
|
-
|
|
60
|
+
| Replay strategy | First-answer accuracy | Mean context tokens |
|
|
61
|
+
| --- | ---: | ---: |
|
|
62
|
+
| No compaction | 19/39 (48.72%) | 72.6k |
|
|
63
|
+
| Codex Compact Sync | 20/39 (51.28%) | 21.7k |
|
|
64
|
+
| DSH Compact Sync | 18/39 (46.15%) | 26.1k |
|
|
65
|
+
| Pi Compact Sync | 13/39 (33.33%) | 21.6k |
|
|
66
|
+
| **AFU Compact Async** | **23/39 (58.97%)** | **15.9k** |
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
**4.56×** is the ratio of mean context tokens without compaction to those with AFU. The replay used DeepSeek-V3.2 for both answers and summaries, a 131,072-token logical window, and a 20k AFU trigger with a one-turn delay. Synchronous strategies replayed their respective prompts and policies, with at least one compaction for the context-size comparison. This metric excludes answer output and does not measure total API cost or production savings for the npm release.
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
<a id="dashboard"></a>
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
## Web dashboard
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
For Codex, check the integration status, then open the dashboard and select your session. For another host, replace `codex` with `claude`, `pi` or `dsh`.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
```sh
|
|
77
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 status codex
|
|
78
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 ui
|
|
79
|
+
```
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
The dashboard distinguishes **request observed → summary ready → summary applied**. For Claude Code and Codex, application requires a complete successful response to a request carrying the summary. For Pi and DSH it means the host committed the summary to session history. The dashboard also shows summary duration and provider-reported cache usage.
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
**Overview** shows what AFU saved you: estimated tokens saved, recorded conversation wait and how many compactions took effect, followed by your sessions and the state of their latest compaction. Open a **session** to see the context drop after each compaction, how long each one took and what it saved, plus cache hit rates. Integrations, versions and issues live on the **Status** page. The dashboard supports light/dark themes and English/Chinese.
|
|
81
84
|
|
|
82
|
-
|
|
85
|
+
## Models and thresholds
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
All four integrations reuse the conversation’s model and authentication. The default trigger is **40%** for windows of **1,000,000 tokens or more** and **50%** for smaller windows. Pi and DSH follow the host’s selected model and window. Claude Code and Codex query provider metadata after a complete response. When the window is unknown, status reports `manual_window_required` and chat continues.
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
<details>
|
|
90
|
+
<summary>Set a window or ratio, or restore automatic settings</summary>
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
For a Codex model with a known 200,000-token window, the first command sets a 30% trigger. The second restores automatic settings.
|
|
89
93
|
|
|
90
|
-
|
|
94
|
+
```sh
|
|
95
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex --context-window 200000 --ratio 0.3
|
|
96
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex --context-window auto --ratio auto
|
|
97
|
+
```
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
</details>
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
For frequent use, run `npm install -g --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2` and use `afu` in place of the full `npx` invocation. For example, `afu ui` opens the dashboard, and `afu ui --prices ./afu-prices.json` uses your price file to estimate costs.
|
|
102
|
+
|
|
103
|
+
## Upgrade and remove
|
|
104
|
+
|
|
105
|
+
After upgrading a Claude Code or Codex integration, start a new session to use the new gateway. To uninstall, remove the host integration first, then restart the host. Use the appropriate command below.
|
|
96
106
|
|
|
97
107
|
```sh
|
|
98
|
-
afu
|
|
99
|
-
|
|
108
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 upgrade codex
|
|
109
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 remove codex
|
|
100
110
|
```
|
|
101
111
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
command with `--no-autostart`. Do not expect the gateway to restart automatically.
|
|
112
|
+
Use `remove pi` or `remove dsh` for those integrations. In Windows PowerShell, use `npx.cmd` and double-quote the package name.
|
|
113
|
+
|
|
114
|
+
The dashboard listens on loopback. Logs record usage and receipts, and Pi/DSH also persist summaries in local session state. Generating summaries uses model tokens. Savings depend on subsequent reuse and actual cache hits.
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
service file and registration receipt produce `registration_recorded`; this is
|
|
109
|
-
not a live systemd enablement check or proof that a reboot has been tested. Missing
|
|
110
|
-
or edited service files are reported as `registration_file_changed_or_missing`.
|
|
116
|
+
[Apache-2.0](LICENSE)
|
package/README.zh-CN.md
CHANGED
|
@@ -1,79 +1,116 @@
|
|
|
1
|
-
# AFU Context
|
|
1
|
+
# AFU Context · 本地异步上下文压缩
|
|
2
2
|
|
|
3
|
-
[English](README.md) · **简体中文**
|
|
3
|
+
[English](README.md) · **简体中文** · [npm](https://www.npmjs.com/package/@liwu1995/afu-context) · [一键安装](#install) · [评测效果](#results) · [Web 面板](#dashboard)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
长对话里,同一段工具输出可能随每次请求反复发送。AFU 用宿主当前的模型在后台整理已经结束的对话。摘要就绪后,下一次合适的请求可以用摘要替换旧历史,同时保留之后发生的工具调用和新消息。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## AFU 如何压缩对话
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| 时机 | AFU 的行为 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| 达到压缩阈值 | 后台生成摘要,当前会话可以继续。 |
|
|
12
|
+
| 下一次使用历史 | 检查摘要覆盖的对话前缀是否仍然匹配,匹配才替换。 |
|
|
13
|
+
| 使用之后 | 记录模型请求成功回执或宿主提交结果,面板区分已生成与已应用。 |
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
+
<a id="install"></a>
|
|
16
|
+
|
|
17
|
+
## 快速开始
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
先安装并登录要使用的宿主,准备 npm 和 Node.js **22.19+(22.x)或 24+**。选一行,在终端执行安装命令,然后用最后一列的命令启动。
|
|
17
20
|
|
|
18
|
-
|
|
|
21
|
+
| 宿主 | 系统 | 一键安装 | 安装后启动 |
|
|
19
22
|
| --- | --- | --- | --- |
|
|
20
|
-
| Claude Code |
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
23
|
+
| <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install claude` | `claude` |
|
|
24
|
+
| Claude Code | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "@liwu1995/afu-context@0.1.2" install claude` | `claude` |
|
|
25
|
+
| <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex` | `codex` |
|
|
26
|
+
| <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install pi` | `pi` |
|
|
27
|
+
| <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` |
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
macOS 指 Apple Silicon(ARM64),Linux 指 x64,均自带 Python 运行时。macOS 上 Claude Code 和 Codex 已用真实模型完成端到端验证(安装、对话压缩、升级、移除、launchd 自启);Linux 上 Claude Code 用真实模型验证,Codex 用本地模拟服务验证,用户级 systemd 自启尚未验证。Claude Code 和 Codex 安装后新开会话。Pi 扩展在新开的会话自动加载。将 DSH 的 `YOUR_PROFILE` 换成要接入的 profile,例如 `headless` 或 `web`。选择 web 后也可以用 `dsh web` 启动。仅移除某个 profile 时运行 `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 remove dsh --profile YOUR_PROFILE`。
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
¹ Windows 使用 PowerShell,需本机 Python **3.11+** 和支持 PowerShell Hook 的 Claude Code。Windows 原生 Claude Code 已实现,待实机验收。Codex 未支持,Pi 和 DSH 未验收。
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
安装命令固定使用公网 npm 上的 **0.1.2**,避免镜像尚未同步最新版本。如果报错含 `with a date before`,请核对 npm 的 `before` 或 `min-release-age`。此版本发布于 **PUBLISHED_AT**。若使用滚动冷却期,等该版本满足期限后重试;若设置了固定截止日期,需使用允许安装该版本的策略。
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
<details>
|
|
36
|
+
<summary>Linux 手动启动与 Pi / DSH 启动参数</summary>
|
|
37
|
+
|
|
38
|
+
Linux 没有用户级 systemd 时,在 Claude Code 或 Codex 安装命令末尾加 `--no-autostart`,系统重启后重新执行同一命令启动网关。
|
|
39
|
+
|
|
40
|
+
Pi **0.84.4** 可直接传 `--provider` 和 `--model`。DSH 已适配 **0.1.2-rc.1** 和 Cordis **4.0.2**。若 Web profile 提示 HMR API 不兼容,可使用 `npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 dsh --web-compat -- --profile web`。
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
<details>
|
|
45
|
+
<summary>已知问题与网络说明</summary>
|
|
46
|
+
|
|
47
|
+
- Node.js 22.18 及更早版本安装时 npm 会提示 `EBADENGINE`。要求为 22.19+(22.x)或 24+,请先升级 Node.js。
|
|
48
|
+
- 宿主需要通过代理访问模型时,请在同样设置了 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` 的终端执行安装命令。自启服务会在安装时记录这些变量(以及 `SSL_CERT_FILE`、`NODE_EXTRA_CA_CERTS`),变更后需重新执行安装。
|
|
49
|
+
- 经 AFU 使用 Codex 时,偶尔会停顿约三分钟后重连,原因尚未确认。
|
|
50
|
+
- 移除时若你没有改过配置文件,会逐字节恢复原文件;若安装后改过,只撤销 AFU 的条目,保留你的修改。
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
</details>
|
|
38
53
|
|
|
39
|
-
|
|
54
|
+
<a id="results"></a>
|
|
40
55
|
|
|
41
|
-
##
|
|
56
|
+
## 评测效果
|
|
57
|
+
|
|
58
|
+
在 LongBench V2 的 39 道历史问答回放中(20 道 Agent 历史、19 道对话历史),AFU 异步压缩答对 **23 道**,平均上下文为 **15.9k tokens**。
|
|
59
|
+
|
|
60
|
+
| 回放策略 | 首次回答正确率 | 平均上下文 tokens |
|
|
61
|
+
| --- | ---: | ---: |
|
|
62
|
+
| 无压缩 | 19/39 (48.72%) | 72.6k |
|
|
63
|
+
| Codex Compact Sync | 20/39 (51.28%) | 21.7k |
|
|
64
|
+
| DSH Compact Sync | 18/39 (46.15%) | 26.1k |
|
|
65
|
+
| Pi Compact Sync | 13/39 (33.33%) | 21.6k |
|
|
66
|
+
| **AFU Compact Async** | **23/39 (58.97%)** | **15.9k** |
|
|
67
|
+
|
|
68
|
+
**4.56×** 是无压缩与 AFU 的平均上下文 token 之比。回放使用 DeepSeek-V3.2 回答和压缩,逻辑窗口为 131,072 tokens,AFU 在 20k 触发,摘要延迟一轮使用。同步策略按各自提示词和策略回放,比较上下文规模时至少压缩一次。此指标不含回答输出,也不能直接换算成 API 总费用或当前 npm 版本的线上收益。
|
|
69
|
+
|
|
70
|
+
<a id="dashboard"></a>
|
|
71
|
+
|
|
72
|
+
## Web 面板:查看压缩是否生效
|
|
73
|
+
|
|
74
|
+
以 Codex 为例,先看接入状态,再打开面板查看刚才的会话。其它宿主将 `codex` 换成 `claude`、`pi` 或 `dsh`。
|
|
42
75
|
|
|
43
76
|
```sh
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 status codex
|
|
78
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 ui
|
|
46
79
|
```
|
|
47
80
|
|
|
48
|
-
|
|
81
|
+
面板分别记录**请求已接入 → 摘要已生成 → 摘要已应用**。Claude Code 和 Codex 的应用回执表示替换历史后的请求已收到完整成功响应。Pi 和 DSH 的回执表示宿主已把摘要写入会话历史。面板也展示摘要耗时和模型返回的缓存用量。
|
|
49
82
|
|
|
50
|
-
|
|
83
|
+
**总览**先告诉你 AFU 帮你省了多少:节省的 token(估算)、记录到的对话等待时间和生效的压缩次数,下方列出每个会话及其最近一次压缩的状态。点进一个**会话**,可以看到上下文在每次压缩后回落、每次压缩的耗时和节省量,以及缓存命中率。接入情况、版本和异常记录在**运行状态**页查看。面板支持亮色/暗色主题和中英文。
|
|
51
84
|
|
|
52
|
-
##
|
|
85
|
+
## 模型与压缩阈值
|
|
53
86
|
|
|
54
|
-
|
|
55
|
-
- CC/Codex 在主请求结束后异步查询当前模型的供应商元数据;默认窗口 ≥1M 时 40%,其它窗口 50%。未知窗口暂停新压缩,聊天继续。
|
|
56
|
-
- `afu install claude --context-window 200000 --ratio 0.2` 可显式覆盖;`--context-window auto --ratio auto` 恢复自动配置。省略选项保留已有设置,非法值不修改配置。
|
|
57
|
-
- Pi/DSH 跟随宿主当前模型、认证和声明窗口。窗口缺失时保留原生行为。支持 `afu install pi --context-window 200000 --ratio 0.2`,也可换为 dsh;优先级为启动选项、环境变量、保存的配置、自动默认值。阈值使用可压缩工作集的 token 估算,不等同状态栏总用量。
|
|
58
|
-
- `--config-dir`、`--state`、`--no-autostart` 用于独立沙箱或手动管理。不要将 Hook 测试当作系统服务恢复验收。
|
|
87
|
+
四个平台都复用主对话的模型和认证。模型窗口为 **1,000,000 tokens 或更大** 时,默认在 **40%** 触发,更小的窗口按 **50%** 触发。Pi 和 DSH 跟随宿主选择的模型和窗口。Claude Code 和 Codex 在完整响应后查询提供方的窗口信息,查不到时显示 `manual_window_required`,聊天可以继续。
|
|
59
88
|
|
|
60
|
-
|
|
89
|
+
<details>
|
|
90
|
+
<summary>手动指定窗口、调整比例或恢复自动设置</summary>
|
|
61
91
|
|
|
62
|
-
|
|
92
|
+
下面以已知窗口为 200,000 tokens 的 Codex 模型为例。第一条将触发比例设为 30%,第二条恢复自动设置。
|
|
63
93
|
|
|
64
|
-
|
|
94
|
+
```sh
|
|
95
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex --context-window 200000 --ratio 0.3
|
|
96
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 install codex --context-window auto --ratio auto
|
|
97
|
+
```
|
|
65
98
|
|
|
66
|
-
|
|
99
|
+
</details>
|
|
67
100
|
|
|
68
|
-
|
|
101
|
+
如果经常使用 AFU,可运行 `npm install -g --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2`,以后用 `afu` 代替完整的 `npx` 命令。例如,`afu ui` 打开面板,`afu ui --prices ./afu-prices.json` 使用自己的单价文件估算费用。
|
|
69
102
|
|
|
70
|
-
|
|
103
|
+
## 升级与移除
|
|
104
|
+
|
|
105
|
+
升级 Claude Code 或 Codex 接入后,新开会话即可使用新版网关。需要移除时,先撤销对应宿主的接入,再重启宿主。以下两条命令分别用于升级和移除。
|
|
71
106
|
|
|
72
107
|
```sh
|
|
73
|
-
afu
|
|
74
|
-
|
|
108
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 upgrade codex
|
|
109
|
+
npx --yes --registry=https://registry.npmjs.org @liwu1995/afu-context@0.1.2 remove codex
|
|
75
110
|
```
|
|
76
111
|
|
|
77
|
-
|
|
112
|
+
Pi 和 DSH 可分别用 `remove pi` 或 `remove dsh` 撤销。Windows PowerShell 请使用 `npx.cmd` 并将包名放在双引号中。
|
|
113
|
+
|
|
114
|
+
本地面板只监听回环地址。日志记录用量和回执,Pi/DSH 的本地会话状态还会保存摘要。生成摘要本身会调用模型,节省多少取决于后续复用和实际缓存命中。
|
|
78
115
|
|
|
79
|
-
|
|
116
|
+
[Apache-2.0](LICENSE)
|
package/bin/afu.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {spawn} from 'node:child_process';
|
|
4
4
|
import {readFileSync} from 'node:fs';
|
|
5
5
|
import {dirname,join,resolve} from 'node:path';
|
|
6
|
-
import {homedir} from 'node:os';
|
|
6
|
+
import {homedir,constants} from 'node:os';
|
|
7
7
|
import {fileURLToPath} from 'node:url';
|
|
8
8
|
import {guide} from './install-guide.mjs';
|
|
9
9
|
import {prepareRuntime} from './runtime.mjs';
|
|
@@ -15,11 +15,25 @@ const env={...process.env,AFU_INSTALL_ROOT:root};
|
|
|
15
15
|
let args=process.argv.slice(2);
|
|
16
16
|
if(['install','upgrade','status','remove','gc','native'].includes(args[0])){
|
|
17
17
|
const at=args.indexOf('--state');if(at>=0&&args[at+1])env.AFU_STATE_DIR=resolve(args[at+1]);
|
|
18
|
+
const inline=args.find(a=>a.startsWith('--state='));if(inline&&inline.length>8)env.AFU_STATE_DIR=resolve(inline.slice(8));
|
|
18
19
|
}
|
|
19
20
|
let release;
|
|
21
|
+
// Ctrl-C already reaches the child through the shared foreground process group;
|
|
22
|
+
// relaying SIGINT again would deliver it twice. Only forward signals sent to
|
|
23
|
+
// this launcher alone, and only to our own child.
|
|
24
|
+
function relaySignals(child){
|
|
25
|
+
process.on('SIGINT',()=>{});
|
|
26
|
+
for(const signal of ['SIGTERM','SIGHUP'])process.on(signal,()=>child.kill(signal));
|
|
27
|
+
}
|
|
28
|
+
function exitCode(code,signal){
|
|
29
|
+
if(code!==null&&code!==undefined)return code;
|
|
30
|
+
return signal&&constants.signals[signal]?128+constants.signals[signal]:1;
|
|
31
|
+
}
|
|
20
32
|
try{
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
// Only the bare launcher prints its own banner; "afu ui --help" and
|
|
34
|
+
// "afu claude --help" belong to the subcommand or the host.
|
|
35
|
+
if(!args.length || (['--help','-h'].includes(args[0]))){
|
|
36
|
+
console.log('AFU Context: install [claude|codex|pi|dsh], status, remove HOST, gc --dry-run, ui.\nRun npx @liwu1995/afu-context@next install to detect your installed host. Host login remains managed by the host.');
|
|
23
37
|
process.exit(0);
|
|
24
38
|
}
|
|
25
39
|
args=await guide(args,env);
|
|
@@ -36,18 +50,28 @@ try{
|
|
|
36
50
|
if(split<0 && args.length>1)throw Error('PLUGIN_REMOVED: pass host arguments after --');
|
|
37
51
|
// COSEC: fixed host executable, argv array, no shell; no runtime downloads.
|
|
38
52
|
const child=spawn(args[0],split<0?[]:args.slice(split+1),{stdio:'inherit',env,shell:false});
|
|
39
|
-
|
|
53
|
+
relaySignals(child);
|
|
40
54
|
child.on('error',()=>{console.error('AFU: HOST_EXECUTABLE_UNAVAILABLE');process.exitCode=1;});
|
|
41
|
-
child.on('exit',(code,signal)=>{process.exitCode=code
|
|
55
|
+
child.on('exit',(code,signal)=>{process.exitCode=exitCode(code,signal);});
|
|
42
56
|
}else{
|
|
57
|
+
const hostAction = ['pi','dsh'].includes(args[1]) && ['install','upgrade','remove','status'].includes(args[0]);
|
|
58
|
+
// Status always goes through one reporter (it understands autoload receipts,
|
|
59
|
+
// --json and --state); only Pi/DSH install/upgrade/remove need host-autoload.
|
|
60
|
+
const directHost = process.platform!=='win32' && hostAction && args[0]!=='status';
|
|
61
|
+
if (directHost && ['install','upgrade'].includes(args[0])) {
|
|
62
|
+
// Autoload paths must outlive npx eviction and the ordinary launcher cache GC.
|
|
63
|
+
const state = env.AFU_STATE_DIR || join(homedir(), '.local/share/afu-context');
|
|
64
|
+
env.AFU_RUNTIME_DIR = join(state, 'autoload', 'runtimes');
|
|
65
|
+
}
|
|
66
|
+
if (args[0] === 'pi') env.AFU_EXPLICIT_EXTENSION = 'pi';
|
|
43
67
|
release=await holdCache(manifest.build,env);
|
|
44
68
|
const location=await prepareRuntime(root,manifest,env);
|
|
45
69
|
const python=join(location,process.platform==='win32'?'Scripts/python.exe':'bin/python');
|
|
46
70
|
env.AFU_INSTALL_ROOT=await preparePlatform(root,manifest,args,env);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
const entry = directHost ? [join(root,'bin/host-autoload.py')] : ['-m','afu_context.local_cli'];
|
|
72
|
+
const child=spawn(python,[...entry,...args],{stdio:'inherit',env});
|
|
73
|
+
relaySignals(child);
|
|
50
74
|
child.on('error',()=>{release?.();console.error('AFU failed to start');process.exitCode=1;});
|
|
51
|
-
child.on('exit',(code,signal)=>{release?.();if(code===0&&['install','upgrade'].includes(args[0]))console.error(`AFU: installed ${args[1]}; ${['claude','codex'].includes(args[1])?'service checked; open a new '+args[1]+' session':'launch with afu '+args[1]}. Waiting for the first observed request; compression is not yet verified.`);process.exitCode=code
|
|
75
|
+
child.on('exit',(code,signal)=>{release?.();if(code===0&&['install','upgrade'].includes(args[0]))console.error(`AFU: installed ${args[1]}; ${['claude','codex'].includes(args[1])?'service checked; open a new '+args[1]+' session':directHost?'open a new host session':'launch with afu '+args[1]}. Waiting for the first observed request; compression is not yet verified.`);process.exitCode=exitCode(code,signal);});
|
|
52
76
|
}
|
|
53
77
|
}catch(error){release?.();console.error('AFU: '+error.message);process.exitCode=1;}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Install npm-hosted AFU extensions in Pi and DSH's normal discovery paths."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from afu_context import native_install as fs
|
|
14
|
+
from afu_context.diagnostics import state_root
|
|
15
|
+
from afu_context.management import report, validate_policy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(*args, stdin=None):
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
args, input=stdin, capture_output=True, text=True, timeout=30
|
|
21
|
+
)
|
|
22
|
+
if result.returncode:
|
|
23
|
+
raise ValueError("Host configuration could not be loaded")
|
|
24
|
+
return result.stdout
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_owned(file, content, marker):
|
|
28
|
+
if file.is_symlink():
|
|
29
|
+
raise ValueError("Refusing to replace an extension symlink")
|
|
30
|
+
if file.exists() and file.read_text() == content:
|
|
31
|
+
return
|
|
32
|
+
if file.exists() and not file.read_text().startswith(marker):
|
|
33
|
+
raise ValueError("Existing extension belongs to another installation")
|
|
34
|
+
fs.atomic(file, content)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def install_pi(root, adapter, settings, previous):
|
|
38
|
+
agent = (
|
|
39
|
+
Path(os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent"))
|
|
40
|
+
.expanduser()
|
|
41
|
+
.resolve()
|
|
42
|
+
)
|
|
43
|
+
entry = agent / "extensions" / "afu-context.ts"
|
|
44
|
+
if previous and str(entry) != previous["entry"]:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"Remove the previous Pi agent registration before switching directories"
|
|
47
|
+
)
|
|
48
|
+
if (
|
|
49
|
+
entry.exists()
|
|
50
|
+
and previous
|
|
51
|
+
and hashlib.sha256(entry.read_bytes()).hexdigest() != previous["digest"]
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("Pi extension changed since installation; user file preserved")
|
|
54
|
+
marker = "// Managed by AFU " + root.as_uri() + "\n"
|
|
55
|
+
# The explicit launcher supplies its own extension; avoid installing hooks twice.
|
|
56
|
+
body = marker + "import register from " + json.dumps(str(adapter)) + ";\n"
|
|
57
|
+
body += "export default function(pi) {\n if (process.env.AFU_EXPLICIT_EXTENSION === 'pi' || (process.env.AFU_CONTEXT_COMPACT_WORKER_COMMAND && process.argv.some(a => a.endsWith('/afu_host_integrations/pi/dist/index.js')))) return;\n"
|
|
58
|
+
body += (
|
|
59
|
+
" Object.assign(process.env, "
|
|
60
|
+
+ json.dumps(settings)
|
|
61
|
+
+ ");\n register(pi);\n}\n"
|
|
62
|
+
)
|
|
63
|
+
write_owned(entry, body, marker)
|
|
64
|
+
return {"entry": str(entry), "digest": hashlib.sha256(body.encode()).hexdigest()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def profile_manifest(binary, platform, profile):
|
|
68
|
+
args = [binary, "--profile", profile, "--dump-config"]
|
|
69
|
+
tree = run(*args)
|
|
70
|
+
info = json.loads(
|
|
71
|
+
run("node", platform / "scripts/dsh-web-probe.mjs", binary, profile)
|
|
72
|
+
)
|
|
73
|
+
return Path(info["profileDir"]) / "package.json", tree
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def install_dsh(root, platform, adapter, settings, profile, previous):
|
|
77
|
+
binary = shutil.which("dsh")
|
|
78
|
+
manifest, tree = profile_manifest(binary, platform, profile)
|
|
79
|
+
if previous and previous["manifest"] != str(manifest):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"DSH profile directory changed; existing registration preserved"
|
|
82
|
+
)
|
|
83
|
+
profile_dir = manifest.parent
|
|
84
|
+
key = hashlib.sha256(str(profile_dir).encode()).hexdigest()[:20]
|
|
85
|
+
bundle_name = "afu-context-profile-" + key
|
|
86
|
+
bundle = root / "autoload" / key
|
|
87
|
+
entry = bundle / "provider.mjs"
|
|
88
|
+
link = profile_dir / "node_modules" / bundle_name
|
|
89
|
+
marker = "// Managed by AFU " + root.as_uri() + "\n"
|
|
90
|
+
original = manifest.read_text()
|
|
91
|
+
config = json.loads(original)
|
|
92
|
+
bundles = config["dsh"]["profile"]["bundles"]
|
|
93
|
+
deps = config.setdefault("dependencies", {})
|
|
94
|
+
owned_dep = "file:" + str(bundle)
|
|
95
|
+
if link.parent.is_symlink():
|
|
96
|
+
raise ValueError("Cannot register a bundle in a shared node_modules symlink")
|
|
97
|
+
if (link.is_symlink() and link.resolve() != bundle) or (
|
|
98
|
+
link.exists() and not link.is_symlink()
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("Existing DSH bundle directory conflicts with AFU")
|
|
101
|
+
if bundle_name in deps and deps[bundle_name] != owned_dep:
|
|
102
|
+
raise ValueError("DSH bundle dependency has been edited")
|
|
103
|
+
if bundle_name in bundles and deps.get(bundle_name) != owned_dep:
|
|
104
|
+
raise ValueError("DSH bundle is not owned by this installation")
|
|
105
|
+
bundle.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
106
|
+
# Bundle membership is read on host startup. Never edit watched cordis.patch.yml.
|
|
107
|
+
if bundle_name not in bundles:
|
|
108
|
+
patches = run(
|
|
109
|
+
"node",
|
|
110
|
+
platform / "scripts/dsh-overlay.mjs",
|
|
111
|
+
entry,
|
|
112
|
+
bundle,
|
|
113
|
+
binary,
|
|
114
|
+
stdin=tree,
|
|
115
|
+
)
|
|
116
|
+
preset = bundle / "presets.mjs"
|
|
117
|
+
if preset.exists():
|
|
118
|
+
old_arg = ", " + json.dumps(str(bundle)) + ");"
|
|
119
|
+
source = preset.read_text()
|
|
120
|
+
if old_arg not in source:
|
|
121
|
+
raise ValueError("Unsupported preset loader format")
|
|
122
|
+
cleanup = (
|
|
123
|
+
"import {mkdtempSync,rmSync} from 'node:fs';\n"
|
|
124
|
+
"import {tmpdir} from 'node:os';\nimport {join} from 'node:path';\n"
|
|
125
|
+
"const scratch=mkdtempSync(join(tmpdir(),'afu-dsh-presets-'));\n"
|
|
126
|
+
"process.once('exit',()=>rmSync(scratch,{recursive:true,force:true}));\n"
|
|
127
|
+
)
|
|
128
|
+
fs.atomic(preset, cleanup + source.replace(old_arg, ", scratch);"))
|
|
129
|
+
fs.atomic(bundle / "cordis.patch.yml", patches)
|
|
130
|
+
fs.write(
|
|
131
|
+
bundle / "package.json",
|
|
132
|
+
{
|
|
133
|
+
"name": bundle_name,
|
|
134
|
+
"version": "1.0.0",
|
|
135
|
+
"dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
content = marker + "Object.assign(process.env," + json.dumps(settings) + ");\n"
|
|
139
|
+
content += (
|
|
140
|
+
"export default (await import(" + json.dumps(adapter.as_uri()) + ")).default;\n"
|
|
141
|
+
)
|
|
142
|
+
write_owned(entry, content, marker)
|
|
143
|
+
if not link.exists():
|
|
144
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
link.symlink_to(bundle, target_is_directory=True)
|
|
146
|
+
if bundle_name not in bundles:
|
|
147
|
+
bundles.append(bundle_name)
|
|
148
|
+
deps[bundle_name] = owned_dep
|
|
149
|
+
if manifest.read_text() != original:
|
|
150
|
+
raise ValueError("Concurrent DSH configuration change")
|
|
151
|
+
fs.write(manifest, config)
|
|
152
|
+
try:
|
|
153
|
+
composed = run(binary, "--profile", profile, "--dump-config")
|
|
154
|
+
if (
|
|
155
|
+
str(entry) not in composed
|
|
156
|
+
and str(bundle / "presets.mjs") not in composed
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("DSH did not resolve the AFU provider")
|
|
159
|
+
except Exception:
|
|
160
|
+
if fs.read(manifest) == config:
|
|
161
|
+
fs.atomic(manifest, original)
|
|
162
|
+
raise
|
|
163
|
+
return {"manifest": str(manifest), "bundle": str(bundle), "name": bundle_name}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def remove_record(host, record):
|
|
167
|
+
if host == "pi":
|
|
168
|
+
entry = Path(record["entry"])
|
|
169
|
+
if entry.exists():
|
|
170
|
+
if (
|
|
171
|
+
entry.is_symlink()
|
|
172
|
+
or hashlib.sha256(entry.read_bytes()).hexdigest() != record["digest"]
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"Pi extension changed since installation; user file preserved"
|
|
176
|
+
)
|
|
177
|
+
entry.unlink()
|
|
178
|
+
return
|
|
179
|
+
manifest, name = Path(record["manifest"]), record["name"]
|
|
180
|
+
config = fs.read(manifest)
|
|
181
|
+
if not config:
|
|
182
|
+
return
|
|
183
|
+
bundles = config["dsh"]["profile"]["bundles"]
|
|
184
|
+
deps = config.get("dependencies", {})
|
|
185
|
+
if name in deps and deps[name] != "file:" + record["bundle"]:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"DSH dependency changed since installation; user file preserved"
|
|
188
|
+
)
|
|
189
|
+
if name in bundles and name not in deps:
|
|
190
|
+
raise ValueError("DSH bundle ownership changed; user configuration preserved")
|
|
191
|
+
if name in bundles:
|
|
192
|
+
bundles.remove(name)
|
|
193
|
+
deps.pop(name, None)
|
|
194
|
+
fs.write(manifest, config)
|
|
195
|
+
# Keep bundle files for running sessions (some presets resolve lazily).
|
|
196
|
+
link = manifest.parent / "node_modules" / name
|
|
197
|
+
if link.is_symlink() and link.resolve() == Path(record["bundle"]):
|
|
198
|
+
link.unlink()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
p = argparse.ArgumentParser()
|
|
203
|
+
p.add_argument("action", choices=["install", "upgrade", "remove", "status"])
|
|
204
|
+
p.add_argument("host", choices=["pi", "dsh"], nargs="?")
|
|
205
|
+
p.add_argument("--profile")
|
|
206
|
+
p.add_argument("--state", type=Path)
|
|
207
|
+
p.add_argument("--ratio")
|
|
208
|
+
p.add_argument("--context-window")
|
|
209
|
+
p.add_argument("--json", action="store_true")
|
|
210
|
+
args = p.parse_args()
|
|
211
|
+
root = (args.state or state_root()).expanduser().resolve()
|
|
212
|
+
host = args.host
|
|
213
|
+
if args.profile and (
|
|
214
|
+
host != "dsh"
|
|
215
|
+
or not re.fullmatch(r"[\w-]+", args.profile)
|
|
216
|
+
or args.profile == "node_modules"
|
|
217
|
+
):
|
|
218
|
+
p.error("invalid DSH profile")
|
|
219
|
+
key = args.profile or ("web" if host == "dsh" else "user")
|
|
220
|
+
if args.action == "status":
|
|
221
|
+
data = report(host, root=root)
|
|
222
|
+
for install in data["installations"]:
|
|
223
|
+
name = install["host"]
|
|
224
|
+
receipt = fs.read(root / "integrations" / (name + ".json"))
|
|
225
|
+
if receipt.get("autoload"):
|
|
226
|
+
install.update(
|
|
227
|
+
mode="host",
|
|
228
|
+
launch=name if name == "pi" else "dsh --profile PROFILE",
|
|
229
|
+
registrations=sorted(receipt["autoload"]),
|
|
230
|
+
)
|
|
231
|
+
print(json.dumps(data))
|
|
232
|
+
return
|
|
233
|
+
path = root / "integrations" / (host + ".json")
|
|
234
|
+
with fs.lock(path.parent):
|
|
235
|
+
old = fs.read(path)
|
|
236
|
+
registrations = dict(old.get("autoload", {}))
|
|
237
|
+
previous = registrations.get(key, {})
|
|
238
|
+
if args.action == "remove":
|
|
239
|
+
for item in [key] if args.profile or host == "pi" else list(registrations):
|
|
240
|
+
if item in registrations:
|
|
241
|
+
remove_record(host, registrations.pop(item))
|
|
242
|
+
fs.write(
|
|
243
|
+
path,
|
|
244
|
+
{
|
|
245
|
+
**old,
|
|
246
|
+
"host": host,
|
|
247
|
+
"autoload": registrations,
|
|
248
|
+
"removed": not registrations,
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
platform = Path(os.environ["AFU_INSTALL_ROOT"]).resolve()
|
|
253
|
+
adapter = platform / "src/afu_host_integrations" / host / "dist/index.js"
|
|
254
|
+
if not adapter.is_file():
|
|
255
|
+
raise ValueError("Adapter not installed")
|
|
256
|
+
|
|
257
|
+
def policy(raw, field, cast):
|
|
258
|
+
if raw is None:
|
|
259
|
+
return previous.get(
|
|
260
|
+
field, old.get(field) if not registrations else None
|
|
261
|
+
)
|
|
262
|
+
return None if raw == "auto" else cast(raw)
|
|
263
|
+
|
|
264
|
+
ratio = policy(args.ratio, "ratio", float)
|
|
265
|
+
window = policy(args.context_window, "window", int)
|
|
266
|
+
validate_policy(window, ratio)
|
|
267
|
+
if window and ratio and int(window * ratio) < 1:
|
|
268
|
+
raise ValueError("The configured threshold must be at least one token")
|
|
269
|
+
settings = {
|
|
270
|
+
"AFU_CONTEXT_COMPACT_WORKER_COMMAND": sys.executable,
|
|
271
|
+
"AFU_CONTEXT_COMPACT_WORKER_ARGS": json.dumps(
|
|
272
|
+
[
|
|
273
|
+
"-m",
|
|
274
|
+
"afu_context",
|
|
275
|
+
"compact",
|
|
276
|
+
"worker",
|
|
277
|
+
"--factory",
|
|
278
|
+
"afu_context.local_worker:create_plugin",
|
|
279
|
+
]
|
|
280
|
+
),
|
|
281
|
+
"AFU_CONTEXT_COMPACT_WORKER_CWD": str(platform),
|
|
282
|
+
"AFU_INSTALL_ROOT": str(platform),
|
|
283
|
+
"AFU_STATE_DIR": str(root),
|
|
284
|
+
"AFU_HOST_CONTEXT_WINDOW_OVERRIDE": str(window or "auto"),
|
|
285
|
+
}
|
|
286
|
+
if ratio is not None:
|
|
287
|
+
settings["AFU_TRIGGER_RATIO"] = str(ratio)
|
|
288
|
+
if host == "pi":
|
|
289
|
+
record = install_pi(root, adapter, settings, previous)
|
|
290
|
+
else:
|
|
291
|
+
record = install_dsh(root, platform, adapter, settings, key, previous)
|
|
292
|
+
registrations[key] = {**record, "window": window, "ratio": ratio}
|
|
293
|
+
fs.write(
|
|
294
|
+
path,
|
|
295
|
+
{
|
|
296
|
+
**old,
|
|
297
|
+
"host": host,
|
|
298
|
+
"mode": "host",
|
|
299
|
+
"removed": False,
|
|
300
|
+
"window": window,
|
|
301
|
+
"ratio": ratio,
|
|
302
|
+
"build": fs.read(platform / "release.json")["build"],
|
|
303
|
+
"autoload": registrations,
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
print(
|
|
307
|
+
json.dumps(
|
|
308
|
+
{
|
|
309
|
+
"host": host,
|
|
310
|
+
"installed": bool(registrations),
|
|
311
|
+
"upgraded": args.action == "upgrade",
|
|
312
|
+
"mode": "host",
|
|
313
|
+
"registrations": sorted(registrations),
|
|
314
|
+
"launch": "pi" if host == "pi" else "dsh --profile " + key,
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
try:
|
|
322
|
+
main()
|
|
323
|
+
except (OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
|
|
324
|
+
sys.exit(
|
|
325
|
+
"AFU registration failed: " + str(error)
|
|
326
|
+
if type(error) is ValueError
|
|
327
|
+
else "AFU registration failed: " + type(error).__name__
|
|
328
|
+
)
|
package/bin/platform.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {obtainPack} from './prebuilt.mjs';
|
|
2
2
|
import {runInstall} from './install-process.mjs';
|
|
3
3
|
/** Host-only dependencies live outside the installed npm package. */
|
|
4
|
-
import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync} from 'node:fs';
|
|
4
|
+
import {mkdirSync,readFileSync,writeFileSync,existsSync,rmSync,cpSync,renameSync,realpathSync} from 'node:fs';
|
|
5
5
|
import {join,dirname,delimiter} from 'node:path';
|
|
6
6
|
import {homedir} from 'node:os';
|
|
7
7
|
|
|
@@ -56,7 +56,12 @@ export async function preparePlatform(root,manifest,args,env=process.env){
|
|
|
56
56
|
if(manifest.prebuilt && process.platform!=='win32'){
|
|
57
57
|
const payload=await obtainPack(root,manifest,host,env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token}),{mode:0o600}));
|
|
58
58
|
cpSync(join(payload,'node_modules'),join(target,'node_modules'),{recursive:true,verbatimSymlinks:true});
|
|
59
|
-
}else
|
|
59
|
+
}else {
|
|
60
|
+
// npm treats a Windows junction alias as a linked package. Use its real
|
|
61
|
+
// prefix so npm ci validates the shipped lock against the actual root.
|
|
62
|
+
const installTarget=process.platform==='win32'?realpathSync(target):target;
|
|
63
|
+
await runInstall(executable,[...prefix,'ci','--registry=https://registry.npmjs.org','--prefix',installTarget,'--ignore-scripts','--no-audit','--no-fund'],env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token})));
|
|
64
|
+
}
|
|
60
65
|
// Import the real adapters: a package.json marker alone misses broken files
|
|
61
66
|
// and corrupt transitive dependencies. No model request is made by imports.
|
|
62
67
|
await runInstall(process.execPath,['--input-type=module','-e','await import(process.argv[1])',pathToFileURL(join(target,'src/afu_host_integrations',host,'dist/index.js')).href],env);
|
package/bin/prebuilt.mjs
CHANGED
|
@@ -8,14 +8,14 @@ import {runInstall} from './install-process.mjs';
|
|
|
8
8
|
export function packName(kind){
|
|
9
9
|
if(!['runtime','pi','dsh'].includes(kind))throw Error('INVALID_PACK_KIND');
|
|
10
10
|
if(!['darwin','linux','win32'].includes(process.platform)||!['arm64','x64'].includes(process.arch))throw Error('UNSUPPORTED_PLATFORM');
|
|
11
|
-
return
|
|
11
|
+
return `afu-context-${kind}-${process.platform}-${process.arch}`;
|
|
12
12
|
}
|
|
13
13
|
export function verifyPack(folder,manifest,kind){
|
|
14
14
|
const info=JSON.parse(readFileSync(join(folder,'pack-manifest.json'),'utf8'));
|
|
15
15
|
if(info.build!==manifest.build||info.kind!==kind||info.platform!==process.platform||info.arch!==process.arch)throw Error('PLATFORM_PACK_MISMATCH');
|
|
16
16
|
const root=realpathSync(folder);
|
|
17
17
|
const listed=new Set(Object.keys(info.files));
|
|
18
|
-
function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const file=join(dir,entry.name);if(entry.isDirectory())walk(file);else {const name=file.slice(root.length+1);if(!listed.has(name))throw Error('PACK_UNLISTED_FILE');if(lstatSync(file).isSymbolicLink()&&lstatSync(realpathSync(file)).isDirectory())throw Error('PACK_DIRECTORY_LINK');}}}
|
|
18
|
+
function walk(dir){for(const entry of readdirSync(dir,{withFileTypes:true})){const file=join(dir,entry.name);if(entry.isDirectory())walk(file);else {const name=file.slice(root.length+1).split(sep).join('/');if(!listed.has(name))throw Error('PACK_UNLISTED_FILE');if(lstatSync(file).isSymbolicLink()&&lstatSync(realpathSync(file)).isDirectory())throw Error('PACK_DIRECTORY_LINK');}}}
|
|
19
19
|
walk(join(root,'payload'));
|
|
20
20
|
// COSEC: signed npm integrity plus per-file release hashes; forbid escaping
|
|
21
21
|
// paths/symlinks before copying any executable into the private runtime.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liwu1995/afu-context",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Local asynchronous context compression and diagnostics",
|
|
@@ -23,13 +23,9 @@
|
|
|
23
23
|
"README.zh-CN.md",
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
|
-
"optionalDependencies": {
|
|
27
|
-
"@liwu1995/afu-context-runtime-darwin-arm64": "0.1.0",
|
|
28
|
-
"@liwu1995/afu-context-runtime-linux-x64": "0.1.0"
|
|
29
|
-
},
|
|
30
26
|
"publishConfig": {
|
|
31
27
|
"access": "public",
|
|
32
28
|
"registry": "https://registry.npmjs.org",
|
|
33
|
-
"tag": "
|
|
29
|
+
"tag": "latest"
|
|
34
30
|
}
|
|
35
31
|
}
|
package/python/{afu_context_local-0.1.0-py3-none-any.whl → afu_context_local-0.1.2-py3-none-any.whl}
RENAMED
|
Binary file
|
package/release.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
3
|
-
"build": "
|
|
4
|
-
"wheel": "afu_context_local-0.1.
|
|
5
|
-
"prebuilt":
|
|
2
|
+
"version": "0.1.2",
|
|
3
|
+
"build": "1bd035811a344a7ffffae3515a0027a2e509258eafc6568a1800e380dab2280a",
|
|
4
|
+
"wheel": "afu_context_local-0.1.2-py3-none-any.whl",
|
|
5
|
+
"prebuilt": false
|
|
6
6
|
}
|
|
@@ -11,6 +11,11 @@ export class CompactDshEngine extends BasicCompactionEngine {
|
|
|
11
11
|
handed = new WeakMap();
|
|
12
12
|
handoff(agent, result) {
|
|
13
13
|
const id = observationId(result);
|
|
14
|
+
const previous = this.handed.get(agent);
|
|
15
|
+
// A retried or manual compactRegion hands off again; the earlier summary
|
|
16
|
+
// was superseded rather than silently dropped.
|
|
17
|
+
if (previous && previous.id !== id)
|
|
18
|
+
this.log("summary_discarded", { session: sessionHash(agent.session.id), artifact_id: previous.id, reason: "superseded" });
|
|
14
19
|
this.handed.set(agent, { id });
|
|
15
20
|
this.log("summary_handoff", { session: sessionHash(agent.session.id), artifact_id: id });
|
|
16
21
|
}
|
|
@@ -54,6 +59,9 @@ export class CompactDshEngine extends BasicCompactionEngine {
|
|
|
54
59
|
});
|
|
55
60
|
ctx.effect(() => async () => {
|
|
56
61
|
this.log("runtime_stopped");
|
|
62
|
+
// Stop scheduling first so jobs cancelled by shutdown are not reported
|
|
63
|
+
// as preparation failures, then stop the host and let them settle.
|
|
64
|
+
this.idlePrecomputer.stop();
|
|
57
65
|
await this.host.close();
|
|
58
66
|
await this.idlePrecomputer.close();
|
|
59
67
|
this.bindings.clear();
|
|
@@ -123,7 +131,16 @@ export class CompactDshEngine extends BasicCompactionEngine {
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
async summarize(input, agent, signal) {
|
|
126
|
-
|
|
134
|
+
let request;
|
|
135
|
+
try {
|
|
136
|
+
request = await this.boundRequest(agent, input.messages, "sync");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Model window unknown or binding limit reached: DSH's own summarizer
|
|
140
|
+
// must still run (this path is also used by manual compactNow).
|
|
141
|
+
signal?.throwIfAborted();
|
|
142
|
+
return super.summarize(input, agent, signal);
|
|
143
|
+
}
|
|
127
144
|
const sessionId = String(request.session_id);
|
|
128
145
|
const target = dshRoute(agent);
|
|
129
146
|
const prepared = this.selectedResults.get(sessionId) ?? this.idlePrecomputer.latest(sessionId);
|
|
@@ -342,9 +359,13 @@ export class DshIdlePrecomputer {
|
|
|
342
359
|
await Promise.all(this.running.values());
|
|
343
360
|
}
|
|
344
361
|
}
|
|
345
|
-
|
|
362
|
+
/** Stop accepting work; in-flight failures after this are expected. */
|
|
363
|
+
stop() {
|
|
346
364
|
this.closed = true;
|
|
347
365
|
this.pending.clear();
|
|
366
|
+
}
|
|
367
|
+
async close() {
|
|
368
|
+
this.stop();
|
|
348
369
|
await this.drain();
|
|
349
370
|
}
|
|
350
371
|
start(sessionId) {
|
|
@@ -370,7 +391,8 @@ export class DshIdlePrecomputer {
|
|
|
370
391
|
this.completed.set(sessionId, result);
|
|
371
392
|
}
|
|
372
393
|
catch (error) {
|
|
373
|
-
this.
|
|
394
|
+
if (!this.closed)
|
|
395
|
+
this.onError(sessionId, error);
|
|
374
396
|
}
|
|
375
397
|
}
|
|
376
398
|
}
|
|
@@ -44,9 +44,13 @@ export class HostCompactPool {
|
|
|
44
44
|
}
|
|
45
45
|
if (!active) {
|
|
46
46
|
this.entries.delete(key);
|
|
47
|
-
|
|
47
|
+
const closing = old.client.close({ drain: false }).catch(() => undefined);
|
|
48
|
+
if (process.platform === "win32")
|
|
49
|
+
await closing;
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
if (this.closed)
|
|
53
|
+
throw new Error("Host worker closed");
|
|
50
54
|
let entry = this.entries.get(binding.key);
|
|
51
55
|
if (!entry) {
|
|
52
56
|
// Do not evict or interrupt another active session to admit a new one.
|
|
@@ -70,6 +74,8 @@ export class HostCompactPool {
|
|
|
70
74
|
this.entries.set(binding.key, entry);
|
|
71
75
|
}
|
|
72
76
|
entry.binding = binding;
|
|
77
|
+
if (this.closed)
|
|
78
|
+
throw new Error("Host worker closed");
|
|
73
79
|
const result = await entry.client.compact({ ...request, ...(binding.contextWindow === undefined ? {} : { token_budget: binding.contextWindow }), session_id: bindingKey([request.session_id, binding.key]) }, signal);
|
|
74
80
|
if (!await binding.current())
|
|
75
81
|
throw new Error("Host model changed");
|
|
@@ -144,11 +150,15 @@ export class HostCompactPool {
|
|
|
144
150
|
}
|
|
145
151
|
async close() {
|
|
146
152
|
this.closed = true;
|
|
147
|
-
await this.starting;
|
|
153
|
+
await this.starting?.catch(() => undefined);
|
|
148
154
|
for (const controller of this.controllers)
|
|
149
155
|
controller.abort();
|
|
150
|
-
|
|
151
|
-
this.entries.
|
|
156
|
+
// Entries registered by a compact() racing with close are closed here too.
|
|
157
|
+
while (this.entries.size) {
|
|
158
|
+
const entries = [...this.entries.values()];
|
|
159
|
+
this.entries.clear();
|
|
160
|
+
await Promise.allSettled(entries.map(e => e.client.close({ drain: false })));
|
|
161
|
+
}
|
|
152
162
|
this.server?.closeAllConnections();
|
|
153
163
|
if (this.server)
|
|
154
164
|
await new Promise(resolve => this.server.close(() => resolve()));
|
|
@@ -50,6 +50,18 @@ export class CompactWorkerClient {
|
|
|
50
50
|
this.child = undefined;
|
|
51
51
|
this.lines = undefined;
|
|
52
52
|
this.rejectAll(new CompactWorkerError("worker closed"));
|
|
53
|
+
if (process.platform === "win32" && child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
|
|
54
|
+
// Windows keeps the worker's cwd/files locked until the process exits.
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
const finished = () => { clearTimeout(kill); clearTimeout(deadline); resolve(); };
|
|
57
|
+
const kill = setTimeout(() => child.kill(), 5000);
|
|
58
|
+
const deadline = setTimeout(() => {
|
|
59
|
+
child.off("exit", finished);
|
|
60
|
+
reject(new CompactWorkerError("worker did not exit after shutdown"));
|
|
61
|
+
}, 10000);
|
|
62
|
+
child.once("exit", finished);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
call(method, params, signal, allowWhileClosing = false) {
|
|
@@ -98,6 +110,11 @@ export class CompactWorkerClient {
|
|
|
98
110
|
stdio: ["pipe", "pipe", "pipe"],
|
|
99
111
|
});
|
|
100
112
|
this.child = child;
|
|
113
|
+
// A worker that dies mid-write emits EPIPE on stdin; without a listener
|
|
114
|
+
// Node treats that as fatal and takes the host process down with it.
|
|
115
|
+
child.stdin.on("error", (error) => {
|
|
116
|
+
this.rejectAll(new CompactWorkerError(`worker input closed: ${error.message}`));
|
|
117
|
+
});
|
|
101
118
|
this.lines = createInterface({ input: child.stdout });
|
|
102
119
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
103
120
|
child.stderr.on("data", (chunk) => {
|
|
@@ -108,6 +125,13 @@ export class CompactWorkerClient {
|
|
|
108
125
|
process.stderr.write(`[afu_context compact worker] ${text}`);
|
|
109
126
|
});
|
|
110
127
|
child.on("error", (error) => {
|
|
128
|
+
// A spawn failure emits "error" without "exit": forget this child so
|
|
129
|
+
// the next call starts a fresh worker instead of writing to a dead pipe.
|
|
130
|
+
if (this.child === child) {
|
|
131
|
+
this.child = undefined;
|
|
132
|
+
this.lines?.close();
|
|
133
|
+
this.lines = undefined;
|
|
134
|
+
}
|
|
111
135
|
this.rejectAll(new CompactWorkerError(`worker failed to start: ${error.message}`));
|
|
112
136
|
});
|
|
113
137
|
child.on("exit", (code, signal) => {
|
|
@@ -23,12 +23,25 @@ export function normalizeContent(value) {
|
|
|
23
23
|
}
|
|
24
24
|
return safeStringify(value);
|
|
25
25
|
}
|
|
26
|
+
/** Same weights as the Python HeuristicTokenEstimator. */
|
|
27
|
+
function characterWeight(code) {
|
|
28
|
+
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf))
|
|
29
|
+
return 1;
|
|
30
|
+
if ((code >= 0xac00 && code <= 0xd7af) || (code >= 0x1100 && code <= 0x11ff))
|
|
31
|
+
return 1;
|
|
32
|
+
if (code >= 0x3040 && code <= 0x30ff)
|
|
33
|
+
return 1;
|
|
34
|
+
if (code >= 0x1f000)
|
|
35
|
+
return 1;
|
|
36
|
+
if (code >= 0x0370)
|
|
37
|
+
return 0.5;
|
|
38
|
+
return 0.25;
|
|
39
|
+
}
|
|
26
40
|
export function estimateContentTokens(content) {
|
|
27
41
|
const text = typeof content === "string" ? content : JSON.stringify(content);
|
|
28
42
|
let units = 0;
|
|
29
|
-
for (const character of text)
|
|
30
|
-
units += character
|
|
31
|
-
}
|
|
43
|
+
for (const character of text)
|
|
44
|
+
units += characterWeight(character.codePointAt(0) ?? 0);
|
|
32
45
|
return text.length === 0 ? 0 : Math.max(1, Math.ceil(units));
|
|
33
46
|
}
|
|
34
47
|
function isJsonObject(value) {
|