@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 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
- Local asynchronous context compression for Claude Code, Codex, Pi Coding Agent and DeepSeek Harness.
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
- ## Install
7
+ ## How AFU compacts a session
8
8
 
9
- **Release candidate; not yet published.** The npx commands below become available after the main package and platform companions are published.
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
- Requires Node.js 22.19+ (22.x) or 24+, and your host installed and logged in. Platform-pack builds prepare a private Python runtime on supported Mac/Linux systems. Legacy/source and Windows builds still require Python 3.11+. Platform packs must be published and verified before advertising the one-command flow as generally available.
15
+ <a id="install"></a>
12
16
 
13
- ```sh
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
- CC/Codex install no Pi/DSH JavaScript dependencies. Selecting Pi or DSH downloads only that platform’s pinned dependencies into a private versioned cache.
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
- The first command prepares AFU's CLI. First use creates a private Python environment and installs pinned dependencies, reusing uv when available or using pip. Set `AFU_PYTHON` to select your Python executable. No system Python modifications or separate summary API key are needed.
21
-
22
- | Host | Integration | Launch | Validated compatibility |
21
+ | Host | System | Install | Launch after installation |
23
22
  | --- | --- | --- | --- |
24
- | Claude Code | Local gateway and startup hooks | `claude` | macOS/Linux sandbox; Windows implementation awaits machine validation |
25
- | Codex CLI | Local gateway and startup hooks | `codex` | macOS/Linux; not a desktop App plugin; Windows native integration disabled |
26
- | Pi | Launcher-loaded extension | `afu pi -- --provider PROVIDER --model MODEL` | Pi 0.84.4, macOS/Linux |
27
- | DSH | Temporary profile overlay | `afu dsh -- --profile PROFILE` | DSH 0.1.2-rc.1 / Cordis 4.0.2, macOS/Linux |
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
- Native services use user launchd on macOS, systemd user services on Linux, and the existing Windows scheduled-task integration for Claude Code. Linux native automatic installation requires a working `systemctl --user`. Codex may require approving its hooks. Host credentials remain owned by the host. Official Claude subscription/OAuth and Windows real-machine acceptance remain pending.
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
- ## Diagnostics
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
- ```sh
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
- The local read-only dashboard distinguishes installation, observed requests, summary generation and confirmed application. It includes disk/running versions, sessions, timelines, receipts, cache usage and fixed error reasons. Pi/DSH application indicates successful host-history commit; CC/Codex application requires a complete successful upstream response with the replacement. Historical runtime events do not assert that a process is currently alive.
35
+ <details>
36
+ <summary>Linux manual startup and Pi / DSH launch options</summary>
40
37
 
41
- Only allowlisted metadata is logged. Prompts, summary text, credentials, raw provider errors and conversation files are not exposed in the panel. It binds to loopback and requires a random access capability; do not share the printed URL. Cache counters absent from a provider are unknown, not zero. Pi/DSH chat usage and cost analysis are not provided in this version.
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
- ## Remove
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
- ```sh
46
- afu remove claude # substitute codex, pi or dsh
47
- ```
42
+ </details>
48
43
 
49
- Only settings still owned by AFU are restored. Later user changes and existing sessions are preserved. Native clients still using an old route retain passthrough; Pi/DSH future wrapper launches bypass AFU. Remove the integration before uninstalling the npm package. Versioned runtime caches and diagnostic logs remain available for active sessions and inspection.
44
+ <details>
45
+ <summary>Known issues and network notes</summary>
50
46
 
51
- ## Configuration
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
- - `AFU_PYTHON`: Python 3.11+ executable.
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
- This package includes generic context-management code, host adapters, local diagnostics and a public Qwen tokenizer resource with its attribution. It includes no hosted monitoring service or business-specific backend.
54
+ <a id="results"></a>
61
55
 
62
- Licensed under Apache-2.0. Third-party dependencies retain their respective licenses.
56
+ ## Benchmark results
63
57
 
64
- ## Upgrade and model windows
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
- Run `afu upgrade claude` (or `codex`) after updating the package. New sessions use the new generation; restart is not forced on existing sessions. Old generation directories are retained for those sessions.
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
- CC/Codex default to querying the selected model’s provider metadata after a completed request. The lookup is off the chat response path. Default trigger: 40% for windows >= 1M, otherwise 50%. If the provider does not declare a window, new compression is skipped and status reports `manual_window_required`; normal chat continues. Use `afu install claude --context-window 200000` with the actual known capacity to override. An explicit window remains explicit after a model switch until you reconfigure it. OpenAI-compatible model catalogs are not guaranteed to expose context limits.
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
- ### Recovery and cache maintenance
70
+ <a id="dashboard"></a>
71
71
 
72
- Interrupted native upgrades are journaled. The next install/upgrade/remove or native startup completes the switch, or restores the previous configuration if the candidate cannot start. Concurrent user edits stop recovery rather than being overwritten.
72
+ ## Web dashboard
73
73
 
74
- Use `afu gc --dry-run` to preview reclamation and `afu gc` to perform it. Current, busy, pending and unrecognized legacy generations are retained. Retired native services require released client leases and five idle minutes before retirement. Unreferenced managed caches require 24 hours of age; active installers and other registered AFU state directories protect their caches.
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
- Use `afu install claude --context-window auto --ratio auto` (or `codex`) to reset manual overrides. Omitted options preserve existing settings. Unknown model capacity pauses new compression; chat continues.
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
- Host dependencies use release lockfiles and `npm ci` with integrity checks. Pi also ships its upstream shrinkwrap. Installation subprocesses have a 180-second deadline; `AFU_INSTALL_TIMEOUT_MS` may set 1–600000 milliseconds. A timeout stops only that installer's process group and leaves existing generations intact. Registry or integrity errors remain failures; integrity validation is never disabled.
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
- Pi/DSH also persist `afu install pi --context-window 200000 --ratio 0.2` (substitute `dsh`). Launcher options take precedence over environment variables, then saved settings, then automatic model defaults. Explicit `auto` resets the override. Invalid ratios/windows fail without changing saved settings. After removal, `afu pi -- <host arguments>` and `afu dsh -- <host arguments>` launch the host without preparing AFU runtimes or downloading dependencies.
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
- Upgrade recovery is scoped to the selected host/configuration directory. Management failures report an allowlisted reason code, phase, recovery hint, and a bounded latest-error record under the chosen state directory's `logs/management-error.json`. Raw exception text, credentials and host settings are not included.
85
+ ## Models and thresholds
83
86
 
84
- ## Platform-pack release (candidate; not published)
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
- Build the main package with `scripts/build_distribution.py --prebuilt`. Build each runtime/Pi/DSH companion on its target system using `scripts/build_platform_pack.py`. Runtime sources must be relocatable standalone Python; host-pack sources must be lockfile-verified platform caches. Publish companions before the main package, then test the registry installation from a clean user account. Never label local-pack tests as registry publication verification.
89
+ <details>
90
+ <summary>Set a window or ratio, or restore automatic settings</summary>
87
91
 
88
- `AFU_BUNDLE_DIR` selects a local directory of companion packages for offline installs and sandbox validation. Each companion must match the main build, OS and architecture; all payload files are hashed and escaping links or extra files are rejected. CC/Codex do not fetch Pi/DSH companions. `install --dry-run` checks host availability, writable state and the user service manager without installing runtimes or editing host settings.
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
- After a temporary npx install, use `npx @liwu1995/afu-context status` and `npx @liwu1995/afu-context pi -- ...` / `npx @liwu1995/afu-context dsh -- ...`; native CC/Codex retain their normal launch commands. Successful installation is not proof of request routing or summary application.
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
- ### Linux without a systemd user session
99
+ </details>
93
100
 
94
- If installation reports `USER_SERVICE_UNAVAILABLE`, host settings have not been
95
- changed. Enable your systemd user service, or explicitly choose manual startup:
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 install codex --no-autostart
99
- # For Claude Code: afu install claude --no-autostart
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
- Keep the same `--config-dir`, `--state`, upstream and other options if you used
103
- custom settings. Manual mode starts the gateway now but does not register login
104
- or reboot recovery. Before using the host after reboot, rerun the same install
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
- Linux `status` displays `startup.mode` separately from `healthy`. A matching
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
- 为 Claude Code、Codex、Pi Coding Agent 和 DeepSeek Harness 提供本地异步上下文压缩与诊断。后台准备摘要,覆盖范围仍然匹配时再应用。
5
+ 长对话里,同一段工具输出可能随每次请求反复发送。AFU 用宿主当前的模型在后台整理已经结束的对话。摘要就绪后,下一次合适的请求可以用摘要替换旧历史,同时保留之后发生的工具调用和新消息。
6
6
 
7
- ## 安装
7
+ ## AFU 如何压缩对话
8
8
 
9
- **当前为未发布的候选包。** 以下 npx 命令在主包和对应平台配套包正式发布后可用:
9
+ | 时机 | AFU 的行为 |
10
+ | --- | --- |
11
+ | 达到压缩阈值 | 后台生成摘要,当前会话可以继续。 |
12
+ | 下一次使用历史 | 检查摘要覆盖的对话前缀是否仍然匹配,匹配才替换。 |
13
+ | 使用之后 | 记录模型请求成功回执或宿主提交结果,面板区分已生成与已应用。 |
10
14
 
11
- ```sh
12
- npx @liwu1995/afu-context install
13
- # 非交互安装:npx @liwu1995/afu-context install claude
14
- ```
15
+ <a id="install"></a>
16
+
17
+ ## 快速开始
15
18
 
16
- 需要 Node.js 22.19+(22.x)或 24+,以及已安装并登录的宿主。支持的平台配套包在 Mac/Linux 准备私有 Python 运行时;源码、旧分发和 Windows 路径仍需要 Python 3.11+。不修改系统 Python,不需要单独配置摘要模型密钥。`AFU_PYTHON` 可指定解释器。
19
+ 先安装并登录要使用的宿主,准备 npm 和 Node.js **22.19+(22.x)或 24+**。选一行,在终端执行安装命令,然后用最后一列的命令启动。
17
20
 
18
- | 框架 | 接入方式 | 启动 | 验证范围 |
21
+ | 宿主 | 系统 | 一键安装 | 安装后启动 |
19
22
  | --- | --- | --- | --- |
20
- | Claude Code | 本地网关与启动 Hook | `claude` | Mac/Linux 沙箱;Windows 待实机验收 |
21
- | Codex CLI | 本地网关与启动 Hook | `codex` | Mac/Linux;不是桌面 App 插件;Windows 原生接入未启用 |
22
- | Pi | 启动时加载扩展 | `afu pi -- --provider PROVIDER --model MODEL` | Pi 0.84.4,Mac/Linux |
23
- | DSH | 临时 profile 覆盖 | `afu dsh -- --profile PROFILE` | DSH 0.1.2-rc.1 / Cordis 4.0.2,Mac/Linux |
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
- CC/Codex 不下载 Pi/DSH 依赖;选择 Pi 或 DSH 时才准备对应的锁定版本依赖。临时 npx 安装后使用 `npx @liwu1995/afu-context status` 和 `npx @liwu1995/afu-context pi -- ...` / `npx @liwu1995/afu-context dsh -- ...`;原生 CC/Codex 继续照常启动。
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
- macOS 使用用户级 launchd,Linux 使用 systemd 用户服务,Windows Claude Code 使用计划任务。Codex 可能需要确认启用 Hook。宿主继续管理认证;官方 Claude 订阅/OAuth 和 Windows 实机验收尚未完成。
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
- ```sh
32
- afu status
33
- afu status claude --json
34
- afu ui --open
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
- 只读面板分别展示已安装、请求已观测、摘要就绪、应用已确认,以及磁盘/运行版本、时间线、缓存 usage 和错误原因。Pi/DSH 应用回执表示宿主历史提交;CC/Codex 还需包含替换的上游请求完整成功。历史事件不代表进程仍存活。
52
+ </details>
38
53
 
39
- 仅记录允许的元数据,不展示对话正文、摘要正文、认证、原始模型错误或会话文件。面板只监听回环地址并要求随机访问凭证,不要分享启动时的链接。未返回缓存指标时显示未知,不当作零。当前不提供 Pi/DSH 主请求 usage 或成本分析。
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
- afu remove claude # 可换为 codex、pi、dsh
45
- afu upgrade claude # 更新 npm 包后执行;也支持 codex
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
- 仅恢复仍由 AFU 拥有的配置;保留用户后续修改和已有会话。旧原生会话保留透传,Pi/DSH 后续启动绕过 AFU。卸载 npm 包前先移除接入。旧运行时目录、缓存和诊断日志保留供已有会话使用与检查。
81
+ 面板分别记录**请求已接入 → 摘要已生成 → 摘要已应用**。Claude Code 和 Codex 的应用回执表示替换历史后的请求已收到完整成功响应。Pi 和 DSH 的回执表示宿主已把摘要写入会话历史。面板也展示摘要耗时和模型返回的缓存用量。
49
82
 
50
- 升级先验证新服务健康,再切换新会话,不强制重启已有会话。恢复操作限定在指定宿主及配置目录。管理错误以允许的原因码、阶段和恢复提示写入状态目录的 `logs/management-error.json`,不写入原始异常或认证信息。
83
+ **总览**先告诉你 AFU 帮你省了多少:节省的 token(估算)、记录到的对话等待时间和生效的压缩次数,下方列出每个会话及其最近一次压缩的状态。点进一个**会话**,可以看到上下文在每次压缩后回落、每次压缩的耗时和节省量,以及缓存命中率。接入情况、版本和异常记录在**运行状态**页查看。面板支持亮色/暗色主题和中英文。
51
84
 
52
- ## 配置与模型窗口
85
+ ## 模型与压缩阈值
53
86
 
54
- - `AFU_RUNTIME_DIR`:版本化运行时缓存;`AFU_STATE_DIR`:接入回执与日志。
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
- 依赖使用发行锁文件、`npm ci` 与完整性检查;Pi 还包含上游 shrinkwrap。安装子进程默认超时 180 秒,`AFU_INSTALL_TIMEOUT_MS` 可设为 1–600000 毫秒。超时只停止本次安装进程组,不改变已有运行版本;不会关闭完整性校验来绕过下载错误。
89
+ <details>
90
+ <summary>手动指定窗口、调整比例或恢复自动设置</summary>
61
91
 
62
- ## 平台配套包
92
+ 下面以已知窗口为 200,000 tokens 的 Codex 模型为例。第一条将触发比例设为 30%,第二条恢复自动设置。
63
93
 
64
- 维护者使用 `scripts/build_distribution.py --prebuilt` 构建主包,在目标系统用 `scripts/build_platform_pack.py` 构建 runtime/Pi/DSH 配套包。Python 来源须可迁移,宿主依赖须匹配锁文件。配套包先发布,主包最后发布,再从全新用户环境验证公网安装。
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
- `AFU_BUNDLE_DIR` 可指向本地配套包目录,供离线安装和沙箱验证。配套包须与主包构建、系统、架构一致;逐文件校验,拒绝越界链接或多余文件。`install --dry-run` 只检查宿主、状态目录和服务管理器,不安装运行时、不修改宿主配置。本地归档验收不等于公网发布验收。
99
+ </details>
67
100
 
68
- ### Linux 没有 systemd 用户会话时
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
- 若安装返回 `USER_SERVICE_UNAVAILABLE`,宿主配置尚未改变。启用 systemd 用户服务,或明确选择手动启动:
103
+ ## 升级与移除
104
+
105
+ 升级 Claude Code 或 Codex 接入后,新开会话即可使用新版网关。需要移除时,先撤销对应宿主的接入,再重启宿主。以下两条命令分别用于升级和移除。
71
106
 
72
107
  ```sh
73
- afu install codex --no-autostart
74
- # Claude Code:afu install claude --no-autostart
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
- 保留原来的配置目录、状态目录、上游等参数。手动模式启动当前网关,但不注册登录/重启恢复;重启后需再次执行同一安装命令。`status` 分别显示启动模式与健康状态;`registration_recorded` 表示注册文件和回执匹配,不等于实时 systemd 状态或整机重启已验收。
112
+ Pi 和 DSH 可分别用 `remove pi` 或 `remove dsh` 撤销。Windows PowerShell 请使用 `npx.cmd` 并将包名放在双引号中。
113
+
114
+ 本地面板只监听回环地址。日志记录用量和回执,Pi/DSH 的本地会话状态还会保存摘要。生成摘要本身会调用模型,节省多少取决于后续复用和实际缓存命中。
78
115
 
79
- 本包包含通用上下文管理、宿主适配器、本地诊断及带来源声明的公共 Qwen tokenizer,不依赖托管监控或业务后端。项目使用 Apache-2.0,第三方依赖保留各自许可证。
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
- if(!args.length || (args.includes('--help') && (args.indexOf('--')<0 || args.indexOf('--help')<args.indexOf('--')))){
22
- console.log('AFU Context: install [claude|codex|pi|dsh], status, remove HOST, gc --dry-run, ui.\nRun npx @liwu1995/afu-context install to detect your installed host. Host login remains managed by the host.');
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
- for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
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??(signal?130:1);});
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 child=spawn(python,['-m','afu_context.local_cli',...args],{stdio:'inherit',env});
48
- // Relay signals only to our own foreground child; never enumerate other sessions.
49
- for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
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 ?? (signal?130:1);});
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 await runInstall(executable,[...prefix,'ci','--registry=https://registry.npmjs.org','--prefix',target,'--ignore-scripts','--no-audit','--no-fund'],env,pid=>writeFileSync(join(lock,'owner.json'),JSON.stringify({pid:process.pid,childPid:pid,token})));
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 `@liwu1995/afu-context-${kind}-${process.platform}-${process.arch}`;
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.0",
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": "next"
29
+ "tag": "latest"
34
30
  }
35
31
  }
package/release.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.1.0",
3
- "build": "edb9a28f8ba42bc4b9fa4858fa5438e90570cc442b79c8bc82c6b9bfd5fa3d66",
4
- "wheel": "afu_context_local-0.1.0-py3-none-any.whl",
5
- "prebuilt": true
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
- const request = await this.boundRequest(agent, input.messages, "sync");
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
- async close() {
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.onError(sessionId, error);
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
- void old.client.close({ drain: false }).catch(() => undefined);
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
- await Promise.allSettled([...this.entries.values()].map(e => e.client.close({ drain: false })));
151
- this.entries.clear();
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 >= "\u4e00" && character <= "\u9fff" ? 1 : 0.25;
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) {