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
|
@@ -108,7 +108,7 @@ export async function executeSessionCreation(
|
|
|
108
108
|
// Resolve Docker image early
|
|
109
109
|
let effectiveImage: string | null = null;
|
|
110
110
|
if (sandboxEnabled) {
|
|
111
|
-
effectiveImage = "
|
|
111
|
+
effectiveImage = "heyhank:latest";
|
|
112
112
|
} else if ((body.container as Record<string, unknown>)?.image) {
|
|
113
113
|
effectiveImage = (body.container as Record<string, unknown>).image as string;
|
|
114
114
|
}
|
|
@@ -269,7 +269,7 @@ export async function executeSessionCreation(
|
|
|
269
269
|
ports: containerPorts,
|
|
270
270
|
volumes: (body.container as Record<string, unknown>)?.volumes as string[] | undefined,
|
|
271
271
|
env: { ...(envVars ?? {}), DISPLAY: ":99" },
|
|
272
|
-
privileged: sandboxEnabled && effectiveImage === "
|
|
272
|
+
privileged: sandboxEnabled && effectiveImage === "heyhank:latest",
|
|
273
273
|
};
|
|
274
274
|
try {
|
|
275
275
|
containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
|
|
@@ -19,6 +19,8 @@ import { generateSessionTitle } from "./auto-namer.js";
|
|
|
19
19
|
import { heyHankBus } from "./event-bus.js";
|
|
20
20
|
import { metricsCollector } from "./metrics-collector.js";
|
|
21
21
|
import { log } from "./logger.js";
|
|
22
|
+
import { authEvents, attemptRefresh } from "./claude-auth-monitor.js";
|
|
23
|
+
import { deleteClaudeSessionTranscript } from "./claude-session-discovery.js";
|
|
22
24
|
|
|
23
25
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
24
26
|
|
|
@@ -65,6 +67,7 @@ export interface CreateSessionRequest {
|
|
|
65
67
|
container?: { image?: string; ports?: number[]; volumes?: string[] };
|
|
66
68
|
resumeSessionAt?: string;
|
|
67
69
|
forkSession?: boolean;
|
|
70
|
+
providerId?: string;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export type CreateSessionResult =
|
|
@@ -195,6 +198,26 @@ export class SessionOrchestrator {
|
|
|
195
198
|
await this.handleAutoNaming(sessionId, firstUserMessage);
|
|
196
199
|
});
|
|
197
200
|
|
|
201
|
+
// Subscribe to auth failure events for auto-fix orchestration
|
|
202
|
+
authEvents.on("auth:failure", async ({ sessionId }: { error: string; sessionId?: string }) => {
|
|
203
|
+
console.log(`[orchestrator] Auth failure detected${sessionId ? ` for session ${sessionId}` : ""}`);
|
|
204
|
+
const refreshed = await attemptRefresh();
|
|
205
|
+
if (refreshed && sessionId) {
|
|
206
|
+
// Try to relaunch the failed session
|
|
207
|
+
try {
|
|
208
|
+
const session = this.launcher.getSession(sessionId);
|
|
209
|
+
if (session && session.state === "exited") {
|
|
210
|
+
console.log(`[orchestrator] Relaunching session ${sessionId} after auth refresh`);
|
|
211
|
+
await this.handleAutoRelaunch(sessionId);
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.log(`[orchestrator] Failed to relaunch session after auth refresh: ${err}`);
|
|
215
|
+
}
|
|
216
|
+
} else if (!refreshed) {
|
|
217
|
+
authEvents.emit("auth:refresh-exhausted");
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
198
221
|
// Reconnection watchdog for stale sessions after server restart
|
|
199
222
|
this.startReconnectionWatchdog();
|
|
200
223
|
}
|
|
@@ -274,7 +297,7 @@ export class SessionOrchestrator {
|
|
|
274
297
|
// Resolve Docker image early
|
|
275
298
|
let effectiveImage: string | null = null;
|
|
276
299
|
if (sandboxEnabled) {
|
|
277
|
-
effectiveImage = "
|
|
300
|
+
effectiveImage = "heyhank:latest";
|
|
278
301
|
} else if (body.container?.image) {
|
|
279
302
|
effectiveImage = body.container.image;
|
|
280
303
|
}
|
|
@@ -425,7 +448,7 @@ export class SessionOrchestrator {
|
|
|
425
448
|
ports: containerPorts,
|
|
426
449
|
volumes: body.container?.volumes,
|
|
427
450
|
env: { ...(envVars ?? {}), DISPLAY: ":99" },
|
|
428
|
-
privileged: sandboxEnabled && effectiveImage === "
|
|
451
|
+
privileged: sandboxEnabled && effectiveImage === "heyhank:latest",
|
|
429
452
|
};
|
|
430
453
|
try {
|
|
431
454
|
containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
|
|
@@ -628,6 +651,12 @@ export class SessionOrchestrator {
|
|
|
628
651
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
|
629
652
|
|
|
630
653
|
async deleteSession(sessionId: string): Promise<DeleteSessionResult> {
|
|
654
|
+
// Capture CLI session info BEFORE removeSession() wipes it from memory —
|
|
655
|
+
// we need cliSessionId to find the Claude Code transcript on disk.
|
|
656
|
+
const sessionInfo = this.launcher.getSession(sessionId);
|
|
657
|
+
const cliSessionId = sessionInfo?.cliSessionId;
|
|
658
|
+
const backendType = sessionInfo?.backendType;
|
|
659
|
+
|
|
631
660
|
await this.launcher.kill(sessionId);
|
|
632
661
|
containerManager.removeContainer(sessionId);
|
|
633
662
|
const worktreeResult = this.cleanupWorktree(sessionId, true);
|
|
@@ -637,6 +666,29 @@ export class SessionOrchestrator {
|
|
|
637
666
|
this.autoRelaunchCounts.delete(sessionId);
|
|
638
667
|
this.relaunchExhaustedNotified.delete(sessionId);
|
|
639
668
|
this.relaunchingSet.delete(sessionId);
|
|
669
|
+
|
|
670
|
+
// Delete the Claude Code transcript file so the session does not reappear
|
|
671
|
+
// in the "Branch from session" picker via discoverClaudeSessions.
|
|
672
|
+
// Only applies to Claude Code (Codex stores its history elsewhere).
|
|
673
|
+
if (backendType === "claude" && cliSessionId) {
|
|
674
|
+
try {
|
|
675
|
+
const result = deleteClaudeSessionTranscript(cliSessionId);
|
|
676
|
+
if (result.deleted.length > 0) {
|
|
677
|
+
log.info("session-orchestrator", "deleted claude transcript", {
|
|
678
|
+
sessionId,
|
|
679
|
+
cliSessionId,
|
|
680
|
+
paths: result.deleted,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
log.warn("session-orchestrator", "failed to delete claude transcript", {
|
|
685
|
+
sessionId,
|
|
686
|
+
cliSessionId,
|
|
687
|
+
err: err instanceof Error ? err.message : String(err),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
640
692
|
return { ok: true, worktree: worktreeResult };
|
|
641
693
|
}
|
|
642
694
|
|
package/server/session-types.ts
CHANGED
|
@@ -430,6 +430,8 @@ export interface SessionState {
|
|
|
430
430
|
nodeId?: string;
|
|
431
431
|
/** Federation: remote node display name */
|
|
432
432
|
nodeName?: string;
|
|
433
|
+
/** Raw message history kept for search (populated by ws-bridge) */
|
|
434
|
+
messageHistory?: Array<Record<string, unknown>>;
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
// ─── MCP Types ───────────────────────────────────────────────────────────────
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
readFileSync,
|
|
4
4
|
writeFileSync,
|
|
5
5
|
existsSync,
|
|
6
|
+
renameSync,
|
|
6
7
|
} from "node:fs";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
8
9
|
import { HEYHANK_HOME } from "./paths.js";
|
|
@@ -28,6 +29,20 @@ export interface HeyHankSettings {
|
|
|
28
29
|
assistantName: string;
|
|
29
30
|
/** User's display name so the assistant knows who it's talking to */
|
|
30
31
|
userName: string;
|
|
32
|
+
/** Selected chat provider for Hank-UI (default: "gemini-live") */
|
|
33
|
+
hankChatProvider: string;
|
|
34
|
+
/** Selected model for Hank-UI text chat */
|
|
35
|
+
hankChatModel: string;
|
|
36
|
+
/** Whether to show a 3D TalkingHead avatar during Gemini Live sessions */
|
|
37
|
+
hankChatAvatarEnabled: boolean;
|
|
38
|
+
/** URL to a Ready Player Me (or compatible) GLB avatar with ARKit + Oculus visemes */
|
|
39
|
+
hankChatAvatarUrl: string;
|
|
40
|
+
/** @deprecated No longer used — memory is fully local */
|
|
41
|
+
mem0ApiKey: string;
|
|
42
|
+
/** @deprecated No longer used — memory is fully local */
|
|
43
|
+
mem0UserId: string;
|
|
44
|
+
/** Auto-detect and save memories from conversations */
|
|
45
|
+
memoryAutoDetect: boolean;
|
|
31
46
|
editorTabEnabled: boolean;
|
|
32
47
|
/** Provider ID for internal AI features (auto-renaming, AI validation). Empty = auto-detect. */
|
|
33
48
|
internalAiProvider: string;
|
|
@@ -37,6 +52,8 @@ export interface HeyHankSettings {
|
|
|
37
52
|
publicUrl: string;
|
|
38
53
|
updateChannel: UpdateChannel;
|
|
39
54
|
dockerAutoUpdate: boolean;
|
|
55
|
+
/** Path to Obsidian vault folder for memory sync (empty = disabled) */
|
|
56
|
+
obsidianVaultPath: string;
|
|
40
57
|
updatedAt: number;
|
|
41
58
|
}
|
|
42
59
|
|
|
@@ -54,6 +71,15 @@ let settings: HeyHankSettings = {
|
|
|
54
71
|
geminiVoice: "Kore",
|
|
55
72
|
assistantName: "",
|
|
56
73
|
userName: "",
|
|
74
|
+
hankChatProvider: "gemini-live",
|
|
75
|
+
hankChatModel: "",
|
|
76
|
+
hankChatAvatarEnabled: true,
|
|
77
|
+
// No default URL: models.readyplayer.me was retired (DNS NXDOMAIN),
|
|
78
|
+
// so the user must paste a working GLB URL in Settings.
|
|
79
|
+
hankChatAvatarUrl: "",
|
|
80
|
+
mem0ApiKey: "",
|
|
81
|
+
mem0UserId: "",
|
|
82
|
+
memoryAutoDetect: true,
|
|
57
83
|
editorTabEnabled: false,
|
|
58
84
|
internalAiProvider: "",
|
|
59
85
|
aiValidationEnabled: false,
|
|
@@ -62,6 +88,7 @@ let settings: HeyHankSettings = {
|
|
|
62
88
|
publicUrl: "",
|
|
63
89
|
updateChannel: "stable",
|
|
64
90
|
dockerAutoUpdate: false,
|
|
91
|
+
obsidianVaultPath: "",
|
|
65
92
|
updatedAt: 0,
|
|
66
93
|
};
|
|
67
94
|
|
|
@@ -79,6 +106,16 @@ function normalize(raw: Partial<HeyHankSettings> | null | undefined): HeyHankSet
|
|
|
79
106
|
geminiVoice: typeof raw?.geminiVoice === "string" && raw.geminiVoice.trim() ? raw.geminiVoice : "Kore",
|
|
80
107
|
assistantName: typeof raw?.assistantName === "string" ? raw.assistantName.trim() : "",
|
|
81
108
|
userName: typeof raw?.userName === "string" ? raw.userName.trim() : "",
|
|
109
|
+
hankChatProvider: typeof raw?.hankChatProvider === "string" ? raw.hankChatProvider.trim() || "gemini-live" : "gemini-live",
|
|
110
|
+
hankChatModel: typeof raw?.hankChatModel === "string" ? raw.hankChatModel.trim() : "",
|
|
111
|
+
hankChatAvatarEnabled: typeof raw?.hankChatAvatarEnabled === "boolean" ? raw.hankChatAvatarEnabled : true,
|
|
112
|
+
hankChatAvatarUrl:
|
|
113
|
+
typeof raw?.hankChatAvatarUrl === "string"
|
|
114
|
+
? raw.hankChatAvatarUrl.trim()
|
|
115
|
+
: "",
|
|
116
|
+
mem0ApiKey: typeof raw?.mem0ApiKey === "string" ? raw.mem0ApiKey : "",
|
|
117
|
+
mem0UserId: typeof raw?.mem0UserId === "string" ? raw.mem0UserId.trim() : "",
|
|
118
|
+
memoryAutoDetect: typeof raw?.memoryAutoDetect === "boolean" ? raw.memoryAutoDetect : true,
|
|
82
119
|
editorTabEnabled: typeof raw?.editorTabEnabled === "boolean" ? raw.editorTabEnabled : false,
|
|
83
120
|
internalAiProvider: typeof raw?.internalAiProvider === "string" ? raw.internalAiProvider.trim() : "",
|
|
84
121
|
aiValidationEnabled: typeof raw?.aiValidationEnabled === "boolean" ? raw.aiValidationEnabled : false,
|
|
@@ -87,6 +124,7 @@ function normalize(raw: Partial<HeyHankSettings> | null | undefined): HeyHankSet
|
|
|
87
124
|
publicUrl: typeof raw?.publicUrl === "string" ? raw.publicUrl.trim().replace(/\/+$/, "") : "",
|
|
88
125
|
updateChannel: raw?.updateChannel === "prerelease" ? "prerelease" : "stable",
|
|
89
126
|
dockerAutoUpdate: typeof raw?.dockerAutoUpdate === "boolean" ? raw.dockerAutoUpdate : false,
|
|
127
|
+
obsidianVaultPath: typeof raw?.obsidianVaultPath === "string" ? raw.obsidianVaultPath.trim() : "",
|
|
90
128
|
updatedAt: typeof raw?.updatedAt === "number" ? raw.updatedAt : 0,
|
|
91
129
|
};
|
|
92
130
|
}
|
|
@@ -106,7 +144,9 @@ function ensureLoaded(): void {
|
|
|
106
144
|
|
|
107
145
|
function persist(): void {
|
|
108
146
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
109
|
-
|
|
147
|
+
const tmpFile = filePath + ".tmp";
|
|
148
|
+
writeFileSync(tmpFile, JSON.stringify(settings, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
149
|
+
renameSync(tmpFile, filePath);
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
export function getSettings(): HeyHankSettings {
|
|
@@ -115,7 +155,7 @@ export function getSettings(): HeyHankSettings {
|
|
|
115
155
|
}
|
|
116
156
|
|
|
117
157
|
export function updateSettings(
|
|
118
|
-
patch: Partial<Pick<HeyHankSettings, "anthropicApiKey" | "anthropicModel" | "claudeCodeOAuthToken" | "openaiApiKey" | "onboardingCompleted" | "geminiApiKey" | "geminiVoice" | "assistantName" | "userName" | "editorTabEnabled" | "internalAiProvider" | "aiValidationEnabled" | "aiValidationAutoApprove" | "aiValidationAutoDeny" | "publicUrl" | "updateChannel" | "dockerAutoUpdate">>,
|
|
158
|
+
patch: Partial<Pick<HeyHankSettings, "anthropicApiKey" | "anthropicModel" | "claudeCodeOAuthToken" | "openaiApiKey" | "onboardingCompleted" | "geminiApiKey" | "geminiVoice" | "assistantName" | "userName" | "hankChatProvider" | "hankChatModel" | "hankChatAvatarEnabled" | "hankChatAvatarUrl" | "mem0ApiKey" | "mem0UserId" | "memoryAutoDetect" | "editorTabEnabled" | "internalAiProvider" | "aiValidationEnabled" | "aiValidationAutoApprove" | "aiValidationAutoDeny" | "publicUrl" | "updateChannel" | "dockerAutoUpdate" | "obsidianVaultPath">>,
|
|
119
159
|
): HeyHankSettings {
|
|
120
160
|
ensureLoaded();
|
|
121
161
|
settings = normalize({
|
|
@@ -128,6 +168,13 @@ export function updateSettings(
|
|
|
128
168
|
geminiVoice: patch.geminiVoice ?? settings.geminiVoice,
|
|
129
169
|
assistantName: patch.assistantName ?? settings.assistantName,
|
|
130
170
|
userName: patch.userName ?? settings.userName,
|
|
171
|
+
hankChatProvider: patch.hankChatProvider ?? settings.hankChatProvider,
|
|
172
|
+
hankChatModel: patch.hankChatModel ?? settings.hankChatModel,
|
|
173
|
+
hankChatAvatarEnabled: patch.hankChatAvatarEnabled ?? settings.hankChatAvatarEnabled,
|
|
174
|
+
hankChatAvatarUrl: patch.hankChatAvatarUrl ?? settings.hankChatAvatarUrl,
|
|
175
|
+
mem0ApiKey: patch.mem0ApiKey ?? settings.mem0ApiKey,
|
|
176
|
+
mem0UserId: patch.mem0UserId ?? settings.mem0UserId,
|
|
177
|
+
memoryAutoDetect: patch.memoryAutoDetect ?? settings.memoryAutoDetect,
|
|
131
178
|
editorTabEnabled: patch.editorTabEnabled ?? settings.editorTabEnabled,
|
|
132
179
|
internalAiProvider: patch.internalAiProvider ?? settings.internalAiProvider,
|
|
133
180
|
aiValidationEnabled: patch.aiValidationEnabled ?? settings.aiValidationEnabled,
|
|
@@ -136,6 +183,7 @@ export function updateSettings(
|
|
|
136
183
|
publicUrl: patch.publicUrl ?? settings.publicUrl,
|
|
137
184
|
updateChannel: patch.updateChannel ?? settings.updateChannel,
|
|
138
185
|
dockerAutoUpdate: patch.dockerAutoUpdate ?? settings.dockerAutoUpdate,
|
|
186
|
+
obsidianVaultPath: patch.obsidianVaultPath ?? settings.obsidianVaultPath,
|
|
139
187
|
updatedAt: Date.now(),
|
|
140
188
|
});
|
|
141
189
|
persist();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ─── Skill Discovery ────────────────────────────────────────────────────────
|
|
2
|
+
// Lists installed Claude Code skills under ~/.claude/skills/ with their
|
|
3
|
+
// metadata (slug, name, description) so HankChat and other layers can route
|
|
4
|
+
// requests to a matching skill without spawning a Claude Code session.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
export interface InstalledSkill {
|
|
11
|
+
/** Directory name (and slug Claude Code uses to invoke). */
|
|
12
|
+
slug: string;
|
|
13
|
+
/** Display name from frontmatter (falls back to slug). */
|
|
14
|
+
name: string;
|
|
15
|
+
/** When-to-use description from frontmatter (empty string if missing). */
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse the `name:` and `description:` fields from a SKILL.md frontmatter. */
|
|
20
|
+
function parseFrontmatter(content: string): { name?: string; description?: string } {
|
|
21
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
22
|
+
if (!m) return {};
|
|
23
|
+
const out: { name?: string; description?: string } = {};
|
|
24
|
+
for (const line of m[1].split("\n")) {
|
|
25
|
+
const nameMatch = line.match(/^name:\s*["']?(.+?)["']?\s*$/);
|
|
26
|
+
if (nameMatch) out.name = nameMatch[1].trim();
|
|
27
|
+
const descMatch = line.match(/^description:\s*["']?(.+?)["']?\s*$/);
|
|
28
|
+
if (descMatch) out.description = descMatch[1].trim();
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read raw SKILL.md content for a given slug, or null when missing/unreadable. */
|
|
34
|
+
export function readSkillContent(slug: string): string | null {
|
|
35
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) return null;
|
|
36
|
+
const path = join(homedir(), ".claude", "skills", slug, "SKILL.md");
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
try {
|
|
39
|
+
return readFileSync(path, "utf-8");
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Enumerate installed skills with their frontmatter metadata. */
|
|
46
|
+
export function listInstalledSkills(): InstalledSkill[] {
|
|
47
|
+
const skillsDir = join(homedir(), ".claude", "skills");
|
|
48
|
+
if (!existsSync(skillsDir)) return [];
|
|
49
|
+
const out: InstalledSkill[] = [];
|
|
50
|
+
let entries: string[] = [];
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(skillsDir);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
for (const slug of entries) {
|
|
57
|
+
const content = readSkillContent(slug);
|
|
58
|
+
if (!content) continue;
|
|
59
|
+
const fm = parseFrontmatter(content);
|
|
60
|
+
out.push({
|
|
61
|
+
slug,
|
|
62
|
+
name: fm.name || slug,
|
|
63
|
+
description: fm.description || "",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ─── Browser Adapter ─────────────────────────────────────────────────────────
|
|
2
|
+
// Posts to X (Twitter) and TikTok by driving the persistent Playwright context
|
|
3
|
+
// managed by SocialView (server/socialview/browser-manager.ts). User logs in
|
|
4
|
+
// once manually via noVNC; cookies persist in
|
|
5
|
+
// ~/.heyhank/browser-profiles/<platform>.
|
|
6
|
+
//
|
|
7
|
+
// Scope v1:
|
|
8
|
+
// - X: text + optional single image
|
|
9
|
+
// - TikTok: video + description
|
|
10
|
+
// Analytics, comments, reply, list, delete are not supported here.
|
|
11
|
+
|
|
12
|
+
import type { SocialMediaAdapter } from "../adapter.js";
|
|
13
|
+
import type {
|
|
14
|
+
SocialProfile,
|
|
15
|
+
CreatePostInput,
|
|
16
|
+
PostAnalytics,
|
|
17
|
+
AccountAnalytics,
|
|
18
|
+
SocialComment,
|
|
19
|
+
SocialPlatform,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import * as browser from "../../socialview/browser-manager.js";
|
|
22
|
+
import type { SocialPlatform as ViewPlatform } from "../../socialview/types.js";
|
|
23
|
+
import { postToTiktok, postToX, resolveMediaToDiskPath } from "../../socialview/poster.js";
|
|
24
|
+
|
|
25
|
+
// Subset of both the socialmedia and socialview SocialPlatform unions.
|
|
26
|
+
type BrowserBackedPlatform = Extract<SocialPlatform, "twitter" | "tiktok"> & ViewPlatform;
|
|
27
|
+
const SUPPORTED: BrowserBackedPlatform[] = ["twitter", "tiktok"];
|
|
28
|
+
|
|
29
|
+
export class BrowserAdapter implements SocialMediaAdapter {
|
|
30
|
+
/**
|
|
31
|
+
* Target platforms for a single `createPost` call. Set by the manager
|
|
32
|
+
* before calling so we don't accidentally post to both X and TikTok when
|
|
33
|
+
* only one was requested.
|
|
34
|
+
*/
|
|
35
|
+
private targetPlatforms: BrowserBackedPlatform[] = [];
|
|
36
|
+
|
|
37
|
+
setTargetPlatforms(platforms: SocialPlatform[]): void {
|
|
38
|
+
this.targetPlatforms = platforms.filter(
|
|
39
|
+
(p): p is BrowserBackedPlatform => (SUPPORTED as readonly SocialPlatform[]).includes(p),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
supportedPlatforms(): SocialPlatform[] {
|
|
44
|
+
return [...SUPPORTED];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async testConnection(): Promise<{ ok: boolean; error?: string; data?: unknown }> {
|
|
48
|
+
const results: Record<string, { running: boolean; loggedIn: boolean | null }> = {};
|
|
49
|
+
const failures: string[] = [];
|
|
50
|
+
for (const p of SUPPORTED) {
|
|
51
|
+
const status = browser.getStatus(p);
|
|
52
|
+
results[p] = { running: status.running, loggedIn: status.loggedIn };
|
|
53
|
+
if (!status.running) failures.push(`${p}: browser not running`);
|
|
54
|
+
else if (status.loggedIn === false) failures.push(`${p}: not logged in`);
|
|
55
|
+
}
|
|
56
|
+
if (failures.length > 0) {
|
|
57
|
+
return { ok: false, error: failures.join("; "), data: results };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, data: results };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getProfiles(): Promise<SocialProfile[]> {
|
|
63
|
+
// v1: we don't read the actual handle from the DOM. Report a placeholder
|
|
64
|
+
// per running+loggedIn platform so the UI at least shows a profile card.
|
|
65
|
+
const out: SocialProfile[] = [];
|
|
66
|
+
for (const p of SUPPORTED) {
|
|
67
|
+
const status = browser.getStatus(p);
|
|
68
|
+
if (status.running && status.loggedIn !== false) {
|
|
69
|
+
out.push({
|
|
70
|
+
id: `browser:${p}`,
|
|
71
|
+
platform: p,
|
|
72
|
+
name: `${p}-browser`,
|
|
73
|
+
picture: null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async createPost(
|
|
81
|
+
input: CreatePostInput,
|
|
82
|
+
): Promise<{ id: string | null; status: string; backendData?: unknown }> {
|
|
83
|
+
const platforms: BrowserBackedPlatform[] = this.targetPlatforms.length > 0
|
|
84
|
+
? this.targetPlatforms
|
|
85
|
+
: input.platforms.filter(
|
|
86
|
+
(p): p is BrowserBackedPlatform => (SUPPORTED as readonly SocialPlatform[]).includes(p),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (platforms.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
id: null,
|
|
92
|
+
status: "failed",
|
|
93
|
+
backendData: { error: "BrowserAdapter called with no supported platforms" },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const results: Record<string, { ok: boolean; url: string | null; error?: string }> = {};
|
|
98
|
+
let anyOk = false;
|
|
99
|
+
let anyFail = false;
|
|
100
|
+
|
|
101
|
+
for (const platform of platforms) {
|
|
102
|
+
try {
|
|
103
|
+
// Ensure the persistent Chromium is running for this platform.
|
|
104
|
+
const status = browser.getStatus(platform);
|
|
105
|
+
if (!status.running) await browser.startPlatform(platform);
|
|
106
|
+
|
|
107
|
+
const page = browser.getPage(platform);
|
|
108
|
+
if (!page) throw new Error(`${platform}: browser page unavailable`);
|
|
109
|
+
if (browser.getStatus(platform).loggedIn === false) {
|
|
110
|
+
throw new Error(`${platform}: not logged in — open browser in SocialView and sign in`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (platform === "tiktok") {
|
|
114
|
+
// TikTok requires a video. Prefer videoUrl, fall back to first mediaUrl.
|
|
115
|
+
const videoUrl = input.videoUrl || (input.mediaUrls ?? [])[0];
|
|
116
|
+
if (!videoUrl) throw new Error("tiktok: videoUrl (or mediaUrls[0]) required");
|
|
117
|
+
const videoPath = await resolveMediaToDiskPath(videoUrl);
|
|
118
|
+
const r = await postToTiktok(page, {
|
|
119
|
+
description: input.text,
|
|
120
|
+
videoPath,
|
|
121
|
+
});
|
|
122
|
+
results[platform] = { ok: true, url: r.url };
|
|
123
|
+
anyOk = true;
|
|
124
|
+
} else if (platform === "twitter") {
|
|
125
|
+
// X: text + optional single image. v1 takes only mediaUrls[0].
|
|
126
|
+
const firstMedia = (input.mediaUrls ?? [])[0];
|
|
127
|
+
const imagePath = firstMedia ? await resolveMediaToDiskPath(firstMedia) : undefined;
|
|
128
|
+
const r = await postToX(page, { text: input.text, imagePath });
|
|
129
|
+
results[platform] = { ok: true, url: r.url };
|
|
130
|
+
anyOk = true;
|
|
131
|
+
} else {
|
|
132
|
+
results[platform] = { ok: false, url: null, error: "unsupported" };
|
|
133
|
+
anyFail = true;
|
|
134
|
+
}
|
|
135
|
+
} catch (err: unknown) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
137
|
+
results[platform] = { ok: false, url: null, error: message };
|
|
138
|
+
anyFail = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const status = anyOk && anyFail ? "partial" : anyOk ? "published" : "failed";
|
|
143
|
+
// Pick a primary URL (first success) for the flat `id` slot; detailed
|
|
144
|
+
// per-platform results go in backendData.
|
|
145
|
+
const primary = platforms
|
|
146
|
+
.map((p) => results[p])
|
|
147
|
+
.find((r) => r?.ok && r.url)?.url ?? null;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
id: primary,
|
|
151
|
+
status,
|
|
152
|
+
backendData: { results },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async listPosts(): Promise<Array<{ id: string; text: string; status: string; platforms: string[]; createdAt?: string | null; scheduledAt?: string | null }>> {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async deletePost(_postId: string): Promise<boolean> {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getAnalytics(_postId: string): Promise<PostAnalytics> {
|
|
165
|
+
return { impressions: 0, likes: 0, shares: 0, comments: 0 };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getAccountAnalytics(_profileId: string): Promise<AccountAnalytics> {
|
|
169
|
+
return { followers: 0, following: 0, posts: 0 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getComments(_postId: string): Promise<SocialComment[]> {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async replyToComment(_postId: string, _commentId: string | null, _text: string): Promise<{ ok: boolean; error?: string }> {
|
|
177
|
+
return { ok: false, error: "not supported" };
|
|
178
|
+
}
|
|
179
|
+
}
|