@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/pi/zen.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* arrive on its run. Everything here is pure: expression frames and the spinner
|
|
8
8
|
* tick arrive from the caller, and every timestamp arrives as `now`.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { clip, textWidth } from "../width.ts";
|
|
11
11
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
12
12
|
import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
|
|
13
13
|
import { truncate } from "../text.ts";
|
|
@@ -399,9 +399,9 @@ const BANNER_MIN_WIDTH = 60;
|
|
|
399
399
|
/** Boxed title for wide terminals, one-line title otherwise; never wider than `width`. */
|
|
400
400
|
export function bannerLines(width: number): string[] {
|
|
401
401
|
if (width < 1) return [];
|
|
402
|
-
if (width < BANNER_MIN_WIDTH) return [
|
|
402
|
+
if (width < BANNER_MIN_WIDTH) return [clip(BANNER_NARROW, width, "")];
|
|
403
403
|
const inner = BANNER_WIDTH - 2;
|
|
404
|
-
const titleWidth =
|
|
404
|
+
const titleWidth = textWidth(BANNER_TITLE);
|
|
405
405
|
const left = Math.floor((inner - titleWidth) / 2);
|
|
406
406
|
const border = "─".repeat(inner);
|
|
407
407
|
return [`┌${border}┐`, `│${" ".repeat(left)}${BANNER_TITLE}${" ".repeat(inner - titleWidth - left)}│`, `└${border}┘`];
|
|
@@ -476,7 +476,7 @@ function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
|
|
479
|
-
const body =
|
|
479
|
+
const body = clip(content, COMPACT_INNER, "", true);
|
|
480
480
|
return `|${theme ? theme.fg(color, body) : body}|`;
|
|
481
481
|
}
|
|
482
482
|
|
|
@@ -524,7 +524,7 @@ function compactPanel(
|
|
|
524
524
|
const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...strip];
|
|
525
525
|
const tail = tailLines(task, runs, now, tick, steps, opts.theme);
|
|
526
526
|
const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
|
|
527
|
-
return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) =>
|
|
527
|
+
return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
/* -------------------------------------------------------------------------
|
|
@@ -646,7 +646,7 @@ export function panelLines(
|
|
|
646
646
|
// The still scene has no tower or agent strip to fit, so any height keeps the large tier's status box.
|
|
647
647
|
if (width >= LARGE_MIN_WIDTH && (budget >= MIN_LARGE_LINES || opts.still)) {
|
|
648
648
|
const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme, opts.still);
|
|
649
|
-
return scene.map((line) =>
|
|
649
|
+
return scene.map((line) => clip(line, width));
|
|
650
650
|
}
|
|
651
651
|
return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
|
|
652
652
|
}
|
|
@@ -66,6 +66,8 @@ export interface WorkflowConfig {
|
|
|
66
66
|
wrapUpAt: number;
|
|
67
67
|
/** Workers that may run at once when the Master delegates several domains together. */
|
|
68
68
|
maxParallelWorkers: number;
|
|
69
|
+
/** The oracle starts each task with a clean context: its model is sent only the conversation since the task started, or since the last one ended. */
|
|
70
|
+
freshContext: boolean;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export interface KnowledgeConfig {
|
|
@@ -201,6 +203,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
201
203
|
toolStallTimeoutMs: 10 * 60 * 1000,
|
|
202
204
|
wrapUpAt: 0.75,
|
|
203
205
|
maxParallelWorkers: 3,
|
|
206
|
+
freshContext: true,
|
|
204
207
|
},
|
|
205
208
|
knowledge: {
|
|
206
209
|
compactionThreshold: 20000,
|
|
@@ -336,6 +339,7 @@ function normalizeClassifier(value: unknown): ClassifierConfig {
|
|
|
336
339
|
export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
337
340
|
const src = (partial ?? {}) as Record<string, unknown>;
|
|
338
341
|
const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
|
|
342
|
+
workflow.freshContext = workflow.freshContext !== false;
|
|
339
343
|
const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
|
|
340
344
|
const srcAgents = (src.agents ?? {}) as Partial<BotLobbyConfig["agents"]>;
|
|
341
345
|
return {
|
package/src/state/archive.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* abandoned before it is archived, so it never comes back half-driven.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { dirname, join } from "node:path";
|
|
9
|
+
import { dirname, join, sep } from "node:path";
|
|
10
10
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
11
11
|
import { taskDir, tasksRoot } from "../knowledge/paths.ts";
|
|
12
12
|
import { transition } from "./task-state.ts";
|
|
13
13
|
import { dataRoot, readDataRoots } from "./project.ts";
|
|
14
|
+
import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
|
|
14
15
|
|
|
15
16
|
export function archiveRoot(root: string, configDir: string): string {
|
|
16
17
|
return join(dataRoot(root, configDir), "archive", "tasks");
|
|
@@ -43,15 +44,23 @@ function archivedDir(root: string, configDir: string, taskId: string): string {
|
|
|
43
44
|
return join(archiveRoot(root, configDir), checkId(taskId));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
function isObject(value: unknown): value is Task {
|
|
48
|
+
return typeof value === "object" && value !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Archived tasks, most recently archived first (parsed again only when a file changes; shared, for reading). */
|
|
47
52
|
export function listArchivedTasks(root: string, configDir: string): Task[] {
|
|
48
53
|
const dir = archiveRoot(root, configDir);
|
|
49
54
|
if (!existsSync(dir)) return [];
|
|
50
55
|
const tasks: Task[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
51
57
|
for (const entry of readdirSync(dir)) {
|
|
52
|
-
const
|
|
58
|
+
const path = join(dir, entry, "state.json");
|
|
59
|
+
seen.add(path);
|
|
60
|
+
const task = readJsonCached(path, isObject);
|
|
53
61
|
if (task) tasks.push(task);
|
|
54
62
|
}
|
|
63
|
+
forgetCachedUnder(`${dir}${sep}`, seen);
|
|
55
64
|
return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
|
|
56
65
|
}
|
|
57
66
|
|
package/src/state/backlog.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* saving at once cannot overwrite each other.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { join, sep } from "node:path";
|
|
10
10
|
import { writeFileEnsured } from "../knowledge/store.ts";
|
|
11
|
+
import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
|
|
11
12
|
import { dataRoot } from "./project.ts";
|
|
12
13
|
import { taskSlug } from "./persistence.ts";
|
|
13
14
|
|
|
@@ -71,16 +72,25 @@ export function loadPlannedTask(root: string, configDir: string, id: string): Pl
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Every saved entry, pending first, newest first within each group;
|
|
77
|
+
* unreadable files are skipped. Files are parsed again only when they change,
|
|
78
|
+
* and the entries are shared: for reading (the lobby lists them every few
|
|
79
|
+
* seconds); `loadPlannedTask` gives a copy of one's own.
|
|
80
|
+
*/
|
|
75
81
|
export function listPlannedTasks(root: string, configDir: string): PlannedTask[] {
|
|
76
82
|
const dir = backlogDir(root, configDir);
|
|
77
83
|
if (!existsSync(dir)) return [];
|
|
78
84
|
const entries: PlannedTask[] = [];
|
|
85
|
+
const seen = new Set<string>();
|
|
79
86
|
for (const file of readdirSync(dir)) {
|
|
80
87
|
if (!file.endsWith(".json")) continue;
|
|
81
|
-
const
|
|
88
|
+
const path = join(dir, file);
|
|
89
|
+
seen.add(path);
|
|
90
|
+
const entry = readJsonCached(path, isPlannedTask);
|
|
82
91
|
if (entry) entries.push(entry);
|
|
83
92
|
}
|
|
93
|
+
forgetCachedUnder(`${dir}${sep}`, seen);
|
|
84
94
|
return entries.sort((a, b) => {
|
|
85
95
|
if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
|
|
86
96
|
return b.updatedAt.localeCompare(a.updatedAt);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON files as last parsed, kept while each file's stat is unchanged. The
|
|
3
|
+
* owner's clock and the lobby look at every task (and planned or archived
|
|
4
|
+
* task) every few seconds in every session; a file is parsed again only when
|
|
5
|
+
* it changed. A file written a moment ago is not kept (two writes that close
|
|
6
|
+
* together can share a stat), and this process's own writes drop their entry
|
|
7
|
+
* at once. What comes back is shared with every other caller: for reading
|
|
8
|
+
* only.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
/** How long ago a file must have been written for its parse to be kept. */
|
|
13
|
+
export const SETTLE_MS = 1000;
|
|
14
|
+
|
|
15
|
+
/** Files kept at most; the least recently read are let go past this (archived tasks listed once, say). */
|
|
16
|
+
export const CACHE_LIMIT = 512;
|
|
17
|
+
|
|
18
|
+
const parsed = new Map<string, { stamp: string; value: unknown }>();
|
|
19
|
+
|
|
20
|
+
/** The file at `path` parsed as JSON and accepted by `valid`; undefined when missing, unreadable or rejected. */
|
|
21
|
+
export function readJsonCached<T>(path: string, valid: (value: unknown) => value is T): T | undefined {
|
|
22
|
+
let stamp: string;
|
|
23
|
+
let settled: boolean;
|
|
24
|
+
try {
|
|
25
|
+
const stat = statSync(path, { bigint: true });
|
|
26
|
+
stamp = `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
27
|
+
settled = Date.now() - Number(stat.mtimeMs) >= SETTLE_MS;
|
|
28
|
+
} catch {
|
|
29
|
+
parsed.delete(path);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const hit = parsed.get(path);
|
|
33
|
+
if (hit?.stamp === stamp) {
|
|
34
|
+
// Most recently read last, so the one let go is the longest unread.
|
|
35
|
+
parsed.delete(path);
|
|
36
|
+
parsed.set(path, hit);
|
|
37
|
+
return valid(hit.value) ? hit.value : undefined;
|
|
38
|
+
}
|
|
39
|
+
let value: unknown;
|
|
40
|
+
try {
|
|
41
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
parsed.delete(path);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
parsed.delete(path);
|
|
47
|
+
if (settled) {
|
|
48
|
+
parsed.set(path, { stamp, value });
|
|
49
|
+
if (parsed.size > CACHE_LIMIT) parsed.delete(parsed.keys().next().value!);
|
|
50
|
+
}
|
|
51
|
+
return valid(value) ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop a file's entry (this process is about to write it). */
|
|
55
|
+
export function forgetCached(path: string): void {
|
|
56
|
+
parsed.delete(path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Drop the entries under `dir` other than `keep` (files that left the folder). */
|
|
60
|
+
export function forgetCachedUnder(dir: string, keep: ReadonlySet<string>): void {
|
|
61
|
+
for (const path of parsed.keys()) if (path.startsWith(dir) && !keep.has(path)) parsed.delete(path);
|
|
62
|
+
}
|
package/src/state/metrics.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* thinking level, how often it succeeds and what it costs. Append-only JSON
|
|
6
6
|
* lines per project; reads keep the newest `MAX_READ` records.
|
|
7
7
|
*/
|
|
8
|
-
import { appendFileSync,
|
|
8
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
11
11
|
import type { RunLogEntry, Task } from "../schemas/task.ts";
|
|
@@ -119,6 +119,20 @@ function isRecord(value: unknown): value is MetricRecord {
|
|
|
119
119
|
return Boolean(record && typeof record.id === "string" && typeof record.kind === "string" && typeof record.durationMs === "number");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** How much of a long log's end the first read takes: far more than `MAX_READ` records need. */
|
|
123
|
+
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
124
|
+
|
|
125
|
+
/** A metrics log as followed so far: the file it was, how far it was read, and its newest records. */
|
|
126
|
+
interface Followed {
|
|
127
|
+
ino: bigint;
|
|
128
|
+
offset: number;
|
|
129
|
+
records: MetricRecord[];
|
|
130
|
+
/** The first read starts inside the file, part way through a line. */
|
|
131
|
+
midLine: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const followed = new Map<string, Followed>();
|
|
135
|
+
|
|
122
136
|
/** Agent runs for the Metrics tab; classifier calls, a few hundred milliseconds each, are read apart. */
|
|
123
137
|
export function readMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
|
|
124
138
|
return readRecords(root, configDir, limit).filter((record) => record.kind !== "classifier");
|
|
@@ -129,26 +143,62 @@ export function readClassifierMetrics(root: string, configDir: string, limit = M
|
|
|
129
143
|
return readRecords(root, configDir, limit).filter((record) => record.kind === "classifier");
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The newest records of the project's log, agent runs and classifier calls
|
|
148
|
+
* alike. The log only grows, so after the first read (of its end only) each
|
|
149
|
+
* read parses just the lines appended since; a log that shrank or was
|
|
150
|
+
* replaced is read afresh.
|
|
151
|
+
*/
|
|
132
152
|
function readRecords(root: string, configDir: string, limit: number): MetricRecord[] {
|
|
133
153
|
const path = metricsPath(root, configDir);
|
|
134
|
-
|
|
135
|
-
let
|
|
154
|
+
let ino: bigint;
|
|
155
|
+
let size: number;
|
|
136
156
|
try {
|
|
137
|
-
|
|
157
|
+
const stat = statSync(path, { bigint: true });
|
|
158
|
+
ino = stat.ino;
|
|
159
|
+
size = Number(stat.size);
|
|
138
160
|
} catch {
|
|
161
|
+
followed.delete(path);
|
|
139
162
|
return [];
|
|
140
163
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
let log = followed.get(path);
|
|
165
|
+
if (!log || log.ino !== ino || size < log.offset) {
|
|
166
|
+
const offset = Math.max(0, size - TAIL_BYTES);
|
|
167
|
+
log = { ino, offset, records: [], midLine: offset > 0 };
|
|
168
|
+
followed.set(path, log);
|
|
169
|
+
}
|
|
170
|
+
if (size > log.offset) readAppended(path, log, size);
|
|
171
|
+
return log.records.length > limit ? log.records.slice(-limit) : [...log.records];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readAppended(path: string, log: Followed, size: number): void {
|
|
175
|
+
let fd: number | undefined;
|
|
176
|
+
try {
|
|
177
|
+
fd = openSync(path, "r");
|
|
178
|
+
const buffer = Buffer.alloc(size - log.offset);
|
|
179
|
+
const read = readSync(fd, buffer, 0, buffer.length, log.offset);
|
|
180
|
+
// Whole lines only: one still being appended is read next time.
|
|
181
|
+
const end = buffer.lastIndexOf(0x0a, read - 1);
|
|
182
|
+
if (end < 0) return;
|
|
183
|
+
const lines = buffer.toString("utf8", 0, end).split("\n");
|
|
184
|
+
if (log.midLine) lines.shift();
|
|
185
|
+
log.midLine = false;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (!line.trim()) continue;
|
|
188
|
+
try {
|
|
189
|
+
const value = JSON.parse(line) as unknown;
|
|
190
|
+
if (isRecord(value)) log.records.push(value);
|
|
191
|
+
} catch {
|
|
192
|
+
// Torn lines are skipped.
|
|
193
|
+
}
|
|
149
194
|
}
|
|
195
|
+
if (log.records.length > MAX_READ) log.records.splice(0, log.records.length - MAX_READ);
|
|
196
|
+
log.offset += end + 1;
|
|
197
|
+
} catch {
|
|
198
|
+
// Unreadable for now; the next read tries again.
|
|
199
|
+
} finally {
|
|
200
|
+
if (fd !== undefined) closeSync(fd);
|
|
150
201
|
}
|
|
151
|
-
return records.slice(-limit);
|
|
152
202
|
}
|
|
153
203
|
|
|
154
204
|
const AGENT_NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
package/src/state/persistence.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
3
|
import type { KnowledgeConfig } from "../schemas/configuration.ts";
|
|
4
4
|
import type { Domain } from "../schemas/agent.ts";
|
|
5
5
|
import { TERMINAL_STATES, isTaskState, type Task } from "../schemas/task.ts";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type KnowledgeAgent,
|
|
15
15
|
} from "../knowledge/paths.ts";
|
|
16
16
|
import { DEFAULT_KNOWLEDGE_CONTENT, ensureFile, readFileOr, writeFileEnsured } from "../knowledge/store.ts";
|
|
17
|
+
import { forgetCached, forgetCachedUnder, readJsonCached } from "./file-cache.ts";
|
|
17
18
|
|
|
18
19
|
/** Idempotently create the full knowledge + tasks layout with seed files. */
|
|
19
20
|
export function ensureProjectStructure(root: string, configDir: string): void {
|
|
@@ -36,6 +37,7 @@ export function ensureProjectStructure(root: string, configDir: string): void {
|
|
|
36
37
|
export function createTaskDir(root: string, configDir: string, task: Task): void {
|
|
37
38
|
const dir = taskDir(dataRoot(root, configDir), task.id);
|
|
38
39
|
mkdirSync(dir, { recursive: true });
|
|
40
|
+
forgetCached(join(dir, "state.json"));
|
|
39
41
|
writeFileEnsured(join(dir, "state.json"), JSON.stringify(task, null, 2));
|
|
40
42
|
ensureFile(join(dir, "proposal.md"), "");
|
|
41
43
|
ensureFile(join(dir, "plan.md"), "");
|
|
@@ -77,7 +79,9 @@ export function readTaskArtifact(
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export function saveTask(root: string, configDir: string, task: Task): void {
|
|
80
|
-
|
|
82
|
+
const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
|
|
83
|
+
forgetCached(path);
|
|
84
|
+
writeFileEnsured(path, JSON.stringify(task, null, 2));
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
|
|
@@ -147,6 +151,36 @@ export function listTasks(root: string, configDir: string): Task[] {
|
|
|
147
151
|
return tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
/** Any parsed JSON object counts as a task, as `readTaskAt` has it. */
|
|
155
|
+
function isObject(value: unknown): value is Task {
|
|
156
|
+
return typeof value === "object" && value !== null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* All tasks on disk, newest first, like `listTasks`, but a file is read only
|
|
161
|
+
* when it changed since the last look. The tasks are shared with every other
|
|
162
|
+
* caller, so they are for reading (the owner's clock, the lobby): whatever
|
|
163
|
+
* changes a task loads its own copy with `loadTask` and saves that.
|
|
164
|
+
*/
|
|
165
|
+
export function peekTasks(root: string, configDir: string): Task[] {
|
|
166
|
+
const tasks: Task[] = [];
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
for (const { dir } of taskEntries(root, configDir)) {
|
|
169
|
+
const path = join(dir, "state.json");
|
|
170
|
+
seen.add(path);
|
|
171
|
+
const task = readJsonCached(path, isObject);
|
|
172
|
+
if (task) tasks.push(task);
|
|
173
|
+
}
|
|
174
|
+
// Forget tasks that left these folders (deleted or archived).
|
|
175
|
+
for (const dr of readDataRoots(root, configDir)) forgetCachedUnder(`${tasksRoot(dr)}${sep}`, seen);
|
|
176
|
+
return tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The non-terminal task a session owns, from `peekTasks` (for reading only). */
|
|
180
|
+
export function peekOwnedTask(root: string, configDir: string, sessionId: string): Task | undefined {
|
|
181
|
+
return peekTasks(root, configDir).find((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId === sessionId);
|
|
182
|
+
}
|
|
183
|
+
|
|
150
184
|
/** Tasks on disk plus the ids whose state.json could not be read (§59). */
|
|
151
185
|
export function taskHealth(root: string, configDir: string): { tasks: Task[]; corrupted: string[] } {
|
|
152
186
|
const tasks: Task[] = [];
|
package/src/width.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal width of styled text, fast. pi-tui's `visibleWidth` segments every
|
|
3
|
+
* string into grapheme clusters, which is exact but slow, and its cache only
|
|
4
|
+
* helps strings seen before: the lobby's rows are new strings every frame
|
|
5
|
+
* (boxes side by side, a clock, a spinner). This scans them instead: ASCII
|
|
6
|
+
* counts one column, escape sequences none, and every other character in the
|
|
7
|
+
* ranges below is measured once with pi-tui (on its own, as the cluster it
|
|
8
|
+
* always is there) and remembered. Anything else — combining marks, variation
|
|
9
|
+
* selectors, joiners, emoji or CJK outside those ranges — goes to pi-tui for
|
|
10
|
+
* the whole string, so the answer is always pi-tui's.
|
|
11
|
+
*/
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
/** Characters that always stand alone as a cluster: Latin, punctuation, arrows, symbols, box drawing, shapes, braille. */
|
|
15
|
+
function standsAlone(code: number): boolean {
|
|
16
|
+
return (code >= 0xa0 && code < 0x300 && code !== 0xad)
|
|
17
|
+
|| (code >= 0x2010 && code <= 0x2027)
|
|
18
|
+
|| (code >= 0x2030 && code <= 0x205e)
|
|
19
|
+
|| (code >= 0x2190 && code <= 0x23ff)
|
|
20
|
+
|| (code >= 0x2500 && code <= 0x27bf)
|
|
21
|
+
|| (code >= 0x2800 && code <= 0x29ff);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The length of the escape sequence at `index` that takes no columns, exactly
|
|
26
|
+
* as pi-tui reads them — CSI ending in m, G, K, H or J (parameters only
|
|
27
|
+
* before it), OSC and APC ending in BEL or ST — or 0 for any other.
|
|
28
|
+
*/
|
|
29
|
+
function escapeLength(text: string, index: number): number {
|
|
30
|
+
const kind = text.charCodeAt(index + 1);
|
|
31
|
+
if (kind === 0x5b) {
|
|
32
|
+
for (let at = index + 2; at < text.length; at += 1) {
|
|
33
|
+
const code = text.charCodeAt(at);
|
|
34
|
+
// m G K H J end it; digits, ; : ? are its parameters; anything else is read differently by pi-tui.
|
|
35
|
+
if (code === 0x6d || code === 0x47 || code === 0x4b || code === 0x48 || code === 0x4a) return at + 1 - index;
|
|
36
|
+
if (!((code >= 0x30 && code <= 0x3b) || code === 0x3f)) return 0;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
if (kind === 0x5d || kind === 0x5f) {
|
|
41
|
+
for (let at = index + 2; at < text.length; at += 1) {
|
|
42
|
+
const code = text.charCodeAt(at);
|
|
43
|
+
if (code === 0x07) return at + 1 - index;
|
|
44
|
+
if (code === 0x1b) return text.charCodeAt(at + 1) === 0x5c ? at + 2 - index : 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const charWidths = new Map<number, number>();
|
|
51
|
+
|
|
52
|
+
function scan(text: string): number {
|
|
53
|
+
let width = 0;
|
|
54
|
+
for (let index = 0; index < text.length; ) {
|
|
55
|
+
const code = text.charCodeAt(index);
|
|
56
|
+
if (code >= 0x20 && code < 0x7f) {
|
|
57
|
+
width += 1;
|
|
58
|
+
index += 1;
|
|
59
|
+
} else if (code === 0x1b) {
|
|
60
|
+
const length = escapeLength(text, index);
|
|
61
|
+
if (length === 0) return visibleWidth(text);
|
|
62
|
+
index += length;
|
|
63
|
+
} else {
|
|
64
|
+
if (!standsAlone(code)) return visibleWidth(text);
|
|
65
|
+
let columns = charWidths.get(code);
|
|
66
|
+
if (columns === undefined) {
|
|
67
|
+
columns = visibleWidth(String.fromCharCode(code));
|
|
68
|
+
charWidths.set(code, columns);
|
|
69
|
+
}
|
|
70
|
+
width += columns;
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return width;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Widths of recent strings: a frame mostly repeats the last one's lines (a few hundred of them). */
|
|
78
|
+
const recent = new Map<string, number>();
|
|
79
|
+
const RECENT_LIMIT = 1024;
|
|
80
|
+
|
|
81
|
+
/** Columns `text` takes in a terminal; the same as pi-tui's `visibleWidth`. */
|
|
82
|
+
export function textWidth(text: string): number {
|
|
83
|
+
if (text.length < 16) return scan(text);
|
|
84
|
+
const known = recent.get(text);
|
|
85
|
+
if (known !== undefined) return known;
|
|
86
|
+
const width = scan(text);
|
|
87
|
+
if (recent.size >= RECENT_LIMIT) recent.delete(recent.keys().next().value!);
|
|
88
|
+
recent.set(text, width);
|
|
89
|
+
return width;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `text` cut to `width` columns (ending in `ellipsis`), padded to it when
|
|
94
|
+
* `pad`: pi-tui's `truncateToWidth`, which walks every character even when
|
|
95
|
+
* the text already fits — checked first here, since most lines do.
|
|
96
|
+
*/
|
|
97
|
+
export function clip(text: string, width: number, ellipsis = "...", pad = false): string {
|
|
98
|
+
if (width <= 0) return "";
|
|
99
|
+
const columns = textWidth(text);
|
|
100
|
+
if (columns <= width) return pad && columns < width ? `${text}${" ".repeat(width - columns)}` : text;
|
|
101
|
+
return truncateToWidth(text, width, ellipsis, pad);
|
|
102
|
+
}
|