cue-ai 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +757 -110
  2. package/package.json +5 -5
  3. package/profiles/README.md +12 -12
  4. package/profiles/SCHEMA.md +31 -3
  5. package/profiles/_cache/README.md +1 -1
  6. package/profiles/_types.ts +26 -1
  7. package/profiles/backend/profile.yaml +1 -0
  8. package/profiles/career/profile.yaml +13 -0
  9. package/profiles/core/profile.yaml +76 -9
  10. package/profiles/creative-media/README.md +1 -1
  11. package/profiles/cybersecurity/profile.yaml +779 -756
  12. package/profiles/ecc/profile.yaml +39 -0
  13. package/profiles/event-design/profile.yaml +10 -0
  14. package/profiles/fleet-control/README.md +1 -1
  15. package/profiles/frontend/profile.yaml +14 -0
  16. package/profiles/full/README.md +1 -1
  17. package/profiles/go-api/profile.yaml +1 -0
  18. package/profiles/marketing/profile.yaml +12 -1
  19. package/profiles/predict-everything/profile.yaml +9 -0
  20. package/profiles/rust/profile.yaml +22 -3
  21. package/profiles/rust-cli/profile.yaml +14 -0
  22. package/profiles/rust-core/profile.yaml +35 -0
  23. package/profiles/rust-embedded/profile.yaml +11 -0
  24. package/profiles/rust-ffi/profile.yaml +13 -0
  25. package/profiles/rust-game/profile.yaml +11 -0
  26. package/profiles/rust-wasm/profile.yaml +11 -0
  27. package/profiles/rust-web/profile.yaml +17 -0
  28. package/profiles/schema.json +44 -4
  29. package/profiles/trendradar/profile.yaml +11 -0
  30. package/resources/mcps/README.md +39 -164
  31. package/resources/mcps/configs/claude.sanitized.json +55 -0
  32. package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
  33. package/resources/skills/README.md +70 -113
  34. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
  35. package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
  36. package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
  37. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
  38. package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
  39. package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
  40. package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
  41. package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
  42. package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
  43. package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
  44. package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
  45. package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
  46. package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
  47. package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
  48. package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
  49. package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
  50. package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
  51. package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
  52. package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
  53. package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
  54. package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
  55. package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
  56. package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
  57. package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
  58. package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
  59. package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
  60. package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
  61. package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
  62. package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
  63. package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
  64. package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
  65. package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
  66. package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
  67. package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
  68. package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
  69. package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
  70. package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
  71. package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
  72. package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
  73. package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
  74. package/resources/skills/skills/rust/serde/SKILL.md +30 -0
  75. package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
  76. package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
  77. package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
  78. package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
  79. package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
  80. package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
  81. package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
  82. package/src/commands/_index.ts +47 -3
  83. package/src/commands/cli.test.ts +192 -0
  84. package/src/commands/cli.ts +303 -0
  85. package/src/commands/current.ts +1 -1
  86. package/src/commands/debug.test.ts +62 -0
  87. package/src/commands/debug.ts +212 -0
  88. package/src/commands/discover.scoring.test.ts +216 -0
  89. package/src/commands/discover.test.ts +145 -0
  90. package/src/commands/discover.ts +2618 -0
  91. package/src/commands/eval-behavior.test.ts +56 -0
  92. package/src/commands/eval-behavior.ts +189 -0
  93. package/src/commands/eval.test.ts +102 -0
  94. package/src/commands/eval.ts +348 -0
  95. package/src/commands/evolve.ts +291 -0
  96. package/src/commands/failures.test.ts +78 -0
  97. package/src/commands/failures.ts +393 -0
  98. package/src/commands/feedback.ts +219 -0
  99. package/src/commands/init.ts +26 -0
  100. package/src/commands/launch.e2e.test.ts +9 -1
  101. package/src/commands/launch.ts +174 -11
  102. package/src/commands/lint-skill.ts +157 -0
  103. package/src/commands/marketplace.ts +763 -2
  104. package/src/commands/new.ts +1 -1
  105. package/src/commands/optimizer.ts +92 -28
  106. package/src/commands/profile-draft-skill.test.ts +96 -0
  107. package/src/commands/profile-draft-skill.ts +287 -0
  108. package/src/commands/profile-evolve.test.ts +126 -0
  109. package/src/commands/profile-evolve.ts +0 -0
  110. package/src/commands/profile-suggest.ts +223 -0
  111. package/src/commands/profile.ts +41 -0
  112. package/src/commands/quick.ts +2 -17
  113. package/src/commands/scan.ts +2 -2
  114. package/src/commands/score.ts +1 -1
  115. package/src/commands/share.ts +1 -1
  116. package/src/commands/sources.ts +2 -2
  117. package/src/commands/submit-profile.ts +262 -0
  118. package/src/commands/upgrade.ts +1 -1
  119. package/src/commands/use.ts +35 -5
  120. package/src/commands/validate.ts +1 -1
  121. package/src/index.ts +66 -0
  122. package/src/lib/analytics.ts +48 -2
  123. package/src/lib/claude-binary.ts +39 -0
  124. package/src/lib/cli-extractor.ts +77 -0
  125. package/src/lib/cluster-skills.test.ts +268 -0
  126. package/src/lib/cluster-skills.ts +290 -0
  127. package/src/lib/credentials-sync.test.ts +208 -0
  128. package/src/lib/credentials-sync.ts +205 -0
  129. package/src/lib/mcp-materializer.test.ts +1 -1
  130. package/src/lib/persona-playbooks.test.ts +111 -0
  131. package/src/lib/pr-poster.test.ts +243 -0
  132. package/src/lib/pr-poster.ts +285 -0
  133. package/src/lib/pr-throttle.test.ts +148 -0
  134. package/src/lib/pr-throttle.ts +209 -0
  135. package/src/lib/profile-generator.test.ts +1 -1
  136. package/src/lib/profile-generator.ts +2 -2
  137. package/src/lib/profile-linter.test.ts +6 -3
  138. package/src/lib/profile-linter.ts +71 -8
  139. package/src/lib/profile-loader.test.ts +1 -1
  140. package/src/lib/profile-loader.ts +16 -0
  141. package/src/lib/resolver-local.test.ts +1 -1
  142. package/src/lib/resolver-npx.test.ts +76 -1
  143. package/src/lib/resolver-npx.ts +35 -3
  144. package/src/lib/resolver-plugins.test.ts +1 -1
  145. package/src/lib/runtime-materializer.test.ts +191 -7
  146. package/src/lib/runtime-materializer.ts +310 -42
  147. package/src/lib/scan-plugins.test.ts +1 -1
  148. package/src/lib/skill-linter.test.ts +174 -0
  149. package/src/lib/skill-linter.ts +507 -0
  150. package/src/lib/skill-subset.test.ts +95 -0
  151. package/src/lib/skill-subset.ts +166 -0
  152. package/src/lib/star-prompt.ts +1 -1
  153. package/src/lib/uvx-installer.test.ts +229 -0
  154. package/src/lib/uvx-installer.ts +278 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Tests for the deterministic clustering lib.
3
+ *
4
+ * Covers: realistic skill clustering, stopword/IDF behavior, edge cases,
5
+ * the jaccard helper, and the skillFrequency promote-to-core report.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ clusterByKeywords,
11
+ clusterByEmbeddings,
12
+ unclustered,
13
+ jaccard,
14
+ skillFrequency,
15
+ type ClusterItem,
16
+ type EmbedProvider,
17
+ } from "./cluster-skills";
18
+
19
+ describe("clusterByKeywords — realistic skill grouping", () => {
20
+ test("groups reasoning/debate gems under a shared term", () => {
21
+ // All four reasoning items mention "reasoning" — the cluster term should
22
+ // surface that, and the storefront item must not get pulled in.
23
+ const items: ClusterItem[] = [
24
+ { id: "a", text: "Multi-perspective reasoning council with advisors" },
25
+ { id: "b", text: "Reasoning loop via structured debate roles" },
26
+ { id: "c", text: "AI reasoning partner with calibrated pushback" },
27
+ { id: "d", text: "Reasoning advisory board: get second opinions" },
28
+ { id: "e", text: "Storefront ecommerce shop cart checkout" },
29
+ ];
30
+ const clusters = clusterByKeywords(items, { minSize: 3 });
31
+ expect(clusters.length).toBeGreaterThanOrEqual(1);
32
+ const reasoning = clusters.find(c => c.items.some(i => i.id === "a"));
33
+ expect(reasoning).toBeDefined();
34
+ const ids = reasoning!.items.map(i => i.id);
35
+ const reasoningHit = ids.filter(id => ["a","b","c","d"].includes(id)).length;
36
+ expect(reasoningHit).toBeGreaterThanOrEqual(3);
37
+ expect(reasoning!.items.some(i => i.id === "e")).toBe(false);
38
+ });
39
+
40
+ test("respects minSize — no cluster of 2 items is returned", () => {
41
+ const items: ClusterItem[] = [
42
+ { id: "x1", text: "memory session smart markdown manager" },
43
+ { id: "x2", text: "session memory toolkit for context" },
44
+ { id: "y1", text: "completely unrelated thing" },
45
+ ];
46
+ const clusters = clusterByKeywords(items, { minSize: 3 });
47
+ expect(clusters.length).toBe(0);
48
+ });
49
+
50
+ test("filters out terms that appear in >50% of corpus (too generic)", () => {
51
+ // 4 items, all share "thingie" → docfreq 4/4 → filtered as too generic
52
+ const items: ClusterItem[] = [
53
+ { id: "a", text: "thingie alpha beta" },
54
+ { id: "b", text: "thingie alpha gamma" },
55
+ { id: "c", text: "thingie delta epsilon" },
56
+ { id: "d", text: "thingie zeta eta" },
57
+ ];
58
+ const clusters = clusterByKeywords(items, { minSize: 3 });
59
+ // "thingie" should NOT be a cluster (too generic)
60
+ expect(clusters.find(c => c.term === "thingie")).toBeUndefined();
61
+ });
62
+
63
+ test("greedy assignment: each item appears in at most one cluster", () => {
64
+ const items: ClusterItem[] = [
65
+ { id: "a", text: "session memory recall management" },
66
+ { id: "b", text: "session memory snapshot recall" },
67
+ { id: "c", text: "session memory archive management" },
68
+ { id: "d", text: "memory management with custom tools" },
69
+ { id: "e", text: "memory management toolkit advanced" },
70
+ { id: "f", text: "memory management for sessions" },
71
+ ];
72
+ const clusters = clusterByKeywords(items, { minSize: 3 });
73
+ const seenIds = new Set<string>();
74
+ for (const c of clusters) {
75
+ for (const item of c.items) {
76
+ expect(seenIds.has(item.id)).toBe(false);
77
+ seenIds.add(item.id);
78
+ }
79
+ }
80
+ });
81
+
82
+ test("returns empty for empty input", () => {
83
+ expect(clusterByKeywords([], { minSize: 3 })).toEqual([]);
84
+ });
85
+
86
+ test("returns empty when no terms repeat enough", () => {
87
+ const items: ClusterItem[] = [
88
+ { id: "a", text: "wholly distinct alpha" },
89
+ { id: "b", text: "wholly distinct beta" },
90
+ { id: "c", text: "wholly distinct gamma" },
91
+ ];
92
+ // Each item has unique tokens after stopwords; nothing repeats 3 times.
93
+ const clusters = clusterByKeywords(items, { minSize: 3 });
94
+ expect(clusters.length).toBe(0);
95
+ });
96
+
97
+ test("stopwords (claude, code, skill, ai) don't form clusters", () => {
98
+ const items: ClusterItem[] = [
99
+ { id: "a", text: "claude code skill AI agent for foo" },
100
+ { id: "b", text: "claude code skill AI agent for bar" },
101
+ { id: "c", text: "claude code skill AI agent for baz" },
102
+ ];
103
+ const clusters = clusterByKeywords(items, { minSize: 3 });
104
+ // None of the stopwords should anchor a cluster
105
+ for (const c of clusters) {
106
+ expect(["claude", "code", "skill", "ai", "agent"]).not.toContain(c.term);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("unclustered — orphan detection", () => {
112
+ test("returns items not assigned to any cluster", () => {
113
+ const items: ClusterItem[] = [
114
+ { id: "a", text: "deploy docker container" },
115
+ { id: "b", text: "deploy docker registry" },
116
+ { id: "c", text: "deploy docker secrets" },
117
+ { id: "d", text: "wholly unrelated text" },
118
+ ];
119
+ const clusters = clusterByKeywords(items, { minSize: 3 });
120
+ const orphans = unclustered(items, clusters);
121
+ expect(orphans.map(i => i.id)).toEqual(["d"]);
122
+ });
123
+ });
124
+
125
+ describe("jaccard — set similarity", () => {
126
+ test("identical sets → 1.0", () => {
127
+ expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
128
+ });
129
+ test("disjoint sets → 0.0", () => {
130
+ expect(jaccard(new Set(["a", "b"]), new Set(["c", "d"]))).toBe(0);
131
+ });
132
+ test("two empty sets → 1.0 (convention)", () => {
133
+ expect(jaccard(new Set(), new Set())).toBe(1);
134
+ });
135
+ test("partial overlap", () => {
136
+ // intersection {b}, union {a,b,c} → 1/3
137
+ expect(jaccard(new Set(["a", "b"]), new Set(["b", "c"]))).toBeCloseTo(1 / 3);
138
+ });
139
+ });
140
+
141
+ describe("skillFrequency — promote-to-core report", () => {
142
+ test("flags skills that appear in ≥3 profiles", () => {
143
+ const profileSkills = {
144
+ backend: ["auth", "logging", "metrics"],
145
+ frontend: ["auth", "logging", "tailwind"],
146
+ "go-api": ["auth", "logging", "grpc"],
147
+ research: ["pubmed"],
148
+ core: ["should-be-excluded"], // core is ignored
149
+ };
150
+ const result = skillFrequency(profileSkills, { minProfiles: 3 });
151
+ const ids = result.map(r => r.skill);
152
+ expect(ids).toContain("auth");
153
+ expect(ids).toContain("logging");
154
+ expect(ids).not.toContain("metrics"); // only 1 profile
155
+ expect(ids).not.toContain("should-be-excluded"); // in core
156
+ });
157
+
158
+ test("orders by descending profile count", () => {
159
+ const profileSkills = {
160
+ a: ["x", "y"],
161
+ b: ["x", "y"],
162
+ c: ["x"],
163
+ d: ["x", "y"], // y in 3 profiles, x in 4
164
+ };
165
+ const result = skillFrequency(profileSkills, { minProfiles: 3 });
166
+ expect(result[0]?.skill).toBe("x");
167
+ expect(result[0]?.profiles.length).toBeGreaterThan(result[1]?.profiles.length ?? 0);
168
+ });
169
+
170
+ test("excludes `full` (it's a kitchen-sink, not a real profile)", () => {
171
+ const profileSkills = {
172
+ full: ["x", "y", "z"],
173
+ a: ["x"],
174
+ b: ["x"],
175
+ };
176
+ const result = skillFrequency(profileSkills, { minProfiles: 2 });
177
+ // x is in a, b, and full — but full is excluded, so count is 2.
178
+ expect(result.find(r => r.skill === "x")?.profiles).toEqual(["a", "b"]);
179
+ });
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Embedding clustering — stub provider so tests are offline + deterministic
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Each text gets a vector that lives close to the vectors of texts sharing its
188
+ * "topic tag" (first word). Lets us assert grouping without calling Voyage.
189
+ */
190
+ function topicTagProvider(): EmbedProvider {
191
+ // Map first token → distinct unit vector. Two texts share a topic iff their
192
+ // first token matches → cosine ≈ 1; otherwise cosine ≈ 0.
193
+ const axisOf = (text: string): number => {
194
+ const tag = text.toLowerCase().split(/\s+/)[0] ?? "";
195
+ let h = 0;
196
+ for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) % 64;
197
+ return h;
198
+ };
199
+ return {
200
+ async embed(texts) {
201
+ const dim = 64;
202
+ return texts.map(t => {
203
+ const v = new Array(dim).fill(0);
204
+ v[axisOf(t)] = 1;
205
+ return v;
206
+ });
207
+ },
208
+ };
209
+ }
210
+
211
+ describe("clusterByEmbeddings — semantic grouping with stub provider", () => {
212
+ test("groups items sharing a topic tag, splits unrelated ones", async () => {
213
+ const items: ClusterItem[] = [
214
+ { id: "a", text: "memory recall and context for sessions" },
215
+ { id: "b", text: "memory snapshot and prune across runs" },
216
+ { id: "c", text: "memory archive for projects" },
217
+ { id: "d", text: "shop checkout cart for storefront" },
218
+ { id: "e", text: "shop product catalog and search" },
219
+ { id: "f", text: "shop seller dashboard for marketplaces" },
220
+ ];
221
+ const clusters = await clusterByEmbeddings(items, {
222
+ minSize: 3, provider: topicTagProvider(), threshold: 0.9,
223
+ });
224
+ expect(clusters.length).toBe(2);
225
+ const memorySet = new Set(clusters.find(c => c.items.some(i => i.id === "a"))!.items.map(i => i.id));
226
+ expect(memorySet).toEqual(new Set(["a", "b", "c"]));
227
+ const shopSet = new Set(clusters.find(c => c.items.some(i => i.id === "d"))!.items.map(i => i.id));
228
+ expect(shopSet).toEqual(new Set(["d", "e", "f"]));
229
+ });
230
+
231
+ test("respects minSize — clusters below threshold are dropped", async () => {
232
+ const items: ClusterItem[] = [
233
+ { id: "a", text: "memory recall sessions" },
234
+ { id: "b", text: "memory snapshot prune" }, // pair only — under minSize=3
235
+ { id: "c", text: "wholly distinct gamma alone" },
236
+ ];
237
+ const clusters = await clusterByEmbeddings(items, {
238
+ minSize: 3, provider: topicTagProvider(), threshold: 0.9,
239
+ });
240
+ expect(clusters.length).toBe(0);
241
+ });
242
+
243
+ test("returns [] when fewer items than minSize (no API call needed)", async () => {
244
+ let called = false;
245
+ const probe: EmbedProvider = {
246
+ async embed(texts) { called = true; return texts.map(() => [1, 0, 0]); },
247
+ };
248
+ const result = await clusterByEmbeddings(
249
+ [{ id: "x", text: "anything" }],
250
+ { minSize: 3, provider: probe },
251
+ );
252
+ expect(result).toEqual([]);
253
+ expect(called).toBe(false);
254
+ });
255
+
256
+ test("derives a cluster term from the most-frequent non-stopword unigram", async () => {
257
+ const items: ClusterItem[] = [
258
+ { id: "a", text: "memory recall sessions" },
259
+ { id: "b", text: "memory snapshot" },
260
+ { id: "c", text: "memory archive" },
261
+ ];
262
+ const clusters = await clusterByEmbeddings(items, {
263
+ minSize: 3, provider: topicTagProvider(), threshold: 0.9,
264
+ });
265
+ expect(clusters.length).toBe(1);
266
+ expect(clusters[0]!.term).toBe("memory");
267
+ });
268
+ });
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Deterministic keyword clustering for skill/gem grouping.
3
+ *
4
+ * Used by `cue discover suggest-profiles` and `cue profile suggest` to find
5
+ * clusters of skills that share vocabulary and could justify a new profile.
6
+ *
7
+ * Algorithm: tokenize → drop stopwords → unigrams + bigrams → score by
8
+ * frequency × inverse-doc-frequency → group items by their top-scoring term.
9
+ *
10
+ * Why not k-means / LDA / embeddings: this runs in the discover pipeline
11
+ * which already spends API budget on `cmdAnalyze`. We want a fast, offline,
12
+ * explainable signal that the user can sanity-check by reading the cluster's
13
+ * top term. Sophistication moves to the optional Claude naming step.
14
+ */
15
+
16
+ export interface ClusterItem {
17
+ id: string;
18
+ text: string;
19
+ }
20
+
21
+ export interface Cluster {
22
+ /** The dominant n-gram that defined this cluster (e.g. "session memory"). */
23
+ term: string;
24
+ /** Items grouped under this term, sorted by descending term score. */
25
+ items: ClusterItem[];
26
+ }
27
+
28
+ const STOPWORDS = new Set([
29
+ "a", "an", "the", "and", "or", "but", "if", "of", "for", "to", "in", "on",
30
+ "at", "by", "with", "as", "is", "are", "was", "were", "be", "been", "being",
31
+ "this", "that", "these", "those", "it", "its", "from", "into", "via", "over",
32
+ "you", "your", "we", "our", "us", "they", "them", "their", "i", "me", "my",
33
+ "have", "has", "had", "do", "does", "did", "will", "would", "can", "could",
34
+ "should", "may", "might", "must", "not", "no", "yes",
35
+ // Domain noise — these are everywhere in skill repos and don't differentiate.
36
+ "claude", "code", "claude-code", "skill", "skills", "tool", "tools", "ai", "agent", "agents",
37
+ "mcp", "server", "use", "uses", "using", "used", "new", "across",
38
+ // English connectives that survive the basic stopword filter but carry no topic signal.
39
+ "path", "paths", "system", "systems", "platform", "service", "services", "support",
40
+ "build", "make", "get", "set", "run", "show", "list", "find", "based", "via",
41
+ ]);
42
+
43
+ const TOKEN_RE = /[a-z][a-z0-9-]*/g;
44
+
45
+ function tokenize(text: string): string[] {
46
+ const lower = text.toLowerCase();
47
+ const tokens: string[] = [];
48
+ let m: RegExpExecArray | null;
49
+ while ((m = TOKEN_RE.exec(lower)) !== null) {
50
+ const t = m[0]!;
51
+ if (t.length < 3 || t.length > 30) continue;
52
+ if (STOPWORDS.has(t)) continue;
53
+ if (/^\d/.test(t)) continue;
54
+ tokens.push(t);
55
+ }
56
+ return tokens;
57
+ }
58
+
59
+ function ngrams(tokens: string[]): string[] {
60
+ const out: string[] = [...tokens];
61
+ for (let i = 0; i < tokens.length - 1; i++) {
62
+ out.push(`${tokens[i]} ${tokens[i + 1]}`);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Cluster items by top scoring n-gram.
69
+ *
70
+ * Returns clusters with ≥ minSize items, sorted by size descending. Items
71
+ * that don't fit any qualifying cluster are omitted (caller can detect them
72
+ * by id and route to a fallback bucket).
73
+ */
74
+ export function clusterByKeywords(
75
+ items: ClusterItem[],
76
+ opts: { minSize?: number; maxClusters?: number } = {},
77
+ ): Cluster[] {
78
+ const minSize = opts.minSize ?? 3;
79
+ const maxClusters = opts.maxClusters ?? 10;
80
+ if (items.length === 0) return [];
81
+
82
+ // Doc-frequency: how many items contain each term at least once.
83
+ const docFreq = new Map<string, number>();
84
+ const itemTerms = new Map<string, Set<string>>();
85
+ for (const item of items) {
86
+ const terms = new Set(ngrams(tokenize(item.text)));
87
+ itemTerms.set(item.id, terms);
88
+ for (const t of terms) docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
89
+ }
90
+
91
+ const N = items.length;
92
+ // A term qualifies if (a) it appears in ≥ minSize items (enough for a cluster),
93
+ // (b) it doesn't appear in *every* item (df=N is the whole corpus, not a cluster),
94
+ // and (c) it's specific enough — for small corpora that's any term, but for
95
+ // larger ones we reject terms appearing in nearly everything (e.g. "claude"
96
+ // in 40/50 items would be bloat, not signal). The "≥75% of corpus" upper
97
+ // bound only kicks in when the corpus is large enough to make it meaningful.
98
+ const genericCap = Math.max(minSize + 1, Math.floor(N * 0.75));
99
+ const candidateTerms = [...docFreq.entries()]
100
+ .filter(([, df]) => df >= minSize && df < N && df <= genericCap)
101
+ .map(([term, df]) => ({ term, df, idf: Math.log(N / df) }))
102
+ .sort((a, b) => b.df - a.df);
103
+
104
+ if (candidateTerms.length === 0) return [];
105
+
106
+ // Greedy assignment: process terms by document frequency (broad first), so
107
+ // dominant clusters absorb items before narrow terms steal them.
108
+ const assigned = new Set<string>();
109
+ const clusters: Cluster[] = [];
110
+
111
+ for (const cand of candidateTerms) {
112
+ if (clusters.length >= maxClusters) break;
113
+ const members: ClusterItem[] = [];
114
+ for (const item of items) {
115
+ if (assigned.has(item.id)) continue;
116
+ if (itemTerms.get(item.id)!.has(cand.term)) members.push(item);
117
+ }
118
+ if (members.length >= minSize) {
119
+ for (const m of members) assigned.add(m.id);
120
+ clusters.push({ term: cand.term, items: members });
121
+ }
122
+ }
123
+
124
+ return clusters.sort((a, b) => b.items.length - a.items.length);
125
+ }
126
+
127
+ /** Items not assigned to any cluster. Convenience for the caller. */
128
+ export function unclustered(items: ClusterItem[], clusters: Cluster[]): ClusterItem[] {
129
+ const assigned = new Set(clusters.flatMap(c => c.items.map(i => i.id)));
130
+ return items.filter(i => !assigned.has(i.id));
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Embedding-based clustering (opt-in, requires VOYAGE_API_KEY)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Cluster by semantic similarity instead of literal vocabulary. Sends one
139
+ * batch request to Voyage's embeddings API, then runs a simple greedy
140
+ * cosine-similarity grouping. Falls back to keyword clustering if the API
141
+ * key is missing or the request fails.
142
+ *
143
+ * Why Voyage: Anthropic's recommended embeddings partner, voyage-3 is cheap
144
+ * (~$0.06/1M tokens) and high-quality on short technical descriptions.
145
+ * Swappable via `provider.embed` if you want a different backend.
146
+ */
147
+ export interface EmbedProvider {
148
+ /** Return one embedding vector per input text, in order. */
149
+ embed(texts: string[]): Promise<number[][]>;
150
+ }
151
+
152
+ export const voyageProvider: EmbedProvider = {
153
+ async embed(texts) {
154
+ const key = process.env.VOYAGE_API_KEY;
155
+ if (!key) throw new Error("VOYAGE_API_KEY not set");
156
+ const res = await fetch("https://api.voyageai.com/v1/embeddings", {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
159
+ body: JSON.stringify({ input: texts, model: "voyage-3-lite", input_type: "document" }),
160
+ });
161
+ if (!res.ok) throw new Error(`Voyage API ${res.status}: ${await res.text().catch(() => "")}`);
162
+ const data = await res.json() as { data: Array<{ embedding: number[] }> };
163
+ if (!Array.isArray(data.data) || data.data.length !== texts.length) {
164
+ throw new Error("Voyage response shape unexpected");
165
+ }
166
+ return data.data.map(d => d.embedding);
167
+ },
168
+ };
169
+
170
+ function cosine(a: number[], b: number[]): number {
171
+ let dot = 0, na = 0, nb = 0;
172
+ for (let i = 0; i < a.length; i++) {
173
+ dot += a[i]! * b[i]!;
174
+ na += a[i]! * a[i]!;
175
+ nb += b[i]! * b[i]!;
176
+ }
177
+ if (na === 0 || nb === 0) return 0;
178
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
179
+ }
180
+
181
+ /**
182
+ * Greedy clustering: pick the densest seed (item with most neighbors above
183
+ * threshold), absorb its neighborhood, repeat on remainder.
184
+ *
185
+ * minSize and maxClusters semantics match `clusterByKeywords`. `threshold`
186
+ * is the minimum cosine similarity for two items to be neighbors — 0.55 is
187
+ * a reasonable default for voyage-3-lite on short skill descriptions.
188
+ *
189
+ * Cluster terms: since there's no obvious keyword to anchor a semantic
190
+ * cluster, we derive the term by running the keyword tokenizer over just
191
+ * the cluster's text and picking its most frequent non-stopword. This keeps
192
+ * the output shape compatible with `clusterByKeywords` callers.
193
+ */
194
+ export async function clusterByEmbeddings(
195
+ items: ClusterItem[],
196
+ opts: { minSize?: number; maxClusters?: number; threshold?: number; provider?: EmbedProvider } = {},
197
+ ): Promise<Cluster[]> {
198
+ const minSize = opts.minSize ?? 3;
199
+ const maxClusters = opts.maxClusters ?? 10;
200
+ const threshold = opts.threshold ?? 0.55;
201
+ const provider = opts.provider ?? voyageProvider;
202
+ if (items.length < minSize) return [];
203
+
204
+ const vectors = await provider.embed(items.map(i => i.text));
205
+ if (vectors.length !== items.length) return [];
206
+
207
+ const remaining = new Set(items.map((_, i) => i));
208
+ const clusters: Cluster[] = [];
209
+
210
+ while (remaining.size >= minSize && clusters.length < maxClusters) {
211
+ // Build a neighbor list for each remaining item and pick the densest seed.
212
+ let bestSeed = -1;
213
+ let bestNeighbors: number[] = [];
214
+ for (const i of remaining) {
215
+ const neighbors: number[] = [i];
216
+ for (const j of remaining) {
217
+ if (j === i) continue;
218
+ if (cosine(vectors[i]!, vectors[j]!) >= threshold) neighbors.push(j);
219
+ }
220
+ if (neighbors.length > bestNeighbors.length) {
221
+ bestSeed = i;
222
+ bestNeighbors = neighbors;
223
+ }
224
+ }
225
+ if (bestSeed < 0 || bestNeighbors.length < minSize) break;
226
+
227
+ const memberItems = bestNeighbors.map(idx => items[idx]!);
228
+ clusters.push({
229
+ term: deriveClusterTerm(memberItems),
230
+ items: memberItems,
231
+ });
232
+ for (const idx of bestNeighbors) remaining.delete(idx);
233
+ }
234
+
235
+ return clusters.sort((a, b) => b.items.length - a.items.length);
236
+ }
237
+
238
+ /**
239
+ * For embedding-based clusters there's no anchor n-gram — we derive a
240
+ * human-readable label by tokenizing the member texts and picking the most
241
+ * frequent non-stopword unigram. Falls back to "cluster-N" if everything is
242
+ * stopwords.
243
+ */
244
+ function deriveClusterTerm(items: ClusterItem[]): string {
245
+ const counts = new Map<string, number>();
246
+ for (const item of items) {
247
+ for (const tok of tokenize(item.text)) {
248
+ counts.set(tok, (counts.get(tok) ?? 0) + 1);
249
+ }
250
+ }
251
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
252
+ return ranked[0]?.[0] ?? `cluster-${items.length}`;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Profile-overlap helpers (used by `cue profile suggest`)
257
+ // ---------------------------------------------------------------------------
258
+
259
+ export function jaccard<T>(a: Set<T>, b: Set<T>): number {
260
+ if (a.size === 0 && b.size === 0) return 1;
261
+ let intersect = 0;
262
+ for (const x of a) if (b.has(x)) intersect++;
263
+ const union = a.size + b.size - intersect;
264
+ return union === 0 ? 0 : intersect / union;
265
+ }
266
+
267
+ /**
268
+ * Skills that appear in many profiles are candidates for promotion to `core`.
269
+ * Returns skill IDs ordered by descending frequency, with the list of
270
+ * profiles that include them.
271
+ */
272
+ export function skillFrequency(
273
+ profileSkills: Record<string, string[]>,
274
+ opts: { minProfiles?: number } = {},
275
+ ): Array<{ skill: string; profiles: string[] }> {
276
+ const minProfiles = opts.minProfiles ?? 3;
277
+ const freq = new Map<string, string[]>();
278
+ for (const [profile, skills] of Object.entries(profileSkills)) {
279
+ if (profile === "core" || profile === "full") continue;
280
+ for (const s of skills) {
281
+ const list = freq.get(s) ?? [];
282
+ list.push(profile);
283
+ freq.set(s, list);
284
+ }
285
+ }
286
+ return [...freq.entries()]
287
+ .filter(([, profiles]) => profiles.length >= minProfiles)
288
+ .map(([skill, profiles]) => ({ skill, profiles }))
289
+ .sort((a, b) => b.profiles.length - a.profiles.length);
290
+ }