@evanbobo/colco 0.0.0-stage → 0.18.0

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/src/prompt.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ /*
4
+ * Minimal terminal prompts with no dependencies: a numbered multi-select, a single choice, free text and a
5
+ * hidden secret. Every prompt has a non-interactive equivalent flag, so agents and CI never need a TTY.
6
+ */
7
+
8
+ /** Asks one line of text; Enter keeps the default. */
9
+ export async function ask(question, fallback = "") {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ try {
12
+ const answer = (await rl.question(fallback ? `${question} (${fallback}): ` : `${question}: `)).trim();
13
+ return answer || fallback;
14
+ } finally {
15
+ rl.close();
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Numbered multi-select. `options` are `{ value, label, hint, selected }`; the answer is a list such as
21
+ * "1,3" or "all", and Enter keeps the preselected ones.
22
+ */
23
+ export async function multiSelect(question, options) {
24
+ process.stdout.write(`${question}\n`);
25
+ options.forEach((option, index) => {
26
+ process.stdout.write(` ${option.selected ? "[x]" : "[ ]"} ${index + 1}. ${option.label}${option.hint ? ` ${option.hint}` : ""}\n`);
27
+ });
28
+ for (;;) {
29
+ const answer = (await ask("输入编号,逗号分隔;all 表示全部;回车保留已勾选")).toLowerCase();
30
+ if (!answer) return options.filter((option) => option.selected).map((option) => option.value);
31
+ if (answer === "all") return options.map((option) => option.value);
32
+ const picked = answer.split(/[\s,,]+/).filter(Boolean).map(Number);
33
+ if (picked.length > 0 && picked.every((n) => Number.isInteger(n) && n >= 1 && n <= options.length)) {
34
+ return [...new Set(picked)].map((n) => options[n - 1].value);
35
+ }
36
+ process.stdout.write("编号无效,请重试。\n");
37
+ }
38
+ }
39
+
40
+ /** Single choice among `options` (`{ value, label }`); Enter takes the first. */
41
+ export async function choose(question, options) {
42
+ process.stdout.write(`${question}\n`);
43
+ options.forEach((option, index) => process.stdout.write(` ${index + 1}. ${option.label}${index === 0 ? "(默认)" : ""}\n`));
44
+ for (;;) {
45
+ const answer = await ask("输入编号", "1");
46
+ const n = Number(answer);
47
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
48
+ process.stdout.write("编号无效,请重试。\n");
49
+ }
50
+ }
51
+
52
+ /** Reads a secret without echoing it. */
53
+ export function askSecret(question) {
54
+ return new Promise((resolvePromise, reject) => {
55
+ const { stdin, stdout } = process;
56
+ if (!stdin.isTTY) {
57
+ reject(new Error("需要在终端中输入 API Key,或先设置 COLCO_AGENT_TOKEN"));
58
+ return;
59
+ }
60
+ stdout.write(`${question}: `);
61
+ stdin.setRawMode(true);
62
+ stdin.resume();
63
+ stdin.setEncoding("utf8");
64
+ let value = "";
65
+ const done = (error) => {
66
+ stdin.setRawMode(false);
67
+ stdin.pause();
68
+ stdin.off("data", onData);
69
+ stdout.write("\n");
70
+ if (error) reject(error);
71
+ else resolvePromise(value.trim());
72
+ };
73
+ const onData = (chunk) => {
74
+ for (const char of chunk) {
75
+ if (char === "\r" || char === "\n") return done();
76
+ if (char === "\u0003") return done(new Error("已取消"));
77
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
78
+ else if (char >= " ") value += char;
79
+ }
80
+ };
81
+ stdin.on("data", onData);
82
+ });
83
+ }
@@ -0,0 +1,79 @@
1
+ /*
2
+ * Assigns each usage event of a session to a task. A session is a set of streams: the main conversation
3
+ * and one per subagent. In every stream the active task changes at its markers: in_progress starts a
4
+ * task (and ends the one before), any other status ends the active task. A subagent starts with the task
5
+ * that was active in its parent at the moment the parent started it, and its own markers take over from
6
+ * then on, without changing the parent. Events with no active task are unassigned.
7
+ *
8
+ * Stream: { id, parent, spawnAt, markers: [{ at, taskId, status }], events: [usage event] }
9
+ * Usage event: { id, at, model, input, output, cacheRead, cacheWrite, reasoning }; times are epoch ms.
10
+ */
11
+
12
+ const FIELDS = ["input", "output", "cacheRead", "cacheWrite", "reasoning"];
13
+
14
+ /** Empty per-model totals. */
15
+ function emptyTotals() {
16
+ return { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, firstAt: null, lastAt: null };
17
+ }
18
+
19
+ /** Adds one event to the per-model map. */
20
+ function addEvent(models, event) {
21
+ const totals = models.get(event.model) ?? emptyTotals();
22
+ totals.calls += 1;
23
+ for (const field of FIELDS) totals[field] += event[field];
24
+ totals.firstAt = totals.firstAt === null ? event.at : Math.min(totals.firstAt, event.at);
25
+ totals.lastAt = totals.lastAt === null ? event.at : Math.max(totals.lastAt, event.at);
26
+ models.set(event.model, totals);
27
+ }
28
+
29
+ /** Applies one marker to the active task id. */
30
+ function applyMarker(active, marker) {
31
+ if (marker.status === "in_progress") return marker.taskId;
32
+ return marker.taskId === active ? null : active;
33
+ }
34
+
35
+ /**
36
+ * Returns `{ tasks: Map<taskId, Map<model, totals>>, unassigned: Map<model, totals>, taskIds: Set }`.
37
+ * taskIds lists every task the session started, even one that used no tokens, so a report can clear it.
38
+ */
39
+ export function attributeSession(streams) {
40
+ const byId = new Map(streams.map((stream) => [stream.id, stream]));
41
+ // A subagent's markers from before it started are history it replayed from its parent; they are ignored.
42
+ const ownMarkers = (stream) => stream.markers.filter((marker) => stream.parent === null || stream.spawnAt === null || marker.at >= stream.spawnAt);
43
+ const sortedMarkers = new Map(streams.map((stream) => [stream.id, ownMarkers(stream).sort((a, b) => a.at - b.at)]));
44
+ const memo = new Map();
45
+
46
+ /**
47
+ * The task active in `stream` just before time `at` (a marker in the same millisecond has not taken effect
48
+ * yet), following parents for the time before the stream's first marker.
49
+ */
50
+ function activeAt(stream, at, depth = 0) {
51
+ const key = `${stream.id}@${at}`;
52
+ if (memo.has(key)) return memo.get(key);
53
+ const parent = stream.parent !== null && depth < 1000 ? byId.get(stream.parent) : undefined;
54
+ let active = parent && stream.spawnAt !== null ? activeAt(parent, stream.spawnAt, depth + 1) : null;
55
+ for (const marker of sortedMarkers.get(stream.id)) {
56
+ if (marker.at >= at) break;
57
+ active = applyMarker(active, marker);
58
+ }
59
+ memo.set(key, active);
60
+ return active;
61
+ }
62
+
63
+ const tasks = new Map();
64
+ const unassigned = new Map();
65
+ const taskIds = new Set();
66
+ for (const stream of streams) {
67
+ for (const marker of sortedMarkers.get(stream.id)) if (marker.status === "in_progress") taskIds.add(marker.taskId);
68
+ for (const event of stream.events) {
69
+ const taskId = activeAt(stream, event.at);
70
+ if (taskId === null) {
71
+ addEvent(unassigned, event);
72
+ } else {
73
+ if (!tasks.has(taskId)) tasks.set(taskId, new Map());
74
+ addEvent(tasks.get(taskId), event);
75
+ }
76
+ }
77
+ }
78
+ return { tasks, unassigned, taskIds };
79
+ }
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /** Reads a JSONL file into objects, skipping blank and broken lines (agents append while we read). */
4
+ export function readJsonl(path) {
5
+ const rows = [];
6
+ for (const line of readFileSync(path, "utf8").split("\n")) {
7
+ if (!line.trim()) continue;
8
+ try {
9
+ const row = JSON.parse(line);
10
+ if (row && typeof row === "object") rows.push(row);
11
+ } catch {
12
+ // A half-written last line or a corrupt line carries no usable usage.
13
+ }
14
+ }
15
+ return rows;
16
+ }
17
+
18
+ /** Epoch milliseconds of an ISO timestamp, or null. */
19
+ export function timeOf(value) {
20
+ if (typeof value !== "string") return null;
21
+ const ms = Date.parse(value);
22
+ return Number.isFinite(ms) ? ms : null;
23
+ }
24
+
25
+ /** A non-negative integer token count; anything else counts as 0. */
26
+ export function count(value) {
27
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
28
+ }
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { colcoHome } from "../config.mjs";
5
+
6
+ /*
7
+ * Task markers: the moments a session starts or ends work on a Colco task. `colco task start|done` writes
8
+ * each one to Colco's own file, never to the agent's files, so markers cannot change a session or its prompt
9
+ * cache. A marker carries a random id that the command also prints; that output lands in the log of whichever
10
+ * conversation ran it (the main one or a subagent), which is how a report finds where each marker belongs.
11
+ */
12
+
13
+ export const TASK_STATUSES = new Set(["backlog", "todo", "in_progress", "blocked", "review", "done"]);
14
+ /** Above this size the file is rewritten without markers older than KEEP_MS. */
15
+ const PRUNE_BYTES = 512 * 1024;
16
+ const KEEP_MS = 180 * 24 * 60 * 60 * 1000;
17
+
18
+ /** Path of the marker file. */
19
+ export function markersPath(env = process.env) {
20
+ return join(colcoHome(env), "markers.jsonl");
21
+ }
22
+
23
+ /** Whether a parsed line is a well-formed marker. */
24
+ function isMarker(value) {
25
+ return Boolean(value) && typeof value === "object" && typeof value.id === "string" && typeof value.agent === "string"
26
+ && typeof value.session === "string" && Number.isFinite(value.at) && Number.isSafeInteger(value.taskId) && value.taskId > 0
27
+ && TASK_STATUSES.has(value.status);
28
+ }
29
+
30
+ /** Every well-formed marker in the file, oldest first; a broken line is skipped. */
31
+ function readAll(env) {
32
+ const path = markersPath(env);
33
+ if (!existsSync(path)) return [];
34
+ const markers = [];
35
+ for (const line of readFileSync(path, "utf8").split("\n")) {
36
+ if (!line.trim()) continue;
37
+ try {
38
+ const value = JSON.parse(line);
39
+ if (isMarker(value)) markers.push(value);
40
+ } catch {
41
+ // A line cut short by a crash carries no usable marker.
42
+ }
43
+ }
44
+ return markers;
45
+ }
46
+
47
+ /** Drops markers older than KEEP_MS once the file grows past PRUNE_BYTES. */
48
+ function prune(env, now) {
49
+ const path = markersPath(env);
50
+ if (!existsSync(path) || statSync(path).size < PRUNE_BYTES) return;
51
+ const kept = readAll(env).filter((marker) => marker.at >= now - KEEP_MS);
52
+ writeFileSync(path, kept.map((marker) => `${JSON.stringify(marker)}\n`).join(""), { mode: 0o600 });
53
+ }
54
+
55
+ /** Appends one marker for `agent` and `session` and returns it, with its new id. */
56
+ export function recordMarker({ agent, session, taskId, status, at = Date.now() }, env = process.env) {
57
+ const marker = { id: randomUUID(), agent, session, at, taskId, status };
58
+ if (!isMarker(marker)) throw new Error("无效的任务标记");
59
+ const path = markersPath(env);
60
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
61
+ prune(env, at);
62
+ appendFileSync(path, `${JSON.stringify(marker)}\n`, { mode: 0o600 });
63
+ chmodSync(path, 0o600);
64
+ return marker;
65
+ }
66
+
67
+ /** The markers of one agent session, oldest first. */
68
+ export function readMarkers({ agent, session }, env = process.env) {
69
+ return readAll(env).filter((marker) => marker.agent === agent && marker.session === session).sort((a, b) => a.at - b.at);
70
+ }
71
+
72
+ /**
73
+ * Gives each marker to the stream that ran it: a subagent whose log contains the marker's id and that was
74
+ * already running then (a child that replays its parent's history contains older ids too); the latest
75
+ * started such subagent wins, since a parent may quote its child's output. Anything else, including a marker
76
+ * whose output is not logged yet, belongs to the main stream. Streams need `path`, the log file to search.
77
+ */
78
+ export function assignMarkers(streams, markers) {
79
+ const texts = new Map();
80
+ const textOf = (stream) => {
81
+ if (!texts.has(stream.id)) {
82
+ try {
83
+ texts.set(stream.id, stream.path ? readFileSync(stream.path, "utf8") : "");
84
+ } catch {
85
+ texts.set(stream.id, "");
86
+ }
87
+ }
88
+ return texts.get(stream.id);
89
+ };
90
+ const owned = new Map(streams.map((stream) => [stream.id, []]));
91
+ const main = streams.find((stream) => stream.parent === null) ?? streams[0];
92
+ for (const marker of markers) {
93
+ let owner = null;
94
+ for (const stream of streams) {
95
+ if (stream === main || stream.spawnAt === null || marker.at < stream.spawnAt) continue;
96
+ if (!textOf(stream).includes(marker.id)) continue;
97
+ if (!owner || stream.spawnAt > owner.spawnAt) owner = stream;
98
+ }
99
+ owned.get((owner ?? main).id).push({ at: marker.at, taskId: marker.taskId, status: marker.status });
100
+ }
101
+ return streams.map((stream) => ({ ...stream, markers: owned.get(stream.id) }));
102
+ }
103
+
104
+ /** The line the colco command prints after moving a task; the id ties the agent's log to the marker. */
105
+ export function cliConfirmation(taskId, status, markerId) {
106
+ return markerId ? `colco: task ${taskId} ${status} (marker ${markerId})` : `colco: task ${taskId} ${status}`;
107
+ }
@@ -0,0 +1,52 @@
1
+ import { attributeSession } from "./attribute.mjs";
2
+ import { assignMarkers } from "./markers.mjs";
3
+ import { callTool } from "../api.mjs";
4
+
5
+ /*
6
+ * Turns a session and its markers into per-task reports and sends them. Every report covers the whole
7
+ * session as it is on disk now and replaces what was sent for it before, so running a report again only
8
+ * corrects numbers.
9
+ */
10
+
11
+ /** The report_task_usage model entries for one task's per-model totals. */
12
+ function modelEntries(models) {
13
+ return [...models.entries()]
14
+ .map(([model, totals]) => ({
15
+ model, calls: totals.calls, input_tokens: totals.input, output_tokens: totals.output, cache_read_tokens: totals.cacheRead,
16
+ cache_write_tokens: totals.cacheWrite, reasoning_tokens: Math.min(totals.reasoning, totals.output),
17
+ first_at: new Date(totals.firstAt).toISOString(), last_at: new Date(totals.lastAt).toISOString(),
18
+ }))
19
+ .sort((a, b) => a.model.localeCompare(b.model));
20
+ }
21
+
22
+ /** Builds one report per task the session's markers mention; `onlyTask` limits the reports to one task. */
23
+ export function buildReports(session, markers, { onlyTask = null } = {}) {
24
+ const attributed = attributeSession(assignMarkers(session.streams, markers));
25
+ const taskIds = [...new Set([...attributed.taskIds, ...attributed.tasks.keys(), ...markers.map((marker) => marker.taskId)])]
26
+ .filter((id) => onlyTask === null || id === onlyTask)
27
+ .sort((a, b) => a - b);
28
+ return {
29
+ sessionId: session.sessionId,
30
+ reports: taskIds.map((taskId) => ({ taskId, models: modelEntries(attributed.tasks.get(taskId) ?? new Map()) })),
31
+ unassigned: modelEntries(attributed.unassigned),
32
+ };
33
+ }
34
+
35
+ /** Sends every report; one failing task does not stop the others. */
36
+ export async function sendReports(connection, agentName, built, fetchImpl) {
37
+ const results = [];
38
+ for (const report of built.reports) {
39
+ try {
40
+ await callTool(connection, "report_task_usage", { task_id: report.taskId, agent: agentName, session_id: built.sessionId, models: report.models }, fetchImpl);
41
+ results.push({ taskId: report.taskId, ok: true });
42
+ } catch (error) {
43
+ results.push({ taskId: report.taskId, ok: false, message: error instanceof Error ? error.message : String(error) });
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+
49
+ /** Sum of the four token kinds over model entries. */
50
+ export function totalTokens(models) {
51
+ return models.reduce((sum, entry) => sum + entry.input_tokens + entry.output_tokens + entry.cache_read_tokens + entry.cache_write_tokens, 0);
52
+ }