@tekmidian/pai 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
  2. package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
  3. package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
  4. package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
  5. package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
  6. package/dist/cli/index.mjs +14 -14
  7. package/dist/cli/program.mjs +14 -14
  8. package/dist/config-Bn9jpblO.mjs +653 -0
  9. package/dist/config-Bn9jpblO.mjs.map +1 -0
  10. package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
  11. package/dist/config-DYXARgk8.mjs.map +1 -0
  12. package/dist/config-RjeVGJ7K.mjs +3 -0
  13. package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
  14. package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
  15. package/dist/daemon/index.mjs +13 -12
  16. package/dist/daemon/index.mjs.map +1 -1
  17. package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
  18. package/dist/daemon-CBTQYtHF.mjs.map +1 -0
  19. package/dist/daemon-Vf7pHnjR.mjs +25 -0
  20. package/dist/daemon-mcp/index.mjs +186 -23
  21. package/dist/daemon-mcp/index.mjs.map +1 -1
  22. package/dist/factory-Ba8j9K7q.mjs +3 -0
  23. package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
  24. package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
  25. package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
  26. package/dist/fallback-CIGspCSi.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +80 -8
  28. package/dist/hooks/capture-all-events.mjs.map +4 -4
  29. package/dist/hooks/context-compression-hook.mjs +54 -39
  30. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  31. package/dist/hooks/post-compact-inject.mjs +54 -39
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +204 -109
  34. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  35. package/dist/hooks/whisper-rules.mjs +7613 -9
  36. package/dist/hooks/whisper-rules.mjs.map +4 -4
  37. package/dist/hooks/worker-proxy.mjs +204 -109
  38. package/dist/hooks/worker-proxy.mjs.map +4 -4
  39. package/dist/hooks/worker-status-line.mjs +211 -116
  40. package/dist/hooks/worker-status-line.mjs.map +4 -4
  41. package/dist/hooks/worker-supervision.mjs +208 -113
  42. package/dist/hooks/worker-supervision.mjs.map +4 -4
  43. package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
  44. package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
  45. package/dist/main-resolver-BTastpp4.mjs +7 -0
  46. package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
  47. package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
  48. package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
  49. package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
  50. package/dist/{program-D9-wfmkZ.mjs → program-srUeFUfW.mjs} +5806 -5615
  51. package/dist/program-srUeFUfW.mjs.map +1 -0
  52. package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
  53. package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
  54. package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
  55. package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
  56. package/dist/run-TChOnMO_.mjs.map +1 -0
  57. package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
  58. package/dist/server-R9UkCn3v.mjs.map +1 -0
  59. package/dist/session-keepalive-BuG3wvik.mjs +579 -0
  60. package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
  61. package/dist/skills/VaultContext/SKILL.md +1 -1
  62. package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
  63. package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
  64. package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
  65. package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
  66. package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
  67. package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
  68. package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
  69. package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
  70. package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
  71. package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
  72. package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
  73. package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
  74. package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
  75. package/docs/cache-keepalive.md +138 -0
  76. package/docs/commands/README.md +7 -0
  77. package/docs/commands/audit.md +1 -1
  78. package/docs/commands/config.md +84 -0
  79. package/docs/commands/daemon.md +6 -0
  80. package/docs/commands/worker.md +37 -2
  81. package/docs/config.md +171 -0
  82. package/docs/model-catalogue.md +502 -0
  83. package/docs/provider-independence-details.md +1 -1
  84. package/docs/task-bus.md +1 -1
  85. package/docs/worker.md +33 -5
  86. package/docs/workers-config.md +105 -12
  87. package/package.json +1 -1
  88. package/src/hooks/ts/capture-all-events.test.ts +71 -0
  89. package/src/hooks/ts/capture-all-events.ts +10 -3
  90. package/src/hooks/ts/lib/context-fill.test.ts +124 -46
  91. package/src/hooks/ts/lib/context-fill.ts +57 -14
  92. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
  93. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  94. package/statusline-command.sh +103 -16
  95. package/dist/config-DCu8THKi.mjs +0 -228
  96. package/dist/config-DCu8THKi.mjs.map +0 -1
  97. package/dist/config-DeqFqa1N.mjs +0 -3
  98. package/dist/config-DxMZjpvY.mjs.map +0 -1
  99. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  100. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  101. package/dist/factory-47Qf_df7.mjs +0 -3
  102. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  103. package/dist/indexer-C5Le7XE7.mjs +0 -1
  104. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  105. package/dist/program-D9-wfmkZ.mjs.map +0 -1
  106. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  107. package/dist/run-Cls0YGgV.mjs.map +0 -1
  108. package/dist/server-Ch99xt5K.mjs.map +0 -1
  109. package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
@@ -294,7 +294,8 @@ export function formatContextFill(reading: ContextFillReading): FillDisplay {
294
294
  // The configured chain — env override, then a default — is the fallback,
295
295
  // used ONLY when a project has no compaction history yet:
296
296
  //
297
- // effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)
297
+ // effectiveTrigger = measured ?? configuredTrigger(windowSize, override ?? 80)
298
+ // configuredTrigger(windowSize, pct) = pct/100 * (windowSize - 20,000)
298
299
  //
299
300
  // DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered — it
300
301
  // matches the FIRST regime above — and rejected: the costs are asymmetric.
@@ -313,6 +314,17 @@ export function formatContextFill(reading: ContextFillReading): FillDisplay {
313
314
  // waiting for one.
314
315
  export const DEFAULT_AUTOCOMPACT_PCT = 80;
315
316
 
317
+ /**
318
+ * The platform's actual compaction trigger for a window/pct pair. NOT
319
+ * windowSize * pct/100 — that formula gives 800,000 on 1M/80 and 160,000 on
320
+ * 200k/80, but the platform fires at pct/100 * (windowSize - 20,000):
321
+ * 784,000 and 144,000, measured on every compact_boundary on this machine,
322
+ * 2026-09-20 (see the module comment above).
323
+ */
324
+ export function configuredTrigger(windowSize: number, pct: number): number {
325
+ return Math.round((pct / 100) * (windowSize - 20_000));
326
+ }
327
+
316
328
  /** How many of the most recent compact_boundary events to consider, and to
317
329
  * take the minimum of. */
318
330
  const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
@@ -575,26 +587,57 @@ export function selectedCompactionSamples(
575
587
 
576
588
  /**
577
589
  * The measured compaction trigger for a project, or null when it has no
578
- * compaction history yet (a brand-new project, or one whose transcripts
579
- * this process cannot read). Minimum of the most recent
580
- * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by
581
- * the events' own timestamps across every transcript the project has
582
- * (live and archived) — see the module comment above for why minimum, not
583
- * mean, and readCompactBoundarySamples for why "distinct" and "own
584
- * timestamp" both matter (a file-mtime-ordered, non-deduplicated version of
585
- * this returned a stale pre-regime-change trigger on real project data).
590
+ * compaction history yet (a brand-new project, one whose transcripts this
591
+ * process cannot read, or one whose only recent samples belong to a
592
+ * different override regime — see the admissibility filter below).
593
+ * Minimum of the most recent MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT
594
+ * compact_boundary events, ordered by the events' own timestamps across
595
+ * every transcript the project has (live and archived) — see the module
596
+ * comment above for why minimum, not mean, and readCompactBoundarySamples
597
+ * for why "distinct" and "own timestamp" both matter (a file-mtime-ordered,
598
+ * non-deduplicated version of this returned a stale pre-regime-change
599
+ * trigger on real project data).
600
+ *
601
+ * When `configuredTriggerTokens` is given, a sample is admissible only when
602
+ * preTokens >= 0.5 * configuredTriggerTokens — a compaction that fired far
603
+ * below the currently configured trigger came from a lower override or a
604
+ * smaller window, a different regime than the one in force now, and must
605
+ * not drag the trigger down with it. Real case, 2026-09-20:
606
+ * CLAUDE_AUTOCOMPACT_PCT_OVERRIDE was 20 for seven hours, this project
607
+ * compacted three times at ~195k-198k under it, then the override went
608
+ * back to 80 (configured ~784k) — those three samples are inadmissible and
609
+ * must be discarded, not averaged into the new regime's trigger, or the
610
+ * handover would fire at ~155k for days until three fresh ~784k
611
+ * compactions pushed them out on their own.
586
612
  */
587
613
  export function measureCompactionTrigger(
588
614
  cwd: string,
589
615
  projectsDir: string = CLAUDE_PROJECTS_DIR,
590
- configPath?: string
616
+ configPath?: string,
617
+ configuredTriggerTokens?: number
591
618
  ): number | null {
592
619
  const samples = selectedCompactionSamples(cwd, projectsDir, configPath);
593
620
  if (samples.length === 0) return null;
594
- const trigger = Math.min(...samples.map((s) => s.preTokens));
621
+
622
+ let admissible = samples;
623
+ if (configuredTriggerTokens !== undefined) {
624
+ const floor = 0.5 * configuredTriggerTokens;
625
+ admissible = samples.filter((s) => s.preTokens >= floor);
626
+ const discarded = samples.length - admissible.length;
627
+ if (discarded > 0) {
628
+ console.error(
629
+ `[context-fill] discarded ${discarded} compaction sample(s) as other-regime for ${cwd} ` +
630
+ `(preTokens below ${Math.round(floor)}, half of configured=${configuredTriggerTokens}): ` +
631
+ samples.filter((s) => s.preTokens < floor).map((s) => `${s.preTokens}@${s.timestamp}`).join(", ")
632
+ );
633
+ }
634
+ if (admissible.length === 0) return null;
635
+ }
636
+
637
+ const trigger = Math.min(...admissible.map((s) => s.preTokens));
595
638
  console.error(
596
639
  `[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +
597
- samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
640
+ admissible.map((s) => `${s.preTokens}@${s.timestamp}`).join(", ") + ")"
598
641
  );
599
642
  return trigger;
600
643
  }
@@ -717,12 +760,12 @@ export function contextFillThresholds(
717
760
  ): ContextFillThresholds {
718
761
  const windowConfirmed = reading.source === "statusline";
719
762
  const autocompactPct = resolveAutocompactPct(env);
720
- const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
763
+ const configuredTriggerTokens = configuredTrigger(reading.windowSize, autocompactPct);
721
764
 
722
765
  const measuredTriggerTokens = opts.measuredTrigger !== undefined
723
766
  ? opts.measuredTrigger
724
767
  : opts.cwd
725
- ? measureCompactionTrigger(opts.cwd)
768
+ ? measureCompactionTrigger(opts.cwd, CLAUDE_PROJECTS_DIR, undefined, configuredTriggerTokens)
726
769
  : null;
727
770
 
728
771
  let effectiveTriggerTokens: number;
@@ -1,5 +1,8 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach } from "vitest";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
 
4
7
  // whisper-rules.ts calls main() unconditionally at module scope (it's a hook
5
8
  // entrypoint, always run as a subprocess) — including a blocking read of fd 0.
@@ -46,4 +49,56 @@ describe("user-prompt/whisper-rules entrypoint", () => {
46
49
  expect(stdout).toContain("<system-reminder>");
47
50
  expect(stdout).toContain("CURRENT LOCAL TIME");
48
51
  });
52
+
53
+ describe("cache-keepalive beat prompt", () => {
54
+ const dirs: string[] = [];
55
+
56
+ afterEach(() => {
57
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
58
+ });
59
+
60
+ function configuredEnv(config: Record<string, unknown>): NodeJS.ProcessEnv {
61
+ const dir = mkdtempSync(join(tmpdir(), "pai-whisper-keepalive-"));
62
+ dirs.push(dir);
63
+ const file = join(dir, "config.json");
64
+ writeFileSync(file, JSON.stringify(config), "utf8");
65
+ return { ...process.env, PAI_CONFIG_FILE: file };
66
+ }
67
+
68
+ it("replies with only the minimal tool-free instruction, bypassing rules/advisor", () => {
69
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: true, prompt: "keepalive" } } });
70
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
71
+ input: JSON.stringify({ prompt: "keepalive" }),
72
+ encoding: "utf8",
73
+ timeout: 15_000,
74
+ env,
75
+ });
76
+ expect(stdout).toBe(
77
+ "<system-reminder>\nReply with a single period and nothing else. Do not call tools.\n</system-reminder>\n"
78
+ );
79
+ expect(stdout).not.toContain("CURRENT LOCAL TIME");
80
+ });
81
+
82
+ it("falls through to the normal rule block when the feature is disabled", () => {
83
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: false, prompt: "keepalive" } } });
84
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
85
+ input: JSON.stringify({ prompt: "keepalive" }),
86
+ encoding: "utf8",
87
+ timeout: 15_000,
88
+ env,
89
+ });
90
+ expect(stdout).toContain("CURRENT LOCAL TIME");
91
+ });
92
+
93
+ it("falls through to the normal rule block for a prompt that doesn't match the configured word", () => {
94
+ const env = configuredEnv({ sessions: { cacheKeepalive: { enabled: true, prompt: "keepalive" } } });
95
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
96
+ input: JSON.stringify({ prompt: "please fix the login bug" }),
97
+ encoding: "utf8",
98
+ timeout: 15_000,
99
+ env,
100
+ });
101
+ expect(stdout).toContain("CURRENT LOCAL TIME");
102
+ });
103
+ });
49
104
  });
@@ -24,6 +24,7 @@ import { join } from "node:path";
24
24
  import { tmpdir } from "node:os";
25
25
  import { resolveAdvisorMode, type AdvisorConfig } from "../lib/advisor-budget.js";
26
26
  import { whisperRulesPath, advisorModePath } from "../../../config/pai-files.js";
27
+ import { loadConfig } from "../../../daemon/config.js";
27
28
 
28
29
  const WHISPER_FILE = whisperRulesPath();
29
30
  const ADVISOR_FILE = advisorModePath();
@@ -181,6 +182,24 @@ function resetReinjectCounter(sessionId: string): void {
181
182
  * several times per turn during worker-heavy work, so injecting the ~5KB rule
182
183
  * block on each one was landing it 3-5 times in a single turn.
183
184
  */
185
+ /**
186
+ * True when this prompt is the daemon's own idle-triggered cache-keepalive
187
+ * beat (sessions.cacheKeepalive, src/daemon/config.ts): the daemon typed the
188
+ * configured word into this session purely to refresh its prompt-cache TTL,
189
+ * not to ask anything. The whole point is a beat that costs as little output
190
+ * as possible, so this bypasses every other rule/advisor injection below —
191
+ * adding ~5KB of context to answer nothing would erase the saving the beat
192
+ * exists to capture.
193
+ */
194
+ export function isCacheKeepaliveBeat(prompt: string): boolean {
195
+ try {
196
+ const { enabled, prompt: word } = loadConfig().sessions.cacheKeepalive;
197
+ return enabled && prompt.trim() === word;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
184
203
  export function isRelayedPrompt(prompt: string): boolean {
185
204
  return (
186
205
  /^\s*\[Session:[^\]]+\]/.test(prompt) ||
@@ -204,6 +223,11 @@ function main() {
204
223
 
205
224
  resetReinjectCounter(sessionId);
206
225
 
226
+ if (isCacheKeepaliveBeat(prompt)) {
227
+ console.log("<system-reminder>\nReply with a single period and nothing else. Do not call tools.\n</system-reminder>");
228
+ return;
229
+ }
230
+
207
231
  if (isRelayedPrompt(prompt)) return;
208
232
 
209
233
  const isWorker = process.env.PAI_WORKER === "1";
@@ -114,15 +114,90 @@ if [ ! -f "$pai_config" ]; then
114
114
  pai_config="${XDG_CONFIG_HOME:-$HOME/.config}/pai/config.json"
115
115
  fi
116
116
  fi
117
+
118
+ # Providers moved to $pai_home_dir/workers.yaml (top-level `providers:`; see
119
+ # src/workers/config.ts readWorkersSection) on 2026-09-19 — jq never sees the
120
+ # YAML directly (no yq on this machine), so a small node script converts it
121
+ # via the `yaml` package sitting next to this script's own real location
122
+ # (readlink -f "$0": this file is usually reached through a symlink at
123
+ # ~/.claude/statusline-command.sh). The conversion is cached under
124
+ # pai_cache_dir, the cache kept only while it is newer than workers.yaml's own
125
+ # mtime, so node is spawned only when the YAML actually changed. Resolved once
126
+ # into $providers_file (holding just the providers object, unwrapped) so both
127
+ # this lookup and the usage block below read the same data — never re-derived.
128
+ # Any failure (no workers.yaml, no node, parse error) falls back silently to
129
+ # the legacy `.workers.providers` of $pai_config, then to anthropic.
130
+ providers_file=""
131
+ _workers_yaml="${pai_home_dir}/workers.yaml"
132
+ if [ -f "$_workers_yaml" ] && command -v node >/dev/null 2>&1; then
133
+ _sl_script="$(readlink -f "$0" 2>/dev/null || echo "$0")"
134
+ _sl_script_dir="$(dirname "$_sl_script")"
135
+ _providers_cache="${pai_cache_dir}/statusline-providers.json"
136
+ _yaml_mtime=$(stat -f %m "$_workers_yaml" 2>/dev/null || echo 0)
137
+ _cache_mtime=$(stat -f %m "$_providers_cache" 2>/dev/null || echo -1)
138
+ if [ ! -s "$_providers_cache" ] || [ "$_cache_mtime" -lt "$_yaml_mtime" ] 2>/dev/null; then
139
+ mkdir -p "$pai_cache_dir" 2>/dev/null
140
+ # umask 077 so the temp file is born 0600 — it must never exist at a
141
+ # world-readable mode even transiently, since providers carry inline
142
+ # `key:` secrets and only the allow-listed fields below leave this
143
+ # subshell.
144
+ (
145
+ umask 077
146
+ NODE_PATH="${_sl_script_dir}/node_modules" node - "$_workers_yaml" \
147
+ > "${_providers_cache}.tmp" 2>/dev/null <<'PAI_YAML_PROVIDERS'
148
+ const fs = require("fs");
149
+ try {
150
+ const YAML = require("yaml");
151
+ const doc = YAML.parse(fs.readFileSync(process.argv[2], "utf8"));
152
+ const providers = (doc && doc.providers) || {};
153
+ // Allow-list: only what the status line ever reads from a provider.
154
+ // workers.yaml is 0600 because providers carry inline `key:` secrets —
155
+ // this cache must never repeat them (or `env`, `url`, `headers`, …).
156
+ const safe = {};
157
+ for (const [name, p] of Object.entries(providers || {})) {
158
+ safe[name] = {
159
+ models: (p && p.models) || {},
160
+ usage: (p && p.usage) || undefined,
161
+ keyFile: (p && p.keyFile) || undefined,
162
+ };
163
+ }
164
+ process.stdout.write(JSON.stringify(safe));
165
+ } catch {
166
+ process.exit(1);
167
+ }
168
+ PAI_YAML_PROVIDERS
169
+ )
170
+ if [ -s "${_providers_cache}.tmp" ]; then
171
+ mv "${_providers_cache}.tmp" "$_providers_cache"
172
+ else
173
+ rm -f "${_providers_cache}.tmp"
174
+ fi
175
+ fi
176
+ [ -s "$_providers_cache" ] && providers_file="$_providers_cache"
177
+ fi
178
+ if [ -z "$providers_file" ] && [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
179
+ _providers_legacy="${pai_cache_dir}/statusline-providers-legacy.json"
180
+ mkdir -p "$pai_cache_dir" 2>/dev/null
181
+ # Same allow-list and 0600-from-birth handling as the YAML path above:
182
+ # this legacy config can carry the same inline `key:` secrets.
183
+ if (umask 077; jq -c '(.workers.providers // {}) | with_entries(.value |= {models, usage, keyFile})' "$pai_config" > "${_providers_legacy}.tmp" 2>/dev/null) \
184
+ && [ -s "${_providers_legacy}.tmp" ]; then
185
+ mv "${_providers_legacy}.tmp" "$_providers_legacy"
186
+ providers_file="$_providers_legacy"
187
+ else
188
+ rm -f "${_providers_legacy}.tmp"
189
+ fi
190
+ fi
191
+
117
192
  model_base="${model_id%%\[*}"
118
193
  session_provider=anthropic
119
- if [ -n "$model_base" ] && [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
194
+ if [ -n "$model_base" ] && [ -n "$providers_file" ] && command -v jq >/dev/null 2>&1; then
120
195
  _matched_provider=$(jq -r --arg mb "$model_base" '
121
- (.workers.providers // {}) | to_entries[] as $p
196
+ (. // {}) | to_entries[] as $p
122
197
  | (($p.value.models // {}) | to_entries[]) as $m
123
198
  | ($m.value | tostring) as $v
124
199
  | select($v == $mb or ($v | split("[")[0]) == $mb)
125
- | $p.key' "$pai_config" 2>/dev/null | head -1)
200
+ | $p.key' "$providers_file" 2>/dev/null | head -1)
126
201
  [ -n "$_matched_provider" ] && session_provider="$_matched_provider"
127
202
  fi
128
203
 
@@ -624,8 +699,12 @@ if [ "$session_provider" = "anthropic" ]; then
624
699
  remaining_secs=$((seven_reset_epoch - now_epoch))
625
700
  [ "$remaining_secs" -lt 0 ] && remaining_secs=0
626
701
  elapsed_secs=$((window_secs - remaining_secs))
627
- # Daily pace: actual spend/day vs dynamic budget
628
- # Budget = remaining capacity / remaining days (not static 100/7)
702
+ # Daily pace: actual spend/day vs the flat weekly share.
703
+ # Budget = static 100/7 (not remaining capacity / remaining days): the
704
+ # user reads "1d" as "what I may spend today", a fixed share of the
705
+ # week, not a figure that shrinks as the week is used — dividing the
706
+ # shrinking remainder by the shrinking remaining days double-counted
707
+ # overspend and drove the pace red for no new reason.
629
708
  elapsed_days_x10=$((elapsed_secs * 10 / 86400))
630
709
  # A per-day rate divided by a fraction of a day is not a rate, it is an
631
710
  # extrapolation from noise: an hour into a fresh window the old clamp to
@@ -633,10 +712,7 @@ if [ "$session_provider" = "anthropic" ]; then
633
712
  # Below half a day there is nothing to pace against yet — say nothing.
634
713
  if [ "$elapsed_days_x10" -ge 5 ]; then
635
714
  spend_per_day=$((seven_day_int * 10 / elapsed_days_x10))
636
- remaining_days_x10=$((remaining_secs * 10 / 86400))
637
- [ "$remaining_days_x10" -lt 1 ] && remaining_days_x10=1
638
- remaining_budget=$((100 - seven_day_int))
639
- budget_per_day=$((remaining_budget * 10 / remaining_days_x10))
715
+ budget_per_day=$((100 / 7))
640
716
  # Color: green = under budget, orange = near budget, red = over budget
641
717
  overspend=$((spend_per_day - budget_per_day))
642
718
  if [ "$overspend" -le -3 ] 2>/dev/null; then
@@ -738,9 +814,9 @@ _fetch_provider_usage() {
738
814
  if [ "$session_provider" != "anthropic" ]; then
739
815
  provider_usage=""
740
816
  provider_keyfile=""
741
- if [ -f "$pai_config" ] && command -v jq >/dev/null 2>&1; then
742
- provider_usage=$(jq -c --arg p "$session_provider" '.workers.providers[$p].usage // empty' "$pai_config" 2>/dev/null)
743
- provider_keyfile=$(jq -r --arg p "$session_provider" '.workers.providers[$p].keyFile // empty' "$pai_config" 2>/dev/null)
817
+ if [ -n "$providers_file" ] && command -v jq >/dev/null 2>&1; then
818
+ provider_usage=$(jq -c --arg p "$session_provider" '.[$p].usage // empty' "$providers_file" 2>/dev/null)
819
+ provider_keyfile=$(jq -r --arg p "$session_provider" '.[$p].keyFile // empty' "$providers_file" 2>/dev/null)
744
820
  case "$provider_keyfile" in "~/"*) provider_keyfile="$HOME/${provider_keyfile#\~/}" ;; esac
745
821
  fi
746
822
 
@@ -828,10 +904,21 @@ if [ "$session_provider" != "anthropic" ]; then
828
904
  fi
829
905
 
830
906
  # LINE 3 - Context meter + usage limits
831
- # Auto-compact remaining: how much context left until compaction triggers
907
+ # Auto-compact remaining: how much context left until compaction triggers.
908
+ # Claude Code fires the auto-compact hook at pct/100 * (window - 20000)
909
+ # tokens, not at pct% of the raw window — the first 20k tokens of any window
910
+ # are reserved and never count toward the trigger. Computing "% left" against
911
+ # the raw window (as a plain threshold-context_pct subtraction did) reads as
912
+ # "the window is full" on a 1M-token session that still has 80% of its real
913
+ # capacity free. Work in tokens, then re-express as a percent of the real
914
+ # window so the figure is comparable across window sizes.
832
915
  ac_threshold="${CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:-80}"
833
- ac_remaining=$((ac_threshold - context_pct))
834
- [ "$ac_remaining" -lt 0 ] && ac_remaining=0
916
+ ac_trigger_tokens=$((ac_threshold * (context_size - 20000) / 100))
917
+ ac_used_tokens=$((context_pct * context_size / 100))
918
+ ac_left_tokens=$((ac_trigger_tokens - ac_used_tokens))
919
+ [ "$ac_left_tokens" -lt 0 ] && ac_left_tokens=0
920
+ ac_remaining=$((ac_left_tokens * 100 / context_size))
921
+ ac_left_k=$((ac_left_tokens / 1000))
835
922
  # Color the remaining %: red ≤5, yellow ≤15, green otherwise
836
923
  if [ "$ac_remaining" -le 5 ] 2>/dev/null; then
837
924
  ac_color="$BRIGHT_RED"
@@ -840,7 +927,7 @@ elif [ "$ac_remaining" -le 15 ] 2>/dev/null; then
840
927
  else
841
928
  ac_color="$BRIGHT_GREEN"
842
929
  fi
843
- ac_suffix=" ${ac_color}(${ac_remaining}%% left)${RESET}"
930
+ ac_suffix=" ${ac_color}(${ac_remaining}%% left, ${ac_left_k}K)${RESET}"
844
931
 
845
932
  if [ "$context_pct" -gt 0 ] 2>/dev/null; then
846
933
  # Color based on usage: green < 50%, yellow 50-75%, red > 75%
@@ -1,228 +0,0 @@
1
- import { a as paiHomePath, o as resolvePaiFile, r as migratePaiFile } from "./pai-home-ChAOupZc.mjs";
2
- import { i as paiSocketPath } from "./runtime-paths-B0P1TvUr.mjs";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { homedir, userInfo } from "node:os";
5
- import { dirname, join } from "node:path";
6
-
7
- //#region src/notifications/types.ts
8
- const DEFAULT_ROUTING = {
9
- error: [
10
- "whatsapp",
11
- "macos",
12
- "ntfy",
13
- "cli"
14
- ],
15
- completion: [
16
- "whatsapp",
17
- "macos",
18
- "ntfy",
19
- "cli"
20
- ],
21
- info: ["cli"],
22
- progress: ["cli"],
23
- debug: []
24
- };
25
- const DEFAULT_CHANNELS = {
26
- ntfy: {
27
- enabled: false,
28
- url: void 0,
29
- priority: "default"
30
- },
31
- whatsapp: {
32
- enabled: true,
33
- recipient: void 0
34
- },
35
- macos: { enabled: true },
36
- voice: {
37
- enabled: false,
38
- voiceName: "bm_george"
39
- },
40
- cli: { enabled: true }
41
- };
42
- const DEFAULT_NOTIFICATION_CONFIG = {
43
- mode: "auto",
44
- channels: DEFAULT_CHANNELS,
45
- routing: DEFAULT_ROUTING
46
- };
47
-
48
- //#endregion
49
- //#region src/tasks/types.ts
50
- /**
51
- * Prefix marking a tracker label as a PAI ownership assertion.
52
- * A task labelled `pai:acme-api` is owned by the `acme-api` project.
53
- */
54
- const OWNER_LABEL_PREFIX = "pai:";
55
- /** An unresolved owner. UNROUTED is a normal state, not an error. */
56
- const UNROUTED = {
57
- project: null,
58
- rootPath: null,
59
- source: "none"
60
- };
61
- const DEFAULT_TASK_CONFIG = {
62
- enabled: false,
63
- providers: { todoist: { enabled: false } },
64
- autoDispatch: false
65
- };
66
-
67
- //#endregion
68
- //#region src/daemon/config.ts
69
- /**
70
- * config.ts — Configuration loader for PAI Daemon
71
- *
72
- * Loads config from ~/.claude/pai/config.json (the pre-2026-09-19 location
73
- * was ~/.config/pai/config.json, briefly ~/.claude/pai.json in between —
74
- * see paiConfigFilePath/migrateConfigFile).
75
- * Deep-merges with defaults so partial configs work fine.
76
- * Expands ~ in path values at runtime.
77
- */
78
- /** Derive a per-user Postgres database name: pai_<username> */
79
- function perUserDbName() {
80
- return `pai_${userInfo().username.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase()}`;
81
- }
82
- /** Derive the per-user connection string */
83
- function perUserConnectionString() {
84
- return `postgresql://pai:pai@localhost:5432/${perUserDbName()}`;
85
- }
86
- const DEFAULTS = {
87
- socketPath: paiSocketPath(),
88
- indexIntervalSecs: 300,
89
- embedIntervalSecs: 600,
90
- embedOnStartup: false,
91
- storageBackend: "sqlite",
92
- postgres: {
93
- connectionString: perUserConnectionString(),
94
- maxConnections: 5,
95
- connectionTimeoutMs: 5e3
96
- },
97
- embeddingModel: "Snowflake/snowflake-arctic-embed-m-v1.5",
98
- logLevel: "info",
99
- notifications: DEFAULT_NOTIFICATION_CONFIG,
100
- tasks: DEFAULT_TASK_CONFIG,
101
- identity: { selfEmails: [] },
102
- search: {
103
- mode: "keyword",
104
- rerank: true,
105
- recencyBoostDays: 90,
106
- defaultLimit: 10,
107
- snippetLength: 200
108
- }
109
- };
110
- /** Config template — generated at runtime so the DB name is per-user */
111
- function configTemplate() {
112
- return `{
113
- "socketPath": "/tmp/pai.sock",
114
- "indexIntervalSecs": 300,
115
- "embedIntervalSecs": 600,
116
- "storageBackend": "sqlite",
117
- "postgres": {
118
- "connectionString": "${perUserConnectionString()}",
119
- "maxConnections": 5,
120
- "connectionTimeoutMs": 5000
121
- },
122
- "embeddingModel": "Snowflake/snowflake-arctic-embed-m-v1.5",
123
- "logLevel": "info",
124
- "vaultPath": "",
125
- "vaultProjectId": 0,
126
- "search": {
127
- "mode": "keyword",
128
- "rerank": true,
129
- "recencyBoostDays": 90,
130
- "defaultLimit": 10,
131
- "snippetLength": 200
132
- }
133
- }
134
- `;
135
- }
136
- /** Expand a leading ~ to the real home directory */
137
- function expandHome(p) {
138
- if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(1));
139
- return p;
140
- }
141
- /** Canonical location since 2026-09-19: under the PAI_HOME namespace dir,
142
- * so nothing PAI writes can collide with a file Claude Code itself owns. */
143
- const NEW_CONFIG_FILE = paiHomePath("config.json");
144
- /** Briefly the canonical location between 2026-09-19's two migrations —
145
- * read during the transition, never written to again. */
146
- const OLD_CONFIG_FILE = join(homedir(), ".claude", "pai.json");
147
- /** Where the config lived before 2026-09-19 — read during the transition,
148
- * never written to once NEW_CONFIG_FILE exists (see `pai config migrate`). */
149
- const LEGACY_CONFIG_FILE = join(homedir(), ".config", "pai", "config.json");
150
- /**
151
- * The path any read/write of the PAI config actually uses: PAI_CONFIG_FILE
152
- * (tests, power users) first, else the new PAI_HOME location if it exists,
153
- * else the most recent old location that is actually on disk (printing a
154
- * one-time notice), else the new location (the target a first write creates).
155
- */
156
- function paiConfigFilePath() {
157
- const override = process.env.PAI_CONFIG_FILE;
158
- if (override) return override;
159
- return resolvePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], "pai config migrate");
160
- }
161
- const CONFIG_FILE = paiConfigFilePath();
162
- const CONFIG_DIR = dirname(CONFIG_FILE);
163
- /**
164
- * `pai config migrate`: move config.json (from ~/.claude/pai.json or
165
- * ~/.config/pai/config.json, whichever is found) to ~/.claude/pai/config.json
166
- * byte-for-byte, verify the copy, then rename the old file aside as
167
- * config.json.migrated-<YYYYMMDD> (never deleted).
168
- */
169
- function migrateConfigFile(opts = {}) {
170
- return migratePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], opts);
171
- }
172
- function deepMerge(target, source) {
173
- const result = { ...target };
174
- for (const key of Object.keys(source)) {
175
- const srcVal = source[key];
176
- if (srcVal === void 0 || srcVal === null) continue;
177
- const tgtVal = target[key];
178
- if (typeof srcVal === "object" && !Array.isArray(srcVal) && typeof tgtVal === "object" && tgtVal !== null && !Array.isArray(tgtVal)) result[key] = deepMerge(tgtVal, srcVal);
179
- else result[key] = srcVal;
180
- }
181
- return result;
182
- }
183
- /**
184
- * Load configuration from CONFIG_FILE (see paiConfigFilePath).
185
- * Returns defaults merged with any values found in the file.
186
- */
187
- function loadConfig() {
188
- if (!existsSync(CONFIG_FILE)) return { ...DEFAULTS };
189
- let raw;
190
- try {
191
- raw = readFileSync(CONFIG_FILE, "utf-8");
192
- } catch (e) {
193
- process.stderr.write(`[pai-daemon] Could not read config file at ${CONFIG_FILE}: ${e}\n`);
194
- return { ...DEFAULTS };
195
- }
196
- let parsed;
197
- try {
198
- parsed = JSON.parse(raw);
199
- } catch (e) {
200
- process.stderr.write(`[pai-daemon] Config file is not valid JSON: ${e}\n`);
201
- return { ...DEFAULTS };
202
- }
203
- if (parsed.obsidianVaultPath && !parsed.vaultPath) {
204
- parsed.vaultPath = parsed.obsidianVaultPath;
205
- process.stderr.write(`[pai-daemon] Config: mapped obsidianVaultPath → vaultPath (${parsed.vaultPath})\n`);
206
- }
207
- return deepMerge(DEFAULTS, parsed);
208
- }
209
- /**
210
- * Ensure CONFIG_DIR exists and write a default config template to CONFIG_FILE
211
- * if none exists yet. Call this only from the `serve` command.
212
- */
213
- function ensureConfigDir() {
214
- if (!existsSync(CONFIG_DIR)) {
215
- mkdirSync(CONFIG_DIR, { recursive: true });
216
- process.stderr.write(`[pai-daemon] Created config directory: ${CONFIG_DIR}\n`);
217
- }
218
- if (!existsSync(CONFIG_FILE)) try {
219
- writeFileSync(CONFIG_FILE, configTemplate(), "utf-8");
220
- process.stderr.write(`[pai-daemon] Wrote default config to: ${CONFIG_FILE}\n`);
221
- } catch (e) {
222
- process.stderr.write(`[pai-daemon] Could not write default config: ${e}\n`);
223
- }
224
- }
225
-
226
- //#endregion
227
- export { expandHome as a, paiConfigFilePath as c, DEFAULT_NOTIFICATION_CONFIG as d, ensureConfigDir as i, OWNER_LABEL_PREFIX as l, CONFIG_FILE as n, loadConfig as o, DEFAULTS as r, migrateConfigFile as s, CONFIG_DIR as t, UNROUTED as u };
228
- //# sourceMappingURL=config-DCu8THKi.mjs.map