diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eac789..5c322ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Harness spec 1.1.0: planning as one structure shared with LLeMbas. - `_lembas/session/rewind` (capability `rewind`): the web UI editing or regenerating from a turn takes that turn and every later one out of the conversation and the history — as `/undo` does, through the same store marking — without touching the files; a session a terminal runs - is rewound there, and its transcript redrawn. + is rewound there, and its transcript redrawn. A rewound turn's id is forgotten, so the web UI re-sending it runs the turn anew rather than being answered what the reverted turn said. - Prototypes in the terminal: a completed `prototype-chat` / `prototype-page` fence in a reply is saved as a file — the model's output as it is, under `~/.local/state/lembas/prototypes//-.html`, never in the project — and drawn diff --git a/src/acp/agent.ts b/src/acp/agent.ts index f376c67..da7dfc9 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -1008,7 +1008,8 @@ export class AcpAgent { if (s.prompting || s.turns.queued().length) throw new RpcError(INVALID_PARAMS, "this session is working — rewind it when the reply is done") const reverted = s.app.turns.rewind(turnId) if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`) - return { reverted } + s.turns.forget(reverted) + return { reverted: reverted.length } } /** `_lembas/session/delete`: the chat was deleted in the web UI, so the session is too — diff --git a/src/acp/share.ts b/src/acp/share.ts index ed71daa..309ec51 100644 --- a/src/acp/share.ts +++ b/src/acp/share.ts @@ -511,9 +511,10 @@ export class Share { const turnId = String(p?.turnId ?? "") const reverted = this.app.turns.rewind(turnId) if (reverted === undefined) throw new RpcError(INVALID_PARAMS, `this session has no turn ${turnId}`) + this.turns.forget(reverted) this.hooks.rewound?.() - this.hooks.notice(`the web UI rewound ${reverted} turn${reverted === 1 ? "" : "s"} — the files they changed stay as they are`) - return { reverted } + this.hooks.notice(`the web UI rewound ${reverted.length} turn${reverted.length === 1 ? "" : "s"} — the files they changed stay as they are`) + return { reverted: reverted.length } }) peer.handle("hub/steer", (p) => { this.mine(p) diff --git a/src/acp/turns.ts b/src/acp/turns.ts index 20a5017..f2bc589 100644 --- a/src/acp/turns.ts +++ b/src/acp/turns.ts @@ -30,6 +30,8 @@ const TURN_FILE_DAYS = 30 export interface TurnStore { load(): [string, R | null][] save(done: [string, R | null][]): void + /** Turns a rewind took out: gone from the file too, or the next load would bring them back. */ + forget?(ids: string[]): void } /** The file's entries, those that are entries: a damaged file is an empty one, never a crash. */ @@ -64,6 +66,16 @@ export function turnFile(file: string): TurnStore & { drop(): void } { renameSync(tmp, file) } catch {} }, + forget(ids) { + try { + const gone = new Set(ids) + const kept = readEntries(file).filter(([id]) => !gone.has(id)) + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }) + const tmp = `${file}.${process.pid}.${Date.now()}.tmp` + writeFileSync(tmp, JSON.stringify(kept), { mode: 0o600 }) + renameSync(tmp, file) + } catch {} + }, drop() { rmSync(file, { force: true }) }, @@ -178,6 +190,21 @@ export class TurnLog { this.running = id } + /** Turns a rewind took out of the conversation: forgotten, so the same id sent again runs + * anew instead of answering what the reverted turn answered. */ + forget(ids: Iterable) { + const gone: string[] = [] + for (const id of ids) { + if (this.entries.get(id)?.state === "done") { + this.entries.delete(id) + this.values.delete(id) + if (this.last === id) this.last = undefined + gone.push(id) + } + } + if (gone.length) this.store?.forget?.(gone) + } + ended(id: string) { const e = this.entries.get(id) if (e) e.state = "done" diff --git a/src/session/store.ts b/src/session/store.ts index bd994f2..01f5aaa 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -123,10 +123,11 @@ export class Store { return rows.find((r) => (JSON.parse(r.json) as Message).role === "user" && (JSON.parse(r.json) as { turnId?: string }).turnId === turnId)?.id } - /** How many turns start at row `fromId` or after it: the visible user messages that carry a turn id. */ - turnsFrom(sessionId: string, fromId: number): number { - const rows = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND id >= ?").all(sessionId, fromId) as { json: string }[] - return rows.filter((r) => typeof (JSON.parse(r.json) as { turnId?: string }).turnId === "string").length + /** The turns that start at row `fromId` or after it, oldest first: the ids the visible user + * messages carry. */ + turnIdsFrom(sessionId: string, fromId: number): string[] { + const rows = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' AND id >= ? ORDER BY id").all(sessionId, fromId) as { json: string }[] + return rows.map((r) => (JSON.parse(r.json) as { turnId?: string }).turnId).filter((id): id is string => typeof id === "string") } /** Record a compaction: what the model sees from here on starts with this summary. */ diff --git a/src/session/turns.ts b/src/session/turns.ts index 78b9439..0ed2916 100644 --- a/src/session/turns.ts +++ b/src/session/turns.ts @@ -118,9 +118,10 @@ export class Turns { /** `_lembas/session/rewind`: the turn `turnId` and every later one leave the conversation the * model sees and the history — their rows re-kinded as /undo re-kinds a turn's, the messages * dropped — and the files are left as they are (the client says so beside its control). The - * number of turns reverted, or undefined for a turn this session does not have. Not while a - * prompt runs: the caller checks. */ - rewind(turnId: string): number | undefined { + * ids of the turns reverted, oldest first, or undefined for a turn this session does not have; + * the caller forgets them from its turn log, or a client re-sending one would get the old + * answer. Not while a prompt runs: the caller checks. */ + rewind(turnId: string): string[] | undefined { const sessionId = this.engine.sessionId const i = this.done.findIndex((t) => t.turnId === turnId) if (i >= 0) { @@ -130,20 +131,20 @@ export class Turns { this.engine.messages.splice(first.at) if (this.store && sessionId && last.lastRow >= first.firstRow) this.store.setKind(sessionId, first.firstRow, last.lastRow, "reverted") this.undone = [] - return dropped.length + return dropped.map((t) => t.turnId) } // A turn run before this process had the session (opened from the store): by its row. Every // turn run here came after it, so they all go too. if (!this.store || !sessionId) return undefined const row = this.store.turnRow(sessionId, turnId) if (row === undefined) return undefined - const count = this.store.turnsFrom(sessionId, row) + const ids = this.store.turnIdsFrom(sessionId, row) this.store.setKind(sessionId, row, Number.MAX_SAFE_INTEGER, "reverted") const at = this.engine.messages.findIndex((m) => m.role === "user" && m.turnId === turnId) if (at >= 0) this.engine.messages.splice(at) this.done = [] this.undone = [] - return count + return ids } /** Everything changed since the session's first prompt, as a unified diff. */ diff --git a/tests/acp-v2.test.ts b/tests/acp-v2.test.ts index e301438..029a59e 100644 --- a/tests/acp-v2.test.ts +++ b/tests/acp-v2.test.ts @@ -383,7 +383,10 @@ test("rewind: the turn and every later one leave the conversation; the next prom // Gone from the history and from what the model is sent; the file is as it was. const h: any = await c.request("_lembas/session/history", { sessionId: s.sessionId }) expect(h.turns.filter((t: any) => t.role === "user").map((t: any) => t.text)).toEqual(["one"]) - await prompt("four", "T4") + // The reverted ids are forgotten: the same id sent again runs anew instead of answering what + // the reverted turn answered (LLeMbas re-sends a turn under the id of the edited message). + await prompt("four", "T2") + expect(fake.requests.length).toBe(4) const sent = JSON.stringify(fake.requests[3].messages) expect(sent).toContain("first answer") expect(sent).not.toContain("second answer")