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
@@ -46,6 +46,16 @@ export async function getRecent(limit = 20, room?: string): Promise<RecentMessag
46
46
  }));
47
47
  }
48
48
 
49
+ /** When the room last saw any message. Null for a room with no history. */
50
+ export async function getLastAt(room: string): Promise<Date | null> {
51
+ const sql = getSql();
52
+ const rows = await sql`
53
+ SELECT created_at FROM messages WHERE room = ${room}
54
+ ORDER BY created_at DESC LIMIT 1
55
+ `;
56
+ return rows.length > 0 ? new Date(rows[0].created_at) : null;
57
+ }
58
+
49
59
  export async function search(query: string, limit = 20, room?: string): Promise<SearchResult[]> {
50
60
  const sql = getSql();
51
61
  const pattern = `%${query}%`;
@@ -192,3 +192,15 @@ export async function getLatestRoomIndex(prefix: string): Promise<number> {
192
192
  }
193
193
  return max;
194
194
  }
195
+
196
+ /** How many messages of this session the consolidator has already read. */
197
+ export async function getConsolidatedCount(sessionId: string): Promise<number> {
198
+ const sql = getSql();
199
+ const rows = await sql`SELECT consolidated_count FROM sessions WHERE id = ${sessionId}`;
200
+ return rows.length > 0 ? Number(rows[0].consolidated_count ?? 0) : 0;
201
+ }
202
+
203
+ export async function setConsolidatedCount(sessionId: string, count: number): Promise<void> {
204
+ const sql = getSql();
205
+ await sql`UPDATE sessions SET consolidated_count = ${count} WHERE id = ${sessionId}`;
206
+ }
@@ -0,0 +1,46 @@
1
+ import type { NiaTool } from "./tools/types";
2
+ import { log } from "../utils/log";
3
+
4
+ /**
5
+ * Per-run budget for tools with real-world consequences.
6
+ *
7
+ * The CLI backends run with their own approvals bypassed, and that flag governs
8
+ * only their built-ins (shell, file writes) — nothing upstream of Nia limits how
9
+ * often an agent may dial a phone or message the owner. Wrapping the table at
10
+ * the point each per-run server is built puts one cap in front of every
11
+ * backend, in-process Claude included.
12
+ *
13
+ * These are runaway stops, not permission checks: a looping agent burns its
14
+ * budget and is told no, while a run doing the job it was asked to do never
15
+ * reaches them.
16
+ */
17
+ export const SIDE_EFFECT_LIMITS: Record<string, number> = {
18
+ place_call: 3,
19
+ send_message: 25,
20
+ };
21
+
22
+ /**
23
+ * Wrap side-effect tools with a budget. Call once per run — the counters live in
24
+ * the returned closures, so two concurrent runs cannot spend each other's.
25
+ */
26
+ export function gateSideEffects(tools: NiaTool[]): NiaTool[] {
27
+ const used = new Map<string, number>();
28
+
29
+ return tools.map((t) => {
30
+ const limit = SIDE_EFFECT_LIMITS[t.name];
31
+ if (limit === undefined) return t;
32
+
33
+ return {
34
+ ...t,
35
+ handler: async (args: any, ctx) => {
36
+ const spent = used.get(t.name) ?? 0;
37
+ if (spent >= limit) {
38
+ log.warn({ tool: t.name, limit }, "side-effect tool budget exhausted for this run");
39
+ return `Refused: ${t.name} has already run ${limit} times in this run, which is its limit. Ask the owner if you genuinely need more.`;
40
+ }
41
+ used.set(t.name, spent + 1);
42
+ return t.handler(args, ctx);
43
+ },
44
+ };
45
+ });
46
+ }
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { NIA_TOOLS } from "./tools/table";
3
+ import { gateSideEffects } from "./gate";
3
4
  import type { McpSourceContext } from "./index";
4
5
 
5
6
  /**
@@ -11,7 +12,7 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
11
12
  return createSdkMcpServer({
12
13
  name: "nia",
13
14
  version: "0.1.0",
14
- tools: NIA_TOOLS.map((t) =>
15
+ tools: gateSideEffects(NIA_TOOLS).map((t) =>
15
16
  tool(t.name, t.description, t.schema, async (args: unknown) => ({
16
17
  content: [{ type: "text" as const, text: await t.handler(args, sourceCtx) }],
17
18
  })),
@@ -1,4 +1,5 @@
1
1
  import { appendFileSync } from "fs";
2
+ import { errMsg } from "../../utils/errors";
2
3
  import { join } from "path";
3
4
  import { getPaths } from "../../utils/paths";
4
5
  import { scanAgents } from "../../core/agents";
@@ -58,6 +59,6 @@ export async function placeCall(args: {
58
59
  });
59
60
  return `Call placed. callSid=${result.callSid} status=${result.status}. Transcript will land in messages once the call completes.`;
60
61
  } catch (err) {
61
- return `place_call failed: ${err instanceof Error ? err.message : String(err)}`;
62
+ return `place_call failed: ${errMsg(err)}`;
62
63
  }
63
64
  }
@@ -5,6 +5,7 @@ import { Message, Session } from "../../db/models";
5
5
  import { getConfig } from "../../utils/config";
6
6
  import { getChannel } from "../../channels/registry";
7
7
  import { log } from "../../utils/log";
8
+ import { errMsg, ignore } from "../../utils/errors";
8
9
  import type { Recipient } from "../../types";
9
10
  import type { McpSourceContext } from "../index";
10
11
 
@@ -168,7 +169,7 @@ export async function sendMessage(
168
169
  let media: { data: Uint8Array; mimeType: string; filename: string } | undefined;
169
170
  if (mediaPath) {
170
171
  if (!existsSync(mediaPath)) {
171
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
172
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
172
173
  return `Failed to send: file not found: ${mediaPath}`;
173
174
  }
174
175
  const buf = readFileSync(mediaPath);
@@ -190,13 +191,13 @@ export async function sendMessage(
190
191
 
191
192
  // Mark as sent
192
193
  if (messageId) {
193
- await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
194
+ await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
194
195
  }
195
196
 
196
197
  return mediaPath ? "Message with media sent." : "Message sent.";
197
198
  } catch (err) {
198
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
199
- const errText = err instanceof Error ? err.message : String(err);
199
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "failed"), "record failed delivery status");
200
+ const errText = errMsg(err);
200
201
  return `Failed to send: ${errText}`;
201
202
  }
202
203
  }
@@ -103,11 +103,11 @@ export interface SessionFinalizationConfig {
103
103
  export type BackendName = "claude" | "codex" | "gemini";
104
104
 
105
105
  export interface Config {
106
+ /** The primary model. Its provider is derived from the name — config never
107
+ * names a backend directly. */
106
108
  model: string;
107
- /** The primary backend for jobs and chat. */
108
- runner: BackendName;
109
- /** Ordered fallback backends, tried when the primary is provider-down. */
110
- fallback: BackendName[];
109
+ /** Ordered fallback models, tried when the one before them cannot serve. */
110
+ fallback_models: string[];
111
111
  timezone: string;
112
112
  activeHours: { start: string; end: string };
113
113
  database_url: string;
@@ -10,8 +10,7 @@ const TIME_RE = /^\d{2}:\d{2}$/;
10
10
 
11
11
  const DEFAULTS: Config = {
12
12
  model: "default",
13
- runner: "claude",
14
- fallback: [],
13
+ fallback_models: [],
15
14
  timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone,
16
15
  activeHours: { start: "00:00", end: "23:59" },
17
16
  database_url: DEFAULT_DATABASE_URL,
@@ -96,10 +95,10 @@ export function loadConfig(): Config {
96
95
  // Model
97
96
  const model = typeof raw.model === "string" ? raw.model : DEFAULTS.model;
98
97
 
99
- // Backends primary "runner" + ordered "fallback" chain for provider-down failover.
100
- const isBackend = (v: unknown): v is Config["runner"] => v === "claude" || v === "codex" || v === "gemini";
101
- const runner: Config["runner"] = isBackend(raw.runner) ? raw.runner : DEFAULTS.runner;
102
- const fallback: Config["fallback"] = Array.isArray(raw.fallback) ? raw.fallback.filter(isBackend) : DEFAULTS.fallback;
98
+ // Ordered fallback models; each one's provider is derived from its name.
99
+ const fallback_models = Array.isArray(raw.fallback_models)
100
+ ? raw.fallback_models.filter((m): m is string => typeof m === "string" && m.trim().length > 0)
101
+ : DEFAULTS.fallback_models;
103
102
 
104
103
  // Timezone
105
104
  let timezone = DEFAULTS.timezone;
@@ -252,8 +251,7 @@ export function loadConfig(): Config {
252
251
 
253
252
  return {
254
253
  model,
255
- runner,
256
- fallback,
254
+ fallback_models,
257
255
  timezone,
258
256
  activeHours: { start, end },
259
257
  database_url,
@@ -1,4 +1,30 @@
1
+ import { log } from "./log";
2
+
1
3
  /** Extract a human-readable message from an unknown error value. */
2
4
  export function errMsg(err: unknown): string {
3
5
  return err instanceof Error ? err.message : String(err);
4
6
  }
7
+
8
+ /** Normalize an unknown to an Error, keeping the original when it already is one
9
+ * so its stack survives a rethrow. */
10
+ export function asError(err: unknown): Error {
11
+ return err instanceof Error ? err : new Error(errMsg(err));
12
+ }
13
+
14
+ type Report = (context: string, err: string) => void;
15
+
16
+ const report: Report = (context, err) => log.debug({ err }, `ignored failure: ${context}`);
17
+
18
+ /**
19
+ * Await best-effort work whose failure must not stop the caller — cleanup,
20
+ * bookkeeping, teardown. Use instead of `.catch(() => {})`: the outcome is the
21
+ * same, but a failure leaves a trace rather than vanishing, so a persistent one
22
+ * is discoverable instead of silent.
23
+ */
24
+ export async function ignore(work: Promise<unknown>, context: string, onError: Report = report): Promise<void> {
25
+ try {
26
+ await work;
27
+ } catch (err) {
28
+ onError(context, errMsg(err));
29
+ }
30
+ }
@@ -14,51 +14,6 @@ export async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T
14
14
  throw new Error("unreachable"); // satisfies TS return type
15
15
  }
16
16
 
17
- const RETRYABLE_PATTERNS = [/\b500\b/i, /internal server error/i, /overloaded/i, /529/, /rate limit/i];
18
-
19
- /** Check if an error string from the Claude API looks transient/retryable. */
20
- export function isRetryableApiError(error: string): boolean {
21
- return RETRYABLE_PATTERNS.some((p) => p.test(error));
22
- }
23
-
24
- /**
25
- * A blank or opaque ("unknown error") failure means the provider is down rather
26
- * than a specific, surfaceable error — the signal that should trigger failover.
27
- * Distinct from `isRetryableApiError` (a transient error worth an in-backend retry).
28
- */
29
- export function isProviderDownError(error: string | null | undefined): boolean {
30
- const trimmed = error?.trim();
31
- return !trimmed || trimmed.toLowerCase() === "unknown error";
32
- }
33
-
34
- const CLI_PROVIDER_DOWN_PATTERNS = [
35
- /authenticat/i,
36
- /unauthoriz/i,
37
- /\b(401|403)\b/,
38
- /not logged in/i,
39
- /\blogin\b/i,
40
- /\bcredentials?\b/i,
41
- /\bapi key\b/i,
42
- /(session|token) expired/i,
43
- /\b(econnrefused|enotfound|etimedout|econnreset)\b/i,
44
- /connection (refused|reset|timed out)/i,
45
- /network (error|unreachable)/i,
46
- /failed to refresh available models/i,
47
- ];
48
-
49
- /**
50
- * Provider-down classification for CLI-subprocess backends (codex), whose
51
- * failures surface as free-form stderr rather than the Claude SDK's structured
52
- * error. Matches the ways a CLI reports "I could not reach or authenticate with
53
- * the provider" — as opposed to a task that ran and genuinely failed, which must
54
- * stay a real error so the chain does not burn a second backend replaying it.
55
- */
56
- export function isCliProviderDownError(stderr: string | null | undefined): boolean {
57
- const trimmed = stderr?.trim();
58
- if (!trimmed) return true;
59
- return CLI_PROVIDER_DOWN_PATTERNS.some((p) => p.test(trimmed));
60
- }
61
-
62
17
  /** Sleep for ms milliseconds. */
63
18
  export function sleep(ms: number): Promise<void> {
64
19
  return new Promise((r) => setTimeout(r, ms));