@tekmidian/pai 0.36.1 → 0.37.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/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
- package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
- package/dist/cli/index.mjs +5 -4
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +5 -4
- package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
- package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
- package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
- package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
- package/dist/daemon/index.mjs +8 -8
- package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
- package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
- package/dist/daemon-DsGGiIJM.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +428 -1
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
- package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
- package/dist/detector-DtLExmHN.mjs +5 -0
- package/dist/factory-BXzqRYVZ.mjs +3 -0
- package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
- package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +164 -14
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +6 -0
- package/dist/hooks/initialize-session.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +6 -0
- package/dist/hooks/load-core-context.mjs.map +3 -3
- package/dist/hooks/load-project-context.mjs +6 -0
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +48 -7
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +456 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
- package/dist/hooks/status-line.mjs +400 -0
- package/dist/hooks/status-line.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +6 -0
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/subagent-stop-hook.mjs +6 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +6 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/hooks/update-tab-on-action.mjs +6 -0
- package/dist/hooks/update-tab-on-action.mjs.map +3 -3
- package/dist/hooks/update-tab-titles.mjs +6 -0
- package/dist/hooks/update-tab-titles.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +796 -0
- package/dist/hooks/worker-proxy.mjs.map +7 -0
- package/dist/hooks/worker-status-line.mjs +483 -0
- package/dist/hooks/worker-status-line.mjs.map +7 -0
- package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
- package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
- package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
- package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
- package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
- package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
- package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
- package/dist/{program-C-fUghPv.mjs → program-CEIHn_Ma.mjs} +693 -101
- package/dist/program-CEIHn_Ma.mjs.map +1 -0
- package/dist/providers-sXcK5bDZ.mjs +3067 -0
- package/dist/providers-sXcK5bDZ.mjs.map +1 -0
- package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
- package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
- package/dist/query-feedback-DhyLOe5S.mjs +3 -0
- package/dist/router-1zi8jiNF.mjs +3 -0
- package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
- package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
- package/dist/skills/Worker/SKILL.md +41 -0
- package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
- package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
- package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
- package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
- package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
- package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
- package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
- package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
- package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
- package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
- package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
- package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
- package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
- package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
- package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
- package/docs/commands/README.md +28 -0
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +1 -1
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +361 -0
- package/docs/commands/zettel.md +1 -1
- package/docs/worker.md +303 -0
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +46 -1
- package/src/hooks/pre-compact.sh +1 -0
- package/src/hooks/session-autosave.sh +1 -0
- package/src/hooks/session-stop.sh +1 -0
- package/src/hooks/ts/lib/context-fill.test.ts +104 -0
- package/src/hooks/ts/lib/context-fill.ts +73 -0
- package/src/hooks/ts/lib/handover-evidence.ts +227 -0
- package/src/hooks/ts/lib/worker-session.test.ts +24 -0
- package/src/hooks/ts/lib/worker-session.ts +23 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
- package/src/hooks/ts/session-start/initialize-session.ts +2 -0
- package/src/hooks/ts/session-start/load-core-context.ts +2 -0
- package/src/hooks/ts/session-start/load-project-context.ts +2 -0
- package/src/hooks/ts/session-start/post-compact-inject.ts +13 -0
- package/src/hooks/ts/stop/stop-hook.ts +2 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
- package/statusline-command.sh +17 -0
- package/dist/daemon-DRdoA489.mjs +0 -20
- package/dist/detector-DGAk1iBR.mjs +0 -5
- package/dist/factory-CrokPMk2.mjs +0 -3
- package/dist/program-C-fUghPv.mjs.map +0 -1
- package/dist/query-feedback-BBMBp96K.mjs +0 -3
- package/dist/router-BMkOb62X.mjs +0 -3
package/docs/worker.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# Worker Providers
|
|
2
|
+
|
|
3
|
+
PAI can run every subagent on a provider you configure — any endpoint that
|
|
4
|
+
speaks the Anthropic API, any endpoint that speaks the OpenAI Chat
|
|
5
|
+
Completions API (through the built-in proxy), or the Codex CLI — instead of
|
|
6
|
+
on the Anthropic account of the main session. This replaces the earlier
|
|
7
|
+
hard-wired `glm` wrapper with something provider-neutral, and keeps the same
|
|
8
|
+
daily commands.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
main session (Anthropic) workers (configured provider)
|
|
12
|
+
┌──────────────────────┐ ┌──────────────────────────┐
|
|
13
|
+
│ orchestration, │ deny │ pai worker run … │
|
|
14
|
+
│ review, synthesis │ ───────▶ │ (claude -p, headless, │
|
|
15
|
+
└──────────────────────┘ Agent │ strict MCP, streamed, │
|
|
16
|
+
tool │ followed in a pane) │
|
|
17
|
+
└──────────────────────────┘
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Config
|
|
21
|
+
|
|
22
|
+
`~/.config/pai/config.json`, `workers` section:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"workers": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"active": "glm",
|
|
29
|
+
"providers": {
|
|
30
|
+
"glm": {
|
|
31
|
+
"baseUrl": "https://api.z.ai/api/anthropic",
|
|
32
|
+
"keyFile": "~/.config/zai/api_key",
|
|
33
|
+
"models": { "default": "glm-5.3", "fast": "glm-5.3-flash" },
|
|
34
|
+
"env": { "API_TIMEOUT_MS": "3000000" },
|
|
35
|
+
"contextWindow": 200000
|
|
36
|
+
},
|
|
37
|
+
"oai": {
|
|
38
|
+
"protocol": "openai",
|
|
39
|
+
"upstreamUrl": "https://api.openai.com/v1",
|
|
40
|
+
"keyFile": "~/.config/pai/keys/oai",
|
|
41
|
+
"models": { "default": "gpt-5.2", "fast": "gpt-5.2-mini" }
|
|
42
|
+
},
|
|
43
|
+
"codexprov": {
|
|
44
|
+
"engine": "codex",
|
|
45
|
+
"models": { "default": "gpt-5.2-codex" }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"roles": {
|
|
49
|
+
"implement": "glm",
|
|
50
|
+
"research": "glm",
|
|
51
|
+
"spotcheck": "glm/fast",
|
|
52
|
+
"docs": { "provider": "glm", "mcp": ["office"] }
|
|
53
|
+
},
|
|
54
|
+
"mcpSets": {
|
|
55
|
+
"office": ["memory", "github"],
|
|
56
|
+
"tiny": ["fetcher"]
|
|
57
|
+
},
|
|
58
|
+
"pane": { "enabled": true, "fontSize": 13, "autoExitSecs": 60 },
|
|
59
|
+
"logDir": "~/.claude/logs/workers",
|
|
60
|
+
"routing": { "order": [], "cooldownMinutes": 30, "retryOnQuota": true }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `keyFile` holds the API token (chmod 600). Keys never go into the config.
|
|
66
|
+
- `active` is one provider name, or `"auto"` to walk `routing.order`.
|
|
67
|
+
- `protocol: "openai"` routes the provider through the built-in proxy (next
|
|
68
|
+
section); it needs `upstreamUrl`, the Chat Completions base.
|
|
69
|
+
- `engine: "codex"` runs the provider on the Codex CLI instead of Claude
|
|
70
|
+
Code (see below).
|
|
71
|
+
- `contextWindow` overrides the context-meter window when the endpoint's
|
|
72
|
+
init event does not announce one (default 200 000).
|
|
73
|
+
- A role target is `"provider[/model]"` or an object with `provider` and a
|
|
74
|
+
`mcp` allowlist applied on top of `--mcp`.
|
|
75
|
+
|
|
76
|
+
Or add one from the CLI:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
pai worker providers add glm \
|
|
80
|
+
--base-url https://api.z.ai/api/anthropic \
|
|
81
|
+
--key-file ~/.config/zai/api_key \
|
|
82
|
+
--model glm-5.3 --fast-model glm-5.3-flash \
|
|
83
|
+
--env API_TIMEOUT_MS=3000000
|
|
84
|
+
pai worker providers add oai \
|
|
85
|
+
--upstream-url https://api.openai.com/v1 \
|
|
86
|
+
--key-file ~/.config/pai/keys/oai --model gpt-5.2
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The first provider also sets `enabled: true`, makes itself active and seeds
|
|
90
|
+
the three roles. Then:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
pai worker install # Agent hook in settings.json + glm* shims + cleanup
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## The proxy (OpenAI-protocol providers)
|
|
97
|
+
|
|
98
|
+
A provider with `protocol: "openai"` cannot be talked to by Claude Code
|
|
99
|
+
directly, so PAI ships a translating proxy: Anthropic Messages API on the
|
|
100
|
+
front (loopback only), OpenAI Chat Completions on the back. System prompts,
|
|
101
|
+
multi-turn text, tool_use/tool_result ↔ tool_calls, tools ↔ functions,
|
|
102
|
+
streaming SSE (including streamed tool-call arguments), usage and error
|
|
103
|
+
mapping (429 → `rate_limit_error`, 401 → `authentication_error`, 5xx →
|
|
104
|
+
`api_error`) are translated in both directions.
|
|
105
|
+
|
|
106
|
+
- One proxy serves every openai provider: the provider name in the URL path
|
|
107
|
+
selects the upstream. `run` points `ANTHROPIC_BASE_URL` at
|
|
108
|
+
`http://127.0.0.1:8797/<provider>` and starts the proxy on demand
|
|
109
|
+
(detached, pid file under the logDir). The worker config is re-read per
|
|
110
|
+
request, so provider edits apply without a restart.
|
|
111
|
+
- The proxy holds the real token (from the provider's `keyFile`) and injects
|
|
112
|
+
it upstream; the worker itself runs with a placeholder, so a leaked worker
|
|
113
|
+
env leaks nothing.
|
|
114
|
+
- `pai worker proxy [--port N]` starts it by hand (default 8797, loopback
|
|
115
|
+
only), `pai worker proxy stop` stops it again. `providers test` on an
|
|
116
|
+
openai provider goes through the proxy too.
|
|
117
|
+
|
|
118
|
+
## The codex engine
|
|
119
|
+
|
|
120
|
+
A ChatGPT plan gives no API key, only Codex CLI access — such a provider
|
|
121
|
+
sets `engine: "codex"` and `run` shells out to `codex exec --json <prompt>`
|
|
122
|
+
(non-interactive) instead of Claude Code. The Codex JSONL events are folded
|
|
123
|
+
into the same status fields and the same transcript shape, so `ps`, `follow`,
|
|
124
|
+
`replay`, the ledger and the final `--output-format` print work unchanged.
|
|
125
|
+
|
|
126
|
+
Differences: `--allowedTools` and MCP flags have no Codex equivalent and are
|
|
127
|
+
dropped with a `WORKER-NOTE` ledger line; resume continues a Claude session,
|
|
128
|
+
so it is unavailable for codex workers (their thread id is kept, but
|
|
129
|
+
`pai worker resume` refuses with an explanation). `providers test` reports
|
|
130
|
+
`codex not installed` (exit 0) when the CLI is missing.
|
|
131
|
+
|
|
132
|
+
## Daily use
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
pai worker run --label "fix black buttons" -p '<task spec>' \
|
|
136
|
+
--allowedTools 'Read,Edit,Write,Bash,Grep,Glob' --output-format json \
|
|
137
|
+
--mcp office
|
|
138
|
+
pai worker ps # this session's workers
|
|
139
|
+
pai worker follow [id] # live transcript (type to talk to it)
|
|
140
|
+
pai worker replay <id> # transcript of one worker
|
|
141
|
+
pai worker say <id> "<text>" # message a running worker
|
|
142
|
+
pai worker resume <id> "<text>" # continue a finished one, context intact
|
|
143
|
+
pai worker mcp list # MCP servers + sets usable in --mcp
|
|
144
|
+
pai worker proxy [--port N|stop] # the translating proxy, by hand
|
|
145
|
+
pai worker log [all|tail|<id>] # raw streams + routing ledger
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Roles pick the provider for a task class: `--role implement|research|spotcheck`.
|
|
149
|
+
`--no-pane` suppresses the iTerm follow pane; `--provider <name>` bypasses
|
|
150
|
+
roles entirely. If you bring your own `--append-system-prompt`, the worker
|
|
151
|
+
contract below is added alongside it, not instead.
|
|
152
|
+
|
|
153
|
+
The old habits keep working: `glm`, `glm-run`, `glm-ps`, `glm-log` are shims
|
|
154
|
+
to the pai commands (`pai worker install` moves any previous versions to
|
|
155
|
+
`<name>.pre-pai`).
|
|
156
|
+
|
|
157
|
+
## Routing
|
|
158
|
+
|
|
159
|
+
A run resolves its provider as: `--provider` > `--role` > `active`.
|
|
160
|
+
|
|
161
|
+
With `active: "auto"`, providers are tried in `routing.order`, skipping:
|
|
162
|
+
|
|
163
|
+
- disabled providers,
|
|
164
|
+
- providers in a cooldown (set for `cooldownMinutes` after a quota failure),
|
|
165
|
+
- providers whose `quotaProbe` URL reports ≥ `quotaSkipAt` (default 95).
|
|
166
|
+
|
|
167
|
+
A quota failure before the first tool call is re-run on the next provider and
|
|
168
|
+
logged as `WORKER-REROUTE`. `pai worker providers enable <name>` clears a
|
|
169
|
+
cooldown by hand.
|
|
170
|
+
|
|
171
|
+
## What a worker is
|
|
172
|
+
|
|
173
|
+
- One `claude -p … --output-format stream-json --verbose` process per call,
|
|
174
|
+
run with `--input-format stream-json` and its stdin held open: the task
|
|
175
|
+
arrives as the first user message on stdin, and further lines (see `say`
|
|
176
|
+
below) continue the conversation while it runs.
|
|
177
|
+
- Env: `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` from the provider (token
|
|
178
|
+
read from `keyFile`, never from the environment; openai providers point at
|
|
179
|
+
the local proxy instead), `ANTHROPIC_API_KEY` stripped so nothing can fall
|
|
180
|
+
back to Anthropic billing, the three `ANTHROPIC_DEFAULT_*_MODEL` vars, the
|
|
181
|
+
provider's `env`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`.
|
|
182
|
+
- Headless runs get `--strict-mcp-config --mcp-config <config>` and
|
|
183
|
+
`PAI_WORKER=1` so PAI's per-session hooks leave them alone. Interactive
|
|
184
|
+
runs keep full MCP and get `ENABLE_TOOL_SEARCH=true`.
|
|
185
|
+
- Every mirrored event carries an ISO `_ts` stamp; the stream lands in
|
|
186
|
+
`<logDir>/<id>.jsonl`, live state in `<id>.status`, every event in
|
|
187
|
+
`<logDir>/ledger.log`.
|
|
188
|
+
|
|
189
|
+
### The worker contract
|
|
190
|
+
|
|
191
|
+
Headless runs append a system prompt that fixes the shape of the final
|
|
192
|
+
answer: act, verify, then stop with ONE JSON message
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{"changed":[{"path":"…","summary":"…"}],"commands":["…"],
|
|
196
|
+
"checks":[{"name":"…","ok":true,"detail":"…"}],"open":["…"],"notes":"one line"}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The runner parses it: `notes` becomes the one-line `last` the table shows,
|
|
200
|
+
and `--output-format json` carries the parsed `report` next to the raw
|
|
201
|
+
`result`. `follow`/`replay` render it as a compact block (changed paths,
|
|
202
|
+
✓/✗ checks, open items). A final message that is not the contract stays raw
|
|
203
|
+
text — nothing is lost either way.
|
|
204
|
+
|
|
205
|
+
### Talking to a worker (say / resume)
|
|
206
|
+
|
|
207
|
+
While a headless worker runs, `pai worker say <id> "<text>"` (or the MCP
|
|
208
|
+
tool `worker_say`) forwards the text to the child as a user message over the
|
|
209
|
+
per-worker Unix socket `<logDir>/<id>.sock`; it is mirrored into the
|
|
210
|
+
transcript as an `operator` event (`»` marker). After the worker's result,
|
|
211
|
+
stdin closes two seconds later unless another message arrives — after that
|
|
212
|
+
`say` refuses and points at `resume`.
|
|
213
|
+
|
|
214
|
+
`pai worker resume <id> "<text>"` continues the same Claude session (the id
|
|
215
|
+
recorded from the init event) on the same provider, labelled `↩ <original>`,
|
|
216
|
+
and prints a fresh worker id with `--print-id`. A pane running `follow` reads
|
|
217
|
+
its own stdin the same way: type to say while it runs, or to resume after it
|
|
218
|
+
finished.
|
|
219
|
+
|
|
220
|
+
### Context meter
|
|
221
|
+
|
|
222
|
+
Status files carry `contextTokens` (input + cache read + cache creation +
|
|
223
|
+
output of the last assistant turn) and `contextWindow` (from the init event,
|
|
224
|
+
else the provider's `contextWindow`, else 200 000). The `ps` table and the
|
|
225
|
+
status line show `ctx 84k/200k (42%)` once it passes 60 % — yellow past
|
|
226
|
+
70 %, red past 85 % — and the pane's liveness line always shows it.
|
|
227
|
+
|
|
228
|
+
### MCP for workers
|
|
229
|
+
|
|
230
|
+
Headless workers start with **no MCP servers by default**: every server
|
|
231
|
+
definition lands in the system prompt and costs context (and often a
|
|
232
|
+
startup process) before the worker has done anything. When a task genuinely
|
|
233
|
+
needs servers, opt in per run:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
pai worker run --mcp office … # a set, or names: --mcp memory,github
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`--mcp` takes server names and/or `mcpSets` names (comma-separated,
|
|
240
|
+
repeatable); the filtered config is written from `~/.claude.json`'s
|
|
241
|
+
`mcpServers` to `<logDir>/<id>.mcp.json` and passed with
|
|
242
|
+
`--strict-mcp-config --mcp-config`. Role targets may add `"mcp": ["office"]`
|
|
243
|
+
on top. An unknown name fails fast, listing what exists;
|
|
244
|
+
`pai worker mcp list` shows servers and sets. A caller-provided
|
|
245
|
+
`--mcp-config` always wins; MCP is chosen at launch, not mid-run.
|
|
246
|
+
|
|
247
|
+
## Scoping (who sees whose workers)
|
|
248
|
+
|
|
249
|
+
`ps`/`follow`/status line show the workers of the asking terminal:
|
|
250
|
+
|
|
251
|
+
1. AIBroker session id (from `~/.aibroker/session-names.json`) — every pane of
|
|
252
|
+
a named session sees its workers,
|
|
253
|
+
2. else the iTerm tab key (`w<n>t<n>` of `ITERM_SESSION_ID`).
|
|
254
|
+
|
|
255
|
+
`--all` (or no iTerm at all) widens to every worker.
|
|
256
|
+
|
|
257
|
+
## Follow, replay and the pane
|
|
258
|
+
|
|
259
|
+
`follow` renders the live transcript with a `HH:MM:SS │ ` gutter (dim; the
|
|
260
|
+
worker's short id in front when several run at once, a `── date ──`
|
|
261
|
+
separator when the day changes) and, on a TTY, a liveness line
|
|
262
|
+
`⋯ 12s since last event · Bash: npm test` that is overwritten in place and
|
|
263
|
+
erased before the next event. `replay` shows a finished transcript with the
|
|
264
|
+
same gutter.
|
|
265
|
+
|
|
266
|
+
The pane command is `exec pai worker follow …` so the pane holds exactly one
|
|
267
|
+
process — signals reach the follow directly, and when the worker finishes
|
|
268
|
+
the pane counts down its `auto-exit` (default 60 s, `pane.autoExitSecs`).
|
|
269
|
+
|
|
270
|
+
Panes run under the `pai-worker` dynamic profile, written to
|
|
271
|
+
`~/Library/Application Support/iTerm2/DynamicProfiles/pai-worker.json`:
|
|
272
|
+
the font family of iTerm's default profile at `pane.fontSize` points
|
|
273
|
+
(default 13; a legacy `pane.fontScale` is ignored), inheriting everything
|
|
274
|
+
else from that profile. When iTerm's preferences cannot be read the profile
|
|
275
|
+
is still written, with `Menlo-Regular <fontSize>` and no parent, and the
|
|
276
|
+
reason lands on stderr. `pai worker pane <id> --check` prints the profile
|
|
277
|
+
file's path, whether it exists, and the font it contains or would write.
|
|
278
|
+
|
|
279
|
+
## The Agent-tool hook
|
|
280
|
+
|
|
281
|
+
With workers on, a PreToolUse hook denies every `Agent` call and the deny
|
|
282
|
+
reason tells the orchestrator to delegate via `pai worker run` in the
|
|
283
|
+
background instead. Decisions are ledgered (`DENIED-ANTHROPIC-AGENT`,
|
|
284
|
+
`ALLOWED-ANTHROPIC-AGENT`).
|
|
285
|
+
|
|
286
|
+
- `pai worker off` — Agent subagents run on Anthropic again.
|
|
287
|
+
- `ALLOW_ANTHROPIC_AGENTS=1` — bypass for one session.
|
|
288
|
+
|
|
289
|
+
## MCP tools
|
|
290
|
+
|
|
291
|
+
`worker_status`, `worker_providers` (list/add/remove/use/enable/disable/test),
|
|
292
|
+
`worker_roles`, `worker_toggle`, `worker_ps`, `worker_replay`,
|
|
293
|
+
`worker_say` (message a running worker), `worker_resume` (continue a
|
|
294
|
+
finished one) — the same library the CLI calls. `worker_providers add`
|
|
295
|
+
accepts a raw `key`, parks it in `~/.config/pai/keys/<name>` (mode 0600) and
|
|
296
|
+
stores only the path.
|
|
297
|
+
|
|
298
|
+
## Status line
|
|
299
|
+
|
|
300
|
+
Line 4 of the statusline lists this session's running workers (provider,
|
|
301
|
+
label, age, current tool, context meter past 60 %) plus today's ✓/✗ tally.
|
|
302
|
+
It prefers the standalone `~/.claude/worker-status-line.mjs` (plain node,
|
|
303
|
+
built by `bun run build`) and falls back to `pai worker status-line`.
|
package/package.json
CHANGED
package/scripts/build-hooks.mjs
CHANGED
|
@@ -98,6 +98,47 @@ rmSync(STAGING, { recursive: true, force: true });
|
|
|
98
98
|
|
|
99
99
|
console.log(`✔ ${entryPoints.length} hooks built to ${HOOKS_OUT}/`);
|
|
100
100
|
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Standalone worker status-line: src/workers/standalone/status-line.ts →
|
|
103
|
+
// dist/worker-status-line.mjs (same atomic-staging rules; it runs on every
|
|
104
|
+
// statusline refresh of every live session).
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
// [entry, output name in dist/hooks/]
|
|
108
|
+
const STANDALONE_ENTRIES = [
|
|
109
|
+
["src/workers/standalone/status-line.ts", "worker-status-line.mjs"],
|
|
110
|
+
// detached proxy for openai-protocol providers; spawned from dist, not
|
|
111
|
+
// symlinked into ~/.claude, so it is NOT in the --sync list below
|
|
112
|
+
["src/workers/standalone/proxy.ts", "worker-proxy.mjs"],
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
mkdirSync(STAGING, { recursive: true });
|
|
116
|
+
for (const [entry, name] of STANDALONE_ENTRIES) {
|
|
117
|
+
const staged = join(STAGING, name);
|
|
118
|
+
buildSync({
|
|
119
|
+
entryPoints: [entry],
|
|
120
|
+
bundle: true,
|
|
121
|
+
platform: "node",
|
|
122
|
+
target: "node20",
|
|
123
|
+
format: "esm",
|
|
124
|
+
outfile: staged,
|
|
125
|
+
sourcemap: true,
|
|
126
|
+
});
|
|
127
|
+
chmodSync(staged, 0o755);
|
|
128
|
+
const stagedMap = `${staged}.map`;
|
|
129
|
+
if (existsSync(stagedMap)) {
|
|
130
|
+
renameSync(stagedMap, join(HOOKS_OUT, `${name}.map`));
|
|
131
|
+
}
|
|
132
|
+
mkdirSync(HOOKS_OUT, { recursive: true });
|
|
133
|
+
renameSync(staged, join(HOOKS_OUT, name));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
rmSync(STAGING, { recursive: true, force: true });
|
|
137
|
+
|
|
138
|
+
console.log(
|
|
139
|
+
`✔ ${STANDALONE_ENTRIES.length} standalone script(s) built to ${HOOKS_OUT}/ (${STANDALONE_ENTRIES.map(([, n]) => n).join(", ")})`
|
|
140
|
+
);
|
|
141
|
+
|
|
101
142
|
// ---------------------------------------------------------------------------
|
|
102
143
|
// --sync: Symlink (or copy on Windows) all deployable files to ~/.claude/
|
|
103
144
|
// ---------------------------------------------------------------------------
|
|
@@ -161,7 +202,8 @@ if (doSync) {
|
|
|
161
202
|
}
|
|
162
203
|
|
|
163
204
|
// 1. TypeScript hooks: dist/hooks/*.mjs → ~/.claude/Hooks/*.mjs
|
|
164
|
-
|
|
205
|
+
// (worker-proxy.mjs is spawned from dist, not a hook — skip it)
|
|
206
|
+
const mjsFiles = readdirSync(HOOKS_OUT).filter((f) => f.endsWith(".mjs") && f !== "worker-proxy.mjs");
|
|
165
207
|
for (const filename of mjsFiles) {
|
|
166
208
|
syncFile(join(HOOKS_OUT, filename), join(hooksTarget, filename));
|
|
167
209
|
}
|
|
@@ -186,6 +228,9 @@ if (doSync) {
|
|
|
186
228
|
}
|
|
187
229
|
}
|
|
188
230
|
|
|
231
|
+
// 4. Standalone worker status-line: dist/worker-status-line.mjs → ~/.claude/
|
|
232
|
+
syncFile(join(HOOKS_OUT, "worker-status-line.mjs"), join(claudeDir, "worker-status-line.mjs"));
|
|
233
|
+
|
|
189
234
|
const parts = [];
|
|
190
235
|
if (created > 0) parts.push(`${created} created`);
|
|
191
236
|
if (updated > 0) parts.push(`${updated} updated`);
|
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
contextFillThresholds,
|
|
14
14
|
resolveAutocompactPct,
|
|
15
15
|
measureCompactionTrigger,
|
|
16
|
+
modelFamily,
|
|
16
17
|
selectedCompactionSamples,
|
|
18
|
+
transcriptModelFamily,
|
|
17
19
|
crossedThresholds,
|
|
18
20
|
isImmediate,
|
|
19
21
|
DEFAULT_CONTEXT_WINDOW,
|
|
@@ -626,3 +628,105 @@ describe("contextFillThresholds — trigger source", () => {
|
|
|
626
628
|
expect(t.configuredTriggerTokens).toBe(800_000);
|
|
627
629
|
});
|
|
628
630
|
});
|
|
631
|
+
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
// Foreign-model transcripts — a headless worker on another provider (a
|
|
634
|
+
// different context window) shares the project folder; its compactions must
|
|
635
|
+
// not shape THIS project's measured trigger. Observed 2026-09-17: two such
|
|
636
|
+
// workers compacting at ~151k pulled a real trigger from ~784k to ~151k.
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
function assistantLine(model: string): string {
|
|
640
|
+
return JSON.stringify({
|
|
641
|
+
type: "assistant",
|
|
642
|
+
message: { role: "assistant", model, content: [{ type: "text", text: "ok" }] },
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
describe("measureCompactionTrigger — foreign-model transcripts are ignored", () => {
|
|
647
|
+
it("drops compact_boundary samples governed by a non-claude assistant model", () => {
|
|
648
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
649
|
+
const cwd = "/fake/project/foreign";
|
|
650
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
651
|
+
mkdirSync(projectDir, { recursive: true });
|
|
652
|
+
writeFileSync(
|
|
653
|
+
join(projectDir, "real.jsonl"),
|
|
654
|
+
[assistantLine("claude-x-1"), compactBoundaryLine(784_000, "2026-09-17T09:00:00.000Z", "u1")].join("\n") + "\n"
|
|
655
|
+
);
|
|
656
|
+
writeFileSync(
|
|
657
|
+
join(projectDir, "worker.jsonl"),
|
|
658
|
+
[
|
|
659
|
+
assistantLine("other-model-1"),
|
|
660
|
+
compactBoundaryLine(151_000, "2026-09-17T09:30:00.000Z", "u2"),
|
|
661
|
+
compactBoundaryLine(152_000, "2026-09-17T09:35:00.000Z", "u3"),
|
|
662
|
+
].join("\n") + "\n"
|
|
663
|
+
);
|
|
664
|
+
try {
|
|
665
|
+
expect(selectedCompactionSamples(cwd, projectsDir).map((s) => s.preTokens)).toEqual([784_000]);
|
|
666
|
+
expect(measureCompactionTrigger(cwd, projectsDir)).toBe(784_000);
|
|
667
|
+
} finally {
|
|
668
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("keeps samples from a transcript with no model field at all", () => {
|
|
673
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
674
|
+
const cwd = "/fake/project/nomodel";
|
|
675
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
676
|
+
mkdirSync(projectDir, { recursive: true });
|
|
677
|
+
writeFileSync(join(projectDir, "old.jsonl"), compactBoundaryLine(790_000, "2026-09-17T09:00:00.000Z", "u1") + "\n");
|
|
678
|
+
try {
|
|
679
|
+
expect(measureCompactionTrigger(cwd, projectsDir)).toBe(790_000);
|
|
680
|
+
} finally {
|
|
681
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("judges each sample by the model seen BEFORE it in its own file", () => {
|
|
686
|
+
const projectsDir = mkdtempSync(join(tmpdir(), "pai-measured-trigger-test-"));
|
|
687
|
+
const cwd = "/fake/project/order";
|
|
688
|
+
const projectDir = join(projectsDir, encodeForFixture(cwd));
|
|
689
|
+
mkdirSync(projectDir, { recursive: true });
|
|
690
|
+
writeFileSync(
|
|
691
|
+
join(projectDir, "mixed.jsonl"),
|
|
692
|
+
[
|
|
693
|
+
assistantLine("claude-x-1"),
|
|
694
|
+
compactBoundaryLine(780_000, "2026-09-17T09:00:00.000Z", "u1"),
|
|
695
|
+
assistantLine("other-model-1"),
|
|
696
|
+
compactBoundaryLine(150_000, "2026-09-17T09:30:00.000Z", "u2"),
|
|
697
|
+
].join("\n") + "\n"
|
|
698
|
+
);
|
|
699
|
+
try {
|
|
700
|
+
expect(selectedCompactionSamples(cwd, projectsDir).map((s) => s.preTokens)).toEqual([780_000]);
|
|
701
|
+
} finally {
|
|
702
|
+
rmSync(projectsDir, { recursive: true, force: true });
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
describe("modelFamily / transcriptModelFamily", () => {
|
|
708
|
+
it("classifies claude-, foreign, synthetic and missing models", () => {
|
|
709
|
+
expect(modelFamily("claude-opus-5")).toBe("claude");
|
|
710
|
+
expect(modelFamily("other-model-1")).toBe("foreign");
|
|
711
|
+
expect(modelFamily("<synthetic>")).toBe("unknown");
|
|
712
|
+
expect(modelFamily(null)).toBe("unknown");
|
|
713
|
+
expect(modelFamily("")).toBe("unknown");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("reads the LAST assistant model, skips synthetic turns, and is unknown for an unreadable file", () => {
|
|
717
|
+
const dir = mkdtempSync(join(tmpdir(), "pai-model-family-test-"));
|
|
718
|
+
const path = join(dir, "t.jsonl");
|
|
719
|
+
try {
|
|
720
|
+
writeFileSync(
|
|
721
|
+
path,
|
|
722
|
+
[assistantLine("other-model-1"), compactBoundaryLine(1, "2026-09-17T09:00:00.000Z"), assistantLine("claude-x-1")].join("\n") + "\n"
|
|
723
|
+
);
|
|
724
|
+
expect(transcriptModelFamily(path)).toBe("claude");
|
|
725
|
+
writeFileSync(path, [assistantLine("claude-x-1"), assistantLine("other-model-1"), assistantLine("<synthetic>")].join("\n") + "\n");
|
|
726
|
+
expect(transcriptModelFamily(path)).toBe("foreign");
|
|
727
|
+
expect(transcriptModelFamily(join(dir, "missing.jsonl"))).toBe("unknown");
|
|
728
|
+
} finally {
|
|
729
|
+
rmSync(dir, { recursive: true, force: true });
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
});
|
|
@@ -365,6 +365,51 @@ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
|
|
|
365
365
|
return paths;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/** Placeholder model id the platform writes on synthetic assistant turns. */
|
|
369
|
+
const SYNTHETIC_MODEL = "<synthetic>";
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Which family of model wrote a transcript. "claude" is the platform's own;
|
|
373
|
+
* "foreign" is any other provider routed through the same CLI (a different
|
|
374
|
+
* context window, so its compactions say nothing about ours); "unknown" is
|
|
375
|
+
* no usable model field at all.
|
|
376
|
+
*/
|
|
377
|
+
export type TranscriptModelFamily = "claude" | "foreign" | "unknown";
|
|
378
|
+
|
|
379
|
+
export function modelFamily(model: string | null | undefined): TranscriptModelFamily {
|
|
380
|
+
if (typeof model !== "string" || model === "" || model === SYNTHETIC_MODEL) return "unknown";
|
|
381
|
+
return model.startsWith("claude-") ? "claude" : "foreign";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The family of the LAST assistant model in a transcript — scanned from the
|
|
386
|
+
* end so a long transcript costs one read and a few lines of parsing.
|
|
387
|
+
* Unreadable or model-less transcripts are "unknown", never "foreign":
|
|
388
|
+
* the callers that skip work on "foreign" must not skip it on doubt.
|
|
389
|
+
*/
|
|
390
|
+
export function transcriptModelFamily(path: string): TranscriptModelFamily {
|
|
391
|
+
let raw: string;
|
|
392
|
+
try {
|
|
393
|
+
raw = readFileSync(path, "utf-8");
|
|
394
|
+
} catch {
|
|
395
|
+
return "unknown";
|
|
396
|
+
}
|
|
397
|
+
const lines = raw.split("\n");
|
|
398
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
399
|
+
const line = lines[i];
|
|
400
|
+
if (!line.includes('"model"')) continue;
|
|
401
|
+
try {
|
|
402
|
+
const entry = JSON.parse(line) as { type?: string; message?: { model?: unknown } };
|
|
403
|
+
if (entry.type !== "assistant") continue;
|
|
404
|
+
const model = entry.message?.model;
|
|
405
|
+
if (typeof model === "string" && model !== SYNTHETIC_MODEL) return modelFamily(model);
|
|
406
|
+
} catch {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return "unknown";
|
|
411
|
+
}
|
|
412
|
+
|
|
368
413
|
/**
|
|
369
414
|
* Every DISTINCT compact_boundary sample found across ALL of a project's
|
|
370
415
|
* transcripts (live + archived), newest first by the event's OWN timestamp
|
|
@@ -383,6 +428,17 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
|
|
|
383
428
|
continue;
|
|
384
429
|
}
|
|
385
430
|
|
|
431
|
+
// The model governing each sample: the most recent assistant
|
|
432
|
+
// `message.model` seen in this file before the compact_boundary. A
|
|
433
|
+
// transcript written by a non-Claude model (a headless worker on another
|
|
434
|
+
// provider, with a different context window) compacts at a different
|
|
435
|
+
// size and must not shape THIS project's trigger — two such workers
|
|
436
|
+
// compacting at ~151k pulled a real project's trigger from ~784k to
|
|
437
|
+
// ~151k. Samples with no model seen yet are kept: older transcripts
|
|
438
|
+
// may lack the field.
|
|
439
|
+
let lastModel: string | null = null;
|
|
440
|
+
let foreignDiscards = 0;
|
|
441
|
+
|
|
386
442
|
for (const line of raw.split("\n")) {
|
|
387
443
|
if (!line.trim()) continue;
|
|
388
444
|
let entry: {
|
|
@@ -391,23 +447,40 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
|
|
|
391
447
|
timestamp?: string;
|
|
392
448
|
uuid?: string;
|
|
393
449
|
compactMetadata?: { preTokens?: number };
|
|
450
|
+
message?: { model?: unknown };
|
|
394
451
|
};
|
|
395
452
|
try {
|
|
396
453
|
entry = JSON.parse(line);
|
|
397
454
|
} catch {
|
|
398
455
|
continue;
|
|
399
456
|
}
|
|
457
|
+
if (entry.type === "assistant") {
|
|
458
|
+
const model = entry.message?.model;
|
|
459
|
+
if (typeof model === "string" && model !== SYNTHETIC_MODEL) lastModel = model;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
400
462
|
if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
|
|
401
463
|
const preTokens = entry.compactMetadata?.preTokens;
|
|
402
464
|
if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
|
|
403
465
|
const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
404
466
|
if (!Number.isFinite(timestampMs)) continue;
|
|
467
|
+
if (modelFamily(lastModel) === "foreign") {
|
|
468
|
+
foreignDiscards++;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
405
471
|
|
|
406
472
|
const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
|
|
407
473
|
if (!byKey.has(key)) {
|
|
408
474
|
byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });
|
|
409
475
|
}
|
|
410
476
|
}
|
|
477
|
+
|
|
478
|
+
if (foreignDiscards > 0) {
|
|
479
|
+
console.error(
|
|
480
|
+
`[context-fill] ignored ${foreignDiscards} compaction sample(s) from a ` +
|
|
481
|
+
`non-Claude transcript (model=${lastModel}): ${path}`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
411
484
|
}
|
|
412
485
|
|
|
413
486
|
return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);
|