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.
- package/defaults/self/memory.md +8 -7
- package/package.json +1 -1
- package/src/channels/slack.ts +143 -19
- package/src/cli/index.ts +13 -0
- package/src/cli/job.ts +1 -1
- package/src/cli/self.ts +74 -0
- package/src/core/runner.ts +14 -10
- package/src/mcp/server.ts +23 -2
- package/src/mcp/tools.ts +34 -6
- package/src/prompts/channel-slack.md +8 -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/defaults/self/memory.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Memory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Concise things I've picked up that I don't want to forget. I maintain this myself.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
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
|
|
12
|
+
Entries are grouped by date. Use `add_memory` tool to append.
|
|
12
13
|
|
|
13
14
|
---
|
|
14
15
|
|
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,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}
|
|
52
|
+
console.log(` ${job.enabled ? "●" : "○"} ${job.name} ${job.schedule}${type}${tag}`);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
});
|
package/src/cli/self.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|