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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -10
  3. package/bin/cli.ts +7 -7
  4. package/bin/ctl.ts +42 -42
  5. package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
  6. package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
  7. package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
  9. package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
  11. package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
  12. package/dist/assets/MediaPage-C48HTTrt.js +1 -0
  13. package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
  18. package/dist/assets/RunsPage-B9UOyO79.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
  20. package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
  21. package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
  22. package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
  24. package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
  28. package/dist/assets/index-BkjSoVgn.css +32 -0
  29. package/dist/assets/sw-register-C7NOHtIu.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/index.html +2 -2
  32. package/dist/sw.js +1 -1
  33. package/package.json +6 -1
  34. package/server/agent-executor.ts +37 -2
  35. package/server/agent-store.ts +3 -3
  36. package/server/agent-types.ts +11 -0
  37. package/server/assistant-store.ts +232 -6
  38. package/server/auth-manager.ts +9 -0
  39. package/server/cache-headers.ts +1 -1
  40. package/server/calendar-service.ts +10 -0
  41. package/server/ceo/document-store.ts +129 -0
  42. package/server/ceo/finance-store.ts +343 -0
  43. package/server/ceo/kpi-store.ts +208 -0
  44. package/server/ceo/memory-import.ts +277 -0
  45. package/server/ceo/news-store.ts +208 -0
  46. package/server/ceo/template-store.ts +134 -0
  47. package/server/ceo/time-tracking-store.ts +227 -0
  48. package/server/claude-auth-monitor.ts +128 -0
  49. package/server/claude-code-worker.ts +86 -0
  50. package/server/claude-session-discovery.ts +74 -1
  51. package/server/cli-launcher.ts +32 -10
  52. package/server/codex-adapter.ts +2 -2
  53. package/server/codex-ws-proxy.cjs +1 -1
  54. package/server/container-manager.ts +4 -4
  55. package/server/content-intelligence/content-engine.ts +1112 -0
  56. package/server/content-intelligence/platform-knowledge.ts +870 -0
  57. package/server/cron-store.ts +3 -3
  58. package/server/embedding-service.ts +49 -0
  59. package/server/event-bus-types.ts +13 -0
  60. package/server/federation/node-store.ts +5 -4
  61. package/server/fs-utils.ts +28 -1
  62. package/server/hank-notifications-store.ts +91 -0
  63. package/server/hank-tool-executor.ts +1835 -0
  64. package/server/hank-tools.ts +2107 -0
  65. package/server/image-pull-manager.ts +2 -2
  66. package/server/index.ts +25 -2
  67. package/server/llm-providers-streaming.ts +541 -0
  68. package/server/llm-providers.ts +12 -0
  69. package/server/marketplace.ts +249 -0
  70. package/server/mcp-registry.ts +158 -0
  71. package/server/memory-service.ts +296 -0
  72. package/server/obsidian-sync.ts +184 -0
  73. package/server/provider-manager.ts +5 -2
  74. package/server/provider-registry.ts +12 -0
  75. package/server/reminder-scheduler.ts +37 -1
  76. package/server/routes/agent-routes.ts +2 -1
  77. package/server/routes/assistant-routes.ts +198 -5
  78. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  79. package/server/routes/ceo-news-time-routes.ts +137 -0
  80. package/server/routes/ceo-routes.ts +99 -0
  81. package/server/routes/content-routes.ts +116 -0
  82. package/server/routes/email-routes.ts +147 -0
  83. package/server/routes/env-routes.ts +3 -3
  84. package/server/routes/fs-routes.ts +12 -9
  85. package/server/routes/hank-chat-routes.ts +592 -0
  86. package/server/routes/llm-routes.ts +12 -0
  87. package/server/routes/marketplace-routes.ts +63 -0
  88. package/server/routes/media-routes.ts +1 -1
  89. package/server/routes/memory-routes.ts +127 -0
  90. package/server/routes/platform-routes.ts +14 -675
  91. package/server/routes/sandbox-routes.ts +1 -1
  92. package/server/routes/settings-routes.ts +51 -1
  93. package/server/routes/socialmedia-routes.ts +152 -2
  94. package/server/routes/system-routes.ts +2 -2
  95. package/server/routes/team-routes.ts +71 -0
  96. package/server/routes/telephony-routes.ts +98 -18
  97. package/server/routes.ts +36 -9
  98. package/server/session-creation-service.ts +2 -2
  99. package/server/session-orchestrator.ts +54 -2
  100. package/server/session-types.ts +2 -0
  101. package/server/settings-manager.ts +50 -2
  102. package/server/skill-discovery.ts +68 -0
  103. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  104. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  105. package/server/socialmedia/manager.ts +234 -15
  106. package/server/socialmedia/store.ts +51 -1
  107. package/server/socialmedia/types.ts +35 -2
  108. package/server/socialview/browser-manager.ts +150 -0
  109. package/server/socialview/extractors.ts +1298 -0
  110. package/server/socialview/image-describe.ts +188 -0
  111. package/server/socialview/library.ts +119 -0
  112. package/server/socialview/poster.ts +276 -0
  113. package/server/socialview/routes.ts +371 -0
  114. package/server/socialview/style-analyzer.ts +187 -0
  115. package/server/socialview/style-profiles.ts +67 -0
  116. package/server/socialview/types.ts +166 -0
  117. package/server/socialview/vision.ts +127 -0
  118. package/server/socialview/vnc-manager.ts +110 -0
  119. package/server/style-injector.ts +135 -0
  120. package/server/team-service.ts +239 -0
  121. package/server/team-store.ts +75 -0
  122. package/server/team-types.ts +52 -0
  123. package/server/telephony/audio-bridge.ts +281 -35
  124. package/server/telephony/audio-recorder.ts +132 -0
  125. package/server/telephony/call-manager.ts +803 -104
  126. package/server/telephony/call-types.ts +67 -1
  127. package/server/telephony/esl-client.ts +319 -0
  128. package/server/telephony/freeswitch-sync.ts +155 -0
  129. package/server/telephony/phone-utils.ts +63 -0
  130. package/server/telephony/telephony-store.ts +9 -8
  131. package/server/url-validator.ts +82 -0
  132. package/server/vault-markdown.ts +317 -0
  133. package/server/vault-migration.ts +121 -0
  134. package/server/vault-store.ts +466 -0
  135. package/server/vault-watcher.ts +59 -0
  136. package/server/vector-store.ts +210 -0
  137. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  138. package/server/voice-pipeline/greeting-cache.ts +200 -0
  139. package/server/voice-pipeline/manager.ts +249 -0
  140. package/server/voice-pipeline/pipeline.ts +335 -0
  141. package/server/voice-pipeline/providers/index.ts +47 -0
  142. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  143. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  144. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  145. package/server/voice-pipeline/types.ts +247 -0
  146. package/server/ws-bridge-types.ts +6 -1
  147. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  148. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  149. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  150. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  151. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  152. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  153. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  154. package/dist/assets/index-C8M_PUmX.css +0 -32
  155. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  156. 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 = "the-companion:latest";
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 === "the-companion:latest",
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 = "the-companion:latest";
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 === "the-companion:latest",
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
 
@@ -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
- writeFileSync(filePath, JSON.stringify(settings, null, 2), { encoding: "utf-8", mode: 0o600 });
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
+ }