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 +1 -1
- package/src/channels/slack.ts +37 -28
- package/src/cli/index.ts +9 -0
- package/src/commands/validate.ts +153 -0
- package/src/mcp/server.ts +2 -2
- package/src/prompts/channel-slack.md +1 -0
- package/src/prompts/environment.md +1 -1
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -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
|
-
//
|
|
512
|
-
const
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
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
|
|
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
|
|
62
|
+
- `channels.slack.watch` — per-channel proactive monitoring. Keys are `channel_id#channel_name` format.
|
|
63
63
|
{{slackWatch}}
|
|
64
64
|
|
|
65
65
|
## Persona & Memory
|