@tekmidian/pai 0.39.1 → 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-CqlgL7uH.mjs → chain-DhVHVnmT.mjs} +263 -821
  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 +76 -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/{fallback-DqYhiACU.mjs → fallback-CdXv-Np0.mjs} +139 -49
  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 +36 -2
  38. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  39. package/dist/hooks/worker-proxy.mjs +36 -2
  40. package/dist/hooks/worker-proxy.mjs.map +2 -2
  41. package/dist/hooks/worker-status-line.mjs +110 -86
  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-DGV8VFib.mjs → planner-Cm3g6fWH.mjs} +5 -3
  61. package/dist/{planner-DGV8VFib.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-B-QJXCRb.mjs → program-AR0qlYRg.mjs} +309 -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 +6 -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 +4 -2
  97. package/docs/commands/worker.md +48 -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 +29 -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-CqlgL7uH.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/fallback-DqYhiACU.mjs.map +0 -1
  118. package/dist/main-resolver-D6IImXvF.mjs +0 -4
  119. package/dist/main-resolver-DlaLOFBA.mjs.map +0 -1
  120. package/dist/program-B-QJXCRb.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
@@ -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(
@@ -35,8 +35,18 @@ DA_COLOR="${DA_COLOR:-purple}" # Color for the assistant name
35
35
  # Extract data from JSON input
36
36
  current_dir=$(echo "$input" | jq -r '.workspace.current_dir')
37
37
  model_name=$(echo "$input" | jq -r '.model.display_name')
38
+ model_id=$(echo "$input" | jq -r '.model.id // .model.display_name // empty')
38
39
  cc_version=$(echo "$input" | jq -r '.version // "unknown"')
39
40
 
41
+ # Provider detection: strip any [...] suffix (e.g. the [1m] context marker)
42
+ # so "glm-5.3[1m]" is recognized as a glm model. glm sessions read their plan
43
+ # usage from Z.ai, everything else keeps the Anthropic OAuth path.
44
+ model_base="${model_id%%\[*}"
45
+ is_glm=0
46
+ case "$model_base" in
47
+ [Gg][Ll][Mm]*) is_glm=1 ;;
48
+ esac
49
+
40
50
  # Get directory name
41
51
  dir_name=$(basename "$current_dir")
42
52
 
@@ -112,7 +122,14 @@ fi
112
122
 
113
123
  # Extract context window usage from Claude Code's JSON input (no JSONL parsing needed)
114
124
  context_pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0' 2>/dev/null)
115
- context_size=$(echo "$input" | jq -r '.context_window.context_window_size // 200000' 2>/dev/null)
125
+ # When the payload omits the window size, derive it from the model id instead
126
+ # of assuming 200k for every model: [1m] models carry a 1M window, everything
127
+ # else keeps the claude default. This value is persisted below for hooks.
128
+ default_context_size=200000
129
+ case "$model_id" in
130
+ *"[1m]") default_context_size=1000000 ;;
131
+ esac
132
+ context_size=$(echo "$input" | jq -r --argjson d "$default_context_size" '.context_window.context_window_size // $d' 2>/dev/null)
116
133
  context_used_k=$(( (context_pct * context_size / 100) / 1000 ))
117
134
  context_max_k=$((context_size / 1000))
118
135
 
@@ -125,7 +142,7 @@ if [ -n "$statusline_session_id" ]; then
125
142
  context_state_file="${TMPDIR:-/tmp}/pai-context-${statusline_session_id}.json"
126
143
  jq -n \
127
144
  --argjson used_percentage "${context_pct:-0}" \
128
- --argjson context_window_size "${context_size:-200000}" \
145
+ --argjson context_window_size "${context_size:-$default_context_size}" \
129
146
  --arg session_id "$statusline_session_id" \
130
147
  --argjson timestamp "$(date +%s000)" \
131
148
  '{used_percentage: $used_percentage, context_window_size: $context_window_size, session_id: $session_id, timestamp: $timestamp}' \
@@ -347,11 +364,22 @@ if [ -n "$mcp_line2" ]; then
347
364
  fi
348
365
 
349
366
 
350
- # Fetch OAuth usage (5-hour current + 7-day weekly) with caching
367
+ # Usage suffix: provider-aware. glm sessions read the Z.ai plan quota
368
+ # (5-hour + weekly credit windows), everything else keeps the Anthropic
369
+ # OAuth path (5-hour + 1d pace + 7-day). Both cache for usage_cache_ttl.
351
370
  usage_cache="/tmp/claude/statusline-usage-cache.json"
371
+ zai_cache="/tmp/claude/statusline-zai-cache.json"
352
372
  usage_cache_ttl=60 # seconds
353
373
  usage_suffix=""
354
374
 
375
+ # Color based on utilization: green < 50%, yellow 50-75%, red > 75%
376
+ _usage_color() {
377
+ local pct=$1
378
+ if [ "$pct" -gt 75 ] 2>/dev/null; then echo "$BRIGHT_RED"
379
+ elif [ "$pct" -gt 50 ] 2>/dev/null; then echo "$BRIGHT_YELLOW"
380
+ else echo "$BRIGHT_GREEN"; fi
381
+ }
382
+
355
383
  _fetch_usage() {
356
384
  # Try to get OAuth token from macOS Keychain
357
385
  local token=""
@@ -367,18 +395,21 @@ _fetch_usage() {
367
395
  [ -n "$response" ] && echo "$response" > "$usage_cache"
368
396
  }
369
397
 
370
- # Use cache if fresh, otherwise fetch in background
371
- if [ -f "$usage_cache" ]; then
372
- cache_age=$(( $(date +%s) - $(stat -f %m "$usage_cache" 2>/dev/null || echo 0) ))
373
- if [ "$cache_age" -gt "$usage_cache_ttl" ]; then
398
+ # Use cache if fresh, otherwise fetch in background (Anthropic plan only)
399
+ if [ "$is_glm" -eq 0 ]; then
400
+ if [ -f "$usage_cache" ]; then
401
+ cache_age=$(( $(date +%s) - $(stat -f %m "$usage_cache" 2>/dev/null || echo 0) ))
402
+ if [ "$cache_age" -gt "$usage_cache_ttl" ]; then
403
+ _fetch_usage &
404
+ fi
405
+ else
374
406
  _fetch_usage &
375
407
  fi
376
- else
377
- _fetch_usage &
378
408
  fi
379
409
 
380
- # Read cached usage data
381
- if [ -f "$usage_cache" ]; then
410
+ # Read cached usage data — skipped on glm sessions so Anthropic-plan numbers
411
+ # (usage line AND the advisor-mode budget file) never leak into a glm statusline
412
+ if [ "$is_glm" -eq 0 ] && [ -f "$usage_cache" ]; then
382
413
  five_hour=$(jq -r '.five_hour.utilization // 0' "$usage_cache" 2>/dev/null)
383
414
  seven_day=$(jq -r '.seven_day.utilization // 0' "$usage_cache" 2>/dev/null)
384
415
  five_reset=$(jq -r '.five_hour.resets_at // empty' "$usage_cache" 2>/dev/null)
@@ -406,14 +437,6 @@ if [ -f "$usage_cache" ]; then
406
437
  seven_reset_fmt=$([ "$seven_reset_epoch" -gt 0 ] 2>/dev/null && date -r "$seven_reset_epoch" "+%a %H:%M" 2>/dev/null || echo "")
407
438
  fi
408
439
 
409
- # Color based on utilization: green < 50%, yellow 50-75%, red > 75%
410
- _usage_color() {
411
- local pct=$1
412
- if [ "$pct" -gt 75 ] 2>/dev/null; then echo "$BRIGHT_RED"
413
- elif [ "$pct" -gt 50 ] 2>/dev/null; then echo "$BRIGHT_YELLOW"
414
- else echo "$BRIGHT_GREEN"; fi
415
- }
416
-
417
440
  five_color=$(_usage_color "$five_hour_int")
418
441
  seven_color=$(_usage_color "$seven_day_int")
419
442
 
@@ -504,6 +527,80 @@ if [ -f "$usage_cache" ]; then
504
527
  usage_suffix="${usage_suffix} ${SEPARATOR_COLOR}│${RESET} ${seven_color}${seven_label}${RESET}"
505
528
  fi
506
529
 
530
+ # Z.ai plan quota (glm sessions). The monitor endpoint exposes two credit
531
+ # windows: limits[] number==5 is the 5-hour window, number==1 the weekly one
532
+ # (nextResetTime in epoch ms) — no 1d window exists on this plan, so none is
533
+ # rendered. PAI_ZAI_QUOTA_URL overrides the endpoint (kill switch / testing).
534
+ _fetch_zai_usage() {
535
+ local key="${ZAI_API_KEY:-}"
536
+ if [ -z "$key" ] && [ -f "$HOME/.config/zai/api_key" ]; then
537
+ key=$(tr -d '[:space:]' < "$HOME/.config/zai/api_key")
538
+ fi
539
+ [ -n "$key" ] || return
540
+ mkdir -p /tmp/claude
541
+ local response
542
+ response=$(curl -sf --max-time 3 \
543
+ -H "Authorization: Bearer $key" \
544
+ -H "Accept: application/json" \
545
+ "${PAI_ZAI_QUOTA_URL:-https://api.z.ai/api/monitor/usage/quota/limit}" 2>/dev/null)
546
+ # A bad key returns HTTP 200 with empty limits — only cache responses
547
+ # that actually carry quota windows, so a broken fetch is never cached.
548
+ if [ -n "$response" ] && echo "$response" | jq -e '.data.limits | length > 0' >/dev/null 2>&1; then
549
+ echo "$response" > "$zai_cache"
550
+ fi
551
+ }
552
+
553
+ if [ "$is_glm" -eq 1 ]; then
554
+ # Use cache if fresh, otherwise fetch in background
555
+ if [ -f "$zai_cache" ]; then
556
+ cache_age=$(( $(date +%s) - $(stat -f %m "$zai_cache" 2>/dev/null || echo 0) ))
557
+ if [ "$cache_age" -gt "$usage_cache_ttl" ]; then
558
+ _fetch_zai_usage &
559
+ fi
560
+ else
561
+ _fetch_zai_usage &
562
+ fi
563
+
564
+ zai_five_pct=""
565
+ zai_seven_pct=""
566
+ zai_five_reset_ms=""
567
+ zai_seven_reset_ms=""
568
+ if [ -f "$zai_cache" ]; then
569
+ zai_five_pct=$(jq -r '.data.limits[]? | select(.number == 5) | .percentage' "$zai_cache" 2>/dev/null | head -1)
570
+ zai_seven_pct=$(jq -r '.data.limits[]? | select(.number == 1) | .percentage' "$zai_cache" 2>/dev/null | head -1)
571
+ zai_five_reset_ms=$(jq -r '.data.limits[]? | select(.number == 5) | .nextResetTime' "$zai_cache" 2>/dev/null | head -1)
572
+ zai_seven_reset_ms=$(jq -r '.data.limits[]? | select(.number == 1) | .nextResetTime' "$zai_cache" 2>/dev/null | head -1)
573
+ fi
574
+
575
+ if [ -n "$zai_five_pct" ] && [ "$zai_five_pct" != "null" ]; then
576
+ zai_five_int=$(printf "%.0f" "$zai_five_pct" 2>/dev/null || echo "?")
577
+ zai_five_reset_fmt=""
578
+ if [ -n "$zai_five_reset_ms" ] && [ "$zai_five_reset_ms" != "null" ]; then
579
+ # nextResetTime is epoch milliseconds; date -r wants seconds
580
+ zai_five_epoch=$(( zai_five_reset_ms / 1000 ))
581
+ [ "$zai_five_epoch" -gt 0 ] 2>/dev/null && zai_five_reset_fmt=$(date -r "$zai_five_epoch" "+%H:%M" 2>/dev/null || echo "")
582
+ fi
583
+ zai_five_label="zai 5h: ${zai_five_int}%%"
584
+ [ -n "$zai_five_reset_fmt" ] && zai_five_label="${zai_five_label} → ${zai_five_reset_fmt}"
585
+ usage_suffix=" ${SEPARATOR_COLOR}│${RESET} $(_usage_color "$zai_five_int")${zai_five_label}${RESET}"
586
+ if [ -n "$zai_seven_pct" ] && [ "$zai_seven_pct" != "null" ]; then
587
+ zai_seven_int=$(printf "%.0f" "$zai_seven_pct" 2>/dev/null || echo "?")
588
+ zai_seven_reset_fmt=""
589
+ if [ -n "$zai_seven_reset_ms" ] && [ "$zai_seven_reset_ms" != "null" ]; then
590
+ zai_seven_epoch=$(( zai_seven_reset_ms / 1000 ))
591
+ [ "$zai_seven_epoch" -gt 0 ] 2>/dev/null && zai_seven_reset_fmt=$(date -r "$zai_seven_epoch" "+%a %H:%M" 2>/dev/null || echo "")
592
+ fi
593
+ zai_seven_label="7d: ${zai_seven_int}%%"
594
+ [ -n "$zai_seven_reset_fmt" ] && zai_seven_label="${zai_seven_label} → ${zai_seven_reset_fmt}"
595
+ usage_suffix="${usage_suffix} ${SEPARATOR_COLOR}│${RESET} $(_usage_color "$zai_seven_int")${zai_seven_label}${RESET}"
596
+ fi
597
+ else
598
+ # No key, timeout, non-200 or empty limits — show the marker instead
599
+ # of silently falling back to Anthropic-plan numbers.
600
+ usage_suffix=" ${SEPARATOR_COLOR}│${RESET} ${LINE3_ACCENT}zai 5h: ?${RESET}"
601
+ fi
602
+ fi
603
+
507
604
  # LINE 3 - Context meter + usage limits
508
605
  # Auto-compact remaining: how much context left until compaction triggers
509
606
  ac_threshold="${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:-80}"
@@ -517,7 +614,7 @@ elif [ "$ac_remaining" -le 15 ] 2>/dev/null; then
517
614
  else
518
615
  ac_color="$BRIGHT_GREEN"
519
616
  fi
520
- ac_suffix=" ${ac_color}(${ac_remaining}%%)${RESET}"
617
+ ac_suffix=" ${ac_color}(${ac_remaining}%% left)${RESET}"
521
618
 
522
619
  if [ "$context_pct" -gt 0 ] 2>/dev/null; then
523
620
  # Color based on usage: green < 50%, yellow 50-75%, red > 75%
@@ -545,7 +642,9 @@ elif command -v pai >/dev/null 2>&1; then
545
642
  worker_status_cmd="pai worker status-line"
546
643
  fi
547
644
  if [ -n "$worker_status_cmd" ]; then
548
- worker_line="$("$worker_status_cmd" "${ITERM_SESSION_ID:-}" "${PWD:-}" 2>/dev/null)"
645
+ # third arg: this session's claude id — lets the worker line claim workers
646
+ # spawned from this session's Bash tool, which carry no terminal identity
647
+ worker_line="$("$worker_status_cmd" "${ITERM_SESSION_ID:-}" "${PWD:-}" "${statusline_session_id:-}" 2>/dev/null)"
549
648
  if [ -n "$worker_line" ]; then
550
649
  printf "${LINE3_PRIMARY}🐝 ${RESET}${LINE3_ACCENT}%s${RESET}\n" "$worker_line"
551
650
  fi