niahere 0.2.20 → 0.2.22

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.
@@ -1,14 +1,15 @@
1
1
  # Memory
2
2
 
3
- Things I've picked up that I don't want to forget. I maintain this myself.
3
+ Concise things I've picked up that I don't want to forget. I maintain this myself.
4
4
 
5
- Write here when:
6
- - Something surprised me or broke unexpectedly
7
- - {{ownerName}} corrected me or preferred a different approach
8
- - I learned a preference, habit, or pattern worth remembering
9
- - A workaround was needed that future-me should know about
5
+ Rules:
6
+ - One insight per entry, max 200 chars
7
+ - NO raw logs, transcripts, or status dumps
8
+ - NO duplicates check before adding
9
+ - Good: "curator job can hang needs timeout recovery"
10
+ - Bad: pasting nia status output or conversation logs
10
11
 
11
- Entries are grouped by date. Use `add_memory` tool to append, or edit directly.
12
+ Entries are grouped by date. Use `add_memory` tool to append.
12
13
 
13
14
  ---
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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,11 +1,15 @@
1
1
  import { App } from "@slack/bolt";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { createHash } from "crypto";
2
5
  import { createChatEngine } from "../chat/engine";
3
- import type { Channel, ChatState, Attachment } from "../types";
6
+ import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
4
7
  import { getConfig, updateRawConfig } from "../utils/config";
5
8
  import { runMigrations } from "../db/migrate";
6
9
  import { Session } from "../db/models";
7
10
  import { log } from "../utils/log";
8
11
  import { getMcpServers } from "../mcp";
12
+ import { getNiaHome } from "../utils/paths";
9
13
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
10
14
 
11
15
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
@@ -128,6 +132,10 @@ class SlackChannel implements Channel {
128
132
 
129
133
  let botUserId: string | undefined;
130
134
 
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 }>();
138
+
131
139
  // Slash command: /nia
132
140
  app.command("/nia", async ({ command, ack, respond }) => {
133
141
  await ack();
@@ -172,6 +180,26 @@ class SlackChannel implements Channel {
172
180
  await respond("New conversation started.");
173
181
  });
174
182
 
183
+ // Disk-backed file cache: download once, read from disk on subsequent requests
184
+ const attachDir = join(getNiaHome(), "tmp", "attachments");
185
+ mkdirSync(attachDir, { recursive: true });
186
+
187
+ interface CachedFile {
188
+ path: string;
189
+ type: AttachmentType;
190
+ mimeType: string;
191
+ filename?: string;
192
+ }
193
+ const fileIndex = new Map<string, CachedFile>();
194
+
195
+ function urlHash(url: string): string {
196
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
197
+ }
198
+
199
+ function loadCached(entry: CachedFile): Attachment {
200
+ return { type: entry.type, data: readFileSync(entry.path), mimeType: entry.mimeType, filename: entry.filename };
201
+ }
202
+
175
203
  async function downloadSlackFile(url: string): Promise<Buffer> {
176
204
  const resp = await fetch(url, {
177
205
  headers: { Authorization: `Bearer ${botToken}` },
@@ -187,6 +215,31 @@ class SlackChannel implements Channel {
187
215
  const attType = classifyMime(mime);
188
216
  if (!attType) continue;
189
217
  if (!file.url_private_download) continue;
218
+
219
+ // Check in-memory index first
220
+ const cached = fileIndex.get(file.url_private_download);
221
+ if (cached && existsSync(cached.path)) {
222
+ attachments.push(loadCached(cached));
223
+ continue;
224
+ }
225
+
226
+ // Check disk (survives daemon restarts) — keyed by URL hash only (global dedup)
227
+ const hash = urlHash(file.url_private_download);
228
+ const ext = file.name?.split(".").pop() || "bin";
229
+ const diskPath = join(attachDir, `${hash}.${ext}`);
230
+ const metaPath = join(attachDir, `${hash}.meta.json`);
231
+ if (existsSync(diskPath) && existsSync(metaPath)) {
232
+ try {
233
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
234
+ const entry: CachedFile = { path: diskPath, type: meta.type || attType, mimeType: meta.mimeType || mime, filename: meta.filename || file.name };
235
+ fileIndex.set(file.url_private_download, entry);
236
+ attachments.push(loadCached(entry));
237
+ continue;
238
+ } catch {
239
+ // Corrupt meta — re-download
240
+ }
241
+ }
242
+
190
243
  try {
191
244
  const data = await downloadSlackFile(file.url_private_download);
192
245
  const error = validateAttachment(data, mime);
@@ -201,6 +254,13 @@ class SlackChannel implements Channel {
201
254
  finalData = prepared.data;
202
255
  finalMime = prepared.mimeType;
203
256
  }
257
+
258
+ // Save file + metadata to disk
259
+ writeFileSync(diskPath, finalData);
260
+ writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
261
+ const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
262
+ fileIndex.set(file.url_private_download, entry);
263
+
204
264
  attachments.push({ type: attType, data: finalData, mimeType: finalMime, filename: file.name });
205
265
  } catch (err) {
206
266
  log.warn({ err, file: file.name }, "failed to download slack file");
@@ -269,7 +329,11 @@ class SlackChannel implements Channel {
269
329
  }
270
330
  }
271
331
 
272
- if (!isDm && !isMention && !isActiveThread) {
332
+ // Check if this is a watched channel
333
+ const watchConfig = watchChannels.get(msg.channel);
334
+ const isWatched = !!watchConfig;
335
+
336
+ if (!isDm && !isMention && !isActiveThread && !isWatched) {
273
337
  log.debug({
274
338
  channel: msg.channel,
275
339
  text: (msg.text || "").slice(0, 80),
@@ -294,15 +358,6 @@ class SlackChannel implements Channel {
294
358
  text = `[user:${msg.user}] ${text}`;
295
359
  }
296
360
 
297
- // Download any file attachments
298
- let attachments: Attachment[] | undefined;
299
- if (hasFiles) {
300
- attachments = await extractSlackAttachments(msg.files!);
301
- }
302
-
303
- if (!text && (!attachments || attachments.length === 0)) return;
304
- if (!text) text = attachments?.some(a => a.type === "image") ? "What's in this image?" : "Here's a file.";
305
-
306
361
  // Auto-register DM user for outbound messages
307
362
  if (isDm && !self.dmUserId && msg.user) {
308
363
  self.dmUserId = msg.user;
@@ -328,29 +383,65 @@ class SlackChannel implements Channel {
328
383
  replyThreadTs = threadTs;
329
384
  }
330
385
 
386
+ // Download any file attachments
387
+ let attachments: Attachment[] | undefined;
388
+ if (hasFiles) {
389
+ attachments = await extractSlackAttachments(msg.files!);
390
+ }
391
+
392
+ if (!text && (!attachments || attachments.length === 0)) return;
393
+ if (!text) text = attachments?.some(a => a.type === "image") ? "What's in this image?" : "Here's a file.";
394
+
331
395
  // When replying in a thread, fetch thread context so Nia can see the full conversation
332
396
  if (msg.thread_ts) {
333
397
  try {
334
398
  const replies = await client.conversations.replies({
335
399
  channel: msg.channel,
336
400
  ts: msg.thread_ts,
337
- limit: 20,
401
+ limit: 50,
402
+ });
403
+ const priorMessages = (replies.messages || [])
404
+ .filter((m: any) => m.ts !== msg.ts); // exclude the triggering message
405
+
406
+ const threadMessages = priorMessages.map((m: any) => {
407
+ const sender = m.bot_id ? "bot" : (m.user || "unknown");
408
+ const fileHint = m.files?.length ? ` [${m.files.length} file(s) attached]` : "";
409
+ return `[${sender}]: ${m.text || "(no text)"}${fileHint}`;
338
410
  });
339
- const threadMessages = (replies.messages || [])
340
- .filter((m: any) => m.ts !== msg.ts) // exclude the triggering message
341
- .map((m: any) => {
342
- const sender = m.bot_id ? "bot" : (m.user || "unknown");
343
- return `[${sender}]: ${m.text || "(no text)"}`;
344
- });
345
411
  if (threadMessages.length > 0) {
346
412
  text = `[Thread context]\n${threadMessages.join("\n")}\n\n[Current message]\n${text}`;
347
413
  }
414
+
415
+ // Download files from recent thread messages (last 5 messages, max 5 files total)
416
+ if (!attachments) attachments = [];
417
+ const threadFileBudget = 5 - attachments.length;
418
+ if (threadFileBudget > 0) {
419
+ const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0).slice(-5);
420
+ let threadFilesAdded = 0;
421
+ for (const m of messagesWithFiles) {
422
+ if (threadFilesAdded >= threadFileBudget) break;
423
+ const extracted = await extractSlackAttachments(m.files || []);
424
+ for (const att of extracted) {
425
+ if (threadFilesAdded >= threadFileBudget) break;
426
+ attachments.push(att);
427
+ threadFilesAdded++;
428
+ }
429
+ }
430
+ if (threadFilesAdded > 0) {
431
+ log.info({ threadFiles: threadFilesAdded, channel: msg.channel }, "slack: downloaded thread attachments");
432
+ }
433
+ }
348
434
  } catch (err) {
349
435
  log.warn({ err, channel: msg.channel, thread_ts: msg.thread_ts }, "failed to fetch thread context");
350
436
  }
351
437
  }
352
438
 
353
- log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, attachments: attachments?.length || 0 }, "slack message received");
439
+ // Prepend watch behavior context for watched channels
440
+ if (watchConfig) {
441
+ text = `[Watch mode — #${watchConfig.name}]\nBehavior: ${watchConfig.behavior}\nRespond with [NO_REPLY] if no action needed.\n\n${text}`;
442
+ }
443
+
444
+ log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, watched: isWatched, attachments: attachments?.length || 0 }, "slack message received");
354
445
 
355
446
  const state = await getState(key);
356
447
 
@@ -417,6 +508,39 @@ class SlackChannel implements Channel {
417
508
  log.warn({ err }, "could not get slack bot user ID");
418
509
  }
419
510
 
511
+ // Resolve watch channel names → IDs
512
+ const watchConfig = config.channels.slack.watch;
513
+ if (watchConfig) {
514
+ try {
515
+ const channelList: { id: string; name: string }[] = [];
516
+ let cursor: string | undefined;
517
+ do {
518
+ const resp = await app.client.conversations.list({
519
+ types: "public_channel,private_channel",
520
+ exclude_archived: true,
521
+ limit: 200,
522
+ cursor,
523
+ });
524
+ for (const ch of resp.channels || []) {
525
+ if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
526
+ }
527
+ cursor = resp.response_metadata?.next_cursor || undefined;
528
+ } while (cursor);
529
+
530
+ for (const [name, cfg] of Object.entries(watchConfig)) {
531
+ const match = channelList.find((c) => c.name === name);
532
+ if (match) {
533
+ watchChannels.set(match.id, { name, behavior: cfg.behavior });
534
+ log.info({ channel: name, id: match.id }, "slack: watching channel");
535
+ } else {
536
+ log.warn({ channel: name }, "slack: watch channel not found");
537
+ }
538
+ }
539
+ } catch (err) {
540
+ log.warn({ err }, "slack: failed to resolve watch channels");
541
+ }
542
+ }
543
+
420
544
  log.info("slack bot started (Socket Mode)");
421
545
  this.app = app;
422
546
  }
package/src/cli/index.ts CHANGED
@@ -12,6 +12,7 @@ import { fail } from "../utils/cli";
12
12
  import { jobCommand } from "./job";
13
13
  import { statusCommand } from "./status";
14
14
  import { sendCommand, telegramCommand, slackCommand } from "./channels";
15
+ import { rulesCommand, memoryCommand } from "./self";
15
16
 
16
17
  // Set LOG_LEVEL from config before anything else logs
17
18
  try {
@@ -207,6 +208,16 @@ switch (command) {
207
208
  break;
208
209
  }
209
210
 
211
+ case "rules": {
212
+ rulesCommand();
213
+ break;
214
+ }
215
+
216
+ case "memory": {
217
+ memoryCommand();
218
+ break;
219
+ }
220
+
210
221
  case "history": {
211
222
  const room = process.argv[3];
212
223
  try {
@@ -419,6 +430,8 @@ switch (command) {
419
430
  console.log(" history [room] — recent messages");
420
431
  console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
421
432
  console.log(" job <sub> — manage jobs");
433
+ console.log(" rules [show|reset] — view or reset rules.md");
434
+ console.log(" memory [show|reset] — view or reset memory.md");
422
435
  console.log(" db <sub> — database setup/status/migrate");
423
436
  console.log(" skills — list available skills");
424
437
  console.log(" config <sub> — get/set/list config values");
package/src/cli/job.ts CHANGED
@@ -49,7 +49,7 @@ export async function jobCommand(): Promise<void> {
49
49
  for (const job of jobs) {
50
50
  const tag = job.always ? " always" : "";
51
51
  const type = job.scheduleType !== "cron" ? ` (${job.scheduleType})` : "";
52
- console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag} ${job.prompt.slice(0, 60)}${job.prompt.length > 60 ? "..." : ""}`);
52
+ console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}`);
53
53
  }
54
54
  }
55
55
  });
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, copyFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getPaths } from "../utils/paths";
4
+ import { fail } from "../utils/cli";
5
+
6
+ function selfFilePath(name: "rules" | "memory"): string {
7
+ const { selfDir } = getPaths();
8
+ return join(selfDir, `${name}.md`);
9
+ }
10
+
11
+ function defaultFilePath(name: "rules" | "memory"): string {
12
+ const projectRoot = join(import.meta.dir, "../..");
13
+ return join(projectRoot, "defaults", "self", `${name}.md`);
14
+ }
15
+
16
+ function show(name: "rules" | "memory"): void {
17
+ const path = selfFilePath(name);
18
+ if (!existsSync(path)) {
19
+ console.log(`No ${name}.md found.`);
20
+ return;
21
+ }
22
+ console.log(readFileSync(path, "utf8").trim());
23
+ }
24
+
25
+ function reset(name: "rules" | "memory"): void {
26
+ const path = selfFilePath(name);
27
+ const defaultPath = defaultFilePath(name);
28
+
29
+ if (!existsSync(defaultPath)) {
30
+ fail(`Default ${name}.md template not found.`);
31
+ }
32
+
33
+ if (existsSync(path)) {
34
+ copyFileSync(path, `${path}.bak`);
35
+ console.log(` backed up → ${name}.md.bak`);
36
+ }
37
+
38
+ copyFileSync(defaultPath, path);
39
+ console.log(` ${name}.md reset to default.`);
40
+ }
41
+
42
+ export function rulesCommand(): void {
43
+ const sub = process.argv[3];
44
+ switch (sub) {
45
+ case "show":
46
+ case undefined:
47
+ show("rules");
48
+ break;
49
+ case "reset":
50
+ reset("rules");
51
+ break;
52
+ default:
53
+ console.log("Usage: nia rules <show|reset>");
54
+ console.log(" show — display current rules (default)");
55
+ console.log(" reset — reset to default template (backs up current)");
56
+ }
57
+ }
58
+
59
+ export function memoryCommand(): void {
60
+ const sub = process.argv[3];
61
+ switch (sub) {
62
+ case "show":
63
+ case undefined:
64
+ show("memory");
65
+ break;
66
+ case "reset":
67
+ reset("memory");
68
+ break;
69
+ default:
70
+ console.log("Usage: nia memory <show|reset>");
71
+ console.log(" show — display current memory (default)");
72
+ console.log(" reset — reset to default template (backs up current)");
73
+ }
74
+ }
@@ -91,18 +91,22 @@ async function runJobWithClaude(systemPrompt: string, jobPrompt: string, cwd: st
91
91
  let agentText = "";
92
92
  let actualSessionId = sessionId;
93
93
 
94
- for await (const message of handle) {
95
- if (message.type === "system" && (message as any).subtype === "init") {
96
- actualSessionId = (message as any).session_id || sessionId;
97
- }
98
- if (message.type === "result") {
99
- if (!(message as any).is_error) {
100
- agentText = (message as any).result || "";
101
- } else {
102
- const errors = (message as any).errors;
103
- return { agentText: "", sessionId: actualSessionId, error: errors?.join(", ") || "unknown error" };
94
+ try {
95
+ for await (const message of handle) {
96
+ if (message.type === "system" && (message as any).subtype === "init") {
97
+ actualSessionId = (message as any).session_id || sessionId;
98
+ }
99
+ if (message.type === "result") {
100
+ if (!(message as any).is_error) {
101
+ agentText = (message as any).result || "";
102
+ } else {
103
+ const errors = (message as any).errors;
104
+ return { agentText: "", sessionId: actualSessionId, error: errors?.join(", ") || "unknown error" };
105
+ }
104
106
  }
105
107
  }
108
+ } finally {
109
+ handle.close();
106
110
  }
107
111
 
108
112
  return { agentText, sessionId: actualSessionId };
package/src/mcp/server.ts CHANGED
@@ -84,6 +84,27 @@ export function createNiaMcpServer() {
84
84
  content: [{ type: "text" as const, text: await handlers.listMessages(args.limit, args.room) }],
85
85
  }),
86
86
  ),
87
+ tool(
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.",
90
+ {
91
+ name: z.string().describe("Slack channel name (without #), e.g. 'ask-kay-thread-notifications'"),
92
+ behavior: z.string().describe("What to monitor and how to respond, e.g. 'Monitor thread notifications. Flag failures to #tech.'"),
93
+ },
94
+ async (args) => ({
95
+ content: [{ type: "text" as const, text: handlers.addWatchChannel(args.name, args.behavior) }],
96
+ }),
97
+ ),
98
+ tool(
99
+ "remove_watch_channel",
100
+ "Remove a Slack watch channel. Requires daemon restart to take effect.",
101
+ {
102
+ name: z.string().describe("Slack channel name to stop watching"),
103
+ },
104
+ async (args) => ({
105
+ content: [{ type: "text" as const, text: handlers.removeWatchChannel(args.name) }],
106
+ }),
107
+ ),
87
108
  tool(
88
109
  "add_rule",
89
110
  "Add a behavioral rule. Rules are loaded into every session and take effect without restart. Use for 'from now on' / 'always' / 'never' type instructions.",
@@ -96,9 +117,9 @@ export function createNiaMcpServer() {
96
117
  ),
97
118
  tool(
98
119
  "add_memory",
99
- "Save a factual memory for future reference. Memories are read on demand, not loaded automatically. Use for things learned, preferences discovered, or context worth keeping.",
120
+ "Save a concise factual memory for future reference. Memories are read on demand, not loaded automatically. Use for preferences, corrections, or patterns worth keeping. RULES: Max 300 chars. One insight per entry. NO raw logs, NO conversation transcripts, NO status dumps, NO duplicate observations. Bad: pasting nia status output. Good: 'curator job can get stuck in running state — needs timeout recovery'.",
100
121
  {
101
- entry: z.string().describe("What to remember (e.g. 'Aman prefers short Slack messages in #tech')"),
122
+ entry: z.string().max(300).describe("A single concise insight (max 300 chars, no raw logs or transcripts)"),
102
123
  },
103
124
  async (args) => ({
104
125
  content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
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 } from "../utils/config";
6
+ import { getConfig, readRawConfig, updateRawConfig } from "../utils/config";
7
7
  import { getPaths } from "../utils/paths";
8
8
  import { getChannel } from "../channels/registry";
9
9
  import { log } from "../utils/log";
@@ -229,20 +229,48 @@ export function addRule(rule: string): string {
229
229
  return `Rule added to rules.md. Takes effect on next new session.`;
230
230
  }
231
231
 
232
+ export function addWatchChannel(name: string, behavior: string): string {
233
+ const raw = readRawConfig();
234
+ const channels = (raw.channels || {}) as Record<string, unknown>;
235
+ const slack = (channels.slack || {}) as Record<string, unknown>;
236
+ const watch = { ...((slack.watch || {}) as Record<string, unknown>), [name]: { behavior } };
237
+ updateRawConfig({ channels: { slack: { watch } } });
238
+ return `Watch channel "${name}" added. Restart daemon to apply.`;
239
+ }
240
+
241
+ export function removeWatchChannel(name: string): string {
242
+ const raw = readRawConfig();
243
+ const channels = (raw.channels || {}) as Record<string, unknown>;
244
+ const slack = (channels.slack || {}) as Record<string, unknown>;
245
+ const watch = { ...((slack.watch || {}) as Record<string, unknown>) };
246
+ if (!watch[name]) return `Watch channel "${name}" not found.`;
247
+ delete watch[name];
248
+ updateRawConfig({ channels: { slack: { watch } } });
249
+ return `Watch channel "${name}" removed. Restart daemon to apply.`;
250
+ }
251
+
232
252
  export function addMemory(entry: string): string {
253
+ // Guard: reject raw logs, transcripts, and overly long entries
254
+ const trimmed = entry.trim();
255
+ if (!trimmed) return "Rejected: empty entry.";
256
+ if (trimmed.length > 300) return "Rejected: too long (max 300 chars). Distill to a single concise insight.";
257
+ if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag")) return "Rejected: no raw conversation transcripts.";
258
+ if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
259
+
233
260
  const { selfDir } = getPaths();
234
261
  const memoryPath = join(selfDir, "memory.md");
262
+ const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
263
+
264
+ // TODO: add semantic dedup later (embeddings or similar)
265
+
235
266
  const date = new Date().toISOString().slice(0, 10);
236
267
  const header = `\n## ${date}`;
237
268
 
238
- const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
239
269
  if (existing.includes(header)) {
240
- // Append under existing date header
241
- const updated = existing.replace(header, `${header}\n- ${entry}`);
270
+ const updated = existing.replace(header, `${header}\n- ${trimmed}`);
242
271
  writeFileSync(memoryPath, updated, "utf8");
243
272
  } else {
244
- // New date section
245
- appendFileSync(memoryPath, `${header}\n- ${entry}\n`, "utf8");
273
+ appendFileSync(memoryPath, `${header}\n- ${trimmed}\n`, "utf8");
246
274
  }
247
275
  return `Memory saved.`;
248
276
  }
@@ -24,4 +24,11 @@
24
24
  - Stay quiet if: users are talking to each other, the message is clearly not directed at you, or it's a reaction/acknowledgement between humans.
25
25
  - When in doubt, stay quiet. Better to miss one than to interrupt a human conversation.
26
26
  - Never say "was that for me?" or similar — just respond or don't.
27
- - To stay quiet, respond with exactly `[NO_REPLY]` and nothing else. This tells the system to skip sending a message.
27
+ - To stay quiet, respond with exactly `[NO_REPLY]` and nothing else. This tells the system to skip sending a message.
28
+
29
+ ### Watch mode
30
+ - Some channels are configured for proactive monitoring via `channels.slack.watch` in config.
31
+ - In watch channels, you receive ALL messages — not just @mentions. Messages are prefixed with `[Watch mode — #channel-name]` and a behavior prompt.
32
+ - Follow the behavior prompt to decide what to do: flag issues, escalate, or stay quiet.
33
+ - Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
34
+ - You can manage watch channels via `add_watch_channel` / `remove_watch_channel` MCP tools (requires daemon restart).
@@ -22,6 +22,8 @@ You have MCP tools for managing jobs directly — no need for shell commands:
22
22
  - **run_job** — trigger a job to run immediately
23
23
  - **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
24
24
  - **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.
25
27
  - **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..."
26
28
  - **add_memory** — save a factual memory (read on demand). Use when told "remember that...", or when you learn something surprising worth keeping
27
29
 
@@ -57,6 +59,8 @@ Config reference:
57
59
  - `channels.slack.app_token` — Slack app token (xapp-...)
58
60
  - `channels.slack.channel_id` — default Slack channel for outbound
59
61
  - `channels.slack.dm_user_id` — auto-registered DM user
62
+ - `channels.slack.watch` — per-channel proactive monitoring (see Watch mode in Slack docs)
63
+ {{slackWatch}}
60
64
 
61
65
  ## Persona & Memory
62
66
 
@@ -21,6 +21,14 @@ export function getEnvironmentPrompt(): string {
21
21
  const paths = getPaths();
22
22
  const config = getConfig();
23
23
 
24
+ // Build watch channel summary if Slack is configured with watch channels
25
+ let slackWatch = "";
26
+ const watch = config.channels.slack.watch;
27
+ if (watch) {
28
+ const entries = Object.entries(watch).map(([name, cfg]) => ` - #${name}: ${cfg.behavior}`);
29
+ slackWatch = `\nActive watch channels:\n${entries.join("\n")}`;
30
+ }
31
+
24
32
  return interpolate(loadPrompt("environment.md"), {
25
33
  configPath: paths.config,
26
34
  dbUrl: config.database_url.replace(/\/\/.*@/, "//***@"),
@@ -31,6 +39,7 @@ export function getEnvironmentPrompt(): string {
31
39
  activeEnd: config.activeHours.end,
32
40
  model: config.model,
33
41
  logLevel: config.log_level,
42
+ slackWatch,
34
43
  });
35
44
  }
36
45
 
@@ -4,6 +4,10 @@ export interface TelegramConfig {
4
4
  open: boolean;
5
5
  }
6
6
 
7
+ export interface SlackWatchChannel {
8
+ behavior: string;
9
+ }
10
+
7
11
  export interface SlackConfig {
8
12
  bot_token: string | null;
9
13
  app_token: string | null;
@@ -14,6 +18,7 @@ export interface SlackConfig {
14
18
  workspace: string | null;
15
19
  workspace_id: string | null;
16
20
  workspace_url: string | null;
21
+ watch: Record<string, SlackWatchChannel> | null;
17
22
  }
18
23
 
19
24
  export interface ChannelsConfig {
@@ -20,7 +20,7 @@ const DEFAULTS: Config = {
20
20
  enabled: true,
21
21
  default: "telegram",
22
22
  telegram: { bot_token: null, chat_id: null, open: false },
23
- slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null, bot_user_id: null, bot_name: null, workspace: null, workspace_id: null, workspace_url: null },
23
+ slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null, bot_user_id: null, bot_name: null, workspace: null, workspace_id: null, workspace_url: null, watch: null },
24
24
  },
25
25
  };
26
26
 
@@ -142,6 +142,19 @@ export function loadConfig(): Config {
142
142
  const slWorkspaceUrl =
143
143
  typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
144
144
 
145
+ // Slack watch channels
146
+ const rawWatch = chSl.watch as Record<string, unknown> | undefined;
147
+ let slWatch: Record<string, { behavior: string }> | null = null;
148
+ if (rawWatch && typeof rawWatch === "object") {
149
+ slWatch = {};
150
+ for (const [name, val] of Object.entries(rawWatch)) {
151
+ if (val && typeof val === "object" && typeof (val as any).behavior === "string") {
152
+ slWatch[name] = { behavior: (val as any).behavior };
153
+ }
154
+ }
155
+ if (Object.keys(slWatch).length === 0) slWatch = null;
156
+ }
157
+
145
158
  return {
146
159
  model,
147
160
  runner,
@@ -154,7 +167,7 @@ export function loadConfig(): Config {
154
167
  enabled: channelsEnabled,
155
168
  default: defaultChannel,
156
169
  telegram: { bot_token: tgBotToken, chat_id: tgChatId, open: tgOpen },
157
- slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId, bot_user_id: slBotUserId, bot_name: slBotName, workspace: slWorkspace, workspace_id: slWorkspaceId, workspace_url: slWorkspaceUrl },
170
+ slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId, bot_user_id: slBotUserId, bot_name: slBotName, workspace: slWorkspace, workspace_id: slWorkspaceId, workspace_url: slWorkspaceUrl, watch: slWatch },
158
171
  },
159
172
  };
160
173
  }