@a-t-h-i/bot-lobby 0.6.2 → 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 +10 -0
- package/package.json +1 -1
- package/src/index.ts +3 -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/runtime.ts +45 -9
- 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/view.ts +114 -20
- package/src/pi/events.ts +7 -2
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/start-task.ts +3 -0
- 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 +4 -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 +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/width.ts +102 -0
package/src/lobby/view.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* pane toggles, paging) work in both. Everything bot-lobby-specific arrives
|
|
10
10
|
* through `LobbyHost`, so the view renders and reacts the same in tests.
|
|
11
11
|
*/
|
|
12
|
-
import { decodeKittyPrintable, Editor, Input, Key, matchesKey,
|
|
12
|
+
import { decodeKittyPrintable, Editor, Input, Key, matchesKey, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
13
|
+
import { textWidth } from "../width.ts";
|
|
13
14
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
14
15
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
15
16
|
import type { PlannedTask } from "../state/backlog.ts";
|
|
@@ -23,7 +24,7 @@ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } f
|
|
|
23
24
|
import { issueText, type IssuesState } from "./issues.ts";
|
|
24
25
|
import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
|
|
25
26
|
import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
|
|
27
28
|
import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
|
|
28
29
|
import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
|
|
29
30
|
import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
|
|
@@ -113,6 +114,9 @@ function sameView(a: SessionView, b: SessionView): boolean {
|
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/** Tops a conversation whose earlier messages are not in memory. */
|
|
118
|
+
export const OLDER_NOTE = "↑ earlier messages load as you scroll up";
|
|
119
|
+
|
|
116
120
|
const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
|
|
117
121
|
const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
|
|
118
122
|
|
|
@@ -189,8 +193,12 @@ export interface LobbyHost {
|
|
|
189
193
|
archiveTask(taskId: string): string;
|
|
190
194
|
restoreTask(taskId: string): string;
|
|
191
195
|
deleteTask(taskId: string, where: "list" | "archive"): string;
|
|
192
|
-
/** Another session's conversation, read from its saved session file. */
|
|
196
|
+
/** Another session's conversation, read from its saved session file (its newest messages). */
|
|
193
197
|
sessionChat(sessionId: string): ChatEntry[];
|
|
198
|
+
/** Whether another session has messages earlier than `sessionChat` returns. */
|
|
199
|
+
hasOlderChat(sessionId: string): boolean;
|
|
200
|
+
/** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
|
|
201
|
+
chatHistory(sessionId?: string): readonly ChatEntry[];
|
|
194
202
|
/** A task's status without animations, for a session other than this window's. */
|
|
195
203
|
taskScene(task: Task, width: number, height: number): string[];
|
|
196
204
|
profileLabel(kind: LobbyAgentKind): string;
|
|
@@ -216,7 +224,7 @@ class LobbyEditor extends Editor {
|
|
|
216
224
|
if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
|
|
217
225
|
const label = ` ${this.label} `;
|
|
218
226
|
const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
|
|
219
|
-
const rest = width - 2 -
|
|
227
|
+
const rest = width - 2 - textWidth(label) - textWidth(more);
|
|
220
228
|
if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
|
|
221
229
|
return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
|
|
222
230
|
}
|
|
@@ -430,6 +438,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
430
438
|
refreshData(force: boolean): void {
|
|
431
439
|
const now = this.now();
|
|
432
440
|
if (!force && now - this.data.at < DATA_REFRESH_MS) return;
|
|
441
|
+
this.dataVersion += 1;
|
|
433
442
|
this.data = {
|
|
434
443
|
tasks: this.host.tasks(),
|
|
435
444
|
plans: this.host.plans(),
|
|
@@ -838,6 +847,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
838
847
|
break;
|
|
839
848
|
}
|
|
840
849
|
this.clampScroll();
|
|
850
|
+
if (pane === "conversation") this.followHistory();
|
|
841
851
|
}
|
|
842
852
|
|
|
843
853
|
/** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
|
|
@@ -861,6 +871,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
861
871
|
return;
|
|
862
872
|
}
|
|
863
873
|
this.scrollPane(pane, far);
|
|
874
|
+
// Home loads earlier messages at the top; go on to the very first of them.
|
|
875
|
+
if (start && pane === "conversation" && this.history) this.homeOffsets.conversation = Number.MAX_SAFE_INTEGER;
|
|
864
876
|
}
|
|
865
877
|
|
|
866
878
|
/** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
|
|
@@ -954,7 +966,9 @@ export class LobbyView implements Component, Focusable {
|
|
|
954
966
|
const rows = this.taskRowList();
|
|
955
967
|
this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
|
|
956
968
|
this.tasksDetailOffset = 0;
|
|
957
|
-
|
|
969
|
+
// Only the newly selected task's comments: moving through the list must not reread every task from disk.
|
|
970
|
+
const row = rows[this.tasksSelected];
|
|
971
|
+
if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
|
|
958
972
|
}
|
|
959
973
|
|
|
960
974
|
private async loadIssueDetail(): Promise<void> {
|
|
@@ -1205,6 +1219,50 @@ export class LobbyView implements Component, Focusable {
|
|
|
1205
1219
|
void this.host.answerDialog(session).then(() => this.host.requestRender());
|
|
1206
1220
|
}
|
|
1207
1221
|
|
|
1222
|
+
/* -------------------------------------------------------------- history */
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* The whole conversation of the session in view, loaded when the
|
|
1226
|
+
* conversation pane is scrolled back past the messages kept in memory, and
|
|
1227
|
+
* let go once it is back at the newest.
|
|
1228
|
+
*/
|
|
1229
|
+
private history?: { view: SessionView; entries: readonly ChatEntry[] };
|
|
1230
|
+
|
|
1231
|
+
/** The conversation to draw for the session in view, and whether earlier messages wait to be loaded. */
|
|
1232
|
+
private conversationFor(entry: SessionEntry): { chat: readonly ChatEntry[]; older: boolean } {
|
|
1233
|
+
if (this.history && sameView(this.history.view, entry.view)) return { chat: this.history.entries, older: false };
|
|
1234
|
+
if (entry.view.kind === "here") return { chat: this.host.feed.chat, older: this.host.feed.chatOlder };
|
|
1235
|
+
if (entry.view.kind === "background") {
|
|
1236
|
+
const session = this.viewedSessionFor(entry.view.key);
|
|
1237
|
+
return { chat: session?.feed.chat ?? [], older: Boolean(session?.feed.chatOlder && session.sessionId) };
|
|
1238
|
+
}
|
|
1239
|
+
return entry.sessionId ? { chat: this.host.sessionChat(entry.sessionId), older: this.host.hasOlderChat(entry.sessionId) } : { chat: [], older: false };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* After the conversation pane scrolls: at its top, with earlier messages
|
|
1244
|
+
* waiting, load the whole conversation; back at its newest, let it go.
|
|
1245
|
+
* True when history was loaded now.
|
|
1246
|
+
*/
|
|
1247
|
+
private followHistory(): boolean {
|
|
1248
|
+
if (this.tab !== "lobby") return false;
|
|
1249
|
+
if (this.homeOffsets.conversation === 0) {
|
|
1250
|
+
this.history = undefined;
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
if (this.history && sameView(this.history.view, this.viewing)) return false;
|
|
1254
|
+
const pane = this.panes.get("conversation");
|
|
1255
|
+
if (!pane || this.homeOffsets.conversation < pane.total - pane.rows) return false;
|
|
1256
|
+
const entry = this.viewedEntry();
|
|
1257
|
+
if (!this.conversationFor(entry).older) return false;
|
|
1258
|
+
const sessionId = entry.view.kind === "here" ? undefined : entry.view.kind === "background" ? this.viewedSessionFor(entry.view.key)?.sessionId : entry.sessionId;
|
|
1259
|
+
if (entry.view.kind !== "here" && !sessionId) return false;
|
|
1260
|
+
const entries = this.host.chatHistory(sessionId);
|
|
1261
|
+
if (entries.length === 0) return false;
|
|
1262
|
+
this.history = { view: entry.view, entries };
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1208
1266
|
/* ------------------------------------------------------------ sessions */
|
|
1209
1267
|
|
|
1210
1268
|
/** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
|
|
@@ -1270,6 +1328,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
1270
1328
|
this.viewing = target;
|
|
1271
1329
|
this.picking = false;
|
|
1272
1330
|
this.newSession = false;
|
|
1331
|
+
this.history = undefined;
|
|
1273
1332
|
for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
|
|
1274
1333
|
if (this.tab !== "lobby") this.setTab("lobby");
|
|
1275
1334
|
this.setMode("type");
|
|
@@ -1657,8 +1716,41 @@ export class LobbyView implements Component, Focusable {
|
|
|
1657
1716
|
|
|
1658
1717
|
/* -------------------------------------------------------------- render */
|
|
1659
1718
|
|
|
1719
|
+
/** Bumped each time the data is reread. */
|
|
1720
|
+
private dataVersion = 0;
|
|
1721
|
+
/** Task rows as last built: several parts of a frame ask, and they only change with the data, the search or the clock. */
|
|
1722
|
+
private rowsMemo?: { key: string; rows: TaskRow[] };
|
|
1723
|
+
|
|
1724
|
+
/** The Metrics tab's figures as last computed: they change only with the data, the grouping, the order or the search. */
|
|
1725
|
+
private metricsMemo?: { key: string; figures: ReturnType<LobbyView["computeMetrics"]> };
|
|
1726
|
+
|
|
1727
|
+
private metricsFigures(query: string | undefined) {
|
|
1728
|
+
const key = `${this.dataVersion}|${this.metricsBy}|${this.metricsSort}|${query ?? ""}`;
|
|
1729
|
+
if (this.metricsMemo?.key !== key) this.metricsMemo = { key, figures: this.computeMetrics(query) };
|
|
1730
|
+
return this.metricsMemo.figures;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
private computeMetrics(query: string | undefined) {
|
|
1734
|
+
const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
|
|
1735
|
+
return {
|
|
1736
|
+
records,
|
|
1737
|
+
groups: sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort),
|
|
1738
|
+
taskTimes: taskTimesByModel(this.data.tasks, records),
|
|
1739
|
+
stats: taskStats(this.data.tasks),
|
|
1740
|
+
jev: summarizeClassifier(this.data.classifierMetrics, records),
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1660
1744
|
/** Task rows, narrowed by the Tasks tab's search. */
|
|
1661
1745
|
private taskRowList(): TaskRow[] {
|
|
1746
|
+
const key = `${this.dataVersion}|${this.tick}|${this.showArchived}|${this.queries.tasks ?? ""}`;
|
|
1747
|
+
if (this.rowsMemo?.key === key) return this.rowsMemo.rows;
|
|
1748
|
+
const rows = this.buildTaskRows();
|
|
1749
|
+
this.rowsMemo = { key, rows };
|
|
1750
|
+
return rows;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
private buildTaskRows(): TaskRow[] {
|
|
1662
1754
|
const names = new Map<string, string>();
|
|
1663
1755
|
for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
|
|
1664
1756
|
const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
|
|
@@ -1691,12 +1783,12 @@ export class LobbyView implements Component, Focusable {
|
|
|
1691
1783
|
const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
|
|
1692
1784
|
let cell = ` ${number} ${name}${badge} `;
|
|
1693
1785
|
if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
|
|
1694
|
-
const from =
|
|
1786
|
+
const from = textWidth(left);
|
|
1695
1787
|
left += cell;
|
|
1696
|
-
spans.push({ tab, from, to:
|
|
1788
|
+
spans.push({ tab, from, to: textWidth(left) });
|
|
1697
1789
|
}
|
|
1698
1790
|
this.tabSpans = spans;
|
|
1699
|
-
const room = width -
|
|
1791
|
+
const room = width - textWidth(left) - 1;
|
|
1700
1792
|
const entry = this.viewedEntry();
|
|
1701
1793
|
const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
|
|
1702
1794
|
const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
|
|
@@ -1717,8 +1809,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
1717
1809
|
? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
|
|
1718
1810
|
: [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
|
|
1719
1811
|
}
|
|
1720
|
-
const status = choices.find((choice) =>
|
|
1721
|
-
const gap = width -
|
|
1812
|
+
const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
|
|
1813
|
+
const gap = width - textWidth(left) - textWidth(status);
|
|
1722
1814
|
return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
|
|
1723
1815
|
}
|
|
1724
1816
|
|
|
@@ -1780,7 +1872,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
1780
1872
|
const tail = this.searching
|
|
1781
1873
|
? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
|
|
1782
1874
|
: paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
|
|
1783
|
-
const room = Math.max(4, width -
|
|
1875
|
+
const room = Math.max(4, width - textWidth(lead) - textWidth(tail));
|
|
1784
1876
|
const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
|
|
1785
1877
|
return [fit(`${lead}${fit(field, room)}${tail}`, width)];
|
|
1786
1878
|
}
|
|
@@ -2018,12 +2110,9 @@ export class LobbyView implements Component, Focusable {
|
|
|
2018
2110
|
}, width, height, theme);
|
|
2019
2111
|
}
|
|
2020
2112
|
case "metrics": {
|
|
2021
|
-
const records
|
|
2022
|
-
const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
|
|
2113
|
+
const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
|
|
2023
2114
|
this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
|
|
2024
|
-
|
|
2025
|
-
const jev = summarizeClassifier(this.data.classifierMetrics, records);
|
|
2026
|
-
return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
|
|
2115
|
+
return renderMetrics({ groups, taskTimes, records, stats, by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
|
|
2027
2116
|
}
|
|
2028
2117
|
}
|
|
2029
2118
|
}
|
|
@@ -2150,7 +2239,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
2150
2239
|
const room = height - lines.length - 2;
|
|
2151
2240
|
if (room > 2) {
|
|
2152
2241
|
lines.push("", rule(width, "Conversation", theme));
|
|
2153
|
-
const talk = chat.length > 0 ?
|
|
2242
|
+
const talk = chat.length > 0 ? chatTail(chat, width, theme, room - 1).lines : [paint(theme, "dim", "Nothing said yet.")];
|
|
2154
2243
|
lines.push(...talk.slice(-(room - 1)));
|
|
2155
2244
|
}
|
|
2156
2245
|
return lines;
|
|
@@ -2175,9 +2264,11 @@ export class LobbyView implements Component, Focusable {
|
|
|
2175
2264
|
const query = this.query();
|
|
2176
2265
|
const entry = this.viewedEntry();
|
|
2177
2266
|
if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
|
|
2267
|
+
const talk = this.conversationFor(entry);
|
|
2178
2268
|
return renderHome({
|
|
2179
2269
|
...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
|
|
2180
|
-
chat:
|
|
2270
|
+
chat: talk.chat,
|
|
2271
|
+
...(talk.older ? { olderNote: OLDER_NOTE } : {}),
|
|
2181
2272
|
...(feed.reply ? { liveReply: feed.reply } : {}),
|
|
2182
2273
|
activity: feed.activity,
|
|
2183
2274
|
thoughts: feed.thoughts,
|
|
@@ -2207,6 +2298,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
2207
2298
|
*/
|
|
2208
2299
|
private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
|
|
2209
2300
|
const session = this.viewedSession();
|
|
2301
|
+
const talk = this.conversationFor(entry);
|
|
2210
2302
|
const task = entry.task;
|
|
2211
2303
|
const query = this.query();
|
|
2212
2304
|
const common = {
|
|
@@ -2232,7 +2324,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
2232
2324
|
const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
|
|
2233
2325
|
return renderHome({
|
|
2234
2326
|
...common,
|
|
2235
|
-
chat:
|
|
2327
|
+
chat: talk.chat,
|
|
2328
|
+
...(talk.older ? { olderNote: OLDER_NOTE } : {}),
|
|
2236
2329
|
...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
|
|
2237
2330
|
activity: session.feed.activity,
|
|
2238
2331
|
thoughts: session.feed.thoughts,
|
|
@@ -2243,7 +2336,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
2243
2336
|
const running = entry.where === "other terminal";
|
|
2244
2337
|
return renderHome({
|
|
2245
2338
|
...common,
|
|
2246
|
-
chat:
|
|
2339
|
+
chat: talk.chat,
|
|
2340
|
+
...(talk.older ? { olderNote: OLDER_NOTE } : {}),
|
|
2247
2341
|
activity: [],
|
|
2248
2342
|
thoughts: [],
|
|
2249
2343
|
busy: false,
|
package/src/pi/events.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
|
|
|
15
15
|
import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
|
|
16
16
|
import { isAutoMode } from "../state/auto.ts";
|
|
17
17
|
import { triageContext } from "../classifier/triage.ts";
|
|
18
|
+
import { previousTaskNote } from "./fresh-context.ts";
|
|
18
19
|
|
|
19
20
|
/** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
|
|
20
21
|
const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
|
|
@@ -92,9 +93,13 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
|
92
93
|
pi.on("before_agent_start", (event, ctx) => {
|
|
93
94
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
94
95
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
95
|
-
const
|
|
96
|
+
const hidden = isSubagentProcess() || isMinimized();
|
|
97
|
+
const task = hidden ? undefined : activeTask(root, configDir, sessionId);
|
|
96
98
|
if (!task) {
|
|
97
|
-
|
|
99
|
+
// After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
|
|
100
|
+
const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
|
|
101
|
+
if (previous) event.systemPromptOptions.sections["bot-lobby"] = previous;
|
|
102
|
+
else delete event.systemPromptOptions.sections["bot-lobby"];
|
|
98
103
|
return;
|
|
99
104
|
}
|
|
100
105
|
const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A clean slate for the oracle at every task. The session keeps its whole
|
|
3
|
+
* conversation (the lobby, /tree and /resume still show all of it), but the
|
|
4
|
+
* model is sent only what came after the latest task boundary: a task
|
|
5
|
+
* starting in this session, or this session's task ending. So one long
|
|
6
|
+
* session no longer carries every past task into every call; the task in
|
|
7
|
+
* hand reaches the oracle through bot-lobby's system prompt section and its
|
|
8
|
+
* task folder, which do not depend on the conversation.
|
|
9
|
+
*
|
|
10
|
+
* A boundary is a custom entry in the session file, which is never sent to
|
|
11
|
+
* the model: it survives /resume and belongs to the branch it was made on.
|
|
12
|
+
* Subagents (scouts, workers, reviewers, the planning panel, quick fixes)
|
|
13
|
+
* already run as pi processes of their own, each with its own context, and
|
|
14
|
+
* are not touched here.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { relative } from "node:path";
|
|
18
|
+
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
19
|
+
import { peekTasks, taskDirFor } from "../state/persistence.ts";
|
|
20
|
+
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
21
|
+
import { CLEARED_NOTE, CONTEXT_MARK, lobbyFeed } from "../lobby/feed.ts";
|
|
22
|
+
import { isSubagentProcess } from "./quiet.ts";
|
|
23
|
+
|
|
24
|
+
export { CLEARED_NOTE, CONTEXT_MARK };
|
|
25
|
+
|
|
26
|
+
export interface ContextMark {
|
|
27
|
+
kind: "start" | "end";
|
|
28
|
+
taskId: string;
|
|
29
|
+
/** Messages from this time on (ms) are what the model is sent. */
|
|
30
|
+
at: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The boundaries on a session branch, oldest first. */
|
|
34
|
+
export function contextMarks(entries: readonly unknown[]): ContextMark[] {
|
|
35
|
+
const marks: ContextMark[] = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const mark = asMark(entry);
|
|
38
|
+
if (mark) marks.push(mark);
|
|
39
|
+
}
|
|
40
|
+
return marks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A session entry as a boundary, when it is one. */
|
|
44
|
+
export function asMark(entry: unknown): ContextMark | undefined {
|
|
45
|
+
const record = entry as { type?: string; customType?: string; data?: Partial<ContextMark> } | undefined;
|
|
46
|
+
if (record?.type !== "custom" || record.customType !== CONTEXT_MARK) return undefined;
|
|
47
|
+
const data = record.data;
|
|
48
|
+
if (!data || typeof data.at !== "number" || typeof data.taskId !== "string" || (data.kind !== "start" && data.kind !== "end")) return undefined;
|
|
49
|
+
return { kind: data.kind, taskId: data.taskId, at: data.at };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Replies and tool results answer what came before them, so what the model is sent never opens with one. */
|
|
53
|
+
const ANSWERS = new Set(["assistant", "toolResult"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The messages from the boundary on: from the first one at or after it that
|
|
57
|
+
* can open a conversation (a request, a summary, an extension's message).
|
|
58
|
+
* All of them when nothing has come since the boundary (a task that ended
|
|
59
|
+
* mid-run still gets its closing turn).
|
|
60
|
+
*/
|
|
61
|
+
export function fromBoundary<T extends { role: string; timestamp?: number }>(messages: T[], boundary: number | undefined): T[] {
|
|
62
|
+
if (boundary === undefined) return messages;
|
|
63
|
+
const start = messages.findIndex((message) => typeof message.timestamp === "number" && message.timestamp >= boundary && !ANSWERS.has(message.role));
|
|
64
|
+
return start <= 0 ? messages : messages.slice(start);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the oracle starts clean at each task (`workflow.freshContext`, on by default). */
|
|
68
|
+
export function freshContextOn(): boolean {
|
|
69
|
+
return loadConfig().workflow.freshContext;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Record a boundary in this session. */
|
|
73
|
+
export function markContext(pi: ExtensionAPI, mark: ContextMark): void {
|
|
74
|
+
pi.appendEntry<ContextMark>(CONTEXT_MARK, mark);
|
|
75
|
+
if (mark.kind === "end") lobbyFeed.say("note", CLEARED_NOTE, mark.at);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The session's newest task: the one its conversation was last about. */
|
|
79
|
+
function lastTask(ctx: ExtensionContext, configDir: string): Task | undefined {
|
|
80
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
81
|
+
return peekTasks(detectProjectRoot(ctx.cwd, configDir), configDir).find((task) => task.ownerSessionId === sessionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Close the session's task once it has ended (completed or abandoned): the next request starts clean. */
|
|
85
|
+
export function closeEndedTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, now = Date.now()): boolean {
|
|
86
|
+
if (isSubagentProcess() || !freshContextOn()) return false;
|
|
87
|
+
const task = lastTask(ctx, configDir);
|
|
88
|
+
if (!task || !TERMINAL_STATES.includes(task.state)) return false;
|
|
89
|
+
if (contextMarks(ctx.sessionManager.getBranch()).some((mark) => mark.kind === "end" && mark.taskId === task.id)) return false;
|
|
90
|
+
markContext(pi, { kind: "end", taskId: task.id, at: now });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The note bot-lobby's system prompt section carries after a task ended and
|
|
96
|
+
* cleared the context: which task it was and where its record is, for when
|
|
97
|
+
* the user refers back to it.
|
|
98
|
+
*/
|
|
99
|
+
export function previousTaskNote(ctx: ExtensionContext, configDir: string): string | undefined {
|
|
100
|
+
if (!freshContextOn()) return undefined;
|
|
101
|
+
const last = contextMarks(ctx.sessionManager.getBranch()).at(-1);
|
|
102
|
+
if (last?.kind !== "end") return undefined;
|
|
103
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
104
|
+
const task = peekTasks(root, configDir).find((entry) => entry.id === last.taskId);
|
|
105
|
+
if (!task) return undefined;
|
|
106
|
+
const record = relative(ctx.cwd, taskDirFor(root, configDir, task.id)) || ".";
|
|
107
|
+
return [
|
|
108
|
+
`Your previous task, ${task.id} "${task.title}", ended (${task.state}). Its conversation was cleared from your context so the next piece of work starts fresh.`,
|
|
109
|
+
`Its record (request, proposal, plan, decisions, runs) is in ${record}/. Read it only if the user refers back to that task.`,
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Trim what the oracle is sent to its latest boundary, and close tasks as they end. */
|
|
114
|
+
export function registerFreshContext(pi: ExtensionAPI, configDir: string): void {
|
|
115
|
+
if (isSubagentProcess()) return;
|
|
116
|
+
pi.on("context", (event, ctx) => {
|
|
117
|
+
if (!freshContextOn()) return undefined;
|
|
118
|
+
const boundary = contextMarks(ctx.sessionManager.getBranch()).at(-1)?.at;
|
|
119
|
+
const messages = fromBoundary(event.messages, boundary);
|
|
120
|
+
return messages.length === event.messages.length ? undefined : { messages };
|
|
121
|
+
});
|
|
122
|
+
// A task's last turn is over: whatever is asked next starts clean.
|
|
123
|
+
pi.on("agent_end", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
|
|
124
|
+
// It may have ended while the oracle was idle (abandoned or archived from the lobby, finished in another window).
|
|
125
|
+
pi.on("before_agent_start", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
|
|
126
|
+
// Compacting a conversation the next request drops anyway would only cost a model call.
|
|
127
|
+
pi.on("session_before_compact", (event) => {
|
|
128
|
+
if (event.reason === "manual" || !freshContextOn()) return undefined;
|
|
129
|
+
const last = contextMarks(event.branchEntries).at(-1);
|
|
130
|
+
if (!last) return undefined;
|
|
131
|
+
const since = event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
|
|
132
|
+
return since ? undefined : { cancel: true };
|
|
133
|
+
});
|
|
134
|
+
}
|
package/src/pi/owner.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { KeyId } from "@earendil-works/pi-tui";
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { peekOwnedTask } from "../state/persistence.ts";
|
|
14
14
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
15
15
|
import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
|
|
16
16
|
import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
|
|
@@ -42,6 +42,8 @@ interface Owner {
|
|
|
42
42
|
configDir: string;
|
|
43
43
|
timer?: ReturnType<typeof setInterval>;
|
|
44
44
|
auto?: AutoTrack;
|
|
45
|
+
/** The session's task as this tick read it: one read serves the whole tick. */
|
|
46
|
+
tick?: { task: Task | undefined };
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
let owner: Owner | undefined;
|
|
@@ -53,7 +55,8 @@ export function onOwnerEvent(fn: ((event: OwnerEvent) => void) | undefined): voi
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function ownTask(state: Owner): Task | undefined {
|
|
56
|
-
|
|
58
|
+
if (state.tick) return state.tick.task;
|
|
59
|
+
return peekOwnedTask(state.root, state.configDir, state.ctx.sessionManager.getSessionId());
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
function live(task: Task | undefined): task is Task {
|
|
@@ -68,8 +71,7 @@ export function deliverComments(): number {
|
|
|
68
71
|
if (!live(task) || task.paused) return 0;
|
|
69
72
|
const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
|
|
70
73
|
if (fresh.length === 0) return 0;
|
|
71
|
-
|
|
72
|
-
state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
|
|
74
|
+
state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(task.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
|
|
73
75
|
markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
|
|
74
76
|
listener?.({ kind: "comments", taskId: task.id, count: fresh.length });
|
|
75
77
|
return fresh.length;
|
|
@@ -211,11 +213,18 @@ export function toggleOwnAuto(): string {
|
|
|
211
213
|
|
|
212
214
|
/** One tick of the owner's clock. */
|
|
213
215
|
export function ownerTick(): void {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
const state = owner;
|
|
217
|
+
if (!state) return;
|
|
218
|
+
state.tick = { task: ownTask(state) };
|
|
219
|
+
try {
|
|
220
|
+
heartbeat();
|
|
221
|
+
deliverComments();
|
|
222
|
+
deliverInbox();
|
|
223
|
+
deliverSessionInbox();
|
|
224
|
+
driveAuto();
|
|
225
|
+
} finally {
|
|
226
|
+
state.tick = undefined;
|
|
227
|
+
}
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
function stop(): void {
|
|
@@ -254,7 +263,7 @@ export function registerOwner(pi: ExtensionAPI, configDir: string): void {
|
|
|
254
263
|
// Auto mode has nobody to answer a question: tell the oracle to decide instead.
|
|
255
264
|
pi.on("tool_call", (event, ctx) => {
|
|
256
265
|
if (event.toolName !== "ask_user_question" || !owner) return undefined;
|
|
257
|
-
const task =
|
|
266
|
+
const task = peekOwnedTask(owner.root, owner.configDir, ctx.sessionManager.getSessionId());
|
|
258
267
|
if (!live(task) || !isAutoMode(owner.root, owner.configDir, task.id)) return undefined;
|
|
259
268
|
return { block: true, reason: `Auto mode is on for ${task.id}: nobody will answer. Decide this yourself from the request, the plan and your reconnaissance, record the decision, and continue.` };
|
|
260
269
|
});
|
package/src/pi/start-task.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { applyMasterModel } from "./settings-ui.ts";
|
|
|
15
15
|
import { setAutoMode } from "../state/auto.ts";
|
|
16
16
|
import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
|
|
17
17
|
import { triageFor } from "../classifier/instance.ts";
|
|
18
|
+
import { freshContextOn, markContext } from "./fresh-context.ts";
|
|
18
19
|
|
|
19
20
|
function uniqueTaskId(root: string, configDir: string, request: string): string {
|
|
20
21
|
const base = nextTaskId(request);
|
|
@@ -76,6 +77,8 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
|
|
|
76
77
|
applyStatus(ctx, root, configDir);
|
|
77
78
|
await applyMasterModel(pi, ctx, loadConfig());
|
|
78
79
|
ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
|
|
80
|
+
// The oracle takes the task on with a clean context: nothing said before the kickoff is sent to its model.
|
|
81
|
+
if (freshContextOn()) markContext(pi, { kind: "start", taskId: task.id, at: Date.now() });
|
|
79
82
|
// A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
|
|
80
83
|
pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
81
84
|
return task;
|
package/src/pi/ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Key,
|
|
2
|
+
import { Key, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { clip } from "../width.ts";
|
|
3
4
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
4
5
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
5
6
|
import { activeTask } from "../state/persistence.ts";
|
|
@@ -255,7 +256,7 @@ export class ZenScene {
|
|
|
255
256
|
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
|
|
256
257
|
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
257
258
|
const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still };
|
|
258
|
-
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) =>
|
|
259
|
+
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
|
|
259
260
|
this.cache = { key, theme, lines };
|
|
260
261
|
return lines;
|
|
261
262
|
}
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clip, textWidth } from "../width.ts";
|
|
2
2
|
import {
|
|
3
3
|
BAR,
|
|
4
4
|
BUBBLE,
|
|
@@ -243,11 +243,11 @@ function labelPaint(status: SlotState, theme?: PanelTheme): ((text: string) => s
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
function padTo(text: string, width: number): string {
|
|
246
|
-
return
|
|
246
|
+
return clip(text, width, "", true);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
function centre(text: string, width: number): string {
|
|
250
|
-
return padTo(" ".repeat(Math.max(0, Math.floor((width -
|
|
250
|
+
return padTo(" ".repeat(Math.max(0, Math.floor((width - textWidth(text)) / 2))) + text, width);
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
function mod(index: number, length: number): number {
|
|
@@ -273,7 +273,7 @@ function sceneCentre(width: number): number {
|
|
|
273
273
|
|
|
274
274
|
/** Place a row of at most `SCENE_WIDTH` columns on the scene's centre column. */
|
|
275
275
|
function place(row: string, width: number): string {
|
|
276
|
-
return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH -
|
|
276
|
+
return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - textWidth(row)) / 2))) + row;
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
function alertLine(
|
|
@@ -283,7 +283,7 @@ function alertLine(
|
|
|
283
283
|
kind: "warning" | "error" = "warning",
|
|
284
284
|
): string {
|
|
285
285
|
const left = sceneLeft(width);
|
|
286
|
-
const line = paint(
|
|
286
|
+
const line = paint(clip(`! ${alert}`, width - left, "…"), kind, theme, true);
|
|
287
287
|
return " ".repeat(left) + line;
|
|
288
288
|
}
|
|
289
289
|
|
|
@@ -294,8 +294,8 @@ function boxLines(input: LargeSceneInput, width: number, theme?: PanelTheme): st
|
|
|
294
294
|
|
|
295
295
|
function topBorder(input: LargeSceneInput, theme?: PanelTheme): string {
|
|
296
296
|
const lead = "┌─ BOT-LOBBY ── ";
|
|
297
|
-
const label =
|
|
298
|
-
const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 -
|
|
297
|
+
const label = clip(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
|
|
298
|
+
const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - textWidth(lead) - textWidth(label)));
|
|
299
299
|
return paint(lead, "muted", theme) + paint(label, "accent", theme, true) + paint(`${tail}┐`, "muted", theme);
|
|
300
300
|
}
|
|
301
301
|
|
|
@@ -349,7 +349,7 @@ function bodyLines(
|
|
|
349
349
|
/** One dim line under the agent strip; warnings take the warning colour. */
|
|
350
350
|
function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
|
|
351
351
|
const left = sceneLeft(width) + 1;
|
|
352
|
-
const text =
|
|
352
|
+
const text = clip(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
|
|
353
353
|
return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
|
|
354
354
|
}
|
|
355
355
|
|
|
@@ -494,7 +494,7 @@ export function oracleAside(input: LargeSceneInput): string {
|
|
|
494
494
|
function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
|
|
495
495
|
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
496
496
|
const border = (text: string) => paint(text, "muted", theme);
|
|
497
|
-
const text = (value: string) => padTo(
|
|
497
|
+
const text = (value: string) => padTo(clip(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
|
|
498
498
|
const rule = "─".repeat(BUBBLE.width - 2);
|
|
499
499
|
return [
|
|
500
500
|
border(`╭${rule}╮`),
|
|
@@ -661,7 +661,7 @@ function assemble(end: number, cells: readonly Cell[]): string {
|
|
|
661
661
|
let cursor = 0;
|
|
662
662
|
for (const cell of cells) {
|
|
663
663
|
row += " ".repeat(Math.max(0, cell.offset - cursor)) + (cell.paint ? cell.paint(cell.text) : cell.text);
|
|
664
|
-
cursor = cell.offset +
|
|
664
|
+
cursor = cell.offset + textWidth(cell.text);
|
|
665
665
|
}
|
|
666
666
|
return row + " ".repeat(Math.max(0, end - cursor));
|
|
667
667
|
}
|