niahere 0.2.29 → 0.2.31

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.29",
3
+ "version": "0.2.31",
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": {
package/src/cli/index.ts CHANGED
@@ -424,6 +424,38 @@ switch (command) {
424
424
  process.exit(result.ok ? 0 : 1);
425
425
  }
426
426
 
427
+ case "update": {
428
+ const { version: currentVersion } = await import("../../package.json");
429
+ console.log(`Current: v${currentVersion}`);
430
+ console.log("Updating...");
431
+ const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
432
+ const installExit = await install.exited;
433
+ if (installExit !== 0) {
434
+ fail("Update failed.");
435
+ }
436
+ // Get new version
437
+ const check = Bun.spawn(["npm", "view", "niahere", "version"], { stdout: "pipe", stderr: "pipe" });
438
+ const newVersion = (await new Response(check.stdout).text()).trim();
439
+ await check.exited;
440
+ if (newVersion === currentVersion) {
441
+ console.log("Already on latest.");
442
+ } else {
443
+ console.log(`Updated: v${currentVersion} → v${newVersion}`);
444
+ if (isRunning()) {
445
+ console.log("Restarting daemon...");
446
+ const { isServiceInstalled, restartService } = await import("../commands/service");
447
+ if (isServiceInstalled()) {
448
+ await restartService();
449
+ } else {
450
+ stopDaemon();
451
+ startDaemon();
452
+ }
453
+ console.log("Restarted.");
454
+ }
455
+ }
456
+ break;
457
+ }
458
+
427
459
  case "init": {
428
460
  const { runInit } = await import("../commands/init");
429
461
  await runInit();
@@ -432,6 +464,7 @@ switch (command) {
432
464
 
433
465
  default:
434
466
  console.log("Usage: nia <command>\n");
467
+ console.log(" update — update to latest version and restart");
435
468
  console.log(" init — setup nia");
436
469
  console.log(" start / stop — daemon + service control");
437
470
  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,28 +1,17 @@
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
- const RECOVERY_THRESHOLD = 30; // 30 consecutive failures = ~30 min
7
7
 
8
8
  let timer: ReturnType<typeof setInterval> | null = null;
9
- let consecutiveFailures = 0;
9
+ let lastFailures: string[] = [];
10
10
  let recoveryAttempted = false;
11
11
 
12
- async function checkDb(): Promise<boolean> {
13
- try {
14
- const sql = getSql();
15
- await sql`SELECT 1`;
16
- return true;
17
- } catch {
18
- return false;
19
- }
20
- }
21
-
22
- async function attemptReconnect(): Promise<boolean> {
12
+ async function attemptDbReconnect(): Promise<boolean> {
23
13
  try {
24
14
  await closeDb();
25
- // getSql() will create a fresh connection on next call
26
15
  const sql = getSql();
27
16
  await sql`SELECT 1`;
28
17
  return true;
@@ -35,7 +24,6 @@ async function attemptReconnect(): Promise<boolean> {
35
24
  async function notifyUser(message: string): Promise<void> {
36
25
  const config = getConfig();
37
26
 
38
- // Try Telegram first
39
27
  const tgToken = config.channels.telegram.bot_token;
40
28
  const tgChatId = config.channels.telegram.chat_id;
41
29
  if (tgToken && tgChatId) {
@@ -50,7 +38,6 @@ async function notifyUser(message: string): Promise<void> {
50
38
  }
51
39
  }
52
40
 
53
- // Fall back to Slack
54
41
  const slToken = config.channels.slack.bot_token;
55
42
  const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
56
43
  if (slToken && slRecipient) {
@@ -72,37 +59,39 @@ async function notifyUser(message: string): Promise<void> {
72
59
  log.error("alive: could not notify user — no channel available");
73
60
  }
74
61
 
75
- /** Layer 1: Run an LLM recovery agent to diagnose and fix the issue. */
76
- 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 }> {
77
64
  try {
78
65
  const { runJobWithClaude } = await import("./runner");
79
66
  const { homedir } = await import("os");
80
67
 
68
+ const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
69
+
81
70
  const systemPrompt = [
82
71
  "You are a system recovery agent for the Nia daemon.",
83
- "The database connection has been failing for 30+ minutes.",
84
- "Your job: diagnose the issue, attempt to fix it, and report the outcome.",
72
+ "Health checks detected failures. Diagnose and fix what you can.",
85
73
  "",
86
- "Steps:",
87
- "1. Check if PostgreSQL is running: pg_isready, brew services list (macOS), systemctl status postgresql (Linux)",
88
- "2. If stopped, try to start it: brew services start postgresql@17 (macOS) or systemctl start postgresql (Linux)",
89
- "3. Wait a few seconds, then verify connectivity: psql -d niahere -c 'SELECT 1'",
90
- "4. If it's a different issue (disk space, permissions, etc), diagnose and report",
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'",
78
+ "",
79
+ "For other issues: diagnose, attempt fix if safe, report findings.",
91
80
  "",
92
81
  "Respond with a brief postmortem:",
93
82
  "- What was wrong",
94
- "- What you did to fix it",
95
- "- Whether it's recovered or still failing",
96
- "- Any recommendations",
83
+ "- What you did",
84
+ "- Current status",
97
85
  ].join("\n");
98
86
 
99
- const jobPrompt = `Database has been unreachable for 30+ minutes.\nLast error: ${error}\n\nDiagnose and fix if possible.`;
87
+ const jobPrompt = `Health check failures:\n${failureSummary}\n\nDiagnose and fix.`;
100
88
 
101
89
  const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
102
- const recovered = await checkDb();
103
90
 
91
+ // Re-check after recovery attempt
92
+ const remaining = await getFailures();
104
93
  return {
105
- recovered,
94
+ recovered: remaining.length === 0,
106
95
  report: result.agentText || "Recovery agent returned no output.",
107
96
  };
108
97
  } catch (err) {
@@ -114,61 +103,64 @@ async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; re
114
103
  }
115
104
 
116
105
  async function heartbeat(): Promise<void> {
117
- const ok = await checkDb();
118
-
119
- if (ok) {
120
- if (consecutiveFailures > 0) {
121
- log.info({ previousFailures: consecutiveFailures }, "alive: database recovered");
122
- if (consecutiveFailures >= RECOVERY_THRESHOLD) {
123
- await notifyUser(`Database recovered after ${consecutiveFailures} minutes of downtime.`);
124
- }
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.`);
125
114
  }
126
- consecutiveFailures = 0;
115
+ lastFailures = [];
127
116
  recoveryAttempted = false;
128
117
  return;
129
118
  }
130
119
 
131
- consecutiveFailures++;
132
- 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
+ }
133
125
 
134
- // Try reconnect on every failure
135
- const reconnected = await attemptReconnect();
136
- if (reconnected) {
137
- log.info("alive: reconnected to database");
138
- consecutiveFailures = 0;
139
- recoveryAttempted = false;
140
- return;
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
+ }
138
+ }
141
139
  }
142
140
 
143
- // After threshold, trigger recovery (once)
144
- if (consecutiveFailures >= RECOVERY_THRESHOLD && !recoveryAttempted) {
141
+ // Run recovery agent once per outage
142
+ if (!recoveryAttempted) {
145
143
  recoveryAttempted = true;
146
- log.info("alive: triggering recovery after " + consecutiveFailures + " failures");
144
+ log.info({ failures: failureNames }, "alive: running recovery agent");
147
145
 
148
- // Layer 1: LLM recovery agent
149
- const lastError = "PostgreSQL unreachable after " + consecutiveFailures + " consecutive heartbeat failures";
150
- const { recovered, report } = await runRecoveryAgent(lastError);
146
+ const { recovered, report } = await runRecoveryAgent(failures);
151
147
 
152
148
  if (recovered) {
153
- log.info("alive: recovery agent succeeded");
154
- await notifyUser(`Database was down for ~${consecutiveFailures} min. Recovery agent fixed it.\n\n${report}`);
155
- consecutiveFailures = 0;
149
+ log.info({ report }, "alive: recovery agent succeeded");
150
+ await notifyUser(report);
151
+ lastFailures = [];
156
152
  recoveryAttempted = false;
157
153
  } else {
158
- // Layer 2: Direct notification
159
- log.error("alive: recovery agent failed, notifying user");
160
- await notifyUser(
161
- `Database has been down for ~${consecutiveFailures} min and auto-recovery failed.\n\n` +
162
- `Recovery report:\n${report}\n\n` +
163
- `Run \`nia health\` to check status. You may need to restart PostgreSQL manually.`
164
- );
154
+ log.error({ report }, "alive: recovery failed, notifying user");
155
+ await notifyUser(report);
165
156
  }
166
157
  }
158
+
159
+ lastFailures = failureNames;
167
160
  }
168
161
 
169
162
  export function startAlive(): void {
170
163
  log.info("alive started (60s heartbeat)");
171
- // Initial check after a short delay (let startup finish)
172
164
  setTimeout(heartbeat, 10_000);
173
165
  timer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
174
166
  }
@@ -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
+ }