@inetafrica/open-claudia 1.20.0 → 2.0.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/core/config.js ADDED
@@ -0,0 +1,197 @@
1
+ // Loads .env, exposes paths, parses CHANNELS list, resolves optional CLIs.
2
+ // Pure configuration — never touches runtime state.
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { execSync } = require("child_process");
7
+ const CONFIG_DIR = require("../config-dir");
8
+ const { truthy: configTruthy } = require("../project-transcripts");
9
+
10
+ const BOT_DIR = path.resolve(__dirname, "..");
11
+ const ENV_PATH = path.join(CONFIG_DIR, ".env");
12
+
13
+ function loadEnv() {
14
+ if (!fs.existsSync(ENV_PATH)) {
15
+ console.error("No .env file found. Run: node setup.js");
16
+ process.exit(1);
17
+ }
18
+ const lines = fs.readFileSync(ENV_PATH, "utf-8").split("\n");
19
+ const env = {};
20
+ for (const line of lines) {
21
+ const idx = line.indexOf("=");
22
+ if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
23
+ }
24
+ return env;
25
+ }
26
+
27
+ function saveEnvKey(key, value) {
28
+ const content = fs.readFileSync(ENV_PATH, "utf-8");
29
+ const lines = content.split("\n");
30
+ let found = false;
31
+ const updated = lines.map((line) => {
32
+ if (line.startsWith(key + "=")) { found = true; return `${key}=${value}`; }
33
+ return line;
34
+ });
35
+ if (!found) updated.push(`${key}=${value}`);
36
+ fs.writeFileSync(ENV_PATH, updated.join("\n"));
37
+ }
38
+
39
+ const config = loadEnv();
40
+
41
+ // Telegram (kept for backwards compat — channels list defaults to telegram)
42
+ const TOKEN = config.TELEGRAM_BOT_TOKEN;
43
+ const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
44
+ const CHAT_ID = CHAT_IDS[0];
45
+
46
+ const WORKSPACE = config.WORKSPACE;
47
+ const CLAUDE_PATH = config.CLAUDE_PATH;
48
+ const CURSOR_PATH = config.CURSOR_PATH || null;
49
+ const CODEX_PATH = config.CODEX_PATH || null;
50
+ const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "280000", 10);
51
+ const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
52
+ const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
53
+ const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
54
+ const WHISPER_CLI = config.WHISPER_CLI || "";
55
+ const WHISPER_MODEL = config.WHISPER_MODEL || "";
56
+ const FFMPEG = config.FFMPEG || "";
57
+ const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
58
+ const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
59
+ const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
60
+ const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
61
+ const IDENTITIES_FILE = config.IDENTITIES_FILE || path.join(CONFIG_DIR, "identities.json");
62
+ const STATE_FILE = path.join(CONFIG_DIR, "state.json");
63
+ const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
64
+
65
+ const TEMP_DIR = path.join(CONFIG_DIR, "media");
66
+ const FILES_DIR = path.join(CONFIG_DIR, "files");
67
+
68
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
69
+ const MAX_VOICE_SIZE = 10 * 1024 * 1024;
70
+ const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
71
+
72
+ if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
73
+ if (!CLAUDE_PATH) { console.error("CLAUDE_PATH not set"); process.exit(1); }
74
+
75
+ if (!fs.existsSync(WORKSPACE)) {
76
+ try {
77
+ fs.mkdirSync(WORKSPACE, { recursive: true });
78
+ console.log(`Created workspace: ${WORKSPACE}`);
79
+ } catch (e) {
80
+ console.error(`Failed to create workspace: ${e.message}`);
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ if (!fs.existsSync(CLAUDE_PATH)) {
86
+ try {
87
+ execSync(`which "${CLAUDE_PATH}" 2>/dev/null || where "${CLAUDE_PATH}" 2>nul`, { encoding: "utf-8" });
88
+ } catch (e) {
89
+ console.error(`Claude CLI not found at: ${CLAUDE_PATH}`);
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ let resolvedCursorPath = CURSOR_PATH;
95
+ if (!resolvedCursorPath) {
96
+ try { resolvedCursorPath = execSync("which agent 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
97
+ catch (e) { resolvedCursorPath = null; }
98
+ }
99
+ if (resolvedCursorPath) console.log(`Cursor Agent CLI: ${resolvedCursorPath}`);
100
+
101
+ let resolvedCodexPath = CODEX_PATH;
102
+ if (!resolvedCodexPath) {
103
+ try { resolvedCodexPath = execSync("which codex 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
104
+ catch (e) { resolvedCodexPath = null; }
105
+ }
106
+ if (resolvedCodexPath) console.log(`Codex CLI: ${resolvedCodexPath}`);
107
+
108
+ if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
109
+ if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
110
+
111
+ const FULL_PATH = [
112
+ path.dirname(CLAUDE_PATH),
113
+ resolvedCursorPath ? path.dirname(resolvedCursorPath) : null,
114
+ resolvedCodexPath ? path.dirname(resolvedCodexPath) : null,
115
+ path.dirname(process.execPath),
116
+ FFMPEG ? path.dirname(FFMPEG) : null,
117
+ WHISPER_CLI ? path.dirname(WHISPER_CLI) : null,
118
+ ...(process.platform === "win32"
119
+ ? [process.env.APPDATA, process.env.LOCALAPPDATA].filter(Boolean).map((p) => path.join(p, "npm"))
120
+ : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
121
+ ),
122
+ ].filter(Boolean).join(path.delimiter);
123
+
124
+ // Channels: declarative list of {id, type, opts}. Defaults to telegram for
125
+ // backwards compat with installs predating CHANNELS.
126
+ function loadChannels() {
127
+ const raw = (config.CHANNELS || "telegram").split(",").map((s) => s.trim()).filter(Boolean);
128
+ const channels = [];
129
+ for (const entry of raw) {
130
+ const [type, idOverride] = entry.split(":");
131
+ const id = idOverride || type;
132
+ if (type === "telegram") {
133
+ if (!TOKEN) {
134
+ console.error("CHANNELS includes telegram but TELEGRAM_BOT_TOKEN is unset — skipping.");
135
+ continue;
136
+ }
137
+ if (!CHAT_ID) {
138
+ console.warn("TELEGRAM_CHAT_ID is unset — first /auth will register the owner.");
139
+ }
140
+ channels.push({
141
+ id,
142
+ type: "telegram",
143
+ opts: { token: TOKEN, ownerChatId: CHAT_ID || "", chatIds: CHAT_IDS },
144
+ });
145
+ } else if (type === "kazee") {
146
+ const url = config.KAZEE_URL;
147
+ const token = config.KAZEE_BOT_TOKEN;
148
+ const ownerUserId = config.KAZEE_OWNER_USER_ID || "";
149
+ const botUserId = config.KAZEE_BOT_USER_ID || "";
150
+ if (!url || !token) {
151
+ console.error(`CHANNELS includes ${entry} but KAZEE_URL or KAZEE_BOT_TOKEN is unset — skipping.`);
152
+ continue;
153
+ }
154
+ if (!botUserId) {
155
+ console.warn(`CHANNELS includes ${entry} but KAZEE_BOT_USER_ID is unset — slash commands and self-echo filtering will not work.`);
156
+ }
157
+ channels.push({
158
+ id,
159
+ type: "kazee",
160
+ opts: { url, token, ownerUserId, botUserId },
161
+ });
162
+ } else {
163
+ console.error(`Unknown channel type: ${type} — skipping.`);
164
+ }
165
+ }
166
+ return channels;
167
+ }
168
+
169
+ function botSubprocessEnv() {
170
+ return { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
171
+ }
172
+
173
+ module.exports = {
174
+ config,
175
+ saveEnvKey,
176
+ loadChannels,
177
+ botSubprocessEnv,
178
+ BOT_DIR,
179
+ CONFIG_DIR,
180
+ TOKEN, CHAT_IDS, CHAT_ID,
181
+ WORKSPACE,
182
+ CLAUDE_PATH,
183
+ CURSOR_PATH,
184
+ CODEX_PATH,
185
+ resolvedCursorPath,
186
+ resolvedCodexPath,
187
+ AUTO_COMPACT_TOKENS,
188
+ PROJECT_TRANSCRIPTS,
189
+ TRANSCRIPT_MAX_ENTRY_CHARS,
190
+ TRANSCRIPTS_DIR,
191
+ WHISPER_CLI, WHISPER_MODEL, FFMPEG,
192
+ SOUL_FILE, CRONS_FILE, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
193
+ STATE_FILE, SESSIONS_FILE,
194
+ TEMP_DIR, FILES_DIR,
195
+ MAX_FILE_SIZE, MAX_VOICE_SIZE, MAX_PROCESS_TIMEOUT,
196
+ FULL_PATH,
197
+ };
@@ -0,0 +1,44 @@
1
+ // Per-request async context: every inbound message runs inside one of
2
+ // these scopes so that send()/edit()/etc. resolve to the right adapter
3
+ // + channel without threading them through every function signature.
4
+
5
+ const { AsyncLocalStorage } = require("node:async_hooks");
6
+
7
+ const chatContext = new AsyncLocalStorage();
8
+
9
+ function runInChat({ adapter, channelId, canonicalUserId, raw }, fn) {
10
+ return chatContext.run({ adapter, channelId, canonicalUserId, raw }, fn);
11
+ }
12
+
13
+ function currentStore() {
14
+ return chatContext.getStore() || null;
15
+ }
16
+
17
+ function currentAdapter() {
18
+ const s = currentStore();
19
+ return s ? s.adapter : null;
20
+ }
21
+
22
+ function currentChannelId() {
23
+ const s = currentStore();
24
+ return s ? s.channelId : null;
25
+ }
26
+
27
+ function currentCanonicalUserId() {
28
+ const s = currentStore();
29
+ return s ? s.canonicalUserId : null;
30
+ }
31
+
32
+ function currentRaw() {
33
+ const s = currentStore();
34
+ return s ? s.raw : null;
35
+ }
36
+
37
+ module.exports = {
38
+ chatContext,
39
+ runInChat,
40
+ currentAdapter,
41
+ currentChannelId,
42
+ currentCanonicalUserId,
43
+ currentRaw,
44
+ };
package/core/cron.js ADDED
@@ -0,0 +1,77 @@
1
+ // Cron loader and scheduler. Cron jobs fire from a timer (no inbound
2
+ // message, so no chatContext) — we resolve the owner's preferred
3
+ // channel from identities and bind the run to that adapter.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const cron = require("node-cron");
8
+ const { CRONS_FILE, WORKSPACE, CHAT_ID } = require("./config");
9
+ const { runInChat } = require("./context");
10
+ const { canonicalForTelegram, identities } = require("./identity");
11
+ const { runClaudeSilent } = require("./runner");
12
+
13
+ const activeCrons = new Map();
14
+ let adaptersById = new Map();
15
+
16
+ function setAdapters(adapters) {
17
+ adaptersById = new Map();
18
+ for (const a of adapters) adaptersById.set(a.type, a);
19
+ }
20
+
21
+ function loadCrons() {
22
+ try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); }
23
+ catch (e) { return []; }
24
+ }
25
+
26
+ function saveCrons(list) {
27
+ fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
28
+ }
29
+
30
+ function ownerDispatchTarget() {
31
+ // Prefer an explicit owner mapping; fall back to legacy Telegram CHAT_ID.
32
+ const ownerId = canonicalForTelegram(CHAT_ID || "");
33
+ const preferred = identities.preferred[ownerId];
34
+ if (preferred && preferred.transport && adaptersById.get(preferred.transport)) {
35
+ return {
36
+ adapter: adaptersById.get(preferred.transport),
37
+ channelId: String(preferred.channelId),
38
+ canonicalUserId: ownerId,
39
+ };
40
+ }
41
+ const tg = adaptersById.get("telegram");
42
+ if (tg && CHAT_ID) {
43
+ return { adapter: tg, channelId: String(CHAT_ID), canonicalUserId: ownerId };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function scheduleCron(c) {
49
+ const cwd = path.join(WORKSPACE, c.project);
50
+ if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
51
+ const task = cron.schedule(c.schedule, () => {
52
+ const target = ownerDispatchTarget();
53
+ if (!target) {
54
+ console.error(`Cron ${c.label}: no adapter available to deliver result`);
55
+ return;
56
+ }
57
+ runInChat(target, () => runClaudeSilent(c.prompt, cwd, c.label));
58
+ });
59
+ activeCrons.set(c.id, { task, config: c });
60
+ }
61
+
62
+ function initCrons() {
63
+ for (const c of loadCrons()) {
64
+ try { scheduleCron(c); }
65
+ catch (e) { console.error("Cron error:", e.message); }
66
+ }
67
+ console.log(`Loaded ${loadCrons().length} cron(s)`);
68
+ }
69
+
70
+ module.exports = {
71
+ activeCrons,
72
+ setAdapters,
73
+ loadCrons,
74
+ saveCrons,
75
+ scheduleCron,
76
+ initCrons,
77
+ };
package/core/doctor.js ADDED
@@ -0,0 +1,126 @@
1
+ // /doctor and /requirements helpers — sanity-check CLIs, auth, and disk
2
+ // before the user spends time on a task that's going to fail anyway.
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { execSync } = require("child_process");
7
+ const {
8
+ CLAUDE_PATH, CURSOR_PATH, CODEX_PATH, resolvedCursorPath, resolvedCodexPath,
9
+ WHISPER_CLI, WHISPER_MODEL, FFMPEG, WORKSPACE, CONFIG_DIR,
10
+ botSubprocessEnv,
11
+ } = require("./config");
12
+ const { redactSensitive, stripTerminalControls } = require("./redact");
13
+ const { isClaudeAuthErrorText, isCodexAuthErrorText, claudeSubprocessEnv } = require("./auth-flow");
14
+
15
+ function shellQuote(value) {
16
+ return `"${String(value).replace(/"/g, '\\"')}"`;
17
+ }
18
+
19
+ function runCommandForDoctor(command, args = [], opts = {}) {
20
+ try {
21
+ const out = execSync([command, ...args].map(shellQuote).join(" "), {
22
+ cwd: opts.cwd || process.env.HOME || require("os").homedir(),
23
+ env: opts.env || botSubprocessEnv(),
24
+ encoding: "utf-8",
25
+ timeout: opts.timeout || 10000,
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ });
28
+ return { ok: true, output: out.trim(), code: 0 };
29
+ } catch (e) {
30
+ return { ok: false, output: `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim(), code: e.status ?? 1 };
31
+ }
32
+ }
33
+
34
+ function binaryCheck(binPath, name) {
35
+ if (!binPath) return { ok: false, label: name, detail: "not configured/found" };
36
+ try {
37
+ if (fs.existsSync(binPath)) fs.accessSync(binPath, fs.constants.X_OK);
38
+ else execSync(process.platform === "win32" ? `where ${shellQuote(binPath)}` : `which ${shellQuote(binPath)}`, { stdio: "ignore", env: botSubprocessEnv() });
39
+ return { ok: true, label: name, detail: binPath };
40
+ } catch (e) {
41
+ return { ok: false, label: name, detail: `not executable: ${binPath}` };
42
+ }
43
+ }
44
+
45
+ function checkWritableDir(dirPath, label) {
46
+ if (!dirPath) return { ok: false, label, detail: "not configured" };
47
+ try {
48
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
49
+ const test = path.join(dirPath, `.open-claudia-write-test-${Date.now()}`);
50
+ fs.writeFileSync(test, "ok");
51
+ fs.unlinkSync(test);
52
+ return { ok: true, label, detail: dirPath };
53
+ } catch (e) {
54
+ return { ok: false, label, detail: redactSensitive(e.message) };
55
+ }
56
+ }
57
+
58
+ function summarize(output, ok) {
59
+ const clean = redactSensitive(stripTerminalControls(output || "")).replace(/\s+/g, " ").trim();
60
+ if (!clean) return ok ? "ok" : "no output";
61
+ return clean.length > 160 ? clean.slice(0, 157) + "..." : clean;
62
+ }
63
+
64
+ function runDoctorChecks() {
65
+ const checks = [];
66
+ const nodeMajor = parseInt(process.version.slice(1).split(".")[0], 10);
67
+ checks.push({ ok: nodeMajor >= 18, label: "Node.js", detail: process.version, action: nodeMajor >= 18 ? "" : "Install Node.js 18+." });
68
+
69
+ const claudeBin = binaryCheck(CLAUDE_PATH, "Claude CLI");
70
+ if (claudeBin.ok) {
71
+ const ver = runCommandForDoctor(CLAUDE_PATH, ["--version"]);
72
+ const auth = runCommandForDoctor(CLAUDE_PATH, ["auth", "status"], { env: claudeSubprocessEnv(), timeout: 12000 });
73
+ checks.push({ ok: ver.ok, label: "Claude version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CLAUDE_PATH." });
74
+ checks.push({ ok: auth.ok && !isClaudeAuthErrorText(auth.output), label: "Claude auth", detail: summarize(auth.output, auth.ok), action: auth.ok && !isClaudeAuthErrorText(auth.output) ? "" : "Run /auth_status then /setup_token or /login." });
75
+ } else checks.push({ ...claudeBin, action: "Install @anthropic-ai/claude-code or fix CLAUDE_PATH." });
76
+
77
+ if (CURSOR_PATH || resolvedCursorPath) {
78
+ const cursorPath = resolvedCursorPath || CURSOR_PATH;
79
+ const cursorBin = binaryCheck(cursorPath, "Cursor Agent");
80
+ if (cursorBin.ok) {
81
+ const ver = runCommandForDoctor(cursorPath, ["--version"]);
82
+ const status = runCommandForDoctor(cursorPath, ["status"], { timeout: 12000 });
83
+ checks.push({ ok: ver.ok, label: "Cursor version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CURSOR_PATH." });
84
+ checks.push({ ok: status.ok, label: "Cursor auth/status", detail: summarize(status.output, status.ok), action: status.ok ? "" : "Run `agent login` on this host." });
85
+ } else checks.push({ ...cursorBin, action: "Install Cursor Agent or fix CURSOR_PATH." });
86
+ } else checks.push({ ok: true, warn: true, label: "Cursor Agent", detail: "not configured/found (optional)", action: "Install only if you want /cursor." });
87
+
88
+ if (CODEX_PATH || resolvedCodexPath) {
89
+ const codexPath = resolvedCodexPath || CODEX_PATH;
90
+ const codexBin = binaryCheck(codexPath, "Codex CLI");
91
+ if (codexBin.ok) {
92
+ const ver = runCommandForDoctor(codexPath, ["--version"]);
93
+ const status = runCommandForDoctor(codexPath, ["login", "status"], { timeout: 12000 });
94
+ checks.push({ ok: ver.ok, label: "Codex version", detail: summarize(ver.output, ver.ok), action: ver.ok ? "" : "Check CODEX_PATH." });
95
+ checks.push({ ok: status.ok && !isCodexAuthErrorText(status.output), label: "Codex auth", detail: summarize(status.output, status.ok), action: status.ok && !isCodexAuthErrorText(status.output) ? "" : "Run /codex_login or /codex_setup_token." });
96
+ } else checks.push({ ...codexBin, action: "Install @openai/codex or fix CODEX_PATH." });
97
+ } else checks.push({ ok: true, warn: true, label: "Codex CLI", detail: "not configured/found (optional)", action: "Install only if you want /codex." });
98
+
99
+ if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
100
+ const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
101
+ const wh = binaryCheck(WHISPER_CLI, "Whisper CLI");
102
+ checks.push({ ...ff, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG." });
103
+ checks.push({ ...wh, action: wh.ok ? "" : "Install whisper.cpp or set WHISPER_CLI." });
104
+ checks.push({ ok: !!WHISPER_MODEL && fs.existsSync(WHISPER_MODEL), label: "Whisper model", detail: WHISPER_MODEL || "not configured", action: WHISPER_MODEL ? "Check model path." : "Set WHISPER_MODEL for voice notes." });
105
+ } else checks.push({ ok: true, warn: true, label: "Voice stack", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for voice notes." });
106
+
107
+ checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
108
+ checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
109
+ return checks;
110
+ }
111
+
112
+ function formatDoctorReport(checks) {
113
+ const hardProblems = checks.filter((c) => !c.ok && !c.warn);
114
+ const warnings = checks.filter((c) => c.warn || (!c.ok && c.warn));
115
+ const lines = [hardProblems.length ? "Open Claudia doctor found issues" : "Open Claudia doctor looks good", ""];
116
+ for (const c of checks) {
117
+ const icon = c.ok ? (c.warn ? "[!]" : "[ok]") : "[!]";
118
+ lines.push(`${icon} ${c.label}: ${c.detail || (c.ok ? "ok" : "issue")}`);
119
+ }
120
+ const actions = checks.filter((c) => (!c.ok || c.warn) && c.action).map((c) => `- ${c.label}: ${c.action}`);
121
+ if (actions.length) lines.push("", "Next actions:", ...actions.slice(0, 8));
122
+ if (warnings.length && !hardProblems.length) lines[0] = "Core requirements pass (optional items noted)";
123
+ return lines.join("\n");
124
+ }
125
+
126
+ module.exports = { runDoctorChecks, formatDoctorReport };