heyhank 0.1.0 → 0.3.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 (161) 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-DqjDAcIw.js} +3 -3
  6. package/dist/assets/AssistantPage-C50CQFSB.js +2 -0
  7. package/dist/assets/BusinessPage-AY70tf1k.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-Dt7LLuRr.js} +1 -1
  9. package/dist/assets/HelpPage-tlGx7fQF.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-B4XOuHXu.js} +1 -1
  11. package/dist/assets/JarvisHUD-BDvuRd0I.js +120 -0
  12. package/dist/assets/MediaPage-CofV9Rd-.js +1 -0
  13. package/dist/assets/MemoryPage-Cj7FeqmJ.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-B9kXAlH1.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-Cka-pRkP.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-BqhQgfYj.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-VveKc9uX.js} +2 -2
  18. package/dist/assets/RunsPage-DXVEk0AZ.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-DACcwfDF.js} +1 -1
  20. package/dist/assets/SettingsPage-jfuQh8Tu.js +51 -0
  21. package/dist/assets/SkillsMarketplace-DrigiApe.js +1 -0
  22. package/dist/assets/SocialMediaPage-DOh3IPe8.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DLhJWATT.js} +1 -1
  24. package/dist/assets/TelephonyPage-9C4C3_ot.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-ChX-8Wu7.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/index-C6Q5UQHD.js +229 -0
  28. package/dist/assets/index-ZxGXgiV3.css +32 -0
  29. package/dist/assets/sw-register-BBYuk-kw.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
  32. package/dist/index.html +2 -2
  33. package/dist/sw.js +1 -1
  34. package/dist/{workbox-d2a0910a.js → workbox-080c8b91.js} +1 -1
  35. package/package.json +6 -1
  36. package/server/agent-executor.ts +102 -2
  37. package/server/agent-store.ts +3 -3
  38. package/server/agent-types.ts +11 -0
  39. package/server/assistant-store.ts +232 -6
  40. package/server/auth-manager.ts +9 -0
  41. package/server/cache-headers.ts +1 -1
  42. package/server/calendar-service.ts +10 -0
  43. package/server/ceo/document-store.ts +129 -0
  44. package/server/ceo/finance-store.ts +343 -0
  45. package/server/ceo/kpi-store.ts +208 -0
  46. package/server/ceo/memory-import.ts +277 -0
  47. package/server/ceo/news-store.ts +208 -0
  48. package/server/ceo/template-store.ts +134 -0
  49. package/server/ceo/time-tracking-store.ts +227 -0
  50. package/server/claude-auth-monitor.ts +128 -0
  51. package/server/claude-code-worker.ts +86 -0
  52. package/server/claude-session-discovery.ts +74 -1
  53. package/server/cli-launcher.ts +32 -10
  54. package/server/codex-adapter.ts +2 -2
  55. package/server/codex-ws-proxy.cjs +1 -1
  56. package/server/container-manager.ts +4 -4
  57. package/server/content-intelligence/content-engine.ts +1112 -0
  58. package/server/content-intelligence/platform-knowledge.ts +870 -0
  59. package/server/cron-store.ts +3 -3
  60. package/server/embedding-service.ts +49 -0
  61. package/server/event-bus-types.ts +13 -0
  62. package/server/execution-store.ts +54 -1
  63. package/server/federation/node-store.ts +5 -4
  64. package/server/fs-utils.ts +28 -1
  65. package/server/hank-notifications-store.ts +91 -0
  66. package/server/hank-tool-executor.ts +1835 -0
  67. package/server/hank-tools.ts +2107 -0
  68. package/server/image-pull-manager.ts +2 -2
  69. package/server/index.ts +25 -2
  70. package/server/llm-providers-streaming.ts +541 -0
  71. package/server/llm-providers.ts +12 -0
  72. package/server/marketplace.ts +249 -0
  73. package/server/mcp-registry.ts +158 -0
  74. package/server/memory-service.ts +296 -0
  75. package/server/obsidian-sync.ts +184 -0
  76. package/server/provider-manager.ts +5 -2
  77. package/server/provider-registry.ts +12 -0
  78. package/server/reminder-scheduler.ts +37 -1
  79. package/server/routes/agent-routes.ts +44 -1
  80. package/server/routes/assistant-routes.ts +198 -5
  81. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  82. package/server/routes/ceo-news-time-routes.ts +137 -0
  83. package/server/routes/ceo-routes.ts +99 -0
  84. package/server/routes/content-routes.ts +116 -0
  85. package/server/routes/email-routes.ts +147 -0
  86. package/server/routes/env-routes.ts +3 -3
  87. package/server/routes/fs-routes.ts +12 -9
  88. package/server/routes/hank-chat-routes.ts +592 -0
  89. package/server/routes/llm-routes.ts +12 -0
  90. package/server/routes/marketplace-routes.ts +63 -0
  91. package/server/routes/media-routes.ts +1 -1
  92. package/server/routes/memory-routes.ts +127 -0
  93. package/server/routes/platform-routes.ts +14 -675
  94. package/server/routes/sandbox-routes.ts +1 -1
  95. package/server/routes/settings-routes.ts +51 -1
  96. package/server/routes/socialmedia-routes.ts +152 -2
  97. package/server/routes/system-routes.ts +2 -2
  98. package/server/routes/team-routes.ts +71 -0
  99. package/server/routes/telephony-routes.ts +98 -18
  100. package/server/routes.ts +36 -9
  101. package/server/session-creation-service.ts +2 -2
  102. package/server/session-orchestrator.ts +54 -2
  103. package/server/session-types.ts +2 -0
  104. package/server/settings-manager.ts +50 -2
  105. package/server/skill-discovery.ts +68 -0
  106. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  107. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  108. package/server/socialmedia/manager.ts +234 -15
  109. package/server/socialmedia/store.ts +51 -1
  110. package/server/socialmedia/types.ts +35 -2
  111. package/server/socialview/browser-manager.ts +150 -0
  112. package/server/socialview/extractors.ts +1298 -0
  113. package/server/socialview/image-describe.ts +188 -0
  114. package/server/socialview/library.ts +119 -0
  115. package/server/socialview/poster.ts +276 -0
  116. package/server/socialview/routes.ts +371 -0
  117. package/server/socialview/style-analyzer.ts +187 -0
  118. package/server/socialview/style-profiles.ts +67 -0
  119. package/server/socialview/types.ts +166 -0
  120. package/server/socialview/vision.ts +127 -0
  121. package/server/socialview/vnc-manager.ts +110 -0
  122. package/server/style-injector.ts +135 -0
  123. package/server/team-service.ts +239 -0
  124. package/server/team-store.ts +75 -0
  125. package/server/team-types.ts +52 -0
  126. package/server/telephony/audio-bridge.ts +281 -35
  127. package/server/telephony/audio-recorder.ts +132 -0
  128. package/server/telephony/call-manager.ts +803 -104
  129. package/server/telephony/call-types.ts +67 -1
  130. package/server/telephony/esl-client.ts +319 -0
  131. package/server/telephony/freeswitch-sync.ts +155 -0
  132. package/server/telephony/phone-utils.ts +63 -0
  133. package/server/telephony/telephony-store.ts +9 -8
  134. package/server/url-validator.ts +82 -0
  135. package/server/vault-markdown.ts +317 -0
  136. package/server/vault-migration.ts +121 -0
  137. package/server/vault-store.ts +466 -0
  138. package/server/vault-watcher.ts +59 -0
  139. package/server/vector-store.ts +210 -0
  140. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  141. package/server/voice-pipeline/greeting-cache.ts +200 -0
  142. package/server/voice-pipeline/manager.ts +249 -0
  143. package/server/voice-pipeline/pipeline.ts +335 -0
  144. package/server/voice-pipeline/providers/index.ts +47 -0
  145. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  146. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  147. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  148. package/server/voice-pipeline/types.ts +247 -0
  149. package/server/ws-bridge-types.ts +6 -1
  150. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  151. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  152. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  153. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  154. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  155. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  156. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  157. package/dist/assets/index-C8M_PUmX.css +0 -32
  158. package/dist/assets/index-CEqZnThB.js +0 -204
  159. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  160. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +0 -2
  161. package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
@@ -18,11 +18,27 @@ const CLI_CONNECT_TIMEOUT_MS = 30_000;
18
18
  /** Poll interval when waiting for CLI connection */
19
19
  const CLI_CONNECT_POLL_MS = 500;
20
20
 
21
+ /** Skill-aware preamble for Claude agents (see skillRoutingEnabled below). */
22
+ const SKILL_ROUTING_PREAMBLE = [
23
+ "[Skill-aware mode]",
24
+ "You have access to user-installed Claude Code skills via the Skill tool. Skills are structured multi-stage workflows for specific tasks (content plans, post writing, audits, code review, etc.) — their descriptions are listed in your system context.",
25
+ "",
26
+ "BEFORE following the specialized instructions below, check whether the user's request matches a skill's description. If yes, invoke that skill via the Skill tool instead of executing custom logic.",
27
+ "",
28
+ "The specialized instructions below apply when no skill is a better fit.",
29
+ "",
30
+ "---",
31
+ "",
32
+ "",
33
+ ].join("\n");
34
+
35
+
21
36
  export interface ExecuteAgentOptions {
22
37
  force?: boolean;
23
38
  triggerType?: "manual" | "webhook" | "schedule";
24
39
  additionalEnv?: Record<string, string>;
25
40
  systemPrompt?: string;
41
+ cwdOverride?: string;
26
42
  }
27
43
 
28
44
  export class AgentExecutor {
@@ -155,7 +171,7 @@ export class AgentExecutor {
155
171
  }
156
172
 
157
173
  // Resolve working directory
158
- let cwd = agent.cwd;
174
+ let cwd = opts?.cwdOverride || agent.cwd;
159
175
  if (cwd === "temp" || !cwd) {
160
176
  cwd = mkdtempSync(join(tmpdir(), `heyhank-agent-${agent.id}-`));
161
177
  }
@@ -170,12 +186,24 @@ export class AgentExecutor {
170
186
  `but agent sessions always run with bypassPermissions`,
171
187
  );
172
188
  }
189
+ // Skill-aware routing: when enabled (default for Claude backends),
190
+ // ensure the `Skill` tool is in allowedTools so the agent can invoke
191
+ // any user-installed skill from ~/.claude/skills/ when its description
192
+ // matches the user's request.
193
+ const skillRoutingEnabled = agent.backendType === "claude" && agent.skillRouting !== false;
194
+ let effectiveAllowedTools = agent.allowedTools;
195
+ if (skillRoutingEnabled && Array.isArray(agent.allowedTools) && agent.allowedTools.length > 0) {
196
+ if (!agent.allowedTools.includes("Skill")) {
197
+ effectiveAllowedTools = [...agent.allowedTools, "Skill"];
198
+ }
199
+ }
200
+
173
201
  const sessionInfo = this.launcher.launch({
174
202
  model: agent.model,
175
203
  permissionMode: "bypassPermissions",
176
204
  cwd,
177
205
  env: envVars,
178
- allowedTools: agent.allowedTools,
206
+ allowedTools: effectiveAllowedTools,
179
207
  backendType: agent.backendType,
180
208
  codexInternetAccess: agent.backendType === "codex" ? (agent.codexInternetAccess ?? true) : undefined,
181
209
  codexSandbox: agent.backendType === "codex"
@@ -220,6 +248,13 @@ export class AgentExecutor {
220
248
  resolvedPrompt = resolvedPrompt.replace(/\{\{input\}\}/g, "");
221
249
  }
222
250
 
251
+ // Skill-aware preamble: routes attention to matching skills before
252
+ // following specialized instructions. Skill descriptions are already
253
+ // in the CLI system context; this just teaches the agent to consult them.
254
+ if (skillRoutingEnabled) {
255
+ resolvedPrompt = SKILL_ROUTING_PREAMBLE + resolvedPrompt;
256
+ }
257
+
223
258
  // Send the prompt with agent prefix for traceability
224
259
  const fullPrompt = `[agent:${agent.id} ${agent.name}]\n\n${resolvedPrompt}`;
225
260
  this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
@@ -316,6 +351,71 @@ export class AgentExecutor {
316
351
  return this.executionStore.list(opts);
317
352
  }
318
353
 
354
+ /**
355
+ * Cancel a running execution. Marks the in-memory + persisted record as
356
+ * completed (success=false). Returns true if a matching in-memory execution
357
+ * was found and updated, false if only the on-disk record was touched (e.g.
358
+ * for zombie sessions whose CLI process is long gone).
359
+ */
360
+ cancelExecution(sessionId: string, reason = "Stopped by user"): boolean {
361
+ for (const [, execs] of this.executions) {
362
+ const exec = execs.find((e) => e.sessionId === sessionId && !e.completedAt);
363
+ if (exec) {
364
+ exec.completedAt = Date.now();
365
+ exec.success = false;
366
+ exec.error = exec.error || reason;
367
+ this.executionStore.update(sessionId, {
368
+ completedAt: exec.completedAt,
369
+ success: exec.success,
370
+ error: exec.error,
371
+ });
372
+ return true;
373
+ }
374
+ }
375
+ // Fallback: touch the on-disk record directly (zombie cleanup).
376
+ this.executionStore.update(sessionId, {
377
+ completedAt: Date.now(),
378
+ success: false,
379
+ error: reason,
380
+ });
381
+ return false;
382
+ }
383
+
384
+ /**
385
+ * Permanently delete execution records (single or bulk). Filters by status
386
+ * if provided, or by an explicit list of sessionIds. Returns the count of
387
+ * removed records. Refuses to delete records that are still running.
388
+ */
389
+ deleteExecutions(opts: { sessionIds?: string[]; status?: "success" | "error" }): number {
390
+ let targets: string[] = [];
391
+
392
+ if (opts.sessionIds && opts.sessionIds.length > 0) {
393
+ // Filter out running ones — caller must cancel first.
394
+ const all = this.executionStore.list({ limit: 10000 }).executions;
395
+ const byId = new Map(all.map((e) => [e.sessionId, e] as const));
396
+ for (const sid of opts.sessionIds) {
397
+ const exec = byId.get(sid);
398
+ if (!exec || exec.completedAt) targets.push(sid);
399
+ }
400
+ } else if (opts.status) {
401
+ const matching = this.executionStore.list({ status: opts.status, limit: 10000 }).executions;
402
+ targets = matching.map((e) => e.sessionId);
403
+ } else {
404
+ return 0;
405
+ }
406
+
407
+ if (targets.length === 0) return 0;
408
+
409
+ // Also drop them from each agent's in-memory list so getExecutions stays consistent.
410
+ const targetSet = new Set(targets);
411
+ for (const [agentId, list] of this.executions) {
412
+ const next = list.filter((e) => !targetSet.has(e.sessionId));
413
+ if (next.length !== list.length) this.executions.set(agentId, next);
414
+ }
415
+
416
+ return this.executionStore.deleteBySessionIds(targets);
417
+ }
418
+
319
419
  /** Handle session exit: mark the corresponding execution as completed. */
320
420
  handleSessionExited(sessionId: string, exitCode: number | null): void {
321
421
  for (const [, execs] of this.executions) {
@@ -2,12 +2,12 @@ import {
2
2
  mkdirSync,
3
3
  readdirSync,
4
4
  readFileSync,
5
- writeFileSync,
6
5
  unlinkSync,
7
6
  existsSync,
8
7
  } from "node:fs";
9
8
  import { join } from "node:path";
10
9
  import { HEYHANK_HOME } from "./paths.js";
10
+ import { atomicWriteFileSync } from "./fs-utils.js";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import type { AgentConfig, AgentConfigCreateInput } from "./agent-types.js";
13
13
 
@@ -115,7 +115,7 @@ export function createAgent(data: AgentConfigCreateInput): AgentConfig {
115
115
  totalRuns: 0,
116
116
  consecutiveFailures: 0,
117
117
  };
118
- writeFileSync(filePath(id), JSON.stringify(agent, null, 2), "utf-8");
118
+ atomicWriteFileSync(filePath(id), JSON.stringify(agent, null, 2));
119
119
  return agent;
120
120
  }
121
121
 
@@ -155,7 +155,7 @@ export function updateAgent(
155
155
  }
156
156
  }
157
157
 
158
- writeFileSync(filePath(newId), JSON.stringify(agent, null, 2), "utf-8");
158
+ atomicWriteFileSync(filePath(newId), JSON.stringify(agent, null, 2));
159
159
  return agent;
160
160
  }
161
161
 
@@ -49,6 +49,17 @@ export interface AgentConfig {
49
49
  // ── Skills ──
50
50
  /** Skill slugs to attach (from ~/.claude/skills/) */
51
51
  skills?: string[];
52
+ /**
53
+ * Skill-aware routing. When true (default), the agent is prepended with a
54
+ * generic skill-discovery preamble and the `Skill` tool is added to its
55
+ * allowedTools, so it can invoke any user-installed skill from
56
+ * ~/.claude/skills/ when a skill description matches the user's request.
57
+ *
58
+ * Only applies to Claude backends (Codex/Ollama/etc. don't support skills).
59
+ * Set to false for agents that must follow strict, scripted instructions
60
+ * without skill detours (e.g. low-level coordinator agents).
61
+ */
62
+ skillRouting?: boolean;
52
63
 
53
64
  // ── Docker ──
54
65
  /** Optional Docker container configuration */
@@ -1,10 +1,18 @@
1
1
  // ─── Assistant Store ──────────────────────────────────────────────────────────
2
2
  // Persistent storage for personal assistant features: todos, notes, reminders.
3
3
  // All data stored as JSON in ~/.heyhank/assistant/
4
+ // When an Obsidian vault is configured, delegates to vault-store.ts instead.
4
5
 
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { HEYHANK_HOME } from "./paths.js";
9
+ import { atomicWriteFileSync } from "./fs-utils.js";
10
+ import { getSettings } from "./settings-manager.js";
11
+ import * as vaultStore from "./vault-store.js";
12
+
13
+ function useVault(): boolean {
14
+ return !!getSettings().obsidianVaultPath;
15
+ }
8
16
 
9
17
  const ASSISTANT_DIR = join(HEYHANK_HOME, "assistant");
10
18
 
@@ -27,7 +35,7 @@ function readJson<T>(filename: string, fallback: T): T {
27
35
 
28
36
  function writeJson(filename: string, data: unknown): void {
29
37
  ensureDir();
30
- writeFileSync(join(ASSISTANT_DIR, filename), JSON.stringify(data, null, 2), "utf-8");
38
+ atomicWriteFileSync(join(ASSISTANT_DIR, filename), JSON.stringify(data, null, 2));
31
39
  }
32
40
 
33
41
  // ─── Todos ────────────────────────────────────────────────────────────────────
@@ -40,6 +48,10 @@ export interface Todo {
40
48
  createdAt: string;
41
49
  doneAt?: string;
42
50
  category?: string;
51
+ delegatedTo?: string;
52
+ dueDate?: string;
53
+ followUpDate?: string;
54
+ project?: string;
43
55
  }
44
56
 
45
57
  function genId(): string {
@@ -47,6 +59,7 @@ function genId(): string {
47
59
  }
48
60
 
49
61
  export function listTodos(filter?: { done?: boolean; priority?: string; category?: string }): Todo[] {
62
+ if (useVault()) return vaultStore.listTodos(filter);
50
63
  const todos = readJson<Todo[]>("todos.json", []);
51
64
  return todos.filter((t) => {
52
65
  if (filter?.done !== undefined && t.done !== filter.done) return false;
@@ -56,7 +69,8 @@ export function listTodos(filter?: { done?: boolean; priority?: string; category
56
69
  });
57
70
  }
58
71
 
59
- export function addTodo(text: string, priority: string = "medium", category?: string): Todo {
72
+ export function addTodo(text: string, priority: string = "medium", category?: string, extra?: { delegatedTo?: string; dueDate?: string; followUpDate?: string; project?: string }): Todo {
73
+ if (useVault()) return vaultStore.addTodo(text, priority, category, extra);
60
74
  const todos = readJson<Todo[]>("todos.json", []);
61
75
  const todo: Todo = {
62
76
  id: genId(),
@@ -65,6 +79,7 @@ export function addTodo(text: string, priority: string = "medium", category?: st
65
79
  done: false,
66
80
  createdAt: new Date().toISOString(),
67
81
  category,
82
+ ...extra,
68
83
  };
69
84
  todos.push(todo);
70
85
  writeJson("todos.json", todos);
@@ -72,6 +87,7 @@ export function addTodo(text: string, priority: string = "medium", category?: st
72
87
  }
73
88
 
74
89
  export function completeTodo(id: string): Todo | null {
90
+ if (useVault()) return vaultStore.completeTodo(id);
75
91
  const todos = readJson<Todo[]>("todos.json", []);
76
92
  const todo = todos.find((t) => t.id === id);
77
93
  if (!todo) return null;
@@ -82,6 +98,7 @@ export function completeTodo(id: string): Todo | null {
82
98
  }
83
99
 
84
100
  export function deleteTodo(id: string): boolean {
101
+ if (useVault()) return vaultStore.deleteTodo(id);
85
102
  const todos = readJson<Todo[]>("todos.json", []);
86
103
  const idx = todos.findIndex((t) => t.id === id);
87
104
  if (idx === -1) return false;
@@ -90,7 +107,8 @@ export function deleteTodo(id: string): boolean {
90
107
  return true;
91
108
  }
92
109
 
93
- export function updateTodo(id: string, patch: { text?: string; priority?: string; category?: string }): Todo | null {
110
+ export function updateTodo(id: string, patch: { text?: string; priority?: string; category?: string; delegatedTo?: string; dueDate?: string; followUpDate?: string; project?: string }): Todo | null {
111
+ if (useVault()) return vaultStore.updateTodo(id, patch);
94
112
  const todos = readJson<Todo[]>("todos.json", []);
95
113
  const todo = todos.find((t) => t.id === id);
96
114
  if (!todo) return null;
@@ -99,10 +117,24 @@ export function updateTodo(id: string, patch: { text?: string; priority?: string
99
117
  todo.priority = patch.priority as Todo["priority"];
100
118
  }
101
119
  if (patch.category !== undefined) todo.category = patch.category;
120
+ if (patch.delegatedTo !== undefined) todo.delegatedTo = patch.delegatedTo;
121
+ if (patch.dueDate !== undefined) todo.dueDate = patch.dueDate;
122
+ if (patch.followUpDate !== undefined) todo.followUpDate = patch.followUpDate;
123
+ if (patch.project !== undefined) todo.project = patch.project;
102
124
  writeJson("todos.json", todos);
103
125
  return todo;
104
126
  }
105
127
 
128
+ export function listDelegations(person?: string): Todo[] {
129
+ const todos = listTodos();
130
+ const delegated = todos.filter((t) => t.delegatedTo && (!person || t.delegatedTo.toLowerCase() === person.toLowerCase()));
131
+ return delegated.sort((a, b) => {
132
+ if (!a.dueDate) return 1;
133
+ if (!b.dueDate) return -1;
134
+ return a.dueDate.localeCompare(b.dueDate);
135
+ });
136
+ }
137
+
106
138
  // ─── Notes ────────────────────────────────────────────────────────────────────
107
139
 
108
140
  export interface Note {
@@ -115,6 +147,7 @@ export interface Note {
115
147
  }
116
148
 
117
149
  export function listNotes(search?: string): Note[] {
150
+ if (useVault()) return vaultStore.listNotes(search);
118
151
  const notes = readJson<Note[]>("notes.json", []);
119
152
  if (!search) return notes;
120
153
  const q = search.toLowerCase();
@@ -126,6 +159,7 @@ export function listNotes(search?: string): Note[] {
126
159
  }
127
160
 
128
161
  export function addNote(title: string, content: string, tags: string[] = []): Note {
162
+ if (useVault()) return vaultStore.addNote(title, content, tags);
129
163
  const notes = readJson<Note[]>("notes.json", []);
130
164
  const note: Note = {
131
165
  id: genId(),
@@ -141,11 +175,13 @@ export function addNote(title: string, content: string, tags: string[] = []): No
141
175
  }
142
176
 
143
177
  export function getNote(id: string): Note | null {
178
+ if (useVault()) return vaultStore.getNote(id);
144
179
  const notes = readJson<Note[]>("notes.json", []);
145
180
  return notes.find((n) => n.id === id) || null;
146
181
  }
147
182
 
148
183
  export function updateNote(id: string, patch: { title?: string; content?: string; tags?: string[] }): Note | null {
184
+ if (useVault()) return vaultStore.updateNote(id, patch);
149
185
  const notes = readJson<Note[]>("notes.json", []);
150
186
  const note = notes.find((n) => n.id === id);
151
187
  if (!note) return null;
@@ -158,6 +194,7 @@ export function updateNote(id: string, patch: { title?: string; content?: string
158
194
  }
159
195
 
160
196
  export function deleteNote(id: string): boolean {
197
+ if (useVault()) return vaultStore.deleteNote(id);
161
198
  const notes = readJson<Note[]>("notes.json", []);
162
199
  const idx = notes.findIndex((n) => n.id === id);
163
200
  if (idx === -1) return false;
@@ -174,14 +211,17 @@ export interface Reminder {
174
211
  triggerAt: string; // ISO datetime
175
212
  fired: boolean;
176
213
  createdAt: string;
214
+ calendarEventUid?: string;
177
215
  }
178
216
 
179
217
  export function listReminders(includeFired = false): Reminder[] {
218
+ if (useVault()) return vaultStore.listReminders(includeFired);
180
219
  const reminders = readJson<Reminder[]>("reminders.json", []);
181
220
  return includeFired ? reminders : reminders.filter((r) => !r.fired);
182
221
  }
183
222
 
184
- export function addReminder(text: string, triggerAt: string): Reminder {
223
+ export function addReminder(text: string, triggerAt: string, calendarEventUid?: string): Reminder {
224
+ if (useVault()) return vaultStore.addReminder(text, triggerAt, calendarEventUid);
185
225
  const reminders = readJson<Reminder[]>("reminders.json", []);
186
226
  const reminder: Reminder = {
187
227
  id: genId(),
@@ -189,13 +229,27 @@ export function addReminder(text: string, triggerAt: string): Reminder {
189
229
  triggerAt,
190
230
  fired: false,
191
231
  createdAt: new Date().toISOString(),
232
+ ...(calendarEventUid ? { calendarEventUid } : {}),
192
233
  };
193
234
  reminders.push(reminder);
194
235
  writeJson("reminders.json", reminders);
195
236
  return reminder;
196
237
  }
197
238
 
239
+ export function updateReminder(id: string, updates: { text?: string; triggerAt?: string; calendarEventUid?: string }): Reminder | null {
240
+ if (useVault()) return vaultStore.updateReminder(id, updates);
241
+ const reminders = readJson<Reminder[]>("reminders.json", []);
242
+ const r = reminders.find((rem) => rem.id === id);
243
+ if (!r) return null;
244
+ if (updates.text !== undefined) r.text = updates.text;
245
+ if (updates.triggerAt !== undefined) r.triggerAt = updates.triggerAt;
246
+ if (updates.calendarEventUid !== undefined) r.calendarEventUid = updates.calendarEventUid;
247
+ writeJson("reminders.json", reminders);
248
+ return r;
249
+ }
250
+
198
251
  export function fireReminder(id: string): Reminder | null {
252
+ if (useVault()) return vaultStore.fireReminder(id);
199
253
  const reminders = readJson<Reminder[]>("reminders.json", []);
200
254
  const r = reminders.find((rem) => rem.id === id);
201
255
  if (!r) return null;
@@ -205,6 +259,7 @@ export function fireReminder(id: string): Reminder | null {
205
259
  }
206
260
 
207
261
  export function deleteReminder(id: string): boolean {
262
+ if (useVault()) return vaultStore.deleteReminder(id);
208
263
  const reminders = readJson<Reminder[]>("reminders.json", []);
209
264
  const idx = reminders.findIndex((r) => r.id === id);
210
265
  if (idx === -1) return false;
@@ -215,12 +270,179 @@ export function deleteReminder(id: string): boolean {
215
270
 
216
271
  /** Get all reminders that should have fired by now */
217
272
  export function getDueReminders(): Reminder[] {
273
+ if (useVault()) return vaultStore.getDueReminders();
218
274
  const now = new Date().toISOString();
219
275
  const reminders = readJson<Reminder[]>("reminders.json", []);
220
276
  return reminders.filter((r) => !r.fired && r.triggerAt <= now);
221
277
  }
222
278
 
223
- // ─── Gemini Conversations ──────��─────────────────────────────────────────────
279
+ // ─── Contacts/CRM ────────────────────────────────────────────────────────────
280
+
281
+ export interface ContactInteraction {
282
+ date: string;
283
+ type: "call" | "email" | "meeting" | "note";
284
+ summary: string;
285
+ }
286
+
287
+ export interface Contact {
288
+ id: string;
289
+ name: string;
290
+ company?: string;
291
+ email?: string;
292
+ phone?: string;
293
+ notes?: string;
294
+ tags: string[];
295
+ lastContactDate?: string;
296
+ interactions: ContactInteraction[];
297
+ createdAt: string;
298
+ updatedAt: string;
299
+ }
300
+
301
+ export function listContacts(search?: string): Contact[] {
302
+ if (useVault()) return vaultStore.listContacts(search);
303
+ const contacts = readJson<Contact[]>("contacts.json", []);
304
+ if (!search) return contacts;
305
+ const q = search.toLowerCase();
306
+ return contacts.filter((c) =>
307
+ c.name.toLowerCase().includes(q) ||
308
+ (c.company && c.company.toLowerCase().includes(q)) ||
309
+ (c.email && c.email.toLowerCase().includes(q)) ||
310
+ (c.phone && c.phone.includes(q)) ||
311
+ c.tags.some((t) => t.toLowerCase().includes(q))
312
+ );
313
+ }
314
+
315
+ export function addContact(name: string, company?: string, email?: string, phone?: string, notes?: string, tags: string[] = []): Contact {
316
+ if (useVault()) return vaultStore.addContact(name, company, email, phone, notes, tags);
317
+ const contacts = readJson<Contact[]>("contacts.json", []);
318
+ const contact: Contact = {
319
+ id: genId(),
320
+ name,
321
+ company,
322
+ email,
323
+ phone,
324
+ notes,
325
+ tags,
326
+ interactions: [],
327
+ createdAt: new Date().toISOString(),
328
+ updatedAt: new Date().toISOString(),
329
+ };
330
+ contacts.push(contact);
331
+ writeJson("contacts.json", contacts);
332
+ return contact;
333
+ }
334
+
335
+ export function getContact(id: string): Contact | null {
336
+ if (useVault()) return vaultStore.getContact(id);
337
+ const contacts = readJson<Contact[]>("contacts.json", []);
338
+ return contacts.find((c) => c.id === id) || null;
339
+ }
340
+
341
+ export function updateContact(id: string, patch: { name?: string; company?: string; email?: string; phone?: string; notes?: string; tags?: string[] }): Contact | null {
342
+ if (useVault()) return vaultStore.updateContact(id, patch);
343
+ const contacts = readJson<Contact[]>("contacts.json", []);
344
+ const contact = contacts.find((c) => c.id === id);
345
+ if (!contact) return null;
346
+ if (patch.name) contact.name = patch.name;
347
+ if (patch.company !== undefined) contact.company = patch.company;
348
+ if (patch.email !== undefined) contact.email = patch.email;
349
+ if (patch.phone !== undefined) contact.phone = patch.phone;
350
+ if (patch.notes !== undefined) contact.notes = patch.notes;
351
+ if (patch.tags) contact.tags = patch.tags;
352
+ contact.updatedAt = new Date().toISOString();
353
+ writeJson("contacts.json", contacts);
354
+ return contact;
355
+ }
356
+
357
+ export function deleteContact(id: string): boolean {
358
+ if (useVault()) return vaultStore.deleteContact(id);
359
+ const contacts = readJson<Contact[]>("contacts.json", []);
360
+ const idx = contacts.findIndex((c) => c.id === id);
361
+ if (idx === -1) return false;
362
+ contacts.splice(idx, 1);
363
+ writeJson("contacts.json", contacts);
364
+ return true;
365
+ }
366
+
367
+ export function logInteraction(contactId: string, interaction: Omit<ContactInteraction, "date">): Contact | null {
368
+ if (useVault()) return vaultStore.logInteraction(contactId, interaction);
369
+ const contacts = readJson<Contact[]>("contacts.json", []);
370
+ const contact = contacts.find((c) => c.id === contactId);
371
+ if (!contact) return null;
372
+ const entry: ContactInteraction = {
373
+ date: new Date().toISOString(),
374
+ type: interaction.type,
375
+ summary: interaction.summary,
376
+ };
377
+ contact.interactions.push(entry);
378
+ contact.lastContactDate = entry.date;
379
+ contact.updatedAt = new Date().toISOString();
380
+ writeJson("contacts.json", contacts);
381
+ return contact;
382
+ }
383
+
384
+ // ─── Decisions ───────────────────────────────────────────────────────────────
385
+
386
+ export interface Decision {
387
+ id: string;
388
+ title: string;
389
+ context: string;
390
+ decision: string;
391
+ alternatives: string[];
392
+ reasoning: string;
393
+ tags: string[];
394
+ createdAt: string;
395
+ }
396
+
397
+ export function listDecisions(search?: string): Decision[] {
398
+ if (useVault()) return vaultStore.listDecisions(search);
399
+ const decisions = readJson<Decision[]>("decisions.json", []);
400
+ if (!search) return decisions;
401
+ const q = search.toLowerCase();
402
+ return decisions.filter((d) =>
403
+ d.title.toLowerCase().includes(q) ||
404
+ d.context.toLowerCase().includes(q) ||
405
+ d.decision.toLowerCase().includes(q) ||
406
+ d.reasoning.toLowerCase().includes(q) ||
407
+ d.tags.some((t) => t.toLowerCase().includes(q))
408
+ );
409
+ }
410
+
411
+ export function addDecision(title: string, context: string, decision: string, alternatives: string[] = [], reasoning: string = "", tags: string[] = []): Decision {
412
+ if (useVault()) return vaultStore.addDecision(title, context, decision, alternatives, reasoning, tags);
413
+ const decisions = readJson<Decision[]>("decisions.json", []);
414
+ const entry: Decision = {
415
+ id: genId(),
416
+ title,
417
+ context,
418
+ decision,
419
+ alternatives,
420
+ reasoning,
421
+ tags,
422
+ createdAt: new Date().toISOString(),
423
+ };
424
+ decisions.push(entry);
425
+ writeJson("decisions.json", decisions);
426
+ return entry;
427
+ }
428
+
429
+ export function getDecision(id: string): Decision | null {
430
+ if (useVault()) return vaultStore.getDecision(id);
431
+ const decisions = readJson<Decision[]>("decisions.json", []);
432
+ return decisions.find((d) => d.id === id) || null;
433
+ }
434
+
435
+ export function deleteDecision(id: string): boolean {
436
+ if (useVault()) return vaultStore.deleteDecision(id);
437
+ const decisions = readJson<Decision[]>("decisions.json", []);
438
+ const idx = decisions.findIndex((d) => d.id === id);
439
+ if (idx === -1) return false;
440
+ decisions.splice(idx, 1);
441
+ writeJson("decisions.json", decisions);
442
+ return true;
443
+ }
444
+
445
+ // ─── Gemini Conversations ────────────────────────────────────────────────────
224
446
 
225
447
  export interface GeminiConversation {
226
448
  id: string;
@@ -231,6 +453,7 @@ export interface GeminiConversation {
231
453
  }
232
454
 
233
455
  export function listGeminiConversations(): GeminiConversation[] {
456
+ if (useVault()) return vaultStore.listGeminiConversations();
234
457
  return readJson<GeminiConversation[]>("gemini-conversations.json", [])
235
458
  .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
236
459
  }
@@ -239,6 +462,7 @@ export function saveGeminiConversation(
239
462
  messages: Array<{ role: "user" | "gemini" | "system"; text: string; ts: number }>,
240
463
  duration?: number,
241
464
  ): GeminiConversation {
465
+ if (useVault()) return vaultStore.saveGeminiConversation(messages, duration);
242
466
  const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
243
467
  // Generate title from first user message
244
468
  const firstUser = messages.find((m) => m.role === "user");
@@ -258,11 +482,13 @@ export function saveGeminiConversation(
258
482
  }
259
483
 
260
484
  export function getGeminiConversation(id: string): GeminiConversation | null {
485
+ if (useVault()) return vaultStore.getGeminiConversation(id);
261
486
  const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
262
487
  return convos.find((c) => c.id === id) || null;
263
488
  }
264
489
 
265
490
  export function deleteGeminiConversation(id: string): boolean {
491
+ if (useVault()) return vaultStore.deleteGeminiConversation(id);
266
492
  const convos = readJson<GeminiConversation[]>("gemini-conversations.json", []);
267
493
  const idx = convos.findIndex((c) => c.id === id);
268
494
  if (idx === -1) return false;
@@ -144,6 +144,15 @@ export function regenerateToken(): string {
144
144
  return token;
145
145
  }
146
146
 
147
+ /**
148
+ * Check whether a token file already exists on disk (i.e. not a fresh first-run).
149
+ * Used to decide whether to print the token to console on startup.
150
+ */
151
+ export function isTokenPersisted(): boolean {
152
+ if (process.env.HEYHANK_AUTH_TOKEN || process.env.COMPANION_AUTH_TOKEN) return true;
153
+ return existsSync(AUTH_FILE);
154
+ }
155
+
147
156
  /** Reset cached state — for testing only */
148
157
  export function _resetForTest(): void {
149
158
  cachedToken = null;
@@ -31,7 +31,7 @@ export function cacheControlMiddleware(): MiddlewareHandler {
31
31
 
32
32
  // index.html (served for / and /index.html): must be fresh
33
33
  if (path === "/" || path === "/index.html") {
34
- c.header("Cache-Control", "no-cache");
34
+ c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
35
35
  return;
36
36
  }
37
37
 
@@ -291,6 +291,7 @@ export async function createEvent(
291
291
  end: string; // ISO datetime or YYYY-MM-DD for all-day
292
292
  allDay?: boolean;
293
293
  calendarUrl?: string;
294
+ alarm?: number; // minutes before event to trigger alarm (0 = at event time)
294
295
  },
295
296
  ): Promise<{ success: boolean; uid: string }> {
296
297
  const client = await createClient(account);
@@ -330,6 +331,15 @@ export async function createEvent(
330
331
  if (event.description) lines.push(`DESCRIPTION:${escapeICS(event.description)}`);
331
332
  if (event.location) lines.push(`LOCATION:${escapeICS(event.location)}`);
332
333
  lines.push(`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}`);
334
+ if (event.alarm !== undefined) {
335
+ lines.push(
336
+ "BEGIN:VALARM",
337
+ "ACTION:DISPLAY",
338
+ `DESCRIPTION:${escapeICS(event.summary)}`,
339
+ `TRIGGER:${event.alarm === 0 ? "PT0S" : `-PT${event.alarm}M`}`,
340
+ "END:VALARM",
341
+ );
342
+ }
333
343
  lines.push("END:VEVENT", "END:VCALENDAR");
334
344
 
335
345
  const icsData = lines.join("\r\n");