talon-agent 3.16.0 → 3.17.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/package.json +1 -1
- package/src/app.ts +2 -0
- package/src/backend/codex/factory.ts +9 -0
- package/src/backend/codex/plan-usage.ts +160 -0
- package/src/bootstrap.ts +14 -0
- package/src/cli/doctor.ts +11 -1
- package/src/core/background/plan-alerts.ts +115 -0
- package/src/core/doctor.ts +71 -8
- package/src/frontend/discord/callbacks/components.ts +16 -0
- package/src/frontend/discord/commands/admin.ts +188 -14
- package/src/frontend/discord/commands/definitions.ts +37 -2
- package/src/frontend/discord/commands/info.ts +38 -1
- package/src/frontend/discord/commands/router.ts +21 -1
- package/src/frontend/discord/helpers.ts +148 -1
- package/src/frontend/discord/index.ts +10 -1
- package/src/frontend/discord/middleware.ts +24 -0
- package/src/frontend/shared/plan-usage-report.ts +72 -0
- package/src/frontend/telegram/commands/admin.ts +10 -0
- package/src/frontend/telegram/commands/definitions.ts +1 -0
- package/src/frontend/telegram/helpers/diagnostics.ts +38 -4
- package/src/util/config.ts +10 -0
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
|
|
|
11
11
|
import { flushDatabase } from "./storage/db.js";
|
|
12
12
|
import { getActiveCount } from "./core/engine/dispatcher.js";
|
|
13
13
|
import { startPulseTimer, stopPulseTimer } from "./core/background/pulse.js";
|
|
14
|
+
import { stopPlanAlerts } from "./core/background/plan-alerts.js";
|
|
14
15
|
import {
|
|
15
16
|
startHeartbeatTimer,
|
|
16
17
|
stopHeartbeatTimer,
|
|
@@ -179,6 +180,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|
|
179
180
|
await awaitHeartbeat();
|
|
180
181
|
});
|
|
181
182
|
await shutdownStep("cron timer", stopCronTimer);
|
|
183
|
+
await shutdownStep("plan alerts", stopPlanAlerts);
|
|
182
184
|
await shutdownStep("trigger prune timer", () => {
|
|
183
185
|
if (triggerPruneTimer) clearInterval(triggerPruneTimer);
|
|
184
186
|
triggerPruneTimer = null;
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
type ChatBackend,
|
|
17
17
|
type BackgroundRunner,
|
|
18
18
|
type ModelCatalog,
|
|
19
|
+
type UsageTelemetry,
|
|
19
20
|
} from "../../core/agent-runtime/capabilities.js";
|
|
21
|
+
import { getPlanUsage as getCodexPlanUsage } from "./plan-usage.js";
|
|
20
22
|
|
|
21
23
|
import { initCodexAgent, getCodexAuthInfo } from "./init.js";
|
|
22
24
|
import { handleMessage as codexHandleMessage } from "./handler/index.js";
|
|
@@ -77,6 +79,12 @@ const codexFactory: BackendFactory = {
|
|
|
77
79
|
// the per-chat thread lifecycle directly via `setSessionId` on
|
|
78
80
|
// `storage/sessions.ts`. No `sessions` slot needed; `/reset`
|
|
79
81
|
// clears the stored thread id through the storage path.
|
|
82
|
+
// No per-session snapshot to offer, but a ChatGPT-plan install can
|
|
83
|
+
// report its rate-limit windows.
|
|
84
|
+
const usage: UsageTelemetry = {
|
|
85
|
+
getPlanUsage: () => getCodexPlanUsage(),
|
|
86
|
+
};
|
|
87
|
+
|
|
80
88
|
const backend = composeBackend({
|
|
81
89
|
id: "codex",
|
|
82
90
|
label: "Codex",
|
|
@@ -84,6 +92,7 @@ const codexFactory: BackendFactory = {
|
|
|
84
92
|
chat,
|
|
85
93
|
background,
|
|
86
94
|
models,
|
|
95
|
+
usage,
|
|
87
96
|
});
|
|
88
97
|
|
|
89
98
|
return {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT subscription rate-limit windows for the Codex backend.
|
|
3
|
+
*
|
|
4
|
+
* The Codex CLI reads these from an endpoint on the ChatGPT backend and
|
|
5
|
+
* caches the result in its session transcripts; `/status` renders that cache
|
|
6
|
+
* rather than re-fetching. Talon queries the endpoint directly so `/usage`
|
|
7
|
+
* reports the current state instead of whatever the last turn happened to
|
|
8
|
+
* see, using the OAuth token `codex login` already stored.
|
|
9
|
+
*
|
|
10
|
+
* Degrades to `undefined` for API-key installs (no plan to report), a
|
|
11
|
+
* missing or expired token, or any transport failure.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { logWarn } from "../../util/log.js";
|
|
18
|
+
import type {
|
|
19
|
+
PlanUsage,
|
|
20
|
+
PlanWindow,
|
|
21
|
+
} from "../../core/agent-runtime/capabilities.js";
|
|
22
|
+
|
|
23
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
25
|
+
const CACHE_TTL_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
let cache: { value: PlanUsage; fetchedAt: number } | undefined;
|
|
28
|
+
let inFlight: Promise<PlanUsage | undefined> | undefined;
|
|
29
|
+
|
|
30
|
+
function authPath(): string {
|
|
31
|
+
const home = process.env.CODEX_HOME?.trim();
|
|
32
|
+
return home && home.length > 0
|
|
33
|
+
? join(home, "auth.json")
|
|
34
|
+
: join(homedir(), ".codex", "auth.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CodexAuth {
|
|
38
|
+
accessToken: string;
|
|
39
|
+
accountId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readAuth(): Promise<CodexAuth | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(authPath(), "utf8")) as {
|
|
45
|
+
auth_mode?: string;
|
|
46
|
+
tokens?: { access_token?: string; account_id?: string };
|
|
47
|
+
};
|
|
48
|
+
const token = parsed.tokens?.access_token;
|
|
49
|
+
if (!token) return undefined;
|
|
50
|
+
return {
|
|
51
|
+
accessToken: token,
|
|
52
|
+
...(parsed.tokens?.account_id
|
|
53
|
+
? { accountId: parsed.tokens.account_id }
|
|
54
|
+
: {}),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface RawWindow {
|
|
62
|
+
used_percent?: number;
|
|
63
|
+
limit_window_seconds?: number;
|
|
64
|
+
reset_at?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Window label from its length. The plan exposes a weekly window and,
|
|
69
|
+
* historically, a shorter one; naming them by duration keeps the label
|
|
70
|
+
* right whichever windows the account actually has.
|
|
71
|
+
*/
|
|
72
|
+
function windowLabel(seconds: number | undefined): string {
|
|
73
|
+
if (!seconds || !Number.isFinite(seconds)) return "limit";
|
|
74
|
+
const hours = Math.round(seconds / 3600);
|
|
75
|
+
if (hours % 24 === 0 && hours >= 24) return `${hours / 24}d`;
|
|
76
|
+
return `${hours}h`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toWindow(raw: RawWindow | null | undefined): PlanWindow | undefined {
|
|
80
|
+
if (!raw || typeof raw.used_percent !== "number") return undefined;
|
|
81
|
+
return {
|
|
82
|
+
label: windowLabel(raw.limit_window_seconds),
|
|
83
|
+
percent: Math.max(0, Math.min(100, Math.round(raw.used_percent))),
|
|
84
|
+
// `reset_at` is unix seconds; the shared shape speaks ISO.
|
|
85
|
+
...(typeof raw.reset_at === "number" && raw.reset_at > 0
|
|
86
|
+
? { resetsAt: new Date(raw.reset_at * 1000).toISOString() }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseCodexUsage(body: unknown): PlanUsage | undefined {
|
|
92
|
+
const data = body as {
|
|
93
|
+
plan_type?: string;
|
|
94
|
+
rate_limit?: {
|
|
95
|
+
primary_window?: RawWindow | null;
|
|
96
|
+
secondary_window?: RawWindow | null;
|
|
97
|
+
};
|
|
98
|
+
} | null;
|
|
99
|
+
const limit = data?.rate_limit;
|
|
100
|
+
if (!limit) return undefined;
|
|
101
|
+
|
|
102
|
+
const windows = [
|
|
103
|
+
toWindow(limit.primary_window),
|
|
104
|
+
toWindow(limit.secondary_window),
|
|
105
|
+
].filter((w): w is PlanWindow => Boolean(w));
|
|
106
|
+
if (windows.length === 0) return undefined;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
...(data?.plan_type ? { plan: data.plan_type } : {}),
|
|
110
|
+
windows,
|
|
111
|
+
fetchedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function load(): Promise<PlanUsage | undefined> {
|
|
116
|
+
const auth = await readAuth();
|
|
117
|
+
if (!auth) return undefined;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(USAGE_ENDPOINT, {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
123
|
+
...(auth.accountId ? { "chatgpt-account-id": auth.accountId } : {}),
|
|
124
|
+
Accept: "application/json",
|
|
125
|
+
},
|
|
126
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
logWarn("agent", `codex usage: endpoint returned ${res.status}`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return parseCodexUsage(await res.json());
|
|
133
|
+
} catch (err) {
|
|
134
|
+
logWarn(
|
|
135
|
+
"agent",
|
|
136
|
+
`codex usage: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Plan windows for `/usage`, cached for a minute. A failed refresh keeps
|
|
144
|
+
* serving the last known values — `fetchedAt` lets the caller age them.
|
|
145
|
+
*/
|
|
146
|
+
export async function getPlanUsage(): Promise<PlanUsage | undefined> {
|
|
147
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.value;
|
|
148
|
+
|
|
149
|
+
inFlight ??= load().finally(() => {
|
|
150
|
+
inFlight = undefined;
|
|
151
|
+
});
|
|
152
|
+
const loaded = await inFlight;
|
|
153
|
+
if (loaded) cache = { value: loaded, fetchedAt: loaded.fetchedAt };
|
|
154
|
+
return loaded ?? cache?.value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resetCodexPlanUsageForTest(): void {
|
|
158
|
+
cache = undefined;
|
|
159
|
+
inFlight = undefined;
|
|
160
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { bus } from "./core/bus/index.js";
|
|
|
26
26
|
import { appendToJournal } from "./storage/journal.js";
|
|
27
27
|
import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
|
|
28
28
|
import { initCron } from "./core/background/cron.js";
|
|
29
|
+
import { initPlanAlerts } from "./core/background/plan-alerts.js";
|
|
29
30
|
import {
|
|
30
31
|
initTriggers,
|
|
31
32
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
@@ -420,6 +421,19 @@ export async function initBackendAndDispatcher(
|
|
|
420
421
|
log("triggers", `resumeAfterRestart failed: ${err}`),
|
|
421
422
|
);
|
|
422
423
|
|
|
424
|
+
initPlanAlerts({
|
|
425
|
+
sendMessage: async (chatId: number, text: string, stringId?: string) =>
|
|
426
|
+
resolveFrontendByNumericId(chatId, stringId, frontends).sendMessage(
|
|
427
|
+
chatId,
|
|
428
|
+
text,
|
|
429
|
+
),
|
|
430
|
+
enabled: config.planAlerts,
|
|
431
|
+
threshold: config.planAlertThreshold,
|
|
432
|
+
chatId:
|
|
433
|
+
config.planAlertChatId ??
|
|
434
|
+
(config.adminUserId ? String(config.adminUserId) : undefined),
|
|
435
|
+
});
|
|
436
|
+
|
|
423
437
|
// Soul — initialize the identity kernel singleton from config so the prompt
|
|
424
438
|
// injection / dream hooks see the right enabled state. Off by default; a
|
|
425
439
|
// failure here must never block startup.
|
package/src/cli/doctor.ts
CHANGED
|
@@ -25,9 +25,19 @@ export async function runDoctor(): Promise<void> {
|
|
|
25
25
|
config: hasConfigFile ? loadConfig() : undefined,
|
|
26
26
|
hasConfigFile,
|
|
27
27
|
});
|
|
28
|
-
|
|
28
|
+
const print = (check: (typeof report.checks)[number]): void => {
|
|
29
29
|
const detail = check.detail ? ` ${pc.dim(`(${check.detail})`)}` : "";
|
|
30
30
|
console.log(` ${DOCTOR_ICONS[check.status]} ${check.label}${detail}`);
|
|
31
|
+
};
|
|
32
|
+
for (const check of report.checks.filter((c) => !c.inactive)) print(check);
|
|
33
|
+
|
|
34
|
+
// Configured-but-idle backends describe what a switch would run into,
|
|
35
|
+
// not the state of the running deployment.
|
|
36
|
+
const idle = report.checks.filter((c) => c.inactive);
|
|
37
|
+
if (idle.length > 0) {
|
|
38
|
+
console.log(`\n ${pc.bold("Other backends")}\n`);
|
|
39
|
+
for (const check of idle) print(check);
|
|
40
|
+
console.log();
|
|
31
41
|
}
|
|
32
42
|
// Native plane, one line per embedded module with provenance.
|
|
33
43
|
for (const mod of report.native) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan rate-limit warnings.
|
|
3
|
+
*
|
|
4
|
+
* Off unless `planAlerts` is enabled. Polls the subscription's windows on a
|
|
5
|
+
* timer and messages the admin chat the first time one crosses the
|
|
6
|
+
* threshold, so a long background run doesn't walk into the ceiling
|
|
7
|
+
* unannounced.
|
|
8
|
+
*
|
|
9
|
+
* One message per window per reset cycle: the reset timestamp identifies the
|
|
10
|
+
* cycle, and a window that drops back under the threshold re-arms (which is
|
|
11
|
+
* also what covers windows the plan reports no reset for).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getPooledBackend } from "../engine/backend-controller/index.js";
|
|
15
|
+
import { log, logWarn } from "../../util/log.js";
|
|
16
|
+
import { formatSmartTimestamp } from "../../util/time.js";
|
|
17
|
+
|
|
18
|
+
const CHECK_INTERVAL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
export interface PlanAlertDeps {
|
|
21
|
+
sendMessage: (
|
|
22
|
+
chatId: number,
|
|
23
|
+
text: string,
|
|
24
|
+
stringId?: string,
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
threshold: number;
|
|
28
|
+
/** Chat the warnings go to. Callers resolve the admin default. */
|
|
29
|
+
chatId: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let deps: PlanAlertDeps | undefined;
|
|
33
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
/** Window label → the reset cycle it was last warned about. */
|
|
36
|
+
const warned = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
export function initPlanAlerts(d: PlanAlertDeps): void {
|
|
39
|
+
deps = d;
|
|
40
|
+
stopPlanAlerts();
|
|
41
|
+
warned.clear();
|
|
42
|
+
if (!d.enabled) return;
|
|
43
|
+
if (!d.chatId) {
|
|
44
|
+
logWarn(
|
|
45
|
+
"bot",
|
|
46
|
+
"planAlerts is on but no chat to warn — set planAlertChatId or adminUserId",
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
timer = setInterval(() => {
|
|
51
|
+
void checkPlanAlerts();
|
|
52
|
+
}, CHECK_INTERVAL_MS);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
log("bot", `Plan alerts: on (threshold ${d.threshold}%)`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stopPlanAlerts(): void {
|
|
58
|
+
if (timer) {
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
timer = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function warningText(
|
|
65
|
+
label: string,
|
|
66
|
+
percent: number,
|
|
67
|
+
resetsAt: string | undefined,
|
|
68
|
+
): string {
|
|
69
|
+
const ts = resetsAt ? Date.parse(resetsAt) : NaN;
|
|
70
|
+
const reset = Number.isFinite(ts)
|
|
71
|
+
? `, resets ${formatSmartTimestamp(Math.round(ts / 60_000) * 60_000)}`
|
|
72
|
+
: "";
|
|
73
|
+
return `⚠️ Plan limit — ${label} at ${percent}% used${reset}.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One pass. Exported so the timer isn't the only way to drive it. */
|
|
77
|
+
export async function checkPlanAlerts(): Promise<void> {
|
|
78
|
+
const d = deps;
|
|
79
|
+
if (!d?.enabled || !d.chatId) return;
|
|
80
|
+
|
|
81
|
+
const usage = await getPooledBackend("claude")
|
|
82
|
+
?.usage?.getPlanUsage?.()
|
|
83
|
+
.catch(() => undefined);
|
|
84
|
+
if (!usage) return;
|
|
85
|
+
|
|
86
|
+
for (const window of usage.windows) {
|
|
87
|
+
// An unwarned window that is back under the threshold re-arms.
|
|
88
|
+
if (window.percent < d.threshold) {
|
|
89
|
+
warned.delete(window.label);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const cycle = window.resetsAt ?? "";
|
|
93
|
+
if (warned.get(window.label) === cycle) continue;
|
|
94
|
+
warned.set(window.label, cycle);
|
|
95
|
+
|
|
96
|
+
const text = warningText(window.label, window.percent, window.resetsAt);
|
|
97
|
+
try {
|
|
98
|
+
await d.sendMessage(Number(d.chatId), text, d.chatId);
|
|
99
|
+
log("bot", `Plan alert sent: ${window.label} at ${window.percent}%`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Keep it marked as warned — a frontend that can't deliver now won't
|
|
102
|
+
// deliver on the next tick either, and retrying would spam on recovery.
|
|
103
|
+
logWarn(
|
|
104
|
+
"bot",
|
|
105
|
+
`Plan alert delivery failed: ${err instanceof Error ? err.message : err}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resetPlanAlertsForTest(): void {
|
|
112
|
+
stopPlanAlerts();
|
|
113
|
+
deps = undefined;
|
|
114
|
+
warned.clear();
|
|
115
|
+
}
|
package/src/core/doctor.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface DoctorCheck {
|
|
|
29
29
|
* starts but a backend won't work.
|
|
30
30
|
*/
|
|
31
31
|
issue?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A backend that is configured but not serving chats. Renderers group
|
|
34
|
+
* these away from the environment so a handful of idle providers can't
|
|
35
|
+
* bury the checks that describe the running deployment.
|
|
36
|
+
*/
|
|
37
|
+
inactive?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** One embedded native module: provenance plus a live self-test result. */
|
|
@@ -59,6 +65,8 @@ export interface DoctorReport {
|
|
|
59
65
|
export interface DoctorConfigSlice {
|
|
60
66
|
frontend: string | string[];
|
|
61
67
|
backend?: string;
|
|
68
|
+
/** Backends config exposes. Empty / absent means every registered one. */
|
|
69
|
+
enabledBackends?: string[];
|
|
62
70
|
model?: string;
|
|
63
71
|
heartbeatModel?: string;
|
|
64
72
|
heartbeatBackend?: string;
|
|
@@ -284,12 +292,55 @@ async function checkClaudeConfiguredModels(
|
|
|
284
292
|
return checks;
|
|
285
293
|
}
|
|
286
294
|
|
|
287
|
-
/**
|
|
295
|
+
/** Every backend id doctor knows how to inspect. */
|
|
296
|
+
const KNOWN_BACKENDS = [
|
|
297
|
+
"claude",
|
|
298
|
+
"codex",
|
|
299
|
+
"kilo",
|
|
300
|
+
"opencode",
|
|
301
|
+
"openai-agents",
|
|
302
|
+
] as const;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Binary / auth checks across every backend the config exposes.
|
|
306
|
+
*
|
|
307
|
+
* Only the active one counts toward the issue total; the rest are reported
|
|
308
|
+
* so a switch doesn't have to be the thing that discovers a backend can't
|
|
309
|
+
* run. That distinction matters now that a chat can be rebound at runtime —
|
|
310
|
+
* a report of all-green while two backends are one click from failing is
|
|
311
|
+
* worse than no report.
|
|
312
|
+
*/
|
|
288
313
|
async function checkBackend(
|
|
289
314
|
config: DoctorConfigSlice | undefined,
|
|
315
|
+
): Promise<DoctorCheck[]> {
|
|
316
|
+
const active = config?.backend ?? "claude";
|
|
317
|
+
const exposed = config?.enabledBackends?.length
|
|
318
|
+
? config.enabledBackends
|
|
319
|
+
: [...KNOWN_BACKENDS];
|
|
320
|
+
|
|
321
|
+
const checks = await checkOneBackend(active, config, true);
|
|
322
|
+
for (const id of exposed) {
|
|
323
|
+
if (id === active || !KNOWN_BACKENDS.includes(id as never)) continue;
|
|
324
|
+
// An idle backend's missing binary is a heads-up, not a fault of this
|
|
325
|
+
// deployment: downgrade it and keep it out of the issue count.
|
|
326
|
+
for (const check of await checkOneBackend(id, config, false)) {
|
|
327
|
+
checks.push({
|
|
328
|
+
...check,
|
|
329
|
+
status: check.status === "fail" ? "warn" : check.status,
|
|
330
|
+
issue: false,
|
|
331
|
+
inactive: true,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return checks;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function checkOneBackend(
|
|
339
|
+
backend: string,
|
|
340
|
+
config: DoctorConfigSlice | undefined,
|
|
341
|
+
isActive: boolean,
|
|
290
342
|
): Promise<DoctorCheck[]> {
|
|
291
343
|
const checks: DoctorCheck[] = [];
|
|
292
|
-
const backend = config?.backend ?? "claude";
|
|
293
344
|
|
|
294
345
|
if (backend === "claude") {
|
|
295
346
|
if (config?.claudeBinary) {
|
|
@@ -313,7 +364,9 @@ async function checkBackend(
|
|
|
313
364
|
: { label: "Claude Code not found", status: "fail" },
|
|
314
365
|
);
|
|
315
366
|
}
|
|
316
|
-
|
|
367
|
+
// Model resolution spawns a probe — worth it for the backend actually
|
|
368
|
+
// serving chats, wasteful for one nobody is using.
|
|
369
|
+
if (isActive) checks.push(...(await checkClaudeConfiguredModels(config)));
|
|
317
370
|
} else if (backend === "codex") {
|
|
318
371
|
if (!binaryOnPath("codex")) {
|
|
319
372
|
checks.push({
|
|
@@ -349,11 +402,21 @@ async function checkBackend(
|
|
|
349
402
|
});
|
|
350
403
|
}
|
|
351
404
|
} else if (backend === "kilo" || backend === "opencode") {
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
405
|
+
// The SDK ships as an npm dep but only talks to a server it spawns from
|
|
406
|
+
// the CLI of the same name (`cross-spawn` → PATH lookup). A present
|
|
407
|
+
// package with an absent binary fails at the first turn with a bare
|
|
408
|
+
// ENOENT, so check what actually gets executed.
|
|
409
|
+
const label = backend === "kilo" ? "Kilo" : "OpenCode";
|
|
410
|
+
checks.push(
|
|
411
|
+
binaryOnPath(backend)
|
|
412
|
+
? { label: `${label} CLI installed`, status: "ok" }
|
|
413
|
+
: {
|
|
414
|
+
label: `${label} CLI not found`,
|
|
415
|
+
status: "fail",
|
|
416
|
+
detail: `the ${label} SDK spawns \`${backend}\` — install it or this backend cannot start`,
|
|
417
|
+
issue: true,
|
|
418
|
+
},
|
|
419
|
+
);
|
|
357
420
|
} else if (backend === "openai-agents") {
|
|
358
421
|
checks.push({ label: "OpenAI Agents SDK bundled", status: "ok" });
|
|
359
422
|
const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
DISCORD_MAX_TEXT,
|
|
69
69
|
} from "../formatting.js";
|
|
70
70
|
import { chatIdFromInteraction, type ComponentInteraction } from "./shared.js";
|
|
71
|
+
import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
|
|
71
72
|
|
|
72
73
|
export async function handleComponentInteraction(
|
|
73
74
|
interaction: ComponentInteraction,
|
|
@@ -352,6 +353,21 @@ export async function handleComponentInteraction(
|
|
|
352
353
|
return;
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
// ── /metrics today ↔ all-time toggle ────────────────────────────────────
|
|
357
|
+
if (customId === "metrics:today" || customId === "metrics:all") {
|
|
358
|
+
const view = customId === "metrics:all" ? "all" : "today";
|
|
359
|
+
const messages = renderMetricsView(view);
|
|
360
|
+
try {
|
|
361
|
+
await interaction.update({
|
|
362
|
+
content: messages[0]!,
|
|
363
|
+
components: [metricsViewRow(view).toJSON()],
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
/* ignore */
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
355
371
|
// ── AI-generated buttons (prefixed `ai:`) → forward to agent ──────────
|
|
356
372
|
if (customId.startsWith("ai:")) {
|
|
357
373
|
await forwardToAgent(interaction, gateway);
|
|
@@ -3,16 +3,37 @@
|
|
|
3
3
|
* All gate on `isAdmin`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type ChatInputCommandInteraction,
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
ButtonBuilder,
|
|
10
|
+
ButtonStyle,
|
|
11
|
+
MessageFlags,
|
|
12
|
+
} from "discord.js";
|
|
7
13
|
import type { TalonConfig } from "../../../util/config.js";
|
|
8
14
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
9
15
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
10
16
|
import { forceDream } from "../../../core/background/dream.js";
|
|
11
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
formatDuration,
|
|
19
|
+
renderMetricsMessages,
|
|
20
|
+
renderDoctorMessages,
|
|
21
|
+
} from "../helpers.js";
|
|
22
|
+
import { collectDoctorReport } from "../../../core/doctor.js";
|
|
23
|
+
import { getSoul } from "../../../core/soul/service.js";
|
|
12
24
|
import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
|
|
13
25
|
import { handleAdminSubcommand } from "../admin.js";
|
|
14
26
|
import { isAdmin } from "../handlers/index.js";
|
|
15
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
suppressMentions,
|
|
29
|
+
DISCORD_MAX_TEXT,
|
|
30
|
+
safeSlice,
|
|
31
|
+
escapeForCodeBlock,
|
|
32
|
+
} from "../formatting.js";
|
|
33
|
+
import {
|
|
34
|
+
getRepoRoot,
|
|
35
|
+
runSelfUpdate,
|
|
36
|
+
} from "../../../core/update/self-update.js";
|
|
16
37
|
import { reply } from "./shared.js";
|
|
17
38
|
|
|
18
39
|
export async function handleRestart(
|
|
@@ -34,18 +55,112 @@ export async function handleMetrics(
|
|
|
34
55
|
return;
|
|
35
56
|
}
|
|
36
57
|
// Ephemeral — admin counters (token usage, latencies, errors) shouldn't leak
|
|
37
|
-
// into a public channel where non-admins can read them.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
// into a public channel where non-admins can read them. Both views are one
|
|
59
|
+
// button apart rather than two bursts of messages.
|
|
60
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
61
|
+
const messages = renderMetricsMessages(
|
|
62
|
+
getTodayMetrics(),
|
|
63
|
+
undefined,
|
|
64
|
+
"📊 Metrics — today (UTC)",
|
|
65
|
+
);
|
|
66
|
+
await i.editReply({
|
|
67
|
+
content: messages[0]!,
|
|
68
|
+
components: [metricsViewRow("today").toJSON()],
|
|
69
|
+
});
|
|
70
|
+
for (const extra of messages.slice(1)) {
|
|
71
|
+
await i.followUp({ content: extra, flags: MessageFlags.Ephemeral });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Today / all-time toggle under the metrics panel. */
|
|
76
|
+
export function metricsViewRow(
|
|
77
|
+
view: MetricsView,
|
|
78
|
+
): ActionRowBuilder<ButtonBuilder> {
|
|
79
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
80
|
+
new ButtonBuilder()
|
|
81
|
+
.setCustomId("metrics:today")
|
|
82
|
+
.setLabel("Today")
|
|
83
|
+
.setStyle(view === "today" ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
|
84
|
+
.setDisabled(view === "today"),
|
|
85
|
+
new ButtonBuilder()
|
|
86
|
+
.setCustomId("metrics:all")
|
|
87
|
+
.setLabel("All time")
|
|
88
|
+
.setStyle(view === "all" ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
|
89
|
+
.setDisabled(view === "all"),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type MetricsView = "today" | "all";
|
|
94
|
+
|
|
95
|
+
/** Panel body for one view — shared by the command and the toggle. */
|
|
96
|
+
export function renderMetricsView(view: MetricsView): string[] {
|
|
97
|
+
return view === "today"
|
|
98
|
+
? renderMetricsMessages(
|
|
99
|
+
getTodayMetrics(),
|
|
100
|
+
undefined,
|
|
101
|
+
"📊 Metrics — today (UTC)",
|
|
102
|
+
)
|
|
103
|
+
: renderMetricsMessages(getMetrics(), undefined, "📊 Metrics — all time");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function handleDoctor(
|
|
107
|
+
i: ChatInputCommandInteraction,
|
|
108
|
+
config: TalonConfig,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (!isAdmin(i.user.id)) {
|
|
111
|
+
await reply(i, "Not authorized.", true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
115
|
+
await i.editReply("🩺 Running checks...");
|
|
116
|
+
try {
|
|
117
|
+
// Same checks as `talon doctor` — config exists by definition when the
|
|
118
|
+
// bot is processing this command.
|
|
119
|
+
const report = await collectDoctorReport({ config, hasConfigFile: true });
|
|
120
|
+
const messages = renderDoctorMessages(report);
|
|
121
|
+
await i.editReply(messages[0]!);
|
|
122
|
+
for (const extra of messages.slice(1))
|
|
123
|
+
await i.followUp({
|
|
124
|
+
content: extra,
|
|
125
|
+
flags: MessageFlags.Ephemeral,
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
129
|
+
await i.editReply(`🩺 Doctor failed: ${msg}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* /soul — read-only introspection of the compiled identity. `action:dream`
|
|
135
|
+
* runs the organic maintenance pass and is admin-only. Inert while the soul
|
|
136
|
+
* is disabled, so it is safe to ship dormant.
|
|
137
|
+
*/
|
|
138
|
+
export async function handleSoul(
|
|
139
|
+
i: ChatInputCommandInteraction,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const soul = getSoul();
|
|
142
|
+
if (!soul.enabled) {
|
|
143
|
+
await reply(
|
|
144
|
+
i,
|
|
145
|
+
"Soul is disabled (set TALON_SOUL_ENABLED to enable).",
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (i.options.getString("action") === "dream") {
|
|
151
|
+
if (!isAdmin(i.user.id)) {
|
|
152
|
+
await reply(i, "Not authorized.", true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
156
|
+
await i.editReply("🧠 Soul dreaming...");
|
|
157
|
+
soul
|
|
158
|
+
.dream()
|
|
159
|
+
.then(() => i.editReply("🧠 Soul dream complete."))
|
|
160
|
+
.catch(() => undefined);
|
|
161
|
+
return;
|
|
48
162
|
}
|
|
163
|
+
await reply(i, suppressMentions(soul.introspect()), true);
|
|
49
164
|
}
|
|
50
165
|
|
|
51
166
|
export async function handleDream(
|
|
@@ -71,6 +186,65 @@ export async function handleDream(
|
|
|
71
186
|
});
|
|
72
187
|
}
|
|
73
188
|
|
|
189
|
+
/**
|
|
190
|
+
* /update — pull, reinstall, run setup, restart. Only reachable on developer
|
|
191
|
+
* builds running from a git checkout; the command is not registered at all
|
|
192
|
+
* otherwise (see buildCommandDefinitions).
|
|
193
|
+
*/
|
|
194
|
+
export async function handleUpdate(
|
|
195
|
+
i: ChatInputCommandInteraction,
|
|
196
|
+
config: TalonConfig,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
if (!isAdmin(i.user.id)) {
|
|
199
|
+
await reply(i, "Not authorized.", true);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const repoRoot = config.devBuild ? getRepoRoot() : null;
|
|
203
|
+
if (!repoRoot) {
|
|
204
|
+
await reply(i, "Update is only available on developer builds.", true);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const remote = config.update?.remote ?? "origin";
|
|
208
|
+
const branch = config.update?.branch ?? "main";
|
|
209
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
210
|
+
await i.editReply(`⏳ Updating from \`${remote}/${branch}\`…`);
|
|
211
|
+
const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
|
|
212
|
+
|
|
213
|
+
// Fire-and-forget so the gateway keeps processing other interactions.
|
|
214
|
+
runSelfUpdate({
|
|
215
|
+
remote,
|
|
216
|
+
branch,
|
|
217
|
+
setup: config.update?.setup,
|
|
218
|
+
repoRoot,
|
|
219
|
+
})
|
|
220
|
+
.then(async (res) => {
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const tail = res.steps[res.steps.length - 1]?.output ?? "";
|
|
223
|
+
await edit(
|
|
224
|
+
`⚠️ Update failed: ${res.error ?? "unknown error"}` +
|
|
225
|
+
(tail
|
|
226
|
+
? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
|
|
227
|
+
: ""),
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!res.changed) {
|
|
232
|
+
await edit(
|
|
233
|
+
`✅ Already up to date at \`${res.before ?? "?"}\` — no restart needed.`,
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await edit(
|
|
238
|
+
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
|
|
239
|
+
);
|
|
240
|
+
respawnSelf("discord /update");
|
|
241
|
+
})
|
|
242
|
+
.catch(async (err: unknown) => {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
244
|
+
await edit(`⚠️ Update crashed: ${msg}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
74
248
|
export async function handleAdmin(
|
|
75
249
|
i: ChatInputCommandInteraction,
|
|
76
250
|
config: TalonConfig,
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
import { type Client, REST, Routes, SlashCommandBuilder } from "discord.js";
|
|
16
16
|
import type { TalonConfig } from "../../../util/config.js";
|
|
17
17
|
import { log, logError, logWarn } from "../../../util/log.js";
|
|
18
|
+
import { getRepoRoot } from "../../../core/update/self-update.js";
|
|
18
19
|
|
|
19
|
-
export function buildCommandDefinitions(): unknown[] {
|
|
20
|
+
export function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
20
21
|
return [
|
|
21
22
|
new SlashCommandBuilder()
|
|
22
23
|
.setName("start")
|
|
@@ -97,6 +98,40 @@ export function buildCommandDefinitions(): unknown[] {
|
|
|
97
98
|
.setName("plugins")
|
|
98
99
|
.setDescription("List loaded plugins")
|
|
99
100
|
.toJSON(),
|
|
101
|
+
new SlashCommandBuilder()
|
|
102
|
+
.setName("usage")
|
|
103
|
+
.setDescription("Plan limits across every backend")
|
|
104
|
+
.toJSON(),
|
|
105
|
+
new SlashCommandBuilder()
|
|
106
|
+
.setName("doctor")
|
|
107
|
+
.setDescription("Environment and native-module health (admin)")
|
|
108
|
+
.toJSON(),
|
|
109
|
+
new SlashCommandBuilder()
|
|
110
|
+
.setName("mesh")
|
|
111
|
+
.setDescription("Ping and list mesh devices")
|
|
112
|
+
.toJSON(),
|
|
113
|
+
new SlashCommandBuilder()
|
|
114
|
+
.setName("soul")
|
|
115
|
+
.setDescription("Inspect the compiled identity")
|
|
116
|
+
.addStringOption((o) =>
|
|
117
|
+
o
|
|
118
|
+
.setName("action")
|
|
119
|
+
.setDescription("Leave empty to introspect")
|
|
120
|
+
.setRequired(false)
|
|
121
|
+
.addChoices({ name: "dream", value: "dream" }),
|
|
122
|
+
)
|
|
123
|
+
.toJSON(),
|
|
124
|
+
// /update only exists on developer builds running from a git checkout —
|
|
125
|
+
// a packaged binary has no source tree to pull into, so the command is
|
|
126
|
+
// never registered there (same gate as the Telegram handler).
|
|
127
|
+
...(devBuild && getRepoRoot()
|
|
128
|
+
? [
|
|
129
|
+
new SlashCommandBuilder()
|
|
130
|
+
.setName("update")
|
|
131
|
+
.setDescription("Pull latest, reinstall, restart (admin)")
|
|
132
|
+
.toJSON(),
|
|
133
|
+
]
|
|
134
|
+
: []),
|
|
100
135
|
new SlashCommandBuilder()
|
|
101
136
|
.setName("admin")
|
|
102
137
|
.setDescription("Admin operations (admin only)")
|
|
@@ -128,7 +163,7 @@ export async function registerCommandsForGuilds(
|
|
|
128
163
|
): Promise<void> {
|
|
129
164
|
const dc = config.discord!;
|
|
130
165
|
const rest = new REST({ version: "10" }).setToken(dc.botToken);
|
|
131
|
-
const definitions = buildCommandDefinitions();
|
|
166
|
+
const definitions = buildCommandDefinitions(config.devBuild);
|
|
132
167
|
|
|
133
168
|
// Step 1: clear or set global commands depending on DM setting.
|
|
134
169
|
try {
|
|
@@ -7,8 +7,16 @@ import {
|
|
|
7
7
|
type Client,
|
|
8
8
|
MessageFlags,
|
|
9
9
|
} from "discord.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
formatDuration,
|
|
12
|
+
renderMeshReport,
|
|
13
|
+
renderUsageMessage,
|
|
14
|
+
} from "../helpers.js";
|
|
15
|
+
import type { TalonConfig } from "../../../util/config.js";
|
|
16
|
+
import { collectPlanUsage } from "../../shared/plan-usage-report.js";
|
|
11
17
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
18
|
+
import { getMeshService } from "../../../core/mesh/index.js";
|
|
19
|
+
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
12
20
|
import { reply } from "./shared.js";
|
|
13
21
|
|
|
14
22
|
export async function handleStart(
|
|
@@ -67,6 +75,35 @@ export async function handleHelp(
|
|
|
67
75
|
);
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* /usage — plan limits across every exposed backend, not just this chat's.
|
|
80
|
+
* Not admin-gated: it says how close the shared account is to a wall, which
|
|
81
|
+
* is exactly what a user hitting one needs to know.
|
|
82
|
+
*/
|
|
83
|
+
export async function handleUsage(
|
|
84
|
+
i: ChatInputCommandInteraction,
|
|
85
|
+
config: TalonConfig,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
88
|
+
const entries = await collectPlanUsage(config);
|
|
89
|
+
await i.editReply(renderUsageMessage(entries));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function handleMesh(
|
|
93
|
+
i: ChatInputCommandInteraction,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
96
|
+
await i.editReply("Pinging mesh devices…");
|
|
97
|
+
let results: MeshPingResult[];
|
|
98
|
+
try {
|
|
99
|
+
results = await getMeshService().pingAll();
|
|
100
|
+
} catch {
|
|
101
|
+
await i.editReply("Could not reach the mesh service.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await i.editReply(renderMeshReport(results));
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
export async function handlePing(
|
|
71
108
|
i: ChatInputCommandInteraction,
|
|
72
109
|
): Promise<void> {
|
|
@@ -22,7 +22,14 @@ import {
|
|
|
22
22
|
handleModalSubmit,
|
|
23
23
|
} from "../callbacks/index.js";
|
|
24
24
|
import { chatIdFromInteraction, reply, client } from "./shared.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
handleStart,
|
|
27
|
+
handleHelp,
|
|
28
|
+
handlePing,
|
|
29
|
+
handlePlugins,
|
|
30
|
+
handleMesh,
|
|
31
|
+
handleUsage,
|
|
32
|
+
} from "./info.js";
|
|
26
33
|
import { handleReset, handleStatus } from "./session.js";
|
|
27
34
|
import {
|
|
28
35
|
handleModel,
|
|
@@ -35,6 +42,9 @@ import {
|
|
|
35
42
|
handleMetrics,
|
|
36
43
|
handleDream,
|
|
37
44
|
handleAdmin,
|
|
45
|
+
handleDoctor,
|
|
46
|
+
handleSoul,
|
|
47
|
+
handleUpdate,
|
|
38
48
|
} from "./admin.js";
|
|
39
49
|
|
|
40
50
|
export function registerInteractionRouter(
|
|
@@ -141,6 +151,16 @@ async function routeSlashCommand(
|
|
|
141
151
|
return handleDream(interaction);
|
|
142
152
|
case "plugins":
|
|
143
153
|
return handlePlugins(interaction);
|
|
154
|
+
case "usage":
|
|
155
|
+
return handleUsage(interaction, config);
|
|
156
|
+
case "doctor":
|
|
157
|
+
return handleDoctor(interaction, config);
|
|
158
|
+
case "mesh":
|
|
159
|
+
return handleMesh(interaction);
|
|
160
|
+
case "soul":
|
|
161
|
+
return handleSoul(interaction);
|
|
162
|
+
case "update":
|
|
163
|
+
return handleUpdate(interaction, config);
|
|
144
164
|
case "admin":
|
|
145
165
|
return handleAdmin(interaction, config, gateway);
|
|
146
166
|
default:
|
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { REASONING_LEVEL_DESCRIPTIONS } from "../../core/models/reasoning-levels.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
DISCORD_MAX_TEXT,
|
|
13
|
+
DISCORD_SAFE_RESERVE,
|
|
14
|
+
splitMessage,
|
|
15
|
+
} from "./formatting.js";
|
|
16
|
+
import type { DoctorReport } from "../../core/doctor.js";
|
|
17
|
+
import type { MeshPingResult } from "../../core/mesh/service.js";
|
|
18
|
+
import type { BackendUsageEntry } from "../shared/plan-usage-report.js";
|
|
12
19
|
import {
|
|
13
20
|
DEFAULT_PULSE_INTERVAL_MS,
|
|
14
21
|
formatDuration,
|
|
22
|
+
formatBytes,
|
|
15
23
|
formatModelLabel,
|
|
16
24
|
} from "../shared/format.js";
|
|
17
25
|
|
|
@@ -157,6 +165,145 @@ export function renderMetricsMessages(
|
|
|
157
165
|
return chunks;
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
const DOCTOR_ICONS: Record<string, string> = {
|
|
169
|
+
ok: "✅",
|
|
170
|
+
warn: "⚠️",
|
|
171
|
+
fail: "❌",
|
|
172
|
+
info: "▫️",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Render a DoctorReport as Discord markdown, split to fit the message cap.
|
|
177
|
+
* Same data as `talon doctor` and Telegram's /doctor.
|
|
178
|
+
*/
|
|
179
|
+
export function renderDoctorMessages(
|
|
180
|
+
report: DoctorReport,
|
|
181
|
+
maxLen = DEFAULT_METRICS_MESSAGE_MAX,
|
|
182
|
+
): string[] {
|
|
183
|
+
const lines = ["**🩺 Talon Doctor**", "", "**Environment**"];
|
|
184
|
+
|
|
185
|
+
const render = (check: DoctorReport["checks"][number]): string =>
|
|
186
|
+
`${DOCTOR_ICONS[check.status]} ${check.label}${check.detail ? ` (${check.detail})` : ""}`;
|
|
187
|
+
|
|
188
|
+
for (const check of report.checks.filter((c) => !c.inactive)) {
|
|
189
|
+
lines.push(render(check));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Configured-but-idle backends get their own block: they describe what a
|
|
193
|
+
// switch would run into, not the state of the running deployment.
|
|
194
|
+
const idle = report.checks.filter((c) => c.inactive);
|
|
195
|
+
if (idle.length > 0) {
|
|
196
|
+
lines.push("", "**Other backends**", ...idle.map(render));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
lines.push("", "**Native modules**");
|
|
200
|
+
for (const mod of report.native) {
|
|
201
|
+
const size =
|
|
202
|
+
mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
|
|
203
|
+
const note = mod.note ? ` (${mod.note})` : "";
|
|
204
|
+
lines.push(
|
|
205
|
+
`${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} \`${mod.name}\` — ${mod.language} → ${mod.target}${size}${note}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
lines.push(
|
|
210
|
+
"",
|
|
211
|
+
"**Process**",
|
|
212
|
+
`Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · Node ${process.versions.node}`,
|
|
213
|
+
"",
|
|
214
|
+
report.issues === 0
|
|
215
|
+
? `${DOCTOR_ICONS.ok} All checks passed.`
|
|
216
|
+
: `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return splitMessage(lines.join("\n"), maxLen);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function meshDeviceLine(r: MeshPingResult, now: number): string {
|
|
223
|
+
const d = r.device;
|
|
224
|
+
const bits: string[] = [d.platform];
|
|
225
|
+
if (r.reachable && typeof r.latencyMs === "number") {
|
|
226
|
+
bits.push(`${r.latencyMs} ms`);
|
|
227
|
+
} else if (d.online && r.error) {
|
|
228
|
+
bits.push(r.error);
|
|
229
|
+
} else if (!d.online) {
|
|
230
|
+
bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
|
|
231
|
+
}
|
|
232
|
+
if (typeof d.battery === "number") {
|
|
233
|
+
bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
|
|
234
|
+
}
|
|
235
|
+
return ` **${d.name}** — ${bits.join(" · ")}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Render the /mesh fleet report as Discord markdown. Devices group under a
|
|
240
|
+
* state heading; empty groups are omitted.
|
|
241
|
+
*/
|
|
242
|
+
export function renderMeshReport(
|
|
243
|
+
results: MeshPingResult[],
|
|
244
|
+
now = Date.now(),
|
|
245
|
+
): string {
|
|
246
|
+
if (results.length === 0) {
|
|
247
|
+
return "**Mesh**\n\n_No devices have registered yet._";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const responding = results
|
|
251
|
+
.filter((r) => r.reachable)
|
|
252
|
+
.sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
|
|
253
|
+
const unreachable = results
|
|
254
|
+
.filter((r) => !r.reachable && r.device.online)
|
|
255
|
+
.sort((a, b) => a.device.name.localeCompare(b.device.name));
|
|
256
|
+
const offline = results
|
|
257
|
+
.filter((r) => !r.reachable && !r.device.online)
|
|
258
|
+
.sort((a, b) => b.device.lastSeen - a.device.lastSeen);
|
|
259
|
+
|
|
260
|
+
const summary = [
|
|
261
|
+
`${results.length} device${results.length === 1 ? "" : "s"}`,
|
|
262
|
+
`${responding.length} responding`,
|
|
263
|
+
...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
|
|
264
|
+
...(offline.length > 0 ? [`${offline.length} offline`] : []),
|
|
265
|
+
].join(" · ");
|
|
266
|
+
|
|
267
|
+
const lines = ["**Mesh**", summary];
|
|
268
|
+
const section = (title: string, entries: MeshPingResult[]): void => {
|
|
269
|
+
if (entries.length === 0) return;
|
|
270
|
+
lines.push(
|
|
271
|
+
"",
|
|
272
|
+
`**${title}**`,
|
|
273
|
+
...entries.map((r) => meshDeviceLine(r, now)),
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
section("Responding", responding);
|
|
277
|
+
section("Unreachable", unreachable);
|
|
278
|
+
section("Offline", offline);
|
|
279
|
+
|
|
280
|
+
return lines.join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Render the `/usage` report — one block per exposed backend. */
|
|
284
|
+
export function renderUsageMessage(entries: BackendUsageEntry[]): string {
|
|
285
|
+
const lines = ["**📊 Plan usage**"];
|
|
286
|
+
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
const name = entry.label || entry.id;
|
|
289
|
+
if (!entry.plan) {
|
|
290
|
+
lines.push("", `**${name}** — _${entry.note ?? ""}_`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const age = entry.plan.ageLabel ? ` *(${entry.plan.ageLabel})*` : "";
|
|
294
|
+
const plan = entry.plan.plan ? ` · ${entry.plan.plan}` : "";
|
|
295
|
+
lines.push("", `**${name}**${plan}${age}`);
|
|
296
|
+
for (const w of entry.plan.windows) {
|
|
297
|
+
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
298
|
+
lines.push(
|
|
299
|
+
` \`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${reset}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
160
307
|
/** Settings panel: build the markdown body. */
|
|
161
308
|
export function renderSettingsText(
|
|
162
309
|
model: string,
|
|
@@ -73,8 +73,17 @@ export function createDiscordFrontend(
|
|
|
73
73
|
GatewayIntentBits.MessageContent,
|
|
74
74
|
GatewayIntentBits.DirectMessages,
|
|
75
75
|
GatewayIntentBits.GuildMessageReactions,
|
|
76
|
+
GatewayIntentBits.DirectMessageReactions,
|
|
77
|
+
],
|
|
78
|
+
// Partials.Reaction is what makes reactions on messages that predate the
|
|
79
|
+
// current cache arrive at all — without it the soul's reaction tap only
|
|
80
|
+
// ever sees freshly-cached messages.
|
|
81
|
+
partials: [
|
|
82
|
+
Partials.Channel,
|
|
83
|
+
Partials.Message,
|
|
84
|
+
Partials.User,
|
|
85
|
+
Partials.Reaction,
|
|
76
86
|
],
|
|
77
|
-
partials: [Partials.Channel, Partials.Message, Partials.User],
|
|
78
87
|
allowedMentions: { parse: [] }, // never ping anyone unless we explicitly opt in
|
|
79
88
|
});
|
|
80
89
|
|
|
@@ -21,6 +21,7 @@ import { pushMessage } from "../../storage/history.js";
|
|
|
21
21
|
import { registerChat } from "../../core/background/pulse.js";
|
|
22
22
|
import { deriveNumericChatId } from "../../util/chat-id.js";
|
|
23
23
|
import { handleMessage, getSenderName } from "./handlers/index.js";
|
|
24
|
+
import { recordReactionToBot } from "../../core/soul/taps.js";
|
|
24
25
|
|
|
25
26
|
export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
26
27
|
client.on("messageCreate", (msg: Message) => {
|
|
@@ -85,4 +86,27 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
|
85
86
|
/* logged inside */
|
|
86
87
|
});
|
|
87
88
|
});
|
|
89
|
+
|
|
90
|
+
// ── Reaction tap — feed reactions on Talon's own messages to the soul ────
|
|
91
|
+
// The gateway records outgoing message ids as `Number(snowflake)`, so the
|
|
92
|
+
// lookup here must use the same conversion to match. Inert unless the soul
|
|
93
|
+
// is enabled.
|
|
94
|
+
client.on("messageReactionAdd", async (reaction, user) => {
|
|
95
|
+
if (user.id === client.user?.id) return;
|
|
96
|
+
try {
|
|
97
|
+
// A partial arrives when the message predates the cache; the chat id
|
|
98
|
+
// can't be built without the channel/guild it belongs to.
|
|
99
|
+
if (reaction.partial) await reaction.fetch();
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const msg = reaction.message;
|
|
104
|
+
const emoji = reaction.emoji.name;
|
|
105
|
+
if (!emoji) return;
|
|
106
|
+
const chatId =
|
|
107
|
+
msg.channel.type === ChannelType.DM
|
|
108
|
+
? `discord_dm_${msg.author?.id ?? user.id}`
|
|
109
|
+
: `discord_guild_${msg.guildId}_${msg.channelId}`;
|
|
110
|
+
recordReactionToBot(chatId, Number(msg.id), [emoji]);
|
|
111
|
+
});
|
|
88
112
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/usage` data gathering — plan limits across every backend the config
|
|
3
|
+
* exposes, not just the one serving this chat.
|
|
4
|
+
*
|
|
5
|
+
* Most backends have no plan to report: a gateway (Kilo, OpenCode) bills
|
|
6
|
+
* through whichever provider it fronts, and an API-key install pays per
|
|
7
|
+
* token with no window to be near the end of. Those are listed with a
|
|
8
|
+
* reason rather than omitted, so the answer to "am I close to a limit?" is
|
|
9
|
+
* never silence.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
13
|
+
import type { PlanUsage } from "../../core/agent-runtime/capabilities.js";
|
|
14
|
+
import {
|
|
15
|
+
listAvailableBackends,
|
|
16
|
+
getPooledBackend,
|
|
17
|
+
} from "../../core/engine/backend-controller/index.js";
|
|
18
|
+
import { buildPlanDisplay, type PlanDisplay } from "./status-context.js";
|
|
19
|
+
|
|
20
|
+
export interface BackendUsageEntry {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** Rendered windows, or null when this backend reported nothing. */
|
|
24
|
+
plan: PlanDisplay | null;
|
|
25
|
+
/** Why there is nothing to show. Absent when `plan` is set. */
|
|
26
|
+
note?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One entry per exposed backend, in config order.
|
|
31
|
+
*
|
|
32
|
+
* Only backends already running are queried — booting one to read a
|
|
33
|
+
* number would spawn a subprocess or a server per idle provider, which is
|
|
34
|
+
* far more than a status command should cost.
|
|
35
|
+
*/
|
|
36
|
+
export async function collectPlanUsage(
|
|
37
|
+
config: TalonConfig,
|
|
38
|
+
): Promise<BackendUsageEntry[]> {
|
|
39
|
+
const entries: BackendUsageEntry[] = [];
|
|
40
|
+
|
|
41
|
+
for (const { id, label } of listAvailableBackends(config)) {
|
|
42
|
+
const backend = getPooledBackend(id);
|
|
43
|
+
if (!backend) {
|
|
44
|
+
entries.push({ id, label, plan: null, note: "not running" });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!backend.usage?.getPlanUsage) {
|
|
48
|
+
entries.push({
|
|
49
|
+
id,
|
|
50
|
+
label,
|
|
51
|
+
plan: null,
|
|
52
|
+
note: "no plan limits on this backend",
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let usage: PlanUsage | undefined;
|
|
58
|
+
try {
|
|
59
|
+
usage = await backend.usage.getPlanUsage();
|
|
60
|
+
} catch {
|
|
61
|
+
usage = undefined;
|
|
62
|
+
}
|
|
63
|
+
const plan = buildPlanDisplay(usage);
|
|
64
|
+
entries.push(
|
|
65
|
+
plan
|
|
66
|
+
? { id, label, plan }
|
|
67
|
+
: { id, label, plan: null, note: "no usage information available" },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
renderDoctorMessage,
|
|
22
22
|
renderMetricsKeyboard,
|
|
23
23
|
renderMetricsPanel,
|
|
24
|
+
renderUsageMessage,
|
|
24
25
|
} from "../helpers/index.js";
|
|
26
|
+
import { collectPlanUsage } from "../../shared/plan-usage-report.js";
|
|
25
27
|
import { collectDoctorReport } from "../../../core/doctor.js";
|
|
26
28
|
import { handleAdminCommand } from "../admin.js";
|
|
27
29
|
import { getTodayMetrics } from "../../../storage/metrics.js";
|
|
@@ -53,6 +55,14 @@ export function registerAdminCommands(
|
|
|
53
55
|
});
|
|
54
56
|
});
|
|
55
57
|
|
|
58
|
+
// /usage — plan limits across every exposed backend, not just this
|
|
59
|
+
// chat's. Not admin-gated: it says how close the shared account is to a
|
|
60
|
+
// wall, which is exactly what a user hitting one needs to know.
|
|
61
|
+
bot.command("usage", async (ctx) => {
|
|
62
|
+
const entries = await collectPlanUsage(config);
|
|
63
|
+
await ctx.reply(renderUsageMessage(entries), { parse_mode: "HTML" });
|
|
64
|
+
});
|
|
65
|
+
|
|
56
66
|
bot.command("doctor", async (ctx) => {
|
|
57
67
|
if (!isAuthorizedAdmin(ctx)) {
|
|
58
68
|
await ctx.reply("Not authorized.");
|
|
@@ -28,6 +28,7 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
28
28
|
{ command: "pulse", description: "Conversation engagement settings" },
|
|
29
29
|
{ command: "reset", description: "Clear session and start fresh" },
|
|
30
30
|
{ command: "restart", description: "Restart the bot (admin)" },
|
|
31
|
+
{ command: "usage", description: "Plan limits across every backend" },
|
|
31
32
|
{ command: "metrics", description: "Aggregate performance metrics" },
|
|
32
33
|
{
|
|
33
34
|
command: "doctor",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { escapeHtml } from "../formatting.js";
|
|
6
6
|
import type { DoctorReport } from "../../../core/doctor.js";
|
|
7
7
|
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
8
|
+
import type { BackendUsageEntry } from "../../shared/plan-usage-report.js";
|
|
8
9
|
import type { SettingsButton } from "./menu.js";
|
|
9
10
|
import { formatDuration, formatBytes } from "./format.js";
|
|
10
11
|
|
|
@@ -208,14 +209,47 @@ const DOCTOR_ICONS: Record<string, string> = {
|
|
|
208
209
|
* when this renders, the bot is by definition running, so the CLI's
|
|
209
210
|
* "is the bot up" probe becomes an uptime line instead.
|
|
210
211
|
*/
|
|
212
|
+
/** Render the `/usage` report — one block per exposed backend. */
|
|
213
|
+
export function renderUsageMessage(entries: BackendUsageEntry[]): string {
|
|
214
|
+
const lines = ["<b>📊 Plan usage</b>"];
|
|
215
|
+
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const name = escapeHtml(entry.label || entry.id);
|
|
218
|
+
if (!entry.plan) {
|
|
219
|
+
lines.push("", `<b>${name}</b> — <i>${escapeHtml(entry.note ?? "")}</i>`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const age = entry.plan.ageLabel ? ` <i>(${entry.plan.ageLabel})</i>` : "";
|
|
223
|
+
const plan = entry.plan.plan ? ` · ${escapeHtml(entry.plan.plan)}` : "";
|
|
224
|
+
lines.push("", `<b>${name}</b>${plan}${age}`);
|
|
225
|
+
for (const w of entry.plan.windows) {
|
|
226
|
+
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
227
|
+
lines.push(
|
|
228
|
+
` <code>${escapeHtml(w.label.padEnd(6))}${w.bar} ${String(w.percent).padStart(3)}%</code>${reset}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
export function renderDoctorMessage(report: DoctorReport): string {
|
|
212
237
|
const lines = ["<b>🩺 Talon Doctor</b>", "", "<b>Environment</b>"];
|
|
213
238
|
|
|
214
|
-
|
|
239
|
+
const render = (check: DoctorReport["checks"][number]): string => {
|
|
215
240
|
const detail = check.detail ? ` (${escapeHtml(check.detail)})` : "";
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
return `${DOCTOR_ICONS[check.status]} ${escapeHtml(check.label)}${detail}`;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
for (const check of report.checks.filter((c) => !c.inactive)) {
|
|
245
|
+
lines.push(render(check));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Configured-but-idle backends get their own block: they describe what a
|
|
249
|
+
// switch would run into, not the state of the running deployment.
|
|
250
|
+
const idle = report.checks.filter((c) => c.inactive);
|
|
251
|
+
if (idle.length > 0) {
|
|
252
|
+
lines.push("", "<b>Other backends</b>", ...idle.map(render));
|
|
219
253
|
}
|
|
220
254
|
|
|
221
255
|
lines.push("", "<b>Native modules</b>");
|
package/src/util/config.ts
CHANGED
|
@@ -340,6 +340,16 @@ const configSchema = z.object({
|
|
|
340
340
|
allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
|
|
341
341
|
pulse: z.boolean().default(true),
|
|
342
342
|
pulseIntervalMs: z.number().int().min(60000).default(300000),
|
|
343
|
+
/**
|
|
344
|
+
* Warn the admin chat when a subscription rate-limit window crosses
|
|
345
|
+
* `planAlertThreshold`. Off by default. Needs a backend that reports plan
|
|
346
|
+
* limits (Claude on a subscription); one message per window per reset
|
|
347
|
+
* cycle.
|
|
348
|
+
*/
|
|
349
|
+
planAlerts: z.boolean().default(false),
|
|
350
|
+
planAlertThreshold: z.number().int().min(1).max(100).default(80),
|
|
351
|
+
/** Chat that receives plan warnings. Defaults to `adminUserId`. */
|
|
352
|
+
planAlertChatId: z.string().optional(),
|
|
343
353
|
/** Background memory-consolidation (dream) runs. Mirrors `pulse`/`heartbeat`. */
|
|
344
354
|
dream: z.boolean().default(true),
|
|
345
355
|
/**
|