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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,9 +44,9 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "^0.3.190",
48
- "@anthropic-ai/sdk": "^0.105.0",
49
- "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
48
+ "@anthropic-ai/sdk": "0.115.0",
49
+ "@modelcontextprotocol/sdk": "1.30.0",
50
50
  "@slack/bolt": "^4.6.0",
51
51
  "cron-parser": "^5.5.0",
52
52
  "grammy": "^1.41.1",
@@ -1,6 +1,21 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
3
  import { isRetryable, scopeOf, parseFailure } from "../failure";
4
+ import type { FailoverScope } from "../types";
5
+
6
+ /**
7
+ * Terminal reasons that mean the turn died. The SDK used to report several of
8
+ * these as `completed`, so a dead turn landed in the audit as a successful run.
9
+ * The value is how far the chain should skip; undefined means stop.
10
+ */
11
+ const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
12
+ api_error: "provider",
13
+ budget_exhausted: undefined,
14
+ malformed_tool_use_exhausted: undefined,
15
+ structured_output_retry_exhausted: undefined,
16
+ tool_deferred_unavailable: undefined,
17
+ turn_setup_failed: undefined,
18
+ };
4
19
 
5
20
  /**
6
21
  * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
@@ -41,6 +56,17 @@ export class SdkNormalizer implements Normalizer {
41
56
  return [];
42
57
  }
43
58
 
59
+ if (msg.type === "system" && msg.subtype === "compact_boundary") {
60
+ // Long sessions compact silently; surface it so the transcript records
61
+ // that history was summarized and how much was dropped.
62
+ const meta = msg.compact_metadata ?? {};
63
+ const trigger = meta.trigger === "manual" ? "manual" : "auto";
64
+ const pre = meta.pre_tokens ?? 0;
65
+ const post = meta.post_tokens;
66
+ const shrink = post !== undefined ? `${pre} → ${post} tokens` : `${pre} tokens`;
67
+ return [{ type: "thinking", delta: `context compacted (${trigger}, ${shrink})` }];
68
+ }
69
+
44
70
  if (msg.type === "system") {
45
71
  // Subagent/task lifecycle (subtype init handled above).
46
72
  if (msg.subtype === "task_started" && msg.description) {
@@ -98,6 +124,16 @@ export class SdkNormalizer implements Normalizer {
98
124
  }
99
125
 
100
126
  private consumeResult(msg: any): AgentEvent {
127
+ const deadTurn = msg.terminal_reason as string | undefined;
128
+ if (!msg.is_error && deadTurn && deadTurn in DEAD_TURNS) {
129
+ return {
130
+ type: "error",
131
+ message: (msg.errors?.join(", ") as string) || `turn ended: ${deadTurn}`,
132
+ retryable: false,
133
+ failover: DEAD_TURNS[deadTurn],
134
+ terminalReason: deadTurn,
135
+ };
136
+ }
101
137
  if (!msg.is_error) {
102
138
  return {
103
139
  type: "result",
@@ -126,7 +162,11 @@ export class SdkNormalizer implements Normalizer {
126
162
  type: "error",
127
163
  message: raw,
128
164
  retryable: isRetryable(raw),
129
- failover: scopeOf(parseFailure(raw), "provider"),
165
+ // api_error_status is the reliable signal; prose is the fallback.
166
+ failover: scopeOf(
167
+ { ...parseFailure(raw), status: typeof msg.api_error_status === "number" ? msg.api_error_status : undefined },
168
+ "provider",
169
+ ),
130
170
  terminalReason: msg.terminal_reason,
131
171
  };
132
172
  }
@@ -1,4 +1,5 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { asError } from "../../utils/errors";
2
3
  import { randomUUID } from "crypto";
3
4
  import { existsSync } from "fs";
4
5
  import { join } from "path";
@@ -126,7 +127,7 @@ class ClaudeSession implements AgentSession {
126
127
  res = await this.iterator!.next();
127
128
  } catch (err) {
128
129
  if (this.aborted) throw new Error(this.aborted);
129
- throw err instanceof Error ? err : new Error(String(err));
130
+ throw asError(err);
130
131
  }
131
132
  if (this.aborted) throw new Error(this.aborted);
132
133
  if (res.done) {
@@ -14,6 +14,10 @@ import { scopeOf, parseFailure } from "../failure";
14
14
  * skill budget) and are dropped.
15
15
  */
16
16
  export class CodexNormalizer implements Normalizer {
17
+ /** The model this run was launched on, for usage attribution — codex's own
18
+ * events don't name it. */
19
+ constructor(private readonly model?: string) {}
20
+
17
21
  private threadId = "";
18
22
  private agentText = "";
19
23
  private failed = false;
@@ -35,20 +39,30 @@ export class CodexNormalizer implements Normalizer {
35
39
  return this.fail(typeof e.message === "string" ? e.message : "");
36
40
  case "turn.failed":
37
41
  return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
38
- case "turn.completed":
42
+ case "turn.completed": {
43
+ const input = e.usage?.input_tokens ?? 0;
44
+ const output = e.usage?.output_tokens ?? 0;
39
45
  return [
40
46
  {
41
47
  type: "result",
42
48
  text: this.agentText,
43
- usage: {
44
- tokens: {
45
- input: e.usage?.input_tokens ?? 0,
46
- output: e.usage?.output_tokens ?? 0,
49
+ usage: { tokens: { input, output } },
50
+ backendSessionId: this.threadId,
51
+ // Same shape the Claude path emits, so one accumulator serves both
52
+ // and a failed-over turn is attributable to the provider that ran it.
53
+ metadata: {
54
+ model_usage: {
55
+ [this.model || "default"]: {
56
+ provider: "codex",
57
+ inputTokens: input,
58
+ outputTokens: output,
59
+ cacheReadInputTokens: e.usage?.cached_input_tokens ?? 0,
60
+ },
47
61
  },
48
62
  },
49
- backendSessionId: this.threadId,
50
63
  },
51
64
  ];
65
+ }
52
66
  default:
53
67
  return [];
54
68
  }
@@ -7,6 +7,7 @@ import type { McpSourceContext } from "../../mcp";
7
7
  import { CodexNormalizer } from "./codex-normalize";
8
8
  import { mintRun, revokeRun } from "../mcp-endpoint";
9
9
  import { scopeOf, parseFailure } from "../failure";
10
+ import { ignore } from "../../utils/errors";
10
11
 
11
12
  /**
12
13
  * Resolve the codex binary's absolute path. The daemon runs under launchd with a
@@ -200,7 +201,7 @@ class CodexSession implements AgentSession {
200
201
  // Started now, not after exit: an undrained pipe blocks the child.
201
202
  const stderr = drainStderr(proc.stderr);
202
203
 
203
- const normalizer = new CodexNormalizer();
204
+ const normalizer = new CodexNormalizer(this.ctx.model);
204
205
  const stdout = proc.stdout.getReader();
205
206
  const lines = readLines(stdout)[Symbol.asyncIterator]();
206
207
  let sawTerminal = false;
@@ -256,7 +257,7 @@ class CodexSession implements AgentSession {
256
257
  };
257
258
  }
258
259
  } finally {
259
- await stdout.cancel().catch(() => {});
260
+ await ignore(stdout.cancel(), "cancel codex stdout reader");
260
261
  revokeRun(token);
261
262
  this.proc = null;
262
263
  }
@@ -82,8 +82,10 @@ function scopeOfStatus(status: number, message: string): FailoverScope | undefin
82
82
  */
83
83
  export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
84
84
  const t = failure.message.trim();
85
- if (!t || t.toLowerCase() === "unknown error") return blank;
85
+ // A status is authoritative even when the message is empty — the prose that
86
+ // accompanies a 429 or 529 is routinely unhelpful.
86
87
  if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
88
+ if (!t || t.toLowerCase() === "unknown error") return blank;
87
89
  if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
88
90
  if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
89
91
  return undefined;
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "crypto";
4
4
  import type { NiaTool } from "../mcp/tools/types";
5
5
  import type { McpSourceContext } from "../mcp";
6
6
  import { log } from "../utils/log";
7
+ import { ignore } from "../utils/errors";
7
8
  import { gateSideEffects } from "../mcp/gate";
8
9
 
9
10
  /**
@@ -93,8 +94,8 @@ export function revokeRun(token: string): void {
93
94
  const entry = runs.get(token);
94
95
  if (!entry) return;
95
96
  runs.delete(token);
96
- entry.transport.close().catch(() => {});
97
- entry.server.close().catch(() => {});
97
+ void ignore(entry.transport.close(), "close run transport");
98
+ void ignore(entry.server.close(), "close run server");
98
99
  }
99
100
 
100
101
  /** Test/diagnostic: number of live runs. */
@@ -54,3 +54,63 @@ export async function rotateRoom(
54
54
  export function chainLock(state: ChatState, fn: () => Promise<void>): void {
55
55
  state.lock = state.lock.then(fn, fn);
56
56
  }
57
+
58
+ /** Seam so the registry is testable without a database. */
59
+ export interface SessionOpener {
60
+ open(prefix: string, build: EngineFactory): Promise<ChatState>;
61
+ rotate(prefix: string, prev: ChatState | undefined, build: EngineFactory): Promise<ChatState>;
62
+ }
63
+
64
+ const defaultOpener: SessionOpener = { open: openChatEngine, rotate: rotateRoom };
65
+
66
+ /**
67
+ * A channel's per-sender chat sessions. Every message-driven channel keeps one
68
+ * of these keyed by sender, and they all want the same four things: open on
69
+ * first use, reuse after, rotate on `/reset`, close everything on shutdown.
70
+ */
71
+ export class ChatSessions<K> {
72
+ private readonly states = new Map<K, ChatState>();
73
+
74
+ constructor(
75
+ private readonly prefixFor: (key: K) => string,
76
+ private readonly build: EngineFactory,
77
+ private readonly opener: SessionOpener = defaultOpener,
78
+ ) {}
79
+
80
+ /** `build` overrides the default options for this call — Slack needs it to
81
+ * thread watch behavior and thread context into a newly opened engine. */
82
+ async get(key: K, build: EngineFactory = this.build): Promise<ChatState> {
83
+ const existing = this.states.get(key);
84
+ if (existing) return existing;
85
+ const state = await this.opener.open(this.prefixFor(key), build);
86
+ this.states.set(key, state);
87
+ return state;
88
+ }
89
+
90
+ /** Start a fresh room for this sender, closing the previous one. */
91
+ async rotate(key: K, build: EngineFactory = this.build): Promise<ChatState> {
92
+ const state = await this.opener.rotate(this.prefixFor(key), this.states.get(key), build);
93
+ this.states.set(key, state);
94
+ return state;
95
+ }
96
+
97
+ /** The cached session, without opening one. */
98
+ peek(key: K): ChatState | undefined {
99
+ return this.states.get(key);
100
+ }
101
+
102
+ /** Whether a session is already open — Slack uses it to tell a live thread
103
+ * from one it should ignore. */
104
+ has(key: K): boolean {
105
+ return this.states.has(key);
106
+ }
107
+
108
+ keys(): Iterable<K> {
109
+ return this.states.keys();
110
+ }
111
+
112
+ closeAll(): void {
113
+ for (const state of this.states.values()) state.engine.close();
114
+ this.states.clear();
115
+ }
116
+ }
@@ -6,6 +6,7 @@
6
6
  * Heavyweight (multi-second latency) by design — keep usage selective.
7
7
  */
8
8
  import Anthropic from "@anthropic-ai/sdk";
9
+ import { errMsg } from "../../utils/errors";
9
10
  import { loadIdentity } from "../../chat/identity";
10
11
  import { log } from "../../utils/log";
11
12
 
@@ -38,6 +39,6 @@ export async function consultClaude(question: string, callerLabel: string): Prom
38
39
  return "(no answer)";
39
40
  } catch (err) {
40
41
  log.error({ err }, "phone: consult_claude failed");
41
- return `error consulting Claude: ${err instanceof Error ? err.message : String(err)}`;
42
+ return `error consulting Claude: ${errMsg(err)}`;
42
43
  }
43
44
  }
@@ -25,6 +25,7 @@ import type { ServerWebSocket } from "bun";
25
25
  import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
26
26
  import { getConfig } from "../../utils/config";
27
27
  import { log } from "../../utils/log";
28
+ import { ignore } from "../../utils/errors";
28
29
  import { getChannel } from "../registry";
29
30
  import { Session, Message } from "../../db/models";
30
31
  import { runMigrations } from "../../db/migrate";
@@ -194,9 +195,10 @@ class PhoneChannel implements Channel {
194
195
 
195
196
  if (!allowed) {
196
197
  log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
197
- getChannel("telegram")
198
- ?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
199
- .catch(() => {});
198
+ const notice = getChannel("telegram")?.deliver({
199
+ text: `Phone: rejected call from ${from} (CallSid ${callSid})`,
200
+ });
201
+ if (notice) void ignore(notice, "notify rejected caller");
200
202
  return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
201
203
  }
202
204
 
@@ -12,6 +12,7 @@
12
12
  * const result = await relay.completion;
13
13
  */
14
14
  import { log } from "../../utils/log";
15
+ import { errMsg } from "../../utils/errors";
15
16
 
16
17
  export interface PhoneToolDefinition {
17
18
  name: string;
@@ -57,6 +58,17 @@ export interface RelayOpts {
57
58
  model: string;
58
59
  voice: string;
59
60
  context: CallContext;
61
+ /** Injectable so the bridge is testable without dialing OpenAI. */
62
+ openAiWsFactory?: (url: string, key: string) => OpenAiSocket;
63
+ }
64
+
65
+ /** The slice of the realtime socket the relay drives. */
66
+ export interface OpenAiSocket extends WebSocketLike {
67
+ addEventListener(type: string, listener: (ev: any) => void): void;
68
+ }
69
+
70
+ function connectOpenAi(url: string, key: string): OpenAiSocket {
71
+ return new WebSocket(url, { headers: { Authorization: `Bearer ${key}` } } as any) as unknown as OpenAiSocket;
60
72
  }
61
73
 
62
74
  export interface WebSocketLike {
@@ -76,7 +88,7 @@ export interface RelayHandle {
76
88
  }
77
89
 
78
90
  export function createRelay(opts: RelayOpts): RelayHandle {
79
- const { twilioWs, openAiKey, model, voice, context } = opts;
91
+ const { twilioWs, openAiKey, model, voice, context, openAiWsFactory = connectOpenAi } = opts;
80
92
  const transcript: TranscriptTurn[] = [];
81
93
  let endedReason: RelayEndReason = "twilio_stop";
82
94
  let errorMsg: string | undefined;
@@ -84,9 +96,7 @@ export function createRelay(opts: RelayOpts): RelayHandle {
84
96
  let pendingAssistantText = "";
85
97
 
86
98
  const openAiUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`;
87
- const openAiWs = new WebSocket(openAiUrl, {
88
- headers: { Authorization: `Bearer ${openAiKey}` },
89
- } as any);
99
+ const openAiWs = openAiWsFactory(openAiUrl, openAiKey);
90
100
 
91
101
  /** Whether a response is currently in flight on the OpenAI side. */
92
102
  let responseActive = false;
@@ -268,7 +278,7 @@ export function createRelay(opts: RelayOpts): RelayHandle {
268
278
  try {
269
279
  output = await tool.handler(args);
270
280
  } catch (err) {
271
- output = `error: ${err instanceof Error ? err.message : String(err)}`;
281
+ output = `error: ${errMsg(err)}`;
272
282
  log.error({ err, callSid: context.callSid, toolName }, "phone tool handler failed");
273
283
  }
274
284
  }
@@ -5,11 +5,14 @@ import { relativeTime } from "../utils/format";
5
5
  import { runMigrations } from "../db/migrate";
6
6
  import { Session, Message } from "../db/models";
7
7
  import { log } from "../utils/log";
8
+ import { errMsg, ignore } from "../utils/errors";
8
9
  import { getMcpServers } from "../mcp";
9
- import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
10
+ import { ChatSessions, chainLock } from "./common/chat-session";
10
11
  import { SlackAttachmentCache } from "./slack/attachments";
11
12
  import { SlackWatchReloader } from "./slack/watch";
12
13
 
14
+ const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
15
+
13
16
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
14
17
  function cleanSentinel(text: string): string {
15
18
  return text.replace(/`/g, "").trim();
@@ -75,7 +78,7 @@ class SlackChannel implements Channel {
75
78
 
76
79
  this.dmUserId = config.channels.slack.dm_user_id;
77
80
 
78
- const chats = new Map<string, ChatState>();
81
+ const chats = new ChatSessions<string>(roomPrefix, buildEngineOpts());
79
82
  const channelNames = new Map<string, string>();
80
83
 
81
84
  async function resolveChannelName(app: App, channelId: string): Promise<string> {
@@ -111,30 +114,20 @@ class SlackChannel implements Channel {
111
114
  });
112
115
  }
113
116
 
114
- async function getState(
117
+ const getState = (
115
118
  key: string,
116
119
  watchBehavior?: { channel: string; behavior: string },
117
120
  slackCtx?: SlackContext,
118
- ): Promise<ChatState> {
119
- let state = chats.get(key);
120
- if (state) return state;
121
- state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
122
- chats.set(key, state);
123
- return state;
124
- }
121
+ ): Promise<ChatState> => chats.get(key, buildEngineOpts(watchBehavior, slackCtx));
125
122
 
126
- async function restartChat(
123
+ const restartChat = (
127
124
  key: string,
128
125
  watchBehavior?: { channel: string; behavior: string },
129
126
  slackCtx?: SlackContext,
130
- ): Promise<ChatState> {
131
- const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
132
- chats.set(key, state);
133
- return state;
134
- }
127
+ ): Promise<ChatState> => chats.rotate(key, buildEngineOpts(watchBehavior, slackCtx));
135
128
 
136
129
  function withLock(key: string, fn: () => Promise<void>): void {
137
- const state = chats.get(key);
130
+ const state = chats.peek(key);
138
131
  if (!state) {
139
132
  fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
140
133
  return;
@@ -177,13 +170,11 @@ class SlackChannel implements Channel {
177
170
  withLock(key, async () => {
178
171
  try {
179
172
  const { result } = await state.engine.send(subcommand, {
180
- onActivity(status) {
181
- log.debug({ status }, "slack engine activity");
182
- },
173
+ onActivity: logActivity,
183
174
  });
184
175
  await respond(result.trim() || "(no response)");
185
176
  } catch (err) {
186
- const errText = err instanceof Error ? err.message : String(err);
177
+ const errText = errMsg(err);
187
178
  await respond(`[error] ${errText}`);
188
179
  }
189
180
  });
@@ -437,9 +428,7 @@ class SlackChannel implements Channel {
437
428
  const { result, messageId, signal } = await state.engine.send(
438
429
  text,
439
430
  {
440
- onActivity(status) {
441
- log.debug({ status }, "slack engine activity");
442
- },
431
+ onActivity: logActivity,
443
432
  },
444
433
  attachments,
445
434
  );
@@ -448,7 +437,7 @@ class SlackChannel implements Channel {
448
437
  await reactToSlackMessage(client, msg.channel, msg.ts, "skull").catch((err) =>
449
438
  log.debug({ err, channel: msg.channel }, "slack: failed to add provider-down reaction"),
450
439
  );
451
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
440
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
452
441
  log.info({ channel: msg.channel, key, reaction: "skull" }, "slack provider failure sent as reaction");
453
442
  return;
454
443
  }
@@ -468,7 +457,7 @@ class SlackChannel implements Channel {
468
457
  "slack: [NO_REPLY] sentinel mixed with content; suppressing send",
469
458
  );
470
459
  }
471
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
460
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
472
461
  return;
473
462
  }
474
463
 
@@ -482,26 +471,27 @@ class SlackChannel implements Channel {
482
471
  } else {
483
472
  await say(reply);
484
473
  }
485
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
474
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
486
475
  log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
487
476
  } catch (sendErr) {
488
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
477
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
489
478
  throw sendErr;
490
479
  }
491
480
  } catch (err) {
492
- const errText = err instanceof Error ? err.message : String(err);
481
+ const errText = errMsg(err);
493
482
  log.error({ err, channel: msg.channel }, "slack message processing failed");
494
483
 
495
484
  if (replyThreadTs) {
496
- await client.chat
497
- .postMessage({
485
+ await ignore(
486
+ client.chat.postMessage({
498
487
  channel: msg.channel,
499
488
  text: `[error] ${errText}`,
500
489
  thread_ts: replyThreadTs,
501
- })
502
- .catch(() => {});
490
+ }),
491
+ "reply engine error in thread",
492
+ );
503
493
  } else {
504
- await say(`[error] ${errText}`).catch(() => {});
494
+ await ignore(say(`[error] ${errText}`), "reply engine error");
505
495
  }
506
496
  } finally {
507
497
  await client.reactions
@@ -16,25 +16,29 @@
16
16
  */
17
17
  import { getMcpServers } from "../mcp";
18
18
  import { runMigrations } from "../db/migrate";
19
- import type { Channel, ChatState, Outbound, TwilioConfig } from "../types";
19
+ import type { Channel, Outbound, TwilioConfig } from "../types";
20
20
  import { getConfig } from "../utils/config";
21
21
  import { log } from "../utils/log";
22
+ import { errMsg, ignore } from "../utils/errors";
22
23
  import { sendMessage as twilioSendMessage } from "./twilio/rest";
23
24
  import { getTwilioServer } from "./twilio/server";
24
- import { chainLock, openChatEngine } from "./common/chat-session";
25
-
26
- const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
25
+ import { ChatSessions, chainLock } from "./common/chat-session";
26
+ import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
27
27
 
28
28
  class SmsChannel implements Channel {
29
29
  name = "sms" as const;
30
30
  private readonly twilio: TwilioConfig;
31
31
  /** Cached resolved "from" number: sms.from_number || phone.from_number */
32
32
  private readonly fromNumber: string;
33
- private readonly chats = new Map<string, ChatState>();
33
+ private readonly chats: ChatSessions<string>;
34
34
 
35
35
  constructor(twilio: TwilioConfig, fromNumber: string) {
36
36
  this.twilio = twilio;
37
37
  this.fromNumber = fromNumber;
38
+ this.chats = new ChatSessions((remote) => `sms-${remote}`, () => ({
39
+ channel: "sms",
40
+ mcpServers: getMcpServers(),
41
+ }));
38
42
  }
39
43
 
40
44
  async start(): Promise<void> {
@@ -70,8 +74,7 @@ class SmsChannel implements Channel {
70
74
  }
71
75
 
72
76
  async stop(): Promise<void> {
73
- for (const state of this.chats.values()) state.engine.close();
74
- this.chats.clear();
77
+ this.chats.closeAll();
75
78
  }
76
79
 
77
80
  /** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
@@ -92,12 +95,12 @@ class SmsChannel implements Channel {
92
95
  const from = params.From || "";
93
96
  const body = params.Body || "";
94
97
 
95
- if (!this.isAllowed(from)) {
98
+ if (!isAllowedSender(this.twilio, from)) {
96
99
  log.warn({ from }, "sms: rejecting non-allowlisted sender");
97
- return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
100
+ return ackTwiml();
98
101
  }
99
102
 
100
- const state = await this.getState(from);
103
+ const state = await this.chats.get(from);
101
104
  // Ack the webhook immediately; reply via REST asynchronously to avoid
102
105
  // Twilio's ~15s webhook timeout when the engine takes longer.
103
106
  chainLock(state, async () => {
@@ -107,24 +110,15 @@ class SmsChannel implements Channel {
107
110
  await this.sendTo(from, reply);
108
111
  } catch (err) {
109
112
  log.error({ err, from }, "sms: engine error");
110
- await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
113
+ await ignore(this.sendTo(from, `[error] ${errMsg(err)}`), "reply engine error");
111
114
  }
112
115
  });
113
116
 
114
- return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
117
+ return ackTwiml();
115
118
  }
116
119
 
117
120
  private handleStatus(params: Record<string, string>): Response {
118
- log.info(
119
- {
120
- messageSid: params.MessageSid,
121
- status: params.MessageStatus,
122
- errorCode: params.ErrorCode,
123
- to: params.To,
124
- },
125
- "sms: delivery status",
126
- );
127
- return new Response("", { status: 204 });
121
+ return deliveryStatusAck("sms", params);
128
122
  }
129
123
 
130
124
  // --- Outbound ---
@@ -153,20 +147,6 @@ class SmsChannel implements Channel {
153
147
  }
154
148
  }
155
149
 
156
- // --- Helpers ---
157
-
158
- private isAllowed(remoteE164: string): boolean {
159
- if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
160
- return this.twilio.allowlist.includes(remoteE164);
161
- }
162
-
163
- private async getState(remoteE164: string): Promise<ChatState> {
164
- let state = this.chats.get(remoteE164);
165
- if (state) return state;
166
- state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
167
- this.chats.set(remoteE164, state);
168
- return state;
169
- }
170
150
  }
171
151
 
172
152
  export function createSmsChannel(): SmsChannel | null {