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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 [];
@@ -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
- log.warn("sms: twilio sid/secret missing, cannot send");
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
 
@@ -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(from, `New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`);
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)) return;
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)) return;
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
- return;
253
+ throw err;
251
254
  }
252
255
  await this.postMessage(remoteE164, "", [mediaUrl]);
253
256
  }
@@ -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
- // Inject recent session summaries for continuity
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
 
@@ -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, getConfiguredChannelNames } from "../channels";
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
- const result = await startChannels();
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(channels);
371
+ await stopChannels(getStarted());
398
372
 
399
373
  try {
400
374
  if (force) {
@@ -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
- try {
44
- const nextRun = computeNextRun(job.scheduleType, job.schedule, config.timezone, new Date());
45
- if (nextRun) await Job.markRun(job.name, nextRun).catch(() => {});
46
- } catch {}
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
  }
@@ -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
@@ -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(metadata->'models_used', '[]'::jsonb) || ${modelsDelta},
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}