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.
- package/README.md +6 -1
- package/dist/agent/agent.js +408 -55
- package/dist/agent/llm.js +4 -2
- package/dist/agent/tools.js +123 -1
- package/dist/config-schema.js +51 -0
- package/dist/index.js +28 -4
- package/dist/master.js +270 -2
- package/dist/server/api.js +109 -11
- package/package.json +4 -2
- package/public/assets/addon-fit-DIOBYJe3.js +2 -1
- package/public/assets/addon-fit-DIOBYJe3.js.map +1 -0
- package/public/assets/index-4DOCB8Mz.js +15 -0
- package/public/assets/index-4DOCB8Mz.js.map +1 -0
- package/public/assets/index-Q714iow7.css +1 -0
- package/public/assets/xterm-C3BHN0de.js +2 -1
- package/public/assets/xterm-C3BHN0de.js.map +1 -0
- package/public/index.html +2 -2
- package/skills/gyaru-review/SKILL.md +41 -0
- package/skills/qa-adversarial/SKILL.md +41 -0
- package/public/assets/index-BJB4iFSq.js +0 -14
- package/public/assets/index-C4cJD5q_.css +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
|
250
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 === "
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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:
|
|
778
|
+
content: fb.trim() || "(no feedback rules recorded yet)",
|
|
676
779
|
});
|
|
677
780
|
continue;
|
|
678
781
|
}
|
|
679
|
-
if (call.function.name === "
|
|
680
|
-
const
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|