@vellumai/assistant 0.4.49 → 0.4.50

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 (239) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/memory.md +180 -119
  4. package/package.json +2 -2
  5. package/src/__tests__/agent-loop.test.ts +3 -1
  6. package/src/__tests__/anthropic-provider.test.ts +114 -23
  7. package/src/__tests__/approval-cascade.test.ts +1 -15
  8. package/src/__tests__/approval-routes-http.test.ts +2 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  10. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  11. package/src/__tests__/checker.test.ts +13 -0
  12. package/src/__tests__/config-schema.test.ts +1 -68
  13. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  14. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  15. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  16. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  17. package/src/__tests__/credential-vault-unit.test.ts +4 -0
  18. package/src/__tests__/credential-vault.test.ts +13 -1
  19. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  20. package/src/__tests__/date-context.test.ts +93 -77
  21. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  22. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  23. package/src/__tests__/history-repair.test.ts +245 -0
  24. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  25. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  26. package/src/__tests__/invite-redemption-service.test.ts +65 -1
  27. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  28. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  29. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  30. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  31. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  32. package/src/__tests__/memory-regressions.test.ts +477 -2841
  33. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  34. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  35. package/src/__tests__/mime-builder.test.ts +28 -0
  36. package/src/__tests__/native-web-search.test.ts +1 -0
  37. package/src/__tests__/oauth-cli.test.ts +572 -5
  38. package/src/__tests__/oauth-store.test.ts +120 -6
  39. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  40. package/src/__tests__/registry.test.ts +0 -1
  41. package/src/__tests__/relay-server.test.ts +46 -1
  42. package/src/__tests__/schedule-tools.test.ts +32 -0
  43. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  44. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  45. package/src/__tests__/secure-keys.test.ts +7 -2
  46. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  47. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  48. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  49. package/src/__tests__/session-agent-loop.test.ts +19 -15
  50. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  51. package/src/__tests__/session-error.test.ts +124 -2
  52. package/src/__tests__/session-history-web-search.test.ts +918 -0
  53. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  54. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  55. package/src/__tests__/session-queue.test.ts +37 -27
  56. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  57. package/src/__tests__/session-slash-known.test.ts +1 -15
  58. package/src/__tests__/session-slash-queue.test.ts +1 -15
  59. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  60. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  61. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  62. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  63. package/src/__tests__/skills-install-extract.test.ts +93 -0
  64. package/src/__tests__/skillssh-registry.test.ts +451 -0
  65. package/src/__tests__/trust-store.test.ts +15 -0
  66. package/src/__tests__/voice-invite-redemption.test.ts +32 -1
  67. package/src/agent/ax-tree-compaction.test.ts +51 -0
  68. package/src/agent/loop.ts +39 -12
  69. package/src/approvals/AGENTS.md +1 -1
  70. package/src/approvals/guardian-request-resolvers.ts +14 -2
  71. package/src/bundler/compiler-tools.ts +66 -2
  72. package/src/calls/call-domain.ts +132 -0
  73. package/src/calls/call-store.ts +6 -0
  74. package/src/calls/relay-server.ts +43 -5
  75. package/src/calls/relay-setup-router.ts +17 -1
  76. package/src/calls/twilio-config.ts +1 -1
  77. package/src/calls/types.ts +3 -1
  78. package/src/cli/commands/doctor.ts +4 -3
  79. package/src/cli/commands/mcp.ts +46 -59
  80. package/src/cli/commands/memory.ts +16 -165
  81. package/src/cli/commands/oauth/apps.ts +31 -2
  82. package/src/cli/commands/oauth/connections.ts +431 -97
  83. package/src/cli/commands/oauth/providers.ts +15 -1
  84. package/src/cli/commands/sessions.ts +5 -2
  85. package/src/cli/commands/skills.ts +173 -1
  86. package/src/cli/http-client.ts +0 -20
  87. package/src/cli/main-screen.tsx +2 -2
  88. package/src/cli/program.ts +5 -6
  89. package/src/cli.ts +4 -10
  90. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  91. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  92. package/src/config/bundled-tool-registry.ts +2 -5
  93. package/src/config/schema.ts +1 -12
  94. package/src/config/schemas/memory-lifecycle.ts +0 -9
  95. package/src/config/schemas/memory-processing.ts +0 -180
  96. package/src/config/schemas/memory-retrieval.ts +32 -104
  97. package/src/config/schemas/memory.ts +0 -10
  98. package/src/config/types.ts +0 -4
  99. package/src/context/window-manager.ts +4 -1
  100. package/src/daemon/config-watcher.ts +61 -3
  101. package/src/daemon/daemon-control.ts +1 -1
  102. package/src/daemon/date-context.ts +114 -31
  103. package/src/daemon/handlers/sessions.ts +18 -13
  104. package/src/daemon/handlers/skills.ts +20 -1
  105. package/src/daemon/history-repair.ts +72 -8
  106. package/src/daemon/host-cu-proxy.ts +55 -26
  107. package/src/daemon/lifecycle.ts +31 -3
  108. package/src/daemon/mcp-reload-service.ts +2 -2
  109. package/src/daemon/message-types/computer-use.ts +1 -12
  110. package/src/daemon/message-types/memory.ts +4 -16
  111. package/src/daemon/message-types/messages.ts +1 -0
  112. package/src/daemon/message-types/sessions.ts +4 -0
  113. package/src/daemon/server.ts +12 -1
  114. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  115. package/src/daemon/session-agent-loop.ts +334 -48
  116. package/src/daemon/session-error.ts +89 -6
  117. package/src/daemon/session-history.ts +17 -7
  118. package/src/daemon/session-media-retry.ts +6 -2
  119. package/src/daemon/session-memory.ts +69 -149
  120. package/src/daemon/session-process.ts +10 -1
  121. package/src/daemon/session-runtime-assembly.ts +49 -19
  122. package/src/daemon/session-surfaces.ts +4 -1
  123. package/src/daemon/session-tool-setup.ts +7 -1
  124. package/src/daemon/session.ts +12 -2
  125. package/src/instrument.ts +61 -1
  126. package/src/memory/admin.ts +2 -191
  127. package/src/memory/canonical-guardian-store.ts +38 -2
  128. package/src/memory/conversation-crud.ts +0 -33
  129. package/src/memory/conversation-queries.ts +22 -3
  130. package/src/memory/db-init.ts +28 -0
  131. package/src/memory/embedding-backend.ts +84 -8
  132. package/src/memory/embedding-types.ts +9 -1
  133. package/src/memory/indexer.ts +7 -46
  134. package/src/memory/items-extractor.ts +274 -76
  135. package/src/memory/job-handlers/backfill.ts +2 -127
  136. package/src/memory/job-handlers/cleanup.ts +2 -16
  137. package/src/memory/job-handlers/extraction.ts +2 -138
  138. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  139. package/src/memory/job-handlers/summarization.ts +3 -148
  140. package/src/memory/job-utils.ts +21 -59
  141. package/src/memory/jobs-store.ts +1 -159
  142. package/src/memory/jobs-worker.ts +9 -52
  143. package/src/memory/migrations/104-core-indexes.ts +3 -3
  144. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  145. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  146. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  147. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  148. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  149. package/src/memory/migrations/154-drop-fts.ts +20 -0
  150. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  151. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  152. package/src/memory/migrations/index.ts +7 -0
  153. package/src/memory/qdrant-client.ts +148 -51
  154. package/src/memory/raw-query.ts +1 -1
  155. package/src/memory/retriever.test.ts +294 -273
  156. package/src/memory/retriever.ts +421 -645
  157. package/src/memory/schema/calls.ts +2 -0
  158. package/src/memory/schema/memory-core.ts +3 -48
  159. package/src/memory/schema/oauth.ts +2 -0
  160. package/src/memory/search/formatting.ts +263 -176
  161. package/src/memory/search/lexical.ts +1 -254
  162. package/src/memory/search/ranking.ts +0 -455
  163. package/src/memory/search/semantic.ts +100 -14
  164. package/src/memory/search/staleness.ts +47 -0
  165. package/src/memory/search/tier-classifier.ts +21 -0
  166. package/src/memory/search/types.ts +15 -77
  167. package/src/memory/task-memory-cleanup.ts +4 -6
  168. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  169. package/src/oauth/byo-connection.test.ts +8 -1
  170. package/src/oauth/oauth-store.ts +113 -27
  171. package/src/oauth/seed-providers.ts +6 -0
  172. package/src/oauth/token-persistence.ts +11 -3
  173. package/src/permissions/defaults.ts +1 -0
  174. package/src/permissions/trust-store.ts +23 -1
  175. package/src/playbooks/playbook-compiler.ts +1 -1
  176. package/src/prompts/system-prompt.ts +18 -2
  177. package/src/providers/anthropic/client.ts +56 -126
  178. package/src/providers/types.ts +7 -1
  179. package/src/runtime/AGENTS.md +9 -0
  180. package/src/runtime/auth/route-policy.ts +6 -3
  181. package/src/runtime/guardian-reply-router.ts +24 -22
  182. package/src/runtime/http-server.ts +2 -2
  183. package/src/runtime/invite-redemption-service.ts +19 -1
  184. package/src/runtime/invite-service.ts +25 -0
  185. package/src/runtime/pending-interactions.ts +2 -2
  186. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  187. package/src/runtime/routes/conversation-routes.ts +9 -1
  188. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  189. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  190. package/src/runtime/routes/memory-item-routes.ts +503 -0
  191. package/src/runtime/routes/session-management-routes.ts +3 -3
  192. package/src/runtime/routes/settings-routes.ts +2 -2
  193. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  194. package/src/runtime/routes/workspace-routes.ts +2 -1
  195. package/src/security/keychain-broker-client.ts +17 -4
  196. package/src/security/secure-keys.ts +25 -3
  197. package/src/security/token-manager.ts +36 -36
  198. package/src/skills/catalog-install.ts +74 -18
  199. package/src/skills/skillssh-registry.ts +503 -0
  200. package/src/tools/assets/search.ts +5 -1
  201. package/src/tools/computer-use/definitions.ts +0 -10
  202. package/src/tools/computer-use/registry.ts +1 -1
  203. package/src/tools/credentials/vault.ts +1 -3
  204. package/src/tools/memory/definitions.ts +4 -13
  205. package/src/tools/memory/handlers.test.ts +83 -103
  206. package/src/tools/memory/handlers.ts +50 -85
  207. package/src/tools/schedule/create.ts +8 -1
  208. package/src/tools/schedule/update.ts +8 -1
  209. package/src/tools/skills/load.ts +25 -2
  210. package/src/__tests__/clarification-resolver.test.ts +0 -193
  211. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  212. package/src/__tests__/conflict-policy.test.ts +0 -269
  213. package/src/__tests__/conflict-store.test.ts +0 -372
  214. package/src/__tests__/contradiction-checker.test.ts +0 -361
  215. package/src/__tests__/entity-extractor.test.ts +0 -211
  216. package/src/__tests__/entity-search.test.ts +0 -1117
  217. package/src/__tests__/profile-compiler.test.ts +0 -392
  218. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  219. package/src/__tests__/session-profile-injection.test.ts +0 -557
  220. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  221. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  222. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  223. package/src/daemon/session-conflict-gate.ts +0 -167
  224. package/src/daemon/session-dynamic-profile.ts +0 -77
  225. package/src/memory/clarification-resolver.ts +0 -417
  226. package/src/memory/conflict-intent.ts +0 -205
  227. package/src/memory/conflict-policy.ts +0 -127
  228. package/src/memory/conflict-store.ts +0 -410
  229. package/src/memory/contradiction-checker.ts +0 -508
  230. package/src/memory/entity-extractor.ts +0 -535
  231. package/src/memory/format-recall.ts +0 -47
  232. package/src/memory/fts-reconciler.ts +0 -165
  233. package/src/memory/job-handlers/conflict.ts +0 -200
  234. package/src/memory/profile-compiler.ts +0 -195
  235. package/src/memory/recall-cache.ts +0 -117
  236. package/src/memory/search/entity.ts +0 -535
  237. package/src/memory/search/query-expansion.test.ts +0 -70
  238. package/src/memory/search/query-expansion.ts +0 -118
  239. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -0,0 +1,93 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+
6
+ import { extractTarToDir } from "../skills/catalog-install.js";
7
+
8
+ let tempDir: string;
9
+
10
+ function makeTarEntry(name: string, content: string): Buffer {
11
+ const header = Buffer.alloc(512, 0);
12
+ const nameBuffer = Buffer.from(name, "utf-8");
13
+ nameBuffer.copy(header, 0, 0, Math.min(nameBuffer.length, 100));
14
+
15
+ const mode = Buffer.from("0000644\0", "ascii");
16
+ mode.copy(header, 100);
17
+ Buffer.from("0000000\0", "ascii").copy(header, 108); // uid
18
+ Buffer.from("0000000\0", "ascii").copy(header, 116); // gid
19
+
20
+ const sizeOct = content.length.toString(8).padStart(11, "0") + "\0";
21
+ Buffer.from(sizeOct, "ascii").copy(header, 124);
22
+
23
+ Buffer.from("00000000000\0", "ascii").copy(header, 136); // mtime
24
+ Buffer.from(" ", "ascii").copy(header, 148); // checksum placeholder
25
+ header[156] = "0".charCodeAt(0);
26
+ Buffer.from("ustar\0", "ascii").copy(header, 257);
27
+ Buffer.from("00", "ascii").copy(header, 263);
28
+
29
+ let sum = 0;
30
+ for (let i = 0; i < 512; i += 1) sum += header[i] ?? 0;
31
+ const checksum = sum.toString(8).padStart(6, "0");
32
+ Buffer.from(`${checksum}\0 `, "ascii").copy(header, 148);
33
+
34
+ const data = Buffer.from(content, "utf-8");
35
+ const paddedSize = Math.ceil(data.length / 512) * 512;
36
+ const padded = Buffer.alloc(paddedSize, 0);
37
+ data.copy(padded);
38
+
39
+ return Buffer.concat([header, padded]);
40
+ }
41
+
42
+ function makeTar(entries: Array<{ name: string; content: string }>): Buffer {
43
+ const body = entries.map((entry) => makeTarEntry(entry.name, entry.content));
44
+ return Buffer.concat([...body, Buffer.alloc(1024, 0)]);
45
+ }
46
+
47
+ beforeEach(() => {
48
+ tempDir = join(
49
+ tmpdir(),
50
+ `skills-extract-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
51
+ );
52
+ mkdirSync(tempDir, { recursive: true });
53
+ });
54
+
55
+ afterEach(() => {
56
+ rmSync(tempDir, { recursive: true, force: true });
57
+ });
58
+
59
+ describe("extractTarToDir", () => {
60
+ test("extracts valid files and detects SKILL.md", () => {
61
+ const tar = makeTar([
62
+ { name: "SKILL.md", content: "# demo\n" },
63
+ { name: "scripts/run.sh", content: "echo ok\n" },
64
+ ]);
65
+
66
+ const foundSkillMd = extractTarToDir(tar, tempDir);
67
+
68
+ expect(foundSkillMd).toBe(true);
69
+ expect(readFileSync(join(tempDir, "SKILL.md"), "utf-8")).toBe("# demo\n");
70
+ expect(readFileSync(join(tempDir, "scripts", "run.sh"), "utf-8")).toBe(
71
+ "echo ok\n",
72
+ );
73
+ });
74
+
75
+ test("rejects traversal and absolute archive paths", () => {
76
+ const tar = makeTar([
77
+ { name: "SKILL.md", content: "# demo\n" },
78
+ { name: "../../escape.txt", content: "nope\n" },
79
+ { name: "..\\..\\win-escape.txt", content: "nope\n" },
80
+ { name: "/absolute.txt", content: "nope\n" },
81
+ { name: "C:/windows.txt", content: "nope\n" },
82
+ ]);
83
+
84
+ const foundSkillMd = extractTarToDir(tar, tempDir);
85
+
86
+ expect(foundSkillMd).toBe(true);
87
+ expect(existsSync(join(tempDir, "escape.txt"))).toBe(false);
88
+ expect(existsSync(join(tempDir, "win-escape.txt"))).toBe(false);
89
+ expect(existsSync(join(tempDir, "absolute.txt"))).toBe(false);
90
+ expect(existsSync(join(tempDir, "windows.txt"))).toBe(false);
91
+ expect(readFileSync(join(tempDir, "SKILL.md"), "utf-8")).toBe("# demo\n");
92
+ });
93
+ });
@@ -0,0 +1,451 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type {
4
+ AuditResponse,
5
+ SkillAuditData,
6
+ SkillsShSearchResult,
7
+ } from "../skills/skillssh-registry.js";
8
+ import {
9
+ fetchSkillAudits,
10
+ fetchSkillFromGitHub,
11
+ formatAuditBadges,
12
+ providerDisplayName,
13
+ resolveSkillSource,
14
+ riskToDisplay,
15
+ searchSkillsRegistry,
16
+ validateSkillSlug,
17
+ } from "../skills/skillssh-registry.js";
18
+
19
+ // ─── Fetch mock helpers ──────────────────────────────────────────────────────
20
+
21
+ const originalFetch = globalThis.fetch;
22
+
23
+ let mockFetchImpl: (url: string | URL | Request) => Promise<Response>;
24
+
25
+ beforeEach(() => {
26
+ mockFetchImpl = () =>
27
+ Promise.resolve(new Response("not mocked", { status: 500 }));
28
+ globalThis.fetch = mock((input: string | URL | Request) =>
29
+ mockFetchImpl(typeof input === "string" ? input : input.toString()),
30
+ ) as unknown as typeof fetch;
31
+ });
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ });
36
+
37
+ // ─── searchSkillsRegistry ────────────────────────────────────────────────────
38
+
39
+ describe("searchSkillsRegistry", () => {
40
+ test("sends correct query parameters and returns results", async () => {
41
+ const mockResults: SkillsShSearchResult[] = [
42
+ {
43
+ id: "vercel-labs/agent-skills/vercel-react-best-practices",
44
+ skillId: "vercel-react-best-practices",
45
+ name: "Vercel React Best Practices",
46
+ installs: 1200,
47
+ source: "vercel-labs/agent-skills",
48
+ },
49
+ ];
50
+
51
+ mockFetchImpl = (url: string | URL | Request) => {
52
+ const urlStr = url.toString();
53
+ expect(urlStr).toContain("skills.sh/api/search");
54
+ expect(urlStr).toContain("q=react");
55
+ expect(urlStr).toContain("limit=5");
56
+ return Promise.resolve(
57
+ new Response(JSON.stringify({ skills: mockResults }), {
58
+ status: 200,
59
+ headers: { "Content-Type": "application/json" },
60
+ }),
61
+ );
62
+ };
63
+
64
+ const results = await searchSkillsRegistry("react", 5);
65
+ expect(results).toEqual(mockResults);
66
+ });
67
+
68
+ test("omits limit parameter when not provided", async () => {
69
+ mockFetchImpl = (url: string | URL | Request) => {
70
+ const urlStr = url.toString();
71
+ expect(urlStr).not.toContain("limit=");
72
+ return Promise.resolve(
73
+ new Response(JSON.stringify({ skills: [] }), {
74
+ status: 200,
75
+ headers: { "Content-Type": "application/json" },
76
+ }),
77
+ );
78
+ };
79
+
80
+ const results = await searchSkillsRegistry("test");
81
+ expect(results).toEqual([]);
82
+ });
83
+
84
+ test("throws on non-OK response", async () => {
85
+ mockFetchImpl = () =>
86
+ Promise.resolve(new Response("Not Found", { status: 404 }));
87
+
88
+ await expect(searchSkillsRegistry("bad-query")).rejects.toThrow(
89
+ "skills.sh search failed: HTTP 404",
90
+ );
91
+ });
92
+ });
93
+
94
+ // ─── fetchSkillAudits ────────────────────────────────────────────────────────
95
+
96
+ describe("fetchSkillAudits", () => {
97
+ test("sends correct parameters and returns audit data", async () => {
98
+ const mockAudits: AuditResponse = {
99
+ "vercel-react-best-practices": {
100
+ ath: {
101
+ risk: "safe",
102
+ alerts: 0,
103
+ score: 100,
104
+ analyzedAt: "2025-01-15T00:00:00Z",
105
+ },
106
+ socket: {
107
+ risk: "low",
108
+ alerts: 1,
109
+ score: 95,
110
+ analyzedAt: "2025-01-15T00:00:00Z",
111
+ },
112
+ },
113
+ };
114
+
115
+ mockFetchImpl = (url: string | URL | Request) => {
116
+ const urlStr = url.toString();
117
+ expect(urlStr).toContain("add-skill.vercel.sh/audit");
118
+ expect(urlStr).toContain("source=vercel-labs%2Fagent-skills");
119
+ expect(urlStr).toContain(
120
+ "skills=vercel-react-best-practices%2Canother-skill",
121
+ );
122
+ return Promise.resolve(
123
+ new Response(JSON.stringify(mockAudits), {
124
+ status: 200,
125
+ headers: { "Content-Type": "application/json" },
126
+ }),
127
+ );
128
+ };
129
+
130
+ const audits = await fetchSkillAudits("vercel-labs/agent-skills", [
131
+ "vercel-react-best-practices",
132
+ "another-skill",
133
+ ]);
134
+ expect(audits).toEqual(mockAudits);
135
+ });
136
+
137
+ test("returns empty object for empty slugs list", async () => {
138
+ const audits = await fetchSkillAudits("some/source", []);
139
+ expect(audits).toEqual({});
140
+ // fetch should not have been called
141
+ expect(globalThis.fetch).not.toHaveBeenCalled();
142
+ });
143
+
144
+ test("throws on non-OK response", async () => {
145
+ mockFetchImpl = () =>
146
+ Promise.resolve(new Response("Internal Server Error", { status: 500 }));
147
+
148
+ await expect(fetchSkillAudits("some/source", ["slug"])).rejects.toThrow(
149
+ "Audit fetch failed: HTTP 500",
150
+ );
151
+ });
152
+ });
153
+
154
+ // ─── Display helpers ─────────────────────────────────────────────────────────
155
+
156
+ describe("riskToDisplay", () => {
157
+ test("maps risk levels correctly", () => {
158
+ expect(riskToDisplay("safe")).toBe("PASS");
159
+ expect(riskToDisplay("low")).toBe("PASS");
160
+ expect(riskToDisplay("medium")).toBe("WARN");
161
+ expect(riskToDisplay("high")).toBe("FAIL");
162
+ expect(riskToDisplay("critical")).toBe("FAIL");
163
+ expect(riskToDisplay("unknown")).toBe("?");
164
+ });
165
+ });
166
+
167
+ describe("providerDisplayName", () => {
168
+ test("maps known providers", () => {
169
+ expect(providerDisplayName("ath")).toBe("ATH");
170
+ expect(providerDisplayName("socket")).toBe("Socket");
171
+ expect(providerDisplayName("snyk")).toBe("Snyk");
172
+ });
173
+
174
+ test("returns raw name for unknown providers", () => {
175
+ expect(providerDisplayName("custom-auditor")).toBe("custom-auditor");
176
+ });
177
+ });
178
+
179
+ describe("formatAuditBadges", () => {
180
+ test("formats multiple providers as badges", () => {
181
+ const auditData: SkillAuditData = {
182
+ ath: { risk: "safe", analyzedAt: "2025-01-15T00:00:00Z" },
183
+ socket: { risk: "safe", analyzedAt: "2025-01-15T00:00:00Z" },
184
+ snyk: { risk: "medium", analyzedAt: "2025-01-15T00:00:00Z" },
185
+ };
186
+ expect(formatAuditBadges(auditData)).toBe(
187
+ "Security: [ATH:PASS] [Socket:PASS] [Snyk:WARN]",
188
+ );
189
+ });
190
+
191
+ test("returns fallback message when no providers present", () => {
192
+ expect(formatAuditBadges({})).toBe("Security: no audit data");
193
+ });
194
+
195
+ test("handles single provider", () => {
196
+ const auditData: SkillAuditData = {
197
+ ath: { risk: "critical", analyzedAt: "2025-01-15T00:00:00Z" },
198
+ };
199
+ expect(formatAuditBadges(auditData)).toBe("Security: [ATH:FAIL]");
200
+ });
201
+ });
202
+
203
+ // ─── resolveSkillSource ─────────────────────────────────────────────────────
204
+
205
+ describe("resolveSkillSource", () => {
206
+ test("parses owner/repo@skill-name format", () => {
207
+ const result = resolveSkillSource("vercel-labs/skills@find-skills");
208
+ expect(result).toEqual({
209
+ owner: "vercel-labs",
210
+ repo: "skills",
211
+ skillSlug: "find-skills",
212
+ });
213
+ });
214
+
215
+ test("parses owner/repo/skill-name format", () => {
216
+ const result = resolveSkillSource("vercel-labs/skills/find-skills");
217
+ expect(result).toEqual({
218
+ owner: "vercel-labs",
219
+ repo: "skills",
220
+ skillSlug: "find-skills",
221
+ });
222
+ });
223
+
224
+ test("parses full GitHub URL with main branch", () => {
225
+ const result = resolveSkillSource(
226
+ "https://github.com/vercel-labs/skills/tree/main/skills/find-skills",
227
+ );
228
+ expect(result).toEqual({
229
+ owner: "vercel-labs",
230
+ repo: "skills",
231
+ skillSlug: "find-skills",
232
+ ref: "main",
233
+ });
234
+ });
235
+
236
+ test("parses full GitHub URL with non-main branch", () => {
237
+ const result = resolveSkillSource(
238
+ "https://github.com/some-org/repo/tree/develop/skills/my-skill",
239
+ );
240
+ expect(result).toEqual({
241
+ owner: "some-org",
242
+ repo: "repo",
243
+ skillSlug: "my-skill",
244
+ ref: "develop",
245
+ });
246
+ });
247
+
248
+ test("parses GitHub URL with trailing slash", () => {
249
+ const result = resolveSkillSource(
250
+ "https://github.com/owner/repo/tree/main/skills/skill-name/",
251
+ );
252
+ expect(result).toEqual({
253
+ owner: "owner",
254
+ repo: "repo",
255
+ skillSlug: "skill-name",
256
+ ref: "main",
257
+ });
258
+ });
259
+
260
+ test("throws on bare skill name (no owner/repo)", () => {
261
+ expect(() => resolveSkillSource("find-skills")).toThrow(
262
+ 'Invalid skill source "find-skills"',
263
+ );
264
+ });
265
+
266
+ test("throws on empty string", () => {
267
+ expect(() => resolveSkillSource("")).toThrow('Invalid skill source ""');
268
+ });
269
+
270
+ test("throws on owner-only format", () => {
271
+ expect(() => resolveSkillSource("vercel-labs")).toThrow(
272
+ 'Invalid skill source "vercel-labs"',
273
+ );
274
+ });
275
+
276
+ test("throws on owner/repo without skill", () => {
277
+ expect(() => resolveSkillSource("vercel-labs/skills")).toThrow(
278
+ 'Invalid skill source "vercel-labs/skills"',
279
+ );
280
+ });
281
+
282
+ test("rejects path traversal in @ format slug", () => {
283
+ expect(() => resolveSkillSource("owner/repo@../../malicious")).toThrow(
284
+ 'Invalid skill source "owner/repo@../../malicious"',
285
+ );
286
+ });
287
+
288
+ test("rejects uppercase slug in @ format", () => {
289
+ expect(() => resolveSkillSource("owner/repo@BadSlug")).toThrow(
290
+ 'Invalid skill source "owner/repo@BadSlug"',
291
+ );
292
+ });
293
+ });
294
+
295
+ // ─── validateSkillSlug ──────────────────────────────────────────────────────
296
+
297
+ describe("validateSkillSlug", () => {
298
+ test("accepts valid slugs", () => {
299
+ expect(() => validateSkillSlug("my-skill")).not.toThrow();
300
+ expect(() => validateSkillSlug("skill123")).not.toThrow();
301
+ expect(() => validateSkillSlug("my.skill")).not.toThrow();
302
+ expect(() => validateSkillSlug("my_skill")).not.toThrow();
303
+ });
304
+
305
+ test("rejects path traversal characters", () => {
306
+ expect(() => validateSkillSlug("../../malicious")).toThrow(
307
+ "path traversal",
308
+ );
309
+ expect(() => validateSkillSlug("foo/bar")).toThrow("path traversal");
310
+ expect(() => validateSkillSlug("foo\\bar")).toThrow("path traversal");
311
+ });
312
+
313
+ test("rejects slugs starting with special chars", () => {
314
+ expect(() => validateSkillSlug(".hidden")).toThrow();
315
+ expect(() => validateSkillSlug("-dash")).toThrow();
316
+ });
317
+
318
+ test("rejects empty input", () => {
319
+ expect(() => validateSkillSlug("")).toThrow("Skill slug is required");
320
+ });
321
+ });
322
+
323
+ // ─── fetchSkillFromGitHub ───────────────────────────────────────────────────
324
+
325
+ describe("fetchSkillFromGitHub", () => {
326
+ test("fetches from conventional skills/<slug>/ path", async () => {
327
+ mockFetchImpl = (url: string | URL | Request) => {
328
+ const urlStr = url.toString();
329
+ // Probe request for skills/my-skill
330
+ if (urlStr.includes("/contents/skills/my-skill")) {
331
+ return Promise.resolve(
332
+ new Response(
333
+ JSON.stringify([
334
+ {
335
+ name: "SKILL.md",
336
+ type: "file",
337
+ download_url: "https://raw.example.com/SKILL.md",
338
+ },
339
+ ]),
340
+ { status: 200, headers: { "Content-Type": "application/json" } },
341
+ ),
342
+ );
343
+ }
344
+ // File download
345
+ if (urlStr.includes("raw.example.com/SKILL.md")) {
346
+ return Promise.resolve(new Response("# My Skill", { status: 200 }));
347
+ }
348
+ return Promise.resolve(new Response("not found", { status: 404 }));
349
+ };
350
+
351
+ const files = await fetchSkillFromGitHub("owner", "repo", "my-skill");
352
+ expect(files["SKILL.md"]).toBe("# My Skill");
353
+ });
354
+
355
+ test("falls back to tree search when skills/<slug>/ returns 404", async () => {
356
+ mockFetchImpl = (url: string | URL | Request) => {
357
+ const urlStr = url.toString();
358
+ // Probe for conventional path returns 404
359
+ if (urlStr.includes("/contents/skills/csv")) {
360
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
361
+ }
362
+ // Tree search returns the skill at a non-standard path
363
+ if (urlStr.includes("/git/trees/")) {
364
+ return Promise.resolve(
365
+ new Response(
366
+ JSON.stringify({
367
+ tree: [
368
+ { path: "examples/skills/csv/SKILL.md", type: "blob" },
369
+ { path: "examples/skills/csv/scripts/filter.sh", type: "blob" },
370
+ { path: "README.md", type: "blob" },
371
+ ],
372
+ }),
373
+ { status: 200, headers: { "Content-Type": "application/json" } },
374
+ ),
375
+ );
376
+ }
377
+ // Subdirectory listing (must precede parent path check — both use
378
+ // .includes() and the parent path is a prefix of this one)
379
+ if (urlStr.includes("/contents/examples/skills/csv/scripts")) {
380
+ return Promise.resolve(
381
+ new Response(
382
+ JSON.stringify([
383
+ {
384
+ name: "filter.sh",
385
+ type: "file",
386
+ download_url: "https://raw.example.com/filter.sh",
387
+ },
388
+ ]),
389
+ { status: 200, headers: { "Content-Type": "application/json" } },
390
+ ),
391
+ );
392
+ }
393
+ // Contents API for the discovered path
394
+ if (urlStr.includes("/contents/examples/skills/csv")) {
395
+ return Promise.resolve(
396
+ new Response(
397
+ JSON.stringify([
398
+ {
399
+ name: "SKILL.md",
400
+ type: "file",
401
+ download_url: "https://raw.example.com/SKILL.md",
402
+ },
403
+ {
404
+ name: "scripts",
405
+ type: "dir",
406
+ download_url: null,
407
+ },
408
+ ]),
409
+ { status: 200, headers: { "Content-Type": "application/json" } },
410
+ ),
411
+ );
412
+ }
413
+ // File downloads
414
+ if (urlStr.includes("raw.example.com/SKILL.md")) {
415
+ return Promise.resolve(new Response("# CSV Skill", { status: 200 }));
416
+ }
417
+ if (urlStr.includes("raw.example.com/filter.sh")) {
418
+ return Promise.resolve(
419
+ new Response("#!/bin/bash\necho filter", { status: 200 }),
420
+ );
421
+ }
422
+ return Promise.resolve(new Response("not found", { status: 404 }));
423
+ };
424
+
425
+ const files = await fetchSkillFromGitHub("vercel-labs", "bash-tool", "csv");
426
+ expect(files["SKILL.md"]).toBe("# CSV Skill");
427
+ expect(files["scripts/filter.sh"]).toBe("#!/bin/bash\necho filter");
428
+ });
429
+
430
+ test("throws when skill not found in tree either", async () => {
431
+ mockFetchImpl = (url: string | URL | Request) => {
432
+ const urlStr = url.toString();
433
+ if (urlStr.includes("/contents/skills/missing")) {
434
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
435
+ }
436
+ if (urlStr.includes("/git/trees/")) {
437
+ return Promise.resolve(
438
+ new Response(
439
+ JSON.stringify({ tree: [{ path: "README.md", type: "blob" }] }),
440
+ { status: 200, headers: { "Content-Type": "application/json" } },
441
+ ),
442
+ );
443
+ }
444
+ return Promise.resolve(new Response("not found", { status: 404 }));
445
+ };
446
+
447
+ await expect(
448
+ fetchSkillFromGitHub("owner", "repo", "missing"),
449
+ ).rejects.toThrow("Searched skills/missing/ and the full repo tree");
450
+ });
451
+ });
@@ -777,6 +777,7 @@ describe("Trust Store", () => {
777
777
  "computer_use_click",
778
778
  "computer_use_drag",
779
779
  "computer_use_key",
780
+ "computer_use_observe",
780
781
  "computer_use_open_app",
781
782
  "computer_use_run_applescript",
782
783
  "computer_use_scroll",
@@ -900,6 +901,20 @@ describe("Trust Store", () => {
900
901
  );
901
902
  });
902
903
 
904
+ test("findHighestPriorityRule matches default ask for computer_use_observe", () => {
905
+ const match = findHighestPriorityRule(
906
+ "computer_use_observe",
907
+ ["computer_use_observe:"],
908
+ "/tmp",
909
+ );
910
+ expect(match).not.toBeNull();
911
+ expect(match!.id).toBe("default:ask-computer_use_observe-global");
912
+ expect(match!.decision).toBe("ask");
913
+ expect(match!.priority).toBe(
914
+ DEFAULT_PRIORITY_BY_ID.get("default:ask-computer_use_observe-global")!,
915
+ );
916
+ });
917
+
903
918
  test("bootstrap delete rule matches only when workingDir is the workspace dir", () => {
904
919
  const workspaceDir = join(testDir, "workspace");
905
920
  // Should match when workingDir is the workspace directory — the bootstrap
@@ -23,7 +23,10 @@ mock.module("../util/logger.js", () => ({
23
23
  }),
24
24
  }));
25
25
 
26
- import { findContactChannel } from "../contacts/contact-store.js";
26
+ import {
27
+ findContactChannel,
28
+ upsertContact,
29
+ } from "../contacts/contact-store.js";
27
30
  import { upsertContactChannel } from "../contacts/contacts-write.js";
28
31
  import { getSqlite, initializeDb, resetDb } from "../memory/db.js";
29
32
  import { createInvite, revokeInvite } from "../memory/invite-store.js";
@@ -354,4 +357,32 @@ describe("redeemVoiceInviteCode", () => {
354
357
 
355
358
  expect(result).toEqual({ ok: false, reason: "invalid_or_expired" });
356
359
  });
360
+
361
+ test("returns invalid_or_expired for a revoked guardian to prevent invite-based reactivation", () => {
362
+ const phone = "+15559998888";
363
+ const { code } = createVoiceInvite({ callerPhone: phone });
364
+
365
+ // Pre-create a guardian contact with a revoked phone channel
366
+ upsertContact({
367
+ displayName: "Guardian",
368
+ role: "guardian",
369
+ channels: [
370
+ {
371
+ type: "phone",
372
+ address: phone,
373
+ externalUserId: phone,
374
+ status: "revoked",
375
+ },
376
+ ],
377
+ });
378
+
379
+ const result = redeemVoiceInviteCode({
380
+ callerExternalUserId: phone,
381
+ sourceChannel: "phone",
382
+ code,
383
+ });
384
+
385
+ // Must reject — guardian channels are managed via the binding flow, not invites
386
+ expect(result).toEqual({ ok: false, reason: "invalid_or_expired" });
387
+ });
357
388
  });
@@ -182,6 +182,57 @@ describe("compactAxTreeHistory", () => {
182
182
  expect(result).toEqual([]);
183
183
  });
184
184
 
185
+ test("counts AX trees per block, not per message", () => {
186
+ // One message has two AX tree blocks — they should count as 2 trees
187
+ const messages: Message[] = [
188
+ {
189
+ role: "user",
190
+ content: [
191
+ {
192
+ type: "tool_result",
193
+ tool_use_id: "t1a",
194
+ content: "<ax-tree>\ntree-1a\n</ax-tree>",
195
+ is_error: false,
196
+ },
197
+ {
198
+ type: "tool_result",
199
+ tool_use_id: "t1b",
200
+ content: "<ax-tree>\ntree-1b\n</ax-tree>",
201
+ is_error: false,
202
+ },
203
+ ],
204
+ },
205
+ assistantText("ok"),
206
+ axTreeToolResult("t2", "tree-2"),
207
+ ];
208
+
209
+ const result = compactAxTreeHistory(messages);
210
+
211
+ // 3 total AX tree blocks, keep last 2 → strip only first block (t1a)
212
+ const msg0 = result[0];
213
+ const block0 = msg0.content[0];
214
+ expect(block0.type).toBe("tool_result");
215
+ if (block0.type === "tool_result") {
216
+ expect(block0.content).toContain("<ax_tree_omitted />");
217
+ expect(block0.content).not.toContain("<ax-tree>");
218
+ }
219
+
220
+ // Second block in same message (t1b) should be kept
221
+ const block1 = msg0.content[1];
222
+ expect(block1.type).toBe("tool_result");
223
+ if (block1.type === "tool_result") {
224
+ expect(block1.content).toContain("<ax-tree>");
225
+ expect(block1.content).not.toContain("<ax_tree_omitted />");
226
+ }
227
+
228
+ // Last message (t2) should also be kept
229
+ const lastBlock = result[2].content[0];
230
+ expect(lastBlock.type).toBe("tool_result");
231
+ if (lastBlock.type === "tool_result") {
232
+ expect(lastBlock.content).toContain("<ax-tree>");
233
+ }
234
+ });
235
+
185
236
  test("is pure — does not mutate input messages", () => {
186
237
  const messages: Message[] = [
187
238
  axTreeToolResult("t1", "tree-1"),