teapot-coding-agent 0.9.0 → 0.11.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 CHANGED
@@ -133,6 +133,11 @@ master (Hono server, src/master.ts + src/server/api.ts)
133
133
  through tools (`get_goal` / `set_goal` / `read_memory` / `set_memory`);
134
134
  `AGENTS.md` is optional project knowledge in the workspace root that agents
135
135
  are told to read at session start. Nothing is seeded into your project.
136
+ - **Task list** — `todo.md` in the session dir, editable by BOTH sides:
137
+ humans write it from the web UI (✅ tasks panel) or
138
+ `POST /api/agents/:id/todo {text, notify?}`, the agent reads and updates it
139
+ via `get_todo` / `set_todo`. Saving with notify queues a harness prompt so
140
+ the agent picks up changes at the next turn boundary.
136
141
 
137
142
  ### Web UI
138
143
 
@@ -173,8 +178,10 @@ description: Steps to cut a release safely
173
178
  - The system prompt lists every discovered skill (name + description);
174
179
  workspace skills override same-named global ones.
175
180
  - The agent calls `load_skill(name)` when a task matches and follows it.
176
- - The agent calls `save_skill(name, description, content)` to distill a
177
- reusable procedure it developed available from the next turn, forever.
181
+ - The agent calls `save_skill(name, description, content, files?)` to distill a
182
+ reusable procedure `files` bundles helper scripts next to `SKILL.md`
183
+ (made executable automatically), and `load_skill` lists them as runnable
184
+ workspace paths. Available from the next turn, forever.
178
185
 
179
186
  ### Session log format (JSONL)
180
187
 
@@ -218,10 +225,13 @@ GET /api/agents list snapshots
218
225
  GET /api/agents/:id one snapshot
219
226
  POST /api/agents/:id/prompt {text, start?}
220
227
  POST /api/agents/:id/start | /stop
221
- POST /api/agents/:id/goal {text} or {status}
228
+ POST /api/agents/:id/load lazy session restore (stopped → idle)
229
+ POST /api/agents/:id/goal {text, notify?} or {status}
222
230
  POST /api/agents/:id/fork {} → new branch, same session log
231
+ POST /api/agents/:id/edit-prompt {eventId, text, tail: "discard"|"summarize"} → fork & resend
223
232
  GET /api/agents/:id/events?limit&branch&session
224
233
  GET /api/agents/:id/branches
234
+ GET /api/tasks scheduled tasks with computed next-fire times
225
235
  GET /api/metrics master rss/heap/load + per-agent stats
226
236
  GET /api/events SSE updates (push, no polling)
227
237
  GET /brew 418 I'm a teapot (RFC 2324)
@@ -27,6 +27,8 @@ Session state is not injected into prompts — fetch it with tools instead:
27
27
  - set_goal(text) → change the objective itself (not routine updates).
28
28
  - finish(goalComplete=true, summary) → goal fully achieved.
29
29
  - read_memory() / set_memory(content) → your durable notes (memory.md).
30
+ - get_todo() / set_todo(content) → the operator-maintained task list
31
+ (todo.md); check it when picking up work, keep it current as you go.
30
32
  - list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
31
33
  - AGENTS.md in the workspace root (optional) holds project knowledge — read it
32
34
  with read_file at session start when present, keep it current.
@@ -40,6 +42,10 @@ Session state is not injected into prompts — fetch it with tools instead:
40
42
  text-munging (sed/awk/heredoc) remains available for quick bulk transforms
41
43
  when that is genuinely faster.
42
44
  - When a loaded skill matches your task, follow its playbook.
45
+ - Turn proven procedures into skills: once something non-trivial worked well,
46
+ save_skill(name, description, content, files=[{name, content}]) so future
47
+ sessions can load_skill them — helper scripts go through files and are made
48
+ executable automatically.
43
49
  - When you make meaningful progress, call report_progress.
44
50
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
45
51
  export class Agent {
@@ -52,10 +58,15 @@ export class Agent {
52
58
  currentBranch = "br0";
53
59
  goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
54
60
  latestProgress = null;
61
+ /** operator-maintained task list (todo.md) — humans edit, agent reads */
62
+ todo = "";
63
+ /** set once the conversation has been restored (lazy: on first interaction) */
64
+ readyPromise = null;
55
65
  stats = {
56
66
  turns: 0,
57
67
  toolCalls: 0,
58
68
  inputTokens: 0,
69
+ cachedInputTokens: 0,
59
70
  outputTokens: 0,
60
71
  compactions: 0,
61
72
  startedAt: null,
@@ -76,19 +87,31 @@ export class Agent {
76
87
  toolAbort = new AbortController();
77
88
  runChain = Promise.resolve();
78
89
  lastProgressAt = Date.now();
90
+ /** real assistant output since the last progress report (chars / turns) */
91
+ activityChars = 0;
92
+ turnsSinceProgress = 0;
79
93
  consecutiveToolErrors = 0;
80
94
  constructor(opts) {
81
95
  this.opts = {
82
96
  progressIntervalMs: 10 * 60_000,
97
+ progressMinChars: 4_000,
98
+ progressMaxQuietTurns: 40,
83
99
  autoContinue: true,
84
100
  continueDelayMs: 15_000,
85
101
  maxConsecutiveToolErrors: 5,
86
102
  contextTokenBudget: 96_000,
103
+ contextWindowTokens: 0,
87
104
  restoreSession: true,
88
105
  globalSkillsDir: "",
89
106
  provider: "",
90
107
  ...opts,
91
108
  };
109
+ // a declared window implies its own budget: compact at ~75% of the
110
+ // model's real context unless the config set an explicit one (the 96k
111
+ // default only makes sense for unknown-window models)
112
+ if (this.opts.contextWindowTokens && !opts.contextTokenBudget) {
113
+ this.opts.contextTokenBudget = Math.round(this.opts.contextWindowTokens * 0.75);
114
+ }
92
115
  this.log = new EventLog(path.join(opts.sessionDir, "chat.jsonl"), opts.id);
93
116
  this.skillRoots = [
94
117
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
@@ -143,9 +166,37 @@ export class Agent {
143
166
  await this.migrateGoalFromWorkspace();
144
167
  const stored = await this.readGoalStore();
145
168
  this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
146
- if (this.opts.restoreSession)
147
- await this.restoreFromLog();
169
+ // operator-maintained task list lives beside goal.md
170
+ this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
148
171
  await this.refreshSkills();
172
+ // the conversation is NOT restored here: boot cost stays O(agents), not
173
+ // O(history). It is rebuilt lazily by ensureReady() on first interaction.
174
+ if (this.opts.restoreSession) {
175
+ this.status = "stopped";
176
+ this.statusReason = "session not loaded — select it or send a prompt";
177
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
178
+ }
179
+ }
180
+ /**
181
+ * Restore the conversation from the JSONL log exactly once, on demand.
182
+ * Everything that touches history (prompts, start, fork, UI selection)
183
+ * funnels through here; boot stays cheap no matter how many sessions exist.
184
+ */
185
+ ensureReady() {
186
+ if (!this.readyPromise) {
187
+ this.readyPromise = (async () => {
188
+ if (this.opts.restoreSession) {
189
+ await this.restoreFromLog();
190
+ if (this.status === "stopped")
191
+ this.setStatus("idle", "session loaded");
192
+ }
193
+ })();
194
+ }
195
+ return this.readyPromise;
196
+ }
197
+ /** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
198
+ async load() {
199
+ await this.ensureReady();
149
200
  }
150
201
  /** harness-managed files inside the session directory */
151
202
  get goalFile() {
@@ -154,6 +205,9 @@ export class Agent {
154
205
  get memoryFile() {
155
206
  return path.join(this.opts.sessionDir, "memory.md");
156
207
  }
208
+ get todoFile() {
209
+ return path.join(this.opts.sessionDir, "todo.md");
210
+ }
157
211
  async readGoalStoreRaw() {
158
212
  return fs.readFile(this.goalFile, "utf8").catch(() => null);
159
213
  }
@@ -195,114 +249,11 @@ export class Agent {
195
249
  const events = await readEvents(this.log.filePath);
196
250
  if (events.length === 0)
197
251
  return;
198
- const byId = new Map(events.map((e) => [e.id, e]));
199
- let last = events[events.length - 1];
200
- // walk backwards to the root via parent links (fork-safe)
201
- const lineage = [];
202
- const seen = new Set();
203
- for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
204
- if (seen.has(cur.id))
205
- break;
206
- seen.add(cur.id);
207
- lineage.push(cur);
208
- }
209
- lineage.reverse();
210
- // skip the trailing fork event itself (it is bookkeeping, not conversation)
211
- while (lineage.length && lineage[0].type === "fork")
212
- lineage.shift();
213
- const msgs = [];
214
- // Prompts are logged the moment the user hits send — which can be while a
215
- // tool batch is still open. Replaying them verbatim would slot a user
216
- // message BETWEEN an assistant tool_call and its tool_result (API-invalid),
217
- // so hold them until the open batch is answered, like live delivery does.
218
- const META_TOOLS = new Set([
219
- "finish", "report_progress", "set_goal", "get_goal",
220
- "read_memory", "set_memory", "list_skills",
221
- ]);
222
- const openCalls = new Map(); // real tool_call id -> name
223
- const bufferedUsers = [];
224
- const flushUsers = () => {
225
- if (openCalls.size === 0) {
226
- for (const text of bufferedUsers.splice(0))
227
- msgs.push({ role: "user", content: text });
228
- }
229
- };
230
- for (const e of lineage) {
231
- const d = e.data;
232
- if (e.type === "prompt" && typeof d.text === "string") {
233
- if (openCalls.size > 0)
234
- bufferedUsers.push(d.text);
235
- else
236
- msgs.push({ role: "user", content: d.text });
237
- }
238
- else if (e.type === "message") {
239
- const role = d.role === "assistant" ? "assistant" : "user";
240
- const m = { role, content: typeof d.content === "string" ? d.content : "" };
241
- if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
242
- m.tool_calls = d.toolCalls.map((c) => ({
243
- id: c.id,
244
- type: "function",
245
- function: { name: c.name, arguments: "{}" },
246
- }));
247
- // meta tools are answered inline by the harness (no logged result);
248
- // the hole-filling pass below synthesizes theirs where they belong
249
- for (const t of m.tool_calls)
250
- if (!META_TOOLS.has(t.function.name))
251
- openCalls.set(t.id, t.function.name);
252
- }
253
- msgs.push(m);
254
- }
255
- else if (e.type === "tool_call") {
256
- // enrich the preceding assistant tool_calls with real arguments
257
- const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
258
- const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
259
- if (tc)
260
- tc.function.arguments = JSON.stringify(d.args ?? {});
261
- }
262
- else if (e.type === "tool_result") {
263
- msgs.push({
264
- role: "tool",
265
- tool_call_id: String(d.callId ?? ""),
266
- content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
267
- });
268
- openCalls.delete(String(d.callId ?? ""));
269
- flushUsers();
270
- }
271
- else if (e.type === "progress") {
272
- // progress events may follow an assistant report_progress call that
273
- // has no logged tool result — patch it in when present
274
- const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
275
- if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
276
- for (const t of lastAssistant.tool_calls) {
277
- if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
278
- msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
279
- }
280
- openCalls.delete(t.id);
281
- }
282
- flushUsers();
283
- }
284
- }
285
- }
286
- // every assistant tool_call must be answered by a tool message, or the
287
- // API rejects the sequence — close any holes left by meta tools (finish)
288
- for (let i = 0; i < msgs.length; i++) {
289
- const m = msgs[i];
290
- if (m.role === "assistant" && m.tool_calls?.length) {
291
- for (const t of m.tool_calls) {
292
- if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
293
- msgs.splice(i + 1, 0, {
294
- role: "tool",
295
- tool_call_id: t.id,
296
- content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
297
- });
298
- i++;
299
- }
300
- }
301
- }
302
- }
303
- // prompts that were still waiting on a hole-filled tail land here
304
- for (const text of bufferedUsers.splice(0))
305
- msgs.push({ role: "user", content: text });
252
+ const lineage = lineageOf(events);
253
+ if (!lineage.length)
254
+ return;
255
+ const last = lineage[lineage.length - 1];
256
+ const msgs = rebuildMessagesFrom(lineage);
306
257
  if (msgs.length > 0) {
307
258
  this.messages = msgs;
308
259
  this.currentBranch = last.branch;
@@ -313,6 +264,61 @@ export class Agent {
313
264
  });
314
265
  }
315
266
  }
267
+ /**
268
+ * Edit a previously-sent prompt: fork the conversation at that point,
269
+ * replace its text, and optionally fold everything that happened after it
270
+ * into a summary note on the new branch (ChatGPT-edit style). The agent
271
+ * must not be running — editing under a live loop would race its history.
272
+ */
273
+ async editPromptAt(eventId, text, tail) {
274
+ if (this.status === "running")
275
+ throw new Error("agent is running — stop it before editing history");
276
+ const all = await readEvents(this.log.filePath);
277
+ const target = all.find((e) => e.id === eventId);
278
+ if (!target || target.type !== "prompt")
279
+ throw new Error("event not found on this session (or not a prompt)");
280
+ const lineage = lineageOf(all);
281
+ const tIdx = lineage.findIndex((e) => e.id === eventId);
282
+ if (tIdx === -1)
283
+ throw new Error("prompt is not on this agent's current lineage");
284
+ const kept = lineage.slice(0, tIdx);
285
+ const dropped = lineage.slice(tIdx); // includes the original prompt itself
286
+ const msgs = rebuildMessagesFrom(kept);
287
+ if (tail === "summarize" && dropped.length > 0) {
288
+ try {
289
+ const droppedMsgs = rebuildMessagesFrom(dropped);
290
+ if (droppedMsgs.length) {
291
+ const summary = await this.summarize(droppedMsgs);
292
+ if (summary.trim()) {
293
+ msgs.push({
294
+ role: "user",
295
+ content: "[harness] The conversation continued past this point on another timeline. " +
296
+ `Notes from what happened there:\n\n${summary}`,
297
+ });
298
+ }
299
+ }
300
+ }
301
+ catch {
302
+ // summarization is best-effort; the fork proceeds without notes
303
+ }
304
+ }
305
+ msgs.push({ role: "user", content: text });
306
+ const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
307
+ await this.log.append("fork", this.currentSession, newBranch, {
308
+ fromSession: this.currentSession,
309
+ fromBranch: this.currentBranch,
310
+ fromEvent: kept.at(-1)?.id ?? null,
311
+ newBranch,
312
+ reason: "prompt-edited",
313
+ droppedEvents: dropped.length,
314
+ tailMode: tail,
315
+ });
316
+ this.currentBranch = newBranch;
317
+ this.messages = msgs;
318
+ await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
319
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
320
+ return { droppedEvents: dropped.length, branch: newBranch };
321
+ }
316
322
  parseGoalFile(text) {
317
323
  // humans and agents may append their own status lines — latest wins
318
324
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
@@ -344,6 +350,12 @@ export class Agent {
344
350
  await this.writeGoalFile();
345
351
  await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
346
352
  }
353
+ /** Persist the operator-maintained task list (todo.md). */
354
+ async setTodo(text, by = "human") {
355
+ this.todo = text;
356
+ await fs.writeFile(this.todoFile, text, "utf8");
357
+ await this.log.append("todo", this.currentSession, this.currentBranch, { event: "set", by });
358
+ }
347
359
  async setGoalStatus(status) {
348
360
  this.goal = { ...this.goal, status, updatedAt: new Date().toISOString() };
349
361
  await this.writeGoalFile();
@@ -363,19 +375,28 @@ export class Agent {
363
375
  model: this.opts.llm.model,
364
376
  provider: this.opts.provider,
365
377
  sessionDir: this.opts.sessionDir,
378
+ ctx: {
379
+ usedTokens: this.estimateTokens(),
380
+ compactAt: this.opts.contextTokenBudget,
381
+ window: this.opts.contextWindowTokens || 0,
382
+ },
366
383
  pendingPrompts: this.pendingPrompts.length,
384
+ todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
367
385
  };
368
386
  }
369
387
  /**
370
388
  * Queue a user prompt. Returns immediately: the event is logged right away
371
389
  * (so every connected UI sees it instantly) and the text is handed to the
372
390
  * model at the next turn boundary — never mid-turn, and never blocked by
373
- * the running loop.
391
+ * the running loop. The very first prompt on a fresh boot also triggers the
392
+ * lazy session restore (before the mailbox is filled, so no duplicates).
374
393
  */
375
394
  enqueuePrompt(text, source = "user") {
376
- this.pendingPrompts.push({ source, text });
377
- void this.log
378
- .append("prompt", this.currentSession, this.currentBranch, { source, text })
395
+ void this.ensureReady()
396
+ .then(() => {
397
+ this.pendingPrompts.push({ source, text });
398
+ return this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
399
+ })
379
400
  .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
380
401
  .catch(() => { });
381
402
  }
@@ -400,6 +421,7 @@ export class Agent {
400
421
  return;
401
422
  this.stopRequested = false;
402
423
  void this.enqueue(async () => {
424
+ await this.ensureReady(); // lazy restore before the loop touches history
403
425
  this.setStatus("running", reason);
404
426
  this.stats.startedAt ??= new Date().toISOString();
405
427
  });
@@ -540,16 +562,43 @@ export class Agent {
540
562
  turn: ++this.stats.turns,
541
563
  });
542
564
  // stream the assistant reply live to connected clients
543
- const res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
544
- bus.emit("update", {
545
- kind: "llm-delta",
546
- agentId: this.opts.id,
547
- text: s.text,
548
- reasoning: s.reasoning,
565
+ let res;
566
+ try {
567
+ res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
568
+ bus.emit("update", {
569
+ kind: "llm-delta",
570
+ agentId: this.opts.id,
571
+ text: s.text,
572
+ reasoning: s.reasoning,
573
+ });
549
574
  });
550
- });
575
+ }
576
+ catch (err) {
577
+ // user stop mid-stream: persist the partial output so the timeline
578
+ // keeps what was already visible (otherwise it silently vanishes)
579
+ const partial = err.partial;
580
+ if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
581
+ await this.log.append("message", this.currentSession, this.currentBranch, {
582
+ role: "assistant",
583
+ content: partial.text ?? "",
584
+ reasoning: partial.reasoning,
585
+ interrupted: true,
586
+ });
587
+ this.messages.push({ role: "assistant", content: partial.text ?? "" });
588
+ }
589
+ else if (this.stopRequested) {
590
+ // nothing had streamed — leave an explicit marker so the log shows
591
+ // why this prompt has no reply
592
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
593
+ event: "turn-interrupted",
594
+ detail: "stopped before any output arrived",
595
+ });
596
+ }
597
+ throw err;
598
+ }
551
599
  if (res.usage) {
552
600
  this.stats.inputTokens += res.usage.inputTokens ?? 0;
601
+ this.stats.cachedInputTokens += res.usage.cachedInputTokens ?? 0;
553
602
  this.stats.outputTokens += res.usage.outputTokens ?? 0;
554
603
  await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
555
604
  }
@@ -561,6 +610,8 @@ export class Agent {
561
610
  reasoning: res.reasoning,
562
611
  });
563
612
  this.messages.push(m);
613
+ this.turnsSinceProgress++;
614
+ this.activityChars += m.content?.length ?? 0;
564
615
  if (!m.tool_calls?.length)
565
616
  return finished;
566
617
  for (const call of m.tool_calls) {
@@ -606,6 +657,25 @@ export class Agent {
606
657
  });
607
658
  continue;
608
659
  }
660
+ if (call.function.name === "get_todo") {
661
+ this.messages.push({
662
+ role: "tool",
663
+ tool_call_id: call.id,
664
+ content: this.todo.trim() || "(todo.md is empty — no task list yet)",
665
+ });
666
+ continue;
667
+ }
668
+ if (call.function.name === "set_todo") {
669
+ const a = safeParse(call.function.arguments);
670
+ const content = String(a.content ?? "").slice(0, 32_000);
671
+ await this.setTodo(content, "agent");
672
+ this.messages.push({
673
+ role: "tool",
674
+ tool_call_id: call.id,
675
+ content: "task list updated (visible to the operator)",
676
+ });
677
+ continue;
678
+ }
609
679
  if (call.function.name === "read_memory") {
610
680
  const mem = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
611
681
  this.messages.push({
@@ -677,9 +747,14 @@ export class Agent {
677
747
  });
678
748
  }
679
749
  async maybeRequestProgress() {
680
- if (Date.now() - this.lastProgressAt < this.opts.progressIntervalMs)
681
- return;
750
+ const elapsedOk = Date.now() - this.lastProgressAt >= this.opts.progressIntervalMs;
751
+ const activityOk = this.activityChars >= this.opts.progressMinChars ||
752
+ this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
753
+ if (!elapsedOk || !activityOk)
754
+ return; // stalling provider → don't waste a turn asking
682
755
  this.lastProgressAt = Date.now();
756
+ this.activityChars = 0;
757
+ this.turnsSinceProgress = 0;
683
758
  const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
684
759
  "what you recently tried, any problems, and your next step. Keep it under 10 lines.";
685
760
  // log both sides so a session restore replays this exchange faithfully
@@ -815,6 +890,10 @@ export class Agent {
815
890
  next: str(a.next) || undefined,
816
891
  ts: new Date().toISOString(),
817
892
  };
893
+ // a report (voluntary or requested) restarts the progress gates
894
+ this.lastProgressAt = Date.now();
895
+ this.activityChars = 0;
896
+ this.turnsSinceProgress = 0;
818
897
  await this.log.append("progress", this.currentSession, this.currentBranch, this.latestProgress);
819
898
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
820
899
  }
@@ -921,6 +1000,26 @@ function allToolSpecs() {
921
1000
  parameters: { type: "object", properties: {} },
922
1001
  },
923
1002
  },
1003
+ {
1004
+ type: "function",
1005
+ function: {
1006
+ name: "get_todo",
1007
+ description: "Fetch the operator-maintained task list (todo.md). Check it when picking up work or when unsure what to do next.",
1008
+ parameters: { type: "object", properties: {} },
1009
+ },
1010
+ },
1011
+ {
1012
+ type: "function",
1013
+ function: {
1014
+ name: "set_todo",
1015
+ description: "Replace the operator-visible task list (todo.md) — e.g. check off finished items or restate what remains. Keep it terse.",
1016
+ parameters: {
1017
+ type: "object",
1018
+ properties: { content: { type: "string" } },
1019
+ required: ["content"],
1020
+ },
1021
+ },
1022
+ },
924
1023
  {
925
1024
  type: "function",
926
1025
  function: {
@@ -962,3 +1061,120 @@ function safeParse(json) {
962
1061
  return {};
963
1062
  }
964
1063
  }
1064
+ /** Walk parent links backwards from the newest event, then flip forward. */
1065
+ function lineageOf(events) {
1066
+ if (events.length === 0)
1067
+ return [];
1068
+ const byId = new Map(events.map((e) => [e.id, e]));
1069
+ const last = events[events.length - 1];
1070
+ const lineage = [];
1071
+ const seen = new Set();
1072
+ for (let cur = last; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
1073
+ if (seen.has(cur.id))
1074
+ break;
1075
+ seen.add(cur.id);
1076
+ lineage.push(cur);
1077
+ }
1078
+ lineage.reverse();
1079
+ // the trailing fork event itself is bookkeeping, not conversation
1080
+ while (lineage.length && lineage[0].type === "fork")
1081
+ lineage.shift();
1082
+ return lineage;
1083
+ }
1084
+ /**
1085
+ * Replay ordered events into ChatMessages (shared by session restore and
1086
+ * prompt-edit forks). Prompts logged inside an open tool batch are buffered
1087
+ * until it closes, so user messages never split a tool_call/tool_result pair.
1088
+ */
1089
+ function rebuildMessagesFrom(list) {
1090
+ const msgs = [];
1091
+ const META_TOOLS = new Set([
1092
+ "finish", "report_progress", "set_goal", "get_goal",
1093
+ "read_memory", "set_memory", "list_skills", "get_todo", "set_todo",
1094
+ ]);
1095
+ const openCalls = new Map(); // real tool_call id -> name
1096
+ const bufferedUsers = [];
1097
+ const flushUsers = () => {
1098
+ if (openCalls.size === 0) {
1099
+ for (const text of bufferedUsers.splice(0))
1100
+ msgs.push({ role: "user", content: text });
1101
+ }
1102
+ };
1103
+ for (const e of list) {
1104
+ const d = e.data;
1105
+ if (e.type === "prompt" && typeof d.text === "string") {
1106
+ if (openCalls.size > 0)
1107
+ bufferedUsers.push(d.text);
1108
+ else
1109
+ msgs.push({ role: "user", content: d.text });
1110
+ }
1111
+ else if (e.type === "message") {
1112
+ const role = d.role === "assistant" ? "assistant" : "user";
1113
+ const m = { role, content: typeof d.content === "string" ? d.content : "" };
1114
+ if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
1115
+ m.tool_calls = d.toolCalls.map((c) => ({
1116
+ id: c.id,
1117
+ type: "function",
1118
+ function: { name: c.name, arguments: "{}" },
1119
+ }));
1120
+ // meta tools are answered inline by the harness (no logged result);
1121
+ // the hole-filling pass below synthesizes theirs where they belong
1122
+ for (const t of m.tool_calls)
1123
+ if (!META_TOOLS.has(t.function.name))
1124
+ openCalls.set(t.id, t.function.name);
1125
+ }
1126
+ msgs.push(m);
1127
+ }
1128
+ else if (e.type === "tool_call") {
1129
+ // enrich the preceding assistant tool_calls with real arguments
1130
+ const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
1131
+ const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
1132
+ if (tc)
1133
+ tc.function.arguments = JSON.stringify(d.args ?? {});
1134
+ }
1135
+ else if (e.type === "tool_result") {
1136
+ msgs.push({
1137
+ role: "tool",
1138
+ tool_call_id: String(d.callId ?? ""),
1139
+ content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
1140
+ });
1141
+ openCalls.delete(String(d.callId ?? ""));
1142
+ flushUsers();
1143
+ }
1144
+ else if (e.type === "progress") {
1145
+ // progress events may follow an assistant report_progress call that
1146
+ // has no logged tool result — patch it in when present
1147
+ const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
1148
+ if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
1149
+ for (const t of lastAssistant.tool_calls) {
1150
+ if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1151
+ msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
1152
+ }
1153
+ openCalls.delete(t.id);
1154
+ }
1155
+ flushUsers();
1156
+ }
1157
+ }
1158
+ }
1159
+ // every assistant tool_call must be answered by a tool message, or the
1160
+ // API rejects the sequence — close any holes left by meta tools (finish)
1161
+ for (let i = 0; i < msgs.length; i++) {
1162
+ const m = msgs[i];
1163
+ if (m.role === "assistant" && m.tool_calls?.length) {
1164
+ for (const t of m.tool_calls) {
1165
+ if (!msgs.slice(i + 1).some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1166
+ msgs.splice(i + 1, 0, {
1167
+ role: "tool",
1168
+ tool_call_id: t.id,
1169
+ content: t.function.name === "finish" ? `(round ended: ${m.content || "finished"})` : "(no result recorded)",
1170
+ });
1171
+ i++;
1172
+ }
1173
+ }
1174
+ }
1175
+ }
1176
+ // prompts that were still waiting on a hole-filled tail land here
1177
+ for (const text of bufferedUsers.splice(0))
1178
+ msgs.push({ role: "user", content: text });
1179
+ return msgs;
1180
+ }