baychat 0.24.0 → 0.25.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.
@@ -0,0 +1,307 @@
1
+ "use strict";
2
+ // How full is an attached agent's context — measured on this machine, reported to BayChat.
3
+ //
4
+ // Three sources feed this module, and each is the runtime's OWN number, never a guess:
5
+ //
6
+ // • Claude Code — the status-line JSON (`context_window.*`) and the PreCompact/PostCompact
7
+ // hooks, read by `claude-context.ts` in a process of its own.
8
+ // • Codex — the `token_count` events in the session's rollout JSONL, tailed by the relay
9
+ // (`relay/codex-context.ts`).
10
+ // • ACP agents (dsh, Cursor) — the standard `session/update` → `usage_update { used, size }`,
11
+ // which the relay used to throw away (`relay/acp/client.ts`).
12
+ //
13
+ // All three become ONE body (contract §7) and go out through ONE function. What this module
14
+ // guarantees, and what every caller relies on:
15
+ //
16
+ // 1. IT NEVER THROWS INTO A CALLER. A context report is a nicety riding beside the relay's
17
+ // real job (waking sessions); a failure here must never cost a delivery. Failures are
18
+ // LOGGED — never swallowed — and repeated failures are logged once, not once per sample.
19
+ // 2. IT IS CHEAP. "usage" is throttled: at most one every 15 s per session, sooner only when
20
+ // the fill moved by a whole percentage point. Compaction events are never throttled.
21
+ // 3. IT IS HARMLESS AGAINST A SERVER THAT DOES NOT HAVE THE ROUTE YET. A 404 with no error
22
+ // `code` is Express's "no such route" — phase 2 not deployed — and the reporter goes quiet
23
+ // for an hour instead of sending a doomed request every 15 seconds. A 404 WITH a code
24
+ // (`SESSION_NOT_FOUND`) is a real refusal about one session and backs off only that one.
25
+ //
26
+ // THE CREDENTIAL. The relay holds a DEVICE credential (`bay_u_…`); a session agent has no token
27
+ // of its own (REMOTE_MCP_SESSIONS.md: "An agent with a sessionKey has no apiToken"). So a device
28
+ // posts to the device surface and names the session, exactly as `/approvals` and the relay's
29
+ // command replies do; an agent token (a standing agent) posts to the agent surface as itself.
30
+ // Recorded in the contract's CHANGES, 2026-09-26.
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.ContextReporter = exports.SESSION_REFUSED_BACKOFF_MS = exports.NOT_DEPLOYED_BACKOFF_MS = exports.USAGE_MIN_STEP_PERCENT = exports.USAGE_MIN_INTERVAL_MS = void 0;
33
+ exports.usageDue = usageDue;
34
+ exports.claudeUsageFromStatus = claudeUsageFromStatus;
35
+ exports.claudeAutoCompactAt = claudeAutoCompactAt;
36
+ exports.codexUsageFromTokenCount = codexUsageFromTokenCount;
37
+ exports.codexAutoCompactAt = codexAutoCompactAt;
38
+ exports.acpUsageFromUpdate = acpUsageFromUpdate;
39
+ exports.isDeviceCredential = isDeviceCredential;
40
+ exports.postContextEvent = postContextEvent;
41
+ const api_1 = require("./api");
42
+ // ─── Throttle ────────────────────────────────────────────────────────────────
43
+ exports.USAGE_MIN_INTERVAL_MS = 15_000;
44
+ exports.USAGE_MIN_STEP_PERCENT = 1;
45
+ /** How long a server without the route is left alone. */
46
+ exports.NOT_DEPLOYED_BACKOFF_MS = 60 * 60_000;
47
+ /** How long one session the server refused (ended, not live) is left alone. */
48
+ exports.SESSION_REFUSED_BACKOFF_MS = 5 * 60_000;
49
+ /**
50
+ * Is this usage sample worth a request?
51
+ *
52
+ * Yes when nothing was sent yet, when the window itself changed (a model switch), or when the
53
+ * fill moved by a whole percentage point. Otherwise yes only once 15 s have passed AND the
54
+ * number actually moved — a session sitting idle at 43 % sends nothing at all.
55
+ */
56
+ function usageDue(prev, next, now) {
57
+ if (!prev)
58
+ return true;
59
+ if (prev.size !== next.size || prev.autoCompactAt !== next.autoCompactAt)
60
+ return true;
61
+ const step = next.size > 0 ? (Math.abs(next.used - prev.used) / next.size) * 100 : 0;
62
+ if (step >= exports.USAGE_MIN_STEP_PERCENT)
63
+ return true;
64
+ return next.used !== prev.used && now - prev.at >= exports.USAGE_MIN_INTERVAL_MS;
65
+ }
66
+ // ─── Normalisers: one per source ─────────────────────────────────────────────
67
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
68
+ const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : undefined;
69
+ /**
70
+ * Claude Code's status-line JSON → a sample, or null when it carries none yet.
71
+ *
72
+ * `current_usage` is the exact per-call count and is preferred; `used` is input + cache writes +
73
+ * cache reads — the same input-only formula Claude Code's own `used_percentage` uses (docs:
74
+ * "It does not include output_tokens"). It is null before the first API call and again right
75
+ * after `/compact`, and then `used_percentage` is the fallback. Neither → null, never a zero:
76
+ * "no number yet" and "empty" are different claims.
77
+ */
78
+ function claudeUsageFromStatus(status) {
79
+ if (!isRecord(status) || !isRecord(status.context_window))
80
+ return null;
81
+ const cw = status.context_window;
82
+ const size = count(cw.context_window_size);
83
+ if (!size)
84
+ return null;
85
+ const cu = cw.current_usage;
86
+ if (isRecord(cu)) {
87
+ const parts = [cu.input_tokens, cu.cache_creation_input_tokens, cu.cache_read_input_tokens].map(count);
88
+ if (parts.every((p) => p !== undefined))
89
+ return { used: parts.reduce((a, b) => a + b, 0), size };
90
+ }
91
+ const pct = typeof cw.used_percentage === "number" && Number.isFinite(cw.used_percentage) ? cw.used_percentage : undefined;
92
+ if (pct === undefined)
93
+ return null;
94
+ return { used: Math.round((Math.max(0, pct) / 100) * size), size };
95
+ }
96
+ /**
97
+ * Where Claude Code will compact on its own.
98
+ *
99
+ * Documented (model-config, "Default auto-compact thresholds"): an explicit window —
100
+ * `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, else the `autoCompactWindow` setting — capped at the model's
101
+ * window; otherwise about 967K on a native 1M window and the window itself below that.
102
+ */
103
+ function claudeAutoCompactAt(size, override) {
104
+ if (override && override > 0)
105
+ return Math.min(Math.round(override), size);
106
+ return size >= 1_000_000 ? 967_000 : size;
107
+ }
108
+ /**
109
+ * A Codex rollout `event_msg` whose payload is `token_count` → a sample.
110
+ *
111
+ * `info.last_token_usage` is the LAST request — what is in the window now. `total_token_usage`
112
+ * is cumulative across the whole thread (13M on a real rollout against a 258K window) and must
113
+ * never be used; neither must `codex exec --json`'s totals, which are cumulative too.
114
+ */
115
+ function codexUsageFromTokenCount(payload) {
116
+ if (!isRecord(payload) || payload.type !== "token_count" || !isRecord(payload.info))
117
+ return null;
118
+ const last = payload.info.last_token_usage;
119
+ const size = count(payload.info.model_context_window);
120
+ if (!isRecord(last) || !size)
121
+ return null;
122
+ const total = count(last.total_tokens);
123
+ const used = total ?? (count(last.input_tokens) ?? 0) + (count(last.output_tokens) ?? 0);
124
+ return { used, size };
125
+ }
126
+ /** Codex compacts on its own at about 90 % of the window (contract §7). */
127
+ function codexAutoCompactAt(size) {
128
+ return Math.floor(size * 0.9);
129
+ }
130
+ /** ACP `usage_update { used, size }` → a sample. */
131
+ function acpUsageFromUpdate(update) {
132
+ if (!isRecord(update))
133
+ return null;
134
+ const used = count(update.used);
135
+ const size = count(update.size);
136
+ return used !== undefined && size ? { used, size } : null;
137
+ }
138
+ /** A device credential names the session; an agent token IS the agent. */
139
+ function isDeviceCredential(auth) {
140
+ return auth.token.startsWith("bay_u_");
141
+ }
142
+ /**
143
+ * POST one event. NEVER THROWS — every failure is an outcome.
144
+ *
145
+ * `session` is required on the device route (a device owns several sessions and the server must
146
+ * never pick one for us); on the agent route it is not sent at all.
147
+ */
148
+ async function postContextEvent(auth, session, event, request = api_1.apiRequest) {
149
+ try {
150
+ if (isDeviceCredential(auth)) {
151
+ if (!session)
152
+ return { kind: "refused", status: 0, message: "no session named for a device report" };
153
+ await request(auth, "POST", "/api/device-api/context", { session, ...event });
154
+ }
155
+ else {
156
+ await request(auth, "POST", "/api/agent-api/context", event);
157
+ }
158
+ return { kind: "sent" };
159
+ }
160
+ catch (err) {
161
+ if (err instanceof api_1.ApiError) {
162
+ // Express's own 404 carries no JSON envelope, so no code: the route does not exist here.
163
+ if (err.status === 404 && !err.code)
164
+ return { kind: "not-deployed" };
165
+ if (err.status >= 400 && err.status < 500 && err.status !== 429) {
166
+ return { kind: "refused", status: err.status, code: err.code, message: err.message };
167
+ }
168
+ }
169
+ return { kind: "failed", message: err instanceof Error ? err.message : String(err) };
170
+ }
171
+ }
172
+ /**
173
+ * Throttles, sends and backs off, per session, in the relay's process.
174
+ *
175
+ * `report` returns immediately and never throws; `idle()` resolves when everything queued has
176
+ * been attempted (tests, and a clean shutdown).
177
+ */
178
+ class ContextReporter {
179
+ deps;
180
+ sessions = new Map();
181
+ notDeployedUntil = 0;
182
+ closed = false;
183
+ constructor(deps) {
184
+ this.deps = deps;
185
+ }
186
+ now() {
187
+ return (this.deps.now ?? Date.now)();
188
+ }
189
+ state(session) {
190
+ let s = this.sessions.get(session);
191
+ if (!s) {
192
+ s = { chain: Promise.resolve() };
193
+ this.sessions.set(session, s);
194
+ }
195
+ return s;
196
+ }
197
+ report(session, event) {
198
+ try {
199
+ if (this.closed)
200
+ return;
201
+ const s = this.state(session);
202
+ if (event.event === "usage") {
203
+ if (event.used === undefined || event.size === undefined)
204
+ return;
205
+ const sample = { used: event.used, size: event.size, autoCompactAt: event.autoCompactAt };
206
+ if (!usageDue(s.sent, sample, this.now())) {
207
+ this.holdTrailing(session, s, event);
208
+ return;
209
+ }
210
+ s.timer?.cancel();
211
+ s.timer = undefined;
212
+ s.trailing = undefined;
213
+ // Counted as sent when it is QUEUED, so a burst of samples cannot all pass the throttle
214
+ // while the first request is still in flight.
215
+ s.sent = { ...sample, at: this.now() };
216
+ }
217
+ else {
218
+ // A compaction resets the baseline: the next usage is the "after" number and must go.
219
+ s.sent = undefined;
220
+ s.trailing = undefined;
221
+ s.timer?.cancel();
222
+ s.timer = undefined;
223
+ }
224
+ this.enqueue(session, s, event);
225
+ }
226
+ catch (err) {
227
+ this.deps.log(`context: could not queue a report for ${session}: ${errText(err)}`);
228
+ }
229
+ }
230
+ /** Resolves once every queued send has been attempted. */
231
+ async idle() {
232
+ await Promise.all([...this.sessions.values()].map((s) => s.chain));
233
+ }
234
+ close() {
235
+ this.closed = true;
236
+ for (const s of this.sessions.values())
237
+ s.timer?.cancel();
238
+ }
239
+ holdTrailing(session, s, event) {
240
+ s.trailing = event;
241
+ if (s.timer || !s.sent)
242
+ return;
243
+ const wait = Math.max(0, s.sent.at + exports.USAGE_MIN_INTERVAL_MS - this.now());
244
+ const setTimer = this.deps.setTimer ??
245
+ ((fn, ms) => {
246
+ const t = setTimeout(fn, ms);
247
+ t.unref?.();
248
+ return { cancel: () => clearTimeout(t) };
249
+ });
250
+ s.timer = setTimer(() => {
251
+ s.timer = undefined;
252
+ const held = s.trailing;
253
+ s.trailing = undefined;
254
+ // Re-run through `report` so the throttle is the one rule, not a second copy of it.
255
+ if (held)
256
+ this.report(session, held);
257
+ }, wait);
258
+ }
259
+ enqueue(session, s, event) {
260
+ s.chain = s.chain.then(() => this.send(session, s, event)).catch((err) => {
261
+ this.deps.log(`context: report for ${session} failed unexpectedly: ${errText(err)}`);
262
+ });
263
+ }
264
+ async send(session, s, event) {
265
+ const now = this.now();
266
+ if (now < this.notDeployedUntil)
267
+ return;
268
+ if (s.refusedUntil && now < s.refusedUntil)
269
+ return;
270
+ const auth = this.deps.auth();
271
+ if (!auth)
272
+ return;
273
+ const outcome = await postContextEvent(auth, session, event, this.deps.request ?? api_1.apiRequest);
274
+ switch (outcome.kind) {
275
+ case "sent":
276
+ if (s.lastFailure)
277
+ this.deps.log(`context: reports for ${session} are going through again`);
278
+ s.lastFailure = undefined;
279
+ return;
280
+ case "not-deployed":
281
+ this.notDeployedUntil = this.now() + exports.NOT_DEPLOYED_BACKOFF_MS;
282
+ this.deps.log("context: this BayChat server does not accept context reports yet — trying again in an hour");
283
+ return;
284
+ case "refused": {
285
+ s.refusedUntil = this.now() + exports.SESSION_REFUSED_BACKOFF_MS;
286
+ // The sample that was refused was never recorded by the server; the next one must go.
287
+ s.sent = undefined;
288
+ this.logOnce(s, `context: BayChat refused a report for ${session} (${outcome.message}${outcome.code ? `, ${outcome.code}` : ""}) — pausing that session's reports for 5 minutes`);
289
+ return;
290
+ }
291
+ case "failed":
292
+ s.sent = undefined;
293
+ this.logOnce(s, `context: could not report for ${session} (${outcome.message}) — will retry with the next change`);
294
+ return;
295
+ }
296
+ }
297
+ logOnce(s, line) {
298
+ if (s.lastFailure === line)
299
+ return;
300
+ s.lastFailure = line;
301
+ this.deps.log(line);
302
+ }
303
+ }
304
+ exports.ContextReporter = ContextReporter;
305
+ function errText(err) {
306
+ return err instanceof Error ? err.message : String(err);
307
+ }
@@ -20,7 +20,7 @@
20
20
  * only one of the two places.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.HELP_TOPICS = exports.UPDATE_HELP = exports.UPDATE_COMMAND = exports.ROOMS_TOPIC = void 0;
23
+ exports.HELP_TOPICS = exports.ROOMS_TOPIC = void 0;
24
24
  exports.findTopic = findTopic;
25
25
  exports.topicIndex = topicIndex;
26
26
  /**
@@ -52,16 +52,6 @@ Never create on ambiguity, denied access, authentication or network errors.
52
52
  Never invent a title or substitute a similar group. If creation returns
53
53
  GROUP_TITLE_TAKEN, retry joining that exact title once; if still ambiguous or
54
54
  unavailable, report it and stop. Do not loop or create a second name.`;
55
- /**
56
- * How to update — the command the owner kept having to look up.
57
- *
58
- * Kept here, once, and spliced into the top of `baychat help` and into the `/help` reply a
59
- * session sends back in chat, so the two can never name different commands.
60
- * `baychat update` (update-command.ts) already does every step: the package, each
61
- * connected client's config, and a relay restart onto the new code.
62
- */
63
- exports.UPDATE_COMMAND = "baychat update";
64
- exports.UPDATE_HELP = `Update: ${exports.UPDATE_COMMAND} — then check with baychat --version`;
65
55
  const GROUPS = {
66
56
  name: "groups",
67
57
  summary: "Make a group, or find the ones you are already in",
@@ -176,18 +166,7 @@ cards. It is NOT on by default and moves the last line between an agent and your
176
166
  machine onto a phone — read docs/features/REMOTE_APPROVAL_HOOK.md before enabling
177
167
  it. It fails CLOSED: every error denies.`,
178
168
  };
179
- const UPDATE = {
180
- name: "update",
181
- summary: "Update the CLI and restart the relay onto the new version",
182
- keywords: ["update", "upgrade", "version", "latest", "new version"],
183
- body: `${exports.UPDATE_HELP}
184
-
185
- \`${exports.UPDATE_COMMAND}\` upgrades the package (for a global install), refreshes every
186
- connected client's config, and restarts the relay so it runs the new code.
187
- Running through npx? Use \`npx baychat@latest update\` — plain npx can run a cached
188
- older copy.`,
189
- };
190
- exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS, UPDATE];
169
+ exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
191
170
  /** Exact name first, then keyword, then a substring of the body. */
192
171
  function findTopic(query) {
193
172
  const q = query.trim().toLowerCase();