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,296 @@
1
+ // ─── Memory Service ─────────────────────────────────────────────────────────
2
+ // 100% local semantic memory using vectra + transformers.js embeddings.
3
+ // When obsidianVaultPath is set, vault .md files are the primary store
4
+ // and Vectra becomes a search-only index rebuilt from vault files.
5
+
6
+ import { getEmbedding, isEmbeddingReady } from "./embedding-service.js";
7
+ import * as vectorStore from "./vector-store.js";
8
+ import { getSettings } from "./settings-manager.js";
9
+ import * as vaultMd from "./vault-markdown.js";
10
+ import { markPendingWrite } from "./vault-watcher.js";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ export interface Memory {
15
+ id: string;
16
+ content: string;
17
+ metadata?: Record<string, unknown>;
18
+ createdAt?: string;
19
+ updatedAt?: string;
20
+ }
21
+
22
+ // ─── Vault Helpers ──────────────────────────────────────────────────────────
23
+
24
+ function getMemoryDir(): string | null {
25
+ const vaultPath = getSettings().obsidianVaultPath;
26
+ if (!vaultPath) return null;
27
+ const dir = join(vaultPath, "HeyHank", "Memory");
28
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
29
+ return dir;
30
+ }
31
+
32
+ // ─── Public API ─────────────────────────────────────────────────────────────
33
+
34
+ export async function addMemory(content: string, metadata?: Record<string, unknown>): Promise<Memory> {
35
+ const id = crypto.randomUUID();
36
+ const createdAt = new Date().toISOString();
37
+ const meta = { ...metadata, createdAt };
38
+
39
+ // Write to vault if configured (primary store)
40
+ const dir = getMemoryDir();
41
+ if (dir) {
42
+ const memObj: vaultMd.Memory = {
43
+ id,
44
+ content,
45
+ createdAt,
46
+ updatedAt: createdAt,
47
+ category: metadata?.category as string | undefined,
48
+ source: metadata?.source as string | undefined,
49
+ };
50
+ markPendingWrite(`memory-${id}.md`);
51
+ writeFileSync(join(dir, `memory-${id}.md`), vaultMd.memoryToMarkdown(memObj), "utf-8");
52
+ }
53
+
54
+ // Index in Vectra (search index)
55
+ let embedding: number[];
56
+ try {
57
+ embedding = await getEmbedding(content, "passage: ");
58
+ } catch (err) {
59
+ console.log(`[memory] Embedding failed, storing with zero vector: ${err}`);
60
+ embedding = new Array(384).fill(0);
61
+ }
62
+ await vectorStore.addItem(id, content, embedding, meta);
63
+
64
+ return { id, content, metadata, createdAt };
65
+ }
66
+
67
+ export async function searchMemories(query: string, limit = 5): Promise<Memory[]> {
68
+ try {
69
+ const embedding = await getEmbedding(query, "query: ");
70
+ const results = await vectorStore.searchByVector(embedding, limit);
71
+ return results.map(toMemory);
72
+ } catch (err) {
73
+ console.log(`[memory] Semantic search failed, falling back to keyword: ${err}`);
74
+ const results = await vectorStore.keywordSearch(query, limit);
75
+ return results.map(toMemory);
76
+ }
77
+ }
78
+
79
+ export async function listMemories(limit = 50): Promise<Memory[]> {
80
+ const dir = getMemoryDir();
81
+ if (dir) {
82
+ // Read from vault (primary)
83
+ const files = readdirSync(dir).filter(f => f.startsWith("memory-") && f.endsWith(".md"));
84
+ const memories: Memory[] = [];
85
+ for (const file of files) {
86
+ try {
87
+ const raw = readFileSync(join(dir, file), "utf-8");
88
+ const mem = vaultMd.markdownToMemory(raw);
89
+ memories.push({
90
+ id: mem.id,
91
+ content: mem.content,
92
+ metadata: {
93
+ createdAt: mem.createdAt,
94
+ ...(mem.category ? { category: mem.category } : {}),
95
+ ...(mem.source ? { source: mem.source } : {}),
96
+ },
97
+ createdAt: mem.createdAt,
98
+ updatedAt: mem.updatedAt || undefined,
99
+ });
100
+ } catch { /* skip malformed files */ }
101
+ }
102
+ return memories
103
+ .sort((a, b) => {
104
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
105
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
106
+ return tb - ta;
107
+ })
108
+ .slice(0, limit);
109
+ }
110
+
111
+ // Fallback: read from Vectra
112
+ const items = await vectorStore.listAll();
113
+ return items
114
+ .map(toMemory)
115
+ .sort((a, b) => {
116
+ const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
117
+ const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
118
+ return tb - ta;
119
+ })
120
+ .slice(0, limit);
121
+ }
122
+
123
+ export async function updateMemory(id: string, content: string, metadata?: Record<string, unknown>): Promise<Memory | null> {
124
+ const dir = getMemoryDir();
125
+ const updatedAt = new Date().toISOString();
126
+
127
+ if (dir) {
128
+ // Update in vault (primary)
129
+ const filepath = join(dir, `memory-${id}.md`);
130
+ if (!existsSync(filepath)) return null;
131
+ const oldRaw = readFileSync(filepath, "utf-8");
132
+ const oldMem = vaultMd.markdownToMemory(oldRaw);
133
+ const memObj: vaultMd.Memory = {
134
+ id,
135
+ content,
136
+ createdAt: oldMem.createdAt,
137
+ updatedAt,
138
+ category: (metadata?.category as string | undefined) || oldMem.category,
139
+ source: (metadata?.source as string | undefined) || oldMem.source,
140
+ };
141
+ markPendingWrite(`memory-${id}.md`);
142
+ writeFileSync(filepath, vaultMd.memoryToMarkdown(memObj), "utf-8");
143
+
144
+ // Re-index in Vectra
145
+ const mergedMeta = { ...metadata, createdAt: oldMem.createdAt, updatedAt };
146
+ let embedding: number[];
147
+ try { embedding = await getEmbedding(content, "passage: "); } catch { embedding = new Array(384).fill(0); }
148
+ await vectorStore.deleteItem(id);
149
+ await vectorStore.addItem(id, content, embedding, mergedMeta);
150
+
151
+ return { id, content, metadata: mergedMeta, createdAt: oldMem.createdAt, updatedAt };
152
+ }
153
+
154
+ // Fallback: original Vectra-only logic
155
+ const items = await vectorStore.listAll();
156
+ const existing = items.find(i => i.id === id);
157
+ if (!existing) return null;
158
+ const mergedMeta = { ...existing.metadata, ...metadata, updatedAt };
159
+ let embedding: number[];
160
+ try { embedding = await getEmbedding(content, "passage: "); } catch { embedding = new Array(384).fill(0); }
161
+ await vectorStore.deleteItem(id);
162
+ await vectorStore.addItem(id, content, embedding, mergedMeta);
163
+ return { id, content, metadata: mergedMeta, createdAt: (mergedMeta as Record<string, unknown>).createdAt as string | undefined, updatedAt };
164
+ }
165
+
166
+ export async function deleteMemory(id: string): Promise<boolean> {
167
+ const dir = getMemoryDir();
168
+ if (dir) {
169
+ const filepath = join(dir, `memory-${id}.md`);
170
+ try {
171
+ if (existsSync(filepath)) {
172
+ markPendingWrite(`memory-${id}.md`);
173
+ unlinkSync(filepath);
174
+ }
175
+ } catch { /* ignore */ }
176
+ }
177
+ return vectorStore.deleteItem(id);
178
+ }
179
+
180
+ /** Get relevant memory context for a user message (for system prompt injection) */
181
+ export async function getContextForMessage(message: string): Promise<string> {
182
+ try {
183
+ const memories = await searchMemories(message, 5);
184
+ if (memories.length === 0) return "";
185
+ return `\nUSER MEMORY (known facts about the user):\n${memories.map(m => `- ${m.content}`).join("\n")}\nUse this context to personalize your responses.`;
186
+ } catch {
187
+ return "";
188
+ }
189
+ }
190
+
191
+ /** Memory is always configured — no external API key needed */
192
+ export function isMemoryConfigured(): boolean {
193
+ return true;
194
+ }
195
+
196
+ /** Returns "semantic" when embedding model is loaded, "local-keyword" otherwise */
197
+ export function getBackendName(): string {
198
+ return isEmbeddingReady() ? "semantic" : "local-keyword";
199
+ }
200
+
201
+ // ─── Auto-Detection ─────────────────────────────────────────────────────────
202
+
203
+ export interface DetectedFact {
204
+ fact: string;
205
+ category: string;
206
+ }
207
+
208
+ /**
209
+ * Extract memorable facts from a conversation using an LLM.
210
+ * Returns only genuinely new facts (deduplicated against existing memories).
211
+ */
212
+ export async function detectMemorableFacts(
213
+ messages: Array<{ role: string; content: string }>,
214
+ llmCall: (systemPrompt: string, userPrompt: string) => Promise<string>,
215
+ ): Promise<DetectedFact[]> {
216
+ // Only process conversations with at least 1 user message
217
+ const userMessages = messages.filter(m => m.role === "user");
218
+ if (userMessages.length === 0) return [];
219
+
220
+ // Get existing memories to avoid duplicates
221
+ const existing = await listMemories(100);
222
+ const existingFacts = existing.map(m => m.content).join("\n- ");
223
+
224
+ const systemPrompt = `You extract personal facts about the user from conversations.
225
+ Return ONLY a JSON array of objects with "fact" and "category" fields.
226
+ Categories: preference, personal, technical, habit, schedule, contact
227
+ Rules:
228
+ - Only extract FACTS the user explicitly stated about themselves
229
+ - Skip greetings, questions, commands, and transient requests
230
+ - Skip facts that are already known (see EXISTING below)
231
+ - Keep facts concise (one sentence max)
232
+ - Return [] if no new facts found
233
+ EXISTING MEMORIES:
234
+ ${existingFacts ? `- ${existingFacts}` : "(none)"}`;
235
+
236
+ const conversationText = messages
237
+ .filter(m => m.role === "user" || m.role === "assistant")
238
+ .slice(-10) // last 10 messages max
239
+ .map(m => `${m.role}: ${m.content}`)
240
+ .join("\n");
241
+
242
+ const userPrompt = `Extract new personal facts from this conversation:\n\n${conversationText}\n\nRespond with ONLY a JSON array, no markdown.`;
243
+
244
+ try {
245
+ const response = await llmCall(systemPrompt, userPrompt);
246
+ // Parse JSON from response (handle markdown code fences)
247
+ const cleaned = response.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
248
+ const parsed = JSON.parse(cleaned);
249
+ if (!Array.isArray(parsed)) return [];
250
+ return parsed.filter((f: any) => f.fact && f.category && typeof f.fact === "string");
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ // ─── Vault Sync ─────────────────────────────────────────────────────────────
257
+
258
+ /** Initialize vault sync — call once at server startup */
259
+ export async function initVaultSync(): Promise<void> {
260
+ const dir = getMemoryDir();
261
+ if (!dir) return;
262
+
263
+ // Rebuild Vectra index from vault files (for memories not yet indexed)
264
+ await vectorStore.rebuildFromVault(dir);
265
+
266
+ // Start file watcher for Memory/ subfolder only
267
+ const { startMemoryWatcher } = await import("./vault-watcher.js");
268
+ startMemoryWatcher(dir, async (id, content) => {
269
+ // External edit — re-index in Vectra
270
+ let embedding: number[];
271
+ try { embedding = await getEmbedding(content, "passage: "); } catch { embedding = new Array(384).fill(0); }
272
+ await vectorStore.deleteItem(id);
273
+ await vectorStore.addItem(id, content, embedding, {});
274
+ }, async (id) => {
275
+ // External delete — remove from Vectra
276
+ await vectorStore.deleteItem(id);
277
+ });
278
+ }
279
+
280
+ /** Restart vault sync (called when settings change) */
281
+ export async function restartVaultSync(): Promise<void> {
282
+ const { stopMemoryWatcher } = await import("./vault-watcher.js");
283
+ stopMemoryWatcher();
284
+ await initVaultSync();
285
+ }
286
+
287
+ // ─── Helpers ────────────────────────────────────────────────────────────────
288
+
289
+ function toMemory(item: vectorStore.VectorItem): Memory {
290
+ return {
291
+ id: item.id,
292
+ content: item.content,
293
+ metadata: item.metadata,
294
+ createdAt: item.metadata?.createdAt as string | undefined,
295
+ };
296
+ }
@@ -0,0 +1,184 @@
1
+ // ─── Obsidian Vault Sync ─────────────────────────────────────────────────────
2
+ // Mirrors HeyHank memories as Markdown files in an Obsidian vault.
3
+ // Bi-directional: changes in either system sync to the other.
4
+
5
+ import { watch, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
6
+ import { join, basename } from "node:path";
7
+ import type { FSWatcher } from "node:fs";
8
+ import { getSettings } from "./settings-manager.js";
9
+
10
+ const SUBFOLDER = "HeyHank";
11
+
12
+ // Track which writes we initiated to avoid re-importing our own changes
13
+ const pendingWrites = new Set<string>();
14
+ let watcher: FSWatcher | null = null;
15
+ let currentVaultPath = "";
16
+
17
+ // Callbacks for syncing changes back to memory service
18
+ let onExternalEdit: ((id: string, content: string) => Promise<void>) | null = null;
19
+ let onExternalDelete: ((id: string) => Promise<void>) | null = null;
20
+
21
+ function getVaultDir(): string | null {
22
+ const vaultPath = getSettings().obsidianVaultPath;
23
+ if (!vaultPath) return null;
24
+ return join(vaultPath, SUBFOLDER);
25
+ }
26
+
27
+ function filenameForId(id: string): string {
28
+ return `memory-${id}.md`;
29
+ }
30
+
31
+ function idFromFilename(filename: string): string | null {
32
+ const match = filename.match(/^memory-(.+)\.md$/);
33
+ return match ? match[1] : null;
34
+ }
35
+
36
+ /** Build markdown content from a memory */
37
+ function toMarkdown(content: string, metadata?: Record<string, unknown>): string {
38
+ const lines: string[] = ["---"];
39
+ if (metadata?.createdAt) lines.push(`created: ${metadata.createdAt}`);
40
+ if (metadata?.updatedAt) lines.push(`updated: ${metadata.updatedAt}`);
41
+ if (metadata?.category) lines.push(`category: ${metadata.category}`);
42
+ if (metadata?.source) lines.push(`source: ${metadata.source}`);
43
+ lines.push("synced: true");
44
+ lines.push("---");
45
+ lines.push("");
46
+ lines.push(content);
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /** Parse markdown back to content (strip frontmatter) */
51
+ function fromMarkdown(md: string): string {
52
+ const fmMatch = md.match(/^---\n[\s\S]*?\n---\n\n?([\s\S]*)$/);
53
+ return fmMatch ? fmMatch[1].trim() : md.trim();
54
+ }
55
+
56
+ // ─── Public API ─────────────────────────────────────────────────────────────
57
+
58
+ /** Write a memory to the vault as a .md file */
59
+ export function syncToVault(id: string, content: string, metadata?: Record<string, unknown>): void {
60
+ const dir = getVaultDir();
61
+ if (!dir) return;
62
+
63
+ try {
64
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
+ const filepath = join(dir, filenameForId(id));
66
+ const md = toMarkdown(content, metadata);
67
+ pendingWrites.add(filenameForId(id));
68
+ writeFileSync(filepath, md, "utf-8");
69
+ // Clear pending after a short delay to allow watcher to see it
70
+ setTimeout(() => pendingWrites.delete(filenameForId(id)), 500);
71
+ } catch (err) {
72
+ console.warn("[obsidian-sync] Failed to write:", err instanceof Error ? err.message : err);
73
+ }
74
+ }
75
+
76
+ /** Remove a memory's .md file from the vault */
77
+ export function removeFromVault(id: string): void {
78
+ const dir = getVaultDir();
79
+ if (!dir) return;
80
+
81
+ try {
82
+ const filepath = join(dir, filenameForId(id));
83
+ if (existsSync(filepath)) {
84
+ pendingWrites.add(filenameForId(id));
85
+ unlinkSync(filepath);
86
+ setTimeout(() => pendingWrites.delete(filenameForId(id)), 500);
87
+ }
88
+ } catch (err) {
89
+ console.warn("[obsidian-sync] Failed to delete:", err instanceof Error ? err.message : err);
90
+ }
91
+ }
92
+
93
+ /** Start watching the vault subfolder for external changes */
94
+ export function startWatcher(
95
+ onEdit: (id: string, content: string) => Promise<void>,
96
+ onDelete: (id: string) => Promise<void>,
97
+ ): void {
98
+ stopWatcher();
99
+ onExternalEdit = onEdit;
100
+ onExternalDelete = onDelete;
101
+
102
+ const dir = getVaultDir();
103
+ if (!dir) return;
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
105
+
106
+ currentVaultPath = dir;
107
+
108
+ try {
109
+ watcher = watch(dir, (eventType, filename) => {
110
+ if (!filename || !filename.endsWith(".md")) return;
111
+ if (pendingWrites.has(filename)) return; // Our own write, skip
112
+
113
+ const id = idFromFilename(filename);
114
+ if (!id) return;
115
+
116
+ const filepath = join(dir, filename);
117
+
118
+ // Small debounce to let file writes complete
119
+ setTimeout(async () => {
120
+ try {
121
+ if (existsSync(filepath)) {
122
+ const md = readFileSync(filepath, "utf-8");
123
+ const content = fromMarkdown(md);
124
+ if (content && onExternalEdit) {
125
+ await onExternalEdit(id, content);
126
+ console.log(`[obsidian-sync] Imported edit for memory ${id}`);
127
+ }
128
+ } else {
129
+ // File was deleted externally
130
+ if (onExternalDelete) {
131
+ await onExternalDelete(id);
132
+ console.log(`[obsidian-sync] Imported delete for memory ${id}`);
133
+ }
134
+ }
135
+ } catch (err) {
136
+ console.warn("[obsidian-sync] Watcher error:", err instanceof Error ? err.message : err);
137
+ }
138
+ }, 300);
139
+ });
140
+ console.log(`[obsidian-sync] Watching ${dir}`);
141
+ } catch (err) {
142
+ console.warn("[obsidian-sync] Failed to start watcher:", err instanceof Error ? err.message : err);
143
+ }
144
+ }
145
+
146
+ /** Stop the file watcher */
147
+ export function stopWatcher(): void {
148
+ if (watcher) {
149
+ watcher.close();
150
+ watcher = null;
151
+ if (currentVaultPath) {
152
+ console.log(`[obsidian-sync] Stopped watching ${currentVaultPath}`);
153
+ }
154
+ currentVaultPath = "";
155
+ }
156
+ }
157
+
158
+ /** Export all existing memories to the vault (initial sync) */
159
+ export function bulkExportToVault(memories: Array<{ id: string; content: string; metadata?: Record<string, unknown> }>): void {
160
+ const dir = getVaultDir();
161
+ if (!dir) return;
162
+
163
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
164
+
165
+ let count = 0;
166
+ for (const mem of memories) {
167
+ try {
168
+ const filepath = join(dir, filenameForId(mem.id));
169
+ if (!existsSync(filepath)) {
170
+ const md = toMarkdown(mem.content, mem.metadata);
171
+ pendingWrites.add(filenameForId(mem.id));
172
+ writeFileSync(filepath, md, "utf-8");
173
+ setTimeout(() => pendingWrites.delete(filenameForId(mem.id)), 500);
174
+ count++;
175
+ }
176
+ } catch { /* skip individual failures */ }
177
+ }
178
+ if (count > 0) console.log(`[obsidian-sync] Exported ${count} memories to vault`);
179
+ }
180
+
181
+ /** Check if Obsidian sync is enabled */
182
+ export function isEnabled(): boolean {
183
+ return !!getSettings().obsidianVaultPath;
184
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Provider config storage — persists user's provider credentials to ~/.heyhank/providers.json
3
3
  */
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
+ import { readFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { getProviderById } from "./provider-registry.js";
8
+ import { atomicWriteFileSync } from "./fs-utils.js";
8
9
 
9
10
  export interface ProviderConfig {
10
11
  providerId: string;
@@ -35,7 +36,9 @@ function readAll(): ProviderConfig[] {
35
36
 
36
37
  function writeAll(configs: ProviderConfig[]): void {
37
38
  ensureDir();
38
- writeFileSync(PROVIDERS_FILE, JSON.stringify(configs, null, 2), { mode: 0o600 });
39
+ atomicWriteFileSync(PROVIDERS_FILE, JSON.stringify(configs, null, 2));
40
+ // chmod separately — atomic rename preserves temp file's mode (0o644 by default)
41
+ try { chmodSync(PROVIDERS_FILE, 0o600); } catch { /* best-effort */ }
39
42
  }
40
43
 
41
44
  export function listProviderConfigs(): ProviderConfig[] {
@@ -106,6 +106,18 @@ const PROVIDER_REGISTRY: ProviderDefinition[] = [
106
106
  { key: "OPENROUTER_API_KEY", label: "API Key", required: true, secret: true, placeholder: "sk-or-..." },
107
107
  ],
108
108
  },
109
+ {
110
+ id: "groq",
111
+ name: "Groq",
112
+ description: "Ultra-low-latency inference (Llama, Mixtral, Whisper). Ideal for real-time voice.",
113
+ cliProviderFlag: "groq",
114
+ category: "cloud",
115
+ defaultModel: "llama-3.3-70b-versatile",
116
+ docsUrl: "https://console.groq.com/docs",
117
+ envFields: [
118
+ { key: "GROQ_API_KEY", label: "API Key", required: true, secret: true, placeholder: "gsk_..." },
119
+ ],
120
+ },
109
121
  {
110
122
  id: "together",
111
123
  name: "Together AI",
@@ -1,11 +1,16 @@
1
1
  // ─── Reminder Scheduler ──────────────────────────────────────────────────────
2
2
  // Checks every 60 seconds for due reminders and sends push notifications.
3
3
 
4
- import { getDueReminders, fireReminder } from "./assistant-store.js";
4
+ import { getDueReminders, fireReminder, listTodos } from "./assistant-store.js";
5
5
  import { sendNotification } from "./push-notifications.js";
6
6
 
7
7
  let intervalId: ReturnType<typeof setInterval> | null = null;
8
8
 
9
+ // Dedup set for delegation notifications: key `${todoId}:${dueDate}`.
10
+ // Prevents the same "delegation is due today/tomorrow" notification from
11
+ // firing every minute. Cleared on process restart (at most one duplicate).
12
+ const notifiedDelegations = new Set<string>();
13
+
9
14
  async function checkReminders(): Promise<void> {
10
15
  const due = getDueReminders();
11
16
  for (const reminder of due) {
@@ -20,6 +25,37 @@ async function checkReminders(): Promise<void> {
20
25
  }
21
26
  fireReminder(reminder.id);
22
27
  }
28
+
29
+ // Check for delegated todos with approaching due dates.
30
+ // Notify once per (todoId, dueDate) pair to avoid per-minute spam.
31
+ try {
32
+ const today = new Date().toISOString().slice(0, 10);
33
+ const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
34
+ const todos = listTodos({ done: false });
35
+
36
+ // Garbage-collect keys whose dueDate is neither today nor tomorrow anymore
37
+ for (const key of notifiedDelegations) {
38
+ const dueDate = key.split(":")[1];
39
+ if (dueDate !== today && dueDate !== tomorrow) notifiedDelegations.delete(key);
40
+ }
41
+
42
+ for (const todo of todos) {
43
+ if (!todo.delegatedTo || !todo.dueDate) continue;
44
+ if (todo.dueDate !== today && todo.dueDate !== tomorrow) continue;
45
+ const key = `${todo.id}:${todo.dueDate}`;
46
+ if (notifiedDelegations.has(key)) continue;
47
+
48
+ const label = todo.dueDate === today ? "heute" : "morgen";
49
+ await sendNotification(
50
+ "Delegation fällig",
51
+ `${todo.delegatedTo}: "${todo.text}" — fällig ${label}`,
52
+ { tag: `delegation-${todo.id}-${todo.dueDate}`, icon: "/icon-192.png" },
53
+ );
54
+ notifiedDelegations.add(key);
55
+ }
56
+ } catch (err) {
57
+ console.error("[reminder-scheduler] Failed to check delegation due dates:", err);
58
+ }
23
59
  }
24
60
 
25
61
  export function startReminderScheduler(): void {
@@ -164,8 +164,9 @@ export function registerAgentRoutes(
164
164
  if (!agent) return c.json({ error: "Agent not found" }, 404);
165
165
  const body = await c.req.json().catch(() => ({}));
166
166
  const input = typeof body.input === "string" ? body.input : undefined;
167
+ const cwdOverride = typeof body.cwd === "string" ? body.cwd : undefined;
167
168
  try {
168
- const sessionInfo = await agentExecutor?.executeAgent(id, input, { force: true, triggerType: "manual" });
169
+ const sessionInfo = await agentExecutor?.executeAgent(id, input, { force: true, triggerType: "manual", cwdOverride });
169
170
  return c.json({
170
171
  ok: true,
171
172
  message: "Agent triggered",