niahere 0.5.6 → 0.5.8
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 +1 -1
- package/src/agent/auth.ts +28 -3
- package/src/agent/backends/claude-normalize.ts +16 -0
- package/src/agent/backends/claude.ts +10 -1
- package/src/agent/credentials.ts +77 -0
- package/src/channels/common/reply.ts +79 -0
- package/src/channels/slack.ts +3 -2
- package/src/channels/sms.ts +9 -2
- package/src/channels/telegram.ts +7 -1
- package/src/channels/whatsapp.ts +7 -1
- package/src/cli/channels.ts +28 -18
- package/src/types/config.ts +5 -0
- package/src/utils/config.ts +9 -0
- package/src/channels/common/watch-judgement.ts +0 -61
package/package.json
CHANGED
package/src/agent/auth.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import type { ProviderName } from "./models";
|
|
5
|
+
import { resolveClaudeCredential } from "./credentials";
|
|
6
|
+
import { getConfig } from "../utils/config";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Provider sign-in state, read from whatever each CLI stores on disk.
|
|
@@ -43,6 +45,17 @@ const defaultReader: AuthReader = {
|
|
|
43
45
|
env: (k) => process.env[k],
|
|
44
46
|
};
|
|
45
47
|
|
|
48
|
+
/** Config is optional here: a health check must never fail because config is
|
|
49
|
+
* unreadable, it must say so. */
|
|
50
|
+
function credentialConfig(): { anthropic_oauth_token: string | null; anthropic_api_key: string | null } {
|
|
51
|
+
try {
|
|
52
|
+
const c = getConfig();
|
|
53
|
+
return { anthropic_oauth_token: c.anthropic_oauth_token, anthropic_api_key: c.anthropic_api_key };
|
|
54
|
+
} catch {
|
|
55
|
+
return { anthropic_oauth_token: null, anthropic_api_key: null };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
export function claudeCredentialsPath(): string {
|
|
47
60
|
return join(homedir(), ".claude", ".credentials.json");
|
|
48
61
|
}
|
|
@@ -62,8 +75,16 @@ function ago(ms: number): string {
|
|
|
62
75
|
export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader = defaultReader): AuthStatus {
|
|
63
76
|
const base = { provider: "claude" as const };
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
// Ask the same resolver the backend uses. Reading the environment here while
|
|
79
|
+
// the backend also reads config meant the check could report one credential
|
|
80
|
+
// while a different one served the turn — the exact ambiguity this exists to
|
|
81
|
+
// remove.
|
|
82
|
+
const credential = resolveClaudeCredential(credentialConfig(), reader.env);
|
|
83
|
+
if (credential.kind === "oauth_token") {
|
|
84
|
+
return { ...base, state: "ok", detail: "configured oauth token (subscription, no refresh needed)" };
|
|
85
|
+
}
|
|
86
|
+
if (credential.kind === "api_key") {
|
|
87
|
+
return { ...base, state: "ok", detail: "configured API key (metered — billed per token)" };
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
const path = claudeCredentialsPath();
|
|
@@ -97,7 +118,11 @@ export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader =
|
|
|
97
118
|
return { ...status, state: "stale", detail: `access token lapsed ${ago(now - access)} ago, renewable${plan}` };
|
|
98
119
|
}
|
|
99
120
|
if (access !== undefined) {
|
|
100
|
-
return {
|
|
121
|
+
return {
|
|
122
|
+
...status,
|
|
123
|
+
state: "ok",
|
|
124
|
+
detail: `Claude Code login, valid for ${ago(access - now)}${plan} — renewed only when the CLI is used here`,
|
|
125
|
+
};
|
|
101
126
|
}
|
|
102
127
|
return { ...status, state: "unknown", detail: "credentials file carries no expiry" };
|
|
103
128
|
}
|
|
@@ -90,6 +90,16 @@ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
|
|
|
90
90
|
* stay backend-agnostic.
|
|
91
91
|
*/
|
|
92
92
|
export class SdkNormalizer implements Normalizer {
|
|
93
|
+
/**
|
|
94
|
+
* The SDK's `apiKeySource` only describes where an *API key* came from: it
|
|
95
|
+
* reports "none" for every OAuth path, so it cannot tell a configured token
|
|
96
|
+
* from the CLI's inherited login. Nia resolved the credential itself, so it
|
|
97
|
+
* records that too — the SDK's value is kept because it is genuinely
|
|
98
|
+
* informative when an API key is in play.
|
|
99
|
+
*/
|
|
100
|
+
constructor(private readonly credential?: string) {}
|
|
101
|
+
|
|
102
|
+
private apiKeySource: string | undefined;
|
|
93
103
|
private accumulatedThinking = "";
|
|
94
104
|
private lastThinkingLine = "";
|
|
95
105
|
|
|
@@ -97,6 +107,10 @@ export class SdkNormalizer implements Normalizer {
|
|
|
97
107
|
const msg = message as any;
|
|
98
108
|
|
|
99
109
|
if (msg.type === "system" && msg.subtype === "init") {
|
|
110
|
+
// apiKeySource names which credential served the session ('oauth' is
|
|
111
|
+
// Claude Code's own login). Recording it is what makes a silent switch
|
|
112
|
+
// of credential visible after the fact.
|
|
113
|
+
if (typeof msg.apiKeySource === "string") this.apiKeySource = msg.apiKeySource;
|
|
100
114
|
return [{ type: "session", backendSessionId: msg.session_id }];
|
|
101
115
|
}
|
|
102
116
|
|
|
@@ -211,6 +225,8 @@ export class SdkNormalizer implements Normalizer {
|
|
|
211
225
|
terminal_reason: msg.terminal_reason,
|
|
212
226
|
session_id: msg.session_id,
|
|
213
227
|
subtype: msg.subtype,
|
|
228
|
+
api_key_source: this.apiKeySource,
|
|
229
|
+
credential: this.credential,
|
|
214
230
|
usage: msg.usage,
|
|
215
231
|
model_usage: attribute(msg.modelUsage),
|
|
216
232
|
},
|
|
@@ -11,6 +11,7 @@ import { MessageStream } from "../message-stream";
|
|
|
11
11
|
import { getSdkSkillsSetting } from "../../core/skills";
|
|
12
12
|
import { getSdkHooks } from "../../core/sdk-hooks";
|
|
13
13
|
import { getConfig } from "../../utils/config";
|
|
14
|
+
import { resolveClaudeCredential, credentialEnv } from "../credentials";
|
|
14
15
|
import { sleep } from "../../utils/retry";
|
|
15
16
|
|
|
16
17
|
/** The shape of the SDK `query()` handle the session consumes. Injected so the
|
|
@@ -106,6 +107,14 @@ class ClaudeSession implements AgentSession {
|
|
|
106
107
|
// same cwd; jobs always run with a unique id and never auto-continued.
|
|
107
108
|
if (this.ctx.interactive) options.continue = false;
|
|
108
109
|
}
|
|
110
|
+
// Hand the CLI a credential Nia owns when one is configured. Without this
|
|
111
|
+
// it inherits ~/.claude/.credentials.json, which only refreshes when a
|
|
112
|
+
// human runs `claude` on this machine — the coupling that had Nia
|
|
113
|
+
// answering as codex for sixteen days.
|
|
114
|
+
const credential = resolveClaudeCredential(getConfig());
|
|
115
|
+
if (credential.envVar) {
|
|
116
|
+
options.env = credentialEnv(credential, process.env as Record<string, string>);
|
|
117
|
+
}
|
|
109
118
|
if (this.ctx.outputSchema) options.outputFormat = { type: "json_schema", schema: this.ctx.outputSchema };
|
|
110
119
|
if (this.ctx.mcpServers) options.mcpServers = this.ctx.mcpServers;
|
|
111
120
|
if (this.ctx.subagents && Object.keys(this.ctx.subagents).length > 0) options.agents = this.ctx.subagents;
|
|
@@ -119,7 +128,7 @@ class ClaudeSession implements AgentSession {
|
|
|
119
128
|
while (true) {
|
|
120
129
|
if (!this.iterator || !this.stream) this.startQuery();
|
|
121
130
|
this.stream!.push(text, attachments);
|
|
122
|
-
const normalizer = new SdkNormalizer();
|
|
131
|
+
const normalizer = new SdkNormalizer(resolveClaudeCredential(getConfig()).kind);
|
|
123
132
|
let retry = false;
|
|
124
133
|
|
|
125
134
|
while (true) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Config } from "../types/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which Claude credential the daemon should use, and where it came from.
|
|
5
|
+
*
|
|
6
|
+
* Nia used to have no answer to this: it inherited whatever `claude` had last
|
|
7
|
+
* written to `~/.claude/.credentials.json`, refreshed by a human opening a
|
|
8
|
+
* terminal on the same machine. When that stopped, Nia answered as codex for
|
|
9
|
+
* sixteen days and nothing said why. A credential the daemon is handed
|
|
10
|
+
* explicitly is one it can report on, and one that does not lapse because
|
|
11
|
+
* nobody logged in today.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CredentialKind = "oauth_token" | "api_key" | "claude_code_login";
|
|
15
|
+
|
|
16
|
+
export interface ClaudeCredential {
|
|
17
|
+
kind: CredentialKind;
|
|
18
|
+
/** The variable the CLI reads it from. Absent for the inherited login. */
|
|
19
|
+
envVar?: "CLAUDE_CODE_OAUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
|
20
|
+
value?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `subscription` rides the plan. `metered` bills per token — worth saying out
|
|
23
|
+
* loud, because Nia has run $552 in a week and that is an invoice on the API.
|
|
24
|
+
*/
|
|
25
|
+
billing: "subscription" | "metered";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every variable a credential could occupy, so switching never leaves a stale one behind. */
|
|
29
|
+
const TOKEN_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
|
30
|
+
|
|
31
|
+
const clean = (v: unknown): string | undefined => {
|
|
32
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
33
|
+
return s.length > 0 ? s : undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type EnvLookup = (key: string) => string | undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Config first, then the ambient environment, then Claude Code's own login.
|
|
40
|
+
* Config wins so the daemon is not at the mercy of whatever shell launched it.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveClaudeCredential(
|
|
43
|
+
config: Pick<Config, "anthropic_oauth_token" | "anthropic_api_key">,
|
|
44
|
+
env: EnvLookup = (k) => process.env[k],
|
|
45
|
+
): ClaudeCredential {
|
|
46
|
+
const oauth = clean(config.anthropic_oauth_token) ?? clean(env("CLAUDE_CODE_OAUTH_TOKEN"));
|
|
47
|
+
if (oauth) {
|
|
48
|
+
return { kind: "oauth_token", envVar: "CLAUDE_CODE_OAUTH_TOKEN", value: oauth, billing: "subscription" };
|
|
49
|
+
}
|
|
50
|
+
const key = clean(config.anthropic_api_key) ?? clean(env("ANTHROPIC_API_KEY"));
|
|
51
|
+
if (key) {
|
|
52
|
+
return { kind: "api_key", envVar: "ANTHROPIC_API_KEY", value: key, billing: "metered" };
|
|
53
|
+
}
|
|
54
|
+
return { kind: "claude_code_login", billing: "subscription" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The environment for the spawned CLI. The base is passed through — it still
|
|
59
|
+
* needs PATH and HOME — with exactly one credential variable set, and any other
|
|
60
|
+
* cleared so a removed credential stops working immediately rather than at the
|
|
61
|
+
* next restart.
|
|
62
|
+
*/
|
|
63
|
+
export function credentialEnv(credential: ClaudeCredential, base: Record<string, string>): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {};
|
|
65
|
+
for (const [k, v] of Object.entries(base)) {
|
|
66
|
+
if ((TOKEN_VARS as readonly string[]).includes(k)) continue;
|
|
67
|
+
env[k] = v;
|
|
68
|
+
}
|
|
69
|
+
if (credential.envVar && credential.value) env[credential.envVar] = credential.value;
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function describeCredential(credential: ClaudeCredential): string {
|
|
74
|
+
if (credential.kind === "oauth_token") return "configured oauth token (subscription, long-lived)";
|
|
75
|
+
if (credential.kind === "api_key") return "configured API key (metered, billed per token)";
|
|
76
|
+
return "Claude Code's own login (refreshed by whoever last used the CLI here)";
|
|
77
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What counts as a reply, and what is just the model talking to itself.
|
|
3
|
+
*
|
|
4
|
+
* Two related jobs live here on purpose, because splitting them is how the
|
|
5
|
+
* codebase ended up with two sentinel parsers that disagreed:
|
|
6
|
+
*
|
|
7
|
+
* - `shouldSuppressReply` is the cross-channel guard. Control artifacts have
|
|
8
|
+
* escaped as real messages before — `nia send --help` once DM'd the flag
|
|
9
|
+
* itself — so every channel checks the same list.
|
|
10
|
+
* - `decideWatchReply` is the richer judgement a watch turn needs: it prefers
|
|
11
|
+
* a schema's answer, and can tell a bare sentinel from one tangled up with
|
|
12
|
+
* content.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const WATCH_JUDGEMENT_SCHEMA: Record<string, unknown> = {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
reply: {
|
|
19
|
+
type: ["string", "null"],
|
|
20
|
+
description: "The message to post in the channel, or null to stay silent. Most turns are null.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["reply"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Strip markdown fencing so a sentinel matches even when the model wraps it.
|
|
28
|
+
* Underscores are left alone — the sentinel contains one. */
|
|
29
|
+
export function cleanControlReply(text: string): string {
|
|
30
|
+
return text.replace(/[`*]/g, "").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Outputs that are control artifacts rather than anything a person should see. */
|
|
34
|
+
const CONTROL_ARTIFACTS = new Set(["[NO_REPLY]", "--help", "-h"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Exact matches only. "Use `--help` to see the flags" is a real answer and must
|
|
38
|
+
* survive; a reply that is nothing but `--help` is the CLI leaking.
|
|
39
|
+
*/
|
|
40
|
+
export function shouldSuppressReply(text: string): boolean {
|
|
41
|
+
const cleaned = cleanControlReply(text);
|
|
42
|
+
return !cleaned || CONTROL_ARTIFACTS.has(cleaned);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WatchDecision {
|
|
46
|
+
/** Whether to post at all. */
|
|
47
|
+
send: boolean;
|
|
48
|
+
/** What to post. Empty when staying quiet. */
|
|
49
|
+
text: string;
|
|
50
|
+
/** Which path decided, so a drop in schema coverage is visible in the log. */
|
|
51
|
+
source: "structured" | "sentinel";
|
|
52
|
+
/** Set only on the sentinel path, when the model emitted both a sentinel and content. */
|
|
53
|
+
ambiguous?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `structured` wins when it carries the `reply` key the schema requires —
|
|
58
|
+
* including an explicit null, which is a decision, not an absence.
|
|
59
|
+
*
|
|
60
|
+
* The sentinel fallback matches `[NO_REPLY]` anywhere, not just exactly: a watch
|
|
61
|
+
* turn that says both is confused, and staying quiet is the safer reading. That
|
|
62
|
+
* is deliberately looser than `shouldSuppressReply`, which guards ordinary
|
|
63
|
+
* replies where a substring match would swallow real answers.
|
|
64
|
+
*/
|
|
65
|
+
export function decideWatchReply(structured: unknown, raw: string): WatchDecision {
|
|
66
|
+
if (structured && typeof structured === "object" && "reply" in structured) {
|
|
67
|
+
const reply = (structured as { reply: unknown }).reply;
|
|
68
|
+
const text = typeof reply === "string" ? reply.trim() : "";
|
|
69
|
+
return { send: text.length > 0, text, source: "structured" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const trimmed = raw.trim();
|
|
73
|
+
const cleaned = cleanControlReply(trimmed);
|
|
74
|
+
if (!trimmed || cleaned.includes("[NO_REPLY]") || CONTROL_ARTIFACTS.has(cleaned)) {
|
|
75
|
+
const exact = !trimmed || CONTROL_ARTIFACTS.has(cleaned);
|
|
76
|
+
return { send: false, text: "", source: "sentinel", ambiguous: !exact };
|
|
77
|
+
}
|
|
78
|
+
return { send: true, text: trimmed, source: "sentinel" };
|
|
79
|
+
}
|
package/src/channels/slack.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ChatSessions, chainLock } from "./common/chat-session";
|
|
|
12
12
|
import { SlackAttachmentCache } from "./slack/attachments";
|
|
13
13
|
import { SlackWatchReloader } from "./slack/watch";
|
|
14
14
|
|
|
15
|
-
import { decideWatchReply } from "./common/
|
|
15
|
+
import { decideWatchReply, shouldSuppressReply } from "./common/reply";
|
|
16
16
|
import { createTurnPump } from "./common/coalesce";
|
|
17
17
|
|
|
18
18
|
const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
|
|
@@ -304,7 +304,8 @@ class SlackChannel implements Channel {
|
|
|
304
304
|
const { result } = await state.engine.send(subcommand, {
|
|
305
305
|
onActivity: logActivity,
|
|
306
306
|
});
|
|
307
|
-
|
|
307
|
+
if (shouldSuppressReply(result)) return;
|
|
308
|
+
await respond(result.trim());
|
|
308
309
|
} catch (err) {
|
|
309
310
|
const errText = errMsg(err);
|
|
310
311
|
await respond(`[error] ${errText}`);
|
package/src/channels/sms.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { errMsg, ignore } from "../utils/errors";
|
|
|
23
23
|
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
24
24
|
import { getTwilioServer } from "./twilio/server";
|
|
25
25
|
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
26
|
+
import { Message } from "../db/models";
|
|
27
|
+
import { shouldSuppressReply } from "./common/reply";
|
|
26
28
|
import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
|
|
27
29
|
|
|
28
30
|
class SmsChannel implements Channel {
|
|
@@ -105,8 +107,13 @@ class SmsChannel implements Channel {
|
|
|
105
107
|
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
106
108
|
chainLock(state, async () => {
|
|
107
109
|
try {
|
|
108
|
-
const { result } = await state.engine.send(body);
|
|
109
|
-
const reply = result.trim()
|
|
110
|
+
const { result, messageId } = await state.engine.send(body);
|
|
111
|
+
const reply = result.trim();
|
|
112
|
+
if (shouldSuppressReply(reply)) {
|
|
113
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
|
|
114
|
+
log.info({ from }, "sms: agent chose not to reply");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
110
117
|
await this.sendTo(from, reply);
|
|
111
118
|
} catch (err) {
|
|
112
119
|
log.error({ err, from }, "sms: engine error");
|
package/src/channels/telegram.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { getMcpServers } from "../mcp";
|
|
|
12
12
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
13
13
|
import { getNiaHome } from "../utils/paths";
|
|
14
14
|
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
15
|
+
import { shouldSuppressReply } from "./common/reply";
|
|
15
16
|
|
|
16
17
|
function safeExtension(filename?: string): string {
|
|
17
18
|
const ext = filename?.split(".").pop();
|
|
@@ -187,7 +188,12 @@ class TelegramChannel implements Channel {
|
|
|
187
188
|
|
|
188
189
|
try {
|
|
189
190
|
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
190
|
-
const reply = result.trim()
|
|
191
|
+
const reply = result.trim();
|
|
192
|
+
if (shouldSuppressReply(reply)) {
|
|
193
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
|
|
194
|
+
log.info({ chatId }, "telegram: agent chose not to reply");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
191
197
|
try {
|
|
192
198
|
await bot.api.sendMessage(chatId, reply);
|
|
193
199
|
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
package/src/channels/whatsapp.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { getMcpServers } from "../mcp";
|
|
|
21
21
|
import { Message } from "../db/models";
|
|
22
22
|
import { runMigrations } from "../db/migrate";
|
|
23
23
|
import { ChatSessions, chainLock } from "./common/chat-session";
|
|
24
|
+
import { shouldSuppressReply } from "./common/reply";
|
|
24
25
|
import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
|
|
25
26
|
import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
|
|
26
27
|
import { getConfig } from "../utils/config";
|
|
@@ -201,7 +202,12 @@ class WhatsAppChannel implements Channel {
|
|
|
201
202
|
|
|
202
203
|
try {
|
|
203
204
|
const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
|
|
204
|
-
const reply = result.trim()
|
|
205
|
+
const reply = result.trim();
|
|
206
|
+
if (shouldSuppressReply(reply)) {
|
|
207
|
+
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
|
|
208
|
+
log.info({ from }, "whatsapp: agent chose not to reply");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
205
211
|
try {
|
|
206
212
|
await this.sendTextTo(from, reply);
|
|
207
213
|
if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
|
package/src/cli/channels.ts
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import { getConfig, updateRawConfig } from "../utils/config";
|
|
2
2
|
import { getPaths } from "../utils/paths";
|
|
3
3
|
import { errMsg } from "../utils/errors";
|
|
4
|
-
import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
|
|
4
|
+
import { fail, ICON_PASS, ICON_FAIL, parseArgs } from "../utils/cli";
|
|
5
5
|
import { log } from "../utils/log";
|
|
6
6
|
|
|
7
|
+
const SEND_USAGE = "Usage: nia send [-c channel] [--to <slack-channel-id>] [--thread <ts>] <message>";
|
|
8
|
+
|
|
9
|
+
export interface SendArgs {
|
|
10
|
+
channel?: string;
|
|
11
|
+
toChannelId?: string;
|
|
12
|
+
threadTs?: string;
|
|
13
|
+
message: string;
|
|
14
|
+
help: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Extracted so the parse is testable — `nia send --help` used to DM the flag. */
|
|
18
|
+
export function parseSendArgs(args: string[] = process.argv.slice(3)): SendArgs {
|
|
19
|
+
const parsed = parseArgs(args);
|
|
20
|
+
return {
|
|
21
|
+
channel: parsed.getString("channel") || parsed.getString("c"),
|
|
22
|
+
toChannelId: parsed.getString("to"),
|
|
23
|
+
threadTs: parsed.getString("thread"),
|
|
24
|
+
message: parsed.positional.join(" "),
|
|
25
|
+
help: parsed.help,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
export async function sendCommand(): Promise<void> {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const msgParts: string[] = [];
|
|
13
|
-
for (let i = 0; i < args.length; i++) {
|
|
14
|
-
if ((args[i] === "--channel" || args[i] === "-c") && args[i + 1]) {
|
|
15
|
-
channel = args[++i];
|
|
16
|
-
} else if (args[i] === "--to" && args[i + 1]) {
|
|
17
|
-
toChannelId = args[++i];
|
|
18
|
-
} else if (args[i] === "--thread" && args[i + 1]) {
|
|
19
|
-
threadTs = args[++i];
|
|
20
|
-
} else {
|
|
21
|
-
msgParts.push(args[i]);
|
|
22
|
-
}
|
|
30
|
+
let { channel, toChannelId, threadTs, message, help } = parseSendArgs();
|
|
31
|
+
if (help) {
|
|
32
|
+
console.log(SEND_USAGE);
|
|
33
|
+
return;
|
|
23
34
|
}
|
|
24
|
-
|
|
25
|
-
if (!message) fail("Usage: nia send [-c channel] [--to <slack-channel-id>] [--thread <ts>] <message>");
|
|
35
|
+
if (!message) fail(SEND_USAGE);
|
|
26
36
|
|
|
27
37
|
// --to implies slack channel
|
|
28
38
|
if (toChannelId) channel = channel || "slack";
|
package/src/types/config.ts
CHANGED
|
@@ -112,6 +112,11 @@ export interface Config {
|
|
|
112
112
|
activeHours: { start: string; end: string };
|
|
113
113
|
database_url: string;
|
|
114
114
|
log_level: string;
|
|
115
|
+
/** Long-lived token from `claude setup-token`. Preferred: it keeps the
|
|
116
|
+
* subscription and does not depend on anyone logging in on this machine. */
|
|
117
|
+
anthropic_oauth_token: string | null;
|
|
118
|
+
/** Metered per-token billing — a different bill from the subscription. */
|
|
119
|
+
anthropic_api_key: string | null;
|
|
115
120
|
gemini_api_key: string | null;
|
|
116
121
|
sessionFinalization: SessionFinalizationConfig;
|
|
117
122
|
channels: ChannelsConfig;
|
package/src/utils/config.ts
CHANGED
|
@@ -15,6 +15,8 @@ const DEFAULTS: Config = {
|
|
|
15
15
|
activeHours: { start: "00:00", end: "23:59" },
|
|
16
16
|
database_url: DEFAULT_DATABASE_URL,
|
|
17
17
|
log_level: "info",
|
|
18
|
+
anthropic_oauth_token: null,
|
|
19
|
+
anthropic_api_key: null,
|
|
18
20
|
gemini_api_key: null,
|
|
19
21
|
sessionFinalization: {
|
|
20
22
|
enabled: true,
|
|
@@ -129,6 +131,11 @@ export function loadConfig(): Config {
|
|
|
129
131
|
const log_level = process.env.LOG_LEVEL || (typeof raw.log_level === "string" ? raw.log_level : DEFAULTS.log_level);
|
|
130
132
|
|
|
131
133
|
// Gemini API key — env var overrides config
|
|
134
|
+
const anthropic_oauth_token =
|
|
135
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
136
|
+
(typeof raw.anthropic_oauth_token === "string" ? raw.anthropic_oauth_token : null);
|
|
137
|
+
const anthropic_api_key =
|
|
138
|
+
process.env.ANTHROPIC_API_KEY || (typeof raw.anthropic_api_key === "string" ? raw.anthropic_api_key : null);
|
|
132
139
|
const gemini_api_key =
|
|
133
140
|
process.env.GEMINI_API_KEY || (typeof raw.gemini_api_key === "string" ? raw.gemini_api_key : null);
|
|
134
141
|
|
|
@@ -256,6 +263,8 @@ export function loadConfig(): Config {
|
|
|
256
263
|
activeHours: { start, end },
|
|
257
264
|
database_url,
|
|
258
265
|
log_level,
|
|
266
|
+
anthropic_oauth_token,
|
|
267
|
+
anthropic_api_key,
|
|
259
268
|
gemini_api_key,
|
|
260
269
|
sessionFinalization,
|
|
261
270
|
channels: {
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A watch turn answers one question: say something, or stay quiet.
|
|
3
|
-
*
|
|
4
|
-
* That answer used to travel as a `[NO_REPLY]` sentinel inside prose, which
|
|
5
|
-
* means parsing a decision out of an answer that was free to phrase it any way
|
|
6
|
-
* it liked. It mostly worked — and 47 times it produced the sentinel *and*
|
|
7
|
-
* content, where the code has to guess which the model meant.
|
|
8
|
-
*
|
|
9
|
-
* A schema removes the guess. The sentinel path stays as the fallback, because
|
|
10
|
-
* a backend can decline to produce structured output and a watch that stops
|
|
11
|
-
* deciding is worse than one that decides the old way.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export const WATCH_JUDGEMENT_SCHEMA: Record<string, unknown> = {
|
|
15
|
-
type: "object",
|
|
16
|
-
properties: {
|
|
17
|
-
reply: {
|
|
18
|
-
type: ["string", "null"],
|
|
19
|
-
description: "The message to post in the channel, or null to stay silent. Most turns are null.",
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
required: ["reply"],
|
|
23
|
-
additionalProperties: false,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export interface WatchDecision {
|
|
27
|
-
/** Whether to post at all. */
|
|
28
|
-
send: boolean;
|
|
29
|
-
/** What to post. Empty when staying quiet. */
|
|
30
|
-
text: string;
|
|
31
|
-
/** Which path decided, so a drop in schema coverage is visible in the log. */
|
|
32
|
-
source: "structured" | "sentinel";
|
|
33
|
-
/** Set only on the sentinel path, when the model emitted both a sentinel and content. */
|
|
34
|
-
ambiguous?: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Strip markdown fencing so a sentinel matches even when the model wraps it.
|
|
38
|
-
* Underscores are left alone — the sentinel contains one. */
|
|
39
|
-
export function cleanSentinel(text: string): string {
|
|
40
|
-
return text.replace(/[`*]/g, "").trim();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* `structured` wins when it carries the `reply` key the schema requires —
|
|
45
|
-
* including an explicit null, which is a decision, not an absence.
|
|
46
|
-
*/
|
|
47
|
-
export function decideWatchReply(structured: unknown, raw: string): WatchDecision {
|
|
48
|
-
if (structured && typeof structured === "object" && "reply" in structured) {
|
|
49
|
-
const reply = (structured as { reply: unknown }).reply;
|
|
50
|
-
const text = typeof reply === "string" ? reply.trim() : "";
|
|
51
|
-
return { send: text.length > 0, text, source: "structured" };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const trimmed = raw.trim();
|
|
55
|
-
const cleaned = cleanSentinel(trimmed);
|
|
56
|
-
if (!trimmed || cleaned.includes("[NO_REPLY]")) {
|
|
57
|
-
const exact = !trimmed || cleaned === "[NO_REPLY]";
|
|
58
|
-
return { send: false, text: "", source: "sentinel", ambiguous: !exact };
|
|
59
|
-
}
|
|
60
|
-
return { send: true, text: trimmed, source: "sentinel" };
|
|
61
|
-
}
|