heyhank 0.1.0 → 0.3.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 (161) 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-DqjDAcIw.js} +3 -3
  6. package/dist/assets/AssistantPage-C50CQFSB.js +2 -0
  7. package/dist/assets/BusinessPage-AY70tf1k.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-Dt7LLuRr.js} +1 -1
  9. package/dist/assets/HelpPage-tlGx7fQF.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-B4XOuHXu.js} +1 -1
  11. package/dist/assets/JarvisHUD-BDvuRd0I.js +120 -0
  12. package/dist/assets/MediaPage-CofV9Rd-.js +1 -0
  13. package/dist/assets/MemoryPage-Cj7FeqmJ.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-B9kXAlH1.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-Cka-pRkP.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-BqhQgfYj.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-VveKc9uX.js} +2 -2
  18. package/dist/assets/RunsPage-DXVEk0AZ.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-DACcwfDF.js} +1 -1
  20. package/dist/assets/SettingsPage-jfuQh8Tu.js +51 -0
  21. package/dist/assets/SkillsMarketplace-DrigiApe.js +1 -0
  22. package/dist/assets/SocialMediaPage-DOh3IPe8.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DLhJWATT.js} +1 -1
  24. package/dist/assets/TelephonyPage-9C4C3_ot.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-ChX-8Wu7.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/index-C6Q5UQHD.js +229 -0
  28. package/dist/assets/index-ZxGXgiV3.css +32 -0
  29. package/dist/assets/sw-register-BBYuk-kw.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
  32. package/dist/index.html +2 -2
  33. package/dist/sw.js +1 -1
  34. package/dist/{workbox-d2a0910a.js → workbox-080c8b91.js} +1 -1
  35. package/package.json +6 -1
  36. package/server/agent-executor.ts +102 -2
  37. package/server/agent-store.ts +3 -3
  38. package/server/agent-types.ts +11 -0
  39. package/server/assistant-store.ts +232 -6
  40. package/server/auth-manager.ts +9 -0
  41. package/server/cache-headers.ts +1 -1
  42. package/server/calendar-service.ts +10 -0
  43. package/server/ceo/document-store.ts +129 -0
  44. package/server/ceo/finance-store.ts +343 -0
  45. package/server/ceo/kpi-store.ts +208 -0
  46. package/server/ceo/memory-import.ts +277 -0
  47. package/server/ceo/news-store.ts +208 -0
  48. package/server/ceo/template-store.ts +134 -0
  49. package/server/ceo/time-tracking-store.ts +227 -0
  50. package/server/claude-auth-monitor.ts +128 -0
  51. package/server/claude-code-worker.ts +86 -0
  52. package/server/claude-session-discovery.ts +74 -1
  53. package/server/cli-launcher.ts +32 -10
  54. package/server/codex-adapter.ts +2 -2
  55. package/server/codex-ws-proxy.cjs +1 -1
  56. package/server/container-manager.ts +4 -4
  57. package/server/content-intelligence/content-engine.ts +1112 -0
  58. package/server/content-intelligence/platform-knowledge.ts +870 -0
  59. package/server/cron-store.ts +3 -3
  60. package/server/embedding-service.ts +49 -0
  61. package/server/event-bus-types.ts +13 -0
  62. package/server/execution-store.ts +54 -1
  63. package/server/federation/node-store.ts +5 -4
  64. package/server/fs-utils.ts +28 -1
  65. package/server/hank-notifications-store.ts +91 -0
  66. package/server/hank-tool-executor.ts +1835 -0
  67. package/server/hank-tools.ts +2107 -0
  68. package/server/image-pull-manager.ts +2 -2
  69. package/server/index.ts +25 -2
  70. package/server/llm-providers-streaming.ts +541 -0
  71. package/server/llm-providers.ts +12 -0
  72. package/server/marketplace.ts +249 -0
  73. package/server/mcp-registry.ts +158 -0
  74. package/server/memory-service.ts +296 -0
  75. package/server/obsidian-sync.ts +184 -0
  76. package/server/provider-manager.ts +5 -2
  77. package/server/provider-registry.ts +12 -0
  78. package/server/reminder-scheduler.ts +37 -1
  79. package/server/routes/agent-routes.ts +44 -1
  80. package/server/routes/assistant-routes.ts +198 -5
  81. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  82. package/server/routes/ceo-news-time-routes.ts +137 -0
  83. package/server/routes/ceo-routes.ts +99 -0
  84. package/server/routes/content-routes.ts +116 -0
  85. package/server/routes/email-routes.ts +147 -0
  86. package/server/routes/env-routes.ts +3 -3
  87. package/server/routes/fs-routes.ts +12 -9
  88. package/server/routes/hank-chat-routes.ts +592 -0
  89. package/server/routes/llm-routes.ts +12 -0
  90. package/server/routes/marketplace-routes.ts +63 -0
  91. package/server/routes/media-routes.ts +1 -1
  92. package/server/routes/memory-routes.ts +127 -0
  93. package/server/routes/platform-routes.ts +14 -675
  94. package/server/routes/sandbox-routes.ts +1 -1
  95. package/server/routes/settings-routes.ts +51 -1
  96. package/server/routes/socialmedia-routes.ts +152 -2
  97. package/server/routes/system-routes.ts +2 -2
  98. package/server/routes/team-routes.ts +71 -0
  99. package/server/routes/telephony-routes.ts +98 -18
  100. package/server/routes.ts +36 -9
  101. package/server/session-creation-service.ts +2 -2
  102. package/server/session-orchestrator.ts +54 -2
  103. package/server/session-types.ts +2 -0
  104. package/server/settings-manager.ts +50 -2
  105. package/server/skill-discovery.ts +68 -0
  106. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  107. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  108. package/server/socialmedia/manager.ts +234 -15
  109. package/server/socialmedia/store.ts +51 -1
  110. package/server/socialmedia/types.ts +35 -2
  111. package/server/socialview/browser-manager.ts +150 -0
  112. package/server/socialview/extractors.ts +1298 -0
  113. package/server/socialview/image-describe.ts +188 -0
  114. package/server/socialview/library.ts +119 -0
  115. package/server/socialview/poster.ts +276 -0
  116. package/server/socialview/routes.ts +371 -0
  117. package/server/socialview/style-analyzer.ts +187 -0
  118. package/server/socialview/style-profiles.ts +67 -0
  119. package/server/socialview/types.ts +166 -0
  120. package/server/socialview/vision.ts +127 -0
  121. package/server/socialview/vnc-manager.ts +110 -0
  122. package/server/style-injector.ts +135 -0
  123. package/server/team-service.ts +239 -0
  124. package/server/team-store.ts +75 -0
  125. package/server/team-types.ts +52 -0
  126. package/server/telephony/audio-bridge.ts +281 -35
  127. package/server/telephony/audio-recorder.ts +132 -0
  128. package/server/telephony/call-manager.ts +803 -104
  129. package/server/telephony/call-types.ts +67 -1
  130. package/server/telephony/esl-client.ts +319 -0
  131. package/server/telephony/freeswitch-sync.ts +155 -0
  132. package/server/telephony/phone-utils.ts +63 -0
  133. package/server/telephony/telephony-store.ts +9 -8
  134. package/server/url-validator.ts +82 -0
  135. package/server/vault-markdown.ts +317 -0
  136. package/server/vault-migration.ts +121 -0
  137. package/server/vault-store.ts +466 -0
  138. package/server/vault-watcher.ts +59 -0
  139. package/server/vector-store.ts +210 -0
  140. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  141. package/server/voice-pipeline/greeting-cache.ts +200 -0
  142. package/server/voice-pipeline/manager.ts +249 -0
  143. package/server/voice-pipeline/pipeline.ts +335 -0
  144. package/server/voice-pipeline/providers/index.ts +47 -0
  145. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  146. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  147. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  148. package/server/voice-pipeline/types.ts +247 -0
  149. package/server/ws-bridge-types.ts +6 -1
  150. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  151. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  152. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  153. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  154. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  155. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  156. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  157. package/dist/assets/index-C8M_PUmX.css +0 -32
  158. package/dist/assets/index-CEqZnThB.js +0 -204
  159. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  160. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +0 -2
  161. package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
@@ -0,0 +1,371 @@
1
+ // ─── SocialView Routes ───────────────────────────────────────────────────────
2
+ // REST endpoints for controlling the browser-based viewer.
3
+
4
+ import type { Hono } from "hono";
5
+ import { streamSSE } from "hono/streaming";
6
+ import * as browser from "./browser-manager.js";
7
+ import * as vnc from "./vnc-manager.js";
8
+ import * as library from "./library.js";
9
+ import * as styleProfiles from "./style-profiles.js";
10
+ import { analyzeHandleStyle } from "./style-analyzer.js";
11
+ import { extractCurrentPage } from "./extractors.js";
12
+ import { SOCIAL_PLATFORMS, type SocialPlatform } from "./types.js";
13
+
14
+ function parsePlatform(p: string): SocialPlatform | null {
15
+ return SOCIAL_PLATFORMS.includes(p as SocialPlatform) ? (p as SocialPlatform) : null;
16
+ }
17
+
18
+ export function registerSocialViewRoutes(api: Hono): void {
19
+ /** Status of all platforms + VNC infra. */
20
+ api.get("/socialview/status", async (c) => {
21
+ return c.json({
22
+ vnc: await vnc.getVncStatus(),
23
+ platforms: browser.getAllStatus(),
24
+ });
25
+ });
26
+
27
+ /** Start a platform browser. Also boots VNC if needed. */
28
+ api.post("/socialview/:platform/start", async (c) => {
29
+ const platform = parsePlatform(c.req.param("platform"));
30
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
31
+ try {
32
+ // startPlatform ensures Xvfb is up; VNC must come after so x11vnc can attach to :99
33
+ const status = await browser.startPlatform(platform);
34
+ await vnc.ensureVnc();
35
+ return c.json({ ok: true, status, vnc: await vnc.getVncStatus() });
36
+ } catch (e) {
37
+ return c.json({ error: e instanceof Error ? e.message : "failed" }, 500);
38
+ }
39
+ });
40
+
41
+ /** Stop a platform browser (profile stays on disk). */
42
+ api.post("/socialview/:platform/stop", async (c) => {
43
+ const platform = parsePlatform(c.req.param("platform"));
44
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
45
+ try {
46
+ await browser.stopPlatform(platform);
47
+ return c.json({ ok: true, status: browser.getStatus(platform) });
48
+ } catch (e) {
49
+ return c.json({ error: e instanceof Error ? e.message : "failed" }, 500);
50
+ }
51
+ });
52
+
53
+ /** Navigate an already-running browser to a URL (e.g. a profile page). */
54
+ api.post("/socialview/:platform/goto", async (c) => {
55
+ const platform = parsePlatform(c.req.param("platform"));
56
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
57
+ try {
58
+ const body = (await c.req.json()) as { url?: string };
59
+ if (!body.url) return c.json({ error: "url required" }, 400);
60
+ const status = await browser.gotoUrl(platform, body.url);
61
+ return c.json({ ok: true, status });
62
+ } catch (e) {
63
+ return c.json({ error: e instanceof Error ? e.message : "failed" }, 500);
64
+ }
65
+ });
66
+
67
+ /** Per-platform status. */
68
+ api.get("/socialview/:platform/status", (c) => {
69
+ const platform = parsePlatform(c.req.param("platform"));
70
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
71
+ return c.json(browser.getStatus(platform));
72
+ });
73
+
74
+ // ─── Debug: dump permalink page DOM ─────────────────────────────────
75
+ // POST { url } → opens the permalink in a background tab using the live
76
+ // FB browser context, expands all comments, and writes the rendered HTML +
77
+ // a JSON analysis of comment-container candidates to /tmp/. Used to refine
78
+ // the comment-extraction selectors when the live extractor returns 0
79
+ // own-comments. Temporary; safe to remove once selectors are stable.
80
+ api.post("/socialview/:platform/debug-permalink", async (c) => {
81
+ const platform = parsePlatform(c.req.param("platform"));
82
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
83
+ const page = browser.getPage(platform);
84
+ if (!page) return c.json({ error: `${platform} not running` }, 400);
85
+ const body = (await c.req.json()) as { url?: string; ownerHandle?: string };
86
+ if (!body.url) return c.json({ error: "url required" }, 400);
87
+ const owner = (body.ownerHandle || "").toLowerCase();
88
+
89
+ const tab = await page.context().newPage();
90
+ try {
91
+ await tab.goto(body.url, { waitUntil: "domcontentloaded", timeout: 25_000 });
92
+ await tab.waitForTimeout(2500);
93
+
94
+ // Expand comments + replies repeatedly.
95
+ for (let pass = 0; pass < 6; pass++) {
96
+ const clicked = await tab.evaluate(() => {
97
+ const cands = Array.from(
98
+ document.querySelectorAll("div[role='button'], span[role='button'], span"),
99
+ ) as HTMLElement[];
100
+ const re = /^(weitere kommentare anzeigen|view more comments|alle kommentare anzeigen|view all comments|previous comments|vorherige kommentare|antworten anzeigen|view replies?|view all \d+ replies?|\d+ antworten|\d+ replies?|kommentar(e)? anzeigen|see more|mehr anzeigen|weiterlesen)$/i;
101
+ let n = 0;
102
+ for (const el of cands) {
103
+ if (!el.isConnected) continue;
104
+ const t = (el.textContent || "").trim();
105
+ if (!t || t.length > 60) continue;
106
+ if (re.test(t)) {
107
+ try { el.click(); n++; } catch { /* noop */ }
108
+ }
109
+ }
110
+ return n;
111
+ }).catch(() => 0);
112
+ if (!clicked) break;
113
+ await tab.waitForTimeout(900);
114
+ }
115
+
116
+ // Analyse + dump.
117
+ const analysis = await tab.evaluate(({ owner }) => {
118
+ const seen: string[] = [];
119
+ // Find anything that looks like a comment container.
120
+ const articleNodes = Array.from(document.querySelectorAll("[role='article']")) as HTMLElement[];
121
+ const articleInfo = articleNodes.map((el, i) => ({
122
+ idx: i,
123
+ ariaLabel: el.getAttribute("aria-label") || "",
124
+ hasVerfasser: !!Array.from(el.querySelectorAll("span, div")).find((s) => {
125
+ const t = (s.textContent || "").trim();
126
+ return t === "Verfasser" || t === "Author";
127
+ }),
128
+ authorLinks: Array.from(el.querySelectorAll("a[href]"))
129
+ .slice(0, 8)
130
+ .map((a) => (a as HTMLAnchorElement).getAttribute("href") || ""),
131
+ textPreview: (el.innerText || "").trim().slice(0, 200),
132
+ }));
133
+
134
+ // Standalone "Verfasser"-tagged elements (not inside role='article').
135
+ const verfasserNodes = Array.from(document.querySelectorAll("span, div"))
136
+ .filter((el) => {
137
+ const t = (el.textContent || "").trim();
138
+ return t === "Verfasser" || t === "Author";
139
+ })
140
+ .map((el, i) => {
141
+ // Walk up to find the comment unit.
142
+ let cur: HTMLElement | null = el as HTMLElement;
143
+ const ancestry: string[] = [];
144
+ for (let k = 0; k < 8 && cur; k++) {
145
+ cur = cur.parentElement;
146
+ if (!cur) break;
147
+ ancestry.push(`${cur.tagName.toLowerCase()}${cur.getAttribute("role") ? `[role=${cur.getAttribute("role")}]` : ""}`);
148
+ }
149
+ return { idx: i, ancestry: ancestry.slice(0, 6).join(" > ") };
150
+ });
151
+
152
+ return {
153
+ url: location.href,
154
+ title: document.title,
155
+ totalArticles: articleNodes.length,
156
+ articleInfo,
157
+ verfasserCount: verfasserNodes.length,
158
+ verfasserPaths: verfasserNodes.slice(0, 12),
159
+ owner,
160
+ };
161
+ }, { owner });
162
+
163
+ const fs = await import("node:fs/promises");
164
+ const html = await tab.content();
165
+ await fs.writeFile("/tmp/fb-permalink-debug.html", html.slice(0, 1_500_000));
166
+ await fs.writeFile("/tmp/fb-permalink-debug.json", JSON.stringify(analysis, null, 2));
167
+ return c.json({ ok: true, analysis });
168
+ } catch (e) {
169
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
170
+ } finally {
171
+ await tab.close().catch(() => { /* noop */ });
172
+ }
173
+ });
174
+
175
+ // ─── Extraction ─────────────────────────────────────────────────────
176
+ /** Extract the post(s) currently visible on the platform's browser page.
177
+ * If the page is a profile, extracts the first N posts linked from it. */
178
+ api.post("/socialview/:platform/extract", async (c) => {
179
+ const platform = parsePlatform(c.req.param("platform"));
180
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
181
+ try {
182
+ const body = (await c.req.json().catch(() => ({}))) as {
183
+ source?: "own" | "role-model";
184
+ };
185
+ const source = body.source === "role-model" ? "role-model" : "own";
186
+ const page = browser.getPage(platform);
187
+ if (!page) return c.json({ error: "platform not running — click Start first" }, 400);
188
+
189
+ const result = await extractCurrentPage({ platform, page, source });
190
+ // Persist each extracted post to library.
191
+ for (const post of result.posts) library.savePost(post);
192
+ return c.json({
193
+ ok: true,
194
+ extracted: result.posts.length,
195
+ postIds: result.posts.map((p) => p.id),
196
+ errors: result.errors,
197
+ });
198
+ } catch (e) {
199
+ return c.json({ error: e instanceof Error ? e.message : "extract failed" }, 500);
200
+ }
201
+ });
202
+
203
+ /**
204
+ * Streaming variant of /extract. Returns Server-Sent Events so the frontend
205
+ * can render live progress (current URL, detected mode, per-post links,
206
+ * success / failure for each).
207
+ *
208
+ * Event types (all data is JSON):
209
+ * log — {message}
210
+ * url — {url} current browser URL at start
211
+ * mode — {mode, maxPosts} detected extraction mode
212
+ * post — {index, total, href, ok, error?, title?}
213
+ * done — {extracted, errors, postIds}
214
+ * fatal — {error}
215
+ */
216
+ api.post("/socialview/:platform/extract-stream", async (c) => {
217
+ const platform = parsePlatform(c.req.param("platform"));
218
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
219
+
220
+ const body = (await c.req.json().catch(() => ({}))) as { source?: "own" | "role-model" };
221
+ const source = body.source === "role-model" ? "role-model" : "own";
222
+
223
+ return streamSSE(c, async (stream) => {
224
+ const send = async (event: string, data: unknown) => {
225
+ await stream.writeSSE({ event, data: JSON.stringify(data) });
226
+ };
227
+ try {
228
+ const page = browser.getPage(platform);
229
+ if (!page) {
230
+ await send("fatal", { error: "platform not running — click Start first" });
231
+ return;
232
+ }
233
+ await send("url", { url: page.url() });
234
+
235
+ const result = await extractCurrentPage({
236
+ platform,
237
+ page,
238
+ source,
239
+ onLog: async (msg) => { await send("log", { message: msg }); },
240
+ });
241
+ for (const post of result.posts) library.savePost(post);
242
+
243
+ await send("done", {
244
+ extracted: result.posts.length,
245
+ postIds: result.posts.map((p) => p.id),
246
+ errors: result.errors,
247
+ });
248
+ } catch (e) {
249
+ await send("fatal", { error: e instanceof Error ? e.message : "extract failed" });
250
+ }
251
+ });
252
+ });
253
+
254
+ // ─── Library ────────────────────────────────────────────────────────
255
+ /** List library posts with optional filters. */
256
+ api.get("/socialview/library", (c) => {
257
+ const params = c.req.query();
258
+ const platform = params.platform ? parsePlatform(params.platform) : null;
259
+ if (params.platform && !platform) return c.json({ error: "invalid platform" }, 400);
260
+ const source = params.source === "own" || params.source === "role-model" ? params.source : undefined;
261
+ const goldOnly = params.goldOnly === "true" || params.gold === "true";
262
+ const minEngagementRate = params.minEngagement ? Number(params.minEngagement) : undefined;
263
+ const tags = params.tags ? params.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
264
+ const limit = params.limit ? Number(params.limit) : undefined;
265
+ const posts = library.listPosts({
266
+ platform: platform ?? undefined,
267
+ source,
268
+ goldOnly,
269
+ minEngagementRate,
270
+ tags,
271
+ limit,
272
+ });
273
+ return c.json({ posts });
274
+ });
275
+
276
+ /** Update a library post (tags, isGold, source, notes). */
277
+ api.patch("/socialview/library/:platform/:id", async (c) => {
278
+ const platform = parsePlatform(c.req.param("platform"));
279
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
280
+ try {
281
+ const body = (await c.req.json()) as {
282
+ tags?: string[];
283
+ isGold?: boolean;
284
+ notes?: string;
285
+ source?: "own" | "role-model";
286
+ };
287
+ const updated = library.updatePost(platform, c.req.param("id"), body);
288
+ if (!updated) return c.json({ error: "not found" }, 404);
289
+ return c.json({ ok: true, post: updated });
290
+ } catch (e) {
291
+ return c.json({ error: e instanceof Error ? e.message : "update failed" }, 400);
292
+ }
293
+ });
294
+
295
+ /** Delete a library post. */
296
+ api.delete("/socialview/library/:platform/:id", (c) => {
297
+ const platform = parsePlatform(c.req.param("platform"));
298
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
299
+ const ok = library.deletePost(platform, c.req.param("id"));
300
+ if (!ok) return c.json({ error: "not found" }, 404);
301
+ return c.json({ ok: true });
302
+ });
303
+
304
+ // ─── Style Profiles ──────────────────────────────────────────────────
305
+ /** List all style profiles. */
306
+ api.get("/socialview/style-profiles", (c) => {
307
+ return c.json({ profiles: styleProfiles.listProfiles() });
308
+ });
309
+
310
+ /** Get a single style profile. */
311
+ api.get("/socialview/style-profiles/:platform/:handle", (c) => {
312
+ const platform = parsePlatform(c.req.param("platform"));
313
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
314
+ const profile = styleProfiles.getProfile(platform, c.req.param("handle"));
315
+ if (!profile) return c.json({ error: "not found" }, 404);
316
+ return c.json(profile);
317
+ });
318
+
319
+ /**
320
+ * Generate (or regenerate) a style profile for a handle by running an
321
+ * LLM analysis over all library posts of that handle. Returns the new
322
+ * profile.
323
+ */
324
+ api.post("/socialview/style-profiles/:platform/:handle/analyze", async (c) => {
325
+ const platform = parsePlatform(c.req.param("platform"));
326
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
327
+ try {
328
+ const profile = await analyzeHandleStyle(platform, c.req.param("handle"));
329
+ return c.json({ ok: true, profile });
330
+ } catch (e) {
331
+ return c.json({ error: e instanceof Error ? e.message : "analysis failed" }, 500);
332
+ }
333
+ });
334
+
335
+ /** Manually edit fields of a style profile. */
336
+ api.patch("/socialview/style-profiles/:platform/:handle", async (c) => {
337
+ const platform = parsePlatform(c.req.param("platform"));
338
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
339
+ const handle = c.req.param("handle");
340
+ const existing = styleProfiles.getProfile(platform, handle);
341
+ if (!existing) return c.json({ error: "not found" }, 404);
342
+ try {
343
+ const body = await c.req.json();
344
+ const updated = {
345
+ ...existing,
346
+ ...body,
347
+ // Don't let the client overwrite identity / audit fields.
348
+ id: existing.id,
349
+ platform: existing.platform,
350
+ handle: existing.handle,
351
+ basedOnPostIds: existing.basedOnPostIds,
352
+ basedOnPostCount: existing.basedOnPostCount,
353
+ createdAt: existing.createdAt,
354
+ updatedAt: new Date().toISOString(),
355
+ };
356
+ styleProfiles.saveProfile(updated);
357
+ return c.json({ ok: true, profile: updated });
358
+ } catch (e) {
359
+ return c.json({ error: e instanceof Error ? e.message : "update failed" }, 400);
360
+ }
361
+ });
362
+
363
+ /** Delete a style profile. */
364
+ api.delete("/socialview/style-profiles/:platform/:handle", (c) => {
365
+ const platform = parsePlatform(c.req.param("platform"));
366
+ if (!platform) return c.json({ error: "invalid platform" }, 400);
367
+ const ok = styleProfiles.deleteProfile(platform, c.req.param("handle"));
368
+ if (!ok) return c.json({ error: "not found" }, 404);
369
+ return c.json({ ok: true });
370
+ });
371
+ }
@@ -0,0 +1,187 @@
1
+ // ─── Style Analyzer ──────────────────────────────────────────────────────────
2
+ // Distills a writing-style profile from all library posts of a given handle.
3
+ // One LLM call: read all posts of <handle> on <platform>, return a structured
4
+ // `StyleProfile`. The result is saved via `style-profiles.ts` and consumed by
5
+ // `content-engine.ts` when generating new posts in that handle's style.
6
+
7
+ import { randomUUID } from "node:crypto";
8
+ import { callClaudeCodeHeadless } from "../claude-code-worker.js";
9
+ import { backfillImageDescriptions } from "./image-describe.js";
10
+ import { listPosts } from "./library.js";
11
+ import { saveProfile } from "./style-profiles.js";
12
+ import type { LibraryPost, SocialPlatform, StyleProfile } from "./types.js";
13
+
14
+ /**
15
+ * Analyze all library posts for a single handle on a platform and produce a
16
+ * `StyleProfile`. Saves the profile to disk before returning.
17
+ *
18
+ * Throws if the library has no posts for this handle (caller should surface
19
+ * "extract some posts first").
20
+ */
21
+ export async function analyzeHandleStyle(
22
+ platform: SocialPlatform,
23
+ handle: string,
24
+ ): Promise<StyleProfile> {
25
+ const allOnPlatform = listPosts({ platform });
26
+ const posts = allOnPlatform.filter(
27
+ (p) => p.author.handle.toLowerCase() === handle.toLowerCase(),
28
+ );
29
+ if (posts.length === 0) {
30
+ throw new Error(`No library posts found for ${platform}/${handle}`);
31
+ }
32
+
33
+ const displayName = posts[0]!.author.displayName ?? handle;
34
+
35
+ const llmProfile = await runLlmAnalysis(posts, platform, handle);
36
+
37
+ const now = new Date().toISOString();
38
+ const profile: StyleProfile = {
39
+ id: randomUUID(),
40
+ platform,
41
+ handle,
42
+ displayName,
43
+ basedOnPostCount: posts.length,
44
+ basedOnPostIds: posts.map((p) => p.id),
45
+ averageWordCount: avgWordCount(posts),
46
+ lengthCategory: classifyLength(avgWordCount(posts)),
47
+ hookPatterns: llmProfile.hookPatterns,
48
+ ctaPatterns: llmProfile.ctaPatterns,
49
+ emojiStyle: llmProfile.emojiStyle,
50
+ emojiList: llmProfile.emojiList,
51
+ hashtagStyle: llmProfile.hashtagStyle,
52
+ contentPillars: llmProfile.contentPillars,
53
+ toneOfVoice: llmProfile.toneOfVoice,
54
+ commentEngagementPattern: llmProfile.commentEngagementPattern,
55
+ visualStyle: llmProfile.visualStyle,
56
+ rawAnalysis: llmProfile.rawAnalysis,
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ };
60
+
61
+ saveProfile(profile);
62
+ return profile;
63
+ }
64
+
65
+ // ─── Helpers ────────────────────────────────────────────────────────────────
66
+
67
+ function avgWordCount(posts: LibraryPost[]): number {
68
+ if (posts.length === 0) return 0;
69
+ const total = posts.reduce((sum, p) => sum + p.text.split(/\s+/).filter(Boolean).length, 0);
70
+ return Math.round(total / posts.length);
71
+ }
72
+
73
+ function classifyLength(avg: number): "kompakt" | "mittel" | "lang" {
74
+ if (avg < 60) return "kompakt";
75
+ if (avg < 200) return "mittel";
76
+ return "lang";
77
+ }
78
+
79
+ interface LlmStyleResult {
80
+ hookPatterns: StyleProfile["hookPatterns"];
81
+ ctaPatterns: StyleProfile["ctaPatterns"];
82
+ emojiStyle: StyleProfile["emojiStyle"];
83
+ emojiList: string[];
84
+ hashtagStyle: StyleProfile["hashtagStyle"];
85
+ contentPillars: string[];
86
+ toneOfVoice: string;
87
+ commentEngagementPattern: string;
88
+ visualStyle: string;
89
+ rawAnalysis: string;
90
+ }
91
+
92
+ async function runLlmAnalysis(
93
+ posts: LibraryPost[],
94
+ platform: SocialPlatform,
95
+ handle: string,
96
+ ): Promise<LlmStyleResult> {
97
+ // Slice posts to keep the prompt manageable. 25 posts × ~800 chars ≈ 20K
98
+ // chars — well within Sonnet's window. Pick highest-engagement first if
99
+ // there are more than that many; otherwise take everything.
100
+ const SAMPLE_LIMIT = 25;
101
+ const sample = posts
102
+ .slice() // don't mutate caller
103
+ .sort((a, b) => (b.engagementRate ?? 0) - (a.engagementRate ?? 0))
104
+ .slice(0, SAMPLE_LIMIT);
105
+
106
+ // Backfill missing image descriptions via Claude Code Subscription. Persists
107
+ // results back to the library so subsequent persona runs are fast.
108
+ await backfillImageDescriptions(sample);
109
+
110
+ const formattedPosts = sample
111
+ .map((p, i) => {
112
+ const lines: string[] = [`### Post ${i + 1}`];
113
+ if (p.engagementRate !== null) lines.push(`engagement_rate: ${p.engagementRate.toFixed(3)}`);
114
+ if (p.postType) lines.push(`type: ${p.postType}`);
115
+ // text already has [Eigener Kommentar] markers from extractor
116
+ lines.push(`text: ${p.text}`);
117
+ if (p.hashtags.length) lines.push(`hashtags: ${p.hashtags.map((h) => "#" + h).join(" ")}`);
118
+ const visual = p.media.find((m) => m.description)?.description;
119
+ if (visual) lines.push(`visual: ${visual.slice(0, 240)}`);
120
+ return lines.join("\n");
121
+ })
122
+ .join("\n\n");
123
+
124
+ const prompt = `Analysiere die folgenden ${sample.length} Posts von **${handle}** auf ${platform} und destilliere daraus ein strukturiertes Stil-Profil. Achte besonders auf wiederkehrende Muster, nicht auf einzelne Inhalte.
125
+
126
+ POSTS:
127
+ ${formattedPosts}
128
+
129
+ Wichtig: Posts enthalten teilweise [Eigener Kommentar]-Blöcke. Das sind Antworten/Ergänzungen, die der Autor selbst unter seinem Post geschrieben hat — extrem wertvoll für das Feld "commentEngagementPattern".
130
+
131
+ Antworte mit GENAU diesem JSON (kein Markdown, keine Erklärung):
132
+ {
133
+ "hookPatterns": [
134
+ { "type": "Kurzlabel z.B. 'rhetorische Frage', 'mutige These', 'Story-Opener', 'Statistik'", "frequency": 0.0_bis_1.0, "examples": ["beispielhafter Hook 1 (max 100 Zeichen)", "..."] }
135
+ ],
136
+ "ctaPatterns": [
137
+ { "type": "Label z.B. 'Frage am Ende', 'Link in Bio', 'Kommentar-Aufforderung', 'kein expliziter CTA'", "frequency": 0.0_bis_1.0, "examples": ["..."] }
138
+ ],
139
+ "emojiStyle": "keine|sparsam|moderat|dicht",
140
+ "emojiList": ["✨", "💪"],
141
+ "hashtagStyle": "keine|wenige|viele",
142
+ "contentPillars": ["3-6 Hauptthemen, jeweils 1-3 Worte"],
143
+ "toneOfVoice": "Fließtext (1-2 Sätze) der die Stimme beschreibt: direkt/indirekt, formell/casual, motivational/sachlich, Du/Sie, etc.",
144
+ "commentEngagementPattern": "Fließtext (1-2 Sätze): Was macht der Autor unter seinen eigenen Posts? Antwortet mit Frage zurück? Ergänzt CTA? Erklärt nach? Falls keine Eigenkommentare in den Posts: leerer String.",
145
+ "visualStyle": "Fließtext (3-5 Sätze): Synthese der visuellen Patterns über alle Posts hinweg, basierend auf den 'visual:'-Beschreibungen oben. Komposition, Farbpalette, wiederkehrende Overlay-/Branding-Muster, Stilkategorie (Foto vs. Grafik vs. Meme), Produktionsqualität. Ein Imitator soll daraus ableiten können wie ein passendes neues Bild aussehen muss. Falls keine Bilder beschrieben: leerer String.",
146
+ "rawAnalysis": "3-5 Sätze Fließtext-Zusammenfassung: Wie schreibt diese Person? Was unterscheidet sie? Was sollte ein Imitator beachten?"
147
+ }`;
148
+
149
+ // Run via Claude Code Subscription (no Anthropic API charges) — no fallback.
150
+ const result = await callClaudeCodeHeadless({
151
+ systemPrompt:
152
+ "Du bist ein Linguist und Social-Media-Stilanalytiker. Du destillierst aus konkreten Posts wiederkehrende Schreib-Muster (Hooks, CTAs, Tonfall, Engagement-Tricks). Antworte ausschließlich mit validem JSON, keine Markdown-Fences, keine Erklärung.",
153
+ userPrompt: prompt,
154
+ model: "sonnet",
155
+ timeoutMs: 180_000,
156
+ });
157
+
158
+ if (!result.ok || !result.text) {
159
+ throw new Error(`Style analysis failed: ${"error" in result ? result.error : "no output"}`);
160
+ }
161
+
162
+ let jsonText = result.text.trim();
163
+ const fence = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
164
+ if (fence) jsonText = fence[1].trim();
165
+
166
+ let parsed: Partial<LlmStyleResult>;
167
+ try {
168
+ parsed = JSON.parse(jsonText);
169
+ } catch (e) {
170
+ throw new Error(
171
+ `Style analysis returned invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
172
+ );
173
+ }
174
+
175
+ return {
176
+ hookPatterns: Array.isArray(parsed.hookPatterns) ? parsed.hookPatterns : [],
177
+ ctaPatterns: Array.isArray(parsed.ctaPatterns) ? parsed.ctaPatterns : [],
178
+ emojiStyle: parsed.emojiStyle ?? "moderat",
179
+ emojiList: Array.isArray(parsed.emojiList) ? parsed.emojiList : [],
180
+ hashtagStyle: parsed.hashtagStyle ?? "wenige",
181
+ contentPillars: Array.isArray(parsed.contentPillars) ? parsed.contentPillars : [],
182
+ toneOfVoice: parsed.toneOfVoice ?? "",
183
+ commentEngagementPattern: parsed.commentEngagementPattern ?? "",
184
+ visualStyle: parsed.visualStyle ?? "",
185
+ rawAnalysis: parsed.rawAnalysis ?? "",
186
+ };
187
+ }
@@ -0,0 +1,67 @@
1
+ // ─── Style Profiles Storage ──────────────────────────────────────────────────
2
+ // File-based CRUD for `StyleProfile`s. Layout:
3
+ // ~/.heyhank/socialview/style-profiles/<platform>-<handle>.json
4
+ // Mirrors `library.ts` — flat directory, no DB.
5
+
6
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, unlinkSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { HEYHANK_HOME } from "../paths.js";
9
+ import type { StyleProfile, SocialPlatform } from "./types.js";
10
+
11
+ const PROFILES_ROOT = join(HEYHANK_HOME, "socialview", "style-profiles");
12
+
13
+ function ensureDir(): void {
14
+ mkdirSync(PROFILES_ROOT, { recursive: true });
15
+ }
16
+
17
+ /** Filesystem-safe key: <platform>-<handle>. Handle is normalized. */
18
+ function profileFilename(platform: SocialPlatform, handle: string): string {
19
+ const safe = handle.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
20
+ return `${platform}-${safe}.json`;
21
+ }
22
+
23
+ function profilePath(platform: SocialPlatform, handle: string): string {
24
+ return join(PROFILES_ROOT, profileFilename(platform, handle));
25
+ }
26
+
27
+ export function saveProfile(profile: StyleProfile): void {
28
+ ensureDir();
29
+ writeFileSync(profilePath(profile.platform, profile.handle), JSON.stringify(profile, null, 2));
30
+ }
31
+
32
+ export function getProfile(platform: SocialPlatform, handle: string): StyleProfile | null {
33
+ const path = profilePath(platform, handle);
34
+ if (!existsSync(path)) return null;
35
+ try {
36
+ return JSON.parse(readFileSync(path, "utf-8")) as StyleProfile;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function deleteProfile(platform: SocialPlatform, handle: string): boolean {
43
+ const path = profilePath(platform, handle);
44
+ if (!existsSync(path)) return false;
45
+ try {
46
+ unlinkSync(path);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /** List all profiles, newest first. */
54
+ export function listProfiles(): StyleProfile[] {
55
+ ensureDir();
56
+ const out: StyleProfile[] = [];
57
+ for (const file of readdirSync(PROFILES_ROOT)) {
58
+ if (!file.endsWith(".json")) continue;
59
+ try {
60
+ out.push(JSON.parse(readFileSync(join(PROFILES_ROOT, file), "utf-8")) as StyleProfile);
61
+ } catch {
62
+ // skip malformed
63
+ }
64
+ }
65
+ out.sort((a, b) => (b.updatedAt > a.updatedAt ? 1 : -1));
66
+ return out;
67
+ }