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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,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: resolved after app.start() via conversations.list
136
- // Maps channel ID { name, behavior }
137
- const watchChannels = new Map<string, { name: string; behavior: string }>();
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 watchConfig = watchChannels.get(msg.channel);
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
- return `[${sender}]: ${m.text || "(no text)"}${fileHint}`;
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
- // Parse watch channels — keys are "channel_id#channel_name" or just "channel_name"
512
- const rawWatchConfig = config.channels.slack.watch;
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");
@@ -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
+ }
@@ -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()) {
@@ -127,11 +127,12 @@ export function validateConfig(): Result {
127
127
  ok = false;
128
128
  continue;
129
129
  }
130
- const hasId = key.includes("#");
131
- if (hasId) {
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(`${WARN} slack.watch.${key}: using channel name — prefer "channel_id#${key}" format for reliability`);
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
+ }
@@ -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
 
@@ -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. Requires daemon restart to take effect.",
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. Requires daemon restart to take effect.",
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. Restart daemon to apply.`;
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 = { ...((slack.watch || {}) as Record<string, unknown>) };
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}" removed. Restart daemon to apply.`;
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 (requires daemon restart).
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 no need for shell commands:
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. Requires daemon restart.
26
- - **remove_watch_channel** — stop watching a Slack channel. Requires daemon restart.
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
 
@@ -6,6 +6,7 @@ export interface TelegramConfig {
6
6
 
7
7
  export interface SlackWatchChannel {
8
8
  behavior: string;
9
+ enabled: boolean;
9
10
  }
10
11
 
11
12
  export interface SlackConfig {
@@ -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
- slWatch[name] = { behavior: (val as any).behavior };
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);