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
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { getLogger } from '../util/logger.js';
6
6
  import { getConfig } from '../config/loader.js';
7
+ import { PromiseGuard } from '../util/promise-guard.js';
7
8
 
8
9
  const execFileAsync = promisify(execFile);
9
10
  const log = getLogger('workspace-git');
@@ -133,7 +134,7 @@ export class WorkspaceGitService {
133
134
  private readonly workspaceDir: string;
134
135
  private readonly mutex: Mutex;
135
136
  private initialized = false;
136
- private initPromise: Promise<void> | null = null;
137
+ private readonly initGuard = new PromiseGuard<void>();
137
138
  private consecutiveFailures = 0;
138
139
  private nextAllowedAttemptMs = 0;
139
140
  private initConsecutiveFailures = 0;
@@ -236,81 +237,38 @@ export class WorkspaceGitService {
236
237
  }
237
238
 
238
239
  // If initialization is in progress, wait for it
239
- if (this.initPromise) {
240
- return this.initPromise;
240
+ if (this.initGuard.active) {
241
+ return this.initGuard.run(() => { throw new Error('unreachable'); });
241
242
  }
242
243
 
243
244
  // Circuit breaker: skip if multiple recent init attempts have been failing.
244
- // Checked AFTER initPromise so callers waiting on in-progress init aren't
245
+ // Checked AFTER initGuard.active so callers waiting on in-progress init aren't
245
246
  // blocked, and only activates after 2+ consecutive failures so that a
246
247
  // single transient failure allows immediate retry.
247
248
  if (this.isInitBreakerOpen()) {
248
249
  throw new Error('Init circuit breaker open: backing off after repeated failures');
249
250
  }
250
251
 
251
- // Start initialization
252
- this.initPromise = this.mutex.withLock(async () => {
253
- // Double-check after acquiring lock
254
- if (this.initialized) {
255
- return;
256
- }
257
-
258
- const gitDir = join(this.workspaceDir, '.git');
259
-
260
- if (existsSync(gitDir)) {
261
- // Validate existing repo is not corrupted before marking as ready.
262
- // A corrupted .git directory (e.g. missing HEAD) would cause all
263
- // subsequent git operations to fail with confusing errors.
264
- try {
265
- await this.execGit(['rev-parse', '--git-dir']);
266
- } catch (err: unknown) {
267
- // Distinguish transient failures from genuine corruption.
268
- // Transient errors (timeouts, permissions, missing git binary)
269
- // should NOT destroy .git — they will resolve on retry via
270
- // the initPromise clearing logic.
271
- const errMsg = err instanceof Error ? err.message : String(err);
272
- const execErr = err as ExecError;
273
- const isTimeout = execErr.killed === true
274
- || execErr.signal === 'SIGTERM'
275
- || errMsg.includes('SIGTERM')
276
- || errMsg.includes('timed out');
277
- const isPermission = execErr.code === 'EACCES'
278
- || errMsg.includes('EACCES')
279
- || errMsg.toLowerCase().includes('permission denied');
280
- const isMissingBinary = execErr.code === 'ENOENT'
281
- || errMsg.includes('ENOENT');
282
-
283
- if (isTimeout || isPermission || isMissingBinary) {
284
- // Re-throw so initialization fails gracefully without
285
- // destroying valid git history.
286
- throw err;
287
- }
288
-
289
- // Genuine corruption (e.g. missing HEAD, broken refs) —
290
- // remove corrupted .git and fall through to full init below.
291
- log.warn(
292
- { workspaceDir: this.workspaceDir, err: errMsg },
293
- 'Corrupted .git directory detected; reinitializing',
294
- );
295
- const { rmSync } = await import('node:fs');
296
- rmSync(gitDir, { recursive: true, force: true });
252
+ return this.initGuard.run(
253
+ () => this.mutex.withLock(async () => {
254
+ // Double-check after acquiring lock
255
+ if (this.initialized) {
256
+ return;
297
257
  }
298
258
 
259
+ const gitDir = join(this.workspaceDir, '.git');
260
+
299
261
  if (existsSync(gitDir)) {
300
- // .git exists and passed the corruption check, but we still
301
- // need to verify that at least one commit exists. A partial
302
- // init (e.g. git init succeeded but the initial commit failed)
303
- // leaves .git present with an undefined HEAD. In that case,
304
- // fall through to the initial commit logic below.
305
- let headExists = false;
262
+ // Validate existing repo is not corrupted before marking as ready.
263
+ // A corrupted .git directory (e.g. missing HEAD) would cause all
264
+ // subsequent git operations to fail with confusing errors.
306
265
  try {
307
- await this.execGit(['rev-parse', 'HEAD']);
308
- headExists = true;
266
+ await this.execGit(['rev-parse', '--git-dir']);
309
267
  } catch (err: unknown) {
310
- // Distinguish transient failures from genuine "no commits".
268
+ // Distinguish transient failures from genuine corruption.
311
269
  // Transient errors (timeouts, permissions, missing git binary)
312
- // should NOT fall through to re-initialization — they will
313
- // resolve on retry via the initPromise clearing logic.
270
+ // should NOT destroy .git — they will resolve on retry via
271
+ // the guard clearing logic.
314
272
  const errMsg = err instanceof Error ? err.message : String(err);
315
273
  const execErr = err as ExecError;
316
274
  const isTimeout = execErr.killed === true
@@ -324,68 +282,104 @@ export class WorkspaceGitService {
324
282
  || errMsg.includes('ENOENT');
325
283
 
326
284
  if (isTimeout || isPermission || isMissingBinary) {
285
+ // Re-throw so initialization fails gracefully without
286
+ // destroying valid git history.
327
287
  throw err;
328
288
  }
329
- // Genuine "no commits" (unborn HEAD) — fall through to
330
- // create the initial commit.
331
- }
332
289
 
333
- if (headExists) {
334
- // HEAD resolves repo is fully initialized.
335
- // Run normalization for existing repos that may have been
336
- // created before these helpers existed, or by external tools.
337
- // These calls are OUTSIDE the rev-parse try/catch so that
338
- // normalization errors are not misclassified as "no commits".
339
- this.ensureGitignoreRulesLocked();
340
- await this.ensureCommitIdentityLocked();
341
- await this.ensureOnMainLocked();
342
- this.initialized = true;
343
- this.recordInitSuccess();
344
- return;
290
+ // Genuine corruption (e.g. missing HEAD, broken refs)
291
+ // remove corrupted .git and fall through to full init below.
292
+ log.warn(
293
+ { workspaceDir: this.workspaceDir, err: errMsg },
294
+ 'Corrupted .git directory detected; reinitializing',
295
+ );
296
+ const { rmSync } = await import('node:fs');
297
+ rmSync(gitDir, { recursive: true, force: true });
345
298
  }
346
- }
347
- // Otherwise fall through to reinitialize / create initial commit
348
- }
349
299
 
350
- // Initialize new git repository
351
- await this.execGit(['init', '-b', 'main']);
352
-
353
- // Run normalization (gitignore + identity + branch enforcement).
354
- // For fresh `git init -b main` the branch is already main, but
355
- // in the corruption-recovery path we fall through here after
356
- // removing .git, so branch enforcement is still useful.
357
- this.ensureGitignoreRulesLocked();
358
- await this.ensureCommitIdentityLocked();
359
- await this.ensureOnMainLocked();
360
-
361
- // Create initial commit synchronously within the lock to prevent
362
- // races with the first commitChanges() call. Without this, the
363
- // initial commit could run concurrently and consume edits meant
364
- // for the first user-requested commit.
365
- const status = await this.getStatusInternal();
366
- const hasExistingFiles = status.untracked.length > 1 || // More than just .gitignore
367
- status.untracked.some(f => f !== '.gitignore');
300
+ if (existsSync(gitDir)) {
301
+ // .git exists and passed the corruption check, but we still
302
+ // need to verify that at least one commit exists. A partial
303
+ // init (e.g. git init succeeded but the initial commit failed)
304
+ // leaves .git present with an undefined HEAD. In that case,
305
+ // fall through to the initial commit logic below.
306
+ let headExists = false;
307
+ try {
308
+ await this.execGit(['rev-parse', 'HEAD']);
309
+ headExists = true;
310
+ } catch (err: unknown) {
311
+ // Distinguish transient failures from genuine "no commits".
312
+ // Transient errors (timeouts, permissions, missing git binary)
313
+ // should NOT fall through to re-initialization they will
314
+ // resolve on retry via the guard clearing logic.
315
+ const errMsg = err instanceof Error ? err.message : String(err);
316
+ const execErr = err as ExecError;
317
+ const isTimeout = execErr.killed === true
318
+ || execErr.signal === 'SIGTERM'
319
+ || errMsg.includes('SIGTERM')
320
+ || errMsg.includes('timed out');
321
+ const isPermission = execErr.code === 'EACCES'
322
+ || errMsg.includes('EACCES')
323
+ || errMsg.toLowerCase().includes('permission denied');
324
+ const isMissingBinary = execErr.code === 'ENOENT'
325
+ || errMsg.includes('ENOENT');
326
+
327
+ if (isTimeout || isPermission || isMissingBinary) {
328
+ throw err;
329
+ }
330
+ // Genuine "no commits" (unborn HEAD) — fall through to
331
+ // create the initial commit.
332
+ }
368
333
 
369
- await this.execGit(['add', '-A']);
334
+ if (headExists) {
335
+ // HEAD resolves — repo is fully initialized.
336
+ // Run normalization for existing repos that may have been
337
+ // created before these helpers existed, or by external tools.
338
+ // These calls are OUTSIDE the rev-parse try/catch so that
339
+ // normalization errors are not misclassified as "no commits".
340
+ this.ensureGitignoreRulesLocked();
341
+ await this.ensureCommitIdentityLocked();
342
+ await this.ensureOnMainLocked();
343
+ this.initialized = true;
344
+ this.recordInitSuccess();
345
+ return;
346
+ }
347
+ }
348
+ // Otherwise fall through to reinitialize / create initial commit
349
+ }
370
350
 
371
- const message = hasExistingFiles
372
- ? 'Initial commit: migrated existing workspace'
373
- : 'Initial commit: new workspace';
351
+ // Initialize new git repository
352
+ await this.execGit(['init', '-b', 'main']);
353
+
354
+ // Run normalization (gitignore + identity + branch enforcement).
355
+ // For fresh `git init -b main` the branch is already main, but
356
+ // in the corruption-recovery path we fall through here after
357
+ // removing .git, so branch enforcement is still useful.
358
+ this.ensureGitignoreRulesLocked();
359
+ await this.ensureCommitIdentityLocked();
360
+ await this.ensureOnMainLocked();
361
+
362
+ // Create initial commit synchronously within the lock to prevent
363
+ // races with the first commitChanges() call. Without this, the
364
+ // initial commit could run concurrently and consume edits meant
365
+ // for the first user-requested commit.
366
+ const status = await this.getStatusInternal();
367
+ const hasExistingFiles = status.untracked.length > 1 || // More than just .gitignore
368
+ status.untracked.some(f => f !== '.gitignore');
374
369
 
375
- await this.execGit(['commit', '-m', message, '--allow-empty']);
370
+ await this.execGit(['add', '-A']);
376
371
 
377
- this.initialized = true;
378
- this.recordInitSuccess();
379
- });
372
+ const message = hasExistingFiles
373
+ ? 'Initial commit: migrated existing workspace'
374
+ : 'Initial commit: new workspace';
380
375
 
381
- // If initialization fails, clear the cached promise so subsequent
382
- // calls can retry instead of permanently returning the rejected promise.
383
- this.initPromise.catch(() => {
384
- this.initPromise = null;
385
- this.recordInitFailure();
386
- });
376
+ await this.execGit(['commit', '-m', message, '--allow-empty']);
387
377
 
388
- return this.initPromise;
378
+ this.initialized = true;
379
+ this.recordInitSuccess();
380
+ }),
381
+ () => this.recordInitFailure(),
382
+ );
389
383
  }
390
384
 
391
385
  /**
@@ -732,6 +726,29 @@ export class WorkspaceGitService {
732
726
  }
733
727
  }
734
728
 
729
+ /**
730
+ * Run an arbitrary read-only git command in the workspace directory.
731
+ * Uses the same clean env and timeout as other git operations.
732
+ * Does NOT acquire the mutex — callers must ensure they are not
733
+ * writing to the repo concurrently (or accept eventual-consistency).
734
+ */
735
+ async runReadOnlyGit(args: string[]): Promise<{ stdout: string; stderr: string }> {
736
+ await this.ensureInitialized();
737
+ return this.execGit(args);
738
+ }
739
+
740
+ /**
741
+ * Run a sequence of git commands atomically under the workspace mutex.
742
+ * Use this for write operations that need serialization with other
743
+ * git mutations (e.g. checkout + commit).
744
+ */
745
+ async runWithMutex(fn: (exec: (args: string[]) => Promise<{ stdout: string; stderr: string }>) => Promise<void>): Promise<void> {
746
+ await this.ensureInitialized();
747
+ await this.mutex.withLock(async () => {
748
+ await fn((args) => this.execGit(args));
749
+ });
750
+ }
751
+
735
752
  /**
736
753
  * Get the commit hash of the current HEAD.
737
754
  * This is a lightweight read-only operation that does not require the mutex.
@@ -1,55 +0,0 @@
1
- import type { ToolContext, ToolExecutionResult } from '../types.js';
2
- import { mergeContacts, getContact } from '../../contacts/contact-store.js';
3
-
4
- export async function executeContactMerge(
5
- input: Record<string, unknown>,
6
- _context: ToolContext,
7
- ): Promise<ToolExecutionResult> {
8
- const keepId = input.keep_id as string | undefined;
9
- const mergeId = input.merge_id as string | undefined;
10
-
11
- if (!keepId || typeof keepId !== 'string') {
12
- return { content: 'Error: keep_id is required', isError: true };
13
- }
14
- if (!mergeId || typeof mergeId !== 'string') {
15
- return { content: 'Error: merge_id is required', isError: true };
16
- }
17
-
18
- // Show what will be merged for clarity
19
- const keepContact = getContact(keepId);
20
- const mergeContact = getContact(mergeId);
21
-
22
- if (!keepContact) {
23
- return { content: `Error: Contact "${keepId}" not found`, isError: true };
24
- }
25
- if (!mergeContact) {
26
- return { content: `Error: Contact "${mergeId}" not found`, isError: true };
27
- }
28
-
29
- try {
30
- const merged = mergeContacts(keepId, mergeId);
31
-
32
- const channelList = merged.channels
33
- .map((ch) => ` - ${ch.type}: ${ch.address}${ch.isPrimary ? ' (primary)' : ''}`)
34
- .join('\n');
35
-
36
- return {
37
- content: [
38
- `Merged "${mergeContact.displayName}" into "${keepContact.displayName}".`,
39
- ``,
40
- `Surviving contact (${merged.id}):`,
41
- ` Name: ${merged.displayName}`,
42
- ` Importance: ${merged.importance.toFixed(2)}`,
43
- ` Interactions: ${merged.interactionCount}`,
44
- merged.relationship ? ` Relationship: ${merged.relationship}` : null,
45
- merged.channels.length > 0 ? ` Channels:\n${channelList}` : null,
46
- ``,
47
- `Deleted contact: ${mergeContact.displayName} (${mergeId})`,
48
- ].filter(Boolean).join('\n'),
49
- isError: false,
50
- };
51
- } catch (err) {
52
- const msg = err instanceof Error ? err.message : String(err);
53
- return { content: `Error: ${msg}`, isError: true };
54
- }
55
- }
@@ -1,58 +0,0 @@
1
- import type { ToolContext, ToolExecutionResult } from '../types.js';
2
- import { searchContacts } from '../../contacts/contact-store.js';
3
- import type { ContactWithChannels } from '../../contacts/types.js';
4
-
5
- function formatContactSummary(c: ContactWithChannels): string {
6
- const parts = [`- **${c.displayName}** (ID: ${c.id})`];
7
- if (c.relationship) parts.push(` Relationship: ${c.relationship}`);
8
- parts.push(` Importance: ${c.importance.toFixed(2)} | Interactions: ${c.interactionCount}`);
9
- if (c.channels.length > 0) {
10
- const channelList = c.channels
11
- .map((ch) => `${ch.type}:${ch.address}${ch.isPrimary ? '*' : ''}`)
12
- .join(', ');
13
- parts.push(` Channels: ${channelList}`);
14
- }
15
- return parts.join('\n');
16
- }
17
-
18
- export async function executeContactSearch(
19
- input: Record<string, unknown>,
20
- _context: ToolContext,
21
- ): Promise<ToolExecutionResult> {
22
- const query = input.query as string | undefined;
23
- const channelAddress = input.channel_address as string | undefined;
24
- const channelType = input.channel_type as string | undefined;
25
- const relationship = input.relationship as string | undefined;
26
- const limit = input.limit as number | undefined;
27
-
28
- if (!query && !channelAddress && !relationship) {
29
- return {
30
- content: 'Error: At least one search criterion is required (query, channel_address, or relationship)',
31
- isError: true,
32
- };
33
- }
34
-
35
- try {
36
- const results = searchContacts({
37
- query,
38
- channelAddress,
39
- channelType,
40
- relationship,
41
- limit,
42
- });
43
-
44
- if (results.length === 0) {
45
- return { content: 'No contacts found matching the search criteria.', isError: false };
46
- }
47
-
48
- const lines = [`Found ${results.length} contact(s):\n`];
49
- for (const contact of results) {
50
- lines.push(formatContactSummary(contact));
51
- }
52
-
53
- return { content: lines.join('\n'), isError: false };
54
- } catch (err) {
55
- const msg = err instanceof Error ? err.message : String(err);
56
- return { content: `Error: ${msg}`, isError: true };
57
- }
58
- }
@@ -1,64 +0,0 @@
1
- import type { ToolContext, ToolExecutionResult } from '../types.js';
2
- import { upsertContact } from '../../contacts/contact-store.js';
3
-
4
- function formatContact(c: ReturnType<typeof upsertContact>): string {
5
- const lines = [
6
- `Contact ${c.id}`,
7
- ` Name: ${c.displayName}`,
8
- ];
9
- if (c.relationship) lines.push(` Relationship: ${c.relationship}`);
10
- lines.push(` Importance: ${c.importance.toFixed(2)}`);
11
- if (c.responseExpectation) lines.push(` Response expectation: ${c.responseExpectation}`);
12
- if (c.preferredTone) lines.push(` Preferred tone: ${c.preferredTone}`);
13
- if (c.interactionCount > 0) lines.push(` Interactions: ${c.interactionCount}`);
14
- if (c.channels.length > 0) {
15
- lines.push(' Channels:');
16
- for (const ch of c.channels) {
17
- const primary = ch.isPrimary ? ' (primary)' : '';
18
- lines.push(` - ${ch.type}: ${ch.address}${primary}`);
19
- }
20
- }
21
- return lines.join('\n');
22
- }
23
-
24
- export async function executeContactUpsert(
25
- input: Record<string, unknown>,
26
- _context: ToolContext,
27
- ): Promise<ToolExecutionResult> {
28
- const displayName = input.display_name as string | undefined;
29
- if (!displayName || typeof displayName !== 'string' || displayName.trim().length === 0) {
30
- return { content: 'Error: display_name is required and must be a non-empty string', isError: true };
31
- }
32
-
33
- const importance = input.importance as number | undefined;
34
- if (importance !== undefined && (typeof importance !== 'number' || importance < 0 || importance > 1)) {
35
- return { content: 'Error: importance must be a number between 0 and 1', isError: true };
36
- }
37
-
38
- const rawChannels = input.channels as Array<{ type: string; address: string; is_primary?: boolean }> | undefined;
39
- const channels = rawChannels?.map((ch) => ({
40
- type: ch.type,
41
- address: ch.address,
42
- isPrimary: ch.is_primary,
43
- }));
44
-
45
- try {
46
- const contact = upsertContact({
47
- id: input.id as string | undefined,
48
- displayName: displayName.trim(),
49
- relationship: input.relationship as string | undefined,
50
- importance,
51
- responseExpectation: input.response_expectation as string | undefined,
52
- preferredTone: input.preferred_tone as string | undefined,
53
- channels,
54
- });
55
-
56
- return {
57
- content: `${contact.created ? 'Created' : 'Updated'} contact:\n${formatContact(contact)}`,
58
- isError: false,
59
- };
60
- } catch (err) {
61
- const msg = err instanceof Error ? err.message : String(err);
62
- return { content: `Error: ${msg}`, isError: true };
63
- }
64
- }
@@ -1,4 +0,0 @@
1
- export { executePlaybookCreate } from './playbook-create.js';
2
- export { executePlaybookList } from './playbook-list.js';
3
- export { executePlaybookUpdate } from './playbook-update.js';
4
- export { executePlaybookDelete } from './playbook-delete.js';
@@ -1,96 +0,0 @@
1
- import { and, eq } from 'drizzle-orm';
2
- import { v4 as uuid } from 'uuid';
3
- import type { ToolContext, ToolExecutionResult } from '../types.js';
4
- import { getDb } from '../../memory/db.js';
5
- import { computeMemoryFingerprint } from '../../memory/fingerprint.js';
6
- import { memoryItems } from '../../memory/schema.js';
7
- import { enqueueMemoryJob } from '../../memory/jobs-store.js';
8
- import type { Playbook, PlaybookAutonomyLevel } from '../../playbooks/types.js';
9
- import { truncate } from '../../util/truncate.js';
10
-
11
- const VALID_AUTONOMY_LEVELS = new Set<string>(['auto', 'draft', 'notify']);
12
-
13
- export async function executePlaybookCreate(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
14
- const trigger = input.trigger as string;
15
- const action = input.action as string;
16
-
17
- if (!trigger || typeof trigger !== 'string') {
18
- return { content: 'Error: trigger is required and must be a string', isError: true };
19
- }
20
- if (!action || typeof action !== 'string') {
21
- return { content: 'Error: action is required and must be a string', isError: true };
22
- }
23
-
24
- const channel = typeof input.channel === 'string' ? input.channel : '*';
25
- const category = typeof input.category === 'string' ? input.category : 'general';
26
- const autonomyLevel: PlaybookAutonomyLevel =
27
- typeof input.autonomy_level === 'string' && VALID_AUTONOMY_LEVELS.has(input.autonomy_level)
28
- ? (input.autonomy_level as PlaybookAutonomyLevel)
29
- : 'draft';
30
- const priority = typeof input.priority === 'number' ? input.priority : 0;
31
-
32
- const playbook: Playbook = { trigger, channel, category, action, autonomyLevel, priority };
33
- const statement = JSON.stringify(playbook);
34
- const subject = truncate(`Playbook: ${trigger}`, 80, '');
35
- const scopeId = context.memoryScopeId ?? 'default';
36
-
37
- const fingerprint = computeMemoryFingerprint(scopeId, 'playbook', subject, statement);
38
-
39
- try {
40
- const db = getDb();
41
-
42
- const existing = db
43
- .select()
44
- .from(memoryItems)
45
- .where(and(eq(memoryItems.fingerprint, fingerprint), eq(memoryItems.scopeId, scopeId)))
46
- .get();
47
-
48
- if (existing) {
49
- return {
50
- content: `A playbook with this exact configuration already exists (ID: ${existing.id}).`,
51
- isError: false,
52
- };
53
- }
54
-
55
- const id = uuid();
56
- const now = Date.now();
57
-
58
- db.insert(memoryItems).values({
59
- id,
60
- kind: 'playbook',
61
- subject,
62
- statement,
63
- status: 'active',
64
- confidence: 0.95,
65
- importance: 0.8,
66
- fingerprint,
67
- verificationState: 'user_confirmed',
68
- scopeId,
69
- firstSeenAt: now,
70
- lastSeenAt: now,
71
- lastUsedAt: null,
72
- }).run();
73
-
74
- enqueueMemoryJob('embed_item', { itemId: id });
75
-
76
- const autonomyLabel = autonomyLevel === 'auto' ? 'execute automatically'
77
- : autonomyLevel === 'draft' ? 'draft for review' : 'notify only';
78
-
79
- return {
80
- content: [
81
- 'Playbook created successfully.',
82
- ` ID: ${id}`,
83
- ` Trigger: ${trigger}`,
84
- ` Channel: ${channel}`,
85
- ` Category: ${category}`,
86
- ` Action: ${action}`,
87
- ` Autonomy: ${autonomyLabel}`,
88
- ` Priority: ${priority}`,
89
- ].join('\n'),
90
- isError: false,
91
- };
92
- } catch (err) {
93
- const msg = err instanceof Error ? err.message : String(err);
94
- return { content: `Error creating playbook: ${msg}`, isError: true };
95
- }
96
- }
@@ -1,52 +0,0 @@
1
- import { and, eq } from 'drizzle-orm';
2
- import type { ToolContext, ToolExecutionResult } from '../types.js';
3
- import { getDb } from '../../memory/db.js';
4
- import { memoryItems } from '../../memory/schema.js';
5
- import { parsePlaybookStatement } from '../../playbooks/types.js';
6
-
7
- export async function executePlaybookDelete(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
8
- const playbookId = input.playbook_id as string;
9
- if (!playbookId || typeof playbookId !== 'string') {
10
- return { content: 'Error: playbook_id is required and must be a string', isError: true };
11
- }
12
-
13
- const scopeId = context.memoryScopeId ?? 'default';
14
-
15
- try {
16
- const db = getDb();
17
-
18
- const existing = db
19
- .select()
20
- .from(memoryItems)
21
- .where(and(
22
- eq(memoryItems.id, playbookId),
23
- eq(memoryItems.kind, 'playbook'),
24
- eq(memoryItems.scopeId, scopeId),
25
- ))
26
- .get();
27
-
28
- if (!existing) {
29
- return { content: `Error: Playbook with ID "${playbookId}" not found`, isError: true };
30
- }
31
-
32
- const playbook = parsePlaybookStatement(existing.statement);
33
- const triggerLabel = playbook?.trigger ?? existing.subject;
34
-
35
- // Soft-delete by marking as superseded rather than hard-deleting,
36
- // consistent with how other memory items are retired.
37
- // Setting invalidAt so the cleanup job can eventually hard-delete it.
38
- const now = Date.now();
39
- db.update(memoryItems)
40
- .set({ status: 'superseded', invalidAt: now })
41
- .where(eq(memoryItems.id, existing.id))
42
- .run();
43
-
44
- return {
45
- content: `Playbook deleted (ID: ${existing.id}, trigger: "${triggerLabel}").`,
46
- isError: false,
47
- };
48
- } catch (err) {
49
- const msg = err instanceof Error ? err.message : String(err);
50
- return { content: `Error deleting playbook: ${msg}`, isError: true };
51
- }
52
- }