heyhank 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// ─── Vector Store ───────────────────────────────────────────────────────────
|
|
2
|
+
// Wraps vectra LocalIndex for local semantic vector storage + migration from old Notes.
|
|
3
|
+
|
|
4
|
+
import { LocalIndex } from "vectra";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
const INDEX_PATH = join(HEYHANK_HOME, "memory", "vector-index");
|
|
10
|
+
const MIGRATED_MARKER = join(HEYHANK_HOME, "memory", ".migrated");
|
|
11
|
+
|
|
12
|
+
let index: LocalIndex | null = null;
|
|
13
|
+
|
|
14
|
+
export interface VectorItem {
|
|
15
|
+
id: string;
|
|
16
|
+
content: string;
|
|
17
|
+
metadata: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function ensureIndex(): Promise<LocalIndex> {
|
|
21
|
+
if (index) return index;
|
|
22
|
+
|
|
23
|
+
mkdirSync(INDEX_PATH, { recursive: true });
|
|
24
|
+
const idx = new LocalIndex(INDEX_PATH);
|
|
25
|
+
|
|
26
|
+
if (!await idx.isIndexCreated()) {
|
|
27
|
+
await idx.createIndex();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
index = idx;
|
|
31
|
+
|
|
32
|
+
// Run migration from old notes if needed
|
|
33
|
+
await migrateFromNotes();
|
|
34
|
+
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function migrateFromNotes(): Promise<void> {
|
|
39
|
+
if (existsSync(MIGRATED_MARKER)) return;
|
|
40
|
+
|
|
41
|
+
const notesPath = join(HEYHANK_HOME, "assistant", "notes.json");
|
|
42
|
+
if (!existsSync(notesPath)) {
|
|
43
|
+
// No notes to migrate, mark as done
|
|
44
|
+
mkdirSync(join(HEYHANK_HOME, "memory"), { recursive: true });
|
|
45
|
+
writeFileSync(MIGRATED_MARKER, new Date().toISOString(), "utf-8");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const notes = JSON.parse(readFileSync(notesPath, "utf-8")) as Array<{
|
|
51
|
+
id: string;
|
|
52
|
+
title: string;
|
|
53
|
+
content: string;
|
|
54
|
+
tags: string[];
|
|
55
|
+
createdAt: string;
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
const memoryNotes = notes.filter(
|
|
59
|
+
(n) => n.tags?.includes("memory") || n.tags?.includes("hank-memory")
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (memoryNotes.length > 0) {
|
|
63
|
+
console.log(`[vector-store] Migrating ${memoryNotes.length} memory notes to vector index...`);
|
|
64
|
+
|
|
65
|
+
// Import embedding lazily to avoid circular deps
|
|
66
|
+
const { getEmbedding } = await import("./embedding-service.js");
|
|
67
|
+
|
|
68
|
+
for (const note of memoryNotes) {
|
|
69
|
+
try {
|
|
70
|
+
const embedding = await getEmbedding(note.content, "passage: ");
|
|
71
|
+
await index!.insertItem({
|
|
72
|
+
vector: embedding,
|
|
73
|
+
metadata: {
|
|
74
|
+
id: note.id,
|
|
75
|
+
content: note.content,
|
|
76
|
+
createdAt: note.createdAt,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
// If embedding fails, store with zero vector
|
|
81
|
+
const zeroVector = new Array(384).fill(0);
|
|
82
|
+
await index!.insertItem({
|
|
83
|
+
vector: zeroVector,
|
|
84
|
+
metadata: {
|
|
85
|
+
id: note.id,
|
|
86
|
+
content: note.content,
|
|
87
|
+
createdAt: note.createdAt,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log(`[vector-store] Migration complete.`);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error("[vector-store] Migration error:", err);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
writeFileSync(MIGRATED_MARKER, new Date().toISOString(), "utf-8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function addItem(
|
|
102
|
+
id: string,
|
|
103
|
+
content: string,
|
|
104
|
+
embedding: number[],
|
|
105
|
+
metadata: Record<string, unknown> = {}
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const idx = await ensureIndex();
|
|
108
|
+
await idx.insertItem({
|
|
109
|
+
vector: embedding,
|
|
110
|
+
metadata: { id, content, ...metadata },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function searchByVector(embedding: number[], topK = 5): Promise<VectorItem[]> {
|
|
115
|
+
const idx = await ensureIndex();
|
|
116
|
+
const results = await idx.queryItems(embedding, "", topK);
|
|
117
|
+
return results.map((r) => ({
|
|
118
|
+
id: (r.item.metadata as Record<string, unknown>).id as string,
|
|
119
|
+
content: (r.item.metadata as Record<string, unknown>).content as string,
|
|
120
|
+
metadata: r.item.metadata as Record<string, unknown>,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function keywordSearch(query: string, limit = 5): Promise<VectorItem[]> {
|
|
125
|
+
const idx = await ensureIndex();
|
|
126
|
+
const allItems = await idx.listItems();
|
|
127
|
+
const q = query.toLowerCase();
|
|
128
|
+
const matches: VectorItem[] = [];
|
|
129
|
+
|
|
130
|
+
for (const item of allItems) {
|
|
131
|
+
const meta = item.metadata as Record<string, unknown>;
|
|
132
|
+
const content = (meta.content as string) || "";
|
|
133
|
+
if (content.toLowerCase().includes(q)) {
|
|
134
|
+
matches.push({
|
|
135
|
+
id: meta.id as string,
|
|
136
|
+
content,
|
|
137
|
+
metadata: meta,
|
|
138
|
+
});
|
|
139
|
+
if (matches.length >= limit) break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return matches;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function listAll(): Promise<VectorItem[]> {
|
|
147
|
+
const idx = await ensureIndex();
|
|
148
|
+
const allItems = await idx.listItems();
|
|
149
|
+
return allItems.map((item) => {
|
|
150
|
+
const meta = item.metadata as Record<string, unknown>;
|
|
151
|
+
return {
|
|
152
|
+
id: meta.id as string,
|
|
153
|
+
content: (meta.content as string) || "",
|
|
154
|
+
metadata: meta,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function deleteItem(id: string): Promise<boolean> {
|
|
160
|
+
const idx = await ensureIndex();
|
|
161
|
+
const allItems = await idx.listItems();
|
|
162
|
+
for (const item of allItems) {
|
|
163
|
+
const meta = item.metadata as Record<string, unknown>;
|
|
164
|
+
if (meta.id === id) {
|
|
165
|
+
await idx.deleteItem(item.id);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function rebuildFromVault(vaultMemoryDir: string): Promise<void> {
|
|
173
|
+
const { readdirSync, readFileSync } = await import("node:fs");
|
|
174
|
+
const { join } = await import("node:path");
|
|
175
|
+
const { parseFrontmatter } = await import("./vault-markdown.js");
|
|
176
|
+
const { getEmbedding } = await import("./embedding-service.js");
|
|
177
|
+
|
|
178
|
+
const files = readdirSync(vaultMemoryDir).filter(f => f.startsWith("memory-") && f.endsWith(".md"));
|
|
179
|
+
if (files.length === 0) return;
|
|
180
|
+
|
|
181
|
+
// Get existing indexed IDs to skip
|
|
182
|
+
const existingItems = await listAll();
|
|
183
|
+
const existingIds = new Set(existingItems.map(i => i.id));
|
|
184
|
+
|
|
185
|
+
let indexed = 0;
|
|
186
|
+
for (const file of files) {
|
|
187
|
+
const id = file.replace(/^memory-/, "").replace(/\.md$/, "");
|
|
188
|
+
if (existingIds.has(id)) continue;
|
|
189
|
+
|
|
190
|
+
const content = readFileSync(join(vaultMemoryDir, file), "utf-8");
|
|
191
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
192
|
+
|
|
193
|
+
let embedding: number[];
|
|
194
|
+
try {
|
|
195
|
+
embedding = await getEmbedding(body, "passage: ");
|
|
196
|
+
} catch {
|
|
197
|
+
embedding = new Array(384).fill(0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const metadata: Record<string, unknown> = {};
|
|
201
|
+
if (frontmatter.createdAt) metadata.createdAt = frontmatter.createdAt;
|
|
202
|
+
if (frontmatter.category) metadata.category = frontmatter.category;
|
|
203
|
+
if (frontmatter.source) metadata.source = frontmatter.source;
|
|
204
|
+
|
|
205
|
+
await addItem(id, body, embedding, metadata);
|
|
206
|
+
indexed++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (indexed > 0) console.log(`[vector-store] Indexed ${indexed} new memories from vault`);
|
|
210
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ─── Gemini Live Adapter ─────────────────────────────────────────────────────
|
|
2
|
+
// Wraps the existing AudioBridge (Gemini Live) so it conforms to the
|
|
3
|
+
// VoiceEngineSession interface. Lets call-manager use both engines uniformly.
|
|
4
|
+
|
|
5
|
+
import type { ServerWebSocket } from "bun";
|
|
6
|
+
import { AudioBridge, base64ToBuffer, downsampleTo8k } from "../telephony/audio-bridge.js";
|
|
7
|
+
import type { TelephonyContact, TranscriptEntry, CallState, TelephonySettings } from "../telephony/call-types.js";
|
|
8
|
+
import type { VoiceEngineSession } from "./manager.js";
|
|
9
|
+
|
|
10
|
+
interface GeminiLiveAdapterParams {
|
|
11
|
+
callId: string;
|
|
12
|
+
voice: string;
|
|
13
|
+
systemPrompt: string;
|
|
14
|
+
geminiKey: string;
|
|
15
|
+
telSettings: TelephonySettings;
|
|
16
|
+
tools: unknown[];
|
|
17
|
+
isInbound: boolean;
|
|
18
|
+
onTranscript: (entry: TranscriptEntry) => void;
|
|
19
|
+
onStatusChange: (status: CallState["status"]) => void;
|
|
20
|
+
onToolCall: (calls: Array<{ id: string; name: string; args: Record<string, unknown> }>) => Promise<Array<{ id: string; name: string; response: unknown }>>;
|
|
21
|
+
/** For listen-mode: receives raw 24kHz base64 PCM from Gemini before downsampling */
|
|
22
|
+
onRawGeminiAudio?: (base64Pcm: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createGeminiLiveEngine(params: GeminiLiveAdapterParams): VoiceEngineSession & { bridge: AudioBridge } {
|
|
26
|
+
const { callId, voice, systemPrompt, geminiKey, telSettings, tools, isInbound, onTranscript, onStatusChange, onToolCall, onRawGeminiAudio } = params;
|
|
27
|
+
|
|
28
|
+
const bridge = new AudioBridge(callId, {
|
|
29
|
+
geminiApiKey: geminiKey,
|
|
30
|
+
voice,
|
|
31
|
+
systemPrompt,
|
|
32
|
+
vertexAI: telSettings.geminiBackend === "vertexai" ? {
|
|
33
|
+
enabled: true,
|
|
34
|
+
projectId: telSettings.gcpProjectId,
|
|
35
|
+
location: telSettings.gcpLocation,
|
|
36
|
+
serviceAccountKey: telSettings.gcpServiceAccountKey,
|
|
37
|
+
} : undefined,
|
|
38
|
+
tools,
|
|
39
|
+
onTranscript,
|
|
40
|
+
onStatusChange,
|
|
41
|
+
onToolCall,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let onAudioCb: (pcm8k: Uint8Array) => void = () => {};
|
|
45
|
+
let onTurnCompleteCb: (() => void) | undefined;
|
|
46
|
+
|
|
47
|
+
// Gemini emits base64 24kHz PCM → downsample to 8kHz for FreeSWITCH
|
|
48
|
+
bridge.onGeminiAudio = (base64Pcm: string) => {
|
|
49
|
+
onRawGeminiAudio?.(base64Pcm);
|
|
50
|
+
const pcm = base64ToBuffer(base64Pcm);
|
|
51
|
+
const downsampled = downsampleTo8k(pcm, 24000);
|
|
52
|
+
onAudioCb(downsampled);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
bridge.onTurnComplete = () => {
|
|
56
|
+
onTurnCompleteCb?.();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const engine: VoiceEngineSession & { bridge: AudioBridge } = {
|
|
60
|
+
bridge,
|
|
61
|
+
engineLabel: telSettings.geminiBackend === "vertexai" ? "Gemini Live (Vertex AI)" : "Gemini Live (AI Studio)",
|
|
62
|
+
onAudio: () => {},
|
|
63
|
+
async connect() {
|
|
64
|
+
await bridge.connect();
|
|
65
|
+
},
|
|
66
|
+
sendCallerAudio(pcm) {
|
|
67
|
+
bridge.sendCallerAudio(pcm);
|
|
68
|
+
},
|
|
69
|
+
playGreetingIfReady() {
|
|
70
|
+
// Gemini Live needs a text trigger to start speaking (no pre-rendered greeting)
|
|
71
|
+
bridge.sendTrigger(isInbound
|
|
72
|
+
? "A caller is now on the line. Start speaking NOW — greet them warmly and ask how you can help."
|
|
73
|
+
: "The callee just picked up the phone. Start speaking NOW — greet them immediately according to your instructions.");
|
|
74
|
+
},
|
|
75
|
+
disconnect() {
|
|
76
|
+
bridge.disconnect();
|
|
77
|
+
},
|
|
78
|
+
get isReady() {
|
|
79
|
+
return bridge.isReady;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Wire the audio callback so call-manager can patch it
|
|
84
|
+
Object.defineProperty(engine, "onAudio", {
|
|
85
|
+
get() { return onAudioCb; },
|
|
86
|
+
set(fn: (pcm8k: Uint8Array) => void) { onAudioCb = fn; },
|
|
87
|
+
});
|
|
88
|
+
Object.defineProperty(engine, "onTurnComplete", {
|
|
89
|
+
get() { return onTurnCompleteCb; },
|
|
90
|
+
set(fn: () => void) { onTurnCompleteCb = fn; },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return engine;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Re-export for convenience
|
|
97
|
+
export type { ServerWebSocket };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ─── Greeting Cache ─────────────────────────────────────────────────────────
|
|
2
|
+
// Pre-renders greeting MP3s/PCM for instant playback at call answer.
|
|
3
|
+
// Cache key: contactId + direction + voice + text-hash.
|
|
4
|
+
// Auto-invalidates when contact name/script changes.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { TelephonyContact } from "../telephony/call-types.js";
|
|
11
|
+
import type { TTSConfig, VoicePipelineSettings } from "./types.js";
|
|
12
|
+
import { getTTSProvider } from "./providers/index.js";
|
|
13
|
+
import { normalizePhoneE164 } from "../telephony/phone-utils.js";
|
|
14
|
+
|
|
15
|
+
const CACHE_DIR = join(homedir(), ".heyhank", "telephony", "greetings");
|
|
16
|
+
|
|
17
|
+
function ensureDir(): void {
|
|
18
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hashText(text: string): string {
|
|
22
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build cache file path */
|
|
26
|
+
function cachePath(key: string, format: "pcm" | "mp3"): string {
|
|
27
|
+
return join(CACHE_DIR, `${key}.${format}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options to customize the greeting text without changing every call site. */
|
|
31
|
+
export interface GreetingTextOptions {
|
|
32
|
+
/** Operator name to mention (e.g. "Markus Stöger"). Empty → no operator clause. */
|
|
33
|
+
operatorName?: string;
|
|
34
|
+
/** Append AI + recording disclosure (EU AI Act Art. 50 + DSGVO Art. 13). Default: true. */
|
|
35
|
+
legalDisclosure?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build the greeting text from contact + direction. */
|
|
39
|
+
export function buildGreetingText(
|
|
40
|
+
direction: "inbound" | "outbound",
|
|
41
|
+
contact: TelephonyContact | null,
|
|
42
|
+
options: GreetingTextOptions = {},
|
|
43
|
+
): string {
|
|
44
|
+
const operator = options.operatorName?.trim();
|
|
45
|
+
const disclose = options.legalDisclosure !== false;
|
|
46
|
+
const ofOperator = operator ? ` von ${operator}` : "";
|
|
47
|
+
const aiLabel = disclose ? "der KI-Assistent" : "der Assistent";
|
|
48
|
+
const recordingClause = disclose
|
|
49
|
+
? " Dieses Gespräch wird aufgezeichnet und mit künstlicher Intelligenz verarbeitet."
|
|
50
|
+
: "";
|
|
51
|
+
|
|
52
|
+
if (direction === "outbound") {
|
|
53
|
+
if (contact) {
|
|
54
|
+
return `Servus ${contact.name}! Hier ist Hank, ${aiLabel}${ofOperator}.${recordingClause} Hast du kurz Zeit?`;
|
|
55
|
+
}
|
|
56
|
+
return `Hallo, hier ist Hank, ${aiLabel}${ofOperator}.${recordingClause}`;
|
|
57
|
+
}
|
|
58
|
+
// inbound
|
|
59
|
+
if (contact) {
|
|
60
|
+
return `Servus ${contact.name}! Hier ist Hank, ${aiLabel}${ofOperator}.${recordingClause} Was kann ich für dich tun?`;
|
|
61
|
+
}
|
|
62
|
+
// unknown caller
|
|
63
|
+
return `Hallo, hier ist Hank, ${aiLabel}${ofOperator}.${recordingClause} Wie kann ich dir helfen?`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build a cache key for the given parameters */
|
|
67
|
+
function buildKey(params: {
|
|
68
|
+
direction: "inbound" | "outbound";
|
|
69
|
+
contactId: string | null;
|
|
70
|
+
voice: string;
|
|
71
|
+
text: string;
|
|
72
|
+
}): string {
|
|
73
|
+
const contactPart = params.contactId || "unknown";
|
|
74
|
+
return `${params.direction}-${contactPart}-${params.voice}-${hashText(params.text)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Get or render a greeting → returns PCM 8kHz bytes for FreeSWITCH */
|
|
78
|
+
export async function getOrRenderGreeting(params: {
|
|
79
|
+
direction: "inbound" | "outbound";
|
|
80
|
+
contact: TelephonyContact | null;
|
|
81
|
+
settings: VoicePipelineSettings;
|
|
82
|
+
/** Optional override text (else built from contact + direction) */
|
|
83
|
+
overrideText?: string;
|
|
84
|
+
}): Promise<{ pcm: Uint8Array; text: string; cached: boolean }> {
|
|
85
|
+
ensureDir();
|
|
86
|
+
const text = params.overrideText || buildGreetingText(params.direction, params.contact, {
|
|
87
|
+
operatorName: params.settings.operatorName,
|
|
88
|
+
legalDisclosure: params.settings.legalDisclosureInGreeting,
|
|
89
|
+
});
|
|
90
|
+
const voice = params.settings.tts.voice;
|
|
91
|
+
const key = buildKey({
|
|
92
|
+
direction: params.direction,
|
|
93
|
+
contactId: params.contact?.id ?? null,
|
|
94
|
+
voice,
|
|
95
|
+
text,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const pcmPath = cachePath(key, "pcm");
|
|
99
|
+
if (existsSync(pcmPath)) {
|
|
100
|
+
return { pcm: readFileSync(pcmPath), text, cached: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Render via TTS
|
|
104
|
+
const ttsProvider = getTTSProvider(params.settings.tts.provider);
|
|
105
|
+
const config: TTSConfig = {
|
|
106
|
+
voice,
|
|
107
|
+
language: params.settings.stt.language,
|
|
108
|
+
format: "PCM_8000",
|
|
109
|
+
speakingRate: params.settings.tts.speakingRate,
|
|
110
|
+
};
|
|
111
|
+
// Phonetic fix: German TTS says "Hank" with a long A ("Haank").
|
|
112
|
+
// Spelling it "Henk" yields the English short-A pronunciation.
|
|
113
|
+
const ttsText = text.replace(/\bHank\b/g, "Henk");
|
|
114
|
+
const result = await ttsProvider.synthesize(ttsText, config);
|
|
115
|
+
|
|
116
|
+
// Save to cache
|
|
117
|
+
writeFileSync(pcmPath, Buffer.from(result.audio));
|
|
118
|
+
|
|
119
|
+
return { pcm: result.audio, text, cached: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pre-render greetings for ALL contacts (background task).
|
|
124
|
+
* Called on contact create/update or settings change.
|
|
125
|
+
* Renders both inbound + outbound personalized greetings + the unknown-caller default.
|
|
126
|
+
*/
|
|
127
|
+
export async function preRenderAllGreetings(
|
|
128
|
+
contacts: TelephonyContact[],
|
|
129
|
+
settings: VoicePipelineSettings,
|
|
130
|
+
): Promise<{ rendered: number; cached: number; errors: number }> {
|
|
131
|
+
if (!settings.preRenderGreetings) {
|
|
132
|
+
return { rendered: 0, cached: 0, errors: 0 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let rendered = 0;
|
|
136
|
+
let cached = 0;
|
|
137
|
+
let errors = 0;
|
|
138
|
+
|
|
139
|
+
// Default unknown-caller inbound greeting
|
|
140
|
+
try {
|
|
141
|
+
const r = await getOrRenderGreeting({ direction: "inbound", contact: null, settings });
|
|
142
|
+
if (r.cached) cached++; else rendered++;
|
|
143
|
+
} catch {
|
|
144
|
+
errors++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Per-contact greetings (both directions)
|
|
148
|
+
for (const c of contacts) {
|
|
149
|
+
for (const dir of ["inbound", "outbound"] as const) {
|
|
150
|
+
try {
|
|
151
|
+
const r = await getOrRenderGreeting({ direction: dir, contact: c, settings });
|
|
152
|
+
if (r.cached) cached++; else rendered++;
|
|
153
|
+
} catch {
|
|
154
|
+
errors++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { rendered, cached, errors };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find contact by phone number. Normalizes to E.164 first, so a caller-ID
|
|
164
|
+
* arriving as "06508920611" (national format) still matches a contact stored
|
|
165
|
+
* as "+436508920611". Pass the trunk's country-code digits (no `+`) as
|
|
166
|
+
* `defaultCountryCode` to enable national→E.164 conversion.
|
|
167
|
+
*/
|
|
168
|
+
export function findContactByPhone(
|
|
169
|
+
contacts: TelephonyContact[],
|
|
170
|
+
phone: string,
|
|
171
|
+
defaultCountryCode: string = "",
|
|
172
|
+
): TelephonyContact | null {
|
|
173
|
+
if (!phone) return null;
|
|
174
|
+
const target = normalizePhoneE164(phone, defaultCountryCode);
|
|
175
|
+
return contacts.find((c) => normalizePhoneE164(c.phone, defaultCountryCode) === target) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wipe stale cache entries — call when settings change (different voice).
|
|
180
|
+
* Deletes all PCM files; they'll be re-rendered on demand.
|
|
181
|
+
*/
|
|
182
|
+
export function clearCache(): { deleted: number } {
|
|
183
|
+
ensureDir();
|
|
184
|
+
let deleted = 0;
|
|
185
|
+
try {
|
|
186
|
+
for (const file of readdirSync(CACHE_DIR)) {
|
|
187
|
+
if (file.endsWith(".pcm") || file.endsWith(".mp3")) {
|
|
188
|
+
try {
|
|
189
|
+
unlinkSync(join(CACHE_DIR, file));
|
|
190
|
+
deleted++;
|
|
191
|
+
} catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
return { deleted };
|
|
200
|
+
}
|