niahere 0.5.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -90,6 +90,15 @@ 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
+
93
102
  private apiKeySource: string | undefined;
94
103
  private accumulatedThinking = "";
95
104
  private lastThinkingLine = "";
@@ -217,6 +226,7 @@ export class SdkNormalizer implements Normalizer {
217
226
  session_id: msg.session_id,
218
227
  subtype: msg.subtype,
219
228
  api_key_source: this.apiKeySource,
229
+ credential: this.credential,
220
230
  usage: msg.usage,
221
231
  model_usage: attribute(msg.modelUsage),
222
232
  },
@@ -128,7 +128,7 @@ class ClaudeSession implements AgentSession {
128
128
  while (true) {
129
129
  if (!this.iterator || !this.stream) this.startQuery();
130
130
  this.stream!.push(text, attachments);
131
- const normalizer = new SdkNormalizer();
131
+ const normalizer = new SdkNormalizer(resolveClaudeCredential(getConfig()).kind);
132
132
  let retry = false;
133
133
 
134
134
  while (true) {
@@ -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
+ }
@@ -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/watch-judgement";
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
- await respond(result.trim() || "(no response)");
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}`);
@@ -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() || "(no response)";
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");
@@ -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() || "(no response)";
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");
@@ -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() || "(no response)";
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");
@@ -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
- const args = process.argv.slice(3);
9
- let channel: string | undefined;
10
- let toChannelId: string | undefined;
11
- let threadTs: string | undefined;
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
- const message = msgParts.join(" ");
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";
@@ -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
- }