@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -40,6 +40,12 @@ import { promisify } from "node:util";
40
40
 
41
41
  import { ensureBun } from "../../util/bun-runtime.js";
42
42
  import { getWorkspacePluginsDir } from "../../util/platform.js";
43
+ import {
44
+ computeContentHash,
45
+ computeFingerprint,
46
+ type Fingerprint,
47
+ parseFingerprint,
48
+ } from "./plugin-fingerprint.js";
43
49
  import {
44
50
  fetchMarketplaceEntries,
45
51
  MarketplaceFetchError,
@@ -389,11 +395,25 @@ export async function installPlugin(
389
395
  throw new PluginNotFoundError(name, ref, sourceLabel(source));
390
396
  }
391
397
 
392
- // Record install provenance (source coordinates + resolved commit) as a
393
- // hidden sidecar before the swap so it lands atomically with the files. The
394
- // daemon loader enumerates plugin directories and reads each plugin's
395
- // `package.json`, skipping dotfiles — so this never gets mistaken for code.
396
- writeInstallManifest(stagingDir, name, source, ref, commit);
398
+ // Hash the materialized tree before the sidecar is written (so the sidecar
399
+ // never hashes itself) — the baseline `plugins inspect` uses to detect later
400
+ // local edits. The per-file fingerprint answers "which files changed"; the
401
+ // whole-tree content hash is a compact integrity signal mirroring skills.
402
+ const fingerprint = computeFingerprint(stagingDir, [INSTALL_META_FILENAME]);
403
+ const contentHash = computeContentHash(stagingDir, [INSTALL_META_FILENAME]);
404
+
405
+ // Record install provenance (source coordinates + resolved commit + content
406
+ // digests) as a sidecar before the swap so it lands atomically with the
407
+ // files. The external plugin loader only reads `package.json` and the
408
+ // `hooks/`/`tools/` dirs, so this JSON file is never mistaken for code.
409
+ writeInstallMeta(stagingDir, {
410
+ name,
411
+ source,
412
+ ref,
413
+ commit,
414
+ fingerprint,
415
+ contentHash,
416
+ });
397
417
 
398
418
  // Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
399
419
  // atomic, so the only window where the target is absent is between the
@@ -412,8 +432,78 @@ export async function installPlugin(
412
432
  /** Cap on any single git invocation; a shallow fetch is well under this. */
413
433
  const GIT_TIMEOUT_MS = 120_000;
414
434
 
415
- /** Install-provenance sidecar written at the plugin root. */
416
- const INSTALL_MANIFEST_FILENAME = ".vellum-plugin.json";
435
+ /**
436
+ * Install-provenance sidecar written at the plugin root. Named to match the
437
+ * skills' sidecar (`install-meta.json`, see `src/skills/install-meta.ts`) so
438
+ * both subsystems share one vocabulary. The external plugin loader only reads
439
+ * `package.json` and the `hooks/`/`tools/` surface dirs, so a plain JSON file
440
+ * at the root is ignored by it.
441
+ */
442
+ export const INSTALL_META_FILENAME = "install-meta.json";
443
+
444
+ /**
445
+ * Which catalog manages an installed plugin. Mirrors the skill origin values
446
+ * (`SkillInstallMeta.origin` in `src/skills/install-meta.ts`) so the two
447
+ * systems keep a consistent vocabulary. `"vellum"` denotes the first-party
448
+ * `marketplace.json`; the union widens as new sources are supported.
449
+ */
450
+ export type InstallOrigin = "vellum";
451
+
452
+ /** Resolved source coordinates recorded in the provenance sidecar. */
453
+ export interface InstallMetaSource {
454
+ /** Source kind. Only `github` is written today. */
455
+ readonly kind: string;
456
+ readonly owner: string;
457
+ readonly repo: string;
458
+ /** Repo-relative directory holding the plugin root; absent = repo root. */
459
+ readonly path?: string;
460
+ /** Ref the install resolved through (the pinned commit SHA for marketplace installs). */
461
+ readonly ref: string;
462
+ }
463
+
464
+ /**
465
+ * Parsed contents of the `install-meta.json` provenance sidecar — what was
466
+ * installed, from where, and at exactly which commit. Read by
467
+ * {@link readInstallMeta} for provenance reporting (e.g. `plugins inspect`).
468
+ *
469
+ * The leading fields share names (and meaning) with the skills'
470
+ * `SkillInstallMeta`; everything below {@link InstallMeta.name} is the
471
+ * plugin-specific superset that the git-backed install needs.
472
+ */
473
+ export interface InstallMeta {
474
+ /** Catalog the install is managed from. */
475
+ readonly origin: InstallOrigin;
476
+ /** ISO-8601 timestamp of when the install was materialized. */
477
+ readonly installedAt: string;
478
+ /** Principal that initiated the install, when known. */
479
+ readonly installedBy?: string;
480
+ /** Set by a backfill migration when provenance was reconstructed after the fact. */
481
+ readonly backfilledBy?: string;
482
+ /** Plugin `package.json` version at install time, when present. */
483
+ readonly version?: string;
484
+ /** Registry slug, recorded when it diverges from {@link InstallMeta.name}. */
485
+ readonly slug?: string;
486
+ /** `owner/repo` the install was sourced from. */
487
+ readonly sourceRepo?: string;
488
+ /**
489
+ * Whole-tree `v2:` content hash — a compact integrity signal using the same
490
+ * scheme as the skills' `contentHash`. Complements the per-file
491
+ * {@link InstallMeta.fingerprint}.
492
+ */
493
+ readonly contentHash?: string;
494
+
495
+ /** Install name. Matches the plugins directory and `plugins install <name>`. */
496
+ readonly name: string;
497
+ readonly source: InstallMetaSource;
498
+ /** Resolved commit SHA the source was cloned at; `null` when it could not be read at install time. */
499
+ readonly commit: string | null;
500
+ /**
501
+ * Per-file content digest of the materialized tree, captured at install
502
+ * time. `null` for older installs written before fingerprinting; callers
503
+ * then report local-modification state as unknown rather than clean.
504
+ */
505
+ readonly fingerprint: Fingerprint | null;
506
+ }
417
507
 
418
508
  /**
419
509
  * Materialize an external plugin by shallow-cloning its repo at the pinned ref.
@@ -852,20 +942,59 @@ function pluginGitEnv(): NodeJS.ProcessEnv {
852
942
  return env;
853
943
  }
854
944
 
945
+ /** Inputs for {@link writeInstallMeta}, resolved during a fresh install. */
946
+ interface WriteInstallMetaParams {
947
+ readonly name: string;
948
+ readonly source: PluginFetchSource;
949
+ readonly ref: string;
950
+ readonly commit: string | null;
951
+ readonly fingerprint: Fingerprint;
952
+ readonly contentHash: string;
953
+ }
954
+
955
+ /**
956
+ * Read the `version` field from a staged plugin's `package.json`. Lenient — a
957
+ * missing or malformed manifest simply yields `undefined` so provenance is
958
+ * recorded without it rather than failing the install.
959
+ */
960
+ function readStagedPackageVersion(stagingDir: string): string | undefined {
961
+ const pkgPath = join(stagingDir, "package.json");
962
+ if (!existsSync(pkgPath)) return undefined;
963
+ try {
964
+ const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
965
+ if (typeof parsed === "object" && parsed !== null) {
966
+ const version = (parsed as Record<string, unknown>).version;
967
+ if (typeof version === "string" && version.length > 0) return version;
968
+ }
969
+ } catch {
970
+ // fall through to undefined
971
+ }
972
+ return undefined;
973
+ }
974
+
855
975
  /**
856
- * Write the install-provenance sidecar into the staged plugin root, recording
857
- * the resolved source coordinates and commit so we can later report or verify
858
- * exactly what is installed. Hidden (dot-prefixed) so the daemon loader, which
859
- * skips dotfiles, never mistakes it for plugin code.
976
+ * Write the `install-meta.json` provenance sidecar into the staged plugin root,
977
+ * recording the resolved source coordinates, commit, and content digests so we
978
+ * can later report or verify exactly what is installed. The schema is an
979
+ * equal-name superset of the skills' `SkillInstallMeta`.
860
980
  */
861
- function writeInstallManifest(
981
+ function writeInstallMeta(
862
982
  stagingDir: string,
863
- name: string,
864
- source: PluginFetchSource,
865
- ref: string,
866
- commit: string | null,
983
+ {
984
+ name,
985
+ source,
986
+ ref,
987
+ commit,
988
+ fingerprint,
989
+ contentHash,
990
+ }: WriteInstallMetaParams,
867
991
  ): void {
868
- const manifest = {
992
+ const meta: InstallMeta = {
993
+ origin: "vellum",
994
+ installedAt: new Date().toISOString(),
995
+ version: readStagedPackageVersion(stagingDir),
996
+ sourceRepo: `${source.owner}/${source.repo}`,
997
+ contentHash,
869
998
  name,
870
999
  source: {
871
1000
  kind: "github",
@@ -874,15 +1003,79 @@ function writeInstallManifest(
874
1003
  path: source.rootPath || undefined,
875
1004
  ref,
876
1005
  },
877
- commit: commit ?? undefined,
878
- installedAt: new Date().toISOString(),
1006
+ commit,
1007
+ fingerprint,
879
1008
  };
880
1009
  writeFileSync(
881
- join(stagingDir, INSTALL_MANIFEST_FILENAME),
882
- `${JSON.stringify(manifest, null, 2)}\n`,
1010
+ join(stagingDir, INSTALL_META_FILENAME),
1011
+ `${JSON.stringify(meta, null, 2)}\n`,
883
1012
  );
884
1013
  }
885
1014
 
1015
+ /**
1016
+ * Read the install-provenance sidecar from an installed plugin's root.
1017
+ *
1018
+ * Lenient by design — a missing, unreadable, or malformed sidecar yields
1019
+ * `null` rather than throwing, mirroring {@link ./list-installed-plugins}.
1020
+ * Older or manually-copied installs that predate the sidecar simply report no
1021
+ * provenance. The resolved commit is the authoritative record of which bytes
1022
+ * are installed, so callers (e.g. `plugins inspect`) can compare it against the
1023
+ * marketplace's current pin to detect drift.
1024
+ */
1025
+ export function readInstallMeta(pluginDir: string): InstallMeta | null {
1026
+ const metaPath = join(pluginDir, INSTALL_META_FILENAME);
1027
+ if (!existsSync(metaPath)) return null;
1028
+
1029
+ let parsed: unknown;
1030
+ try {
1031
+ parsed = JSON.parse(readFileSync(metaPath, "utf8"));
1032
+ } catch {
1033
+ return null;
1034
+ }
1035
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1036
+ return null;
1037
+ }
1038
+
1039
+ const obj = parsed as Record<string, unknown>;
1040
+ const src = obj.source;
1041
+ if (typeof src !== "object" || src === null || Array.isArray(src)) {
1042
+ return null;
1043
+ }
1044
+ const source = src as Record<string, unknown>;
1045
+ if (
1046
+ typeof obj.name !== "string" ||
1047
+ typeof source.owner !== "string" ||
1048
+ typeof source.repo !== "string" ||
1049
+ typeof source.ref !== "string"
1050
+ ) {
1051
+ return null;
1052
+ }
1053
+
1054
+ const optionalString = (value: unknown): string | undefined =>
1055
+ typeof value === "string" ? value : undefined;
1056
+
1057
+ return {
1058
+ origin: "vellum",
1059
+ installedAt: typeof obj.installedAt === "string" ? obj.installedAt : "",
1060
+ installedBy: optionalString(obj.installedBy),
1061
+ backfilledBy: optionalString(obj.backfilledBy),
1062
+ version: optionalString(obj.version),
1063
+ slug: optionalString(obj.slug),
1064
+ sourceRepo: optionalString(obj.sourceRepo),
1065
+ contentHash: optionalString(obj.contentHash),
1066
+ name: obj.name,
1067
+ source: {
1068
+ kind: typeof source.kind === "string" ? source.kind : "github",
1069
+ owner: source.owner,
1070
+ repo: source.repo,
1071
+ path: typeof source.path === "string" ? source.path : undefined,
1072
+ ref: source.ref,
1073
+ },
1074
+ commit: typeof obj.commit === "string" ? obj.commit : null,
1075
+ fingerprint: parseFingerprint(obj.fingerprint),
1076
+ };
1077
+ }
1078
+
886
1079
  /**
887
1080
  * Recursively copy a curated adapter stub directory via the GitHub Contents API.
888
1081
  *
@@ -12,12 +12,7 @@
12
12
  * and we want both surfaces to agree on what's present.
13
13
  */
14
14
 
15
- import {
16
- existsSync,
17
- readdirSync,
18
- readFileSync,
19
- statSync,
20
- } from "node:fs";
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
16
  import { join } from "node:path";
22
17
 
23
18
  import { getWorkspacePluginsDir } from "../../util/platform.js";
@@ -81,6 +76,22 @@ export function listInstalledPlugins(
81
76
  return entries.map((name) => readPluginEntry(pluginsDir, name));
82
77
  }
83
78
 
79
+ /**
80
+ * Read a single installed plugin entry by name, or `null` when no directory
81
+ * for it exists under the workspace plugins directory. Parses leniently like
82
+ * {@link listInstalledPlugins} — a malformed `package.json` surfaces as an
83
+ * `issues` entry rather than throwing.
84
+ */
85
+ export function readInstalledPlugin(
86
+ name: string,
87
+ opts: ListInstalledPluginsOptions = {},
88
+ ): InstalledPluginInfo | null {
89
+ const pluginsDir = opts.workspacePluginsDir ?? getWorkspacePluginsDir();
90
+ const target = join(pluginsDir, name);
91
+ if (!existsSync(target) || !statSync(target).isDirectory()) return null;
92
+ return readPluginEntry(pluginsDir, name);
93
+ }
94
+
84
95
  function readPluginEntry(
85
96
  pluginsDir: string,
86
97
  name: string,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Parse a plugin's prebuilt client-artifact descriptor from its
3
+ * `package.json` `vellum.artifact` block.
4
+ *
5
+ * Some plugins ship a native companion the assistant cannot build itself —
6
+ * e.g. a macOS app whose Swift toolchain is absent from the Linux container
7
+ * the daemon runs in. Those plugins publish a prebuilt, author-signed binary
8
+ * out-of-band (a GitHub Release) and point at it from their manifest:
9
+ *
10
+ * "vellum": {
11
+ * "artifact": {
12
+ * "url": "https://github.com/owner/repo/releases/download/v1.0.0/App.dmg",
13
+ * "sha256": "<64-hex>",
14
+ * "label": "Download for macOS"
15
+ * }
16
+ * }
17
+ *
18
+ * The `url` is mutable (a Release asset can be re-uploaded); the `sha256` is
19
+ * the integrity anchor a client verifies the download against — the same
20
+ * "pin the immutable hash, not the mutable pointer" philosophy the source
21
+ * whitelist already enforces with full commit SHAs.
22
+ *
23
+ * An artifact is only surfaced when **both** fields are well-formed. A
24
+ * missing block, a non-`https:` URL, or an absent / placeholder `sha256`
25
+ * (e.g. the empty string a release workflow fills in on its first run) all
26
+ * resolve to `null` — "no downloadable artifact yet" — so a client never
27
+ * offers an unverifiable download.
28
+ */
29
+
30
+ import { z } from "zod";
31
+
32
+ /** A verified, downloadable client artifact declared by a plugin. */
33
+ export interface PluginArtifact {
34
+ /** HTTPS URL the artifact is downloaded from. */
35
+ readonly url: string;
36
+ /** Lowercase 64-char hex SHA-256 the download is verified against. */
37
+ readonly sha256: string;
38
+ /**
39
+ * Optional human label for the download affordance — useful when a plugin
40
+ * ships more than one artifact (e.g. "Download for macOS", "Apple Silicon").
41
+ * Absent (or blank) when the plugin doesn't name it; clients fall back to a
42
+ * generic label.
43
+ */
44
+ readonly label?: string;
45
+ }
46
+
47
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
48
+
49
+ /**
50
+ * Schema for a complete artifact descriptor. The URL must be absolute and
51
+ * `https:` (an artifact fetched over plaintext defeats the integrity story);
52
+ * the digest must be canonical lowercase hex so client-side comparison is a
53
+ * plain string equality with no normalization step.
54
+ */
55
+ const PluginArtifactSchema = z.object({
56
+ url: z
57
+ .string()
58
+ .url()
59
+ .refine((u) => u.startsWith("https://"), {
60
+ message: "artifact url must be an https:// URL",
61
+ }),
62
+ sha256: z
63
+ .string()
64
+ .regex(SHA256_HEX_RE, "artifact sha256 must be 64 lowercase hex chars"),
65
+ // Optional, non-critical metadata: a malformed label must never nullify an
66
+ // otherwise-valid `url` + `sha256`, so a wrong-typed value falls back to
67
+ // `undefined` rather than failing the whole descriptor.
68
+ label: z.string().optional().catch(undefined),
69
+ });
70
+
71
+ /**
72
+ * Read `vellum.artifact` from an already-parsed `package.json` value and
73
+ * return it only when it is a complete, well-formed descriptor. Any shape
74
+ * problem — missing block, wrong types, non-https URL, placeholder/empty
75
+ * `sha256` — yields `null` rather than throwing, so callers can union this
76
+ * across sources without per-source error handling.
77
+ */
78
+ export function parsePluginArtifact(
79
+ packageJson: unknown,
80
+ ): PluginArtifact | null {
81
+ if (
82
+ typeof packageJson !== "object" ||
83
+ packageJson === null ||
84
+ Array.isArray(packageJson)
85
+ ) {
86
+ return null;
87
+ }
88
+ const vellum = (packageJson as Record<string, unknown>).vellum;
89
+ if (typeof vellum !== "object" || vellum === null || Array.isArray(vellum)) {
90
+ return null;
91
+ }
92
+ const artifact = (vellum as Record<string, unknown>).artifact;
93
+ const parsed = PluginArtifactSchema.safeParse(artifact);
94
+ if (!parsed.success) return null;
95
+ // A blank or whitespace-only label is treated as absent so it never
96
+ // invalidates an otherwise well-formed `url` + `sha256` descriptor.
97
+ const label = parsed.data.label?.trim();
98
+ return {
99
+ url: parsed.data.url,
100
+ sha256: parsed.data.sha256,
101
+ ...(label ? { label } : {}),
102
+ };
103
+ }
@@ -33,6 +33,7 @@ import {
33
33
  type FetchLike,
34
34
  sanitizePluginName,
35
35
  } from "./install-from-github.js";
36
+ import { parsePluginArtifact, type PluginArtifact } from "./plugin-artifact.js";
36
37
  import {
37
38
  fetchMarketplaceEntries,
38
39
  type MarketplaceEntry,
@@ -56,6 +57,7 @@ interface PluginManifestFields {
56
57
  readonly description: string | null;
57
58
  readonly homepage: string | null;
58
59
  readonly license: string | null;
60
+ readonly artifact: PluginArtifact | null;
59
61
  }
60
62
 
61
63
  /** Options that control which plugin to resolve and at what ref. */
@@ -97,6 +99,13 @@ export interface PluginDetails {
97
99
  readonly readme: string | null;
98
100
  /** Git ref the catalog metadata / README were resolved at. */
99
101
  readonly ref: string;
102
+ /**
103
+ * Prebuilt client artifact (download URL + sha256) declared in the
104
+ * plugin's `package.json` `vellum.artifact`, resolved from the installed
105
+ * copy first then the repo; `null` when the plugin ships none or its
106
+ * descriptor is incomplete (e.g. a placeholder `sha256`).
107
+ */
108
+ readonly artifact: PluginArtifact | null;
100
109
  }
101
110
 
102
111
  /** No installed copy and no catalog/source entry claims the name. */
@@ -175,6 +184,7 @@ export async function getPluginDetails(
175
184
  source,
176
185
  readme,
177
186
  ref,
187
+ artifact: local.manifest.artifact ?? remote.manifest.artifact,
178
188
  };
179
189
  }
180
190
 
@@ -342,7 +352,13 @@ function githubFetch(
342
352
  }
343
353
 
344
354
  function emptyManifest(): PluginManifestFields {
345
- return { version: null, description: null, homepage: null, license: null };
355
+ return {
356
+ version: null,
357
+ description: null,
358
+ homepage: null,
359
+ license: null,
360
+ artifact: null,
361
+ };
346
362
  }
347
363
 
348
364
  function safeParseManifest(raw: string): PluginManifestFields {
@@ -365,6 +381,7 @@ function parseManifest(raw: string): PluginManifestFields {
365
381
  description: typeof meta.description === "string" ? meta.description : null,
366
382
  homepage: typeof meta.homepage === "string" ? meta.homepage : null,
367
383
  license: normalizeLicense(meta.license),
384
+ artifact: parsePluginArtifact(parsed),
368
385
  };
369
386
  }
370
387
 
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Content fingerprint of an installed plugin tree, used to detect local
3
+ * modifications after install.
4
+ *
5
+ * A plugin install is a flattened snapshot of a commit — the `.git` metadata is
6
+ * stripped during materialization (see {@link ./install-from-github}), so there
7
+ * is no working tree to ask `git status`. To tell whether a user has edited an
8
+ * installed copy, install records a per-file digest of the materialized tree in
9
+ * the provenance sidecar; later a recompute over the on-disk copy is compared
10
+ * against that baseline.
11
+ *
12
+ * The fingerprint is a one-way digest map — it answers "did this change?" and
13
+ * "which files?", but cannot reconstruct the original bytes. Producing an
14
+ * actual diff or a 3-way merge instead re-derives the baseline from the
15
+ * recorded immutable commit SHA (a separate concern from this module).
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { readdirSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ /** Digest algorithm recorded alongside the file map, for forward compatibility. */
23
+ export type FingerprintAlgorithm = "sha256";
24
+
25
+ /**
26
+ * Per-file content digest of a plugin tree. Keys are POSIX-style
27
+ * (forward-slash) paths relative to the plugin root so the baseline is stable
28
+ * across platforms; values are lowercase hex digests of each file's bytes.
29
+ */
30
+ export interface Fingerprint {
31
+ readonly algorithm: FingerprintAlgorithm;
32
+ readonly files: Readonly<Record<string, string>>;
33
+ }
34
+
35
+ /**
36
+ * Difference between a recorded fingerprint and the current on-disk tree.
37
+ * Paths are POSIX-relative, matching {@link Fingerprint.files}. A rename
38
+ * surfaces as one `removed` plus one `added` entry.
39
+ */
40
+ export interface FingerprintComparison {
41
+ /** Present in both, but the content digest differs. */
42
+ readonly modified: readonly string[];
43
+ /** Present on disk, absent from the recorded baseline. */
44
+ readonly added: readonly string[];
45
+ /** Recorded in the baseline, absent on disk. */
46
+ readonly removed: readonly string[];
47
+ /** True when the on-disk tree exactly matches the recorded baseline. */
48
+ readonly clean: boolean;
49
+ }
50
+
51
+ function hashFile(absPath: string): string {
52
+ return createHash("sha256").update(readFileSync(absPath)).digest("hex");
53
+ }
54
+
55
+ /**
56
+ * Walk `root` and return a content digest for every regular file, keyed by its
57
+ * POSIX-relative path. Symlinks are skipped (the loader does not follow them,
58
+ * and install never materializes them); top-level entries named in `exclude`
59
+ * are skipped so the provenance sidecar never fingerprints itself.
60
+ */
61
+ export function computeFingerprint(
62
+ root: string,
63
+ exclude: readonly string[] = [],
64
+ ): Fingerprint {
65
+ const excluded = new Set(exclude);
66
+ const files: Record<string, string> = {};
67
+
68
+ const walk = (relDir: string): void => {
69
+ const absDir = relDir ? join(root, relDir) : root;
70
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
71
+ if (relDir === "" && excluded.has(entry.name)) continue;
72
+ // Only regular files contribute to the digest; symlinks are never part of
73
+ // a materialized install and a directory is descended into, not hashed.
74
+ if (entry.isSymbolicLink()) continue;
75
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
76
+ if (entry.isDirectory()) {
77
+ walk(rel);
78
+ } else if (entry.isFile()) {
79
+ files[rel] = hashFile(join(root, rel));
80
+ }
81
+ }
82
+ };
83
+
84
+ walk("");
85
+ return { algorithm: "sha256", files };
86
+ }
87
+
88
+ /**
89
+ * Compare the current contents of `root` against a recorded fingerprint,
90
+ * applying the same `exclude` used to compute the baseline so the sidecar is
91
+ * not counted as an addition.
92
+ */
93
+ export function compareFingerprint(
94
+ root: string,
95
+ baseline: Fingerprint,
96
+ exclude: readonly string[] = [],
97
+ ): FingerprintComparison {
98
+ const current = computeFingerprint(root, exclude).files;
99
+ const modified: string[] = [];
100
+ const added: string[] = [];
101
+ const removed: string[] = [];
102
+
103
+ for (const [path, digest] of Object.entries(current)) {
104
+ const recorded = baseline.files[path];
105
+ if (recorded === undefined) added.push(path);
106
+ else if (recorded !== digest) modified.push(path);
107
+ }
108
+ for (const path of Object.keys(baseline.files)) {
109
+ if (current[path] === undefined) removed.push(path);
110
+ }
111
+
112
+ modified.sort();
113
+ added.sort();
114
+ removed.sort();
115
+ return {
116
+ modified,
117
+ added,
118
+ removed,
119
+ clean: modified.length === 0 && added.length === 0 && removed.length === 0,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Parse a fingerprint from already-decoded JSON. Lenient by design — any shape
125
+ * problem yields `null` so an older or hand-edited sidecar simply reports "no
126
+ * recorded baseline" rather than throwing.
127
+ */
128
+ export function parseFingerprint(value: unknown): Fingerprint | null {
129
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
130
+ return null;
131
+ }
132
+ const obj = value as Record<string, unknown>;
133
+ if (obj.algorithm !== "sha256") return null;
134
+ const rawFiles = obj.files;
135
+ if (
136
+ typeof rawFiles !== "object" ||
137
+ rawFiles === null ||
138
+ Array.isArray(rawFiles)
139
+ ) {
140
+ return null;
141
+ }
142
+ const files: Record<string, string> = {};
143
+ for (const [path, digest] of Object.entries(rawFiles)) {
144
+ if (typeof digest !== "string") return null;
145
+ files[path] = digest;
146
+ }
147
+ return { algorithm: "sha256", files };
148
+ }
149
+
150
+ /**
151
+ * Aggregate SHA-256 digest over a tree's contents, returned as `v2:<hex>`.
152
+ *
153
+ * This is the same scheme skills record in their `install-meta.json`
154
+ * `contentHash` (see `src/skills/install-meta.ts`): files are visited in
155
+ * POSIX-relative path order and each contributes a length-prefixed path
156
+ * segment followed by its length-prefixed bytes, so neither path/content
157
+ * boundaries nor reordering can collide. The `v2:` prefix marks the hashing
158
+ * scheme so it can evolve without ambiguity. Unlike {@link Fingerprint}, this
159
+ * is a single whole-tree digest — useful as a compact integrity signal
160
+ * alongside the per-file map. Symlinks are skipped and top-level entries named
161
+ * in `exclude` (e.g. the sidecar itself) are omitted, matching
162
+ * {@link computeFingerprint}.
163
+ */
164
+ export function computeContentHash(
165
+ root: string,
166
+ exclude: readonly string[] = [],
167
+ ): string {
168
+ const excluded = new Set(exclude);
169
+ const entries: Array<{ rel: string; abs: string }> = [];
170
+
171
+ const walk = (relDir: string): void => {
172
+ const absDir = relDir ? join(root, relDir) : root;
173
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
174
+ if (relDir === "" && excluded.has(entry.name)) continue;
175
+ if (entry.isSymbolicLink()) continue;
176
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
177
+ if (entry.isDirectory()) {
178
+ walk(rel);
179
+ } else if (entry.isFile()) {
180
+ entries.push({ rel, abs: join(root, rel) });
181
+ }
182
+ }
183
+ };
184
+ walk("");
185
+ entries.sort((a, b) => a.rel.localeCompare(b.rel));
186
+
187
+ const hash = createHash("sha256");
188
+ for (const { rel, abs } of entries) {
189
+ const pathBuf = Buffer.from(rel, "utf-8");
190
+ const content = readFileSync(abs);
191
+ hash.update(`${pathBuf.length}:`);
192
+ hash.update(pathBuf);
193
+ hash.update(`${content.length}:`);
194
+ hash.update(content);
195
+ }
196
+ return `v2:${hash.digest("hex")}`;
197
+ }