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 +1 -1
- package/src/chat/identity.ts +7 -74
- package/src/cli/index.ts +45 -5
- package/src/commands/health.ts +3 -102
- package/src/core/alive.ts +56 -63
- package/src/core/health.ts +145 -0
- package/src/core/skills.ts +67 -0
package/package.json
CHANGED
package/src/chat/identity.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
2
|
-
import { join
|
|
3
|
-
import {
|
|
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 {
|
|
5
|
+
import { getSkillsSummary } from "../core/skills";
|
|
8
6
|
import type { Mode } from "../types";
|
|
9
7
|
|
|
10
|
-
//
|
|
11
|
-
|
|
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 =
|
|
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 {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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");
|
package/src/commands/health.ts
CHANGED
|
@@ -1,107 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
9
|
+
let lastFailures: string[] = [];
|
|
9
10
|
let recoveryAttempted = false;
|
|
10
11
|
|
|
11
|
-
async function
|
|
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
|
|
74
|
-
async function runRecoveryAgent(
|
|
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
|
-
"
|
|
82
|
-
"
|
|
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
|
-
"
|
|
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
|
|
93
|
-
"-
|
|
94
|
-
"- Any recommendations",
|
|
83
|
+
"- What you did",
|
|
84
|
+
"- Current status",
|
|
95
85
|
].join("\n");
|
|
96
86
|
|
|
97
|
-
const jobPrompt = `
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
115
|
+
lastFailures = [];
|
|
123
116
|
recoveryAttempted = false;
|
|
124
117
|
return;
|
|
125
118
|
}
|
|
126
119
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
141
|
+
// Run recovery agent once per outage
|
|
143
142
|
if (!recoveryAttempted) {
|
|
144
143
|
recoveryAttempted = true;
|
|
145
|
-
log.info(
|
|
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(
|
|
154
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
+
}
|