niahere 0.2.28 → 0.2.30
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 -52
- package/src/commands/health.ts +4 -0
- package/src/commands/validate.ts +5 -4
- package/src/core/alive.ts +180 -0
- package/src/core/daemon.ts +5 -0
- package/src/core/runner.ts +1 -1
- package/src/mcp/server.ts +4 -4
- package/src/mcp/tools.ts +4 -4
- package/src/prompts/channel-slack.md +1 -1
- package/src/prompts/environment.md +12 -4
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,49 +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
|
-
if (!cfg.enabled) {
|
|
516
|
-
log.info({ channel: key }, "slack: watch channel disabled, skipping");
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
const hashIdx = key.indexOf("#");
|
|
520
|
-
if (hashIdx !== -1) {
|
|
521
|
-
const id = key.slice(0, hashIdx);
|
|
522
|
-
const name = key.slice(hashIdx + 1);
|
|
523
|
-
watchChannels.set(id, { name, behavior: cfg.behavior });
|
|
524
|
-
log.info({ channel: name, id }, "slack: watching channel");
|
|
525
|
-
} else {
|
|
526
|
-
try {
|
|
527
|
-
const channelList: { id: string; name: string }[] = [];
|
|
528
|
-
let cursor: string | undefined;
|
|
529
|
-
do {
|
|
530
|
-
const resp = await app.client.conversations.list({
|
|
531
|
-
types: "public_channel,private_channel",
|
|
532
|
-
exclude_archived: true,
|
|
533
|
-
limit: 200,
|
|
534
|
-
cursor,
|
|
535
|
-
});
|
|
536
|
-
for (const ch of resp.channels || []) {
|
|
537
|
-
if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
|
|
538
|
-
}
|
|
539
|
-
cursor = resp.response_metadata?.next_cursor || undefined;
|
|
540
|
-
} while (cursor);
|
|
541
|
-
const match = channelList.find((c) => c.name === key);
|
|
542
|
-
if (match) {
|
|
543
|
-
watchChannels.set(match.id, { name: key, behavior: cfg.behavior });
|
|
544
|
-
log.info({ channel: key, id: match.id }, "slack: watching channel (resolved by name)");
|
|
545
|
-
} else {
|
|
546
|
-
log.warn({ channel: key }, "slack: watch channel not found");
|
|
547
|
-
}
|
|
548
|
-
} catch (err) {
|
|
549
|
-
log.warn({ err, channel: key }, "slack: failed to resolve watch channel");
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
547
|
+
// Initial watch channel load
|
|
548
|
+
reloadWatchChannels();
|
|
554
549
|
|
|
555
550
|
log.info("slack bot started (Socket Mode)");
|
|
556
551
|
this.app = app;
|
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,180 @@
|
|
|
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
|
+
|
|
7
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
8
|
+
let consecutiveFailures = 0;
|
|
9
|
+
let recoveryAttempted = false;
|
|
10
|
+
|
|
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> {
|
|
22
|
+
try {
|
|
23
|
+
await closeDb();
|
|
24
|
+
const sql = getSql();
|
|
25
|
+
await sql`SELECT 1`;
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Send a raw message via channel API — no DB needed, no agent needed. */
|
|
33
|
+
async function notifyUser(message: string): Promise<void> {
|
|
34
|
+
const config = getConfig();
|
|
35
|
+
|
|
36
|
+
// Try Telegram first
|
|
37
|
+
const tgToken = config.channels.telegram.bot_token;
|
|
38
|
+
const tgChatId = config.channels.telegram.chat_id;
|
|
39
|
+
if (tgToken && tgChatId) {
|
|
40
|
+
try {
|
|
41
|
+
const { Bot } = await import("grammy");
|
|
42
|
+
const bot = new Bot(tgToken);
|
|
43
|
+
await bot.api.sendMessage(tgChatId, message);
|
|
44
|
+
log.info("alive: notified user via telegram");
|
|
45
|
+
return;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.warn({ err }, "alive: telegram notification failed");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fall back to Slack
|
|
52
|
+
const slToken = config.channels.slack.bot_token;
|
|
53
|
+
const slRecipient = config.channels.slack.dm_user_id || config.channels.slack.channel_id;
|
|
54
|
+
if (slToken && slRecipient) {
|
|
55
|
+
try {
|
|
56
|
+
const resp = await fetch("https://slack.com/api/chat.postMessage", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ channel: slRecipient, text: message }),
|
|
60
|
+
});
|
|
61
|
+
if (resp.ok) {
|
|
62
|
+
log.info("alive: notified user via slack");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.warn({ err }, "alive: slack notification failed");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log.error("alive: could not notify user — no channel available");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run an LLM recovery agent to diagnose and fix the issue. */
|
|
74
|
+
async function runRecoveryAgent(error: string): Promise<{ recovered: boolean; report: string }> {
|
|
75
|
+
try {
|
|
76
|
+
const { runJobWithClaude } = await import("./runner");
|
|
77
|
+
const { homedir } = await import("os");
|
|
78
|
+
|
|
79
|
+
const systemPrompt = [
|
|
80
|
+
"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.",
|
|
83
|
+
"",
|
|
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",
|
|
89
|
+
"",
|
|
90
|
+
"Respond with a brief postmortem:",
|
|
91
|
+
"- What was wrong",
|
|
92
|
+
"- What you did to fix it",
|
|
93
|
+
"- Whether it's recovered or still failing",
|
|
94
|
+
"- Any recommendations",
|
|
95
|
+
].join("\n");
|
|
96
|
+
|
|
97
|
+
const jobPrompt = `Database is unreachable. Reconnect failed.\nError: ${error}\n\nDiagnose and fix.`;
|
|
98
|
+
|
|
99
|
+
const result = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
100
|
+
const recovered = await checkDb();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
recovered,
|
|
104
|
+
report: result.agentText || "Recovery agent returned no output.",
|
|
105
|
+
};
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return {
|
|
108
|
+
recovered: false,
|
|
109
|
+
report: `Recovery agent failed to run: ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
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.`);
|
|
121
|
+
}
|
|
122
|
+
consecutiveFailures = 0;
|
|
123
|
+
recoveryAttempted = false;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
consecutiveFailures++;
|
|
128
|
+
log.warn({ consecutiveFailures }, "alive: database unreachable");
|
|
129
|
+
|
|
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.`);
|
|
136
|
+
}
|
|
137
|
+
consecutiveFailures = 0;
|
|
138
|
+
recoveryAttempted = false;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Reconnect failed — run recovery agent immediately (once per outage)
|
|
143
|
+
if (!recoveryAttempted) {
|
|
144
|
+
recoveryAttempted = true;
|
|
145
|
+
log.info("alive: reconnect failed, running recovery agent");
|
|
146
|
+
|
|
147
|
+
const { recovered, report } = await runRecoveryAgent(
|
|
148
|
+
"PostgreSQL unreachable, reconnect failed after " + consecutiveFailures + " heartbeat(s)"
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
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;
|
|
155
|
+
recoveryAttempted = false;
|
|
156
|
+
} 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
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// After recovery attempted: just log failures, don't spam user or agent.
|
|
166
|
+
// When DB comes back, the ok branch above will notify.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function startAlive(): void {
|
|
170
|
+
log.info("alive started (60s heartbeat)");
|
|
171
|
+
setTimeout(heartbeat, 10_000);
|
|
172
|
+
timer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function stopAlive(): void {
|
|
176
|
+
if (timer) {
|
|
177
|
+
clearInterval(timer);
|
|
178
|
+
timer = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
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
|
},
|
|
@@ -107,7 +107,7 @@ export function createNiaMcpServer() {
|
|
|
107
107
|
),
|
|
108
108
|
tool(
|
|
109
109
|
"enable_watch_channel",
|
|
110
|
-
"Enable a disabled Slack watch channel.
|
|
110
|
+
"Enable a disabled Slack watch channel. Takes effect on next message (hot-reloads).",
|
|
111
111
|
{
|
|
112
112
|
name: z.string().describe("Slack channel key to enable"),
|
|
113
113
|
},
|
|
@@ -117,7 +117,7 @@ export function createNiaMcpServer() {
|
|
|
117
117
|
),
|
|
118
118
|
tool(
|
|
119
119
|
"disable_watch_channel",
|
|
120
|
-
"Disable a Slack watch channel without removing it.
|
|
120
|
+
"Disable a Slack watch channel without removing it. Takes effect on next message (hot-reloads).",
|
|
121
121
|
{
|
|
122
122
|
name: z.string().describe("Slack channel key to disable"),
|
|
123
123
|
},
|
package/src/mcp/tools.ts
CHANGED
|
@@ -235,7 +235,7 @@ export function addWatchChannel(name: string, behavior: string): string {
|
|
|
235
235
|
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
236
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 (enabled).
|
|
238
|
+
return `Watch channel "${name}" added (enabled). Takes effect on next message.`;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
export function removeWatchChannel(name: string): string {
|
|
@@ -246,7 +246,7 @@ export function removeWatchChannel(name: string): string {
|
|
|
246
246
|
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
247
247
|
delete watch[name];
|
|
248
248
|
writeRawConfig(raw);
|
|
249
|
-
return `Watch channel "${name}" removed.
|
|
249
|
+
return `Watch channel "${name}" removed. Takes effect on next message.`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
export function enableWatchChannel(name: string): string {
|
|
@@ -258,7 +258,7 @@ export function enableWatchChannel(name: string): string {
|
|
|
258
258
|
const entry = watch[name] as Record<string, unknown>;
|
|
259
259
|
entry.enabled = true;
|
|
260
260
|
updateRawConfig({ channels: { slack: { watch } } });
|
|
261
|
-
return `Watch channel "${name}" enabled.
|
|
261
|
+
return `Watch channel "${name}" enabled. Takes effect on next message.`;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
export function disableWatchChannel(name: string): string {
|
|
@@ -270,7 +270,7 @@ export function disableWatchChannel(name: string): string {
|
|
|
270
270
|
const entry = watch[name] as Record<string, unknown>;
|
|
271
271
|
entry.enabled = false;
|
|
272
272
|
updateRawConfig({ channels: { slack: { watch } } });
|
|
273
|
-
return `Watch channel "${name}" disabled.
|
|
273
|
+
return `Watch channel "${name}" disabled. Takes effect on next message.`;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
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,9 +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.
|
|
27
|
-
- **enable_watch_channel** / **disable_watch_channel** — toggle a watch channel on/off without removing it.
|
|
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.
|
|
28
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..."
|
|
29
37
|
- **add_memory** — save a factual memory (read on demand). Use when told "remember that...", or when you learn something surprising worth keeping
|
|
30
38
|
|