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.
- package/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- 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,
|
|
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
|
-
|
|
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",
|