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/channels/whatsapp.ts
CHANGED
|
@@ -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 {
|
|
21
|
+
import { Message } from "../db/models";
|
|
23
22
|
import { runMigrations } from "../db/migrate";
|
|
24
|
-
import
|
|
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
|
|
94
|
-
async
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
183
|
+
built.push({ type: attType, data, mimeType: mime });
|
|
198
184
|
}
|
|
199
185
|
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/chat/identity.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
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.",
|
package/src/commands/init.ts
CHANGED
|
@@ -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 = (
|
|
221
|
-
let phoneTwilioSecret = (
|
|
222
|
-
let phoneTwilioAuthToken = (
|
|
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 = (
|
|
225
|
-
let phonePublicBaseUrl = (
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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;
|
package/src/commands/service.ts
CHANGED
|
@@ -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], {
|
|
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], {
|
|
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 {
|
|
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 {
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, "SIGKILL");
|
|
249
|
+
} catch {}
|
|
231
250
|
}
|
|
232
251
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool handler barrel. Domain modules live next to this file. Callers
|
|
3
|
+
* `import * as handlers from "./tools"` and get all of them.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./jobs";
|
|
6
|
+
export * from "./send";
|
|
7
|
+
export * from "./messages";
|
|
8
|
+
export * from "./watch";
|
|
9
|
+
export * from "./misc";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Job } from "../../db/models";
|
|
2
|
+
import { computeInitialNextRun } from "../../core/scheduler";
|
|
3
|
+
import { getConfig } from "../../utils/config";
|
|
4
|
+
import { resolveJobPrompt } from "../../core/job-prompt";
|
|
5
|
+
import type { ScheduleType } from "../../types";
|
|
6
|
+
|
|
7
|
+
export async function listJobs(): Promise<string> {
|
|
8
|
+
const jobs = await Job.list();
|
|
9
|
+
if (jobs.length === 0) return "No jobs found.";
|
|
10
|
+
const withPromptSource = jobs.map((job) => {
|
|
11
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
12
|
+
return {
|
|
13
|
+
...job,
|
|
14
|
+
prompt: resolvedPrompt.prompt,
|
|
15
|
+
promptSource: resolvedPrompt.source,
|
|
16
|
+
promptPath: resolvedPrompt.filePath,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
return JSON.stringify(withPromptSource, null, 2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function addJob(args: {
|
|
23
|
+
name: string;
|
|
24
|
+
schedule: string;
|
|
25
|
+
prompt: string;
|
|
26
|
+
schedule_type?: ScheduleType;
|
|
27
|
+
always?: boolean;
|
|
28
|
+
agent?: string;
|
|
29
|
+
employee?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
stateless?: boolean;
|
|
32
|
+
}): Promise<string> {
|
|
33
|
+
const scheduleType = args.schedule_type || "cron";
|
|
34
|
+
const always = args.always || false;
|
|
35
|
+
const stateless = args.stateless || false;
|
|
36
|
+
const config = getConfig();
|
|
37
|
+
|
|
38
|
+
const nextRunAt = computeInitialNextRun(scheduleType, args.schedule, config.timezone);
|
|
39
|
+
await Job.create(
|
|
40
|
+
args.name,
|
|
41
|
+
args.schedule,
|
|
42
|
+
args.prompt,
|
|
43
|
+
always,
|
|
44
|
+
scheduleType,
|
|
45
|
+
nextRunAt,
|
|
46
|
+
args.agent,
|
|
47
|
+
stateless,
|
|
48
|
+
args.model,
|
|
49
|
+
args.employee,
|
|
50
|
+
);
|
|
51
|
+
const agentNote = args.agent ? ` [agent: ${args.agent}]` : "";
|
|
52
|
+
const employeeNote = args.employee ? ` [employee: ${args.employee}]` : "";
|
|
53
|
+
const modelNote = args.model ? ` [model: ${args.model}]` : "";
|
|
54
|
+
return `Job "${args.name}" created (${scheduleType}: ${args.schedule})${agentNote}${employeeNote}${modelNote}. Next run: ${nextRunAt.toISOString()}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function updateJob(args: {
|
|
58
|
+
name: string;
|
|
59
|
+
schedule?: string;
|
|
60
|
+
prompt?: string;
|
|
61
|
+
always?: boolean;
|
|
62
|
+
agent?: string | null;
|
|
63
|
+
employee?: string | null;
|
|
64
|
+
model?: string | null;
|
|
65
|
+
stateless?: boolean;
|
|
66
|
+
schedule_type?: "cron" | "interval" | "once";
|
|
67
|
+
}): Promise<string> {
|
|
68
|
+
const fields: Partial<{
|
|
69
|
+
schedule: string;
|
|
70
|
+
prompt: string;
|
|
71
|
+
always: boolean;
|
|
72
|
+
stateless: boolean;
|
|
73
|
+
model: string | null;
|
|
74
|
+
agent: string | null;
|
|
75
|
+
employee: string | null;
|
|
76
|
+
scheduleType: "cron" | "interval" | "once";
|
|
77
|
+
}> = {};
|
|
78
|
+
if (args.schedule) fields.schedule = args.schedule;
|
|
79
|
+
if (args.prompt) fields.prompt = args.prompt;
|
|
80
|
+
if (args.always !== undefined) fields.always = args.always;
|
|
81
|
+
if (args.stateless !== undefined) fields.stateless = args.stateless;
|
|
82
|
+
if (args.model !== undefined) fields.model = args.model;
|
|
83
|
+
if (args.agent !== undefined) fields.agent = args.agent;
|
|
84
|
+
if (args.employee !== undefined) fields.employee = args.employee;
|
|
85
|
+
if (args.schedule_type) fields.scheduleType = args.schedule_type;
|
|
86
|
+
|
|
87
|
+
if (Object.keys(fields).length === 0)
|
|
88
|
+
return "Nothing to update. Pass at least one field (schedule, prompt, always, stateless, model, agent, employee, or schedule_type).";
|
|
89
|
+
|
|
90
|
+
const updated = await Job.update(args.name, fields);
|
|
91
|
+
if (!updated) return `Job "${args.name}" not found.`;
|
|
92
|
+
if (fields.prompt !== undefined) {
|
|
93
|
+
const job = await Job.get(args.name);
|
|
94
|
+
if (job) {
|
|
95
|
+
const resolvedPrompt = resolveJobPrompt(job);
|
|
96
|
+
if (resolvedPrompt.source === "file") {
|
|
97
|
+
return `Job "${args.name}" updated. Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return `Job "${args.name}" updated.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function removeJob(name: string): Promise<string> {
|
|
105
|
+
const removed = await Job.remove(name);
|
|
106
|
+
return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function enableJob(name: string): Promise<string> {
|
|
110
|
+
const updated = await Job.update(name, { status: "active" });
|
|
111
|
+
if (!updated) return `Job "${name}" not found.`;
|
|
112
|
+
|
|
113
|
+
const job = await Job.get(name);
|
|
114
|
+
if (job) {
|
|
115
|
+
const config = getConfig();
|
|
116
|
+
const nextRun = computeInitialNextRun(job.scheduleType, job.schedule, config.timezone);
|
|
117
|
+
const { getSql } = await import("../../db/connection");
|
|
118
|
+
await getSql()`UPDATE jobs SET next_run_at = ${nextRun} WHERE name = ${name}`;
|
|
119
|
+
}
|
|
120
|
+
return `Job "${name}" enabled.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function disableJob(name: string): Promise<string> {
|
|
124
|
+
const updated = await Job.update(name, { status: "disabled" });
|
|
125
|
+
return updated ? `Job "${name}" disabled.` : `Job "${name}" not found.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function archiveJob(name: string): Promise<string> {
|
|
129
|
+
const updated = await Job.update(name, { status: "archived" });
|
|
130
|
+
return updated ? `Job "${name}" archived.` : `Job "${name}" not found.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function unarchiveJob(name: string): Promise<string> {
|
|
134
|
+
const updated = await Job.update(name, { status: "disabled" });
|
|
135
|
+
return updated ? `Job "${name}" unarchived (disabled). Enable with enable_job.` : `Job "${name}" not found.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runJobNow(name: string): Promise<string> {
|
|
139
|
+
const job = await Job.get(name);
|
|
140
|
+
if (!job) return `Job "${name}" not found.`;
|
|
141
|
+
|
|
142
|
+
const { getSql } = await import("../../db/connection");
|
|
143
|
+
await getSql()`UPDATE jobs SET next_run_at = NOW() WHERE name = ${name}`;
|
|
144
|
+
return `Job "${name}" queued for immediate execution.`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Message, Session } from "../../db/models";
|
|
2
|
+
|
|
3
|
+
export async function listMessages(limit = 20, room?: string): Promise<string> {
|
|
4
|
+
const messages = await Message.getRecent(limit, room);
|
|
5
|
+
if (messages.length === 0) return "No messages found.";
|
|
6
|
+
return JSON.stringify(messages, null, 2);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function listSessions(limit = 10, room?: string): Promise<string> {
|
|
10
|
+
const sessions = await Session.listRecent(limit, room);
|
|
11
|
+
if (sessions.length === 0) return "No sessions found.";
|
|
12
|
+
return JSON.stringify(sessions, null, 2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function searchMessages(query: string, limit = 20, room?: string): Promise<string> {
|
|
16
|
+
const results = await Message.search(query, limit, room);
|
|
17
|
+
if (results.length === 0) return "No matching messages found.";
|
|
18
|
+
return JSON.stringify(results, null, 2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readSession(sessionId: string): Promise<string> {
|
|
22
|
+
const messages = await Message.getBySession(sessionId);
|
|
23
|
+
if (messages.length === 0) return "Session not found or has no messages.";
|
|
24
|
+
return JSON.stringify(messages, null, 2);
|
|
25
|
+
}
|