niahere 0.2.27 → 0.2.29
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/channels/slack.ts +47 -50
- package/src/cli/index.ts +7 -0
- package/src/cli/watch.ts +71 -0
- package/src/commands/health.ts +4 -0
- package/src/commands/validate.ts +5 -4
- package/src/core/alive.ts +181 -0
- package/src/core/daemon.ts +5 -0
- package/src/core/runner.ts +1 -1
- package/src/mcp/server.ts +22 -2
- package/src/mcp/tools.ts +29 -5
- package/src/prompts/channel-slack.md +1 -1
- package/src/prompts/environment.md +12 -3
- package/src/types/config.ts +1 -0
- package/src/utils/config.ts +9 -5
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { App } from "@slack/bolt";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { createHash } from "crypto";
|
|
5
5
|
import { createChatEngine } from "../chat/engine";
|
|
6
6
|
import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
|
|
7
|
-
import { getConfig, updateRawConfig } from "../utils/config";
|
|
7
|
+
import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
|
|
8
|
+
import { relativeTime } from "../utils/format";
|
|
8
9
|
import { runMigrations } from "../db/migrate";
|
|
9
10
|
import { Session } from "../db/models";
|
|
10
11
|
import { log } from "../utils/log";
|
|
11
12
|
import { getMcpServers } from "../mcp";
|
|
12
|
-
import { getNiaHome } from "../utils/paths";
|
|
13
|
+
import { getNiaHome, getPaths } from "../utils/paths";
|
|
13
14
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
14
15
|
|
|
15
16
|
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
@@ -132,9 +133,41 @@ class SlackChannel implements Channel {
|
|
|
132
133
|
|
|
133
134
|
let botUserId: string | undefined;
|
|
134
135
|
|
|
135
|
-
// Watch channels:
|
|
136
|
-
//
|
|
137
|
-
|
|
136
|
+
// Watch channels: mtime-based hot-reload from config.yaml
|
|
137
|
+
// Keys are always channel_id#channel_name format
|
|
138
|
+
let watchCache: Map<string, { name: string; behavior: string }> = new Map();
|
|
139
|
+
let watchConfigMtime = 0;
|
|
140
|
+
|
|
141
|
+
function reloadWatchChannels(): Map<string, { name: string; behavior: string }> {
|
|
142
|
+
const configPath = getPaths().config;
|
|
143
|
+
let mtime = 0;
|
|
144
|
+
try { mtime = statSync(configPath).mtimeMs; } catch { return watchCache; }
|
|
145
|
+
if (mtime === watchConfigMtime) return watchCache;
|
|
146
|
+
|
|
147
|
+
watchConfigMtime = mtime;
|
|
148
|
+
resetConfig(); // clear cached config so getConfig() re-reads from disk
|
|
149
|
+
const cfg = getConfig();
|
|
150
|
+
const watch = cfg.channels.slack.watch;
|
|
151
|
+
const fresh = new Map<string, { name: string; behavior: string }>();
|
|
152
|
+
if (watch) {
|
|
153
|
+
for (const [key, entry] of Object.entries(watch)) {
|
|
154
|
+
if (!entry.enabled) continue;
|
|
155
|
+
const hashIdx = key.indexOf("#");
|
|
156
|
+
if (hashIdx === -1) {
|
|
157
|
+
log.warn({ channel: key }, "slack: watch key must use channel_id#name format, skipping");
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const id = key.slice(0, hashIdx);
|
|
161
|
+
const name = key.slice(hashIdx + 1);
|
|
162
|
+
fresh.set(id, { name, behavior: entry.behavior });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (fresh.size !== watchCache.size) {
|
|
166
|
+
log.info({ count: fresh.size }, "slack: watch channels reloaded");
|
|
167
|
+
}
|
|
168
|
+
watchCache = fresh;
|
|
169
|
+
return watchCache;
|
|
170
|
+
}
|
|
138
171
|
|
|
139
172
|
// Slash command: /nia
|
|
140
173
|
app.command("/nia", async ({ command, ack, respond }) => {
|
|
@@ -329,8 +362,9 @@ class SlackChannel implements Channel {
|
|
|
329
362
|
}
|
|
330
363
|
}
|
|
331
364
|
|
|
332
|
-
// Check if this is a watched channel
|
|
333
|
-
const
|
|
365
|
+
// Check if this is a watched channel (hot-reloads from config.yaml via mtime)
|
|
366
|
+
const currentWatch = reloadWatchChannels();
|
|
367
|
+
const watchConfig = currentWatch.get(msg.channel);
|
|
334
368
|
const isWatched = !!watchConfig;
|
|
335
369
|
|
|
336
370
|
if (!isDm && !isMention && !isActiveThread && !isWatched) {
|
|
@@ -403,10 +437,12 @@ class SlackChannel implements Channel {
|
|
|
403
437
|
const priorMessages = (replies.messages || [])
|
|
404
438
|
.filter((m: any) => m.ts !== msg.ts); // exclude the triggering message
|
|
405
439
|
|
|
440
|
+
const now = new Date();
|
|
406
441
|
const threadMessages = priorMessages.map((m: any) => {
|
|
407
442
|
const sender = m.bot_id ? "bot" : (m.user || "unknown");
|
|
408
443
|
const fileHint = m.files?.length ? ` [${m.files.length} file(s) attached]` : "";
|
|
409
|
-
|
|
444
|
+
const age = m.ts ? ` (${relativeTime(new Date(parseFloat(m.ts) * 1000), now)})` : "";
|
|
445
|
+
return `[${sender}]${age}: ${m.text || "(no text)"}${fileHint}`;
|
|
410
446
|
});
|
|
411
447
|
if (threadMessages.length > 0) {
|
|
412
448
|
text = `[Thread context]\n${threadMessages.join("\n")}\n\n[Current message]\n${text}`;
|
|
@@ -508,47 +544,8 @@ class SlackChannel implements Channel {
|
|
|
508
544
|
log.warn({ err }, "could not get slack bot user ID");
|
|
509
545
|
}
|
|
510
546
|
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
if (rawWatchConfig) {
|
|
514
|
-
for (const [key, cfg] of Object.entries(rawWatchConfig)) {
|
|
515
|
-
const hashIdx = key.indexOf("#");
|
|
516
|
-
if (hashIdx !== -1) {
|
|
517
|
-
// channel_id#channel_name format — use ID directly, no API call needed
|
|
518
|
-
const id = key.slice(0, hashIdx);
|
|
519
|
-
const name = key.slice(hashIdx + 1);
|
|
520
|
-
watchChannels.set(id, { name, behavior: cfg.behavior });
|
|
521
|
-
log.info({ channel: name, id }, "slack: watching channel");
|
|
522
|
-
} else {
|
|
523
|
-
// Legacy: plain channel name — resolve via API
|
|
524
|
-
try {
|
|
525
|
-
const channelList: { id: string; name: string }[] = [];
|
|
526
|
-
let cursor: string | undefined;
|
|
527
|
-
do {
|
|
528
|
-
const resp = await app.client.conversations.list({
|
|
529
|
-
types: "public_channel,private_channel",
|
|
530
|
-
exclude_archived: true,
|
|
531
|
-
limit: 200,
|
|
532
|
-
cursor,
|
|
533
|
-
});
|
|
534
|
-
for (const ch of resp.channels || []) {
|
|
535
|
-
if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
|
|
536
|
-
}
|
|
537
|
-
cursor = resp.response_metadata?.next_cursor || undefined;
|
|
538
|
-
} while (cursor);
|
|
539
|
-
const match = channelList.find((c) => c.name === key);
|
|
540
|
-
if (match) {
|
|
541
|
-
watchChannels.set(match.id, { name: key, behavior: cfg.behavior });
|
|
542
|
-
log.info({ channel: key, id: match.id }, "slack: watching channel (resolved by name)");
|
|
543
|
-
} else {
|
|
544
|
-
log.warn({ channel: key }, "slack: watch channel not found");
|
|
545
|
-
}
|
|
546
|
-
} catch (err) {
|
|
547
|
-
log.warn({ err, channel: key }, "slack: failed to resolve watch channel");
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
547
|
+
// Initial watch channel load
|
|
548
|
+
reloadWatchChannels();
|
|
552
549
|
|
|
553
550
|
log.info("slack bot started (Socket Mode)");
|
|
554
551
|
this.app = app;
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { jobCommand } from "./job";
|
|
|
13
13
|
import { statusCommand } from "./status";
|
|
14
14
|
import { sendCommand, telegramCommand, slackCommand } from "./channels";
|
|
15
15
|
import { rulesCommand, memoryCommand } from "./self";
|
|
16
|
+
import { watchCommand } from "./watch";
|
|
16
17
|
|
|
17
18
|
// Set LOG_LEVEL from config before anything else logs
|
|
18
19
|
try {
|
|
@@ -216,6 +217,11 @@ switch (command) {
|
|
|
216
217
|
break;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
case "watch": {
|
|
221
|
+
watchCommand();
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
219
225
|
case "history": {
|
|
220
226
|
const room = process.argv[3];
|
|
221
227
|
try {
|
|
@@ -440,6 +446,7 @@ switch (command) {
|
|
|
440
446
|
console.log(" memory [show|reset] — view or reset memory.md");
|
|
441
447
|
console.log(" db <sub> — database setup/status/migrate");
|
|
442
448
|
console.log(" skills — list available skills");
|
|
449
|
+
console.log(" watch <sub> — manage Slack watch channels");
|
|
443
450
|
console.log(" validate — validate config.yaml");
|
|
444
451
|
console.log(" config <sub> — get/set/list config values");
|
|
445
452
|
console.log(" send [-c ch] <msg> — send a message via channel");
|
package/src/cli/watch.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readRawConfig } from "../utils/config";
|
|
2
|
+
import { addWatchChannel, removeWatchChannel, enableWatchChannel, disableWatchChannel } from "../mcp/tools";
|
|
3
|
+
import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
|
|
4
|
+
|
|
5
|
+
export function watchCommand(): void {
|
|
6
|
+
const sub = process.argv[3];
|
|
7
|
+
|
|
8
|
+
switch (sub) {
|
|
9
|
+
case "list":
|
|
10
|
+
case undefined: {
|
|
11
|
+
const raw = readRawConfig();
|
|
12
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
13
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
14
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
const entries = Object.entries(watch);
|
|
17
|
+
if (entries.length === 0) {
|
|
18
|
+
console.log("No watch channels configured.");
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
for (const [key, val] of entries) {
|
|
22
|
+
const cfg = val as Record<string, unknown>;
|
|
23
|
+
const enabled = cfg.enabled !== false;
|
|
24
|
+
const icon = enabled ? ICON_PASS : ICON_FAIL;
|
|
25
|
+
const behavior = typeof cfg.behavior === "string" ? cfg.behavior.slice(0, 80).replace(/\n/g, " ") : "";
|
|
26
|
+
console.log(` ${icon} ${key} ${behavior}${behavior.length >= 80 ? "..." : ""}`);
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case "add": {
|
|
32
|
+
const name = process.argv[4];
|
|
33
|
+
const behavior = process.argv.slice(5).join(" ");
|
|
34
|
+
if (!name || !behavior) {
|
|
35
|
+
fail('Usage: nia watch add <channel_id#name> <behavior>');
|
|
36
|
+
}
|
|
37
|
+
console.log(addWatchChannel(name, behavior));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case "remove": {
|
|
42
|
+
const name = process.argv[4];
|
|
43
|
+
if (!name) fail("Usage: nia watch remove <channel_id#name>");
|
|
44
|
+
console.log(removeWatchChannel(name));
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case "enable": {
|
|
49
|
+
const name = process.argv[4];
|
|
50
|
+
if (!name) fail("Usage: nia watch enable <channel_id#name>");
|
|
51
|
+
console.log(enableWatchChannel(name));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case "disable": {
|
|
56
|
+
const name = process.argv[4];
|
|
57
|
+
if (!name) fail("Usage: nia watch disable <channel_id#name>");
|
|
58
|
+
console.log(disableWatchChannel(name));
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
console.log("Usage: nia watch <list|add|remove|enable|disable>\n");
|
|
64
|
+
console.log(" list — list watch channels (default)");
|
|
65
|
+
console.log(" add <channel_id#name> <behavior> — add a watch channel");
|
|
66
|
+
console.log(" remove <channel_id#name> — remove a watch channel");
|
|
67
|
+
console.log(" enable <channel_id#name> — enable a watch channel");
|
|
68
|
+
console.log(" disable <channel_id#name> — disable a watch channel");
|
|
69
|
+
process.exit(sub ? 1 : 0);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/commands/health.ts
CHANGED
|
@@ -16,6 +16,10 @@ export async function healthCommand(): Promise<void> {
|
|
|
16
16
|
const checks: Check[] = [];
|
|
17
17
|
const paths = getPaths();
|
|
18
18
|
|
|
19
|
+
// 0. Version
|
|
20
|
+
const { version } = await import("../../package.json");
|
|
21
|
+
push(checks, "nia", "ok", "v" + version);
|
|
22
|
+
|
|
19
23
|
// 1. Daemon
|
|
20
24
|
const pid = readPid();
|
|
21
25
|
if (isRunning()) {
|
package/src/commands/validate.ts
CHANGED
|
@@ -127,11 +127,12 @@ export function validateConfig(): Result {
|
|
|
127
127
|
ok = false;
|
|
128
128
|
continue;
|
|
129
129
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
messages.push(`${PASS} slack.watch: ${key}`);
|
|
130
|
+
if (key.includes("#")) {
|
|
131
|
+
const enabled = (val as Record<string, unknown>).enabled !== false;
|
|
132
|
+
messages.push(`${enabled ? PASS : WARN} slack.watch: ${key}${enabled ? "" : " (disabled)"}`);
|
|
133
133
|
} else {
|
|
134
|
-
messages.push(`${
|
|
134
|
+
messages.push(`${FAIL} slack.watch.${key}: must use "channel_id#${key}" format`);
|
|
135
|
+
ok = false;
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { log } from "../utils/log";
|
|
2
|
+
import { getConfig } from "../utils/config";
|
|
3
|
+
import { getSql, closeDb } from "../db/connection";
|
|
4
|
+
|
|
5
|
+
const HEARTBEAT_INTERVAL = 60_000; // 60s
|
|
6
|
+
const RECOVERY_THRESHOLD = 30; // 30 consecutive failures = ~30 min
|
|
7
|
+
|
|
8
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
let consecutiveFailures = 0;
|
|
10
|
+
let recoveryAttempted = false;
|
|
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> {
|
|
23
|
+
try {
|
|
24
|
+
await closeDb();
|
|
25
|
+
// getSql() will create a fresh connection on next call
|
|
26
|
+
const sql = getSql();
|
|
27
|
+
await sql`SELECT 1`;
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Send a raw message via channel API — no DB needed, no agent needed. */
|
|
35
|
+
async function notifyUser(message: string): Promise<void> {
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
|
|
38
|
+
// Try Telegram first
|
|
39
|
+
const tgToken = config.channels.telegram.bot_token;
|
|
40
|
+
const tgChatId = config.channels.telegram.chat_id;
|
|
41
|
+
if (tgToken && tgChatId) {
|
|
42
|
+
try {
|
|
43
|
+
const { Bot } = await import("grammy");
|
|
44
|
+
const bot = new Bot(tgToken);
|
|
45
|
+
await bot.api.sendMessage(tgChatId, message);
|
|
46
|
+
log.info("alive: notified user via telegram");
|
|
47
|
+
return;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
log.warn({ err }, "alive: telegram notification failed");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fall back to Slack
|
|
54
|
+
const slToken = config.channels.slack.bot_token;
|
|
55
|
+
const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
|
|
56
|
+
if (slToken && slRecipient) {
|
|
57
|
+
try {
|
|
58
|
+
const resp = await fetch("https://slack.com/api/chat.postMessage", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ channel: slRecipient, text: message }),
|
|
62
|
+
});
|
|
63
|
+
if (resp.ok) {
|
|
64
|
+
log.info("alive: notified user via slack");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
log.warn({ err }, "alive: slack notification failed");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log.error("alive: could not notify user — no channel available");
|
|
73
|
+
}
|
|
74
|
+
|
|
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 }> {
|
|
77
|
+
try {
|
|
78
|
+
const { runJobWithClaude } = await import("./runner");
|
|
79
|
+
const { homedir } = await import("os");
|
|
80
|
+
|
|
81
|
+
const systemPrompt = [
|
|
82
|
+
"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.",
|
|
85
|
+
"",
|
|
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",
|
|
91
|
+
"",
|
|
92
|
+
"Respond with a brief postmortem:",
|
|
93
|
+
"- What was wrong",
|
|
94
|
+
"- What you did to fix it",
|
|
95
|
+
"- Whether it's recovered or still failing",
|
|
96
|
+
"- Any recommendations",
|
|
97
|
+
].join("\n");
|
|
98
|
+
|
|
99
|
+
const jobPrompt = `Database has been unreachable for 30+ minutes.\nLast error: ${error}\n\nDiagnose and fix if possible.`;
|
|
100
|
+
|
|
101
|
+
const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
102
|
+
const recovered = await checkDb();
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
recovered,
|
|
106
|
+
report: result.agentText || "Recovery agent returned no output.",
|
|
107
|
+
};
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
recovered: false,
|
|
111
|
+
report: `Recovery agent failed to run: ${err instanceof Error ? err.message : String(err)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
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
|
+
}
|
|
125
|
+
}
|
|
126
|
+
consecutiveFailures = 0;
|
|
127
|
+
recoveryAttempted = false;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
consecutiveFailures++;
|
|
132
|
+
log.warn({ consecutiveFailures }, "alive: database unreachable");
|
|
133
|
+
|
|
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;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// After threshold, trigger recovery (once)
|
|
144
|
+
if (consecutiveFailures >= RECOVERY_THRESHOLD && !recoveryAttempted) {
|
|
145
|
+
recoveryAttempted = true;
|
|
146
|
+
log.info("alive: triggering recovery after " + consecutiveFailures + " failures");
|
|
147
|
+
|
|
148
|
+
// Layer 1: LLM recovery agent
|
|
149
|
+
const lastError = "PostgreSQL unreachable after " + consecutiveFailures + " consecutive heartbeat failures";
|
|
150
|
+
const { recovered, report } = await runRecoveryAgent(lastError);
|
|
151
|
+
|
|
152
|
+
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;
|
|
156
|
+
recoveryAttempted = false;
|
|
157
|
+
} 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
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function startAlive(): void {
|
|
170
|
+
log.info("alive started (60s heartbeat)");
|
|
171
|
+
// Initial check after a short delay (let startup finish)
|
|
172
|
+
setTimeout(heartbeat, 10_000);
|
|
173
|
+
timer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function stopAlive(): void {
|
|
177
|
+
if (timer) {
|
|
178
|
+
clearInterval(timer);
|
|
179
|
+
timer = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/core/daemon.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { closeDb, getSql } from "../db/connection";
|
|
|
9
9
|
import { registerAllChannels, startChannels, stopChannels } from "../channels";
|
|
10
10
|
import type { Channel } from "../types";
|
|
11
11
|
import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler";
|
|
12
|
+
import { startAlive, stopAlive } from "./alive";
|
|
12
13
|
import { createNiaMcpServer } from "../mcp/server";
|
|
13
14
|
import { setMcpServers } from "../mcp";
|
|
14
15
|
|
|
@@ -229,6 +230,9 @@ export async function runDaemon(): Promise<void> {
|
|
|
229
230
|
// Start unified scheduler (replaces node-cron)
|
|
230
231
|
startScheduler();
|
|
231
232
|
|
|
233
|
+
// Start alive monitor (DB heartbeat + recovery)
|
|
234
|
+
startAlive();
|
|
235
|
+
|
|
232
236
|
// Listen for job changes via Postgres LISTEN/NOTIFY
|
|
233
237
|
try {
|
|
234
238
|
const sql = getSql();
|
|
@@ -257,6 +261,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
257
261
|
|
|
258
262
|
log.info("shutting down...");
|
|
259
263
|
|
|
264
|
+
stopAlive();
|
|
260
265
|
stopScheduler();
|
|
261
266
|
await stopChannels(channels);
|
|
262
267
|
|
package/src/core/runner.ts
CHANGED
|
@@ -65,7 +65,7 @@ async function runJobWithCodex(fullPrompt: string, cwd: string, model: string):
|
|
|
65
65
|
// Claude Agent SDK runner
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
|
|
68
|
-
async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: string): Promise<RunnerOutput> {
|
|
68
|
+
export async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: string): Promise<RunnerOutput> {
|
|
69
69
|
const sessionId = randomUUID();
|
|
70
70
|
|
|
71
71
|
// One-shot async iterable: emit a single user message then close
|
package/src/mcp/server.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function createNiaMcpServer() {
|
|
|
86
86
|
),
|
|
87
87
|
tool(
|
|
88
88
|
"add_watch_channel",
|
|
89
|
-
"Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions) and act based on the behavior prompt.
|
|
89
|
+
"Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions) and act based on the behavior prompt. Takes effect on next message (hot-reloads).",
|
|
90
90
|
{
|
|
91
91
|
name: z.string().describe("Slack channel key as 'channel_id#channel_name', e.g. 'C1234567890#ask-kay-thread-notifications'"),
|
|
92
92
|
behavior: z.string().describe("What to monitor and how to respond, e.g. 'Monitor thread notifications. Flag failures to #tech.'"),
|
|
@@ -97,7 +97,7 @@ export function createNiaMcpServer() {
|
|
|
97
97
|
),
|
|
98
98
|
tool(
|
|
99
99
|
"remove_watch_channel",
|
|
100
|
-
"Remove a Slack watch channel.
|
|
100
|
+
"Remove a Slack watch channel. Takes effect on next message (hot-reloads).",
|
|
101
101
|
{
|
|
102
102
|
name: z.string().describe("Slack channel key to stop watching (e.g. 'C1234567890#ask-kay-thread-notifications')"),
|
|
103
103
|
},
|
|
@@ -105,6 +105,26 @@ export function createNiaMcpServer() {
|
|
|
105
105
|
content: [{ type: "text" as const, text: handlers.removeWatchChannel(args.name) }],
|
|
106
106
|
}),
|
|
107
107
|
),
|
|
108
|
+
tool(
|
|
109
|
+
"enable_watch_channel",
|
|
110
|
+
"Enable a disabled Slack watch channel. Takes effect on next message (hot-reloads).",
|
|
111
|
+
{
|
|
112
|
+
name: z.string().describe("Slack channel key to enable"),
|
|
113
|
+
},
|
|
114
|
+
async (args) => ({
|
|
115
|
+
content: [{ type: "text" as const, text: handlers.enableWatchChannel(args.name) }],
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
tool(
|
|
119
|
+
"disable_watch_channel",
|
|
120
|
+
"Disable a Slack watch channel without removing it. Takes effect on next message (hot-reloads).",
|
|
121
|
+
{
|
|
122
|
+
name: z.string().describe("Slack channel key to disable"),
|
|
123
|
+
},
|
|
124
|
+
async (args) => ({
|
|
125
|
+
content: [{ type: "text" as const, text: handlers.disableWatchChannel(args.name) }],
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
108
128
|
tool(
|
|
109
129
|
"add_rule",
|
|
110
130
|
"Add a behavioral rule. Rules are loaded into every session and take effect without restart. Use for 'from now on' / 'always' / 'never' type instructions.",
|
package/src/mcp/tools.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ScheduleType } from "../types";
|
|
|
3
3
|
import { basename, join } from "path";
|
|
4
4
|
import { Job, Message, Session } from "../db/models";
|
|
5
5
|
import { computeInitialNextRun } from "../core/scheduler";
|
|
6
|
-
import { getConfig, readRawConfig, updateRawConfig } from "../utils/config";
|
|
6
|
+
import { getConfig, readRawConfig, updateRawConfig, writeRawConfig } from "../utils/config";
|
|
7
7
|
import { getPaths } from "../utils/paths";
|
|
8
8
|
import { getChannel } from "../channels/registry";
|
|
9
9
|
import { log } from "../utils/log";
|
|
@@ -233,20 +233,44 @@ export function addWatchChannel(name: string, behavior: string): string {
|
|
|
233
233
|
const raw = readRawConfig();
|
|
234
234
|
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
235
235
|
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
236
|
-
const watch = { ...((slack.watch || {}) as Record<string, unknown>), [name]: { behavior } };
|
|
236
|
+
const watch = { ...((slack.watch || {}) as Record<string, unknown>), [name]: { behavior, enabled: true } };
|
|
237
237
|
updateRawConfig({ channels: { slack: { watch } } });
|
|
238
|
-
return `Watch channel "${name}" added.
|
|
238
|
+
return `Watch channel "${name}" added (enabled). Takes effect on next message.`;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
export function removeWatchChannel(name: string): string {
|
|
242
242
|
const raw = readRawConfig();
|
|
243
243
|
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
244
244
|
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
245
|
-
const watch =
|
|
245
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
246
246
|
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
247
247
|
delete watch[name];
|
|
248
|
+
writeRawConfig(raw);
|
|
249
|
+
return `Watch channel "${name}" removed. Takes effect on next message.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function enableWatchChannel(name: string): string {
|
|
253
|
+
const raw = readRawConfig();
|
|
254
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
255
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
256
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
257
|
+
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
258
|
+
const entry = watch[name] as Record<string, unknown>;
|
|
259
|
+
entry.enabled = true;
|
|
260
|
+
updateRawConfig({ channels: { slack: { watch } } });
|
|
261
|
+
return `Watch channel "${name}" enabled. Takes effect on next message.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function disableWatchChannel(name: string): string {
|
|
265
|
+
const raw = readRawConfig();
|
|
266
|
+
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
267
|
+
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
268
|
+
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
269
|
+
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
270
|
+
const entry = watch[name] as Record<string, unknown>;
|
|
271
|
+
entry.enabled = false;
|
|
248
272
|
updateRawConfig({ channels: { slack: { watch } } });
|
|
249
|
-
return `Watch channel "${name}"
|
|
273
|
+
return `Watch channel "${name}" disabled. Takes effect on next message.`;
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
export function addMemory(entry: string): string {
|
|
@@ -47,4 +47,4 @@
|
|
|
47
47
|
- Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
|
|
48
48
|
- To escalate to a different channel, use `send_message` with the channel name (e.g. `send_message("deploy failed: ...", "slack")`). To DM the owner, use `send_message` with no channel (uses default).
|
|
49
49
|
- Your reply goes in-thread in the watched channel. Use `send_message` when you need to notify elsewhere.
|
|
50
|
-
- You can manage watch channels via `add_watch_channel` / `remove_watch_channel` MCP tools (
|
|
50
|
+
- You can manage watch channels via `add_watch_channel` / `remove_watch_channel` / `enable_watch_channel` / `disable_watch_channel` MCP tools. Changes take effect on the next message (hot-reloads via config.yaml mtime).
|
|
@@ -7,9 +7,17 @@ You are running as part of the assistant daemon.
|
|
|
7
7
|
- Timezone: {{timezone}}
|
|
8
8
|
- Current time: {{currentTime}}
|
|
9
9
|
|
|
10
|
+
## Nia CLI
|
|
11
|
+
|
|
12
|
+
You are `nia` — the CLI and daemon. You have access to Bash, so you can run `nia` commands directly.
|
|
13
|
+
If unsure about available commands, run `nia` or `nia <command>` with no args to see usage/help.
|
|
14
|
+
Prefer MCP tools for job/message management (faster, no subprocess overhead), but use the CLI when MCP tools don't cover it.
|
|
15
|
+
|
|
16
|
+
> **`nia run` ≠ `nia job run`**: `nia run <prompt>` starts a new one-shot chat. `nia job run <name>` executes a saved job by name. When asked to run a job, use the `run_job` MCP tool or `nia job run` — never `nia run`.
|
|
17
|
+
|
|
10
18
|
## Managing Jobs
|
|
11
19
|
|
|
12
|
-
You have MCP tools for managing jobs directly
|
|
20
|
+
You have MCP tools for managing jobs directly (preferred over CLI for speed):
|
|
13
21
|
|
|
14
22
|
- **list_jobs** — see all scheduled jobs with status and next run time
|
|
15
23
|
- **add_job** — create a new job. Supports three schedule types:
|
|
@@ -22,8 +30,9 @@ You have MCP tools for managing jobs directly — no need for shell commands:
|
|
|
22
30
|
- **run_job** — trigger a job to run immediately
|
|
23
31
|
- **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
|
|
24
32
|
- **list_messages** — read recent chat history
|
|
25
|
-
- **add_watch_channel** — add a Slack channel for proactive monitoring. Specify channel name and behavior prompt.
|
|
26
|
-
- **remove_watch_channel** — stop watching a Slack channel.
|
|
33
|
+
- **add_watch_channel** — add a Slack channel for proactive monitoring. Specify channel key (`channel_id#name`) and behavior prompt. Hot-reloads.
|
|
34
|
+
- **remove_watch_channel** — stop watching a Slack channel. Hot-reloads.
|
|
35
|
+
- **enable_watch_channel** / **disable_watch_channel** — toggle a watch channel on/off without removing it. Hot-reloads.
|
|
27
36
|
- **add_rule** — save a behavioral rule (loaded into every session, no restart needed). Use when told "from now on", "always", "never", or "remember to always..."
|
|
28
37
|
- **add_memory** — save a factual memory (read on demand). Use when told "remember that...", or when you learn something surprising worth keeping
|
|
29
38
|
|
package/src/types/config.ts
CHANGED
package/src/utils/config.ts
CHANGED
|
@@ -144,12 +144,13 @@ export function loadConfig(): Config {
|
|
|
144
144
|
|
|
145
145
|
// Slack watch channels
|
|
146
146
|
const rawWatch = chSl.watch as Record<string, unknown> | undefined;
|
|
147
|
-
let slWatch: Record<string, { behavior: string }> | null = null;
|
|
147
|
+
let slWatch: Record<string, { behavior: string; enabled: boolean }> | null = null;
|
|
148
148
|
if (rawWatch && typeof rawWatch === "object") {
|
|
149
149
|
slWatch = {};
|
|
150
150
|
for (const [name, val] of Object.entries(rawWatch)) {
|
|
151
151
|
if (val && typeof val === "object" && typeof (val as any).behavior === "string") {
|
|
152
|
-
|
|
152
|
+
const enabled = (val as any).enabled !== false; // default true
|
|
153
|
+
slWatch[name] = { behavior: (val as any).behavior, enabled };
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
if (Object.keys(slWatch).length === 0) slWatch = null;
|
|
@@ -199,16 +200,19 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
|
|
|
199
200
|
|
|
200
201
|
/** Deep-merge fields into config.yaml and write back atomically. */
|
|
201
202
|
export function updateRawConfig(fields: Record<string, unknown>): void {
|
|
202
|
-
const { config } = getPaths();
|
|
203
203
|
const raw = readRawConfig();
|
|
204
204
|
deepMerge(raw, fields);
|
|
205
|
+
writeRawConfig(raw);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Write a full config object to config.yaml atomically (backup + temp + rename). */
|
|
209
|
+
export function writeRawConfig(raw: Record<string, unknown>): void {
|
|
210
|
+
const { config } = getPaths();
|
|
205
211
|
const dir = dirname(config);
|
|
206
212
|
mkdirSync(dir, { recursive: true });
|
|
207
|
-
// Back up current config before overwriting
|
|
208
213
|
if (existsSync(config)) {
|
|
209
214
|
copyFileSync(config, join(dir, "config.yaml.bak"));
|
|
210
215
|
}
|
|
211
|
-
// Write to temp file then rename for atomic update (prevents corruption on crash)
|
|
212
216
|
const tmp = join(dir, `.config.yaml.tmp.${process.pid}`);
|
|
213
217
|
writeFileSync(tmp, yaml.dump(raw, { lineWidth: -1 }));
|
|
214
218
|
renameSync(tmp, config);
|