teapot-coding-agent 0.11.0 → 0.12.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.
@@ -26,9 +26,14 @@ Session state is not injected into prompts — fetch it with tools instead:
26
26
  compaction notice, or whenever you lose the thread.
27
27
  - set_goal(text) → change the objective itself (not routine updates).
28
28
  - finish(goalComplete=true, summary) → goal fully achieved.
29
+ - ask_user(question, options?) → park the loop and wait for the operator's
30
+ decision (plan confirmation, ambiguity). One concrete question at a time.
29
31
  - read_memory() / set_memory(content) → your durable notes (memory.md).
30
32
  - get_todo() / set_todo(content) → the operator-maintained task list
31
33
  (todo.md); check it when picking up work, keep it current as you go.
34
+ - When corrected, add_feedback(rule) so it sticks; review via get_feedback().
35
+ - Log significant choices with record_decision(decision, rationale,
36
+ alternatives?) — compaction forgets reasoning, decisions.md doesn't.
32
37
  - list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
33
38
  - AGENTS.md in the workspace root (optional) holds project knowledge — read it
34
39
  with read_file at session start when present, keep it current.
@@ -87,6 +92,12 @@ export class Agent {
87
92
  toolAbort = new AbortController();
88
93
  runChain = Promise.resolve();
89
94
  lastProgressAt = Date.now();
95
+ /** the provider's own prompt_tokens from the last completed turn */
96
+ lastUsage;
97
+ /** messages.length right after the last successful compaction */
98
+ compactedAtLen = 0;
99
+ /** set by ask_user: the loop is parked until the operator replies */
100
+ awaitingUser = false;
90
101
  /** real assistant output since the last progress report (chars / turns) */
91
102
  activityChars = 0;
92
103
  turnsSinceProgress = 0;
@@ -103,6 +114,10 @@ export class Agent {
103
114
  contextWindowTokens: 0,
104
115
  restoreSession: true,
105
116
  globalSkillsDir: "",
117
+ bundledSkillsDir: "",
118
+ spawnDepth: 0,
119
+ readOnlyTools: false,
120
+ parent: "",
106
121
  provider: "",
107
122
  ...opts,
108
123
  };
@@ -116,6 +131,8 @@ export class Agent {
116
131
  this.skillRoots = [
117
132
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
118
133
  ...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
134
+ // shipped-with-package skills: lowest priority, always discoverable
135
+ ...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
119
136
  ];
120
137
  this.toolCtx = {
121
138
  cwd: opts.workspace,
@@ -123,6 +140,7 @@ export class Agent {
123
140
  maxOutputBytes: 60_000,
124
141
  skillRoots: this.skillRoots,
125
142
  signal: this.toolAbort.signal,
143
+ readOnly: this.opts.readOnlyTools,
126
144
  };
127
145
  // the session id IS the directory name — one directory per incarnation
128
146
  this.mainSession = path.basename(opts.sessionDir);
@@ -147,6 +165,15 @@ export class Agent {
147
165
  opts_id() {
148
166
  return this.opts.id;
149
167
  }
168
+ /** snapshot of the current conversation for fork-by-reference spawning */
169
+ exportMessages() {
170
+ return [...this.messages];
171
+ }
172
+ /** seed a conversation (fork-by-reference sub-agents) — replaces history */
173
+ importMessages(msgs) {
174
+ this.messages = msgs;
175
+ this.compactedAtLen = 0;
176
+ }
150
177
  get workspace() {
151
178
  return this.opts.workspace;
152
179
  }
@@ -208,6 +235,12 @@ export class Agent {
208
235
  get todoFile() {
209
236
  return path.join(this.opts.sessionDir, "todo.md");
210
237
  }
238
+ get feedbackFile() {
239
+ return path.join(this.opts.sessionDir, "feedback.md");
240
+ }
241
+ get decisionsFile() {
242
+ return path.join(this.opts.sessionDir, "decisions.md");
243
+ }
211
244
  async readGoalStoreRaw() {
212
245
  return fs.readFile(this.goalFile, "utf8").catch(() => null);
213
246
  }
@@ -245,10 +278,55 @@ export class Agent {
245
278
  * (finish / report_progress) never produced logged tool results, so we
246
279
  * synthesize their responses to keep the message sequence valid.
247
280
  */
281
+ /**
282
+ * Resolve a leading sub_fork header: load the parent session's file (and
283
+ * recurse if THAT one is itself a sub_fork), take its lineage up to the
284
+ * recorded event, and return it prepended to our own events. Never copies
285
+ * bytes into our log — the prefix lives in the parent's file. Cycle-safe.
286
+ */
287
+ async spliceSubForkPrefix(own, baseDir = path.dirname(this.log.filePath)) {
288
+ const header = own.find((e) => e.type === "sub_fork");
289
+ if (!header)
290
+ return own;
291
+ const d = header.data;
292
+ if (!d.parentSession || !d.upToEvent)
293
+ return own;
294
+ const parentDir = path.join(baseDir, d.parentSession);
295
+ let parentEvents = await readEvents(path.join(parentDir, "chat.jsonl")).catch(() => []);
296
+ // grandchild chains: the parent file may itself open with a sub_fork
297
+ if (parentEvents.some((e) => e.type === "sub_fork")) {
298
+ parentEvents = await this.spliceSubForkPrefix(parentEvents, parentDir);
299
+ }
300
+ if (parentEvents.length === 0) {
301
+ console.warn(`[teapot] sub_fork: parent session ${d.parentSession} unreadable — starting without inherited context`);
302
+ return own;
303
+ }
304
+ // lineage of the parent up to (and including) the recorded fork point
305
+ const byId = new Map(parentEvents.map((e) => [e.id, e]));
306
+ const tip = byId.get(d.upToEvent);
307
+ if (!tip) {
308
+ console.warn(`[teapot] sub_fork: fork point ${d.upToEvent} not found in ${d.parentSession}`);
309
+ return own;
310
+ }
311
+ const prefix = [];
312
+ for (let cur = tip; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
313
+ if (prefix.some((p) => p.id === cur.id))
314
+ break; // cycle guard
315
+ prefix.push(cur);
316
+ }
317
+ prefix.reverse();
318
+ while (prefix.length && prefix[0].type === "fork")
319
+ prefix.shift();
320
+ return [...prefix, ...own.filter((e) => e !== header)];
321
+ }
248
322
  async restoreFromLog() {
249
- const events = await readEvents(this.log.filePath);
250
- if (events.length === 0)
323
+ const own = await readEvents(this.log.filePath);
324
+ if (own.length === 0)
251
325
  return;
326
+ // a sub_fork header points at the session this agent branched from —
327
+ // splice that prefix in from the parent's file (recursively, cycle-safe)
328
+ // instead of ever copying parent history into our own log
329
+ const events = await this.spliceSubForkPrefix(own);
252
330
  const lineage = lineageOf(events);
253
331
  if (!lineage.length)
254
332
  return;
@@ -264,6 +342,20 @@ export class Agent {
264
342
  });
265
343
  }
266
344
  }
345
+ /**
346
+ * Force a compaction pass (manual /compact). Serialized on the run chain so
347
+ * it lands at a safe point relative to a running loop; reports whether
348
+ * anything was actually compacted.
349
+ */
350
+ async compactNow() {
351
+ const ran = await this.enqueue(async () => {
352
+ await this.ensureReady();
353
+ const before = this.stats.compactions;
354
+ await this.maybeCompact(true);
355
+ return this.stats.compactions > before;
356
+ });
357
+ return { ran };
358
+ }
267
359
  /**
268
360
  * Edit a previously-sent prompt: fork the conversation at that point,
269
361
  * replace its text, and optionally fold everything that happened after it
@@ -376,12 +468,14 @@ export class Agent {
376
468
  provider: this.opts.provider,
377
469
  sessionDir: this.opts.sessionDir,
378
470
  ctx: {
379
- usedTokens: this.estimateTokens(),
471
+ usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
380
472
  compactAt: this.opts.contextTokenBudget,
381
473
  window: this.opts.contextWindowTokens || 0,
382
474
  },
383
475
  pendingPrompts: this.pendingPrompts.length,
384
476
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
477
+ parent: this.opts.parent,
478
+ awaiting: this.awaitingUser,
385
479
  };
386
480
  }
387
481
  /**
@@ -420,6 +514,7 @@ export class Agent {
420
514
  if (this.status === "running")
421
515
  return;
422
516
  this.stopRequested = false;
517
+ this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
423
518
  void this.enqueue(async () => {
424
519
  await this.ensureReady(); // lazy restore before the loop touches history
425
520
  this.setStatus("running", reason);
@@ -479,7 +574,7 @@ export class Agent {
479
574
  catch (err) {
480
575
  const name = err.name;
481
576
  // a stop (user abort or pre-call guard) is control flow, not a failure
482
- if (this.stopRequested || name === "AbortError" || name === "StopRequested")
577
+ if (this.stopRequested || name === "AbortError" || name === "StopRequested" || name === "WaitForUser")
483
578
  break;
484
579
  const msg = err.message ?? String(err);
485
580
  await this.log.append("error", this.currentSession, this.currentBranch, { message: msg });
@@ -487,7 +582,9 @@ export class Agent {
487
582
  return;
488
583
  }
489
584
  }
490
- if (!this.stopRequested)
585
+ // waiting on an ask_user answer is a status of its own — not idle (which
586
+ // would let auto-continue nag) and not stopped
587
+ if (!this.stopRequested && !this.awaitingUser)
491
588
  this.setStatus("idle", "round complete");
492
589
  }
493
590
  /**
@@ -504,7 +601,9 @@ export class Agent {
504
601
  */
505
602
  async llmCall(messages, tools, onDelta) {
506
603
  const maxAttempts = 4;
507
- const waits = [30_000, 60_000, 120_000];
604
+ // fail fast, escalate late: most provider hiccups recover in seconds;
605
+ // only sustained failure earns a long cooldown (operator request)
606
+ const waits = [5_000, 5_000, 30_000];
508
607
  for (let attempt = 1;; attempt++) {
509
608
  if (this.stopRequested)
510
609
  throw Object.assign(new Error("stopped"), { name: "StopRequested" });
@@ -600,6 +699,11 @@ export class Agent {
600
699
  this.stats.inputTokens += res.usage.inputTokens ?? 0;
601
700
  this.stats.cachedInputTokens += res.usage.cachedInputTokens ?? 0;
602
701
  this.stats.outputTokens += res.usage.outputTokens ?? 0;
702
+ this.lastUsage = {
703
+ input: res.usage.inputTokens ?? 0,
704
+ output: res.usage.outputTokens ?? 0,
705
+ cached: res.usage.cachedInputTokens,
706
+ };
603
707
  await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
604
708
  }
605
709
  const m = res.message;
@@ -620,21 +724,13 @@ export class Agent {
620
724
  if (call.function.name === "finish") {
621
725
  await this.handleFinish(call.function.arguments);
622
726
  // answer the tool_call so a follow-up round stays API-valid
623
- this.messages.push({
624
- role: "tool",
625
- tool_call_id: call.id,
626
- content: this.goal.status === "done" ? "(goal complete)" : "(round ended)",
627
- });
727
+ await this.answerMeta(call, this.goal.status === "done" ? "(goal complete)" : "(round ended)");
628
728
  finished = true;
629
729
  continue;
630
730
  }
631
731
  if (call.function.name === "report_progress") {
632
732
  await this.recordProgress(call.function.arguments);
633
- this.messages.push({
634
- role: "tool",
635
- tool_call_id: call.id,
636
- content: "progress recorded",
637
- });
733
+ await this.answerMeta(call, "progress recorded");
638
734
  continue;
639
735
  }
640
736
  if (call.function.name === "set_goal") {
@@ -642,72 +738,134 @@ export class Agent {
642
738
  const text = String(a.text ?? "").trim();
643
739
  if (text)
644
740
  await this.setGoal(text);
645
- this.messages.push({
646
- role: "tool",
647
- tool_call_id: call.id,
648
- content: text ? "goal updated" : "empty goal rejected",
649
- });
741
+ await this.answerMeta(call, text ? "goal updated" : "empty goal rejected");
650
742
  continue;
651
743
  }
652
744
  if (call.function.name === "get_goal") {
653
- this.messages.push({
654
- role: "tool",
655
- tool_call_id: call.id,
656
- content: JSON.stringify({ goal: this.goal.text || "(none set)", status: this.goal.status }, null, 1),
657
- });
745
+ await this.answerMeta(call, JSON.stringify({ goal: this.goal.text || "(none set)", status: this.goal.status }, null, 1));
658
746
  continue;
659
747
  }
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)",
748
+ if (call.function.name === "ask_user") {
749
+ const a = safeParse(call.function.arguments);
750
+ const question = String(a.question ?? "").slice(0, 2000);
751
+ const options = Array.isArray(a.options) ? a.options.map(String).slice(0, 6) : [];
752
+ await this.log.append("question", this.currentSession, this.currentBranch, {
753
+ question,
754
+ options,
665
755
  });
756
+ await this.answerMeta(call, "question shown to the operator — the loop is parked until they reply");
757
+ this.awaitingUser = true;
758
+ this.setStatus("waiting", question.slice(0, 80));
759
+ // control flow: park the loop; the operator's next prompt resumes it
760
+ throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
761
+ }
762
+ if (call.function.name === "get_todo") {
763
+ await this.answerMeta(call, this.todo.trim() || "(todo.md is empty — no task list yet)");
666
764
  continue;
667
765
  }
668
766
  if (call.function.name === "set_todo") {
669
767
  const a = safeParse(call.function.arguments);
670
768
  const content = String(a.content ?? "").slice(0, 32_000);
671
769
  await this.setTodo(content, "agent");
770
+ await this.answerMeta(call, "task list updated (visible to the operator)");
771
+ continue;
772
+ }
773
+ if (call.function.name === "get_feedback") {
774
+ const fb = await fs.readFile(this.feedbackFile, "utf8").catch(() => "");
672
775
  this.messages.push({
673
776
  role: "tool",
674
777
  tool_call_id: call.id,
675
- content: "task list updated (visible to the operator)",
778
+ content: fb.trim() || "(no feedback rules recorded yet)",
676
779
  });
677
780
  continue;
678
781
  }
679
- if (call.function.name === "read_memory") {
680
- const mem = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
782
+ if (call.function.name === "add_feedback") {
783
+ const a = safeParse(call.function.arguments);
784
+ const rule = String(a.rule ?? "").trim().slice(0, 500);
785
+ if (!rule) {
786
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: "rule required" });
787
+ continue;
788
+ }
789
+ // repeated corrections gain weight: [xN] tag counts occurrences
790
+ const existing = await fs.readFile(this.feedbackFile, "utf8").catch(() => "");
791
+ const lines = existing.split("\n");
792
+ const idx = lines.findIndex((l) => l.includes(rule.slice(0, 60)));
793
+ if (idx !== -1 && /^\s*- /.test(lines[idx])) {
794
+ const m = lines[idx].match(/\[x(\d+)\]/);
795
+ const count = m ? Number(m[1]) + 1 : 2;
796
+ lines[idx] = lines[idx].replace(/\[x\d+\]\s*/, "").replace(/- /, `- [x${count}] `);
797
+ await fs.writeFile(this.feedbackFile, lines.join("\n"), "utf8");
798
+ this.messages.push({
799
+ role: "tool",
800
+ tool_call_id: call.id,
801
+ content: `rule already existed — count raised to ${count}. Repeated violations will be enforced more strictly.`,
802
+ });
803
+ }
804
+ else {
805
+ const entry = `\n- [x1] ${rule}`;
806
+ await fs.writeFile(this.feedbackFile, existing + (existing ? "\n" : "") + "# Feedback rules\n" + entry, "utf8");
807
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: "feedback rule recorded — follow it from now on" });
808
+ }
809
+ continue;
810
+ }
811
+ if (call.function.name === "record_decision") {
812
+ const a = safeParse(call.function.arguments);
813
+ const decision = String(a.decision ?? "").trim().slice(0, 500);
814
+ const rationale = String(a.rationale ?? "").trim().slice(0, 2000);
815
+ const alternatives = Array.isArray(a.alternatives) ? a.alternatives.map(String).slice(0, 5) : [];
816
+ if (!decision || !rationale) {
817
+ this.messages.push({
818
+ role: "tool",
819
+ tool_call_id: call.id,
820
+ content: "decision and rationale are both required — record why, not just what",
821
+ });
822
+ continue;
823
+ }
824
+ await fs.appendFile(this.decisionsFile, `\n## ${new Date().toISOString()} — ${decision}\n` +
825
+ `- Why: ${rationale}\n` +
826
+ (alternatives.length ? `- Alternatives considered:\n${alternatives.map((x) => ` - ${x}`).join("\n")}\n` : ""), "utf8");
827
+ await this.log.append("decision", this.currentSession, this.currentBranch, {
828
+ decision,
829
+ rationale,
830
+ alternatives,
831
+ });
832
+ await this.answerMeta(call, "decision recorded to decisions.md");
833
+ continue;
834
+ }
835
+ if (call.function.name === "get_decisions") {
836
+ const dec = await fs.readFile(this.decisionsFile, "utf8").catch(() => "");
681
837
  this.messages.push({
682
838
  role: "tool",
683
839
  tool_call_id: call.id,
684
- content: mem.trim() || "(memory.md is empty — nothing noted yet)",
840
+ content: dec.trim() || "(decisions.md is empty — no decisions recorded yet)",
685
841
  });
686
842
  continue;
687
843
  }
844
+ if (call.function.name === "read_memory") {
845
+ const mem = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
846
+ await this.answerMeta(call, mem.trim() || "(memory.md is empty — nothing noted yet)");
847
+ continue;
848
+ }
688
849
  if (call.function.name === "list_skills") {
689
850
  await this.refreshSkills();
690
851
  const list = this.skillsCache.length
691
852
  ? this.skillsCache.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n")
692
853
  : "(no skills yet — create one with save_skill)";
693
- this.messages.push({ role: "tool", tool_call_id: call.id, content: list });
854
+ await this.answerMeta(call, list);
694
855
  continue;
695
856
  }
696
857
  if (call.function.name === "set_memory") {
697
858
  const a = safeParse(call.function.arguments);
698
859
  const content = String(a.content ?? "").slice(0, 32_000);
699
860
  await fs.writeFile(this.memoryFile, content, "utf8");
700
- this.messages.push({
701
- role: "tool",
702
- tool_call_id: call.id,
703
- content: "memory saved (injected into future prompts)",
704
- });
861
+ await this.answerMeta(call, "memory saved (injected into future prompts)");
705
862
  continue;
706
863
  }
707
864
  await this.log.append("tool_call", this.currentSession, this.currentBranch, {
708
865
  callId: call.id,
709
866
  name: call.function.name,
710
867
  args: safeParse(call.function.arguments),
868
+ argsRaw: call.function.arguments, // byte-exact for cache-safe restore
711
869
  });
712
870
  const t0 = Date.now();
713
871
  const result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
@@ -773,13 +931,31 @@ export class Agent {
773
931
  this.messages.push(res.message);
774
932
  }
775
933
  /* ---------- context compaction ---------- */
776
- /** rough token estimate (~4 chars/token); good enough to trigger before overflow */
934
+ /**
935
+ * Rough token estimate good enough to trigger compaction before overflow.
936
+ * ASCII runs ≈ 4 chars/token; CJK (kana/kanji/hanja and friends) ≈ 1
937
+ * token/char — the old flat /4 underestimated Japanese sessions ~4x.
938
+ * Adds a small per-message overhead for role/framing tokens.
939
+ */
777
940
  estimateTokens() {
778
- let chars = 0;
941
+ let ascii = 0;
942
+ let wide = 0;
943
+ const count = (s) => {
944
+ for (let i = 0; i < s.length; i++) {
945
+ if (s.charCodeAt(i) > 0x2e7f)
946
+ wide++;
947
+ else
948
+ ascii++;
949
+ }
950
+ };
779
951
  for (const m of this.messages) {
780
- chars += (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? "").length;
952
+ count(m.content ?? "");
953
+ for (const t of m.tool_calls ?? []) {
954
+ count(t.function.name);
955
+ count(t.function.arguments);
956
+ }
781
957
  }
782
- return Math.ceil(chars / 4);
958
+ return Math.ceil(ascii / 4 + wide * 1.2 + this.messages.length * 8);
783
959
  }
784
960
  /**
785
961
  * Latest index whose message may START the kept tail of a compacted
@@ -794,11 +970,87 @@ export class Agent {
794
970
  }
795
971
  return -1;
796
972
  }
797
- /** Compact history when the estimated token count exceeds the budget. */
798
- async maybeCompact() {
799
- const before = this.estimateTokens();
800
- if (before < this.opts.contextTokenBudget)
973
+ /**
974
+ * Answer a meta tool call (finish / get_goal / ask_user / …): into the
975
+ * in-memory history AND the log as a regular tool_result, so session
976
+ * restores replay the exact same bytes and provider prefix caches stay
977
+ * warm across restarts.
978
+ */
979
+ async answerMeta(call, content) {
980
+ const raw = call.function.arguments ?? "{}";
981
+ this.messages.push({ role: "tool", tool_call_id: call.id, content });
982
+ // log the call AND its answer so restores replay byte-exact sequences
983
+ // (meta calls previously went unlogged and restored as bare "{}" args)
984
+ await this.log.append("tool_call", this.currentSession, this.currentBranch, {
985
+ callId: call.id,
986
+ name: call.function.name,
987
+ args: safeParse(raw),
988
+ argsRaw: raw,
989
+ });
990
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
991
+ callId: call.id,
992
+ name: call.function.name,
993
+ ok: true,
994
+ durationMs: 0,
995
+ result: content,
996
+ });
997
+ }
998
+ /**
999
+ * Stage 1 of context management (OpenCode-style): before paying for a full
1000
+ * summarize, clip OLD oversized tool outputs — they are the usual bulk and
1001
+ * their details rarely matter once executed. The most recent window is
1002
+ * protected so current work never loses its footing.
1003
+ */
1004
+ maybePrune() {
1005
+ const est = this.lastUsage?.input ?? this.estimateTokens();
1006
+ const budget = this.opts.contextTokenBudget;
1007
+ if (!budget || est < budget * 0.6)
1008
+ return 0; // prune only when it matters
1009
+ // protect the recent tail (~half the budget in chars) from any pruning
1010
+ const protectChars = budget * 2;
1011
+ let seen = 0;
1012
+ let boundary = this.messages.length;
1013
+ for (let i = this.messages.length - 1; i >= 0; i--) {
1014
+ seen += this.messages[i].content?.length ?? 0;
1015
+ boundary = i;
1016
+ if (seen >= protectChars)
1017
+ break;
1018
+ }
1019
+ const PRUNE_MIN = 3_000;
1020
+ let saved = 0;
1021
+ let count = 0;
1022
+ for (let i = 0; i < boundary; i++) {
1023
+ const m = this.messages[i];
1024
+ if (m.role === "tool" && (m.content?.length ?? 0) > PRUNE_MIN) {
1025
+ const len = m.content.length;
1026
+ saved += len - 400;
1027
+ m.content = m.content.slice(0, 400) + `\n…[pruned ${len} bytes of tool output]`;
1028
+ count++;
1029
+ }
1030
+ }
1031
+ if (count > 0)
1032
+ void this.log.append("system_note", this.currentSession, this.currentBranch, {
1033
+ event: "context-pruned",
1034
+ outputs: count,
1035
+ savedBytes: saved,
1036
+ });
1037
+ return count;
1038
+ }
1039
+ /** Compact history when the real prompt size exceeds the budget. */
1040
+ async maybeCompact(force = false) {
1041
+ // prefer the provider's own count from the last response — it is exactly
1042
+ // what would overflow the window; the char heuristic is only a fallback
1043
+ // for providers that omit usage
1044
+ // stage 1: clip oversized old tool outputs before considering a summarize
1045
+ this.maybePrune();
1046
+ const before = this.lastUsage?.input ?? this.estimateTokens();
1047
+ if (!force && before < this.opts.contextTokenBudget)
801
1048
  return;
1049
+ // a forced pass on an already-tiny history would just summarize the
1050
+ // summary — report ran=false instead
1051
+ if (force && this.messages.length <= this.compactedAtLen)
1052
+ return;
1053
+ this.lastUsage = undefined; // stale after compaction — re-armed next turn
802
1054
  // keep roughly the most recent quarter of the budget as live context
803
1055
  const keepCharBudget = (this.opts.contextTokenBudget / 4) * 4;
804
1056
  let keepChars = 0;
@@ -820,6 +1072,8 @@ export class Agent {
820
1072
  let droppedCount = 0;
821
1073
  try {
822
1074
  summary = await this.summarize(old);
1075
+ if (summary)
1076
+ await this.harvestLessons(summary); // durable knowledge → memory.md
823
1077
  }
824
1078
  catch (err) {
825
1079
  await this.log.append("error", this.currentSession, this.currentBranch, {
@@ -849,6 +1103,7 @@ export class Agent {
849
1103
  }
850
1104
  const after = this.estimateTokens();
851
1105
  this.stats.compactions++;
1106
+ this.compactedAtLen = this.messages.length;
852
1107
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
853
1108
  event: "context-compacted",
854
1109
  tokensBefore: before,
@@ -873,13 +1128,28 @@ export class Agent {
873
1128
  {
874
1129
  role: "system",
875
1130
  content: "You compress a coding agent's conversation into dense notes for it to continue working. " +
876
- "Preserve: current goal state, key decisions, files created/changed, important command results, " +
877
- "open problems, and the next step. Be terse bullet points, no prose flourishes.",
1131
+ "Preserve: current goal state, key decisions AND the reasoning behind them, files created/changed, " +
1132
+ "important command results, open problems, and the next step. Be terse bullet points, no prose flourishes. " +
1133
+ 'Finish with a section "## Durable lessons" listing reusable insights worth keeping forever ' +
1134
+ "(gotchas, user preferences, what worked) — or omit the section if there are none.",
878
1135
  },
879
1136
  { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
880
1137
  ], [], undefined);
881
1138
  return res.message.content ?? "";
882
1139
  }
1140
+ /**
1141
+ * Extract the "## Durable lessons" block from a compaction summary and
1142
+ * append it to memory.md — knowledge survives compaction automatically
1143
+ * (inspired by hook-driven CLAUDE.md growers; zero extra LLM calls).
1144
+ */
1145
+ async harvestLessons(summary) {
1146
+ const m = summary.match(/##\s*Durable lessons?\s*\n([\s\S]*?)(?=\n##\s|$)/i);
1147
+ const lessons = m?.[1]?.trim();
1148
+ if (!lessons)
1149
+ return;
1150
+ const stamped = `\n<!-- lessons harvested from compaction ${new Date().toISOString()} -->\n${lessons}\n`;
1151
+ await fs.appendFile(this.memoryFile, stamped, "utf8").catch(() => { });
1152
+ }
883
1153
  async recordProgress(argsJson) {
884
1154
  const a = safeParse(argsJson);
885
1155
  this.latestProgress = {
@@ -992,6 +1262,27 @@ function allToolSpecs() {
992
1262
  },
993
1263
  },
994
1264
  },
1265
+ {
1266
+ type: "function",
1267
+ function: {
1268
+ name: "ask_user",
1269
+ description: "Pause and ask the operator a question — plan confirmation, ambiguous requirements, " +
1270
+ "a decision only they can make. The loop parks until they reply; their message arrives " +
1271
+ "as your next user turn. Use sparingly: do your homework first, then ask once, concretely.",
1272
+ parameters: {
1273
+ type: "object",
1274
+ properties: {
1275
+ question: { type: "string", description: "what you need decided — include the context and trade-offs" },
1276
+ options: {
1277
+ type: "array",
1278
+ items: { type: "string" },
1279
+ description: "optional short answer choices the operator can tap",
1280
+ },
1281
+ },
1282
+ required: ["question"],
1283
+ },
1284
+ },
1285
+ },
995
1286
  {
996
1287
  type: "function",
997
1288
  function: {
@@ -1000,6 +1291,54 @@ function allToolSpecs() {
1000
1291
  parameters: { type: "object", properties: {} },
1001
1292
  },
1002
1293
  },
1294
+ {
1295
+ type: "function",
1296
+ function: {
1297
+ name: "record_decision",
1298
+ description: "Log a significant choice you made AND why — alternatives considered, trade-offs. Compaction forgets reasoning; this file doesn't.",
1299
+ parameters: {
1300
+ type: "object",
1301
+ properties: {
1302
+ decision: { type: "string", description: "what was decided, in one sentence" },
1303
+ rationale: { type: "string", description: "why — the reasoning and trade-offs" },
1304
+ alternatives: {
1305
+ type: "array",
1306
+ items: { type: "string" },
1307
+ description: "options considered but rejected",
1308
+ },
1309
+ },
1310
+ required: ["decision", "rationale"],
1311
+ },
1312
+ },
1313
+ },
1314
+ {
1315
+ type: "function",
1316
+ function: {
1317
+ name: "get_decisions",
1318
+ description: "Read previously logged decisions and their rationale (decisions.md).",
1319
+ parameters: { type: "object", properties: {} },
1320
+ },
1321
+ },
1322
+ {
1323
+ type: "function",
1324
+ function: {
1325
+ name: "get_feedback",
1326
+ description: "Read the operator's feedback rules (corrections they've given you, with repetition counts). Check after being corrected.",
1327
+ parameters: { type: "object", properties: {} },
1328
+ },
1329
+ },
1330
+ {
1331
+ type: "function",
1332
+ function: {
1333
+ name: "add_feedback",
1334
+ description: "Record a correction as a durable rule (or raise an existing rule's count). Call whenever the operator corrects your behavior so the same mistake isn't repeated.",
1335
+ parameters: {
1336
+ type: "object",
1337
+ properties: { rule: { type: "string", description: "the rule in one imperative sentence" } },
1338
+ required: ["rule"],
1339
+ },
1340
+ },
1341
+ },
1003
1342
  {
1004
1343
  type: "function",
1005
1344
  function: {
@@ -1091,8 +1430,12 @@ function rebuildMessagesFrom(list) {
1091
1430
  const META_TOOLS = new Set([
1092
1431
  "finish", "report_progress", "set_goal", "get_goal",
1093
1432
  "read_memory", "set_memory", "list_skills", "get_todo", "set_todo",
1433
+ "get_feedback", "add_feedback", "record_decision", "get_decisions",
1094
1434
  ]);
1095
1435
  const openCalls = new Map(); // real tool_call id -> name
1436
+ // meta answers are now logged as regular tool_results; the legacy progress
1437
+ // synthesizer below must not duplicate them
1438
+ const loggedResults = new Set(list.filter((e) => e.type === "tool_result").map((e) => String(e.data.callId ?? "")));
1096
1439
  const bufferedUsers = [];
1097
1440
  const flushUsers = () => {
1098
1441
  if (openCalls.size === 0) {
@@ -1109,6 +1452,10 @@ function rebuildMessagesFrom(list) {
1109
1452
  msgs.push({ role: "user", content: d.text });
1110
1453
  }
1111
1454
  else if (e.type === "message") {
1455
+ // final summaries are operator-facing only — the live loop never puts
1456
+ // them in model history, so restores must skip them too
1457
+ if (d.final === true)
1458
+ continue;
1112
1459
  const role = d.role === "assistant" ? "assistant" : "user";
1113
1460
  const m = { role, content: typeof d.content === "string" ? d.content : "" };
1114
1461
  if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
@@ -1126,11 +1473,17 @@ function rebuildMessagesFrom(list) {
1126
1473
  msgs.push(m);
1127
1474
  }
1128
1475
  else if (e.type === "tool_call") {
1129
- // enrich the preceding assistant tool_calls with real arguments
1476
+ // enrich the preceding assistant tool_calls with real arguments
1477
+ // prefer the provider's raw string so restored requests stay
1478
+ // byte-identical (prefix caches stay warm across restarts)
1130
1479
  const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
1131
1480
  const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
1132
- if (tc)
1133
- tc.function.arguments = JSON.stringify(d.args ?? {});
1481
+ if (tc) {
1482
+ if (typeof d.argsRaw === "string")
1483
+ tc.function.arguments = d.argsRaw;
1484
+ else if (d.args !== undefined)
1485
+ tc.function.arguments = JSON.stringify(d.args ?? {});
1486
+ }
1134
1487
  }
1135
1488
  else if (e.type === "tool_result") {
1136
1489
  msgs.push({
@@ -1147,7 +1500,7 @@ function rebuildMessagesFrom(list) {
1147
1500
  const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
1148
1501
  if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
1149
1502
  for (const t of lastAssistant.tool_calls) {
1150
- if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1503
+ if (!loggedResults.has(t.id) && !msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
1151
1504
  msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
1152
1505
  }
1153
1506
  openCalls.delete(t.id);