niahere 0.2.22 → 0.2.24

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.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -508,36 +508,45 @@ class SlackChannel implements Channel {
508
508
  log.warn({ err }, "could not get slack bot user ID");
509
509
  }
510
510
 
511
- // Resolve watch channel names IDs
512
- const watchConfig = config.channels.slack.watch;
513
- if (watchConfig) {
514
- try {
515
- const channelList: { id: string; name: string }[] = [];
516
- let cursor: string | undefined;
517
- do {
518
- const resp = await app.client.conversations.list({
519
- types: "public_channel,private_channel",
520
- exclude_archived: true,
521
- limit: 200,
522
- cursor,
523
- });
524
- for (const ch of resp.channels || []) {
525
- if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
526
- }
527
- cursor = resp.response_metadata?.next_cursor || undefined;
528
- } while (cursor);
529
-
530
- for (const [name, cfg] of Object.entries(watchConfig)) {
531
- const match = channelList.find((c) => c.name === name);
532
- if (match) {
533
- watchChannels.set(match.id, { name, behavior: cfg.behavior });
534
- log.info({ channel: name, id: match.id }, "slack: watching channel");
535
- } else {
536
- log.warn({ channel: name }, "slack: watch channel not found");
511
+ // Parse watch channels keys are "channel_id#channel_name" or just "channel_name"
512
+ const rawWatchConfig = config.channels.slack.watch;
513
+ if (rawWatchConfig) {
514
+ for (const [key, cfg] of Object.entries(rawWatchConfig)) {
515
+ const hashIdx = key.indexOf("#");
516
+ if (hashIdx !== -1) {
517
+ // channel_id#channel_name format — use ID directly, no API call needed
518
+ const id = key.slice(0, hashIdx);
519
+ const name = key.slice(hashIdx + 1);
520
+ watchChannels.set(id, { name, behavior: cfg.behavior });
521
+ log.info({ channel: name, id }, "slack: watching channel");
522
+ } else {
523
+ // Legacy: plain channel name — resolve via API
524
+ try {
525
+ const channelList: { id: string; name: string }[] = [];
526
+ let cursor: string | undefined;
527
+ do {
528
+ const resp = await app.client.conversations.list({
529
+ types: "public_channel,private_channel",
530
+ exclude_archived: true,
531
+ limit: 200,
532
+ cursor,
533
+ });
534
+ for (const ch of resp.channels || []) {
535
+ if (ch.id && ch.name) channelList.push({ id: ch.id, name: ch.name });
536
+ }
537
+ cursor = resp.response_metadata?.next_cursor || undefined;
538
+ } while (cursor);
539
+ const match = channelList.find((c) => c.name === key);
540
+ if (match) {
541
+ watchChannels.set(match.id, { name: key, behavior: cfg.behavior });
542
+ log.info({ channel: key, id: match.id }, "slack: watching channel (resolved by name)");
543
+ } else {
544
+ log.warn({ channel: key }, "slack: watch channel not found");
545
+ }
546
+ } catch (err) {
547
+ log.warn({ err, channel: key }, "slack: failed to resolve watch channel");
537
548
  }
538
549
  }
539
- } catch (err) {
540
- log.warn({ err }, "slack: failed to resolve watch channels");
541
550
  }
542
551
  }
543
552
 
package/src/chat/repl.ts CHANGED
@@ -6,16 +6,7 @@ import { getMcpServers, setMcpServers } from "../mcp";
6
6
  import { createNiaMcpServer } from "../mcp/server";
7
7
  import { Session } from "../db/models";
8
8
  import { relativeTime } from "../utils/format";
9
-
10
- // ANSI helpers
11
- const DIM = "\x1b[2m";
12
- const BOLD = "\x1b[1m";
13
- const CYAN = "\x1b[36m";
14
- const RESET = "\x1b[0m";
15
- const CLEAR_LINE = "\x1b[2K\r";
16
-
17
- // Braille spinner frames
18
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ import { DIM, BOLD, CYAN, RESET, CLEAR_LINE, SPINNER } from "../utils/cli";
19
10
 
20
11
  class StatusLine {
21
12
  private frame = 0;
@@ -1,7 +1,7 @@
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 } from "../utils/cli";
4
+ import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
5
5
  import { log } from "../utils/log";
6
6
 
7
7
  export async function sendCommand(): Promise<void> {
@@ -98,7 +98,7 @@ export async function slackCommand(): Promise<void> {
98
98
  });
99
99
  const data = (await resp.json()) as Record<string, unknown>;
100
100
  if (data.ok) {
101
- console.log(` Auth: \u2713 valid`);
101
+ console.log(` Auth: ${ICON_PASS} valid`);
102
102
  // Backfill workspace info if missing
103
103
  if (!config.channels.slack.workspace) {
104
104
  const enriched = await enrichSlackConfig(config.channels.slack.bot_token);
@@ -108,10 +108,10 @@ export async function slackCommand(): Promise<void> {
108
108
  }
109
109
  }
110
110
  } else {
111
- console.log(` Auth: \u2717 ${data.error}`);
111
+ console.log(` Auth: ${ICON_FAIL} ${data.error}`);
112
112
  }
113
113
  } catch (err) {
114
- console.log(` Auth: \u2717 could not reach Slack API`);
114
+ console.log(` Auth: ${ICON_FAIL} could not reach Slack API`);
115
115
  }
116
116
  } else {
117
117
  console.log("Slack: not configured");
package/src/cli/index.ts CHANGED
@@ -8,7 +8,7 @@ import { Message } from "../db/models";
8
8
  import { withDb } from "../db/connection";
9
9
  import { getNiaHome, getPaths } from "../utils/paths";
10
10
  import { errMsg } from "../utils/errors";
11
- import { fail } from "../utils/cli";
11
+ import { fail, ICON_PASS, ICON_WARN } from "../utils/cli";
12
12
  import { jobCommand } from "./job";
13
13
  import { statusCommand } from "./status";
14
14
  import { sendCommand, telegramCommand, slackCommand } from "./channels";
@@ -64,14 +64,14 @@ async function awaitStartup(timeout = 60_000): Promise<void> {
64
64
  if (ready.has(name)) continue;
65
65
  if (content.includes(STARTUP_MARKERS[name])) {
66
66
  ready.add(name);
67
- console.log(` \u2713 ${name}`);
67
+ console.log(` ${ICON_PASS} ${name}`);
68
68
  }
69
69
  }
70
70
  }
71
71
 
72
72
  const pending = [...expecting].filter((e) => !ready.has(e));
73
73
  if (pending.length > 0) {
74
- console.log(` \u26A0 timed out waiting for: ${pending.join(", ")}`);
74
+ console.log(` ${ICON_WARN} timed out waiting for: ${pending.join(", ")}`);
75
75
  }
76
76
  }
77
77
 
@@ -143,9 +143,7 @@ switch (command) {
143
143
  if (prompt) {
144
144
  const { createChatEngine } = await import("../chat/engine");
145
145
  const { getMcpServers } = await import("../mcp");
146
- const DIM = "\x1b[2m";
147
- const RST = "\x1b[0m";
148
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
146
+ const { DIM, RESET: RST, CLEAR_LINE, SPINNER: FRAMES } = await import("../utils/cli");
149
147
  let frame = 0;
150
148
  let statusText = "thinking";
151
149
  let spinTimer: ReturnType<typeof setInterval> | null = null;
@@ -153,7 +151,7 @@ switch (command) {
153
151
  let streaming = false;
154
152
 
155
153
  const renderSpinner = () => {
156
- process.stderr.write(`\x1b[2K\r${DIM} ${FRAMES[frame]} ${statusText}${RST}`);
154
+ process.stderr.write(`${CLEAR_LINE}${DIM} ${FRAMES[frame]} ${statusText}${RST}`);
157
155
  frame = (frame + 1) % FRAMES.length;
158
156
  };
159
157
 
@@ -412,6 +410,14 @@ switch (command) {
412
410
  process.exit(exitCode);
413
411
  }
414
412
 
413
+ case "validate": {
414
+ const { validateConfig } = await import("../commands/validate");
415
+ const result = validateConfig();
416
+ for (const msg of result.messages) console.log(` ${msg}`);
417
+ console.log(result.ok ? "\nConfig is valid." : "\nConfig has errors.");
418
+ process.exit(result.ok ? 0 : 1);
419
+ }
420
+
415
421
  case "init": {
416
422
  const { runInit } = await import("../commands/init");
417
423
  await runInit();
@@ -434,6 +440,7 @@ switch (command) {
434
440
  console.log(" memory [show|reset] — view or reset memory.md");
435
441
  console.log(" db <sub> — database setup/status/migrate");
436
442
  console.log(" skills — list available skills");
443
+ console.log(" validate — validate config.yaml");
437
444
  console.log(" config <sub> — get/set/list config values");
438
445
  console.log(" send [-c ch] <msg> — send a message via channel");
439
446
  console.log(" telegram <token> — configure telegram");
package/src/cli/job.ts CHANGED
@@ -8,7 +8,7 @@ import { Job } from "../db/models";
8
8
  import { withDb } from "../db/connection";
9
9
  import type { ScheduleType } from "../types";
10
10
  import { errMsg } from "../utils/errors";
11
- import { fail, pickFromList } from "../utils/cli";
11
+ import { fail, pickFromList, ICON_PASS, ICON_FAIL } from "../utils/cli";
12
12
  import { computeInitialNextRun } from "../core/scheduler";
13
13
 
14
14
  async function pickJob(prompt = "Pick a job"): Promise<string> {
@@ -164,7 +164,7 @@ export async function jobCommand(): Promise<void> {
164
164
  for (const e of entries) {
165
165
  const time = localTime(new Date(e.timestamp));
166
166
  const dur = `${e.duration_ms}ms`;
167
- const icon = e.status === "ok" ? "\u2713" : "\u2717";
167
+ const icon = e.status === "ok" ? ICON_PASS : ICON_FAIL;
168
168
  const summary = e.error || e.result.slice(0, 60).replace(/\n/g, " ") || "-";
169
169
  console.log(` ${icon} ${time} ${dur.padStart(8)} ${summary}`);
170
170
  }
@@ -230,7 +230,7 @@ export async function jobCommand(): Promise<void> {
230
230
  for (const e of entries) {
231
231
  const time = localTime(new Date(e.timestamp));
232
232
  const dur = `${e.duration_ms}ms`;
233
- const status = e.status === "ok" ? "\u2713" : "\u2717";
233
+ const status = e.status === "ok" ? ICON_PASS : ICON_FAIL;
234
234
  const summary = e.error || e.result.slice(0, 80).replace(/\n/g, " ") || "-";
235
235
  console.log(` ${status} ${time} ${dur.padStart(8)} ${e.job} ${summary}`);
236
236
  }
package/src/cli/status.ts CHANGED
@@ -8,6 +8,7 @@ import type { ScheduleType, JobStateStatus, RoomStats } from "../types";
8
8
  import { withDb } from "../db/connection";
9
9
  import { errMsg } from "../utils/errors";
10
10
  import { checkForUpdate } from "../utils/update";
11
+ import { ICON_PASS, ICON_FAIL, ICON_RUNNING } from "../utils/cli";
11
12
 
12
13
  type StatusOptions = {
13
14
  json: boolean;
@@ -245,7 +246,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
245
246
  safeDate(nextRun)!.getTime() <= now.getTime() &&
246
247
  !stateInfo;
247
248
 
248
- const statusIcon = status === "ok" ? "\u2713" : status === "error" ? "\u2717" : status === "running" ? "\u21bb" : "\u2217";
249
+ const statusIcon = status === "ok" ? ICON_PASS : status === "error" ? ICON_FAIL : status === "running" ? ICON_RUNNING : "\u2217";
249
250
  const durationText = stateInfo?.duration_ms === undefined ? "n/a" : `${stateInfo.duration_ms}ms`;
250
251
  const nextText = nextRun ? formatTimeLine(nextRun, now) : "unknown";
251
252
  const lastText = lastRun ? formatTimeLine(lastRun, now) : "never";
@@ -292,7 +293,7 @@ export async function statusCommand(argv: string[] = []): Promise<void> {
292
293
  console.log("\nJobs (from state file):");
293
294
  for (const [name, info] of fallbackEntries) {
294
295
  const last = formatTimeLine(info.lastRun, now);
295
- const icon = info.status === "ok" ? "\u2713" : info.status === "error" ? "\u2717" : "\u2217";
296
+ const icon = info.status === "ok" ? ICON_PASS : info.status === "error" ? ICON_FAIL : "\u2217";
296
297
  console.log(` ${icon} ${name}: ${info.status} (last: ${last}, ${info.duration_ms}ms)`);
297
298
  }
298
299
  } else if (dbError) {
@@ -98,14 +98,11 @@ export async function healthCommand(): Promise<void> {
98
98
  push(checks, "bun", "ok", "v" + bunVersion);
99
99
 
100
100
  // Output
101
- const GREEN = "\x1b[32m";
102
- const YELLOW = "\x1b[33m";
103
- const RED = "\x1b[31m";
104
- const RST = "\x1b[0m";
101
+ const { GREEN, YELLOW, RED, RESET, ICON_PASS, ICON_FAIL, ICON_WARN } = await import("../utils/cli");
105
102
  const icons: Record<string, string> = {
106
- ok: GREEN + "\u2713" + RST,
107
- warn: YELLOW + "!" + RST,
108
- fail: RED + "\u2717" + RST,
103
+ ok: GREEN + ICON_PASS + RESET,
104
+ warn: YELLOW + ICON_WARN + RESET,
105
+ fail: RED + ICON_FAIL + RESET,
109
106
  };
110
107
 
111
108
  console.log();
@@ -0,0 +1,150 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import yaml from "js-yaml";
3
+ import { getPaths } from "../utils/paths";
4
+ import { ICON_PASS as PASS, ICON_FAIL as FAIL, ICON_WARN as WARN } from "../utils/cli";
5
+
6
+ interface Result {
7
+ ok: boolean;
8
+ messages: string[];
9
+ }
10
+
11
+ function check(label: string, fn: () => string | null): { icon: string; label: string; detail?: string } {
12
+ const err = fn();
13
+ if (err) return { icon: FAIL, label, detail: err };
14
+ return { icon: PASS, label };
15
+ }
16
+
17
+ function warn(label: string, detail: string): { icon: string; label: string; detail?: string } {
18
+ return { icon: WARN, label, detail };
19
+ }
20
+
21
+ export function validateConfig(): Result {
22
+ const { config: configPath } = getPaths();
23
+ const messages: string[] = [];
24
+ let ok = true;
25
+
26
+ // File exists
27
+ if (!existsSync(configPath)) {
28
+ return { ok: false, messages: [`${FAIL} config.yaml not found at ${configPath}`] };
29
+ }
30
+
31
+ // Valid YAML
32
+ let raw: Record<string, unknown>;
33
+ try {
34
+ const parsed = yaml.load(readFileSync(configPath, "utf8"));
35
+ if (!parsed || typeof parsed !== "object") {
36
+ return { ok: false, messages: [`${FAIL} config.yaml is empty or not an object`] };
37
+ }
38
+ raw = parsed as Record<string, unknown>;
39
+ messages.push(`${PASS} valid YAML`);
40
+ } catch (err) {
41
+ return { ok: false, messages: [`${FAIL} invalid YAML: ${(err as Error).message}`] };
42
+ }
43
+
44
+ // Timezone
45
+ if (raw.timezone) {
46
+ try {
47
+ Intl.DateTimeFormat(undefined, { timeZone: raw.timezone as string });
48
+ messages.push(`${PASS} timezone: ${raw.timezone}`);
49
+ } catch {
50
+ messages.push(`${FAIL} invalid timezone: ${raw.timezone}`);
51
+ ok = false;
52
+ }
53
+ }
54
+
55
+ // Active hours
56
+ const ah = raw.active_hours as Record<string, string> | undefined;
57
+ if (ah) {
58
+ const timeRe = /^\d{2}:\d{2}$/;
59
+ if (ah.start && !timeRe.test(ah.start)) {
60
+ messages.push(`${FAIL} active_hours.start invalid: "${ah.start}" (expected HH:MM)`);
61
+ ok = false;
62
+ } else if (ah.start) {
63
+ messages.push(`${PASS} active_hours: ${ah.start}–${ah.end || "?"}`);
64
+ }
65
+ if (ah.end && !timeRe.test(ah.end)) {
66
+ messages.push(`${FAIL} active_hours.end invalid: "${ah.end}" (expected HH:MM)`);
67
+ ok = false;
68
+ }
69
+ }
70
+
71
+ // Database URL
72
+ const dbUrl = (process.env.DATABASE_URL || raw.database_url) as string | undefined;
73
+ if (dbUrl && dbUrl.startsWith("postgres")) {
74
+ messages.push(`${PASS} database_url set`);
75
+ } else if (!dbUrl) {
76
+ messages.push(`${WARN} database_url not set (will use default)`);
77
+ }
78
+
79
+ // Runner
80
+ const runner = raw.runner as string | undefined;
81
+ if (runner && runner !== "claude" && runner !== "codex") {
82
+ messages.push(`${FAIL} runner must be "claude" or "codex", got "${runner}"`);
83
+ ok = false;
84
+ } else if (runner) {
85
+ messages.push(`${PASS} runner: ${runner}`);
86
+ }
87
+
88
+ // Channels
89
+ const ch = raw.channels as Record<string, unknown> | undefined;
90
+ if (ch) {
91
+ // Telegram
92
+ const tg = ch.telegram as Record<string, unknown> | undefined;
93
+ if (tg) {
94
+ if (tg.bot_token) {
95
+ messages.push(`${PASS} telegram.bot_token set`);
96
+ } else {
97
+ messages.push(`${WARN} telegram.bot_token missing — telegram won't start`);
98
+ }
99
+ }
100
+
101
+ // Slack
102
+ const sl = ch.slack as Record<string, unknown> | undefined;
103
+ if (sl) {
104
+ if (!sl.bot_token) {
105
+ messages.push(`${WARN} slack.bot_token missing — slack won't start`);
106
+ } else {
107
+ messages.push(`${PASS} slack.bot_token set`);
108
+ }
109
+ if (!sl.app_token) {
110
+ messages.push(`${WARN} slack.app_token missing — slack won't start (Socket Mode requires app_token)`);
111
+ } else {
112
+ messages.push(`${PASS} slack.app_token set`);
113
+ }
114
+
115
+ // Watch channels
116
+ const watch = sl.watch as Record<string, unknown> | undefined;
117
+ if (watch) {
118
+ for (const [key, val] of Object.entries(watch)) {
119
+ if (!val || typeof val !== "object") {
120
+ messages.push(`${FAIL} slack.watch.${key}: must be an object with "behavior" field`);
121
+ ok = false;
122
+ continue;
123
+ }
124
+ const behavior = (val as Record<string, unknown>).behavior;
125
+ if (typeof behavior !== "string" || !behavior.trim()) {
126
+ messages.push(`${FAIL} slack.watch.${key}: missing "behavior" string`);
127
+ ok = false;
128
+ continue;
129
+ }
130
+ const hasId = key.includes("#");
131
+ if (hasId) {
132
+ messages.push(`${PASS} slack.watch: ${key}`);
133
+ } else {
134
+ messages.push(`${WARN} slack.watch.${key}: using channel name — prefer "channel_id#${key}" format for reliability`);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ // Unknown channel keys
141
+ const knownChannelKeys = new Set(["enabled", "default", "telegram", "slack"]);
142
+ for (const key of Object.keys(ch)) {
143
+ if (!knownChannelKeys.has(key)) {
144
+ messages.push(`${WARN} unknown channel key: "channels.${key}" — did you mean "channels.slack"?`);
145
+ }
146
+ }
147
+ }
148
+
149
+ return { ok, messages };
150
+ }
package/src/mcp/server.ts CHANGED
@@ -88,7 +88,7 @@ export function createNiaMcpServer() {
88
88
  "add_watch_channel",
89
89
  "Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions) and act based on the behavior prompt. Requires daemon restart to take effect.",
90
90
  {
91
- name: z.string().describe("Slack channel name (without #), e.g. 'ask-kay-thread-notifications'"),
91
+ name: z.string().describe("Slack channel key as 'channel_id#channel_name', e.g. 'C1234567890#ask-kay-thread-notifications'"),
92
92
  behavior: z.string().describe("What to monitor and how to respond, e.g. 'Monitor thread notifications. Flag failures to #tech.'"),
93
93
  },
94
94
  async (args) => ({
@@ -99,7 +99,7 @@ export function createNiaMcpServer() {
99
99
  "remove_watch_channel",
100
100
  "Remove a Slack watch channel. Requires daemon restart to take effect.",
101
101
  {
102
- name: z.string().describe("Slack channel name to stop watching"),
102
+ name: z.string().describe("Slack channel key to stop watching (e.g. 'C1234567890#ask-kay-thread-notifications')"),
103
103
  },
104
104
  async (args) => ({
105
105
  content: [{ type: "text" as const, text: handlers.removeWatchChannel(args.name) }],
@@ -28,6 +28,7 @@
28
28
 
29
29
  ### Watch mode
30
30
  - Some channels are configured for proactive monitoring via `channels.slack.watch` in config.
31
+ - Watch channel keys use the format `channel_id#channel_name` (e.g. `C1234567890#ask-kay-thread-notifications`). The ID is used for matching; the name is for readability.
31
32
  - In watch channels, you receive ALL messages — not just @mentions. Messages are prefixed with `[Watch mode — #channel-name]` and a behavior prompt.
32
33
  - Follow the behavior prompt to decide what to do: flag issues, escalate, or stay quiet.
33
34
  - Use `[NO_REPLY]` for messages that don't need action. Most watch messages will be `[NO_REPLY]`.
@@ -59,7 +59,7 @@ Config reference:
59
59
  - `channels.slack.app_token` — Slack app token (xapp-...)
60
60
  - `channels.slack.channel_id` — default Slack channel for outbound
61
61
  - `channels.slack.dm_user_id` — auto-registered DM user
62
- - `channels.slack.watch` — per-channel proactive monitoring (see Watch mode in Slack docs)
62
+ - `channels.slack.watch` — per-channel proactive monitoring. Keys are `channel_id#channel_name` format.
63
63
  {{slackWatch}}
64
64
 
65
65
  ## Persona & Memory
package/src/utils/cli.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import * as readline from "readline";
2
2
 
3
+ // ANSI colors
4
+ export const DIM = "\x1b[2m";
5
+ export const BOLD = "\x1b[1m";
6
+ export const RESET = "\x1b[0m";
7
+ export const RED = "\x1b[31m";
8
+ export const GREEN = "\x1b[32m";
9
+ export const YELLOW = "\x1b[33m";
10
+ export const CYAN = "\x1b[36m";
11
+ export const CLEAR_LINE = "\x1b[2K\r";
12
+
13
+ // Icons
14
+ export const ICON_PASS = "\u2713";
15
+ export const ICON_FAIL = "\u2717";
16
+ export const ICON_WARN = "\u26A0";
17
+ export const ICON_RUNNING = "\u21bb";
18
+
19
+ // Spinner frames
20
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+
3
22
  export function fail(msg: string): never {
4
23
  console.log(msg);
5
24
  process.exit(1);