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.
- package/package.json +2 -2
- package/src/agent/backends/claude-normalize.ts +5 -6
- package/src/agent/backends/claude.ts +6 -4
- package/src/agent/backends/codex-normalize.ts +19 -3
- package/src/agent/backends/codex.ts +100 -35
- package/src/agent/chain.ts +60 -0
- package/src/agent/failure.ts +90 -0
- package/src/agent/health.ts +41 -0
- package/src/agent/index.ts +3 -1
- package/src/agent/mcp-endpoint.ts +5 -3
- package/src/agent/models.ts +59 -0
- package/src/agent/registry.ts +54 -29
- package/src/agent/types.ts +9 -4
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +83 -34
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/commands/validate.ts +17 -13
- package/src/core/alive.ts +6 -5
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +64 -47
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +12 -0
- package/src/mcp/gate.ts +46 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- package/src/types/config.ts +4 -4
- package/src/utils/config.ts +6 -8
- package/src/utils/errors.ts +26 -0
- package/src/utils/retry.ts +0 -45
package/src/db/models/message.ts
CHANGED
|
@@ -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}%`;
|
package/src/db/models/session.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/mcp/gate.ts
ADDED
|
@@ -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
|
})),
|
package/src/mcp/tools/misc.ts
CHANGED
|
@@ -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: ${
|
|
62
|
+
return `place_call failed: ${errMsg(err)}`;
|
|
62
63
|
}
|
|
63
64
|
}
|
package/src/mcp/tools/send.ts
CHANGED
|
@@ -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")
|
|
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")
|
|
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")
|
|
199
|
-
const errText =
|
|
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
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
108
|
-
|
|
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;
|
package/src/utils/config.ts
CHANGED
|
@@ -10,8 +10,7 @@ const TIME_RE = /^\d{2}:\d{2}$/;
|
|
|
10
10
|
|
|
11
11
|
const DEFAULTS: Config = {
|
|
12
12
|
model: "default",
|
|
13
|
-
|
|
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
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
256
|
-
fallback,
|
|
254
|
+
fallback_models,
|
|
257
255
|
timezone,
|
|
258
256
|
activeHours: { start, end },
|
|
259
257
|
database_url,
|
package/src/utils/errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -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));
|