niahere 0.2.70 → 0.2.72

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.70",
3
+ "version": "0.2.72",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -35,6 +35,14 @@ class SlackChannel implements Channel {
35
35
  if (result.ts) this.outboundTs.add(result.ts);
36
36
  }
37
37
 
38
+ async sendToThread(channelId: string, text: string, threadTs?: string): Promise<void> {
39
+ if (!this.app) throw new Error("Slack not started");
40
+ const opts: Record<string, unknown> = { channel: channelId, text };
41
+ if (threadTs) opts.thread_ts = threadTs;
42
+ const result = await this.app.client.chat.postMessage(opts as any);
43
+ if (result.ts) this.outboundTs.add(result.ts);
44
+ }
45
+
38
46
  async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
39
47
  if (!this.app) throw new Error("Slack not started");
40
48
  const target = this.defaultChannelId || this.dmUserId;
@@ -79,7 +87,12 @@ class SlackChannel implements Channel {
79
87
  return `slack-${key}-${index}`;
80
88
  }
81
89
 
82
- async function getState(key: string, watchBehavior?: { channel: string; behavior: string }): Promise<ChatState> {
90
+ interface SlackContext {
91
+ slackChannelId?: string;
92
+ slackThreadTs?: string;
93
+ }
94
+
95
+ async function getState(key: string, watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext): Promise<ChatState> {
83
96
  let state = chats.get(key);
84
97
  if (!state) {
85
98
  const prefix = roomPrefix(key);
@@ -89,7 +102,7 @@ class SlackChannel implements Channel {
89
102
  room,
90
103
  channel: "slack",
91
104
  resume: true,
92
- mcpServers: getMcpServers(),
105
+ mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
93
106
  watchBehavior,
94
107
  });
95
108
  state = { engine, roomIndex: idx, lock: Promise.resolve() };
@@ -98,7 +111,7 @@ class SlackChannel implements Channel {
98
111
  return state;
99
112
  }
100
113
 
101
- async function restartChat(key: string, watchBehavior?: { channel: string; behavior: string }): Promise<ChatState> {
114
+ async function restartChat(key: string, watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext): Promise<ChatState> {
102
115
  const old = chats.get(key);
103
116
  if (old) old.engine.close();
104
117
 
@@ -116,7 +129,7 @@ class SlackChannel implements Channel {
116
129
  room,
117
130
  channel: "slack",
118
131
  resume: false,
119
- mcpServers: getMcpServers(),
132
+ mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
120
133
  watchBehavior,
121
134
  });
122
135
  const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
@@ -446,7 +459,9 @@ class SlackChannel implements Channel {
446
459
  }
447
460
 
448
461
  // Build session key:
449
- // - DMs: flat, one session per user slack-dm-{userId}
462
+ // - DMs: flat by default, threaded if replying in a thread
463
+ // - Flat: slack-dm-{userId}
464
+ // - Threaded: slack-dm-{userId}-t{threadTs} (scoped to that thread)
450
465
  // - Channels: per-thread → slack-{channelName}-t{threadTs}
451
466
  // - Top-level @mention starts a new thread (uses msg.ts as thread root)
452
467
  // - Reply in thread continues that thread's session
@@ -454,8 +469,13 @@ class SlackChannel implements Channel {
454
469
  let replyThreadTs: string | undefined;
455
470
 
456
471
  if (isDm) {
457
- key = `dm-${msg.user}`;
458
- // DMs stay flat, no threading
472
+ if (msg.thread_ts) {
473
+ // Thread reply in DM — scoped session for this thread
474
+ key = `dm-${msg.user}-t${msg.thread_ts}`;
475
+ replyThreadTs = msg.thread_ts;
476
+ } else {
477
+ key = `dm-${msg.user}`;
478
+ }
459
479
  } else {
460
480
  const channelName = await resolveChannelName(app, msg.channel);
461
481
  const threadTs = msg.thread_ts || msg.ts; // existing thread or start new one
@@ -540,14 +560,37 @@ class SlackChannel implements Channel {
540
560
  );
541
561
 
542
562
  let state: ChatState;
563
+ const slackCtx: SlackContext = {
564
+ slackChannelId: msg.channel,
565
+ slackThreadTs: replyThreadTs,
566
+ };
543
567
  try {
544
- state = await getState(key, watchBehavior);
568
+ state = await getState(key, watchBehavior, slackCtx);
545
569
  } catch (err) {
546
570
  log.error({ err, key }, "slack: failed to create chat engine");
547
571
  return;
548
572
  }
549
573
 
550
574
  withLock(key, async () => {
575
+ // For flat DM messages (no thread), prepend recent notifications so
576
+ // the bot knows what jobs/watches recently sent to this user.
577
+ if (isDm && !msg.thread_ts) {
578
+ try {
579
+ const dmPrefix = `slack-dm-${msg.user}`;
580
+ const notifications = await Message.getRecentNotifications(dmPrefix);
581
+ if (notifications.length > 0) {
582
+ const lines = notifications.map((n) => {
583
+ const ago = relativeTime(new Date(n.createdAt), new Date());
584
+ const src = n.source ? ` via ${n.source}` : "";
585
+ return `- (${ago}${src}): ${n.content}`;
586
+ });
587
+ text = `[Recent notifications you sent to the user]\n${lines.join("\n")}\n\n[Current message]\n${text}`;
588
+ }
589
+ } catch (err) {
590
+ log.warn({ err }, "slack: failed to load recent notifications for DM context");
591
+ }
592
+ }
593
+
551
594
  // Add thinking reaction inside the lock so cleanup is guaranteed
552
595
  await client.reactions
553
596
  .add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
package/src/chat/repl.ts CHANGED
@@ -126,7 +126,7 @@ export async function startRepl(
126
126
 
127
127
  // Initialize MCP server factory if not already set (standalone chat mode)
128
128
  if (!getMcpServers()) {
129
- setMcpFactory(() => ({ nia: createNiaMcpServer() }));
129
+ setMcpFactory((ctx) => ({ nia: createNiaMcpServer(ctx) }));
130
130
  }
131
131
 
132
132
  // Determine session to use
@@ -263,7 +263,7 @@ export async function runDaemon(): Promise<void> {
263
263
  }
264
264
 
265
265
  // Initialize MCP server factory (each query gets its own Protocol instance)
266
- setMcpFactory(() => ({ nia: createNiaMcpServer() }));
266
+ setMcpFactory((ctx) => ({ nia: createNiaMcpServer(ctx) }));
267
267
  log.info("MCP server factory initialized");
268
268
 
269
269
  // Register and start channels
@@ -12,7 +12,8 @@ import { buildEmployeePrompt } from "../chat/employee-prompt";
12
12
  import { getEmployee } from "./employees";
13
13
  import { scanAgents } from "./agents";
14
14
  import { truncate, formatToolUse } from "../utils/format-activity";
15
- import { getMcpServers } from "../mcp";
15
+ import { getMcpServers, type McpSourceContext } from "../mcp";
16
+ import { formatPromptDate } from "../utils/time";
16
17
  import { ActiveEngine } from "../db/models";
17
18
  import { getPaths } from "../utils/paths";
18
19
  import { log } from "../utils/log";
@@ -97,6 +98,7 @@ export async function runJobWithClaude(
97
98
  cwd: string,
98
99
  onActivity?: ActivityCallback,
99
100
  model?: string,
101
+ sourceCtx?: McpSourceContext,
100
102
  ): Promise<RunnerOutput> {
101
103
  const sessionId = randomUUID();
102
104
 
@@ -121,7 +123,7 @@ export async function runJobWithClaude(
121
123
  options.model = model;
122
124
  }
123
125
 
124
- const mcpServers = getMcpServers();
126
+ const mcpServers = getMcpServers(sourceCtx);
125
127
  if (mcpServers) {
126
128
  options.mcpServers = mcpServers;
127
129
  }
@@ -332,9 +334,10 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
332
334
  systemPrompt = buildSystemPrompt("job");
333
335
  }
334
336
 
337
+ const authoritativeDate = formatPromptDate(new Date(), config.timezone);
335
338
  let jobPrompt = job.prompt
336
- ? `Job: ${job.name} (schedule: ${job.schedule})\n\n${job.prompt}`
337
- : `Job: ${job.name} (schedule: ${job.schedule})\n\nExecute your scheduled tasks.`;
339
+ ? `Job: ${job.name} (schedule: ${job.schedule})\nToday: ${authoritativeDate}\n\n${job.prompt}`
340
+ : `Job: ${job.name} (schedule: ${job.schedule})\nToday: ${authoritativeDate}\n\nExecute your scheduled tasks.`;
338
341
 
339
342
  // Working memory: give stateful jobs a persistent workspace
340
343
  jobPrompt += buildWorkingMemory(job.name, job.stateless);
@@ -345,11 +348,13 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
345
348
  const MAX_API_RETRIES = 2;
346
349
  const RETRY_DELAYS = [3_000, 8_000]; // 3s, then 8s
347
350
 
351
+ const jobSourceCtx: McpSourceContext = { jobName: job.name, channel: "system" };
352
+
348
353
  if (config.runner === "codex") {
349
354
  const fullPrompt = `${systemPrompt}\n\n---\n\n${jobPrompt}`;
350
355
  output = await runJobWithCodex(fullPrompt, cwd, resolvedModel);
351
356
  } else {
352
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel);
357
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
353
358
 
354
359
  for (let attempt = 0; attempt < MAX_API_RETRIES && output.error && isRetryableApiError(output.error); attempt++) {
355
360
  const delay = RETRY_DELAYS[attempt] ?? 8_000;
@@ -358,7 +363,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
358
363
  "retrying after transient API error",
359
364
  );
360
365
  await sleep(delay);
361
- output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel);
366
+ output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
362
367
  }
363
368
  }
364
369
 
@@ -1,4 +1,5 @@
1
1
  import { getSql } from "../connection";
2
+ import { escapeRegex } from "./session";
2
3
  import type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "../../types";
3
4
 
4
5
  export type DeliveryStatus = "pending" | "sent" | "failed";
@@ -102,6 +103,45 @@ export async function getBySession(sessionId: string): Promise<SessionMessage[]>
102
103
  }));
103
104
  }
104
105
 
106
+ /**
107
+ * Fetch recent bot-authored notification messages for a room prefix.
108
+ * Used to inject context into flat DM replies so the user can reply
109
+ * to job/watch notifications without the bot losing context.
110
+ * Returns messages since the last user message, capped at 72h and limit.
111
+ */
112
+ export async function getRecentNotifications(
113
+ roomPrefix: string,
114
+ limit = 10,
115
+ ): Promise<Array<{ content: string; source: string | null; createdAt: string }>> {
116
+ const sql = getSql();
117
+ const pattern = `^${escapeRegex(roomPrefix)}-\\d+$`;
118
+ const rows = await sql`
119
+ SELECT m.content, m.metadata->>'source' AS source, m.created_at
120
+ FROM messages m
121
+ JOIN sessions s ON m.session_id = s.id
122
+ WHERE s.room ~ ${pattern}
123
+ AND m.is_from_agent = true
124
+ AND m.metadata->>'kind' = 'notification'
125
+ AND m.delivery_status != 'failed'
126
+ AND m.created_at >= GREATEST(
127
+ COALESCE(
128
+ (SELECT MAX(m2.created_at) FROM messages m2
129
+ JOIN sessions s2 ON m2.session_id = s2.id
130
+ WHERE s2.room ~ ${pattern} AND m2.is_from_agent = false),
131
+ NOW() - INTERVAL '72 hours'
132
+ ),
133
+ NOW() - INTERVAL '72 hours'
134
+ )
135
+ ORDER BY m.created_at DESC
136
+ LIMIT ${limit}
137
+ `;
138
+ return rows.reverse().map((r) => ({
139
+ content: r.content,
140
+ source: r.source || null,
141
+ createdAt: String(r.created_at),
142
+ }));
143
+ }
144
+
105
145
  export async function getRoomStats(): Promise<RoomStats[]> {
106
146
  const sql = getSql();
107
147
  const rows = await sql`
@@ -1,5 +1,10 @@
1
1
  import { getSql } from "../connection";
2
2
 
3
+ /** Escape regex metacharacters so a literal string can be used in a PostgreSQL ~ pattern. */
4
+ export function escapeRegex(s: string): string {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+
3
8
  export interface SessionSummary {
4
9
  id: string;
5
10
  room: string;
@@ -98,10 +103,11 @@ export async function getRecentSummaries(room: string, limit = 3): Promise<Array
98
103
  // Match summaries from sessions in the same channel (e.g. slack-dm-U...-*)
99
104
  // by extracting the room prefix (everything before the last -N index)
100
105
  const prefix = room.replace(/-\d+$/, "");
106
+ const pattern = `^${escapeRegex(prefix)}-\\d+$`;
101
107
  const rows = await sql`
102
108
  SELECT summary, updated_at
103
109
  FROM sessions
104
- WHERE room LIKE ${prefix + "-%"}
110
+ WHERE room ~ ${pattern}
105
111
  AND summary IS NOT NULL
106
112
  AND id != ${""}
107
113
  ORDER BY updated_at DESC
@@ -167,9 +173,10 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
167
173
 
168
174
  export async function getLatestRoomIndex(prefix: string): Promise<number> {
169
175
  const sql = getSql();
176
+ const pattern = `^${escapeRegex(prefix)}-\\d+$`;
170
177
  const rows = await sql`
171
178
  SELECT room FROM sessions
172
- WHERE room LIKE ${prefix + "-%"}
179
+ WHERE room ~ ${pattern}
173
180
  ORDER BY updated_at DESC
174
181
  LIMIT 1
175
182
  `;
package/src/mcp/index.ts CHANGED
@@ -1,10 +1,24 @@
1
+ /** Source context passed through the MCP factory so tools know who called them. */
2
+ export interface McpSourceContext {
3
+ /** Job name if called from a job runner, e.g. "scout" */
4
+ jobName?: string;
5
+ /** Channel name: "slack" | "telegram" | "terminal" */
6
+ channel?: string;
7
+ /** The room the calling engine is operating in */
8
+ room?: string;
9
+ /** Slack channel ID where the current session lives (for thread replies) */
10
+ slackChannelId?: string;
11
+ /** Slack thread timestamp (for replying back to the current thread) */
12
+ slackThreadTs?: string;
13
+ }
14
+
1
15
  /** Factory for per-query MCP servers — each query gets its own Protocol instance. */
2
- let _mcpFactory: (() => Record<string, unknown>) | null = null;
16
+ let _mcpFactory: ((ctx?: McpSourceContext) => Record<string, unknown>) | null = null;
3
17
 
4
- export function setMcpFactory(factory: () => Record<string, unknown>): void {
18
+ export function setMcpFactory(factory: (ctx?: McpSourceContext) => Record<string, unknown>): void {
5
19
  _mcpFactory = factory;
6
20
  }
7
21
 
8
- export function getMcpServers(): Record<string, unknown> | undefined {
9
- return _mcpFactory?.() ?? undefined;
22
+ export function getMcpServers(ctx?: McpSourceContext): Record<string, unknown> | undefined {
23
+ return _mcpFactory?.(ctx) ?? undefined;
10
24
  }
package/src/mcp/server.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { z } from "zod";
3
3
  import * as handlers from "./tools";
4
+ import type { McpSourceContext } from "./index";
4
5
 
5
- export function createNiaMcpServer() {
6
+ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
6
7
  return createSdkMcpServer({
7
8
  name: "nia",
8
9
  version: "0.1.0",
@@ -137,7 +138,7 @@ export function createNiaMcpServer() {
137
138
  ),
138
139
  tool(
139
140
  "send_message",
140
- "Send a message to the user via configured channel (telegram, slack). Uses default_channel from config if not specified. Can also send a file/image by providing media_path.",
141
+ "Send a message via configured channel. By default sends to the current context (if in a Slack thread, replies there; otherwise DMs the owner). Use target='dm' to force a DM regardless of context, or target='thread' to explicitly reply in the current thread.",
141
142
  {
142
143
  text: z.string().describe("Message text to send"),
143
144
  channel: z.string().optional().describe("Channel name (telegram, slack). Omit to use default."),
@@ -145,12 +146,16 @@ export function createNiaMcpServer() {
145
146
  .string()
146
147
  .optional()
147
148
  .describe("Absolute path to a file to send as an attachment (image, document)"),
149
+ target: z
150
+ .enum(["auto", "dm", "thread"])
151
+ .default("auto")
152
+ .describe("Where to send: 'auto' (current context — thread if in one, else DM), 'dm' (always DM the owner), 'thread' (reply in current thread)"),
148
153
  },
149
154
  async (args) => ({
150
155
  content: [
151
156
  {
152
157
  type: "text" as const,
153
- text: await handlers.sendMessage(args.text, args.channel, args.media_path),
158
+ text: await handlers.sendMessage(args.text, args.channel, args.media_path, sourceCtx, args.target),
154
159
  },
155
160
  ],
156
161
  }),
package/src/mcp/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
2
2
  import type { ScheduleType } from "../types";
3
3
  import { basename, join } from "path";
4
+ import { randomUUID } from "crypto";
4
5
  import { Job, Message, Session } from "../db/models";
5
6
  import { computeInitialNextRun } from "../core/scheduler";
6
7
  import { getConfig, readRawConfig, updateRawConfig, writeRawConfig } from "../utils/config";
@@ -10,6 +11,7 @@ import { log } from "../utils/log";
10
11
  import { classifyMime } from "../utils/attachment";
11
12
  import { scanAgents } from "../core/agents";
12
13
  import { listEmployeesForMcp } from "../core/employees";
14
+ import type { McpSourceContext } from "./index";
13
15
 
14
16
  export async function listJobs(): Promise<string> {
15
17
  const jobs = await Job.list();
@@ -219,17 +221,80 @@ async function sendMediaDirect(target: string, data: Buffer, mimeType: string, f
219
221
  throw new Error(`Channel "${target}" not configured`);
220
222
  }
221
223
 
222
- export async function sendMessage(text: string, channelName?: string, mediaPath?: string): Promise<string> {
224
+ export async function sendMessage(text: string, channelName?: string, mediaPath?: string, sourceCtx?: McpSourceContext, target: "auto" | "dm" | "thread" = "auto"): Promise<string> {
223
225
  const config = getConfig();
224
- const target = channelName || config.channels.default;
226
+ const channelTarget = channelName || config.channels.default;
225
227
 
226
228
  // Use started channel if available (daemon), otherwise call API directly (CLI)
227
- const channel = getChannel(target);
229
+ const channel = getChannel(channelTarget);
230
+
231
+ // Resolve send target: thread reply vs DM
232
+ // "auto" = if we have thread context, reply there; otherwise DM
233
+ // "dm" = always DM the owner
234
+ // "thread" = reply in current thread (falls back to DM if no thread context)
235
+ const hasThreadCtx = sourceCtx?.slackChannelId && sourceCtx?.slackThreadTs;
236
+ const useThread = (target === "auto" && hasThreadCtx) || (target === "thread" && hasThreadCtx);
237
+
238
+ // Compute room prefix for DB storage BEFORE sending
239
+ let roomPrefix: string | undefined;
240
+ if (channelTarget === "telegram") {
241
+ const chatId = config.channels.telegram.chat_id;
242
+ if (chatId) roomPrefix = `tg-${chatId}`;
243
+ } else if (channelTarget === "slack") {
244
+ if (useThread && sourceCtx?.room) {
245
+ // Replying in-thread: use the source session's room prefix
246
+ roomPrefix = sourceCtx.room.replace(/-\d+$/, "");
247
+ } else {
248
+ const channelId = config.channels.slack.channel_id;
249
+ const dmUserId = config.channels.slack.dm_user_id;
250
+ if (channelId) {
251
+ roomPrefix = `slack-${channelId}`;
252
+ } else if (dmUserId) {
253
+ roomPrefix = `slack-dm-${dmUserId}`;
254
+ }
255
+ }
256
+ }
257
+
258
+ // Save pending notification to DB before sending (avoids race with fast replies)
259
+ let messageId: number | undefined;
260
+ if (roomPrefix) {
261
+ try {
262
+ const idx = await Session.getLatestRoomIndex(roomPrefix);
263
+ const fullRoom = `${roomPrefix}-${idx}`;
264
+ let sessionId = await Session.getLatest(fullRoom);
265
+
266
+ // Auto-create a backing session if none exists (e.g. first proactive DM)
267
+ if (!sessionId) {
268
+ sessionId = randomUUID();
269
+ await Session.create(sessionId, fullRoom);
270
+ }
271
+
272
+ const content = mediaPath ? `${text} [media: ${basename(mediaPath)}]` : text;
273
+ const source = sourceCtx?.jobName ? `job:${sourceCtx.jobName}` : sourceCtx?.channel || undefined;
274
+ const metadata: Record<string, unknown> = { kind: useThread ? "thread_reply" : "notification" };
275
+ if (source) metadata.source = source;
276
+
277
+ messageId = await Message.save({
278
+ sessionId,
279
+ room: fullRoom,
280
+ sender: "nia",
281
+ content,
282
+ isFromAgent: true,
283
+ deliveryStatus: "pending",
284
+ metadata,
285
+ });
286
+ } catch (err) {
287
+ log.warn({ err, channelTarget, roomPrefix }, "sendMessage: failed to save pending notification to DB");
288
+ }
289
+ }
228
290
 
229
291
  try {
230
292
  // Handle media attachment if provided
231
293
  if (mediaPath) {
232
- if (!existsSync(mediaPath)) return `Failed to send: file not found: ${mediaPath}`;
294
+ if (!existsSync(mediaPath)) {
295
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
296
+ return `Failed to send: file not found: ${mediaPath}`;
297
+ }
233
298
  const data = readFileSync(mediaPath);
234
299
  const mimeType = guessMime(mediaPath);
235
300
  const filename = basename(mediaPath);
@@ -237,55 +302,40 @@ export async function sendMessage(text: string, channelName?: string, mediaPath?
237
302
  if (channel?.sendMedia) {
238
303
  await channel.sendMedia(data, mimeType, filename);
239
304
  } else {
240
- await sendMediaDirect(target, data, mimeType, filename);
305
+ await sendMediaDirect(channelTarget, data, mimeType, filename);
241
306
  }
242
307
 
243
308
  // Also send text if provided (as a separate message)
244
309
  if (text) {
245
- if (channel?.sendMessage) {
310
+ if (useThread && channel?.sendToThread) {
311
+ await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
312
+ } else if (channel?.sendMessage) {
246
313
  await channel.sendMessage(text);
247
314
  } else {
248
- await sendDirect(target, text);
315
+ await sendDirect(channelTarget, text);
249
316
  }
250
317
  }
251
318
  } else {
252
- if (channel?.sendMessage) {
319
+ if (useThread && channel?.sendToThread) {
320
+ await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
321
+ } else if (channel?.sendMessage) {
253
322
  await channel.sendMessage(text);
254
323
  } else {
255
- await sendDirect(target, text);
324
+ await sendDirect(channelTarget, text);
256
325
  }
257
326
  }
258
327
 
259
- // Store in messages table (best-effort)
260
- try {
261
- let room: string | undefined;
262
- if (target === "telegram") {
263
- const chatId = config.channels.telegram.chat_id;
264
- if (chatId) room = `tg-${chatId}`;
265
- } else if (target === "slack") {
266
- const channelId = config.channels.slack.channel_id;
267
- if (channelId) room = `slack-${channelId}`;
268
- }
269
-
270
- if (room) {
271
- const idx = await Session.getLatestRoomIndex(room);
272
- const fullRoom = `${room}-${idx}`;
273
- const sessionId = await Session.getLatest(fullRoom);
274
- if (sessionId) {
275
- const content = mediaPath ? `${text} [media: ${basename(mediaPath)}]` : text;
276
- await Message.save({
277
- sessionId,
278
- room: fullRoom,
279
- sender: "nia",
280
- content,
281
- isFromAgent: true,
282
- });
283
- }
284
- }
285
- } catch {}
328
+ // Mark as sent
329
+ if (messageId) {
330
+ await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
331
+ }
286
332
 
287
333
  return mediaPath ? "Message with media sent." : "Message sent.";
288
334
  } catch (err) {
335
+ // Mark as failed
336
+ if (messageId) {
337
+ await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
338
+ }
289
339
  const msg = err instanceof Error ? err.message : String(err);
290
340
  return `Failed to send: ${msg}`;
291
341
  }
@@ -23,7 +23,7 @@
23
23
 
24
24
  ### Reply routing
25
25
  - Always reply in the same thread you received the message in. Don't DM someone unless the conversation is already in DMs.
26
- - For watch mode escalation, use `send_message` to post to a different channel but still reply in-thread too if relevant.
26
+ - `send_message` defaults to your current context (thread if in one, DM if in DM). For escalations, mention the owner in-thread rather than DMing — keeps context where the conversation is.
27
27
  - If the user wants a file/image sent, use `send_message` with `media_path`. When a Slack file was attached to the message, use the `[Attachment local paths]` block from context.
28
28
 
29
29
  ### Who's talking
@@ -46,6 +46,7 @@
46
46
  - In watch channels, you receive ALL messages — not just @mentions. Messages are prefixed with `[Watch mode — #channel-name]` and a behavior prompt.
47
47
  - Follow the behavior prompt to decide what to do: flag issues, escalate, or stay quiet.
48
48
  - Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
49
- - To escalate to a different channel, use `send_message` with the channel name (e.g. `send_message("deploy failed: ...", "slack")`). To DM the owner, use `send_message` with no channel (uses default).
50
- - Your reply goes in-thread in the watched channel. Use `send_message` when you need to notify elsewhere.
49
+ - `send_message` defaults to replying in your current thread (target=auto). To post to a different channel, specify the channel name.
50
+ - To escalate in a watch thread, **mention the owner** (e.g. `<@U06PBA2P680> heads up — this workflow is stuck`) in your thread reply. Don't DM — keep the context where the conversation is. The owner's Slack ID is in config (`channels.slack.dm_user_id`) or owner.md.
51
+ - Your normal reply (via the chat response) goes in-thread automatically. Use `send_message` only when you need to notify *elsewhere* (different channel) or send a proactive update mid-task.
51
52
  - You can manage watch channels via `add_watch_channel` / `remove_watch_channel` / `enable_watch_channel` / `disable_watch_channel` MCP tools. Changes take effect on the next message (hot-reloads via config.yaml mtime).
@@ -39,7 +39,11 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
39
39
  - **unarchive_job** — unarchive a job back to disabled state
40
40
  - **run_job** — trigger a job to run immediately
41
41
  - **list_employees** — list all employees with role, project, status
42
- - **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files. For inbound channel files, check the message context for an `[Attachment local paths]` block and reuse those absolute paths when forwarding.
42
+ - **send_message** — send a message via configured channel. Supports `media_path` to send images/files. The `target` param controls routing:
43
+ - `auto` (default) — replies in the current Slack thread if you're in one, otherwise DMs the owner. This means watch sessions and thread chats reply in-thread by default.
44
+ - `dm` — always DMs the owner, regardless of current context. Use sparingly — prefer @mentioning the owner in-thread to keep context visible.
45
+ - `thread` — explicitly reply in the current thread (same as auto when in a thread, falls back to DM otherwise).
46
+ For inbound channel files, check the message context for an `[Attachment local paths]` block and reuse those absolute paths when forwarding.
43
47
  - **list_messages** — read recent chat history
44
48
  - **list_sessions** — browse past conversation sessions with previews and message counts. Returns session IDs.
45
49
  - **search_messages** — keyword search across all past messages. Find when something was discussed.
@@ -4,6 +4,8 @@ export interface Channel {
4
4
  stop(): Promise<void>;
5
5
  sendMessage?(text: string): Promise<void>;
6
6
  sendMedia?(data: Buffer, mimeType: string, filename?: string): Promise<void>;
7
+ /** Send a message to a specific channel/thread (e.g. reply back to a Slack thread). */
8
+ sendToThread?(channelId: string, text: string, threadTs?: string): Promise<void>;
7
9
  }
8
10
 
9
11
  export type ChannelFactory = () => Channel | null;