@tekmidian/pai 0.39.0 → 0.40.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.
Files changed (126) hide show
  1. package/README.md +41 -0
  2. package/dist/{auto-route-BlWOWT4z.mjs → auto-route-BwzmlW3W.mjs} +2 -2
  3. package/dist/{auto-route-BlWOWT4z.mjs.map → auto-route-BwzmlW3W.mjs.map} +1 -1
  4. package/dist/{chain-CTtHligI.mjs → chain-DhVHVnmT.mjs} +263 -791
  5. package/dist/chain-DhVHVnmT.mjs.map +1 -0
  6. package/dist/cli/index.mjs +10 -8
  7. package/dist/cli/index.mjs.map +1 -1
  8. package/dist/cli/program.mjs +10 -8
  9. package/dist/{clusters-wlK0w41E.mjs → clusters-DRHhfli9.mjs} +1 -1
  10. package/dist/{clusters-wlK0w41E.mjs.map → clusters-DRHhfli9.mjs.map} +1 -1
  11. package/dist/{config-B64vFg14.mjs → config-D1G9IFpn.mjs} +50 -3
  12. package/dist/config-D1G9IFpn.mjs.map +1 -0
  13. package/dist/{config-C_ErGddD.mjs → config-DD52BPB-.mjs} +1 -1
  14. package/dist/config-Dl8lT4Lu.mjs +527 -0
  15. package/dist/config-Dl8lT4Lu.mjs.map +1 -0
  16. package/dist/{context-handover-cache-BpUojjsi.mjs → context-handover-cache-C6_rs7JT.mjs} +112 -24
  17. package/dist/context-handover-cache-C6_rs7JT.mjs.map +1 -0
  18. package/dist/daemon/index.mjs +12 -10
  19. package/dist/daemon/index.mjs.map +1 -1
  20. package/dist/daemon-BSL9sXxm.mjs +22 -0
  21. package/dist/{daemon-CTkN_Vn8.mjs → daemon-CpdVUnl_.mjs} +26 -26
  22. package/dist/{daemon-CTkN_Vn8.mjs.map → daemon-CpdVUnl_.mjs.map} +1 -1
  23. package/dist/daemon-mcp/index.mjs +127 -6
  24. package/dist/daemon-mcp/index.mjs.map +1 -1
  25. package/dist/detector-BLVOCiJ_.mjs +5 -0
  26. package/dist/{detector-Bwk_4Pk4.mjs → detector-smpCm7ku.mjs} +1 -1
  27. package/dist/{detector-Bwk_4Pk4.mjs.map → detector-smpCm7ku.mjs.map} +1 -1
  28. package/dist/{factory-A9x-T2Kg.mjs → factory-Bc5EyN54.mjs} +7 -7
  29. package/dist/{factory-A9x-T2Kg.mjs.map → factory-Bc5EyN54.mjs.map} +1 -1
  30. package/dist/factory-C7ua_3ik.mjs +3 -0
  31. package/dist/{providers-FYkZjn_C.mjs → fallback-CdXv-Np0.mjs} +411 -51
  32. package/dist/fallback-CdXv-Np0.mjs.map +1 -0
  33. package/dist/hooks/context-compression-hook.mjs +136 -22
  34. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  35. package/dist/hooks/post-compact-inject.mjs +98 -7
  36. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  37. package/dist/hooks/route-agents-to-worker.mjs +75 -6
  38. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  39. package/dist/hooks/worker-proxy.mjs +75 -6
  40. package/dist/hooks/worker-proxy.mjs.map +2 -2
  41. package/dist/hooks/worker-status-line.mjs +149 -90
  42. package/dist/hooks/worker-status-line.mjs.map +4 -4
  43. package/dist/{indexer-backend-TG64CCQC.mjs → indexer-backend-rohVVKrA.mjs} +1 -1
  44. package/dist/{indexer-backend-TG64CCQC.mjs.map → indexer-backend-rohVVKrA.mjs.map} +1 -1
  45. package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C7AVpsyv.mjs} +2 -2
  46. package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C7AVpsyv.mjs.map} +1 -1
  47. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  48. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  49. package/dist/{latent-ideas-B7wq75Pt.mjs → latent-ideas-iNp5s55R.mjs} +2 -2
  50. package/dist/{latent-ideas-B7wq75Pt.mjs.map → latent-ideas-iNp5s55R.mjs.map} +1 -1
  51. package/dist/{link-boost-HkG7JWZR.mjs → link-boost-NNzADHX_.mjs} +1 -1
  52. package/dist/{link-boost-HkG7JWZR.mjs.map → link-boost-NNzADHX_.mjs.map} +1 -1
  53. package/dist/{main-resolver-DlaLOFBA.mjs → main-resolver-CM1IHbuu.mjs} +78 -75
  54. package/dist/main-resolver-CM1IHbuu.mjs.map +1 -0
  55. package/dist/main-resolver-E5WlilhM.mjs +6 -0
  56. package/dist/{neighborhood-lThN-MaQ.mjs → neighborhood-AGL7oXQ2.mjs} +1 -1
  57. package/dist/{neighborhood-lThN-MaQ.mjs.map → neighborhood-AGL7oXQ2.mjs.map} +1 -1
  58. package/dist/{note-context-b6k0mAKi.mjs → note-context-BHwYpSFf.mjs} +1 -1
  59. package/dist/{note-context-b6k0mAKi.mjs.map → note-context-BHwYpSFf.mjs.map} +1 -1
  60. package/dist/{planner-BDI7bE9B.mjs → planner-Cm3g6fWH.mjs} +5 -3
  61. package/dist/{planner-BDI7bE9B.mjs.map → planner-Cm3g6fWH.mjs.map} +1 -1
  62. package/dist/{postgres-mW1n7Vi1.mjs → postgres-Dwxe290H.mjs} +1 -1
  63. package/dist/{postgres-mW1n7Vi1.mjs.map → postgres-Dwxe290H.mjs.map} +1 -1
  64. package/dist/{program-DXBwJV7h.mjs → program-AR0qlYRg.mjs} +334 -78
  65. package/dist/program-AR0qlYRg.mjs.map +1 -0
  66. package/dist/query-feedback-CGcEiGtY.mjs +3 -0
  67. package/dist/{query-feedback-BV4CcxqS.mjs → query-feedback-DZfjI34w.mjs} +1 -1
  68. package/dist/{query-feedback-BV4CcxqS.mjs.map → query-feedback-DZfjI34w.mjs.map} +1 -1
  69. package/dist/router-CAtyVQud.mjs +3 -0
  70. package/dist/{router-DcHKnEPa.mjs → router-DHracTUC.mjs} +1 -1
  71. package/dist/{router-DcHKnEPa.mjs.map → router-DHracTUC.mjs.map} +1 -1
  72. package/dist/server-BOuAOj9b.mjs +386 -0
  73. package/dist/server-BOuAOj9b.mjs.map +1 -0
  74. package/dist/skills/Worker/SKILL.md +19 -1
  75. package/dist/{sources-kLnQsNrW.mjs → sources-ju6AA7lL.mjs} +1 -1
  76. package/dist/{sources-kLnQsNrW.mjs.map → sources-ju6AA7lL.mjs.map} +1 -1
  77. package/dist/{sqlite-BenGr3UP.mjs → sqlite-kFZeWCJ2.mjs} +1 -1
  78. package/dist/{sqlite-BenGr3UP.mjs.map → sqlite-kFZeWCJ2.mjs.map} +1 -1
  79. package/dist/{state-Ca9F_sZc.mjs → state-DH_mp5Xe.mjs} +1 -1
  80. package/dist/{state-CAeyOdfq.mjs → state-ahFAbD8b.mjs} +1 -1
  81. package/dist/{state-CAeyOdfq.mjs.map → state-ahFAbD8b.mjs.map} +1 -1
  82. package/dist/{themes-BI4GMRP9.mjs → themes-DqhNUUQk.mjs} +1 -1
  83. package/dist/{themes-BI4GMRP9.mjs.map → themes-DqhNUUQk.mjs.map} +1 -1
  84. package/dist/{tools-DGcB3o_S.mjs → tools-CMlHa9lZ.mjs} +14 -14
  85. package/dist/{tools-DGcB3o_S.mjs.map → tools-CMlHa9lZ.mjs.map} +1 -1
  86. package/dist/{tools-Bp7hj6OW.mjs → tools-COna9vZR.mjs} +1 -1
  87. package/dist/{trace-bobARFEX.mjs → trace-DTP6k4B_.mjs} +1 -1
  88. package/dist/{trace-bobARFEX.mjs.map → trace-DTP6k4B_.mjs.map} +1 -1
  89. package/dist/{vault-indexer-C3OfXTNF.mjs → vault-indexer-YMH22mLU.mjs} +1 -1
  90. package/dist/{vault-indexer-C3OfXTNF.mjs.map → vault-indexer-YMH22mLU.mjs.map} +1 -1
  91. package/dist/{work-queue-worker-R7UGIag_.mjs → work-queue-worker-B3nl8yyn.mjs} +23 -26
  92. package/dist/work-queue-worker-B3nl8yyn.mjs.map +1 -0
  93. package/dist/work-queue-worker-BM1GcAAq.mjs +14 -0
  94. package/dist/{zettelkasten-m5QPtb-L.mjs → zettelkasten-DDvKRm2V.mjs} +3 -3
  95. package/dist/{zettelkasten-m5QPtb-L.mjs.map → zettelkasten-DDvKRm2V.mjs.map} +1 -1
  96. package/docs/commands/README.md +5 -2
  97. package/docs/commands/worker.md +66 -5
  98. package/docs/images/provider-abstraction-live.png +0 -0
  99. package/docs/images/workers.png +0 -0
  100. package/docs/launcher-independence.md +70 -0
  101. package/docs/provider-abstraction.md +87 -0
  102. package/docs/provider-independence-details.md +330 -0
  103. package/docs/provider-independence.md +75 -0
  104. package/docs/worker.md +72 -11
  105. package/package.json +1 -1
  106. package/scripts/build-hooks.mjs +6 -1
  107. package/scripts/build-skill-stubs.mjs +17 -4
  108. package/src/hooks/ts/lib/context-fill.test.ts +158 -4
  109. package/src/hooks/ts/lib/context-fill.ts +102 -35
  110. package/statusline-command.sh +120 -21
  111. package/dist/chain-CTtHligI.mjs.map +0 -1
  112. package/dist/config-B64vFg14.mjs.map +0 -1
  113. package/dist/context-handover-cache-BpUojjsi.mjs.map +0 -1
  114. package/dist/daemon-CmHHmKpg.mjs +0 -20
  115. package/dist/detector-DO730Zq0.mjs +0 -5
  116. package/dist/factory-BD-np0Vd.mjs +0 -3
  117. package/dist/main-resolver-D6IImXvF.mjs +0 -4
  118. package/dist/main-resolver-DlaLOFBA.mjs.map +0 -1
  119. package/dist/program-DXBwJV7h.mjs.map +0 -1
  120. package/dist/providers-FYkZjn_C.mjs.map +0 -1
  121. package/dist/query-feedback-DSVyHtrG.mjs +0 -3
  122. package/dist/router-Bk77E7hj.mjs +0 -3
  123. package/dist/runtime-paths-rni52zHX.mjs +0 -50
  124. package/dist/runtime-paths-rni52zHX.mjs.map +0 -1
  125. package/dist/work-queue-worker-DW8lz-Oo.mjs +0 -11
  126. package/dist/work-queue-worker-R7UGIag_.mjs.map +0 -1
package/docs/worker.md CHANGED
@@ -265,6 +265,49 @@ A quota failure before the first tool call is re-run on the next provider and
265
265
  logged as `WORKER-REROUTE`. `pai worker providers enable <name>` clears a
266
266
  cooldown by hand.
267
267
 
268
+ ## When the Anthropic plan runs out
269
+
270
+ `pai worker fallback on [provider]` switches the whole machine: every NEW
271
+ Claude Code process — interactive sessions, task-bus sessions, the daemon's
272
+ headless summarizer — runs on that worker provider instead of the Anthropic
273
+ login, until `pai worker fallback off`. Use it when the plan budget is gone
274
+ but work must continue.
275
+
276
+ What `on` does:
277
+
278
+ - Writes the provider into the `env` block of `~/.claude/settings.json`:
279
+ `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, the three
280
+ `ANTHROPIC_DEFAULT_*_MODEL` pins (fast model as haiku, default as sonnet
281
+ and opus), the provider's own env (`API_TIMEOUT_MS` …), plus
282
+ `ENABLE_TOOL_SEARCH=true` and
283
+ `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`.
284
+ - Pins the top-level `model` key to the provider's default model.
285
+ - Saves the replaced values under `workers.fallback.saved` in
286
+ `~/.config/pai/config.json` and writes a `FALLBACK-ACTIVE.md` note into the
287
+ workers log dir telling running sessions what to do.
288
+
289
+ Notes:
290
+
291
+ - **The token sits in settings.json.** `on` reads it from the provider's key
292
+ file at switch time; `off` removes it again. That is the price of switching
293
+ every process without touching each one's environment.
294
+ - `off` restores settings.json exactly — only the keys `on` touched change.
295
+ Both files are written atomically; `CLAUDE_SETTINGS_PATH` points `on`/`off`
296
+ at a copy for dry runs.
297
+ - **Running sessions are not switched.** A Claude Code process keeps the
298
+ provider it started with until restarted. `pai worker fallback status`
299
+ lists running sessions (AIBroker registry when populated, else `ps`) and
300
+ the note path; its one-line instruction for each of them: restart the
301
+ session in its project directory, keep the same AIBroker name.
302
+ - The Agent hook is unchanged: subagents keep routing to `pai worker run`
303
+ workers, which set their own per-provider env.
304
+ - `on` is idempotent (re-applying heals an interrupted switch), and switching
305
+ providers while on restores the first switch's savings before saving fresh.
306
+ - MCP: `worker_fallback` (action `on`/`off`/`status`, optional `provider`);
307
+ chat phrases "switch everything to glm", "fallback on", "back to
308
+ anthropic", "fallback off", "is fallback on" are mapped in the Worker
309
+ skill.
310
+
268
311
  ## What a worker is
269
312
 
270
313
  - One `claude -p … --output-format stream-json --verbose` process per call,
@@ -340,8 +383,8 @@ behaviour — no prompt row, no ticker, stdin still the operator channel.
340
383
  Any worker may start its own workers: the runner exports `PAI_WORKER_ID` in
341
384
  every worker's environment, and a `pai worker run` launched from inside one
342
385
  records `parent` in its status — so the forest is visible in `ps` (children
343
- indented under their parent, `├`/`└` connectors), the status line (`↳` under
344
- the parent) and each child gets its own follow pane. Handoffs travel **up
386
+ indented under their parent, `├`/`└` connectors) and each child gets its own
387
+ follow pane. Handoffs travel **up
345
388
  only**, from a child to its parent:
346
389
 
347
390
  ```
@@ -352,8 +395,8 @@ pai worker handoff '{"kind":"proposal","text":"run this on a cheap provider","da
352
395
  `result` is sent automatically when a child finishes). The handoff is appended
353
396
  to `<logDir>/<parent>.inbox.jsonl` (durable, ordered) and, when the parent is
354
397
  running, also delivered as an operator message `[handoff from <child id>]`. The
355
- parent sees it in its pane (`◆ from <id> · kind: text`, magenta), `ps` and the
356
- status line show `◆N` for an inbox with N handoffs, and `replay`/`follow`
398
+ parent sees it in its pane (`◆ from <id> · kind: text`, magenta), `ps` shows
399
+ `◆N` for an inbox with N handoffs, and `replay`/`follow`
357
400
  merge them into the transcript by timestamp. There is no sideways channel:
358
401
  siblings never see each other, everything goes up.
359
402
 
@@ -425,9 +468,9 @@ drive the desktop until it is handed over.
425
468
 
426
469
  Status files carry `contextTokens` (input + cache read + cache creation +
427
470
  output of the last assistant turn) and `contextWindow` (from the init event,
428
- else the provider's `contextWindow`, else 200 000). The `ps` table and the
429
- status line show `ctx 84k/200k (42%)` once it passes 60 % — yellow past
430
- 70 %, red past 85 % — and the pane's liveness line always shows it.
471
+ else the provider's `contextWindow`, else 200 000). The `ps` table shows
472
+ `ctx 84k/200k (42%)` once it passes 60 % — yellow past 70 %, red past
473
+ 85 % — and the pane's liveness line always shows it.
431
474
 
432
475
  ### MCP for workers
433
476
 
@@ -516,7 +559,25 @@ the CLI calls.
516
559
 
517
560
  ## Status line
518
561
 
519
- Line 4 of the statusline lists this session's running workers (provider,
520
- label, age, current tool, context meter past 60 %) plus today's ✓/✗ tally.
521
- It prefers the standalone `~/.claude/worker-status-line.mjs` (plain node,
522
- built by `bun run build`) and falls back to `pai worker status-line`.
562
+ Line 4 of the statusline is the worker bar: the provider and running-worker
563
+ count, one row per running spawned worker, and today's ✓/✗ tally. It prefers
564
+ the standalone `~/.claude/worker-status-line.mjs` (plain node, built by
565
+ `bun run build`) and falls back to `pai worker status-line`.
566
+
567
+ Example: `glm ▶2 | fix black buttons · 6m · grep … | spotcheck login · 1m · Edit: button.ts ✓75 ✗4 today`
568
+
569
+ Segment by segment:
570
+
571
+ - provider tag (`glm`) — the chat pane's provider; `workers` when there is no tracked chat pane and the running workers are mixed
572
+ - `▶2` — this terminal's running spawned workers: `state=running` **and** a live pid **and** not the chat pane; nothing at zero, so an idle terminal shows just `glm`
573
+ - row `name · age · step` — the worker's `--label` (else the first 70 chars of the prompt, `unlabeled` when the run had neither), time since it started, and the verb of what it is doing; `#id` (the short form `pai worker ps` shows) joins the name only when two running workers share a label
574
+ - step (`grep …`) — the current tool trimmed to its verb: no `Bash:` prefix, no flags, no quotes, ≤24 chars; file tools keep their `Edit: button.ts` shape
575
+ - `✓75 ✗4 today` — today's finished workers: done vs failed
576
+
577
+ The chat pane itself contributes only its provider — its age, state and
578
+ inbox live in `pai worker ps`, never in the bar. Rows are flat and
579
+ oldest-first; the worker forest (`↳` depth) and `◆N` inbox marks are `ps`
580
+ territory too. A status file from before the `origin` flag (a running entry
581
+ with no origin, the unlabeled placeholder and zero turns) is treated as the
582
+ chat pane so it does not render as a phantom worker — that shim goes away
583
+ once every pane runs code that writes `origin`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -29,6 +29,7 @@ import {
29
29
  } from "fs";
30
30
  import { join, resolve, basename } from "path";
31
31
  import { homedir, platform } from "os";
32
+ import { isInsideWorkerWorktree } from "./lib/sync-guard.mjs";
32
33
 
33
34
  const HOOKS_SRC = "src/hooks/ts";
34
35
  const HOOKS_OUT = "dist/hooks";
@@ -143,7 +144,11 @@ console.log(
143
144
  // --sync: Symlink (or copy on Windows) all deployable files to ~/.claude/
144
145
  // ---------------------------------------------------------------------------
145
146
 
146
- if (doSync) {
147
+ if (doSync && isInsideWorkerWorktree(process.cwd())) {
148
+ // Never repoint the live ~/.claude symlinks at a worktree that `pai worker
149
+ // merge` will delete — see scripts/lib/sync-guard.mjs.
150
+ console.log("✔ Hook symlinks skipped: build runs inside a worker worktree");
151
+ } else if (doSync) {
147
152
  const useSymlinks = platform() !== "win32";
148
153
  const claudeDir = join(homedir(), ".claude");
149
154
  const hooksTarget = join(claudeDir, "Hooks");
@@ -32,6 +32,7 @@ import {
32
32
  } from "fs";
33
33
  import { join, resolve } from "path";
34
34
  import { homedir, platform } from "os";
35
+ import { isInsideWorkerWorktree } from "./lib/sync-guard.mjs";
35
36
 
36
37
  const PROMPTS_DIR = "src/daemon-mcp/prompts";
37
38
  const CUSTOM_DIR = join(PROMPTS_DIR, "custom");
@@ -107,13 +108,21 @@ function syncSymlinks(generatedNames) {
107
108
  const source = resolve(join(STUBS_OUT, name));
108
109
  const target = join(skillsDir, name);
109
110
 
111
+ // lstat once: dangling links must count as symlinks (existsSync misses them)
112
+ let entry;
113
+ try {
114
+ entry = lstatSync(target);
115
+ } catch {
116
+ // absent — fresh install
117
+ }
118
+
110
119
  // Never overwrite non-symlink directories (user's own skills)
111
- if (existsSync(target) && !lstatSync(target).isSymbolicLink()) {
120
+ if (entry && !entry.isSymbolicLink()) {
112
121
  continue;
113
122
  }
114
123
 
115
- // Check existing symlink
116
- if (existsSync(target) && lstatSync(target).isSymbolicLink()) {
124
+ // Check existing symlink (may be dangling)
125
+ if (entry) {
117
126
  if (resolve(readlinkSync(target)) === source) {
118
127
  current++;
119
128
  continue;
@@ -197,6 +206,10 @@ for (const [fileName, dir] of [
197
206
  const customLabel = customNames.length > 0 ? ` (${builtinNames.length} built-in + ${customNames.length} custom)` : "";
198
207
  console.log(`✔ ${generated} skill stubs generated in ${STUBS_OUT}/${customLabel}`);
199
208
 
200
- if (doSync) {
209
+ if (doSync && isInsideWorkerWorktree(process.cwd())) {
210
+ // Never repoint the live ~/.claude/skills symlinks at a worktree that
211
+ // `pai worker merge` will delete — see scripts/lib/sync-guard.mjs.
212
+ console.log("✔ Skill symlinks skipped: build runs inside a worker worktree");
213
+ } else if (doSync) {
201
214
  syncSymlinks(generatedDirNames);
202
215
  }
@@ -14,6 +14,8 @@ import {
14
14
  resolveAutocompactPct,
15
15
  measureCompactionTrigger,
16
16
  modelFamily,
17
+ isForeignModelFamily,
18
+ nativeModelFamilies,
17
19
  selectedCompactionSamples,
18
20
  transcriptModelFamily,
19
21
  crossedThresholds,
@@ -21,6 +23,7 @@ import {
21
23
  DEFAULT_CONTEXT_WINDOW,
22
24
  type ContextFillReading,
23
25
  } from "./context-fill.js";
26
+ import { contextWindowFromModelId } from "../../../utils/model-window.js";
24
27
 
25
28
  let root: string;
26
29
 
@@ -705,9 +708,12 @@ describe("measureCompactionTrigger — foreign-model transcripts are ignored", (
705
708
  });
706
709
 
707
710
  describe("modelFamily / transcriptModelFamily", () => {
708
- it("classifies claude-, foreign, synthetic and missing models", () => {
711
+ it("keys families from the id itself, not from an Anthropic allowlist", () => {
709
712
  expect(modelFamily("claude-opus-5")).toBe("claude");
710
- expect(modelFamily("other-model-1")).toBe("foreign");
713
+ expect(modelFamily("other-model-1")).toBe("other-model-1");
714
+ // the variant suffix is stripped, so one family has one key
715
+ expect(modelFamily("glm-5.3[1m]")).toBe("glm-5.3");
716
+ expect(modelFamily("glm-5.3")).toBe("glm-5.3");
711
717
  expect(modelFamily("<synthetic>")).toBe("unknown");
712
718
  expect(modelFamily(null)).toBe("unknown");
713
719
  expect(modelFamily("")).toBe("unknown");
@@ -722,11 +728,159 @@ describe("modelFamily / transcriptModelFamily", () => {
722
728
  [assistantLine("other-model-1"), compactBoundaryLine(1, "2026-09-17T09:00:00.000Z"), assistantLine("claude-x-1")].join("\n") + "\n"
723
729
  );
724
730
  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");
731
+ writeFileSync(path, [assistantLine("claude-x-1"), assistantLine("glm-5.3[1m]"), assistantLine("<synthetic>")].join("\n") + "\n");
732
+ expect(transcriptModelFamily(path)).toBe("glm-5.3");
727
733
  expect(transcriptModelFamily(join(dir, "missing.jsonl"))).toBe("unknown");
728
734
  } finally {
729
735
  rmSync(dir, { recursive: true, force: true });
730
736
  }
731
737
  });
732
738
  });
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Foreignness is a comparison, not a prefix: a family is foreign exactly when
742
+ // it is neither Claude nor one the worker registry configures. Keying it on
743
+ // the claude- prefix made a non-Anthropic PRIMARY model read as foreign and
744
+ // lose its own compaction/handover history.
745
+ // ---------------------------------------------------------------------------
746
+
747
+ describe("isForeignModelFamily / nativeModelFamilies", () => {
748
+ it("claude and unknown are never foreign; anything else depends on the registry", () => {
749
+ expect(isForeignModelFamily("claude")).toBe(false);
750
+ expect(isForeignModelFamily("unknown")).toBe(false);
751
+ expect(isForeignModelFamily("glm-5.3", new Set())).toBe(true);
752
+ expect(isForeignModelFamily("glm-5.3", new Set(["glm-5.3"]))).toBe(false);
753
+ });
754
+
755
+ it("collects the families of every configured provider model", () => {
756
+ const dir = mkdtempSync(join(tmpdir(), "pai-native-families-"));
757
+ const configPath = join(dir, "config.json");
758
+ writeFileSync(
759
+ configPath,
760
+ JSON.stringify({
761
+ workers: {
762
+ enabled: true,
763
+ active: "glm",
764
+ providers: {
765
+ glm: {
766
+ baseUrl: "https://api.example.com/api/anthropic",
767
+ keyFile: null,
768
+ models: { default: "example-5.3[1m]", fast: "example-5.3-flash" },
769
+ env: {},
770
+ },
771
+ },
772
+ },
773
+ })
774
+ );
775
+ try {
776
+ const families = nativeModelFamilies(configPath);
777
+ expect(families.has("example-5.3")).toBe(true); // variant suffix stripped
778
+ expect(families.has("example-5.3-flash")).toBe(true);
779
+ expect(isForeignModelFamily("example-5.3", families)).toBe(false);
780
+ expect(isForeignModelFamily("other-model-1", families)).toBe(true);
781
+ } finally {
782
+ rmSync(dir, { recursive: true, force: true });
783
+ }
784
+ });
785
+
786
+ it("an unreadable registry means nothing is native (the historical rule)", () => {
787
+ expect(nativeModelFamilies("/nonexistent/pai-config.json").size).toBe(0);
788
+ });
789
+ });
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // A provider-model primary session keeps its compaction history: the samples
793
+ // are glm, the registry configures glm, so nothing is discarded — before and
794
+ // after a simulated compaction alike.
795
+ // ---------------------------------------------------------------------------
796
+
797
+ describe("measureCompactionTrigger — provider-model transcripts are native", () => {
798
+ it("keeps glm compaction history across a simulated compaction, still drops foreign models", () => {
799
+ const dir = mkdtempSync(join(tmpdir(), "pai-native-trigger-"));
800
+ const configPath = join(dir, "config.json");
801
+ writeFileSync(
802
+ configPath,
803
+ JSON.stringify({
804
+ workers: {
805
+ enabled: true,
806
+ active: "glm",
807
+ providers: {
808
+ glm: {
809
+ baseUrl: "https://api.example.com/api/anthropic",
810
+ keyFile: null,
811
+ models: { default: "glm-5.3[1m]", fast: "glm-5.3-flash" },
812
+ env: {},
813
+ },
814
+ },
815
+ },
816
+ })
817
+ );
818
+ const projectsDir = join(dir, "projects");
819
+ const cwd = "/fake/project/glm";
820
+ const projectDir = join(projectsDir, encodeForFixture(cwd));
821
+ mkdirSync(projectDir, { recursive: true });
822
+ writeFileSync(
823
+ join(projectDir, "live.jsonl"),
824
+ [
825
+ assistantLine("glm-5.3[1m]"),
826
+ compactBoundaryLine(990_000, "2026-09-16T09:00:00.000Z", "u1"), // before
827
+ assistantLine("glm-5.3[1m]"),
828
+ compactBoundaryLine(784_000, "2026-09-17T09:00:00.000Z", "u2"), // after
829
+ ].join("\n") + "\n"
830
+ );
831
+ // a foreign-model worker transcript must still not shape the trigger
832
+ writeFileSync(
833
+ join(projectDir, "worker.jsonl"),
834
+ [assistantLine("other-model-1"), compactBoundaryLine(151_000, "2026-09-17T09:30:00.000Z", "u3")].join("\n") + "\n"
835
+ );
836
+ try {
837
+ expect(selectedCompactionSamples(cwd, projectsDir, configPath).map((s) => s.preTokens))
838
+ .toEqual([784_000, 990_000]);
839
+ expect(measureCompactionTrigger(cwd, projectsDir, configPath)).toBe(784_000);
840
+ } finally {
841
+ rmSync(dir, { recursive: true, force: true });
842
+ }
843
+ });
844
+ });
845
+
846
+ // ---------------------------------------------------------------------------
847
+ // Window size from the model id — a session whose model declares its window
848
+ // ("[1m]") is measured against that, not against the assumed 200k default.
849
+ // ---------------------------------------------------------------------------
850
+
851
+ describe("contextWindowFromModelId", () => {
852
+ it("reads the bracketed variant suffix, and only that", () => {
853
+ expect(contextWindowFromModelId("glm-5.3[1m]")).toBe(1_000_000);
854
+ expect(contextWindowFromModelId("glm-5.3")).toBeNull();
855
+ expect(contextWindowFromModelId("")).toBeNull();
856
+ expect(contextWindowFromModelId(null)).toBeNull();
857
+ });
858
+ });
859
+
860
+ describe("contextFillFromTranscript — window from the transcript's own model", () => {
861
+ const glmTurn = (tokens: number) => ({
862
+ type: "assistant",
863
+ message: { role: "assistant", model: "glm-5.3[1m]", usage: { input_tokens: tokens, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } },
864
+ });
865
+
866
+ it("derives a 1M window from a glm-5.3[1m] transcript when the caller has none", () => {
867
+ const p = join(root, "glm.jsonl");
868
+ writeJsonl(p, [glmTurn(500_000)]);
869
+ const reading = contextFillFromTranscript(p);
870
+ expect(reading.windowSize).toBe(1_000_000);
871
+ expect(reading.fraction).toBeCloseTo(0.5);
872
+ });
873
+
874
+ it("an explicit caller window still wins, and a bare id falls back to the default", () => {
875
+ const p = join(root, "glm.jsonl");
876
+ writeJsonl(p, [glmTurn(500_000)]);
877
+ expect(contextFillFromTranscript(p, 200_000).windowSize).toBe(200_000);
878
+
879
+ const bare = join(root, "bare.jsonl");
880
+ writeJsonl(bare, [{
881
+ type: "assistant",
882
+ message: { role: "assistant", model: "glm-5.3", usage: { input_tokens: 500, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } },
883
+ }]);
884
+ expect(contextFillFromTranscript(bare).windowSize).toBe(DEFAULT_CONTEXT_WINDOW);
885
+ });
886
+ });
@@ -32,10 +32,14 @@
32
32
  import { existsSync, readdirSync, readFileSync } from "node:fs";
33
33
  import { homedir, tmpdir } from "node:os";
34
34
  import { join } from "node:path";
35
+ import {
36
+ DEFAULT_CONTEXT_WINDOW,
37
+ contextWindowFromModelId,
38
+ stripModelVariant,
39
+ } from "../../../utils/model-window.js";
40
+ import { readWorkersSection } from "../../../workers/config.js";
35
41
 
36
- /** Fallback window size when nothing on hand reports one. Matches the
37
- * default statusline-command.sh already falls back to. */
38
- export const DEFAULT_CONTEXT_WINDOW = 200_000;
42
+ export { DEFAULT_CONTEXT_WINDOW };
39
43
 
40
44
  /** How many trailing lines of the transcript to scan for a usage entry.
41
45
  * A single turn is rarely more than a handful of JSONL lines (assistant
@@ -147,44 +151,60 @@ function usageTotal(usage: UsageEntry): number {
147
151
  * `message.usage` entry, read from the end backwards so a trailing line with
148
152
  * no usage field (a plain text turn, a tool_result) doesn't hide one just
149
153
  * before it.
154
+ *
155
+ * The window size, when the caller has none, is derived from the transcript's
156
+ * own last assistant model id ("glm-5.3[1m]" → 1,000,000) before falling back
157
+ * to the assumed default — a session whose model declares its window should
158
+ * not be measured against a guess.
150
159
  */
151
160
  export function contextFillFromTranscript(
152
161
  transcriptPath: string,
153
- windowSize = DEFAULT_CONTEXT_WINDOW
162
+ windowSize?: number
154
163
  ): ContextFillReading {
155
- if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);
164
+ if (!transcriptPath || !existsSync(transcriptPath)) {
165
+ return unknownReading(windowSize ?? DEFAULT_CONTEXT_WINDOW);
166
+ }
156
167
 
157
168
  let raw: string;
158
169
  try {
159
170
  raw = readFileSync(transcriptPath, "utf-8");
160
171
  } catch {
161
- return unknownReading(windowSize);
172
+ return unknownReading(windowSize ?? DEFAULT_CONTEXT_WINDOW);
162
173
  }
163
174
 
164
175
  const lines = raw.trim().split("\n").filter((l) => l.trim());
165
176
  const tail = lines.slice(-TRANSCRIPT_TAIL_LINES);
166
177
 
178
+ // one pass from the end: the first usage entry is the fill reading, and the
179
+ // last assistant model seen (before or at that point) declares the window
180
+ let lastModel: string | null = null;
167
181
  for (let i = tail.length - 1; i >= 0; i--) {
168
- let entry: { message?: { usage?: UsageEntry } };
182
+ let entry: { type?: string; message?: { usage?: UsageEntry; model?: unknown } };
169
183
  try {
170
184
  entry = JSON.parse(tail[i]);
171
185
  } catch {
172
186
  continue;
173
187
  }
188
+ if (entry?.type === "assistant" && lastModel === null) {
189
+ const model = entry.message?.model;
190
+ if (typeof model === "string" && model !== "") lastModel = model;
191
+ }
174
192
  const usage = entry?.message?.usage;
175
193
  if (usage && typeof usage === "object") {
194
+ const window = windowSize ?? contextWindowFromModelId(lastModel) ?? DEFAULT_CONTEXT_WINDOW;
176
195
  const usedTokens = usageTotal(usage);
177
196
  return {
178
197
  status: "ok",
179
198
  usedTokens,
180
- windowSize,
181
- fraction: usedTokens / windowSize,
199
+ windowSize: window,
200
+ fraction: usedTokens / window,
182
201
  source: "transcript",
183
202
  };
184
203
  }
185
204
  }
186
205
 
187
- return unknownReading(windowSize);
206
+ const window = windowSize ?? contextWindowFromModelId(lastModel) ?? DEFAULT_CONTEXT_WINDOW;
207
+ return unknownReading(window);
188
208
  }
189
209
 
190
210
  // ---------------------------------------------------------------------------
@@ -195,18 +215,18 @@ export function getContextFill(
195
215
  input: { sessionId?: string; transcriptPath?: string; windowSize?: number },
196
216
  now = Date.now()
197
217
  ): ContextFillReading {
198
- const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;
199
-
200
218
  if (input.sessionId) {
201
219
  const fromStatusline = readStatuslineFill(input.sessionId, now);
202
220
  if (fromStatusline) return fromStatusline;
203
221
  }
204
222
 
205
223
  if (input.transcriptPath) {
206
- return contextFillFromTranscript(input.transcriptPath, windowSize);
224
+ // windowSize passes through only when the caller knows it; otherwise the
225
+ // transcript's own model id is asked first (see contextFillFromTranscript)
226
+ return contextFillFromTranscript(input.transcriptPath, input.windowSize);
207
227
  }
208
228
 
209
- return unknownReading(windowSize);
229
+ return unknownReading(input.windowSize ?? DEFAULT_CONTEXT_WINDOW);
210
230
  }
211
231
 
212
232
  // ---------------------------------------------------------------------------
@@ -369,23 +389,61 @@ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
369
389
  const SYNTHETIC_MODEL = "<synthetic>";
370
390
 
371
391
  /**
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.
392
+ * Which family of model wrote a transcript: a stable key derived from the id
393
+ * itself, never from an Anthropic allowlist. "claude" is the platform's own;
394
+ * any other id reduces to its base form before any bracketed variant suffix
395
+ * ("glm-5.3[1m]" "glm-5.3"), so the same model family always produces the
396
+ * same key; "unknown" is no usable model field at all. Whether a family is
397
+ * foreign is decided by comparison (see isForeignModelFamily), not by the
398
+ * key — keying "foreign" here is what made non-Anthropic primary models lose
399
+ * their own compaction history.
376
400
  */
377
- export type TranscriptModelFamily = "claude" | "foreign" | "unknown";
401
+ export type TranscriptModelFamily = "claude" | "unknown" | (string & {});
378
402
 
379
403
  export function modelFamily(model: string | null | undefined): TranscriptModelFamily {
380
404
  if (typeof model !== "string" || model === "" || model === SYNTHETIC_MODEL) return "unknown";
381
- return model.startsWith("claude-") ? "claude" : "foreign";
405
+ const base = stripModelVariant(model);
406
+ return base.startsWith("claude-") ? "claude" : base;
407
+ }
408
+
409
+ /**
410
+ * Is a model id foreign to this machine's sessions? A model is native when it
411
+ * is a Claude model or one the worker registry configures (any provider's
412
+ * default or fast alias — the models this stack actually runs on). Anything
413
+ * else, with a readable model id, is foreign. Doubt (no id, unreadable
414
+ * registry) reads as native: the callers that skip work on "foreign" must not
415
+ * skip it on doubt.
416
+ */
417
+ export function isForeignModelFamily(
418
+ family: TranscriptModelFamily,
419
+ nativeFamilies?: Set<string>
420
+ ): boolean {
421
+ if (family === "unknown" || family === "claude") return false;
422
+ return !(nativeFamilies ?? nativeModelFamilies()).has(family);
423
+ }
424
+
425
+ /** Families of every model id the worker registry configures. Never throws:
426
+ * an unreadable or missing registry means "nothing configured", which leaves
427
+ * every non-Claude family foreign — the historical behaviour. */
428
+ export function nativeModelFamilies(configPath?: string): Set<string> {
429
+ const families = new Set<string>();
430
+ try {
431
+ for (const p of Object.values(readWorkersSection(configPath).workers.providers)) {
432
+ families.add(modelFamily(p.models.default));
433
+ if (p.models.fast) families.add(modelFamily(p.models.fast));
434
+ }
435
+ } catch {
436
+ return families;
437
+ }
438
+ return families;
382
439
  }
383
440
 
384
441
  /**
385
442
  * The family of the LAST assistant model in a transcript — scanned from the
386
443
  * 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.
444
+ * Unreadable or model-less transcripts are "unknown", never foreign:
445
+ * the callers that skip work on foreign models must not skip it on doubt.
446
+ * Pair with isForeignModelFamily to judge the key.
389
447
  */
390
448
  export function transcriptModelFamily(path: string): TranscriptModelFamily {
391
449
  let raw: string;
@@ -417,7 +475,11 @@ export function transcriptModelFamily(path: string): TranscriptModelFamily {
417
475
  * the event's uuid (falling back to a timestamp+preTokens key for the rare
418
476
  * line with no uuid) so an event mirrored into `sessions/` is counted once.
419
477
  */
420
- function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {
478
+ function readCompactBoundarySamples(
479
+ cwd: string,
480
+ projectsDir: string,
481
+ nativeFamilies: Set<string>
482
+ ): CompactBoundarySample[] {
421
483
  const byKey = new Map<string, CompactBoundarySample>();
422
484
 
423
485
  for (const path of listProjectTranscripts(cwd, projectsDir)) {
@@ -430,12 +492,14 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
430
492
 
431
493
  // The model governing each sample: the most recent assistant
432
494
  // `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.
495
+ // transcript written by a model foreign to this machine (a headless
496
+ // worker on another provider, with a different context window) compacts
497
+ // at a different size and must not shape THIS project's trigger — two
498
+ // such workers compacting at ~151k pulled a real project's trigger from
499
+ // ~784k to ~151k. Native is Claude plus every model the registry
500
+ // configures, so a non-Anthropic PRIMARY model keeps its own history.
501
+ // Samples with no model seen yet are kept: older transcripts may lack
502
+ // the field.
439
503
  let lastModel: string | null = null;
440
504
  let foreignDiscards = 0;
441
505
 
@@ -464,7 +528,7 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
464
528
  if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
465
529
  const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
466
530
  if (!Number.isFinite(timestampMs)) continue;
467
- if (modelFamily(lastModel) === "foreign") {
531
+ if (isForeignModelFamily(modelFamily(lastModel), nativeFamilies)) {
468
532
  foreignDiscards++;
469
533
  continue;
470
534
  }
@@ -478,7 +542,7 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
478
542
  if (foreignDiscards > 0) {
479
543
  console.error(
480
544
  `[context-fill] ignored ${foreignDiscards} compaction sample(s) from a ` +
481
- `non-Claude transcript (model=${lastModel}): ${path}`
545
+ `foreign-model transcript (model=${lastModel}): ${path}`
482
546
  );
483
547
  }
484
548
  }
@@ -495,10 +559,12 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
495
559
  */
496
560
  export function selectedCompactionSamples(
497
561
  cwd: string,
498
- projectsDir: string = CLAUDE_PROJECTS_DIR
562
+ projectsDir: string = CLAUDE_PROJECTS_DIR,
563
+ configPath?: string
499
564
  ): CompactBoundarySample[] {
500
565
  if (!cwd) return [];
501
- return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
566
+ return readCompactBoundarySamples(cwd, projectsDir, nativeModelFamilies(configPath))
567
+ .slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
502
568
  }
503
569
 
504
570
  /**
@@ -514,9 +580,10 @@ export function selectedCompactionSamples(
514
580
  */
515
581
  export function measureCompactionTrigger(
516
582
  cwd: string,
517
- projectsDir: string = CLAUDE_PROJECTS_DIR
583
+ projectsDir: string = CLAUDE_PROJECTS_DIR,
584
+ configPath?: string
518
585
  ): number | null {
519
- const samples = selectedCompactionSamples(cwd, projectsDir);
586
+ const samples = selectedCompactionSamples(cwd, projectsDir, configPath);
520
587
  if (samples.length === 0) return null;
521
588
  const trigger = Math.min(...samples.map((s) => s.preTokens));
522
589
  console.error(