heyhank 0.1.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 +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// ─── Email Service ────────────────────────────────────────────────────────────
|
|
2
|
+
// Multi-account IMAP/SMTP email integration for the personal assistant.
|
|
3
|
+
|
|
4
|
+
import { ImapFlow } from "imapflow";
|
|
5
|
+
import nodemailer from "nodemailer";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const CONFIG_PATH = join(HEYHANK_HOME, "assistant", "email-accounts.json");
|
|
11
|
+
|
|
12
|
+
export interface EmailAccount {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string; // display name, e.g. "Work" or "Gmail"
|
|
15
|
+
email: string;
|
|
16
|
+
imap: {
|
|
17
|
+
host: string;
|
|
18
|
+
port: number;
|
|
19
|
+
secure: boolean;
|
|
20
|
+
};
|
|
21
|
+
smtp: {
|
|
22
|
+
host: string;
|
|
23
|
+
port: number;
|
|
24
|
+
secure: boolean;
|
|
25
|
+
};
|
|
26
|
+
auth: {
|
|
27
|
+
user: string;
|
|
28
|
+
pass: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EmailSummary {
|
|
33
|
+
uid: number;
|
|
34
|
+
subject: string;
|
|
35
|
+
from: string;
|
|
36
|
+
to: string;
|
|
37
|
+
date: string;
|
|
38
|
+
seen: boolean;
|
|
39
|
+
accountId: string;
|
|
40
|
+
accountName: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EmailFull extends EmailSummary {
|
|
44
|
+
textBody: string;
|
|
45
|
+
htmlBody?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Account Management ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function ensureDir(): void {
|
|
51
|
+
const dir = join(HEYHANK_HOME, "assistant");
|
|
52
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadAccounts(): EmailAccount[] {
|
|
56
|
+
ensureDir();
|
|
57
|
+
try {
|
|
58
|
+
if (existsSync(CONFIG_PATH)) {
|
|
59
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as EmailAccount[];
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveAccounts(accounts: EmailAccount[]): void {
|
|
66
|
+
ensureDir();
|
|
67
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(accounts, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function addAccount(account: Omit<EmailAccount, "id">): EmailAccount {
|
|
71
|
+
const accounts = loadAccounts();
|
|
72
|
+
const newAccount: EmailAccount = {
|
|
73
|
+
...account,
|
|
74
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
75
|
+
};
|
|
76
|
+
accounts.push(newAccount);
|
|
77
|
+
saveAccounts(accounts);
|
|
78
|
+
return newAccount;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function removeAccount(id: string): boolean {
|
|
82
|
+
const accounts = loadAccounts();
|
|
83
|
+
const idx = accounts.findIndex((a) => a.id === id);
|
|
84
|
+
if (idx === -1) return false;
|
|
85
|
+
accounts.splice(idx, 1);
|
|
86
|
+
saveAccounts(accounts);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Simple similarity score (0-1) between two strings */
|
|
91
|
+
function similarity(a: string, b: string): number {
|
|
92
|
+
const al = a.toLowerCase();
|
|
93
|
+
const bl = b.toLowerCase();
|
|
94
|
+
if (al === bl) return 1;
|
|
95
|
+
if (al.includes(bl) || bl.includes(al)) return 0.8;
|
|
96
|
+
// Character overlap (Dice coefficient)
|
|
97
|
+
const bigrams = (s: string) => {
|
|
98
|
+
const set = new Set<string>();
|
|
99
|
+
for (let i = 0; i < s.length - 1; i++) set.add(s.slice(i, i + 2));
|
|
100
|
+
return set;
|
|
101
|
+
};
|
|
102
|
+
const aBi = bigrams(al);
|
|
103
|
+
const bBi = bigrams(bl);
|
|
104
|
+
let overlap = 0;
|
|
105
|
+
for (const bi of aBi) if (bBi.has(bi)) overlap++;
|
|
106
|
+
return aBi.size + bBi.size > 0 ? (2 * overlap) / (aBi.size + bBi.size) : 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Find account by exact match or fuzzy match (tolerates typos like "Basecamp" for "Baseloq") */
|
|
110
|
+
export function getAccount(idOrEmail: string): EmailAccount | undefined {
|
|
111
|
+
const accounts = loadAccounts();
|
|
112
|
+
// Exact matches first
|
|
113
|
+
const exact = accounts.find((a) => a.id === idOrEmail || a.email === idOrEmail || a.name.toLowerCase() === idOrEmail.toLowerCase());
|
|
114
|
+
if (exact) return exact;
|
|
115
|
+
|
|
116
|
+
// Fuzzy match on name and email (threshold 0.4)
|
|
117
|
+
let best: EmailAccount | undefined;
|
|
118
|
+
let bestScore = 0.4; // minimum threshold
|
|
119
|
+
for (const a of accounts) {
|
|
120
|
+
const nameScore = similarity(idOrEmail, a.name);
|
|
121
|
+
const emailScore = similarity(idOrEmail, a.email.split("@")[0]);
|
|
122
|
+
const score = Math.max(nameScore, emailScore);
|
|
123
|
+
if (score > bestScore) {
|
|
124
|
+
bestScore = score;
|
|
125
|
+
best = a;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── IMAP Operations ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async function withImap<T>(account: EmailAccount, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
|
134
|
+
const client = new ImapFlow({
|
|
135
|
+
host: account.imap.host,
|
|
136
|
+
port: account.imap.port,
|
|
137
|
+
secure: account.imap.secure,
|
|
138
|
+
auth: {
|
|
139
|
+
user: account.auth.user,
|
|
140
|
+
pass: account.auth.pass,
|
|
141
|
+
},
|
|
142
|
+
logger: false,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await client.connect();
|
|
146
|
+
try {
|
|
147
|
+
return await fn(client);
|
|
148
|
+
} finally {
|
|
149
|
+
await client.logout().catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatAddress(addr: any): string {
|
|
154
|
+
if (!addr) return "";
|
|
155
|
+
if (Array.isArray(addr)) {
|
|
156
|
+
return addr.map(formatAddress).join(", ");
|
|
157
|
+
}
|
|
158
|
+
if (addr.name && addr.address) return `${addr.name} <${addr.address}>`;
|
|
159
|
+
if (addr.address) return addr.address;
|
|
160
|
+
return String(addr);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** List recent emails from a specific account */
|
|
164
|
+
export async function listEmails(
|
|
165
|
+
account: EmailAccount,
|
|
166
|
+
options?: { folder?: string; limit?: number; unseen?: boolean },
|
|
167
|
+
): Promise<EmailSummary[]> {
|
|
168
|
+
const folder = options?.folder || "INBOX";
|
|
169
|
+
const limit = options?.limit || 10;
|
|
170
|
+
|
|
171
|
+
return withImap(account, async (client) => {
|
|
172
|
+
const lock = await client.getMailboxLock(folder);
|
|
173
|
+
try {
|
|
174
|
+
const emails: EmailSummary[] = [];
|
|
175
|
+
// Fetch most recent messages
|
|
176
|
+
const range = options?.unseen ? "1:*" : "*";
|
|
177
|
+
const searchCriteria = options?.unseen ? { seen: false } : undefined;
|
|
178
|
+
|
|
179
|
+
let uids: number[];
|
|
180
|
+
if (searchCriteria) {
|
|
181
|
+
uids = await client.search(searchCriteria) as unknown as number[];
|
|
182
|
+
} else {
|
|
183
|
+
uids = await client.search({ all: true }) as unknown as number[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Take the last N UIDs (most recent)
|
|
187
|
+
const recentUids = uids.slice(-limit);
|
|
188
|
+
|
|
189
|
+
for await (const msg of client.fetch(recentUids, { envelope: true, flags: true })) {
|
|
190
|
+
emails.push({
|
|
191
|
+
uid: msg.uid,
|
|
192
|
+
subject: msg.envelope?.subject || "(no subject)",
|
|
193
|
+
from: formatAddress(msg.envelope?.from),
|
|
194
|
+
to: formatAddress(msg.envelope?.to),
|
|
195
|
+
date: msg.envelope?.date?.toISOString() || "",
|
|
196
|
+
seen: msg.flags?.has("\\Seen") || false,
|
|
197
|
+
accountId: account.id,
|
|
198
|
+
accountName: account.name,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return emails.reverse(); // newest first
|
|
203
|
+
} finally {
|
|
204
|
+
lock.release();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Read a specific email by UID */
|
|
210
|
+
export async function readEmail(account: EmailAccount, uid: number, folder = "INBOX"): Promise<EmailFull | null> {
|
|
211
|
+
return withImap(account, async (client) => {
|
|
212
|
+
const lock = await client.getMailboxLock(folder);
|
|
213
|
+
try {
|
|
214
|
+
const msg = await client.fetchOne(String(uid), { envelope: true, flags: true, source: true }, { uid: true });
|
|
215
|
+
if (!msg) return null;
|
|
216
|
+
|
|
217
|
+
// Parse the raw source to extract text body
|
|
218
|
+
const source = msg.source?.toString("utf-8") || "";
|
|
219
|
+
let textBody = "";
|
|
220
|
+
|
|
221
|
+
// Simple text extraction from raw email
|
|
222
|
+
const parts = source.split(/\r?\n\r?\n/);
|
|
223
|
+
if (parts.length > 1) {
|
|
224
|
+
textBody = parts.slice(1).join("\n\n");
|
|
225
|
+
// Strip HTML tags for a rough text version
|
|
226
|
+
textBody = textBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
227
|
+
// Limit length
|
|
228
|
+
if (textBody.length > 2000) {
|
|
229
|
+
textBody = textBody.slice(0, 2000) + "...";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Mark as seen
|
|
234
|
+
await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true });
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
uid: msg.uid,
|
|
238
|
+
subject: msg.envelope?.subject || "(no subject)",
|
|
239
|
+
from: formatAddress(msg.envelope?.from),
|
|
240
|
+
to: formatAddress(msg.envelope?.to),
|
|
241
|
+
date: msg.envelope?.date?.toISOString() || "",
|
|
242
|
+
seen: true,
|
|
243
|
+
accountId: account.id,
|
|
244
|
+
accountName: account.name,
|
|
245
|
+
textBody,
|
|
246
|
+
};
|
|
247
|
+
} finally {
|
|
248
|
+
lock.release();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Search emails across one account */
|
|
254
|
+
export async function searchEmails(
|
|
255
|
+
account: EmailAccount,
|
|
256
|
+
query: string,
|
|
257
|
+
limit = 10,
|
|
258
|
+
): Promise<EmailSummary[]> {
|
|
259
|
+
return withImap(account, async (client) => {
|
|
260
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
261
|
+
try {
|
|
262
|
+
const uids = await client.search({
|
|
263
|
+
or: [
|
|
264
|
+
{ subject: query },
|
|
265
|
+
{ from: query },
|
|
266
|
+
{ body: query },
|
|
267
|
+
],
|
|
268
|
+
}) as unknown as number[];
|
|
269
|
+
|
|
270
|
+
const recentUids = uids.slice(-limit);
|
|
271
|
+
const emails: EmailSummary[] = [];
|
|
272
|
+
|
|
273
|
+
for await (const msg of client.fetch(recentUids, { envelope: true, flags: true })) {
|
|
274
|
+
emails.push({
|
|
275
|
+
uid: msg.uid,
|
|
276
|
+
subject: msg.envelope?.subject || "(no subject)",
|
|
277
|
+
from: formatAddress(msg.envelope?.from),
|
|
278
|
+
to: formatAddress(msg.envelope?.to),
|
|
279
|
+
date: msg.envelope?.date?.toISOString() || "",
|
|
280
|
+
seen: msg.flags?.has("\\Seen") || false,
|
|
281
|
+
accountId: account.id,
|
|
282
|
+
accountName: account.name,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return emails.reverse();
|
|
287
|
+
} finally {
|
|
288
|
+
lock.release();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── SMTP Operations ─────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/** Send an email */
|
|
296
|
+
export async function sendEmail(
|
|
297
|
+
account: EmailAccount,
|
|
298
|
+
to: string,
|
|
299
|
+
subject: string,
|
|
300
|
+
body: string,
|
|
301
|
+
): Promise<{ messageId: string }> {
|
|
302
|
+
const transporter = nodemailer.createTransport({
|
|
303
|
+
host: account.smtp.host,
|
|
304
|
+
port: account.smtp.port,
|
|
305
|
+
secure: account.smtp.secure,
|
|
306
|
+
auth: {
|
|
307
|
+
user: account.auth.user,
|
|
308
|
+
pass: account.auth.pass,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const info = await transporter.sendMail({
|
|
313
|
+
from: `${account.name} <${account.email}>`,
|
|
314
|
+
to,
|
|
315
|
+
subject,
|
|
316
|
+
text: body,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return { messageId: info.messageId };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Reply to an email */
|
|
323
|
+
export async function replyToEmail(
|
|
324
|
+
account: EmailAccount,
|
|
325
|
+
originalUid: number,
|
|
326
|
+
body: string,
|
|
327
|
+
folder = "INBOX",
|
|
328
|
+
): Promise<{ messageId: string }> {
|
|
329
|
+
// First fetch the original email to get reply details
|
|
330
|
+
const original = await readEmail(account, originalUid, folder);
|
|
331
|
+
if (!original) throw new Error("Original email not found");
|
|
332
|
+
|
|
333
|
+
const replyTo = original.from;
|
|
334
|
+
const subject = original.subject.startsWith("Re:") ? original.subject : `Re: ${original.subject}`;
|
|
335
|
+
|
|
336
|
+
return sendEmail(account, replyTo, subject, body);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Convenience: Multi-account operations ───────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/** Get unread count across all accounts */
|
|
342
|
+
export async function getUnreadSummary(): Promise<Array<{ accountName: string; email: string; unread: number }>> {
|
|
343
|
+
const accounts = loadAccounts();
|
|
344
|
+
const results = await Promise.allSettled(
|
|
345
|
+
accounts.map(async (acc) => {
|
|
346
|
+
const emails = await listEmails(acc, { unseen: true, limit: 100 });
|
|
347
|
+
return { accountName: acc.name, email: acc.email, unread: emails.length };
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return results
|
|
352
|
+
.filter((r): r is PromiseFulfilledResult<{ accountName: string; email: string; unread: number }> => r.status === "fulfilled")
|
|
353
|
+
.map((r) => r.value);
|
|
354
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface HeyHankEnv {
|
|
15
|
+
name: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
variables: Record<string, string>;
|
|
18
|
+
|
|
19
|
+
createdAt: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fields that can be updated via the update API */
|
|
24
|
+
export interface EnvUpdateFields {
|
|
25
|
+
name?: string;
|
|
26
|
+
variables?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const ENVS_DIR = join(HEYHANK_HOME, "envs");
|
|
32
|
+
|
|
33
|
+
function ensureDir(): void {
|
|
34
|
+
mkdirSync(ENVS_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate that a slug contains only safe characters (prevents path traversal) */
|
|
38
|
+
function validateSlug(slug: string): void {
|
|
39
|
+
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
40
|
+
throw new Error("Invalid slug: must contain only lowercase alphanumeric characters and hyphens");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function filePath(slug: string): string {
|
|
45
|
+
validateSlug(slug);
|
|
46
|
+
return join(ENVS_DIR, `${slug}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function slugify(name: string): string {
|
|
52
|
+
return name
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/\s+/g, "-")
|
|
55
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
56
|
+
.replace(/-+/g, "-")
|
|
57
|
+
.replace(/^-|-$/g, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function listEnvs(): HeyHankEnv[] {
|
|
63
|
+
ensureDir();
|
|
64
|
+
try {
|
|
65
|
+
const files = readdirSync(ENVS_DIR).filter((f) => f.endsWith(".json"));
|
|
66
|
+
const envs: HeyHankEnv[] = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(join(ENVS_DIR, file), "utf-8");
|
|
70
|
+
envs.push(JSON.parse(raw));
|
|
71
|
+
} catch {
|
|
72
|
+
// Skip corrupt files
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
envs.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
return envs;
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getEnv(slug: string): HeyHankEnv | null {
|
|
83
|
+
ensureDir();
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(filePath(slug), "utf-8");
|
|
86
|
+
return JSON.parse(raw) as HeyHankEnv;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createEnv(
|
|
93
|
+
name: string,
|
|
94
|
+
variables: Record<string, string> = {},
|
|
95
|
+
): HeyHankEnv {
|
|
96
|
+
if (!name || !name.trim()) throw new Error("Environment name is required");
|
|
97
|
+
const slug = slugify(name.trim());
|
|
98
|
+
if (!slug) throw new Error("Environment name must contain alphanumeric characters");
|
|
99
|
+
|
|
100
|
+
ensureDir();
|
|
101
|
+
if (existsSync(filePath(slug))) {
|
|
102
|
+
throw new Error(`An environment with a similar name already exists ("${slug}")`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const env: HeyHankEnv = {
|
|
107
|
+
name: name.trim(),
|
|
108
|
+
slug,
|
|
109
|
+
variables,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
writeFileSync(filePath(slug), JSON.stringify(env, null, 2), "utf-8");
|
|
115
|
+
return env;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function updateEnv(
|
|
119
|
+
slug: string,
|
|
120
|
+
updates: EnvUpdateFields,
|
|
121
|
+
): HeyHankEnv | null {
|
|
122
|
+
ensureDir();
|
|
123
|
+
const existing = getEnv(slug);
|
|
124
|
+
if (!existing) return null;
|
|
125
|
+
|
|
126
|
+
const newName = updates.name?.trim() || existing.name;
|
|
127
|
+
const newSlug = slugify(newName);
|
|
128
|
+
if (!newSlug) throw new Error("Environment name must contain alphanumeric characters");
|
|
129
|
+
|
|
130
|
+
// If name changed, check for slug collision with a different env
|
|
131
|
+
if (newSlug !== slug && existsSync(filePath(newSlug))) {
|
|
132
|
+
throw new Error(`An environment with a similar name already exists ("${newSlug}")`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const env: HeyHankEnv = {
|
|
136
|
+
...existing,
|
|
137
|
+
name: newName,
|
|
138
|
+
slug: newSlug,
|
|
139
|
+
variables: updates.variables ?? existing.variables,
|
|
140
|
+
updatedAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// If slug changed, delete old file
|
|
144
|
+
if (newSlug !== slug) {
|
|
145
|
+
try { unlinkSync(filePath(slug)); } catch { /* ok */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeFileSync(filePath(newSlug), JSON.stringify(env, null, 2), "utf-8");
|
|
149
|
+
return env;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function deleteEnv(slug: string): boolean {
|
|
153
|
+
ensureDir();
|
|
154
|
+
if (!existsSync(filePath(slug))) return false;
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(filePath(slug));
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Typed event map for the HeyHank internal event bus.
|
|
2
|
+
// Each key is a namespaced event name; values are the payload passed to handlers.
|
|
3
|
+
|
|
4
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
5
|
+
import type { CodexAdapter } from "./codex-adapter.js";
|
|
6
|
+
import type { SessionPhase } from "./session-state-machine.js";
|
|
7
|
+
|
|
8
|
+
export interface HeyHankEventMap {
|
|
9
|
+
// ── Session lifecycle ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** CLI reported its internal session ID (used for --resume). */
|
|
12
|
+
"session:cli-id-received": { sessionId: string; cliSessionId: string };
|
|
13
|
+
|
|
14
|
+
/** CLI/Codex process exited. */
|
|
15
|
+
"session:exited": { sessionId: string; exitCode: number | null };
|
|
16
|
+
|
|
17
|
+
/** CLI WebSocket disconnected and a browser needs a relaunch. */
|
|
18
|
+
"session:relaunch-needed": { sessionId: string };
|
|
19
|
+
|
|
20
|
+
/** Idle-kill threshold reached with no connected browsers. */
|
|
21
|
+
"session:idle-kill": { sessionId: string };
|
|
22
|
+
|
|
23
|
+
/** First non-error turn completed (triggers auto-naming). */
|
|
24
|
+
"session:first-turn-completed": {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
firstUserMessage: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Git info resolved for a session (branch and cwd known). */
|
|
30
|
+
"session:git-info-ready": { sessionId: string; cwd: string; branch: string };
|
|
31
|
+
|
|
32
|
+
/** Session phase changed (formal state machine transition). */
|
|
33
|
+
"session:phase-changed": {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
from: SessionPhase;
|
|
36
|
+
to: SessionPhase;
|
|
37
|
+
trigger: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Backend integration ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Codex adapter created and ready to be attached to WsBridge. */
|
|
43
|
+
"backend:codex-adapter-created": {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
adapter: CodexAdapter;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Per-session messages (high volume) ─────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** An assistant message was processed and broadcast to browsers. */
|
|
51
|
+
"message:assistant": {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
message: BrowserIncomingMessage;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A stream event was processed and broadcast to browsers. */
|
|
57
|
+
"message:stream_event": {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
message: BrowserIncomingMessage;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A result (turn completion) was processed and broadcast to browsers. */
|
|
63
|
+
"message:result": { sessionId: string; message: BrowserIncomingMessage };
|
|
64
|
+
|
|
65
|
+
// ── Federation ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** A remote federation node connected. */
|
|
68
|
+
"federation:node-connected": { nodeId: string; name: string };
|
|
69
|
+
|
|
70
|
+
/** A remote federation node disconnected. */
|
|
71
|
+
"federation:node-disconnected": { nodeId: string; name: string };
|
|
72
|
+
|
|
73
|
+
/** Remote sessions list updated from a peer. */
|
|
74
|
+
"federation:sessions-updated": { nodeId: string };
|
|
75
|
+
}
|