botholomew 0.21.2 → 0.22.1
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 +1 -0
- package/package.json +1 -1
- package/src/chat/agent.ts +3 -0
- package/src/cli.ts +2 -0
- package/src/commands/status.ts +194 -0
- package/src/prompts/capabilities.ts +1 -1
- package/src/status/collect.ts +241 -0
- package/src/worker/llm.ts +23 -2
- package/src/worker/prompt.ts +17 -0
package/README.md
CHANGED
|
@@ -232,6 +232,7 @@ semantic search, append-only versioning, and URL refresh all live there.
|
|
|
232
232
|
| Command | Purpose |
|
|
233
233
|
|---|---|
|
|
234
234
|
| `botholomew init` | Initialize the current directory as a project (refuses on iCloud/Dropbox/NFS without `--force`) |
|
|
235
|
+
| `botholomew status` | One-command dashboard: workers, task counts/claims, schedules (with a live "due?" check), quarantined files, store info. `--json` for scripting, `--no-evaluate` to skip the LLM schedule check |
|
|
235
236
|
| `botholomew worker run\|start` | Run a worker (foreground or background); `--persist` for long-running, `--task-id <id>` to target one task |
|
|
236
237
|
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
237
238
|
| `botholomew chat` | Interactive Ink/React TUI |
|
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { maybeStoreResult } from "../worker/large-results.ts";
|
|
|
28
28
|
import {
|
|
29
29
|
buildMetaHeader,
|
|
30
30
|
extractKeywords,
|
|
31
|
+
LARGE_JSON_SECTION,
|
|
31
32
|
loadPersistentContext,
|
|
32
33
|
MEMBOT_PROMPT_SECTION,
|
|
33
34
|
STYLE_RULES,
|
|
@@ -64,6 +65,7 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
64
65
|
"membot_count_lines",
|
|
65
66
|
"membot_copy",
|
|
66
67
|
"membot_pipe",
|
|
68
|
+
"membot_query",
|
|
67
69
|
"list_threads",
|
|
68
70
|
"view_thread",
|
|
69
71
|
"search_threads",
|
|
@@ -120,6 +122,7 @@ Format your responses using Markdown. Use headings, bold, italic, lists, and cod
|
|
|
120
122
|
`;
|
|
121
123
|
|
|
122
124
|
prompt += `\n${MEMBOT_PROMPT_SECTION}`;
|
|
125
|
+
prompt += `\n${LARGE_JSON_SECTION}`;
|
|
123
126
|
|
|
124
127
|
if (options?.hasMcpTools) {
|
|
125
128
|
prompt += `
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { registerPrepareCommand } from "./commands/prepare.ts";
|
|
|
13
13
|
import { registerPromptsCommand } from "./commands/prompts.ts";
|
|
14
14
|
import { registerScheduleCommand } from "./commands/schedule.ts";
|
|
15
15
|
import { registerSkillCommand } from "./commands/skill.ts";
|
|
16
|
+
import { registerStatusCommand } from "./commands/status.ts";
|
|
16
17
|
import { registerTaskCommand } from "./commands/task.ts";
|
|
17
18
|
import { registerThreadCommand } from "./commands/thread.ts";
|
|
18
19
|
import { registerUpgradeCommand } from "./commands/upgrade.ts";
|
|
@@ -57,6 +58,7 @@ program
|
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
registerInitCommand(program);
|
|
61
|
+
registerStatusCommand(program);
|
|
60
62
|
registerWorkerCommand(program);
|
|
61
63
|
registerTaskCommand(program);
|
|
62
64
|
registerThreadCommand(program);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../config/loader.ts";
|
|
4
|
+
import {
|
|
5
|
+
collectStatus,
|
|
6
|
+
type ScheduleEntry,
|
|
7
|
+
type StatusReport,
|
|
8
|
+
type WorkerSummary,
|
|
9
|
+
} from "../status/collect.ts";
|
|
10
|
+
import type { TaskStatus } from "../tasks/schema.ts";
|
|
11
|
+
import type { WorkerStatus } from "../workers/store.ts";
|
|
12
|
+
|
|
13
|
+
function formatAge(fromIso: string | null, to = new Date()): string {
|
|
14
|
+
if (!fromIso) return "never";
|
|
15
|
+
const from = new Date(fromIso);
|
|
16
|
+
const secs = Math.max(0, Math.floor((to.getTime() - from.getTime()) / 1000));
|
|
17
|
+
if (secs < 60) return `${secs}s ago`;
|
|
18
|
+
const mins = Math.floor(secs / 60);
|
|
19
|
+
if (mins < 60) return `${mins}m ago`;
|
|
20
|
+
const hours = Math.floor(mins / 60);
|
|
21
|
+
if (hours < 24) return `${hours}h ago`;
|
|
22
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function padColored(colored: string, raw: string, width: number): string {
|
|
26
|
+
return colored + " ".repeat(Math.max(0, width - raw.length));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function workerStatusColor(status: WorkerStatus): string {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "running":
|
|
32
|
+
return ansis.green(status);
|
|
33
|
+
case "stopped":
|
|
34
|
+
return ansis.dim(status);
|
|
35
|
+
case "dead":
|
|
36
|
+
return ansis.red(status);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function taskStatusColor(status: TaskStatus, label: string): string {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case "pending":
|
|
43
|
+
return ansis.yellow(label);
|
|
44
|
+
case "in_progress":
|
|
45
|
+
return ansis.blue(label);
|
|
46
|
+
case "complete":
|
|
47
|
+
return ansis.green(label);
|
|
48
|
+
case "failed":
|
|
49
|
+
return ansis.red(label);
|
|
50
|
+
case "waiting":
|
|
51
|
+
return ansis.magenta(label);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function section(title: string): void {
|
|
56
|
+
console.log(`\n${ansis.bold.underline(title)}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderWorkers(w: WorkerSummary): void {
|
|
60
|
+
section("Workers");
|
|
61
|
+
const parts = [
|
|
62
|
+
`${ansis.green(`${w.running} running`)}`,
|
|
63
|
+
`${ansis.dim(`${w.stopped} stopped`)}`,
|
|
64
|
+
`${w.dead > 0 ? ansis.red(`${w.dead} dead`) : ansis.dim("0 dead")}`,
|
|
65
|
+
];
|
|
66
|
+
console.log(
|
|
67
|
+
` ${w.total} total — ${parts.join(", ")} latest heartbeat: ${formatAge(w.latest_heartbeat_at)}`,
|
|
68
|
+
);
|
|
69
|
+
if (w.list.length === 0) {
|
|
70
|
+
console.log(ansis.dim(" (none)"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const wk of w.list) {
|
|
74
|
+
const short = ansis.bold(wk.id.slice(0, 8));
|
|
75
|
+
const status = workerStatusColor(wk.status);
|
|
76
|
+
const task = wk.task_id ? ` task=${wk.task_id.slice(0, 8)}` : "";
|
|
77
|
+
console.log(
|
|
78
|
+
` ${short} ${status} mode=${wk.mode} pid=${wk.pid} heartbeat ${formatAge(wk.last_heartbeat_at)}${task}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderTasks(t: StatusReport["tasks"]): void {
|
|
84
|
+
section("Tasks");
|
|
85
|
+
const order: TaskStatus[] = [
|
|
86
|
+
"pending",
|
|
87
|
+
"in_progress",
|
|
88
|
+
"waiting",
|
|
89
|
+
"complete",
|
|
90
|
+
"failed",
|
|
91
|
+
];
|
|
92
|
+
const counts = order
|
|
93
|
+
.map((s) => taskStatusColor(s, `${t.by_status[s]} ${s}`))
|
|
94
|
+
.join(", ");
|
|
95
|
+
console.log(` ${t.total} total — ${counts}`);
|
|
96
|
+
|
|
97
|
+
if (t.claimed.length > 0) {
|
|
98
|
+
console.log(ansis.bold("\n Claimed:"));
|
|
99
|
+
for (const c of t.claimed) {
|
|
100
|
+
console.log(
|
|
101
|
+
` ${ansis.dim(c.id.slice(0, 8))} ${c.name} ${ansis.dim(`by ${c.claimed_by.slice(0, 8)} (${formatAge(c.claimed_at)})`)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dueLabel(entry: ScheduleEntry): string {
|
|
108
|
+
if (!entry.evaluation) return ansis.dim("—");
|
|
109
|
+
return entry.evaluation.is_due
|
|
110
|
+
? ansis.green.bold("due")
|
|
111
|
+
: ansis.dim("not due");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderSchedules(s: StatusReport["schedules"]): void {
|
|
115
|
+
section("Schedules");
|
|
116
|
+
console.log(
|
|
117
|
+
` ${s.total} total — ${ansis.green(`${s.enabled} enabled`)}, ${ansis.dim(`${s.disabled} disabled`)}`,
|
|
118
|
+
);
|
|
119
|
+
if (s.list.length === 0) {
|
|
120
|
+
console.log(ansis.dim(" (none)"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const e of s.list) {
|
|
124
|
+
const name = e.enabled ? e.name : ansis.dim(e.name);
|
|
125
|
+
const freq = ansis.cyan(e.frequency);
|
|
126
|
+
const last = ansis.dim(`last run ${formatAge(e.last_run_at)}`);
|
|
127
|
+
let line = ` ${name} (${freq}) ${last} ${dueLabel(e)}`;
|
|
128
|
+
if (e.evaluation?.is_due && e.evaluation.tasks_to_create > 0) {
|
|
129
|
+
line += ansis.dim(` → ${e.evaluation.tasks_to_create} task(s)`);
|
|
130
|
+
}
|
|
131
|
+
console.log(line);
|
|
132
|
+
if (e.evaluation?.is_due) {
|
|
133
|
+
console.log(ansis.dim(` ${e.evaluation.reasoning}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderQuarantined(report: StatusReport): void {
|
|
139
|
+
const items = [
|
|
140
|
+
...report.tasks.quarantined.map((q) => ({ kind: "task", ...q })),
|
|
141
|
+
...report.schedules.quarantined.map((q) => ({ kind: "schedule", ...q })),
|
|
142
|
+
];
|
|
143
|
+
if (items.length === 0) return;
|
|
144
|
+
section("Quarantined (invalid frontmatter)");
|
|
145
|
+
for (const q of items) {
|
|
146
|
+
console.log(
|
|
147
|
+
` ${ansis.red(q.kind)} ${ansis.dim(q.id)} — ${ansis.yellow(q.reason)}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderStore(store: StatusReport["store"]): void {
|
|
153
|
+
section("Store");
|
|
154
|
+
console.log(
|
|
155
|
+
` membot: ${ansis.cyan(store.membot_scope)} ${ansis.dim(store.membot_dir)}`,
|
|
156
|
+
);
|
|
157
|
+
console.log(
|
|
158
|
+
` mcpx: ${ansis.cyan(store.mcpx_scope)} ${ansis.dim(store.mcpx_dir)}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderText(report: StatusReport): void {
|
|
163
|
+
console.log(ansis.bold(`Project: ${report.project_dir}`));
|
|
164
|
+
renderWorkers(report.workers);
|
|
165
|
+
renderTasks(report.tasks);
|
|
166
|
+
renderSchedules(report.schedules);
|
|
167
|
+
renderQuarantined(report);
|
|
168
|
+
renderStore(report.store);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function registerStatusCommand(program: Command): void {
|
|
172
|
+
program
|
|
173
|
+
.command("status")
|
|
174
|
+
.description(
|
|
175
|
+
"One-command project dashboard: workers, tasks, schedules, quarantined files, and store info",
|
|
176
|
+
)
|
|
177
|
+
.option("--json", "output a serialized StatusReport as JSON", false)
|
|
178
|
+
.option(
|
|
179
|
+
"--no-evaluate",
|
|
180
|
+
"skip the LLM evaluation of enabled schedules (faster / offline)",
|
|
181
|
+
)
|
|
182
|
+
.action(async (opts: { json?: boolean; evaluate?: boolean }) => {
|
|
183
|
+
const dir = program.opts().dir;
|
|
184
|
+
const config = await loadConfig(dir);
|
|
185
|
+
const report = await collectStatus(dir, config, {
|
|
186
|
+
evaluateSchedules: opts.evaluate !== false,
|
|
187
|
+
});
|
|
188
|
+
if (opts.json) {
|
|
189
|
+
console.log(JSON.stringify(report, null, 2));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
renderText(report);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -339,7 +339,7 @@ function renderFallback(inv: RawInventory, now: Date): string {
|
|
|
339
339
|
schedule:
|
|
340
340
|
"create and list recurring schedules that automatically generate tasks",
|
|
341
341
|
membot:
|
|
342
|
-
"add, read, write, edit, move, copy, delete, and search content in the agent's knowledge store; track every version and refresh from URL sources",
|
|
342
|
+
"add, read, write, edit, move, copy, delete, and search content in the agent's knowledge store; track every version and refresh from URL sources; capture large tool outputs into the store and reduce/reshape stored JSON without loading it into context",
|
|
343
343
|
prompt:
|
|
344
344
|
"list, read, create, edit, and delete the project's prompt files (goals, beliefs, capabilities, plus any agent-authored ones)",
|
|
345
345
|
skill: "list, read, write, edit, delete, and search slash-command skills",
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { BotholomewConfig, Scope } from "../config/schemas.ts";
|
|
2
|
+
import { readWithMtime } from "../fs/atomic.ts";
|
|
3
|
+
import { resolveMcpxDir } from "../mcpx/client.ts";
|
|
4
|
+
import { resolveMembotDir } from "../mem/client.ts";
|
|
5
|
+
import type { Schedule } from "../schedules/schema.ts";
|
|
6
|
+
import {
|
|
7
|
+
listScheduleFiles,
|
|
8
|
+
parseScheduleFile,
|
|
9
|
+
scheduleFilePath,
|
|
10
|
+
} from "../schedules/store.ts";
|
|
11
|
+
import { TASK_STATUSES, type TaskStatus } from "../tasks/schema.ts";
|
|
12
|
+
import { listTaskFiles, parseTaskFile, taskFilePath } from "../tasks/store.ts";
|
|
13
|
+
import { evaluateSchedule } from "../worker/schedules.ts";
|
|
14
|
+
import { listWorkers, type Worker } from "../workers/store.ts";
|
|
15
|
+
|
|
16
|
+
export interface QuarantinedFile {
|
|
17
|
+
id: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorkerSummary {
|
|
22
|
+
total: number;
|
|
23
|
+
running: number;
|
|
24
|
+
stopped: number;
|
|
25
|
+
dead: number;
|
|
26
|
+
/** Most recent heartbeat across all workers, or null if none. */
|
|
27
|
+
latest_heartbeat_at: string | null;
|
|
28
|
+
list: Array<
|
|
29
|
+
Pick<
|
|
30
|
+
Worker,
|
|
31
|
+
"id" | "status" | "mode" | "pid" | "last_heartbeat_at" | "task_id"
|
|
32
|
+
>
|
|
33
|
+
>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TaskSummary {
|
|
37
|
+
/** Count of valid (parseable) tasks. */
|
|
38
|
+
total: number;
|
|
39
|
+
by_status: Record<TaskStatus, number>;
|
|
40
|
+
claimed: Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
claimed_by: string;
|
|
44
|
+
claimed_at: string | null;
|
|
45
|
+
}>;
|
|
46
|
+
quarantined: QuarantinedFile[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ScheduleEntry {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
frequency: string;
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
last_run_at: string | null;
|
|
55
|
+
/** Present only when schedule evaluation ran (enabled schedules, --evaluate). */
|
|
56
|
+
evaluation?: {
|
|
57
|
+
is_due: boolean;
|
|
58
|
+
reasoning: string;
|
|
59
|
+
tasks_to_create: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ScheduleSummary {
|
|
64
|
+
total: number;
|
|
65
|
+
enabled: number;
|
|
66
|
+
disabled: number;
|
|
67
|
+
list: ScheduleEntry[];
|
|
68
|
+
quarantined: QuarantinedFile[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StoreSummary {
|
|
72
|
+
membot_scope: Scope;
|
|
73
|
+
membot_dir: string;
|
|
74
|
+
mcpx_scope: Scope;
|
|
75
|
+
mcpx_dir: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface StatusReport {
|
|
79
|
+
project_dir: string;
|
|
80
|
+
workers: WorkerSummary;
|
|
81
|
+
tasks: TaskSummary;
|
|
82
|
+
schedules: ScheduleSummary;
|
|
83
|
+
store: StoreSummary;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CollectStatusOptions {
|
|
87
|
+
/** Run the LLM schedule evaluator for each enabled schedule. */
|
|
88
|
+
evaluateSchedules: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function collectWorkers(projectDir: string): Promise<WorkerSummary> {
|
|
92
|
+
const workers = await listWorkers(projectDir);
|
|
93
|
+
let running = 0;
|
|
94
|
+
let stopped = 0;
|
|
95
|
+
let dead = 0;
|
|
96
|
+
let latest: string | null = null;
|
|
97
|
+
for (const w of workers) {
|
|
98
|
+
if (w.status === "running") running++;
|
|
99
|
+
else if (w.status === "stopped") stopped++;
|
|
100
|
+
else dead++;
|
|
101
|
+
if (!latest || w.last_heartbeat_at > latest) latest = w.last_heartbeat_at;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
total: workers.length,
|
|
105
|
+
running,
|
|
106
|
+
stopped,
|
|
107
|
+
dead,
|
|
108
|
+
latest_heartbeat_at: latest,
|
|
109
|
+
list: workers.map((w) => ({
|
|
110
|
+
id: w.id,
|
|
111
|
+
status: w.status,
|
|
112
|
+
mode: w.mode,
|
|
113
|
+
pid: w.pid,
|
|
114
|
+
last_heartbeat_at: w.last_heartbeat_at,
|
|
115
|
+
task_id: w.task_id,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Single-pass task scan. Parses each `tasks/<id>.md` directly via
|
|
122
|
+
* `parseTaskFile` (rather than `getTask`/`listTasks`) so malformed files land
|
|
123
|
+
* in `quarantined` without spraying `logger.warn` over the dashboard output.
|
|
124
|
+
*/
|
|
125
|
+
async function collectTasks(projectDir: string): Promise<TaskSummary> {
|
|
126
|
+
const by_status = Object.fromEntries(
|
|
127
|
+
TASK_STATUSES.map((s) => [s, 0]),
|
|
128
|
+
) as Record<TaskStatus, number>;
|
|
129
|
+
const claimed: TaskSummary["claimed"] = [];
|
|
130
|
+
const quarantined: QuarantinedFile[] = [];
|
|
131
|
+
let total = 0;
|
|
132
|
+
|
|
133
|
+
for (const id of await listTaskFiles(projectDir)) {
|
|
134
|
+
const file = await readWithMtime(taskFilePath(projectDir, id));
|
|
135
|
+
if (!file) continue;
|
|
136
|
+
const parsed = parseTaskFile(file.content, file.mtimeMs);
|
|
137
|
+
if (!parsed.ok) {
|
|
138
|
+
quarantined.push({ id, reason: parsed.reason });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const t = parsed.task;
|
|
142
|
+
total++;
|
|
143
|
+
by_status[t.status]++;
|
|
144
|
+
if (t.status === "in_progress" && t.claimed_by) {
|
|
145
|
+
claimed.push({
|
|
146
|
+
id: t.id,
|
|
147
|
+
name: t.name,
|
|
148
|
+
claimed_by: t.claimed_by,
|
|
149
|
+
claimed_at: t.claimed_at,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { total, by_status, claimed, quarantined };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function collectSchedules(
|
|
158
|
+
projectDir: string,
|
|
159
|
+
config: BotholomewConfig,
|
|
160
|
+
evaluateSchedules: boolean,
|
|
161
|
+
): Promise<ScheduleSummary> {
|
|
162
|
+
const quarantined: QuarantinedFile[] = [];
|
|
163
|
+
// Keep each entry paired with its full parsed Schedule so the evaluator
|
|
164
|
+
// receives the real description, last_run_at, etc.
|
|
165
|
+
const parsed: Array<{ entry: ScheduleEntry; schedule: Schedule }> = [];
|
|
166
|
+
let enabled = 0;
|
|
167
|
+
let disabled = 0;
|
|
168
|
+
|
|
169
|
+
for (const id of await listScheduleFiles(projectDir)) {
|
|
170
|
+
const file = await readWithMtime(scheduleFilePath(projectDir, id));
|
|
171
|
+
if (!file) continue;
|
|
172
|
+
const result = parseScheduleFile(file.content, file.mtimeMs);
|
|
173
|
+
if (!result.ok) {
|
|
174
|
+
quarantined.push({ id, reason: result.reason });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const s = result.schedule;
|
|
178
|
+
if (s.enabled) enabled++;
|
|
179
|
+
else disabled++;
|
|
180
|
+
parsed.push({
|
|
181
|
+
entry: {
|
|
182
|
+
id: s.id,
|
|
183
|
+
name: s.name,
|
|
184
|
+
frequency: s.frequency,
|
|
185
|
+
enabled: s.enabled,
|
|
186
|
+
last_run_at: s.last_run_at,
|
|
187
|
+
},
|
|
188
|
+
schedule: s,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Evaluate enabled schedules in parallel. evaluateSchedule has no disk
|
|
193
|
+
// side-effects and already degrades gracefully on LLM error.
|
|
194
|
+
if (evaluateSchedules) {
|
|
195
|
+
await Promise.all(
|
|
196
|
+
parsed
|
|
197
|
+
.filter((p) => p.schedule.enabled)
|
|
198
|
+
.map(async ({ entry, schedule }) => {
|
|
199
|
+
const evaluation = await evaluateSchedule(config, schedule);
|
|
200
|
+
entry.evaluation = {
|
|
201
|
+
is_due: evaluation.isDue,
|
|
202
|
+
reasoning: evaluation.reasoning,
|
|
203
|
+
tasks_to_create: evaluation.tasksToCreate.length,
|
|
204
|
+
};
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const list = parsed.map((p) => p.entry);
|
|
210
|
+
return { total: list.length, enabled, disabled, list, quarantined };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Gather a read-only snapshot of project state for `botholomew status`. Pure
|
|
215
|
+
* data-gathering: no disk mutation (schedule evaluation is read-only). The
|
|
216
|
+
* returned object is fully serializable for `--json`.
|
|
217
|
+
*/
|
|
218
|
+
export async function collectStatus(
|
|
219
|
+
projectDir: string,
|
|
220
|
+
config: BotholomewConfig,
|
|
221
|
+
opts: CollectStatusOptions,
|
|
222
|
+
): Promise<StatusReport> {
|
|
223
|
+
const [workers, tasks, schedules] = await Promise.all([
|
|
224
|
+
collectWorkers(projectDir),
|
|
225
|
+
collectTasks(projectDir),
|
|
226
|
+
collectSchedules(projectDir, config, opts.evaluateSchedules),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
project_dir: projectDir,
|
|
231
|
+
workers,
|
|
232
|
+
tasks,
|
|
233
|
+
schedules,
|
|
234
|
+
store: {
|
|
235
|
+
membot_scope: config.membot_scope,
|
|
236
|
+
membot_dir: resolveMembotDir(projectDir, config),
|
|
237
|
+
mcpx_scope: config.mcpx_scope,
|
|
238
|
+
mcpx_dir: resolveMcpxDir(projectDir, config),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/worker/llm.ts
CHANGED
|
@@ -114,6 +114,7 @@ export async function runAgentLoop(input: {
|
|
|
114
114
|
const maxInputTokens = await getMaxInputTokens(config.llm);
|
|
115
115
|
|
|
116
116
|
const maxTurns = config.max_turns;
|
|
117
|
+
let nudgeCount = 0;
|
|
117
118
|
for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
|
|
118
119
|
const startTime = Date.now();
|
|
119
120
|
fitToContextWindow(messages, systemPrompt, maxInputTokens);
|
|
@@ -188,9 +189,29 @@ export async function runAgentLoop(input: {
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
if (collectedToolCalls.length === 0) {
|
|
192
|
+
// An implicit tick-end (the model stopped emitting tool calls) is
|
|
193
|
+
// ambiguous evidence — it usually means the agent hit a dead end,
|
|
194
|
+
// exhausted its output budget mid-thought, or forgot to declare a
|
|
195
|
+
// terminal status. Don't treat it as success. Nudge once to give the
|
|
196
|
+
// agent a chance to recover (e.g. emit the final tool call it was about
|
|
197
|
+
// to make, or fail_task on a capability gap); fail if it still doesn't.
|
|
198
|
+
if (nudgeCount === 0) {
|
|
199
|
+
nudgeCount++;
|
|
200
|
+
const nudge =
|
|
201
|
+
"You ended your turn without calling a terminal status tool. Every tick must end with exactly one of: complete_task (only if the required deliverable truly exists — verify it), fail_task (if you are blocked or a required tool/capability is unavailable — state the gap), or wait_task (if you must wait on something external). Call the appropriate one now.";
|
|
202
|
+
messages.push({ role: "user", content: nudge });
|
|
203
|
+
await logInteraction(projectDir, threadId, {
|
|
204
|
+
role: "system",
|
|
205
|
+
kind: "status_change",
|
|
206
|
+
content:
|
|
207
|
+
"Agent ended its turn without a terminal status tool; nudging to call complete_task/fail_task/wait_task.",
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
191
211
|
return {
|
|
192
|
-
status: "
|
|
193
|
-
reason:
|
|
212
|
+
status: "failed",
|
|
213
|
+
reason:
|
|
214
|
+
"Agent ended its tick without calling a terminal status tool (complete_task/fail_task/wait_task)",
|
|
194
215
|
};
|
|
195
216
|
}
|
|
196
217
|
|
package/src/worker/prompt.ts
CHANGED
|
@@ -18,6 +18,20 @@ export const MEMBOT_PROMPT_SECTION = `## Knowledge store (membot)
|
|
|
18
18
|
${MEMBOT_INSTRUCTIONS}
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Teaches the `membot_pipe` → `membot_query` pattern for large JSON. Shared
|
|
23
|
+
* verbatim by the worker and chat prompts (chat imports it). These are core
|
|
24
|
+
* knowledge-store tools — not MCP-only — so the section renders unconditionally;
|
|
25
|
+
* without it the model only learns about `membot_query` from its tool schema,
|
|
26
|
+
* exactly when it's most context-pressured (holding a big blob).
|
|
27
|
+
*/
|
|
28
|
+
export const LARGE_JSON_SECTION = `## Large JSON results
|
|
29
|
+
When a tool would return a large JSON payload (mcp_exec dumps, search results, web fetches) that you don't need to read verbatim, don't pull it into context:
|
|
30
|
+
1. \`membot_pipe\` the call into a \`logical_path\` — the bytes land in the store, you get back only an ack.
|
|
31
|
+
2. \`membot_query\` that \`logical_path\` with a JSONata expression to filter / pluck / group / dedup / sort / aggregate down to the small slice you actually need (pass \`expression="?"\` for the syntax reference). Chain with \`output_logical_path\` to refine in steps.
|
|
32
|
+
This keeps big blobs out of the conversation. Reach for it whenever you expect a result bigger than what you need.
|
|
33
|
+
`;
|
|
34
|
+
|
|
21
35
|
export const STYLE_RULES = `## Style
|
|
22
36
|
- Open with the result, action, or next step. Skip preambles like "Great question", "You're absolutely right", "Let me…", "I'll go ahead and…".
|
|
23
37
|
- Don't flatter the user or their ideas. If a request is wrong, ambiguous, or risky, say so plainly with the reason.
|
|
@@ -132,10 +146,13 @@ export async function buildSystemPrompt(
|
|
|
132
146
|
prompt += `## Instructions
|
|
133
147
|
You are Botholomew, a wise-owl worker that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.
|
|
134
148
|
|
|
149
|
+
Always end your tick by calling exactly one terminal status tool — never just stop. Call complete_task ONLY if the required deliverable actually exists (verify it). If you are blocked or a required tool/capability is unavailable (e.g. no way to produce the requested output), call fail_task and state the gap — do not pretend success. If you must wait on something external, call wait_task.
|
|
150
|
+
|
|
135
151
|
When calling complete_task, write a summary that captures your key findings, decisions, and outputs. This summary becomes the task's output and is provided to any downstream tasks that depend on this one. Include specific results (data, names, paths, conclusions) rather than vague descriptions of what you did — downstream tasks will rely on this information to do their work.
|
|
136
152
|
`;
|
|
137
153
|
|
|
138
154
|
prompt += `\n${MEMBOT_PROMPT_SECTION}`;
|
|
155
|
+
prompt += `\n${LARGE_JSON_SECTION}`;
|
|
139
156
|
|
|
140
157
|
if (options?.hasMcpTools) {
|
|
141
158
|
prompt += `
|