niahere 0.3.1 → 0.3.2

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.
@@ -17,11 +17,11 @@
17
17
  * expires after 72h of inactivity; the join code stays valid. Outbound
18
18
  * is further gated by Meta's 24-hour customer-service window.
19
19
  */
20
- import { createChatEngine } from "../chat/engine";
21
20
  import { getMcpServers } from "../mcp";
22
- import { Session, Message } from "../db/models";
21
+ import { Message } from "../db/models";
23
22
  import { runMigrations } from "../db/migrate";
24
- import type { Attachment, Channel, ChatState, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
23
+ import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
24
+ import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
25
25
  import { getConfig } from "../utils/config";
26
26
  import { log } from "../utils/log";
27
27
  import { classifyMime, prepareImage, validateAttachment } from "../utils/attachment";
@@ -38,7 +38,7 @@ const RESET_RE = /^\s*\/(reset|new)\s*$/i;
38
38
  const VOICE_MIME_PREFIX = "audio/";
39
39
 
40
40
  class WhatsAppChannel implements Channel {
41
- name = "whatsapp";
41
+ name = "whatsapp" as const;
42
42
  private readonly twilio: TwilioConfig;
43
43
  private readonly whatsapp: WhatsappConfig;
44
44
  private readonly phone: PhoneConfig;
@@ -90,16 +90,16 @@ class WhatsAppChannel implements Channel {
90
90
  this.chats.clear();
91
91
  }
92
92
 
93
- /** Outbound text to the owner — used by send_message MCP tool. */
94
- async sendMessage(text: string): Promise<void> {
93
+ /** Outbound to the owner — used by send_message MCP tool. WhatsApp has no threading. */
94
+ async deliver(out: Outbound): Promise<void> {
95
95
  if (!this.twilio.owner_number) throw new Error("whatsapp: owner_number not set");
96
- await this.sendTextTo(this.twilio.owner_number, text);
97
- }
98
-
99
- /** Outbound media to the owner — used by send_message MCP tool with attachments. */
100
- async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
101
- if (!this.twilio.owner_number) throw new Error("whatsapp: owner_number not set");
102
- await this.sendMediaTo(this.twilio.owner_number, data, mimeType, filename);
96
+ const to = this.twilio.owner_number;
97
+ if (out.media) {
98
+ await this.sendMediaTo(to, Buffer.from(out.media.data), out.media.mimeType, out.media.filename);
99
+ }
100
+ if (out.text) {
101
+ await this.sendTextTo(to, out.text);
102
+ }
103
103
  }
104
104
 
105
105
  // --- Inbound webhook ---
@@ -119,107 +119,98 @@ class WhatsAppChannel implements Channel {
119
119
  // Serialize through the same lock so a /reset chasing an in-flight
120
120
  // engine.send() waits its turn instead of yanking the engine away.
121
121
  const state = await this.getState(from);
122
- state.lock = state.lock.then(
123
- async () => {
124
- const newState = await this.restartChat(from);
125
- await this.sendTextTo(
126
- from,
127
- `New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`,
128
- );
129
- },
130
- (err) => log.error({ err, from }, "whatsapp: /reset lock chain error"),
131
- );
122
+ chainLock(state, async () => {
123
+ const newState = await this.restartChat(from);
124
+ await this.sendTextTo(from, `New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`);
125
+ });
132
126
  return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
133
127
  }
134
128
 
135
129
  const descriptors = extractMedia(params);
136
130
 
137
131
  const state = await this.getState(from);
138
- state.lock = state.lock.then(
139
- async () => {
140
- let userText = body;
141
- let attachments: Attachment[] | undefined;
142
-
143
- if (descriptors.length > 0) {
144
- const downloaded = await downloadInboundMedia(descriptors, {
145
- accountSid: this.twilio.sid!,
146
- authSid: this.twilio.sid!,
147
- authSecret: this.twilio.secret!,
148
- });
149
-
150
- const voiceParts: string[] = [];
151
- const built: Attachment[] = [];
152
-
153
- for (const item of downloaded) {
154
- if (item.mime.startsWith(VOICE_MIME_PREFIX)) {
155
- if (!this.phone.openai_api_key) {
156
- voiceParts.push("[voice note: transcription unavailable — channels.phone.openai_api_key not set]");
157
- continue;
158
- }
159
- try {
160
- const transcript = await transcribeAudio({
161
- apiKey: this.phone.openai_api_key,
162
- data: item.data,
163
- mime: item.mime,
164
- });
165
- voiceParts.push(transcript || "[empty voice note]");
166
- } catch (err) {
167
- log.error({ err, from }, "whatsapp: voice transcription failed");
168
- voiceParts.push(
169
- `[voice note: transcription failed — ${err instanceof Error ? err.message : String(err)}]`,
170
- );
171
- }
132
+ chainLock(state, async () => {
133
+ let userText = body;
134
+ let attachments: Attachment[] | undefined;
135
+
136
+ if (descriptors.length > 0) {
137
+ const downloaded = await downloadInboundMedia(descriptors, {
138
+ accountSid: this.twilio.sid!,
139
+ authSid: this.twilio.sid!,
140
+ authSecret: this.twilio.secret!,
141
+ });
142
+
143
+ const voiceParts: string[] = [];
144
+ const built: Attachment[] = [];
145
+
146
+ for (const item of downloaded) {
147
+ if (item.mime.startsWith(VOICE_MIME_PREFIX)) {
148
+ if (!this.phone.openai_api_key) {
149
+ voiceParts.push("[voice note: transcription unavailable — channels.phone.openai_api_key not set]");
172
150
  continue;
173
151
  }
174
-
175
- const error = validateAttachment(item.data, item.mime);
176
- if (error) {
177
- log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
178
- await this.sendTextTo(from, `[error] ${error}`).catch(() => {});
179
- continue;
152
+ try {
153
+ const transcript = await transcribeAudio({
154
+ apiKey: this.phone.openai_api_key,
155
+ data: item.data,
156
+ mime: item.mime,
157
+ });
158
+ voiceParts.push(transcript || "[empty voice note]");
159
+ } catch (err) {
160
+ log.error({ err, from }, "whatsapp: voice transcription failed");
161
+ voiceParts.push(
162
+ `[voice note: transcription failed — ${err instanceof Error ? err.message : String(err)}]`,
163
+ );
180
164
  }
165
+ continue;
166
+ }
181
167
 
182
- const attType = classifyMime(item.mime) || "file";
183
- let data = item.data;
184
- let mime = item.mime;
185
- if (attType === "image") {
186
- const prepared = await prepareImage(data, mime);
187
- data = prepared.data;
188
- mime = prepared.mimeType;
189
- }
190
- built.push({ type: attType, data, mimeType: mime });
168
+ const error = validateAttachment(item.data);
169
+ if (error) {
170
+ log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
171
+ await this.sendTextTo(from, `[error] ${error}`).catch(() => {});
172
+ continue;
191
173
  }
192
174
 
193
- if (voiceParts.length > 0) {
194
- const joined = voiceParts.join("\n\n");
195
- userText = userText ? `${userText}\n\n${joined}` : joined;
175
+ const attType = classifyMime(item.mime) || "file";
176
+ let data = item.data;
177
+ let mime = item.mime;
178
+ if (attType === "image") {
179
+ const prepared = await prepareImage(data, mime);
180
+ data = prepared.data;
181
+ mime = prepared.mimeType;
196
182
  }
197
- if (built.length > 0) attachments = built;
183
+ built.push({ type: attType, data, mimeType: mime });
198
184
  }
199
185
 
200
- if (!userText && !attachments) {
201
- log.debug({ from }, "whatsapp: empty inbound (no body, no usable media)");
202
- return;
186
+ if (voiceParts.length > 0) {
187
+ const joined = voiceParts.join("\n\n");
188
+ userText = userText ? `${userText}\n\n${joined}` : joined;
203
189
  }
190
+ if (built.length > 0) attachments = built;
191
+ }
192
+
193
+ if (!userText && !attachments) {
194
+ log.debug({ from }, "whatsapp: empty inbound (no body, no usable media)");
195
+ return;
196
+ }
204
197
 
198
+ try {
199
+ const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
200
+ const reply = result.trim() || "(no response)";
205
201
  try {
206
- const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
207
- const reply = result.trim() || "(no response)";
208
- try {
209
- await this.sendTextTo(from, reply);
210
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
211
- } catch (sendErr) {
212
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
213
- throw sendErr;
214
- }
215
- } catch (err) {
216
- log.error({ err, from }, "whatsapp: engine error");
217
- const errText = err instanceof Error ? err.message : String(err);
218
- await this.sendTextTo(from, `[error] ${errText}`).catch(() => {});
202
+ await this.sendTextTo(from, reply);
203
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
204
+ } catch (sendErr) {
205
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
206
+ throw sendErr;
219
207
  }
220
- },
221
- (err) => log.error({ err, from }, "whatsapp: lock chain error"),
222
- );
208
+ } catch (err) {
209
+ log.error({ err, from }, "whatsapp: engine error");
210
+ const errText = err instanceof Error ? err.message : String(err);
211
+ await this.sendTextTo(from, `[error] ${errText}`).catch(() => {});
212
+ }
213
+ });
223
214
 
224
215
  return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
225
216
  }
@@ -321,43 +312,20 @@ class WhatsAppChannel implements Channel {
321
312
  private async getState(remoteE164: string): Promise<ChatState> {
322
313
  let state = this.chats.get(remoteE164);
323
314
  if (state) return state;
324
- const prefix = this.roomPrefix(remoteE164);
325
- const idx = await Session.getLatestRoomIndex(prefix);
326
- const room = `${prefix}-${idx}`;
327
- log.info({ remoteE164, room }, "whatsapp: creating chat engine");
328
- const engine = await createChatEngine({
329
- room,
315
+ state = await openChatEngine(this.roomPrefix(remoteE164), () => ({
330
316
  channel: "whatsapp",
331
- resume: true,
332
317
  mcpServers: getMcpServers(),
333
- });
334
- state = { engine, roomIndex: idx, lock: Promise.resolve() };
318
+ }));
335
319
  this.chats.set(remoteE164, state);
336
320
  return state;
337
321
  }
338
322
 
339
323
  private async restartChat(remoteE164: string): Promise<ChatState> {
340
- const old = this.chats.get(remoteE164);
341
- if (old) old.engine.close();
342
-
343
- const prefix = this.roomPrefix(remoteE164);
344
- const prevIdx = await Session.getLatestRoomIndex(prefix);
345
- const newIdx = prevIdx + 1;
346
- const room = `${prefix}-${newIdx}`;
347
-
348
- // Persist a placeholder session so the room index survives daemon
349
- // restarts (otherwise getState falls back to the old room).
350
- await Session.create(`placeholder-${room}`, room);
351
-
352
- const engine = await createChatEngine({
353
- room,
324
+ const state = await rotateRoom(this.roomPrefix(remoteE164), this.chats.get(remoteE164), () => ({
354
325
  channel: "whatsapp",
355
- resume: false,
356
326
  mcpServers: getMcpServers(),
357
- });
358
- const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
327
+ }));
359
328
  this.chats.set(remoteE164, state);
360
- log.info({ remoteE164, room }, "whatsapp: new conversation started");
361
329
  return state;
362
330
  }
363
331
  }
@@ -8,8 +8,7 @@ import { getEmployeesSummary } from "../core/employees";
8
8
  import { Session } from "../db/models";
9
9
  import type { Mode } from "../types";
10
10
 
11
- // Re-export for backwards compat
12
- export { scanSkills as loadSkills, getSkillNames as loadSkillNames, type SkillInfo } from "../core/skills";
11
+ export { type SkillInfo } from "../core/skills";
13
12
 
14
13
  function loadFile(dir: string, name: string): string {
15
14
  const filePath = join(dir, name);
package/src/cli/phone.ts CHANGED
@@ -45,7 +45,7 @@ async function phoneCallCommand(): Promise<void> {
45
45
  const channel = createPhoneChannel();
46
46
  if (!channel) {
47
47
  fail(
48
- "Phone channel not configured. Set channels.phone.{twilio_sid,twilio_secret,from_number} in ~/.niahere/config.yaml (also channels.phone.{openai_api_key,public_base_url} for the realtime voice loop). Env vars TWILIO_SID / TWILIO_SECRET / PHONE_FROM_NUMBER / OPENAI_API_KEY / PUBLIC_BASE_URL override if you prefer .env.",
48
+ "Phone channel not configured. Set channels.twilio.{sid,secret} and channels.phone.from_number in ~/.niahere/config.yaml (also channels.phone.openai_api_key and channels.twilio.public_base_url for the realtime voice loop). Env vars TWILIO_SID / TWILIO_SECRET / PHONE_FROM_NUMBER / OPENAI_API_KEY / PUBLIC_BASE_URL override if you prefer .env.",
49
49
  );
50
50
  }
51
51
 
@@ -125,11 +125,14 @@ function helpText(): string {
125
125
  " phone server, dials, waits, prints transcript.",
126
126
  " status Show phone channel configuration.",
127
127
  "",
128
- "Config lives in ~/.niahere/config.yaml under channels.phone:",
129
- " twilio_sid, twilio_secret, from_number (required)",
130
- " openai_api_key, public_base_url (required for realtime voice loop)",
131
- " twilio_auth_token (required if twilio_sid is an API Key SID)",
132
- " port, voice, realtime_model, allowlist (optional)",
128
+ "Config lives in ~/.niahere/config.yaml:",
129
+ " channels.twilio.{sid, secret} (required)",
130
+ " channels.phone.from_number (required)",
131
+ " channels.phone.openai_api_key, channels.twilio.public_base_url",
132
+ " (required for realtime voice loop)",
133
+ " channels.twilio.auth_token (required if sid is an API Key SID)",
134
+ " channels.twilio.{owner_number, allowlist, port} (optional)",
135
+ " channels.phone.{voice, realtime_model} (optional)",
133
136
  "",
134
137
  "Each field can be overridden by the matching env var (TWILIO_SID, OPENAI_API_KEY, etc.)",
135
138
  "if you prefer .env. See the nia-phone skill for full deploy walkthrough.",
@@ -215,14 +215,18 @@ export async function runInit(): Promise<void> {
215
215
  }
216
216
  }
217
217
 
218
- // Phone (Twilio Voice + OpenAI Realtime)
218
+ // Phone (Twilio Voice + OpenAI Realtime). Shared Twilio creds (sid,
219
+ // secret, auth_token, owner_number, public_base_url) live under
220
+ // channels.twilio so SMS and WhatsApp can reuse them; voice-specific
221
+ // fields stay under channels.phone.
222
+ const exTw = (exCh.twilio || {}) as Record<string, unknown>;
219
223
  const exPh = (exCh.phone || {}) as Record<string, unknown>;
220
- let phoneTwilioSid = (exPh.twilio_sid as string) || "";
221
- let phoneTwilioSecret = (exPh.twilio_secret as string) || "";
222
- let phoneTwilioAuthToken = (exPh.twilio_auth_token as string) || "";
224
+ let phoneTwilioSid = (exTw.sid as string) || "";
225
+ let phoneTwilioSecret = (exTw.secret as string) || "";
226
+ let phoneTwilioAuthToken = (exTw.auth_token as string) || "";
223
227
  let phoneFromNumber = (exPh.from_number as string) || "";
224
- let phoneOwnerNumber = (exPh.owner_number as string) || "";
225
- let phonePublicBaseUrl = (exPh.public_base_url as string) || "";
228
+ let phoneOwnerNumber = (exTw.owner_number as string) || "";
229
+ let phonePublicBaseUrl = (exTw.public_base_url as string) || "";
226
230
  let phoneOpenAiKey = (exPh.openai_api_key as string) || "";
227
231
  let phoneVoice = (exPh.voice as string) || "";
228
232
 
@@ -485,14 +489,13 @@ export async function runInit(): Promise<void> {
485
489
  channels.default = "slack";
486
490
  }
487
491
  if (phoneTwilioSid && phoneTwilioSecret && phoneFromNumber) {
488
- const ph: Record<string, unknown> = {
489
- twilio_sid: phoneTwilioSid,
490
- twilio_secret: phoneTwilioSecret,
491
- from_number: phoneFromNumber,
492
- };
493
- if (phoneTwilioAuthToken) ph.twilio_auth_token = phoneTwilioAuthToken;
494
- if (phoneOwnerNumber) ph.owner_number = phoneOwnerNumber;
495
- if (phonePublicBaseUrl) ph.public_base_url = phonePublicBaseUrl.replace(/\/$/, "");
492
+ const tw: Record<string, unknown> = { sid: phoneTwilioSid, secret: phoneTwilioSecret };
493
+ if (phoneTwilioAuthToken) tw.auth_token = phoneTwilioAuthToken;
494
+ if (phoneOwnerNumber) tw.owner_number = phoneOwnerNumber;
495
+ if (phonePublicBaseUrl) tw.public_base_url = phonePublicBaseUrl.replace(/\/$/, "");
496
+ channels.twilio = tw;
497
+
498
+ const ph: Record<string, unknown> = { from_number: phoneFromNumber };
496
499
  if (phoneOpenAiKey) ph.openai_api_key = phoneOpenAiKey;
497
500
  if (phoneVoice && phoneVoice !== "marin") ph.voice = phoneVoice;
498
501
  channels.phone = ph;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
2
- import { join } from "path";
2
+ import { join, resolve } from "path";
3
3
  import { homedir } from "os";
4
4
  import { getPaths } from "../utils/paths";
5
5
  import { findDaemonPids } from "../core/daemon";
@@ -29,6 +29,9 @@ function plistPath(): string {
29
29
  function buildPlist(): string {
30
30
  const paths = getPaths();
31
31
  const [execPath, cliPath] = getExecCommand();
32
+ // Bun auto-loads .env from cwd. Without WorkingDirectory, launchd
33
+ // spawns the daemon with cwd=/ and any credentials in .env never load.
34
+ const workingDir = resolve(cliPath, "../../..");
32
35
 
33
36
  return `<?xml version="1.0" encoding="UTF-8"?>
34
37
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -42,6 +45,8 @@ function buildPlist(): string {
42
45
  <string>${cliPath}</string>
43
46
  <string>run</string>
44
47
  </array>
48
+ <key>WorkingDirectory</key>
49
+ <string>${workingDir}</string>
45
50
  <key>RunAtLoad</key>
46
51
  <true/>
47
52
  <key>KeepAlive</key>
@@ -108,6 +113,7 @@ function unitPath(): string {
108
113
  function buildUnit(): string {
109
114
  const paths = getPaths();
110
115
  const [execPath, cliPath] = getExecCommand();
116
+ const workingDir = resolve(cliPath, "../../..");
111
117
 
112
118
  return `[Unit]
113
119
  Description=nia personal AI assistant
@@ -115,6 +121,7 @@ After=network.target
115
121
 
116
122
  [Service]
117
123
  ExecStart=${execPath} ${cliPath} run
124
+ WorkingDirectory=${workingDir}
118
125
  Restart=always
119
126
  RestartSec=5
120
127
  StandardOutput=append:${paths.daemonLog}
@@ -137,7 +144,10 @@ async function installSystemd(): Promise<void> {
137
144
  const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
138
145
  await reload.exited;
139
146
 
140
- const enable = Bun.spawn(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], { stdout: "pipe", stderr: "pipe" });
147
+ const enable = Bun.spawn(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], {
148
+ stdout: "pipe",
149
+ stderr: "pipe",
150
+ });
141
151
  const exitCode = await enable.exited;
142
152
 
143
153
  if (exitCode !== 0) {
@@ -150,10 +160,17 @@ async function uninstallSystemd(): Promise<void> {
150
160
  const path = unitPath();
151
161
  if (!existsSync(path)) return;
152
162
 
153
- const disable = Bun.spawn(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], { stdout: "pipe", stderr: "pipe" });
163
+ const disable = Bun.spawn(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], {
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ });
154
167
  await disable.exited;
155
168
 
156
- try { unlinkSync(path); } catch { /* already gone */ }
169
+ try {
170
+ unlinkSync(path);
171
+ } catch {
172
+ /* already gone */
173
+ }
157
174
 
158
175
  const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
159
176
  await reload.exited;
@@ -197,7 +214,7 @@ export async function restartService(opts: { force?: boolean } = {}): Promise<vo
197
214
  const path = plistPath();
198
215
  // Unload — sends SIGTERM and disables KeepAlive respawn
199
216
  const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
200
- if (await unload.exited !== 0) {
217
+ if ((await unload.exited) !== 0) {
201
218
  const stderr = await new Response(unload.stderr).text();
202
219
  console.error(` warning: launchctl unload failed: ${stderr.trim()}`);
203
220
  }
@@ -206,7 +223,7 @@ export async function restartService(opts: { force?: boolean } = {}): Promise<vo
206
223
  if (opts.force) clearForceShutdownRequest();
207
224
  // Load — starts a fresh single instance
208
225
  const load = Bun.spawn(["launchctl", "load", path], { stdout: "pipe", stderr: "pipe" });
209
- if (await load.exited !== 0) {
226
+ if ((await load.exited) !== 0) {
210
227
  const stderr = await new Response(load.stderr).text();
211
228
  console.error(` warning: launchctl load failed: ${stderr.trim()}`);
212
229
  }
@@ -227,6 +244,8 @@ async function waitForDaemonExit(timeoutMs: number): Promise<void> {
227
244
  }
228
245
  // Force kill any stragglers
229
246
  for (const pid of findDaemonPids()) {
230
- try { process.kill(pid, "SIGKILL"); } catch {}
247
+ try {
248
+ process.kill(pid, "SIGKILL");
249
+ } catch {}
231
250
  }
232
251
  }
package/src/mcp/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
2
- import type { ScheduleType } from "../types";
1
+ import { readFileSync, appendFileSync, existsSync } from "fs";
2
+ import type { Recipient, ScheduleType } from "../types";
3
3
  import { basename, join } from "path";
4
4
  import { randomUUID } from "crypto";
5
5
  import { Job, Message, Session } from "../db/models";
@@ -8,7 +8,6 @@ import { getConfig, readRawConfig, updateRawConfig, writeRawConfig } from "../ut
8
8
  import { getPaths } from "../utils/paths";
9
9
  import { getChannel } from "../channels/registry";
10
10
  import { log } from "../utils/log";
11
- import { classifyMime } from "../utils/attachment";
12
11
  import { scanAgents } from "../core/agents";
13
12
  import { listEmployeesForMcp } from "../core/employees";
14
13
  import { resolveJobPrompt } from "../core/job-prompt";
@@ -312,42 +311,27 @@ export async function sendMessage(
312
311
  }
313
312
 
314
313
  try {
315
- // Handle media attachment if provided
314
+ let media: { data: Uint8Array; mimeType: string; filename: string } | undefined;
316
315
  if (mediaPath) {
317
316
  if (!existsSync(mediaPath)) {
318
317
  if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
319
318
  return `Failed to send: file not found: ${mediaPath}`;
320
319
  }
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
- }
320
+ const buf = readFileSync(mediaPath);
321
+ media = { data: new Uint8Array(buf), mimeType: guessMime(mediaPath), filename: basename(mediaPath) };
322
+ }
332
323
 
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
- }
324
+ const recipient: Recipient = useThread
325
+ ? { kind: "thread", channelId: sourceCtx!.slackChannelId!, threadTs: sourceCtx!.slackThreadTs }
326
+ : { kind: "owner" };
327
+
328
+ if (channel) {
329
+ await channel.deliver({ text: text || undefined, media, to: recipient });
343
330
  } 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
- }
331
+ // No started channel in this process (e.g. CLI `nia send` outside the daemon).
332
+ // Fall back to API-direct send — text-only, no thread fan-out.
333
+ if (media) await sendMediaDirect(channelTarget, Buffer.from(media.data), media.mimeType, media.filename);
334
+ if (text) await sendDirect(channelTarget, text);
351
335
  }
352
336
 
353
337
  // Mark as sent
@@ -480,7 +464,7 @@ export async function placeCall(args: {
480
464
  const { getPhoneChannel } = await import("../channels/phone");
481
465
  const phone = getPhoneChannel();
482
466
  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.";
467
+ return "Phone channel is not configured. Add channels.twilio.{sid, secret, public_base_url} and channels.phone.{from_number, openai_api_key} to ~/.niahere/config.yaml (or set the matching env vars in .env), then restart the daemon.";
484
468
  }
485
469
  try {
486
470
  const result = await phone.placeCall({
@@ -1,13 +1,42 @@
1
+ /**
2
+ * Where an outbound payload is delivered.
3
+ *
4
+ * - `owner` → the channel's configured default recipient (DM user, owner
5
+ * phone number, etc.). Always supported.
6
+ * - `thread` → reply in a specific Slack thread. Channels that don't
7
+ * support threads fall back to `owner`.
8
+ */
9
+ export type Recipient = { kind: "owner" } | { kind: "thread"; channelId: string; threadTs?: string };
10
+
11
+ export interface OutboundMedia {
12
+ data: Uint8Array;
13
+ mimeType: string;
14
+ filename?: string;
15
+ }
16
+
17
+ /**
18
+ * Structured payload for agent-initiated outbound messages (`send_message`
19
+ * MCP tool, cross-channel notifications). Replaces the old optional
20
+ * sendMessage / sendMedia / sendToThread / sendMediaToThread surface.
21
+ */
22
+ export interface Outbound {
23
+ text?: string;
24
+ media?: OutboundMedia;
25
+ /** Defaults to `{ kind: "owner" }`. */
26
+ to?: Recipient;
27
+ }
28
+
1
29
  export interface Channel {
30
+ /** Channel identifier. Built-in channels use the `ChannelName` literals; test fixtures may use other strings. */
2
31
  name: string;
3
32
  start(): Promise<void>;
4
33
  stop(): Promise<void>;
5
- sendMessage?(text: string): Promise<void>;
6
- sendMedia?(data: Buffer, mimeType: string, filename?: string): Promise<void>;
7
- /** Send media to a specific channel/thread when the channel supports it. */
8
- sendMediaToThread?(channelId: string, data: Buffer, mimeType: string, filename?: string, threadTs?: string): Promise<void>;
9
- /** Send a message to a specific channel/thread (e.g. reply back to a Slack thread). */
10
- sendToThread?(channelId: string, text: string, threadTs?: string): Promise<void>;
34
+ /**
35
+ * Deliver an outbound payload. Channels are expected to handle either
36
+ * a text-only, media-only, or text+media payload; format details (chunking,
37
+ * markdown, attachment shape) are channel-specific.
38
+ */
39
+ deliver(out: Outbound): Promise<void>;
11
40
  }
12
41
 
13
42
  export type ChannelFactory = () => Channel | null;
@@ -3,7 +3,7 @@ export type { JobStatus, JobStateStatus, JobLifecycle, ScheduleType, Mode, Attac
3
3
  export type { JobInput, JobPromptSource, JobResult, ResolvedJobPrompt } from "./job";
4
4
  export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "./engine";
5
5
  export type { AuditEntry, JobState, CronState } from "./audit";
6
- export type { Channel, ChannelFactory } from "./channel";
6
+ export type { Channel, ChannelFactory, Outbound, OutboundMedia, Recipient } from "./channel";
7
7
  export type { ChatState } from "./chat-state";
8
8
  export type {
9
9
  Config,
@@ -1,5 +1,11 @@
1
1
  import type { AttachmentType } from "../types";
2
- import { IMAGE_MIMES, DOCUMENT_MIMES, MAX_ATTACHMENT_SIZE, MAX_IMAGE_DIMENSION, JPEG_QUALITY } from "../constants/attachment";
2
+ import {
3
+ IMAGE_MIMES,
4
+ DOCUMENT_MIMES,
5
+ MAX_ATTACHMENT_SIZE,
6
+ MAX_IMAGE_DIMENSION,
7
+ JPEG_QUALITY,
8
+ } from "../constants/attachment";
3
9
 
4
10
  export function classifyMime(mimeType: string): AttachmentType | null {
5
11
  if (IMAGE_MIMES.has(mimeType)) return "image";
@@ -8,7 +14,7 @@ export function classifyMime(mimeType: string): AttachmentType | null {
8
14
  return "file";
9
15
  }
10
16
 
11
- export function validateAttachment(data: Buffer, mimeType: string): string | null {
17
+ export function validateAttachment(data: Buffer): string | null {
12
18
  if (data.length > MAX_ATTACHMENT_SIZE) {
13
19
  return `File too large (${(data.length / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_SIZE / 1024 / 1024}MB)`;
14
20
  }