niahere 0.2.38 → 0.2.41

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.38",
3
+ "version": "0.2.41",
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": {
@@ -48,6 +48,8 @@ function buildPlist(): string {
48
48
  <key>SuccessfulExit</key>
49
49
  <false/>
50
50
  </dict>
51
+ <key>ThrottleInterval</key>
52
+ <integer>10</integer>
51
53
  <key>StandardOutPath</key>
52
54
  <string>${paths.daemonLog}</string>
53
55
  <key>StandardErrorPath</key>
@@ -86,10 +88,10 @@ async function uninstallLaunchd(): Promise<void> {
86
88
  const path = plistPath();
87
89
  if (!existsSync(path)) return;
88
90
 
91
+ // Unload to stop the process and disable KeepAlive respawn.
92
+ // Keep the plist file so RunAtLoad starts the daemon on next login.
89
93
  const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
90
94
  await unload.exited;
91
-
92
- try { unlinkSync(path); } catch { /* already gone */ }
93
95
  }
94
96
 
95
97
  function isLaunchdInstalled(): boolean {
package/src/core/alive.ts CHANGED
@@ -4,11 +4,72 @@ import { getSql, closeDb } from "../db/connection";
4
4
  import { getFailures, type Check } from "./health";
5
5
 
6
6
  const HEARTBEAT_INTERVAL = 60_000; // 60s
7
+ const PG_DATA_DIRS = [
8
+ "/opt/homebrew/var/postgresql@18",
9
+ "/opt/homebrew/var/postgresql@17",
10
+ "/opt/homebrew/var/postgres",
11
+ ];
7
12
 
8
13
  let timer: ReturnType<typeof setInterval> | null = null;
9
14
  let lastFailures: string[] = [];
10
15
  let recoveryAttempted = false;
11
16
 
17
+ /** Deterministic Postgres recovery: remove stale PID file + restart service. */
18
+ async function recoverPostgres(): Promise<boolean> {
19
+ const ready = Bun.spawnSync(["pg_isready"]);
20
+ if (ready.exitCode === 0) return true; // already up
21
+
22
+ log.info("alive: postgres not ready, attempting deterministic recovery");
23
+
24
+ // Find and remove stale postmaster.pid
25
+ const { existsSync, unlinkSync, readFileSync } = await import("fs");
26
+ for (const dir of PG_DATA_DIRS) {
27
+ const pidFile = `${dir}/postmaster.pid`;
28
+ if (!existsSync(pidFile)) continue;
29
+
30
+ // Read the PID from line 1 and check if it's actually a postgres process
31
+ try {
32
+ const pid = parseInt(readFileSync(pidFile, "utf8").split("\n")[0], 10);
33
+ if (!isNaN(pid)) {
34
+ const check = Bun.spawnSync(["ps", "-p", String(pid), "-o", "comm="]);
35
+ const comm = new TextDecoder().decode(check.stdout).trim();
36
+ if (check.exitCode !== 0 || !comm.includes("postgres")) {
37
+ log.info({ pidFile, stalePid: pid, actualProcess: comm || "dead" }, "alive: removing stale postmaster.pid");
38
+ unlinkSync(pidFile);
39
+ }
40
+ }
41
+ } catch (err) {
42
+ log.warn({ err, pidFile }, "alive: could not inspect postmaster.pid");
43
+ }
44
+ }
45
+
46
+ // Restart the service
47
+ if (process.platform === "darwin") {
48
+ // Try common brew postgresql service names
49
+ for (const svc of ["postgresql@18", "postgresql@17", "postgresql"]) {
50
+ const result = Bun.spawnSync(["brew", "services", "start", svc]);
51
+ if (result.exitCode === 0) {
52
+ log.info({ service: svc }, "alive: brew service start issued");
53
+ break;
54
+ }
55
+ }
56
+ } else {
57
+ Bun.spawnSync(["systemctl", "start", "postgresql"]);
58
+ }
59
+
60
+ // Wait briefly for postgres to come up
61
+ await new Promise((r) => setTimeout(r, 3000));
62
+
63
+ const check = Bun.spawnSync(["pg_isready"]);
64
+ if (check.exitCode === 0) {
65
+ log.info("alive: postgres recovered via deterministic fix");
66
+ return true;
67
+ }
68
+
69
+ log.warn("alive: deterministic postgres recovery failed");
70
+ return false;
71
+ }
72
+
12
73
  async function attemptDbReconnect(): Promise<boolean> {
13
74
  try {
14
75
  await closeDb();
@@ -138,10 +199,28 @@ async function heartbeat(): Promise<void> {
138
199
  }
139
200
  }
140
201
 
141
- // Run recovery agent once per outage
202
+ // Deterministic postgres recovery before LLM agent
203
+ if (failureNames.includes("database") && !recoveryAttempted) {
204
+ const pgFixed = await recoverPostgres();
205
+ if (pgFixed) {
206
+ const reconnected = await attemptDbReconnect();
207
+ if (reconnected) {
208
+ const remaining = await getFailures();
209
+ if (remaining.length === 0) {
210
+ log.info("alive: postgres recovered (deterministic fix, no LLM needed)");
211
+ await notifyUser("Postgres was down (stale PID). Fixed automatically — no LLM agent needed.");
212
+ lastFailures = [];
213
+ recoveryAttempted = false;
214
+ return;
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // Run LLM recovery agent once per outage (fallback for non-trivial issues)
142
221
  if (!recoveryAttempted) {
143
222
  recoveryAttempted = true;
144
- log.info({ failures: failureNames }, "alive: running recovery agent");
223
+ log.info({ failures: failureNames }, "alive: running LLM recovery agent");
145
224
 
146
225
  const { recovered, report } = await runRecoveryAgent(failures);
147
226
 
@@ -121,7 +121,7 @@ function waitForExit(timeoutMs: number): void {
121
121
  /** Return PIDs of running daemon processes (excluding ourselves). */
122
122
  export function findDaemonPids(): number[] {
123
123
  try {
124
- const result = Bun.spawnSync(["pgrep", "-f", "niahere/src/cli.* run$"]);
124
+ const result = Bun.spawnSync(["pgrep", "-f", "src/cli\\.ts run$"]);
125
125
  const stdout = new TextDecoder().decode(result.stdout).trim();
126
126
  if (!stdout) return [];
127
127
  return stdout.split("\n")
@@ -150,16 +150,19 @@ export async function runDaemon(): Promise<void> {
150
150
  delete process.env.CLAUDE_CODE_ENTRYPOINT;
151
151
  delete process.env.CLAUDE_AGENT_SDK_VERSION;
152
152
 
153
- // Startup guard: if another daemon is alive, exit immediately
153
+ // Startup guard: if another nia daemon is alive, exit immediately.
154
+ // Use pgrep (via findDaemonPids) instead of kill(pid,0) to verify the
155
+ // PID is actually a nia process — not a recycled OS PID from something else.
154
156
  const existingPid = readPid();
155
157
  if (existingPid !== null && existingPid !== process.pid) {
156
- try {
157
- process.kill(existingPid, 0); // Check if alive
158
+ const aliveDaemons = findDaemonPids();
159
+ if (aliveDaemons.includes(existingPid)) {
158
160
  log.debug({ existingPid, myPid: process.pid }, "another daemon is already running, exiting");
159
161
  process.exit(0);
160
- } catch {
161
- // Dead PID in pidfile — safe to take over
162
162
  }
163
+ // PID in file is stale (dead or recycled by OS) — safe to take over
164
+ log.warn({ stalePid: existingPid }, "taking over from stale pid");
165
+ removePid();
163
166
  }
164
167
 
165
168
  // Crash handlers — ensure PID cleanup and logging on unhandled errors.
@@ -247,13 +247,15 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
247
247
  };
248
248
  appendAudit(auditEntry);
249
249
 
250
- state[job.name] = {
250
+ // Re-read state to avoid clobbering concurrent job updates
251
+ const freshState = { ...readState() };
252
+ freshState[job.name] = {
251
253
  lastRun: timestamp,
252
254
  status: result.status,
253
255
  duration_ms: result.duration_ms,
254
256
  error: result.error,
255
257
  };
256
- writeState(state);
258
+ writeState(freshState);
257
259
 
258
260
  return result;
259
261
  } catch (err) {
@@ -278,13 +280,15 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
278
280
  error: errorMsg,
279
281
  });
280
282
 
281
- state[job.name] = {
283
+ // Re-read state to avoid clobbering concurrent job updates
284
+ const freshState = { ...readState() };
285
+ freshState[job.name] = {
282
286
  lastRun: timestamp,
283
287
  status: "error",
284
288
  duration_ms,
285
289
  error: errorMsg,
286
290
  };
287
- writeState(state);
291
+ writeState(freshState);
288
292
 
289
293
  return result;
290
294
  }
@@ -1,5 +1,5 @@
1
1
  import { getSql } from "../connection";
2
- import type { SaveMessageParams, RoomStats, RecentMessage } from "../../types";
2
+ import type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "../../types";
3
3
 
4
4
  export async function save(params: SaveMessageParams): Promise<void> {
5
5
  const sql = getSql();
@@ -29,6 +29,45 @@ export async function getRecent(limit = 20, room?: string): Promise<RecentMessag
29
29
  }));
30
30
  }
31
31
 
32
+ export async function search(query: string, limit = 20, room?: string): Promise<SearchResult[]> {
33
+ const sql = getSql();
34
+ const pattern = `%${query}%`;
35
+ const rows = room
36
+ ? await sql`
37
+ SELECT session_id, room, sender, content, created_at
38
+ FROM messages WHERE content ILIKE ${pattern} AND room = ${room}
39
+ ORDER BY created_at DESC LIMIT ${limit}
40
+ `
41
+ : await sql`
42
+ SELECT session_id, room, sender, content, created_at
43
+ FROM messages WHERE content ILIKE ${pattern}
44
+ ORDER BY created_at DESC LIMIT ${limit}
45
+ `;
46
+ return rows.map((r) => ({
47
+ sessionId: r.session_id,
48
+ room: r.room,
49
+ sender: r.sender,
50
+ content: r.content,
51
+ createdAt: String(r.created_at),
52
+ }));
53
+ }
54
+
55
+ export async function getBySession(sessionId: string): Promise<SessionMessage[]> {
56
+ const sql = getSql();
57
+ const rows = await sql`
58
+ SELECT room, sender, content, is_from_agent, created_at
59
+ FROM messages WHERE session_id = ${sessionId}
60
+ ORDER BY created_at ASC
61
+ `;
62
+ return rows.map((r) => ({
63
+ room: r.room,
64
+ sender: r.sender,
65
+ content: r.content,
66
+ isFromAgent: r.is_from_agent,
67
+ createdAt: String(r.created_at),
68
+ }));
69
+ }
70
+
32
71
  export async function getRoomStats(): Promise<RoomStats[]> {
33
72
  const sql = getSql();
34
73
  const rows = await sql`
@@ -49,6 +49,35 @@ export async function getRecent(room: string, limit = 10): Promise<SessionSummar
49
49
  }));
50
50
  }
51
51
 
52
+ export async function listRecent(limit = 10, room?: string): Promise<SessionSummary[]> {
53
+ if (room) return getRecent(room, limit);
54
+ const sql = getSql();
55
+ const rows = await sql`
56
+ SELECT
57
+ s.id,
58
+ s.room,
59
+ s.created_at,
60
+ s.updated_at,
61
+ (
62
+ SELECT content FROM messages m
63
+ WHERE m.session_id = s.id AND m.sender = 'user'
64
+ ORDER BY m.created_at ASC LIMIT 1
65
+ ) AS preview,
66
+ (SELECT COUNT(*)::int FROM messages m WHERE m.session_id = s.id) AS message_count
67
+ FROM sessions s
68
+ ORDER BY s.updated_at DESC
69
+ LIMIT ${limit}
70
+ `;
71
+ return rows.map((r) => ({
72
+ id: r.id,
73
+ room: r.room,
74
+ createdAt: String(r.created_at),
75
+ updatedAt: String(r.updated_at),
76
+ preview: r.preview ? String(r.preview) : null,
77
+ messageCount: r.message_count,
78
+ }));
79
+ }
80
+
52
81
  export async function create(id: string, room: string): Promise<void> {
53
82
  const sql = getSql();
54
83
  await sql`INSERT INTO sessions (id, room) VALUES (${id}, ${room})`;
package/src/mcp/server.ts CHANGED
@@ -99,6 +99,39 @@ export function createNiaMcpServer() {
99
99
  content: [{ type: "text" as const, text: await handlers.listMessages(args.limit, args.room) }],
100
100
  }),
101
101
  ),
102
+ tool(
103
+ "list_sessions",
104
+ "Browse past conversation sessions with previews. Returns session IDs you can pass to read_session.",
105
+ {
106
+ room: z.string().optional().describe("Filter by room name"),
107
+ limit: z.number().default(10).describe("Number of sessions to return"),
108
+ },
109
+ async (args) => ({
110
+ content: [{ type: "text" as const, text: await handlers.listSessions(args.limit, args.room) }],
111
+ }),
112
+ ),
113
+ tool(
114
+ "search_messages",
115
+ "Search across all past messages by keyword. Returns matching messages with session IDs for deeper reading.",
116
+ {
117
+ query: z.string().describe("Text to search for in message content"),
118
+ room: z.string().optional().describe("Filter by room name"),
119
+ limit: z.number().default(20).describe("Max results to return"),
120
+ },
121
+ async (args) => ({
122
+ content: [{ type: "text" as const, text: await handlers.searchMessages(args.query, args.limit, args.room) }],
123
+ }),
124
+ ),
125
+ tool(
126
+ "read_session",
127
+ "Load the full transcript of a specific conversation session. Use list_sessions or search_messages to find session IDs.",
128
+ {
129
+ session_id: z.string().describe("Session ID to read"),
130
+ },
131
+ async (args) => ({
132
+ content: [{ type: "text" as const, text: await handlers.readSession(args.session_id) }],
133
+ }),
134
+ ),
102
135
  tool(
103
136
  "add_watch_channel",
104
137
  "Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions) and act based on the behavior prompt. Takes effect on next message (hot-reloads).",
package/src/mcp/tools.ts CHANGED
@@ -244,6 +244,24 @@ export async function listMessages(limit = 20, room?: string): Promise<string> {
244
244
  return JSON.stringify(messages, null, 2);
245
245
  }
246
246
 
247
+ export async function listSessions(limit = 10, room?: string): Promise<string> {
248
+ const sessions = await Session.listRecent(limit, room);
249
+ if (sessions.length === 0) return "No sessions found.";
250
+ return JSON.stringify(sessions, null, 2);
251
+ }
252
+
253
+ export async function searchMessages(query: string, limit = 20, room?: string): Promise<string> {
254
+ const results = await Message.search(query, limit, room);
255
+ if (results.length === 0) return "No matching messages found.";
256
+ return JSON.stringify(results, null, 2);
257
+ }
258
+
259
+ export async function readSession(sessionId: string): Promise<string> {
260
+ const messages = await Message.getBySession(sessionId);
261
+ if (messages.length === 0) return "Session not found or has no messages.";
262
+ return JSON.stringify(messages, null, 2);
263
+ }
264
+
247
265
  export function addRule(rule: string): string {
248
266
  const { selfDir } = getPaths();
249
267
  const rulesPath = join(selfDir, "rules.md");
@@ -31,6 +31,9 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
31
31
  - **run_job** — trigger a job to run immediately
32
32
  - **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
33
33
  - **list_messages** — read recent chat history
34
+ - **list_sessions** — browse past conversation sessions with previews and message counts. Returns session IDs.
35
+ - **search_messages** — keyword search across all past messages. Find when something was discussed.
36
+ - **read_session** — load the full transcript of a specific session by ID.
34
37
  - **add_watch_channel** — add a Slack channel for proactive monitoring. Specify channel key (`channel_id#name`) and behavior prompt. Hot-reloads.
35
38
  - **remove_watch_channel** — stop watching a Slack channel. Hot-reloads.
36
39
  - **enable_watch_channel** / **disable_watch_channel** — toggle a watch channel on/off without removing it. Hot-reloads.
@@ -73,6 +76,16 @@ Config reference:
73
76
  - `channels.slack.watch` — per-channel proactive monitoring. Keys are `channel_id#channel_name` format.
74
77
  {{slackWatch}}
75
78
 
79
+ ## Conversation History
80
+
81
+ You have access to all prior conversations stored in the database:
82
+
83
+ - **list_sessions** — browse past sessions (with previews and message counts). Use to find a conversation.
84
+ - **search_messages** — search across all past messages by keyword. Returns session IDs for deeper reading.
85
+ - **read_session** — load the full transcript of a session by ID.
86
+
87
+ Use these when the user asks "did we talk about...", "what did I say about...", or when you need context from a prior conversation. Combine with `read_memory` for a complete picture.
88
+
76
89
  ## Persona & Memory
77
90
 
78
91
  Your persona files live in {{selfDir}}/:
@@ -7,5 +7,5 @@ export type { Channel, ChannelFactory } from "./channel";
7
7
  export type { ChatState } from "./chat-state";
8
8
  export type { Config, ChannelsConfig, TelegramConfig, SlackConfig } from "./config";
9
9
  export type { Paths } from "./paths";
10
- export type { SaveMessageParams, RoomStats, RecentMessage } from "./message";
10
+ export type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "./message";
11
11
  export type { AgentInfo } from "./agent";
@@ -19,3 +19,19 @@ export interface RecentMessage {
19
19
  content: string;
20
20
  createdAt: string;
21
21
  }
22
+
23
+ export interface SearchResult {
24
+ sessionId: string;
25
+ room: string;
26
+ sender: string;
27
+ content: string;
28
+ createdAt: string;
29
+ }
30
+
31
+ export interface SessionMessage {
32
+ room: string;
33
+ sender: string;
34
+ content: string;
35
+ isFromAgent: boolean;
36
+ createdAt: string;
37
+ }