@minhpnq1807/contextos 0.5.50 → 0.5.51

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.51
4
+
5
+ - **Faster prompt fallback:** Direct prompt-hook fallback now skips embedding work and uses a shorter timeout, so context injection can still return deterministic rule, file, skill, and workflow candidates when MCP or semantic scoring is unavailable.
6
+ - **Shared skill index fallback:** Skill discovery now warms a shared global skill index and searches it when the workspace-specific skill index has no matches, improving reuse across projects.
7
+ - **Agent-visible skill dedupe:** Community skill installs and skill sync now remove duplicate skills visible through shared, Codex, and Antigravity roots while preserving unique agent-specific skills.
8
+ - **Workspace prompt path detection:** Explicit file paths in prompts now tolerate line and column suffixes and can resolve files from workspace packages, improving suggested-file accuracy in monorepos.
9
+
3
10
  ## 0.5.50
4
11
 
5
12
  - **Explicit skill activation:** Prompt skills named with `$skill-name` are now preserved and ranked before semantic suggestions, so user-requested skills such as `$threejs` or `$design-taste-frontend` appear in prompt context even when semantic ranking would not select them.
package/bin/ctx.js CHANGED
@@ -29,7 +29,7 @@ import { installCopilotMcp } from "../plugins/ctx/lib/copilot-mcp.js";
29
29
  import { readCodexMcpServers, syncRules } from "../plugins/ctx/lib/ruler-sync.js";
30
30
  import { detectGraphStrategy, embedCodeReviewGraph, formatCodeReviewGraphEmbedding, formatGraphStrategy } from "../plugins/ctx/lib/graph-strategy.js";
31
31
  import { writeInnerGitignore, ensureRootGitignore } from "../plugins/ctx/lib/gitignore.js";
32
- import { repairSkillSymlinks, syncSkills, detectExistingSkills } from "../plugins/ctx/lib/skillshare-sync.js";
32
+ import { dedupeAgentVisibleSkills, repairSkillSymlinks, syncSkills, detectExistingSkills } from "../plugins/ctx/lib/skillshare-sync.js";
33
33
  import { scanSkills, warmSkillEmbeddings } from "../plugins/ctx/lib/skill-discoverer.js";
34
34
  import { parsePassthroughArgs, runPassthrough } from "../plugins/ctx/lib/passthrough.js";
35
35
  import { parseAgentList, parseSetupArgs, setupSummaryLines } from "../plugins/ctx/lib/setup-wizard.js";
@@ -143,6 +143,10 @@ async function runCommunitySkillInstaller(agents = []) {
143
143
  if (afterRepair.repaired.length || afterRepair.removedBroken.length) {
144
144
  console.log(`${DIM}│${RESET} Repaired ${afterRepair.repaired.length} skill links after install.`);
145
145
  }
146
+ const deduped = dedupeAgentVisibleSkills({ cwd: process.cwd(), home: os.homedir(), agents });
147
+ if (deduped.removed.length) {
148
+ console.log(`${DIM}│${RESET} Removed ${deduped.removed.length} duplicate agent-visible skills.`);
149
+ }
146
150
  successCount++;
147
151
 
148
152
  if (installInfo.verify) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.50",
3
+ "version": "0.5.51",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx",
3
- "version": "0.5.50",
3
+ "version": "0.5.51",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { findGraphRelevantFiles, mergeRelevantFiles } from "./graph-retriever.js";
4
4
  import { expandImportGraph } from "./import-graph.js";
5
5
  import { findEmbeddingRelevantFiles } from "./file-embedding-retriever.js";
6
+ import { workspacePackagePaths } from "./project-profiler.js";
6
7
 
7
8
  const STOP_WORDS = new Set([
8
9
  "a", "an", "and", "are", "as", "at", "be", "by", "cho", "co", "cua", "do", "fix", "for",
@@ -433,9 +434,9 @@ function addAll(target, values) {
433
434
  export function findExplicitPromptFiles({ cwd = process.cwd(), task = "", limit = 6 } = {}) {
434
435
  const candidates = new Set();
435
436
  const normalizedTask = String(task || "").replace(/\/\s+/g, "/");
436
- const matches = normalizedTask.match(/[A-Za-z0-9_.()[\]@~:-]+(?:\/[A-Za-z0-9_.()[\]@~:-]+)+/g) || [];
437
+ const matches = normalizedTask.match(/[A-Za-z0-9_.()[\]@~:,-]+(?:\/[A-Za-z0-9_.()[\]@~:,-]+)+/g) || [];
437
438
  for (const match of matches) {
438
- const cleaned = match.replace(/[),.;:]+$/g, "");
439
+ const cleaned = cleanPromptFilePath(match);
439
440
  for (const filePath of resolvePromptPathCandidates({ cwd, promptPath: cleaned })) {
440
441
  candidates.add(filePath);
441
442
  if (candidates.size >= limit) break;
@@ -450,6 +451,13 @@ export function findExplicitPromptFiles({ cwd = process.cwd(), task = "", limit
450
451
  }));
451
452
  }
452
453
 
454
+ function cleanPromptFilePath(value) {
455
+ return String(value || "")
456
+ .replace(/\.(tsx?|jsx?|mjs|cjs|json|md|sql|py)\(\d+(?:,\d+)?\)[),.;:]*$/i, ".$1")
457
+ .replace(/\.(tsx?|jsx?|mjs|cjs|json|md|sql|py):\d+(?::\d+)?[),.;:]*$/i, ".$1")
458
+ .replace(/[),.;:]+$/g, "");
459
+ }
460
+
453
461
  function resolvePromptPathCandidates({ cwd, promptPath }) {
454
462
  if (!promptPath || promptPath.includes("://")) return [];
455
463
  const relative = promptPath.replace(/^\.?\//, "");
@@ -470,6 +478,13 @@ function resolvePromptPathCandidates({ cwd, promptPath }) {
470
478
  if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
471
479
  }
472
480
  }
481
+ if (!resolved.length && !relative.startsWith("..")) {
482
+ for (const packagePath of workspacePackagePaths(cwd).slice(1)) {
483
+ const packageDir = path.dirname(packagePath);
484
+ const candidate = path.join(packageDir, relative);
485
+ if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
486
+ }
487
+ }
473
488
  return resolved;
474
489
  }
475
490
 
@@ -57,6 +57,7 @@ export function maybeAutoWarmWorkspace({
57
57
  function shouldAutoWarm(reason) {
58
58
  if (reason === "no-context-candidates") return true;
59
59
  if (reason === "enabled-sections-empty-after-formatting") return true;
60
+ if (String(reason || "").startsWith("enabled-sections-missing-candidates:")) return true;
60
61
  return false;
61
62
  }
62
63
 
@@ -40,6 +40,7 @@ export async function callCtxScoreContext(payload, {
40
40
  let raw = "";
41
41
  let responseTimer;
42
42
  const connectTimer = setTimeout(() => {
43
+ invalidateSocketIfUnchanged(socketPath, socketIdentity);
43
44
  client.destroy();
44
45
  reject(new Error(`ctx-mcp bridge connect timed out after ${connectTimeoutMs}ms`));
45
46
  }, connectTimeoutMs);
@@ -47,6 +48,7 @@ export async function callCtxScoreContext(payload, {
47
48
  client.on("connect", () => {
48
49
  clearTimeout(connectTimer);
49
50
  responseTimer = setTimeout(() => {
51
+ invalidateSocketIfUnchanged(socketPath, socketIdentity);
50
52
  client.destroy();
51
53
  reject(new Error(`ctx-mcp bridge timed out after ${timeoutMs}ms`));
52
54
  }, timeoutMs);
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { writeJsonFile } from "./fs-utils.js";
4
4
  import { defaultDataRoot, workspaceDataDir } from "./workspace-data.js";
5
5
 
6
+ let pendingStdoutWrites = 0;
7
+
6
8
  export async function readStdinJson() {
7
9
  const chunks = [];
8
10
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -13,13 +15,21 @@ export async function readStdinJson() {
13
15
 
14
16
  export function writeJson(value) {
15
17
  try {
16
- process.stdout.write(`${JSON.stringify(value)}\n`);
18
+ pendingStdoutWrites += 1;
19
+ process.stdout.write(`${JSON.stringify(value)}\n`, () => {
20
+ pendingStdoutWrites = Math.max(0, pendingStdoutWrites - 1);
21
+ });
17
22
  } catch (error) {
23
+ pendingStdoutWrites = Math.max(0, pendingStdoutWrites - 1);
18
24
  if (error?.code !== "EPIPE") throw error;
19
25
  }
20
26
  }
21
27
 
22
28
  export function exitAfterStdout(code = 0) {
29
+ if (pendingStdoutWrites > 0) {
30
+ setImmediate(() => exitAfterStdout(code));
31
+ return;
32
+ }
23
33
  if (process.stdout.writableNeedDrain) {
24
34
  process.stdout.once("drain", () => process.exit(code));
25
35
  return;
@@ -6,6 +6,7 @@ const PROFILE_CACHE_FILE = "project-profile.json";
6
6
  const MAX_DEPENDENCIES = 80;
7
7
  const MAX_SCRIPTS = 30;
8
8
  const MAX_RECENT_FILES = 20;
9
+ const MAX_FUSED_PROFILE_CHARS = 500;
9
10
 
10
11
  export function projectProfile({ cwd = process.cwd(), dataDir } = {}) {
11
12
  const fingerprint = projectFingerprint(cwd);
@@ -30,7 +31,10 @@ export function projectProfile({ cwd = process.cwd(), dataDir } = {}) {
30
31
 
31
32
  export function fusedProjectQuery({ prompt = "", cwd = process.cwd(), dataDir } = {}) {
32
33
  const profile = projectProfile({ cwd, dataDir });
33
- return [String(prompt || "").trim(), profile.embeddableString].filter(Boolean).join("\n");
34
+ const profileSignal = profile.embeddableString
35
+ ? profile.embeddableString.slice(0, MAX_FUSED_PROFILE_CHARS)
36
+ : "";
37
+ return [String(prompt || "").trim(), profileSignal].filter(Boolean).join("\n");
34
38
  }
35
39
 
36
40
  function readCachedProfile(cachePath, fingerprint) {
@@ -21,7 +21,7 @@ export async function handlePromptPayload(
21
21
  autoWarmWorkspace = maybeAutoWarmWorkspace,
22
22
  mcpDataDir,
23
23
  outputConfig,
24
- directFallbackTimeoutMs = Number(process.env.CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS || 6000)
24
+ directFallbackTimeoutMs = Number(process.env.CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS || 2500)
25
25
  } = {}
26
26
  ) {
27
27
  const prompt = payload.prompt || payload.message || payload.user_prompt || "";
@@ -55,6 +55,7 @@ export async function handlePromptPayload(
55
55
  maxSkills: promptLimits.skills,
56
56
  maxWorkflows: promptLimits.workflows,
57
57
  dataDir: mcpDataDir || dataDir,
58
+ allowEmbeddings: false,
58
59
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS || 500),
59
60
  fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 1000),
60
61
  skillEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_SKILL_EMBEDDING_TIMEOUT_MS || 2000)
@@ -197,6 +198,9 @@ function emptyContextReason({ scheduled, outputConfig, injectContext }) {
197
198
  if (scheduled.suggestedSkills?.length) available.push("skills");
198
199
  if (scheduled.suggestedWorkflows?.length) available.push("workflows");
199
200
  if (!available.length) return "no-context-candidates";
201
+ const enabledMissing = ["rules", "files", "skills", "workflows"]
202
+ .filter((section) => sections[section] !== false && !available.includes(section));
203
+ if (enabledMissing.length) return `enabled-sections-missing-candidates:${enabledMissing.join(",")}`;
200
204
  const enabled = available.filter((section) => sections[section] !== false);
201
205
  return enabled.length ? "enabled-sections-empty-after-formatting" : `available-sections-disabled:${available.join(",")}`;
202
206
  }
@@ -19,7 +19,8 @@ export async function scoreContext({
19
19
  embeddingTimeoutMs = 5000,
20
20
  fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 1000),
21
21
  skillEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || embeddingTimeoutMs),
22
- skillSearchOptions = {}
22
+ skillSearchOptions = {},
23
+ allowEmbeddings = true
23
24
  } = {}) {
24
25
  const started = Date.now();
25
26
  const ruleInputsPromise = Promise.resolve().then(() => {
@@ -35,6 +36,14 @@ export async function scoreContext({
35
36
  });
36
37
 
37
38
  const rulesPromise = ruleInputsPromise.then(({ merged, baseScoredRules }) => {
39
+ if (!allowEmbeddings) {
40
+ return {
41
+ rules: baseScoredRules,
42
+ status: "disabled",
43
+ model: null,
44
+ cachePath: dataDir
45
+ };
46
+ }
38
47
  return enhanceRuleScoresWithEmbeddings(baseScoredRules, prompt, {
39
48
  dataDir,
40
49
  sources: merged.sources,
@@ -52,6 +61,7 @@ export async function scoreContext({
52
61
  limit: maxFiles,
53
62
  fileEmbeddingTimeoutMs,
54
63
  fileEmbeddingOptions: {
64
+ enabled: allowEmbeddings,
55
65
  allowRemote: false
56
66
  }
57
67
  });
@@ -68,6 +78,7 @@ export async function scoreContext({
68
78
  dataDir,
69
79
  limit: maxSkills,
70
80
  timeoutMs: skillEmbeddingTimeoutMs,
81
+ embeddingsEnabled: allowEmbeddings,
71
82
  ...skillSearchOptions
72
83
  })
73
84
  };
@@ -77,7 +88,7 @@ export async function scoreContext({
77
88
  const catalog = Array.isArray(workflows) ? workflows : scanWorkflows({ cwd });
78
89
  return {
79
90
  catalog,
80
- suggestions: await suggestWorkflows({ prompt, workflows: catalog, dataDir, limit: maxWorkflows })
91
+ suggestions: await suggestWorkflows({ prompt, workflows: catalog, dataDir, limit: maxWorkflows, embeddingsEnabled: allowEmbeddings })
81
92
  };
82
93
  });
83
94
 
@@ -167,22 +167,18 @@ export async function suggestSkills({
167
167
  limit = DEFAULT_LIMIT,
168
168
  timeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_SKILL_TIMEOUT_MS),
169
169
  indexedSearcher = searchIndexedEmbeddings,
170
- embeddingEnhancer = enhanceRuleScoresWithEmbeddings
170
+ embeddingEnhancer = enhanceRuleScoresWithEmbeddings,
171
+ embeddingsEnabled = true
171
172
  } = {}) {
172
173
  if (!String(prompt || "").trim() || !skills.length) return [];
173
174
  const catalog = dedupeSkills(skills);
174
- const query = fusedProjectQuery({ prompt, cwd, dataDir });
175
+ const query = skillQuery({ prompt, cwd, dataDir });
175
176
  const byId = new Map(catalog.map((skill) => [skillIndexId(skill), skill]));
176
177
  const explicitSkills = explicitSkillSuggestions({ prompt, byId });
178
+ if (!embeddingsEnabled) return finalizeSkillScores(explicitSkills, limit);
177
179
 
178
180
  if (dataDir) {
179
- const indexed = await indexedSearcher({
180
- kind: skillIndexKind(cwd),
181
- task: query,
182
- dataDir,
183
- timeoutMs,
184
- allowRemote: false
185
- });
181
+ const indexed = await searchSkillIndexes({ cwd, query, dataDir, timeoutMs, indexedSearcher });
186
182
  if (indexed.status === "enabled" && indexed.items.length) {
187
183
  return finalizeSkillScores([
188
184
  ...explicitSkills,
@@ -212,6 +208,12 @@ export async function suggestSkills({
212
208
  return finalizeSkillScores([...explicitSkills, ...embedding.rules], limit);
213
209
  }
214
210
 
211
+ function skillQuery({ prompt = "", cwd = process.cwd(), dataDir } = {}) {
212
+ const focusedPrompt = String(prompt || "").trim();
213
+ const fused = fusedProjectQuery({ prompt, cwd, dataDir });
214
+ return [focusedPrompt, focusedPrompt, fused].filter(Boolean).join("\n");
215
+ }
216
+
215
217
  function explicitSkillSuggestions({ prompt = "", byId = new Map() } = {}) {
216
218
  const names = extractExplicitSkillNames(prompt);
217
219
  return names
@@ -275,7 +277,7 @@ export async function warmSkillEmbeddings({
275
277
  } = {}) {
276
278
  if (!dataDir || !skills.length) return { count: 0, cachePath: null };
277
279
  const catalog = dedupeSkills(skills);
278
- return warmIndexedEmbeddings({
280
+ const workspaceResult = await warmIndexedEmbeddings({
279
281
  kind: skillIndexKind(cwd),
280
282
  items: catalog.map((skill) => ({
281
283
  id: skillIndexId(skill),
@@ -286,6 +288,19 @@ export async function warmSkillEmbeddings({
286
288
  sources: catalog.map((skill) => skill.path).filter(Boolean),
287
289
  allowRemote
288
290
  });
291
+ if (workspaceResult.status === "missing-model" || workspaceResult.status === "warm-failed") return workspaceResult;
292
+ await warmIndexedEmbeddings({
293
+ kind: sharedSkillIndexKind(),
294
+ items: catalog.map((skill) => ({
295
+ id: skillIndexId(skill),
296
+ text: skillEmbeddingText(skill)
297
+ })),
298
+ task: fusedProjectQuery({ prompt: "skill discovery semantic retrieval", cwd, dataDir }),
299
+ dataDir,
300
+ sources: catalog.map((skill) => skill.path).filter(Boolean),
301
+ allowRemote
302
+ });
303
+ return workspaceResult;
289
304
  }
290
305
 
291
306
  function skillRule({ skill, index }) {
@@ -343,6 +358,30 @@ function skillIndexKind(cwd) {
343
358
  return `skill:${path.resolve(cwd)}`;
344
359
  }
345
360
 
361
+ function sharedSkillIndexKind() {
362
+ return "skill:global";
363
+ }
364
+
365
+ async function searchSkillIndexes({ cwd, query, dataDir, timeoutMs, indexedSearcher }) {
366
+ const workspace = await indexedSearcher({
367
+ kind: skillIndexKind(cwd),
368
+ task: query,
369
+ dataDir,
370
+ timeoutMs,
371
+ allowRemote: false
372
+ });
373
+ if (workspace.status === "enabled" && workspace.items.length) return workspace;
374
+ const shared = await indexedSearcher({
375
+ kind: sharedSkillIndexKind(),
376
+ task: query,
377
+ dataDir,
378
+ timeoutMs,
379
+ allowRemote: false
380
+ });
381
+ if (shared.status === "enabled" && shared.items.length) return shared;
382
+ return workspace.status === "enabled" ? workspace : shared;
383
+ }
384
+
346
385
  function skillIndexId(skill) {
347
386
  return normalize(skill.name);
348
387
  }
@@ -244,6 +244,106 @@ export function repairSkillSymlinks({
244
244
  return { repaired: [...new Set(repaired)], removedBroken: [...new Set(removedBroken)] };
245
245
  }
246
246
 
247
+ export function dedupeAgentVisibleSkills({
248
+ cwd = process.cwd(),
249
+ home = os.homedir(),
250
+ agents = DEFAULT_AGENTS,
251
+ dryRun = false
252
+ } = {}) {
253
+ const roots = visibleSkillRootsForAgents({ cwd, home, agents });
254
+ const seen = new Map();
255
+ const kept = [];
256
+ const removed = [];
257
+
258
+ for (const root of roots) {
259
+ for (const skill of listSkillsInRoot(root)) {
260
+ const previous = seen.get(skill.key);
261
+ if (!previous) {
262
+ seen.set(skill.key, skill);
263
+ kept.push(skill.path);
264
+ continue;
265
+ }
266
+
267
+ const previousReal = safeRealpath(previous.path);
268
+ const currentReal = safeRealpath(skill.path);
269
+ if (previousReal && currentReal && previousReal === currentReal) {
270
+ if (!dryRun) fs.rmSync(skill.path, { force: true, recursive: true });
271
+ removed.push(skill.path);
272
+ continue;
273
+ }
274
+
275
+ if (!dryRun) fs.rmSync(skill.path, { force: true, recursive: true });
276
+ removed.push(skill.path);
277
+ }
278
+ }
279
+
280
+ return { kept: [...new Set(kept)], removed: [...new Set(removed)], roots };
281
+ }
282
+
283
+ function visibleSkillRootsForAgents({ cwd, home, agents }) {
284
+ const normalizedAgents = normalizeAgentList(agents);
285
+ const roots = [];
286
+ const addShared = () => {
287
+ roots.push(path.join(home, ".agents", "skills"));
288
+ roots.push(path.join(cwd, ".agents", "skills"));
289
+ };
290
+
291
+ addShared();
292
+ if (normalizedAgents.includes("codex")) {
293
+ roots.push(path.join(home, ".codex", "skills"));
294
+ roots.push(path.join(cwd, ".codex", "skills"));
295
+ }
296
+ if (normalizedAgents.includes("claude")) {
297
+ roots.push(path.join(home, ".claude", "skills"));
298
+ roots.push(path.join(cwd, ".claude", "skills"));
299
+ }
300
+ if (normalizedAgents.includes("antigravity")) {
301
+ roots.push(path.join(home, ".gemini", "skills"));
302
+ roots.push(path.join(home, ".gemini", "antigravity", "skills"));
303
+ roots.push(path.join(home, ".gemini", "antigravity-cli", "skills"));
304
+ roots.push(path.join(cwd, ".gemini", "skills"));
305
+ roots.push(path.join(cwd, ".gemini", "antigravity", "skills"));
306
+ roots.push(path.join(cwd, ".gemini", "antigravity-cli", "skills"));
307
+ }
308
+
309
+ return uniquePaths(roots);
310
+ }
311
+
312
+ function listSkillsInRoot(root) {
313
+ return findSkillFiles(root)
314
+ .map((skillFile) => {
315
+ const skillDir = path.dirname(skillFile);
316
+ const name = readSkillName(skillFile) || path.basename(skillDir);
317
+ return {
318
+ name,
319
+ key: normalizeSkillName(name),
320
+ path: skillDir,
321
+ root
322
+ };
323
+ })
324
+ .filter((skill) => skill.key)
325
+ .sort((a, b) => a.path.localeCompare(b.path));
326
+ }
327
+
328
+ function readSkillName(skillFile) {
329
+ try {
330
+ const content = fs.readFileSync(skillFile, "utf8");
331
+ return content.match(/^\s*name:\s*(.+?)\s*$/m)?.[1]
332
+ ?.replace(/^["']|["']$/g, "")
333
+ .trim() || "";
334
+ } catch {
335
+ return "";
336
+ }
337
+ }
338
+
339
+ function normalizeSkillName(name) {
340
+ return String(name || "")
341
+ .trim()
342
+ .toLowerCase()
343
+ .replace(/[^a-z0-9._-]+/g, "-")
344
+ .replace(/^-+|-+$/g, "");
345
+ }
346
+
247
347
  function skillRoots({ cwd, home }) {
248
348
  return uniquePaths([
249
349
  path.join(home, ".claude", "skills"),
@@ -503,6 +603,18 @@ export async function syncSkills({
503
603
  const syncedCount = countSkillFiles(skillshareSourceDir({ home }));
504
604
  logger(statusLine("Running skillshare sync...", options.dryRun ? "dry-run" : `✓ ${syncedCount} skills → ${options.agents.join(", ")}`));
505
605
 
606
+ const deduped = dedupeAgentVisibleSkills({
607
+ cwd,
608
+ home,
609
+ agents: options.agents,
610
+ dryRun: options.dryRun
611
+ });
612
+ if (deduped.removed.length) {
613
+ logger(statusLine("Deduping agent-visible skills...", options.dryRun
614
+ ? `dry-run (${deduped.removed.length} duplicates)`
615
+ : `✓ ${deduped.removed.length} duplicates removed`));
616
+ }
617
+
506
618
  let embeddings = { count: 0, cachePath: null, skipped: options.dryRun || options.noEmbeddings };
507
619
  if (options.noEmbeddings) {
508
620
  logger(statusLine("Rebuilding skill embeddings...", "skipped by --no-embeddings"));
@@ -128,12 +128,14 @@ export async function suggestWorkflows({
128
128
  workflows = [],
129
129
  dataDir,
130
130
  limit = DEFAULT_LIMIT,
131
- timeoutMs = Number(process.env.CONTEXTOS_WORKFLOW_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || 800)
131
+ timeoutMs = Number(process.env.CONTEXTOS_WORKFLOW_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || 800),
132
+ embeddingsEnabled = true
132
133
  } = {}) {
133
134
  if (!String(prompt || "").trim() || !workflows.length) return [];
134
135
  const base = scoreWorkflowsByKeyword({ prompt, workflows });
135
136
  const embeddingCandidates = selectWorkflowEmbeddingCandidates(base);
136
137
  if (!embeddingCandidates.length) return [];
138
+ if (!embeddingsEnabled) return finalizeWorkflowScores(embeddingCandidates, limit);
137
139
 
138
140
  const embedding = await enhanceRuleScoresWithEmbeddings(embeddingCandidates, prompt, {
139
141
  dataDir,