teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,55 +1,14 @@
1
- /**
2
- * Append-only JSONL event log.
3
- *
4
- * Design goals:
5
- * - One file per agent (sessions.log.jsonl). Every conversation, including
6
- * forks, lives in the SAME file as an interleaved event stream.
7
- * - Each line is one self-contained JSON object; humans can `cat`/`jq` it.
8
- * - Lineage is explicit: every event carries `session`, `branch`, and
9
- * `parent` (the previous event on the same branch). A `fork` event records
10
- * where the new branch started (fromSession/fromBranch/fromEvent), so any
11
- * session can be reconstructed by filtering `branch === X` or by walking
12
- * parent links from the fork point backwards into ancestor branches.
13
- * - Append-only + monotonic `seq` makes corruption detectable (a torn final
14
- * line is simply ignored on read).
15
- */
16
1
  import { createWriteStream } from "node:fs";
17
2
  import { mkdirSync } from "node:fs";
18
3
  import path from "node:path";
19
- /**
20
- * How long an idle write stream is kept open (#143).
21
- *
22
- * The stream used to be opened in `load()` and closed only by `close()`, which
23
- * runs on dispose and shutdown. A loaded agent therefore held a file descriptor
24
- * for the whole process lifetime — MEASURED on a real master: 493 agents in
25
- * config meant 493 open `chat.jsonl` descriptors, ~300 MB RSS, growing with
26
- * every session the operator creates.
27
- *
28
- * The log is append-only, so a stream is never needed to keep state: reopening
29
- * with `flags: "a"` continues exactly where the last write stopped. Closing it
30
- * is therefore free, and re-opening on the next append costs one `open()` per
31
- * burst of activity rather than one per session forever.
32
- *
33
- * 15s, because a working agent appends many times per second — holding the
34
- * stream across that activity means file rotation / inspection outside the app
35
- * still sees a quiet, closed file, and a burst of tool calls costs ONE open.
36
- */
37
4
  const IDLE_CLOSE_MS = 15_000;
38
5
  export class EventLog {
39
6
  stream = null;
40
7
  seq = 0;
41
8
  chain = Promise.resolve();
42
- /** branch -> last event id (in-memory reconstruction of parent chains) */
43
9
  lastByBranch = new Map();
44
- /** optional observer (e.g. console logger wired by the master) */
45
10
  onEvent = null;
46
- /** #143: timer that closes the stream once this log goes quiet */
47
11
  idleTimer = null;
48
- /**
49
- * #143: set by close() so an append that resumes afterwards knows the log was
50
- * DISPOSED rather than merely idle — an idle log must reopen, a disposed one
51
- * must keep dropping (and saying so), which is the pre-existing contract.
52
- */
53
12
  disposed = false;
54
13
  filePath;
55
14
  agentId;
@@ -57,26 +16,6 @@ export class EventLog {
57
16
  this.filePath = filePath;
58
17
  this.agentId = agentId;
59
18
  }
60
- /**
61
- * Bytes read from the END of the log to rebuild the in-memory head.
62
- *
63
- * `load()` used to `readFile()` the ENTIRE log and `JSON.parse` every event,
64
- * only to learn two things: the highest `seq`, and the last event id per
65
- * branch. MEASURED on a real 813-session data dir (369 MB of logs): that read
66
- * was the single largest boot cost — 1603 ms of `load()` against 493 agents,
67
- * plus the GC pressure of holding one whole log as a string and its `split`
68
- * array (~50 MB of temporary garbage for a 23 MB log).
69
- *
70
- * Both facts live at the END of an append-only file:
71
- * - `seq` is assigned monotonically, so the last complete line holds the max
72
- * - `lastEventId()` is only ever called for the CURRENT branch (fork/spawn),
73
- * which is by definition the most recently written one
74
- *
75
- * So the tail is both sufficient and exact. A branch that has been dormant for
76
- * more than WINDOW bytes loses its in-memory head; that only affects
77
- * `parent:` on the next append to that stale branch, and the paths that matter
78
- * (`fork`, child spawn) re-seed explicitly through `seedBranch()`.
79
- */
80
19
  static LOAD_TAIL_BYTES = 512 * 1024;
81
20
  async load() {
82
21
  mkdirSync(path.dirname(this.filePath), { recursive: true });
@@ -85,7 +24,7 @@ export class EventLog {
85
24
  const st = await stat(this.filePath);
86
25
  let text;
87
26
  if (st.size <= EventLog.LOAD_TAIL_BYTES) {
88
- text = await readFile(this.filePath, "utf8"); // small log: read it all
27
+ text = await readFile(this.filePath, "utf8");
89
28
  }
90
29
  else {
91
30
  const start = st.size - EventLog.LOAD_TAIL_BYTES;
@@ -98,7 +37,6 @@ export class EventLog {
98
37
  finally {
99
38
  await fh.close();
100
39
  }
101
- // the window starts mid-line: drop the partial first line
102
40
  const nl = text.indexOf("\n");
103
41
  text = nl === -1 ? "" : text.slice(nl + 1);
104
42
  }
@@ -112,15 +50,11 @@ export class EventLog {
112
50
  this.lastByBranch.set(e.branch, e.id);
113
51
  }
114
52
  catch {
115
- /* torn trailing line: ignore */
116
53
  }
117
54
  }
118
55
  }
119
56
  catch {
120
- /* new log */
121
57
  }
122
- // repair a torn tail: without this, the next append would fuse into the
123
- // corrupt partial line and destroy two events instead of one
124
58
  try {
125
59
  const { stat, appendFile } = await import("node:fs/promises");
126
60
  const st = await stat(this.filePath);
@@ -134,18 +68,8 @@ export class EventLog {
134
68
  }
135
69
  }
136
70
  catch {
137
- /* file may not exist yet */
138
71
  }
139
- // #143: NO stream here. Opening it in load() is what held a descriptor per
140
- // loaded agent for the lifetime of the process (measured: 493 of them).
141
- // `load()` still reads and repairs the file — it just does not keep it open.
142
72
  }
143
- /**
144
- * Open the append stream if it is not already open (#143).
145
- *
146
- * Called from the write chain so an append cannot race a close: both run
147
- * inside `this.chain`, which is a promise queue.
148
- */
149
73
  openStream() {
150
74
  if (this.stream)
151
75
  return this.stream;
@@ -156,48 +80,29 @@ export class EventLog {
156
80
  this.stream = s;
157
81
  return s;
158
82
  }
159
- /** #143: close the stream once this log has been quiet for IDLE_CLOSE_MS. */
160
83
  scheduleIdleClose() {
161
84
  if (this.idleTimer)
162
- return; // already armed; the existing deadline stands
85
+ return;
163
86
  this.idleTimer = setTimeout(() => this.fireIdleClose(), IDLE_CLOSE_MS);
164
- // the timer must never be the reason a process cannot exit
165
87
  this.idleTimer.unref?.();
166
88
  }
167
- /**
168
- * What the idle timer does — separated so it can be driven directly.
169
- *
170
- * Clears the deadline FIRST so `releaseIdleStream` can tell "nothing new
171
- * arrived" (close) from "an append re-armed it while I waited" (defer).
172
- */
173
89
  fireIdleClose() {
174
90
  this.idleTimer = null;
175
91
  void this.releaseIdleStream();
176
92
  }
177
- /**
178
- * Close the stream after a quiet period.
179
- *
180
- * Runs behind `chain`, so it cannot interleave with a queued write. An append
181
- * that arrived while this was pending re-armed the timer, and that newer
182
- * deadline owns the close — hence the check on `idleTimer` AFTER draining,
183
- * not before.
184
- */
185
93
  async releaseIdleStream() {
186
94
  await this.chain.catch(() => { });
187
95
  if (this.disposed)
188
96
  return;
189
97
  if (!this.stream)
190
98
  return;
191
- // a later append re-armed the timer — let that deadline own the close
192
99
  if (this.idleTimer)
193
100
  return;
194
101
  const s = this.stream;
195
102
  this.stream = null;
196
103
  await new Promise((res) => s.end(res)).catch(() => {
197
- /* the write error handler already reported it */
198
104
  });
199
105
  }
200
- /** Append an event; resolves when it is handed to the OS (write flushed). */
201
106
  append(type, session, branch, data) {
202
107
  const evt = {
203
108
  v: 1,
@@ -212,36 +117,13 @@ export class EventLog {
212
117
  data,
213
118
  };
214
119
  this.lastByBranch.set(branch, evt.id);
215
- // #54: WRITE FIRST, then notify.
216
- //
217
- // The observer used to fire BEFORE the write. The frontend receives a
218
- // `kind:"event"` frame and — by design — treats it as "something changed"
219
- // and re-reads `/events`. So the reload could read the log BEFORE this
220
- // event reached disk, and the reload would simply not contain it.
221
- //
222
- // Measured against a real EventLog, reloading on every notification:
223
- //
224
- // appends: 30 WS-triggered reloads that MISSED the event: 30
225
- //
226
- // The 120ms debounce usually hides this, which is exactly why #54 felt
227
- // random: a slow write misses, a fast one lands. Notifying after the write
228
- // makes the event visible to any reader the observer triggers.
229
- // `written` distinguishes a REAL write from the post-close drop path, which
230
- // also resolves. #54: announcing a dropped event would tell the frontend to
231
- // reload for something that will never appear — the row would flicker in and
232
- // straight back out.
233
120
  let written = false;
234
121
  const p = new Promise((resolve, reject) => {
235
122
  this.chain = this.chain.then(() => {
236
123
  if (this.disposed) {
237
- // log DISPOSED (not merely idle) — resolve so callers don't await
238
- // forever, but say so: silent drops made post-shutdown writes look
239
- // logged. #143: this is a distinct state from an idle stream, which
240
- // must reopen instead of dropping.
241
124
  console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
242
125
  return resolve(evt);
243
126
  }
244
- // #143: open on demand — an idle log keeps no descriptor
245
127
  this.openStream().write(JSON.stringify(evt) + "\n", "utf8", (err) => {
246
128
  if (err)
247
129
  return reject(err);
@@ -250,50 +132,31 @@ export class EventLog {
250
132
  });
251
133
  }, () => resolve(evt));
252
134
  });
253
- // #143: re-arm the idle close for every append. Set here rather than inside
254
- // the write callback: the timer measures quiet time from the last APPEND, and
255
- // a long write must not count as activity after the fact.
256
135
  this.scheduleIdleClose();
257
136
  this.chain = this.chain.then(() => { }, () => { });
258
- // notify once the write is handed to the OS, so a reload the observer
259
- // triggers can actually see this event
260
137
  void p
261
138
  .then(() => {
262
139
  if (!written)
263
- return; // dropped after close: nothing on disk to announce
140
+ return;
264
141
  try {
265
142
  this.onEvent?.(evt);
266
143
  }
267
144
  catch {
268
- /* observer must never break the log */
269
145
  }
270
146
  })
271
147
  .catch(() => {
272
- /* a failed write must not notify — the event is not on disk */
273
148
  });
274
149
  return p;
275
150
  }
276
151
  lastEventId(branch) {
277
152
  return this.lastByBranch.get(branch) ?? null;
278
153
  }
279
- /**
280
- * Seed the parent link for a brand-new branch so its first event chains to
281
- * the event that created it.
282
- *
283
- * `lastByBranch` is per branch, so without this a new branch's first event
284
- * got `parent: null` — severing the chain. lineageOf() walks parents from the
285
- * newest event, so the entire pre-fork history became unreachable and a
286
- * restart rebuilt the conversation with none of it (#38).
287
- */
288
154
  seedBranch(branch, parentEventId) {
289
155
  if (!branch)
290
156
  return;
291
157
  this.lastByBranch.set(branch, parentEventId ?? "");
292
158
  }
293
159
  async close() {
294
- // #143: mark DISPOSED before draining, so an append queued behind us drops
295
- // loudly instead of reopening the log we are shutting down. This is the
296
- // difference between "idle" (reopen on the next write) and "closed".
297
160
  this.disposed = true;
298
161
  if (this.idleTimer) {
299
162
  clearTimeout(this.idleTimer);
@@ -307,7 +170,6 @@ export class EventLog {
307
170
  await new Promise((res) => s.end(res));
308
171
  }
309
172
  }
310
- /** Read all events from a JSONL file (tolerates a torn final line). */
311
173
  export async function readEvents(filePath) {
312
174
  try {
313
175
  const { readFile } = await import("node:fs/promises");
@@ -319,7 +181,6 @@ export async function readEvents(filePath) {
319
181
  out.push(JSON.parse(line));
320
182
  }
321
183
  catch {
322
- /* skip bad line */
323
184
  }
324
185
  }
325
186
  return out;
@@ -328,26 +189,7 @@ export async function readEvents(filePath) {
328
189
  return [];
329
190
  }
330
191
  }
331
- /** Default floor for a tail read, so a small `limit` cannot cause a tiny read. */
332
192
  const TAIL_MIN_BYTES = 64 * 1024;
333
- /**
334
- * Read only the LAST `limit` events from an append-only JSONL log.
335
- *
336
- * #59: the web UI polls this endpoint every ~120 ms for the selected agent, and
337
- * the full re-parse cost 348 ms on a 57 MB / 200 000-event log. The mtime cache
338
- * could not help — a RUNNING agent appends constantly, so `size` and `mtimeMs`
339
- * change on every single request and the cache only ever helped stopped agents.
340
- *
341
- * The log is strictly append-only: `EventLog.append` is the only writer and
342
- * always writes a whole `JSON.stringify(evt) + "\n"` under a serialized chain,
343
- * so an append never rewrites earlier bytes. That makes a tail read safe, and
344
- * makes byte-offset caching legitimate.
345
- *
346
- * Anchored to the tail deliberately. The read starts at a byte offset chosen for
347
- * the file's current size, so the FIRST line it sees is usually a fragment —
348
- * dropped. `bytesScanned` is reported so callers (and tests) can assert the read
349
- * really was partial rather than trusting a wall-clock number.
350
- */
351
193
  export async function readEventsTail(filePath, limit) {
352
194
  const empty = { events: [], total: 0, bytesScanned: 0, truncated: false };
353
195
  if (limit <= 0)
@@ -359,19 +201,12 @@ export async function readEventsTail(filePath, limit) {
359
201
  const st = await fh.stat();
360
202
  if (st.size === 0)
361
203
  return empty;
362
- // grow the window until it holds `limit` whole lines. Start at a floor so a
363
- // small limit still reads a sensible block, then double — bounded by the
364
- // file size, because a pathological file must not make us read forever.
365
204
  let want = Math.min(st.size, Math.max(TAIL_MIN_BYTES, limit * 512));
366
205
  for (;;) {
367
206
  const r = await readWindow(fh, st.size, want);
368
207
  if (r.events.length >= limit || want >= st.size) {
369
208
  return {
370
209
  events: r.events.slice(-limit),
371
- // `total` is an APPROXIMATE count: only the window was parsed, so this
372
- // is "at least this many". /events uses it for the "load older" hint,
373
- // where approximate is fine — an exact count would mean reading the
374
- // entire file, which is the cost this function exists to avoid.
375
210
  total: r.events.length,
376
211
  bytesScanned: r.bytes,
377
212
  truncated: r.bytes < st.size,
@@ -387,7 +222,6 @@ export async function readEventsTail(filePath, limit) {
387
222
  await fh?.close().catch(() => { });
388
223
  }
389
224
  }
390
- /** read the last `bytes` of the file, dropping the leading partial line */
391
225
  async function readWindow(fh, size, bytes) {
392
226
  const start = Math.max(0, size - bytes);
393
227
  const buf = Buffer.alloc(size - start);
@@ -395,7 +229,6 @@ async function readWindow(fh, size, bytes) {
395
229
  await fh.read(buf, 0, buf.length, start);
396
230
  const text = buf.toString("utf8");
397
231
  const nl = text.indexOf("\n");
398
- // the first line is a fragment unless we started at 0
399
232
  const body = start > 0 ? (nl === -1 ? "" : text.slice(nl + 1)) : text;
400
233
  const events = [];
401
234
  for (const line of body.split("\n")) {
@@ -405,38 +238,15 @@ async function readWindow(fh, size, bytes) {
405
238
  events.push(JSON.parse(line));
406
239
  }
407
240
  catch {
408
- /* torn or corrupt line: skip it, never return a fragment */
409
241
  }
410
242
  }
411
243
  return { events, bytes: buf.length };
412
244
  }
413
- /**
414
- * Keep one branch's rows out of a timeline, optionally including the history it
415
- * inherited before it forked.
416
- *
417
- * #38. A fork is a new timeline, but the agent's CONTEXT on it still includes
418
- * everything before the fork point — `rebuildMessagesFrom` walks the parent
419
- * chain across branch boundaries precisely so a restarted fork keeps its
420
- * history. Filtering the view by strict equality therefore showed *less* than
421
- * the model actually reasons over, which is why a filtered fork looked like
422
- * data loss even though nothing was missing from the log.
423
- *
424
- * `includeInherited` walks the parent links out of the branch's own events and
425
- * keeps everything they reach, so the view matches the model's context. Strict
426
- * remains the default: a caller that genuinely wants only a branch's own rows
427
- * (counting, auditing) should not be handed inherited history silently.
428
- *
429
- * Pure and exported so the rule can be tested without an HTTP server.
430
- */
431
245
  export function filterByBranch(events, branch, includeInherited = false) {
432
246
  if (!includeInherited)
433
247
  return events.filter((e) => e.branch === branch);
434
248
  const byId = new Map(events.map((e) => [e.id, e]));
435
- // every event belonging to the branch, plus everything reachable by `parent`
436
249
  const wanted = new Set();
437
- // A WORKLIST, not a loop over the growing set: iterating `wanted` while
438
- // adding to it inside the same pass re-visits nodes and, on a parent cycle,
439
- // never terminates.
440
250
  const queue = [];
441
251
  for (const e of events) {
442
252
  if (e.branch !== branch)
@@ -448,8 +258,6 @@ export function filterByBranch(events, branch, includeInherited = false) {
448
258
  }
449
259
  while (queue.length) {
450
260
  const cur = byId.get(queue.pop());
451
- // walk up through the fork boundary — a fork's parent is the last event of
452
- // the SOURCE branch, and that branch's history is inherited too
453
261
  if (cur?.parent && !wanted.has(cur.parent)) {
454
262
  wanted.add(cur.parent);
455
263
  queue.push(cur.parent);