niahere 0.4.6 → 0.5.1

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.
Files changed (60) hide show
  1. package/package.json +2 -2
  2. package/src/agent/backends/claude-normalize.ts +5 -6
  3. package/src/agent/backends/claude.ts +6 -4
  4. package/src/agent/backends/codex-normalize.ts +19 -3
  5. package/src/agent/backends/codex.ts +100 -35
  6. package/src/agent/chain.ts +60 -0
  7. package/src/agent/failure.ts +90 -0
  8. package/src/agent/health.ts +41 -0
  9. package/src/agent/index.ts +3 -1
  10. package/src/agent/mcp-endpoint.ts +5 -3
  11. package/src/agent/models.ts +59 -0
  12. package/src/agent/registry.ts +54 -29
  13. package/src/agent/types.ts +9 -4
  14. package/src/channels/common/chat-session.ts +60 -0
  15. package/src/channels/phone/consult.ts +2 -1
  16. package/src/channels/phone/index.ts +5 -3
  17. package/src/channels/phone/relay.ts +15 -5
  18. package/src/channels/slack.ts +24 -34
  19. package/src/channels/sms.ts +16 -36
  20. package/src/channels/telegram.ts +22 -34
  21. package/src/channels/twilio/media-cache.ts +4 -3
  22. package/src/channels/twilio/rest.ts +0 -31
  23. package/src/channels/twilio/server.ts +0 -5
  24. package/src/channels/twilio/shared.ts +36 -0
  25. package/src/channels/whatsapp.ts +24 -58
  26. package/src/chat/engine.ts +83 -34
  27. package/src/chat/gap-marker.ts +63 -0
  28. package/src/chat/repl.ts +5 -5
  29. package/src/cli/config.ts +71 -0
  30. package/src/cli/index.ts +23 -288
  31. package/src/cli/job.ts +1 -2
  32. package/src/cli/logs.ts +55 -0
  33. package/src/cli/run.ts +74 -0
  34. package/src/cli/skills.ts +13 -0
  35. package/src/cli/status.ts +0 -1
  36. package/src/cli/test.ts +32 -0
  37. package/src/cli/update.ts +79 -0
  38. package/src/commands/backup.ts +1 -2
  39. package/src/commands/init.ts +1 -2
  40. package/src/commands/validate.ts +17 -13
  41. package/src/core/alive.ts +6 -5
  42. package/src/core/consolidator.ts +78 -31
  43. package/src/core/daemon.ts +3 -2
  44. package/src/core/finalizer.ts +9 -5
  45. package/src/core/runner.ts +64 -47
  46. package/src/core/scheduler.ts +19 -4
  47. package/src/core/skills.ts +0 -4
  48. package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
  49. package/src/db/models/active_engine.ts +0 -7
  50. package/src/db/models/job.ts +3 -2
  51. package/src/db/models/message.ts +10 -0
  52. package/src/db/models/session.ts +12 -0
  53. package/src/mcp/gate.ts +46 -0
  54. package/src/mcp/server.ts +2 -1
  55. package/src/mcp/tools/misc.ts +2 -1
  56. package/src/mcp/tools/send.ts +5 -4
  57. package/src/types/config.ts +4 -4
  58. package/src/utils/config.ts +6 -8
  59. package/src/utils/errors.ts +26 -0
  60. package/src/utils/retry.ts +0 -45
package/src/cli/index.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, mkdirSync } from "fs";
2
+ import { mkdirSync, readFileSync } from "fs";
3
3
  import { isRunning, readPid, runDaemon, startDaemon, stopDaemon } from "../core/daemon";
4
4
  import { getConfig } from "../utils/config";
5
- import { localTime } from "../utils/time";
6
5
  import { startRepl } from "../chat/repl";
7
- import { Message } from "../db/models";
8
- import { withDb } from "../db/with-db";
9
6
  import { getNiaHome, getPaths } from "../utils/paths";
10
- import { errMsg } from "../utils/errors";
11
7
  import { fail, ICON_PASS, ICON_WARN } from "../utils/cli";
12
8
  import { jobCommand } from "./job";
13
9
  import { statusCommand } from "./status";
@@ -19,7 +15,7 @@ import { rulesCommand, memoryCommand } from "./self";
19
15
  import { watchCommand } from "./watch";
20
16
  import { agentCommand } from "./agent";
21
17
  import { employeeCommand } from "./employee";
22
- import { guardActiveEngines, parseGuardFlags, withDefaultWait } from "../core/engine-guard";
18
+ import { guardActiveEngines, parseGuardFlags } from "../core/engine-guard";
23
19
 
24
20
  // Set LOG_LEVEL from config before anything else logs
25
21
  try {
@@ -58,7 +54,6 @@ async function awaitStartup(timeout = 60_000): Promise<void> {
58
54
 
59
55
  if (expecting.size === 0) return;
60
56
 
61
- const { readFileSync } = await import("fs");
62
57
  const ready = new Set<string>();
63
58
  let logOffset = 0;
64
59
  try {
@@ -167,80 +162,13 @@ switch (command) {
167
162
 
168
163
  case "run": {
169
164
  const prompt = process.argv.slice(3).join(" ");
170
- if (prompt) {
171
- const { createChatEngine } = await import("../chat/engine");
172
- const { getMcpServers } = await import("../mcp");
173
- const { DIM, RESET: RST, CLEAR_LINE, SPINNER: FRAMES } = await import("../utils/cli");
174
- let frame = 0;
175
- let statusText = "thinking";
176
- let spinTimer: ReturnType<typeof setInterval> | null = null;
177
- let streamedLen = 0;
178
- let streaming = false;
179
-
180
- const renderSpinner = () => {
181
- process.stderr.write(`${CLEAR_LINE}${DIM} ${FRAMES[frame]} ${statusText}${RST}`);
182
- frame = (frame + 1) % FRAMES.length;
183
- };
184
-
185
- await withDb(async () => {
186
- const engine = await createChatEngine({
187
- room: "cli-run",
188
- channel: "terminal",
189
- resume: false,
190
- mcpServers: getMcpServers(),
191
- });
192
- spinTimer = setInterval(renderSpinner, 80);
193
- renderSpinner();
194
-
195
- const { result, costUsd, turns } = await engine.send(prompt, {
196
- onStream(textSoFar) {
197
- if (!streaming) {
198
- if (spinTimer) {
199
- clearInterval(spinTimer);
200
- spinTimer = null;
201
- }
202
- process.stderr.write("\x1b[2K\r");
203
- streaming = true;
204
- }
205
- const chunk = textSoFar.slice(streamedLen);
206
- if (chunk) {
207
- process.stdout.write(chunk);
208
- streamedLen = textSoFar.length;
209
- }
210
- },
211
- onActivity(text) {
212
- if (!streaming) statusText = text;
213
- },
214
- });
215
-
216
- if (spinTimer) {
217
- clearInterval(spinTimer);
218
- spinTimer = null;
219
- }
220
-
221
- if (!streaming && result.trim()) {
222
- process.stderr.write("\x1b[2K\r");
223
- process.stdout.write(result.trim());
224
- } else if (streaming) {
225
- const rest = result.slice(streamedLen);
226
- if (rest.trim()) process.stdout.write(rest);
227
- } else {
228
- process.stderr.write("\x1b[2K\r");
229
- }
230
-
231
- const costStr = costUsd > 0 ? `$${costUsd.toFixed(4)}` : "";
232
- const turnsStr = turns > 0 ? `${turns} turn${turns !== 1 ? "s" : ""}` : "";
233
- const meta = [costStr, turnsStr].filter(Boolean).join(" · ");
234
- if (meta) process.stderr.write(`\n${DIM}${meta}${RST}`);
235
- process.stdout.write("\n");
236
-
237
- await engine.close();
238
- });
239
- process.exit(0);
240
- } else {
165
+ if (!prompt) {
241
166
  await runDaemon();
167
+ break;
242
168
  }
243
- break;
169
+ const { runPrompt } = await import("./run");
170
+ await runPrompt(prompt);
171
+ process.exit(0);
244
172
  }
245
173
 
246
174
  case "job": {
@@ -264,52 +192,14 @@ switch (command) {
264
192
  }
265
193
 
266
194
  case "history": {
267
- const room = process.argv[3];
268
- try {
269
- await withDb(async () => {
270
- const messages = await Message.getRecent(20, room);
271
- if (messages.length === 0) {
272
- console.log("No messages yet.");
273
- } else {
274
- for (const m of messages) {
275
- const time = localTime(new Date(m.createdAt));
276
- const prefix = m.sender === "user" ? "you" : m.sender;
277
- const roomTag = room ? "" : `[${m.room}] `;
278
- const snippet = m.content.length > 120 ? m.content.slice(0, 120) + "..." : m.content;
279
- console.log(` ${roomTag}${time} ${prefix} > ${snippet.replace(/\n/g, " ")}`);
280
- }
281
- }
282
- });
283
- } catch (err) {
284
- fail(`Failed: ${errMsg(err)}`);
285
- }
195
+ const { historyCommand } = await import("./logs");
196
+ await historyCommand(process.argv[3]);
286
197
  break;
287
198
  }
288
199
 
289
200
  case "logs": {
290
- const { daemonLog } = getPaths();
291
- if (!existsSync(daemonLog)) fail("No daemon log found. Is nia running?");
292
- const logArgs = process.argv.slice(3);
293
- const follow = logArgs.includes("-f") || logArgs.includes("--follow");
294
- // --channel <name> filters logs by channel/component via grep
295
- const chIdx = logArgs.indexOf("--channel");
296
- const channelFilter = chIdx !== -1 && logArgs[chIdx + 1] ? logArgs[chIdx + 1] : null;
297
-
298
- if (channelFilter) {
299
- // Pipe through grep to filter by channel name in structured logs
300
- const tailArgs = follow ? ["tail", "-f", daemonLog] : ["tail", "-200", daemonLog];
301
- const tail = Bun.spawn(tailArgs, {
302
- stdio: ["ignore", "pipe", "inherit"],
303
- });
304
- const grep = Bun.spawn(["grep", "-i", channelFilter], {
305
- stdio: [tail.stdout, "inherit", "inherit"],
306
- });
307
- await grep.exited;
308
- } else {
309
- const args = follow ? ["tail", "-f", daemonLog] : ["tail", "-50", daemonLog];
310
- const proc = Bun.spawn(args, { stdio: ["ignore", "inherit", "inherit"] });
311
- await proc.exited;
312
- }
201
+ const { logsCommand } = await import("./logs");
202
+ await logsCommand(process.argv.slice(3));
313
203
  break;
314
204
  }
315
205
 
@@ -352,20 +242,8 @@ switch (command) {
352
242
  }
353
243
 
354
244
  case "skills": {
355
- const { scanSkills: loadSkills } = await import("../core/skills");
356
- const filter = process.argv[3]; // e.g. "project", "nia", "shared", "claude"
357
- let skills = loadSkills();
358
- if (filter) {
359
- skills = skills.filter((s) => s.source === filter);
360
- }
361
- if (skills.length === 0) {
362
- console.log(filter ? `No skills found in "${filter}".` : "No skills found.");
363
- } else {
364
- for (const s of skills) {
365
- const tag = filter ? "" : ` [${s.source}]`;
366
- console.log(` ${s.name}${tag}`);
367
- }
368
- }
245
+ const { skillsCommand } = await import("./skills");
246
+ skillsCommand(process.argv[3]);
369
247
  break;
370
248
  }
371
249
 
@@ -390,90 +268,19 @@ switch (command) {
390
268
  }
391
269
 
392
270
  case "config": {
393
- const configSub = process.argv[3];
394
- const configKey = process.argv[4];
395
- const configVal = process.argv.slice(5).join(" ");
396
- const { readRawConfig, updateRawConfig } = await import("../utils/config");
397
-
398
- if (configSub === "set" && configKey) {
399
- if (!configVal) fail("Usage: nia config set <key> <value>");
400
- // Support dot notation for nested keys (e.g. channels.default)
401
- const parts = configKey.split(".");
402
- let obj: Record<string, unknown> = {};
403
- let cursor = obj;
404
- for (let i = 0; i < parts.length - 1; i++) {
405
- cursor[parts[i]] = {};
406
- cursor = cursor[parts[i]] as Record<string, unknown>;
407
- }
408
- // Auto-detect booleans and numbers
409
- let parsed: unknown = configVal;
410
- if (configVal === "true") parsed = true;
411
- else if (configVal === "false") parsed = false;
412
- else if (/^\d+$/.test(configVal)) parsed = Number(configVal);
413
- cursor[parts[parts.length - 1]] = parsed;
414
- updateRawConfig(obj);
415
- console.log(`${configKey} = ${configVal}`);
416
- } else if (configSub === "get" && configKey) {
417
- const raw = readRawConfig();
418
- const parts = configKey.split(".");
419
- let val: unknown = raw;
420
- for (const p of parts) {
421
- if (val && typeof val === "object") val = (val as Record<string, unknown>)[p];
422
- else {
423
- val = undefined;
424
- break;
425
- }
426
- }
427
- if (val === undefined) {
428
- console.log(`${configKey}: (not set)`);
429
- } else if (typeof val === "object") {
430
- const yaml = (await import("js-yaml")).default;
431
- console.log(yaml.dump(val, { lineWidth: -1 }).trim());
432
- } else {
433
- console.log(`${configKey} = ${val}`);
434
- }
435
- } else if (!configSub || configSub === "list") {
436
- const raw = readRawConfig();
437
- const yaml = (await import("js-yaml")).default;
438
- console.log(yaml.dump(raw, { lineWidth: -1 }).trim());
439
- } else {
440
- console.log("Usage: nia config <set|get|list>");
441
- console.log(" nia config set <key> <value> — set a config value");
442
- console.log(" nia config get <key> — get a config value");
443
- console.log(" nia config list — show all config");
444
- }
271
+ const { configCommand } = await import("./config");
272
+ await configCommand(process.argv[3], process.argv[4], process.argv.slice(5).join(" "));
445
273
  break;
446
274
  }
447
275
 
448
276
  case "channels": {
449
277
  const sub = process.argv[3];
450
- const target = process.argv[4];
451
- const { updateRawConfig } = await import("../utils/config");
452
- if (sub === "on" || sub === "off") {
453
- const enabled = sub === "on";
454
- if (target) {
455
- const supported = new Set(["telegram", "slack", "phone", "sms", "whatsapp"]);
456
- if (!supported.has(target)) fail("Usage: nia channels <on|off> [telegram|slack|phone|sms|whatsapp]");
457
- updateRawConfig({ channels: { ...(enabled ? { enabled: true } : {}), [target]: { enabled } } });
458
- } else {
459
- updateRawConfig({ channels: { enabled } });
460
- }
461
- const pid = readPid();
462
- if (pid && isRunning()) {
463
- process.kill(pid, "SIGHUP");
464
- console.log(
465
- target ? `${target} ${enabled ? "enabled" : "disabled"}` : `channels ${enabled ? "enabled" : "disabled"}`,
466
- );
467
- } else {
468
- console.log(
469
- target
470
- ? `${target} ${enabled ? "enabled" : "disabled"} — start nia to apply`
471
- : `channels ${enabled ? "enabled" : "disabled"} — start nia to apply`,
472
- );
473
- }
474
- } else {
278
+ if (sub !== "on" && sub !== "off") {
475
279
  console.log(`channels: ${getConfig().channels.enabled ? "on" : "off"}`);
280
+ break;
476
281
  }
282
+ const { channelsToggleCommand } = await import("./update");
283
+ await channelsToggleCommand(sub, process.argv[4]);
477
284
  break;
478
285
  }
479
286
 
@@ -484,35 +291,8 @@ switch (command) {
484
291
  }
485
292
 
486
293
  case "test": {
487
- const verbose = process.argv.includes("-v") || process.argv.includes("--verbose");
488
- const extraArgs = process.argv.slice(3).filter((a) => a !== "-v" && a !== "--verbose");
489
- const proc = Bun.spawn(["bun", "test", ...extraArgs], {
490
- stdio: ["ignore", "pipe", "pipe"],
491
- cwd: import.meta.dir + "/../..",
492
- env: { ...process.env, LOG_LEVEL: "silent" },
493
- });
494
-
495
- const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
496
- const exitCode = await proc.exited;
497
- const output = stdout + stderr;
498
-
499
- if (verbose) {
500
- process.stdout.write(output);
501
- } else {
502
- for (const line of output.split("\n")) {
503
- if (
504
- /^\s*\d+ pass/.test(line) ||
505
- /^\s*\d+ fail/.test(line) ||
506
- /^Ran \d+ tests/.test(line) ||
507
- /expect\(\) calls/.test(line)
508
- ) {
509
- console.log(line);
510
- } else if (/^✗|FAIL|error:/i.test(line.trim())) {
511
- console.log(line);
512
- }
513
- }
514
- }
515
- process.exit(exitCode);
294
+ const { testCommand } = await import("./test");
295
+ await testCommand(process.argv.slice(3));
516
296
  }
517
297
 
518
298
  case "backup": {
@@ -530,53 +310,8 @@ switch (command) {
530
310
  }
531
311
 
532
312
  case "update": {
533
- const updateGuard = withDefaultWait(parseGuardFlags(process.argv.slice(3)), 1);
534
- const { version: currentVersion } = await import("../../package.json");
535
- console.log(`Current: v${currentVersion}`);
536
-
537
- // Check active engines before doing anything destructive
538
- if (isRunning() && !(await guardActiveEngines("update", updateGuard))) process.exit(1);
539
-
540
- // Auto-backup before update
541
- try {
542
- const { createBackup } = await import("../commands/backup");
543
- console.log("Backing up...");
544
- await createBackup(true);
545
- console.log("✓ pre-update backup created");
546
- } catch (err) {
547
- console.log(`⚠ backup skipped: ${errMsg(err)}`);
548
- }
549
- console.log("Updating...");
550
- const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], {
551
- stdio: ["ignore", "inherit", "inherit"],
552
- });
553
- const installExit = await install.exited;
554
- if (installExit !== 0) {
555
- fail("Update failed.");
556
- }
557
- // Get new version
558
- const check = Bun.spawn(["npm", "view", "niahere", "version"], {
559
- stdout: "pipe",
560
- stderr: "pipe",
561
- });
562
- const newVersion = (await new Response(check.stdout).text()).trim();
563
- await check.exited;
564
- if (newVersion === currentVersion) {
565
- console.log("Already on latest.");
566
- } else {
567
- console.log(`Updated: v${currentVersion} → v${newVersion}`);
568
- if (isRunning()) {
569
- console.log("Restarting daemon...");
570
- const { isServiceInstalled, restartService } = await import("../commands/service");
571
- if (isServiceInstalled()) {
572
- await restartService({ force: updateGuard.force });
573
- } else {
574
- stopDaemon({ force: updateGuard.force });
575
- startDaemon();
576
- }
577
- console.log("Restarted.");
578
- }
579
- }
313
+ const { updateCommand } = await import("./update");
314
+ await updateCommand(process.argv.slice(3));
580
315
  break;
581
316
  }
582
317
 
package/src/cli/job.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as readline from "readline";
2
+ import { existsSync, readFileSync } from "fs";
2
3
  import { readState, readAudit } from "../utils/logger";
3
4
  import { getConfig } from "../utils/config";
4
5
  import { runJob } from "../core/runner";
@@ -145,7 +146,6 @@ export async function jobCommand(): Promise<void> {
145
146
  const promptFile = args.getString("prompt-file");
146
147
  let prompt: string;
147
148
  if (promptFile) {
148
- const { existsSync, readFileSync } = await import("fs");
149
149
  if (!existsSync(promptFile)) fail(`File not found: ${promptFile}`);
150
150
  prompt = readFileSync(promptFile, "utf8").trim();
151
151
  } else if (promptFlag) {
@@ -243,7 +243,6 @@ export async function jobCommand(): Promise<void> {
243
243
  const promptFile = args.getString("prompt-file");
244
244
  let prompt = args.getString("prompt");
245
245
  if (promptFile) {
246
- const { existsSync, readFileSync } = await import("fs");
247
246
  if (!existsSync(promptFile)) fail(`File not found: ${promptFile}`);
248
247
  prompt = readFileSync(promptFile, "utf8").trim();
249
248
  }
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "fs";
2
+ import { getPaths } from "../utils/paths";
3
+ import { withDb } from "../db/with-db";
4
+ import { Message } from "../db/models";
5
+ import { localTime } from "../utils/time";
6
+ import { errMsg } from "../utils/errors";
7
+
8
+ const SNIPPET = 120;
9
+
10
+ /** `nia history [room]` — recent messages, newest last. */
11
+ export async function historyCommand(room?: string): Promise<void> {
12
+ try {
13
+ await withDb(async () => {
14
+ const messages = await Message.getRecent(20, room);
15
+ if (messages.length === 0) {
16
+ console.log("No messages yet.");
17
+ return;
18
+ }
19
+ for (const m of messages) {
20
+ const time = localTime(new Date(m.createdAt));
21
+ const prefix = m.sender === "user" ? "you" : m.sender;
22
+ const roomTag = room ? "" : `[${m.room}] `;
23
+ const snippet = m.content.length > SNIPPET ? m.content.slice(0, SNIPPET) + "..." : m.content;
24
+ console.log(` ${roomTag}${time} ${prefix} > ${snippet.replace(/\n/g, " ")}`);
25
+ }
26
+ });
27
+ } catch (err) {
28
+ console.error(`Failed: ${errMsg(err)}`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ /** `nia logs [-f] [--channel <name>]` — tail the daemon log. */
34
+ export async function logsCommand(args: string[]): Promise<void> {
35
+ const { daemonLog } = getPaths();
36
+ if (!existsSync(daemonLog)) {
37
+ console.error("No daemon log found. Is nia running?");
38
+ process.exit(1);
39
+ }
40
+
41
+ const follow = args.includes("-f") || args.includes("--follow");
42
+ const chIdx = args.indexOf("--channel");
43
+ const channelFilter = chIdx !== -1 && args[chIdx + 1] ? args[chIdx + 1] : null;
44
+ const tailArgs = follow ? ["tail", "-f", daemonLog] : ["tail", channelFilter ? "-200" : "-50", daemonLog];
45
+
46
+ if (!channelFilter) {
47
+ const proc = Bun.spawn(tailArgs, { stdio: ["ignore", "inherit", "inherit"] });
48
+ await proc.exited;
49
+ return;
50
+ }
51
+
52
+ const tail = Bun.spawn(tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
53
+ const grep = Bun.spawn(["grep", "-i", channelFilter], { stdio: [tail.stdout, "inherit", "inherit"] });
54
+ await grep.exited;
55
+ }
package/src/cli/run.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { withDb } from "../db/with-db";
2
+
3
+ /** `nia run <prompt>` — one-shot terminal turn with a spinner and streamed output. */
4
+ export async function runPrompt(prompt: string): Promise<void> {
5
+ const { createChatEngine } = await import("../chat/engine");
6
+ const { getMcpServers } = await import("../mcp");
7
+ const { DIM, RESET, CLEAR_LINE, SPINNER } = await import("../utils/cli");
8
+
9
+ let frame = 0;
10
+ let statusText = "thinking";
11
+ let spinTimer: ReturnType<typeof setInterval> | null = null;
12
+ let streamedLen = 0;
13
+ let streaming = false;
14
+
15
+ const renderSpinner = () => {
16
+ process.stderr.write(`${CLEAR_LINE}${DIM} ${SPINNER[frame]} ${statusText}${RESET}`);
17
+ frame = (frame + 1) % SPINNER.length;
18
+ };
19
+ const stopSpinner = () => {
20
+ if (spinTimer) {
21
+ clearInterval(spinTimer);
22
+ spinTimer = null;
23
+ }
24
+ };
25
+
26
+ await withDb(async () => {
27
+ const engine = await createChatEngine({
28
+ room: "cli-run",
29
+ channel: "terminal",
30
+ resume: false,
31
+ mcpServers: getMcpServers(),
32
+ });
33
+ spinTimer = setInterval(renderSpinner, 80);
34
+ renderSpinner();
35
+
36
+ const { result, costUsd, turns } = await engine.send(prompt, {
37
+ onStream(textSoFar) {
38
+ if (!streaming) {
39
+ stopSpinner();
40
+ process.stderr.write("\x1b[2K\r");
41
+ streaming = true;
42
+ }
43
+ const chunk = textSoFar.slice(streamedLen);
44
+ if (chunk) {
45
+ process.stdout.write(chunk);
46
+ streamedLen = textSoFar.length;
47
+ }
48
+ },
49
+ onActivity(text) {
50
+ if (!streaming) statusText = text;
51
+ },
52
+ });
53
+
54
+ stopSpinner();
55
+
56
+ if (!streaming && result.trim()) {
57
+ process.stderr.write("\x1b[2K\r");
58
+ process.stdout.write(result.trim());
59
+ } else if (streaming) {
60
+ const rest = result.slice(streamedLen);
61
+ if (rest.trim()) process.stdout.write(rest);
62
+ } else {
63
+ process.stderr.write("\x1b[2K\r");
64
+ }
65
+
66
+ const costStr = costUsd > 0 ? `$${costUsd.toFixed(4)}` : "";
67
+ const turnsStr = turns > 0 ? `${turns} turn${turns !== 1 ? "s" : ""}` : "";
68
+ const meta = [costStr, turnsStr].filter(Boolean).join(" · ");
69
+ if (meta) process.stderr.write(`\n${DIM}${meta}${RESET}`);
70
+ process.stdout.write("\n");
71
+
72
+ await engine.close();
73
+ });
74
+ }
@@ -0,0 +1,13 @@
1
+ import { scanSkills } from "../core/skills";
2
+
3
+ /** `nia skills [source]` — list discovered skills, optionally filtered by source. */
4
+ export function skillsCommand(filter?: string): void {
5
+ const skills = filter ? scanSkills().filter((s) => s.source === filter) : scanSkills();
6
+ if (skills.length === 0) {
7
+ console.log(filter ? `No skills found in "${filter}".` : "No skills found.");
8
+ return;
9
+ }
10
+ for (const s of skills) {
11
+ console.log(` ${s.name}${filter ? "" : ` [${s.source}]`}`);
12
+ }
13
+ }
package/src/cli/status.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { isRunning, readPid } from "../core/daemon";
2
2
  import { readState } from "../utils/logger";
3
3
  import { getConfig } from "../utils/config";
4
- import { localTime } from "../utils/time";
5
4
  import { maskToken, safeDate, dateSortValue, formatTimeLine } from "../utils/format";
6
5
  import { Message, ActiveEngine, Job } from "../db/models";
7
6
  import type { ScheduleType, JobStateStatus, RoomStats } from "../types";
@@ -0,0 +1,32 @@
1
+ /** `nia test` — run the suite, showing only the summary unless -v. */
2
+
3
+ const SUMMARY = [/^\s*\d+ pass/, /^\s*\d+ fail/, /^Ran \d+ tests/, /expect\(\) calls/];
4
+ const FAILURE = /^✗|FAIL|error:/i;
5
+
6
+ function isWorthShowing(line: string): boolean {
7
+ return SUMMARY.some((p) => p.test(line)) || FAILURE.test(line.trim());
8
+ }
9
+
10
+ export async function testCommand(argv: string[]): Promise<never> {
11
+ const verbose = argv.includes("-v") || argv.includes("--verbose");
12
+ const extraArgs = argv.filter((a) => a !== "-v" && a !== "--verbose");
13
+
14
+ const proc = Bun.spawn(["bun", "test", ...extraArgs], {
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ cwd: import.meta.dir + "/../..",
17
+ env: { ...process.env, LOG_LEVEL: "silent" },
18
+ });
19
+
20
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
21
+ const exitCode = await proc.exited;
22
+ const output = stdout + stderr;
23
+
24
+ if (verbose) {
25
+ process.stdout.write(output);
26
+ } else {
27
+ for (const line of output.split("\n")) {
28
+ if (isWorthShowing(line)) console.log(line);
29
+ }
30
+ }
31
+ process.exit(exitCode);
32
+ }
@@ -0,0 +1,79 @@
1
+ import { isRunning, readPid } from "../utils/pid";
2
+ import { errMsg } from "../utils/errors";
3
+ import { guardActiveEngines, parseGuardFlags, withDefaultWait } from "../core/engine-guard";
4
+ import { startDaemon, stopDaemon } from "../core/daemon";
5
+
6
+ /** `nia update` — upgrade the global install, then restart if the daemon is up. */
7
+ export async function updateCommand(argv: string[]): Promise<void> {
8
+ const guard = withDefaultWait(parseGuardFlags(argv), 1);
9
+ const { version: currentVersion } = await import("../../package.json");
10
+ console.log(`Current: v${currentVersion}`);
11
+
12
+ // Never upgrade out from under a live turn.
13
+ if (isRunning() && !(await guardActiveEngines("update", guard))) process.exit(1);
14
+
15
+ try {
16
+ const { createBackup } = await import("../commands/backup");
17
+ console.log("Backing up...");
18
+ await createBackup(true);
19
+ console.log("✓ pre-update backup created");
20
+ } catch (err) {
21
+ console.log(`⚠ backup skipped: ${errMsg(err)}`);
22
+ }
23
+
24
+ console.log("Updating...");
25
+ const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
26
+ if ((await install.exited) !== 0) {
27
+ console.error("Update failed.");
28
+ process.exit(1);
29
+ }
30
+
31
+ const check = Bun.spawn(["npm", "view", "niahere", "version"], { stdout: "pipe", stderr: "pipe" });
32
+ const newVersion = (await new Response(check.stdout).text()).trim();
33
+ await check.exited;
34
+
35
+ if (newVersion === currentVersion) {
36
+ console.log("Already on latest.");
37
+ return;
38
+ }
39
+
40
+ console.log(`Updated: v${currentVersion} → v${newVersion}`);
41
+ if (!isRunning()) return;
42
+
43
+ console.log("Restarting daemon...");
44
+ const { isServiceInstalled, restartService } = await import("../commands/service");
45
+ if (isServiceInstalled()) {
46
+ await restartService({ force: guard.force });
47
+ } else {
48
+ stopDaemon({ force: guard.force });
49
+ startDaemon();
50
+ }
51
+ console.log("Restarted.");
52
+ }
53
+
54
+ /** `nia channels <on|off> [name]` — toggle channels and nudge a running daemon. */
55
+ export async function channelsToggleCommand(sub: string, target?: string): Promise<void> {
56
+ const { updateRawConfig } = await import("../utils/config");
57
+ const SUPPORTED = new Set(["telegram", "slack", "phone", "sms", "whatsapp"]);
58
+ const enabled = sub === "on";
59
+
60
+ if (target) {
61
+ if (!SUPPORTED.has(target)) {
62
+ console.error(`Usage: nia channels <on|off> [${[...SUPPORTED].join("|")}]`);
63
+ process.exit(1);
64
+ }
65
+ updateRawConfig({ channels: { ...(enabled ? { enabled: true } : {}), [target]: { enabled } } });
66
+ } else {
67
+ updateRawConfig({ channels: { enabled } });
68
+ }
69
+
70
+ const what = target ?? "channels";
71
+ const state = enabled ? "enabled" : "disabled";
72
+ const pid = readPid();
73
+ if (pid && isRunning()) {
74
+ process.kill(pid, "SIGHUP");
75
+ console.log(`${what} ${state}`);
76
+ } else {
77
+ console.log(`${what} ${state} — start nia to apply`);
78
+ }
79
+ }