@a-t-h-i/bot-lobby 0.6.0 → 0.6.2
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 +177 -799
- 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 +6 -0
- package/src/lobby/keys.ts +1 -1
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +136 -5
- package/src/lobby/session-files.ts +12 -0
- package/src/lobby/sessions.ts +28 -2
- package/src/lobby/tabs/home.ts +54 -26
- 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/tabs/tasks.ts +30 -14
- package/src/lobby/view.ts +334 -41
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +24 -4
- package/src/pi/events.ts +11 -2
- package/src/pi/owner.ts +57 -8
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +4 -0
- package/src/pi/tools.ts +7 -2
- package/src/schemas/configuration.ts +125 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +24 -0
- package/src/state/archive.ts +99 -0
- package/src/state/inbox.ts +49 -15
- package/src/state/metrics.ts +74 -1
- package/src/state/presence.ts +99 -0
- package/src/workflow/workflow.ts +49 -1
package/src/pi/tools.ts
CHANGED
|
@@ -5,10 +5,11 @@ import { Type } from "typebox";
|
|
|
5
5
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
6
6
|
import type { ProcessRunner } from "../execution/pi-runner.ts";
|
|
7
7
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
8
|
+
import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
|
|
8
9
|
import { truncate } from "../text.ts";
|
|
9
10
|
import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
|
|
10
11
|
import { isQuiet } from "./quiet.ts";
|
|
11
|
-
import { createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
12
|
+
import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
|
|
12
13
|
import { agentName, describeRun } from "./run-summary.ts";
|
|
13
14
|
import {
|
|
14
15
|
ORCHESTRATE_ACTIONS,
|
|
@@ -22,7 +23,7 @@ const OrchestrateSchema = Type.Object({
|
|
|
22
23
|
action: StringEnum(ORCHESTRATE_ACTIONS, { description: "Workflow step to run" }),
|
|
23
24
|
taskId: Type.Optional(Type.String({ description: "Task id; defaults to the active task" })),
|
|
24
25
|
question: Type.Optional(Type.String({ description: "clarify: question for the user" })),
|
|
25
|
-
options: Type.Optional(Type.Array(Type.String(), { description: "clarify: optional answer choices" })),
|
|
26
|
+
options: Type.Optional(Type.Array(Type.String(), { description: "clarify: optional answer choices; put your recommended one first and mark it (Recommended)" })),
|
|
26
27
|
domains: Type.Optional(Type.Array(Type.String(), { description: "scout: any of designer, backend, qa" })),
|
|
27
28
|
instruction: Type.Optional(Type.String({ description: "scout/research: what to investigate (for scout, also used to target-verify a claim)" })),
|
|
28
29
|
proposal: Type.Optional(Type.String({ description: "propose: the user-facing proposal as a short `- ` bullet list, one line per change" })),
|
|
@@ -104,6 +105,10 @@ export function workflowDeps(
|
|
|
104
105
|
signal,
|
|
105
106
|
onUpdate,
|
|
106
107
|
runProcess,
|
|
108
|
+
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
109
|
+
classifier: classifier(),
|
|
110
|
+
triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
|
|
111
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
107
112
|
ask: async (question) => (hasUI ? ctx.ui.input(question) : undefined),
|
|
108
113
|
choose: async (title, options) => (hasUI ? ctx.ui.select(title, options) : undefined),
|
|
109
114
|
notify: (message, level = "info") => {
|
|
@@ -108,6 +108,59 @@ export interface LobbyConfig {
|
|
|
108
108
|
keys: Record<string, string>;
|
|
109
109
|
/** Clicks and the wheel work in the lobby (click a draft line to comment on it); shift+drag still selects text. */
|
|
110
110
|
mouse: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Planning rounds before the oracle finalizes the plan on its own: the last
|
|
113
|
+
* round skips the seats and asks nothing; later replies only revise. 0 = unlimited.
|
|
114
|
+
*/
|
|
115
|
+
maxPlanningRounds: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Decisions the classifier can make, each switched on or off on its own. */
|
|
119
|
+
export const CLASSIFIER_FEATURES = ["seats", "answers", "files", "triage", "effort"] as const;
|
|
120
|
+
export type ClassifierFeature = (typeof CLASSIFIER_FEATURES)[number];
|
|
121
|
+
|
|
122
|
+
/** Hosts that serve Jev behind the same System One API; `auto` takes OpenCode's free Jev when pi holds an OpenCode key, else TypeSafe. */
|
|
123
|
+
export const JEV_HOSTS = ["auto", "opencode", "typesafe", "openrouter", "vercel"] as const;
|
|
124
|
+
export type JevHostName = (typeof JEV_HOSTS)[number];
|
|
125
|
+
|
|
126
|
+
/** Probability and confidence cut-offs, 0 to 1; edited in the config file only. */
|
|
127
|
+
export interface ClassifierThresholds {
|
|
128
|
+
/** A planning seat runs when the idea or the latest answers touch its domain at least this likely. */
|
|
129
|
+
seatAt: number;
|
|
130
|
+
/** A seat that was READY comes back only at this probability. */
|
|
131
|
+
reseatReadyAt: number;
|
|
132
|
+
/** An obvious question is answered for you at this probability, when the pick is the recommended option… */
|
|
133
|
+
autoAnswerAt: number;
|
|
134
|
+
/** …and leads the runner-up by at least this much. */
|
|
135
|
+
autoAnswerMargin: number;
|
|
136
|
+
/** A file is a likely file at this relevance. */
|
|
137
|
+
fileRelevantAt: number;
|
|
138
|
+
/** A step scored simple at this confidence runs one thinking level lower. */
|
|
139
|
+
simpleAt: number;
|
|
140
|
+
/** A step scored trivial at this confidence runs on the cheaper model. */
|
|
141
|
+
trivialAt: number;
|
|
142
|
+
/** A quick fix scored large at this confidence is held instead of started. */
|
|
143
|
+
quickFixLargeAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The Jev classifier: a fast model for obvious decisions, so large models spend fewer tokens on them. */
|
|
147
|
+
export interface ClassifierConfig {
|
|
148
|
+
enabled: boolean;
|
|
149
|
+
/** Where Jev is called; the key comes from pi's own key store for that host. */
|
|
150
|
+
provider: JevHostName;
|
|
151
|
+
/** A pinned Jev model; empty uses the host's default. */
|
|
152
|
+
model: string;
|
|
153
|
+
/** A base URL override (a proxy); empty uses the host's. */
|
|
154
|
+
baseUrl: string;
|
|
155
|
+
/** Time limit per classifier call. */
|
|
156
|
+
timeoutMs: number;
|
|
157
|
+
features: Record<ClassifierFeature, boolean>;
|
|
158
|
+
thresholds: ClassifierThresholds;
|
|
159
|
+
fileHints: { topK: number; maxCandidates: number; budgetMs: number };
|
|
160
|
+
/** The cheaper model trivial steps run on; `inherit` keeps the configured model and only lowers thinking. */
|
|
161
|
+
effort: { cheapModel: ModelRef };
|
|
162
|
+
/** Path globs never sent to the classifier (file hints). */
|
|
163
|
+
exclude: string[];
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
export interface BotLobbyConfig {
|
|
@@ -122,6 +175,7 @@ export interface BotLobbyConfig {
|
|
|
122
175
|
workflow: WorkflowConfig;
|
|
123
176
|
knowledge: KnowledgeConfig;
|
|
124
177
|
lobby: LobbyConfig;
|
|
178
|
+
classifier: ClassifierConfig;
|
|
125
179
|
}
|
|
126
180
|
|
|
127
181
|
export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
@@ -162,9 +216,34 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
162
216
|
panels: { animations: false, conversation: true, activity: true, thinking: true },
|
|
163
217
|
keys: {},
|
|
164
218
|
mouse: true,
|
|
219
|
+
maxPlanningRounds: 5,
|
|
220
|
+
},
|
|
221
|
+
classifier: {
|
|
222
|
+
enabled: false,
|
|
223
|
+
provider: "auto",
|
|
224
|
+
model: "",
|
|
225
|
+
baseUrl: "",
|
|
226
|
+
timeoutMs: 4000,
|
|
227
|
+
features: { seats: true, answers: true, files: true, triage: true, effort: true },
|
|
228
|
+
thresholds: {
|
|
229
|
+
seatAt: 0.35,
|
|
230
|
+
reseatReadyAt: 0.6,
|
|
231
|
+
autoAnswerAt: 0.9,
|
|
232
|
+
autoAnswerMargin: 0.5,
|
|
233
|
+
fileRelevantAt: 0.5,
|
|
234
|
+
simpleAt: 0.7,
|
|
235
|
+
trivialAt: 0.8,
|
|
236
|
+
quickFixLargeAt: 0.8,
|
|
237
|
+
},
|
|
238
|
+
fileHints: { topK: 8, maxCandidates: 480, budgetMs: 1500 },
|
|
239
|
+
effort: { cheapModel: INHERIT_MODEL },
|
|
240
|
+
exclude: [],
|
|
165
241
|
},
|
|
166
242
|
};
|
|
167
243
|
|
|
244
|
+
/** Choices the settings menu cycles through for the planning round limit; 0 = unlimited. */
|
|
245
|
+
export const PLANNING_ROUND_CHOICES = [2, 3, 5, 8, 0] as const;
|
|
246
|
+
|
|
168
247
|
function positive(value: unknown): number | undefined {
|
|
169
248
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
170
249
|
}
|
|
@@ -191,7 +270,7 @@ function flag(value: unknown, fallback: boolean): boolean {
|
|
|
191
270
|
}
|
|
192
271
|
|
|
193
272
|
function normalizeLobby(value: unknown): LobbyConfig {
|
|
194
|
-
const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown } | undefined;
|
|
273
|
+
const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; maxPlanningRounds?: unknown } | undefined;
|
|
195
274
|
const defaults = DEFAULT_CONFIG.lobby;
|
|
196
275
|
const panel = Array.isArray(source?.planningPanel)
|
|
197
276
|
? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
|
|
@@ -206,6 +285,50 @@ function normalizeLobby(value: unknown): LobbyConfig {
|
|
|
206
285
|
panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
|
|
207
286
|
keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
|
|
208
287
|
mouse: flag(source?.mouse, defaults.mouse),
|
|
288
|
+
maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A whole number of rounds, 0 for unlimited; anything else keeps the default. */
|
|
293
|
+
function roundLimit(value: unknown, fallback: number): number {
|
|
294
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function probability(value: unknown, fallback: number): number {
|
|
298
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : fallback;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function count(value: unknown, fallback: number): number {
|
|
302
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function text(value: unknown, fallback: string): string {
|
|
306
|
+
return typeof value === "string" ? value.trim() : fallback;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeClassifier(value: unknown): ClassifierConfig {
|
|
310
|
+
const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
311
|
+
const defaults = DEFAULT_CONFIG.classifier;
|
|
312
|
+
const features = (source.features ?? {}) as Partial<Record<ClassifierFeature, unknown>>;
|
|
313
|
+
const thresholds = (source.thresholds ?? {}) as Partial<Record<keyof ClassifierThresholds, unknown>>;
|
|
314
|
+
const hints = (source.fileHints ?? {}) as Record<string, unknown>;
|
|
315
|
+
const effort = (source.effort ?? {}) as Record<string, unknown>;
|
|
316
|
+
const provider = typeof source.provider === "string" && (JEV_HOSTS as readonly string[]).includes(source.provider) ? source.provider as JevHostName : defaults.provider;
|
|
317
|
+
return {
|
|
318
|
+
enabled: flag(source.enabled, defaults.enabled),
|
|
319
|
+
provider,
|
|
320
|
+
model: text(source.model, defaults.model),
|
|
321
|
+
baseUrl: text(source.baseUrl, defaults.baseUrl),
|
|
322
|
+
timeoutMs: count(source.timeoutMs, defaults.timeoutMs),
|
|
323
|
+
features: Object.fromEntries(CLASSIFIER_FEATURES.map((name) => [name, flag(features[name], defaults.features[name])])) as Record<ClassifierFeature, boolean>,
|
|
324
|
+
thresholds: Object.fromEntries(Object.entries(defaults.thresholds).map(([name, fallback]) => [name, probability(thresholds[name as keyof ClassifierThresholds], fallback)])) as unknown as ClassifierThresholds,
|
|
325
|
+
fileHints: {
|
|
326
|
+
topK: count(hints.topK, defaults.fileHints.topK),
|
|
327
|
+
maxCandidates: count(hints.maxCandidates, defaults.fileHints.maxCandidates),
|
|
328
|
+
budgetMs: count(hints.budgetMs, defaults.fileHints.budgetMs),
|
|
329
|
+
},
|
|
330
|
+
effort: { cheapModel: typeof effort.cheapModel === "string" && effort.cheapModel.trim() ? effort.cheapModel.trim() : defaults.effort.cheapModel },
|
|
331
|
+
exclude: Array.isArray(source.exclude) ? source.exclude.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : [...defaults.exclude],
|
|
209
332
|
};
|
|
210
333
|
}
|
|
211
334
|
|
|
@@ -229,6 +352,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
|
229
352
|
workflow,
|
|
230
353
|
knowledge,
|
|
231
354
|
lobby: normalizeLobby(src.lobby),
|
|
355
|
+
classifier: normalizeClassifier(src.classifier),
|
|
232
356
|
};
|
|
233
357
|
}
|
|
234
358
|
|
package/src/schemas/findings.ts
CHANGED
|
@@ -133,4 +133,8 @@ export interface AgentRun {
|
|
|
133
133
|
wrappedUp?: boolean;
|
|
134
134
|
/** File this worker is queued for at the file desk. */
|
|
135
135
|
waitingFor?: string;
|
|
136
|
+
/** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
|
|
137
|
+
routedFrom?: string;
|
|
138
|
+
/** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
|
|
139
|
+
route?: string;
|
|
136
140
|
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -89,11 +89,31 @@ export interface RunLogEntry {
|
|
|
89
89
|
stalled?: boolean;
|
|
90
90
|
wrappedUp?: boolean;
|
|
91
91
|
error?: string;
|
|
92
|
+
/** The classifier routed this run down from this configured profile. */
|
|
93
|
+
routedFrom?: string;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/** Upper bound on persisted run-log entries. */
|
|
95
97
|
export const MAX_RUN_LOG = 64;
|
|
96
98
|
|
|
99
|
+
export type TriageSize = "trivial" | "small" | "medium" | "large";
|
|
100
|
+
|
|
101
|
+
/** What the classifier made of the request when the task started: hints for the Master, never rules. */
|
|
102
|
+
export interface TaskTriage {
|
|
103
|
+
size: TriageSize;
|
|
104
|
+
sizeConfidence: number;
|
|
105
|
+
/** How likely each domain is touched, 0 to 1. */
|
|
106
|
+
domains: Partial<Record<Domain, number>>;
|
|
107
|
+
/** How likely building it needs outside facts. */
|
|
108
|
+
research: number;
|
|
109
|
+
/** How likely it is ambiguous as written. */
|
|
110
|
+
ambiguous: number;
|
|
111
|
+
kind?: string;
|
|
112
|
+
kindProbability?: number;
|
|
113
|
+
likelyFiles?: string[];
|
|
114
|
+
at: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
export interface Task {
|
|
98
118
|
id: string;
|
|
99
119
|
title: string;
|
|
@@ -121,6 +141,10 @@ export interface Task {
|
|
|
121
141
|
ownerSessionId?: string;
|
|
122
142
|
/** The planned task (PLAN-…) whose plan the user agreed in the planning panel: its proposal needs no approval. */
|
|
123
143
|
approvedPlan?: string;
|
|
144
|
+
/** When the task was archived from the lobby (it then lives under archive/tasks, out of every list). */
|
|
145
|
+
archivedAt?: string;
|
|
146
|
+
/** The classifier's read of the request, when it was on as the task started. */
|
|
147
|
+
triage?: TaskTriage;
|
|
124
148
|
}
|
|
125
149
|
|
|
126
150
|
export function createTask(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archiving and deleting tasks from the lobby. An archived task moves, whole,
|
|
3
|
+
* out of the task tree to `archive/tasks/<id>` beside the knowledge archive:
|
|
4
|
+
* no list, widget or session sees it any more, and it can be restored as it
|
|
5
|
+
* was. Deleting removes a task's folder for good. A task still under way is
|
|
6
|
+
* abandoned before it is archived, so it never comes back half-driven.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
11
|
+
import { taskDir, tasksRoot } from "../knowledge/paths.ts";
|
|
12
|
+
import { transition } from "./task-state.ts";
|
|
13
|
+
import { dataRoot, readDataRoots } from "./project.ts";
|
|
14
|
+
|
|
15
|
+
export function archiveRoot(root: string, configDir: string): string {
|
|
16
|
+
return join(dataRoot(root, configDir), "archive", "tasks");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Task ids become folder names: only ever one plain path segment. */
|
|
20
|
+
function checkId(taskId: string): string {
|
|
21
|
+
if (!/^[\w.-]+$/.test(taskId) || taskId === "." || taskId === "..") throw new Error(`not a task id: ${taskId}`);
|
|
22
|
+
return taskId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readAt(dir: string): Task | undefined {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(dir, "state.json"), "utf8")) as Task;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeAt(dir: string, task: Task): void {
|
|
34
|
+
writeFileSync(join(dir, "state.json"), JSON.stringify(task, null, 2), "utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a task on the list lives: the newest tree that has it (bot-lobby, then the pre-rename ones). */
|
|
38
|
+
function liveDir(root: string, configDir: string, taskId: string): string | undefined {
|
|
39
|
+
return readDataRoots(root, configDir).map((dr) => taskDir(dr, taskId)).find((dir) => existsSync(join(dir, "state.json")));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function archivedDir(root: string, configDir: string, taskId: string): string {
|
|
43
|
+
return join(archiveRoot(root, configDir), checkId(taskId));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Archived tasks, most recently archived first. */
|
|
47
|
+
export function listArchivedTasks(root: string, configDir: string): Task[] {
|
|
48
|
+
const dir = archiveRoot(root, configDir);
|
|
49
|
+
if (!existsSync(dir)) return [];
|
|
50
|
+
const tasks: Task[] = [];
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
const task = readAt(join(dir, entry));
|
|
53
|
+
if (task) tasks.push(task);
|
|
54
|
+
}
|
|
55
|
+
return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Move a task out of every list into the archive; one still under way is
|
|
60
|
+
* abandoned first (and says so in its decisions). Returns the archived task.
|
|
61
|
+
*/
|
|
62
|
+
export function archiveTask(root: string, configDir: string, taskId: string, now = new Date()): Task {
|
|
63
|
+
const from = liveDir(root, configDir, checkId(taskId));
|
|
64
|
+
const task = from ? readAt(from) : undefined;
|
|
65
|
+
if (!from || !task) throw new Error(`no task ${taskId}`);
|
|
66
|
+
const to = archivedDir(root, configDir, taskId);
|
|
67
|
+
if (existsSync(to)) throw new Error(`${taskId} is already in the archive`);
|
|
68
|
+
const stamp = now.toISOString();
|
|
69
|
+
if (!TERMINAL_STATES.includes(task.state)) {
|
|
70
|
+
transition(task, "abandoned", stamp);
|
|
71
|
+
task.decisions.push({ domain: "master", text: "Abandoned when it was archived from the lobby.", createdAt: stamp });
|
|
72
|
+
}
|
|
73
|
+
task.archivedAt = stamp;
|
|
74
|
+
writeAt(from, task);
|
|
75
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
76
|
+
renameSync(from, to);
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bring an archived task back to the lists, as it was when archived. */
|
|
81
|
+
export function restoreTask(root: string, configDir: string, taskId: string): Task {
|
|
82
|
+
const from = archivedDir(root, configDir, taskId);
|
|
83
|
+
const task = readAt(from);
|
|
84
|
+
if (!task) throw new Error(`${taskId} is not in the archive`);
|
|
85
|
+
const to = taskDir(dataRoot(root, configDir), taskId);
|
|
86
|
+
if (existsSync(to)) throw new Error(`a task ${taskId} is already on the list`);
|
|
87
|
+
delete task.archivedAt;
|
|
88
|
+
writeAt(from, task);
|
|
89
|
+
mkdirSync(tasksRoot(dataRoot(root, configDir)), { recursive: true });
|
|
90
|
+
renameSync(from, to);
|
|
91
|
+
return task;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove a task for good: its folder, from the list or from the archive. */
|
|
95
|
+
export function deleteTask(root: string, configDir: string, taskId: string, where: "list" | "archive" = "list"): void {
|
|
96
|
+
const dir = where === "archive" ? archivedDir(root, configDir, taskId) : liveDir(root, configDir, checkId(taskId));
|
|
97
|
+
if (!dir || !existsSync(dir)) throw new Error(where === "archive" ? `${taskId} is not in the archive` : `no task ${taskId}`);
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
package/src/state/inbox.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (the lobby of another window talking to a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Inboxes: messages for another session's oracle written from any pi session
|
|
3
|
+
* (the lobby of another window talking to a session it does not drive). A
|
|
4
|
+
* task's inbox lives beside the task, so a message waits there until some
|
|
5
|
+
* session drives the task; a session's inbox lives beside its heartbeat, for
|
|
6
|
+
* a running session with no task. Like plan comments they are append-only
|
|
7
|
+
* JSON-lines logs, never inside state.json, and the session they are for
|
|
8
|
+
* delivers them to its Master and records the delivery.
|
|
7
9
|
*/
|
|
8
10
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
11
|
import { dirname, join } from "node:path";
|
|
10
12
|
import { taskDirFor } from "./persistence.ts";
|
|
13
|
+
import { presenceDir } from "./presence.ts";
|
|
11
14
|
|
|
12
15
|
export interface InboxMessage {
|
|
13
16
|
id: string;
|
|
14
|
-
|
|
17
|
+
/** The task it was left for; absent for a session's own inbox. */
|
|
18
|
+
taskId?: string;
|
|
15
19
|
text: string;
|
|
16
20
|
createdAt: string;
|
|
17
21
|
/** The pi session that wrote it. */
|
|
@@ -30,6 +34,10 @@ export function inboxPath(root: string, configDir: string, taskId: string): stri
|
|
|
30
34
|
return join(taskDirFor(root, configDir, taskId), "inbox.jsonl");
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
export function sessionInboxPath(root: string, configDir: string, sessionId: string): string {
|
|
38
|
+
return join(presenceDir(root, configDir), `${sessionId.replace(/[^\w.-]/g, "_")}.inbox.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function append(path: string, event: InboxEvent): void {
|
|
34
42
|
mkdirSync(dirname(path), { recursive: true });
|
|
35
43
|
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
@@ -56,12 +64,11 @@ function readEvents(path: string): InboxEvent[] {
|
|
|
56
64
|
return events;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
|
|
67
|
+
function readAt(path: string, taskId?: string): InboxMessage[] {
|
|
61
68
|
const byId = new Map<string, InboxMessage>();
|
|
62
|
-
for (const event of readEvents(
|
|
69
|
+
for (const event of readEvents(path)) {
|
|
63
70
|
if (event.kind === "message" && !byId.has(event.id)) {
|
|
64
|
-
byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
|
|
71
|
+
byId.set(event.id, { id: event.id, ...(taskId ? { taskId } : {}), text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), delivered: false });
|
|
65
72
|
} else if (event.kind === "delivered") {
|
|
66
73
|
const message = byId.get(event.id);
|
|
67
74
|
if (message) message.delivered = true;
|
|
@@ -70,17 +77,44 @@ export function readInbox(root: string, configDir: string, taskId: string): Inbo
|
|
|
70
77
|
return [...byId.values()];
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
80
|
+
function sendAt(path: string, text: string, by: string | undefined, now: Date, taskId?: string): InboxMessage {
|
|
75
81
|
const body = text.trim().slice(0, MAX_INBOX_CHARS);
|
|
76
82
|
if (!body) throw new Error("a message needs some text");
|
|
77
83
|
const id = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
78
|
-
append(
|
|
79
|
-
return { id, taskId, text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
|
|
84
|
+
append(path, { kind: "message", id, text: body, at: now.toISOString(), ...(by ? { by } : {}) });
|
|
85
|
+
return { id, ...(taskId ? { taskId } : {}), text: body, createdAt: now.toISOString(), ...(by ? { by } : {}), delivered: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function markAt(path: string, ids: readonly string[], now: Date): void {
|
|
89
|
+
for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The task's messages, oldest first, with whether each reached the oracle. */
|
|
93
|
+
export function readInbox(root: string, configDir: string, taskId: string): InboxMessage[] {
|
|
94
|
+
return readAt(inboxPath(root, configDir, taskId), taskId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Leave a message for the task's oracle; returns it. */
|
|
98
|
+
export function sendToInbox(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
99
|
+
return sendAt(inboxPath(root, configDir, taskId), text, by, now, taskId);
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
export function markInboxDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
83
|
-
|
|
103
|
+
markAt(inboxPath(root, configDir, taskId), ids, now);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A running session's own messages (it has no task to hold them), oldest first. */
|
|
107
|
+
export function readSessionInbox(root: string, configDir: string, sessionId: string): InboxMessage[] {
|
|
108
|
+
return readAt(sessionInboxPath(root, configDir, sessionId));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Leave a message for a running session's oracle; returns it. */
|
|
112
|
+
export function sendToSession(root: string, configDir: string, sessionId: string, text: string, by?: string, now = new Date()): InboxMessage {
|
|
113
|
+
return sendAt(sessionInboxPath(root, configDir, sessionId), text, by, now);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function markSessionInboxDelivered(root: string, configDir: string, sessionId: string, ids: readonly string[], now = new Date()): void {
|
|
117
|
+
markAt(sessionInboxPath(root, configDir, sessionId), ids, now);
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
/** The message the owning session sends its Master for undelivered inbox messages. */
|
package/src/state/metrics.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { AgentRun } from "../schemas/findings.ts";
|
|
|
11
11
|
import type { RunLogEntry, Task } from "../schemas/task.ts";
|
|
12
12
|
import { dataRoot } from "./project.ts";
|
|
13
13
|
|
|
14
|
-
export const METRIC_KINDS = ["master", "scout", "worker", "reviewer", "researcher", "quickfix", "planner", "panel"] as const;
|
|
14
|
+
export const METRIC_KINDS = ["master", "scout", "worker", "reviewer", "researcher", "quickfix", "planner", "panel", "classifier"] as const;
|
|
15
15
|
export type MetricKind = (typeof METRIC_KINDS)[number];
|
|
16
16
|
|
|
17
17
|
export type MetricStatus = "success" | "failed" | "cancelled" | "timeout";
|
|
@@ -33,6 +33,67 @@ export interface MetricRecord {
|
|
|
33
33
|
cost?: number;
|
|
34
34
|
taskId?: string;
|
|
35
35
|
stalled?: boolean;
|
|
36
|
+
/** Classifier calls: what the call decided (seats, answers, files, triage, effort, test). */
|
|
37
|
+
purpose?: string;
|
|
38
|
+
/** A run the classifier routed down: the configured profile it came from. */
|
|
39
|
+
routedFrom?: string;
|
|
40
|
+
/** Classifier calls: what the decision spared (seat runs skipped, questions answered, quick fixes held). */
|
|
41
|
+
saved?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the classifier did and spared, for the Metrics tab. */
|
|
45
|
+
export interface ClassifierSummary {
|
|
46
|
+
calls: number;
|
|
47
|
+
ok: number;
|
|
48
|
+
p50Ms: number;
|
|
49
|
+
p90Ms: number;
|
|
50
|
+
/** Tokens Jev read across every call. */
|
|
51
|
+
input: number;
|
|
52
|
+
/** Calls per decision (seats, answers, files, triage, effort, test). */
|
|
53
|
+
byPurpose: Record<string, number>;
|
|
54
|
+
seatRunsSkipped: number;
|
|
55
|
+
questionsAnswered: number;
|
|
56
|
+
quickFixesHeld: number;
|
|
57
|
+
/** Agent runs the classifier routed down, and how many of them succeeded. */
|
|
58
|
+
routed: number;
|
|
59
|
+
routedOk: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function quantile(sorted: readonly number[], q: number): number {
|
|
63
|
+
if (sorted.length === 0) return 0;
|
|
64
|
+
return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Classifier calls and the agent runs it routed, summarised; undefined when it never ran. */
|
|
68
|
+
export function summarizeClassifier(calls: readonly MetricRecord[], runs: readonly MetricRecord[]): ClassifierSummary | undefined {
|
|
69
|
+
const routed = runs.filter((record) => record.routedFrom);
|
|
70
|
+
if (calls.length === 0 && routed.length === 0) return undefined;
|
|
71
|
+
const times = calls.map((record) => record.durationMs).sort((a, b) => a - b);
|
|
72
|
+
const byPurpose: Record<string, number> = {};
|
|
73
|
+
let seatRunsSkipped = 0;
|
|
74
|
+
let questionsAnswered = 0;
|
|
75
|
+
let quickFixesHeld = 0;
|
|
76
|
+
for (const record of calls) {
|
|
77
|
+
const purpose = record.purpose ?? "other";
|
|
78
|
+
byPurpose[purpose] = (byPurpose[purpose] ?? 0) + 1;
|
|
79
|
+
const saved = record.saved ?? 0;
|
|
80
|
+
if (purpose === "seats") seatRunsSkipped += saved;
|
|
81
|
+
else if (purpose === "answers") questionsAnswered += saved;
|
|
82
|
+
else if (purpose === "triage") quickFixesHeld += saved;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
calls: calls.length,
|
|
86
|
+
ok: calls.filter((record) => record.status === "success").length,
|
|
87
|
+
p50Ms: quantile(times, 0.5),
|
|
88
|
+
p90Ms: quantile(times, 0.9),
|
|
89
|
+
input: calls.reduce((total, record) => total + (record.input ?? 0), 0),
|
|
90
|
+
byPurpose,
|
|
91
|
+
seatRunsSkipped,
|
|
92
|
+
questionsAnswered,
|
|
93
|
+
quickFixesHeld,
|
|
94
|
+
routed: routed.length,
|
|
95
|
+
routedOk: routed.filter((record) => record.status === "success").length,
|
|
96
|
+
};
|
|
36
97
|
}
|
|
37
98
|
|
|
38
99
|
/** Newest records kept in memory for aggregation. */
|
|
@@ -58,7 +119,17 @@ function isRecord(value: unknown): value is MetricRecord {
|
|
|
58
119
|
return Boolean(record && typeof record.id === "string" && typeof record.kind === "string" && typeof record.durationMs === "number");
|
|
59
120
|
}
|
|
60
121
|
|
|
122
|
+
/** Agent runs for the Metrics tab; classifier calls, a few hundred milliseconds each, are read apart. */
|
|
61
123
|
export function readMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
|
|
124
|
+
return readRecords(root, configDir, limit).filter((record) => record.kind !== "classifier");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Classifier calls only. */
|
|
128
|
+
export function readClassifierMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
|
|
129
|
+
return readRecords(root, configDir, limit).filter((record) => record.kind === "classifier");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readRecords(root: string, configDir: string, limit: number): MetricRecord[] {
|
|
62
133
|
const path = metricsPath(root, configDir);
|
|
63
134
|
if (!existsSync(path)) return [];
|
|
64
135
|
let text: string;
|
|
@@ -110,6 +181,7 @@ export function metricFromRun(run: AgentRun): MetricRecord {
|
|
|
110
181
|
...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
|
|
111
182
|
taskId: run.taskId,
|
|
112
183
|
...(run.stalled ? { stalled: true } : {}),
|
|
184
|
+
...(run.routedFrom ? { routedFrom: run.routedFrom } : {}),
|
|
113
185
|
};
|
|
114
186
|
}
|
|
115
187
|
|
|
@@ -130,6 +202,7 @@ export function metricFromLog(entry: RunLogEntry, taskId: string): MetricRecord
|
|
|
130
202
|
...(entry.input !== undefined ? { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0 } : {}),
|
|
131
203
|
taskId,
|
|
132
204
|
...(entry.stalled ? { stalled: true } : {}),
|
|
205
|
+
...(entry.routedFrom ? { routedFrom: entry.routedFrom } : {}),
|
|
133
206
|
};
|
|
134
207
|
}
|
|
135
208
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pi sessions are running in this project right now. Every interactive
|
|
3
|
+
* or headless session with bot-lobby loaded (never a subagent) keeps a small
|
|
4
|
+
* heartbeat file beside the tasks, refreshed on its owner clock and removed
|
|
5
|
+
* when it ends; the lobby reads them to list the live sessions it can view,
|
|
6
|
+
* message or switch to. A heartbeat older than a few beats, or from a process
|
|
7
|
+
* that is gone, does not count.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { dataRoot } from "./project.ts";
|
|
12
|
+
|
|
13
|
+
export interface Presence {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
pid: number;
|
|
16
|
+
/** The pi session's name (a task's title once it starts one). */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** The saved session file, so another window can switch to it once it ends. */
|
|
19
|
+
sessionFile?: string;
|
|
20
|
+
/** The task it drives, if any. */
|
|
21
|
+
taskId?: string;
|
|
22
|
+
/** `tui` for a terminal, `rpc` for a session another window started in the background. */
|
|
23
|
+
mode: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A heartbeat older than this means the session is gone (its owner clock ticks every few seconds). */
|
|
28
|
+
export const PRESENCE_TTL_MS = 15_000;
|
|
29
|
+
/** A heartbeat this old is removed even if its process still runs (it switched sessions without saying so). */
|
|
30
|
+
const FORGET_MS = 10 * 60_000;
|
|
31
|
+
|
|
32
|
+
export function presenceDir(root: string, configDir: string): string {
|
|
33
|
+
return join(dataRoot(root, configDir), "sessions");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Session ids become file names: keep them to safe characters. */
|
|
37
|
+
function safeId(sessionId: string): string | undefined {
|
|
38
|
+
return /^[\w.-]+$/.test(sessionId) ? sessionId : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function presencePath(root: string, configDir: string, sessionId: string): string | undefined {
|
|
42
|
+
const id = safeId(sessionId);
|
|
43
|
+
return id ? join(presenceDir(root, configDir), `${id}.json`) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Record (or refresh) this session's heartbeat; atomic, so readers never see half a file. */
|
|
47
|
+
export function writePresence(root: string, configDir: string, presence: Omit<Presence, "updatedAt">, now = new Date()): void {
|
|
48
|
+
const path = presencePath(root, configDir, presence.sessionId);
|
|
49
|
+
if (!path) return;
|
|
50
|
+
mkdirSync(presenceDir(root, configDir), { recursive: true });
|
|
51
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
52
|
+
writeFileSync(tmp, JSON.stringify({ ...presence, updatedAt: now.toISOString() }), "utf8");
|
|
53
|
+
renameSync(tmp, path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removePresence(root: string, configDir: string, sessionId: string): void {
|
|
57
|
+
const path = presencePath(root, configDir, sessionId);
|
|
58
|
+
if (path) rmSync(path, { force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a process is running: signal 0 checks without touching it (EPERM still means it exists). */
|
|
62
|
+
export function processAlive(pid: number): boolean {
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The sessions running in this project, newest heartbeat first. Heartbeats
|
|
74
|
+
* of processes that are gone are removed; merely late ones are skipped.
|
|
75
|
+
*/
|
|
76
|
+
export function livePresence(root: string, configDir: string, now = Date.now(), alive: (pid: number) => boolean = processAlive): Presence[] {
|
|
77
|
+
const dir = presenceDir(root, configDir);
|
|
78
|
+
if (!existsSync(dir)) return [];
|
|
79
|
+
const live: Presence[] = [];
|
|
80
|
+
for (const file of readdirSync(dir)) {
|
|
81
|
+
if (!file.endsWith(".json") || file.endsWith(".tmp")) continue;
|
|
82
|
+
let presence: Presence;
|
|
83
|
+
try {
|
|
84
|
+
presence = JSON.parse(readFileSync(join(dir, file), "utf8")) as Presence;
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof presence?.sessionId !== "string" || typeof presence.pid !== "number") continue;
|
|
89
|
+
const age = now - Date.parse(presence.updatedAt);
|
|
90
|
+
// Gone, or so long silent that its process has moved on to another session: clear it away.
|
|
91
|
+
if (!alive(presence.pid) || !(age < FORGET_MS)) {
|
|
92
|
+
rmSync(join(dir, file), { force: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (age > PRESENCE_TTL_MS) continue;
|
|
96
|
+
live.push(presence);
|
|
97
|
+
}
|
|
98
|
+
return live.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
99
|
+
}
|