@a-t-h-i/bot-lobby 0.6.2 → 0.6.4

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.
Files changed (69) hide show
  1. package/README.md +112 -11
  2. package/package.json +1 -4
  3. package/prompts/master.md +47 -1
  4. package/prompts/researcher.md +8 -2
  5. package/prompts/reviewer.md +42 -0
  6. package/prompts/worker.md +7 -0
  7. package/src/ask/dialog.ts +167 -0
  8. package/src/ask/image.ts +202 -0
  9. package/src/ask/png.ts +179 -0
  10. package/src/ask/relay.ts +89 -0
  11. package/src/ask/state.ts +160 -0
  12. package/src/ask/tool.ts +126 -0
  13. package/src/ask/types.ts +55 -0
  14. package/src/ask/view.ts +159 -0
  15. package/src/execution/agent-runner.ts +103 -11
  16. package/src/execution/git.ts +111 -14
  17. package/src/execution/pi-runner.ts +164 -11
  18. package/src/index.ts +9 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/feed.ts +76 -8
  21. package/src/lobby/layout.ts +38 -18
  22. package/src/lobby/markdown.ts +92 -19
  23. package/src/lobby/planner.ts +1 -1
  24. package/src/lobby/quickfix.ts +21 -0
  25. package/src/lobby/runtime.ts +51 -37
  26. package/src/lobby/session-files.ts +162 -25
  27. package/src/lobby/sessions.ts +21 -7
  28. package/src/lobby/tabs/home.ts +289 -67
  29. package/src/lobby/tabs/issues.ts +4 -3
  30. package/src/lobby/tabs/plan.ts +4 -4
  31. package/src/lobby/tabs/quickfix.ts +7 -1
  32. package/src/lobby/tabs/tasks.ts +14 -4
  33. package/src/lobby/theme.ts +30 -0
  34. package/src/lobby/view.ts +141 -25
  35. package/src/master/decisions.ts +1 -1
  36. package/src/master/master.ts +41 -4
  37. package/src/master/research.ts +5 -2
  38. package/src/pi/commands.ts +94 -10
  39. package/src/pi/events.ts +54 -12
  40. package/src/pi/fresh-context.ts +134 -0
  41. package/src/pi/owner.ts +19 -10
  42. package/src/pi/quiet.ts +22 -4
  43. package/src/pi/start-task.ts +11 -2
  44. package/src/pi/tools.ts +26 -8
  45. package/src/pi/ui.ts +9 -3
  46. package/src/pi/zen-large.ts +10 -10
  47. package/src/pi/zen-metrics.ts +13 -3
  48. package/src/pi/zen.ts +22 -15
  49. package/src/roles/reviewer.ts +23 -4
  50. package/src/roles/worker.ts +18 -0
  51. package/src/schemas/configuration.ts +8 -0
  52. package/src/schemas/findings.ts +14 -0
  53. package/src/schemas/task.ts +21 -0
  54. package/src/state/archive.ts +12 -3
  55. package/src/state/backlog.ts +13 -3
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/state/file-cache.ts +62 -0
  59. package/src/state/metrics.ts +63 -13
  60. package/src/state/persistence.ts +36 -2
  61. package/src/text.ts +28 -2
  62. package/src/web/extract.ts +332 -0
  63. package/src/web/fetch.ts +232 -0
  64. package/src/web/html.ts +183 -0
  65. package/src/web/read.ts +113 -0
  66. package/src/web/search.ts +202 -0
  67. package/src/web/tools.ts +279 -0
  68. package/src/width.ts +102 -0
  69. package/src/workflow/workflow.ts +592 -42
@@ -6,11 +6,11 @@
6
6
  * turn into the feed, passes plan comments to the Master that owns a task and
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
12
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
- import { listTasks, loadTask } from "../state/persistence.ts";
13
+ import { loadTask, peekTasks } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
15
  import { addPlanComment, readPlanComments } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
@@ -28,20 +28,20 @@ import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-
28
28
  import { modelLookup } from "../pi/tools.ts";
29
29
  import { startPlannedTask, startTask } from "../pi/start-task.ts";
30
30
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
31
- import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
31
+ import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike, type ChatEntry } from "./feed.ts";
32
32
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
33
33
  import { checkThinking } from "../pi/model-support.ts";
34
34
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
35
35
  import { SessionChats } from "./session-files.ts";
36
- import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
36
+ import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
37
37
  import { QuickFixQueue } from "./quickfix.ts";
38
38
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
39
39
  import { execCommand, IssuesState } from "./issues.ts";
40
40
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
41
- import type { LobbyTheme } from "./layout.ts";
42
- import { createMarkdownRenderer } from "./markdown.ts";
41
+ import { lobbyTheme } from "./theme.ts";
43
42
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
44
43
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
44
+ import { budgetClock } from "../state/budget.ts";
45
45
 
46
46
  export const ANCHOR_KEY = "bot-lobby-anchor";
47
47
  export { deliverComments };
@@ -62,8 +62,8 @@ interface Runtime {
62
62
  planner?: PlanningSession;
63
63
  issues: IssuesState;
64
64
  unsubscribeFeed?: () => void;
65
- /** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
66
- asker?: Promise<Asker>;
65
+ /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
66
+ asker?: Asker;
67
67
  /** A questionnaire is on screen. */
68
68
  asking: boolean;
69
69
  /** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
@@ -83,8 +83,28 @@ export function lobbyView(): LobbyView | undefined {
83
83
  return runtime?.view;
84
84
  }
85
85
 
86
+ /** Repaints asked for by what happens in the background (a streamed token, a run update) come at most this often. */
87
+ export const FRAME_MS = 40;
88
+ let lastFrame = 0;
89
+ let frameTimer: ReturnType<typeof setTimeout> | undefined;
90
+
91
+ /**
92
+ * Repaint the lobby for something that happened in the background. A reply
93
+ * streams dozens of events a second; they share frames, at most one per
94
+ * `FRAME_MS`, instead of each asking for its own. Keys repaint at once
95
+ * through pi.
96
+ */
86
97
  function rerender(): void {
87
- if (runtime?.visible) runtime.tui?.requestRender();
98
+ if (!runtime?.visible || frameTimer) return;
99
+ const wait = lastFrame + FRAME_MS - Date.now();
100
+ const paint = () => {
101
+ frameTimer = undefined;
102
+ lastFrame = Date.now();
103
+ if (runtime?.visible) runtime.tui?.requestRender();
104
+ };
105
+ if (wait <= 0) return paint();
106
+ frameTimer = setTimeout(paint, wait);
107
+ frameTimer.unref?.();
88
108
  }
89
109
 
90
110
  function sessionModel(ctx: ExtensionContext): string | undefined {
@@ -304,6 +324,16 @@ function liveSessions(state: Runtime): LiveSession[] {
304
324
  return sessions;
305
325
  }
306
326
 
327
+ /**
328
+ * A session's whole conversation, oldest first: this window's from pi's
329
+ * branch (already in memory), another's from its session file. Loaded only
330
+ * while the lobby is scrolled back through it.
331
+ */
332
+ function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
333
+ if (sessionId) return chats.history(sessionId);
334
+ return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ id: -(index + 1), at: line.at ?? 0, role: line.role, text: line.text }));
335
+ }
336
+
307
337
  /** Leave a message for a session running in another terminal. */
308
338
  function sendToSession(state: Runtime, sessionId: string, text: string): string {
309
339
  try {
@@ -395,7 +425,9 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
395
425
 
396
426
  /** A task's status box without animations, for a session other than this window's. */
397
427
  function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
398
- return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
428
+ const now = Date.now();
429
+ const time = budgetClock(state.root, state.configDir, task.id, now);
430
+ return panelLines(task, persistedRuns(task), now, false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme, ...(time ? { time } : {}) });
399
431
  }
400
432
 
401
433
  function seatProfile(state: Runtime, member: PanelMember) {
@@ -433,10 +465,9 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
433
465
  return state.planner;
434
466
  }
435
467
 
436
- /** The library's questionnaire when it loads, pi's own dialogs otherwise. */
437
- function panelAsker(state: Runtime): Promise<Asker> {
438
- state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
439
- return state.asker;
468
+ /** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
469
+ function panelAsker(state: Runtime): Asker {
470
+ return state.asker ?? askUser;
440
471
  }
441
472
 
442
473
  /**
@@ -453,7 +484,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
453
484
  if (state.asking) return "the questions are already open";
454
485
  state.asking = true;
455
486
  try {
456
- const asker = await panelAsker(state);
487
+ const asker = panelAsker(state);
457
488
  const chunks = questionnaires(session.questions);
458
489
  for (let index = session.answered.length; index < chunks.length; index++) {
459
490
  const result = await asker(chunks[index]!, state.ctx);
@@ -478,26 +509,6 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
478
509
  }
479
510
  }
480
511
 
481
- const renderMarkdown = createMarkdownRenderer();
482
- const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
483
-
484
- /** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
485
- function lobbyTheme(theme: Theme): LobbyTheme {
486
- let wrapped = lobbyThemes.get(theme);
487
- if (!wrapped) {
488
- wrapped = {
489
- fg: (color, text) => theme.fg(color, text),
490
- bold: (text) => theme.bold(text),
491
- italic: (text) => theme.italic(text),
492
- bg: (color, text) => theme.bg(color, text),
493
- markdown: renderMarkdown,
494
- strike: (text) => theme.strikethrough(text),
495
- };
496
- lobbyThemes.set(theme, wrapped);
497
- }
498
- return wrapped;
499
- }
500
-
501
512
  function host(state: Runtime, tui: TUI): LobbyHost {
502
513
  return {
503
514
  rows: () => tui.terminal.rows,
@@ -515,7 +526,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
515
526
  },
516
527
  feed: lobbyFeed,
517
528
  masterBusy: () => !state.ctx.isIdle(),
518
- tasks: () => listTasks(state.root, state.configDir),
529
+ tasks: () => peekTasks(state.root, state.configDir),
519
530
  plans: () => listPlannedTasks(state.root, state.configDir),
520
531
  comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
521
532
  metrics: () => readMetrics(state.root, state.configDir),
@@ -561,6 +572,8 @@ function host(state: Runtime, tui: TUI): LobbyHost {
561
572
  restoreTask: (taskId) => restoreTask(state, taskId),
562
573
  deleteTask: (taskId, where) => deleteTask(state, taskId, where),
563
574
  sessionChat: (sessionId) => chats.chat(sessionId),
575
+ hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
576
+ chatHistory: (sessionId) => chatHistory(state, sessionId),
564
577
  taskScene: (task, width, height) => taskScene(state, task, width, height),
565
578
  requestRender: () => tui.requestRender(),
566
579
  };
@@ -742,7 +755,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
742
755
  });
743
756
  runtime = state;
744
757
  lobbyFeed.clear();
745
- lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch()));
758
+ // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
759
+ lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
746
760
  state.unsubscribeFeed = lobbyFeed.onChange(rerender);
747
761
  setWidgetSuppressor(() => runtime?.visible === true);
748
762
  onRunUpdates((runs) => lobbyFeed.runs(runs));
@@ -2,10 +2,12 @@
2
2
  * Other sessions' conversations, read from their saved pi session files. A
3
3
  * task another terminal drives has no live feed in this window, so the lobby
4
4
  * shows what its session file holds: the current branch's user and assistant
5
- * messages, reread whenever the file changes.
5
+ * messages. Files are read as they grow — only the bytes appended since the
6
+ * last look — and only what the conversation needs is kept: each entry's
7
+ * parent, and the text of user and assistant messages (never tool output).
6
8
  */
7
- import { readFileSync, statSync } from "node:fs";
8
- import { chatFromEntries, MAX_CHAT, type ChatEntry } from "./feed.ts";
9
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
10
+ import { chatFromEntries, chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
9
11
 
10
12
  interface EntryLike {
11
13
  type?: string;
@@ -51,22 +53,151 @@ export function chatFromFile(text: string, max = MAX_CHAT): ChatEntry[] {
51
53
  return chatFromEntries(currentBranch(parseEntries(text)), max).map((line, index) => ({ id: index + 1, at: line.at ?? 0, role: line.role, text: line.text }));
52
54
  }
53
55
 
56
+ /** What a session log keeps of one entry: its parent, and its conversation lines if it is a message. */
57
+ interface LogNode {
58
+ parentId?: string;
59
+ chat?: ChatEntry[];
60
+ }
61
+
62
+ /** Bytes read at most per look, so a huge file is caught up over a few frames rather than in one. */
63
+ const READ_CHUNK = 4 * 1024 * 1024;
64
+
65
+ let logIds = 0;
66
+
67
+ /**
68
+ * One session file, followed as it grows. Its entries form a tree (pi
69
+ * branches); the conversation is the branch that ends at the newest entry.
70
+ * The newest messages are kept ready; the whole history is walked only when
71
+ * asked for.
72
+ */
73
+ export class SessionLog {
74
+ readonly path: string;
75
+ private offset = 0;
76
+ private leaf?: string;
77
+ private readonly nodes = new Map<string, LogNode>();
78
+ private recentCache?: { max: number; entries: ChatEntry[]; older: boolean };
79
+
80
+ constructor(path: string) {
81
+ this.path = path;
82
+ }
83
+
84
+ /** Read what was appended since the last look; true when the conversation may have changed. */
85
+ update(): boolean {
86
+ let size: number;
87
+ try {
88
+ size = statSync(this.path).size;
89
+ } catch {
90
+ return false;
91
+ }
92
+ if (size < this.offset) this.reset();
93
+ if (size === this.offset) return false;
94
+ let changed = false;
95
+ let fd: number | undefined;
96
+ try {
97
+ fd = openSync(this.path, "r");
98
+ while (this.offset < size) {
99
+ const length = Math.min(READ_CHUNK, size - this.offset);
100
+ const buffer = Buffer.alloc(length);
101
+ const read = readSync(fd, buffer, 0, length, this.offset);
102
+ // Only whole lines: a line still being written (or a character cut in two) waits for the next look.
103
+ const end = buffer.lastIndexOf(0x0a, read - 1);
104
+ if (end < 0) break;
105
+ for (const line of buffer.toString("utf8", 0, end).split("\n")) changed = this.add(line) || changed;
106
+ this.offset += end + 1;
107
+ if (length === READ_CHUNK) break;
108
+ }
109
+ } catch {
110
+ // Unreadable for now; the next look tries again.
111
+ } finally {
112
+ if (fd !== undefined) closeSync(fd);
113
+ }
114
+ if (changed) this.recentCache = undefined;
115
+ return changed;
116
+ }
117
+
118
+ private reset(): void {
119
+ this.offset = 0;
120
+ this.leaf = undefined;
121
+ this.nodes.clear();
122
+ this.recentCache = undefined;
123
+ }
124
+
125
+ private add(line: string): boolean {
126
+ if (!line.trim()) return false;
127
+ let entry: { type?: string; id?: unknown; parentId?: unknown; timestamp?: unknown; message?: { role?: string; content?: unknown } };
128
+ try {
129
+ entry = JSON.parse(line);
130
+ } catch {
131
+ return false;
132
+ }
133
+ if (typeof entry?.id !== "string") return false;
134
+ const node: LogNode = typeof entry.parentId === "string" ? { parentId: entry.parentId } : {};
135
+ const role = entry.message?.role;
136
+ const parsed = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
137
+ const at = Number.isFinite(parsed) ? parsed : 0;
138
+ if (entry.type === "message" && (role === "user" || role === "assistant")) {
139
+ const chat = chatText(role, textOf(entry.message?.content)).map((part) => ({ id: ++logIds, at, role: part.role, text: part.text }));
140
+ if (chat.length > 0) node.chat = chat;
141
+ } else if (isClearedMark(entry)) {
142
+ node.chat = [{ id: ++logIds, at, role: "note", text: CLEARED_NOTE }];
143
+ }
144
+ this.nodes.set(entry.id, node);
145
+ this.leaf = entry.id;
146
+ return true;
147
+ }
148
+
149
+ /** The conversation's messages from the newest back, visiting at most `max`; whether any are left before them. */
150
+ private walk(max: number): { entries: ChatEntry[]; older: boolean } {
151
+ const parts: ChatEntry[][] = [];
152
+ let count = 0;
153
+ let older = false;
154
+ const seen = new Set<string>();
155
+ for (let id = this.leaf; id && !seen.has(id); id = this.nodes.get(id)?.parentId) {
156
+ seen.add(id);
157
+ const chat = this.nodes.get(id)?.chat;
158
+ if (!chat) continue;
159
+ if (count >= max) {
160
+ older = true;
161
+ break;
162
+ }
163
+ parts.push(chat);
164
+ count += chat.length;
165
+ }
166
+ const entries = parts.reverse().flat();
167
+ return { entries: entries.length > max ? entries.slice(-max) : entries, older: older || entries.length > max };
168
+ }
169
+
170
+ /** The newest `max` messages (kept until the file changes), and whether earlier ones exist. */
171
+ recent(max = MAX_CHAT): { entries: ChatEntry[]; older: boolean } {
172
+ if (this.recentCache?.max !== max) this.recentCache = { max, ...this.walk(max) };
173
+ return this.recentCache;
174
+ }
175
+
176
+ /** Every message of the conversation, oldest first (for scrolling back through it). */
177
+ history(): ChatEntry[] {
178
+ return this.walk(Number.POSITIVE_INFINITY).entries;
179
+ }
180
+ }
181
+
54
182
  /** How the cache finds session files: pi's session list, id → path. */
55
183
  export type SessionLister = () => Promise<ReadonlyArray<{ id: string; path: string }>>;
56
184
 
57
185
  /** How often the id → path map is refreshed while someone looks. */
58
186
  export const LIST_REFRESH_MS = 5000;
59
187
 
188
+ /** Session files followed at once; the least recently looked at is let go past this. */
189
+ export const MAX_LOGS = 8;
190
+
60
191
  /**
61
- * Chats of sessions by id, cached per file and reread only when its size or
62
- * mtime changes. Finding a file is async (pi lists sessions from disk), so the
192
+ * Chats of sessions by id, each followed through a `SessionLog` (only new
193
+ * bytes read). Finding a file is async (pi lists sessions from disk), so the
63
194
  * first look returns nothing and `onChange` fires once new files are known.
64
195
  */
65
196
  export class SessionChats {
66
197
  private paths = new Map<string, string>();
67
198
  private listing?: Promise<void>;
68
199
  private listedAt = Number.NEGATIVE_INFINITY;
69
- private readonly chats = new Map<string, { stamp: string; chat: ChatEntry[] }>();
200
+ private readonly logs = new Map<string, SessionLog>();
70
201
 
71
202
  private readonly list: SessionLister;
72
203
  private readonly onChange: () => void;
@@ -95,30 +226,36 @@ export class SessionChats {
95
226
  this.paths.set(sessionId, path);
96
227
  }
97
228
 
98
- chat(sessionId: string): ChatEntry[] {
229
+ /** The session's log, following its file (the least recently used let go past `MAX_LOGS`). */
230
+ private log(sessionId: string): SessionLog | undefined {
99
231
  const path = this.paths.get(sessionId);
100
232
  if (!path) {
101
233
  // A file's path never changes, so pi's list is only read (at most every few seconds) for sessions not found yet.
102
234
  if (this.now() - this.listedAt >= LIST_REFRESH_MS) this.refresh();
103
- return [];
104
- }
105
- let stamp: string;
106
- try {
107
- const stat = statSync(path);
108
- stamp = `${stat.size}:${stat.mtimeMs}`;
109
- } catch {
110
- return this.chats.get(sessionId)?.chat ?? [];
111
- }
112
- const cached = this.chats.get(sessionId);
113
- if (cached?.stamp === stamp) return cached.chat;
114
- let chat: ChatEntry[];
115
- try {
116
- chat = chatFromFile(readFileSync(path, "utf8"));
117
- } catch {
118
- return cached?.chat ?? [];
235
+ return undefined;
119
236
  }
120
- this.chats.set(sessionId, { stamp, chat });
121
- return chat;
237
+ let log = this.logs.get(sessionId);
238
+ if (!log || log.path !== path) log = new SessionLog(path);
239
+ this.logs.delete(sessionId);
240
+ this.logs.set(sessionId, log);
241
+ if (this.logs.size > MAX_LOGS) this.logs.delete(this.logs.keys().next().value!);
242
+ log.update();
243
+ return log;
244
+ }
245
+
246
+ /** The newest messages of a session's conversation (the same objects until its file grows). */
247
+ chat(sessionId: string): ChatEntry[] {
248
+ return this.log(sessionId)?.recent().entries ?? [];
249
+ }
250
+
251
+ /** Whether the session has messages earlier than `chat` returns. */
252
+ hasOlder(sessionId: string): boolean {
253
+ return this.log(sessionId)?.recent().older ?? false;
254
+ }
255
+
256
+ /** The session's whole conversation, oldest first. */
257
+ history(sessionId: string): ChatEntry[] {
258
+ return this.log(sessionId)?.history() ?? [];
122
259
  }
123
260
 
124
261
  private refresh(): void {
@@ -190,14 +190,14 @@ export class BackgroundSession {
190
190
 
191
191
  /** Split stdout into JSON lines (LF only, as pi's RPC framing requires) and handle each. */
192
192
  private receive(chunk: string): void {
193
- this.buffer += chunk;
194
- let newline = this.buffer.indexOf("\n");
195
- while (newline >= 0) {
196
- const line = this.buffer.slice(0, newline).replace(/\r$/, "");
197
- this.buffer = this.buffer.slice(newline + 1);
193
+ const text = this.buffer + chunk;
194
+ let start = 0;
195
+ for (let newline = text.indexOf("\n"); newline >= 0; newline = text.indexOf("\n", start)) {
196
+ const line = text.slice(start, newline).replace(/\r$/, "");
197
+ start = newline + 1;
198
198
  if (line.trim()) this.handle(line);
199
- newline = this.buffer.indexOf("\n");
200
199
  }
200
+ this.buffer = text.slice(start);
201
201
  }
202
202
 
203
203
  private handle(line: string): void {
@@ -280,7 +280,10 @@ export const launchPi: SessionLauncher = (args, cwd) => {
280
280
  return proc as unknown as SessionProcess;
281
281
  };
282
282
 
283
- /** Every background session this window started, oldest first. */
283
+ /** Ended background sessions kept in the list (with their feeds); older ones are let go. */
284
+ export const MAX_ENDED = 6;
285
+
286
+ /** Every background session this window started, oldest first (ended ones beyond the newest few let go). */
284
287
  export class SessionRegistry {
285
288
  readonly sessions: BackgroundSession[] = [];
286
289
  private readonly launcher: SessionLauncher;
@@ -295,10 +298,21 @@ export class SessionRegistry {
295
298
  const proc = this.launcher(sessionArgs(start.name, model), cwd);
296
299
  const session = new BackgroundSession(proc, start, this.onChange);
297
300
  this.sessions.push(session);
301
+ this.prune();
298
302
  this.onChange();
299
303
  return session;
300
304
  }
301
305
 
306
+ /** Let go of all but the newest `MAX_ENDED` sessions that have ended (their conversations stay in their session files). */
307
+ private prune(): void {
308
+ let ended = 0;
309
+ for (let index = this.sessions.length - 1; index >= 0; index -= 1) {
310
+ if (this.sessions[index]!.alive) continue;
311
+ ended += 1;
312
+ if (ended > MAX_ENDED) this.sessions.splice(index, 1);
313
+ }
314
+ }
315
+
302
316
  get(key: string): BackgroundSession | undefined {
303
317
  return this.sessions.find((session) => session.key === key);
304
318
  }