@minhpnq1807/contextos 0.5.49 → 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,17 @@
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
+
10
+ ## 0.5.50
11
+
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.
13
+ - **Agents skill root discovery:** Skill discovery now scans project and global `.agents/skills` roots in addition to Codex, Claude, Gemini, and skillshare roots.
14
+
3
15
  ## 0.5.49
4
16
 
5
17
  - **Cold-cache MCP smoke:** `npm run test:mcp` now verifies the MCP tool contract without requiring a pre-downloaded ContextOS embedding model. When the model exists it still runs the semantic/performance smoke; otherwise it asserts cold-cache fallback behavior so publish CI does not fail before model warmup.
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.49",
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.49",
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
 
@@ -22,11 +22,13 @@ const scanCache = new Map();
22
22
  export function skillSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
23
23
  return [
24
24
  path.join(cwd, ".codex", "skills"),
25
+ path.join(cwd, ".agents", "skills"),
25
26
  path.join(cwd, ".claude", "skills"),
26
27
  path.join(cwd, ".gemini", "skills"),
27
28
  path.join(cwd, ".gemini", "antigravity", "skills"),
28
29
  path.join(cwd, ".gemini", "antigravity-cli", "skills"),
29
30
  path.join(home, ".codex", "skills"),
31
+ path.join(home, ".agents", "skills"),
30
32
  path.join(home, ".claude", "skills"),
31
33
  path.join(home, ".config", "skillshare", "skills"),
32
34
  path.join(home, ".gemini", "skills"),
@@ -165,36 +167,36 @@ export async function suggestSkills({
165
167
  limit = DEFAULT_LIMIT,
166
168
  timeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_SKILL_TIMEOUT_MS),
167
169
  indexedSearcher = searchIndexedEmbeddings,
168
- embeddingEnhancer = enhanceRuleScoresWithEmbeddings
170
+ embeddingEnhancer = enhanceRuleScoresWithEmbeddings,
171
+ embeddingsEnabled = true
169
172
  } = {}) {
170
173
  if (!String(prompt || "").trim() || !skills.length) return [];
171
174
  const catalog = dedupeSkills(skills);
172
- const query = fusedProjectQuery({ prompt, cwd, dataDir });
175
+ const query = skillQuery({ prompt, cwd, dataDir });
173
176
  const byId = new Map(catalog.map((skill) => [skillIndexId(skill), skill]));
177
+ const explicitSkills = explicitSkillSuggestions({ prompt, byId });
178
+ if (!embeddingsEnabled) return finalizeSkillScores(explicitSkills, limit);
174
179
 
175
180
  if (dataDir) {
176
- const indexed = await indexedSearcher({
177
- kind: skillIndexKind(cwd),
178
- task: query,
179
- dataDir,
180
- timeoutMs,
181
- allowRemote: false
182
- });
181
+ const indexed = await searchSkillIndexes({ cwd, query, dataDir, timeoutMs, indexedSearcher });
183
182
  if (indexed.status === "enabled" && indexed.items.length) {
184
- return finalizeSkillScores(indexed.items
183
+ return finalizeSkillScores([
184
+ ...explicitSkills,
185
+ ...indexed.items
185
186
  .map((item) => {
186
187
  const skill = byId.get(item.id);
187
188
  if (!skill) return null;
188
189
  return skillScoreFromEmbedding(skill, item.embeddingScore, [`embedding:${Number(item.embeddingScore || 0).toFixed(2)}`]);
189
190
  })
190
- .filter(Boolean), limit);
191
+ .filter(Boolean)
192
+ ], limit);
191
193
  }
192
194
  }
193
195
 
194
- if (catalog.length > DEFAULT_EMBEDDING_CANDIDATES) return [];
196
+ if (catalog.length > DEFAULT_EMBEDDING_CANDIDATES) return finalizeSkillScores(explicitSkills, limit);
195
197
 
196
198
  const embeddingCandidates = catalog.map((skill, index) => skillRule({ skill, index }));
197
- if (!embeddingCandidates.length) return [];
199
+ if (!embeddingCandidates.length) return finalizeSkillScores(explicitSkills, limit);
198
200
 
199
201
  const embedding = await embeddingEnhancer(embeddingCandidates, query, {
200
202
  dataDir,
@@ -203,7 +205,36 @@ export async function suggestSkills({
203
205
  allowRemote: false
204
206
  });
205
207
 
206
- return finalizeSkillScores(embedding.rules, limit);
208
+ return finalizeSkillScores([...explicitSkills, ...embedding.rules], limit);
209
+ }
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
+
217
+ function explicitSkillSuggestions({ prompt = "", byId = new Map() } = {}) {
218
+ const names = extractExplicitSkillNames(prompt);
219
+ return names
220
+ .map((name, index) => ({ skill: byId.get(normalize(name)), index }))
221
+ .filter(({ skill }) => Boolean(skill))
222
+ .map(({ skill, index }) => skillScoreFromEmbedding(skill, 1 - index * 0.0001, ["explicit-skill"]));
223
+ }
224
+
225
+ function extractExplicitSkillNames(prompt = "") {
226
+ const names = [];
227
+ const seen = new Set();
228
+ const pattern = /(?:^|[\s([{,])\$([A-Za-z0-9][A-Za-z0-9_.:-]*)/g;
229
+ let match;
230
+ while ((match = pattern.exec(String(prompt || "")))) {
231
+ const name = match[1];
232
+ const key = normalize(name);
233
+ if (!key || seen.has(key)) continue;
234
+ seen.add(key);
235
+ names.push(name);
236
+ }
237
+ return names;
207
238
  }
208
239
 
209
240
  function finalizeSkillScores(skills, limit) {
@@ -246,7 +277,7 @@ export async function warmSkillEmbeddings({
246
277
  } = {}) {
247
278
  if (!dataDir || !skills.length) return { count: 0, cachePath: null };
248
279
  const catalog = dedupeSkills(skills);
249
- return warmIndexedEmbeddings({
280
+ const workspaceResult = await warmIndexedEmbeddings({
250
281
  kind: skillIndexKind(cwd),
251
282
  items: catalog.map((skill) => ({
252
283
  id: skillIndexId(skill),
@@ -257,6 +288,19 @@ export async function warmSkillEmbeddings({
257
288
  sources: catalog.map((skill) => skill.path).filter(Boolean),
258
289
  allowRemote
259
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;
260
304
  }
261
305
 
262
306
  function skillRule({ skill, index }) {
@@ -314,6 +358,30 @@ function skillIndexKind(cwd) {
314
358
  return `skill:${path.resolve(cwd)}`;
315
359
  }
316
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
+
317
385
  function skillIndexId(skill) {
318
386
  return normalize(skill.name);
319
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,