niahere 0.4.3 → 0.4.5
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/index.ts +41 -3
- package/src/channels/sms.ts +5 -2
- package/src/channels/whatsapp.ts +7 -4
- package/src/chat/engine.ts +9 -4
- package/src/core/alive.ts +22 -0
- package/src/core/daemon.ts +7 -33
- package/src/core/scheduler.ts +4 -4
- package/src/db/models/message.ts +0 -25
- package/src/db/models/session.ts +2 -1
package/package.json
CHANGED
package/src/channels/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Channel } from "../types";
|
|
2
|
-
import { registerChannel, getFactories, trackStarted, clearStarted } from "./registry";
|
|
2
|
+
import { registerChannel, getFactories, trackStarted, clearStarted, getStarted } from "./registry";
|
|
3
3
|
import { log } from "../utils/log";
|
|
4
4
|
import { getConfig } from "../utils/config";
|
|
5
5
|
import { createTelegramChannel } from "./telegram";
|
|
@@ -25,10 +25,12 @@ export interface StartResult {
|
|
|
25
25
|
failed: string[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function startChannels(): Promise<StartResult> {
|
|
28
|
+
export async function startChannels(only?: readonly string[]): Promise<StartResult> {
|
|
29
|
+
const onlySet = only ? new Set(only) : null;
|
|
29
30
|
const pending = getFactories()
|
|
30
31
|
.map((factory) => factory())
|
|
31
|
-
.filter((ch): ch is Channel => ch !== null)
|
|
32
|
+
.filter((ch): ch is Channel => ch !== null)
|
|
33
|
+
.filter((ch) => onlySet === null || onlySet.has(ch.name));
|
|
32
34
|
|
|
33
35
|
if (pending.length === 0) return { started: [], failed: [] };
|
|
34
36
|
|
|
@@ -60,6 +62,42 @@ export async function startChannels(): Promise<StartResult> {
|
|
|
60
62
|
return { started, failed };
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Bring the running channel set back in line with configuration. Starts any
|
|
67
|
+
* configured-but-not-running channels and stops any running-but-unconfigured
|
|
68
|
+
* ones. A no-op when already in sync, so it is safe to call on a timer.
|
|
69
|
+
*
|
|
70
|
+
* This is the recovery path for the boot-time race where channels fail to
|
|
71
|
+
* start because Postgres isn't ready yet (channel `.start()` reads the DB):
|
|
72
|
+
* `startChannels` abandons the failed channels with no retry, so without
|
|
73
|
+
* reconciliation Nia stays alive but deaf on every channel until a manual
|
|
74
|
+
* restart. The alive monitor calls this every healthy heartbeat.
|
|
75
|
+
*/
|
|
76
|
+
export async function reconcileChannels(): Promise<StartResult> {
|
|
77
|
+
const wanted = getConfiguredChannelNames();
|
|
78
|
+
const running = getStarted();
|
|
79
|
+
const runningNames = new Set(running.map((ch) => ch.name));
|
|
80
|
+
const wantedSet = new Set(wanted);
|
|
81
|
+
|
|
82
|
+
const missing = wanted.filter((name) => !runningNames.has(name));
|
|
83
|
+
const extra = running.filter((ch) => !wantedSet.has(ch.name));
|
|
84
|
+
if (missing.length === 0 && extra.length === 0) return { started: [], failed: [] };
|
|
85
|
+
|
|
86
|
+
log.warn({ missing, extra: extra.map((ch) => ch.name) }, "channels out of sync, reconciling");
|
|
87
|
+
|
|
88
|
+
// A channel was removed from config. stopChannels() tears down the whole
|
|
89
|
+
// registry (it stops the shared Twilio server and clears all tracking), so a
|
|
90
|
+
// partial stop isn't possible — rebuild the full configured set instead.
|
|
91
|
+
if (extra.length > 0) {
|
|
92
|
+
await stopChannels(running);
|
|
93
|
+
return wanted.length > 0 ? startChannels() : { started: [], failed: [] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only additions: start just the missing channels so healthy ones stay
|
|
97
|
+
// connected and one persistently-failing channel can't thrash the rest.
|
|
98
|
+
return startChannels(missing);
|
|
99
|
+
}
|
|
100
|
+
|
|
63
101
|
export function getConfiguredChannelNames(): string[] {
|
|
64
102
|
const { channels } = getConfig();
|
|
65
103
|
if (!channels.enabled) return [];
|
package/src/channels/sms.ts
CHANGED
|
@@ -131,8 +131,7 @@ class SmsChannel implements Channel {
|
|
|
131
131
|
|
|
132
132
|
private async sendTo(remoteE164: string, body: string): Promise<void> {
|
|
133
133
|
if (!this.twilio.sid || !this.twilio.secret) {
|
|
134
|
-
|
|
135
|
-
return;
|
|
134
|
+
throw new Error("sms: twilio sid/secret missing, cannot send");
|
|
136
135
|
}
|
|
137
136
|
try {
|
|
138
137
|
const res = await twilioSendMessage({
|
|
@@ -147,6 +146,10 @@ class SmsChannel implements Channel {
|
|
|
147
146
|
log.info({ to: remoteE164, sid: res.messageSid, status: res.status }, "sms: sent");
|
|
148
147
|
} catch (err) {
|
|
149
148
|
log.error({ err, to: remoteE164 }, "sms: send failed");
|
|
149
|
+
// Rethrow so deliver() propagates the failure: the inbound handler falls
|
|
150
|
+
// back to an error reply, and the send_message tool reports "Failed to
|
|
151
|
+
// send" instead of falsely claiming delivery.
|
|
152
|
+
throw err;
|
|
150
153
|
}
|
|
151
154
|
}
|
|
152
155
|
|
package/src/channels/whatsapp.ts
CHANGED
|
@@ -121,7 +121,10 @@ class WhatsAppChannel implements Channel {
|
|
|
121
121
|
const state = await this.getState(from);
|
|
122
122
|
chainLock(state, async () => {
|
|
123
123
|
const newState = await this.restartChat(from);
|
|
124
|
-
await this.sendTextTo(
|
|
124
|
+
await this.sendTextTo(
|
|
125
|
+
from,
|
|
126
|
+
`New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`,
|
|
127
|
+
).catch((err) => log.warn({ err, from }, "whatsapp: failed to send reset confirmation"));
|
|
125
128
|
});
|
|
126
129
|
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
127
130
|
}
|
|
@@ -231,7 +234,7 @@ class WhatsAppChannel implements Channel {
|
|
|
231
234
|
// --- Outbound ---
|
|
232
235
|
|
|
233
236
|
private async sendTextTo(remoteE164: string, body: string): Promise<void> {
|
|
234
|
-
if (!this.canSend(remoteE164))
|
|
237
|
+
if (!this.canSend(remoteE164)) throw new Error("whatsapp: cannot send (missing creds or outside 24h window)");
|
|
235
238
|
const converted = toWhatsAppMarkdown(body);
|
|
236
239
|
const chunks = chunkText(converted, CHUNK_LIMIT);
|
|
237
240
|
for (const chunk of chunks) {
|
|
@@ -240,14 +243,14 @@ class WhatsAppChannel implements Channel {
|
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
private async sendMediaTo(remoteE164: string, data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
243
|
-
if (!this.canSend(remoteE164))
|
|
246
|
+
if (!this.canSend(remoteE164)) throw new Error("whatsapp: cannot send (missing creds or outside 24h window)");
|
|
244
247
|
const ext = filename ? extOf(filename) : undefined;
|
|
245
248
|
let mediaUrl: string;
|
|
246
249
|
try {
|
|
247
250
|
mediaUrl = await getTwilioServer().serveMedia(new Uint8Array(data), mimeType, ext);
|
|
248
251
|
} catch (err) {
|
|
249
252
|
log.error({ err }, "whatsapp: serveMedia failed");
|
|
250
|
-
|
|
253
|
+
throw err;
|
|
251
254
|
}
|
|
252
255
|
await this.postMessage(remoteE164, "", [mediaUrl]);
|
|
253
256
|
}
|
package/src/chat/engine.ts
CHANGED
|
@@ -37,11 +37,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
37
37
|
const { room, channel, resume, mcpServers } = opts;
|
|
38
38
|
let systemPrompt = buildSystemPrompt("chat", channel);
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// Recent session summaries for continuity. Appended AFTER persona selection
|
|
41
|
+
// below — the employee/agent branches replace systemPrompt wholesale, so
|
|
42
|
+
// injecting here would be discarded for those contexts.
|
|
41
43
|
const sessionContext = await getSessionContext(room);
|
|
42
|
-
if (sessionContext) {
|
|
43
|
-
systemPrompt += "\n\n" + sessionContext;
|
|
44
|
-
}
|
|
45
44
|
|
|
46
45
|
// Context overrides: employee > agent > job > default
|
|
47
46
|
let cwd = homedir();
|
|
@@ -86,6 +85,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
86
85
|
}
|
|
87
86
|
}
|
|
88
87
|
|
|
88
|
+
// Continuity summaries — appended after persona so employee/agent contexts keep them.
|
|
89
|
+
if (sessionContext) {
|
|
90
|
+
systemPrompt += "\n\n" + sessionContext;
|
|
91
|
+
}
|
|
92
|
+
|
|
89
93
|
// Watch mode: inject behavior into system prompt
|
|
90
94
|
if (opts.watchBehavior) {
|
|
91
95
|
const { channel: watchChannel, behavior } = opts.watchBehavior;
|
|
@@ -312,6 +316,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
312
316
|
log.warn({ room, to: backends[backendIndex]!.name }, "chat provider down, failing over to next backend");
|
|
313
317
|
await teardown(); // close the dead session so ensureSession opens the next backend
|
|
314
318
|
sessionId = null; // a cross-backend session id is meaningless; start fresh
|
|
319
|
+
userSaved = false; // re-save the user turn under the new backend's session
|
|
315
320
|
continue;
|
|
316
321
|
}
|
|
317
322
|
break;
|
package/src/core/alive.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { log } from "../utils/log";
|
|
2
2
|
import { getConfig } from "../utils/config";
|
|
3
3
|
import { getSql, closeDb } from "../db/connection";
|
|
4
|
+
import { reconcileChannels } from "../channels";
|
|
4
5
|
import { getFailures, type Check } from "./health";
|
|
5
6
|
|
|
6
7
|
const HEARTBEAT_INTERVAL = 60_000; // 60s
|
|
@@ -164,6 +165,23 @@ async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Restart any configured channel that isn't currently running. Only acts when
|
|
170
|
+
* the running set drifts from config (the boot-time postgres race is the common
|
|
171
|
+
* cause), and notifies the user once when it recovers channels.
|
|
172
|
+
*/
|
|
173
|
+
async function reconcileDeadChannels(): Promise<void> {
|
|
174
|
+
try {
|
|
175
|
+
const { started } = await reconcileChannels();
|
|
176
|
+
if (started.length > 0) {
|
|
177
|
+
log.warn({ started }, "alive: restarted dead channels");
|
|
178
|
+
await notifyUser(`Channels were down and have been restarted: ${started.join(", ")}.`);
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log.warn({ err }, "alive: channel reconcile failed");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
167
185
|
async function heartbeat(): Promise<void> {
|
|
168
186
|
const failures = await getFailures();
|
|
169
187
|
const failureNames = failures.map((f) => f.name);
|
|
@@ -176,6 +194,10 @@ async function heartbeat(): Promise<void> {
|
|
|
176
194
|
}
|
|
177
195
|
lastFailures = [];
|
|
178
196
|
recoveryAttempted = false;
|
|
197
|
+
// The DB is healthy, so any channel that failed to start (e.g. it lost the
|
|
198
|
+
// boot-time race against Postgres) can be brought back now. No-op when the
|
|
199
|
+
// running channels already match config.
|
|
200
|
+
await reconcileDeadChannels();
|
|
179
201
|
return;
|
|
180
202
|
}
|
|
181
203
|
|
package/src/core/daemon.ts
CHANGED
|
@@ -8,8 +8,7 @@ import { isRunning, readPid, removePid, writePid } from "../utils/pid";
|
|
|
8
8
|
import { ActiveEngine, Job } from "../db/models";
|
|
9
9
|
import { runMigrations } from "../db/migrate";
|
|
10
10
|
import { closeDb, getSql } from "../db/connection";
|
|
11
|
-
import { registerAllChannels, startChannels, stopChannels, getStarted,
|
|
12
|
-
import type { Channel } from "../types";
|
|
11
|
+
import { registerAllChannels, startChannels, stopChannels, getStarted, reconcileChannels } from "../channels";
|
|
13
12
|
import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler";
|
|
14
13
|
import { startAlive, stopAlive } from "./alive";
|
|
15
14
|
import { createNiaMcpServer } from "../mcp/server";
|
|
@@ -282,13 +281,13 @@ export async function runDaemon(): Promise<void> {
|
|
|
282
281
|
// composition root) so the endpoint module stays free of the handler chain.
|
|
283
282
|
await startMcpEndpoint(NIA_TOOLS);
|
|
284
283
|
|
|
285
|
-
// Register and start channels
|
|
284
|
+
// Register and start channels. The running set is tracked in the channel
|
|
285
|
+
// registry (getStarted()); the alive monitor reconciles it if any channel
|
|
286
|
+
// fails to start (e.g. it lost the boot-time race against Postgres).
|
|
286
287
|
registerAllChannels();
|
|
287
|
-
let channels: Channel[] = [];
|
|
288
288
|
const config = getConfig();
|
|
289
289
|
if (config.channels.enabled) {
|
|
290
|
-
|
|
291
|
-
channels = result.started;
|
|
290
|
+
await startChannels();
|
|
292
291
|
} else {
|
|
293
292
|
log.info("channels disabled (channels_enabled: false)");
|
|
294
293
|
}
|
|
@@ -353,32 +352,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
353
352
|
process.on("SIGHUP", async () => {
|
|
354
353
|
log.info("received SIGHUP, reloading config");
|
|
355
354
|
resetConfig();
|
|
356
|
-
|
|
357
|
-
const running = getStarted();
|
|
358
|
-
const wantedNames = getConfiguredChannelNames();
|
|
359
|
-
const runningNames = running.map((channel) => channel.name).sort();
|
|
360
|
-
const wantChannels = wantedNames.length > 0;
|
|
361
|
-
const haveChannels = running.length > 0;
|
|
362
|
-
const needsReconcile =
|
|
363
|
-
wantChannels &&
|
|
364
|
-
haveChannels &&
|
|
365
|
-
(wantedNames.length !== runningNames.length || wantedNames.sort().some((name, i) => name !== runningNames[i]));
|
|
366
|
-
|
|
367
|
-
if (wantChannels && !haveChannels) {
|
|
368
|
-
log.info("SIGHUP: starting channels");
|
|
369
|
-
const result = await startChannels();
|
|
370
|
-
channels = result.started;
|
|
371
|
-
} else if (!wantChannels && haveChannels) {
|
|
372
|
-
log.info("SIGHUP: stopping channels");
|
|
373
|
-
await stopChannels(running);
|
|
374
|
-
channels = [];
|
|
375
|
-
} else if (needsReconcile) {
|
|
376
|
-
log.info({ wantedNames, runningNames }, "SIGHUP: reconciling channels");
|
|
377
|
-
await stopChannels(running);
|
|
378
|
-
const result = await startChannels();
|
|
379
|
-
channels = result.started;
|
|
380
|
-
}
|
|
381
|
-
|
|
355
|
+
await reconcileChannels();
|
|
382
356
|
await recomputeAllNextRuns().catch(() => {});
|
|
383
357
|
});
|
|
384
358
|
|
|
@@ -394,7 +368,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
394
368
|
stopAlive();
|
|
395
369
|
stopScheduler();
|
|
396
370
|
stopMcpEndpoint();
|
|
397
|
-
await stopChannels(
|
|
371
|
+
await stopChannels(getStarted());
|
|
398
372
|
|
|
399
373
|
try {
|
|
400
374
|
if (force) {
|
package/src/core/scheduler.ts
CHANGED
|
@@ -40,10 +40,10 @@ async function tick(): Promise<void> {
|
|
|
40
40
|
|
|
41
41
|
for (const job of dueJobs) {
|
|
42
42
|
if (!job.always && !isWithinActiveHours()) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// Leave next_run_at untouched so the job stays due and fires as soon as
|
|
44
|
+
// active hours resume. Rescheduling here would advance a cron job whose
|
|
45
|
+
// only fire time sits outside the window to the next (also-outside)
|
|
46
|
+
// occurrence — starving it forever.
|
|
47
47
|
log.info({ job: job.name }, "scheduler: skipping — outside active hours");
|
|
48
48
|
continue;
|
|
49
49
|
}
|
package/src/db/models/message.ts
CHANGED
|
@@ -26,31 +26,6 @@ export async function updateDeliveryStatus(id: number, status: DeliveryStatus):
|
|
|
26
26
|
await sql`UPDATE messages SET delivery_status = ${status} WHERE id = ${id}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export async function getUndelivered(
|
|
30
|
-
room?: string,
|
|
31
|
-
): Promise<Array<{ id: number; room: string; content: string; createdAt: string }>> {
|
|
32
|
-
const sql = getSql();
|
|
33
|
-
const rows = room
|
|
34
|
-
? await sql`
|
|
35
|
-
SELECT id, room, content, created_at
|
|
36
|
-
FROM messages
|
|
37
|
-
WHERE delivery_status = 'failed' AND is_from_agent = true AND room = ${room}
|
|
38
|
-
ORDER BY created_at ASC
|
|
39
|
-
`
|
|
40
|
-
: await sql`
|
|
41
|
-
SELECT id, room, content, created_at
|
|
42
|
-
FROM messages
|
|
43
|
-
WHERE delivery_status = 'failed' AND is_from_agent = true
|
|
44
|
-
ORDER BY created_at ASC
|
|
45
|
-
`;
|
|
46
|
-
return rows.map((r) => ({
|
|
47
|
-
id: r.id,
|
|
48
|
-
room: r.room,
|
|
49
|
-
content: r.content,
|
|
50
|
-
createdAt: String(r.created_at),
|
|
51
|
-
}));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
29
|
export async function getRecent(limit = 20, room?: string): Promise<RecentMessage[]> {
|
|
55
30
|
const sql = getSql();
|
|
56
31
|
const rows = room
|
package/src/db/models/session.ts
CHANGED
|
@@ -171,7 +171,8 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
171
171
|
'total_cache_read_tokens', COALESCE((metadata->>'total_cache_read_tokens')::int, 0) + (${delta}->>'total_cache_read_tokens')::int,
|
|
172
172
|
'total_cache_creation_tokens', COALESCE((metadata->>'total_cache_creation_tokens')::int, 0) + (${delta}->>'total_cache_creation_tokens')::int,
|
|
173
173
|
'message_count', COALESCE((metadata->>'message_count')::int, 0) + 1,
|
|
174
|
-
'models_used', COALESCE(
|
|
174
|
+
'models_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
|
|
175
|
+
FROM jsonb_array_elements(COALESCE(metadata->'models_used', '[]'::jsonb) || ${modelsDelta}) AS e),
|
|
175
176
|
'channel', COALESCE(metadata->>'channel', ${(resultMeta.channel as string) || null})
|
|
176
177
|
)
|
|
177
178
|
WHERE id = ${id}
|