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
@@ -1,7 +1,8 @@
1
1
  // ─── Telephony Store ──────────────────────────────────────────────────────────
2
2
  // File-based persistence for telephony settings and call history.
3
3
 
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
5
+ import { atomicWriteFileSync } from "../fs-utils.js";
5
6
  import { homedir } from "node:os";
6
7
  import { join } from "node:path";
7
8
  import type { TelephonySettings, CallState, TelephonyContact } from "./call-types.js";
@@ -31,7 +32,7 @@ export function getSettings(): TelephonySettings {
31
32
 
32
33
  export function saveSettings(settings: TelephonySettings): void {
33
34
  ensureDirs();
34
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
35
+ atomicWriteFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
35
36
  }
36
37
 
37
38
  // ─── Contacts ───────────────────────────────────────────────────────────────
@@ -85,7 +86,7 @@ export function resolveContactByName(nameQuery: string): TelephonyContact | null
85
86
  export function saveCall(call: CallState): void {
86
87
  ensureDirs();
87
88
  const file = join(CALLS_DIR, `${call.id}.json`);
88
- writeFileSync(file, JSON.stringify(call, null, 2), "utf-8");
89
+ atomicWriteFileSync(file, JSON.stringify(call, null, 2));
89
90
  }
90
91
 
91
92
  export function getCall(callId: string): CallState | null {
@@ -102,17 +103,17 @@ export function listCalls(limit = 50): CallState[] {
102
103
  ensureDirs();
103
104
  try {
104
105
  const files = readdirSync(CALLS_DIR)
105
- .filter((f) => f.endsWith(".json"))
106
- .sort()
107
- .reverse()
108
- .slice(0, limit);
109
- return files.map((f) => {
106
+ .filter((f) => f.endsWith(".json"));
107
+ // Parse all calls, sort by startedAt descending (newest first), then apply limit
108
+ const calls = files.map((f) => {
110
109
  try {
111
110
  return JSON.parse(readFileSync(join(CALLS_DIR, f), "utf-8")) as CallState;
112
111
  } catch {
113
112
  return null;
114
113
  }
115
114
  }).filter(Boolean) as CallState[];
115
+ calls.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
116
+ return calls.slice(0, limit);
116
117
  } catch {
117
118
  return [];
118
119
  }
@@ -0,0 +1,82 @@
1
+ // ─── URL Validation ──────────────────────────────────────────────────────────
2
+ // SSRF protection: reject baseUrl values that point to internal/private networks
3
+ // unless they are known safe provider domains.
4
+
5
+ const KNOWN_PROVIDER_HOSTS = new Set([
6
+ "api.openai.com",
7
+ "generativelanguage.googleapis.com",
8
+ "openrouter.ai",
9
+ "api.anthropic.com",
10
+ "api.together.xyz",
11
+ "api.groq.com",
12
+ "api.mistral.ai",
13
+ "api.deepseek.com",
14
+ "api.fireworks.ai",
15
+ "api.perplexity.ai",
16
+ "api.cohere.com",
17
+ ]);
18
+
19
+ /**
20
+ * Check if a base URL is safe to make requests to.
21
+ * Rejects URLs pointing to internal/private IP ranges unless the host
22
+ * is a known LLM provider domain or localhost (for Ollama).
23
+ */
24
+ export function isAllowedBaseUrl(url: string): boolean {
25
+ let parsed: URL;
26
+ try {
27
+ parsed = new URL(url);
28
+ } catch {
29
+ return false;
30
+ }
31
+
32
+ const hostname = parsed.hostname.toLowerCase();
33
+
34
+ // Allow known provider domains
35
+ if (KNOWN_PROVIDER_HOSTS.has(hostname)) return true;
36
+
37
+ // Allow localhost / 127.0.0.1 (for Ollama and local services)
38
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true;
39
+
40
+ // Allow Tailscale domains (.ts.net)
41
+ if (hostname.endsWith(".ts.net")) return true;
42
+
43
+ // Block internal/private IP ranges
44
+ if (isPrivateHostname(hostname)) return false;
45
+
46
+ // Allow all other public hostnames
47
+ return true;
48
+ }
49
+
50
+ function isPrivateHostname(hostname: string): boolean {
51
+ // IPv6 loopback
52
+ if (hostname === "[::1]" || hostname === "::1") return true;
53
+
54
+ // Check if it looks like an IP address
55
+ const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
56
+ if (ipv4Match) {
57
+ const [, a, b] = ipv4Match.map(Number);
58
+ // 127.x.x.x (loopback — already allowed above for localhost, but block raw IPs other than 127.0.0.1)
59
+ if (a === 127) return true;
60
+ // 10.x.x.x (private)
61
+ if (a === 10) return true;
62
+ // 172.16.0.0 - 172.31.255.255 (private)
63
+ if (a === 172 && b >= 16 && b <= 31) return true;
64
+ // 192.168.x.x (private)
65
+ if (a === 192 && b === 168) return true;
66
+ // 169.254.x.x (link-local)
67
+ if (a === 169 && b === 254) return true;
68
+ // 0.0.0.0
69
+ if (a === 0) return true;
70
+ }
71
+
72
+ // IPv6 private ranges (simplified check for common patterns)
73
+ if (hostname.startsWith("[")) {
74
+ const inner = hostname.slice(1, -1).toLowerCase();
75
+ if (inner.startsWith("fc") || inner.startsWith("fd")) return true; // ULA
76
+ if (inner.startsWith("fe80")) return true; // link-local
77
+ if (inner === "::1") return true;
78
+ if (inner === "::") return true;
79
+ }
80
+
81
+ return false;
82
+ }
@@ -0,0 +1,317 @@
1
+ // ─── Vault Markdown Serialization ────────────────────────────────────────────
2
+ // Pure functions to convert HeyHank data models to/from Markdown with YAML frontmatter.
3
+ // Used by vault-store.ts for Obsidian-as-primary-store architecture.
4
+
5
+ import type { Todo, Note, Reminder, GeminiConversation, Contact, ContactInteraction, Decision } from "./assistant-store.js";
6
+
7
+ // ─── Frontmatter Parser ─────────────────────────────────────────────────────
8
+
9
+ export function parseFrontmatter(md: string): { frontmatter: Record<string, string>; body: string } {
10
+ const match = md.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
11
+ if (!match) return { frontmatter: {}, body: md.trim() };
12
+ const fm: Record<string, string> = {};
13
+ for (const line of match[1].split("\n")) {
14
+ const idx = line.indexOf(": ");
15
+ if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 2).trim();
16
+ }
17
+ return { frontmatter: fm, body: match[2].trim() };
18
+ }
19
+
20
+ function buildFrontmatter(fields: Array<[string, string | undefined]>): string {
21
+ const lines: string[] = ["---"];
22
+ for (const [key, value] of fields) {
23
+ if (value !== undefined) lines.push(`${key}: ${value}`);
24
+ }
25
+ lines.push("---");
26
+ return lines.join("\n");
27
+ }
28
+
29
+ // ─── Todos ───────────────────────────────────────────────────────────────────
30
+
31
+ export function todoToMarkdown(todo: Todo): string {
32
+ const fm = buildFrontmatter([
33
+ ["id", todo.id],
34
+ ["priority", todo.priority],
35
+ ["done", String(todo.done)],
36
+ ["category", todo.category],
37
+ ["delegatedTo", todo.delegatedTo],
38
+ ["dueDate", todo.dueDate],
39
+ ["followUpDate", todo.followUpDate],
40
+ ["project", todo.project],
41
+ ["createdAt", todo.createdAt],
42
+ ["doneAt", todo.doneAt],
43
+ ]);
44
+ return `${fm}\n\n${todo.text}\n`;
45
+ }
46
+
47
+ export function markdownToTodo(md: string): Todo {
48
+ const { frontmatter: fm, body } = parseFrontmatter(md);
49
+ return {
50
+ id: fm.id || "",
51
+ text: body,
52
+ priority: (["high", "medium", "low"].includes(fm.priority) ? fm.priority : "medium") as Todo["priority"],
53
+ done: fm.done === "true",
54
+ createdAt: fm.createdAt || "",
55
+ doneAt: fm.doneAt || undefined,
56
+ category: fm.category || undefined,
57
+ delegatedTo: fm.delegatedTo || undefined,
58
+ dueDate: fm.dueDate || undefined,
59
+ followUpDate: fm.followUpDate || undefined,
60
+ project: fm.project || undefined,
61
+ };
62
+ }
63
+
64
+ // ─── Notes ───────────────────────────────────────────────────────────────────
65
+
66
+ export function noteToMarkdown(note: Note): string {
67
+ const fm = buildFrontmatter([
68
+ ["id", note.id],
69
+ ["createdAt", note.createdAt],
70
+ ["updatedAt", note.updatedAt],
71
+ ["tags", note.tags.length > 0 ? note.tags.join(", ") : undefined],
72
+ ]);
73
+ return `${fm}\n\n# ${note.title}\n\n${note.content}\n`;
74
+ }
75
+
76
+ export function markdownToNote(md: string): Note {
77
+ const { frontmatter: fm, body } = parseFrontmatter(md);
78
+ // Parse title from first heading, rest is content
79
+ const titleMatch = body.match(/^# (.+)\n\n?([\s\S]*)$/);
80
+ const title = titleMatch ? titleMatch[1].trim() : body.split("\n")[0];
81
+ const content = titleMatch ? titleMatch[2].trim() : "";
82
+ const tags = fm.tags ? fm.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
83
+ return {
84
+ id: fm.id || "",
85
+ title,
86
+ content,
87
+ tags,
88
+ createdAt: fm.createdAt || "",
89
+ updatedAt: fm.updatedAt || "",
90
+ };
91
+ }
92
+
93
+ // ─── Reminders ───────────────────────────────────────────────────────────────
94
+
95
+ export function reminderToMarkdown(reminder: Reminder): string {
96
+ const fm = buildFrontmatter([
97
+ ["id", reminder.id],
98
+ ["triggerAt", reminder.triggerAt],
99
+ ["fired", String(reminder.fired)],
100
+ ["createdAt", reminder.createdAt],
101
+ ["calendarEventUid", reminder.calendarEventUid],
102
+ ]);
103
+ return `${fm}\n\n${reminder.text}\n`;
104
+ }
105
+
106
+ export function markdownToReminder(md: string): Reminder {
107
+ const { frontmatter: fm, body } = parseFrontmatter(md);
108
+ const reminder: Reminder = {
109
+ id: fm.id || "",
110
+ text: body,
111
+ triggerAt: fm.triggerAt || "",
112
+ fired: fm.fired === "true",
113
+ createdAt: fm.createdAt || "",
114
+ };
115
+ if (fm.calendarEventUid) reminder.calendarEventUid = fm.calendarEventUid;
116
+ return reminder;
117
+ }
118
+
119
+ // ─── Conversations ───────────────────────────────────────────────────────────
120
+
121
+ const ROLE_LABELS: Record<string, string> = {
122
+ user: "You",
123
+ gemini: "Hank",
124
+ system: "System",
125
+ };
126
+
127
+ export function conversationToMarkdown(convo: GeminiConversation): string {
128
+ const fm = buildFrontmatter([
129
+ ["id", convo.id],
130
+ ["title", convo.title],
131
+ ["createdAt", convo.createdAt],
132
+ ["duration", convo.duration !== undefined ? String(convo.duration) : undefined],
133
+ ]);
134
+ const msgLines = convo.messages.map((m) => {
135
+ const label = ROLE_LABELS[m.role] || m.role;
136
+ return `**${label}**: ${m.text}`;
137
+ });
138
+ return `${fm}\n\n${msgLines.join("\n\n")}\n`;
139
+ }
140
+
141
+ export function markdownToConversation(md: string): GeminiConversation {
142
+ const { frontmatter: fm, body } = parseFrontmatter(md);
143
+ // Parse messages from body: **Label**: text
144
+ const messages: Array<{ role: "user" | "gemini" | "system"; text: string; ts: number }> = [];
145
+ const labelToRole: Record<string, "user" | "gemini" | "system"> = {
146
+ You: "user",
147
+ Hank: "gemini",
148
+ System: "system",
149
+ };
150
+ // Split on message boundaries: lines starting with **Label**:
151
+ const parts = body.split(/\n\n(?=\*\*(?:You|Hank|System)\*\*:)/);
152
+ for (const part of parts) {
153
+ const msgMatch = part.match(/^\*\*(\w+)\*\*:\s*([\s\S]*)$/);
154
+ if (msgMatch) {
155
+ const role = labelToRole[msgMatch[1]] || "user";
156
+ messages.push({ role, text: msgMatch[2].trim(), ts: 0 });
157
+ }
158
+ }
159
+ return {
160
+ id: fm.id || "",
161
+ title: fm.title || "",
162
+ messages,
163
+ createdAt: fm.createdAt || "",
164
+ duration: fm.duration ? Number(fm.duration) : undefined,
165
+ };
166
+ }
167
+
168
+ // ─── Memories ────────────────────────────────────────────────────────────────
169
+
170
+ export interface Memory {
171
+ id: string;
172
+ content: string;
173
+ createdAt: string;
174
+ updatedAt: string;
175
+ category?: string;
176
+ source?: string;
177
+ }
178
+
179
+ export function memoryToMarkdown(memory: Memory): string {
180
+ const fm = buildFrontmatter([
181
+ ["id", memory.id],
182
+ ["createdAt", memory.createdAt],
183
+ ["updatedAt", memory.updatedAt],
184
+ ["category", memory.category],
185
+ ["source", memory.source],
186
+ ]);
187
+ return `${fm}\n\n${memory.content}\n`;
188
+ }
189
+
190
+ export function markdownToMemory(md: string): Memory {
191
+ const { frontmatter: fm, body } = parseFrontmatter(md);
192
+ return {
193
+ id: fm.id || "",
194
+ content: body,
195
+ createdAt: fm.createdAt || "",
196
+ updatedAt: fm.updatedAt || "",
197
+ category: fm.category || undefined,
198
+ source: fm.source || undefined,
199
+ };
200
+ }
201
+
202
+ // ─── Contacts ───────────────────────────────────────────────────────────────
203
+
204
+ export function contactToMarkdown(c: Contact): string {
205
+ const fm = buildFrontmatter([
206
+ ["id", c.id],
207
+ ["name", c.name],
208
+ ["company", c.company],
209
+ ["email", c.email],
210
+ ["phone", c.phone],
211
+ ["tags", c.tags.length > 0 ? c.tags.join(", ") : undefined],
212
+ ["lastContactDate", c.lastContactDate],
213
+ ["createdAt", c.createdAt],
214
+ ["updatedAt", c.updatedAt],
215
+ ]);
216
+ const bodyParts: string[] = [];
217
+ bodyParts.push(`## Notes\n${c.notes || ""}`);
218
+ bodyParts.push("\n## Interactions");
219
+ for (const i of c.interactions) {
220
+ bodyParts.push(`- ${i.date.slice(0, 10)} [${i.type}] ${i.summary}`);
221
+ }
222
+ return `${fm}\n\n${bodyParts.join("\n")}\n`;
223
+ }
224
+
225
+ export function markdownToContact(md: string): Contact {
226
+ const { frontmatter: fm, body } = parseFrontmatter(md);
227
+ const tags = fm.tags ? fm.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
228
+ // Parse notes: between ## Notes and ## Interactions
229
+ const notesMatch = body.match(/## Notes\n([\s\S]*?)(?=\n## Interactions|$)/);
230
+ const notes = notesMatch ? notesMatch[1].trim() : "";
231
+ // Parse interactions
232
+ const interactions: ContactInteraction[] = [];
233
+ const interSection = body.match(/## Interactions\n([\s\S]*)$/);
234
+ if (interSection) {
235
+ const lines = interSection[1].trim().split("\n");
236
+ for (const line of lines) {
237
+ const m = line.match(/^- (\S+) \[(\w+)\] (.+)$/);
238
+ if (m) {
239
+ interactions.push({ date: m[1], type: m[2] as ContactInteraction["type"], summary: m[3] });
240
+ }
241
+ }
242
+ }
243
+ return {
244
+ id: fm.id || "",
245
+ name: fm.name || "",
246
+ company: fm.company || undefined,
247
+ email: fm.email || undefined,
248
+ phone: fm.phone || undefined,
249
+ notes: notes || undefined,
250
+ tags,
251
+ lastContactDate: fm.lastContactDate || undefined,
252
+ interactions,
253
+ createdAt: fm.createdAt || "",
254
+ updatedAt: fm.updatedAt || "",
255
+ };
256
+ }
257
+
258
+ // ─── Decisions ──────────────────────────────────────────────────────────────
259
+
260
+ export function decisionToMarkdown(d: Decision): string {
261
+ const fm = buildFrontmatter([
262
+ ["id", d.id],
263
+ ["tags", d.tags.length > 0 ? d.tags.join(", ") : undefined],
264
+ ["alternatives", d.alternatives.length > 0 ? d.alternatives.join(", ") : undefined],
265
+ ["createdAt", d.createdAt],
266
+ ]);
267
+ return `${fm}\n\n# ${d.title}\n\n## Context\n${d.context}\n\n## Decision\n${d.decision}\n\n## Reasoning\n${d.reasoning}\n`;
268
+ }
269
+
270
+ export function markdownToDecision(md: string): Decision {
271
+ const { frontmatter: fm, body } = parseFrontmatter(md);
272
+ const tags = fm.tags ? fm.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
273
+ const alternatives = fm.alternatives ? fm.alternatives.split(",").map((a) => a.trim()).filter(Boolean) : [];
274
+ const titleMatch = body.match(/^# (.+)/);
275
+ const title = titleMatch ? titleMatch[1].trim() : "";
276
+ const contextMatch = body.match(/## Context\n([\s\S]*?)(?=\n## Decision)/);
277
+ const context = contextMatch ? contextMatch[1].trim() : "";
278
+ const decisionMatch = body.match(/## Decision\n([\s\S]*?)(?=\n## Reasoning)/);
279
+ const decision = decisionMatch ? decisionMatch[1].trim() : "";
280
+ const reasoningMatch = body.match(/## Reasoning\n([\s\S]*)$/);
281
+ const reasoning = reasoningMatch ? reasoningMatch[1].trim() : "";
282
+ return { id: fm.id || "", title, context, decision, alternatives, reasoning, tags, createdAt: fm.createdAt || "" };
283
+ }
284
+
285
+ // ─── Calls (export only) ────────────────────────────────────────────────────
286
+
287
+ export interface CallData {
288
+ id: string;
289
+ phone: string;
290
+ status: string;
291
+ durationSeconds: number;
292
+ startedAt: number;
293
+ transcript?: Array<{ speaker: string; text: string; ts: number }>;
294
+ summary?: string | null;
295
+ }
296
+
297
+ export function callToMarkdown(call: CallData): string {
298
+ const fm = buildFrontmatter([
299
+ ["id", call.id],
300
+ ["phone", call.phone],
301
+ ["status", call.status],
302
+ ["durationSeconds", String(call.durationSeconds)],
303
+ ["startedAt", new Date(call.startedAt).toISOString()],
304
+ ]);
305
+ const bodyParts: string[] = [];
306
+ if (call.transcript && call.transcript.length > 0) {
307
+ bodyParts.push("## Transcript\n");
308
+ for (const entry of call.transcript) {
309
+ bodyParts.push(`**${entry.speaker}**: ${entry.text}`);
310
+ }
311
+ }
312
+ if (call.summary) {
313
+ bodyParts.push("\n## Summary\n");
314
+ bodyParts.push(call.summary);
315
+ }
316
+ return `${fm}\n\n${bodyParts.join("\n")}\n`;
317
+ }
@@ -0,0 +1,121 @@
1
+ // ─── Vault Migration ─────────────────────────────────────────────────────────
2
+ // One-time migration of JSON-based assistant data to Obsidian vault (.md files).
3
+ // Reads directly from ~/.heyhank/assistant/*.json (bypassing assistant-store to
4
+ // avoid vault delegation) and writes .md files using vault-markdown serializers.
5
+
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { HEYHANK_HOME } from "./paths.js";
9
+ import { getSettings } from "./settings-manager.js";
10
+ import * as md from "./vault-markdown.js";
11
+ import type { Todo, Note, Reminder, GeminiConversation } from "./assistant-store.js";
12
+
13
+ const ASSISTANT_DIR = join(HEYHANK_HOME, "assistant");
14
+ const MIGRATION_MARKER = join(ASSISTANT_DIR, ".migrated-to-vault");
15
+
16
+ /** Read a JSON file directly from the assistant directory (bypasses vault delegation). */
17
+ function readJsonFile<T>(filename: string, fallback: T): T {
18
+ const filepath = join(ASSISTANT_DIR, filename);
19
+ try {
20
+ if (!existsSync(filepath)) return fallback;
21
+ return JSON.parse(readFileSync(filepath, "utf-8")) as T;
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ function ensureDir(dir: string): void {
28
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
29
+ }
30
+
31
+ /** Write a file only if it doesn't already exist (idempotent migration). */
32
+ function writeIfNotExists(filepath: string, content: string): void {
33
+ if (!existsSync(filepath)) writeFileSync(filepath, content, "utf-8");
34
+ }
35
+
36
+ /**
37
+ * Migrate all JSON-based assistant data to the Obsidian vault.
38
+ * This is idempotent: it writes a marker file after completion and
39
+ * skips if the marker already exists. Individual files are also
40
+ * written only if they don't already exist.
41
+ */
42
+ export function migrateToVault(): void {
43
+ const vaultPath = getSettings().obsidianVaultPath;
44
+ if (!vaultPath) return;
45
+ if (existsSync(MIGRATION_MARKER)) return;
46
+
47
+ console.log("[vault-migration] Starting migration to Obsidian vault...");
48
+
49
+ const vaultBase = join(vaultPath, "HeyHank");
50
+
51
+ // ─── Todos ──────────────────────────────────────────────────────────────────
52
+ try {
53
+ const todos = readJsonFile<Todo[]>("todos.json", []);
54
+ if (todos.length > 0) {
55
+ const todosDir = join(vaultBase, "Todos");
56
+ ensureDir(todosDir);
57
+ for (const todo of todos) {
58
+ writeIfNotExists(join(todosDir, `todo-${todo.id}.md`), md.todoToMarkdown(todo));
59
+ }
60
+ console.log(`[vault-migration] Migrated ${todos.length} todos`);
61
+ }
62
+ } catch (e) {
63
+ console.warn("[vault-migration] Todos:", e);
64
+ }
65
+
66
+ // ─── Notes ──────────────────────────────────────────────────────────────────
67
+ try {
68
+ const notes = readJsonFile<Note[]>("notes.json", []);
69
+ if (notes.length > 0) {
70
+ const notesDir = join(vaultBase, "Notes");
71
+ ensureDir(notesDir);
72
+ for (const note of notes) {
73
+ writeIfNotExists(join(notesDir, `note-${note.id}.md`), md.noteToMarkdown(note));
74
+ }
75
+ console.log(`[vault-migration] Migrated ${notes.length} notes`);
76
+ }
77
+ } catch (e) {
78
+ console.warn("[vault-migration] Notes:", e);
79
+ }
80
+
81
+ // ─── Reminders ──────────────────────────────────────────────────────────────
82
+ try {
83
+ const reminders = readJsonFile<Reminder[]>("reminders.json", []);
84
+ if (reminders.length > 0) {
85
+ const remindersDir = join(vaultBase, "Reminders");
86
+ ensureDir(remindersDir);
87
+ for (const reminder of reminders) {
88
+ writeIfNotExists(
89
+ join(remindersDir, `reminder-${reminder.id}.md`),
90
+ md.reminderToMarkdown(reminder),
91
+ );
92
+ }
93
+ console.log(`[vault-migration] Migrated ${reminders.length} reminders`);
94
+ }
95
+ } catch (e) {
96
+ console.warn("[vault-migration] Reminders:", e);
97
+ }
98
+
99
+ // ─── Conversations ──────────────────────────────────────────────────────────
100
+ try {
101
+ const convos = readJsonFile<GeminiConversation[]>("gemini-conversations.json", []);
102
+ if (convos.length > 0) {
103
+ const convosDir = join(vaultBase, "Conversations");
104
+ ensureDir(convosDir);
105
+ for (const convo of convos) {
106
+ writeIfNotExists(
107
+ join(convosDir, `convo-${convo.id}.md`),
108
+ md.conversationToMarkdown(convo),
109
+ );
110
+ }
111
+ console.log(`[vault-migration] Migrated ${convos.length} conversations`);
112
+ }
113
+ } catch (e) {
114
+ console.warn("[vault-migration] Conversations:", e);
115
+ }
116
+
117
+ // ─── Mark complete ──────────────────────────────────────────────────────────
118
+ ensureDir(ASSISTANT_DIR);
119
+ writeFileSync(MIGRATION_MARKER, new Date().toISOString(), "utf-8");
120
+ console.log("[vault-migration] Migration complete!");
121
+ }