teapot-coding-agent 0.5.0 → 0.7.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 +21 -9
- package/dist/agent/agent.js +176 -53
- package/dist/master.js +70 -6
- package/dist/server/api.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,13 +110,24 @@ master (Hono server, src/master.ts + src/server/api.ts)
|
|
|
110
110
|
never hard-coded.
|
|
111
111
|
- **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function
|
|
112
112
|
specs — `read_file`, `write_file`, `edit_file`, `list_dir`, `bash` (git goes
|
|
113
|
-
through bash), plus meta tools `finish` / `report_progress
|
|
113
|
+
through bash), plus meta tools `finish` / `report_progress` / `get_goal` /
|
|
114
|
+
`set_goal` / `read_memory` / `set_memory` / `list_skills`. Paths are
|
|
114
115
|
confined to the workspace; bash runs detached in its own process group and
|
|
115
116
|
the whole group is SIGKILLed on timeout.
|
|
116
|
-
- **
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
- **Cache-friendly prompt design** — the system prompt is byte-identical on
|
|
118
|
+
every turn; session state (goal, memory, skills) is fetched via tools, never
|
|
119
|
+
injected. Combined with the append-only message history this keeps provider
|
|
120
|
+
prefix caches hot, so long sessions pay incremental input prices instead of
|
|
121
|
+
re-sending full context every turn.
|
|
122
|
+
- **Session storage** — everything teapot manages lives under
|
|
123
|
+
`<dataDir>/sessions/<sid>/` (`chat.jsonl`, `goal.md`, `memory.md`), so agent
|
|
124
|
+
workspaces stay clean. Each incarnation gets a fresh `<agentId>-<uuid>`
|
|
125
|
+
directory (no history leaks across projects); restarts reuse the latest one.
|
|
126
|
+
Legacy layouts are migrated automatically.
|
|
127
|
+
- **Goal / knowledge** — goal + memory are harness-managed and read/written
|
|
128
|
+
through tools (`get_goal` / `set_goal` / `read_memory` / `set_memory`);
|
|
129
|
+
`AGENTS.md` is optional project knowledge in the workspace root that agents
|
|
130
|
+
are told to read at session start. Nothing is seeded into your project.
|
|
120
131
|
|
|
121
132
|
### Web UI
|
|
122
133
|
|
|
@@ -162,13 +173,14 @@ description: Steps to cut a release safely
|
|
|
162
173
|
|
|
163
174
|
### Session log format (JSONL)
|
|
164
175
|
|
|
165
|
-
One file per
|
|
166
|
-
forks* lives in the same
|
|
176
|
+
One file per session (`<dataDir>/sessions/<sid>/chat.jsonl`, sid =
|
|
177
|
+
`<agentId>-<uuid8>`); every conversation *including forks* lives in the same
|
|
178
|
+
interleaved stream:
|
|
167
179
|
|
|
168
180
|
```json
|
|
169
|
-
{"v":1,"id":"e27","seq":27,"ts":"…","agent":"alpha","session":"
|
|
181
|
+
{"v":1,"id":"e27","seq":27,"ts":"…","agent":"alpha","session":"alpha-9f3c21ab",
|
|
170
182
|
"branch":"br032sl","parent":"e26","type":"fork",
|
|
171
|
-
"data":{"fromSession":"
|
|
183
|
+
"data":{"fromSession":"alpha-9f3c21ab","fromBranch":"br0","fromEvent":"e26","newBranch":"br032sl"}}
|
|
172
184
|
```
|
|
173
185
|
|
|
174
186
|
- every event carries `session`, `branch`, `parent` (previous event on the same branch), monotonic `seq`
|
package/dist/agent/agent.js
CHANGED
|
@@ -14,19 +14,32 @@ import { executeTool, toolSpecs, currentSkills } from "./tools.js";
|
|
|
14
14
|
import { bus } from "../bus.js";
|
|
15
15
|
const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- MEMORY.md : durable notes you write for yourself
|
|
17
|
+
This system prompt is intentionally STATIC — session state is never injected
|
|
18
|
+
into it, so API prompt caches stay hot across turns. Fetch state with tools
|
|
19
|
+
instead; they are cheap and always current.
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
## Goal (harness-managed)
|
|
22
|
+
- get_goal() → current objective + status. Call it at session start, after a
|
|
23
|
+
compaction notice, or whenever you lose the thread.
|
|
24
|
+
- set_goal(text) → change the objective itself (not routine updates).
|
|
25
|
+
- finish(goalComplete=true, summary) → goal fully achieved.
|
|
26
|
+
|
|
27
|
+
## Your notes (memory.md, harness-managed)
|
|
28
|
+
- read_memory() / set_memory(content) → durable notes injected nowhere else.
|
|
29
|
+
Keep them terse: decisions, gotchas, where you left off.
|
|
30
|
+
|
|
31
|
+
## Skills (reusable playbooks)
|
|
32
|
+
- list_skills() → what exists. load_skill(name) → full playbook.
|
|
33
|
+
- save_skill(name, description, content) → distill a reusable procedure.
|
|
34
|
+
|
|
35
|
+
## Project knowledge
|
|
36
|
+
- AGENTS.md in the workspace root (optional): conventions & commands. Read it
|
|
37
|
+
with read_file at session start when present, keep it current.
|
|
23
38
|
|
|
24
39
|
## Rules
|
|
25
40
|
- Work step by step with tools. Verify results (run tests/builds) before claiming progress.
|
|
26
|
-
- When a
|
|
27
|
-
- When you develop a reusable procedure, save_skill it — skills persist and are offered to future sessions.
|
|
41
|
+
- When a loaded skill matches your task, follow its playbook.
|
|
28
42
|
- When you make meaningful progress, call report_progress.
|
|
29
|
-
- When the goal is fully achieved, call finish(goalComplete=true) with a short summary.
|
|
30
43
|
- Be frugal: prefer small precise edits, avoid runaway loops.`;
|
|
31
44
|
export class Agent {
|
|
32
45
|
log;
|
|
@@ -66,7 +79,7 @@ export class Agent {
|
|
|
66
79
|
provider: "",
|
|
67
80
|
...opts,
|
|
68
81
|
};
|
|
69
|
-
this.log = new EventLog(opts.
|
|
82
|
+
this.log = new EventLog(path.join(opts.sessionDir, "chat.jsonl"), opts.id);
|
|
70
83
|
this.skillRoots = [
|
|
71
84
|
{ dir: path.join(opts.workspace, "skills"), source: "workspace" },
|
|
72
85
|
...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
|
|
@@ -77,7 +90,8 @@ export class Agent {
|
|
|
77
90
|
maxOutputBytes: 60_000,
|
|
78
91
|
skillRoots: this.skillRoots,
|
|
79
92
|
};
|
|
80
|
-
|
|
93
|
+
// the session id IS the directory name — one directory per incarnation
|
|
94
|
+
this.mainSession = path.basename(opts.sessionDir);
|
|
81
95
|
this.currentSession = this.mainSession;
|
|
82
96
|
}
|
|
83
97
|
skillRoots;
|
|
@@ -91,17 +105,6 @@ export class Agent {
|
|
|
91
105
|
/* keep previous cache */
|
|
92
106
|
}
|
|
93
107
|
}
|
|
94
|
-
skillsListing() {
|
|
95
|
-
if (this.skillsCache.length === 0) {
|
|
96
|
-
return ("## Skills\n" +
|
|
97
|
-
"No skills exist yet. When you develop a reusable procedure worth keeping " +
|
|
98
|
-
"(build steps, checklists, project conventions), distill it into a durable playbook " +
|
|
99
|
-
"with save_skill so future sessions can load it via load_skill.");
|
|
100
|
-
}
|
|
101
|
-
return ("## Skills (reusable playbooks)\n" +
|
|
102
|
-
"When the current task matches a description below, call load_skill(name) first and follow it.\n" +
|
|
103
|
-
this.skillsCache.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n"));
|
|
104
|
-
}
|
|
105
108
|
callLlm(messages, tools, onDelta) {
|
|
106
109
|
const fn = this.opts.chatFn ?? chatStream;
|
|
107
110
|
return fn(this.opts.llm, messages, tools, this.abort?.signal, onDelta);
|
|
@@ -124,18 +127,49 @@ export class Agent {
|
|
|
124
127
|
async init() {
|
|
125
128
|
await this.log.load();
|
|
126
129
|
await fs.mkdir(this.workspace, { recursive: true });
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
await this.
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
this.goal = this.parseGoalFile(goalText);
|
|
133
|
-
else
|
|
134
|
-
await this.writeGoalFile();
|
|
130
|
+
// goal lives next to the session log (dataDir), NOT in the workspace —
|
|
131
|
+
// migrate a legacy workspace GOAL.md once, then never touch the workspace
|
|
132
|
+
await this.migrateGoalFromWorkspace();
|
|
133
|
+
const stored = await this.readGoalStore();
|
|
134
|
+
this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
135
135
|
if (this.opts.restoreSession)
|
|
136
136
|
await this.restoreFromLog();
|
|
137
137
|
await this.refreshSkills();
|
|
138
138
|
}
|
|
139
|
+
/** harness-managed files inside the session directory */
|
|
140
|
+
get goalFile() {
|
|
141
|
+
return path.join(this.opts.sessionDir, "goal.md");
|
|
142
|
+
}
|
|
143
|
+
get memoryFile() {
|
|
144
|
+
return path.join(this.opts.sessionDir, "memory.md");
|
|
145
|
+
}
|
|
146
|
+
async readGoalStoreRaw() {
|
|
147
|
+
return fs.readFile(this.goalFile, "utf8").catch(() => null);
|
|
148
|
+
}
|
|
149
|
+
async readGoalStore() {
|
|
150
|
+
const raw = await this.readGoalStoreRaw();
|
|
151
|
+
return raw === null ? null : this.parseGoalFile(raw);
|
|
152
|
+
}
|
|
153
|
+
/** One-time import of a pre-0.6.0 workspace GOAL.md; content is preserved. */
|
|
154
|
+
async migrateGoalFromWorkspace() {
|
|
155
|
+
const legacy = path.join(this.workspace, "GOAL.md");
|
|
156
|
+
let wsText;
|
|
157
|
+
try {
|
|
158
|
+
wsText = await fs.readFile(legacy, "utf8");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return; // nothing to migrate
|
|
162
|
+
}
|
|
163
|
+
const existing = await this.readGoalStoreRaw();
|
|
164
|
+
if (existing === null)
|
|
165
|
+
await fs.writeFile(this.goalFile, wsText, "utf8");
|
|
166
|
+
await fs.rm(legacy).catch(() => { });
|
|
167
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
168
|
+
event: "goal-migrated",
|
|
169
|
+
from: "GOAL.md",
|
|
170
|
+
to: this.goalFile,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
139
173
|
/**
|
|
140
174
|
* Rebuild the in-memory conversation from the JSONL event log so a restart
|
|
141
175
|
* continues where the agent left off instead of starting blank.
|
|
@@ -237,24 +271,20 @@ export class Agent {
|
|
|
237
271
|
});
|
|
238
272
|
}
|
|
239
273
|
}
|
|
240
|
-
async seed(file, content) {
|
|
241
|
-
const p = path.join(this.workspace, file);
|
|
242
|
-
try {
|
|
243
|
-
await fs.access(p);
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
await fs.writeFile(p, content, "utf8");
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
readGoalFile() {
|
|
250
|
-
return fs.readFile(path.join(this.workspace, "GOAL.md"), "utf8").catch(() => null);
|
|
251
|
-
}
|
|
252
274
|
parseGoalFile(text) {
|
|
253
275
|
// humans and agents may append their own status lines — latest wins
|
|
254
276
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
255
277
|
const last = all[all.length - 1]?.[1];
|
|
256
278
|
const status = last === "done" ? "done" : last === "paused" ? "paused" : "active";
|
|
257
|
-
|
|
279
|
+
// keep bookkeeping lines out of the injected goal text
|
|
280
|
+
let body = text.trim();
|
|
281
|
+
for (let i = 0; i < 4; i++) {
|
|
282
|
+
const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
|
|
283
|
+
if (stripped === body)
|
|
284
|
+
break;
|
|
285
|
+
body = stripped;
|
|
286
|
+
}
|
|
287
|
+
return { text: body, status, updatedAt: new Date().toISOString() };
|
|
258
288
|
}
|
|
259
289
|
async writeGoalFile() {
|
|
260
290
|
// don't let previously-appended bookkeeping lines accumulate in the body
|
|
@@ -265,7 +295,7 @@ export class Agent {
|
|
|
265
295
|
break;
|
|
266
296
|
body = stripped;
|
|
267
297
|
}
|
|
268
|
-
await fs.writeFile(
|
|
298
|
+
await fs.writeFile(this.goalFile, `${body}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n`, "utf8");
|
|
269
299
|
}
|
|
270
300
|
async setGoal(text) {
|
|
271
301
|
this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
|
|
@@ -290,6 +320,7 @@ export class Agent {
|
|
|
290
320
|
stats: { ...this.stats },
|
|
291
321
|
model: this.opts.llm.model,
|
|
292
322
|
provider: this.opts.provider,
|
|
323
|
+
sessionDir: this.opts.sessionDir,
|
|
293
324
|
};
|
|
294
325
|
}
|
|
295
326
|
/** Queue a user prompt; wakes the loop if needed. Returns immediately. */
|
|
@@ -354,7 +385,7 @@ export class Agent {
|
|
|
354
385
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
355
386
|
if (this.stopRequested)
|
|
356
387
|
break;
|
|
357
|
-
const nudge = "Continue working toward the goal
|
|
388
|
+
const nudge = "Continue working toward the current goal. If you are blocked, explain why briefly.";
|
|
358
389
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
359
390
|
source: "harness",
|
|
360
391
|
text: nudge,
|
|
@@ -491,6 +522,54 @@ export class Agent {
|
|
|
491
522
|
});
|
|
492
523
|
continue;
|
|
493
524
|
}
|
|
525
|
+
if (call.function.name === "set_goal") {
|
|
526
|
+
const a = safeParse(call.function.arguments);
|
|
527
|
+
const text = String(a.text ?? "").trim();
|
|
528
|
+
if (text)
|
|
529
|
+
await this.setGoal(text);
|
|
530
|
+
this.messages.push({
|
|
531
|
+
role: "tool",
|
|
532
|
+
tool_call_id: call.id,
|
|
533
|
+
content: text ? "goal updated" : "empty goal rejected",
|
|
534
|
+
});
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (call.function.name === "get_goal") {
|
|
538
|
+
this.messages.push({
|
|
539
|
+
role: "tool",
|
|
540
|
+
tool_call_id: call.id,
|
|
541
|
+
content: JSON.stringify({ goal: this.goal.text || "(none set)", status: this.goal.status }, null, 1),
|
|
542
|
+
});
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (call.function.name === "read_memory") {
|
|
546
|
+
const mem = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
547
|
+
this.messages.push({
|
|
548
|
+
role: "tool",
|
|
549
|
+
tool_call_id: call.id,
|
|
550
|
+
content: mem.trim() || "(memory.md is empty — nothing noted yet)",
|
|
551
|
+
});
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (call.function.name === "list_skills") {
|
|
555
|
+
await this.refreshSkills();
|
|
556
|
+
const list = this.skillsCache.length
|
|
557
|
+
? this.skillsCache.map((s) => `- ${s.name}: ${s.description || "(no description)"}`).join("\n")
|
|
558
|
+
: "(no skills yet — create one with save_skill)";
|
|
559
|
+
this.messages.push({ role: "tool", tool_call_id: call.id, content: list });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (call.function.name === "set_memory") {
|
|
563
|
+
const a = safeParse(call.function.arguments);
|
|
564
|
+
const content = String(a.content ?? "").slice(0, 32_000);
|
|
565
|
+
await fs.writeFile(this.memoryFile, content, "utf8");
|
|
566
|
+
this.messages.push({
|
|
567
|
+
role: "tool",
|
|
568
|
+
tool_call_id: call.id,
|
|
569
|
+
content: "memory saved (injected into future prompts)",
|
|
570
|
+
});
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
494
573
|
await this.log.append("tool_call", this.currentSession, this.currentBranch, {
|
|
495
574
|
callId: call.id,
|
|
496
575
|
name: call.function.name,
|
|
@@ -518,13 +597,9 @@ export class Agent {
|
|
|
518
597
|
throw new Error("runaway detection: too many turns in one round (>200)");
|
|
519
598
|
}
|
|
520
599
|
buildMessages() {
|
|
521
|
-
|
|
522
|
-
if (this.goal.text) {
|
|
523
|
-
sys.push(`## Current goal (${this.goal.status})\n${this.goal.text}`);
|
|
524
|
-
}
|
|
525
|
-
sys.push(this.skillsListing());
|
|
600
|
+
// deliberately static: [system] + append-only history keeps prefix caches hot
|
|
526
601
|
const hasSystem = this.messages[0]?.role === "system";
|
|
527
|
-
const head = [{ role: "system", content:
|
|
602
|
+
const head = [{ role: "system", content: SYSTEM_TEMPLATE }];
|
|
528
603
|
return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
|
|
529
604
|
}
|
|
530
605
|
async handleFinish(argsJson) {
|
|
@@ -627,7 +702,7 @@ export class Agent {
|
|
|
627
702
|
{
|
|
628
703
|
role: "user",
|
|
629
704
|
content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
|
|
630
|
-
"
|
|
705
|
+
"Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
|
|
631
706
|
`## Summary of earlier conversation\n${summary}`,
|
|
632
707
|
},
|
|
633
708
|
...this.messages.slice(cut),
|
|
@@ -723,7 +798,7 @@ export class Agent {
|
|
|
723
798
|
this.currentBranch = branch;
|
|
724
799
|
}
|
|
725
800
|
}
|
|
726
|
-
/** workspace tools + agent-meta tools (finish / report_progress) */
|
|
801
|
+
/** workspace tools + agent-meta tools (finish / report_progress / set_goal / set_memory) */
|
|
727
802
|
function allToolSpecs() {
|
|
728
803
|
return [
|
|
729
804
|
...toolSpecs(),
|
|
@@ -731,7 +806,7 @@ function allToolSpecs() {
|
|
|
731
806
|
type: "function",
|
|
732
807
|
function: {
|
|
733
808
|
name: "finish",
|
|
734
|
-
description: "End the current round. Call with goalComplete=true only when the goal in
|
|
809
|
+
description: "End the current round. Call with goalComplete=true only when the current goal (shown in your prompt) is fully achieved.",
|
|
735
810
|
parameters: {
|
|
736
811
|
type: "object",
|
|
737
812
|
properties: {
|
|
@@ -759,6 +834,54 @@ function allToolSpecs() {
|
|
|
759
834
|
},
|
|
760
835
|
},
|
|
761
836
|
},
|
|
837
|
+
{
|
|
838
|
+
type: "function",
|
|
839
|
+
function: {
|
|
840
|
+
name: "set_goal",
|
|
841
|
+
description: "Replace the harness-managed goal text. Use when the objective itself changes — not for routine updates.",
|
|
842
|
+
parameters: {
|
|
843
|
+
type: "object",
|
|
844
|
+
properties: { text: { type: "string" } },
|
|
845
|
+
required: ["text"],
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
type: "function",
|
|
851
|
+
function: {
|
|
852
|
+
name: "get_goal",
|
|
853
|
+
description: "Fetch the current goal and its status. Cheap — call at session start, after a compaction notice, or when unsure.",
|
|
854
|
+
parameters: { type: "object", properties: {} },
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
type: "function",
|
|
859
|
+
function: {
|
|
860
|
+
name: "read_memory",
|
|
861
|
+
description: "Read your durable notes (memory.md).",
|
|
862
|
+
parameters: { type: "object", properties: {} },
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
type: "function",
|
|
867
|
+
function: {
|
|
868
|
+
name: "set_memory",
|
|
869
|
+
description: "Overwrite your durable notes (memory.md). Keep them terse: decisions, gotchas, where you left off.",
|
|
870
|
+
parameters: {
|
|
871
|
+
type: "object",
|
|
872
|
+
properties: { content: { type: "string" } },
|
|
873
|
+
required: ["content"],
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
type: "function",
|
|
879
|
+
function: {
|
|
880
|
+
name: "list_skills",
|
|
881
|
+
description: "List available skills (name + description). Call before load_skill or to avoid duplicating an existing skill.",
|
|
882
|
+
parameters: { type: "object", properties: {} },
|
|
883
|
+
},
|
|
884
|
+
},
|
|
762
885
|
];
|
|
763
886
|
}
|
|
764
887
|
function str(v) {
|
package/dist/master.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Agents are in-process async loops (I/O bound only); all CPU-heavy work is
|
|
4
4
|
* delegated to subprocesses managed by the bash tool with hard timeouts.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } from "node:fs";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import { Agent } from "./agent/agent.js";
|
|
@@ -192,8 +193,14 @@ export class Master {
|
|
|
192
193
|
// single low-frequency tick for everything periodic (idle cost ≈ 0)
|
|
193
194
|
setInterval(() => void this.tick(), 15_000).unref();
|
|
194
195
|
}
|
|
195
|
-
/**
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Create an agent; optionally persist it to the config file.
|
|
198
|
+
* Each incarnation gets its own session directory under
|
|
199
|
+
* <dataDir>/sessions/<agentId>-<uuid>/ (chat.jsonl, goal.md, memory.md).
|
|
200
|
+
* Restarts reuse the latest existing session; fresh creations never touch
|
|
201
|
+
* an older one's history.
|
|
202
|
+
*/
|
|
203
|
+
async addAgent(ac, opts = {}) {
|
|
197
204
|
if (this.agents.has(ac.id))
|
|
198
205
|
throw new Error(`agent id already exists: ${ac.id}`);
|
|
199
206
|
// provider resolution: inline overrides > named provider > legacy llm block
|
|
@@ -210,12 +217,13 @@ export class Master {
|
|
|
210
217
|
};
|
|
211
218
|
if (!llm.model)
|
|
212
219
|
throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
|
|
213
|
-
const
|
|
220
|
+
const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
|
|
221
|
+
await mkdirSync(sessionDir, { recursive: true });
|
|
214
222
|
const agent = new Agent({
|
|
215
223
|
id: ac.id,
|
|
216
224
|
workspace: path.resolve(ac.workspace),
|
|
217
225
|
llm,
|
|
218
|
-
|
|
226
|
+
sessionDir,
|
|
219
227
|
progressIntervalMs: this.config.progressIntervalMs,
|
|
220
228
|
autoContinue: true,
|
|
221
229
|
...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
|
|
@@ -225,12 +233,68 @@ export class Master {
|
|
|
225
233
|
agent.log.onEvent = (e) => printAgentEvent(e);
|
|
226
234
|
await agent.init();
|
|
227
235
|
this.agents.set(ac.id, agent);
|
|
228
|
-
if (persist) {
|
|
236
|
+
if (opts.persist) {
|
|
229
237
|
this.config.agents.push(ac);
|
|
230
238
|
this.saveConfig();
|
|
231
239
|
}
|
|
232
240
|
return agent;
|
|
233
241
|
}
|
|
242
|
+
/** sessions root + helpers */
|
|
243
|
+
sessionsRoot() {
|
|
244
|
+
return path.join(this.config.dataDir, "sessions");
|
|
245
|
+
}
|
|
246
|
+
findLatestSessionDir(agentId) {
|
|
247
|
+
let dirs = [];
|
|
248
|
+
try {
|
|
249
|
+
dirs = readdirSync(this.sessionsRoot())
|
|
250
|
+
.filter((d) => d === agentId || d.startsWith(`${agentId}-`))
|
|
251
|
+
.sort((a, b) => {
|
|
252
|
+
// newest chat.jsonl wins
|
|
253
|
+
const ma = this.sessionMtime(path.join(this.sessionsRoot(), a));
|
|
254
|
+
const mb = this.sessionMtime(path.join(this.sessionsRoot(), b));
|
|
255
|
+
return mb - ma;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return dirs[0] ? path.join(this.sessionsRoot(), dirs[0]) : null;
|
|
262
|
+
}
|
|
263
|
+
sessionMtime(dir) {
|
|
264
|
+
try {
|
|
265
|
+
return statSync(path.join(dir, "chat.jsonl")).mtimeMs;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
resolveSessionDir(agentId, fresh) {
|
|
272
|
+
const root = this.sessionsRoot();
|
|
273
|
+
mkdirSync(root, { recursive: true });
|
|
274
|
+
// one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
|
|
275
|
+
if (!fresh) {
|
|
276
|
+
const legacy = path.join(this.config.dataDir, `${agentId}.jsonl`);
|
|
277
|
+
if (existsSync(legacy)) {
|
|
278
|
+
const target = path.join(root, agentId);
|
|
279
|
+
if (!existsSync(target)) {
|
|
280
|
+
mkdirSync(target, { recursive: true });
|
|
281
|
+
renameSync(legacy, path.join(target, "chat.jsonl"));
|
|
282
|
+
console.log(`[teapot] migrated session storage: ${agentId}.jsonl → sessions/${agentId}/chat.jsonl`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!fresh) {
|
|
287
|
+
const existing = this.findLatestSessionDir(agentId);
|
|
288
|
+
if (existing)
|
|
289
|
+
return existing; // restart → continue where we left off
|
|
290
|
+
}
|
|
291
|
+
// fresh incarnation: guaranteed-unique directory
|
|
292
|
+
let sid = "";
|
|
293
|
+
do {
|
|
294
|
+
sid = `${agentId}-${randomUUID().slice(0, 8)}`;
|
|
295
|
+
} while (existsSync(path.join(root, sid)));
|
|
296
|
+
return path.join(root, sid);
|
|
297
|
+
}
|
|
234
298
|
async removeAgent(id) {
|
|
235
299
|
const agent = this.agents.get(id);
|
|
236
300
|
if (!agent)
|
package/dist/server/api.js
CHANGED
|
@@ -143,7 +143,7 @@ export function buildApp(master) {
|
|
|
143
143
|
}
|
|
144
144
|
const id = (body.id?.trim() || path.basename(ws)).replace(/[^\w.-]/g, "-").slice(0, 40);
|
|
145
145
|
try {
|
|
146
|
-
const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, true);
|
|
146
|
+
const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
|
|
147
147
|
if (body.start !== false)
|
|
148
148
|
agent.start("created via web");
|
|
149
149
|
return c.json({ ok: true, agent: agent.snapshot() });
|