niahere 0.2.91 → 0.3.0

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/README.md CHANGED
@@ -36,13 +36,14 @@ nia start # starts daemon + registers OS service
36
36
 
37
37
  - **Telegram** — message your agent from your phone, typing indicator while processing
38
38
  - **Slack** — Socket Mode bot with thread awareness, thinking emoji, watch channels for proactive monitoring
39
+ - **Phone (voice)** — Twilio + OpenAI Realtime. Inbound calls from allowlisted contacts and outbound calls via `place_call` MCP tool. Scheduled jobs can dial you (morning standup, evening retro, escalation). See `/nia-phone` skill.
39
40
  - **Terminal chat** — REPL with session resume support
40
41
  - **Scheduled jobs** — recurring jobs and crons that run Claude and can message you back. Stateful by default (working memory), per-job model routing for cost savings
41
42
  - **Persona system** — customizable identity, soul, owner profile, rules, and memory (preloaded every session)
42
43
  - **Agents** — domain specialists (marketer, senior-dev) via Claude Agent SDK subagents
43
44
  - **Skills** — loads skills from multiple directories, invokable as slash commands
44
45
  - **Cross-platform service** — launchd (macOS), systemd (Linux), service-aware restart
45
- - **MCP tools** — 20 tools for job management, messaging, memory, rules, and channel control
46
+ - **MCP tools** — 21 tools for job management, messaging, memory, rules, channel control, and outbound phone calls
46
47
  - **Background memory consolidation** — stages memory candidates from conversations automatically
47
48
  - **Session summaries** — optional handoff notes between sessions for continuity
48
49
  - **Backups** — `nia backup` with auto-backup before updates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.91",
3
+ "version": "0.3.0",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -26,26 +26,43 @@ Voice to the OpenAI Realtime API. It exposes:
26
26
  Transcripts persist to the `messages` table with `channel = 'phone'` and
27
27
  `room = phone-<callSid>`.
28
28
 
29
- ## Required env vars
29
+ ## Configuration
30
30
 
31
- ```bash
32
- TWILIO_SID # Account SID (AC…) or an API Key SID (SK…)
33
- TWILIO_SECRET # Auth Token (if SID is AC) or API Key Secret (if SID is SK)
34
- TWILIO_AUTH_TOKEN # Required when SID is an API Key — signs webhooks.
35
- # Omit if TWILIO_SECRET is already the Auth Token.
36
- PRIMARY_PHONE_USER # Owner's number in E.164 (e.g. +917667078414).
37
- PHONE_FROM_NUMBER # Your Twilio number in E.164 (e.g. +13025480697).
38
- PUBLIC_BASE_URL # https://<your-tunnel-hostname> — NO trailing slash.
39
- OPENAI_API_KEY # For the Realtime voice loop.
40
-
41
- # Optional
42
- PHONE_PORT=7079 # Local port the webhook server binds to.
43
- PHONE_ALLOWLIST=+12025550100,+14155551234 # Extra allowed inbound callers.
44
- PHONE_VOICE=marin # Realtime voice (marin | cedar | shimmer | coral | alloy | ash | …).
45
- PHONE_REALTIME_MODEL=gpt-realtime # Override if you want a specific model.
31
+ Phone config lives in `~/.niahere/config.yaml` under `channels.phone` —
32
+ same place as `channels.telegram` and `channels.slack`. Every field is
33
+ overridable by the matching env var if you prefer `.env` for secrets.
34
+
35
+ ```yaml
36
+ # ~/.niahere/config.yaml
37
+ channels:
38
+ phone:
39
+ twilio_sid: AC... # Account SID or an API Key SID (SK…)
40
+ twilio_secret: ... # Auth Token if SID is AC, API Key Secret if SID is SK
41
+ twilio_auth_token:
42
+ ... # Required when twilio_sid is an API Key (SK…); signs webhooks.
43
+ # Omit if twilio_secret is already the Auth Token.
44
+ from_number: "+1..." # Your Twilio number (E.164)
45
+ owner_number: "+91..." # Owner's phone (E.164) highest-trust caller
46
+ public_base_url: https://nia.example.com # No trailing slash
47
+ openai_api_key: sk-proj-... # For the Realtime voice loop
48
+
49
+ # Optional
50
+ port: 7079 # Local port the webhook server binds to
51
+ allowlist: ["+12025550100"] # Extra allowed inbound callers (E.164)
52
+ voice: marin # Realtime voice (marin | cedar | shimmer | coral | alloy | ash | …)
53
+ realtime_model: gpt-realtime
54
+ ```
55
+
56
+ Env overrides (use these if you'd rather keep secrets in `.env`):
57
+
58
+ ```
59
+ TWILIO_SID, TWILIO_SECRET, TWILIO_AUTH_TOKEN
60
+ PHONE_FROM_NUMBER, PRIMARY_PHONE_USER
61
+ PUBLIC_BASE_URL, OPENAI_API_KEY
62
+ PHONE_PORT, PHONE_ALLOWLIST (comma-separated), PHONE_VOICE, PHONE_REALTIME_MODEL
46
63
  ```
47
64
 
48
- `nia phone status` prints which vars are set / missing.
65
+ `nia phone status` prints which fields are set / missing.
49
66
 
50
67
  ## Cloudflared named tunnel (production)
51
68
 
@@ -370,17 +370,20 @@ class PhoneChannel implements Channel {
370
370
  private requireCreds(): { accountSid: string; authToken: string } {
371
371
  const sid = this.cfg.twilio_sid;
372
372
  const secret = this.cfg.twilio_secret;
373
- if (!sid || !secret) throw new Error("phone: TWILIO_SID/TWILIO_SECRET not configured");
373
+ if (!sid || !secret)
374
+ throw new Error("phone: channels.phone.twilio_sid and twilio_secret not set (config.yaml or env)");
374
375
  return { accountSid: sid, authToken: secret };
375
376
  }
376
377
 
377
378
  private requirePublicBaseUrl(): string {
378
- if (!this.cfg.public_base_url) throw new Error("phone: PUBLIC_BASE_URL not configured");
379
+ if (!this.cfg.public_base_url)
380
+ throw new Error("phone: channels.phone.public_base_url not set (config.yaml or PUBLIC_BASE_URL env)");
379
381
  return this.cfg.public_base_url;
380
382
  }
381
383
 
382
384
  private requireFromNumber(): string {
383
- if (!this.cfg.from_number) throw new Error("phone: PHONE_FROM_NUMBER not configured");
385
+ if (!this.cfg.from_number)
386
+ throw new Error("phone: channels.phone.from_number not set (config.yaml or PHONE_FROM_NUMBER env)");
384
387
  return this.cfg.from_number;
385
388
  }
386
389
 
package/src/cli/phone.ts CHANGED
@@ -45,7 +45,7 @@ async function phoneCallCommand(): Promise<void> {
45
45
  const channel = createPhoneChannel();
46
46
  if (!channel) {
47
47
  fail(
48
- "Phone channel not configured. Need TWILIO_SID, TWILIO_SECRET, PHONE_FROM_NUMBER in .env (plus OPENAI_API_KEY and PUBLIC_BASE_URL for the realtime voice loop).",
48
+ "Phone channel not configured. Set channels.phone.{twilio_sid,twilio_secret,from_number} in ~/.niahere/config.yaml (also channels.phone.{openai_api_key,public_base_url} for the realtime voice loop). Env vars TWILIO_SID / TWILIO_SECRET / PHONE_FROM_NUMBER / OPENAI_API_KEY / PUBLIC_BASE_URL override if you prefer .env.",
49
49
  );
50
50
  }
51
51
 
@@ -53,13 +53,13 @@ async function phoneCallCommand(): Promise<void> {
53
53
  const cfg = getConfig().channels.phone;
54
54
  console.log(`${ICON_PASS} phone server up on :${cfg.port}`);
55
55
  if (!cfg.public_base_url) {
56
- console.log(`${ICON_WARN} PUBLIC_BASE_URL not set — Twilio cannot reach this server.`);
57
- console.log(` Start cloudflared (or your tunnel) and set PUBLIC_BASE_URL in .env first.`);
56
+ console.log(`${ICON_WARN} public_base_url not set — Twilio cannot reach this server.`);
57
+ console.log(` Start cloudflared (or your tunnel) and set channels.phone.public_base_url in config.yaml.`);
58
58
  await channel!.stop();
59
59
  process.exit(1);
60
60
  }
61
61
  if (!cfg.openai_api_key) {
62
- console.log(`${ICON_WARN} OPENAI_API_KEY not set — realtime voice loop will fall back to TwiML <Say>.`);
62
+ console.log(`${ICON_WARN} openai_api_key not set — realtime voice loop will fall back to TwiML <Say>.`);
63
63
  }
64
64
 
65
65
  console.log(` dialing ${number} ...`);
@@ -119,9 +119,13 @@ function helpText(): string {
119
119
  " phone server, dials, waits, prints transcript.",
120
120
  " status Show phone channel configuration.",
121
121
  "",
122
- "Required env:",
123
- " TWILIO_SID, TWILIO_SECRET, PHONE_FROM_NUMBER",
124
- " OPENAI_API_KEY (for realtime voice loop)",
125
- " PUBLIC_BASE_URL (cloudflared/ngrok tunnel pointing at PHONE_PORT)",
122
+ "Config lives in ~/.niahere/config.yaml under channels.phone:",
123
+ " twilio_sid, twilio_secret, from_number (required)",
124
+ " openai_api_key, public_base_url (required for realtime voice loop)",
125
+ " twilio_auth_token (required if twilio_sid is an API Key SID)",
126
+ " port, voice, realtime_model, allowlist (optional)",
127
+ "",
128
+ "Each field can be overridden by the matching env var (TWILIO_SID, OPENAI_API_KEY, etc.)",
129
+ "if you prefer .env. See the nia-phone skill for full deploy walkthrough.",
126
130
  ].join("\n");
127
131
  }
@@ -215,6 +215,60 @@ export async function runInit(): Promise<void> {
215
215
  }
216
216
  }
217
217
 
218
+ // Phone (Twilio Voice + OpenAI Realtime)
219
+ const exPh = (exCh.phone || {}) as Record<string, unknown>;
220
+ let phoneTwilioSid = (exPh.twilio_sid as string) || "";
221
+ let phoneTwilioSecret = (exPh.twilio_secret as string) || "";
222
+ let phoneTwilioAuthToken = (exPh.twilio_auth_token as string) || "";
223
+ let phoneFromNumber = (exPh.from_number as string) || "";
224
+ let phoneOwnerNumber = (exPh.owner_number as string) || "";
225
+ let phonePublicBaseUrl = (exPh.public_base_url as string) || "";
226
+ let phoneOpenAiKey = (exPh.openai_api_key as string) || "";
227
+ let phoneVoice = (exPh.voice as string) || "";
228
+
229
+ const existingPhoneSid = phoneTwilioSid;
230
+ if (existingPhoneSid) {
231
+ const masked = `...${existingPhoneSid.slice(-6)}`;
232
+ const reconfigure = await ask(rl, `\nPhone (Twilio + Realtime): configured (${masked}). Reconfigure? (y/n)`, "n");
233
+ if (reconfigure.toLowerCase() === "y") {
234
+ phoneTwilioSid = (await ask(rl, "Twilio SID (AC… or SK…)", phoneTwilioSid)) || phoneTwilioSid;
235
+ phoneTwilioSecret =
236
+ (await ask(rl, "Twilio Secret (Auth Token if AC, API Key Secret if SK)", phoneTwilioSecret)) ||
237
+ phoneTwilioSecret;
238
+ if (phoneTwilioSid.startsWith("SK")) {
239
+ phoneTwilioAuthToken =
240
+ (await ask(rl, "Twilio Auth Token (account-level — needed for webhook signing)", phoneTwilioAuthToken)) ||
241
+ phoneTwilioAuthToken;
242
+ }
243
+ phoneFromNumber =
244
+ (await ask(rl, "Twilio number to dial from (E.164, e.g. +13025551234)", phoneFromNumber)) || phoneFromNumber;
245
+ phoneOwnerNumber = (await ask(rl, "Your phone (E.164)", phoneOwnerNumber)) || phoneOwnerNumber;
246
+ phonePublicBaseUrl =
247
+ (await ask(rl, "Public base URL (cloudflared/ngrok https://…)", phonePublicBaseUrl)) || phonePublicBaseUrl;
248
+ phoneOpenAiKey = (await ask(rl, "OpenAI API key (for Realtime voice loop)", phoneOpenAiKey)) || phoneOpenAiKey;
249
+ phoneVoice =
250
+ (await ask(rl, "Realtime voice (marin, cedar, shimmer, coral, alloy…)", phoneVoice || "marin")) || phoneVoice;
251
+ }
252
+ } else {
253
+ const setupPhone = await ask(rl, "\nSet up phone (Twilio + OpenAI Realtime voice calls)? (y/n)", "n");
254
+ if (setupPhone.toLowerCase() === "y") {
255
+ console.log(" You'll need: a Twilio voice number, your phone number, an OpenAI API key, and a public tunnel.");
256
+ console.log(" See /nia-phone skill for the full deploy walkthrough.\n");
257
+ phoneTwilioSid = await ask(rl, "Twilio SID (AC… or SK…)", "");
258
+ if (phoneTwilioSid) {
259
+ phoneTwilioSecret = await ask(rl, "Twilio Secret (Auth Token if AC, API Key Secret if SK)", "");
260
+ if (phoneTwilioSid.startsWith("SK")) {
261
+ phoneTwilioAuthToken = await ask(rl, "Twilio Auth Token (account-level — for webhook signing)", "");
262
+ }
263
+ phoneFromNumber = await ask(rl, "Twilio number to dial from (E.164, e.g. +13025551234)", "");
264
+ phoneOwnerNumber = await ask(rl, "Your phone (E.164)", "");
265
+ phonePublicBaseUrl = await ask(rl, "Public base URL (cloudflared/ngrok https://…)", "");
266
+ phoneOpenAiKey = await ask(rl, "OpenAI API key", "");
267
+ phoneVoice = await ask(rl, "Realtime voice", "marin");
268
+ }
269
+ }
270
+ }
271
+
218
272
  // Gemini API key (for image generation)
219
273
  let geminiApiKey = "";
220
274
  const existingGemini = (existing.gemini_api_key as string) || "";
@@ -430,6 +484,19 @@ export async function runInit(): Promise<void> {
430
484
  if (slackBotToken && !telegramToken) {
431
485
  channels.default = "slack";
432
486
  }
487
+ if (phoneTwilioSid && phoneTwilioSecret && phoneFromNumber) {
488
+ const ph: Record<string, unknown> = {
489
+ twilio_sid: phoneTwilioSid,
490
+ twilio_secret: phoneTwilioSecret,
491
+ from_number: phoneFromNumber,
492
+ };
493
+ if (phoneTwilioAuthToken) ph.twilio_auth_token = phoneTwilioAuthToken;
494
+ if (phoneOwnerNumber) ph.owner_number = phoneOwnerNumber;
495
+ if (phonePublicBaseUrl) ph.public_base_url = phonePublicBaseUrl.replace(/\/$/, "");
496
+ if (phoneOpenAiKey) ph.openai_api_key = phoneOpenAiKey;
497
+ if (phoneVoice && phoneVoice !== "marin") ph.voice = phoneVoice;
498
+ channels.phone = ph;
499
+ }
433
500
  if (Object.keys(channels).length > 0) {
434
501
  config.channels = channels;
435
502
  }
package/src/mcp/tools.ts CHANGED
@@ -480,7 +480,7 @@ export async function placeCall(args: {
480
480
  const { getPhoneChannel } = await import("../channels/phone");
481
481
  const phone = getPhoneChannel();
482
482
  if (!phone) {
483
- return "Phone channel is not configured. Set TWILIO_SID, TWILIO_SECRET, PHONE_FROM_NUMBER, PUBLIC_BASE_URL, OPENAI_API_KEY in .env and restart the daemon.";
483
+ return "Phone channel is not configured. Add a channels.phone block to ~/.niahere/config.yaml with twilio_sid, twilio_secret, from_number, public_base_url, openai_api_key (or set the matching env vars in .env), then restart the daemon.";
484
484
  }
485
485
  try {
486
486
  const result = await phone.placeCall({