niahere 0.3.9 → 0.3.11

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/README.md CHANGED
@@ -86,7 +86,8 @@ nia agent show <name> — show agent details and prompt
86
86
  nia skills [source] — list available skills
87
87
 
88
88
  nia channels — show channel status (on/off)
89
- nia channels on / off — enable/disable channels (applied via SIGHUP, no restart)
89
+ nia channels on / off — enable/disable all channels (applied via SIGHUP, no restart)
90
+ nia channels off telegram — disable one channel without removing its token
90
91
  nia watch list — list Slack watch channels
91
92
  nia watch add/remove/enable/disable — manage watch channels
92
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -105,13 +105,15 @@ def encode_file(path: str) -> str:
105
105
  return base64.b64encode(f.read()).decode("utf-8")
106
106
 
107
107
 
108
- def resolve_output_path(output: str | None, ext: str = ".png") -> Path:
108
+ def resolve_output_path(output: str | None, ext: str = ".png", index: int | None = None) -> Path:
109
+ # For multi-image output, append _1, _2, ... before the extension.
110
+ suffix = f"_{index + 1}" if index is not None else ""
109
111
  if output:
110
112
  out = Path(output).expanduser()
111
113
  if out.suffix:
112
- return out
113
- return out / f"image_{time.strftime(TIMESTAMP_FORMAT)}{ext}"
114
- return Path(f"/tmp/image_{time.strftime(TIMESTAMP_FORMAT)}{ext}")
114
+ return out.with_name(f"{out.stem}{suffix}{out.suffix}")
115
+ return out / f"image_{time.strftime(TIMESTAMP_FORMAT)}{suffix}{ext}"
116
+ return Path(f"/tmp/image_{time.strftime(TIMESTAMP_FORMAT)}{suffix}{ext}")
115
117
 
116
118
 
117
119
  def read_config_key(key: str) -> str:
@@ -159,14 +161,14 @@ def generate_openai(
159
161
  quality: str,
160
162
  reference_path: str | None = None,
161
163
  n: int = 1,
162
- ) -> tuple[bytes, str]:
163
- """Generate image via OpenAI Images API."""
164
+ ) -> list[tuple[bytes, str]]:
165
+ """Generate image(s) via OpenAI Images API. Returns one entry per image."""
164
166
  if reference_path and Path(reference_path).is_file():
165
167
  return _openai_edit(api_key, prompt, reference_path, model, size, quality, n)
166
168
  return _openai_generate(api_key, prompt, model, size, quality, n)
167
169
 
168
170
 
169
- def _openai_generate(api_key: str, prompt: str, model: str, size: str, quality: str, n: int) -> tuple[bytes, str]:
171
+ def _openai_generate(api_key: str, prompt: str, model: str, size: str, quality: str, n: int) -> list[tuple[bytes, str]]:
170
172
  url = "https://api.openai.com/v1/images/generations"
171
173
  payload: dict = {
172
174
  "model": model,
@@ -191,7 +193,7 @@ def _openai_generate(api_key: str, prompt: str, model: str, size: str, quality:
191
193
 
192
194
  def _openai_edit(
193
195
  api_key: str, prompt: str, reference_path: str, model: str, size: str, quality: str, n: int
194
- ) -> tuple[bytes, str]:
196
+ ) -> list[tuple[bytes, str]]:
195
197
  """Use OpenAI images/edits endpoint with a reference image."""
196
198
  import io
197
199
 
@@ -235,7 +237,7 @@ def _openai_edit(
235
237
  return _openai_request(req)
236
238
 
237
239
 
238
- def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
240
+ def _openai_request(req: urllib.request.Request) -> list[tuple[bytes, str]]:
239
241
  try:
240
242
  with urllib.request.urlopen(req, timeout=180) as resp:
241
243
  response = json.loads(resp.read().decode("utf-8"))
@@ -247,11 +249,11 @@ def _openai_request(req: urllib.request.Request) -> tuple[bytes, str]:
247
249
  if not data_list:
248
250
  raise RuntimeError(f"No data in OpenAI response: {json.dumps(response, indent=2)}")
249
251
 
250
- b64 = data_list[0].get("b64_json")
251
- if not b64:
252
+ images = [(base64.b64decode(item["b64_json"]), "image/png") for item in data_list if item.get("b64_json")]
253
+ if not images:
252
254
  raise RuntimeError("No b64_json in OpenAI response.")
253
255
 
254
- return base64.b64decode(b64), "image/png"
256
+ return images
255
257
 
256
258
 
257
259
  # --- Gemini Generation ---
@@ -422,7 +424,7 @@ Examples:
422
424
  raise SystemExit(f"2K is only supported on gpt-image-2 (got --model {model}).")
423
425
  size_map = OPENAI_SIZE_MAP_2K if args.resolution == "2K" else OPENAI_SIZE_MAP_1K
424
426
  size = size_map.get(args.aspect_ratio, size_map["1:1"])
425
- image_data, mime = generate_openai(
427
+ images = generate_openai(
426
428
  api_key=api_key,
427
429
  prompt=args.prompt,
428
430
  model=model,
@@ -432,21 +434,27 @@ Examples:
432
434
  n=args.n,
433
435
  )
434
436
  else:
435
- image_data, mime = generate_gemini(
436
- api_key=api_key,
437
- prompt=args.prompt,
438
- model=model,
439
- aspect_ratio=args.aspect_ratio,
440
- resolution=args.resolution,
441
- reference_path=ref,
442
- )
443
-
444
- ext = ".png" if "png" in mime else ".jpg"
445
- out = resolve_output_path(args.output, ext)
446
- out.parent.mkdir(parents=True, exist_ok=True)
447
- out.write_bytes(image_data)
448
- print(f"Saved: {out}")
449
- print(f"Provider: {provider} | Model: {model} | Ratio: {args.aspect_ratio} | Resolution: {args.resolution}")
437
+ images = [
438
+ generate_gemini(
439
+ api_key=api_key,
440
+ prompt=args.prompt,
441
+ model=model,
442
+ aspect_ratio=args.aspect_ratio,
443
+ resolution=args.resolution,
444
+ reference_path=ref,
445
+ )
446
+ ]
447
+
448
+ multiple = len(images) > 1
449
+ for idx, (image_data, mime) in enumerate(images):
450
+ ext = ".png" if "png" in mime else ".jpg"
451
+ out = resolve_output_path(args.output, ext, index=idx if multiple else None)
452
+ out.parent.mkdir(parents=True, exist_ok=True)
453
+ out.write_bytes(image_data)
454
+ print(f"Saved: {out}")
455
+ print(
456
+ f"Provider: {provider} | Model: {model} | Ratio: {args.aspect_ratio} | Resolution: {args.resolution} | Images: {len(images)}"
457
+ )
450
458
  except Exception as exc:
451
459
  print(f"Error: {exc}", file=sys.stderr)
452
460
  raise SystemExit(1) from exc
@@ -1,6 +1,7 @@
1
1
  import type { Channel } from "../types";
2
2
  import { registerChannel, getFactories, trackStarted, clearStarted } from "./registry";
3
3
  import { log } from "../utils/log";
4
+ import { getConfig } from "../utils/config";
4
5
  import { createTelegramChannel } from "./telegram";
5
6
  import { createSlackChannel } from "./slack";
6
7
  import { createPhoneChannel } from "./phone";
@@ -59,6 +60,24 @@ export async function startChannels(): Promise<StartResult> {
59
60
  return { started, failed };
60
61
  }
61
62
 
63
+ export function getConfiguredChannelNames(): string[] {
64
+ const { channels } = getConfig();
65
+ if (!channels.enabled) return [];
66
+
67
+ const names: string[] = [];
68
+ if (channels.telegram.enabled && channels.telegram.bot_token) names.push("telegram");
69
+ if (channels.slack.enabled && channels.slack.bot_token && channels.slack.app_token) names.push("slack");
70
+ if (channels.phone.enabled && channels.twilio.sid && channels.twilio.secret && channels.phone.from_number) {
71
+ names.push("phone");
72
+ }
73
+ const smsFromNumber = channels.sms.from_number ?? channels.phone.from_number;
74
+ if (channels.sms.enabled && channels.twilio.sid && channels.twilio.secret && smsFromNumber) names.push("sms");
75
+ if (channels.whatsapp.enabled && channels.twilio.sid && channels.twilio.secret && channels.whatsapp.from_number) {
76
+ names.push("whatsapp");
77
+ }
78
+ return names;
79
+ }
80
+
62
81
  export async function stopChannels(channels: Channel[]): Promise<void> {
63
82
  const results = await Promise.allSettled(
64
83
  channels.map(async (channel) => {
@@ -517,6 +517,8 @@ class SlackChannel implements Channel {
517
517
 
518
518
  export function createSlackChannel(): SlackChannel | null {
519
519
  const config = getConfig();
520
- if (!config.channels.slack.bot_token || !config.channels.slack.app_token) return null;
520
+ if (!config.channels.slack.enabled || !config.channels.slack.bot_token || !config.channels.slack.app_token) {
521
+ return null;
522
+ }
521
523
  return new SlackChannel();
522
524
  }
@@ -286,6 +286,7 @@ class TelegramChannel implements Channel {
286
286
  }
287
287
 
288
288
  export function createTelegramChannel(): TelegramChannel | null {
289
- if (!getConfig().channels.telegram.bot_token) return null;
289
+ const telegram = getConfig().channels.telegram;
290
+ if (!telegram.enabled || !telegram.bot_token) return null;
290
291
  return new TelegramChannel();
291
292
  }
@@ -33,8 +33,7 @@ const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
33
33
  const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
34
34
  const MAX_SEND_RETRIES = 2;
35
35
  const SEND_RETRY_DELAYS = [3_000, 8_000];
36
- const GENERIC_CHAT_ERROR =
37
- "Claude/Anthropic returned an error without details. This is usually temporary; please try again shortly.";
36
+ const GENERIC_CHAT_ERROR = "💀";
38
37
 
39
38
  interface SDKUserMessage {
40
39
  type: "user";
package/src/cli/index.ts CHANGED
@@ -49,8 +49,10 @@ async function awaitStartup(timeout = 60_000): Promise<void> {
49
49
  const config = getConfig();
50
50
  const expecting = new Set<string>();
51
51
  if (config.channels.enabled) {
52
- if (config.channels.telegram.bot_token) expecting.add("telegram");
53
- if (config.channels.slack.bot_token && config.channels.slack.app_token) expecting.add("slack");
52
+ if (config.channels.telegram.enabled && config.channels.telegram.bot_token) expecting.add("telegram");
53
+ if (config.channels.slack.enabled && config.channels.slack.bot_token && config.channels.slack.app_token) {
54
+ expecting.add("slack");
55
+ }
54
56
  }
55
57
  expecting.add("scheduler");
56
58
 
@@ -445,16 +447,29 @@ switch (command) {
445
447
 
446
448
  case "channels": {
447
449
  const sub = process.argv[3];
450
+ const target = process.argv[4];
448
451
  const { updateRawConfig } = await import("../utils/config");
449
452
  if (sub === "on" || sub === "off") {
450
453
  const enabled = sub === "on";
451
- updateRawConfig({ channels: { enabled } });
454
+ if (target) {
455
+ const supported = new Set(["telegram", "slack", "phone", "sms", "whatsapp"]);
456
+ if (!supported.has(target)) fail("Usage: nia channels <on|off> [telegram|slack|phone|sms|whatsapp]");
457
+ updateRawConfig({ channels: { ...(enabled ? { enabled: true } : {}), [target]: { enabled } } });
458
+ } else {
459
+ updateRawConfig({ channels: { enabled } });
460
+ }
452
461
  const pid = readPid();
453
462
  if (pid && isRunning()) {
454
463
  process.kill(pid, "SIGHUP");
455
- console.log(`channels ${enabled ? "enabled" : "disabled"}`);
464
+ console.log(
465
+ target ? `${target} ${enabled ? "enabled" : "disabled"}` : `channels ${enabled ? "enabled" : "disabled"}`,
466
+ );
456
467
  } else {
457
- console.log(`channels ${enabled ? "enabled" : "disabled"} — start nia to apply`);
468
+ console.log(
469
+ target
470
+ ? `${target} ${enabled ? "enabled" : "disabled"} — start nia to apply`
471
+ : `channels ${enabled ? "enabled" : "disabled"} — start nia to apply`,
472
+ );
458
473
  }
459
474
  } else {
460
475
  console.log(`channels: ${getConfig().channels.enabled ? "on" : "off"}`);
@@ -605,7 +620,7 @@ Persona:
605
620
  skills [source] List available skills
606
621
 
607
622
  Channels:
608
- channels [on|off] Toggle channels
623
+ channels [on|off] [name] Toggle all channels or one channel
609
624
  watch <sub> Manage Slack watch channels
610
625
  telegram [setup] Configure telegram
611
626
  slack [setup] Configure slack
package/src/cli/status.ts CHANGED
@@ -84,7 +84,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
84
84
  configured: Boolean(config.channels.telegram.bot_token),
85
85
  status: !config.channels.telegram.bot_token
86
86
  ? "not configured"
87
- : !config.channels.enabled
87
+ : !config.channels.enabled || !config.channels.telegram.enabled
88
88
  ? "disabled"
89
89
  : running
90
90
  ? "active"
@@ -96,7 +96,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
96
96
  appTokenConfigured: Boolean(config.channels.slack.app_token),
97
97
  status: !config.channels.slack.bot_token
98
98
  ? "not configured"
99
- : !config.channels.enabled
99
+ : !config.channels.enabled || !config.channels.slack.enabled
100
100
  ? "disabled"
101
101
  : running
102
102
  ? config.channels.slack.app_token
@@ -114,6 +114,7 @@ export function validateConfig(): Result {
114
114
  // Telegram
115
115
  const tg = ch.telegram as Record<string, unknown> | undefined;
116
116
  if (tg) {
117
+ if (tg.enabled === false) messages.push(`${WARN} telegram disabled`);
117
118
  if (tg.bot_token) {
118
119
  messages.push(`${PASS} telegram.bot_token set`);
119
120
  } else {
@@ -124,6 +125,7 @@ export function validateConfig(): Result {
124
125
  // Slack
125
126
  const sl = ch.slack as Record<string, unknown> | undefined;
126
127
  if (sl) {
128
+ if (sl.enabled === false) messages.push(`${WARN} slack disabled`);
127
129
  if (!sl.bot_token) {
128
130
  messages.push(`${WARN} slack.bot_token missing — slack won't start`);
129
131
  } else {
@@ -8,7 +8,7 @@ import { isRunning, readPid, removePid, writePid } from "../utils/pid";
8
8
  import { ActiveEngine, Job } from "../db/models";
9
9
  import { runMigrations } from "../db/migrate";
10
10
  import { closeDb, getSql } from "../db/connection";
11
- import { registerAllChannels, startChannels, stopChannels, getStarted } from "../channels";
11
+ import { registerAllChannels, startChannels, stopChannels, getStarted, getConfiguredChannelNames } from "../channels";
12
12
  import type { Channel } from "../types";
13
13
  import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler";
14
14
  import { startAlive, stopAlive } from "./alive";
@@ -346,11 +346,16 @@ export async function runDaemon(): Promise<void> {
346
346
  process.on("SIGHUP", async () => {
347
347
  log.info("received SIGHUP, reloading config");
348
348
  resetConfig();
349
- const fresh = getConfig();
350
349
 
351
350
  const running = getStarted();
352
- const wantChannels = fresh.channels.enabled;
351
+ const wantedNames = getConfiguredChannelNames();
352
+ const runningNames = running.map((channel) => channel.name).sort();
353
+ const wantChannels = wantedNames.length > 0;
353
354
  const haveChannels = running.length > 0;
355
+ const needsReconcile =
356
+ wantChannels &&
357
+ haveChannels &&
358
+ (wantedNames.length !== runningNames.length || wantedNames.sort().some((name, i) => name !== runningNames[i]));
354
359
 
355
360
  if (wantChannels && !haveChannels) {
356
361
  log.info("SIGHUP: starting channels");
@@ -360,6 +365,11 @@ export async function runDaemon(): Promise<void> {
360
365
  log.info("SIGHUP: stopping channels");
361
366
  await stopChannels(running);
362
367
  channels = [];
368
+ } else if (needsReconcile) {
369
+ log.info({ wantedNames, runningNames }, "SIGHUP: reconciling channels");
370
+ await stopChannels(running);
371
+ const result = await startChannels();
372
+ channels = result.started;
363
373
  }
364
374
 
365
375
  await recomputeAllNextRuns().catch(() => {});
@@ -1,4 +1,5 @@
1
1
  export interface TelegramConfig {
2
+ enabled: boolean;
2
3
  bot_token: string | null;
3
4
  chat_id: number | null;
4
5
  open: boolean;
@@ -17,6 +18,7 @@ export interface SlackWatchChannel {
17
18
  }
18
19
 
19
20
  export interface SlackConfig {
21
+ enabled: boolean;
20
22
  bot_token: string | null;
21
23
  app_token: string | null;
22
24
  dm_user_id: string | null;
@@ -24,8 +24,9 @@ const DEFAULTS: Config = {
24
24
  channels: {
25
25
  enabled: true,
26
26
  default: "telegram",
27
- telegram: { bot_token: null, chat_id: null, open: false },
27
+ telegram: { enabled: true, bot_token: null, chat_id: null, open: false },
28
28
  slack: {
29
+ enabled: true,
29
30
  bot_token: null,
30
31
  app_token: null,
31
32
  dm_user_id: null,
@@ -157,6 +158,8 @@ export function loadConfig(): Config {
157
158
  const defaultChannel = typeof ch.default === "string" ? ch.default : DEFAULTS.channels.default;
158
159
 
159
160
  // Telegram — env vars override config
161
+ const tgEnabled = chTg.enabled !== false;
162
+
160
163
  const tgBotToken = process.env.TELEGRAM_BOT_TOKEN || (typeof chTg.bot_token === "string" ? chTg.bot_token : null);
161
164
 
162
165
  const tgChatId =
@@ -166,6 +169,8 @@ export function loadConfig(): Config {
166
169
  const tgOpen = chTg.open === true;
167
170
 
168
171
  // Slack — env vars override config
172
+ const slEnabled = chSl.enabled !== false;
173
+
169
174
  const slBotToken = process.env.SLACK_BOT_TOKEN || (typeof chSl.bot_token === "string" ? chSl.bot_token : null);
170
175
 
171
176
  const slAppToken = process.env.SLACK_APP_TOKEN || (typeof chSl.app_token === "string" ? chSl.app_token : null);
@@ -254,8 +259,9 @@ export function loadConfig(): Config {
254
259
  channels: {
255
260
  enabled: channelsEnabled,
256
261
  default: defaultChannel,
257
- telegram: { bot_token: tgBotToken, chat_id: tgChatId, open: tgOpen },
262
+ telegram: { enabled: tgEnabled, bot_token: tgBotToken, chat_id: tgChatId, open: tgOpen },
258
263
  slack: {
264
+ enabled: slEnabled,
259
265
  bot_token: slBotToken,
260
266
  app_token: slAppToken,
261
267
  dm_user_id: slDmUserId,