niahere 0.2.30 → 0.2.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,14 +1,12 @@
1
- import { existsSync, readFileSync, readdirSync } from "fs";
2
- import { join, resolve } from "path";
3
- import { homedir } from "os";
4
- import yaml from "js-yaml";
5
- import { getNiaHome, getPaths } from "../utils/paths";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getPaths } from "../utils/paths";
6
4
  import { getEnvironmentPrompt, getModePrompt, getChannelPrompt } from "../prompts";
7
- import { log } from "../utils/log";
5
+ import { getSkillsSummary } from "../core/skills";
8
6
  import type { Mode } from "../types";
9
7
 
10
- // niahere project root (resolved from this file's location)
11
- const PROJECT_ROOT = resolve(import.meta.dir, "../..");
8
+ // Re-export for backwards compat
9
+ export { scanSkills as loadSkills, getSkillNames as loadSkillNames, type SkillInfo } from "../core/skills";
12
10
 
13
11
  function loadFile(dir: string, name: string): string {
14
12
  const filePath = join(dir, name);
@@ -22,71 +20,6 @@ export function loadIdentity(): string {
22
20
  return files.map((f) => loadFile(selfDir, f)).filter(Boolean).join("\n\n");
23
21
  }
24
22
 
25
- function scanSkills(): { name: string; description: string }[] {
26
- const home = homedir();
27
- const cwd = process.cwd();
28
- const niaHome = getNiaHome();
29
- const skillDirs = [
30
- join(cwd, "skills"),
31
- join(PROJECT_ROOT, "skills"),
32
- join(niaHome, "skills"),
33
- join(home, ".shared", "skills"),
34
- join(home, ".claude", "skills"),
35
- join(home, ".codex", "skills"),
36
- ];
37
-
38
- const skills: { name: string; description: string }[] = [];
39
- const seen = new Set<string>();
40
-
41
- for (const dir of skillDirs) {
42
- if (!existsSync(dir)) continue;
43
-
44
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
46
-
47
- const skillFile = join(dir, entry.name, "SKILL.md");
48
- if (!existsSync(skillFile)) continue;
49
-
50
- const content = readFileSync(skillFile, "utf8");
51
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
52
- if (!fmMatch) continue;
53
-
54
- let meta: Record<string, unknown> = {};
55
- try {
56
- meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
57
- } catch (err) {
58
- log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
59
- continue;
60
- }
61
- const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
62
-
63
- if (seen.has(name)) continue;
64
- seen.add(name);
65
-
66
- skills.push({
67
- name,
68
- description: typeof meta.description === "string" ? meta.description : "",
69
- });
70
- }
71
- }
72
-
73
- return skills;
74
- }
75
-
76
- export function loadSkillNames(): string[] {
77
- return scanSkills().map((s) => s.name);
78
- }
79
-
80
- export function loadSkillsSummary(): string {
81
- const skills = scanSkills();
82
- if (skills.length === 0) return "";
83
-
84
- const lines = skills.map((s) =>
85
- s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`,
86
- );
87
- return `Available skills:\n${lines.join("\n")}`;
88
- }
89
-
90
23
  export function buildSystemPrompt(mode: Mode = "chat", channel: string = "terminal"): string {
91
24
  const parts: string[] = [];
92
25
 
@@ -101,7 +34,7 @@ export function buildSystemPrompt(mode: Mode = "chat", channel: string = "termin
101
34
  const channelPrompt = getChannelPrompt(channel);
102
35
  if (channelPrompt) parts.push(channelPrompt);
103
36
 
104
- const skills = loadSkillsSummary();
37
+ const skills = getSkillsSummary();
105
38
  if (skills) parts.push(skills);
106
39
 
107
40
  return parts.join("\n\n");
package/src/cli/index.ts CHANGED
@@ -287,12 +287,19 @@ switch (command) {
287
287
  }
288
288
 
289
289
  case "skills": {
290
- const { loadSkillNames } = await import("../chat/identity");
291
- const names = loadSkillNames();
292
- if (names.length === 0) {
293
- console.log("No skills found.");
290
+ const { scanSkills: loadSkills } = await import("../core/skills");
291
+ const filter = process.argv[3]; // e.g. "project", "nia", "shared", "claude"
292
+ let skills = loadSkills();
293
+ if (filter) {
294
+ skills = skills.filter((s) => s.source === filter);
295
+ }
296
+ if (skills.length === 0) {
297
+ console.log(filter ? `No skills found in "${filter}".` : "No skills found.");
294
298
  } else {
295
- for (const name of names) console.log(` ${name}`);
299
+ for (const s of skills) {
300
+ const tag = filter ? "" : ` [${s.source}]`;
301
+ console.log(` ${s.name}${tag}`);
302
+ }
296
303
  }
297
304
  break;
298
305
  }
@@ -424,6 +431,38 @@ switch (command) {
424
431
  process.exit(result.ok ? 0 : 1);
425
432
  }
426
433
 
434
+ case "update": {
435
+ const { version: currentVersion } = await import("../../package.json");
436
+ console.log(`Current: v${currentVersion}`);
437
+ console.log("Updating...");
438
+ const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
439
+ const installExit = await install.exited;
440
+ if (installExit !== 0) {
441
+ fail("Update failed.");
442
+ }
443
+ // Get new version
444
+ const check = Bun.spawn(["npm", "view", "niahere", "version"], { stdout: "pipe", stderr: "pipe" });
445
+ const newVersion = (await new Response(check.stdout).text()).trim();
446
+ await check.exited;
447
+ if (newVersion === currentVersion) {
448
+ console.log("Already on latest.");
449
+ } else {
450
+ console.log(`Updated: v${currentVersion} → v${newVersion}`);
451
+ if (isRunning()) {
452
+ console.log("Restarting daemon...");
453
+ const { isServiceInstalled, restartService } = await import("../commands/service");
454
+ if (isServiceInstalled()) {
455
+ await restartService();
456
+ } else {
457
+ stopDaemon();
458
+ startDaemon();
459
+ }
460
+ console.log("Restarted.");
461
+ }
462
+ }
463
+ break;
464
+ }
465
+
427
466
  case "init": {
428
467
  const { runInit } = await import("../commands/init");
429
468
  await runInit();
@@ -432,6 +471,7 @@ switch (command) {
432
471
 
433
472
  default:
434
473
  console.log("Usage: nia <command>\n");
474
+ console.log(" update — update to latest version and restart");
435
475
  console.log(" init — setup nia");
436
476
  console.log(" start / stop — daemon + service control");
437
477
  console.log(" restart — restart daemon");
@@ -1,107 +1,6 @@
1
- import { existsSync, statSync } from "fs";
2
- import { join } from "path";
3
- import { isRunning, readPid } from "../core/daemon";
4
- import { getConfig, readRawConfig } from "../utils/config";
5
- import { getPaths } from "../utils/paths";
6
- import { errMsg } from "../utils/errors";
7
- import { localTime } from "../utils/time";
8
-
9
- type Check = { name: string; status: "ok" | "warn" | "fail"; detail: string };
10
-
11
- function push(checks: Check[], name: string, status: Check["status"], detail: string): void {
12
- checks.push({ name, status, detail });
13
- }
1
+ import { runHealthChecks } from "../core/health";
14
2
 
15
3
  export async function healthCommand(): Promise<void> {
16
- const checks: Check[] = [];
17
- const paths = getPaths();
18
-
19
- // 0. Version
20
- const { version } = await import("../../package.json");
21
- push(checks, "nia", "ok", "v" + version);
22
-
23
- // 1. Daemon
24
- const pid = readPid();
25
- if (isRunning()) {
26
- push(checks, "daemon", "ok", "running (pid: " + pid + ")");
27
- } else if (pid) {
28
- push(checks, "daemon", "fail", "stale pid file (pid: " + pid + ", not running)");
29
- } else {
30
- push(checks, "daemon", "warn", "not running");
31
- }
32
-
33
- // 2. Config
34
- if (existsSync(paths.config)) {
35
- const raw = readRawConfig();
36
- push(checks, "config", "ok", Object.keys(raw).length + " keys loaded");
37
- } else {
38
- push(checks, "config", "fail", "missing (" + paths.config + ")");
39
- }
40
-
41
- // 3. Database
42
- try {
43
- const config = getConfig();
44
- if (!config.database_url || !config.database_url.startsWith("postgres")) {
45
- push(checks, "database", "fail", 'invalid url: "' + (config.database_url || "(empty)") + '"');
46
- } else {
47
- const { checkDbHealth } = await import("./health-db");
48
- const ok = await checkDbHealth(config.database_url);
49
- push(checks, "database", ok ? "ok" : "fail", config.database_url.replace(/\/\/.*@/, "//***@"));
50
- }
51
- } catch (err) {
52
- push(checks, "database", "fail", errMsg(err));
53
- }
54
-
55
- // 4. Channels
56
- const config = getConfig();
57
- if (!config.channels.enabled) {
58
- push(checks, "channels", "warn", "disabled");
59
- } else {
60
- const chans: string[] = [];
61
- if (config.channels.telegram.bot_token) chans.push("telegram");
62
- if (config.channels.slack.bot_token && config.channels.slack.app_token) chans.push("slack");
63
- if (chans.length > 0) {
64
- push(checks, "channels", "ok", "configured: " + chans.join(", "));
65
- } else {
66
- push(checks, "channels", "warn", "enabled but no tokens configured");
67
- }
68
- }
69
-
70
- // 5. API keys
71
- const geminiKey = config.gemini_api_key;
72
- const rawConfig = readRawConfig();
73
- const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
74
- const apiKeys: string[] = [];
75
- if (geminiKey) apiKeys.push("gemini");
76
- if (openaiKey) apiKeys.push("openai");
77
- push(checks, "api keys", apiKeys.length > 0 ? "ok" : "warn",
78
- apiKeys.length > 0 ? apiKeys.join(", ") : "none configured");
79
-
80
- // 6. Persona files
81
- const personaFiles = ["identity.md", "owner.md", "soul.md"];
82
- const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
83
- if (missing.length === 0) {
84
- push(checks, "persona", "ok", "all files present");
85
- } else {
86
- push(checks, "persona", "warn", "missing: " + missing.join(", "));
87
- }
88
-
89
- // 7. Daemon log
90
- if (existsSync(paths.daemonLog)) {
91
- const stat = statSync(paths.daemonLog);
92
- const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
93
- const lastMod = localTime(stat.mtime);
94
- push(checks, "logs", stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
95
- sizeMb + " MB, last write: " + lastMod);
96
- } else {
97
- push(checks, "logs", "warn", "no log file");
98
- }
99
-
100
- // 8. Bun version
101
- const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
102
- push(checks, "bun", "ok", "v" + bunVersion);
103
-
104
- // Output
105
4
  const { GREEN, YELLOW, RED, RESET, ICON_PASS, ICON_FAIL, ICON_WARN } = await import("../utils/cli");
106
5
  const icons: Record<string, string> = {
107
6
  ok: GREEN + ICON_PASS + RESET,
@@ -109,6 +8,8 @@ export async function healthCommand(): Promise<void> {
109
8
  fail: RED + ICON_FAIL + RESET,
110
9
  };
111
10
 
11
+ const checks = await runHealthChecks();
12
+
112
13
  console.log();
113
14
  for (const c of checks) {
114
15
  console.log(" " + icons[c.status] + " " + c.name.padEnd(12) + " " + c.detail);
package/src/core/alive.ts CHANGED
@@ -1,24 +1,15 @@
1
1
  import { log } from "../utils/log";
2
2
  import { getConfig } from "../utils/config";
3
3
  import { getSql, closeDb } from "../db/connection";
4
+ import { getFailures, type Check } from "./health";
4
5
 
5
6
  const HEARTBEAT_INTERVAL = 60_000; // 60s
6
7
 
7
8
  let timer: ReturnType<typeof setInterval> | null = null;
8
- let consecutiveFailures = 0;
9
+ let lastFailures: string[] = [];
9
10
  let recoveryAttempted = false;
10
11
 
11
- async function checkDb(): Promise<boolean> {
12
- try {
13
- const sql = getSql();
14
- await sql`SELECT 1`;
15
- return true;
16
- } catch {
17
- return false;
18
- }
19
- }
20
-
21
- async function attemptReconnect(): Promise<boolean> {
12
+ async function attemptDbReconnect(): Promise<boolean> {
22
13
  try {
23
14
  await closeDb();
24
15
  const sql = getSql();
@@ -33,7 +24,6 @@ async function attemptReconnect(): Promise<boolean> {
33
24
  async function notifyUser(message: string): Promise<void> {
34
25
  const config = getConfig();
35
26
 
36
- // Try Telegram first
37
27
  const tgToken = config.channels.telegram.bot_token;
38
28
  const tgChatId = config.channels.telegram.chat_id;
39
29
  if (tgToken && tgChatId) {
@@ -48,7 +38,6 @@ async function notifyUser(message: string): Promise<void> {
48
38
  }
49
39
  }
50
40
 
51
- // Fall back to Slack
52
41
  const slToken = config.channels.slack.bot_token;
53
42
  const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
54
43
  if (slToken && slRecipient) {
@@ -70,37 +59,39 @@ async function notifyUser(message: string): Promise<void> {
70
59
  log.error("alive: could not notify user — no channel available");
71
60
  }
72
61
 
73
- /** Run an LLM recovery agent to diagnose and fix the issue. */
74
- async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; report: string }> {
62
+ /** Run LLM recovery agent for failures it can fix (e.g. DB down). */
63
+ async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean; report: string }> {
75
64
  try {
76
65
  const { runJobWithClaude } = await import("./runner");
77
66
  const { homedir } = await import("os");
78
67
 
68
+ const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
69
+
79
70
  const systemPrompt = [
80
71
  "You are a system recovery agent for the Nia daemon.",
81
- "The database connection just failed and reconnect didn't work.",
82
- "Your job: diagnose the issue, attempt to fix it, and report the outcome.",
72
+ "Health checks detected failures. Diagnose and fix what you can.",
73
+ "",
74
+ "For database issues:",
75
+ "1. Check: pg_isready, brew services list (macOS), systemctl status postgresql (Linux)",
76
+ "2. Fix: brew services start postgresql@17 (macOS) or systemctl start postgresql (Linux)",
77
+ "3. Verify: psql -d niahere -c 'SELECT 1'",
83
78
  "",
84
- "Steps:",
85
- "1. Check if PostgreSQL is running: pg_isready, brew services list (macOS), systemctl status postgresql (Linux)",
86
- "2. If stopped, try to start it: brew services start postgresql@17 (macOS) or systemctl start postgresql (Linux)",
87
- "3. Wait a few seconds, then verify connectivity: psql -d niahere -c 'SELECT 1'",
88
- "4. If it's a different issue (disk space, permissions, etc), diagnose and report",
79
+ "For other issues: diagnose, attempt fix if safe, report findings.",
89
80
  "",
90
81
  "Respond with a brief postmortem:",
91
82
  "- What was wrong",
92
- "- What you did to fix it",
93
- "- Whether it's recovered or still failing",
94
- "- Any recommendations",
83
+ "- What you did",
84
+ "- Current status",
95
85
  ].join("\n");
96
86
 
97
- const jobPrompt = `Database is unreachable. Reconnect failed.\nError: ${error}\n\nDiagnose and fix.`;
87
+ const jobPrompt = `Health check failures:\n${failureSummary}\n\nDiagnose and fix.`;
98
88
 
99
89
  const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
100
- const recovered = await checkDb();
101
90
 
91
+ // Re-check after recovery attempt
92
+ const remaining = await getFailures();
102
93
  return {
103
- recovered,
94
+ recovered: remaining.length === 0,
104
95
  report: result.agentText || "Recovery agent returned no output.",
105
96
  };
106
97
  } catch (err) {
@@ -112,58 +103,60 @@ async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; re
112
103
  }
113
104
 
114
105
  async function heartbeat(): Promise<void> {
115
- const ok = await checkDb();
116
-
117
- if (ok) {
118
- if (consecutiveFailures > 0) {
119
- log.info({ previousFailures: consecutiveFailures }, "alive: database recovered");
120
- await notifyUser(`Database recovered after ~${consecutiveFailures} min of downtime.`);
106
+ const failures = await getFailures();
107
+ const failureNames = failures.map((f) => f.name);
108
+
109
+ // All clear
110
+ if (failures.length === 0) {
111
+ if (lastFailures.length > 0) {
112
+ log.info({ recovered: lastFailures }, "alive: all checks passing");
113
+ await notifyUser(`Recovered: ${lastFailures.join(", ")} back to normal.`);
121
114
  }
122
- consecutiveFailures = 0;
115
+ lastFailures = [];
123
116
  recoveryAttempted = false;
124
117
  return;
125
118
  }
126
119
 
127
- consecutiveFailures++;
128
- log.warn({ consecutiveFailures }, "alive: database unreachable");
120
+ // New failures detected
121
+ const newFailures = failureNames.filter((f) => !lastFailures.includes(f));
122
+ if (newFailures.length > 0) {
123
+ log.warn({ failures: failureNames }, "alive: health check failures detected");
124
+ }
129
125
 
130
- // Try reconnect
131
- const reconnected = await attemptReconnect();
132
- if (reconnected) {
133
- log.info("alive: reconnected to database");
134
- if (consecutiveFailures > 1) {
135
- await notifyUser(`Database reconnected after ~${consecutiveFailures} min.`);
126
+ // Try DB reconnect if database is failing
127
+ if (failureNames.includes("database")) {
128
+ const reconnected = await attemptDbReconnect();
129
+ if (reconnected) {
130
+ log.info("alive: database reconnected");
131
+ // Re-check everything
132
+ const remaining = await getFailures();
133
+ if (remaining.length === 0) {
134
+ lastFailures = [];
135
+ recoveryAttempted = false;
136
+ return;
137
+ }
136
138
  }
137
- consecutiveFailures = 0;
138
- recoveryAttempted = false;
139
- return;
140
139
  }
141
140
 
142
- // Reconnect failed — run recovery agent immediately (once per outage)
141
+ // Run recovery agent once per outage
143
142
  if (!recoveryAttempted) {
144
143
  recoveryAttempted = true;
145
- log.info("alive: reconnect failed, running recovery agent");
144
+ log.info({ failures: failureNames }, "alive: running recovery agent");
146
145
 
147
- const { recovered, report } = await runRecoveryAgent(
148
- "PostgreSQL unreachable, reconnect failed after " + consecutiveFailures + " heartbeat(s)"
149
- );
146
+ const { recovered, report } = await runRecoveryAgent(failures);
150
147
 
151
148
  if (recovered) {
152
- log.info("alive: recovery agent succeeded");
153
- await notifyUser(`Database was down. Recovery agent fixed it.\n\n${report}`);
154
- consecutiveFailures = 0;
149
+ log.info({ report }, "alive: recovery agent succeeded");
150
+ await notifyUser(report);
151
+ lastFailures = [];
155
152
  recoveryAttempted = false;
156
153
  } else {
157
- log.error("alive: recovery failed, notifying user");
158
- await notifyUser(
159
- `Database is down and auto-recovery failed.\n\n` +
160
- `Recovery report:\n${report}\n\n` +
161
- `Run \`nia health\` to check status.`
162
- );
154
+ log.error({ report }, "alive: recovery failed, notifying user");
155
+ await notifyUser(report);
163
156
  }
164
157
  }
165
- // After recovery attempted: just log failures, don't spam user or agent.
166
- // When DB comes back, the ok branch above will notify.
158
+
159
+ lastFailures = failureNames;
167
160
  }
168
161
 
169
162
  export function startAlive(): void {
@@ -0,0 +1,145 @@
1
+ import { existsSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import { getConfig, readRawConfig } from "../utils/config";
4
+ import { getPaths } from "../utils/paths";
5
+ import { isRunning, readPid } from "./daemon";
6
+ import { errMsg } from "../utils/errors";
7
+ import { localTime } from "../utils/time";
8
+
9
+ export type CheckStatus = "ok" | "warn" | "fail";
10
+ export type Check = { name: string; status: CheckStatus; detail: string };
11
+
12
+ /** Run all health checks. Returns structured results usable by CLI and alive monitor. */
13
+ export async function runHealthChecks(): Promise<Check[]> {
14
+ const checks: Check[] = [];
15
+ const paths = getPaths();
16
+ const config = getConfig();
17
+
18
+ // Version
19
+ const { version } = await import("../../package.json");
20
+ checks.push({ name: "nia", status: "ok", detail: "v" + version });
21
+
22
+ // Daemon
23
+ const pid = readPid();
24
+ if (isRunning()) {
25
+ checks.push({ name: "daemon", status: "ok", detail: "running (pid: " + pid + ")" });
26
+ } else if (pid) {
27
+ checks.push({ name: "daemon", status: "fail", detail: "stale pid file (pid: " + pid + ", not running)" });
28
+ } else {
29
+ checks.push({ name: "daemon", status: "warn", detail: "not running" });
30
+ }
31
+
32
+ // Config
33
+ if (existsSync(paths.config)) {
34
+ const raw = readRawConfig();
35
+ checks.push({ name: "config", status: "ok", detail: Object.keys(raw).length + " keys loaded" });
36
+ } else {
37
+ checks.push({ name: "config", status: "fail", detail: "missing (" + paths.config + ")" });
38
+ }
39
+
40
+ // Database
41
+ try {
42
+ if (!config.database_url || !config.database_url.startsWith("postgres")) {
43
+ checks.push({ name: "database", status: "fail", detail: 'invalid url: "' + (config.database_url || "(empty)") + '"' });
44
+ } else {
45
+ const { checkDbHealth } = await import("../commands/health-db");
46
+ const ok = await checkDbHealth(config.database_url);
47
+ checks.push({ name: "database", status: ok ? "ok" : "fail", detail: ok ? "connected" : "unreachable" });
48
+ }
49
+ } catch (err) {
50
+ checks.push({ name: "database", status: "fail", detail: errMsg(err) });
51
+ }
52
+
53
+ // Channels — check actual connectivity, not just config
54
+ if (!config.channels.enabled) {
55
+ checks.push({ name: "channels", status: "warn", detail: "disabled" });
56
+ } else {
57
+ const results: string[] = [];
58
+
59
+ // Telegram
60
+ const tgToken = config.channels.telegram.bot_token;
61
+ if (tgToken) {
62
+ try {
63
+ const resp = await fetch(`https://api.telegram.org/bot${tgToken}/getMe`);
64
+ const data = await resp.json() as { ok: boolean };
65
+ results.push(data.ok ? "telegram: connected" : "telegram: auth failed");
66
+ if (!data.ok) checks.push({ name: "telegram", status: "fail", detail: "auth failed" });
67
+ } catch {
68
+ results.push("telegram: unreachable");
69
+ checks.push({ name: "telegram", status: "fail", detail: "unreachable" });
70
+ }
71
+ }
72
+
73
+ // Slack
74
+ const slToken = config.channels.slack.bot_token;
75
+ if (slToken) {
76
+ try {
77
+ const resp = await fetch("https://slack.com/api/auth.test", {
78
+ method: "POST",
79
+ headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
80
+ });
81
+ const data = await resp.json() as { ok: boolean; error?: string };
82
+ results.push(data.ok ? "slack: connected" : `slack: ${data.error || "auth failed"}`);
83
+ if (!data.ok) checks.push({ name: "slack", status: "fail", detail: data.error || "auth failed" });
84
+ } catch {
85
+ results.push("slack: unreachable");
86
+ checks.push({ name: "slack", status: "fail", detail: "unreachable" });
87
+ }
88
+ }
89
+
90
+ if (results.length === 0) {
91
+ checks.push({ name: "channels", status: "warn", detail: "enabled but no tokens configured" });
92
+ } else {
93
+ const allOk = results.every((r) => r.includes("connected"));
94
+ checks.push({ name: "channels", status: allOk ? "ok" : "warn", detail: results.join(", ") });
95
+ }
96
+ }
97
+
98
+ // API keys
99
+ const geminiKey = config.gemini_api_key;
100
+ const rawConfig = readRawConfig();
101
+ const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
102
+ const apiKeys: string[] = [];
103
+ if (geminiKey) apiKeys.push("gemini");
104
+ if (openaiKey) apiKeys.push("openai");
105
+ checks.push({
106
+ name: "api keys",
107
+ status: apiKeys.length > 0 ? "ok" : "warn",
108
+ detail: apiKeys.length > 0 ? apiKeys.join(", ") : "none configured",
109
+ });
110
+
111
+ // Persona files
112
+ const personaFiles = ["identity.md", "owner.md", "soul.md"];
113
+ const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
114
+ checks.push({
115
+ name: "persona",
116
+ status: missing.length === 0 ? "ok" : "warn",
117
+ detail: missing.length === 0 ? "all files present" : "missing: " + missing.join(", "),
118
+ });
119
+
120
+ // Daemon log
121
+ if (existsSync(paths.daemonLog)) {
122
+ const stat = statSync(paths.daemonLog);
123
+ const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
124
+ const lastMod = localTime(stat.mtime);
125
+ checks.push({
126
+ name: "logs",
127
+ status: stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
128
+ detail: sizeMb + " MB, last write: " + lastMod,
129
+ });
130
+ } else {
131
+ checks.push({ name: "logs", status: "warn", detail: "no log file" });
132
+ }
133
+
134
+ // Bun version
135
+ const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
136
+ checks.push({ name: "bun", status: "ok", detail: "v" + bunVersion });
137
+
138
+ return checks;
139
+ }
140
+
141
+ /** Quick check — returns just the failures. Used by alive monitor. */
142
+ export async function getFailures(): Promise<Check[]> {
143
+ const checks = await runHealthChecks();
144
+ return checks.filter((c) => c.status === "fail");
145
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync, readdirSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { homedir } from "os";
4
+ import yaml from "js-yaml";
5
+ import { getNiaHome } from "../utils/paths";
6
+ import { log } from "../utils/log";
7
+
8
+ // niahere project root (resolved from this file's location)
9
+ const PROJECT_ROOT = resolve(import.meta.dir, "../..");
10
+
11
+ export type SkillInfo = { name: string; description: string; source: string };
12
+
13
+ const SKILL_DIRS: { dir: string; source: string }[] = [
14
+ { dir: join(process.cwd(), "skills"), source: "cwd" },
15
+ { dir: join(PROJECT_ROOT, "skills"), source: "project" },
16
+ { dir: join(getNiaHome(), "skills"), source: "nia" },
17
+ { dir: join(homedir(), ".shared", "skills"), source: "shared" },
18
+ { dir: join(homedir(), ".claude", "skills"), source: "claude" },
19
+ { dir: join(homedir(), ".codex", "skills"), source: "codex" },
20
+ ];
21
+
22
+ export function scanSkills(): SkillInfo[] {
23
+ const skills: SkillInfo[] = [];
24
+ const seen = new Set<string>();
25
+
26
+ for (const { dir, source } of SKILL_DIRS) {
27
+ if (!existsSync(dir)) continue;
28
+
29
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
30
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
31
+
32
+ const skillFile = join(dir, entry.name, "SKILL.md");
33
+ if (!existsSync(skillFile)) continue;
34
+
35
+ const content = readFileSync(skillFile, "utf8");
36
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
37
+ if (!fmMatch) continue;
38
+
39
+ let meta: Record<string, unknown> = {};
40
+ try {
41
+ meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
42
+ } catch (err) {
43
+ log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
44
+ continue;
45
+ }
46
+ const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
47
+
48
+ if (seen.has(name)) continue;
49
+ seen.add(name);
50
+
51
+ skills.push({ name, description: typeof meta.description === "string" ? meta.description : "", source });
52
+ }
53
+ }
54
+
55
+ return skills;
56
+ }
57
+
58
+ export function getSkillNames(): string[] {
59
+ return scanSkills().map((s) => s.name);
60
+ }
61
+
62
+ export function getSkillsSummary(): string {
63
+ const skills = scanSkills();
64
+ if (skills.length === 0) return "";
65
+ const lines = skills.map((s) => s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`);
66
+ return `Available skills:\n${lines.join("\n")}`;
67
+ }