vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,189 @@
1
+ import { mkdir, stat, readFile, writeFile, chmod } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { X509Certificate, createPrivateKey } from 'node:crypto';
4
+ import { getRootDir } from '../util/platform.js';
5
+ import { getLogger } from '../util/logger.js';
6
+
7
+ const log = getLogger('tls-certs');
8
+
9
+ const TLS_DIR = 'tls';
10
+ const CERT_FILENAME = 'cert.pem';
11
+ const KEY_FILENAME = 'key.pem';
12
+ const FINGERPRINT_FILENAME = 'fingerprint';
13
+
14
+ /** Returns the TLS directory path (~/.vellum/tls/). */
15
+ export function getTlsDir(): string {
16
+ return join(getRootDir(), TLS_DIR);
17
+ }
18
+
19
+ /** Returns the path to the TLS certificate. */
20
+ export function getTlsCertPath(): string {
21
+ return join(getTlsDir(), CERT_FILENAME);
22
+ }
23
+
24
+ /** Returns the path to the TLS private key. */
25
+ export function getTlsKeyPath(): string {
26
+ return join(getTlsDir(), KEY_FILENAME);
27
+ }
28
+
29
+ /** Returns the path to the certificate fingerprint file. */
30
+ export function getTlsFingerprintPath(): string {
31
+ return join(getTlsDir(), FINGERPRINT_FILENAME);
32
+ }
33
+
34
+ /**
35
+ * Compute the SHA-256 fingerprint of a DER-encoded certificate.
36
+ * Returns hex lowercase, no colons.
37
+ */
38
+ function computeFingerprint(cert: X509Certificate): string {
39
+ // X509Certificate.fingerprint256 returns colon-separated uppercase hex.
40
+ // Normalize to lowercase without colons for compact storage and comparison.
41
+ return cert.fingerprint256.replace(/:/g, '').toLowerCase();
42
+ }
43
+
44
+ /**
45
+ * Check whether an existing cert+key pair is valid:
46
+ * - All three files exist (cert, key, fingerprint)
47
+ * - Cert is parseable as X509
48
+ * - Cert is not expired
49
+ * - Fingerprint file exists and matches the cert
50
+ * - Private key is valid and matches the certificate
51
+ */
52
+ async function isExistingCertValid(): Promise<boolean> {
53
+ const certPath = getTlsCertPath();
54
+ const keyPath = getTlsKeyPath();
55
+ const fpPath = getTlsFingerprintPath();
56
+
57
+ // Check all three files exist
58
+ const [certExists, keyExists, fpExists] = await Promise.all([
59
+ stat(certPath).then(() => true, () => false),
60
+ stat(keyPath).then(() => true, () => false),
61
+ stat(fpPath).then(() => true, () => false),
62
+ ]);
63
+
64
+ if (!certExists || !keyExists || !fpExists) {
65
+ return false;
66
+ }
67
+
68
+ try {
69
+ const [certPem, keyPem, storedFp] = await Promise.all([
70
+ readFile(certPath, 'utf-8'),
71
+ readFile(keyPath, 'utf-8'),
72
+ readFile(fpPath, 'utf-8'),
73
+ ]);
74
+
75
+ const x509 = new X509Certificate(certPem);
76
+
77
+ // Check expiration
78
+ const notAfter = new Date(x509.validTo);
79
+ if (notAfter <= new Date()) {
80
+ log.info('Existing TLS certificate has expired, will regenerate');
81
+ return false;
82
+ }
83
+
84
+ // Check fingerprint matches
85
+ const actualFp = computeFingerprint(x509);
86
+ if (actualFp !== storedFp.trim()) {
87
+ log.info('TLS fingerprint mismatch, will regenerate');
88
+ return false;
89
+ }
90
+
91
+ // Verify the private key is valid and matches the certificate's public key.
92
+ // This catches corrupted key files or cert/key mismatches that would cause
93
+ // tls.createServer() to fail at runtime.
94
+ const privateKey = createPrivateKey(keyPem);
95
+ if (!x509.checkPrivateKey(privateKey)) {
96
+ log.info('TLS private key does not match certificate, will regenerate');
97
+ return false;
98
+ }
99
+
100
+ return true;
101
+ } catch (err) {
102
+ log.warn({ err }, 'Failed to validate existing TLS certificate, will regenerate');
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Ensure a self-signed TLS certificate exists for the daemon.
109
+ *
110
+ * Stores files in `~/.vellum/tls/`:
111
+ * - `cert.pem` (0o644) — self-signed certificate
112
+ * - `key.pem` (0o600) — private key
113
+ * - `fingerprint` (0o644) — SHA-256 hex fingerprint (lowercase, no colons)
114
+ *
115
+ * Idempotent: skips generation if a valid cert already exists.
116
+ * Auto-regenerates if the cert is expired, fingerprint is missing/mismatched,
117
+ * or key/cert files are corrupt.
118
+ *
119
+ * Returns the cert, key (PEM strings), and fingerprint.
120
+ */
121
+ export async function ensureTlsCert(): Promise<{ cert: string; key: string; fingerprint: string }> {
122
+ const tlsDir = getTlsDir();
123
+ const certPath = getTlsCertPath();
124
+ const keyPath = getTlsKeyPath();
125
+ const fpPath = getTlsFingerprintPath();
126
+
127
+ // Check if existing cert is still valid
128
+ if (await isExistingCertValid()) {
129
+ const [cert, key, fingerprint] = await Promise.all([
130
+ readFile(certPath, 'utf-8'),
131
+ readFile(keyPath, 'utf-8'),
132
+ readFile(fpPath, 'utf-8'),
133
+ ]);
134
+ log.info('Using existing TLS certificate');
135
+ return { cert, key, fingerprint: fingerprint.trim() };
136
+ }
137
+
138
+ // Generate new cert
139
+ log.info('Generating new self-signed TLS certificate');
140
+ await mkdir(tlsDir, { recursive: true });
141
+
142
+ // Generate RSA 2048 key
143
+ const keyProc = Bun.spawn(
144
+ ['openssl', 'genrsa', '-out', keyPath, '2048'],
145
+ { stdout: 'pipe', stderr: 'pipe' },
146
+ );
147
+ const keyExit = await keyProc.exited;
148
+ if (keyExit !== 0) {
149
+ const stderr = await new Response(keyProc.stderr).text();
150
+ throw new Error(`Failed to generate TLS key: ${stderr}`);
151
+ }
152
+
153
+ // Generate self-signed cert (10-year validity)
154
+ const certProc = Bun.spawn(
155
+ [
156
+ 'openssl', 'req', '-new', '-x509',
157
+ '-key', keyPath,
158
+ '-out', certPath,
159
+ '-days', '3650',
160
+ '-subj', '/CN=Vellum Daemon',
161
+ ],
162
+ { stdout: 'pipe', stderr: 'pipe' },
163
+ );
164
+ const certExit = await certProc.exited;
165
+ if (certExit !== 0) {
166
+ const stderr = await new Response(certProc.stderr).text();
167
+ throw new Error(`Failed to generate TLS certificate: ${stderr}`);
168
+ }
169
+
170
+ // Compute and write fingerprint
171
+ const certPem = await readFile(certPath, 'utf-8');
172
+ const x509 = new X509Certificate(certPem);
173
+ const fingerprint = computeFingerprint(x509);
174
+ await writeFile(fpPath, fingerprint);
175
+
176
+ // Set permissions: key is private, cert and fingerprint are readable
177
+ await Promise.all([
178
+ chmod(keyPath, 0o600),
179
+ chmod(certPath, 0o644),
180
+ chmod(fpPath, 0o644),
181
+ ]);
182
+
183
+ log.info({ fingerprint, certPath }, 'TLS certificate generated');
184
+ return {
185
+ cert: certPem,
186
+ key: await readFile(keyPath, 'utf-8'),
187
+ fingerprint,
188
+ };
189
+ }
@@ -35,11 +35,13 @@ export async function generateVideoThumbnail(dataBase64: string): Promise<string
35
35
  outputPath,
36
36
  ], { stderr: 'pipe' });
37
37
 
38
- // Race against a 10s timeout to avoid hanging on slow/stuck ffmpeg
38
+ // Race against a 10s timeout to avoid hanging on slow/stuck ffmpeg.
39
+ // The timer handle is cleared via finally() so it doesn't leak when ffmpeg exits normally.
40
+ let timer: ReturnType<typeof setTimeout>;
39
41
  const exitCode = await Promise.race([
40
- proc.exited,
42
+ proc.exited.finally(() => clearTimeout(timer)),
41
43
  new Promise<never>((_, reject) =>
42
- setTimeout(() => { proc.kill(); reject(new Error('ffmpeg timed out')); }, 10_000)
44
+ timer = setTimeout(() => { proc.kill(); reject(new Error('ffmpeg timed out')); }, 10_000)
43
45
  ),
44
46
  ]);
45
47
 
@@ -3,6 +3,7 @@ import { discoverHooks } from './discovery.js';
3
3
  import { runHookScript } from './runner.js';
4
4
  import { getLogger, isDebug } from '../util/logger.js';
5
5
  import { getHooksDir } from '../util/platform.js';
6
+ import { Debouncer } from '../util/debounce.js';
6
7
  import type { DiscoveredHook, HookEventName, HookEventData, HookTriggerResult } from './types.js';
7
8
 
8
9
  const log = getLogger('hooks-manager');
@@ -11,7 +12,7 @@ export class HookManager {
11
12
  private hooks: DiscoveredHook[] = [];
12
13
  private eventIndex = new Map<HookEventName, DiscoveredHook[]>();
13
14
  private watcher: FSWatcher | null = null;
14
- private debounceTimer: ReturnType<typeof setTimeout> | null = null;
15
+ private readonly debouncer = new Debouncer(500);
15
16
 
16
17
  initialize(): void {
17
18
  this.hooks = discoverHooks();
@@ -82,12 +83,10 @@ export class HookManager {
82
83
 
83
84
  try {
84
85
  this.watcher = watch(hooksDir, { recursive: true }, (_eventType, filename) => {
85
- if (this.debounceTimer) clearTimeout(this.debounceTimer);
86
- this.debounceTimer = setTimeout(() => {
87
- this.debounceTimer = null;
86
+ this.debouncer.schedule(() => {
88
87
  log.info({ filename: String(filename ?? '') }, 'Hooks directory changed, reloading');
89
88
  this.reload();
90
- }, 500);
89
+ });
91
90
  });
92
91
  log.info({ dir: hooksDir }, 'Watching hooks directory for changes');
93
92
  } catch (err) {
@@ -96,10 +95,7 @@ export class HookManager {
96
95
  }
97
96
 
98
97
  stopWatching(): void {
99
- if (this.debounceTimer) {
100
- clearTimeout(this.debounceTimer);
101
- this.debounceTimer = null;
102
- }
98
+ this.debouncer.cancel();
103
99
  if (this.watcher) {
104
100
  this.watcher.close();
105
101
  this.watcher = null;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Git-backed version control for user-defined apps.
3
+ *
4
+ * Initializes a git repository in the apps directory (~/.vellum/apps/) and
5
+ * commits after every app mutation (create, update, delete, file write/edit).
6
+ * Commits are fire-and-forget — they never block the caller.
7
+ *
8
+ * Also exposes query methods (history, diff, file-at-version, restore) for
9
+ * browsing and reverting app version history.
10
+ *
11
+ * Reuses WorkspaceGitService for all git operations (mutex, circuit breaker,
12
+ * lazy init, etc.).
13
+ */
14
+
15
+ import { getWorkspaceGitService } from '../workspace/git-service.js';
16
+ import { getAppsDir } from './app-store.js';
17
+ import { getLogger } from '../util/logger.js';
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const log = getLogger('app-git');
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface AppVersion {
28
+ commitHash: string;
29
+ message: string;
30
+ /** Unix milliseconds */
31
+ timestamp: number;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Gitignore management
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Patterns excluded from app version tracking.
40
+ * - *.preview — large base64 preview images
41
+ * - records directories — user data (form submissions), not app code
42
+ */
43
+ const APP_GITIGNORE_RULES = [
44
+ '*.preview',
45
+ '*/records/',
46
+ ];
47
+
48
+ /**
49
+ * Ensure the apps directory .gitignore contains app-specific exclusion rules.
50
+ * Idempotent: only appends rules that are missing.
51
+ */
52
+ function ensureAppGitignoreRules(appsDir: string): void {
53
+ const gitignorePath = join(appsDir, '.gitignore');
54
+ let content = '';
55
+ if (existsSync(gitignorePath)) {
56
+ content = readFileSync(gitignorePath, 'utf-8');
57
+ }
58
+
59
+ const missingRules = APP_GITIGNORE_RULES.filter(rule => !content.includes(rule));
60
+ if (missingRules.length > 0) {
61
+ if (content && !content.endsWith('\n')) {
62
+ content += '\n';
63
+ }
64
+ content += missingRules.join('\n') + '\n';
65
+ writeFileSync(gitignorePath, content, 'utf-8');
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Input validation
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Validate that a string looks like a hex commit hash (short or full). */
74
+ function validateCommitHash(hash: string): void {
75
+ if (!/^[0-9a-f]{4,40}$/i.test(hash)) {
76
+ throw new Error(`Invalid commit hash: ${hash}`);
77
+ }
78
+ }
79
+
80
+ /** Validate an app ID (UUID-like, no path traversal or git pathspec chars). */
81
+ function validateAppId(id: string): void {
82
+ if (!id || id.includes('/') || id.includes('\\') || id.includes('..') || id !== id.trim()) {
83
+ throw new Error(`Invalid app ID: ${id}`);
84
+ }
85
+ // Reject git pathspec metacharacters to prevent cross-app operations
86
+ if (/[*?[\]:(]/.test(id)) {
87
+ throw new Error(`Invalid app ID: contains git pathspec characters: ${id}`);
88
+ }
89
+ }
90
+
91
+ /** Validate a relative file path within an app (no traversal). */
92
+ function validateRelativePath(path: string): void {
93
+ if (!path || path.includes('..') || path.startsWith('/') || path.startsWith('\\')) {
94
+ throw new Error(`Invalid file path: ${path}`);
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Initialization & commit
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Eagerly initialize the app git repo so that the "Initial commit" is
104
+ * created before any app files are written. Without this, the first
105
+ * mutation's files get absorbed into WorkspaceGitService's bootstrap
106
+ * commit and the "Create app: ..." commit ends up empty.
107
+ *
108
+ * Safe to call multiple times — ensureInitialized() is idempotent.
109
+ * Fire-and-forget: errors are logged but never thrown.
110
+ */
111
+ export async function initAppGit(): Promise<void> {
112
+ try {
113
+ const appsDir = getAppsDir();
114
+ ensureAppGitignoreRules(appsDir);
115
+ const gitService = getWorkspaceGitService(appsDir);
116
+ await gitService.ensureInitialized();
117
+ } catch (err) {
118
+ log.error({ err }, 'Failed to initialize app git repo');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Commit app changes to the apps git repository.
124
+ *
125
+ * This is fire-and-forget: errors are logged but never thrown.
126
+ * The caller should not await the returned promise unless it needs
127
+ * to guarantee the commit completed (e.g. in tests).
128
+ */
129
+ export async function commitAppChange(message: string): Promise<void> {
130
+ try {
131
+ const appsDir = getAppsDir();
132
+
133
+ // Re-check .gitignore rules every call in case the apps dir was
134
+ // recreated while the process was running.
135
+ ensureAppGitignoreRules(appsDir);
136
+
137
+ const gitService = getWorkspaceGitService(appsDir);
138
+ await gitService.commitChanges(message);
139
+ } catch (err) {
140
+ log.error({ err, message }, 'Failed to commit app change');
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Query methods
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Get the commit history for a specific app.
150
+ *
151
+ * Scopes `git log` to files belonging to this app:
152
+ * {appId}.json, {appId}/index.html, {appId}/pages/*, etc.
153
+ */
154
+ export async function getAppHistory(appId: string, limit = 50): Promise<AppVersion[]> {
155
+ validateAppId(appId);
156
+ const safeLimit = Math.max(1, Math.min(Math.floor(limit) || 50, 500));
157
+ const appsDir = getAppsDir();
158
+ const gitService = getWorkspaceGitService(appsDir);
159
+
160
+ // Format: hash<TAB>unix-seconds<TAB>subject line
161
+ const { stdout } = await gitService.runReadOnlyGit([
162
+ 'log',
163
+ `--max-count=${safeLimit}`,
164
+ '--format=%H\t%at\t%s',
165
+ '--',
166
+ `${appId}.json`,
167
+ `${appId}/`,
168
+ ]);
169
+
170
+ if (!stdout.trim()) return [];
171
+
172
+ return stdout.trim().split('\n').map(line => {
173
+ const [commitHash, epochSec, ...messageParts] = line.split('\t');
174
+ return {
175
+ commitHash,
176
+ message: messageParts.join('\t'),
177
+ timestamp: parseInt(epochSec, 10) * 1000,
178
+ };
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Get a unified diff for a specific app between two commits.
184
+ * If `toCommit` is omitted, diffs against HEAD.
185
+ */
186
+ export async function getAppDiff(
187
+ appId: string,
188
+ fromCommit: string,
189
+ toCommit?: string,
190
+ ): Promise<string> {
191
+ validateAppId(appId);
192
+ validateCommitHash(fromCommit);
193
+ if (toCommit) validateCommitHash(toCommit);
194
+
195
+ const appsDir = getAppsDir();
196
+ const gitService = getWorkspaceGitService(appsDir);
197
+
198
+ const range = toCommit ? `${fromCommit}..${toCommit}` : `${fromCommit}..HEAD`;
199
+ const { stdout } = await gitService.runReadOnlyGit([
200
+ 'diff',
201
+ range,
202
+ '--',
203
+ `${appId}.json`,
204
+ `${appId}/`,
205
+ ]);
206
+
207
+ return stdout;
208
+ }
209
+
210
+ /**
211
+ * Get the contents of a file at a specific commit.
212
+ */
213
+ export async function getAppFileAtVersion(
214
+ appId: string,
215
+ path: string,
216
+ commitHash: string,
217
+ ): Promise<string> {
218
+ validateAppId(appId);
219
+ validateRelativePath(path);
220
+ validateCommitHash(commitHash);
221
+
222
+ const appsDir = getAppsDir();
223
+ const gitService = getWorkspaceGitService(appsDir);
224
+
225
+ const { stdout } = await gitService.runReadOnlyGit([
226
+ 'show',
227
+ `${commitHash}:${appId}/${path}`,
228
+ ]);
229
+
230
+ return stdout;
231
+ }
232
+
233
+ /**
234
+ * Restore an app's files to a previous version.
235
+ *
236
+ * Checks out the app's files at `commitHash`, then creates a new commit
237
+ * recording the restore action. Both operations run under the git mutex
238
+ * to prevent concurrent commits from interfering.
239
+ *
240
+ * Uses --no-overlay so files added after the target commit are removed,
241
+ * giving a true restore rather than a merge.
242
+ */
243
+ export async function restoreAppVersion(appId: string, commitHash: string): Promise<void> {
244
+ validateAppId(appId);
245
+ validateCommitHash(commitHash);
246
+
247
+ const appsDir = getAppsDir();
248
+ const gitService = getWorkspaceGitService(appsDir);
249
+
250
+ await gitService.runWithMutex(async (exec) => {
251
+ // Checkout the app's files at the target commit.
252
+ // --no-overlay removes files that don't exist at the target commit.
253
+ await exec([
254
+ 'checkout',
255
+ commitHash,
256
+ '--no-overlay',
257
+ '--',
258
+ `${appId}.json`,
259
+ `${appId}/`,
260
+ ]);
261
+
262
+ // Read the app name and refresh updatedAt so the restored app
263
+ // doesn't appear stale in recency ordering.
264
+ let appName = appId;
265
+ const jsonPath = join(appsDir, `${appId}.json`);
266
+ if (existsSync(jsonPath)) {
267
+ try {
268
+ const raw = readFileSync(jsonPath, 'utf-8');
269
+ const app = JSON.parse(raw);
270
+ if (app.name) appName = app.name;
271
+ app.updatedAt = Date.now();
272
+ writeFileSync(jsonPath, JSON.stringify(app, null, 2) + '\n', 'utf-8');
273
+ } catch {
274
+ // fall back to id
275
+ }
276
+ }
277
+
278
+ const shortHash = commitHash.substring(0, 7);
279
+
280
+ // Stage only this app's files and commit atomically within the same mutex lock
281
+ await exec(['add', '--', `${appId}.json`, `${appId}/`]);
282
+ await exec(['commit', '-m', `Restore app: ${appName} to ${shortHash}`, '--allow-empty']);
283
+ });
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Test helpers
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * @internal Test-only: reset module state.
292
+ */
293
+ export function _resetAppGitState(): void {
294
+ // no-op — kept for test API compatibility
295
+ }
@@ -29,6 +29,7 @@ import {
29
29
  isPrebuiltHomeBaseApp,
30
30
  validatePrebuiltHomeBaseHtml,
31
31
  } from '../home-base/prebuilt-home-base-updater.js';
32
+ import { commitAppChange } from './app-git-service.js';
32
33
 
33
34
  export interface AppDefinition {
34
35
  id: string;
@@ -210,6 +211,8 @@ export function createApp(params: {
210
211
  app.pages = params.pages;
211
212
  }
212
213
 
214
+ void commitAppChange(`Create app: ${params.name}`);
215
+
213
216
  return app;
214
217
  }
215
218
 
@@ -372,14 +375,27 @@ export function updateApp(
372
375
  updated.pages = loadedPages;
373
376
  }
374
377
 
378
+ const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined);
379
+ void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`);
380
+
375
381
  return updated;
376
382
  }
377
383
 
378
384
  export function deleteApp(id: string): void {
379
385
  validateId(id);
380
386
  const dir = getAppsDir();
387
+
388
+ // Read app name before deleting for the commit message
389
+ let appName = id;
381
390
  const filePath = join(dir, `${id}.json`);
382
391
  if (existsSync(filePath)) {
392
+ try {
393
+ const raw = readFileSync(filePath, 'utf-8');
394
+ const app = JSON.parse(raw);
395
+ if (app.name) appName = app.name;
396
+ } catch {
397
+ // fall back to id
398
+ }
383
399
  unlinkSync(filePath);
384
400
  }
385
401
  const previewPath = join(dir, `${id}.preview`);
@@ -388,6 +404,8 @@ export function deleteApp(id: string): void {
388
404
  }
389
405
  const appDir = join(dir, id);
390
406
  rmSync(appDir, { recursive: true, force: true });
407
+
408
+ void commitAppChange(`Delete app: ${appName}`);
391
409
  }
392
410
 
393
411
  export function createAppRecord(appId: string, data: Record<string, unknown>): AppRecord {
@@ -527,6 +545,8 @@ export function writeAppFile(appId: string, path: string, content: string): void
527
545
  const dir = join(resolved, '..');
528
546
  mkdirSync(dir, { recursive: true });
529
547
  writeFileSync(resolved, content, 'utf-8');
548
+
549
+ void commitAppChange(`Write ${path} in app ${appId}`);
530
550
  }
531
551
 
532
552
  /**
@@ -549,6 +569,7 @@ export function editAppFile(
549
569
  const result = applyEdit(content, oldString, newString, replaceAll ?? false);
550
570
  if (result.ok) {
551
571
  writeFileSync(resolved, result.updatedContent, 'utf-8');
572
+ void commitAppChange(`Edit ${path} in app ${appId}`);
552
573
  }
553
574
  return result;
554
575
  }
@@ -22,16 +22,30 @@ export function computeConflictRelevance(
22
22
  );
23
23
  }
24
24
 
25
- function tokenizeForConflictRelevance(input: string): Set<string> {
26
- const tokens = input
25
+ const NOISE_TOKENS = new Set([
26
+ 'http', 'https', 'github', 'gitlab', 'www', 'com', 'org',
27
+ ]);
28
+
29
+ // Matches full URLs (http(s)://...) and scheme-less bare domain URLs for known
30
+ // code hosting sites (e.g. github.com/org/repo/pull/123) so embedded path
31
+ // segments like "pull", "issue", "ticket" don't leak into relevance scoring.
32
+ // The scheme-less branch is restricted to known hosting domains to avoid
33
+ // stripping dotted identifiers like "index.ts/runtime".
34
+ const URL_PATTERN = /https?:\/\/[^\s)>\]]+|(?:github|gitlab|bitbucket)\.(?:com|org|io)\/[^\s)>\]]*/gi;
35
+
36
+ export function tokenizeForConflictRelevance(input: string): Set<string> {
37
+ const stripped = input.replace(URL_PATTERN, ' ');
38
+ const tokens = stripped
27
39
  .toLowerCase()
28
40
  .split(/[^a-z0-9]+/g)
29
41
  .map((token) => token.trim())
30
- .filter((token) => token.length >= 4);
42
+ .filter((token) => token.length >= 4)
43
+ .filter((token) => !/^\d+$/.test(token))
44
+ .filter((token) => !NOISE_TOKENS.has(token));
31
45
  return new Set(tokens);
32
46
  }
33
47
 
34
- function overlapRatio(left: Set<string>, right: Set<string>): number {
48
+ export function overlapRatio(left: Set<string>, right: Set<string>): number {
35
49
  if (left.size === 0 || right.size === 0) return 0;
36
50
  let overlap = 0;
37
51
  for (const token of left) {
@@ -40,6 +54,35 @@ function overlapRatio(left: Set<string>, right: Set<string>): number {
40
54
  return overlap / Math.max(left.size, right.size);
41
55
  }
42
56
 
57
+ /**
58
+ * Tokenize for statement-to-statement coherence checking.
59
+ * Uses a lower minimum token length (>= 3) than `tokenizeForConflictRelevance`
60
+ * to preserve short technical terms like "vim", "css", "git", "npm", etc.
61
+ */
62
+ function tokenizeForCoherence(input: string): Set<string> {
63
+ const stripped = input.replace(URL_PATTERN, ' ');
64
+ const tokens = stripped
65
+ .toLowerCase()
66
+ .split(/[^a-z0-9]+/g)
67
+ .map((token) => token.trim())
68
+ .filter((token) => token.length >= 3)
69
+ .filter((token) => !/^\d+$/.test(token))
70
+ .filter((token) => !NOISE_TOKENS.has(token));
71
+ return new Set(tokens);
72
+ }
73
+
74
+ /**
75
+ * Check whether two conflict statements have topical coherence.
76
+ * Returns true if the statements share at least one meaningful token.
77
+ * Uses a permissive tokenizer (>= 3 chars) to avoid dropping short
78
+ * technical terms like "vim", "css", "git", etc.
79
+ */
80
+ export function areStatementsCoherent(statementA: string, statementB: string): boolean {
81
+ const tokensA = tokenizeForCoherence(statementA);
82
+ const tokensB = tokenizeForCoherence(statementB);
83
+ return overlapRatio(tokensA, tokensB) > 0;
84
+ }
85
+
43
86
  // Action verbs that signal the user is making a deliberate choice.
44
87
  const ACTION_CUES = new Set([
45
88
  'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',