baychat 0.23.1 → 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.
- package/README.md +120 -15
- package/dist/api.js +19 -2
- package/dist/claude-context-flush.js +219 -0
- package/dist/claude-context-state.js +191 -0
- package/dist/claude-context.js +311 -0
- package/dist/claude-settings.js +503 -0
- package/dist/codex-onboarding.js +33 -23
- package/dist/context-hooks-command.js +165 -0
- package/dist/context-report.js +307 -0
- package/dist/index.js +26 -497
- package/dist/main.js +511 -0
- package/dist/mcp-notes.js +110 -0
- package/dist/mcp.js +84 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/client.js +18 -0
- package/dist/relay/acp/prompt.js +54 -3
- package/dist/relay/acp/runner.js +100 -2
- package/dist/relay/acp/turn-queue.js +99 -3
- package/dist/relay/codex-context.js +242 -0
- package/dist/relay/compact-command.js +63 -0
- package/dist/relay/compact-focus.attacks.js +36 -0
- package/dist/relay/compact-focus.js +64 -0
- package/dist/relay/context-notice.js +144 -0
- package/dist/relay/daemon.js +429 -73
- package/dist/relay/fresh-start-verify.js +237 -0
- package/dist/relay/fresh-start.js +141 -0
- package/dist/relay/message-format.js +34 -15
- package/dist/relay/project-brief.js +195 -0
- package/dist/relay/project-notes.js +300 -0
- package/dist/relay/resume.js +25 -0
- package/dist/relay/server-registration.js +40 -0
- package/dist/relay/session-commands.js +2 -0
- package/dist/relay/terminal-text.js +13 -0
- package/dist/relay/transport.js +2 -1
- package/dist/relay/updates.js +46 -8
- package/dist/relay/ws.js +49 -6
- package/dist/runtimes.js +24 -6
- package/dist/tool-defs.js +93 -0
- package/package.json +1 -1
package/dist/relay/acp/runner.js
CHANGED
|
@@ -34,18 +34,59 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AcpSessionRunner = void 0;
|
|
37
|
+
exports.acpContextObserver = acpContextObserver;
|
|
37
38
|
exports.effectiveMode = effectiveMode;
|
|
39
|
+
exports.looksLikeContextOverflow = looksLikeContextOverflow;
|
|
38
40
|
const os = __importStar(require("os"));
|
|
39
41
|
const runtime_binary_1 = require("../../runtime-binary");
|
|
40
42
|
const spawn_env_1 = require("../spawn-env");
|
|
43
|
+
const fresh_start_1 = require("../fresh-start");
|
|
41
44
|
const agents_1 = require("./agents");
|
|
42
45
|
const approval_1 = require("./approval");
|
|
46
|
+
const context_report_1 = require("../../context-report");
|
|
43
47
|
const client_1 = require("./client");
|
|
44
48
|
const modes_1 = require("./modes");
|
|
45
49
|
const policy_1 = require("./policy");
|
|
46
50
|
const presence_1 = require("./presence");
|
|
47
51
|
const prompt_1 = require("./prompt");
|
|
48
52
|
const types_1 = require("./types");
|
|
53
|
+
/**
|
|
54
|
+
* Turns one turn's ACP context updates into contract events for the room that woke it.
|
|
55
|
+
*
|
|
56
|
+
* A compaction is reported when it has COMPLETED, with the last number before it as
|
|
57
|
+
* `beforeTokens` and the first number after it as `afterTokens` — or without `afterTokens` when
|
|
58
|
+
* the turn ends first. There is no `compact_start` for ACP: `in_progress` may end in `failed`,
|
|
59
|
+
* and a start with no end would leave the app showing a tidy that never happened.
|
|
60
|
+
*/
|
|
61
|
+
function acpContextObserver(report, conversationId) {
|
|
62
|
+
let lastUsed;
|
|
63
|
+
let afterCompaction;
|
|
64
|
+
const base = { runtime: "acp", source: "exact", conversationId };
|
|
65
|
+
const endCompaction = (after) => {
|
|
66
|
+
if (!afterCompaction)
|
|
67
|
+
return;
|
|
68
|
+
const { before } = afterCompaction;
|
|
69
|
+
afterCompaction = undefined;
|
|
70
|
+
report({ ...base, event: "compact_end", ...(before !== undefined ? { beforeTokens: before } : {}), ...(after !== undefined ? { afterTokens: after } : {}) });
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
onContext(update) {
|
|
74
|
+
if (update.kind === "fresh")
|
|
75
|
+
return report({ ...base, event: "session_start" });
|
|
76
|
+
if (update.kind === "compacted") {
|
|
77
|
+
afterCompaction = { before: lastUsed };
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const sample = (0, context_report_1.acpUsageFromUpdate)(update);
|
|
81
|
+
if (!sample)
|
|
82
|
+
return;
|
|
83
|
+
endCompaction(sample.used);
|
|
84
|
+
lastUsed = sample.used;
|
|
85
|
+
report({ ...base, event: "usage", used: sample.used, size: sample.size });
|
|
86
|
+
},
|
|
87
|
+
end: () => endCompaction(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
49
90
|
/**
|
|
50
91
|
* How much power ONE turn gets.
|
|
51
92
|
*
|
|
@@ -74,6 +115,15 @@ function isBayChatSend(call, mcpServer) {
|
|
|
74
115
|
return true;
|
|
75
116
|
return call.rawInput.providerIdentifier === mcpServer && call.rawInput.toolName === "send_message";
|
|
76
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Does an ACP turn's failure read as "the context window is full"? The wordings of the providers
|
|
120
|
+
* behind dsh and Cursor (OpenAI-style `context_length_exceeded`, Anthropic's "prompt is too long",
|
|
121
|
+
* "maximum context length"), matched loosely: a false positive only offers a fresh start the
|
|
122
|
+
* owner asked for.
|
|
123
|
+
*/
|
|
124
|
+
function looksLikeContextOverflow(reason) {
|
|
125
|
+
return /context[_ ]length[_ ]exceeded|maximum context length|context window (is )?(full|exceeded)|prompt is too long|too many tokens/i.test(reason);
|
|
126
|
+
}
|
|
77
127
|
const RETRY_MAX_MS = 60_000;
|
|
78
128
|
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
79
129
|
/**
|
|
@@ -90,6 +140,10 @@ class AcpSessionRunner {
|
|
|
90
140
|
said = new Map();
|
|
91
141
|
backoff = new Map();
|
|
92
142
|
retryTimers = new Set();
|
|
143
|
+
/** A fresh start's first prompt, run as the session's next turn, alone (`freshStart`). */
|
|
144
|
+
freshTurns = new Map();
|
|
145
|
+
/** Sessions whose running turn a fresh start stopped: its messages wait, they are not lost. */
|
|
146
|
+
requeueOnStop = new Map();
|
|
93
147
|
closed = false;
|
|
94
148
|
constructor(deps) {
|
|
95
149
|
this.deps = deps;
|
|
@@ -110,6 +164,24 @@ class AcpSessionRunner {
|
|
|
110
164
|
reset(session) {
|
|
111
165
|
this.deps.registry.setAcpSession(session, undefined);
|
|
112
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* "Fresh start with a handoff" for an ACP session (fresh-start.ts): forget the ACP session, so
|
|
169
|
+
* the next prompt opens a new one, and make `first` — the note — that prompt, ALONE. A turn
|
|
170
|
+
* running now (the one that wrote the note, or a person's newer one) is stopped and its messages
|
|
171
|
+
* go back in the queue behind the note, never recorded lost — except this fresh start's own
|
|
172
|
+
* note request, which the fresh session must never be asked again.
|
|
173
|
+
*/
|
|
174
|
+
freshStart(session, first, handoffId) {
|
|
175
|
+
this.deps.queue.discard(session, (m) => (0, fresh_start_1.isNoteRequestFor)(m, handoffId));
|
|
176
|
+
this.freshTurns.set(session, first);
|
|
177
|
+
const running = this.running.get(session);
|
|
178
|
+
if (running) {
|
|
179
|
+
this.requeueOnStop.set(session, handoffId);
|
|
180
|
+
running.abort();
|
|
181
|
+
}
|
|
182
|
+
this.reset(session);
|
|
183
|
+
this.kick(session);
|
|
184
|
+
}
|
|
113
185
|
setMode(session, wanted) {
|
|
114
186
|
const target = this.deps.registry.get(session);
|
|
115
187
|
if (!target?.acp)
|
|
@@ -162,7 +234,10 @@ class AcpSessionRunner {
|
|
|
162
234
|
for (;;) {
|
|
163
235
|
if (this.closed)
|
|
164
236
|
return "closed";
|
|
165
|
-
const
|
|
237
|
+
const first = this.freshTurns.get(session);
|
|
238
|
+
if (first)
|
|
239
|
+
this.freshTurns.delete(session);
|
|
240
|
+
const turn = first ? { addressed: [first], digest: { messages: [], omitted: 0 } } : this.deps.queue.take(session);
|
|
166
241
|
if (!turn)
|
|
167
242
|
return "empty";
|
|
168
243
|
let next;
|
|
@@ -256,6 +331,7 @@ class AcpSessionRunner {
|
|
|
256
331
|
return answer.allowed ? "allow" : "reject";
|
|
257
332
|
};
|
|
258
333
|
const caller = (deps.caller ?? presence_1.deviceMcpCaller)(auth);
|
|
334
|
+
const context = acpContextObserver((event) => deps.reportContext?.(session, event), room);
|
|
259
335
|
const abort = new AbortController();
|
|
260
336
|
let typing;
|
|
261
337
|
let stopGuard;
|
|
@@ -287,6 +363,7 @@ class AcpSessionRunner {
|
|
|
287
363
|
typing?.stop();
|
|
288
364
|
},
|
|
289
365
|
classifyError: (m) => row.classifyError(m),
|
|
366
|
+
onContext: context.onContext,
|
|
290
367
|
signal: abort.signal,
|
|
291
368
|
}, { spawn: deps.spawn, log: deps.log, platform });
|
|
292
369
|
}
|
|
@@ -294,6 +371,7 @@ class AcpSessionRunner {
|
|
|
294
371
|
// Every exit — outcome or throw — ends typing and the guard and frees `/stop`, or `/stop` would lie.
|
|
295
372
|
stopGuard?.();
|
|
296
373
|
typing?.stop();
|
|
374
|
+
context.end();
|
|
297
375
|
this.running.delete(session);
|
|
298
376
|
}
|
|
299
377
|
if (breach !== undefined) {
|
|
@@ -306,8 +384,22 @@ class AcpSessionRunner {
|
|
|
306
384
|
return "next";
|
|
307
385
|
}
|
|
308
386
|
}
|
|
387
|
+
// A fresh start stopped this turn: its messages wait behind the note (minus the note request).
|
|
388
|
+
const requeueFor = this.requeueOnStop.get(session);
|
|
389
|
+
if (requeueFor !== undefined) {
|
|
390
|
+
this.requeueOnStop.delete(session);
|
|
391
|
+
const keep = (m) => !(0, fresh_start_1.isNoteRequestFor)(m, requeueFor);
|
|
392
|
+
deps.queue.putBack(session, {
|
|
393
|
+
addressed: turn.addressed.filter(keep),
|
|
394
|
+
digest: { messages: turn.digest.messages.filter(keep), omitted: turn.digest.omitted },
|
|
395
|
+
});
|
|
396
|
+
deps.log(`acp: ${session} turn stopped for a fresh start — its messages wait for the fresh session`);
|
|
397
|
+
return "next";
|
|
398
|
+
}
|
|
309
399
|
if (outcome.kind === "completed") {
|
|
310
|
-
|
|
400
|
+
// A fresh start that landed while this turn ran has already forgotten the old session.
|
|
401
|
+
if (!this.freshTurns.has(session))
|
|
402
|
+
deps.registry.setAcpSession(session, outcome.sessionId);
|
|
311
403
|
deps.onDelivered(session, turn.addressed);
|
|
312
404
|
this.said.delete(session);
|
|
313
405
|
this.backoff.delete(session);
|
|
@@ -336,6 +428,12 @@ class AcpSessionRunner {
|
|
|
336
428
|
const named = reasons[outcome.cause];
|
|
337
429
|
if (named)
|
|
338
430
|
return drop(outcome.cause, named);
|
|
431
|
+
// A turn refused because the context is full: say so to BayChat, which tells the session's
|
|
432
|
+
// owner once (protocol 1.17 `overflow`). It starts nothing: a handoff note needs a turn the
|
|
433
|
+
// full session cannot take, so starting fresh is the owner's call from the memory sheet.
|
|
434
|
+
if (looksLikeContextOverflow(outcome.reason)) {
|
|
435
|
+
deps.reportContext?.(session, { runtime: "acp", source: "exact", event: "overflow", conversationId: room });
|
|
436
|
+
}
|
|
339
437
|
// Unclassified: the raw reason can name paths on that computer, so the room gets a generic
|
|
340
438
|
// line and the full (already redacted) reason goes to the log only (Ruling R29).
|
|
341
439
|
deps.log(`acp: ${session} could not answer (${outcome.cause}) — ${outcome.reason}`);
|
|
@@ -38,6 +38,9 @@ exports.acpQueuePath = acpQueuePath;
|
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const config_1 = require("../../config");
|
|
41
|
+
const project_brief_1 = require("../project-brief");
|
|
42
|
+
const project_notes_1 = require("../project-notes");
|
|
43
|
+
const context_notice_1 = require("../context-notice");
|
|
41
44
|
const DEFAULT_CAPS = { digestMessages: 20, digestChars: 4_000, maxAddressed: 50 };
|
|
42
45
|
function acpQueuePath() {
|
|
43
46
|
return path.join((0, config_1.configDir)(), "acp-queue.json");
|
|
@@ -87,7 +90,17 @@ class AcpTurnQueue {
|
|
|
87
90
|
}
|
|
88
91
|
const rawOmitted = Number(value.omitted);
|
|
89
92
|
const omitted = Number.isFinite(rawOmitted) && rawOmitted > 0 ? Math.floor(rawOmitted) : 0;
|
|
90
|
-
|
|
93
|
+
const carried = value.carried && typeof value.carried === "object" ? value.carried : undefined;
|
|
94
|
+
const carriedNotes = parseCarriedNotes(value.carriedNotes);
|
|
95
|
+
const carriedNotice = (0, context_notice_1.contextNoticeFrom)(value.carriedNotice);
|
|
96
|
+
this.sessions.set(name, {
|
|
97
|
+
addressed,
|
|
98
|
+
digest,
|
|
99
|
+
omitted,
|
|
100
|
+
...(carried ? { carried } : {}),
|
|
101
|
+
...(carriedNotes ? { carriedNotes } : {}),
|
|
102
|
+
...(carriedNotice ? { carriedNotice } : {}),
|
|
103
|
+
});
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
add(session, batch) {
|
|
@@ -95,6 +108,21 @@ class AcpTurnQueue {
|
|
|
95
108
|
let addressed = 0;
|
|
96
109
|
let dropped = 0;
|
|
97
110
|
for (const message of batch) {
|
|
111
|
+
const kept = state.carried?.[message.conversationId];
|
|
112
|
+
if (kept) {
|
|
113
|
+
delete state.carried[message.conversationId];
|
|
114
|
+
if (!message.projectBrief || kept.version >= message.projectBrief.version)
|
|
115
|
+
message.projectBrief = kept;
|
|
116
|
+
}
|
|
117
|
+
const keptNotes = state.carriedNotes?.[message.conversationId];
|
|
118
|
+
if (keptNotes) {
|
|
119
|
+
delete state.carriedNotes[message.conversationId];
|
|
120
|
+
message.projectNotes = message.projectNotes ? (0, project_notes_1.mergeNotesDeltas)(keptNotes, message.projectNotes) : keptNotes;
|
|
121
|
+
}
|
|
122
|
+
if (state.carriedNotice) {
|
|
123
|
+
message.notice = (0, context_notice_1.laterNotice)(state.carriedNotice, message.notice);
|
|
124
|
+
delete state.carriedNotice;
|
|
125
|
+
}
|
|
98
126
|
if (message.shouldRespond === true) {
|
|
99
127
|
if (state.addressed.length >= this.caps.maxAddressed)
|
|
100
128
|
dropped += 1;
|
|
@@ -115,7 +143,14 @@ class AcpTurnQueue {
|
|
|
115
143
|
if (!state || state.addressed.length === 0)
|
|
116
144
|
return undefined;
|
|
117
145
|
const turn = { addressed: state.addressed, digest: { messages: state.digest, omitted: state.omitted } };
|
|
118
|
-
this.sessions.set(session, {
|
|
146
|
+
this.sessions.set(session, {
|
|
147
|
+
addressed: [],
|
|
148
|
+
digest: [],
|
|
149
|
+
omitted: 0,
|
|
150
|
+
...(state.carried ? { carried: state.carried } : {}),
|
|
151
|
+
...(state.carriedNotes ? { carriedNotes: state.carriedNotes } : {}),
|
|
152
|
+
...(state.carriedNotice ? { carriedNotice: state.carriedNotice } : {}),
|
|
153
|
+
});
|
|
119
154
|
this.save();
|
|
120
155
|
return turn;
|
|
121
156
|
}
|
|
@@ -134,6 +169,35 @@ class AcpTurnQueue {
|
|
|
134
169
|
this.sessions.delete(session);
|
|
135
170
|
this.save();
|
|
136
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Forget the briefs, notes deltas and notice kept for this session: it started fresh, so they
|
|
174
|
+
* describe a memory it no longer has (the server re-sends the whole set). Waiting messages stay.
|
|
175
|
+
*/
|
|
176
|
+
forgetCarried(session) {
|
|
177
|
+
const state = this.sessions.get(session);
|
|
178
|
+
if (!state || (!state.carried && !state.carriedNotes && !state.carriedNotice))
|
|
179
|
+
return;
|
|
180
|
+
delete state.carried;
|
|
181
|
+
delete state.carriedNotes;
|
|
182
|
+
delete state.carriedNotice;
|
|
183
|
+
this.save();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Remove waiting messages this predicate picks (a fresh start's own note request, which the
|
|
187
|
+
* fresh session must never be asked again). Returns how many.
|
|
188
|
+
*/
|
|
189
|
+
discard(session, pick) {
|
|
190
|
+
const state = this.sessions.get(session);
|
|
191
|
+
if (!state)
|
|
192
|
+
return 0;
|
|
193
|
+
const before = state.addressed.length + state.digest.length;
|
|
194
|
+
state.addressed = state.addressed.filter((m) => !pick(m));
|
|
195
|
+
state.digest = state.digest.filter((m) => !pick(m));
|
|
196
|
+
const removed = before - state.addressed.length - state.digest.length;
|
|
197
|
+
if (removed > 0)
|
|
198
|
+
this.save();
|
|
199
|
+
return removed;
|
|
200
|
+
}
|
|
137
201
|
state(session) {
|
|
138
202
|
let s = this.sessions.get(session);
|
|
139
203
|
if (!s)
|
|
@@ -143,8 +207,28 @@ class AcpTurnQueue {
|
|
|
143
207
|
trimDigest(state) {
|
|
144
208
|
const size = () => state.digest.reduce((n, m) => n + m.content.length, 0);
|
|
145
209
|
while (state.digest.length > this.caps.digestMessages || (state.digest.length > 1 && size() > this.caps.digestChars)) {
|
|
146
|
-
state.digest.shift();
|
|
210
|
+
const dropped = state.digest.shift();
|
|
147
211
|
state.omitted += 1;
|
|
212
|
+
// Its project brief is not dropped with it: the next message for that room carries it.
|
|
213
|
+
if (dropped.projectBrief && !(0, project_brief_1.carryBrief)(dropped.projectBrief, dropped.conversationId, [...state.digest, ...state.addressed])) {
|
|
214
|
+
const held = state.carried?.[dropped.conversationId];
|
|
215
|
+
if (!held || dropped.projectBrief.version >= held.version) {
|
|
216
|
+
state.carried = { ...(state.carried ?? {}), [dropped.conversationId]: dropped.projectBrief };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// …and neither is its notes delta (project-notes.ts) — carried only FORWARD, onto a message
|
|
220
|
+
// at least as new (an addressed message can be older than the digest's oldest).
|
|
221
|
+
if (dropped.projectNotes && !(0, project_notes_1.carryNotes)(dropped.projectNotes, dropped, [...state.digest, ...state.addressed])) {
|
|
222
|
+
const held = state.carriedNotes?.[dropped.conversationId];
|
|
223
|
+
state.carriedNotes = {
|
|
224
|
+
...(state.carriedNotes ?? {}),
|
|
225
|
+
[dropped.conversationId]: held ? (0, project_notes_1.mergeNotesDeltas)(held, dropped.projectNotes) : dropped.projectNotes,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// …nor BayChat's notice: onto the next message still waiting, else kept for the next one.
|
|
229
|
+
if (dropped.notice && !(0, context_notice_1.carryNotice)(dropped.notice, [...state.digest, ...state.addressed])) {
|
|
230
|
+
state.carriedNotice = (0, context_notice_1.laterNotice)(state.carriedNotice, dropped.notice);
|
|
231
|
+
}
|
|
148
232
|
}
|
|
149
233
|
}
|
|
150
234
|
save() {
|
|
@@ -161,3 +245,15 @@ class AcpTurnQueue {
|
|
|
161
245
|
}
|
|
162
246
|
}
|
|
163
247
|
exports.AcpTurnQueue = AcpTurnQueue;
|
|
248
|
+
/** A persisted `carriedNotes` map, each delta re-parsed (a corrupt entry is dropped). */
|
|
249
|
+
function parseCarriedNotes(raw) {
|
|
250
|
+
if (!raw || typeof raw !== "object")
|
|
251
|
+
return undefined;
|
|
252
|
+
const out = {};
|
|
253
|
+
for (const [conversationId, value] of Object.entries(raw)) {
|
|
254
|
+
const delta = (0, project_notes_1.notesDeltaFrom)(value);
|
|
255
|
+
if (delta)
|
|
256
|
+
out[conversationId] = delta;
|
|
257
|
+
}
|
|
258
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
259
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// How full a Codex session's context is, read from the log Codex writes anyway.
|
|
3
|
+
//
|
|
4
|
+
// Codex records every model call in its rollout (`~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl`)
|
|
5
|
+
// as an `event_msg` whose payload is `token_count`:
|
|
6
|
+
//
|
|
7
|
+
// {"type":"event_msg","payload":{"type":"token_count","info":{
|
|
8
|
+
// "total_token_usage":{…13,242,298…}, ← the whole thread: NEVER used
|
|
9
|
+
// "last_token_usage":{"input_tokens":59244,…,"total_tokens":59346}, ← what is in the window
|
|
10
|
+
// "model_context_window":258400}}}
|
|
11
|
+
//
|
|
12
|
+
// (shape from a real rollout, 2026-09-13), and a compaction as a top-level `{"type":"compacted"}`
|
|
13
|
+
// line. The relay already knows each Codex session's thread id — it is how `codex queue` reaches
|
|
14
|
+
// it — so it tails that thread's newest rollout, a few seconds behind, and reports.
|
|
15
|
+
//
|
|
16
|
+
// READ-ONLY, BOUNDED, AND NEVER IN THE WAY. The file is only ever read; a tick reads at most what
|
|
17
|
+
// was appended since the last one (capped); a missing or unreadable rollout is a quiet "nothing
|
|
18
|
+
// to report", said once in the log. Nothing here can delay or fail a delivery.
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.CodexContextTailer = void 0;
|
|
54
|
+
const fs = __importStar(require("fs"));
|
|
55
|
+
const context_report_1 = require("../context-report");
|
|
56
|
+
const resume_1 = require("./resume");
|
|
57
|
+
/** On first sight of a rollout, how far back to look for the current number. */
|
|
58
|
+
const PRIME_BYTES = 512 * 1024;
|
|
59
|
+
/** Most bytes read in one tick; a burst beyond this is skipped to its last PRIME_BYTES. */
|
|
60
|
+
const MAX_TICK_BYTES = 4 * 1024 * 1024;
|
|
61
|
+
/** How often a session's rollout is looked up again (a resume writes a NEW rollout file). */
|
|
62
|
+
const RESOLVE_EVERY_MS = 2 * 60_000;
|
|
63
|
+
/** A compaction whose "after" number never arrives is still reported, without it. */
|
|
64
|
+
const COMPACTION_AFTER_WAIT_MS = 60_000;
|
|
65
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
66
|
+
class CodexContextTailer {
|
|
67
|
+
deps;
|
|
68
|
+
tails = new Map();
|
|
69
|
+
timer;
|
|
70
|
+
running = false;
|
|
71
|
+
constructor(deps) {
|
|
72
|
+
this.deps = deps;
|
|
73
|
+
}
|
|
74
|
+
now() {
|
|
75
|
+
return (this.deps.now ?? Date.now)();
|
|
76
|
+
}
|
|
77
|
+
start(intervalMs = 5_000) {
|
|
78
|
+
if (this.timer)
|
|
79
|
+
return;
|
|
80
|
+
this.timer = setInterval(() => void this.tick(), intervalMs);
|
|
81
|
+
this.timer.unref?.();
|
|
82
|
+
}
|
|
83
|
+
stop() {
|
|
84
|
+
clearInterval(this.timer);
|
|
85
|
+
this.timer = undefined;
|
|
86
|
+
}
|
|
87
|
+
/** One pass over every Codex session. Never throws; never overlaps itself. */
|
|
88
|
+
async tick() {
|
|
89
|
+
if (this.running)
|
|
90
|
+
return;
|
|
91
|
+
this.running = true;
|
|
92
|
+
try {
|
|
93
|
+
const targets = this.deps.targets();
|
|
94
|
+
const live = new Set(targets.map((t) => t.session));
|
|
95
|
+
for (const session of [...this.tails.keys()])
|
|
96
|
+
if (!live.has(session))
|
|
97
|
+
this.tails.delete(session);
|
|
98
|
+
for (const target of targets) {
|
|
99
|
+
try {
|
|
100
|
+
await this.tickOne(target);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
this.deps.log(`context: could not read ${target.session}'s Codex log: ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
this.running = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
emit(session, event) {
|
|
112
|
+
const conversationId = this.deps.conversationFor?.(session);
|
|
113
|
+
this.deps.report(session, { runtime: "codex", source: "exact", ...(conversationId ? { conversationId } : {}), ...event });
|
|
114
|
+
}
|
|
115
|
+
async tickOne(target) {
|
|
116
|
+
const now = this.now();
|
|
117
|
+
let tail = this.tails.get(target.session);
|
|
118
|
+
if (tail && tail.threadId !== target.threadId) {
|
|
119
|
+
// `/new` (or a fresh join) gave this BayChat session another Codex thread: a new context.
|
|
120
|
+
this.tails.delete(target.session);
|
|
121
|
+
tail = undefined;
|
|
122
|
+
this.emit(target.session, { event: "session_start" });
|
|
123
|
+
}
|
|
124
|
+
if (!tail) {
|
|
125
|
+
tail = { threadId: target.threadId, offset: 0, remainder: "", resolvedAt: 0, primed: false };
|
|
126
|
+
this.tails.set(target.session, tail);
|
|
127
|
+
}
|
|
128
|
+
if (!tail.file || now - tail.resolvedAt >= RESOLVE_EVERY_MS) {
|
|
129
|
+
tail.resolvedAt = now;
|
|
130
|
+
const file = await (this.deps.findRollout ?? defaultFindRollout)(target.threadId);
|
|
131
|
+
if (!file) {
|
|
132
|
+
if (!tail.missingSaid)
|
|
133
|
+
this.deps.log(`context: no Codex log found for ${target.session} (thread ${target.threadId}) — its context is not reported yet`);
|
|
134
|
+
tail.missingSaid = true;
|
|
135
|
+
}
|
|
136
|
+
else if (file !== tail.file) {
|
|
137
|
+
// A resumed thread writes a NEW rollout; follow it from its current end.
|
|
138
|
+
Object.assign(tail, { file, offset: 0, remainder: "", primed: false, missingSaid: false });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (tail.compaction && now - tail.compaction.at >= COMPACTION_AFTER_WAIT_MS)
|
|
142
|
+
this.endCompaction(target.session, tail);
|
|
143
|
+
if (!tail.file)
|
|
144
|
+
return;
|
|
145
|
+
let size;
|
|
146
|
+
try {
|
|
147
|
+
size = (await fs.promises.stat(tail.file)).size;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
tail.file = undefined; // gone: look it up again next tick
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (size < tail.offset)
|
|
154
|
+
Object.assign(tail, { offset: 0, remainder: "", primed: false });
|
|
155
|
+
if (!tail.primed) {
|
|
156
|
+
// First sight: only the CURRENT number matters. History (old compactions) is not news.
|
|
157
|
+
const start = Math.max(0, size - PRIME_BYTES);
|
|
158
|
+
const text = await readRange(tail.file, start, size);
|
|
159
|
+
const lines = (start > 0 ? text.slice(text.indexOf("\n") + 1) : text).split("\n");
|
|
160
|
+
let latest = null;
|
|
161
|
+
for (const line of lines)
|
|
162
|
+
latest = parseTokenCount(line) ?? latest;
|
|
163
|
+
Object.assign(tail, { offset: size, remainder: "", primed: true });
|
|
164
|
+
if (latest) {
|
|
165
|
+
tail.lastUsed = latest.used;
|
|
166
|
+
this.emit(target.session, { event: "usage", used: latest.used, size: latest.size, autoCompactAt: (0, context_report_1.codexAutoCompactAt)(latest.size) });
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (size === tail.offset)
|
|
171
|
+
return;
|
|
172
|
+
let from = tail.offset;
|
|
173
|
+
if (size - from > MAX_TICK_BYTES) {
|
|
174
|
+
from = size - PRIME_BYTES;
|
|
175
|
+
tail.remainder = "";
|
|
176
|
+
}
|
|
177
|
+
const text = tail.remainder + (await readRange(tail.file, from, size));
|
|
178
|
+
tail.offset = size;
|
|
179
|
+
const lines = text.split("\n");
|
|
180
|
+
tail.remainder = lines.pop() ?? ""; // an unfinished last line waits for the rest
|
|
181
|
+
for (const line of lines)
|
|
182
|
+
this.line(target.session, tail, line);
|
|
183
|
+
}
|
|
184
|
+
line(session, tail, line) {
|
|
185
|
+
if (!line.trim())
|
|
186
|
+
return;
|
|
187
|
+
if (line.includes('"compacted"') || line.includes("context_compacted")) {
|
|
188
|
+
const entry = safeParse(line);
|
|
189
|
+
const payloadType = isRecord(entry?.payload) ? entry.payload.type : undefined;
|
|
190
|
+
if (entry?.type === "compacted" || payloadType === "context_compacted") {
|
|
191
|
+
// One compaction may be written both ways; the first one wins.
|
|
192
|
+
if (!tail.compaction)
|
|
193
|
+
tail.compaction = { before: tail.lastUsed, at: this.now() };
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const sample = parseTokenCount(line);
|
|
198
|
+
if (!sample)
|
|
199
|
+
return;
|
|
200
|
+
this.endCompaction(session, tail, sample.used);
|
|
201
|
+
tail.lastUsed = sample.used;
|
|
202
|
+
this.emit(session, { event: "usage", used: sample.used, size: sample.size, autoCompactAt: (0, context_report_1.codexAutoCompactAt)(sample.size) });
|
|
203
|
+
}
|
|
204
|
+
endCompaction(session, tail, after) {
|
|
205
|
+
if (!tail.compaction)
|
|
206
|
+
return;
|
|
207
|
+
const { before } = tail.compaction;
|
|
208
|
+
tail.compaction = undefined;
|
|
209
|
+
// No `trigger`: the rollout does not say whether the person asked for it.
|
|
210
|
+
this.emit(session, { event: "compact_end", ...(before !== undefined ? { beforeTokens: before } : {}), ...(after !== undefined ? { afterTokens: after } : {}) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
exports.CodexContextTailer = CodexContextTailer;
|
|
214
|
+
function safeParse(line) {
|
|
215
|
+
try {
|
|
216
|
+
const v = JSON.parse(line);
|
|
217
|
+
return isRecord(v) ? v : undefined;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function parseTokenCount(line) {
|
|
224
|
+
if (!line.includes("token_count"))
|
|
225
|
+
return null;
|
|
226
|
+
const entry = safeParse(line);
|
|
227
|
+
return entry?.type === "event_msg" ? (0, context_report_1.codexUsageFromTokenCount)(entry.payload) : null;
|
|
228
|
+
}
|
|
229
|
+
async function readRange(file, start, end) {
|
|
230
|
+
const handle = await fs.promises.open(file, "r");
|
|
231
|
+
try {
|
|
232
|
+
const buf = Buffer.alloc(end - start);
|
|
233
|
+
await handle.read(buf, 0, buf.length, start);
|
|
234
|
+
return buf.toString("utf8");
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await handle.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function defaultFindRollout(threadId) {
|
|
241
|
+
return (await (0, resume_1.codexRolloutById)(threadId, {}))?.file;
|
|
242
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.COMPACT_COMMAND = exports.sanitizeCompactFocus = exports.COMPACT_FOCUS_MAX = exports.COMPACT_TRIGGER = void 0;
|
|
4
|
+
exports.parseCompactCommand = parseCompactCommand;
|
|
5
|
+
exports.compactKeys = compactKeys;
|
|
6
|
+
const compact_focus_1 = require("./compact-focus");
|
|
7
|
+
Object.defineProperty(exports, "COMPACT_FOCUS_MAX", { enumerable: true, get: function () { return compact_focus_1.COMPACT_FOCUS_MAX; } });
|
|
8
|
+
Object.defineProperty(exports, "sanitizeCompactFocus", { enumerable: true, get: function () { return compact_focus_1.sanitizeCompactFocus; } });
|
|
9
|
+
/**
|
|
10
|
+
* `/compact [focus]` — tidy a session's context now, optionally saying what to keep.
|
|
11
|
+
*
|
|
12
|
+
* ── 🔒 THE ONE COMMAND WITH AN ARGUMENT, AND WHY IT IS SAFE TO HAVE ONE ─────────────────
|
|
13
|
+
*
|
|
14
|
+
* Every other session command obeys "the wire carries a command NAME, never keystrokes"
|
|
15
|
+
* (session-commands.ts). This one carries one line of text, because Claude Code's own
|
|
16
|
+
* `/compact` takes instructions ("/compact keep the API decisions") and a tidy without them
|
|
17
|
+
* throws away what the person cared about. So the text is allowed in, and caged — the cage is
|
|
18
|
+
* `compact-focus.ts`, a twin the API applies too when the app asks for a tidy — and it is typed
|
|
19
|
+
* LITERALLY (`send-keys -l`, via `typeIntoPane`, which refuses `^`, `\` and control characters
|
|
20
|
+
* again on its own). Anything outside the cage is refused, by reason, and nothing is typed.
|
|
21
|
+
* Only Claude Code takes a focus; Codex's `/compact` takes none (checked against codex-cli
|
|
22
|
+
* 0.154.0's command list), so a focus sent to Codex is refused rather than silently dropped.
|
|
23
|
+
*/
|
|
24
|
+
exports.COMPACT_TRIGGER = "/compact";
|
|
25
|
+
exports.COMPACT_COMMAND = {
|
|
26
|
+
id: "compact",
|
|
27
|
+
trigger: exports.COMPACT_TRIGGER,
|
|
28
|
+
summary: `Tidy this session's context now (Claude Code, Codex). Claude Code takes a one-line focus: /compact keep the API decisions — letters, numbers, spaces and ${compact_focus_1.COMPACT_FOCUS_PUNCTUATION}, up to ${compact_focus_1.COMPACT_FOCUS_MAX} characters.`,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* `/compact` or `/compact <focus>` → the parse; anything else → undefined (it is chat).
|
|
32
|
+
*
|
|
33
|
+
* The command word is matched exactly and case-insensitively, as the other commands are:
|
|
34
|
+
* "can you /compact please" and "/compacting" are sentences, not commands.
|
|
35
|
+
*/
|
|
36
|
+
function parseCompactCommand(text) {
|
|
37
|
+
const trimmed = text.trimStart();
|
|
38
|
+
if (trimmed.slice(0, exports.COMPACT_TRIGGER.length).toLowerCase() !== exports.COMPACT_TRIGGER)
|
|
39
|
+
return undefined;
|
|
40
|
+
// Trailing whitespace only: a SECOND line has non-space after its newline, so it survives
|
|
41
|
+
// this and is refused below.
|
|
42
|
+
const tail = trimmed.slice(exports.COMPACT_TRIGGER.length).trimEnd();
|
|
43
|
+
if (tail === "")
|
|
44
|
+
return { kind: "compact" };
|
|
45
|
+
// "/compacting" is a word, not the command: a separator must follow the trigger.
|
|
46
|
+
if (!/^\s/u.test(tail))
|
|
47
|
+
return undefined;
|
|
48
|
+
const checked = (0, compact_focus_1.sanitizeCompactFocus)(tail);
|
|
49
|
+
if (!checked.ok)
|
|
50
|
+
return { kind: "refused", reason: `/compact was not run: ${checked.reason}` };
|
|
51
|
+
return checked.focus ? { kind: "compact", focus: checked.focus } : { kind: "compact" };
|
|
52
|
+
}
|
|
53
|
+
/** What to type for this runtime, or why nothing can be. */
|
|
54
|
+
function compactKeys(runtime, focus) {
|
|
55
|
+
if (runtime === "claude")
|
|
56
|
+
return { ok: true, keys: focus ? `${exports.COMPACT_TRIGGER} ${focus}` : exports.COMPACT_TRIGGER };
|
|
57
|
+
if (runtime === "codex") {
|
|
58
|
+
return focus
|
|
59
|
+
? { ok: false, reason: "Codex's /compact takes no focus. Send /compact on its own to tidy it." }
|
|
60
|
+
: { ok: true, keys: exports.COMPACT_TRIGGER };
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, reason: `/compact is only for Claude Code and Codex sessions; this one runs ${runtime}.` };
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.COMPACT_FOCUS_ATTACKS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Focus texts BOTH the relay and the API must refuse — the injection attempts the cage in
|
|
6
|
+
* `compact-focus.ts` exists for. Each is what follows `/compact` in a message (a leading space
|
|
7
|
+
* included where a person would type one). A twin, byte-identical in
|
|
8
|
+
* `packages/cli/src/relay/` and `apps/api/src/lib/`; each side runs every entry through its own
|
|
9
|
+
* copy of `sanitizeCompactFocus`, and the parity test holds the two files together.
|
|
10
|
+
*
|
|
11
|
+
* It imports nothing (the two packages resolve imports differently), so the refusal codes are
|
|
12
|
+
* spelled out here; they are `CompactFocusRefusal`'s, checked by the tests that use them.
|
|
13
|
+
*/
|
|
14
|
+
exports.COMPACT_FOCUS_ATTACKS = [
|
|
15
|
+
{ focus: " keep this\n/clear", code: "MULTILINE", reason: /single line/ },
|
|
16
|
+
{ focus: " keep this\r\nrm -rf ~", code: "MULTILINE", reason: /single line/ },
|
|
17
|
+
{ focus: "\nrm -rf ~", code: "MULTILINE", reason: /single line/ },
|
|
18
|
+
{ focus: " keep\u2028this", code: "MULTILINE", reason: /single line/ },
|
|
19
|
+
{ focus: " keep\u2029this", code: "MULTILINE", reason: /single line/ },
|
|
20
|
+
{ focus: " \u001b[2J\u001b[31mred", code: "CONTROL", reason: /control or invisible/ },
|
|
21
|
+
{ focus: " keep\tthis", code: "CONTROL", reason: /control or invisible/ },
|
|
22
|
+
{ focus: " a\u0003b", code: "CONTROL", reason: /control or invisible/ },
|
|
23
|
+
{ focus: " safe\u202Eesrever", code: "CONTROL", reason: /control or invisible/ },
|
|
24
|
+
{ focus: " zero\u200Bwidth", code: "CONTROL", reason: /control or invisible/ },
|
|
25
|
+
{ focus: " $(rm -rf ~)", code: "CHARACTER", reason: /"\$" is not allowed/ },
|
|
26
|
+
{ focus: " `id`", code: "CHARACTER", reason: /"`" is not allowed/ },
|
|
27
|
+
{ focus: " keep ^C", code: "CHARACTER", reason: /"\^" is not allowed/ },
|
|
28
|
+
{ focus: " back\\slash", code: "CHARACTER", reason: /"\\" is not allowed/ },
|
|
29
|
+
{ focus: " read @~/.ssh/id_rsa", code: "CHARACTER", reason: /"@" is not allowed/ },
|
|
30
|
+
{ focus: " a | b", code: "CHARACTER", reason: /"\|" is not allowed/ },
|
|
31
|
+
{ focus: " a > /etc/passwd", code: "CHARACTER", reason: /">" is not allowed/ },
|
|
32
|
+
{ focus: " #memory", code: "CHARACTER", reason: /"#" is not allowed/ },
|
|
33
|
+
{ focus: " {x}", code: "CHARACTER", reason: /"\{" is not allowed/ },
|
|
34
|
+
{ focus: " /clear", code: "LEADING_SLASH", reason: /cannot start with \// },
|
|
35
|
+
{ focus: ` ${"a".repeat(201)}`, code: "TOO_LONG", reason: /longer than 200/ },
|
|
36
|
+
];
|