afucontext 0.1.18 → 0.1.20

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
@@ -20,13 +20,13 @@ Install and sign in to your host first. You need npm, Node.js **22.19+ (22.x) or
20
20
 
21
21
  | Host | System | Install | Launch after installation | Tested version |
22
22
  | --- | --- | --- | --- | --- |
23
- | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install claude` | `claude` | 2.1.293 |
24
- | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex` | `codex` | 0.153.4 |
25
- | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install pi` | `pi` | 0.84.4, 1.5.0 |
26
- | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 0.2.2-rc.1 |
27
- | Codex CLI | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install codex` | `codex` | preview, not yet tested; official in the next release |
28
- | Pi | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install pi` | `pi` | preview, not yet tested; official in the next release |
29
- | DSH | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | preview, not yet tested; official in the next release |
23
+ | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install claude` | `claude` | 2.1.293 |
24
+ | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex` | `codex` | 0.153.4 |
25
+ | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install pi` | `pi` | 0.84.4, 1.5.0 |
26
+ | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 0.2.2-rc.1 |
27
+ | Codex CLI | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install codex` | `codex` | preview, not yet tested; official in the next release |
28
+ | Pi | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install pi` | `pi` | preview, not yet tested; official in the next release |
29
+ | DSH | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | preview, not yet tested; official in the next release |
30
30
 
31
31
  Supported on macOS (Apple Silicon) and Linux x64. Start a new session after installing.
32
32
 
@@ -38,7 +38,7 @@ Supported on macOS (Apple Silicon) and Linux x64. Start a new session after inst
38
38
 
39
39
  ## Commands
40
40
 
41
- The terminal commands (`afuctx …`) are placed in `~/.local/bin` by the first install; if that folder is not on `PATH`, use `npx --yes --registry=https://registry.npmjs.org afucontext@latest` in place of `afuctx`. Install, upgrade and remove replace the runtime itself, so they always run through npx.
41
+ The terminal commands (`afuctx …`) are placed in `~/.local/bin` by the first install of any host (the folder is created when missing). If that folder is not on `PATH`, the installer says so: add `export PATH="$HOME/.local/bin:$PATH"` to `~/.bashrc` or `~/.zshrc`, or use `npx --yes --registry=https://registry.npmjs.org afucontext@latest` in place of `afuctx`. Install, upgrade and remove replace the runtime itself, so they always run through npx.
42
42
 
43
43
  | Command | What it does |
44
44
  | --- | --- |
@@ -48,6 +48,8 @@ The terminal commands (`afuctx …`) are placed in `~/.local/bin` by the first i
48
48
  | `npx --yes --registry=https://registry.npmjs.org afucontext@latest remove HOST` | Remove; host settings return to their state before installation. For DSH, `--profile NAME` removes one profile |
49
49
  | `afuctx status` | Whether each host is connected, how far the context is from the trigger, compactions applied; `--json` for the full record |
50
50
  | `afuctx dashboard` | Open the local dashboard (same as `afuctx-admin`); `--prices FILE` estimates costs with your prices |
51
+ | `afuctx doctor` | Checks host → AFU → model end to end, fixes what AFU can and gives exact steps for the rest; add a host name to check one |
52
+ | `afuctx off` / `afuctx on` | Pause / resume: while paused the hosts talk to their models directly; the installation, logs and summaries stay. Add a host name for one host |
51
53
  | `afuctx repair codex --network` | Find and verify the proxy route to the model again (`repair claude` for Claude Code) |
52
54
  | `afuctx gc --dry-run` | List old runtimes and logs that can be removed; without `--dry-run` it removes them |
53
55
  | `afuctx --version` / `afuctx --help` | Version / help |
@@ -60,6 +62,19 @@ Inside a host session:
60
62
  | Codex | `$afuctx-status` | `$afuctx-dashboard` |
61
63
  | Pi / DSH | `/afuctx-status` | `/afuctx-dashboard` |
62
64
 
65
+ ## When something goes wrong
66
+
67
+ | What you see | Do first |
68
+ | --- | --- |
69
+ | After installing, the host cannot connect or keeps reconnecting | `afuctx off`: the host talks to its model directly right away. Then `afuctx doctor` to find the cause, and `afuctx on` once fixed |
70
+ | Status says the model service is unreachable | `afuctx doctor` (or, for the network only, `afuctx repair codex --network`) |
71
+ | A proxy app keeps the host from reaching AFU on this machine | Add `127.0.0.1` and `localhost` to the proxy app's bypass/exception list (for example AntFamily); `afuctx doctor` checks this |
72
+ | Settings or hooks were damaged | Run the install command again |
73
+ | No compaction after a reboot | Open a host session once (its hook starts AFU) |
74
+ | You no longer want AFU | `remove`: the host's settings return to how they were before install |
75
+
76
+ When AFU cannot reach the model service several times in a row, it looks for another working route by itself and verifies it (only AFU's own record changes); the dashboard shows it.
77
+
63
78
  ## Benchmark results
64
79
 
65
80
  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**.
@@ -89,7 +104,7 @@ In a LongBench V2 replay of 39 history questions (20 agent histories and 19 dial
89
104
 
90
105
  - The dashboard runs in the background and does not occupy a terminal; opening it again reuses the same one, and it exits after 30 minutes without an open page.
91
106
  - It opens in the dark theme; switch to light, follow-system or English/Chinese in the top-right corner.
92
- - The terminal commands are placed in `~/.local/bin` by the installer (only when that folder is already on `PATH`; shell startup files are never edited and existing commands of the same name are kept) and are removed with the last integration.
107
+ - The terminal commands are placed in `~/.local/bin` by the installer for every host, creating the folder when needed. Shell startup files are never edited (the installer prints the line to add when the folder is not on `PATH`) and an existing command of the same name is kept (the installer says so). They are removed with the last integration, whichever host that is.
93
108
 
94
109
  ### Check the current session
95
110
 
@@ -98,8 +113,8 @@ Type `/afuctx:status` in Claude Code, `$afuctx-status` in Codex, `/afuctx-status
98
113
  Without the terminal commands (for example when `~/.local/bin` is not on `PATH`), use the full npx commands. For Codex; for another host replace `codex` with `claude`, `pi` or `dsh`:
99
114
 
100
115
  ```sh
101
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 status codex --brief
102
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 ui
116
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 status codex --brief
117
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 ui
103
118
  ```
104
119
 
105
120
  ### What the dashboard shows
@@ -145,12 +160,12 @@ Run the same install command with options, then start a new session. For Codex;
145
160
 
146
161
  ```sh
147
162
  # Trigger at 30% of a manually set 200,000-token window (60,000 tokens)
148
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex --context-window 200000 --ratio 0.3
163
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex --context-window 200000 --ratio 0.3
149
164
  # Restore automatic settings
150
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex --context-window auto --ratio auto
165
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex --context-window auto --ratio auto
151
166
  ```
152
167
 
153
- For frequent use, run `npm install -g --registry=https://registry.npmjs.org afucontext@0.1.18` and use `afuctx` in place of the full `npx` invocation. For example, `afuctx dashboard --prices ./afu-prices.json` opens the dashboard with your price file to estimate costs.
168
+ For frequent use, run `npm install -g --registry=https://registry.npmjs.org afucontext@0.1.20` and use `afuctx` in place of the full `npx` invocation. For example, `afuctx dashboard --prices ./afu-prices.json` opens the dashboard with your price file to estimate costs.
154
169
 
155
170
  ## Upgrade and remove
156
171
 
@@ -172,8 +187,8 @@ npx --yes --registry=https://registry.npmjs.org afucontext@latest status --brief
172
187
  To upgrade or remove one host:
173
188
 
174
189
  ```sh
175
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 upgrade codex
176
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 remove codex
190
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 upgrade codex
191
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 remove codex
177
192
  ```
178
193
 
179
194
  Use `remove pi` or `remove dsh` for those integrations. In Windows PowerShell, use `npx.cmd` and double-quote the package name.
package/README.zh-CN.md CHANGED
@@ -20,13 +20,13 @@
20
20
 
21
21
  | 宿主 | 系统 | 一键安装 | 安装后启动 | 实测版本 |
22
22
  | --- | --- | --- | --- | --- |
23
- | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install claude` | `claude` | 2.1.293 |
24
- | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex` | `codex` | 0.153.4 |
25
- | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install pi` | `pi` | 0.84.4、1.5.0 |
26
- | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 0.2.2-rc.1 |
27
- | Codex CLI | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install codex` | `codex` | 预览,未实测,下一版本正式提供 |
28
- | Pi | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install pi` | `pi` | 预览,未实测,下一版本正式提供 |
29
- | DSH | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.18" install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 预览,未实测,下一版本正式提供 |
23
+ | <a id="afu-claude"></a>Claude Code | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install claude` | `claude` | 2.1.293 |
24
+ | <a id="afu-codex"></a>Codex CLI | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex` | `codex` | 0.153.4 |
25
+ | <a id="afu-pi"></a>Pi | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install pi` | `pi` | 0.84.4、1.5.0 |
26
+ | <a id="afu-dsh"></a>DSH | macOS / Linux | `npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 0.2.2-rc.1 |
27
+ | Codex CLI | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install codex` | `codex` | 预览,未实测,下一版本正式提供 |
28
+ | Pi | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install pi` | `pi` | 预览,未实测,下一版本正式提供 |
29
+ | DSH | Windows 10/11¹ | `npx.cmd --yes --registry=https://registry.npmjs.org "afucontext@0.1.20" install dsh --profile YOUR_PROFILE` | `dsh --profile YOUR_PROFILE` | 预览,未实测,下一版本正式提供 |
30
30
 
31
31
  支持 macOS(Apple Silicon)和 x64 Linux;安装后新开会话即可。
32
32
 
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## 命令一览
40
40
 
41
- 终端命令(`afuctx …`)在首次安装后放到 `~/.local/bin`;该目录不在 `PATH` 时,把 `afuctx` 换成 `npx --yes --registry=https://registry.npmjs.org afucontext@latest`。安装、升级、移除这类会替换运行时本身的命令,始终用 npx 执行。
41
+ 终端命令(`afuctx …`)在首次安装任一宿主后放到 `~/.local/bin`,目录不存在时会自动创建。该目录不在 `PATH` 时,安装程序会提示:把 `export PATH="$HOME/.local/bin:$PATH"` 加到 `~/.bashrc` 或 `~/.zshrc`,或者把 `afuctx` 换成 `npx --yes --registry=https://registry.npmjs.org afucontext@latest`。安装、升级、移除这类会替换运行时本身的命令,始终用 npx 执行。
42
42
 
43
43
  | 命令 | 作用 |
44
44
  | --- | --- |
@@ -48,6 +48,8 @@
48
48
  | `npx --yes --registry=https://registry.npmjs.org afucontext@latest remove 宿主` | 移除,宿主配置恢复成安装前的样子;DSH 可加 `--profile 名称` 只移除一个 profile |
49
49
  | `afuctx status` | 各宿主是否接入、当前上下文离触发线多远、已压缩几次;加 `--json` 输出完整信息 |
50
50
  | `afuctx dashboard` | 打开本地面板(同 `afuctx-admin`);`--prices 文件` 用自己的单价估算费用 |
51
+ | `afuctx doctor` | 检查"宿主 → AFU → 模型"整条链路,能修的直接修,修不了的给出具体步骤;加宿主名只查一个 |
52
+ | `afuctx off` / `afuctx on` | 暂停 / 恢复:暂停时宿主直连模型,AFU 的安装、日志、摘要都保留;加宿主名只作用于一个 |
51
53
  | `afuctx repair codex --network` | 重新查找并验证连到模型服务的代理(Claude Code 用 `repair claude`) |
52
54
  | `afuctx gc --dry-run` | 查看可清理的旧版本运行时和日志;去掉 `--dry-run` 才会真正清理 |
53
55
  | `afuctx --version` / `afuctx --help` | 版本号 / 帮助 |
@@ -60,6 +62,19 @@
60
62
  | Codex | `$afuctx-status` | `$afuctx-dashboard` |
61
63
  | Pi / DSH | `/afuctx-status` | `/afuctx-dashboard` |
62
64
 
65
+ ## 出问题时怎么办
66
+
67
+ | 现象 | 先做 |
68
+ | --- | --- |
69
+ | 装了以后宿主连不上、一直重连 | `afuctx off`,宿主立即恢复直连模型;之后运行 `afuctx doctor` 查原因,修好后 `afuctx on` |
70
+ | 状态提示连不上模型服务 | `afuctx doctor`(只查网络也可以 `afuctx repair codex --network`) |
71
+ | 代理软件导致连不上本机 AFU | 在代理软件(如 AntFamily)的例外 / 绕过列表里加入 `127.0.0.1` 和 `localhost`;`afuctx doctor` 会检查 |
72
+ | 配置或 hook 被改坏 | 重新执行安装命令 |
73
+ | 重启后不压缩 | 开一次宿主会话(hook 会拉起 AFU) |
74
+ | 彻底不用 | `remove`,宿主配置恢复成安装前的样子 |
75
+
76
+ AFU 连续几次连不上模型服务时,会自动重新查找并验证一条能用的网络路线(只改 AFU 自己的记录),面板里能看到。
77
+
63
78
  ## 评测效果
64
79
 
65
80
  在 LongBench V2 的 39 道历史问答回放中(20 道 Agent 历史、19 道对话历史),AFU 异步压缩答对 **23 道**,平均上下文为 **15.9k tokens**。
@@ -89,7 +104,7 @@
89
104
 
90
105
  - 面板在后台运行,不占用终端;再次打开会复用同一个面板,30 分钟没有页面打开时自动退出。
91
106
  - 默认暗色主题,右上角可切换亮色、跟随系统和中英文。
92
- - 终端命令由安装程序放在 `~/.local/bin`(已在 `PATH` 中时才放,不改 shell 配置,不覆盖你已有的同名命令),移除最后一个接入时一并删除。
107
+ - 终端命令由安装程序放在 `~/.local/bin`,四个宿主都会放,目录不存在时自动创建。不改 shell 配置(目录不在 `PATH` 时会提示要加的那一行),不覆盖你已有的同名命令(会提示未覆盖)。移除最后一个接入时一并删除,不论是哪个宿主。
93
108
 
94
109
  ### 查看当前会话
95
110
 
@@ -98,8 +113,8 @@ Claude Code 输入 `/afuctx:status`,Codex 输入 `$afuctx-status`,Pi 和 DSH
98
113
  没有上面的终端命令时(例如 `~/.local/bin` 不在 `PATH` 中),可以用完整的 npx 命令。以 Codex 为例,其它宿主将 `codex` 换成 `claude`、`pi` 或 `dsh`:
99
114
 
100
115
  ```sh
101
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 status codex --brief
102
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 ui
116
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 status codex --brief
117
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 ui
103
118
  ```
104
119
 
105
120
  ### 面板内容
@@ -145,12 +160,12 @@ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 ui
145
160
 
146
161
  ```sh
147
162
  # 在 30% 触发,并手动指定 200,000 的窗口(即 60,000 触发)
148
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex --context-window 200000 --ratio 0.3
163
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex --context-window 200000 --ratio 0.3
149
164
  # 恢复自动设置
150
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 install codex --context-window auto --ratio auto
165
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 install codex --context-window auto --ratio auto
151
166
  ```
152
167
 
153
- 如果经常使用 AFU,可运行 `npm install -g --registry=https://registry.npmjs.org afucontext@0.1.18`,以后用 `afuctx` 代替完整的 `npx` 命令。例如,`afuctx dashboard --prices ./afu-prices.json` 用自己的单价文件打开面板估算费用。
168
+ 如果经常使用 AFU,可运行 `npm install -g --registry=https://registry.npmjs.org afucontext@0.1.20`,以后用 `afuctx` 代替完整的 `npx` 命令。例如,`afuctx dashboard --prices ./afu-prices.json` 用自己的单价文件打开面板估算费用。
154
169
 
155
170
  ## 升级与移除
156
171
 
@@ -172,8 +187,8 @@ npx --yes --registry=https://registry.npmjs.org afucontext@latest status --brief
172
187
  只升级或移除某一个宿主:
173
188
 
174
189
  ```sh
175
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 upgrade codex
176
- npx --yes --registry=https://registry.npmjs.org afucontext@0.1.18 remove codex
190
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 upgrade codex
191
+ npx --yes --registry=https://registry.npmjs.org afucontext@0.1.20 remove codex
177
192
  ```
178
193
 
179
194
  Pi 和 DSH 可分别用 `remove pi` 或 `remove dsh` 撤销。Windows PowerShell 请使用 `npx.cmd` 并将包名放在双引号中。
package/bin/afu.mjs CHANGED
@@ -13,7 +13,7 @@ const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
13
13
  const manifest=JSON.parse(readFileSync(join(root,'release.json'),'utf8'));
14
14
  const env={...process.env,AFU_INSTALL_ROOT:root};
15
15
  let args=process.argv.slice(2);
16
- if(['install','upgrade','status','remove','gc','native','repair'].includes(args[0])){
16
+ if(['install','upgrade','status','remove','gc','native','repair','off','on','doctor'].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
18
  const inline=args.find(a=>a.startsWith('--state='));if(inline&&inline.length>8)env.AFU_STATE_DIR=resolve(inline.slice(8));
19
19
  }
@@ -456,6 +456,8 @@ def main():
456
456
  "autoload": registrations,
457
457
  "plugin_runtime": plugin_runtime,
458
458
  "removed": not registrations and not plugin_runtime,
459
+ # A removed integration is not paused: a later install starts active.
460
+ "paused": bool(old.get("paused")) and bool(registrations or plugin_runtime),
459
461
  },
460
462
  )
461
463
  else:
@@ -512,6 +514,7 @@ def main():
512
514
  "host": host,
513
515
  "mode": "host",
514
516
  "removed": False,
517
+ "paused": bool(old.get("paused")) and not old.get("removed"),
515
518
  "plugin_runtime": args.runtime_only or bool(old.get("plugin_runtime")),
516
519
  **({"host_version": host_version} if host_version else {}),
517
520
  "window": window,
@@ -521,6 +524,11 @@ def main():
521
524
  "autoload": registrations,
522
525
  },
523
526
  )
527
+ from afu_context.management import active_integrations, install_shortcut, remove_shortcut, shortcut_notes
528
+ if args.action == "remove":
529
+ shortcut, removed = None, (remove_shortcut() if not active_integrations(root) else False)
530
+ else:
531
+ shortcut, removed = install_shortcut(root), None
524
532
  print(
525
533
  json.dumps(
526
534
  {
@@ -530,9 +538,14 @@ def main():
530
538
  "mode": "host",
531
539
  "registrations": sorted(registrations),
532
540
  "launch": "pi" if host == "pi" else "dsh --profile " + key,
541
+ **({"shortcut": shortcut} if shortcut is not None else {}),
542
+ **({"shortcut_removed": removed} if removed is not None else {}),
533
543
  }
534
544
  )
535
545
  )
546
+ if not args.json:
547
+ for line in shortcut_notes(shortcut):
548
+ print("AFU: " + line, file=sys.stderr)
536
549
 
537
550
 
538
551
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afucontext",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Local asynchronous context compression and diagnostics",
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "0.1.18",
2
+ "version": "0.1.20",
3
3
  "name": "afucontext",
4
- "build": "238e9b3dd5de64befa91b526eb807222ca9a3d5562d66f9c2bbe750dfd5cd1e5",
5
- "wheel": "afu_context_local-0.1.18-py3-none-any.whl",
4
+ "build": "7103b588cc60356b806eefbe524a14ee65eb28e04c9536bf6c022892af50c437",
5
+ "wheel": "afu_context_local-0.1.20-py3-none-any.whl",
6
6
  "prebuilt": false
7
7
  }
@@ -1,6 +1,6 @@
1
1
  import { BasicCompactionEngine as BundledBasicCompactionEngine, } from "@deepseek-ai/dsh-compaction-basic";
2
2
  import { BlockAssembler as BundledBlockAssembler, createUserMessage as bundledCreateUserMessage, } from "@deepseek-ai/dsh-llm";
3
- import { journal, sessionHash, afuTrigger, summaryPrefix, observationId, HostCompactPool, bindingKey, estimateContentTokens, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
3
+ import { journal, sessionHash, afuTrigger, afuPaused, summaryPrefix, observationId, HostCompactPool, bindingKey, estimateContentTokens, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
4
4
  const hostModules = (globalThis.__afuDshHost) ?? {};
5
5
  const BasicCompactionEngine = typeof hostModules.BasicCompactionEngine === "function" ? hostModules.BasicCompactionEngine : BundledBasicCompactionEngine;
6
6
  const BlockAssembler = typeof hostModules.BlockAssembler === "function" ? hostModules.BlockAssembler : BundledBlockAssembler;
@@ -78,7 +78,7 @@ export class CompactDshEngine extends BasicCompactionEngine {
78
78
  ctx.logger.warn("compact idle preparation unavailable; native compaction remains active");
79
79
  });
80
80
  ctx.on("agent/status", async ({ agent, status }) => {
81
- if (status !== "idle")
81
+ if (status !== "idle" || afuPaused("dsh"))
82
82
  return;
83
83
  // Context size as DSH's own token meter prices it, plus the AFU trigger for the routed model.
84
84
  let tokens, window;
@@ -140,6 +140,9 @@ export class CompactDshEngine extends BasicCompactionEngine {
140
140
  return tokens === undefined ? request : { ...request, metadata: { ...request.metadata, host_context_tokens: tokens } };
141
141
  }
142
142
  async compactIfNeeded(agent, trigger, signal) {
143
+ // afuctx off: DSH's own compaction, unchanged.
144
+ if (afuPaused("dsh"))
145
+ return super.compactIfNeeded(agent, trigger, signal);
143
146
  const started = performance.now();
144
147
  this.handoffCommitted.delete(agent);
145
148
  try {
@@ -200,6 +203,8 @@ export class CompactDshEngine extends BasicCompactionEngine {
200
203
  }
201
204
  }
202
205
  async summarize(input, agent, signal) {
206
+ if (afuPaused("dsh"))
207
+ return super.summarize(input, agent, signal);
203
208
  let request;
204
209
  try {
205
210
  request = await this.boundRequest(agent, input.messages, "sync");
@@ -220,6 +225,12 @@ export class CompactDshEngine extends BasicCompactionEngine {
220
225
  this.handoff(agent, prepared);
221
226
  return { ...preparedSummary, provider: target.provider, model: target.model };
222
227
  }
228
+ if (prepared !== undefined) {
229
+ // Previously only slower; now visible: the advance summary did not fit.
230
+ const artifact = findAppliedArtifact(prepared);
231
+ this.log("summary_skipped", { session: sessionHash(agent.session.id), reason: "host_prepared_unused",
232
+ ...(observationId(prepared) ?? artifact?.artifactId ? { artifact_id: observationId(prepared) ?? artifact?.artifactId } : {}) });
233
+ }
223
234
  try {
224
235
  const result = await this.compactClient.compact(request, signal);
225
236
  const summary = dshSummaryFromResult(request, result);
@@ -400,22 +411,67 @@ export function dshMessagesToRequest(messages, sessionId, mode = "active") {
400
411
  };
401
412
  }
402
413
  /**
403
- * Resolve a prepared Artifact to the exact DSH surface span it covers.
404
- * Stale, reordered, or non-contiguous coverage is rejected without mutation.
414
+ * Where a prepared summary's coverage lies in a request, matched loosely.
415
+ *
416
+ * DSH messages have stable ids. The summary's last covered id is the anchor
417
+ * and must be present; the region runs from the first covered id found to the
418
+ * anchor. Items inside it the summary does not know (inserted by DSH or a
419
+ * plugin) are replaced with the rest, as long as they are few; covered ids
420
+ * that are gone do not matter. System items are context, never coverage.
421
+ */
422
+ export function dshLooseCover(items, replacedIds) {
423
+ const anchor = replacedIds.at(-1);
424
+ let end = -1;
425
+ for (let k = items.length - 1; k >= 0; k--)
426
+ if (items[k].id === anchor) {
427
+ end = k;
428
+ break;
429
+ }
430
+ if (anchor === undefined || end < 0)
431
+ return { reason: "anchor_not_found" };
432
+ const known = new Set(replacedIds);
433
+ const start = items.findIndex((item) => known.has(item.id));
434
+ // Covered ids out of their recorded order, or after the anchor: not the
435
+ // history it summarized.
436
+ for (let k = end + 1; k < items.length; k++)
437
+ if (known.has(items[k].id))
438
+ return { reason: "anchor_not_found" };
439
+ const order = new Map(replacedIds.map((id, k) => [id, k]));
440
+ let last = -1;
441
+ for (let k = start; k <= end; k++) {
442
+ const position = order.get(items[k].id);
443
+ if (position === undefined)
444
+ continue;
445
+ if (position < last)
446
+ return { reason: "anchor_not_found" };
447
+ last = position;
448
+ }
449
+ let unknown = 0, found = 0;
450
+ for (let k = start; k <= end; k++) {
451
+ if (items[k].kind === "system")
452
+ continue;
453
+ if (known.has(items[k].id))
454
+ found++;
455
+ else
456
+ unknown++;
457
+ }
458
+ if (unknown > Math.max(3, Math.floor(0.2 * (unknown + found))) || unknown > found)
459
+ return { reason: "low_overlap" };
460
+ return { start, end, unknown };
461
+ }
462
+ /**
463
+ * Resolve a prepared Artifact to the DSH surface span it covers (loosely; see
464
+ * dshLooseCover). Anything that cannot be located is rejected without mutation.
405
465
  */
406
466
  export function dshPreparedRange(session, request, result) {
407
467
  const artifact = findAppliedArtifact(result);
408
468
  if (artifact === undefined)
409
469
  return undefined;
410
- const requestIds = request.items.map((item) => item.id);
411
- const startIndex = requestIds.indexOf(artifact.replacedItemIds[0] ?? "");
412
- if (startIndex < 0)
470
+ const cover = dshLooseCover(request.items, artifact.replacedItemIds);
471
+ if ("reason" in cover)
413
472
  return undefined;
414
- const coveredIds = requestIds.slice(startIndex, startIndex + artifact.replacedItemIds.length);
415
- if (coveredIds.length !== artifact.replacedItemIds.length ||
416
- coveredIds.some((id, index) => id !== artifact.replacedItemIds[index])) {
417
- return undefined;
418
- }
473
+ const startId = request.items[cover.start].id;
474
+ const endId = request.items[cover.end].id;
419
475
  const seqByMessageId = new Map();
420
476
  for (const seq of session.surface.nodes) {
421
477
  const event = session.eventAt(seq);
@@ -425,26 +481,29 @@ export function dshPreparedRange(session, request, result) {
425
481
  if (message !== null)
426
482
  seqByMessageId.set(String(message.id), seq);
427
483
  }
428
- const start = seqByMessageId.get(artifact.replacedItemIds[0] ?? "");
429
- const end = seqByMessageId.get(artifact.replacedItemIds.at(-1) ?? "");
484
+ const start = seqByMessageId.get(startId);
485
+ const end = seqByMessageId.get(endId);
430
486
  return start === undefined || end === undefined ? undefined : { start, end };
431
487
  }
432
- /** Return a DSH summary only when one Artifact covers the whole selected input. */
488
+ /**
489
+ * A DSH summary when one Artifact covers the region DSH selected (loosely):
490
+ * its anchor must be the region's last conversation item, so nothing DSH
491
+ * keeps after the region is described or lost; a few unknown items inside the
492
+ * region are summarized away with it.
493
+ */
433
494
  export function dshSummaryFromResult(request, result) {
434
495
  const artifact = findAppliedArtifact(result);
435
496
  if (artifact === undefined || typeof artifact.item.content !== "string")
436
497
  return undefined;
498
+ const conversation = request.items.filter((item) => item.kind !== "system");
499
+ if (conversation.at(-1)?.id !== artifact.replacedItemIds.at(-1))
500
+ return undefined;
437
501
  // DSH 0.2 replays the system head in front of the region it summarizes;
438
- // the summary replaces only the region, so a leading system message is
439
- // context, not coverage.
440
- let first = 0;
441
- while (first < request.items.length && request.items[first].kind === "system" && request.items[first].id !== artifact.replacedItemIds[0])
442
- first++;
443
- const requestIds = request.items.slice(first).map((item) => item.id);
444
- if (artifact.replacedItemIds.length !== requestIds.length ||
445
- artifact.replacedItemIds.some((id, index) => id !== requestIds[index])) {
502
+ // the summary replaces only the region, so system messages are context.
503
+ const known = new Set(artifact.replacedItemIds);
504
+ const unknown = conversation.filter((item) => !known.has(item.id)).length;
505
+ if (unknown > Math.max(3, Math.floor(0.2 * conversation.length)) || unknown > conversation.length - unknown)
446
506
  return undefined;
447
- }
448
507
  const summary = [{ type: "text", text: artifact.item.content }];
449
508
  return {
450
509
  summary,
@@ -1,5 +1,29 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { sessionEntryToContextMessages, convertToLlm, estimateTokens, getAgentDir, SettingsManager, DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
2
- import { journal, sessionHash, afuTrigger, summaryPrefix, observationId, afuStatus, afuDashboard, STATUS_COMMAND, DASHBOARD_COMMAND, CompactWorkerClient, HostCompactPool, bindingKey, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
3
+ import { journal, sessionHash, afuTrigger, afuPaused, summaryPrefix, observationId, afuStatus, afuDashboard, STATUS_COMMAND, DASHBOARD_COMMAND, CompactWorkerClient, HostCompactPool, bindingKey, findAppliedArtifact, normalizeContent, } from "../../shared/runtime-client/dist/index.js";
4
+ /** Requests in a row a ready summary may fail to fit before it is dropped. */
5
+ export const MAX_MISSES = 3;
6
+ /**
7
+ * The conversation as Pi sends it to the model, entry by entry.
8
+ *
9
+ * Pi 1.5 builds requests from buildSessionProjection(): context edits applied,
10
+ * an older compaction kept inside the newest retained range contributes
11
+ * nothing, and a compaction entry contributes its system message and its
12
+ * summary. Pi 0.84 has no projection; its context entries map one to one.
13
+ */
14
+ export function piProjectedEntries(manager) {
15
+ const view = manager;
16
+ if (typeof view.buildSessionProjection === "function") {
17
+ return view.buildSessionProjection().entries.map((projected) => ({ entry: projected.sourceEntry, messages: projected.messages }));
18
+ }
19
+ return project(view.buildContextEntries());
20
+ }
21
+ function project(entries) {
22
+ return entries.map((item) => isProjected(item) ? item : { entry: item, messages: sessionEntryToContextMessages(item) });
23
+ }
24
+ function isProjected(item) {
25
+ return isRecord(item) && "entry" in item && Array.isArray(item.messages);
26
+ }
3
27
  export function createCompactPiExtension(options = {}) {
4
28
  return function compactPi(pi) {
5
29
  const injected = options.worker instanceof CompactWorkerClient ? options.worker : undefined;
@@ -17,7 +41,7 @@ export function createCompactPiExtension(options = {}) {
17
41
  let automatic;
18
42
  const fingerprint = (ctx) => bindingKey([
19
43
  ctx.sessionManager.getSessionId(), ctx.model,
20
- ctx.sessionManager.buildContextEntries(),
44
+ piProjectedEntries(ctx.sessionManager).map((item) => [item.entry.id, item.messages]),
21
45
  ]);
22
46
  const invalidate = () => {
23
47
  epoch++;
@@ -47,6 +71,9 @@ export function createCompactPiExtension(options = {}) {
47
71
  // very next model request (as the Codex/Claude Code gateway and DSH do),
48
72
  // whether or not Pi's own compaction later accepts them.
49
73
  const ready = new Map();
74
+ // Consecutive requests a ready summary did not fit; after MAX_MISSES it is
75
+ // dropped so the next settled turn prepares one for the current history.
76
+ const misses = new WeakMap();
50
77
  // What the next model request carries, recorded when Pi builds it and
51
78
  // settled by the assistant message that answers it (request_receipt).
52
79
  const outgoing = new Map();
@@ -55,9 +82,27 @@ export function createCompactPiExtension(options = {}) {
55
82
  // next compaction; those requests are the ones it made smaller.
56
83
  const committed = new Map();
57
84
  pi.on("context", async (event, ctx) => {
85
+ // afuctx off: Pi sends its own requests unchanged.
86
+ if (afuPaused("pi"))
87
+ return undefined;
58
88
  const sid = ctx.sessionManager.getSessionId();
59
89
  const summary = ready.get(sid);
60
- const messages = summary ? applyPreparedSummary(event.messages, summary) : undefined;
90
+ const match = summary ? matchPreparedSummary(event.messages, summary) : undefined;
91
+ const messages = match && "messages" in match ? match.messages : undefined;
92
+ if (summary && match && "reason" in match) {
93
+ const count = (misses.get(summary) ?? 0) + 1;
94
+ misses.set(summary, count);
95
+ // Structure only (positions, counts, a role); never message text.
96
+ if (count === 1)
97
+ log("summary_skipped", { session: sessionHash(sid), artifact_id: summary.artifactId, reason: match.reason,
98
+ ...(match.anchor !== undefined ? { anchor_position: match.anchor } : {}),
99
+ ...(match.unknown !== undefined ? { unknown_messages: match.unknown } : {}),
100
+ ...(match.firstUnknownRole ? { first_unknown_role: match.firstUnknownRole } : {}) });
101
+ if (count >= MAX_MISSES) {
102
+ ready.delete(sid);
103
+ log("summary_discarded", { session: sessionHash(sid), artifact_id: summary.artifactId, reason: "unmatched" });
104
+ }
105
+ }
61
106
  if (!summary || !messages) {
62
107
  const carried = committed.get(sid);
63
108
  const first = event.messages[0];
@@ -67,6 +112,7 @@ export function createCompactPiExtension(options = {}) {
67
112
  return undefined;
68
113
  }
69
114
  outgoing.set(sid, { applied: true, ...(summary.artifactId ? { artifactId: summary.artifactId } : {}), removed: summary.removedTokens });
115
+ misses.delete(summary);
70
116
  log("summary_applied", { session: sessionHash(sid), artifact_id: summary.artifactId, phase: "host_request", removed_tokens_estimate: summary.removedTokens });
71
117
  return { messages: messages };
72
118
  });
@@ -110,6 +156,8 @@ export function createCompactPiExtension(options = {}) {
110
156
  };
111
157
  pi.on("agent_settled", async (_event, ctx) => {
112
158
  invalidate();
159
+ if (afuPaused("pi"))
160
+ return;
113
161
  const generation = epoch;
114
162
  // Context size as Pi itself measures it, for the dashboard's progress to the AFU trigger.
115
163
  let usage;
@@ -124,12 +172,12 @@ export function createCompactPiExtension(options = {}) {
124
172
  ...(usage?.tokens == null ? {} : { estimated_input_tokens: usage.tokens }),
125
173
  ...afuTrigger(usage?.contextWindow),
126
174
  });
127
- const entries = ctx.sessionManager.buildContextEntries();
175
+ const entries = piProjectedEntries(ctx.sessionManager);
128
176
  const visible = modelVisibleEntries(entries);
129
177
  const latest = visible.at(-1);
130
178
  if (latest === undefined)
131
179
  return;
132
- const request = piEntriesToRequest(entries, ctx, new Set([latest.id]));
180
+ const request = piEntriesToRequest(entries, ctx, new Set([latest.entry.id]));
133
181
  if (request === undefined)
134
182
  return;
135
183
  try {
@@ -189,12 +237,14 @@ export function createCompactPiExtension(options = {}) {
189
237
  });
190
238
  pi.on("session_before_compact", async (event, ctx) => {
191
239
  invalidate();
240
+ if (afuPaused("pi"))
241
+ return undefined; // Pi's own compaction, unchanged.
192
242
  const started = performance.now();
193
- const entries = modelVisibleEntries(ctx.sessionManager.buildContextEntries());
243
+ const entries = modelVisibleEntries(piProjectedEntries(ctx.sessionManager));
194
244
  const latest = entries.at(-1);
195
245
  if (latest === undefined)
196
246
  return;
197
- const requiredIds = new Set([latest.id]);
247
+ const requiredIds = new Set([latest.entry.id]);
198
248
  const request = piEntriesToRequest(entries, ctx, requiredIds);
199
249
  if (request === undefined)
200
250
  return;
@@ -287,8 +337,8 @@ export async function piHostBinding(ctx, pi) {
287
337
  contextWindow: model.contextWindow,
288
338
  current: async () => key === await identity(),
289
339
  complete: async (prompt, signal, ids) => {
290
- const entries = ids?.length ? modelVisibleEntries(ctx.sessionManager.buildContextEntries()) : [];
291
- const selected = summaryPrefix(entries.map(entry => ({ id: entry.id, messages: convertToLlm(sessionEntryToContextMessages(entry)) })), ids, prompt);
340
+ const entries = ids?.length ? modelVisibleEntries(piProjectedEntries(ctx.sessionManager)) : [];
341
+ const selected = summaryPrefix(entries.map(item => ({ id: item.entry.id, messages: convertToLlm(item.messages) })), ids, prompt);
292
342
  const definitions = pi?.getAllTools();
293
343
  const tools = pi?.getActiveTools().map(name => {
294
344
  const tool = definitions?.find(t => t.name === name);
@@ -322,19 +372,23 @@ export function piEntriesToRequest(entries, ctx, requiredIds) {
322
372
  if (visible.length < 2)
323
373
  return undefined;
324
374
  const callGroups = toolCallGroups(visible);
325
- const items = visible.map((entry) => {
326
- const message = sessionEntryToContextMessages(entry)[0];
375
+ const items = visible.map((item) => {
376
+ const entry = item.entry;
377
+ // A compaction entry contributes its system message and its summary; the
378
+ // summary is the conversation content to carry forward.
379
+ const message = conversationMessage(item);
327
380
  if (message === undefined)
328
381
  throw new Error(`Pi entry ${entry.id} has no model-visible message`);
329
382
  const record = message;
330
383
  const role = typeof record.role === "string" ? record.role : "user";
331
384
  const callId = typeof record.toolCallId === "string" ? record.toolCallId : undefined;
332
385
  const atomicGroup = callId === undefined ? callGroups.entryGroups.get(entry.id) : callGroups.calls.get(callId);
386
+ const content = record.content ?? (typeof record.summary === "string" ? record.summary : ("summary" in entry ? entry.summary : ""));
333
387
  return {
334
388
  schema_version: "1",
335
389
  id: entry.id,
336
390
  kind: entry.type === "compaction" ? "artifact" : piKind(role),
337
- content: normalizeContent(record.content ?? ("summary" in entry ? entry.summary : "")),
391
+ content: normalizeContent(content),
338
392
  source: { schema_version: "1", provider: "pi.session", source_id: entry.id },
339
393
  required: requiredIds.has(entry.id),
340
394
  ...(atomicGroup === undefined ? {} : { atomic_group: atomicGroup }),
@@ -373,7 +427,7 @@ export function piCompactionFromResult(entries, result, tokensBefore) {
373
427
  if (artifact === undefined || typeof artifact.item.content !== "string")
374
428
  return undefined;
375
429
  const visible = modelVisibleEntries(entries);
376
- const visibleIds = visible.map((entry) => entry.id);
430
+ const visibleIds = visible.map((item) => item.entry.id);
377
431
  const start = coveredStart(visibleIds, artifact.replacedItemIds, visible);
378
432
  if (start < 0 || start + artifact.replacedItemIds.length >= visibleIds.length)
379
433
  return undefined;
@@ -425,70 +479,188 @@ function piKeepRecentTokens(ctx) {
425
479
  */
426
480
  export function piCanCompact(ctx, visible, keepRecent = piKeepRecentTokens(ctx)) {
427
481
  let total = 0;
428
- for (const entry of visible) {
482
+ for (const { entry, messages } of project(visible)) {
429
483
  if (entry.type === "compaction") {
430
484
  total = 0;
431
485
  continue;
432
486
  }
433
- for (const message of sessionEntryToContextMessages(entry))
487
+ for (const message of messages)
434
488
  total += estimateTokens(message);
435
489
  }
436
490
  return total > keepRecent;
437
491
  }
492
+ /**
493
+ * A message's identity in Pi's request: role, timestamp and tool call id.
494
+ * Every Pi message carries a timestamp; parallel tool results can share one
495
+ * millisecond, and their call id tells them apart. Nothing is written into
496
+ * Pi's messages, which end up in the user's session file.
497
+ */
498
+ export function piFingerprint(message) {
499
+ const m = (message ?? {});
500
+ return hash(JSON.stringify([m.role ?? null, m.timestamp ?? null, m.toolCallId ?? null])).slice(0, 24);
501
+ }
502
+ function contentHash(message) {
503
+ const m = (message ?? {});
504
+ return hash(JSON.stringify(m.content ?? m.summary ?? null)).slice(0, 16);
505
+ }
506
+ function hash(text) {
507
+ return createHash("sha256").update(text).digest("hex");
508
+ }
438
509
  /** A ready result that replaces a leading run of the given entries. */
439
- export function preparedSummary(visible, result) {
510
+ export function preparedSummary(entries, result) {
440
511
  const artifact = findAppliedArtifact(result);
441
512
  if (artifact === undefined || typeof artifact.item.content !== "string")
442
513
  return undefined;
443
- const ids = visible.map((entry) => entry.id);
514
+ const visible = modelVisibleEntries(entries);
515
+ const ids = visible.map((item) => item.entry.id);
444
516
  const start = coveredStart(ids, artifact.replacedItemIds, visible);
445
517
  if (start < 0 || start + artifact.replacedItemIds.length >= ids.length)
446
518
  return undefined;
447
- const head = visible.slice(0, start).flatMap((entry) => sessionEntryToContextMessages(entry));
448
- const replaced = visible.slice(start, start + artifact.replacedItemIds.length).flatMap((entry) => sessionEntryToContextMessages(entry));
519
+ // Everything before the end of the covered range, without system messages:
520
+ // Pi 1.5 drops them from the `context` view, and they are kept verbatim.
521
+ const covered = visible.slice(0, start + artifact.replacedItemIds.length)
522
+ .flatMap((item) => item.messages).filter((message) => !isSystemMessage(message));
523
+ if (covered.length === 0)
524
+ return undefined;
525
+ const replaced = visible.slice(start, start + artifact.replacedItemIds.length).flatMap((item) => item.messages);
449
526
  const replacedChars = JSON.stringify(replaced).length;
450
527
  // Same id the summary_started/summary_ready events carry, so the dashboard links them.
451
528
  const artifactId = observationId(result) ?? artifact.artifactId;
452
529
  return {
453
- head,
454
- replaced,
530
+ fingerprints: covered.map(piFingerprint),
531
+ contents: covered.map(contentHash),
455
532
  summary: artifact.item.content,
456
533
  ...(artifactId === undefined ? {} : { artifactId }),
457
534
  removedTokens: Math.max(0, Math.round((replacedChars - artifact.item.content.length) / 4)),
458
535
  };
459
536
  }
460
537
  /**
461
- * Replace the summarized prefix of a model request with Pi's own compaction
462
- * summary message. Only an exact prefix match is rewritten; anything else
463
- * (a later Pi compaction, a branch switch, edited history) is sent unchanged.
538
+ * Where a ready summary goes in this request, if anywhere.
539
+ *
540
+ * The summary covers the conversation up to an anchor (its last message). The
541
+ * request is matched loosely: the anchor must be present and its content
542
+ * unchanged; messages before it the summary does not know (inserted by Pi or
543
+ * an extension, of a new kind) are replaced with the rest; messages it knows
544
+ * that are gone (omitted by a context edit) do not matter. Three cases are
545
+ * refused because they lose more than a few messages: the anchor is missing
546
+ * (another branch, rewound history), the anchor is in the current turn (the
547
+ * user's question would be removed), and most messages before the anchor are
548
+ * unknown (the anchor matched by chance). The cut never separates a tool call
549
+ * from its result: it moves back before the call.
550
+ *
551
+ * System messages stay where Pi expects them: those before the cut are kept,
552
+ * in order, ahead of the summary.
464
553
  */
465
- export function applyPreparedSummary(messages, ready) {
466
- const head = ready.head ?? [];
467
- const count = ready.replaced.length;
468
- if (messages.length <= head.length + count)
469
- return undefined;
470
- for (let index = 0; index < head.length; index++) {
471
- if (JSON.stringify(messages[index]) !== JSON.stringify(head[index]))
472
- return undefined;
554
+ export function matchPreparedSummary(messages, ready) {
555
+ const conversation = [];
556
+ messages.forEach((message, index) => { if (!isSystemMessage(message))
557
+ conversation.push(index); });
558
+ const prints = conversation.map((index) => piFingerprint(messages[index]));
559
+ const anchorPrint = ready.fingerprints.at(-1);
560
+ let anchor = -1;
561
+ for (let k = prints.length - 1; k >= 0; k--)
562
+ if (prints[k] === anchorPrint) {
563
+ anchor = k;
564
+ break;
565
+ }
566
+ if (anchor < 0)
567
+ return { reason: "anchor_not_found" };
568
+ if (contentHash(messages[conversation[anchor]]) !== ready.contents.at(-1))
569
+ return { reason: "content_changed", anchor };
570
+ // The user's current question must stay after the cut.
571
+ const lastUser = prints.reduce((found, _p, k) => roleOf(messages[conversation[k]]) === "user" ? k : found, -1);
572
+ if (anchor >= prints.length - 1 || lastUser <= anchor)
573
+ return { reason: "anchor_in_current_turn", anchor };
574
+ // Most of what lies before the anchor must be what the summary covers.
575
+ const known = new Map(ready.fingerprints.map((print, k) => [print, ready.contents[k]]));
576
+ let unknown = 0, firstUnknownRole;
577
+ for (let k = 0; k < anchor; k++) {
578
+ if (known.has(prints[k]))
579
+ continue;
580
+ unknown++;
581
+ firstUnknownRole ??= roleOf(messages[conversation[k]]);
582
+ }
583
+ if (unknown > Math.max(3, Math.floor(0.2 * anchor)) || unknown > anchor - unknown) {
584
+ return { reason: "low_overlap", anchor, unknown, ...(firstUnknownRole ? { firstUnknownRole } : {}) };
473
585
  }
474
- for (let index = 0; index < count; index++) {
475
- if (JSON.stringify(messages[head.length + index]) !== JSON.stringify(ready.replaced[index]))
476
- return undefined;
586
+ // A known message whose content changed (a context edit rewrote it) is no
587
+ // longer what the summary describes.
588
+ for (let k = 0; k < anchor; k++) {
589
+ const expected = known.get(prints[k]);
590
+ if (expected !== undefined && expected !== contentHash(messages[conversation[k]]))
591
+ return { reason: "content_changed", anchor };
592
+ }
593
+ // Never separate a tool call from its result.
594
+ let cut = anchor + 1;
595
+ cut = toolSafeCut(messages, conversation, cut);
596
+ if (cut <= 0)
597
+ return { reason: "anchor_in_current_turn", anchor };
598
+ const end = conversation[cut - 1] + 1;
599
+ const first = messages[conversation[0]];
600
+ return {
601
+ unknown,
602
+ messages: [
603
+ ...messages.slice(0, end).filter(isSystemMessage),
604
+ { role: "compactionSummary", summary: ready.summary, tokensBefore: ready.removedTokens, timestamp: first?.timestamp ?? Date.now() },
605
+ ...messages.slice(end),
606
+ ],
607
+ };
608
+ }
609
+ /** Earlier cut (in conversation positions) so no tool result is kept without its call. */
610
+ function toolSafeCut(messages, conversation, cut) {
611
+ const callsBefore = (limit) => {
612
+ const ids = new Set();
613
+ for (let k = 0; k < limit; k++)
614
+ for (const id of toolCallIds(messages[conversation[k]]))
615
+ ids.add(id);
616
+ return ids;
617
+ };
618
+ for (let guard = 0; guard < conversation.length && cut > 0; guard++) {
619
+ const before = callsBefore(cut);
620
+ // A result after the cut whose call is before it would lose its call.
621
+ let moved = false;
622
+ for (let k = cut; k < conversation.length; k++) {
623
+ const result = messages[conversation[k]];
624
+ if (result.role === "toolResult" && typeof result.toolCallId === "string" && before.has(result.toolCallId)) {
625
+ // Move the cut before the message that made this call.
626
+ let owner = cut - 1;
627
+ while (owner >= 0 && !toolCallIds(messages[conversation[owner]]).includes(result.toolCallId))
628
+ owner--;
629
+ cut = Math.max(0, owner);
630
+ moved = true;
631
+ break;
632
+ }
633
+ }
634
+ if (!moved)
635
+ return cut;
477
636
  }
478
- const first = messages[head.length];
479
- return [
480
- ...messages.slice(0, head.length),
481
- { role: "compactionSummary", summary: ready.summary, tokensBefore: ready.removedTokens, timestamp: first?.timestamp ?? Date.now() },
482
- ...messages.slice(head.length + count),
483
- ];
637
+ return cut;
638
+ }
639
+ function toolCallIds(message) {
640
+ const content = message?.content;
641
+ if (!Array.isArray(content))
642
+ return [];
643
+ return content.flatMap((block) => isRecord(block) && block.type === "toolCall" && typeof block.id === "string" ? [block.id] : []);
644
+ }
645
+ function roleOf(message) {
646
+ const role = message?.role;
647
+ return typeof role === "string" ? role : "unknown";
648
+ }
649
+ /** The request with the summary applied, or undefined (see matchPreparedSummary). */
650
+ export function applyPreparedSummary(messages, ready) {
651
+ const result = matchPreparedSummary(messages, ready);
652
+ return "messages" in result ? result.messages : undefined;
653
+ }
654
+ function isSystemMessage(message) {
655
+ return message?.role === "system";
484
656
  }
485
657
  /** Tokens (Pi's estimate) the entries before firstKeptEntryId cost, minus the summary replacing them. */
486
658
  function removedBy(entries, firstKeptEntryId, summary) {
487
659
  let total = 0;
488
- for (const entry of entries) {
660
+ for (const { entry, messages } of project(entries)) {
489
661
  if (entry.id === firstKeptEntryId)
490
662
  break;
491
- for (const message of sessionEntryToContextMessages(entry))
663
+ for (const message of messages)
492
664
  total += estimateTokens(message);
493
665
  }
494
666
  return Math.max(0, total - Math.ceil(summary.length / 4));
@@ -502,8 +674,9 @@ function removedBy(entries, firstKeptEntryId, summary) {
502
674
  export function coveredStart(visibleIds, replaced, visible) {
503
675
  if (replaced.length === 0)
504
676
  return -1;
677
+ const projected = project(visible);
505
678
  let start = 0;
506
- while (start < visible.length && visibleIds[start] !== replaced[0] && isSystemEntry(visible[start]))
679
+ while (start < projected.length && visibleIds[start] !== replaced[0] && isSystemEntry(projected[start]))
507
680
  start++;
508
681
  if (visibleIds[start] !== replaced[0])
509
682
  return -1;
@@ -513,10 +686,15 @@ export function coveredStart(visibleIds, replaced, visible) {
513
686
  return start;
514
687
  }
515
688
  function isSystemEntry(entry) {
516
- return sessionEntryToContextMessages(entry).every((message) => message.role === "system");
689
+ return entry.messages.every(isSystemMessage);
517
690
  }
691
+ /** Entries that put at least one message into the model request. */
518
692
  function modelVisibleEntries(entries) {
519
- return entries.filter((entry) => sessionEntryToContextMessages(entry).length > 0);
693
+ return project(entries).filter((item) => item.messages.length > 0);
694
+ }
695
+ /** The message an entry contributes as conversation (a compaction's summary, not its system message). */
696
+ function conversationMessage(item) {
697
+ return item.messages.find((message) => !isSystemMessage(message)) ?? item.messages[0];
520
698
  }
521
699
  function piKind(role) {
522
700
  if (role === "assistant")
@@ -530,8 +708,8 @@ function piKind(role) {
530
708
  function toolCallGroups(entries) {
531
709
  const calls = new Map();
532
710
  const entryGroups = new Map();
533
- for (const entry of entries) {
534
- const message = sessionEntryToContextMessages(entry)[0];
711
+ for (const item of entries) {
712
+ const message = conversationMessage(item);
535
713
  if (!isRecord(message) || !Array.isArray(message.content))
536
714
  continue;
537
715
  const callIds = message.content.flatMap((block) => {
@@ -541,8 +719,8 @@ function toolCallGroups(entries) {
541
719
  });
542
720
  if (callIds.length === 0)
543
721
  continue;
544
- const group = `pi-tool-batch:${entry.id}`;
545
- entryGroups.set(entry.id, group);
722
+ const group = `pi-tool-batch:${item.entry.id}`;
723
+ entryGroups.set(item.entry.id, group);
546
724
  for (const callId of callIds)
547
725
  calls.set(callId, group);
548
726
  }
@@ -237,7 +237,7 @@ function isJsonValue(value) {
237
237
  return isRecord(value) && Object.values(value).every(isJsonValue);
238
238
  }
239
239
  export { HostCompactPool, bindingKey } from "./host-worker.js";
240
- export { journal, sessionHash, afuTrigger } from "./telemetry.js";
240
+ export { journal, sessionHash, afuTrigger, afuPaused } from "./telemetry.js";
241
241
  export { summaryPrefix } from "./prefix.js";
242
242
  export { afuStatus, afuDashboard, STATUS_COMMAND, DASHBOARD_COMMAND } from "./commands.js";
243
243
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,5 @@
1
- import { appendFileSync, openSync, closeSync, mkdirSync, statSync, renameSync, constants, readFileSync } from 'node:fs';
1
+ import { appendFileSync, openSync, closeSync, mkdirSync, statSync, renameSync, constants, readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { gzipSync } from 'node:zlib';
2
3
  import { join } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { randomUUID, createHash } from 'node:crypto';
@@ -36,7 +37,8 @@ export function journal(host) {
36
37
  function createJournal(host) {
37
38
  const root = join(process.env.AFU_STATE_DIR ?? join(homedir(), '.local/share/afu-context'), 'logs');
38
39
  const id = randomUUID().replaceAll('-', '');
39
- const file = join(root, `events-${process.pid}-${id}.jsonl`);
40
+ const file = join(root, `events-${host}-adapter-${process.pid}-${id}.jsonl`);
41
+ let failed = 0;
40
42
  let version = '0.1.0', build = 'development';
41
43
  try {
42
44
  const m = JSON.parse(readFileSync(join(process.env.AFU_INSTALL_ROOT ?? process.cwd(), 'release.json'), 'utf8'));
@@ -51,9 +53,9 @@ function createJournal(host) {
51
53
  const row = { event, host, version, build, runtime_id: id, pid: process.pid, time: new Date().toISOString() };
52
54
  // COSEC: diagnostics never include raw model IDs, credentials, prompts or errors.
53
55
  for (const [k, v] of Object.entries(fields)) {
54
- if (['session', 'artifact_id', 'phase', 'reason'].includes(k) && typeof v === 'string' && /^[A-Za-z0-9_.:+-]{1,100}$/.test(v))
56
+ if (['session', 'artifact_id', 'phase', 'reason', 'first_unknown_role'].includes(k) && typeof v === 'string' && /^[A-Za-z0-9_.:+-]{1,100}$/.test(v))
55
57
  row[k] = v;
56
- if (['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'context_window', 'threshold', 'estimated_input_tokens', 'removed_tokens_estimate', 'duration_ms', 'host_compaction_ms'].includes(k) && typeof v === 'number' && Number.isFinite(v) && v >= 0)
58
+ if (['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'context_window', 'threshold', 'estimated_input_tokens', 'removed_tokens_estimate', 'duration_ms', 'host_compaction_ms', 'anchor_position', 'unknown_messages'].includes(k) && typeof v === 'number' && Number.isFinite(v) && v >= 0)
57
59
  row[k] = v;
58
60
  if (['cache_usage_known', 'applied', 'upstream_success'].includes(k) && typeof v === 'boolean')
59
61
  row[k] = v;
@@ -61,18 +63,57 @@ function createJournal(host) {
61
63
  mkdirSync(root, { recursive: true, mode: 0o700 });
62
64
  try {
63
65
  if (statSync(file).size > 2 * 1024 * 1024)
64
- renameSync(file, file.replace('.jsonl', '.previous.jsonl'));
66
+ rotate(file);
65
67
  }
66
68
  catch { }
69
+ const lines = failed ? [{ event: 'journal_write_failed', host, version, build, runtime_id: id, pid: process.pid, time: row.time, failed_writes: failed }, row] : [row];
67
70
  const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
68
71
  try {
69
- appendFileSync(fd, JSON.stringify(row) + '\n');
72
+ appendFileSync(fd, lines.map(l => JSON.stringify(l) + '\n').join(''));
70
73
  }
71
74
  finally {
72
75
  closeSync(fd);
73
76
  }
77
+ failed = 0;
74
78
  }
75
- catch { } // Logging must never break a host request.
79
+ catch {
80
+ failed++;
81
+ } // Logging must never break a host request.
76
82
  };
77
83
  }
84
+ /** Move a full log to the next numbered, gzip-compressed file; nothing is overwritten. */
85
+ function rotate(file) {
86
+ let n = 1;
87
+ const stem = file.replace(/\.jsonl$/, '');
88
+ while (existsSync(`${stem}.${n}.jsonl`) || existsSync(`${stem}.${n}.jsonl.gz`))
89
+ n++;
90
+ const plain = `${stem}.${n}.jsonl`;
91
+ renameSync(file, plain);
92
+ try {
93
+ writeFileSync(plain + '.gz', gzipSync(readFileSync(plain)), { mode: 0o600 });
94
+ unlinkSync(plain);
95
+ }
96
+ catch { }
97
+ }
98
+ const pausedCache = new Map();
99
+ /**
100
+ * Whether the user paused AFU for this host (afuctx off). Read from the
101
+ * host's record in <state>/integrations at most once a second; any read
102
+ * problem counts as not paused, never as a reason to block the host.
103
+ */
104
+ export function afuPaused(host, now = Date.now()) {
105
+ const cached = pausedCache.get(host);
106
+ if (cached && now - cached.checked < 1000)
107
+ return cached.paused;
108
+ let paused = false;
109
+ try {
110
+ const file = join(process.env.AFU_STATE_DIR ?? join(homedir(), '.local/share/afu-context'), 'integrations', `${host}.json`);
111
+ paused = JSON.parse(readFileSync(file, 'utf8')).paused === true;
112
+ }
113
+ catch {
114
+ paused = false;
115
+ }
116
+ pausedCache.set(host, { checked: now, paused });
117
+ return paused;
118
+ }
78
119
  //# sourceMappingURL=telemetry.js.map