@a-t-h-i/bot-lobby 0.6.1 → 0.6.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/README.md +187 -834
- package/package.json +1 -1
- package/prompts/master.md +17 -0
- package/prompts/panel.md +3 -1
- package/prompts/planner.md +14 -0
- package/prompts/quickfix.md +3 -1
- package/prompts/scout.md +3 -0
- package/prompts/worker.md +3 -0
- package/src/classifier/answers.ts +110 -0
- package/src/classifier/classifier.ts +171 -0
- package/src/classifier/client.ts +239 -0
- package/src/classifier/effort.ts +143 -0
- package/src/classifier/files.ts +427 -0
- package/src/classifier/hosts.ts +157 -0
- package/src/classifier/instance.ts +98 -0
- package/src/classifier/limits.ts +37 -0
- package/src/classifier/seats.ts +102 -0
- package/src/classifier/tools.ts +58 -0
- package/src/classifier/triage.ts +203 -0
- package/src/execution/agent-runner.ts +5 -0
- package/src/index.ts +9 -0
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +14 -13
- package/src/lobby/markdown.ts +61 -18
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +57 -10
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +219 -61
- package/src/lobby/tabs/metrics.ts +28 -1
- package/src/lobby/tabs/plan.ts +27 -4
- package/src/lobby/tabs/quickfix.ts +5 -1
- package/src/lobby/view.ts +140 -22
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +4 -2
- package/src/pi/events.ts +18 -4
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +7 -0
- package/src/pi/tools.ts +7 -2
- package/src/pi/ui.ts +3 -2
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen.ts +6 -6
- package/src/schemas/configuration.ts +129 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +22 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +137 -14
- package/src/state/persistence.ts +36 -2
- package/src/width.ts +102 -0
- package/src/workflow/workflow.ts +49 -1
package/src/lobby/quickfix.ts
CHANGED
|
@@ -10,13 +10,18 @@ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } fr
|
|
|
10
10
|
import { describeToolCall } from "../pi/activity.ts";
|
|
11
11
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
12
12
|
import type { LobbyFeed } from "./feed.ts";
|
|
13
|
+
import type { FileHinter } from "../classifier/files.ts";
|
|
14
|
+
import type { Classifier } from "../classifier/classifier.ts";
|
|
15
|
+
import { quickFixSize } from "../classifier/triage.ts";
|
|
16
|
+
import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
|
|
13
17
|
|
|
14
18
|
/** A quick fix edits code, so it gets the full coding tool set. */
|
|
15
19
|
export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
16
20
|
|
|
17
21
|
export const QUICK_FIX_SOURCE = "QUICK FIX";
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** `held`: the classifier judged the request a task, not a quick fix; it waits for you (r runs it anyway, t makes it a task). */
|
|
24
|
+
export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout" | "held";
|
|
20
25
|
|
|
21
26
|
export interface QuickFixStep {
|
|
22
27
|
at: number;
|
|
@@ -39,6 +44,13 @@ export interface QuickFixJob {
|
|
|
39
44
|
report?: string;
|
|
40
45
|
error?: string;
|
|
41
46
|
usage?: { input: number; output: number; cost: number; turns: number };
|
|
47
|
+
/** Why the job was held, or what became of it. */
|
|
48
|
+
note?: string;
|
|
49
|
+
/** Run even if the classifier judges it large (r on a held job). */
|
|
50
|
+
force?: boolean;
|
|
51
|
+
/** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
|
|
52
|
+
route?: string;
|
|
53
|
+
routedFrom?: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export interface QuickFixProfile {
|
|
@@ -61,6 +73,12 @@ export interface QuickFixDeps {
|
|
|
61
73
|
/** Called after every change so the lobby can repaint. */
|
|
62
74
|
onChange?: () => void;
|
|
63
75
|
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
76
|
+
/** Likely files for the prompt, and the lookup tool, while the classifier's file hints are on. */
|
|
77
|
+
hints?: FileHinter;
|
|
78
|
+
/** Holds a request it judges large (a task, not a quick fix) instead of spending a run on it. */
|
|
79
|
+
classifier?: Classifier;
|
|
80
|
+
/** Lowers thinking or the model for a request the classifier judges simple or trivial. */
|
|
81
|
+
effort?: EffortRouter;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
export const MAX_JOBS = 30;
|
|
@@ -120,6 +138,30 @@ export class QuickFixQueue {
|
|
|
120
138
|
return true;
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/** Run a held job anyway: it goes back in the queue and is not sized again. */
|
|
142
|
+
runAnyway(id: string): boolean {
|
|
143
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
144
|
+
if (!job || job.status !== "held") return false;
|
|
145
|
+
job.status = "queued";
|
|
146
|
+
job.force = true;
|
|
147
|
+
delete job.note;
|
|
148
|
+
delete job.startedAt;
|
|
149
|
+
delete job.finishedAt;
|
|
150
|
+
this.changed();
|
|
151
|
+
this.pump();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A held job that became a task: it leaves the queue with a note. */
|
|
156
|
+
movedToTask(id: string): boolean {
|
|
157
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
158
|
+
if (!job || job.status !== "held") return false;
|
|
159
|
+
job.status = "cancelled";
|
|
160
|
+
job.note = "started as a task in a new session";
|
|
161
|
+
this.changed();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
/** Abort everything (session shutdown). */
|
|
124
166
|
cancelAll(): void {
|
|
125
167
|
for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
|
|
@@ -162,17 +204,23 @@ export class QuickFixQueue {
|
|
|
162
204
|
job.startedAt = Date.now();
|
|
163
205
|
job.thinking = profile.thinking;
|
|
164
206
|
if (profile.model) job.model = profile.model;
|
|
165
|
-
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
166
207
|
this.changed();
|
|
208
|
+
const held = job.force ? undefined : await this.tooLarge(job, controller.signal);
|
|
209
|
+
if (held) {
|
|
210
|
+
this.controllers.delete(job.id);
|
|
211
|
+
return this.hold(job, held);
|
|
212
|
+
}
|
|
213
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
167
214
|
try {
|
|
168
|
-
const
|
|
215
|
+
const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
|
|
216
|
+
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
169
217
|
{
|
|
170
218
|
cwd: this.deps.cwd,
|
|
171
|
-
task: job.prompt,
|
|
219
|
+
task: likely ? `${job.prompt}\n\n${likely}` : job.prompt,
|
|
172
220
|
systemPrompt: quickFixPrompt(profile.instructions),
|
|
173
|
-
tools: QUICK_FIX_TOOLS,
|
|
174
|
-
model
|
|
175
|
-
thinking
|
|
221
|
+
tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
|
|
222
|
+
model,
|
|
223
|
+
thinking,
|
|
176
224
|
timeoutMs: profile.timeoutMs,
|
|
177
225
|
signal: controller.signal,
|
|
178
226
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
@@ -181,6 +229,27 @@ export class QuickFixQueue {
|
|
|
181
229
|
},
|
|
182
230
|
this.deps.runProcess ?? spawnPiProcess,
|
|
183
231
|
);
|
|
232
|
+
let result;
|
|
233
|
+
if (route) {
|
|
234
|
+
job.route = routeLabel(route);
|
|
235
|
+
job.routedFrom = profileLabel(route.from);
|
|
236
|
+
job.thinking = route.thinking;
|
|
237
|
+
if (route.model) job.model = route.model;
|
|
238
|
+
this.changed();
|
|
239
|
+
result = await attempt(route.model, route.thinking);
|
|
240
|
+
if (fellShort(result) && !controller.signal.aborted) {
|
|
241
|
+
// The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
|
|
242
|
+
appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
|
|
243
|
+
this.addStep(job, "the routed attempt fell short; running again on the configured model and thinking", Date.now());
|
|
244
|
+
delete job.route;
|
|
245
|
+
delete job.routedFrom;
|
|
246
|
+
job.thinking = profile.thinking;
|
|
247
|
+
if (profile.model) job.model = profile.model;
|
|
248
|
+
result = await attempt(profile.model, profile.thinking);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
result = await attempt(profile.model, profile.thinking);
|
|
252
|
+
}
|
|
184
253
|
job.status = result.status;
|
|
185
254
|
job.report = result.output.trim() || undefined;
|
|
186
255
|
job.error = result.error;
|
|
@@ -195,6 +264,48 @@ export class QuickFixQueue {
|
|
|
195
264
|
}
|
|
196
265
|
}
|
|
197
266
|
|
|
267
|
+
/** Where the classifier routes this request, or undefined. */
|
|
268
|
+
private async route(prompt: string, profile: QuickFixProfile, signal: AbortSignal): Promise<EffortRoute | undefined> {
|
|
269
|
+
try {
|
|
270
|
+
return await this.deps.effort?.route(prompt, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }, { signal });
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Why a request is too large for a quick fix, when the classifier is sure; undefined runs it. */
|
|
277
|
+
private async tooLarge(job: QuickFixJob, signal: AbortSignal): Promise<string | undefined> {
|
|
278
|
+
const jev = this.deps.classifier;
|
|
279
|
+
if (!jev?.enabled("triage")) return undefined;
|
|
280
|
+
try {
|
|
281
|
+
const sized = await quickFixSize(jev, job.prompt, signal);
|
|
282
|
+
if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
|
|
283
|
+
return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Hold a job instead of running it; nothing ran, so nothing lands in the metrics. */
|
|
290
|
+
private hold(job: QuickFixJob, note: string): void {
|
|
291
|
+
job.status = "held";
|
|
292
|
+
job.note = note;
|
|
293
|
+
job.finishedAt = Date.now();
|
|
294
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `held: ${jobTitle(job)} — ${note}; r runs it anyway, t makes it a task`, "warning", job.finishedAt);
|
|
295
|
+
this.deps.notify?.(`bot-lobby quick fix held: ${jobTitle(job)} ${note}`, "warning");
|
|
296
|
+
this.changed();
|
|
297
|
+
this.pump();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The Likely files block for a prompt, or "" (no hints, nothing stands out, out of time). */
|
|
301
|
+
private async likely(prompt: string, signal: AbortSignal): Promise<string> {
|
|
302
|
+
try {
|
|
303
|
+
return (await this.deps.hints?.block(prompt, signal)) ?? "";
|
|
304
|
+
} catch {
|
|
305
|
+
return "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
198
309
|
private finish(job: QuickFixJob): void {
|
|
199
310
|
job.finishedAt = Date.now();
|
|
200
311
|
for (const step of job.steps) step.pending = false;
|
|
@@ -217,11 +328,12 @@ export function quickFixMetric(job: QuickFixJob): MetricRecord {
|
|
|
217
328
|
agent: QUICK_FIX_SOURCE,
|
|
218
329
|
...(job.model ? { model: job.model } : {}),
|
|
219
330
|
...(job.thinking ? { thinking: job.thinking } : {}),
|
|
220
|
-
status: job.status === "queued" || job.status === "running" ? "failed" : job.status,
|
|
331
|
+
status: job.status === "queued" || job.status === "running" ? "failed" : job.status === "held" ? "cancelled" : job.status,
|
|
221
332
|
startedAt: new Date(started).toISOString(),
|
|
222
333
|
durationMs: Math.max(0, (job.finishedAt ?? started) - started),
|
|
223
334
|
turns: job.usage?.turns || job.turns || undefined,
|
|
224
335
|
tools: job.tools,
|
|
225
336
|
...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
|
|
337
|
+
...(job.routedFrom ? { routedFrom: job.routedFrom } : {}),
|
|
226
338
|
};
|
|
227
339
|
}
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* records each Master turn in the metrics log.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getMarkdownTheme, 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 {
|
|
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";
|
|
@@ -18,7 +18,7 @@ import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts
|
|
|
18
18
|
import { livePresence } from "../state/presence.ts";
|
|
19
19
|
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
20
20
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
21
|
-
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
21
|
+
import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
22
22
|
import { describeToolCall } from "../pi/activity.ts";
|
|
23
23
|
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
24
24
|
import { panelLines } from "../pi/zen.ts";
|
|
@@ -28,7 +28,9 @@ 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
|
+
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
33
|
+
import { checkThinking } from "../pi/model-support.ts";
|
|
32
34
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
33
35
|
import { SessionChats } from "./session-files.ts";
|
|
34
36
|
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
@@ -81,8 +83,28 @@ export function lobbyView(): LobbyView | undefined {
|
|
|
81
83
|
return runtime?.view;
|
|
82
84
|
}
|
|
83
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
|
+
*/
|
|
84
97
|
function rerender(): void {
|
|
85
|
-
if (runtime?.visible)
|
|
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?.();
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
@@ -302,6 +324,16 @@ function liveSessions(state: Runtime): LiveSession[] {
|
|
|
302
324
|
return sessions;
|
|
303
325
|
}
|
|
304
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
|
+
|
|
305
337
|
/** Leave a message for a session running in another terminal. */
|
|
306
338
|
function sendToSession(state: Runtime, sessionId: string, text: string): string {
|
|
307
339
|
try {
|
|
@@ -415,6 +447,10 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
415
447
|
profile: () => lobbyProfile(state, "planner"),
|
|
416
448
|
memberProfile: (member) => seatProfile(state, member),
|
|
417
449
|
panel: seats ?? config.lobby.planningPanel,
|
|
450
|
+
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
451
|
+
classifier: classifier(),
|
|
452
|
+
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
453
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
|
|
418
454
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
419
455
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
420
456
|
feed: lobbyFeed,
|
|
@@ -472,10 +508,13 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
472
508
|
}
|
|
473
509
|
}
|
|
474
510
|
|
|
475
|
-
const renderMarkdown = createMarkdownRenderer();
|
|
476
511
|
const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
|
|
477
512
|
|
|
478
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper
|
|
515
|
+
* (and one Markdown renderer with its cache) per theme, so caches stay warm
|
|
516
|
+
* and a theme switch starts fresh.
|
|
517
|
+
*/
|
|
479
518
|
function lobbyTheme(theme: Theme): LobbyTheme {
|
|
480
519
|
let wrapped = lobbyThemes.get(theme);
|
|
481
520
|
if (!wrapped) {
|
|
@@ -484,7 +523,7 @@ function lobbyTheme(theme: Theme): LobbyTheme {
|
|
|
484
523
|
bold: (text) => theme.bold(text),
|
|
485
524
|
italic: (text) => theme.italic(text),
|
|
486
525
|
bg: (color, text) => theme.bg(color, text),
|
|
487
|
-
markdown:
|
|
526
|
+
markdown: createMarkdownRenderer(getMarkdownTheme()),
|
|
488
527
|
strike: (text) => theme.strikethrough(text),
|
|
489
528
|
};
|
|
490
529
|
lobbyThemes.set(theme, wrapped);
|
|
@@ -509,10 +548,11 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
509
548
|
},
|
|
510
549
|
feed: lobbyFeed,
|
|
511
550
|
masterBusy: () => !state.ctx.isIdle(),
|
|
512
|
-
tasks: () =>
|
|
551
|
+
tasks: () => peekTasks(state.root, state.configDir),
|
|
513
552
|
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
514
553
|
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
515
554
|
metrics: () => readMetrics(state.root, state.configDir),
|
|
555
|
+
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
516
556
|
toOracle: (text) => toOracle(state, text),
|
|
517
557
|
comment: (taskId, text) => addComment(state, taskId, text),
|
|
518
558
|
startPlanned: (plan) => startPlanned(state, plan),
|
|
@@ -524,6 +564,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
524
564
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
525
565
|
answerPanel: () => answerPanel(state),
|
|
526
566
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
567
|
+
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
527
568
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
528
569
|
panels: () => loadConfig().lobby.panels,
|
|
529
570
|
savePanels: (panels) => savePanels(panels),
|
|
@@ -553,6 +594,8 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
553
594
|
restoreTask: (taskId) => restoreTask(state, taskId),
|
|
554
595
|
deleteTask: (taskId, where) => deleteTask(state, taskId, where),
|
|
555
596
|
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
597
|
+
hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
|
|
598
|
+
chatHistory: (sessionId) => chatHistory(state, sessionId),
|
|
556
599
|
taskScene: (task, width, height) => taskScene(state, task, width, height),
|
|
557
600
|
requestRender: () => tui.requestRender(),
|
|
558
601
|
};
|
|
@@ -728,10 +771,14 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
728
771
|
notify: (message, level) => {
|
|
729
772
|
if (!state.visible) ctx.ui.notify(message, level);
|
|
730
773
|
},
|
|
774
|
+
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
775
|
+
classifier: classifier(),
|
|
776
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
731
777
|
});
|
|
732
778
|
runtime = state;
|
|
733
779
|
lobbyFeed.clear();
|
|
734
|
-
|
|
780
|
+
// Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
|
|
781
|
+
lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
|
|
735
782
|
state.unsubscribeFeed = lobbyFeed.onChange(rerender);
|
|
736
783
|
setWidgetSuppressor(() => runtime?.visible === true);
|
|
737
784
|
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
|
|
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 {
|
|
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,
|
|
62
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
121
|
-
|
|
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 {
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -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
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
const line =
|
|
197
|
-
|
|
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
|
-
/**
|
|
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
|
}
|