teapot-coding-agent 0.24.1 → 0.24.3
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/dist/agent/agent.js +50 -3
- package/dist/agent/llm.js +130 -6
- package/dist/agent/tools.js +63 -6
- package/dist/log/events.js +47 -0
- package/dist/server/api.js +2 -2
- package/package.json +1 -1
- package/public/assets/{index-sfdzqjpM.js → index-BKYOoW6L.js} +19 -19
- package/public/assets/index-BKYOoW6L.js.map +1 -0
- package/public/assets/index-C20Fqw6W.css +1 -0
- package/public/assets/{shiki-D1TWrY4w.js → shiki-B_ZS_M6k.js} +2 -2
- package/public/assets/{shiki-D1TWrY4w.js.map → shiki-B_ZS_M6k.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-ClyCarLt.css +0 -1
- package/public/assets/index-sfdzqjpM.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -346,10 +346,23 @@ export class Agent {
|
|
|
346
346
|
return this.manualCompactBudget;
|
|
347
347
|
}
|
|
348
348
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
349
|
+
* Is this agent doing work that `stop()` would actually stop?
|
|
350
|
+
*
|
|
351
|
+
* `status` cannot answer this on its own. Three states count as live:
|
|
352
|
+
* - `running`: the obvious one;
|
|
353
|
+
* - a PARKED TOOL: `parkForTool()` flips the visible status to "idle" so a
|
|
354
|
+
* multi-minute `wait_children` doesn't look like a running spinner, but
|
|
355
|
+
* the loop is very much alive inside the tool;
|
|
356
|
+
* - QUEUED PROMPTS: prompts handed to a stopped agent are accepted and held,
|
|
357
|
+
* and a later `start()` will consume them.
|
|
358
|
+
*
|
|
359
|
+
* Anything keying "should this button stop or start?" off `status` alone
|
|
360
|
+
* therefore offers **start** on a working agent, and the user cannot stop it
|
|
361
|
+
* (#59) — the same trap #38 documented for editing history.
|
|
352
362
|
*/
|
|
363
|
+
isLive() {
|
|
364
|
+
return this.status === "running" || this.parkedByTool || this.pendingPrompts.length > 0;
|
|
365
|
+
}
|
|
353
366
|
/**
|
|
354
367
|
* A background shell (bash background=true) finished. Two channels:
|
|
355
368
|
* 1. a system_note row in the timeline — the operator sees the outcome
|
|
@@ -614,6 +627,15 @@ export class Agent {
|
|
|
614
627
|
if (msgs.length > 0) {
|
|
615
628
|
this.messages = msgs;
|
|
616
629
|
this.currentBranch = last.branch;
|
|
630
|
+
// #50: re-apply pruning to the restored history. `maybePrune` clips
|
|
631
|
+
// oversized tool output IN PLACE, so a live session keeps its clipped
|
|
632
|
+
// text while the log still holds the full result — a restart therefore
|
|
633
|
+
// handed the provider a strictly LARGER prompt than the one that had just
|
|
634
|
+
// succeeded, which is a size regression that only shows up after a
|
|
635
|
+
// reload. It is a size fix, not a semantic one: the model has already
|
|
636
|
+
// seen and acted on that output, and the pruned form keeps the head plus
|
|
637
|
+
// a marker, exactly as it did live.
|
|
638
|
+
this.maybePrune();
|
|
617
639
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
618
640
|
event: "session-restored",
|
|
619
641
|
branch: last.branch,
|
|
@@ -876,6 +898,17 @@ export class Agent {
|
|
|
876
898
|
id: this.opts.id,
|
|
877
899
|
status: this.status,
|
|
878
900
|
statusReason: this.statusReason,
|
|
901
|
+
/**
|
|
902
|
+
* Is there work in flight that `stop` would actually stop?
|
|
903
|
+
*
|
|
904
|
+
* `status` alone is not enough to answer that, and the UI needs it: a
|
|
905
|
+
* parked tool (`wait_children`) rewrites the status to "idle" while the
|
|
906
|
+
* loop is very much alive, so a client keying "stop vs start" off
|
|
907
|
+
* `status === "running"` offered **start** on a running agent and the
|
|
908
|
+
* user could not stop it (#59). This is the same "running is not the
|
|
909
|
+
* only live state" fact #38 had to be taught about editing.
|
|
910
|
+
*/
|
|
911
|
+
live: this.isLive(),
|
|
879
912
|
workspace: this.workspace,
|
|
880
913
|
workspaceMissing: this.workspaceMissing,
|
|
881
914
|
session: this.currentSession,
|
|
@@ -2171,6 +2204,20 @@ export class Agent {
|
|
|
2171
2204
|
phase: "summarizing",
|
|
2172
2205
|
summarized: oldCount,
|
|
2173
2206
|
});
|
|
2207
|
+
// #56: the START belongs on the timeline too. Only the right panel showed
|
|
2208
|
+
// compaction, and it reports the *outcome*; a long summarize (hundreds of
|
|
2209
|
+
// thousands of tokens) looks exactly like a hung agent on the timeline,
|
|
2210
|
+
// because the first `context-compacted` event is only appended once the
|
|
2211
|
+
// whole pass is over. Log the announcement as a visible system_note so
|
|
2212
|
+
// the operator sees WHERE the conversation was rewritten, even if the
|
|
2213
|
+
// pass later fails — `before`/`oldCount` are known at this point, and the
|
|
2214
|
+
// completion divider still carries the authoritative after-figures.
|
|
2215
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2216
|
+
event: "context-compaction-started",
|
|
2217
|
+
tokensBefore: before,
|
|
2218
|
+
messages: oldCount,
|
|
2219
|
+
reason: force ? "manual" : "auto",
|
|
2220
|
+
});
|
|
2174
2221
|
this.compactPhase = "summarizing";
|
|
2175
2222
|
try {
|
|
2176
2223
|
summary = await this.summarize(old, (text) => {
|
package/dist/agent/llm.js
CHANGED
|
@@ -128,6 +128,75 @@ function sanitize(messages) {
|
|
|
128
128
|
return { ...m };
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Why did the response carry no usable message?
|
|
133
|
+
*
|
|
134
|
+
* #50 — this used to be a bare "LLM API returned no choices", which is what
|
|
135
|
+
* made the report undiagnosable: an empty `choices` array, a 200 carrying an
|
|
136
|
+
* `error` object, a `delta`-shaped body, and an SSE stream that the SDK
|
|
137
|
+
* handed back as a plain String all produce the identical line, and none of
|
|
138
|
+
* them is a useful thing to read in a log.
|
|
139
|
+
*
|
|
140
|
+
* The leading text is kept stable at "LLM API returned no choices" so any
|
|
141
|
+
* existing matching (and the retry notes operators have learned to read)
|
|
142
|
+
* still works; the diagnosis follows after a colon.
|
|
143
|
+
*/
|
|
144
|
+
function noChoicesDetail(res) {
|
|
145
|
+
const base = "LLM API returned no choices";
|
|
146
|
+
// a gateway that answers 200 with an error object instead of failing: the
|
|
147
|
+
// actionable message is right there and used to be thrown away
|
|
148
|
+
const err = res?.error;
|
|
149
|
+
if (err && typeof err === "object") {
|
|
150
|
+
const msg = typeof err.message === "string" ? err.message : JSON.stringify(err);
|
|
151
|
+
const code = err.code ?? err.type;
|
|
152
|
+
return `${base}: provider returned an error in a 200 response${code ? ` (${String(code)})` : ""} — ${String(msg).slice(0, 300)}`;
|
|
153
|
+
}
|
|
154
|
+
// the SDK returned raw text — e.g. an SSE body served to a non-streaming
|
|
155
|
+
// request. JSON.parse succeeds for a real object, so a failure here means we
|
|
156
|
+
// were handed something that is not a completion at all.
|
|
157
|
+
if (typeof res === "string") {
|
|
158
|
+
const head = res.slice(0, 160).replace(/\s+/g, " ").trim();
|
|
159
|
+
return `${base}: response was not a completion object (${res.length} bytes of ${res.trimStart().startsWith("data:") || res.trimStart().startsWith("event:") ? "SSE" : "text"})${head ? ` — ${head}` : ""}`;
|
|
160
|
+
}
|
|
161
|
+
const choices = res?.choices;
|
|
162
|
+
if (Array.isArray(choices)) {
|
|
163
|
+
const first = choices[0];
|
|
164
|
+
if (!first)
|
|
165
|
+
return `${base}: the provider returned an empty choices array (0 candidates)`;
|
|
166
|
+
if (first.delta && !first.message) {
|
|
167
|
+
return `${base}: the provider returned a streaming delta on a non-streaming request (finish_reason=${String(first.finish_reason)})`;
|
|
168
|
+
}
|
|
169
|
+
if (first.message === null) {
|
|
170
|
+
return `${base}: the provider returned a choice with a null message (finish_reason=${String(first.finish_reason)})`;
|
|
171
|
+
}
|
|
172
|
+
return `${base}: the provider returned a choice with no message field (finish_reason=${String(first.finish_reason)})`;
|
|
173
|
+
}
|
|
174
|
+
return `${base}: the response had no choices array (${safeShape(res)})`;
|
|
175
|
+
}
|
|
176
|
+
/** a short type summary of an unexpected response, for the log line */
|
|
177
|
+
function safeShape(res) {
|
|
178
|
+
try {
|
|
179
|
+
const o = res;
|
|
180
|
+
if (!o || typeof o !== "object")
|
|
181
|
+
return `got ${typeof res}`;
|
|
182
|
+
return `keys: ${Object.keys(o).slice(0, 8).join(", ") || "(none)"}`;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return "unreadable response";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* HTTP statuses that mean "this request shape is not supported here", so
|
|
190
|
+
* re-issuing it without `stream: true` is a genuinely different request.
|
|
191
|
+
*
|
|
192
|
+
* Deliberately an allow-list rather than the whole 4xx range. 408/425/429 are
|
|
193
|
+
* TRANSIENT: replaying them immediately against an already-saturated provider
|
|
194
|
+
* just doubles the request count without changing anything. 401/403 mean the
|
|
195
|
+
* credentials are wrong, which is not fixed by dropping `stream` either — they
|
|
196
|
+
* are excluded on purpose, since a fallback there would replace one honest
|
|
197
|
+
* auth error with the same one and a wasted round trip.
|
|
198
|
+
*/
|
|
199
|
+
const STREAM_REJECTED_STATUS = new Set([400, 404, 405, 406, 415, 422, 501]);
|
|
131
200
|
export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
132
201
|
try {
|
|
133
202
|
const res = await client(cfg).chat.completions.create({
|
|
@@ -136,10 +205,14 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
136
205
|
...(tools.length ? { tools } : {}),
|
|
137
206
|
...effortForRequest(cfg),
|
|
138
207
|
}, { signal });
|
|
139
|
-
|
|
208
|
+
// #50: a gateway may answer with `null` (or any non-object) on some proxy
|
|
209
|
+
// error paths, so the guard is on `rm`, not on `res` being truthy —
|
|
210
|
+
// reading `res.choices` unguarded threw a bare TypeError that named neither
|
|
211
|
+
// the provider nor the shape.
|
|
212
|
+
const raw = res?.choices?.[0];
|
|
140
213
|
const rm = raw?.message;
|
|
141
214
|
if (!rm)
|
|
142
|
-
throw new Error(
|
|
215
|
+
throw new Error(noChoicesDetail(res));
|
|
143
216
|
// normalize: providers attach extra fields (reasoning, refusal, ...) and
|
|
144
217
|
// nullable content — keep only what our protocol understands
|
|
145
218
|
const message = {
|
|
@@ -198,6 +271,13 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
198
271
|
}
|
|
199
272
|
catch (err) {
|
|
200
273
|
const e = err;
|
|
274
|
+
// #50: our own shape-diagnosis already reads as a full sentence and names
|
|
275
|
+
// the response it saw. It carries no HTTP status (the request SUCCEEDED —
|
|
276
|
+
// that is the whole problem), so the generic wrapper below would have
|
|
277
|
+
// relabelled it "LLM API error ?: …", hiding both the diagnosis and the
|
|
278
|
+
// fact that no status was ever involved.
|
|
279
|
+
if (String(err.message ?? "").startsWith("LLM API returned no choices"))
|
|
280
|
+
throw err;
|
|
201
281
|
if (e.status === undefined && !String(err.message).includes("provider returned"))
|
|
202
282
|
throw err; // not an API error (abort, bug, ...)
|
|
203
283
|
const detail = e.error?.message ?? e.message ?? "unknown provider error";
|
|
@@ -284,14 +364,58 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
284
364
|
function: { name: c.name, arguments: c.args || "{}" },
|
|
285
365
|
}];
|
|
286
366
|
});
|
|
287
|
-
if (!message.content && !message.tool_calls)
|
|
288
|
-
|
|
367
|
+
if (!message.content && !message.tool_calls) {
|
|
368
|
+
// #50: a stream that opened, produced NOTHING, and closed cleanly. The
|
|
369
|
+
// throw carries a marker so the handler below can tell this apart from a
|
|
370
|
+
// request the provider actively rejected: this one is a broken/flaky
|
|
371
|
+
// proxy, and re-issuing it without `stream` is a genuinely different
|
|
372
|
+
// request that usually succeeds. Throwing a bare Error would make the two
|
|
373
|
+
// indistinguishable and cost us that safety net.
|
|
374
|
+
const e = new Error("LLM API error: empty completion");
|
|
375
|
+
e.emptyStream = true;
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
289
378
|
return { message, reasoning: reasoning || undefined, usage };
|
|
290
379
|
}
|
|
291
380
|
catch (err) {
|
|
292
|
-
// provider
|
|
293
|
-
|
|
381
|
+
// #50 — a provider that doesn't support streaming at all still gets ONE
|
|
382
|
+
// clean fallback, but ONLY when the streaming attempt produced nothing for
|
|
383
|
+
// a reason that could plausibly be the stream itself.
|
|
384
|
+
//
|
|
385
|
+
// The old condition was `!gotChunk && !signal?.aborted`, which also matched
|
|
386
|
+
// a request that FAILED — a gateway answering 200 with an SSE `error`
|
|
387
|
+
// frame, or a context-length rejection. In those cases the fallback
|
|
388
|
+
// re-issued the same oversized request in a different shape, and the
|
|
389
|
+
// resulting "LLM API returned no choices" replaced the real diagnosis.
|
|
390
|
+
// That is why a restored 1500-message session reported "no choices" three
|
|
391
|
+
// times and then errored: the genuine "context length exceeded" was
|
|
392
|
+
// discarded on the first attempt and could never be recognised by
|
|
393
|
+
// isContextOverflow(), so the compact-and-retry recovery never ran.
|
|
394
|
+
//
|
|
395
|
+
// Two exclusions, and the second one matters as much as the first:
|
|
396
|
+
//
|
|
397
|
+
// 1. A status that means the REQUEST ITSELF was rejected. Only then is
|
|
398
|
+
// re-issuing it without `stream` a different request rather than the
|
|
399
|
+
// same failing one. `400`/`404`/`405`/`422` are the shapes a provider
|
|
400
|
+
// uses to say "no streaming here" — deliberately NOT the whole 4xx
|
|
401
|
+
// range, because 408/425/429 are transient: replaying those against an
|
|
402
|
+
// already-saturated provider just doubles the request count.
|
|
403
|
+
// 2. A THROWN error, as opposed to a request that simply produced no
|
|
404
|
+
// chunks. A 200 that streams zero chunks and closes cleanly is a
|
|
405
|
+
// broken/flaky proxy, and that is precisely the case the non-streaming
|
|
406
|
+
// retry rescues. Requiring a 4xx here would have deleted that safety
|
|
407
|
+
// net — a regression this file's own tests caught.
|
|
408
|
+
//
|
|
409
|
+
// So the fallback runs when the request was rejected outright, OR when it
|
|
410
|
+
// completed without error and without a single chunk. Everything else —
|
|
411
|
+
// 5xx, an error frame, a mid-stream failure — is the ANSWER, rethrown
|
|
412
|
+
// untouched so overflow recovery can see it.
|
|
413
|
+
const status = err?.status;
|
|
414
|
+
const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
|
|
415
|
+
const emptyStream = err?.emptyStream === true;
|
|
416
|
+
if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
|
|
294
417
|
return chat(cfg, messages, tools, signal, onDelta);
|
|
418
|
+
}
|
|
295
419
|
// user interrupt: hand back whatever streamed so far so the harness can
|
|
296
420
|
// keep the partial output visible instead of losing it
|
|
297
421
|
if (signal?.aborted && (text || reasoning)) {
|
package/dist/agent/tools.js
CHANGED
|
@@ -182,7 +182,13 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
182
182
|
* Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
|
|
183
183
|
export function isContextOverflow(err) {
|
|
184
184
|
const msg = String(err?.message ?? "").toLowerCase();
|
|
185
|
-
return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
|
|
185
|
+
return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
|
|
186
|
+
/\b(400|413)\b.*token/.test(msg) ||
|
|
187
|
+
// #50: some gateways answer an over-long request with HTTP 200 and put the
|
|
188
|
+
// real diagnosis in the body, which llm.ts now surfaces after the "no
|
|
189
|
+
// choices" prefix. Without this the compact-and-retry recovery never
|
|
190
|
+
// recognised it and the turn simply failed.
|
|
191
|
+
/no choices:[\s\S]{0,300}context (length|window|limit)/.test(msg));
|
|
186
192
|
}
|
|
187
193
|
/** Any background shell still running for this workspace? The auto-continue
|
|
188
194
|
* loop consults this before nudging a finished round: an agent that parked
|
|
@@ -709,10 +715,43 @@ export function hashLine(content) {
|
|
|
709
715
|
}
|
|
710
716
|
/** `LINE:HASH| content` for each line, 1-indexed */
|
|
711
717
|
export function renderHashlines(lines, startLine = 1) {
|
|
712
|
-
|
|
718
|
+
// same trailing-newline rule as renderGutter (#7): a file ending in "\n"
|
|
719
|
+
// splits into a final "" that is not a line, and an anchor for it is a lie
|
|
720
|
+
// the model can copy into an edit that then never verifies
|
|
721
|
+
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
722
|
+
return body
|
|
713
723
|
.map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
|
|
714
724
|
.join("\n");
|
|
715
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Width of the `N| ` gutter for a page, so every line in it lines up.
|
|
728
|
+
*
|
|
729
|
+
* #7: the prefix had no padding, so the gutter silently widened at line 1000
|
|
730
|
+
* and the whole block jumped sideways mid-file — the exact "unfriendly line
|
|
731
|
+
* numbers" complaint. Padding to the widest number IN THE PAGE is enough: a
|
|
732
|
+
* page is a bounded window, and aligning to the file's total length would
|
|
733
|
+
* push every line of a 20-line read of a 500k-line file 6 characters right.
|
|
734
|
+
*/
|
|
735
|
+
export function gutterWidth(startLine, count) {
|
|
736
|
+
return String(Math.max(0, startLine - 1) + count).length;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The default `N| ` display, with a stable gutter width and no phantom line.
|
|
740
|
+
*
|
|
741
|
+
* #7, second half: the prefix was added AFTER `text.split("\n")`, so a file
|
|
742
|
+
* ending in a newline produced a final numbered row for a line that does not
|
|
743
|
+
* exist. A model that copies the tail verbatim then hands `edit_file` an
|
|
744
|
+
* old_text with a phantom line in it, and the edit fails for no visible reason.
|
|
745
|
+
* A trailing empty element is dropped, which is what the reader means.
|
|
746
|
+
*/
|
|
747
|
+
export function renderGutter(lines, startLine = 1) {
|
|
748
|
+
// a trailing newline splits into a final "" — not a line of the file
|
|
749
|
+
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
750
|
+
if (!body.length)
|
|
751
|
+
return "";
|
|
752
|
+
const w = gutterWidth(startLine, body.length);
|
|
753
|
+
return body.map((l, i) => `${String(startLine + i).padStart(w)}| ${l}`).join("\n");
|
|
754
|
+
}
|
|
716
755
|
/** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
|
|
717
756
|
export function parseAnchor(anchor) {
|
|
718
757
|
const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
|
|
@@ -913,7 +952,7 @@ export const TOOLS = [
|
|
|
913
952
|
}
|
|
914
953
|
const parts = regions.map(([s, e]) => idMode === "hash"
|
|
915
954
|
? renderHashlines(lines.slice(s, e + 1), s + 1)
|
|
916
|
-
: lines.slice(s, e + 1)
|
|
955
|
+
: renderGutter(lines.slice(s, e + 1), s + 1));
|
|
917
956
|
let result = parts.join("\n--\n");
|
|
918
957
|
if (idxs.length > page.length || off > 0)
|
|
919
958
|
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
@@ -924,8 +963,11 @@ export const TOOLS = [
|
|
|
924
963
|
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
925
964
|
const lim = num(args.limit, 2000);
|
|
926
965
|
const pageLines = lines.slice(off, off + lim);
|
|
927
|
-
const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : pageLines
|
|
928
|
-
|
|
966
|
+
const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : renderGutter(pageLines, off + 1);
|
|
967
|
+
// the phantom trailing line is dropped for the count too, or a file that
|
|
968
|
+
// ends in a newline always reports one line more than it has
|
|
969
|
+
const total = lines.length && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
970
|
+
const more = off + lim < total ? `\n... (${total - off - lim} more lines)` : "";
|
|
929
971
|
return { ok: true, result: slice + more };
|
|
930
972
|
},
|
|
931
973
|
},
|
|
@@ -1090,12 +1132,27 @@ export const TOOLS = [
|
|
|
1090
1132
|
// already proved which lines it means, so a block whose content occurs
|
|
1091
1133
|
// twice must still edit the anchored occurrence rather than fail the
|
|
1092
1134
|
// uniqueness check (#4).
|
|
1135
|
+
// The hashline path splits on "\n" to index by resolved line number, which
|
|
1136
|
+
// leaves a trailing "\r" on every line of a CRLF file. Rejoining with
|
|
1137
|
+
// "\n" then DROPS all of them, so a single edit silently rewrote a
|
|
1138
|
+
// Windows file's line endings: the replaced line arrived with the model's
|
|
1139
|
+
// "\n" while every untouched line kept its "\r\n". The file still parsed
|
|
1140
|
+
// and the edit "succeeded", so nothing reported it — but the next
|
|
1141
|
+
// checkout diff shows the whole file changed, and tools that normalise on
|
|
1142
|
+
// EOL (git, prettier, formatters) now rewrite it.
|
|
1143
|
+
//
|
|
1144
|
+
// So the EOL is detected ONCE from the untouched text and re-applied to
|
|
1145
|
+
// the replacement, which also keeps a CRLF file CRLF when the model sends
|
|
1146
|
+
// LF-separated new_text (the overwhelmingly common case — models never
|
|
1147
|
+
// emit \r).
|
|
1148
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1093
1149
|
const out = hashSpan
|
|
1094
1150
|
? text
|
|
1095
1151
|
.split("\n")
|
|
1096
1152
|
.slice(0, hashSpan.start)
|
|
1097
|
-
.concat(newText.split("\n"), text.split("\n").slice(hashSpan.end))
|
|
1153
|
+
.concat(newText.replace(/\r\n/g, "\n").split("\n"), text.split("\n").slice(hashSpan.end))
|
|
1098
1154
|
.join("\n")
|
|
1155
|
+
.replace(/(?<!\r)\n/g, eol)
|
|
1099
1156
|
: replaceAll
|
|
1100
1157
|
? text.split(oldText).join(newText)
|
|
1101
1158
|
: text.replace(oldText, newText);
|
package/dist/log/events.js
CHANGED
|
@@ -238,3 +238,50 @@ async function readWindow(fh, size, bytes) {
|
|
|
238
238
|
}
|
|
239
239
|
return { events, bytes: buf.length };
|
|
240
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Keep one branch's rows out of a timeline, optionally including the history it
|
|
243
|
+
* inherited before it forked.
|
|
244
|
+
*
|
|
245
|
+
* #38. A fork is a new timeline, but the agent's CONTEXT on it still includes
|
|
246
|
+
* everything before the fork point — `rebuildMessagesFrom` walks the parent
|
|
247
|
+
* chain across branch boundaries precisely so a restarted fork keeps its
|
|
248
|
+
* history. Filtering the view by strict equality therefore showed *less* than
|
|
249
|
+
* the model actually reasons over, which is why a filtered fork looked like
|
|
250
|
+
* data loss even though nothing was missing from the log.
|
|
251
|
+
*
|
|
252
|
+
* `includeInherited` walks the parent links out of the branch's own events and
|
|
253
|
+
* keeps everything they reach, so the view matches the model's context. Strict
|
|
254
|
+
* remains the default: a caller that genuinely wants only a branch's own rows
|
|
255
|
+
* (counting, auditing) should not be handed inherited history silently.
|
|
256
|
+
*
|
|
257
|
+
* Pure and exported so the rule can be tested without an HTTP server.
|
|
258
|
+
*/
|
|
259
|
+
export function filterByBranch(events, branch, includeInherited = false) {
|
|
260
|
+
if (!includeInherited)
|
|
261
|
+
return events.filter((e) => e.branch === branch);
|
|
262
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
263
|
+
// every event belonging to the branch, plus everything reachable by `parent`
|
|
264
|
+
const wanted = new Set();
|
|
265
|
+
// A WORKLIST, not a loop over the growing set: iterating `wanted` while
|
|
266
|
+
// adding to it inside the same pass re-visits nodes and, on a parent cycle,
|
|
267
|
+
// never terminates.
|
|
268
|
+
const queue = [];
|
|
269
|
+
for (const e of events) {
|
|
270
|
+
if (e.branch !== branch)
|
|
271
|
+
continue;
|
|
272
|
+
if (wanted.has(e.id))
|
|
273
|
+
continue;
|
|
274
|
+
wanted.add(e.id);
|
|
275
|
+
queue.push(e.id);
|
|
276
|
+
}
|
|
277
|
+
while (queue.length) {
|
|
278
|
+
const cur = byId.get(queue.pop());
|
|
279
|
+
// walk up through the fork boundary — a fork's parent is the last event of
|
|
280
|
+
// the SOURCE branch, and that branch's history is inherited too
|
|
281
|
+
if (cur?.parent && !wanted.has(cur.parent)) {
|
|
282
|
+
wanted.add(cur.parent);
|
|
283
|
+
queue.push(cur.parent);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return events.filter((e) => wanted.has(e.id));
|
|
287
|
+
}
|
package/dist/server/api.js
CHANGED
|
@@ -51,7 +51,7 @@ async function listModels(baseUrl, apiKey) {
|
|
|
51
51
|
}
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import { bus } from "../bus.js";
|
|
54
|
-
import { readEvents, readEventsTail } from "../log/events.js";
|
|
54
|
+
import { readEvents, readEventsTail, filterByBranch } from "../log/events.js";
|
|
55
55
|
/** media categories the file tree can preview inline */
|
|
56
56
|
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
|
|
57
57
|
const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v"]);
|
|
@@ -976,7 +976,7 @@ export function buildApp(master) {
|
|
|
976
976
|
}
|
|
977
977
|
let events = await readEventsCached(filePath);
|
|
978
978
|
if (branch)
|
|
979
|
-
events = events.
|
|
979
|
+
events = filterByBranch(events, branch, c.req.query("lineage") === "true");
|
|
980
980
|
// cursor pagination for older pages: everything strictly BEFORE this id
|
|
981
981
|
if (before) {
|
|
982
982
|
const idx = events.findIndex((e) => e.id === before);
|