niahere 0.5.0 → 0.5.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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/agent/backends/claude-normalize.ts +41 -1
  3. package/src/agent/backends/claude.ts +2 -1
  4. package/src/agent/backends/codex-normalize.ts +20 -6
  5. package/src/agent/backends/codex.ts +3 -2
  6. package/src/agent/failure.ts +3 -1
  7. package/src/agent/mcp-endpoint.ts +3 -2
  8. package/src/channels/common/chat-session.ts +60 -0
  9. package/src/channels/phone/consult.ts +2 -1
  10. package/src/channels/phone/index.ts +5 -3
  11. package/src/channels/phone/relay.ts +15 -5
  12. package/src/channels/slack.ts +24 -34
  13. package/src/channels/sms.ts +16 -36
  14. package/src/channels/telegram.ts +22 -34
  15. package/src/channels/twilio/media-cache.ts +4 -3
  16. package/src/channels/twilio/rest.ts +0 -31
  17. package/src/channels/twilio/server.ts +0 -5
  18. package/src/channels/twilio/shared.ts +36 -0
  19. package/src/channels/whatsapp.ts +24 -58
  20. package/src/chat/engine.ts +17 -8
  21. package/src/chat/gap-marker.ts +63 -0
  22. package/src/chat/repl.ts +5 -5
  23. package/src/cli/config.ts +71 -0
  24. package/src/cli/index.ts +23 -288
  25. package/src/cli/job.ts +1 -2
  26. package/src/cli/logs.ts +55 -0
  27. package/src/cli/run.ts +74 -0
  28. package/src/cli/skills.ts +13 -0
  29. package/src/cli/status.ts +0 -1
  30. package/src/cli/test.ts +32 -0
  31. package/src/cli/update.ts +79 -0
  32. package/src/commands/backup.ts +1 -2
  33. package/src/commands/init.ts +1 -2
  34. package/src/core/alive.ts +4 -3
  35. package/src/core/consolidator.ts +78 -31
  36. package/src/core/daemon.ts +3 -2
  37. package/src/core/finalizer.ts +9 -5
  38. package/src/core/runner.ts +7 -6
  39. package/src/core/scheduler.ts +19 -4
  40. package/src/core/skills.ts +0 -4
  41. package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
  42. package/src/db/models/active_engine.ts +0 -7
  43. package/src/db/models/job.ts +3 -2
  44. package/src/db/models/message.ts +10 -0
  45. package/src/db/models/session.ts +69 -15
  46. package/src/mcp/tools/misc.ts +2 -1
  47. package/src/mcp/tools/send.ts +5 -4
  48. package/src/utils/errors.ts +26 -0
@@ -7,10 +7,11 @@ import { getConfig, updateRawConfig } from "../utils/config";
7
7
  import { runMigrations } from "../db/migrate";
8
8
  import { Message } from "../db/models";
9
9
  import { log } from "../utils/log";
10
+ import { errMsg, ignore } from "../utils/errors";
10
11
  import { getMcpServers } from "../mcp";
11
12
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
12
13
  import { getNiaHome } from "../utils/paths";
13
- import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
14
+ import { ChatSessions, chainLock } from "./common/chat-session";
14
15
 
15
16
  function safeExtension(filename?: string): string {
16
17
  const ext = filename?.split(".").pop();
@@ -27,7 +28,10 @@ class TelegramChannel implements Channel {
27
28
  private bot: Bot | null = null;
28
29
  private outboundChatId: number | null = null;
29
30
  private isOpen = false;
30
- private readonly chats = new Map<number, ChatState>();
31
+ private readonly chats = new ChatSessions<number>(
32
+ (chatId) => this.keyOf(chatId),
33
+ () => ({ channel: "telegram", mcpServers: getMcpServers() }),
34
+ );
31
35
 
32
36
  async start(): Promise<void> {
33
37
  const config = getConfig();
@@ -51,6 +55,7 @@ class TelegramChannel implements Channel {
51
55
  }
52
56
 
53
57
  async stop(): Promise<void> {
58
+ this.chats.closeAll();
54
59
  if (this.bot) {
55
60
  this.bot.stop();
56
61
  this.bot = null;
@@ -82,14 +87,14 @@ class TelegramChannel implements Channel {
82
87
  private async handleStart(ctx: Context): Promise<void> {
83
88
  if (!ctx.chatId || !this.gate(ctx)) return;
84
89
  this.registerOutbound(ctx.chatId);
85
- const state = await this.getState(ctx.chatId);
90
+ const state = await this.chats.get(ctx.chatId);
86
91
  this.withLock(ctx.chatId, () => this.processMessage(ctx, state, "hi"));
87
92
  }
88
93
 
89
94
  private async handleRestart(ctx: Context): Promise<void> {
90
95
  if (!ctx.chatId || !this.gate(ctx)) return;
91
96
  this.registerOutbound(ctx.chatId);
92
- const state = await this.restartChat(ctx.chatId);
97
+ const state = await this.chats.rotate(ctx.chatId);
93
98
  log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
94
99
  await ctx.reply("New conversation started.");
95
100
  }
@@ -97,7 +102,7 @@ class TelegramChannel implements Channel {
97
102
  private async handleText(ctx: Context): Promise<void> {
98
103
  if (!ctx.chatId || !ctx.message?.text || !this.gate(ctx)) return;
99
104
  this.registerOutbound(ctx.chatId);
100
- const state = await this.getState(ctx.chatId);
105
+ const state = await this.chats.get(ctx.chatId);
101
106
  const text = ctx.message.text;
102
107
  this.withLock(ctx.chatId, () => this.processMessage(ctx, state, text));
103
108
  }
@@ -105,7 +110,7 @@ class TelegramChannel implements Channel {
105
110
  private async handlePhoto(ctx: Context): Promise<void> {
106
111
  if (!ctx.chatId || !ctx.message?.photo || !this.gate(ctx)) return;
107
112
  this.registerOutbound(ctx.chatId);
108
- const state = await this.getState(ctx.chatId);
113
+ const state = await this.chats.get(ctx.chatId);
109
114
  const chatId = ctx.chatId;
110
115
  this.withLock(chatId, async () => {
111
116
  try {
@@ -118,7 +123,7 @@ class TelegramChannel implements Channel {
118
123
  await this.processMessage(ctx, state, caption, [attachment]);
119
124
  } catch (err) {
120
125
  log.error({ err, chatId }, "failed to process photo");
121
- await ctx.reply("Failed to process image.").catch(() => {});
126
+ await ignore(ctx.reply("Failed to process image."), "reply image failure");
122
127
  }
123
128
  });
124
129
  }
@@ -126,7 +131,7 @@ class TelegramChannel implements Channel {
126
131
  private async handleDocument(ctx: Context): Promise<void> {
127
132
  if (!ctx.chatId || !ctx.message?.document || !this.gate(ctx)) return;
128
133
  this.registerOutbound(ctx.chatId);
129
- const state = await this.getState(ctx.chatId);
134
+ const state = await this.chats.get(ctx.chatId);
130
135
  const chatId = ctx.chatId;
131
136
  this.withLock(chatId, async () => {
132
137
  try {
@@ -157,7 +162,7 @@ class TelegramChannel implements Channel {
157
162
  await this.processMessage(ctx, state, caption, [attachment]);
158
163
  } catch (err) {
159
164
  log.error({ err, chatId }, "failed to process document");
160
- await ctx.reply("Failed to process document.").catch(() => {});
165
+ await ignore(ctx.reply("Failed to process document."), "reply document failure");
161
166
  }
162
167
  });
163
168
  }
@@ -176,25 +181,25 @@ class TelegramChannel implements Channel {
176
181
  log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
177
182
 
178
183
  const typingInterval = setInterval(() => {
179
- bot.api.sendChatAction(chatId, "typing").catch(() => {});
184
+ void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
180
185
  }, 4000);
181
- bot.api.sendChatAction(chatId, "typing").catch(() => {});
186
+ void ignore(bot.api.sendChatAction(chatId, "typing"), "send typing indicator");
182
187
 
183
188
  try {
184
189
  const { result, messageId } = await state.engine.send(text, {}, attachments);
185
190
  const reply = result.trim() || "(no response)";
186
191
  try {
187
192
  await bot.api.sendMessage(chatId, reply);
188
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
193
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
189
194
  log.info({ chatId, chars: result.length }, "telegram reply sent");
190
195
  } catch (sendErr) {
191
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
196
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
192
197
  throw sendErr;
193
198
  }
194
199
  } catch (err) {
195
- const errText = err instanceof Error ? err.message : String(err);
200
+ const errText = errMsg(err);
196
201
  log.error({ err, chatId }, "telegram message processing failed");
197
- await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
202
+ await ignore(bot.api.sendMessage(chatId, `[error] ${errText}`), "reply engine error");
198
203
  } finally {
199
204
  clearInterval(typingInterval);
200
205
  }
@@ -202,25 +207,8 @@ class TelegramChannel implements Channel {
202
207
 
203
208
  // --- Session / state helpers ---
204
209
 
205
- private async getState(chatId: number): Promise<ChatState> {
206
- let state = this.chats.get(chatId);
207
- if (state) return state;
208
- state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
209
- this.chats.set(chatId, state);
210
- return state;
211
- }
212
-
213
- private async restartChat(chatId: number): Promise<ChatState> {
214
- const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
215
- channel: "telegram",
216
- mcpServers: getMcpServers(),
217
- }));
218
- this.chats.set(chatId, state);
219
- return state;
220
- }
221
-
222
210
  private withLock(chatId: number, fn: () => Promise<void>): void {
223
- const state = this.chats.get(chatId);
211
+ const state = this.chats.peek(chatId);
224
212
  if (!state) {
225
213
  fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
226
214
  return;
@@ -238,7 +226,7 @@ class TelegramChannel implements Channel {
238
226
  private gate(ctx: Context): boolean {
239
227
  if (!ctx.chatId) return false;
240
228
  if (this.isAllowed(ctx.chatId)) return true;
241
- ctx.reply("Unauthorized.").catch(() => {});
229
+ void ignore(ctx.reply("Unauthorized."), "reply unauthorized");
242
230
  return false;
243
231
  }
244
232
 
@@ -16,6 +16,7 @@ import { createHash } from "crypto";
16
16
  import { join } from "path";
17
17
  import { getNiaHome } from "../../utils/paths";
18
18
  import { log } from "../../utils/log";
19
+ import { ignore } from "../../utils/errors";
19
20
 
20
21
  const MAX_FILES = 100;
21
22
  const MAX_BYTES = 10 * 1024 * 1024;
@@ -85,7 +86,7 @@ async function evict(): Promise<void> {
85
86
  let alive: typeof stats = [];
86
87
  for (const s of stats) {
87
88
  if (now - s.mtime > MAX_AGE_MS) {
88
- await unlink(s.path).catch(() => {});
89
+ await ignore(unlink(s.path), "prune cached media");
89
90
  } else {
90
91
  alive.push(s);
91
92
  }
@@ -95,13 +96,13 @@ async function evict(): Promise<void> {
95
96
 
96
97
  while (alive.length > MAX_FILES) {
97
98
  const victim = alive.shift()!;
98
- await unlink(victim.path).catch(() => {});
99
+ await ignore(unlink(victim.path), "prune cached media");
99
100
  }
100
101
 
101
102
  let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
102
103
  while (totalBytes > MAX_BYTES && alive.length > 0) {
103
104
  const victim = alive.shift()!;
104
105
  totalBytes -= victim.size;
105
- await unlink(victim.path).catch(() => {});
106
+ await ignore(unlink(victim.path), "prune cached media");
106
107
  }
107
108
  }
@@ -73,16 +73,6 @@ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
73
73
  return { callSid: data.sid, status: data.status };
74
74
  }
75
75
 
76
- export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
77
- const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
78
- await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
79
- }
80
-
81
- export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
82
- const body = new URLSearchParams({ Status: "completed" });
83
- await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
84
- }
85
-
86
76
  // --- Messages (SMS / WhatsApp) ---
87
77
 
88
78
  export interface SendMessageOpts extends TwilioCreds {
@@ -111,24 +101,3 @@ export async function sendMessage(opts: SendMessageOpts): Promise<SendMessageRes
111
101
  }
112
102
 
113
103
  // --- Phone number config (update inbound webhook on a number) ---
114
-
115
- export async function updateIncomingPhoneNumber(
116
- opts: TwilioCreds & {
117
- phoneNumberSid: string;
118
- voiceUrl?: string;
119
- voiceMethod?: "GET" | "POST";
120
- smsUrl?: string;
121
- smsMethod?: "GET" | "POST";
122
- statusCallback?: string;
123
- statusCallbackMethod?: "GET" | "POST";
124
- },
125
- ): Promise<void> {
126
- const body = new URLSearchParams();
127
- if (opts.voiceUrl !== undefined) body.set("VoiceUrl", opts.voiceUrl);
128
- if (opts.voiceMethod) body.set("VoiceMethod", opts.voiceMethod);
129
- if (opts.smsUrl !== undefined) body.set("SmsUrl", opts.smsUrl);
130
- if (opts.smsMethod) body.set("SmsMethod", opts.smsMethod);
131
- if (opts.statusCallback !== undefined) body.set("StatusCallback", opts.statusCallback);
132
- if (opts.statusCallbackMethod) body.set("StatusCallbackMethod", opts.statusCallbackMethod);
133
- await twilioPost(opts, `/IncomingPhoneNumbers/${encodeURIComponent(opts.phoneNumberSid)}.json`, body);
134
- }
@@ -248,8 +248,3 @@ export function getTwilioServer(): TwilioServer {
248
248
  if (!_instance) _instance = new TwilioServerImpl();
249
249
  return _instance;
250
250
  }
251
-
252
- /** Test-only: drop the singleton so tests get a fresh server. */
253
- export function resetTwilioServer(): void {
254
- _instance = null;
255
- }
@@ -0,0 +1,36 @@
1
+ import type { TwilioConfig } from "../../types";
2
+ import { log } from "../../utils/log";
3
+
4
+ /** Bits every Twilio-backed channel (sms, whatsapp) needs identically. */
5
+
6
+ const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
7
+
8
+ /** Ack an inbound webhook immediately. Replies go out over REST instead, so a
9
+ * slow engine cannot blow Twilio's ~15s webhook budget. */
10
+ export function ackTwiml(): Response {
11
+ return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
12
+ }
13
+
14
+ export function isAllowedSender(twilio: TwilioConfig, remoteE164: string): boolean {
15
+ if (twilio.owner_number && remoteE164 === twilio.owner_number) return true;
16
+ return twilio.allowlist.includes(remoteE164);
17
+ }
18
+
19
+ type StatusLogger = (label: string, fields: Record<string, unknown>) => void;
20
+
21
+ const logStatus: StatusLogger = (label, fields) => log.info(fields, `${label}: delivery status`);
22
+
23
+ /** Record a delivery-status callback and ack it. */
24
+ export function deliveryStatusAck(
25
+ label: string,
26
+ params: Record<string, string>,
27
+ record: StatusLogger = logStatus,
28
+ ): Response {
29
+ record(label, {
30
+ messageSid: params.MessageSid,
31
+ status: params.MessageStatus,
32
+ errorCode: params.ErrorCode,
33
+ to: params.To,
34
+ });
35
+ return new Response("", { status: 204 });
36
+ }
@@ -20,10 +20,12 @@
20
20
  import { getMcpServers } from "../mcp";
21
21
  import { Message } from "../db/models";
22
22
  import { runMigrations } from "../db/migrate";
23
- import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
23
+ import { ChatSessions, chainLock } from "./common/chat-session";
24
+ import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
24
25
  import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
25
26
  import { getConfig } from "../utils/config";
26
27
  import { log } from "../utils/log";
28
+ import { errMsg, ignore } from "../utils/errors";
27
29
  import { classifyMime, prepareImage, validateAttachment } from "../utils/attachment";
28
30
  import { sendMessage as twilioSendMessage } from "./twilio/rest";
29
31
  import { getTwilioServer } from "./twilio/server";
@@ -32,7 +34,6 @@ import { transcribeAudio } from "./twilio/transcribe";
32
34
 
33
35
  const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
34
36
  const WA_PREFIX = "whatsapp:";
35
- const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
36
37
  const CHUNK_LIMIT = 4096;
37
38
  const RESET_RE = /^\s*\/(reset|new)\s*$/i;
38
39
  const VOICE_MIME_PREFIX = "audio/";
@@ -42,13 +43,14 @@ class WhatsAppChannel implements Channel {
42
43
  private readonly twilio: TwilioConfig;
43
44
  private readonly whatsapp: WhatsappConfig;
44
45
  private readonly phone: PhoneConfig;
45
- private readonly chats = new Map<string, ChatState>();
46
+ private readonly chats: ChatSessions<string>;
46
47
  private readonly lastInboundAt = new Map<string, number>();
47
48
 
48
49
  constructor(twilio: TwilioConfig, whatsapp: WhatsappConfig, phone: PhoneConfig) {
49
50
  this.twilio = twilio;
50
51
  this.whatsapp = whatsapp;
51
52
  this.phone = phone;
53
+ this.chats = new ChatSessions(roomPrefix, () => ({ channel: "whatsapp", mcpServers: getMcpServers() }));
52
54
  }
53
55
 
54
56
  async start(): Promise<void> {
@@ -86,8 +88,7 @@ class WhatsAppChannel implements Channel {
86
88
  }
87
89
 
88
90
  async stop(): Promise<void> {
89
- for (const state of this.chats.values()) state.engine.close();
90
- this.chats.clear();
91
+ this.chats.closeAll();
91
92
  }
92
93
 
93
94
  /** Outbound to the owner — used by send_message MCP tool. WhatsApp has no threading. */
@@ -108,9 +109,9 @@ class WhatsAppChannel implements Channel {
108
109
  const from = (params.From || "").replace(/^whatsapp:/, "");
109
110
  const body = (params.Body || "").trim();
110
111
 
111
- if (!this.isAllowed(from)) {
112
+ if (!isAllowedSender(this.twilio, from)) {
112
113
  log.warn({ from }, "whatsapp: rejecting non-allowlisted sender");
113
- return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
114
+ return ackTwiml();
114
115
  }
115
116
 
116
117
  this.lastInboundAt.set(from, Date.now());
@@ -118,20 +119,20 @@ class WhatsAppChannel implements Channel {
118
119
  if (RESET_RE.test(body)) {
119
120
  // Serialize through the same lock so a /reset chasing an in-flight
120
121
  // engine.send() waits its turn instead of yanking the engine away.
121
- const state = await this.getState(from);
122
+ const state = await this.chats.get(from);
122
123
  chainLock(state, async () => {
123
- const newState = await this.restartChat(from);
124
+ const newState = await this.chats.rotate(from);
124
125
  await this.sendTextTo(
125
126
  from,
126
- `New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`,
127
+ `New conversation started (room ${roomPrefix(from)}-${newState.roomIndex}).`,
127
128
  ).catch((err) => log.warn({ err, from }, "whatsapp: failed to send reset confirmation"));
128
129
  });
129
- return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
130
+ return ackTwiml();
130
131
  }
131
132
 
132
133
  const descriptors = extractMedia(params);
133
134
 
134
- const state = await this.getState(from);
135
+ const state = await this.chats.get(from);
135
136
  chainLock(state, async () => {
136
137
  let userText = body;
137
138
  let attachments: Attachment[] | undefined;
@@ -162,7 +163,7 @@ class WhatsAppChannel implements Channel {
162
163
  } catch (err) {
163
164
  log.error({ err, from }, "whatsapp: voice transcription failed");
164
165
  voiceParts.push(
165
- `[voice note: transcription failed — ${err instanceof Error ? err.message : String(err)}]`,
166
+ `[voice note: transcription failed — ${errMsg(err)}]`,
166
167
  );
167
168
  }
168
169
  continue;
@@ -171,7 +172,7 @@ class WhatsAppChannel implements Channel {
171
172
  const error = validateAttachment(item.data);
172
173
  if (error) {
173
174
  log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
174
- await this.sendTextTo(from, `[error] ${error}`).catch(() => {});
175
+ await ignore(this.sendTextTo(from, `[error] ${error}`), "reply engine error");
175
176
  continue;
176
177
  }
177
178
 
@@ -203,32 +204,23 @@ class WhatsAppChannel implements Channel {
203
204
  const reply = result.trim() || "(no response)";
204
205
  try {
205
206
  await this.sendTextTo(from, reply);
206
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
207
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
207
208
  } catch (sendErr) {
208
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
209
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
209
210
  throw sendErr;
210
211
  }
211
212
  } catch (err) {
212
213
  log.error({ err, from }, "whatsapp: engine error");
213
- const errText = err instanceof Error ? err.message : String(err);
214
- await this.sendTextTo(from, `[error] ${errText}`).catch(() => {});
214
+ const errText = errMsg(err);
215
+ await ignore(this.sendTextTo(from, `[error] ${errText}`), "reply engine error");
215
216
  }
216
217
  });
217
218
 
218
- return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
219
+ return ackTwiml();
219
220
  }
220
221
 
221
222
  private handleStatus(params: Record<string, string>): Response {
222
- log.info(
223
- {
224
- messageSid: params.MessageSid,
225
- status: params.MessageStatus,
226
- errorCode: params.ErrorCode,
227
- to: params.To,
228
- },
229
- "whatsapp: delivery status",
230
- );
231
- return new Response("", { status: 204 });
223
+ return deliveryStatusAck("whatsapp", params);
232
224
  }
233
225
 
234
226
  // --- Outbound ---
@@ -301,36 +293,10 @@ class WhatsAppChannel implements Channel {
301
293
  return true;
302
294
  }
303
295
 
304
- // --- Helpers ---
305
-
306
- private isAllowed(remoteE164: string): boolean {
307
- if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
308
- return this.twilio.allowlist.includes(remoteE164);
309
- }
310
-
311
- private roomPrefix(remoteE164: string): string {
312
- return `wa-${remoteE164}`;
313
- }
314
-
315
- private async getState(remoteE164: string): Promise<ChatState> {
316
- let state = this.chats.get(remoteE164);
317
- if (state) return state;
318
- state = await openChatEngine(this.roomPrefix(remoteE164), () => ({
319
- channel: "whatsapp",
320
- mcpServers: getMcpServers(),
321
- }));
322
- this.chats.set(remoteE164, state);
323
- return state;
324
- }
296
+ }
325
297
 
326
- private async restartChat(remoteE164: string): Promise<ChatState> {
327
- const state = await rotateRoom(this.roomPrefix(remoteE164), this.chats.get(remoteE164), () => ({
328
- channel: "whatsapp",
329
- mcpServers: getMcpServers(),
330
- }));
331
- this.chats.set(remoteE164, state);
332
- return state;
333
- }
298
+ function roomPrefix(remoteE164: string): string {
299
+ return `wa-${remoteE164}`;
334
300
  }
335
301
 
336
302
  /**
@@ -4,10 +4,13 @@ import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./iden
4
4
  import { buildEmployeePrompt } from "./employee-prompt";
5
5
  import { getEmployee } from "../core/employees";
6
6
  import { getAgentDefinitions, scanAgents } from "../core/agents";
7
+ import { gapMarker } from "./gap-marker";
8
+ import { getConfig } from "../utils/config";
7
9
  import { Session, Message, ActiveEngine, Job } from "../db/models";
8
10
  import type { Attachment, SendResult, SendCallbacks, ChatEngine, EngineOptions } from "../types";
9
11
  import { finalizeSession, cancelPending } from "../core/finalizer";
10
12
  import { log } from "../utils/log";
13
+ import { asError, errMsg, ignore } from "../utils/errors";
11
14
  import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
12
15
  import { resolveJobPrompt } from "../core/job-prompt";
13
16
  import { truncate } from "../utils/format-activity";
@@ -169,7 +172,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
169
172
  clearIdleTimer();
170
173
  clearLongRunningTimer();
171
174
  if (session) {
172
- await session.close().catch(() => {});
175
+ await ignore(session.close(), "close chat session");
173
176
  session = null;
174
177
  }
175
178
  unregisterActiveHandle(room);
@@ -218,7 +221,13 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
218
221
  return room;
219
222
  },
220
223
 
221
- async send(userMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
224
+ async send(rawMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
225
+ // Date the turn when time has visibly passed. Computed before anything is
226
+ // saved, and used for BOTH the send and the save so the stored record is
227
+ // exactly what the model received.
228
+ const marker = gapMarker(new Date(), await Message.getLastAt(room).catch(() => null), getConfig().timezone);
229
+ const userMessage = marker ? `${marker}\n${rawMessage}` : rawMessage;
230
+
222
231
  // Re-probe from the top once the failed provider's cooldown lapses, so a
223
232
  // brief outage does not pin the conversation to the fallback for good.
224
233
  if (!cursor.atHead) {
@@ -239,7 +248,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
239
248
 
240
249
  // Cancel any pending finalization — session is active again
241
250
  if (sessionId) {
242
- cancelPending(sessionId).catch(() => {});
251
+ void ignore(cancelPending(sessionId), "cancel pending finalization");
243
252
  }
244
253
 
245
254
  await ActiveEngine.register(room, channel);
@@ -266,7 +275,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
266
275
  // A backend that cannot start must not take the turn down.
267
276
  const next = cursor.advance("provider");
268
277
  log.warn({ room, err: String(err), to: next && describeEntry(next) }, "chat backend failed to start");
269
- if (!next) throw err instanceof Error ? err : new Error(String(err));
278
+ if (!next) throw asError(err);
270
279
  handoff = await buildHandoff(userMessage);
271
280
  continue;
272
281
  }
@@ -318,7 +327,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
318
327
  messageId = await Message.save({ ...saveParams, metadata: undefined });
319
328
  }
320
329
  await Session.touch(sessionId);
321
- Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }).catch(() => {});
330
+ void ignore(Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }), "accumulate session metadata");
322
331
  }
323
332
  result = { result: ev.text, costUsd, turns, messageId };
324
333
  break;
@@ -340,11 +349,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
340
349
  }
341
350
  }
342
351
  } catch (err) {
343
- await ActiveEngine.unregister(room).catch(() => {});
352
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
344
353
  clearLongRunningTimer();
345
354
  inFlight = false;
346
355
  if (sess.backendSessionId) sessionId = sess.backendSessionId;
347
- throw err instanceof Error ? err : new Error(String(err));
356
+ throw asError(err);
348
357
  }
349
358
 
350
359
  // Re-read the backend session id post-send so finalize/DB target it.
@@ -379,7 +388,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
379
388
  }
380
389
  }
381
390
  await teardown();
382
- await ActiveEngine.unregister(room).catch(() => {});
391
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
383
392
  },
384
393
  };
385
394
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Dates a user turn when time has visibly passed.
3
+ *
4
+ * A resumed session replays its transcript with no timestamps, so a reply from
5
+ * three days ago reads as present-tense — ask "what should I wear?" twice in a
6
+ * week and the model happily repeats Monday's weather. The marker is prefixed
7
+ * onto the turn that is sent AND stored, so the transcript dates itself from
8
+ * then on.
9
+ */
10
+
11
+ const MINUTE_MS = 60_000;
12
+ const HOUR_MS = 60 * MINUTE_MS;
13
+ const DAY_MS = 24 * HOUR_MS;
14
+
15
+ /** Gap that counts as "time has passed" even within one day. */
16
+ export const GAP_THRESHOLD_MS = 2 * HOUR_MS;
17
+
18
+ const LOCALE = "en-US";
19
+
20
+ function localDay(date: Date, timezone: string): string {
21
+ return new Intl.DateTimeFormat("en-CA", {
22
+ year: "numeric",
23
+ month: "2-digit",
24
+ day: "2-digit",
25
+ timeZone: timezone,
26
+ }).format(date);
27
+ }
28
+
29
+ function plural(n: number, unit: string): string {
30
+ return `${n} ${unit}${n === 1 ? "" : "s"}`;
31
+ }
32
+
33
+ /** Floored, never rounded up: the absolute stamp alongside it carries the
34
+ * precision, so this should not claim more time passed than actually did. */
35
+ function elapsed(ms: number): string {
36
+ if (ms >= DAY_MS) return plural(Math.floor(ms / DAY_MS), "day");
37
+ if (ms >= HOUR_MS) return plural(Math.floor(ms / HOUR_MS), "hour");
38
+ return plural(Math.max(1, Math.floor(ms / MINUTE_MS)), "minute");
39
+ }
40
+
41
+ /**
42
+ * The prefix for this turn, or null when the conversation is still continuous.
43
+ * Fires when the local date has changed or the gap exceeds the threshold.
44
+ */
45
+ export function gapMarker(now: Date, last: Date | null, timezone: string): string | null {
46
+ if (!last) return null;
47
+
48
+ const gap = now.getTime() - last.getTime();
49
+ const dayChanged = localDay(now, timezone) !== localDay(last, timezone);
50
+ if (gap < GAP_THRESHOLD_MS && !dayChanged) return null;
51
+
52
+ const stamp = new Intl.DateTimeFormat(LOCALE, {
53
+ weekday: "long",
54
+ year: "numeric",
55
+ month: "long",
56
+ day: "numeric",
57
+ hour: "numeric",
58
+ minute: "2-digit",
59
+ timeZone: timezone,
60
+ }).format(now);
61
+
62
+ return `[${stamp} — ${elapsed(gap)} since the last message]`;
63
+ }
package/src/chat/repl.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as readline from "readline";
2
+ import { errMsg, ignore } from "../utils/errors";
2
3
  import { createChatEngine } from "./engine";
3
4
  import { runMigrations } from "../db/migrate";
4
5
  import { closeDb } from "../db/connection";
@@ -118,7 +119,7 @@ export async function startRepl(
118
119
  try {
119
120
  await runMigrations();
120
121
  } catch (err) {
121
- const msg = err instanceof Error ? err.message : String(err);
122
+ const msg = errMsg(err);
122
123
  console.error(`Failed to connect to postgres: ${msg}`);
123
124
  console.error(`Set database_url in ~/.niahere/config.yaml or run \`nia init\``);
124
125
  process.exit(1);
@@ -218,7 +219,7 @@ export async function startRepl(
218
219
  process.stdout.write("\n\n");
219
220
  } catch (err) {
220
221
  status.stop();
221
- const msg = err instanceof Error ? err.message : String(err);
222
+ const msg = errMsg(err);
222
223
  console.error(`\n${DIM}error:${RESET} ${msg}\n`);
223
224
  }
224
225
  }
@@ -252,9 +253,8 @@ export async function startRepl(
252
253
  rl.on("close", async () => {
253
254
  console.log(`\n${DIM}bye${RESET}`);
254
255
  await engine.close();
255
- closeDb()
256
- .catch(() => {})
257
- .finally(() => process.exit(0));
256
+ await ignore(closeDb(), "close db on exit");
257
+ process.exit(0);
258
258
  });
259
259
 
260
260
  process.on("SIGINT", () => {