niahere 0.2.21 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,11 +1,15 @@
1
1
  import { App } from "@slack/bolt";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { createHash } from "crypto";
2
5
  import { createChatEngine } from "../chat/engine";
3
- import type { Channel, ChatState, Attachment } from "../types";
6
+ import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
4
7
  import { getConfig, updateRawConfig } from "../utils/config";
5
8
  import { runMigrations } from "../db/migrate";
6
9
  import { Session } from "../db/models";
7
10
  import { log } from "../utils/log";
8
11
  import { getMcpServers } from "../mcp";
12
+ import { getNiaHome } from "../utils/paths";
9
13
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
10
14
 
11
15
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
@@ -128,6 +132,10 @@ class SlackChannel implements Channel {
128
132
 
129
133
  let botUserId: string | undefined;
130
134
 
135
+ // Watch channels: resolved after app.start() via conversations.list
136
+ // Maps channel ID → { name, behavior }
137
+ const watchChannels = new Map<string, { name: string; behavior: string }>();
138
+
131
139
  // Slash command: /nia
132
140
  app.command("/nia", async ({ command, ack, respond }) => {
133
141
  await ack();
@@ -172,6 +180,26 @@ class SlackChannel implements Channel {
172
180
  await respond("New conversation started.");
173
181
  });
174
182
 
183
+ // Disk-backed file cache: download once, read from disk on subsequent requests
184
+ const attachDir = join(getNiaHome(), "tmp", "attachments");
185
+ mkdirSync(attachDir, { recursive: true });
186
+
187
+ interface CachedFile {
188
+ path: string;
189
+ type: AttachmentType;
190
+ mimeType: string;
191
+ filename?: string;
192
+ }
193
+ const fileIndex = new Map<string, CachedFile>();
194
+
195
+ function urlHash(url: string): string {
196
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
197
+ }
198
+
199
+ function loadCached(entry: CachedFile): Attachment {
200
+ return { type: entry.type, data: readFileSync(entry.path), mimeType: entry.mimeType, filename: entry.filename };
201
+ }
202
+
175
203
  async function downloadSlackFile(url: string): Promise<Buffer> {
176
204
  const resp = await fetch(url, {
177
205
  headers: { Authorization: `Bearer ${botToken}` },
@@ -187,6 +215,31 @@ class SlackChannel implements Channel {
187
215
  const attType = classifyMime(mime);
188
216
  if (!attType) continue;
189
217
  if (!file.url_private_download) continue;
218
+
219
+ // Check in-memory index first
220
+ const cached = fileIndex.get(file.url_private_download);
221
+ if (cached && existsSync(cached.path)) {
222
+ attachments.push(loadCached(cached));
223
+ continue;
224
+ }
225
+
226
+ // Check disk (survives daemon restarts) — keyed by URL hash only (global dedup)
227
+ const hash = urlHash(file.url_private_download);
228
+ const ext = file.name?.split(".").pop() || "bin";
229
+ const diskPath = join(attachDir, `${hash}.${ext}`);
230
+ const metaPath = join(attachDir, `${hash}.meta.json`);
231
+ if (existsSync(diskPath) && existsSync(metaPath)) {
232
+ try {
233
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
234
+ const entry: CachedFile = { path: diskPath, type: meta.type || attType, mimeType: meta.mimeType || mime, filename: meta.filename || file.name };
235
+ fileIndex.set(file.url_private_download, entry);
236
+ attachments.push(loadCached(entry));
237
+ continue;
238
+ } catch {
239
+ // Corrupt meta — re-download
240
+ }
241
+ }
242
+
190
243
  try {
191
244
  const data = await downloadSlackFile(file.url_private_download);
192
245
  const error = validateAttachment(data, mime);
@@ -201,6 +254,13 @@ class SlackChannel implements Channel {
201
254
  finalData = prepared.data;
202
255
  finalMime = prepared.mimeType;
203
256
  }
257
+
258
+ // Save file + metadata to disk
259
+ writeFileSync(diskPath, finalData);
260
+ writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
261
+ const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
262
+ fileIndex.set(file.url_private_download, entry);
263
+
204
264
  attachments.push({ type: attType, data: finalData, mimeType: finalMime, filename: file.name });
205
265
  } catch (err) {
206
266
  log.warn({ err, file: file.name }, "failed to download slack file");
@@ -269,7 +329,11 @@ class SlackChannel implements Channel {
269
329
  }
270
330
  }
271
331
 
272
- if (!isDm && !isMention && !isActiveThread) {
332
+ // Check if this is a watched channel
333
+ const watchConfig = watchChannels.get(msg.channel);
334
+ const isWatched = !!watchConfig;
335
+
336
+ if (!isDm && !isMention && !isActiveThread && !isWatched) {
273
337
  log.debug({
274
338
  channel: msg.channel,
275
339
  text: (msg.text || "").slice(0, 80),
@@ -294,15 +358,6 @@ class SlackChannel implements Channel {
294
358
  text = `[user:${msg.user}] ${text}`;
295
359
  }
296
360
 
297
- // Download any file attachments
298
- let attachments: Attachment[] | undefined;
299
- if (hasFiles) {
300
- attachments = await extractSlackAttachments(msg.files!);
301
- }
302
-
303
- if (!text && (!attachments || attachments.length === 0)) return;
304
- if (!text) text = attachments?.some(a => a.type === "image") ? "What's in this image?" : "Here's a file.";
305
-
306
361
  // Auto-register DM user for outbound messages
307
362
  if (isDm && !self.dmUserId && msg.user) {
308
363
  self.dmUserId = msg.user;
@@ -328,29 +383,65 @@ class SlackChannel implements Channel {
328
383
  replyThreadTs = threadTs;
329
384
  }
330
385
 
386
+ // Download any file attachments
387
+ let attachments: Attachment[] | undefined;
388
+ if (hasFiles) {
389
+ attachments = await extractSlackAttachments(msg.files!);
390
+ }
391
+
392
+ if (!text && (!attachments || attachments.length === 0)) return;
393
+ if (!text) text = attachments?.some(a => a.type === "image") ? "What's in this image?" : "Here's a file.";
394
+
331
395
  // When replying in a thread, fetch thread context so Nia can see the full conversation
332
396
  if (msg.thread_ts) {
333
397
  try {
334
398
  const replies = await client.conversations.replies({
335
399
  channel: msg.channel,
336
400
  ts: msg.thread_ts,
337
- limit: 20,
401
+ limit: 50,
402
+ });
403
+ const priorMessages = (replies.messages || [])
404
+ .filter((m: any) => m.ts !== msg.ts); // exclude the triggering message
405
+
406
+ const threadMessages = priorMessages.map((m: any) => {
407
+ const sender = m.bot_id ? "bot" : (m.user || "unknown");
408
+ const fileHint = m.files?.length ? ` [${m.files.length} file(s) attached]` : "";
409
+ return `[${sender}]: ${m.text || "(no text)"}${fileHint}`;
338
410
  });
339
- const threadMessages = (replies.messages || [])
340
- .filter((m: any) => m.ts !== msg.ts) // exclude the triggering message
341
- .map((m: any) => {
342
- const sender = m.bot_id ? "bot" : (m.user || "unknown");
343
- return `[${sender}]: ${m.text || "(no text)"}`;
344
- });
345
411
  if (threadMessages.length > 0) {
346
412
  text = `[Thread context]\n${threadMessages.join("\n")}\n\n[Current message]\n${text}`;
347
413
  }
414
+
415
+ // Download files from recent thread messages (last 5 messages, max 5 files total)
416
+ if (!attachments) attachments = [];
417
+ const threadFileBudget = 5 - attachments.length;
418
+ if (threadFileBudget > 0) {
419
+ const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0).slice(-5);
420
+ let threadFilesAdded = 0;
421
+ for (const m of messagesWithFiles) {
422
+ if (threadFilesAdded >= threadFileBudget) break;
423
+ const extracted = await extractSlackAttachments(m.files || []);
424
+ for (const att of extracted) {
425
+ if (threadFilesAdded >= threadFileBudget) break;
426
+ attachments.push(att);
427
+ threadFilesAdded++;
428
+ }
429
+ }
430
+ if (threadFilesAdded > 0) {
431
+ log.info({ threadFiles: threadFilesAdded, channel: msg.channel }, "slack: downloaded thread attachments");
432
+ }
433
+ }
348
434
  } catch (err) {
349
435
  log.warn({ err, channel: msg.channel, thread_ts: msg.thread_ts }, "failed to fetch thread context");
350
436
  }
351
437
  }
352
438
 
353
- log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, attachments: attachments?.length || 0 }, "slack message received");
439
+ // Prepend watch behavior context for watched channels
440
+ if (watchConfig) {
441
+ text = `[Watch mode — #${watchConfig.name}]\nBehavior: ${watchConfig.behavior}\nRespond with [NO_REPLY] if no action needed.\n\n${text}`;
442
+ }
443
+
444
+ log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, watched: isWatched, attachments: attachments?.length || 0 }, "slack message received");
354
445
 
355
446
  const state = await getState(key);
356
447
 
@@ -417,6 +508,39 @@ class SlackChannel implements Channel {
417
508
  log.warn({ err }, "could not get slack bot user ID");
418
509
  }
419
510
 
511
+ // Resolve watch channel names → IDs
512
+ const watchConfig = config.channels.slack.watch;
513
+ if (watchConfig) {
514
+ try {
515
+ const channelList: { id: string; name: string }[] = [];
516
+ let cursor: string | undefined;
517
+ do {
518
+ const resp = await app.client.conversations.list({
519
+ types: "public_channel,private_channel",
520
+ exclude_archived: true,
521
+ limit: 200,
522
+ cursor,
523
+ });
524
+ for (const ch of resp.channels || []) {
525
+ if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
526
+ }
527
+ cursor = resp.response_metadata?.next_cursor || undefined;
528
+ } while (cursor);
529
+
530
+ for (const [name, cfg] of Object.entries(watchConfig)) {
531
+ const match = channelList.find((c) => c.name === name);
532
+ if (match) {
533
+ watchChannels.set(match.id, { name, behavior: cfg.behavior });
534
+ log.info({ channel: name, id: match.id }, "slack: watching channel");
535
+ } else {
536
+ log.warn({ channel: name }, "slack: watch channel not found");
537
+ }
538
+ }
539
+ } catch (err) {
540
+ log.warn({ err }, "slack: failed to resolve watch channels");
541
+ }
542
+ }
543
+
420
544
  log.info("slack bot started (Socket Mode)");
421
545
  this.app = app;
422
546
  }
package/src/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 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,11 @@
24
24
  - Stay quiet if: users are talking to each other, the message is clearly not directed at you, or it's a reaction/acknowledgement between humans.
25
25
  - When in doubt, stay quiet. Better to miss one than to interrupt a human conversation.
26
26
  - Never say "was that for me?" or similar — just respond or don't.
27
- - To stay quiet, respond with exactly `[NO_REPLY]` and nothing else. This tells the system to skip sending a message.
27
+ - To stay quiet, respond with exactly `[NO_REPLY]` and nothing else. This tells the system to skip sending a message.
28
+
29
+ ### Watch mode
30
+ - Some channels are configured for proactive monitoring via `channels.slack.watch` in config.
31
+ - In watch channels, you receive ALL messages — not just @mentions. Messages are prefixed with `[Watch mode — #channel-name]` and a behavior prompt.
32
+ - Follow the behavior prompt to decide what to do: flag issues, escalate, or stay quiet.
33
+ - Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
34
+ - You can manage watch channels via `add_watch_channel` / `remove_watch_channel` MCP tools (requires daemon restart).
@@ -22,6 +22,8 @@ You have MCP tools for managing jobs directly — no need for shell commands:
22
22
  - **run_job** — trigger a job to run immediately
23
23
  - **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
24
24
  - **list_messages** — read recent chat history
25
+ - **add_watch_channel** — add a Slack channel for proactive monitoring. Specify channel name and behavior prompt. Requires daemon restart.
26
+ - **remove_watch_channel** — stop watching a Slack channel. Requires daemon restart.
25
27
  - **add_rule** — save a behavioral rule (loaded into every session, no restart needed). Use when told "from now on", "always", "never", or "remember to always..."
26
28
  - **add_memory** — save a factual memory (read on demand). Use when told "remember that...", or when you learn something surprising worth keeping
27
29
 
@@ -57,6 +59,8 @@ Config reference:
57
59
  - `channels.slack.app_token` — Slack app token (xapp-...)
58
60
  - `channels.slack.channel_id` — default Slack channel for outbound
59
61
  - `channels.slack.dm_user_id` — auto-registered DM user
62
+ - `channels.slack.watch` — per-channel proactive monitoring (see Watch mode in Slack docs)
63
+ {{slackWatch}}
60
64
 
61
65
  ## Persona & Memory
62
66
 
@@ -21,6 +21,14 @@ export function getEnvironmentPrompt(): string {
21
21
  const paths = getPaths();
22
22
  const config = getConfig();
23
23
 
24
+ // Build watch channel summary if Slack is configured with watch channels
25
+ let slackWatch = "";
26
+ const watch = config.channels.slack.watch;
27
+ if (watch) {
28
+ const entries = Object.entries(watch).map(([name, cfg]) => ` - #${name}: ${cfg.behavior}`);
29
+ slackWatch = `\nActive watch channels:\n${entries.join("\n")}`;
30
+ }
31
+
24
32
  return interpolate(loadPrompt("environment.md"), {
25
33
  configPath: paths.config,
26
34
  dbUrl: config.database_url.replace(/\/\/.*@/, "//***@"),
@@ -31,6 +39,7 @@ export function getEnvironmentPrompt(): string {
31
39
  activeEnd: config.activeHours.end,
32
40
  model: config.model,
33
41
  logLevel: config.log_level,
42
+ slackWatch,
34
43
  });
35
44
  }
36
45
 
@@ -4,6 +4,10 @@ export interface TelegramConfig {
4
4
  open: boolean;
5
5
  }
6
6
 
7
+ export interface SlackWatchChannel {
8
+ behavior: string;
9
+ }
10
+
7
11
  export interface SlackConfig {
8
12
  bot_token: string | null;
9
13
  app_token: string | null;
@@ -14,6 +18,7 @@ export interface SlackConfig {
14
18
  workspace: string | null;
15
19
  workspace_id: string | null;
16
20
  workspace_url: string | null;
21
+ watch: Record<string, SlackWatchChannel> | null;
17
22
  }
18
23
 
19
24
  export interface ChannelsConfig {
@@ -20,7 +20,7 @@ const DEFAULTS: Config = {
20
20
  enabled: true,
21
21
  default: "telegram",
22
22
  telegram: { bot_token: null, chat_id: null, open: false },
23
- slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null, bot_user_id: null, bot_name: null, workspace: null, workspace_id: null, workspace_url: null },
23
+ slack: { bot_token: null, app_token: null, channel_id: null, dm_user_id: null, bot_user_id: null, bot_name: null, workspace: null, workspace_id: null, workspace_url: null, watch: null },
24
24
  },
25
25
  };
26
26
 
@@ -142,6 +142,19 @@ export function loadConfig(): Config {
142
142
  const slWorkspaceUrl =
143
143
  typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
144
144
 
145
+ // Slack watch channels
146
+ const rawWatch = chSl.watch as Record<string, unknown> | undefined;
147
+ let slWatch: Record<string, { behavior: string }> | null = null;
148
+ if (rawWatch && typeof rawWatch === "object") {
149
+ slWatch = {};
150
+ for (const [name, val] of Object.entries(rawWatch)) {
151
+ if (val && typeof val === "object" && typeof (val as any).behavior === "string") {
152
+ slWatch[name] = { behavior: (val as any).behavior };
153
+ }
154
+ }
155
+ if (Object.keys(slWatch).length === 0) slWatch = null;
156
+ }
157
+
145
158
  return {
146
159
  model,
147
160
  runner,
@@ -154,7 +167,7 @@ export function loadConfig(): Config {
154
167
  enabled: channelsEnabled,
155
168
  default: defaultChannel,
156
169
  telegram: { bot_token: tgBotToken, chat_id: tgChatId, open: tgOpen },
157
- slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId, bot_user_id: slBotUserId, bot_name: slBotName, workspace: slWorkspace, workspace_id: slWorkspaceId, workspace_url: slWorkspaceUrl },
170
+ slack: { bot_token: slBotToken, app_token: slAppToken, channel_id: slChannelId, dm_user_id: slDmUserId, bot_user_id: slBotUserId, bot_name: slBotName, workspace: slWorkspace, workspace_id: slWorkspaceId, workspace_url: slWorkspaceUrl, watch: slWatch },
158
171
  },
159
172
  };
160
173
  }