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 +1 -1
- package/src/channels/slack.ts +152 -19
- package/src/cli/index.ts +9 -0
- package/src/commands/validate.ts +153 -0
- package/src/mcp/server.ts +23 -2
- package/src/mcp/tools.ts +22 -14
- package/src/prompts/channel-slack.md +9 -1
- package/src/prompts/environment.md +4 -0
- package/src/prompts/index.ts +9 -0
- package/src/types/config.ts +5 -0
- package/src/utils/config.ts +15 -2
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
package/src/prompts/index.ts
CHANGED
|
@@ -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
|
|
package/src/types/config.ts
CHANGED
|
@@ -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 {
|
package/src/utils/config.ts
CHANGED
|
@@ -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
|
}
|