niahere 0.3.1 → 0.3.3
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/common/chat-session.ts +56 -0
- package/src/channels/phone/index.ts +13 -4
- package/src/channels/phone/tools.ts +2 -2
- package/src/channels/slack/attachments.ts +142 -0
- package/src/channels/slack/watch.ts +73 -0
- package/src/channels/slack.ts +63 -267
- package/src/channels/sms.ts +25 -35
- package/src/channels/telegram.ts +224 -223
- package/src/channels/whatsapp.ts +90 -122
- package/src/chat/identity.ts +1 -2
- package/src/cli/phone.ts +9 -6
- package/src/commands/init.ts +17 -14
- package/src/commands/service.ts +26 -7
- package/src/mcp/tools/index.ts +9 -0
- package/src/mcp/tools/jobs.ts +145 -0
- package/src/mcp/tools/messages.ts +25 -0
- package/src/mcp/tools/misc.ts +63 -0
- package/src/mcp/tools/send.ts +202 -0
- package/src/mcp/tools/watch.ts +50 -0
- package/src/types/channel.ts +35 -6
- package/src/types/index.ts +1 -1
- package/src/utils/attachment.ts +8 -2
- package/src/utils/config.ts +12 -27
- package/src/mcp/tools.ts +0 -497
package/src/mcp/tools.ts
DELETED
|
@@ -1,497 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
|
|
2
|
-
import type { ScheduleType } from "../types";
|
|
3
|
-
import { basename, join } from "path";
|
|
4
|
-
import { randomUUID } from "crypto";
|
|
5
|
-
import { Job, Message, Session } from "../db/models";
|
|
6
|
-
import { computeInitialNextRun } from "../core/scheduler";
|
|
7
|
-
import { getConfig, readRawConfig, updateRawConfig, writeRawConfig } from "../utils/config";
|
|
8
|
-
import { getPaths } from "../utils/paths";
|
|
9
|
-
import { getChannel } from "../channels/registry";
|
|
10
|
-
import { log } from "../utils/log";
|
|
11
|
-
import { classifyMime } from "../utils/attachment";
|
|
12
|
-
import { scanAgents } from "../core/agents";
|
|
13
|
-
import { listEmployeesForMcp } from "../core/employees";
|
|
14
|
-
import { resolveJobPrompt } from "../core/job-prompt";
|
|
15
|
-
import { readMemory as readMemoryUtil, addMemory as addMemoryUtil } from "../utils/memory";
|
|
16
|
-
import type { McpSourceContext } from "./index";
|
|
17
|
-
|
|
18
|
-
export async function listJobs(): Promise<string> {
|
|
19
|
-
const jobs = await Job.list();
|
|
20
|
-
if (jobs.length === 0) return "No jobs found.";
|
|
21
|
-
const withPromptSource = jobs.map((job) => {
|
|
22
|
-
const resolvedPrompt = resolveJobPrompt(job);
|
|
23
|
-
return {
|
|
24
|
-
...job,
|
|
25
|
-
prompt: resolvedPrompt.prompt,
|
|
26
|
-
promptSource: resolvedPrompt.source,
|
|
27
|
-
promptPath: resolvedPrompt.filePath,
|
|
28
|
-
};
|
|
29
|
-
});
|
|
30
|
-
return JSON.stringify(withPromptSource, null, 2);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function addJob(args: {
|
|
34
|
-
name: string;
|
|
35
|
-
schedule: string;
|
|
36
|
-
prompt: string;
|
|
37
|
-
schedule_type?: ScheduleType;
|
|
38
|
-
always?: boolean;
|
|
39
|
-
agent?: string;
|
|
40
|
-
employee?: string;
|
|
41
|
-
model?: string;
|
|
42
|
-
stateless?: boolean;
|
|
43
|
-
}): Promise<string> {
|
|
44
|
-
const scheduleType = args.schedule_type || "cron";
|
|
45
|
-
const always = args.always || false;
|
|
46
|
-
const stateless = args.stateless || false;
|
|
47
|
-
const config = getConfig();
|
|
48
|
-
|
|
49
|
-
const nextRunAt = computeInitialNextRun(scheduleType, args.schedule, config.timezone);
|
|
50
|
-
await Job.create(
|
|
51
|
-
args.name,
|
|
52
|
-
args.schedule,
|
|
53
|
-
args.prompt,
|
|
54
|
-
always,
|
|
55
|
-
scheduleType,
|
|
56
|
-
nextRunAt,
|
|
57
|
-
args.agent,
|
|
58
|
-
stateless,
|
|
59
|
-
args.model,
|
|
60
|
-
args.employee,
|
|
61
|
-
);
|
|
62
|
-
const agentNote = args.agent ? ` [agent: ${args.agent}]` : "";
|
|
63
|
-
const employeeNote = args.employee ? ` [employee: ${args.employee}]` : "";
|
|
64
|
-
const modelNote = args.model ? ` [model: ${args.model}]` : "";
|
|
65
|
-
return `Job "${args.name}" created (${scheduleType}: ${args.schedule})${agentNote}${employeeNote}${modelNote}. Next run: ${nextRunAt.toISOString()}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function updateJob(args: {
|
|
69
|
-
name: string;
|
|
70
|
-
schedule?: string;
|
|
71
|
-
prompt?: string;
|
|
72
|
-
always?: boolean;
|
|
73
|
-
agent?: string | null;
|
|
74
|
-
employee?: string | null;
|
|
75
|
-
model?: string | null;
|
|
76
|
-
stateless?: boolean;
|
|
77
|
-
schedule_type?: "cron" | "interval" | "once";
|
|
78
|
-
}): Promise<string> {
|
|
79
|
-
const fields: Partial<{
|
|
80
|
-
schedule: string;
|
|
81
|
-
prompt: string;
|
|
82
|
-
always: boolean;
|
|
83
|
-
stateless: boolean;
|
|
84
|
-
model: string | null;
|
|
85
|
-
agent: string | null;
|
|
86
|
-
employee: string | null;
|
|
87
|
-
scheduleType: "cron" | "interval" | "once";
|
|
88
|
-
}> = {};
|
|
89
|
-
if (args.schedule) fields.schedule = args.schedule;
|
|
90
|
-
if (args.prompt) fields.prompt = args.prompt;
|
|
91
|
-
if (args.always !== undefined) fields.always = args.always;
|
|
92
|
-
if (args.stateless !== undefined) fields.stateless = args.stateless;
|
|
93
|
-
if (args.model !== undefined) fields.model = args.model;
|
|
94
|
-
if (args.agent !== undefined) fields.agent = args.agent;
|
|
95
|
-
if (args.employee !== undefined) fields.employee = args.employee;
|
|
96
|
-
if (args.schedule_type) fields.scheduleType = args.schedule_type;
|
|
97
|
-
|
|
98
|
-
if (Object.keys(fields).length === 0)
|
|
99
|
-
return "Nothing to update. Pass at least one field (schedule, prompt, always, stateless, model, agent, employee, or schedule_type).";
|
|
100
|
-
|
|
101
|
-
const updated = await Job.update(args.name, fields);
|
|
102
|
-
if (!updated) return `Job "${args.name}" not found.`;
|
|
103
|
-
if (fields.prompt !== undefined) {
|
|
104
|
-
const job = await Job.get(args.name);
|
|
105
|
-
if (job) {
|
|
106
|
-
const resolvedPrompt = resolveJobPrompt(job);
|
|
107
|
-
if (resolvedPrompt.source === "file") {
|
|
108
|
-
return `Job "${args.name}" updated. Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return `Job "${args.name}" updated.`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function removeJob(name: string): Promise<string> {
|
|
116
|
-
const removed = await Job.remove(name);
|
|
117
|
-
return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export async function enableJob(name: string): Promise<string> {
|
|
121
|
-
const updated = await Job.update(name, { status: "active" });
|
|
122
|
-
if (!updated) return `Job "${name}" not found.`;
|
|
123
|
-
|
|
124
|
-
const job = await Job.get(name);
|
|
125
|
-
if (job) {
|
|
126
|
-
const config = getConfig();
|
|
127
|
-
const nextRun = computeInitialNextRun(job.scheduleType, job.schedule, config.timezone);
|
|
128
|
-
const { getSql } = await import("../db/connection");
|
|
129
|
-
await getSql()`UPDATE jobs SET next_run_at = ${nextRun} WHERE name = ${name}`;
|
|
130
|
-
}
|
|
131
|
-
return `Job "${name}" enabled.`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function disableJob(name: string): Promise<string> {
|
|
135
|
-
const updated = await Job.update(name, { status: "disabled" });
|
|
136
|
-
return updated ? `Job "${name}" disabled.` : `Job "${name}" not found.`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function archiveJob(name: string): Promise<string> {
|
|
140
|
-
const updated = await Job.update(name, { status: "archived" });
|
|
141
|
-
return updated ? `Job "${name}" archived.` : `Job "${name}" not found.`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export async function unarchiveJob(name: string): Promise<string> {
|
|
145
|
-
const updated = await Job.update(name, { status: "disabled" });
|
|
146
|
-
return updated ? `Job "${name}" unarchived (disabled). Enable with enable_job.` : `Job "${name}" not found.`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export async function runJobNow(name: string): Promise<string> {
|
|
150
|
-
const job = await Job.get(name);
|
|
151
|
-
if (!job) return `Job "${name}" not found.`;
|
|
152
|
-
|
|
153
|
-
const { getSql } = await import("../db/connection");
|
|
154
|
-
await getSql()`UPDATE jobs SET next_run_at = NOW() WHERE name = ${name}`;
|
|
155
|
-
return `Job "${name}" queued for immediate execution.`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Guess MIME type from file extension. */
|
|
159
|
-
export function guessMime(filePath: string): string {
|
|
160
|
-
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
161
|
-
const map: Record<string, string> = {
|
|
162
|
-
jpg: "image/jpeg",
|
|
163
|
-
jpeg: "image/jpeg",
|
|
164
|
-
png: "image/png",
|
|
165
|
-
gif: "image/gif",
|
|
166
|
-
webp: "image/webp",
|
|
167
|
-
txt: "text/plain",
|
|
168
|
-
md: "text/markdown",
|
|
169
|
-
csv: "text/csv",
|
|
170
|
-
json: "application/json",
|
|
171
|
-
pdf: "application/pdf",
|
|
172
|
-
html: "text/html",
|
|
173
|
-
};
|
|
174
|
-
return map[ext || ""] || "application/octet-stream";
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Send directly via API when no started channel is available (e.g. CLI `nia send`). */
|
|
178
|
-
async function sendDirect(target: string, text: string): Promise<void> {
|
|
179
|
-
const config = getConfig();
|
|
180
|
-
|
|
181
|
-
if (target === "telegram") {
|
|
182
|
-
const token = config.channels.telegram.bot_token;
|
|
183
|
-
const chatId = config.channels.telegram.chat_id;
|
|
184
|
-
if (!token) throw new Error("Telegram not configured (no bot token)");
|
|
185
|
-
if (!chatId) throw new Error("No Telegram chat ID — send a message to the bot first");
|
|
186
|
-
const { Bot } = await import("grammy");
|
|
187
|
-
const bot = new Bot(token);
|
|
188
|
-
await bot.api.sendMessage(chatId, text);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (target === "slack") {
|
|
193
|
-
const token = config.channels.slack.bot_token;
|
|
194
|
-
const recipient = config.channels.slack.dm_user_id;
|
|
195
|
-
if (!token) throw new Error("Slack not configured (no bot token)");
|
|
196
|
-
if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
197
|
-
const { App } = await import("@slack/bolt");
|
|
198
|
-
const app = new App({ token, signingSecret: "unused" });
|
|
199
|
-
await app.client.chat.postMessage({ token, channel: recipient, text });
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
throw new Error(`Channel "${target}" not configured`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Send media directly via API (no started channel). */
|
|
207
|
-
async function sendMediaDirect(target: string, data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
208
|
-
const config = getConfig();
|
|
209
|
-
|
|
210
|
-
if (target === "telegram") {
|
|
211
|
-
const token = config.channels.telegram.bot_token;
|
|
212
|
-
const chatId = config.channels.telegram.chat_id;
|
|
213
|
-
if (!token) throw new Error("Telegram not configured (no bot token)");
|
|
214
|
-
if (!chatId) throw new Error("No Telegram chat ID — send a message to the bot first");
|
|
215
|
-
const { Bot, InputFile } = await import("grammy");
|
|
216
|
-
const bot = new Bot(token);
|
|
217
|
-
const file = new InputFile(data, filename);
|
|
218
|
-
if (mimeType.startsWith("image/")) {
|
|
219
|
-
await bot.api.sendPhoto(chatId, file);
|
|
220
|
-
} else {
|
|
221
|
-
await bot.api.sendDocument(chatId, file);
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (target === "slack") {
|
|
227
|
-
const token = config.channels.slack.bot_token;
|
|
228
|
-
const recipient = config.channels.slack.dm_user_id;
|
|
229
|
-
if (!token) throw new Error("Slack not configured (no bot token)");
|
|
230
|
-
if (!recipient) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
231
|
-
const { App } = await import("@slack/bolt");
|
|
232
|
-
const app = new App({ token, signingSecret: "unused" });
|
|
233
|
-
await app.client.filesUploadV2({
|
|
234
|
-
channel_id: recipient,
|
|
235
|
-
file: data,
|
|
236
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
237
|
-
});
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
throw new Error(`Channel "${target}" not configured`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export async function sendMessage(
|
|
245
|
-
text: string,
|
|
246
|
-
channelName?: string,
|
|
247
|
-
mediaPath?: string,
|
|
248
|
-
sourceCtx?: McpSourceContext,
|
|
249
|
-
target: "auto" | "dm" | "thread" = "auto",
|
|
250
|
-
): Promise<string> {
|
|
251
|
-
const config = getConfig();
|
|
252
|
-
const channelTarget = channelName || config.channels.default;
|
|
253
|
-
|
|
254
|
-
// Use started channel if available (daemon), otherwise call API directly (CLI)
|
|
255
|
-
const channel = getChannel(channelTarget);
|
|
256
|
-
|
|
257
|
-
// Resolve send target: thread reply vs DM
|
|
258
|
-
// "auto" = if we have thread context, reply there; otherwise DM
|
|
259
|
-
// "dm" = always DM the owner
|
|
260
|
-
// "thread" = reply in current thread (falls back to DM if no thread context)
|
|
261
|
-
const hasThreadCtx = sourceCtx?.slackChannelId && sourceCtx?.slackThreadTs;
|
|
262
|
-
const useThread = (target === "auto" && hasThreadCtx) || (target === "thread" && hasThreadCtx);
|
|
263
|
-
|
|
264
|
-
// Compute room prefix for DB storage BEFORE sending
|
|
265
|
-
let roomPrefix: string | undefined;
|
|
266
|
-
if (channelTarget === "telegram") {
|
|
267
|
-
const chatId = config.channels.telegram.chat_id;
|
|
268
|
-
if (chatId) roomPrefix = `tg-${chatId}`;
|
|
269
|
-
} else if (channelTarget === "slack") {
|
|
270
|
-
if (useThread && sourceCtx?.room) {
|
|
271
|
-
// Replying in-thread: use the source session's room prefix
|
|
272
|
-
roomPrefix = sourceCtx.room.replace(/-\d+$/, "");
|
|
273
|
-
} else {
|
|
274
|
-
const dmUserId = config.channels.slack.dm_user_id;
|
|
275
|
-
if (dmUserId) {
|
|
276
|
-
roomPrefix = `slack-dm-${dmUserId}`;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Save pending notification to DB before sending (avoids race with fast replies)
|
|
282
|
-
let messageId: number | undefined;
|
|
283
|
-
if (roomPrefix) {
|
|
284
|
-
try {
|
|
285
|
-
const idx = await Session.getLatestRoomIndex(roomPrefix);
|
|
286
|
-
const fullRoom = `${roomPrefix}-${idx}`;
|
|
287
|
-
let sessionId = await Session.getLatest(fullRoom);
|
|
288
|
-
|
|
289
|
-
// Auto-create a backing session if none exists (e.g. first proactive DM)
|
|
290
|
-
if (!sessionId) {
|
|
291
|
-
sessionId = randomUUID();
|
|
292
|
-
await Session.create(sessionId, fullRoom);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const content = mediaPath ? `${text} [media: ${basename(mediaPath)}]` : text;
|
|
296
|
-
const source = sourceCtx?.jobName ? `job:${sourceCtx.jobName}` : sourceCtx?.channel || undefined;
|
|
297
|
-
const metadata: Record<string, unknown> = { kind: useThread ? "thread_reply" : "notification" };
|
|
298
|
-
if (source) metadata.source = source;
|
|
299
|
-
|
|
300
|
-
messageId = await Message.save({
|
|
301
|
-
sessionId,
|
|
302
|
-
room: fullRoom,
|
|
303
|
-
sender: "nia",
|
|
304
|
-
content,
|
|
305
|
-
isFromAgent: true,
|
|
306
|
-
deliveryStatus: "pending",
|
|
307
|
-
metadata,
|
|
308
|
-
});
|
|
309
|
-
} catch (err) {
|
|
310
|
-
log.warn({ err, channelTarget, roomPrefix }, "sendMessage: failed to save pending notification to DB");
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
// Handle media attachment if provided
|
|
316
|
-
if (mediaPath) {
|
|
317
|
-
if (!existsSync(mediaPath)) {
|
|
318
|
-
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
319
|
-
return `Failed to send: file not found: ${mediaPath}`;
|
|
320
|
-
}
|
|
321
|
-
const data = readFileSync(mediaPath);
|
|
322
|
-
const mimeType = guessMime(mediaPath);
|
|
323
|
-
const filename = basename(mediaPath);
|
|
324
|
-
|
|
325
|
-
if (useThread && channel?.sendMediaToThread) {
|
|
326
|
-
await channel.sendMediaToThread(sourceCtx!.slackChannelId!, data, mimeType, filename, sourceCtx!.slackThreadTs);
|
|
327
|
-
} else if (channel?.sendMedia) {
|
|
328
|
-
await channel.sendMedia(data, mimeType, filename);
|
|
329
|
-
} else {
|
|
330
|
-
await sendMediaDirect(channelTarget, data, mimeType, filename);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Also send text if provided (as a separate message)
|
|
334
|
-
if (text) {
|
|
335
|
-
if (useThread && channel?.sendToThread) {
|
|
336
|
-
await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
|
|
337
|
-
} else if (channel?.sendMessage) {
|
|
338
|
-
await channel.sendMessage(text);
|
|
339
|
-
} else {
|
|
340
|
-
await sendDirect(channelTarget, text);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
} else {
|
|
344
|
-
if (useThread && channel?.sendToThread) {
|
|
345
|
-
await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
|
|
346
|
-
} else if (channel?.sendMessage) {
|
|
347
|
-
await channel.sendMessage(text);
|
|
348
|
-
} else {
|
|
349
|
-
await sendDirect(channelTarget, text);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Mark as sent
|
|
354
|
-
if (messageId) {
|
|
355
|
-
await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return mediaPath ? "Message with media sent." : "Message sent.";
|
|
359
|
-
} catch (err) {
|
|
360
|
-
// Mark as failed
|
|
361
|
-
if (messageId) {
|
|
362
|
-
await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
363
|
-
}
|
|
364
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
365
|
-
return `Failed to send: ${msg}`;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
export async function listMessages(limit = 20, room?: string): Promise<string> {
|
|
370
|
-
const messages = await Message.getRecent(limit, room);
|
|
371
|
-
if (messages.length === 0) return "No messages found.";
|
|
372
|
-
return JSON.stringify(messages, null, 2);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
export async function listSessions(limit = 10, room?: string): Promise<string> {
|
|
376
|
-
const sessions = await Session.listRecent(limit, room);
|
|
377
|
-
if (sessions.length === 0) return "No sessions found.";
|
|
378
|
-
return JSON.stringify(sessions, null, 2);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export async function searchMessages(query: string, limit = 20, room?: string): Promise<string> {
|
|
382
|
-
const results = await Message.search(query, limit, room);
|
|
383
|
-
if (results.length === 0) return "No matching messages found.";
|
|
384
|
-
return JSON.stringify(results, null, 2);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export async function readSession(sessionId: string): Promise<string> {
|
|
388
|
-
const messages = await Message.getBySession(sessionId);
|
|
389
|
-
if (messages.length === 0) return "Session not found or has no messages.";
|
|
390
|
-
return JSON.stringify(messages, null, 2);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export function addRule(rule: string): string {
|
|
394
|
-
const { selfDir } = getPaths();
|
|
395
|
-
const rulesPath = join(selfDir, "rules.md");
|
|
396
|
-
const line = `\n- ${rule}\n`;
|
|
397
|
-
appendFileSync(rulesPath, line, "utf8");
|
|
398
|
-
return `Rule added to rules.md. Takes effect on next new session.`;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
export function addWatchChannel(name: string, behavior?: string): string {
|
|
402
|
-
const raw = readRawConfig();
|
|
403
|
-
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
404
|
-
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
405
|
-
const entry: Record<string, unknown> = { enabled: true };
|
|
406
|
-
if (behavior !== undefined && behavior !== "") entry.behavior = behavior;
|
|
407
|
-
const watch = {
|
|
408
|
-
...((slack.watch || {}) as Record<string, unknown>),
|
|
409
|
-
[name]: entry,
|
|
410
|
-
};
|
|
411
|
-
updateRawConfig({ channels: { slack: { watch } } });
|
|
412
|
-
return `Watch channel "${name}" added (enabled). Takes effect on next message.`;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
export function removeWatchChannel(name: string): string {
|
|
416
|
-
const raw = readRawConfig();
|
|
417
|
-
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
418
|
-
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
419
|
-
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
420
|
-
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
421
|
-
delete watch[name];
|
|
422
|
-
writeRawConfig(raw);
|
|
423
|
-
return `Watch channel "${name}" removed. Takes effect on next message.`;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export function enableWatchChannel(name: string): string {
|
|
427
|
-
const raw = readRawConfig();
|
|
428
|
-
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
429
|
-
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
430
|
-
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
431
|
-
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
432
|
-
const entry = watch[name] as Record<string, unknown>;
|
|
433
|
-
entry.enabled = true;
|
|
434
|
-
updateRawConfig({ channels: { slack: { watch } } });
|
|
435
|
-
return `Watch channel "${name}" enabled. Takes effect on next message.`;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
export function disableWatchChannel(name: string): string {
|
|
439
|
-
const raw = readRawConfig();
|
|
440
|
-
const channels = (raw.channels || {}) as Record<string, unknown>;
|
|
441
|
-
const slack = (channels.slack || {}) as Record<string, unknown>;
|
|
442
|
-
const watch = (slack.watch || {}) as Record<string, unknown>;
|
|
443
|
-
if (!watch[name]) return `Watch channel "${name}" not found.`;
|
|
444
|
-
const entry = watch[name] as Record<string, unknown>;
|
|
445
|
-
entry.enabled = false;
|
|
446
|
-
updateRawConfig({ channels: { slack: { watch } } });
|
|
447
|
-
return `Watch channel "${name}" disabled. Takes effect on next message.`;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export const readMemory = readMemoryUtil;
|
|
451
|
-
export const addMemory = addMemoryUtil;
|
|
452
|
-
|
|
453
|
-
export function listAgents(): string {
|
|
454
|
-
const agents = scanAgents();
|
|
455
|
-
if (agents.length === 0) return "No agents found.";
|
|
456
|
-
return JSON.stringify(
|
|
457
|
-
agents.map((a) => ({
|
|
458
|
-
name: a.name,
|
|
459
|
-
description: a.description,
|
|
460
|
-
model: a.model,
|
|
461
|
-
source: a.source,
|
|
462
|
-
})),
|
|
463
|
-
null,
|
|
464
|
-
2,
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export function listEmployees(): string {
|
|
469
|
-
return listEmployeesForMcp();
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export async function placeCall(args: {
|
|
473
|
-
number: string;
|
|
474
|
-
goal: string;
|
|
475
|
-
context?: string;
|
|
476
|
-
max_minutes?: number;
|
|
477
|
-
voice?: string;
|
|
478
|
-
}): Promise<string> {
|
|
479
|
-
// Dynamic import avoids a static cycle with channels/phone -> mcp/tools.
|
|
480
|
-
const { getPhoneChannel } = await import("../channels/phone");
|
|
481
|
-
const phone = getPhoneChannel();
|
|
482
|
-
if (!phone) {
|
|
483
|
-
return "Phone channel is not configured. Add a channels.phone block to ~/.niahere/config.yaml with twilio_sid, twilio_secret, from_number, public_base_url, openai_api_key (or set the matching env vars in .env), then restart the daemon.";
|
|
484
|
-
}
|
|
485
|
-
try {
|
|
486
|
-
const result = await phone.placeCall({
|
|
487
|
-
number: args.number,
|
|
488
|
-
goal: args.goal,
|
|
489
|
-
context: args.context,
|
|
490
|
-
maxMinutes: args.max_minutes,
|
|
491
|
-
voice: args.voice,
|
|
492
|
-
});
|
|
493
|
-
return `Call placed. callSid=${result.callSid} status=${result.status}. Transcript will land in messages once the call completes.`;
|
|
494
|
-
} catch (err) {
|
|
495
|
-
return `place_call failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
496
|
-
}
|
|
497
|
-
}
|