niahere 0.2.43 → 0.2.44

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.43",
3
+ "version": "0.2.44",
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": {
@@ -7,7 +7,7 @@ import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
7
7
  import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
8
8
  import { relativeTime } from "../utils/format";
9
9
  import { runMigrations } from "../db/migrate";
10
- import { Session } from "../db/models";
10
+ import { Session, Message } from "../db/models";
11
11
  import { log } from "../utils/log";
12
12
  import { getMcpServers } from "../mcp";
13
13
  import { getNiaHome, getPaths } from "../utils/paths";
@@ -493,7 +493,7 @@ class SlackChannel implements Channel {
493
493
  .catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to add thinking reaction"));
494
494
 
495
495
  try {
496
- const { result } = await state.engine.send(text, {
496
+ const { result, messageId } = await state.engine.send(text, {
497
497
  onActivity(status) {
498
498
  log.debug({ status }, "slack engine activity");
499
499
  },
@@ -504,22 +504,27 @@ class SlackChannel implements Channel {
504
504
  // [NO_REPLY] or empty = agent chose not to respond (thread judgement)
505
505
  if (!reply || cleanSentinel(reply) === "[NO_REPLY]") {
506
506
  log.info({ channel: msg.channel, key }, "slack: agent chose not to reply");
507
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
507
508
  return;
508
509
  }
509
510
 
510
- if (replyThreadTs) {
511
- await client.chat.postMessage({
512
- channel: msg.channel,
513
- text: reply,
514
- thread_ts: replyThreadTs,
515
- });
516
- } else {
517
- await say(reply);
511
+ try {
512
+ if (replyThreadTs) {
513
+ await client.chat.postMessage({
514
+ channel: msg.channel,
515
+ text: reply,
516
+ thread_ts: replyThreadTs,
517
+ });
518
+ } else {
519
+ await say(reply);
520
+ }
521
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
522
+ log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
523
+ } catch (sendErr) {
524
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
525
+ throw sendErr;
518
526
  }
519
-
520
- log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
521
527
  } catch (err) {
522
-
523
528
  const errText = err instanceof Error ? err.message : String(err);
524
529
  log.error({ err, channel: msg.channel }, "slack message processing failed");
525
530
 
@@ -530,7 +535,7 @@ class SlackChannel implements Channel {
530
535
  thread_ts: replyThreadTs,
531
536
  }).catch(() => {});
532
537
  } else {
533
- await say(`[error] ${errText}`);
538
+ await say(`[error] ${errText}`).catch(() => {});
534
539
  }
535
540
  } finally {
536
541
  await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
@@ -3,7 +3,7 @@ import { createChatEngine } from "../chat/engine";
3
3
  import type { Channel, ChatState, Attachment } from "../types";
4
4
  import { getConfig, updateRawConfig } from "../utils/config";
5
5
  import { runMigrations } from "../db/migrate";
6
- import { Session } from "../db/models";
6
+ import { Session, Message } from "../db/models";
7
7
  import { log } from "../utils/log";
8
8
  import { getMcpServers } from "../mcp";
9
9
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
@@ -136,16 +136,21 @@ class TelegramChannel implements Channel {
136
136
  bot.api.sendChatAction(chatId, "typing").catch(() => {});
137
137
 
138
138
  try {
139
- const { result } = await state.engine.send(text, {}, attachments);
139
+ const { result, messageId } = await state.engine.send(text, {}, attachments);
140
140
 
141
141
  const reply = result.trim() || "(no response)";
142
142
  try {
143
- await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
144
- } catch {
145
- await bot.api.sendMessage(chatId, reply).catch(() => {});
143
+ try {
144
+ await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
145
+ } catch {
146
+ await bot.api.sendMessage(chatId, reply);
147
+ }
148
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
149
+ log.info({ chatId, chars: result.length }, "telegram reply sent");
150
+ } catch (sendErr) {
151
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
152
+ throw sendErr;
146
153
  }
147
-
148
- log.info({ chatId, chars: result.length }, "telegram reply sent");
149
154
  } catch (err) {
150
155
  const errText = err instanceof Error ? err.message : String(err);
151
156
  log.error({ err, chatId }, "telegram message processing failed");
@@ -10,8 +10,10 @@ import { getAgentDefinitions } from "../core/agents";
10
10
  import { Session, Message, ActiveEngine } from "../db/models";
11
11
  import type { Attachment, SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "../types";
12
12
  import { truncate, formatToolUse } from "../utils/format-activity";
13
+ import { log } from "../utils/log";
13
14
 
14
15
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
16
+ const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
15
17
 
16
18
  interface SDKUserMessage {
17
19
  type: "user";
@@ -128,20 +130,50 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
128
130
  let queryHandle: Query | null = null;
129
131
  let pending: PendingResult | null = null;
130
132
  let idleTimer: ReturnType<typeof setTimeout> | null = null;
133
+ let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
134
+ let longRunningWarned = false;
131
135
  let alive = false;
132
136
 
137
+ function clearIdleTimer() {
138
+ if (idleTimer) {
139
+ clearTimeout(idleTimer);
140
+ idleTimer = null;
141
+ }
142
+ }
143
+
133
144
  function resetIdleTimer() {
134
- if (idleTimer) clearTimeout(idleTimer);
145
+ clearIdleTimer();
135
146
  idleTimer = setTimeout(() => {
147
+ if (pending) {
148
+ // Don't tear down while a request is in flight
149
+ log.warn({ room }, "idle timer fired while request pending, skipping teardown");
150
+ return;
151
+ }
136
152
  teardown();
137
153
  }, IDLE_TIMEOUT);
138
154
  }
139
155
 
140
- function teardown() {
141
- if (idleTimer) {
142
- clearTimeout(idleTimer);
143
- idleTimer = null;
156
+ function clearLongRunningTimer() {
157
+ if (longRunningTimer) {
158
+ clearTimeout(longRunningTimer);
159
+ longRunningTimer = null;
144
160
  }
161
+ longRunningWarned = false;
162
+ }
163
+
164
+ function startLongRunningTimer() {
165
+ clearLongRunningTimer();
166
+ longRunningTimer = setTimeout(() => {
167
+ if (pending) {
168
+ longRunningWarned = true;
169
+ log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
170
+ }
171
+ }, LONG_RUNNING_WARN);
172
+ }
173
+
174
+ function teardown() {
175
+ clearIdleTimer();
176
+ clearLongRunningTimer();
145
177
  if (stream) {
146
178
  stream.end();
147
179
  stream = null;
@@ -285,31 +317,44 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
285
317
  const costUsd = (message as any).total_cost_usd as number;
286
318
  const turns = (message as any).num_turns as number;
287
319
 
320
+ let messageId: number | undefined;
288
321
  if (sessionId && resultText) {
289
- await Message.save({
322
+ messageId = await Message.save({
290
323
  sessionId,
291
324
  room,
292
325
  sender: "nia",
293
326
  content: resultText,
294
327
  isFromAgent: true,
328
+ deliveryStatus: "pending",
295
329
  });
296
330
  await Session.touch(sessionId);
297
331
  }
298
332
 
299
333
  await ActiveEngine.unregister(room);
300
- pending.resolve({ result: resultText, costUsd, turns });
334
+ clearLongRunningTimer();
335
+ pending.resolve({ result: resultText, costUsd, turns, messageId });
301
336
  pending = null;
302
337
  resetIdleTimer();
303
338
  } else {
304
339
  const errors = (message as any).errors;
305
340
  const errorText = `[error] ${errors?.join(", ") || "unknown error"}`;
306
341
  await ActiveEngine.unregister(room);
342
+ clearLongRunningTimer();
307
343
  pending.resolve({ result: errorText, costUsd: 0, turns: 0 });
308
344
  pending = null;
309
345
  resetIdleTimer();
310
346
  }
311
347
  }
312
348
  }
349
+
350
+ // Stream ended without a result — subprocess exited or was killed
351
+ if (pending) {
352
+ const partial = pending.accumulatedText;
353
+ log.error({ room, partialChars: partial.length }, "query stream ended without result, rejecting pending request");
354
+ await ActiveEngine.unregister(room).catch(() => {});
355
+ pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
356
+ pending = null;
357
+ }
313
358
  } catch (err) {
314
359
  if (pending) {
315
360
  await ActiveEngine.unregister(room).catch(() => {});
@@ -317,6 +362,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
317
362
  pending = null;
318
363
  }
319
364
  } finally {
365
+ clearLongRunningTimer();
320
366
  alive = false;
321
367
  stream = null;
322
368
  queryHandle = null;
@@ -334,6 +380,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
334
380
  },
335
381
 
336
382
  async send(userMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
383
+ // Clear idle timer — engine is not idle while processing a request
384
+ clearIdleTimer();
385
+ startLongRunningTimer();
386
+
337
387
  await ActiveEngine.register(room, channel);
338
388
 
339
389
  if (!alive || !stream) {
package/src/cli/index.ts CHANGED
@@ -433,6 +433,12 @@ switch (command) {
433
433
  process.exit(exitCode);
434
434
  }
435
435
 
436
+ case "backup": {
437
+ const { backupCommand } = await import("../commands/backup");
438
+ await backupCommand();
439
+ break;
440
+ }
441
+
436
442
  case "validate": {
437
443
  const { validateConfig } = await import("../commands/validate");
438
444
  const result = validateConfig();
@@ -444,6 +450,15 @@ switch (command) {
444
450
  case "update": {
445
451
  const { version: currentVersion } = await import("../../package.json");
446
452
  console.log(`Current: v${currentVersion}`);
453
+ // Auto-backup before update
454
+ try {
455
+ const { createBackup } = await import("../commands/backup");
456
+ console.log("Backing up...");
457
+ await createBackup(true);
458
+ console.log("✓ pre-update backup created");
459
+ } catch (err) {
460
+ console.log(`⚠ backup skipped: ${errMsg(err)}`);
461
+ }
447
462
  console.log("Updating...");
448
463
  const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
449
464
  const installExit = await install.exited;
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { getNiaHome } from "../utils/paths";
4
+ import { getConfig } from "../utils/config";
5
+
6
+ const MAX_BACKUPS = 10;
7
+
8
+ function getBackupDir(): string {
9
+ const dir = join(getNiaHome(), "backups");
10
+ mkdirSync(dir, { recursive: true });
11
+ return dir;
12
+ }
13
+
14
+ function humanDate(): string {
15
+ const now = new Date();
16
+ const y = now.getFullYear();
17
+ const m = String(now.getMonth() + 1).padStart(2, "0");
18
+ const d = String(now.getDate()).padStart(2, "0");
19
+ return `${y}-${m}-${d}`;
20
+ }
21
+
22
+ function formatSize(bytes: number): string {
23
+ if (bytes < 1024) return `${bytes}B`;
24
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
25
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
26
+ }
27
+
28
+ function pruneOldBackups(dir: string): void {
29
+ const files = readdirSync(dir)
30
+ .filter((f) => f.startsWith("niahere-") && f.endsWith(".tar.gz"))
31
+ .map((f) => ({ name: f, mtime: statSync(join(dir, f)).mtimeMs }))
32
+ .sort((a, b) => b.mtime - a.mtime);
33
+
34
+ for (const file of files.slice(MAX_BACKUPS)) {
35
+ unlinkSync(join(dir, file.name));
36
+ console.log(` pruned old backup: ${file.name}`);
37
+ }
38
+ }
39
+
40
+ export async function createBackup(silent = false): Promise<string> {
41
+ const home = getNiaHome();
42
+ const backupDir = getBackupDir();
43
+ const filename = `niahere-${humanDate()}-${Math.floor(Date.now() / 1000)}.tar.gz`;
44
+ const outPath = join(backupDir, filename);
45
+
46
+ // Directories/files to include (relative to home)
47
+ const includes: string[] = [];
48
+ if (existsSync(join(home, "config.yaml"))) includes.push("config.yaml");
49
+ if (existsSync(join(home, "self"))) includes.push("self");
50
+ if (existsSync(join(home, "agents"))) includes.push("agents");
51
+ if (existsSync(join(home, "skills"))) includes.push("skills");
52
+
53
+ // Database dump
54
+ const config = getConfig();
55
+ const dbUrl = config.database_url;
56
+ let dbDumped = false;
57
+ if (dbUrl) {
58
+ const dumpPath = join(home, "tmp", "db-backup.sql");
59
+ mkdirSync(join(home, "tmp"), { recursive: true });
60
+ const pg = Bun.spawn(["pg_dump", dbUrl, "-f", dumpPath], {
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ });
64
+ const exitCode = await pg.exited;
65
+ if (exitCode === 0 && existsSync(dumpPath)) {
66
+ // Copy to a relative path for tar
67
+ const relDump = "db-backup.sql";
68
+ const { copyFileSync } = await import("fs");
69
+ copyFileSync(dumpPath, join(home, relDump));
70
+ includes.push(relDump);
71
+ dbDumped = true;
72
+ } else if (!silent) {
73
+ const stderr = await new Response(pg.stderr).text();
74
+ console.log(` ⚠ db dump skipped: ${stderr.trim() || `exit ${exitCode}`}`);
75
+ }
76
+ }
77
+
78
+ if (includes.length === 0) {
79
+ console.log("Nothing to back up.");
80
+ return "";
81
+ }
82
+
83
+ // Create tar.gz
84
+ const tar = Bun.spawn(["tar", "czf", outPath, ...includes], {
85
+ cwd: home,
86
+ stdout: "pipe",
87
+ stderr: "pipe",
88
+ });
89
+ const tarExit = await tar.exited;
90
+ if (tarExit !== 0) {
91
+ const stderr = await new Response(tar.stderr).text();
92
+ throw new Error(`tar failed: ${stderr.trim()}`);
93
+ }
94
+
95
+ // Clean up temp db dump
96
+ if (dbDumped) {
97
+ try { unlinkSync(join(home, "db-backup.sql")); } catch {}
98
+ try { unlinkSync(join(home, "tmp", "db-backup.sql")); } catch {}
99
+ }
100
+
101
+ const size = statSync(outPath).size;
102
+ if (!silent) {
103
+ console.log(`✓ backup created: ${filename} (${formatSize(size)})`);
104
+ if (dbDumped) console.log(" includes: files + database");
105
+ else console.log(" includes: files only (no database)");
106
+ }
107
+
108
+ pruneOldBackups(backupDir);
109
+
110
+ return outPath;
111
+ }
112
+
113
+ export function listBackups(): void {
114
+ const dir = getBackupDir();
115
+ const files = readdirSync(dir)
116
+ .filter((f) => f.startsWith("niahere-") && f.endsWith(".tar.gz"))
117
+ .map((f) => {
118
+ const stat = statSync(join(dir, f));
119
+ return { name: f, size: stat.size, mtime: stat.mtimeMs };
120
+ })
121
+ .sort((a, b) => b.mtime - a.mtime);
122
+
123
+ if (files.length === 0) {
124
+ console.log("No backups found.");
125
+ return;
126
+ }
127
+
128
+ console.log(`${files.length} backup(s) in ${dir}:\n`);
129
+ for (const f of files) {
130
+ const date = new Date(f.mtime).toLocaleString();
131
+ console.log(` ${f.name} ${formatSize(f.size)} ${date}`);
132
+ }
133
+ }
134
+
135
+ export async function backupCommand(): Promise<void> {
136
+ const sub = process.argv[3];
137
+ if (sub === "list") {
138
+ listBackups();
139
+ } else if (!sub) {
140
+ await createBackup();
141
+ } else {
142
+ console.log("Usage:");
143
+ console.log(" nia backup — create a backup");
144
+ console.log(" nia backup list — list existing backups");
145
+ }
146
+ }
@@ -0,0 +1,10 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "008_message_delivery_status";
4
+
5
+ export async function up(sql: postgres.Sql): Promise<void> {
6
+ await sql`
7
+ ALTER TABLE messages
8
+ ADD COLUMN IF NOT EXISTS delivery_status TEXT DEFAULT 'sent'
9
+ `;
10
+ }
@@ -1,12 +1,45 @@
1
1
  import { getSql } from "../connection";
2
2
  import type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "../../types";
3
3
 
4
- export async function save(params: SaveMessageParams): Promise<void> {
4
+ export type DeliveryStatus = "pending" | "sent" | "failed";
5
+
6
+ export async function save(params: SaveMessageParams & { deliveryStatus?: DeliveryStatus }): Promise<number> {
5
7
  const sql = getSql();
6
- await sql`
7
- INSERT INTO messages (session_id, room, sender, content, is_from_agent)
8
- VALUES (${params.sessionId}, ${params.room}, ${params.sender}, ${params.content}, ${params.isFromAgent})
8
+ const status = params.deliveryStatus || "sent";
9
+ const rows = await sql`
10
+ INSERT INTO messages (session_id, room, sender, content, is_from_agent, delivery_status)
11
+ VALUES (${params.sessionId}, ${params.room}, ${params.sender}, ${params.content}, ${params.isFromAgent}, ${status})
12
+ RETURNING id
9
13
  `;
14
+ return rows[0].id;
15
+ }
16
+
17
+ export async function updateDeliveryStatus(id: number, status: DeliveryStatus): Promise<void> {
18
+ const sql = getSql();
19
+ await sql`UPDATE messages SET delivery_status = ${status} WHERE id = ${id}`;
20
+ }
21
+
22
+ export async function getUndelivered(room?: string): Promise<Array<{ id: number; room: string; content: string; createdAt: string }>> {
23
+ const sql = getSql();
24
+ const rows = room
25
+ ? await sql`
26
+ SELECT id, room, content, created_at
27
+ FROM messages
28
+ WHERE delivery_status = 'failed' AND is_from_agent = true AND room = ${room}
29
+ ORDER BY created_at ASC
30
+ `
31
+ : await sql`
32
+ SELECT id, room, content, created_at
33
+ FROM messages
34
+ WHERE delivery_status = 'failed' AND is_from_agent = true
35
+ ORDER BY created_at ASC
36
+ `;
37
+ return rows.map((r) => ({
38
+ id: r.id,
39
+ room: r.room,
40
+ content: r.content,
41
+ createdAt: String(r.created_at),
42
+ }));
10
43
  }
11
44
 
12
45
  export async function getRecent(limit = 20, room?: string): Promise<RecentMessage[]> {
@@ -2,6 +2,8 @@ export interface SendResult {
2
2
  result: string;
3
3
  costUsd: number;
4
4
  turns: number;
5
+ /** DB message ID for delivery status tracking (only set for agent replies) */
6
+ messageId?: number;
5
7
  }
6
8
 
7
9
  export type StreamCallback = (textSoFar: string) => void;