niahere 0.4.6 → 0.5.1

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 (60) hide show
  1. package/package.json +2 -2
  2. package/src/agent/backends/claude-normalize.ts +5 -6
  3. package/src/agent/backends/claude.ts +6 -4
  4. package/src/agent/backends/codex-normalize.ts +19 -3
  5. package/src/agent/backends/codex.ts +100 -35
  6. package/src/agent/chain.ts +60 -0
  7. package/src/agent/failure.ts +90 -0
  8. package/src/agent/health.ts +41 -0
  9. package/src/agent/index.ts +3 -1
  10. package/src/agent/mcp-endpoint.ts +5 -3
  11. package/src/agent/models.ts +59 -0
  12. package/src/agent/registry.ts +54 -29
  13. package/src/agent/types.ts +9 -4
  14. package/src/channels/common/chat-session.ts +60 -0
  15. package/src/channels/phone/consult.ts +2 -1
  16. package/src/channels/phone/index.ts +5 -3
  17. package/src/channels/phone/relay.ts +15 -5
  18. package/src/channels/slack.ts +24 -34
  19. package/src/channels/sms.ts +16 -36
  20. package/src/channels/telegram.ts +22 -34
  21. package/src/channels/twilio/media-cache.ts +4 -3
  22. package/src/channels/twilio/rest.ts +0 -31
  23. package/src/channels/twilio/server.ts +0 -5
  24. package/src/channels/twilio/shared.ts +36 -0
  25. package/src/channels/whatsapp.ts +24 -58
  26. package/src/chat/engine.ts +83 -34
  27. package/src/chat/gap-marker.ts +63 -0
  28. package/src/chat/repl.ts +5 -5
  29. package/src/cli/config.ts +71 -0
  30. package/src/cli/index.ts +23 -288
  31. package/src/cli/job.ts +1 -2
  32. package/src/cli/logs.ts +55 -0
  33. package/src/cli/run.ts +74 -0
  34. package/src/cli/skills.ts +13 -0
  35. package/src/cli/status.ts +0 -1
  36. package/src/cli/test.ts +32 -0
  37. package/src/cli/update.ts +79 -0
  38. package/src/commands/backup.ts +1 -2
  39. package/src/commands/init.ts +1 -2
  40. package/src/commands/validate.ts +17 -13
  41. package/src/core/alive.ts +6 -5
  42. package/src/core/consolidator.ts +78 -31
  43. package/src/core/daemon.ts +3 -2
  44. package/src/core/finalizer.ts +9 -5
  45. package/src/core/runner.ts +64 -47
  46. package/src/core/scheduler.ts +19 -4
  47. package/src/core/skills.ts +0 -4
  48. package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
  49. package/src/db/models/active_engine.ts +0 -7
  50. package/src/db/models/job.ts +3 -2
  51. package/src/db/models/message.ts +10 -0
  52. package/src/db/models/session.ts +12 -0
  53. package/src/mcp/gate.ts +46 -0
  54. package/src/mcp/server.ts +2 -1
  55. package/src/mcp/tools/misc.ts +2 -1
  56. package/src/mcp/tools/send.ts +5 -4
  57. package/src/types/config.ts +4 -4
  58. package/src/utils/config.ts +6 -8
  59. package/src/utils/errors.ts +26 -0
  60. package/src/utils/retry.ts +0 -45
@@ -4,15 +4,22 @@ 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
- import { resolveBackends, type AgentSession } from "../agent";
16
+ import { truncate } from "../utils/format-activity";
17
+ import { resolveChain, ChainCursor, describeEntry, type AgentSession, type FailoverScope } from "../agent";
18
+ import { scopeOf, parseFailure } from "../agent/failure";
14
19
 
15
20
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
21
+ const HANDOFF_MESSAGES = 20;
22
+ const HANDOFF_CHARS = 2000;
16
23
  const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
17
24
  const GENERIC_CHAT_ERROR = "💀";
18
25
 
@@ -29,8 +36,7 @@ export function formatChatError(rawError: string | null | undefined): string {
29
36
  }
30
37
 
31
38
  export function getChatErrorSignal(rawError: string | null | undefined): SendResult["signal"] | undefined {
32
- const error = rawError?.trim();
33
- return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
39
+ return scopeOf(parseFailure(rawError), "provider") === "provider" ? "provider_down" : undefined;
34
40
  }
35
41
 
36
42
  export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
@@ -96,11 +102,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
96
102
  systemPrompt += `\n\n## Watch Mode — #${watchChannel}\n\nYou are monitoring this Slack channel. Follow the behavior instructions below.\nRespond with [NO_REPLY] if no action is needed — do not explain why.\n\n${behavior}`;
97
103
  }
98
104
 
99
- // The backend chain: configured primary first, then provider-down fallbacks.
100
- // Chat normally runs on the primary; a provider-down turn fails over to the
101
- // next backend (answering the current message — see send()).
102
- const backends = resolveBackends();
103
- let backendIndex = 0;
105
+ // A turn that cannot be served moves down the chain and answers the current
106
+ // message there (see send()).
107
+ const chain = resolveChain();
108
+ let cursor = new ChainCursor(chain);
104
109
 
105
110
  let sessionId: string | null = null;
106
111
  if (typeof resume === "string") {
@@ -110,13 +115,15 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
110
115
  sessionId = await Session.getLatest(room);
111
116
  }
112
117
 
113
- // Verify the primary backend can actually resume this session before
114
- // attempting it (Claude probes the on-disk jsonl; others use their own check).
115
- if (sessionId && !(await backends[0]!.canResume(sessionId, cwd))) {
118
+ // Only resume if the head of the chain can actually take this session id.
119
+ if (sessionId && !(await cursor.current!.backend.canResume(sessionId, cwd))) {
116
120
  sessionId = null;
117
121
  }
118
122
 
119
123
  let session: AgentSession | null = null;
124
+ // Prior turns, replayed into the system prompt when a failover starts a fresh
125
+ // session on another backend — the new one has no session to resume.
126
+ let handoff = "";
120
127
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
121
128
  let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
122
129
  let messageCount = 0;
@@ -165,22 +172,34 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
165
172
  clearIdleTimer();
166
173
  clearLongRunningTimer();
167
174
  if (session) {
168
- await session.close().catch(() => {});
175
+ await ignore(session.close(), "close chat session");
169
176
  session = null;
170
177
  }
171
178
  unregisterActiveHandle(room);
172
179
  }
173
180
 
174
- /** Lazily open (and reuse) the current backend's session for this engine. */
181
+ /** Transcript of the conversation so far, for a backend that cannot resume it. */
182
+ async function buildHandoff(currentMessage: string): Promise<string> {
183
+ const recent = await Message.getRecent(HANDOFF_MESSAGES, room).catch(() => []);
184
+ const lines = recent
185
+ .filter((m) => m.content !== currentMessage)
186
+ .map((m) => `${m.sender === "nia" ? "Nia" : "User"}: ${truncate(m.content, HANDOFF_CHARS)}`);
187
+ if (lines.length === 0) return "";
188
+ return `\n\n## Conversation So Far\nYou are continuing this conversation after switching models mid-turn.\n\n${lines.join("\n")}`;
189
+ }
190
+
191
+ /** Lazily open (and reuse) the current chain entry's session for this engine. */
175
192
  async function ensureSession(): Promise<AgentSession> {
176
193
  if (session) return session;
177
- const backend = backends[backendIndex] ?? backends[0]!;
178
- const s = await backend.openSession({
194
+ const entry = cursor.current!;
195
+ const s = await entry.backend.openSession({
179
196
  room,
180
197
  channel,
181
- systemPrompt,
198
+ systemPrompt: systemPrompt + handoff,
182
199
  cwd,
183
- model: contextModel ?? undefined,
200
+ // A context override names a model for the configured provider, so it
201
+ // only applies at the head of the chain.
202
+ model: (cursor.atHead ? (contextModel ?? entry.model) : entry.model) ?? undefined,
184
203
  mcpServers,
185
204
  resume: sessionId ?? false,
186
205
  subagents: getAgentDefinitions(),
@@ -202,7 +221,26 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
202
221
  return room;
203
222
  },
204
223
 
205
- 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
+
231
+ // Re-probe from the top once the failed provider's cooldown lapses, so a
232
+ // brief outage does not pin the conversation to the fallback for good.
233
+ if (!cursor.atHead) {
234
+ const fresh = new ChainCursor(chain);
235
+ if (fresh.current !== cursor.current) {
236
+ log.info({ room, to: fresh.current && describeEntry(fresh.current) }, "chat returning to preferred model");
237
+ cursor = fresh;
238
+ handoff = await buildHandoff("");
239
+ await teardown();
240
+ sessionId = null;
241
+ }
242
+ }
243
+
206
244
  // Clear idle timer — engine is not idle while processing a request
207
245
  clearIdleTimer();
208
246
  startLongRunningTimer();
@@ -210,7 +248,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
210
248
 
211
249
  // Cancel any pending finalization — session is active again
212
250
  if (sessionId) {
213
- cancelPending(sessionId).catch(() => {});
251
+ void ignore(cancelPending(sessionId), "cancel pending finalization");
214
252
  }
215
253
 
216
254
  await ActiveEngine.register(room, channel);
@@ -227,12 +265,22 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
227
265
 
228
266
  let result: SendResult = { result: "", costUsd: 0, turns: 0 };
229
267
 
230
- // Run the turn on the current backend; on a provider-down result, fail over
231
- // to the next backend and answer the current message there.
268
+ // Run the turn on the current chain entry; a turn that cannot be served
269
+ // moves down the chain and answers the current message there.
232
270
  while (true) {
233
- const sess = await ensureSession();
271
+ let sess: AgentSession;
272
+ try {
273
+ sess = await ensureSession();
274
+ } catch (err) {
275
+ // A backend that cannot start must not take the turn down.
276
+ const next = cursor.advance("provider");
277
+ log.warn({ room, err: String(err), to: next && describeEntry(next) }, "chat backend failed to start");
278
+ if (!next) throw asError(err);
279
+ handoff = await buildHandoff(userMessage);
280
+ continue;
281
+ }
234
282
  let accumulated = "";
235
- let providerDown = false;
283
+ let failover: FailoverScope | undefined;
236
284
 
237
285
  try {
238
286
  for await (const ev of sess.send(userMessage, attachments)) {
@@ -279,13 +327,13 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
279
327
  messageId = await Message.save({ ...saveParams, metadata: undefined });
280
328
  }
281
329
  await Session.touch(sessionId);
282
- Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }).catch(() => {});
330
+ void ignore(Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }), "accumulate session metadata");
283
331
  }
284
332
  result = { result: ev.text, costUsd, turns, messageId };
285
333
  break;
286
334
  }
287
335
  case "error": {
288
- providerDown = ev.providerDown;
336
+ failover = ev.failover;
289
337
  log.error(
290
338
  { room, error: ev.message, terminal_reason: ev.terminalReason },
291
339
  "chat send failed with backend error",
@@ -294,29 +342,30 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
294
342
  result: formatChatError(ev.message),
295
343
  costUsd: 0,
296
344
  turns: 0,
297
- signal: ev.providerDown ? "provider_down" : undefined,
345
+ signal: ev.failover === "provider" ? "provider_down" : undefined,
298
346
  };
299
347
  break;
300
348
  }
301
349
  }
302
350
  }
303
351
  } catch (err) {
304
- await ActiveEngine.unregister(room).catch(() => {});
352
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
305
353
  clearLongRunningTimer();
306
354
  inFlight = false;
307
355
  if (sess.backendSessionId) sessionId = sess.backendSessionId;
308
- throw err instanceof Error ? err : new Error(String(err));
356
+ throw asError(err);
309
357
  }
310
358
 
311
359
  // Re-read the backend session id post-send so finalize/DB target it.
312
360
  if (sess.backendSessionId) sessionId = sess.backendSessionId;
313
361
 
314
- if (providerDown && backendIndex < backends.length - 1) {
315
- backendIndex++;
316
- log.warn({ room, to: backends[backendIndex]!.name }, "chat provider down, failing over to next backend");
317
- await teardown(); // close the dead session so ensureSession opens the next backend
362
+ const next = failover && cursor.advance(failover);
363
+ if (next) {
364
+ log.warn({ room, to: describeEntry(next), scope: failover }, "chat failing over to next model");
365
+ await teardown(); // close the dead session so ensureSession opens the next entry
318
366
  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
367
+ userSaved = false; // re-save the user turn under the new session
368
+ handoff = await buildHandoff(userMessage);
320
369
  continue;
321
370
  }
322
371
  break;
@@ -339,7 +388,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
339
388
  }
340
389
  }
341
390
  await teardown();
342
- await ActiveEngine.unregister(room).catch(() => {});
391
+ await ignore(ActiveEngine.unregister(room), "unregister active-engine");
343
392
  },
344
393
  };
345
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", () => {
@@ -0,0 +1,71 @@
1
+ import { readRawConfig, updateRawConfig } from "../utils/config";
2
+
3
+ const USAGE = [
4
+ "Usage: nia config <set|get|list>",
5
+ " nia config set <key> <value> — set a config value",
6
+ " nia config get <key> — get a config value",
7
+ " nia config list — show all config",
8
+ ].join("\n");
9
+
10
+ /** Booleans and integers are written as their real types, not strings. */
11
+ function parseValue(raw: string): unknown {
12
+ if (raw === "true") return true;
13
+ if (raw === "false") return false;
14
+ if (/^\d+$/.test(raw)) return Number(raw);
15
+ return raw;
16
+ }
17
+
18
+ /** Expand `a.b.c` into `{ a: { b: { c: value } } }` for a merging write. */
19
+ function nest(key: string, value: unknown): Record<string, unknown> {
20
+ const parts = key.split(".");
21
+ const root: Record<string, unknown> = {};
22
+ let cursor = root;
23
+ for (const part of parts.slice(0, -1)) {
24
+ cursor[part] = {};
25
+ cursor = cursor[part] as Record<string, unknown>;
26
+ }
27
+ cursor[parts[parts.length - 1]!] = value;
28
+ return root;
29
+ }
30
+
31
+ function readPath(raw: unknown, key: string): unknown {
32
+ let val = raw;
33
+ for (const part of key.split(".")) {
34
+ if (val && typeof val === "object") val = (val as Record<string, unknown>)[part];
35
+ else return undefined;
36
+ }
37
+ return val;
38
+ }
39
+
40
+ export async function configCommand(sub?: string, key?: string, value?: string): Promise<void> {
41
+ if (sub === "set" && key) {
42
+ if (!value) {
43
+ console.error("Usage: nia config set <key> <value>");
44
+ process.exit(1);
45
+ }
46
+ updateRawConfig(nest(key, parseValue(value)));
47
+ console.log(`${key} = ${value}`);
48
+ return;
49
+ }
50
+
51
+ if (sub === "get" && key) {
52
+ const val = readPath(readRawConfig(), key);
53
+ if (val === undefined) {
54
+ console.log(`${key}: (not set)`);
55
+ } else if (typeof val === "object") {
56
+ const yaml = (await import("js-yaml")).default;
57
+ console.log(yaml.dump(val, { lineWidth: -1 }).trim());
58
+ } else {
59
+ console.log(`${key} = ${val}`);
60
+ }
61
+ return;
62
+ }
63
+
64
+ if (!sub || sub === "list") {
65
+ const yaml = (await import("js-yaml")).default;
66
+ console.log(yaml.dump(readRawConfig(), { lineWidth: -1 }).trim());
67
+ return;
68
+ }
69
+
70
+ console.log(USAGE);
71
+ }