@tekmidian/pai 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
- package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
- package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
- package/dist/cli/index.mjs +14 -14
- package/dist/cli/program.mjs +14 -14
- package/dist/config-Bn9jpblO.mjs +653 -0
- package/dist/config-Bn9jpblO.mjs.map +1 -0
- package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
- package/dist/config-DYXARgk8.mjs.map +1 -0
- package/dist/config-RjeVGJ7K.mjs +3 -0
- package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
- package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
- package/dist/daemon/index.mjs +13 -12
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
- package/dist/daemon-CBTQYtHF.mjs.map +1 -0
- package/dist/daemon-Vf7pHnjR.mjs +25 -0
- package/dist/daemon-mcp/index.mjs +186 -23
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/factory-Ba8j9K7q.mjs +3 -0
- package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
- package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
- package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
- package/dist/fallback-CIGspCSi.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs +80 -8
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/context-compression-hook.mjs +54 -39
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/post-compact-inject.mjs +54 -39
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +204 -109
- package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
- package/dist/hooks/whisper-rules.mjs +7613 -9
- package/dist/hooks/whisper-rules.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +204 -109
- package/dist/hooks/worker-proxy.mjs.map +4 -4
- package/dist/hooks/worker-status-line.mjs +211 -116
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/hooks/worker-supervision.mjs +208 -113
- package/dist/hooks/worker-supervision.mjs.map +4 -4
- package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
- package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
- package/dist/main-resolver-BTastpp4.mjs +7 -0
- package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
- package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
- package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
- package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
- package/dist/{program-D9-wfmkZ.mjs → program-srUeFUfW.mjs} +5806 -5615
- package/dist/program-srUeFUfW.mjs.map +1 -0
- package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
- package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
- package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
- package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
- package/dist/run-TChOnMO_.mjs.map +1 -0
- package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
- package/dist/server-R9UkCn3v.mjs.map +1 -0
- package/dist/session-keepalive-BuG3wvik.mjs +579 -0
- package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
- package/dist/skills/VaultContext/SKILL.md +1 -1
- package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
- package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
- package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
- package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
- package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
- package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
- package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
- package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
- package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
- package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
- package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
- package/docs/cache-keepalive.md +138 -0
- package/docs/commands/README.md +7 -0
- package/docs/commands/audit.md +1 -1
- package/docs/commands/config.md +84 -0
- package/docs/commands/daemon.md +6 -0
- package/docs/commands/worker.md +37 -2
- package/docs/config.md +171 -0
- package/docs/model-catalogue.md +502 -0
- package/docs/provider-independence-details.md +1 -1
- package/docs/task-bus.md +1 -1
- package/docs/worker.md +33 -5
- package/docs/workers-config.md +105 -12
- package/package.json +1 -1
- package/src/hooks/ts/capture-all-events.test.ts +71 -0
- package/src/hooks/ts/capture-all-events.ts +10 -3
- package/src/hooks/ts/lib/context-fill.test.ts +124 -46
- package/src/hooks/ts/lib/context-fill.ts +57 -14
- package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
- package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
- package/statusline-command.sh +103 -16
- package/dist/config-DCu8THKi.mjs +0 -228
- package/dist/config-DCu8THKi.mjs.map +0 -1
- package/dist/config-DeqFqa1N.mjs +0 -3
- package/dist/config-DxMZjpvY.mjs.map +0 -1
- package/dist/daemon-CYpVy_Dw.mjs +0 -24
- package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
- package/dist/factory-47Qf_df7.mjs +0 -3
- package/dist/fallback-IeR2FrFn.mjs.map +0 -1
- package/dist/indexer-C5Le7XE7.mjs +0 -1
- package/dist/main-resolver-CnfnWa8C.mjs +0 -7
- package/dist/program-D9-wfmkZ.mjs.map +0 -1
- package/dist/query-feedback-C7rSfiXU.mjs +0 -3
- package/dist/run-Cls0YGgV.mjs.map +0 -1
- package/dist/server-Ch99xt5K.mjs.map +0 -1
- package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
package/docs/workers-config.md
CHANGED
|
@@ -2,26 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
One hand-editable file: which providers PAI can run workers on, the model
|
|
4
4
|
each provider uses per role, and which provider every `--class` goes to.
|
|
5
|
-
Before this, that lived inside `~/.
|
|
5
|
+
Before this, that lived inside `~/.claude/pai/config.json`'s `workers`
|
|
6
6
|
section — JSON, unrelated to the rest of that file, and undocumented in
|
|
7
7
|
place. Adding a provider meant reading code to find out what fields existed.
|
|
8
8
|
|
|
9
9
|
## Where it lives
|
|
10
10
|
|
|
11
11
|
`~/.claude/pai/workers.yaml` — under the PAI_HOME namespace dir, beside
|
|
12
|
-
PAI's other per-user files (`config.
|
|
12
|
+
PAI's other per-user files (`config.yaml`, `whisper-rules.md`,
|
|
13
13
|
`advisor-mode.json`), so nothing PAI writes can collide with a file Claude
|
|
14
14
|
Code itself introduces under `~/.claude`. Set `PAI_WORKERS_YAML=<path>` to
|
|
15
15
|
override this — tests and power users only, and `PAI_HOME=<dir>` to move
|
|
16
16
|
the whole namespace. Only providers, model roles, class routing and MCP
|
|
17
17
|
sets live here. Everything else worker-related — the follow-pane profile,
|
|
18
18
|
log dir, routing cooldown, sub-worker caps, cache-keepalive cadence, the
|
|
19
|
-
machine-wide fallback switch — stays in `config.json`'s `workers` section;
|
|
19
|
+
machine-wide fallback switch — stays in `config.yaml` (or `config.json` until you run `pai config yaml`)'s `workers` section;
|
|
20
20
|
none of it is provider-specific enough to want hand comments.
|
|
21
21
|
|
|
22
22
|
This file is per-user state: it can hold API keys (`key:`, below), is kept
|
|
23
23
|
mode 0600, and is never committed or shared.
|
|
24
24
|
|
|
25
|
+
The non-provider settings mentioned above — `pane`, `routing`, `tree`,
|
|
26
|
+
`cacheKeepaliveSecs`, `logDir`, the machine-wide fallback switch — live in
|
|
27
|
+
the main PAI config instead, under its `workers` section: see
|
|
28
|
+
[docs/config.md](config.md) for `config.yaml`'s layout and `pai config
|
|
29
|
+
get workers.cacheKeepaliveSecs` / `pai config set workers.pane.rows 40`
|
|
30
|
+
style access.
|
|
31
|
+
|
|
25
32
|
Before 2026-09-19 it lived at `~/.config/pai/workers.yaml`, and briefly at
|
|
26
33
|
`~/.claude/workers.yaml` in between. If yours is still at either old
|
|
27
34
|
location, `pai worker config check` and `pai worker providers` say so;
|
|
@@ -65,6 +72,18 @@ providers:
|
|
|
65
72
|
default: k3[1m]
|
|
66
73
|
fast: kimi-for-coding[1m]
|
|
67
74
|
|
|
75
|
+
# An `engine: image` provider does not spawn Claude — `pai worker run
|
|
76
|
+
# --capability image` POSTs straight to its OpenAI-compatible images API and
|
|
77
|
+
# writes the PNG it gets back. Uncomment and point it at a real provider
|
|
78
|
+
# (still nested under providers:, above) to enable `--capability image`:
|
|
79
|
+
# pictures:
|
|
80
|
+
# engine: image
|
|
81
|
+
# url: https://api.example.com/v1
|
|
82
|
+
# key: "<your-api-key>"
|
|
83
|
+
# models:
|
|
84
|
+
# default: example-image-model
|
|
85
|
+
# image: example-image-model
|
|
86
|
+
|
|
68
87
|
# A class names a provider, or provider/role to pick a non-default model.
|
|
69
88
|
# Workers exist to parallelise and to save cost: the default is Sonnet,
|
|
70
89
|
# Haiku for the mechanical classes, and nothing here inherits the
|
|
@@ -80,6 +99,13 @@ classes:
|
|
|
80
99
|
simple: anthropic/fast
|
|
81
100
|
image: glm/image
|
|
82
101
|
|
|
102
|
+
# Cross-provider capability preference: which provider serves a --capability
|
|
103
|
+
# request, in order, when more than one declares it. Unlisted capabilities
|
|
104
|
+
# fall back to the active provider, then any provider that declares them.
|
|
105
|
+
# capabilities:
|
|
106
|
+
# image: [pictures, glm]
|
|
107
|
+
# fast: [anthropic]
|
|
108
|
+
|
|
83
109
|
mcp_sets:
|
|
84
110
|
desktop: [clickr]
|
|
85
111
|
```
|
|
@@ -107,8 +133,10 @@ init` writes on a machine with no workers.yaml yet.
|
|
|
107
133
|
- **`providers.<name>.tier`** — cost/quality tier, 1 (cheapest) … 5 (most
|
|
108
134
|
expensive), default 3. Classes can constrain auto-routing to a max tier.
|
|
109
135
|
- **`providers.<name>.models`** — model id per capability. `default` is
|
|
110
|
-
required; `fast
|
|
111
|
-
|
|
136
|
+
required; every other name is open (`fast`, `image`, or a name of your own
|
|
137
|
+
choosing, matching `^[a-z][a-z0-9-]*$`) and falls back to `default` when a
|
|
138
|
+
class or run asks for it and the provider has none — see "Capabilities"
|
|
139
|
+
below.
|
|
112
140
|
- **`classes.<name>`** — `provider` or `provider/role` (`role` one of
|
|
113
141
|
`default`, `fast`, `image`). An object form
|
|
114
142
|
(`{provider, mcp, maxCostTier, requireTags, order}`) is also accepted for
|
|
@@ -123,10 +151,75 @@ Not in the starter, but read when present (same names `pai worker providers
|
|
|
123
151
|
add` accepts, snake_cased): `enabled: false` (skip in auto-routing without
|
|
124
152
|
deleting the entry), `protocol: openai` + `upstream_url` (routes through the
|
|
125
153
|
built-in Anthropic↔OpenAI proxy), `engine: codex` (runs the provider on the
|
|
126
|
-
Codex CLI instead of Claude Code)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
154
|
+
Codex CLI instead of Claude Code) or `engine: image` (see "Capabilities"
|
|
155
|
+
below), `env` (extra environment for runs through this provider), `note`
|
|
156
|
+
(shown in `pai worker providers`), `quota_probe` + `quota_skip_at`,
|
|
157
|
+
`context_window`, `tags`, `usage`, `model_tiers`. Full field semantics:
|
|
158
|
+
`src/workers/config.ts`.
|
|
159
|
+
|
|
160
|
+
## Capabilities
|
|
161
|
+
|
|
162
|
+
A provider's `models` table (above) picks a model *within* that provider —
|
|
163
|
+
`glm/image` runs on whatever `glm.models.image` names. The top-level
|
|
164
|
+
`capabilities:` map instead picks *which provider* serves a capability at
|
|
165
|
+
all, across the whole config:
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
capabilities:
|
|
169
|
+
image: [pictures, glm] # first usable one wins
|
|
170
|
+
fast: [anthropic]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`pai worker run --capability image -p '<prompt>'` (alias `--for`) resolves
|
|
174
|
+
through this list: the first entry that is enabled, declares an `image`
|
|
175
|
+
model, and passes the same runnable checks as any other provider. A `--class`
|
|
176
|
+
whose implied capability is not `default` (e.g. a class named `image` with no
|
|
177
|
+
provider of its own) resolves the same way; a class already pinned to a
|
|
178
|
+
provider (`image: glm/image`, the starter default) is unaffected — it always
|
|
179
|
+
runs that provider unchanged. An explicit `--provider` always wins over both.
|
|
180
|
+
|
|
181
|
+
Without a `capabilities:` entry, resolution falls back to the active
|
|
182
|
+
provider (if it declares the capability), then any provider that declares it
|
|
183
|
+
(stable order). If nothing anywhere does, the run refuses to guess: it
|
|
184
|
+
errors naming the config key to set, rather than silently running a normal
|
|
185
|
+
model on a prompt it cannot answer. `pai worker capability` (no args) lists
|
|
186
|
+
every preference and what it resolves to right now; `describeCapabilities`
|
|
187
|
+
truthfully reports "unresolved" or "fallback" in the same situation without
|
|
188
|
+
throwing, so listing your config never fails just because you have not
|
|
189
|
+
finished it.
|
|
190
|
+
|
|
191
|
+
### The image engine
|
|
192
|
+
|
|
193
|
+
A provider with `engine: image` (see the commented `pictures:` example
|
|
194
|
+
above) does not spawn Claude Code at all. `pai worker run --capability
|
|
195
|
+
image -p "<prompt>" [--size WxH] [--out <file>]` POSTs one
|
|
196
|
+
OpenAI-compatible request straight to `{url}/images/generations`:
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{"model": "<the resolved model>", "prompt": "<prompt>", "size": "1024x1024", "n": 1, "response_format": "b64_json"}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
...decodes the base64 PNG it gets back, and writes it to `--out` (default:
|
|
203
|
+
`<logDir>/<worker-id>.png`). No worktree, no MCP, no follow pane — one
|
|
204
|
+
request, one file. stdout is the same JSON shape `pai worker run` always
|
|
205
|
+
prints for a headless run: `{ok, path, model, provider, durationMs, bytes}`.
|
|
206
|
+
Asking a `claude`/`codex`-engine provider to run `--capability image` is not
|
|
207
|
+
an error by itself — it just means no image-generating provider is
|
|
208
|
+
configured, which the "nothing anywhere does" case above already reports
|
|
209
|
+
clearly.
|
|
210
|
+
|
|
211
|
+
CLI and MCP agree on the same preferences:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
pai worker capability # list every preference
|
|
215
|
+
pai worker capability image pictures,glm # set (first usable wins)
|
|
216
|
+
pai worker capability image --unset # remove it
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The `worker_capability` MCP tool takes the same `action: list|set|unset`,
|
|
220
|
+
`capability`, `providers` shape; `worker_run`'s `capability` field and
|
|
221
|
+
`worker_model`'s `capability` field (now an open set, not just `default` /
|
|
222
|
+
`fast` / `image`) round out the chat-side surface.
|
|
130
223
|
|
|
131
224
|
## Provider protocol shapes
|
|
132
225
|
|
|
@@ -311,7 +404,7 @@ that resolves `anthropic` or `anthropic/fast` picks up the override.
|
|
|
311
404
|
all come from here, validated as a whole (an unknown provider named by a
|
|
312
405
|
class is a load error naming the file and line, not a spawn-time
|
|
313
406
|
surprise).
|
|
314
|
-
2. Else the JSON `workers` section in `config.json` (`providers`, `classes`
|
|
407
|
+
2. Else the JSON `workers` section in `config.json` (or `config.yaml` if it exists) (`providers`, `classes`
|
|
315
408
|
— or the pre-rename `roles` — `mcpSets`, `active`), unchanged from
|
|
316
409
|
before this file existed. Nothing breaks on a machine that has not
|
|
317
410
|
migrated yet.
|
|
@@ -346,7 +439,7 @@ pai worker config inline-keys # move every key_file's contents inline as key: (q
|
|
|
346
439
|
- **no workers.yaml yet**: reads the JSON `workers` section, writes
|
|
347
440
|
workers.yaml with the same header/section comments as the starter, backs
|
|
348
441
|
the pre-migration JSON section up to `workers.json.migrated-<date>` next
|
|
349
|
-
to `config.json
|
|
442
|
+
to `config.json` (or `config.yaml` if it exists), and strips `providers`/`classes`/`roles`/`mcpSets`/
|
|
350
443
|
`active` from the JSON (everything else in that section — pane, log dir,
|
|
351
444
|
routing, tree, cache-keepalive, fallback — stays).
|
|
352
445
|
|
|
@@ -363,7 +456,7 @@ writing anything.
|
|
|
363
456
|
|
|
364
457
|
PAI is not a Claude Code plugin — it is a self-contained system that Claude
|
|
365
458
|
Code happens to be the first harness for. `~/.claude/pai/` (`PAI_HOME`) is
|
|
366
|
-
the single home for everything PAI owns: `config.
|
|
459
|
+
the single home for everything PAI owns: `config.yaml`, `workers.yaml`,
|
|
367
460
|
`whisper-rules.md`, `advisor-mode.json`, `session-state/`, `queries/`,
|
|
368
461
|
`logs/workers/` and the rest of PAI's per-user state and caches. `pai config
|
|
369
462
|
path` prints where every one of these currently resolves to; `pai config
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for capture-all-events hook
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
|
|
10
|
+
describe('capture-all-events: agent-sessions.json hardening', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
// Create a temporary directory for each test
|
|
15
|
+
tempDir = join(tmpdir(), `test-agent-sessions-${Date.now()}-${Math.random()}`);
|
|
16
|
+
mkdirSync(tempDir, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
// Clean up temp directory
|
|
21
|
+
if (existsSync(tempDir)) {
|
|
22
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('setAgentForSession recovers from corrupt JSON file', async () => {
|
|
27
|
+
// Import the functions dynamically to inject our test path
|
|
28
|
+
const sessionFile = join(tempDir, 'agent-sessions.json');
|
|
29
|
+
|
|
30
|
+
// Create a corrupt JSON file (truncated in the middle of a string)
|
|
31
|
+
writeFileSync(sessionFile, '{"session1": "pai", "session2": "designer", "session3": "un');
|
|
32
|
+
|
|
33
|
+
// Now simulate setAgentForSession behavior: read the file, handle parse error, write fresh map
|
|
34
|
+
let mappings: Record<string, string> = {};
|
|
35
|
+
try {
|
|
36
|
+
mappings = JSON.parse(readFileSync(sessionFile, 'utf-8'));
|
|
37
|
+
} catch {
|
|
38
|
+
// If file exists but is corrupt, start fresh
|
|
39
|
+
mappings = {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
mappings['session4'] = 'researcher';
|
|
43
|
+
|
|
44
|
+
// Write with atomic helper would succeed
|
|
45
|
+
// For this test, we just verify the recovery logic works
|
|
46
|
+
expect(Object.keys(mappings)).toEqual(['session4']);
|
|
47
|
+
expect(mappings['session4']).toBe('researcher');
|
|
48
|
+
|
|
49
|
+
// Verify we could write valid JSON
|
|
50
|
+
const testJson = JSON.stringify(mappings, null, 2);
|
|
51
|
+
expect(() => JSON.parse(testJson)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('setAgentForSession preserves existing mappings when adding new one', async () => {
|
|
55
|
+
const sessionFile = join(tempDir, 'agent-sessions.json');
|
|
56
|
+
|
|
57
|
+
// Create a valid sessions file
|
|
58
|
+
const initialMappings = { session1: 'pai', session2: 'designer' };
|
|
59
|
+
writeFileSync(sessionFile, JSON.stringify(initialMappings, null, 2));
|
|
60
|
+
|
|
61
|
+
// Simulate reading and adding a new session
|
|
62
|
+
let mappings = JSON.parse(readFileSync(sessionFile, 'utf-8'));
|
|
63
|
+
mappings['session3'] = 'researcher';
|
|
64
|
+
|
|
65
|
+
// Verify all mappings are present
|
|
66
|
+
expect(Object.keys(mappings).length).toBe(3);
|
|
67
|
+
expect(mappings['session1']).toBe('pai');
|
|
68
|
+
expect(mappings['session2']).toBe('designer');
|
|
69
|
+
expect(mappings['session3']).toBe('researcher');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Enhanced with agent instance metadata extraction
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, appendFileSync, mkdirSync, existsSync
|
|
9
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { historyDir, agentSessionsPath } from './lib/pai-paths';
|
|
12
12
|
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './lib/metadata-extraction';
|
|
13
13
|
import { isProbeSession } from './lib/project-utils';
|
|
14
|
+
import { writeJsonAtomic } from '../../config/json-store.js';
|
|
14
15
|
|
|
15
16
|
interface HookEvent {
|
|
16
17
|
source_app: string;
|
|
@@ -80,11 +81,17 @@ function setAgentForSession(sessionId: string, agentName: string): void {
|
|
|
80
81
|
let mappings: Record<string, string> = {};
|
|
81
82
|
|
|
82
83
|
if (existsSync(mappingFile)) {
|
|
83
|
-
|
|
84
|
+
try {
|
|
85
|
+
mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
|
|
86
|
+
} catch {
|
|
87
|
+
// If file exists but is corrupt, start fresh with the new mapping
|
|
88
|
+
mappings = {};
|
|
89
|
+
}
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
mappings[sessionId] = agentName;
|
|
87
|
-
|
|
93
|
+
// Fires concurrently across sessions; non-atomic write truncated the file on 2026-09-20
|
|
94
|
+
writeJsonAtomic(mappingFile, mappings, { backup: false, label: 'agent-sessions.json' });
|
|
88
95
|
} catch (error) {
|
|
89
96
|
// Silently fail - don't block
|
|
90
97
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatContextFill,
|
|
12
12
|
statuslineStateFilePath,
|
|
13
13
|
contextFillThresholds,
|
|
14
|
+
configuredTrigger,
|
|
14
15
|
resolveAutocompactPct,
|
|
15
16
|
measureCompactionTrigger,
|
|
16
17
|
modelFamily,
|
|
@@ -269,40 +270,33 @@ function readingAt(windowSize: number, source: ContextFillReading["source"] = "s
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
describe("contextFillThresholds", () => {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
// than an ABSENT override that defaults to the same 80, which the next
|
|
278
|
-
// fixture below requires to be 700,000. Asserting 684,000 here would only
|
|
279
|
-
// be possible by special-casing "override was explicitly set" versus
|
|
280
|
-
// "override defaulted", which the formula gives no basis for. Testing the
|
|
281
|
-
// formula as specified instead: both explicit-80 and absent-defaults-to-80
|
|
282
|
-
// must agree, and they do.
|
|
283
|
-
it("derives warmup from windowSize * (override/100) - 100k, for an explicit override", () => {
|
|
273
|
+
// effectiveTrigger = configuredTrigger(windowSize, pct) = pct/100 *
|
|
274
|
+
// (windowSize - 20,000) — NOT windowSize * pct/100. An EXPLICIT override
|
|
275
|
+
// of 80 and an ABSENT override that defaults to the same 80 must agree,
|
|
276
|
+
// and they do: both give 784,000 on a 1M window, warmUp 684,000.
|
|
277
|
+
it("derives warmup from configuredTrigger(windowSize, override) - 100k, for an explicit override", () => {
|
|
284
278
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
|
|
285
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
286
|
-
expect(t.warmupTokens).toBe(
|
|
279
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
280
|
+
expect(t.warmupTokens).toBe(684_000);
|
|
287
281
|
});
|
|
288
282
|
|
|
289
283
|
it("derives the same effective trigger when the override is absent (defaults to 80)", () => {
|
|
290
284
|
const t = contextFillThresholds(readingAt(1_000_000), {});
|
|
291
285
|
expect(t.autocompactPct).toBe(80);
|
|
292
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
293
|
-
expect(t.warmupTokens).toBe(
|
|
286
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
287
|
+
expect(t.warmupTokens).toBe(684_000);
|
|
294
288
|
});
|
|
295
289
|
|
|
296
290
|
it("scales down correctly for a 200k window with the override absent", () => {
|
|
297
291
|
const t = contextFillThresholds(readingAt(200_000), {});
|
|
298
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
299
|
-
expect(t.warmupTokens).toBe(
|
|
292
|
+
expect(t.effectiveTriggerTokens).toBe(144_000);
|
|
293
|
+
expect(t.warmupTokens).toBe(44_000);
|
|
300
294
|
});
|
|
301
295
|
|
|
302
296
|
it("computes refresh 40k and immediate 15k below the effective trigger", () => {
|
|
303
297
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
|
|
304
|
-
expect(t.refreshTokens).toBe(
|
|
305
|
-
expect(t.immediateTokens).toBe(
|
|
298
|
+
expect(t.refreshTokens).toBe(744_000);
|
|
299
|
+
expect(t.immediateTokens).toBe(769_000);
|
|
306
300
|
});
|
|
307
301
|
|
|
308
302
|
it("marks the window confirmed only for a statusline-sourced reading", () => {
|
|
@@ -313,8 +307,15 @@ describe("contextFillThresholds", () => {
|
|
|
313
307
|
it("clamps a threshold that would go negative instead of returning it", () => {
|
|
314
308
|
// A tiny window where even the trigger itself is below the warmup margin.
|
|
315
309
|
const t = contextFillThresholds(readingAt(50_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" });
|
|
316
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
317
|
-
expect(t.warmupTokens).toBe(0); //
|
|
310
|
+
expect(t.effectiveTriggerTokens).toBe(24_000); // 0.8 * (50,000 - 20,000)
|
|
311
|
+
expect(t.warmupTokens).toBe(0); // 24,000 - 100,000 would be negative
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("configuredTrigger", () => {
|
|
316
|
+
it("is pct/100 * (windowSize - 20,000), not windowSize * pct/100", () => {
|
|
317
|
+
expect(configuredTrigger(1_000_000, 80)).toBe(784_000);
|
|
318
|
+
expect(configuredTrigger(200_000, 80)).toBe(144_000);
|
|
318
319
|
});
|
|
319
320
|
});
|
|
320
321
|
|
|
@@ -570,8 +571,8 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
570
571
|
it("reports triggerSource 'configured' and falls back to the env chain when there is no measured value", () => {
|
|
571
572
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: null });
|
|
572
573
|
expect(t.triggerSource).toBe("configured");
|
|
573
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
574
|
-
expect(t.warmupTokens).toBe(
|
|
574
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
575
|
+
expect(t.warmupTokens).toBe(684_000);
|
|
575
576
|
});
|
|
576
577
|
|
|
577
578
|
it("falls back to configured when cwd is given but the project has no compaction history (real scan, empty fixture)", () => {
|
|
@@ -583,7 +584,7 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
583
584
|
measuredTrigger: measureCompactionTrigger(cwd, projectsDir),
|
|
584
585
|
});
|
|
585
586
|
expect(t.triggerSource).toBe("configured");
|
|
586
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
587
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
587
588
|
} finally {
|
|
588
589
|
rmSync(projectsDir, { recursive: true, force: true });
|
|
589
590
|
}
|
|
@@ -600,27 +601,27 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
600
601
|
|
|
601
602
|
it("CLAMPS a stale-HIGH measured value down to the configured one — the real CaseLeaf case", () => {
|
|
602
603
|
// measured=998,267 (this project's real, but stale, most-recent trigger)
|
|
603
|
-
// configured=
|
|
604
|
+
// configured=784,000 (80% of a 1,000,000 window, platform formula)
|
|
604
605
|
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 998_267 });
|
|
605
606
|
expect(t.measuredTriggerTokens).toBe(998_267);
|
|
606
|
-
expect(t.configuredTriggerTokens).toBe(
|
|
607
|
+
expect(t.configuredTriggerTokens).toBe(784_000);
|
|
607
608
|
expect(t.triggerSource).toBe("measured-clamped");
|
|
608
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
609
|
-
expect(t.warmupTokens).toBe(
|
|
609
|
+
expect(t.effectiveTriggerTokens).toBe(784_000); // min(998267, 784000)
|
|
610
|
+
expect(t.warmupTokens).toBe(684_000);
|
|
610
611
|
});
|
|
611
612
|
|
|
612
613
|
it("uses the measured value directly when it is LOWER than configured — no clamp needed", () => {
|
|
613
|
-
// measured=
|
|
614
|
-
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger:
|
|
614
|
+
// measured=700,000, configured=784,000 -> measured wins, tighter warm-up.
|
|
615
|
+
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 700_000 });
|
|
615
616
|
expect(t.triggerSource).toBe("measured");
|
|
616
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
617
|
-
expect(t.warmupTokens).toBe(
|
|
617
|
+
expect(t.effectiveTriggerTokens).toBe(700_000);
|
|
618
|
+
expect(t.warmupTokens).toBe(600_000);
|
|
618
619
|
});
|
|
619
620
|
|
|
620
621
|
it("uses measured directly when it exactly equals configured (boundary case, not clamped)", () => {
|
|
621
|
-
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger:
|
|
622
|
+
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
|
|
622
623
|
expect(t.triggerSource).toBe("measured");
|
|
623
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
624
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
624
625
|
});
|
|
625
626
|
|
|
626
627
|
it("reports both raw values on the result even when one of them wasn't used, so the clamp is visible", () => {
|
|
@@ -628,7 +629,7 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
628
629
|
// Both numbers are on the object — a caller can see what was measured
|
|
629
630
|
// AND what was configured, not just the winner.
|
|
630
631
|
expect(t.measuredTriggerTokens).toBe(998_267);
|
|
631
|
-
expect(t.configuredTriggerTokens).toBe(
|
|
632
|
+
expect(t.configuredTriggerTokens).toBe(784_000);
|
|
632
633
|
});
|
|
633
634
|
});
|
|
634
635
|
|
|
@@ -642,36 +643,113 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
642
643
|
// ---------------------------------------------------------------------------
|
|
643
644
|
|
|
644
645
|
describe("contextFillThresholds — 2026-09-20 regime change (override 80 -> 20)", () => {
|
|
645
|
-
it("measured 784k + configured
|
|
646
|
+
it("measured 784k + configured 196k (override=20, 1M window) -> clamps to 196k", () => {
|
|
646
647
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: 784_000 });
|
|
647
|
-
expect(t.configuredTriggerTokens).toBe(
|
|
648
|
+
expect(t.configuredTriggerTokens).toBe(196_000); // 0.2 * (1,000,000 - 20,000)
|
|
648
649
|
expect(t.triggerSource).toBe("measured-clamped");
|
|
649
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
650
|
-
expect(t.warmupTokens).toBe(
|
|
650
|
+
expect(t.effectiveTriggerTokens).toBe(196_000);
|
|
651
|
+
expect(t.warmupTokens).toBe(96_000); // 196,000 - 100,000
|
|
651
652
|
});
|
|
652
653
|
|
|
653
|
-
it("measured 784k + configured
|
|
654
|
+
it("measured 784k + configured 784k (override=80, 1M window) -> measured wins at 784k", () => {
|
|
654
655
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: 784_000 });
|
|
655
|
-
expect(t.configuredTriggerTokens).toBe(
|
|
656
|
+
expect(t.configuredTriggerTokens).toBe(784_000);
|
|
656
657
|
expect(t.triggerSource).toBe("measured");
|
|
657
658
|
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
658
659
|
});
|
|
659
660
|
|
|
660
|
-
it("measured null + configured
|
|
661
|
+
it("measured null + configured 196k (override=20, no compaction history yet) -> 196k", () => {
|
|
661
662
|
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "20" }, { measuredTrigger: null });
|
|
662
663
|
expect(t.triggerSource).toBe("configured");
|
|
663
|
-
expect(t.effectiveTriggerTokens).toBe(
|
|
664
|
+
expect(t.effectiveTriggerTokens).toBe(196_000);
|
|
664
665
|
});
|
|
665
666
|
|
|
666
|
-
it("measured 784k + override unset (defaults to 80, configured
|
|
667
|
+
it("measured 784k + override unset (defaults to 80, configured 784k) -> measured wins at 784k", () => {
|
|
667
668
|
const t = contextFillThresholds(readingAt(1_000_000), {}, { measuredTrigger: 784_000 });
|
|
668
669
|
expect(t.autocompactPct).toBe(80);
|
|
669
|
-
expect(t.configuredTriggerTokens).toBe(
|
|
670
|
+
expect(t.configuredTriggerTokens).toBe(784_000);
|
|
670
671
|
expect(t.triggerSource).toBe("measured");
|
|
671
672
|
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
672
673
|
});
|
|
673
674
|
});
|
|
674
675
|
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// measureCompactionTrigger — admissibility filter (Fault 1, 2026-09-20):
|
|
678
|
+
// samples from a different override regime must not win just because they
|
|
679
|
+
// are numerically <= the CURRENT configured trigger. A compaction that
|
|
680
|
+
// fired far below the current configured trigger came from a lower
|
|
681
|
+
// override or a smaller window; it is discarded, not averaged in.
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
describe("measureCompactionTrigger — other-regime samples are discarded", () => {
|
|
685
|
+
it("discards all samples below half of configured and falls back to configured (measured=null, source configured)", () => {
|
|
686
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
687
|
+
const cwd = "/fake/project/other-regime-all-discarded";
|
|
688
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
689
|
+
mkdirSync(projectDir, { recursive: true });
|
|
690
|
+
writeFileSync(
|
|
691
|
+
join(projectDir, "a.jsonl"),
|
|
692
|
+
[
|
|
693
|
+
compactBoundaryLine(196_765, "2026-09-20T13:10:00.000Z", "u1"),
|
|
694
|
+
compactBoundaryLine(195_075, "2026-09-20T15:00:00.000Z", "u2"),
|
|
695
|
+
compactBoundaryLine(197_902, "2026-09-20T19:00:00.000Z", "u3"),
|
|
696
|
+
].join("\n") + "\n"
|
|
697
|
+
);
|
|
698
|
+
try {
|
|
699
|
+
// configured = configuredTrigger(1,000,000, 80) = 784,000; floor = 392,000.
|
|
700
|
+
const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
|
|
701
|
+
expect(measured).toBeNull();
|
|
702
|
+
|
|
703
|
+
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
|
|
704
|
+
expect(t.triggerSource).toBe("configured");
|
|
705
|
+
expect(t.effectiveTriggerTokens).toBe(784_000);
|
|
706
|
+
} finally {
|
|
707
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("keeps admissible samples and discards only the other-regime one (measured=780,000, source measured)", () => {
|
|
712
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
713
|
+
const cwd = "/fake/project/other-regime-partial";
|
|
714
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
715
|
+
mkdirSync(projectDir, { recursive: true });
|
|
716
|
+
writeFileSync(
|
|
717
|
+
join(projectDir, "a.jsonl"),
|
|
718
|
+
[
|
|
719
|
+
compactBoundaryLine(780_000, "2026-09-19T09:00:00.000Z", "u1"),
|
|
720
|
+
compactBoundaryLine(790_000, "2026-09-19T10:00:00.000Z", "u2"),
|
|
721
|
+
compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u3"), // other-regime, discarded
|
|
722
|
+
].join("\n") + "\n"
|
|
723
|
+
);
|
|
724
|
+
try {
|
|
725
|
+
const measured = measureCompactionTrigger(cwd, projectsDir, undefined, 784_000);
|
|
726
|
+
expect(measured).toBe(780_000);
|
|
727
|
+
|
|
728
|
+
const t = contextFillThresholds(readingAt(1_000_000), { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "80" }, { measuredTrigger: measured });
|
|
729
|
+
expect(t.triggerSource).toBe("measured");
|
|
730
|
+
expect(t.effectiveTriggerTokens).toBe(780_000);
|
|
731
|
+
} finally {
|
|
732
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("applies no filter at all when configuredTriggerTokens is omitted (back-compat with the raw scan)", () => {
|
|
737
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
738
|
+
const cwd = "/fake/project/no-filter";
|
|
739
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
740
|
+
mkdirSync(projectDir, { recursive: true });
|
|
741
|
+
writeFileSync(
|
|
742
|
+
join(projectDir, "a.jsonl"),
|
|
743
|
+
compactBoundaryLine(195_000, "2026-09-20T15:00:00.000Z", "u1") + "\n"
|
|
744
|
+
);
|
|
745
|
+
try {
|
|
746
|
+
expect(measureCompactionTrigger(cwd, projectsDir)).toBe(195_000);
|
|
747
|
+
} finally {
|
|
748
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
675
753
|
// ---------------------------------------------------------------------------
|
|
676
754
|
// Foreign-model transcripts — a headless worker on another provider (a
|
|
677
755
|
// different context window) shares the project folder; its compactions must
|