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,343 @@
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 InvoiceItem {
7
+ description: string;
8
+ quantity: number;
9
+ unitPrice: number;
10
+ total: number;
11
+ }
12
+
13
+ export interface Invoice {
14
+ id: string;
15
+ invoiceNumber: string;
16
+ clientName: string;
17
+ clientEmail?: string;
18
+ clientAddress?: string;
19
+ items: InvoiceItem[];
20
+ subtotal: number;
21
+ taxRate: number; // percentage
22
+ taxAmount: number;
23
+ total: number;
24
+ currency: string;
25
+ status: "draft" | "sent" | "paid" | "overdue" | "cancelled";
26
+ issueDate: string;
27
+ dueDate: string;
28
+ paidDate?: string;
29
+ notes?: string;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ }
33
+
34
+ export interface Expense {
35
+ id: string;
36
+ description: string;
37
+ amount: number;
38
+ currency: string;
39
+ category: string;
40
+ date: string;
41
+ project?: string;
42
+ vendor?: string;
43
+ receipt?: string; // file reference
44
+ recurring: boolean;
45
+ notes?: string;
46
+ createdAt: string;
47
+ }
48
+
49
+ export interface FinancialSummary {
50
+ period: string;
51
+ startDate: string;
52
+ endDate: string;
53
+ totalRevenue: number;
54
+ totalExpenses: number;
55
+ netProfit: number;
56
+ currency: string;
57
+ invoicesByStatus: Record<string, { count: number; total: number }>;
58
+ expensesByCategory: Record<string, number>;
59
+ revenueByMonth: Record<string, number>;
60
+ expensesByMonth: Record<string, number>;
61
+ outstandingInvoices: Invoice[];
62
+ }
63
+
64
+ const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "finance");
65
+ const INVOICES_FILE = join(DATA_DIR, "invoices.json");
66
+ const EXPENSES_FILE = join(DATA_DIR, "expenses.json");
67
+ const SETTINGS_FILE = join(DATA_DIR, "settings.json");
68
+
69
+ function ensureDir() {
70
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
71
+ }
72
+
73
+ function loadInvoices(): Invoice[] {
74
+ ensureDir();
75
+ if (!existsSync(INVOICES_FILE)) return [];
76
+ try { return JSON.parse(readFileSync(INVOICES_FILE, "utf-8")); }
77
+ catch { return []; }
78
+ }
79
+
80
+ function saveInvoices(invoices: Invoice[]) {
81
+ ensureDir();
82
+ atomicWriteFileSync(INVOICES_FILE, JSON.stringify(invoices, null, 2));
83
+ }
84
+
85
+ function loadExpenses(): Expense[] {
86
+ ensureDir();
87
+ if (!existsSync(EXPENSES_FILE)) return [];
88
+ try { return JSON.parse(readFileSync(EXPENSES_FILE, "utf-8")); }
89
+ catch { return []; }
90
+ }
91
+
92
+ function saveExpenses(expenses: Expense[]) {
93
+ ensureDir();
94
+ atomicWriteFileSync(EXPENSES_FILE, JSON.stringify(expenses, null, 2));
95
+ }
96
+
97
+ interface FinanceSettings {
98
+ defaultCurrency: string;
99
+ defaultTaxRate: number;
100
+ invoicePrefix: string;
101
+ nextInvoiceNumber: number;
102
+ companyName?: string;
103
+ companyAddress?: string;
104
+ companyEmail?: string;
105
+ bankDetails?: string;
106
+ }
107
+
108
+ function loadSettings(): FinanceSettings {
109
+ ensureDir();
110
+ if (!existsSync(SETTINGS_FILE)) {
111
+ return { defaultCurrency: "EUR", defaultTaxRate: 20, invoicePrefix: "INV", nextInvoiceNumber: 1 };
112
+ }
113
+ try { return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8")); }
114
+ catch { return { defaultCurrency: "EUR", defaultTaxRate: 20, invoicePrefix: "INV", nextInvoiceNumber: 1 }; }
115
+ }
116
+
117
+ function saveSettings(settings: FinanceSettings) {
118
+ ensureDir();
119
+ atomicWriteFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
120
+ }
121
+
122
+ function generateInvoiceNumber(): string {
123
+ const settings = loadSettings();
124
+ const num = `${settings.invoicePrefix}-${String(settings.nextInvoiceNumber).padStart(4, "0")}`;
125
+ settings.nextInvoiceNumber++;
126
+ saveSettings(settings);
127
+ return num;
128
+ }
129
+
130
+ // Invoices
131
+ export function createInvoice(clientName: string, items: InvoiceItem[], opts?: { clientEmail?: string; clientAddress?: string; taxRate?: number; currency?: string; dueDate?: string; notes?: string }): Invoice {
132
+ const invoices = loadInvoices();
133
+ const settings = loadSettings();
134
+ const taxRate = opts?.taxRate ?? settings.defaultTaxRate;
135
+ const subtotal = items.reduce((sum, i) => sum + i.total, 0);
136
+ const taxAmount = Math.round(subtotal * taxRate / 100 * 100) / 100;
137
+
138
+ const invoice: Invoice = {
139
+ id: randomUUID(),
140
+ invoiceNumber: generateInvoiceNumber(),
141
+ clientName,
142
+ clientEmail: opts?.clientEmail,
143
+ clientAddress: opts?.clientAddress,
144
+ items,
145
+ subtotal,
146
+ taxRate,
147
+ taxAmount,
148
+ total: subtotal + taxAmount,
149
+ currency: opts?.currency || settings.defaultCurrency,
150
+ status: "draft",
151
+ issueDate: new Date().toISOString().slice(0, 10),
152
+ dueDate: opts?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
153
+ notes: opts?.notes,
154
+ createdAt: new Date().toISOString(),
155
+ updatedAt: new Date().toISOString()
156
+ };
157
+
158
+ invoices.push(invoice);
159
+ saveInvoices(invoices);
160
+ return invoice;
161
+ }
162
+
163
+ export function listInvoices(status?: string, startDate?: string, endDate?: string): Invoice[] {
164
+ let invoices = loadInvoices();
165
+ if (status) invoices = invoices.filter(i => i.status === status);
166
+ if (startDate) invoices = invoices.filter(i => i.issueDate >= startDate);
167
+ if (endDate) invoices = invoices.filter(i => i.issueDate <= endDate);
168
+ return invoices.sort((a, b) => b.issueDate.localeCompare(a.issueDate));
169
+ }
170
+
171
+ export function getInvoice(id: string): Invoice | null {
172
+ return loadInvoices().find(i => i.id === id) || null;
173
+ }
174
+
175
+ export function updateInvoice(id: string, patch: Partial<Pick<Invoice, "status" | "clientName" | "clientEmail" | "clientAddress" | "items" | "notes" | "dueDate" | "paidDate">>): Invoice | null {
176
+ const invoices = loadInvoices();
177
+ const idx = invoices.findIndex(i => i.id === id);
178
+ if (idx === -1) return null;
179
+
180
+ if (patch.items) {
181
+ const subtotal = patch.items.reduce((sum, i) => sum + i.total, 0);
182
+ invoices[idx].items = patch.items;
183
+ invoices[idx].subtotal = subtotal;
184
+ invoices[idx].taxAmount = Math.round(subtotal * invoices[idx].taxRate / 100 * 100) / 100;
185
+ invoices[idx].total = subtotal + invoices[idx].taxAmount;
186
+ }
187
+ if (patch.status !== undefined) invoices[idx].status = patch.status;
188
+ if (patch.clientName !== undefined) invoices[idx].clientName = patch.clientName;
189
+ if (patch.clientEmail !== undefined) invoices[idx].clientEmail = patch.clientEmail;
190
+ if (patch.clientAddress !== undefined) invoices[idx].clientAddress = patch.clientAddress;
191
+ if (patch.notes !== undefined) invoices[idx].notes = patch.notes;
192
+ if (patch.dueDate !== undefined) invoices[idx].dueDate = patch.dueDate;
193
+ if (patch.paidDate !== undefined) invoices[idx].paidDate = patch.paidDate;
194
+ invoices[idx].updatedAt = new Date().toISOString();
195
+
196
+ saveInvoices(invoices);
197
+ return invoices[idx];
198
+ }
199
+
200
+ export function markPaid(id: string): Invoice | null {
201
+ return updateInvoice(id, { status: "paid", paidDate: new Date().toISOString().slice(0, 10) });
202
+ }
203
+
204
+ export function deleteInvoice(id: string): boolean {
205
+ const invoices = loadInvoices();
206
+ const idx = invoices.findIndex(i => i.id === id);
207
+ if (idx === -1) return false;
208
+ invoices.splice(idx, 1);
209
+ saveInvoices(invoices);
210
+ return true;
211
+ }
212
+
213
+ // Expenses
214
+ export function logExpense(description: string, amount: number, category: string, opts?: { currency?: string; date?: string; project?: string; vendor?: string; recurring?: boolean; notes?: string }): Expense {
215
+ const expenses = loadExpenses();
216
+ const settings = loadSettings();
217
+ const expense: Expense = {
218
+ id: randomUUID(),
219
+ description,
220
+ amount,
221
+ currency: opts?.currency || settings.defaultCurrency,
222
+ category,
223
+ date: opts?.date || new Date().toISOString().slice(0, 10),
224
+ project: opts?.project,
225
+ vendor: opts?.vendor,
226
+ recurring: opts?.recurring || false,
227
+ notes: opts?.notes,
228
+ createdAt: new Date().toISOString()
229
+ };
230
+ expenses.unshift(expense);
231
+ saveExpenses(expenses);
232
+ return expense;
233
+ }
234
+
235
+ export function listExpenses(category?: string, startDate?: string, endDate?: string): Expense[] {
236
+ let expenses = loadExpenses();
237
+ if (category) expenses = expenses.filter(e => e.category === category);
238
+ if (startDate) expenses = expenses.filter(e => e.date >= startDate);
239
+ if (endDate) expenses = expenses.filter(e => e.date <= endDate);
240
+ return expenses.sort((a, b) => b.date.localeCompare(a.date));
241
+ }
242
+
243
+ export function deleteExpense(id: string): boolean {
244
+ const expenses = loadExpenses();
245
+ const idx = expenses.findIndex(e => e.id === id);
246
+ if (idx === -1) return false;
247
+ expenses.splice(idx, 1);
248
+ saveExpenses(expenses);
249
+ return true;
250
+ }
251
+
252
+ // Financial Summary
253
+ export function getFinancialSummary(period: "month" | "quarter" | "year" | "custom", startDate?: string, endDate?: string): FinancialSummary {
254
+ const now = new Date();
255
+ let start: Date;
256
+ let end: Date = now;
257
+
258
+ switch (period) {
259
+ case "month":
260
+ start = new Date(now.getFullYear(), now.getMonth(), 1);
261
+ break;
262
+ case "quarter":
263
+ const qMonth = Math.floor(now.getMonth() / 3) * 3;
264
+ start = new Date(now.getFullYear(), qMonth, 1);
265
+ break;
266
+ case "year":
267
+ start = new Date(now.getFullYear(), 0, 1);
268
+ break;
269
+ case "custom":
270
+ start = startDate ? new Date(startDate) : new Date(now.getFullYear(), 0, 1);
271
+ end = endDate ? new Date(endDate) : now;
272
+ break;
273
+ }
274
+
275
+ const startStr = start.toISOString().slice(0, 10);
276
+ const endStr = end.toISOString().slice(0, 10);
277
+
278
+ const invoices = listInvoices(undefined, startStr, endStr);
279
+ const expenses = listExpenses(undefined, startStr, endStr);
280
+
281
+ const invoicesByStatus: Record<string, { count: number; total: number }> = {};
282
+ let totalRevenue = 0;
283
+ const revenueByMonth: Record<string, number> = {};
284
+ const outstandingInvoices: Invoice[] = [];
285
+
286
+ for (const inv of invoices) {
287
+ if (!invoicesByStatus[inv.status]) invoicesByStatus[inv.status] = { count: 0, total: 0 };
288
+ invoicesByStatus[inv.status].count++;
289
+ invoicesByStatus[inv.status].total += inv.total;
290
+
291
+ if (inv.status === "paid") {
292
+ totalRevenue += inv.total;
293
+ const month = inv.issueDate.slice(0, 7);
294
+ revenueByMonth[month] = (revenueByMonth[month] || 0) + inv.total;
295
+ }
296
+ if (inv.status === "sent" || inv.status === "overdue") {
297
+ outstandingInvoices.push(inv);
298
+ }
299
+ }
300
+
301
+ const expensesByCategory: Record<string, number> = {};
302
+ const expensesByMonth: Record<string, number> = {};
303
+ let totalExpenses = 0;
304
+
305
+ for (const exp of expenses) {
306
+ totalExpenses += exp.amount;
307
+ expensesByCategory[exp.category] = (expensesByCategory[exp.category] || 0) + exp.amount;
308
+ const month = exp.date.slice(0, 7);
309
+ expensesByMonth[month] = (expensesByMonth[month] || 0) + exp.amount;
310
+ }
311
+
312
+ const settings = loadSettings();
313
+
314
+ return {
315
+ period,
316
+ startDate: startStr,
317
+ endDate: endStr,
318
+ totalRevenue,
319
+ totalExpenses,
320
+ netProfit: totalRevenue - totalExpenses,
321
+ currency: settings.defaultCurrency,
322
+ invoicesByStatus,
323
+ expensesByCategory,
324
+ revenueByMonth,
325
+ expensesByMonth,
326
+ outstandingInvoices
327
+ };
328
+ }
329
+
330
+ export function getFinanceSettings(): FinanceSettings {
331
+ return loadSettings();
332
+ }
333
+
334
+ export function updateFinanceSettings(patch: Partial<FinanceSettings>): FinanceSettings {
335
+ const settings = loadSettings();
336
+ Object.assign(settings, patch);
337
+ saveSettings(settings);
338
+ return settings;
339
+ }
340
+
341
+ export function listExpenseCategories(): string[] {
342
+ return [...new Set(loadExpenses().map(e => e.category))].sort();
343
+ }
@@ -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 KPIValue {
7
+ value: number;
8
+ date: string;
9
+ note?: string;
10
+ }
11
+
12
+ export interface KPI {
13
+ id: string;
14
+ name: string;
15
+ description?: string;
16
+ unit: string; // %, EUR, count, hours, etc.
17
+ category: string; // revenue, growth, operations, marketing, custom
18
+ target?: number;
19
+ warningThreshold?: number; // below this = yellow
20
+ criticalThreshold?: number; // below this = red
21
+ direction: "up" | "down"; // up = higher is better, down = lower is better
22
+ currentValue?: number;
23
+ previousValue?: number;
24
+ trend?: "up" | "down" | "stable";
25
+ trendPercent?: number;
26
+ history: KPIValue[];
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ }
30
+
31
+ export interface KPIDashboard {
32
+ kpis: KPI[];
33
+ lastUpdated: string;
34
+ summary: {
35
+ total: number;
36
+ onTarget: number;
37
+ warning: number;
38
+ critical: number;
39
+ noData: number;
40
+ };
41
+ }
42
+
43
+ const DATA_DIR = join(process.env.HEYHANK_HOME || join(process.env.HOME || "/root", ".heyhank"), "kpi");
44
+ const KPI_FILE = join(DATA_DIR, "kpis.json");
45
+
46
+ function ensureDir() {
47
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
48
+ }
49
+
50
+ function load(): KPI[] {
51
+ ensureDir();
52
+ if (!existsSync(KPI_FILE)) return [];
53
+ try { return JSON.parse(readFileSync(KPI_FILE, "utf-8")); }
54
+ catch { return []; }
55
+ }
56
+
57
+ function save(kpis: KPI[]) {
58
+ ensureDir();
59
+ atomicWriteFileSync(KPI_FILE, JSON.stringify(kpis, null, 2));
60
+ }
61
+
62
+ function calculateTrend(history: KPIValue[]): { trend: "up" | "down" | "stable"; percent: number } {
63
+ if (history.length < 2) return { trend: "stable", percent: 0 };
64
+ const sorted = [...history].sort((a, b) => b.date.localeCompare(a.date));
65
+ const current = sorted[0].value;
66
+ const previous = sorted[1].value;
67
+ if (previous === 0) return { trend: current > 0 ? "up" : "stable", percent: 0 };
68
+ const percent = Math.round(((current - previous) / Math.abs(previous)) * 100 * 10) / 10;
69
+ return {
70
+ trend: percent > 1 ? "up" : percent < -1 ? "down" : "stable",
71
+ percent
72
+ };
73
+ }
74
+
75
+ function getStatus(kpi: KPI): "on-target" | "warning" | "critical" | "no-data" {
76
+ if (kpi.currentValue === undefined) return "no-data";
77
+ if (kpi.target === undefined) return "on-target";
78
+
79
+ const ratio = kpi.direction === "up"
80
+ ? kpi.currentValue / kpi.target
81
+ : kpi.target / kpi.currentValue;
82
+
83
+ if (kpi.criticalThreshold !== undefined && ratio < kpi.criticalThreshold / 100) return "critical";
84
+ if (kpi.warningThreshold !== undefined && ratio < kpi.warningThreshold / 100) return "warning";
85
+ return ratio >= 1 ? "on-target" : "warning";
86
+ }
87
+
88
+ export function defineKPI(name: string, unit: string, category: string, opts?: { description?: string; target?: number; direction?: "up" | "down"; warningThreshold?: number; criticalThreshold?: number }): KPI {
89
+ const kpis = load();
90
+ const kpi: KPI = {
91
+ id: randomUUID(),
92
+ name,
93
+ description: opts?.description,
94
+ unit,
95
+ category,
96
+ target: opts?.target,
97
+ warningThreshold: opts?.warningThreshold || 80,
98
+ criticalThreshold: opts?.criticalThreshold || 50,
99
+ direction: opts?.direction || "up",
100
+ history: [],
101
+ createdAt: new Date().toISOString(),
102
+ updatedAt: new Date().toISOString()
103
+ };
104
+ kpis.push(kpi);
105
+ save(kpis);
106
+ return kpi;
107
+ }
108
+
109
+ export function recordValue(kpiId: string, value: number, date?: string, note?: string): KPI | null {
110
+ const kpis = load();
111
+ const idx = kpis.findIndex(k => k.id === kpiId);
112
+ if (idx === -1) return null;
113
+
114
+ const entry: KPIValue = {
115
+ value,
116
+ date: date || new Date().toISOString().slice(0, 10),
117
+ note
118
+ };
119
+
120
+ kpis[idx].history.push(entry);
121
+ kpis[idx].history.sort((a, b) => b.date.localeCompare(a.date));
122
+
123
+ // Keep max 365 entries
124
+ if (kpis[idx].history.length > 365) kpis[idx].history = kpis[idx].history.slice(0, 365);
125
+
126
+ // Update current/previous
127
+ kpis[idx].previousValue = kpis[idx].currentValue;
128
+ kpis[idx].currentValue = value;
129
+
130
+ // Calculate trend
131
+ const { trend, percent } = calculateTrend(kpis[idx].history);
132
+ kpis[idx].trend = trend;
133
+ kpis[idx].trendPercent = percent;
134
+ kpis[idx].updatedAt = new Date().toISOString();
135
+
136
+ save(kpis);
137
+ return kpis[idx];
138
+ }
139
+
140
+ export function getKPI(id: string): KPI | null {
141
+ return load().find(k => k.id === id) || null;
142
+ }
143
+
144
+ export function listKPIs(category?: string): KPI[] {
145
+ let kpis = load();
146
+ if (category) kpis = kpis.filter(k => k.category === category);
147
+ return kpis;
148
+ }
149
+
150
+ export function updateKPI(id: string, patch: Partial<Pick<KPI, "name" | "description" | "unit" | "category" | "target" | "direction" | "warningThreshold" | "criticalThreshold">>): KPI | null {
151
+ const kpis = load();
152
+ const idx = kpis.findIndex(k => k.id === id);
153
+ if (idx === -1) return null;
154
+ Object.assign(kpis[idx], patch);
155
+ kpis[idx].updatedAt = new Date().toISOString();
156
+ save(kpis);
157
+ return kpis[idx];
158
+ }
159
+
160
+ export function deleteKPI(id: string): boolean {
161
+ const kpis = load();
162
+ const idx = kpis.findIndex(k => k.id === id);
163
+ if (idx === -1) return false;
164
+ kpis.splice(idx, 1);
165
+ save(kpis);
166
+ return true;
167
+ }
168
+
169
+ export function getDashboard(): KPIDashboard {
170
+ const kpis = load();
171
+ let onTarget = 0, warning = 0, critical = 0, noData = 0;
172
+
173
+ for (const kpi of kpis) {
174
+ const status = getStatus(kpi);
175
+ if (status === "on-target") onTarget++;
176
+ else if (status === "warning") warning++;
177
+ else if (status === "critical") critical++;
178
+ else noData++;
179
+ }
180
+
181
+ return {
182
+ kpis,
183
+ lastUpdated: new Date().toISOString(),
184
+ summary: { total: kpis.length, onTarget, warning, critical, noData }
185
+ };
186
+ }
187
+
188
+ export function getKPIHistory(id: string, period?: "week" | "month" | "quarter" | "year"): KPIValue[] {
189
+ const kpi = getKPI(id);
190
+ if (!kpi) return [];
191
+
192
+ if (!period) return kpi.history;
193
+
194
+ const now = new Date();
195
+ let cutoff: Date;
196
+ switch (period) {
197
+ case "week": cutoff = new Date(now.getTime() - 7 * 86400000); break;
198
+ case "month": cutoff = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); break;
199
+ case "quarter": cutoff = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); break;
200
+ case "year": cutoff = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); break;
201
+ }
202
+
203
+ return kpi.history.filter(v => v.date >= cutoff.toISOString().slice(0, 10));
204
+ }
205
+
206
+ export function listCategories(): string[] {
207
+ return [...new Set(load().map(k => k.category))].sort();
208
+ }