niahere 0.2.90 → 0.2.91
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 +1 -1
- package/skills/nia-phone/SKILL.md +206 -0
- package/src/channels/index.ts +2 -0
- package/src/channels/phone/consult.ts +43 -0
- package/src/channels/phone/index.ts +464 -0
- package/src/channels/phone/instructions.ts +42 -0
- package/src/channels/phone/relay.ts +334 -0
- package/src/channels/phone/tools.ts +83 -0
- package/src/channels/phone/twilio.ts +125 -0
- package/src/channels/phone/twiml.ts +60 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/phone.ts +127 -0
- package/src/mcp/server.ts +24 -1
- package/src/mcp/tools.ts +37 -38
- package/src/types/config.ts +26 -0
- package/src/types/index.ts +1 -1
- package/src/utils/config.ts +71 -4
- package/src/utils/memory.ts +49 -0
package/src/cli/phone.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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. Need TWILIO_SID, TWILIO_SECRET, PHONE_FROM_NUMBER in .env (plus OPENAI_API_KEY and PUBLIC_BASE_URL for the realtime voice loop).",
|
|
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 PUBLIC_BASE_URL in .env first.`);
|
|
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
|
+
"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)",
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
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(
|
|
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(
|
|
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
|
|
444
|
-
|
|
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. Set TWILIO_SID, TWILIO_SECRET, PHONE_FROM_NUMBER, PUBLIC_BASE_URL, OPENAI_API_KEY in .env and 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
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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";
|
package/src/utils/config.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|