niahere 0.2.70 → 0.2.71
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 +51 -8
- package/src/chat/repl.ts +1 -1
- package/src/core/daemon.ts +1 -1
- package/src/core/runner.ts +7 -4
- package/src/db/models/message.ts +40 -0
- package/src/db/models/session.ts +9 -2
- package/src/mcp/index.ts +18 -4
- package/src/mcp/server.ts +8 -3
- package/src/mcp/tools.ts +86 -36
- package/src/prompts/channel-slack.md +4 -3
- package/src/prompts/environment.md +5 -1
- package/src/types/channel.ts +2 -0
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
458
|
-
|
|
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
|
package/src/core/daemon.ts
CHANGED
|
@@ -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
|
package/src/core/runner.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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
16
|
import { ActiveEngine } from "../db/models";
|
|
17
17
|
import { getPaths } from "../utils/paths";
|
|
18
18
|
import { log } from "../utils/log";
|
|
@@ -97,6 +97,7 @@ export async function runJobWithClaude(
|
|
|
97
97
|
cwd: string,
|
|
98
98
|
onActivity?: ActivityCallback,
|
|
99
99
|
model?: string,
|
|
100
|
+
sourceCtx?: McpSourceContext,
|
|
100
101
|
): Promise<RunnerOutput> {
|
|
101
102
|
const sessionId = randomUUID();
|
|
102
103
|
|
|
@@ -121,7 +122,7 @@ export async function runJobWithClaude(
|
|
|
121
122
|
options.model = model;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const mcpServers = getMcpServers();
|
|
125
|
+
const mcpServers = getMcpServers(sourceCtx);
|
|
125
126
|
if (mcpServers) {
|
|
126
127
|
options.mcpServers = mcpServers;
|
|
127
128
|
}
|
|
@@ -345,11 +346,13 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
345
346
|
const MAX_API_RETRIES = 2;
|
|
346
347
|
const RETRY_DELAYS = [3_000, 8_000]; // 3s, then 8s
|
|
347
348
|
|
|
349
|
+
const jobSourceCtx: McpSourceContext = { jobName: job.name, channel: "system" };
|
|
350
|
+
|
|
348
351
|
if (config.runner === "codex") {
|
|
349
352
|
const fullPrompt = `${systemPrompt}\n\n---\n\n${jobPrompt}`;
|
|
350
353
|
output = await runJobWithCodex(fullPrompt, cwd, resolvedModel);
|
|
351
354
|
} else {
|
|
352
|
-
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel);
|
|
355
|
+
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
|
|
353
356
|
|
|
354
357
|
for (let attempt = 0; attempt < MAX_API_RETRIES && output.error && isRetryableApiError(output.error); attempt++) {
|
|
355
358
|
const delay = RETRY_DELAYS[attempt] ?? 8_000;
|
|
@@ -358,7 +361,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
358
361
|
"retrying after transient API error",
|
|
359
362
|
);
|
|
360
363
|
await sleep(delay);
|
|
361
|
-
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel);
|
|
364
|
+
output = await runJobWithClaude(systemPrompt, jobPrompt, cwd, onActivity, resolvedModel, jobSourceCtx);
|
|
362
365
|
}
|
|
363
366
|
}
|
|
364
367
|
|
package/src/db/models/message.ts
CHANGED
|
@@ -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`
|
package/src/db/models/session.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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))
|
|
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(
|
|
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?.
|
|
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(
|
|
315
|
+
await sendDirect(channelTarget, text);
|
|
249
316
|
}
|
|
250
317
|
}
|
|
251
318
|
} else {
|
|
252
|
-
if (channel?.
|
|
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(
|
|
324
|
+
await sendDirect(channelTarget, text);
|
|
256
325
|
}
|
|
257
326
|
}
|
|
258
327
|
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
50
|
-
-
|
|
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
|
|
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.
|
package/src/types/channel.ts
CHANGED
|
@@ -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;
|