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
@@ -0,0 +1,277 @@
1
+ import { randomUUID } from "crypto";
2
+
3
+ // ─── Platform Detection ─────────────────────────────────────────────────────
4
+
5
+ export type ImportPlatform = "chatgpt" | "claude" | "gemini" | "unknown";
6
+
7
+ export interface ImportResult {
8
+ platform: ImportPlatform;
9
+ memories: string[];
10
+ conversations: number;
11
+ messagesProcessed: number;
12
+ skipped: number;
13
+ errors: string[];
14
+ }
15
+
16
+ /**
17
+ * Auto-detect which platform a JSON export belongs to.
18
+ */
19
+ export function detectPlatform(data: unknown): ImportPlatform {
20
+ if (!data) return "unknown";
21
+
22
+ // ChatGPT: array of objects with "mapping" field (tree structure)
23
+ if (Array.isArray(data) && data.length > 0 && data[0].mapping) return "chatgpt";
24
+
25
+ // ChatGPT memories: array with "id" starting with "mem_"
26
+ if (Array.isArray(data) && data.length > 0 && typeof data[0].id === "string" && data[0].id.startsWith("mem_")) return "chatgpt";
27
+
28
+ // Claude: array of objects with "chat_messages" field
29
+ if (Array.isArray(data) && data.length > 0 && data[0].chat_messages) return "claude";
30
+
31
+ // Claude: object with uuid and chat_messages
32
+ if (typeof data === "object" && !Array.isArray(data) && (data as any).chat_messages) return "claude";
33
+
34
+ // Gemini: object with "messages" array where role is "model"
35
+ if (typeof data === "object" && !Array.isArray(data) && Array.isArray((data as any).messages)) {
36
+ const msgs = (data as any).messages;
37
+ if (msgs.some((m: any) => m.role === "model")) return "gemini";
38
+ }
39
+
40
+ // Gemini: array of conversation objects with messages containing role "model"
41
+ if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0].messages)) {
42
+ if (data[0].messages.some((m: any) => m.role === "model")) return "gemini";
43
+ }
44
+
45
+ return "unknown";
46
+ }
47
+
48
+ // ─── ChatGPT Import ─────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Import ChatGPT memories (memories.json) — direct facts.
52
+ */
53
+ export function parseChatGPTMemories(data: any[]): string[] {
54
+ return data
55
+ .filter(item => item.content && typeof item.content === "string")
56
+ .map(item => item.content.trim())
57
+ .filter(Boolean);
58
+ }
59
+
60
+ /**
61
+ * Extract user messages from ChatGPT conversations.json tree structure.
62
+ * Walk from current_node back through parent pointers to reconstruct linear thread.
63
+ */
64
+ export function parseChatGPTConversations(data: any[]): { conversations: number; messages: Array<{ role: string; content: string; conversationTitle?: string }> } {
65
+ const allMessages: Array<{ role: string; content: string; conversationTitle?: string }> = [];
66
+ let conversations = 0;
67
+
68
+ for (const conv of data) {
69
+ if (!conv.mapping) continue;
70
+ conversations++;
71
+ const title = conv.title || "Untitled";
72
+
73
+ // Reconstruct linear thread from tree
74
+ let nodeId = conv.current_node;
75
+ const chain: any[] = [];
76
+ while (nodeId && conv.mapping[nodeId]) {
77
+ const node = conv.mapping[nodeId];
78
+ if (node.message?.content?.parts) {
79
+ const text = node.message.content.parts
80
+ .filter((p: any) => typeof p === "string")
81
+ .join("\n")
82
+ .trim();
83
+ if (text) {
84
+ chain.push({
85
+ role: node.message.author?.role === "user" ? "user" : "assistant",
86
+ content: text,
87
+ conversationTitle: title,
88
+ });
89
+ }
90
+ }
91
+ nodeId = node.parent;
92
+ }
93
+ chain.reverse();
94
+ allMessages.push(...chain);
95
+ }
96
+
97
+ return { conversations, messages: allMessages };
98
+ }
99
+
100
+ // ─── Claude Import ──────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Extract messages from Claude conversations.json export.
104
+ */
105
+ export function parseClaudeConversations(data: any[]): { conversations: number; messages: Array<{ role: string; content: string; conversationTitle?: string }> } {
106
+ const allMessages: Array<{ role: string; content: string; conversationTitle?: string }> = [];
107
+ let conversations = 0;
108
+
109
+ const convList = Array.isArray(data) ? data : [data];
110
+
111
+ for (const conv of convList) {
112
+ if (!conv.chat_messages) continue;
113
+ conversations++;
114
+ const title = conv.name || "Untitled";
115
+
116
+ for (const msg of conv.chat_messages) {
117
+ const text = typeof msg.text === "string" ? msg.text.trim() : "";
118
+ if (!text) continue;
119
+ allMessages.push({
120
+ role: msg.sender === "human" ? "user" : "assistant",
121
+ content: text,
122
+ conversationTitle: title,
123
+ });
124
+ }
125
+ }
126
+
127
+ return { conversations, messages: allMessages };
128
+ }
129
+
130
+ // ─── Gemini Import ──────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Extract messages from Gemini export (Google Takeout format).
134
+ */
135
+ export function parseGeminiConversations(data: any): { conversations: number; messages: Array<{ role: string; content: string; conversationTitle?: string }> } {
136
+ const allMessages: Array<{ role: string; content: string; conversationTitle?: string }> = [];
137
+ let conversations = 0;
138
+
139
+ const convList = Array.isArray(data) ? data : [data];
140
+
141
+ for (const conv of convList) {
142
+ if (!Array.isArray(conv.messages)) continue;
143
+ conversations++;
144
+ const title = conv.title || "Untitled";
145
+
146
+ for (const msg of conv.messages) {
147
+ const text = typeof msg.content === "string" ? msg.content.trim() : "";
148
+ if (!text) continue;
149
+ allMessages.push({
150
+ role: msg.role === "user" ? "user" : "assistant",
151
+ content: text,
152
+ conversationTitle: title,
153
+ });
154
+ }
155
+ }
156
+
157
+ return { conversations, messages: allMessages };
158
+ }
159
+
160
+ // ─── Fact Extraction ────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Extract memorable facts from user messages without LLM.
164
+ * Simple heuristic: look for "I am", "I work", "I prefer", "My name", etc.
165
+ */
166
+ export function extractFactsHeuristic(messages: Array<{ role: string; content: string }>): string[] {
167
+ const userMessages = messages.filter(m => m.role === "user");
168
+ const facts: Set<string> = new Set();
169
+
170
+ const patterns = [
171
+ /\b(?:I am|I'm|Ich bin)\s+(.{5,80})/gi,
172
+ /\b(?:my name is|mein name ist)\s+(.{2,40})/gi,
173
+ /\b(?:I work|I'm working|Ich arbeite)\s+(.{5,80})/gi,
174
+ /\b(?:I prefer|I like|Ich bevorzuge|Ich mag)\s+(.{5,80})/gi,
175
+ /\b(?:I use|I'm using|Ich verwende|Ich nutze)\s+(.{5,80})/gi,
176
+ /\b(?:I live|I'm from|Ich wohne|Ich komme aus)\s+(.{3,60})/gi,
177
+ /\b(?:my company|my business|meine firma|mein unternehmen)\s+(.{3,60})/gi,
178
+ /\b(?:I always|Ich immer)\s+(.{5,80})/gi,
179
+ /\b(?:I need|I want|Ich brauche|Ich will)\s+(.{5,80})/gi,
180
+ /\b(?:my timezone|my language|my email)\s+(.{3,60})/gi,
181
+ /\b(?:remember that|merke dir|vergiss nicht)\s+(.{5,120})/gi,
182
+ ];
183
+
184
+ for (const msg of userMessages) {
185
+ const content = msg.content;
186
+ // Skip very long messages (likely code or documents)
187
+ if (content.length > 500) continue;
188
+
189
+ for (const pattern of patterns) {
190
+ pattern.lastIndex = 0;
191
+ let match;
192
+ while ((match = pattern.exec(content)) !== null) {
193
+ // Clean up the match — take first sentence
194
+ let fact = match[0].trim();
195
+ const dotIdx = fact.indexOf(".");
196
+ if (dotIdx > 10) fact = fact.slice(0, dotIdx + 1);
197
+ // Remove trailing punctuation patterns
198
+ fact = fact.replace(/[,;:]\s*$/, "").trim();
199
+ if (fact.length >= 10 && fact.length <= 200) {
200
+ facts.add(fact);
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ return [...facts];
207
+ }
208
+
209
+ // ─── Main Import Function ───────────────────────────────────────────────────
210
+
211
+ export interface ImportOptions {
212
+ mode: "memories" | "conversations" | "both";
213
+ extractFacts: boolean; // use heuristic extraction from conversations
214
+ maxConversations?: number; // limit for large exports
215
+ }
216
+
217
+ export function processImport(data: unknown, platform: ImportPlatform, options: ImportOptions): ImportResult {
218
+ const result: ImportResult = {
219
+ platform,
220
+ memories: [],
221
+ conversations: 0,
222
+ messagesProcessed: 0,
223
+ skipped: 0,
224
+ errors: [],
225
+ };
226
+
227
+ try {
228
+ // Direct memories import (ChatGPT memories.json)
229
+ if (Array.isArray(data) && data.length > 0 && data[0].id?.startsWith?.("mem_")) {
230
+ const mems = parseChatGPTMemories(data);
231
+ result.memories.push(...mems);
232
+ result.platform = "chatgpt";
233
+ return result;
234
+ }
235
+
236
+ // Conversation-based import
237
+ let parsed: { conversations: number; messages: Array<{ role: string; content: string }> };
238
+
239
+ switch (platform) {
240
+ case "chatgpt":
241
+ parsed = parseChatGPTConversations(data as any[]);
242
+ break;
243
+ case "claude":
244
+ parsed = parseClaudeConversations(data as any[]);
245
+ break;
246
+ case "gemini":
247
+ parsed = parseGeminiConversations(data);
248
+ break;
249
+ default:
250
+ result.errors.push("Unknown platform format. Please select the correct platform.");
251
+ return result;
252
+ }
253
+
254
+ result.conversations = parsed.conversations;
255
+ result.messagesProcessed = parsed.messages.length;
256
+
257
+ // Limit conversations if needed
258
+ const maxMsgs = (options.maxConversations || 1000) * 20; // ~20 msgs per conversation
259
+ const msgs = parsed.messages.slice(0, maxMsgs);
260
+ if (parsed.messages.length > maxMsgs) {
261
+ result.skipped = parsed.messages.length - maxMsgs;
262
+ }
263
+
264
+ // Extract facts from conversations
265
+ if (options.extractFacts || options.mode === "both") {
266
+ const facts = extractFactsHeuristic(msgs);
267
+ result.memories.push(...facts);
268
+ }
269
+ } catch (err) {
270
+ result.errors.push(err instanceof Error ? err.message : "Import failed");
271
+ }
272
+
273
+ // Deduplicate memories
274
+ result.memories = [...new Set(result.memories)];
275
+
276
+ return result;
277
+ }
@@ -0,0 +1,208 @@
1
+ import { join } from "path";
2
+ import { readFileSync, existsSync, mkdirSync } from "fs";
3
+ import { randomUUID } from "crypto";
4
+ import { atomicWriteFileSync } from "../fs-utils.js";
5
+
6
+ export interface NewsSource {
7
+ id: string;
8
+ name: string;
9
+ type: "rss" | "website" | "keyword";
10
+ url?: string; // for rss/website
11
+ keywords?: string[]; // for keyword monitoring
12
+ category: string;
13
+ enabled: boolean;
14
+ checkInterval: number; // minutes
15
+ lastChecked?: string;
16
+ createdAt: string;
17
+ }
18
+
19
+ export interface NewsItem {
20
+ id: string;
21
+ sourceId: string;
22
+ sourceName: string;
23
+ title: string;
24
+ summary: string;
25
+ url?: string;
26
+ category: string;
27
+ publishedAt: string;
28
+ fetchedAt: string;
29
+ read: boolean;
30
+ saved: boolean;
31
+ relevance?: number; // 0-100, AI-scored
32
+ }
33
+
34
+ const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "news");
35
+ const SOURCES_FILE = join(DATA_DIR, "sources.json");
36
+ const ITEMS_FILE = join(DATA_DIR, "items.json");
37
+
38
+ function ensureDir() {
39
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
40
+ }
41
+
42
+ function loadSources(): NewsSource[] {
43
+ ensureDir();
44
+ if (!existsSync(SOURCES_FILE)) return [];
45
+ try { return JSON.parse(readFileSync(SOURCES_FILE, "utf-8")); }
46
+ catch { return []; }
47
+ }
48
+
49
+ function saveSources(sources: NewsSource[]) {
50
+ ensureDir();
51
+ atomicWriteFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
52
+ }
53
+
54
+ function loadItems(): NewsItem[] {
55
+ ensureDir();
56
+ if (!existsSync(ITEMS_FILE)) return [];
57
+ try { return JSON.parse(readFileSync(ITEMS_FILE, "utf-8")); }
58
+ catch { return []; }
59
+ }
60
+
61
+ function saveItems(items: NewsItem[]) {
62
+ ensureDir();
63
+ // Keep max 500 items
64
+ const trimmed = items.slice(0, 500);
65
+ atomicWriteFileSync(ITEMS_FILE, JSON.stringify(trimmed, null, 2));
66
+ }
67
+
68
+ // Sources
69
+ export function addSource(name: string, type: NewsSource["type"], category: string, url?: string, keywords?: string[], checkInterval?: number): NewsSource {
70
+ const sources = loadSources();
71
+ const source: NewsSource = {
72
+ id: randomUUID(),
73
+ name,
74
+ type,
75
+ url,
76
+ keywords,
77
+ category,
78
+ enabled: true,
79
+ checkInterval: checkInterval || 60,
80
+ createdAt: new Date().toISOString()
81
+ };
82
+ sources.push(source);
83
+ saveSources(sources);
84
+ return source;
85
+ }
86
+
87
+ export function listSources(): NewsSource[] {
88
+ return loadSources();
89
+ }
90
+
91
+ export function updateSource(id: string, patch: Partial<Pick<NewsSource, "name" | "enabled" | "checkInterval" | "keywords" | "url" | "category">>): NewsSource | null {
92
+ const sources = loadSources();
93
+ const idx = sources.findIndex(s => s.id === id);
94
+ if (idx === -1) return null;
95
+ Object.assign(sources[idx], patch);
96
+ saveSources(sources);
97
+ return sources[idx];
98
+ }
99
+
100
+ export function deleteSource(id: string): boolean {
101
+ const sources = loadSources();
102
+ const idx = sources.findIndex(s => s.id === id);
103
+ if (idx === -1) return false;
104
+ sources.splice(idx, 1);
105
+ saveSources(sources);
106
+ // Also remove items from this source
107
+ const items = loadItems().filter(i => i.sourceId !== id);
108
+ saveItems(items);
109
+ return true;
110
+ }
111
+
112
+ export function markSourceChecked(id: string): void {
113
+ const sources = loadSources();
114
+ const idx = sources.findIndex(s => s.id === id);
115
+ if (idx !== -1) {
116
+ sources[idx].lastChecked = new Date().toISOString();
117
+ saveSources(sources);
118
+ }
119
+ }
120
+
121
+ // Items
122
+ export function addNewsItem(sourceId: string, sourceName: string, title: string, summary: string, category: string, url?: string, relevance?: number): NewsItem {
123
+ const items = loadItems();
124
+ // Dedup by title+source
125
+ if (items.some(i => i.sourceId === sourceId && i.title === title)) {
126
+ return items.find(i => i.sourceId === sourceId && i.title === title)!;
127
+ }
128
+ const item: NewsItem = {
129
+ id: randomUUID(),
130
+ sourceId,
131
+ sourceName,
132
+ title,
133
+ summary,
134
+ url,
135
+ category,
136
+ publishedAt: new Date().toISOString(),
137
+ fetchedAt: new Date().toISOString(),
138
+ read: false,
139
+ saved: false,
140
+ relevance
141
+ };
142
+ items.unshift(item);
143
+ saveItems(items);
144
+ return item;
145
+ }
146
+
147
+ export function listNews(category?: string, unreadOnly?: boolean, savedOnly?: boolean, limit?: number): NewsItem[] {
148
+ let items = loadItems();
149
+ if (category) items = items.filter(i => i.category === category);
150
+ if (unreadOnly) items = items.filter(i => !i.read);
151
+ if (savedOnly) items = items.filter(i => i.saved);
152
+ return items.slice(0, limit || 50);
153
+ }
154
+
155
+ export function markRead(id: string): boolean {
156
+ const items = loadItems();
157
+ const idx = items.findIndex(i => i.id === id);
158
+ if (idx === -1) return false;
159
+ items[idx].read = true;
160
+ saveItems(items);
161
+ return true;
162
+ }
163
+
164
+ export function markAllRead(category?: string): number {
165
+ const items = loadItems();
166
+ let count = 0;
167
+ for (const item of items) {
168
+ if (!item.read && (!category || item.category === category)) {
169
+ item.read = true;
170
+ count++;
171
+ }
172
+ }
173
+ saveItems(items);
174
+ return count;
175
+ }
176
+
177
+ export function toggleSaved(id: string): NewsItem | null {
178
+ const items = loadItems();
179
+ const idx = items.findIndex(i => i.id === id);
180
+ if (idx === -1) return null;
181
+ items[idx].saved = !items[idx].saved;
182
+ saveItems(items);
183
+ return items[idx];
184
+ }
185
+
186
+ export function searchNews(query: string): NewsItem[] {
187
+ const q = query.toLowerCase();
188
+ return loadItems().filter(i =>
189
+ i.title.toLowerCase().includes(q) ||
190
+ i.summary.toLowerCase().includes(q) ||
191
+ i.sourceName.toLowerCase().includes(q)
192
+ );
193
+ }
194
+
195
+ export function getNewsStats(): { total: number; unread: number; sources: number; byCategory: Record<string, number> } {
196
+ const items = loadItems();
197
+ const sources = loadSources();
198
+ const byCategory: Record<string, number> = {};
199
+ for (const item of items.filter(i => !i.read)) {
200
+ byCategory[item.category] = (byCategory[item.category] || 0) + 1;
201
+ }
202
+ return {
203
+ total: items.length,
204
+ unread: items.filter(i => !i.read).length,
205
+ sources: sources.filter(s => s.enabled).length,
206
+ byCategory
207
+ };
208
+ }
@@ -0,0 +1,134 @@
1
+ import { join } from "path";
2
+ import { readFileSync, existsSync, mkdirSync } from "fs";
3
+ import { randomUUID } from "crypto";
4
+ import { atomicWriteFileSync } from "../fs-utils.js";
5
+
6
+ export interface TemplateVariable {
7
+ name: string;
8
+ description?: string;
9
+ defaultValue?: string;
10
+ required?: boolean;
11
+ }
12
+
13
+ export interface Template {
14
+ id: string;
15
+ name: string;
16
+ category: string; // email, contract, meeting, invoice, report, custom
17
+ content: string; // with {{variable}} placeholders
18
+ variables: TemplateVariable[];
19
+ tags: string[];
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ usageCount: number;
23
+ lastUsed?: string;
24
+ }
25
+
26
+ const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "templates");
27
+ const STORE_FILE = join(DATA_DIR, "templates.json");
28
+
29
+ function ensureDir() {
30
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
31
+ }
32
+
33
+ function load(): Template[] {
34
+ ensureDir();
35
+ if (!existsSync(STORE_FILE)) return [];
36
+ try { return JSON.parse(readFileSync(STORE_FILE, "utf-8")); }
37
+ catch { return []; }
38
+ }
39
+
40
+ function save(templates: Template[]) {
41
+ ensureDir();
42
+ atomicWriteFileSync(STORE_FILE, JSON.stringify(templates, null, 2));
43
+ }
44
+
45
+ export function listTemplates(category?: string): Template[] {
46
+ let templates = load();
47
+ if (category) templates = templates.filter(t => t.category === category);
48
+ return templates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
49
+ }
50
+
51
+ export function createTemplate(name: string, content: string, category: string, variables?: TemplateVariable[], tags?: string[]): Template {
52
+ const templates = load();
53
+
54
+ // Auto-detect variables from {{...}} placeholders if not provided
55
+ const detectedVars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]);
56
+ const vars: TemplateVariable[] = variables || detectedVars.map(v => ({ name: v, required: true }));
57
+
58
+ const template: Template = {
59
+ id: randomUUID(),
60
+ name,
61
+ category,
62
+ content,
63
+ variables: vars,
64
+ tags: tags || [],
65
+ createdAt: new Date().toISOString(),
66
+ updatedAt: new Date().toISOString(),
67
+ usageCount: 0
68
+ };
69
+
70
+ templates.push(template);
71
+ save(templates);
72
+ return template;
73
+ }
74
+
75
+ export function getTemplate(id: string): Template | null {
76
+ return load().find(t => t.id === id) || null;
77
+ }
78
+
79
+ export function updateTemplate(id: string, patch: Partial<Pick<Template, "name" | "content" | "category" | "variables" | "tags">>): Template | null {
80
+ const templates = load();
81
+ const idx = templates.findIndex(t => t.id === id);
82
+ if (idx === -1) return null;
83
+
84
+ if (patch.name !== undefined) templates[idx].name = patch.name;
85
+ if (patch.content !== undefined) templates[idx].content = patch.content;
86
+ if (patch.category !== undefined) templates[idx].category = patch.category;
87
+ if (patch.variables !== undefined) templates[idx].variables = patch.variables;
88
+ if (patch.tags !== undefined) templates[idx].tags = patch.tags;
89
+ templates[idx].updatedAt = new Date().toISOString();
90
+
91
+ save(templates);
92
+ return templates[idx];
93
+ }
94
+
95
+ export function deleteTemplate(id: string): boolean {
96
+ const templates = load();
97
+ const idx = templates.findIndex(t => t.id === id);
98
+ if (idx === -1) return false;
99
+ templates.splice(idx, 1);
100
+ save(templates);
101
+ return true;
102
+ }
103
+
104
+ export function useTemplate(id: string, variables: Record<string, string>): { result: string; template: Template } | null {
105
+ const templates = load();
106
+ const idx = templates.findIndex(t => t.id === id);
107
+ if (idx === -1) return null;
108
+
109
+ let result = templates[idx].content;
110
+ for (const [key, value] of Object.entries(variables)) {
111
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value);
112
+ }
113
+
114
+ // Update usage stats
115
+ templates[idx].usageCount++;
116
+ templates[idx].lastUsed = new Date().toISOString();
117
+ save(templates);
118
+
119
+ return { result, template: templates[idx] };
120
+ }
121
+
122
+ export function searchTemplates(query: string): Template[] {
123
+ const q = query.toLowerCase();
124
+ return load().filter(t =>
125
+ t.name.toLowerCase().includes(q) ||
126
+ t.category.toLowerCase().includes(q) ||
127
+ t.tags.some(tag => tag.toLowerCase().includes(q)) ||
128
+ t.content.toLowerCase().includes(q)
129
+ );
130
+ }
131
+
132
+ export function listCategories(): string[] {
133
+ return [...new Set(load().map(t => t.category))].sort();
134
+ }