niahere 0.2.17 → 0.2.19
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/defaults/self/memory.md +1 -1
- package/defaults/self/rules.md +9 -0
- package/package.json +1 -1
- package/src/channels/index.ts +44 -17
- package/src/channels/slack.ts +17 -7
- package/src/channels/telegram.ts +6 -4
- package/src/chat/identity.ts +8 -2
- package/src/chat/repl.ts +5 -3
- package/src/cli/index.ts +32 -10
- package/src/commands/health-db.ts +12 -0
- package/src/commands/health.ts +119 -0
- package/src/commands/init.ts +2 -1
- package/src/core/daemon.ts +5 -2
- package/src/db/connection.ts +8 -1
- package/src/mcp/server.ts +20 -0
- package/src/mcp/tools.ts +29 -2
- package/src/prompts/environment.md +19 -5
- package/src/prompts/mode-job.md +3 -1
- package/src/utils/config.ts +13 -5
package/defaults/self/memory.md
CHANGED
|
@@ -8,7 +8,7 @@ Write here when:
|
|
|
8
8
|
- I learned a preference, habit, or pattern worth remembering
|
|
9
9
|
- A workaround was needed that future-me should know about
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Entries are grouped by date. Use `add_memory` tool to append, or edit directly.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Rules
|
|
2
|
+
|
|
3
|
+
Custom instructions and behavioral overrides. Nia reads this file at the start of every session — edits take effect immediately without restart.
|
|
4
|
+
|
|
5
|
+
Add rules here to change how Nia behaves. Examples:
|
|
6
|
+
|
|
7
|
+
- "When asked for a standup, keep it to 1-2 lines"
|
|
8
|
+
- "Always use bullet points for status updates"
|
|
9
|
+
- "Never send messages longer than 3 sentences in Slack channels"
|
package/package.json
CHANGED
package/src/channels/index.ts
CHANGED
|
@@ -12,33 +12,60 @@ export function registerAllChannels(): void {
|
|
|
12
12
|
registerChannel(() => createSlackChannel());
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export
|
|
16
|
-
|
|
15
|
+
export interface StartResult {
|
|
16
|
+
started: Channel[];
|
|
17
|
+
failed: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function startChannels(): Promise<StartResult> {
|
|
21
|
+
const pending = getFactories()
|
|
22
|
+
.map((factory) => factory())
|
|
23
|
+
.filter((ch): ch is Channel => ch !== null);
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
const channel = factory();
|
|
20
|
-
if (!channel) continue;
|
|
25
|
+
if (pending.length === 0) return { started: [], failed: [] };
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
const results = await Promise.allSettled(
|
|
28
|
+
pending.map(async (channel) => {
|
|
23
29
|
await channel.start();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
return channel;
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const started: Channel[] = [];
|
|
35
|
+
const failed: string[] = [];
|
|
36
|
+
for (let i = 0; i < results.length; i++) {
|
|
37
|
+
const result = results[i];
|
|
38
|
+
if (result.status === "fulfilled") {
|
|
39
|
+
started.push(result.value);
|
|
40
|
+
trackStarted(result.value);
|
|
41
|
+
log.info({ channel: result.value.name }, "channel started");
|
|
42
|
+
} else {
|
|
43
|
+
failed.push(pending[i].name);
|
|
44
|
+
log.error({ err: result.reason, channel: pending[i].name }, "channel failed to start");
|
|
29
45
|
}
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
if (failed.length > 0) {
|
|
49
|
+
log.warn({ failed }, "some channels failed to start");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { started, failed };
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
export async function stopChannels(channels: Channel[]): Promise<void> {
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
const results = await Promise.allSettled(
|
|
57
|
+
channels.map(async (channel) => {
|
|
38
58
|
await channel.stop();
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
59
|
+
return channel;
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < results.length; i++) {
|
|
64
|
+
const result = results[i];
|
|
65
|
+
if (result.status === "fulfilled") {
|
|
66
|
+
log.info({ channel: result.value.name }, "channel stopped");
|
|
67
|
+
} else {
|
|
68
|
+
log.error({ err: result.reason, channel: channels[i].name }, "channel failed to stop");
|
|
42
69
|
}
|
|
43
70
|
}
|
|
44
71
|
clearStarted();
|
package/src/channels/slack.ts
CHANGED
|
@@ -8,6 +8,11 @@ import { log } from "../utils/log";
|
|
|
8
8
|
import { getMcpServers } from "../mcp";
|
|
9
9
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
10
10
|
|
|
11
|
+
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
12
|
+
function cleanSentinel(text: string): string {
|
|
13
|
+
return text.replace(/`/g, "").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
class SlackChannel implements Channel {
|
|
12
17
|
name = "slack";
|
|
13
18
|
private app: App | null = null;
|
|
@@ -94,9 +99,11 @@ class SlackChannel implements Channel {
|
|
|
94
99
|
// daemon restarts (otherwise getState falls back to the old room).
|
|
95
100
|
await Session.create(`placeholder-${room}`, room);
|
|
96
101
|
|
|
102
|
+
log.info({ key, room }, "slack: creating chat engine");
|
|
97
103
|
const engine = await createChatEngine({ room, channel: "slack", resume: false, mcpServers: getMcpServers() });
|
|
98
104
|
const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
|
|
99
105
|
chats.set(key, state);
|
|
106
|
+
log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
|
|
100
107
|
return state;
|
|
101
108
|
}
|
|
102
109
|
|
|
@@ -106,6 +113,8 @@ class SlackChannel implements Channel {
|
|
|
106
113
|
fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
107
114
|
return;
|
|
108
115
|
}
|
|
116
|
+
const queued = state.lock !== Promise.resolve();
|
|
117
|
+
if (queued) log.debug({ key }, "slack: message queued behind active lock");
|
|
109
118
|
state.lock = state.lock.then(fn, fn);
|
|
110
119
|
}
|
|
111
120
|
|
|
@@ -345,10 +354,11 @@ class SlackChannel implements Channel {
|
|
|
345
354
|
|
|
346
355
|
const state = await getState(key);
|
|
347
356
|
|
|
348
|
-
// Add thinking reaction while processing
|
|
349
|
-
await client.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
|
|
350
|
-
|
|
351
357
|
withLock(key, async () => {
|
|
358
|
+
// Add thinking reaction inside the lock so cleanup is guaranteed
|
|
359
|
+
await client.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
|
|
360
|
+
.catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to add thinking reaction"));
|
|
361
|
+
|
|
352
362
|
try {
|
|
353
363
|
const { result } = await state.engine.send(text, {
|
|
354
364
|
onActivity(status) {
|
|
@@ -356,12 +366,10 @@ class SlackChannel implements Channel {
|
|
|
356
366
|
},
|
|
357
367
|
}, attachments);
|
|
358
368
|
|
|
359
|
-
await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
|
|
360
|
-
|
|
361
369
|
const reply = result.trim();
|
|
362
370
|
|
|
363
371
|
// [NO_REPLY] or empty = agent chose not to respond (thread judgement)
|
|
364
|
-
if (!reply || reply === "[NO_REPLY]") {
|
|
372
|
+
if (!reply || cleanSentinel(reply) === "[NO_REPLY]") {
|
|
365
373
|
log.info({ channel: msg.channel, key }, "slack: agent chose not to reply");
|
|
366
374
|
return;
|
|
367
375
|
}
|
|
@@ -378,7 +386,6 @@ class SlackChannel implements Channel {
|
|
|
378
386
|
|
|
379
387
|
log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
|
|
380
388
|
} catch (err) {
|
|
381
|
-
await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
|
|
382
389
|
|
|
383
390
|
const errText = err instanceof Error ? err.message : String(err);
|
|
384
391
|
log.error({ err, channel: msg.channel }, "slack message processing failed");
|
|
@@ -392,6 +399,9 @@ class SlackChannel implements Channel {
|
|
|
392
399
|
} else {
|
|
393
400
|
await say(`[error] ${errText}`);
|
|
394
401
|
}
|
|
402
|
+
} finally {
|
|
403
|
+
await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
|
|
404
|
+
.catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to remove thinking reaction"));
|
|
395
405
|
}
|
|
396
406
|
});
|
|
397
407
|
});
|
package/src/channels/telegram.ts
CHANGED
|
@@ -68,9 +68,11 @@ class TelegramChannel implements Channel {
|
|
|
68
68
|
const prefix = roomPrefix(chatId);
|
|
69
69
|
const idx = await Session.getLatestRoomIndex(prefix);
|
|
70
70
|
const room = roomName(chatId, idx);
|
|
71
|
+
log.info({ chatId, room }, "telegram: creating chat engine");
|
|
71
72
|
const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
|
|
72
73
|
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
73
74
|
chats.set(chatId, state);
|
|
75
|
+
log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
|
|
74
76
|
}
|
|
75
77
|
return state;
|
|
76
78
|
}
|
|
@@ -100,6 +102,8 @@ class TelegramChannel implements Channel {
|
|
|
100
102
|
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
105
|
+
const queued = state.lock !== Promise.resolve();
|
|
106
|
+
if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
|
|
103
107
|
state.lock = state.lock.then(fn, fn);
|
|
104
108
|
}
|
|
105
109
|
|
|
@@ -134,8 +138,6 @@ class TelegramChannel implements Channel {
|
|
|
134
138
|
try {
|
|
135
139
|
const { result } = await state.engine.send(text, {}, attachments);
|
|
136
140
|
|
|
137
|
-
clearInterval(typingInterval);
|
|
138
|
-
|
|
139
141
|
const reply = result.trim() || "(no response)";
|
|
140
142
|
try {
|
|
141
143
|
await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
|
|
@@ -145,11 +147,11 @@ class TelegramChannel implements Channel {
|
|
|
145
147
|
|
|
146
148
|
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
147
149
|
} catch (err) {
|
|
148
|
-
clearInterval(typingInterval);
|
|
149
|
-
|
|
150
150
|
const errText = err instanceof Error ? err.message : String(err);
|
|
151
151
|
log.error({ err, chatId }, "telegram message processing failed");
|
|
152
152
|
await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
|
|
153
|
+
} finally {
|
|
154
|
+
clearInterval(typingInterval);
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
package/src/chat/identity.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from "os";
|
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
5
|
import { getNiaHome, getPaths } from "../utils/paths";
|
|
6
6
|
import { getEnvironmentPrompt, getModePrompt, getChannelPrompt } from "../prompts";
|
|
7
|
+
import { log } from "../utils/log";
|
|
7
8
|
import type { Mode } from "../types";
|
|
8
9
|
|
|
9
10
|
// niahere project root (resolved from this file's location)
|
|
@@ -17,7 +18,7 @@ function loadFile(dir: string, name: string): string {
|
|
|
17
18
|
|
|
18
19
|
export function loadIdentity(): string {
|
|
19
20
|
const { selfDir } = getPaths();
|
|
20
|
-
const files = ["identity.md", "owner.md", "soul.md"];
|
|
21
|
+
const files = ["identity.md", "owner.md", "soul.md", "rules.md"];
|
|
21
22
|
return files.map((f) => loadFile(selfDir, f)).filter(Boolean).join("\n\n");
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -51,7 +52,12 @@ function scanSkills(): { name: string; description: string }[] {
|
|
|
51
52
|
if (!fmMatch) continue;
|
|
52
53
|
|
|
53
54
|
let meta: Record<string, unknown> = {};
|
|
54
|
-
try {
|
|
55
|
+
try {
|
|
56
|
+
meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
55
61
|
const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
|
|
56
62
|
|
|
57
63
|
if (seen.has(name)) continue;
|
package/src/chat/repl.ts
CHANGED
|
@@ -112,7 +112,7 @@ async function pickSession(): Promise<string | null> {
|
|
|
112
112
|
|
|
113
113
|
export type ChatMode = "continue" | "new" | "pick";
|
|
114
114
|
|
|
115
|
-
export async function startRepl(mode: ChatMode = "continue"): Promise<void> {
|
|
115
|
+
export async function startRepl(mode: ChatMode = "continue", simulateChannel?: string): Promise<void> {
|
|
116
116
|
try {
|
|
117
117
|
await runMigrations();
|
|
118
118
|
} catch (err) {
|
|
@@ -142,12 +142,14 @@ export async function startRepl(mode: ChatMode = "continue"): Promise<void> {
|
|
|
142
142
|
resume = true;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
const
|
|
145
|
+
const channel = simulateChannel || "terminal";
|
|
146
|
+
const engine = await createChatEngine({ room: "terminal", channel, resume, mcpServers: getMcpServers() });
|
|
146
147
|
|
|
147
148
|
// Welcome
|
|
148
149
|
const isResumed = engine.sessionId && resume;
|
|
149
150
|
const sessionNote = isResumed ? "resumed" : "new session";
|
|
150
|
-
|
|
151
|
+
const channelNote = simulateChannel ? ` as ${simulateChannel}` : "";
|
|
152
|
+
console.log(`\n${DIM}nia chat${channelNote}${RESET} ${DIM}(${sessionNote})${RESET}`);
|
|
151
153
|
console.log(`${DIM}type /exit to quit${RESET}\n`);
|
|
152
154
|
|
|
153
155
|
const rl = readline.createInterface({
|
package/src/cli/index.ts
CHANGED
|
@@ -115,6 +115,12 @@ switch (command) {
|
|
|
115
115
|
break;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
case "health": {
|
|
119
|
+
const { healthCommand } = await import("../commands/health");
|
|
120
|
+
await healthCommand();
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
118
124
|
case "restart": {
|
|
119
125
|
const { isServiceInstalled, restartService } = await import("../commands/service");
|
|
120
126
|
if (isServiceInstalled()) {
|
|
@@ -227,10 +233,23 @@ switch (command) {
|
|
|
227
233
|
case "logs": {
|
|
228
234
|
const { daemonLog } = getPaths();
|
|
229
235
|
if (!existsSync(daemonLog)) fail("No daemon log found. Is nia running?");
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
const logArgs = process.argv.slice(3);
|
|
237
|
+
const follow = logArgs.includes("-f") || logArgs.includes("--follow");
|
|
238
|
+
// --channel <name> filters logs by channel/component via grep
|
|
239
|
+
const chIdx = logArgs.indexOf("--channel");
|
|
240
|
+
const channelFilter = chIdx !== -1 && logArgs[chIdx + 1] ? logArgs[chIdx + 1] : null;
|
|
241
|
+
|
|
242
|
+
if (channelFilter) {
|
|
243
|
+
// Pipe through grep to filter by channel name in structured logs
|
|
244
|
+
const tailArgs = follow ? ["tail", "-f", daemonLog] : ["tail", "-200", daemonLog];
|
|
245
|
+
const tail = Bun.spawn(tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
|
|
246
|
+
const grep = Bun.spawn(["grep", "-i", channelFilter], { stdio: [tail.stdout, "inherit", "inherit"] });
|
|
247
|
+
await grep.exited;
|
|
248
|
+
} else {
|
|
249
|
+
const args = follow ? ["tail", "-f", daemonLog] : ["tail", "-50", daemonLog];
|
|
250
|
+
const proc = Bun.spawn(args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
251
|
+
await proc.exited;
|
|
252
|
+
}
|
|
234
253
|
break;
|
|
235
254
|
}
|
|
236
255
|
|
|
@@ -240,13 +259,15 @@ switch (command) {
|
|
|
240
259
|
}
|
|
241
260
|
|
|
242
261
|
case "chat": {
|
|
243
|
-
const
|
|
244
|
-
const mode = (
|
|
262
|
+
const chatArgs = process.argv.slice(3);
|
|
263
|
+
const mode = (chatArgs.includes("--new") || chatArgs.includes("-n"))
|
|
245
264
|
? "new" as const
|
|
246
|
-
: (
|
|
265
|
+
: (chatArgs.includes("--resume") || chatArgs.includes("-r"))
|
|
247
266
|
? "pick" as const
|
|
248
267
|
: "continue" as const;
|
|
249
|
-
|
|
268
|
+
const chIdx = chatArgs.indexOf("--channel");
|
|
269
|
+
const simChannel = chIdx !== -1 && chatArgs[chIdx + 1] ? chatArgs[chIdx + 1] : undefined;
|
|
270
|
+
await startRepl(mode, simChannel);
|
|
250
271
|
break;
|
|
251
272
|
}
|
|
252
273
|
|
|
@@ -392,10 +413,11 @@ switch (command) {
|
|
|
392
413
|
console.log(" start / stop — daemon + service control");
|
|
393
414
|
console.log(" restart — restart daemon");
|
|
394
415
|
console.log(" status [--json --rooms N --all] — show daemon, jobs, channels");
|
|
395
|
-
console.log("
|
|
416
|
+
console.log(" health — check daemon, db, channels, config");
|
|
417
|
+
console.log(" chat [--channel ch] — interactive chat (--channel simulates a channel)");
|
|
396
418
|
console.log(" run <prompt> — one-shot execution");
|
|
397
419
|
console.log(" history [room] — recent messages");
|
|
398
|
-
console.log(" logs [-f]
|
|
420
|
+
console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
|
|
399
421
|
console.log(" job <sub> — manage jobs");
|
|
400
422
|
console.log(" db <sub> — database setup/status/migrate");
|
|
401
423
|
console.log(" skills — list available skills");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
/** Quick DB connectivity check. Returns true if SELECT 1 succeeds. */
|
|
4
|
+
export async function checkDbHealth(url: string): Promise<boolean> {
|
|
5
|
+
const db = postgres(url, { onnotice: () => {}, connect_timeout: 5 });
|
|
6
|
+
try {
|
|
7
|
+
const [row] = await db`SELECT 1 as ok`;
|
|
8
|
+
return row?.ok === 1;
|
|
9
|
+
} finally {
|
|
10
|
+
await db.end();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, statSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { isRunning, readPid } from "../core/daemon";
|
|
4
|
+
import { getConfig, readRawConfig } from "../utils/config";
|
|
5
|
+
import { getPaths } from "../utils/paths";
|
|
6
|
+
import { errMsg } from "../utils/errors";
|
|
7
|
+
import { localTime } from "../utils/time";
|
|
8
|
+
|
|
9
|
+
type Check = { name: string; status: "ok" | "warn" | "fail"; detail: string };
|
|
10
|
+
|
|
11
|
+
function push(checks: Check[], name: string, status: Check["status"], detail: string): void {
|
|
12
|
+
checks.push({ name, status, detail });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function healthCommand(): Promise<void> {
|
|
16
|
+
const checks: Check[] = [];
|
|
17
|
+
const paths = getPaths();
|
|
18
|
+
|
|
19
|
+
// 1. Daemon
|
|
20
|
+
const pid = readPid();
|
|
21
|
+
if (isRunning()) {
|
|
22
|
+
push(checks, "daemon", "ok", "running (pid: " + pid + ")");
|
|
23
|
+
} else if (pid) {
|
|
24
|
+
push(checks, "daemon", "fail", "stale pid file (pid: " + pid + ", not running)");
|
|
25
|
+
} else {
|
|
26
|
+
push(checks, "daemon", "warn", "not running");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Config
|
|
30
|
+
if (existsSync(paths.config)) {
|
|
31
|
+
const raw = readRawConfig();
|
|
32
|
+
push(checks, "config", "ok", Object.keys(raw).length + " keys loaded");
|
|
33
|
+
} else {
|
|
34
|
+
push(checks, "config", "fail", "missing (" + paths.config + ")");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. Database
|
|
38
|
+
try {
|
|
39
|
+
const config = getConfig();
|
|
40
|
+
if (!config.database_url || !config.database_url.startsWith("postgres")) {
|
|
41
|
+
push(checks, "database", "fail", 'invalid url: "' + (config.database_url || "(empty)") + '"');
|
|
42
|
+
} else {
|
|
43
|
+
const { checkDbHealth } = await import("./health-db");
|
|
44
|
+
const ok = await checkDbHealth(config.database_url);
|
|
45
|
+
push(checks, "database", ok ? "ok" : "fail", config.database_url.replace(/\/\/.*@/, "//***@"));
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
push(checks, "database", "fail", errMsg(err));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. Channels
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
if (!config.channels.enabled) {
|
|
54
|
+
push(checks, "channels", "warn", "disabled");
|
|
55
|
+
} else {
|
|
56
|
+
const chans: string[] = [];
|
|
57
|
+
if (config.channels.telegram.bot_token) chans.push("telegram");
|
|
58
|
+
if (config.channels.slack.bot_token && config.channels.slack.app_token) chans.push("slack");
|
|
59
|
+
if (chans.length > 0) {
|
|
60
|
+
push(checks, "channels", "ok", "configured: " + chans.join(", "));
|
|
61
|
+
} else {
|
|
62
|
+
push(checks, "channels", "warn", "enabled but no tokens configured");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 5. API keys
|
|
67
|
+
const geminiKey = config.gemini_api_key;
|
|
68
|
+
const rawConfig = readRawConfig();
|
|
69
|
+
const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
|
|
70
|
+
const apiKeys: string[] = [];
|
|
71
|
+
if (geminiKey) apiKeys.push("gemini");
|
|
72
|
+
if (openaiKey) apiKeys.push("openai");
|
|
73
|
+
push(checks, "api keys", apiKeys.length > 0 ? "ok" : "warn",
|
|
74
|
+
apiKeys.length > 0 ? apiKeys.join(", ") : "none configured");
|
|
75
|
+
|
|
76
|
+
// 6. Persona files
|
|
77
|
+
const personaFiles = ["identity.md", "owner.md", "soul.md"];
|
|
78
|
+
const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
|
|
79
|
+
if (missing.length === 0) {
|
|
80
|
+
push(checks, "persona", "ok", "all files present");
|
|
81
|
+
} else {
|
|
82
|
+
push(checks, "persona", "warn", "missing: " + missing.join(", "));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 7. Daemon log
|
|
86
|
+
if (existsSync(paths.daemonLog)) {
|
|
87
|
+
const stat = statSync(paths.daemonLog);
|
|
88
|
+
const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
|
|
89
|
+
const lastMod = localTime(stat.mtime);
|
|
90
|
+
push(checks, "logs", stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
|
|
91
|
+
sizeMb + " MB, last write: " + lastMod);
|
|
92
|
+
} else {
|
|
93
|
+
push(checks, "logs", "warn", "no log file");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 8. Bun version
|
|
97
|
+
const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
|
|
98
|
+
push(checks, "bun", "ok", "v" + bunVersion);
|
|
99
|
+
|
|
100
|
+
// Output
|
|
101
|
+
const GREEN = "\x1b[32m";
|
|
102
|
+
const YELLOW = "\x1b[33m";
|
|
103
|
+
const RED = "\x1b[31m";
|
|
104
|
+
const RST = "\x1b[0m";
|
|
105
|
+
const icons: Record<string, string> = {
|
|
106
|
+
ok: GREEN + "\u2713" + RST,
|
|
107
|
+
warn: YELLOW + "!" + RST,
|
|
108
|
+
fail: RED + "\u2717" + RST,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
console.log();
|
|
112
|
+
for (const c of checks) {
|
|
113
|
+
console.log(" " + icons[c.status] + " " + c.name.padEnd(12) + " " + c.detail);
|
|
114
|
+
}
|
|
115
|
+
console.log();
|
|
116
|
+
|
|
117
|
+
const failCount = checks.filter((c) => c.status === "fail").length;
|
|
118
|
+
if (failCount > 0) process.exit(1);
|
|
119
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -427,8 +427,9 @@ export async function runInit(): Promise<void> {
|
|
|
427
427
|
console.log(` \u2713 wrote ${selfFile("owner.md")}`);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
// Soul and memory — only create if missing (user may have customized)
|
|
430
|
+
// Soul, rules, and memory — only create if missing (user may have customized)
|
|
431
431
|
writeIfMissing(selfFile("soul.md"), loadTemplate("soul.md", vars), selfFile("soul.md"));
|
|
432
|
+
writeIfMissing(selfFile("rules.md"), loadTemplate("rules.md", vars), selfFile("rules.md"));
|
|
432
433
|
writeIfMissing(selfFile("memory.md"), loadTemplate("memory.md", vars), selfFile("memory.md"));
|
|
433
434
|
|
|
434
435
|
resetConfig();
|
package/src/core/daemon.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { getPaths } from "../utils/paths";
|
|
4
4
|
import { getConfig } from "../utils/config";
|
|
@@ -46,6 +46,7 @@ export function isRunning(): boolean {
|
|
|
46
46
|
process.kill(pid, 0);
|
|
47
47
|
return true;
|
|
48
48
|
} catch {
|
|
49
|
+
log.warn({ stalePid: pid }, "removing stale pid file (process not running)");
|
|
49
50
|
removePid();
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
@@ -73,6 +74,7 @@ export function startDaemon(): number {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
proc.unref();
|
|
77
|
+
closeSync(logFd); // Child owns the fd now; close parent's copy to prevent leak
|
|
76
78
|
const pid = proc.pid;
|
|
77
79
|
writePid(pid);
|
|
78
80
|
return pid;
|
|
@@ -211,7 +213,8 @@ export async function runDaemon(): Promise<void> {
|
|
|
211
213
|
let channels: Channel[] = [];
|
|
212
214
|
const config = getConfig();
|
|
213
215
|
if (config.channels.enabled) {
|
|
214
|
-
|
|
216
|
+
const result = await startChannels();
|
|
217
|
+
channels = result.started;
|
|
215
218
|
} else {
|
|
216
219
|
log.info("channels disabled (channels_enabled: false)");
|
|
217
220
|
}
|
package/src/db/connection.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import postgres from "postgres";
|
|
2
2
|
import { getConfig } from "../utils/config";
|
|
3
|
+
import { log } from "../utils/log";
|
|
3
4
|
|
|
4
5
|
let _sql: ReturnType<typeof postgres> | null = null;
|
|
5
6
|
|
|
6
7
|
export function getSql(): ReturnType<typeof postgres> {
|
|
7
8
|
if (!_sql) {
|
|
8
|
-
|
|
9
|
+
const url = getConfig().database_url;
|
|
10
|
+
if (!url || !url.startsWith("postgres")) {
|
|
11
|
+
const msg = `Invalid database_url: "${url || "(empty)"}". Expected a postgres:// connection string.`;
|
|
12
|
+
log.error(msg);
|
|
13
|
+
throw new Error(msg);
|
|
14
|
+
}
|
|
15
|
+
_sql = postgres(url, { onnotice: () => {} });
|
|
9
16
|
}
|
|
10
17
|
return _sql;
|
|
11
18
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -84,6 +84,26 @@ export function createNiaMcpServer() {
|
|
|
84
84
|
content: [{ type: "text" as const, text: await handlers.listMessages(args.limit, args.room) }],
|
|
85
85
|
}),
|
|
86
86
|
),
|
|
87
|
+
tool(
|
|
88
|
+
"add_rule",
|
|
89
|
+
"Add a behavioral rule. Rules are loaded into every session and take effect without restart. Use for 'from now on' / 'always' / 'never' type instructions.",
|
|
90
|
+
{
|
|
91
|
+
rule: z.string().describe("The rule to add (e.g. 'stamp updates: 1-2 lines max, no preamble')"),
|
|
92
|
+
},
|
|
93
|
+
async (args) => ({
|
|
94
|
+
content: [{ type: "text" as const, text: handlers.addRule(args.rule) }],
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
tool(
|
|
98
|
+
"add_memory",
|
|
99
|
+
"Save a factual memory for future reference. Memories are read on demand, not loaded automatically. Use for things learned, preferences discovered, or context worth keeping.",
|
|
100
|
+
{
|
|
101
|
+
entry: z.string().describe("What to remember (e.g. 'Aman prefers short Slack messages in #tech')"),
|
|
102
|
+
},
|
|
103
|
+
async (args) => ({
|
|
104
|
+
content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
87
107
|
],
|
|
88
108
|
});
|
|
89
109
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
|
|
2
2
|
import type { ScheduleType } from "../types";
|
|
3
|
-
import { basename } from "path";
|
|
3
|
+
import { basename, join } from "path";
|
|
4
4
|
import { Job, Message, Session } from "../db/models";
|
|
5
5
|
import { computeInitialNextRun } from "../core/scheduler";
|
|
6
6
|
import { getConfig } from "../utils/config";
|
|
7
|
+
import { getPaths } from "../utils/paths";
|
|
7
8
|
import { getChannel } from "../channels/registry";
|
|
8
9
|
import { log } from "../utils/log";
|
|
9
10
|
import { classifyMime } from "../utils/attachment";
|
|
@@ -219,3 +220,29 @@ export async function listMessages(limit = 20, room?: string): Promise<string> {
|
|
|
219
220
|
if (messages.length === 0) return "No messages found.";
|
|
220
221
|
return JSON.stringify(messages, null, 2);
|
|
221
222
|
}
|
|
223
|
+
|
|
224
|
+
export function addRule(rule: string): string {
|
|
225
|
+
const { selfDir } = getPaths();
|
|
226
|
+
const rulesPath = join(selfDir, "rules.md");
|
|
227
|
+
const line = `\n- ${rule}\n`;
|
|
228
|
+
appendFileSync(rulesPath, line, "utf8");
|
|
229
|
+
return `Rule added to rules.md. Takes effect on next new session.`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function addMemory(entry: string): string {
|
|
233
|
+
const { selfDir } = getPaths();
|
|
234
|
+
const memoryPath = join(selfDir, "memory.md");
|
|
235
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
236
|
+
const header = `\n## ${date}`;
|
|
237
|
+
|
|
238
|
+
const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
|
|
239
|
+
if (existing.includes(header)) {
|
|
240
|
+
// Append under existing date header
|
|
241
|
+
const updated = existing.replace(header, `${header}\n- ${entry}`);
|
|
242
|
+
writeFileSync(memoryPath, updated, "utf8");
|
|
243
|
+
} else {
|
|
244
|
+
// New date section
|
|
245
|
+
appendFileSync(memoryPath, `${header}\n- ${entry}\n`, "utf8");
|
|
246
|
+
}
|
|
247
|
+
return `Memory saved.`;
|
|
248
|
+
}
|
|
@@ -22,6 +22,8 @@ You have MCP tools for managing jobs directly — no need for shell commands:
|
|
|
22
22
|
- **run_job** — trigger a job to run immediately
|
|
23
23
|
- **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
|
|
24
24
|
- **list_messages** — read recent chat history
|
|
25
|
+
- **add_rule** — save a behavioral rule (loaded into every session, no restart needed). Use when told "from now on", "always", "never", or "remember to always..."
|
|
26
|
+
- **add_memory** — save a factual memory (read on demand). Use when told "remember that...", or when you learn something surprising worth keeping
|
|
25
27
|
|
|
26
28
|
Active hours: {{activeStart}}–{{activeEnd}} ({{timezone}}). Jobs respect this; crons (always=true) don't.
|
|
27
29
|
|
|
@@ -62,10 +64,22 @@ Your persona files live in {{selfDir}}/:
|
|
|
62
64
|
- `identity.md` — your personality and voice
|
|
63
65
|
- `owner.md` — info about who runs you
|
|
64
66
|
- `soul.md` — how you work
|
|
65
|
-
- `
|
|
67
|
+
- `rules.md` — behavioral overrides and custom instructions (loaded into every session, hot-reloads without restart)
|
|
68
|
+
- `memory.md` — persistent learnings (read on demand, not loaded automatically)
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
### Rules vs Memory
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
+
**Rules** (`rules.md`) = instructions for how to behave. Loaded into every session automatically.
|
|
73
|
+
- "stamp updates should be 1-2 lines max"
|
|
74
|
+
- "never send long messages in #tech"
|
|
75
|
+
- Use `add_rule` tool to add new rules, or edit the file directly.
|
|
76
|
+
|
|
77
|
+
**Memory** (`memory.md`) = facts and context. Read on demand when relevant.
|
|
78
|
+
- "2026-03-13: DB was down, Telegram send failed"
|
|
79
|
+
- "Aman prefers terminal over Slack for debugging"
|
|
80
|
+
- Use `add_memory` tool to save new memories.
|
|
81
|
+
|
|
82
|
+
**Which to use?**
|
|
83
|
+
- "From now on, do X" → rule
|
|
84
|
+
- "Remember that X happened" / "I prefer X" → memory
|
|
85
|
+
- If unsure, ask.
|
package/src/prompts/mode-job.md
CHANGED
|
@@ -3,4 +3,6 @@
|
|
|
3
3
|
You are executing a scheduled job. Be terse — execute the task and report the result. No small talk.
|
|
4
4
|
|
|
5
5
|
- State the outcome first, then supporting details if needed.
|
|
6
|
-
- If the job failed, report what went wrong clearly.
|
|
6
|
+
- If the job failed, report what went wrong clearly.
|
|
7
|
+
- When sending results to a channel (Slack, Telegram), keep it minimal. The recipient knows the context — just deliver the key info.
|
|
8
|
+
- Check rules.md for job-specific output instructions (e.g. brevity rules for specific jobs like stamp/standup).
|
package/src/utils/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import { dirname } from "path";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { getPaths } from "./paths";
|
|
5
5
|
import { log } from "./log";
|
|
@@ -179,12 +179,20 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
/** Deep-merge fields into config.yaml and write back. */
|
|
182
|
+
/** Deep-merge fields into config.yaml and write back atomically. */
|
|
183
183
|
export function updateRawConfig(fields: Record<string, unknown>): void {
|
|
184
184
|
const { config } = getPaths();
|
|
185
185
|
const raw = readRawConfig();
|
|
186
186
|
deepMerge(raw, fields);
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
const dir = dirname(config);
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
// Back up current config before overwriting
|
|
190
|
+
if (existsSync(config)) {
|
|
191
|
+
copyFileSync(config, join(dir, "config.yaml.bak"));
|
|
192
|
+
}
|
|
193
|
+
// Write to temp file then rename for atomic update (prevents corruption on crash)
|
|
194
|
+
const tmp = join(dir, `.config.yaml.tmp.${process.pid}`);
|
|
195
|
+
writeFileSync(tmp, yaml.dump(raw, { lineWidth: -1 }));
|
|
196
|
+
renameSync(tmp, config);
|
|
189
197
|
resetConfig();
|
|
190
198
|
}
|