niahere 0.2.90 → 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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `nia phone <subcommand>` — small CLI surface for the phone channel.
3
+ *
4
+ * Subcommands:
5
+ * call <number> <goal...> — place an outbound call, wait, print transcript
6
+ * status — show phone channel config + state
7
+ *
8
+ * The call subcommand boots a standalone phone channel server, places the
9
+ * call, waits for it to complete, then exits. It does NOT start the full
10
+ * daemon — useful for smoke-testing voice end-to-end without the daemon.
11
+ */
12
+ import { createPhoneChannel } from "../channels/phone";
13
+ import { getConfig } from "../utils/config";
14
+ import { fail, ICON_PASS, ICON_WARN } from "../utils/cli";
15
+
16
+ export async function phoneCommand(): Promise<void> {
17
+ const sub = process.argv[3];
18
+
19
+ switch (sub) {
20
+ case "call":
21
+ await phoneCallCommand();
22
+ return;
23
+ case "status":
24
+ phoneStatusCommand();
25
+ return;
26
+ case undefined:
27
+ case "help":
28
+ case "--help":
29
+ case "-h":
30
+ printHelp();
31
+ return;
32
+ default:
33
+ fail(`Unknown phone subcommand: ${sub}\n\n${helpText()}`);
34
+ }
35
+ }
36
+
37
+ async function phoneCallCommand(): Promise<void> {
38
+ const number = process.argv[4];
39
+ const goalParts = process.argv.slice(5);
40
+ if (!number || goalParts.length === 0) {
41
+ fail('Usage: nia phone call <e164-number> "<goal sentence...>"');
42
+ }
43
+ const goal = goalParts.join(" ");
44
+
45
+ const channel = createPhoneChannel();
46
+ if (!channel) {
47
+ fail(
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
+ );
50
+ }
51
+
52
+ await channel!.start();
53
+ const cfg = getConfig().channels.phone;
54
+ console.log(`${ICON_PASS} phone server up on :${cfg.port}`);
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 channels.phone.public_base_url in config.yaml.`);
58
+ await channel!.stop();
59
+ process.exit(1);
60
+ }
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>.`);
63
+ }
64
+
65
+ console.log(` dialing ${number} ...`);
66
+ console.log(` goal: ${goal}`);
67
+
68
+ const result = await channel!.placeCall({
69
+ number,
70
+ goal,
71
+ maxMinutes: 5,
72
+ });
73
+ console.log(`${ICON_PASS} call placed: ${result.callSid} (${result.status})`);
74
+
75
+ console.log(` waiting for call to complete...`);
76
+ const completion = await channel!.awaitCallCompletion(result.callSid);
77
+ if (!completion) {
78
+ console.log(`${ICON_WARN} no completion handle for ${result.callSid}`);
79
+ await channel!.stop();
80
+ return;
81
+ }
82
+
83
+ console.log("");
84
+ console.log(`--- transcript (${completion.transcript.length} turns, ended: ${completion.endedReason}) ---`);
85
+ for (const turn of completion.transcript) {
86
+ console.log(` ${turn.role}: ${turn.text}`);
87
+ }
88
+ if (completion.error) console.log(` error: ${completion.error}`);
89
+
90
+ await channel!.stop();
91
+ }
92
+
93
+ function phoneStatusCommand(): void {
94
+ const cfg = getConfig().channels.phone;
95
+ const lines = [
96
+ `from: ${cfg.from_number ?? "(not set)"}`,
97
+ `owner: ${cfg.owner_number ?? "(not set)"}`,
98
+ `allowlist: ${cfg.allowlist.length ? cfg.allowlist.join(", ") : "(empty)"}`,
99
+ `port: ${cfg.port}`,
100
+ `public_base_url:${cfg.public_base_url ?? "(not set)"}`,
101
+ `realtime_model: ${cfg.realtime_model}`,
102
+ `voice: ${cfg.voice}`,
103
+ `twilio creds: ${cfg.twilio_sid && cfg.twilio_secret ? "configured" : "MISSING"}`,
104
+ `openai key: ${cfg.openai_api_key ? "configured" : "MISSING"}`,
105
+ ];
106
+ console.log(lines.join("\n"));
107
+ }
108
+
109
+ function printHelp(): void {
110
+ console.log(helpText());
111
+ }
112
+
113
+ function helpText(): string {
114
+ return [
115
+ "Usage: nia phone <subcommand>",
116
+ "",
117
+ "Subcommands:",
118
+ ' call <e164-number> "<goal>" Place an outbound call. Boots a standalone',
119
+ " phone server, dials, waits, prints transcript.",
120
+ " status Show phone channel configuration.",
121
+ "",
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.",
130
+ ].join("\n");
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/server.ts CHANGED
@@ -158,7 +158,9 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
158
158
  target: z
159
159
  .enum(["auto", "dm", "thread"])
160
160
  .default("auto")
161
- .describe("Where to send: 'auto' (current context — thread if in one, else DM), 'dm' (always DM the owner), 'thread' (reply in current thread)"),
161
+ .describe(
162
+ "Where to send: 'auto' (current context — thread if in one, else DM), 'dm' (always DM the owner), 'thread' (reply in current thread)",
163
+ ),
162
164
  },
163
165
  async (args) => ({
164
166
  content: [
@@ -349,6 +351,27 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
349
351
  content: [{ type: "text" as const, text: handlers.listEmployees() }],
350
352
  }),
351
353
  ),
354
+ tool(
355
+ "place_call",
356
+ "Place an outbound phone call. Nia dials the number, introduces herself, and pursues the stated goal. Use for appointments, vendor follow-ups, scheduled standup calls to the owner, or anything that's faster by voice than by message.",
357
+ {
358
+ number: z.string().describe("E.164 phone number to dial (e.g. +13025551234)."),
359
+ goal: z
360
+ .string()
361
+ .describe(
362
+ "What this call should accomplish, in plain English. Seeded into the voice agent's instructions.",
363
+ ),
364
+ context: z
365
+ .string()
366
+ .optional()
367
+ .describe("Extra background to seed the call (calendar dump, prior notes, etc.)."),
368
+ max_minutes: z.number().optional().describe("Hard cap on call duration in minutes (default 10, max 30)."),
369
+ voice: z.string().optional().describe("Override the default realtime voice for this call."),
370
+ },
371
+ async (args) => ({
372
+ content: [{ type: "text" as const, text: await handlers.placeCall(args) }],
373
+ }),
374
+ ),
352
375
  ],
353
376
  });
354
377
  }
package/src/mcp/tools.ts CHANGED
@@ -12,6 +12,7 @@ import { classifyMime } from "../utils/attachment";
12
12
  import { scanAgents } from "../core/agents";
13
13
  import { listEmployeesForMcp } from "../core/employees";
14
14
  import { resolveJobPrompt } from "../core/job-prompt";
15
+ import { readMemory as readMemoryUtil, addMemory as addMemoryUtil } from "../utils/memory";
15
16
  import type { McpSourceContext } from "./index";
16
17
 
17
18
  export async function listJobs(): Promise<string> {
@@ -240,7 +241,13 @@ async function sendMediaDirect(target: string, data: Buffer, mimeType: string, f
240
241
  throw new Error(`Channel "${target}" not configured`);
241
242
  }
242
243
 
243
- export async function sendMessage(text: string, channelName?: string, mediaPath?: string, sourceCtx?: McpSourceContext, target: "auto" | "dm" | "thread" = "auto"): Promise<string> {
244
+ export async function sendMessage(
245
+ text: string,
246
+ channelName?: string,
247
+ mediaPath?: string,
248
+ sourceCtx?: McpSourceContext,
249
+ target: "auto" | "dm" | "thread" = "auto",
250
+ ): Promise<string> {
244
251
  const config = getConfig();
245
252
  const channelTarget = channelName || config.channels.default;
246
253
 
@@ -440,43 +447,8 @@ export function disableWatchChannel(name: string): string {
440
447
  return `Watch channel "${name}" disabled. Takes effect on next message.`;
441
448
  }
442
449
 
443
- export function readMemory(): string {
444
- const { selfDir } = getPaths();
445
- const memoryPath = join(selfDir, "memory.md");
446
- if (!existsSync(memoryPath)) return "No memories saved yet.";
447
- const content = readFileSync(memoryPath, "utf8").trim();
448
- // Extract just the entries, skip the header/instructions
449
- const lines = content.split("\n").filter((l) => l.startsWith("- ") || l.startsWith("## "));
450
- if (lines.length === 0) return "No memories saved yet.";
451
- return lines.join("\n");
452
- }
453
-
454
- export function addMemory(entry: string): string {
455
- // Guard: reject raw logs, transcripts, and overly long entries
456
- const trimmed = entry.trim();
457
- if (!trimmed) return "Rejected: empty entry.";
458
- if (trimmed.length > 300) return "Rejected: too long (max 300 chars). Distill to a single concise insight.";
459
- if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag"))
460
- return "Rejected: no raw conversation transcripts.";
461
- if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
462
-
463
- const { selfDir } = getPaths();
464
- const memoryPath = join(selfDir, "memory.md");
465
- const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
466
-
467
- // TODO: add semantic dedup later (embeddings or similar)
468
-
469
- const date = new Date().toISOString().slice(0, 10);
470
- const header = `\n## ${date}`;
471
-
472
- if (existing.includes(header)) {
473
- const updated = existing.replace(header, `${header}\n- ${trimmed}`);
474
- writeFileSync(memoryPath, updated, "utf8");
475
- } else {
476
- appendFileSync(memoryPath, `${header}\n- ${trimmed}\n`, "utf8");
477
- }
478
- return `Memory saved.`;
479
- }
450
+ export const readMemory = readMemoryUtil;
451
+ export const addMemory = addMemoryUtil;
480
452
 
481
453
  export function listAgents(): string {
482
454
  const agents = scanAgents();
@@ -496,3 +468,30 @@ export function listAgents(): string {
496
468
  export function listEmployees(): string {
497
469
  return listEmployeesForMcp();
498
470
  }
471
+
472
+ export async function placeCall(args: {
473
+ number: string;
474
+ goal: string;
475
+ context?: string;
476
+ max_minutes?: number;
477
+ voice?: string;
478
+ }): Promise<string> {
479
+ // Dynamic import avoids a static cycle with channels/phone -> mcp/tools.
480
+ const { getPhoneChannel } = await import("../channels/phone");
481
+ const phone = getPhoneChannel();
482
+ if (!phone) {
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
+ }
485
+ try {
486
+ const result = await phone.placeCall({
487
+ number: args.number,
488
+ goal: args.goal,
489
+ context: args.context,
490
+ maxMinutes: args.max_minutes,
491
+ voice: args.voice,
492
+ });
493
+ return `Call placed. callSid=${result.callSid} status=${result.status}. Transcript will land in messages once the call completes.`;
494
+ } catch (err) {
495
+ return `place_call failed: ${err instanceof Error ? err.message : String(err)}`;
496
+ }
497
+ }
@@ -28,11 +28,37 @@ export interface SlackConfig {
28
28
  watch: Record<string, SlackWatchChannel> | null;
29
29
  }
30
30
 
31
+ export interface PhoneConfig {
32
+ twilio_sid: string | null;
33
+ twilio_secret: string | null;
34
+ /** Account-level Auth Token used to verify X-Twilio-Signature on inbound webhooks.
35
+ * Falls back to twilio_secret if not set (works when twilio_sid is the Account SID
36
+ * and twilio_secret is the Auth Token). */
37
+ twilio_auth_token: string | null;
38
+ /** Twilio number Nia dials from (E.164, e.g. +13025480697) */
39
+ from_number: string | null;
40
+ /** Owner's phone number (E.164). Highest-trust caller. */
41
+ owner_number: string | null;
42
+ /** Extra allowlisted E.164 numbers (family, close contacts). */
43
+ allowlist: string[];
44
+ /** Public base URL Twilio hits (e.g. https://nia.example.com). No trailing slash. */
45
+ public_base_url: string | null;
46
+ /** Local HTTP port for the Twilio webhook server. */
47
+ port: number;
48
+ /** OpenAI API key for the Realtime voice loop. */
49
+ openai_api_key: string | null;
50
+ /** OpenAI Realtime model id. */
51
+ realtime_model: string;
52
+ /** Realtime voice name (marin, alloy, echo, etc.). */
53
+ voice: string;
54
+ }
55
+
31
56
  export interface ChannelsConfig {
32
57
  enabled: boolean;
33
58
  default: string;
34
59
  telegram: TelegramConfig;
35
60
  slack: SlackConfig;
61
+ phone: PhoneConfig;
36
62
  }
37
63
 
38
64
  export interface SessionFinalizationConfig {
@@ -5,7 +5,7 @@ export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatE
5
5
  export type { AuditEntry, JobState, CronState } from "./audit";
6
6
  export type { Channel, ChannelFactory } from "./channel";
7
7
  export type { ChatState } from "./chat-state";
8
- export type { Config, ChannelsConfig, TelegramConfig, SlackConfig } from "./config";
8
+ export type { Config, ChannelsConfig, TelegramConfig, SlackConfig, PhoneConfig } from "./config";
9
9
  export type { Paths } from "./paths";
10
10
  export type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "./message";
11
11
  export type { AgentInfo } from "./agent";
@@ -36,6 +36,19 @@ const DEFAULTS: Config = {
36
36
  workspace_url: null,
37
37
  watch: null,
38
38
  },
39
+ phone: {
40
+ twilio_sid: null,
41
+ twilio_secret: null,
42
+ twilio_auth_token: null,
43
+ from_number: null,
44
+ owner_number: null,
45
+ allowlist: [],
46
+ public_base_url: null,
47
+ port: 7079,
48
+ openai_api_key: null,
49
+ realtime_model: "gpt-realtime",
50
+ voice: "marin",
51
+ },
39
52
  },
40
53
  };
41
54
 
@@ -117,6 +130,7 @@ export function loadConfig(): Config {
117
130
  const ch = (raw.channels || {}) as Record<string, unknown>;
118
131
  const chTg = (ch.telegram || {}) as Record<string, unknown>;
119
132
  const chSl = (ch.slack || {}) as Record<string, unknown>;
133
+ const chPh = (ch.phone || {}) as Record<string, unknown>;
120
134
 
121
135
  const channelsEnabled = ch.enabled !== false;
122
136
 
@@ -137,11 +151,10 @@ export function loadConfig(): Config {
137
151
  const slAppToken = process.env.SLACK_APP_TOKEN || (typeof chSl.app_token === "string" ? chSl.app_token : null);
138
152
 
139
153
  // Legacy: channel_id was removed in favor of dm_user_id. Fall back to channel_id if dm_user_id is not set.
140
- const legacyChannelId = process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
154
+ const legacyChannelId =
155
+ process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
141
156
  const slDmUserId =
142
- process.env.SLACK_DM_USER_ID ||
143
- (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null) ||
144
- legacyChannelId;
157
+ process.env.SLACK_DM_USER_ID || (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null) || legacyChannelId;
145
158
 
146
159
  const slBotUserId = typeof chSl.bot_user_id === "string" ? chSl.bot_user_id : null;
147
160
  const slBotName = typeof chSl.bot_name === "string" ? chSl.bot_name : null;
@@ -149,6 +162,47 @@ export function loadConfig(): Config {
149
162
  const slWorkspaceId = typeof chSl.workspace_id === "string" ? chSl.workspace_id : null;
150
163
  const slWorkspaceUrl = typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
151
164
 
165
+ // Phone — env vars override config; secrets are env-only by convention
166
+ const phTwilioSid = process.env.TWILIO_SID || (typeof chPh.twilio_sid === "string" ? chPh.twilio_sid : null);
167
+ const phTwilioSecret =
168
+ process.env.TWILIO_SECRET || (typeof chPh.twilio_secret === "string" ? chPh.twilio_secret : null);
169
+ const phTwilioAuthToken =
170
+ process.env.TWILIO_AUTH_TOKEN || (typeof chPh.twilio_auth_token === "string" ? chPh.twilio_auth_token : null);
171
+ const phFromNumber =
172
+ process.env.PHONE_FROM_NUMBER || (typeof chPh.from_number === "string" ? chPh.from_number : null);
173
+ const phOwnerNumber =
174
+ process.env.PRIMARY_PHONE_USER || (typeof chPh.owner_number === "string" ? chPh.owner_number : null);
175
+ const phPublicBaseUrl =
176
+ (
177
+ process.env.PUBLIC_BASE_URL ||
178
+ (typeof chPh.public_base_url === "string" ? chPh.public_base_url : null) ||
179
+ ""
180
+ ).replace(/\/$/, "") || null;
181
+ const phPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
182
+ const phPort =
183
+ phPortRaw && Number.isFinite(phPortRaw)
184
+ ? phPortRaw
185
+ : typeof chPh.port === "number"
186
+ ? chPh.port
187
+ : DEFAULTS.channels.phone.port;
188
+ const phOpenAiKey =
189
+ process.env.OPENAI_API_KEY || (typeof chPh.openai_api_key === "string" ? chPh.openai_api_key : null);
190
+ const phRealtimeModel =
191
+ process.env.PHONE_REALTIME_MODEL ||
192
+ (typeof chPh.realtime_model === "string" ? chPh.realtime_model : DEFAULTS.channels.phone.realtime_model);
193
+ const phVoice =
194
+ process.env.PHONE_VOICE || (typeof chPh.voice === "string" ? chPh.voice : DEFAULTS.channels.phone.voice);
195
+
196
+ const phAllowlistRaw =
197
+ process.env.PHONE_ALLOWLIST ||
198
+ (Array.isArray(chPh.allowlist)
199
+ ? (chPh.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
200
+ : "");
201
+ const phAllowlist = phAllowlistRaw
202
+ .split(",")
203
+ .map((s) => s.trim())
204
+ .filter((s) => s.length > 0);
205
+
152
206
  // Slack watch channels — behavior is optional (defaults to key name lookup)
153
207
  const rawWatch = chSl.watch as Record<string, unknown> | undefined;
154
208
  let slWatch: Record<string, { behavior?: string; enabled: boolean }> | null = null;
@@ -188,6 +242,19 @@ export function loadConfig(): Config {
188
242
  workspace_url: slWorkspaceUrl,
189
243
  watch: slWatch,
190
244
  },
245
+ phone: {
246
+ twilio_sid: phTwilioSid,
247
+ twilio_secret: phTwilioSecret,
248
+ twilio_auth_token: phTwilioAuthToken,
249
+ from_number: phFromNumber,
250
+ owner_number: phOwnerNumber,
251
+ allowlist: phAllowlist,
252
+ public_base_url: phPublicBaseUrl,
253
+ port: phPort,
254
+ openai_api_key: phOpenAiKey,
255
+ realtime_model: phRealtimeModel,
256
+ voice: phVoice,
257
+ },
191
258
  },
192
259
  };
193
260
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Read/write helpers for the persona memory file (~/.niahere/self/memory.md).
3
+ *
4
+ * Lives in utils/ so both the MCP tools (chat surface) and channel modules
5
+ * (e.g. phone) can share the same validation + write semantics without
6
+ * creating an import cycle.
7
+ */
8
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { getPaths } from "./paths";
11
+
12
+ export function readMemory(): string {
13
+ const { selfDir } = getPaths();
14
+ const memoryPath = join(selfDir, "memory.md");
15
+ if (!existsSync(memoryPath)) return "No memories saved yet.";
16
+ const content = readFileSync(memoryPath, "utf8").trim();
17
+ const lines = content.split("\n").filter((l) => l.startsWith("- ") || l.startsWith("## "));
18
+ if (lines.length === 0) return "No memories saved yet.";
19
+ return lines.join("\n");
20
+ }
21
+
22
+ /**
23
+ * Append a single concise insight under today's date heading. Returns a
24
+ * human-readable result string for the caller (MCP tool / phone tool) to
25
+ * relay back to the model.
26
+ */
27
+ export function addMemory(entry: string): string {
28
+ const trimmed = entry.trim();
29
+ if (!trimmed) return "Rejected: empty entry.";
30
+ if (trimmed.length > 300) return "Rejected: too long (max 300 chars). Distill to a single concise insight.";
31
+ if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag"))
32
+ return "Rejected: no raw conversation transcripts.";
33
+ if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
34
+
35
+ const { selfDir } = getPaths();
36
+ const memoryPath = join(selfDir, "memory.md");
37
+ const existing = existsSync(memoryPath) ? readFileSync(memoryPath, "utf8") : "";
38
+
39
+ const date = new Date().toISOString().slice(0, 10);
40
+ const header = `\n## ${date}`;
41
+
42
+ if (existing.includes(header)) {
43
+ const updated = existing.replace(header, `${header}\n- ${trimmed}`);
44
+ writeFileSync(memoryPath, updated, "utf8");
45
+ } else {
46
+ appendFileSync(memoryPath, `${header}\n- ${trimmed}\n`, "utf8");
47
+ }
48
+ return `Memory saved.`;
49
+ }