niahere 0.2.22 → 0.2.23

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.23",
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/cli/index.ts CHANGED
@@ -412,6 +412,14 @@ switch (command) {
412
412
  process.exit(exitCode);
413
413
  }
414
414
 
415
+ case "validate": {
416
+ const { validateConfig } = await import("../commands/validate");
417
+ const result = validateConfig();
418
+ for (const msg of result.messages) console.log(` ${msg}`);
419
+ console.log(result.ok ? "\nConfig is valid." : "\nConfig has errors.");
420
+ process.exit(result.ok ? 0 : 1);
421
+ }
422
+
415
423
  case "init": {
416
424
  const { runInit } = await import("../commands/init");
417
425
  await runInit();
@@ -434,6 +442,7 @@ switch (command) {
434
442
  console.log(" memory [show|reset] — view or reset memory.md");
435
443
  console.log(" db <sub> — database setup/status/migrate");
436
444
  console.log(" skills — list available skills");
445
+ console.log(" validate — validate config.yaml");
437
446
  console.log(" config <sub> — get/set/list config values");
438
447
  console.log(" send [-c ch] <msg> — send a message via channel");
439
448
  console.log(" telegram <token> — configure telegram");
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import yaml from "js-yaml";
3
+ import { getPaths } from "../utils/paths";
4
+
5
+ const PASS = "\u2713";
6
+ const FAIL = "\u2717";
7
+ const WARN = "\u26A0";
8
+
9
+ interface Result {
10
+ ok: boolean;
11
+ messages: string[];
12
+ }
13
+
14
+ function check(label: string, fn: () => string | null): { icon: string; label: string; detail?: string } {
15
+ const err = fn();
16
+ if (err) return { icon: FAIL, label, detail: err };
17
+ return { icon: PASS, label };
18
+ }
19
+
20
+ function warn(label: string, detail: string): { icon: string; label: string; detail?: string } {
21
+ return { icon: WARN, label, detail };
22
+ }
23
+
24
+ export function validateConfig(): Result {
25
+ const { config: configPath } = getPaths();
26
+ const messages: string[] = [];
27
+ let ok = true;
28
+
29
+ // File exists
30
+ if (!existsSync(configPath)) {
31
+ return { ok: false, messages: [`${FAIL} config.yaml not found at ${configPath}`] };
32
+ }
33
+
34
+ // Valid YAML
35
+ let raw: Record<string, unknown>;
36
+ try {
37
+ const parsed = yaml.load(readFileSync(configPath, "utf8"));
38
+ if (!parsed || typeof parsed !== "object") {
39
+ return { ok: false, messages: [`${FAIL} config.yaml is empty or not an object`] };
40
+ }
41
+ raw = parsed as Record<string, unknown>;
42
+ messages.push(`${PASS} valid YAML`);
43
+ } catch (err) {
44
+ return { ok: false, messages: [`${FAIL} invalid YAML: ${(err as Error).message}`] };
45
+ }
46
+
47
+ // Timezone
48
+ if (raw.timezone) {
49
+ try {
50
+ Intl.DateTimeFormat(undefined, { timeZone: raw.timezone as string });
51
+ messages.push(`${PASS} timezone: ${raw.timezone}`);
52
+ } catch {
53
+ messages.push(`${FAIL} invalid timezone: ${raw.timezone}`);
54
+ ok = false;
55
+ }
56
+ }
57
+
58
+ // Active hours
59
+ const ah = raw.active_hours as Record<string, string> | undefined;
60
+ if (ah) {
61
+ const timeRe = /^\d{2}:\d{2}$/;
62
+ if (ah.start && !timeRe.test(ah.start)) {
63
+ messages.push(`${FAIL} active_hours.start invalid: "${ah.start}" (expected HH:MM)`);
64
+ ok = false;
65
+ } else if (ah.start) {
66
+ messages.push(`${PASS} active_hours: ${ah.start}–${ah.end || "?"}`);
67
+ }
68
+ if (ah.end && !timeRe.test(ah.end)) {
69
+ messages.push(`${FAIL} active_hours.end invalid: "${ah.end}" (expected HH:MM)`);
70
+ ok = false;
71
+ }
72
+ }
73
+
74
+ // Database URL
75
+ const dbUrl = (process.env.DATABASE_URL || raw.database_url) as string | undefined;
76
+ if (dbUrl && dbUrl.startsWith("postgres")) {
77
+ messages.push(`${PASS} database_url set`);
78
+ } else if (!dbUrl) {
79
+ messages.push(`${WARN} database_url not set (will use default)`);
80
+ }
81
+
82
+ // Runner
83
+ const runner = raw.runner as string | undefined;
84
+ if (runner && runner !== "claude" && runner !== "codex") {
85
+ messages.push(`${FAIL} runner must be "claude" or "codex", got "${runner}"`);
86
+ ok = false;
87
+ } else if (runner) {
88
+ messages.push(`${PASS} runner: ${runner}`);
89
+ }
90
+
91
+ // Channels
92
+ const ch = raw.channels as Record<string, unknown> | undefined;
93
+ if (ch) {
94
+ // Telegram
95
+ const tg = ch.telegram as Record<string, unknown> | undefined;
96
+ if (tg) {
97
+ if (tg.bot_token) {
98
+ messages.push(`${PASS} telegram.bot_token set`);
99
+ } else {
100
+ messages.push(`${WARN} telegram.bot_token missing — telegram won't start`);
101
+ }
102
+ }
103
+
104
+ // Slack
105
+ const sl = ch.slack as Record<string, unknown> | undefined;
106
+ if (sl) {
107
+ if (!sl.bot_token) {
108
+ messages.push(`${WARN} slack.bot_token missing — slack won't start`);
109
+ } else {
110
+ messages.push(`${PASS} slack.bot_token set`);
111
+ }
112
+ if (!sl.app_token) {
113
+ messages.push(`${WARN} slack.app_token missing — slack won't start (Socket Mode requires app_token)`);
114
+ } else {
115
+ messages.push(`${PASS} slack.app_token set`);
116
+ }
117
+
118
+ // Watch channels
119
+ const watch = sl.watch as Record<string, unknown> | undefined;
120
+ if (watch) {
121
+ for (const [key, val] of Object.entries(watch)) {
122
+ if (!val || typeof val !== "object") {
123
+ messages.push(`${FAIL} slack.watch.${key}: must be an object with "behavior" field`);
124
+ ok = false;
125
+ continue;
126
+ }
127
+ const behavior = (val as Record<string, unknown>).behavior;
128
+ if (typeof behavior !== "string" || !behavior.trim()) {
129
+ messages.push(`${FAIL} slack.watch.${key}: missing "behavior" string`);
130
+ ok = false;
131
+ continue;
132
+ }
133
+ const hasId = key.includes("#");
134
+ if (hasId) {
135
+ messages.push(`${PASS} slack.watch: ${key}`);
136
+ } else {
137
+ messages.push(`${WARN} slack.watch.${key}: using channel name — prefer "channel_id#${key}" format for reliability`);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // Unknown channel keys
144
+ const knownChannelKeys = new Set(["enabled", "default", "telegram", "slack"]);
145
+ for (const key of Object.keys(ch)) {
146
+ if (!knownChannelKeys.has(key)) {
147
+ messages.push(`${WARN} unknown channel key: "channels.${key}" — did you mean "channels.slack"?`);
148
+ }
149
+ }
150
+ }
151
+
152
+ return { ok, messages };
153
+ }
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