niahere 0.2.21 → 0.2.23

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.21",
3
+ "version": "0.2.23",
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,48 @@ class SlackChannel implements Channel {
417
508
  log.warn({ err }, "could not get slack bot user ID");
418
509
  }
419
510
 
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
+ }
552
+
420
553
  log.info("slack bot started (Socket Mode)");
421
554
  this.app = app;
422
555
  }
package/src/cli/index.ts CHANGED
@@ -412,6 +412,14 @@ switch (command) {
412
412
  process.exit(exitCode);
413
413
  }
414
414
 
415
+ case "validate": {
416
+ const { validateConfig } = await import("../commands/validate");
417
+ const result = validateConfig();
418
+ for (const msg of result.messages) console.log(` ${msg}`);
419
+ console.log(result.ok ? "\nConfig is valid." : "\nConfig has errors.");
420
+ process.exit(result.ok ? 0 : 1);
421
+ }
422
+
415
423
  case "init": {
416
424
  const { runInit } = await import("../commands/init");
417
425
  await runInit();
@@ -434,6 +442,7 @@ switch (command) {
434
442
  console.log(" memory [show|reset] — view or reset memory.md");
435
443
  console.log(" db <sub> — database setup/status/migrate");
436
444
  console.log(" skills — list available skills");
445
+ console.log(" validate — validate config.yaml");
437
446
  console.log(" config <sub> — get/set/list config values");
438
447
  console.log(" send [-c ch] <msg> — send a message via channel");
439
448
  console.log(" telegram <token> — configure telegram");
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import yaml from "js-yaml";
3
+ import { getPaths } from "../utils/paths";
4
+
5
+ const PASS = "\u2713";
6
+ const FAIL = "\u2717";
7
+ const WARN = "\u26A0";
8
+
9
+ interface Result {
10
+ ok: boolean;
11
+ messages: string[];
12
+ }
13
+
14
+ function check(label: string, fn: () => string | null): { icon: string; label: string; detail?: string } {
15
+ const err = fn();
16
+ if (err) return { icon: FAIL, label, detail: err };
17
+ return { icon: PASS, label };
18
+ }
19
+
20
+ function warn(label: string, detail: string): { icon: string; label: string; detail?: string } {
21
+ return { icon: WARN, label, detail };
22
+ }
23
+
24
+ export function validateConfig(): Result {
25
+ const { config: configPath } = getPaths();
26
+ const messages: string[] = [];
27
+ let ok = true;
28
+
29
+ // File exists
30
+ if (!existsSync(configPath)) {
31
+ return { ok: false, messages: [`${FAIL} config.yaml not found at ${configPath}`] };
32
+ }
33
+
34
+ // Valid YAML
35
+ let raw: Record<string, unknown>;
36
+ try {
37
+ const parsed = yaml.load(readFileSync(configPath, "utf8"));
38
+ if (!parsed || typeof parsed !== "object") {
39
+ return { ok: false, messages: [`${FAIL} config.yaml is empty or not an object`] };
40
+ }
41
+ raw = parsed as Record<string, unknown>;
42
+ messages.push(`${PASS} valid YAML`);
43
+ } catch (err) {
44
+ return { ok: false, messages: [`${FAIL} invalid YAML: ${(err as Error).message}`] };
45
+ }
46
+
47
+ // Timezone
48
+ if (raw.timezone) {
49
+ try {
50
+ Intl.DateTimeFormat(undefined, { timeZone: raw.timezone as string });
51
+ messages.push(`${PASS} timezone: ${raw.timezone}`);
52
+ } catch {
53
+ messages.push(`${FAIL} invalid timezone: ${raw.timezone}`);
54
+ ok = false;
55
+ }
56
+ }
57
+
58
+ // Active hours
59
+ const ah = raw.active_hours as Record<string, string> | undefined;
60
+ if (ah) {
61
+ const timeRe = /^\d{2}:\d{2}$/;
62
+ if (ah.start && !timeRe.test(ah.start)) {
63
+ messages.push(`${FAIL} active_hours.start invalid: "${ah.start}" (expected HH:MM)`);
64
+ ok = false;
65
+ } else if (ah.start) {
66
+ messages.push(`${PASS} active_hours: ${ah.start}–${ah.end || "?"}`);
67
+ }
68
+ if (ah.end && !timeRe.test(ah.end)) {
69
+ messages.push(`${FAIL} active_hours.end invalid: "${ah.end}" (expected HH:MM)`);
70
+ ok = false;
71
+ }
72
+ }
73
+
74
+ // Database URL
75
+ const dbUrl = (process.env.DATABASE_URL || raw.database_url) as string | undefined;
76
+ if (dbUrl && dbUrl.startsWith("postgres")) {
77
+ messages.push(`${PASS} database_url set`);
78
+ } else if (!dbUrl) {
79
+ messages.push(`${WARN} database_url not set (will use default)`);
80
+ }
81
+
82
+ // Runner
83
+ const runner = raw.runner as string | undefined;
84
+ if (runner && runner !== "claude" && runner !== "codex") {
85
+ messages.push(`${FAIL} runner must be "claude" or "codex", got "${runner}"`);
86
+ ok = false;
87
+ } else if (runner) {
88
+ messages.push(`${PASS} runner: ${runner}`);
89
+ }
90
+
91
+ // Channels
92
+ const ch = raw.channels as Record<string, unknown> | undefined;
93
+ if (ch) {
94
+ // Telegram
95
+ const tg = ch.telegram as Record<string, unknown> | undefined;
96
+ if (tg) {
97
+ if (tg.bot_token) {
98
+ messages.push(`${PASS} telegram.bot_token set`);
99
+ } else {
100
+ messages.push(`${WARN} telegram.bot_token missing — telegram won't start`);
101
+ }
102
+ }
103
+
104
+ // Slack
105
+ const sl = ch.slack as Record<string, unknown> | undefined;
106
+ if (sl) {
107
+ if (!sl.bot_token) {
108
+ messages.push(`${WARN} slack.bot_token missing — slack won't start`);
109
+ } else {
110
+ messages.push(`${PASS} slack.bot_token set`);
111
+ }
112
+ if (!sl.app_token) {
113
+ messages.push(`${WARN} slack.app_token missing — slack won't start (Socket Mode requires app_token)`);
114
+ } else {
115
+ messages.push(`${PASS} slack.app_token set`);
116
+ }
117
+
118
+ // Watch channels
119
+ const watch = sl.watch as Record<string, unknown> | undefined;
120
+ if (watch) {
121
+ for (const [key, val] of Object.entries(watch)) {
122
+ if (!val || typeof val !== "object") {
123
+ messages.push(`${FAIL} slack.watch.${key}: must be an object with "behavior" field`);
124
+ ok = false;
125
+ continue;
126
+ }
127
+ const behavior = (val as Record<string, unknown>).behavior;
128
+ if (typeof behavior !== "string" || !behavior.trim()) {
129
+ messages.push(`${FAIL} slack.watch.${key}: missing "behavior" string`);
130
+ ok = false;
131
+ continue;
132
+ }
133
+ const hasId = key.includes("#");
134
+ if (hasId) {
135
+ messages.push(`${PASS} slack.watch: ${key}`);
136
+ } else {
137
+ messages.push(`${WARN} slack.watch.${key}: using channel name — prefer "channel_id#${key}" format for reliability`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // Unknown channel keys
144
+ const knownChannelKeys = new Set(["enabled", "default", "telegram", "slack"]);
145
+ for (const key of Object.keys(ch)) {
146
+ if (!knownChannelKeys.has(key)) {
147
+ messages.push(`${WARN} unknown channel key: "channels.${key}" — did you mean "channels.slack"?`);
148
+ }
149
+ }
150
+ }
151
+
152
+ return { ok, messages };
153
+ }
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 key as 'channel_id#channel_name', e.g. 'C1234567890#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 key to stop watching (e.g. 'C1234567890#ask-kay-thread-notifications')"),
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 concise factual memory for future reference. Memories are read on demand, not loaded automatically. Use for preferences, corrections, or patterns worth keeping. RULES: Max 200 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'.",
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().max(300).describe("A single concise insight (max 200 chars, no raw logs or transcripts)"),
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,6 +229,26 @@ 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 {
233
253
  // Guard: reject raw logs, transcripts, and overly long entries
234
254
  const trimmed = entry.trim();
@@ -241,19 +261,7 @@ export function addMemory(entry: string): string {
241
261
  const memoryPath = join(selfDir, "memory.md");
242
262
  const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
243
263
 
244
- // Deduplicate: skip if a substantially similar entry already exists
245
- const normalized = trimmed.toLowerCase().replace(/[^a-z0-9 ]/g, "");
246
- const lines = existing.split("\n").filter((l) => l.startsWith("- "));
247
- for (const line of lines) {
248
- const norm = line.slice(2).toLowerCase().replace(/[^a-z0-9 ]/g, "");
249
- // Check if >60% of words overlap
250
- const newWords = new Set(normalized.split(/\s+/).filter(Boolean));
251
- const oldWords = new Set(norm.split(/\s+/).filter(Boolean));
252
- if (newWords.size === 0) continue;
253
- let overlap = 0;
254
- for (const w of newWords) { if (oldWords.has(w)) overlap++; }
255
- if (overlap / newWords.size > 0.6) return "Rejected: similar memory already exists.";
256
- }
264
+ // TODO: add semantic dedup later (embeddings or similar)
257
265
 
258
266
  const date = new Date().toISOString().slice(0, 10);
259
267
  const header = `\n## ${date}`;
@@ -24,4 +24,12 @@
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
+ - Watch channel keys use the format `channel_id#channel_name` (e.g. `C1234567890#ask-kay-thread-notifications`). The ID is used for matching; the name is for readability.
32
+ - In watch channels, you receive ALL messages — not just @mentions. Messages are prefixed with `[Watch mode — #channel-name]` and a behavior prompt.
33
+ - Follow the behavior prompt to decide what to do: flag issues, escalate, or stay quiet.
34
+ - Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
35
+ - 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. Keys are `channel_id#channel_name` format.
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
  }