@minhpnq1807/contextos 0.5.46 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.50
4
+
5
+ - **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.
6
+ - **Agents skill root discovery:** Skill discovery now scans project and global `.agents/skills` roots in addition to Codex, Claude, Gemini, and skillshare roots.
7
+
8
+ ## 0.5.49
9
+
10
+ - **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.
11
+
12
+ ## 0.5.48
13
+
14
+ - **Publish-safe embedding warm:** `ctx embeddings warm` now skips remote model downloads by default in CI and returns best-effort warm results when local embedding model loading fails, preventing npm publish workflows from failing on Hugging Face 429/rate-limit errors.
15
+ - **CI graph embedding skip:** Code-review-graph embedding refresh is skipped when remote embeddings are disabled, avoiding extra Hugging Face calls during publish jobs.
16
+
17
+ ## 0.5.47
18
+
19
+ - **Semantic-only skill discovery:** Replaced taxonomy/keyword skill ranking with fused semantic retrieval. ContextOS now embeds `prompt + project profile` and compares it against cached skill vectors, so mixed Vietnamese/English prompts can use package, language, and recent-file signals without hard-coded categories.
20
+ - **Project profile cache:** Added a bounded project profiler that builds an embeddable string from root/workspace `package.json` dependencies, scripts, detected languages, and recent git files. The cache invalidates when package metadata or git `HEAD` changes.
21
+ - **Skill dedupe and activation format:** Skill catalogs are deduplicated by normalized skill name before indexing/searching, preferring project and Codex roots. Prompt output now renders skill activations as `$skill-name`.
22
+ - **Refresh rebuilds skill index:** `ctx install` and `ctx refresh` now rebuild the skill embedding index by default because prompt-time skill discovery no longer falls back to taxonomy or keyword ranking for large catalogs.
23
+ - **Skill hook timeout split:** Prompt hooks now use a separate skill embedding timeout, so large indexed skill catalogs can still produce suggestions after direct fallback without slowing rule scoring.
24
+ - **Read-only embedding cache close:** Prompt-time embedding reads no longer rewrite the whole `embeddings.db` when no cache entries changed.
25
+
3
26
  ## 0.5.46
4
27
 
5
28
  - **Configurable prompt suggestion limits:** `ctx --config` and interactive `ctx setup` now let users choose how many suggested files, skills, and workflows appear in prompt context. Defaults are five each, with caps of 20 files, 10 skills, and 5 workflows.
package/README.md CHANGED
@@ -445,7 +445,7 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
445
445
  | `ctx skills` | Installs community skill libraries. | You want curated skills without running the full setup wizard. | Opens the community installer, uses a portable shell on Windows/Linux/macOS, repairs unsafe skill symlinks, and syncs installed skills to selected agents. |
446
446
  | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files/skills/workflows. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes rule, file-path, skill, and workflow vectors to `~/.ctx/contextos/embeddings.db`. |
447
447
  | `ctx --config` | Opens an interactive panel for prompt sections and suggestion limits. | You want to reduce ContextOS prompt output noise. | Toggles critical rules, suggested files, suggested skills, and suggested workflows globally under `~/.ctx/contextos/output-config.json`, then lets you set suggestion counts for files, skills, and workflows. |
448
- | `ctx refresh` | Refreshes the active Codex marketplace plugin and rebuilds local indexes. | Local development updates or a stale file retrieval index. | Copies the current package to `$CODEX_HOME/marketplaces/contextos`, rebuilds file-path embeddings and import adjacency, and refreshes code-review-graph embeddings when available. |
448
+ | `ctx refresh` | Refreshes the active Codex marketplace plugin and rebuilds local indexes. | Local development updates or stale file/skill retrieval indexes. | Copies the current package to `$CODEX_HOME/marketplaces/contextos`, rebuilds file-path embeddings, skill embeddings, import adjacency, and refreshes code-review-graph embeddings when available. |
449
449
  | `ctx ruler -- <args>` | Forwards args to the installed `ruler` CLI. | You need native Ruler commands such as `init`, `apply`, or `revert`. | Preserves Ruler stdout/stderr and exit status. |
450
450
  | `ctx skillshare -- <args>` | Forwards args to the installed `skillshare` CLI. | You need native skillshare commands such as `status`, `target list`, `doctor`, `push`, or `pull`. | Preserves skillshare stdout/stderr and exit status. |
451
451
  | `ctx --version` | Prints the installed ContextOS CLI version. | You want to confirm which npm version is being executed. | Prints the version from package metadata. |
@@ -511,17 +511,23 @@ This keeps the hook fast and local while still using graph semantics when availa
511
511
 
512
512
  Prompt scoring does not walk the repository for file candidates or import expansion. `ctx install` and `ctx embeddings warm` rebuild the persisted file-vector index and one-hop import adjacency index by walking source paths once; prompt hooks query those indexes directly. Rules, files, skills, and workflows are scored concurrently with `Promise.all()`.
513
513
 
514
- `ctx embeddings warm` automatically refreshes the active Codex marketplace payload before rebuilding indexes. Use `ctx refresh` when you want the same marketplace sync plus install-style file/import index and code-review-graph embedding refresh in one command.
514
+ `ctx embeddings warm` automatically refreshes the active Codex marketplace payload before rebuilding indexes. Use `ctx refresh` when you want the same marketplace sync plus install-style file, skill, import, and code-review-graph embedding refresh in one command.
515
515
 
516
516
  If a prompt has no usable context candidates, the hook fails open without emitting an empty `hook context` block, records `emptyContextReason` in the workspace runtime file, and starts a detached `autowarm` rebuild with a cooldown. That background rebuild refreshes file vectors, skill/workflow vectors, import adjacency, and available code-review-graph node embeddings for the next prompt while keeping repository walking out of the current prompt hot path.
517
517
 
518
518
  Use `ctx --config` to choose which prompt sections ContextOS injects and how many suggestions each section may show. Interactive `ctx setup` includes the same section picker and limit prompts, while `ctx setup --yes` keeps the current saved config for automation. The panel supports multiple selection with `Space` and persists the global choice in `~/.ctx/contextos/output-config.json`. Defaults are five suggested files, five skills, and five workflows; caps are 20 files, 10 skills, and 5 workflows. Disabling rules hides both critical and additional relevant rule sections; compliance metadata remains available for reports.
519
519
 
520
- Injected prompt sections are intentionally compact: rules show only detected rule text, files show a comma-separated inline list of basenames without paths, skills show unique names as a comma-separated inline list without descriptions, and workflows show names with their agent chain. Stop hooks persist reports silently; run `ctx report` or `ctx evidence` when you want the detailed compliance output.
520
+ Injected prompt sections are intentionally compact: rules show only detected rule text, files show a comma-separated inline list of basenames without paths, skills show unique `$skill-name` activations as a comma-separated inline list without descriptions, and workflows show names with their agent chain. Stop hooks persist reports silently; run `ctx report` or `ctx evidence` when you want the detailed compliance output.
521
521
 
522
522
  Codex may flatten newlines in its `UserPromptSubmit hook (completed)` preview. The injected `additionalContext` payload remains multiline; this is a Codex preview display limitation.
523
523
 
524
- Skill ranking uses bounded project hints from root/workspace `package.json` files and known mobile config files such as `app.json`, `app.config.*`, and `eas.json`. This lets Expo/EAS and MCP tasks activate specialized skills without walking the source tree on every prompt. Document-authoring prompts also get explicit intent handling for README, wiki, workspace documentation, guides, specs, and ADR work, while document-processing or workspace-automation providers only rank highly when the prompt actually names that provider or processing task.
524
+ Skill ranking is semantic-only. ContextOS builds a fused query from the user prompt plus a cached project profile, then compares that vector with cached skill vectors:
525
+
526
+ ```text
527
+ embed(prompt + project profile) -> cosine -> embed(skill name + description)
528
+ ```
529
+
530
+ The project profile is an embeddable string built from bounded root/workspace `package.json` metadata, dependencies, scripts, detected languages, and recent git files. It is cached under the ContextOS workspace data directory and invalidated when package metadata or git `HEAD` changes. ContextOS does not maintain a skill taxonomy or domain gate list for ranking; if the skill index is cold for a large catalog, prompt hooks fail open instead of falling back to arbitrary keyword matches. Skill catalogs are deduplicated by normalized skill name before indexing and rendering.
525
531
 
526
532
  After `ctx refresh`, ContextOS invalidates the private hook bridge socket so prompts fall back to direct scoring until Codex restarts the long-running `ctx-mcp` process. Hook clients also discard a same-inode socket if an older bridge revision is detected.
527
533
 
@@ -538,6 +544,8 @@ CONTEXTOS_HOOK_DEADLINE_MS=8500 hard fail-open deadline for prompt hooks
538
544
  CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS=6000 direct scoring timeout when the bridge is unavailable
539
545
  CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS=500 rule embedding timeout during hook direct fallback
540
546
  CONTEXTOS_EMBEDDING_TIMEOUT_MS=800 embedding scoring timeout inside ctx-mcp/debug
547
+ CONTEXTOS_HOOK_SKILL_EMBEDDING_TIMEOUT_MS=2000 skill retrieval timeout during hook direct fallback
548
+ CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS=2000 skill retrieval timeout inside ctx-mcp/debug
541
549
  CONTEXTOS_FILE_EMBEDDINGS=0 disable file-path embedding retrieval
542
550
  CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS=500 file retrieval timeout during hook direct fallback
543
551
  CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS=1000 file-path embedding retrieval timeout
package/bin/ctx.js CHANGED
@@ -487,6 +487,7 @@ function graphStrategyForInstall() {
487
487
  async function warmInstallEmbeddings() {
488
488
  const dataDir = contextOSDataDir();
489
489
  const modelReady = isModelCacheReady(dataDir);
490
+ const allowRemote = shouldAllowRemoteWarm(modelReady);
490
491
  const result = await warmRuleEmbeddings({
491
492
  rules: [
492
493
  { content: "Always use project rules that are semantically relevant to the user prompt." },
@@ -496,29 +497,29 @@ async function warmInstallEmbeddings() {
496
497
  task: "kiểm duyệt upload moderation semantic code search",
497
498
  dataDir,
498
499
  sources: [],
499
- allowRemote: !modelReady
500
+ allowRemote
500
501
  });
501
502
  const fileResult = await warmFileEmbeddings({
502
503
  cwd: process.cwd(),
503
504
  dataDir,
504
- allowRemote: !modelReady
505
+ allowRemote
506
+ });
507
+ const skillResult = await warmSkillEmbeddings({
508
+ cwd: process.cwd(),
509
+ dataDir,
510
+ allowRemote
505
511
  });
506
512
  const warmDiscovery = process.env.CONTEXTOS_INSTALL_WARM_DISCOVERY === "1";
507
- const skillResult = warmDiscovery
508
- ? await warmSkillEmbeddings({
509
- cwd: process.cwd(),
510
- dataDir,
511
- allowRemote: !modelReady
512
- })
513
- : { count: 0 };
514
513
  const workflowResult = warmDiscovery
515
514
  ? await warmWorkflowEmbeddings({
516
515
  cwd: process.cwd(),
517
516
  dataDir,
518
- allowRemote: !modelReady
517
+ allowRemote
519
518
  })
520
519
  : { count: 0 };
521
- const graphEmbedding = embedCodeReviewGraph({ cwd: process.cwd() });
520
+ const graphEmbedding = allowRemote
521
+ ? embedCodeReviewGraph({ cwd: process.cwd() })
522
+ : { status: "skipped", reason: "remote-embedding-disabled" };
522
523
  return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count, skillCount: skillResult.count, workflowCount: workflowResult.count, graphEmbedding };
523
524
  }
524
525
 
@@ -663,31 +664,36 @@ async function warmEmbeddings(task, { syncMarketplace = true, quiet = false } =
663
664
 
664
665
  async function warmWorkspaceIndexes({ task = "project context" } = {}) {
665
666
  const cwd = process.cwd();
667
+ const dataDir = contextOSDataDir();
668
+ const modelReady = isModelCacheReady(dataDir);
669
+ const allowRemote = shouldAllowRemoteWarm(modelReady);
666
670
  const merged = readAgentsChain({ cwd });
667
671
  const rules = scoreRules(filterActionableRules(parseRules(merged.content)), task, []);
668
672
  const result = await warmRuleEmbeddings({
669
673
  rules,
670
674
  task,
671
- dataDir: contextOSDataDir(),
675
+ dataDir,
672
676
  sources: merged.sources,
673
- allowRemote: true
677
+ allowRemote
674
678
  });
675
679
  const fileResult = await warmFileEmbeddings({
676
680
  cwd,
677
- dataDir: contextOSDataDir(),
678
- allowRemote: true
681
+ dataDir,
682
+ allowRemote
679
683
  });
680
684
  const skillResult = await warmSkillEmbeddings({
681
685
  cwd,
682
- dataDir: contextOSDataDir(),
683
- allowRemote: true
686
+ dataDir,
687
+ allowRemote
684
688
  });
685
689
  const workflowResult = await warmWorkflowEmbeddings({
686
690
  cwd,
687
- dataDir: contextOSDataDir(),
688
- allowRemote: true
691
+ dataDir,
692
+ allowRemote
689
693
  });
690
- const graphEmbedding = embedCodeReviewGraph({ cwd });
694
+ const graphEmbedding = allowRemote
695
+ ? embedCodeReviewGraph({ cwd })
696
+ : { status: "skipped", reason: "remote-embedding-disabled" };
691
697
  return {
692
698
  ruleCount: result.count,
693
699
  fileCount: fileResult.count,
@@ -698,12 +704,28 @@ async function warmWorkspaceIndexes({ task = "project context" } = {}) {
698
704
  };
699
705
  }
700
706
 
707
+ function shouldAllowRemoteWarm(modelReady) {
708
+ if (modelReady) return false;
709
+ if (process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE !== undefined) {
710
+ return process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE === "1";
711
+ }
712
+ return !isCiEnvironment();
713
+ }
714
+
715
+ function isCiEnvironment() {
716
+ return process.env.CI === "true"
717
+ || process.env.GITHUB_ACTIONS === "true"
718
+ || process.env.CONTINUOUS_INTEGRATION === "true"
719
+ || process.env.BUILD_ID !== undefined
720
+ || process.env.RUN_ID !== undefined;
721
+ }
722
+
701
723
  async function refresh() {
702
724
  const marketplaceSync = syncActiveCodexMarketplace();
703
725
  const invalidatedBridge = invalidateCtxMcpSocket(contextOSDataDir());
704
726
  const warmResult = await warmInstallEmbeddings();
705
727
  console.log(`Marketplace: ${marketplaceSync.synced ? "synced" : "already active"} (${marketplaceSync.targetRoot})`);
706
- console.log(`Indexes: ${warmResult.fileCount || 0} file paths rebuilt`);
728
+ console.log(`Indexes: ${warmResult.fileCount || 0} file paths rebuilt, ${warmResult.skillCount || 0} skills indexed`);
707
729
  console.log(`Graph embeddings: ${formatCodeReviewGraphEmbedding(warmResult.graphEmbedding)}`);
708
730
  if (invalidatedBridge) console.log("Bridge: stale private socket invalidated");
709
731
  console.log("Restart Codex if ctx-mcp was already running.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.46",
3
+ "version": "0.5.50",
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.46",
3
+ "version": "0.5.50",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -65,13 +65,28 @@ export async function warmRuleEmbeddings({
65
65
  ...rules.map((rule) => rule.content || "")
66
66
  ].filter((text) => String(text).trim()))];
67
67
 
68
- const cache = await openEmbeddingCache(dataDir);
69
- const embedder = await getExtractor({ allowRemote, dataDir });
70
- for (const text of texts) {
71
- await getCachedEmbedding({ cache, embedder, text, sources });
68
+ let cache;
69
+ try {
70
+ cache = await openEmbeddingCache(dataDir);
71
+ const embedder = await getExtractor({ allowRemote, dataDir });
72
+ for (const text of texts) {
73
+ await getCachedEmbedding({ cache, embedder, text, sources, flush: false });
74
+ }
75
+ cache.close();
76
+ return { count: texts.length, cachePath: cache.path, status: "enabled" };
77
+ } catch (error) {
78
+ try {
79
+ cache?.close();
80
+ } catch {
81
+ // Ignore close failures while reporting a best-effort warm result.
82
+ }
83
+ return {
84
+ count: 0,
85
+ cachePath: path.join(dataDir, "embeddings.db"),
86
+ status: "warm-failed",
87
+ error: error?.message || String(error)
88
+ };
72
89
  }
73
- cache.close();
74
- return { count: texts.length, cachePath: cache.path };
75
90
  }
76
91
 
77
92
  export async function searchIndexedEmbeddings({
@@ -106,20 +121,35 @@ export async function warmIndexedEmbeddings({
106
121
  return { count: 0, cachePath: path.join(dataDir, "embeddings.db"), status: "missing-model" };
107
122
  }
108
123
 
109
- const cache = await openEmbeddingCache(dataDir);
110
- const embedder = await getExtractor({ allowRemote, dataDir });
111
- if (String(task || "").trim()) await getCachedEmbedding({ cache, embedder, text: task, sources });
112
-
113
- const indexed = [];
114
- for (const item of items) {
115
- const text = String(item.text || "");
116
- if (!item.id || !text.trim()) continue;
117
- const vector = await getCachedEmbedding({ cache, embedder, text, sources });
118
- indexed.push({ id: item.id, text, vector });
124
+ let cache;
125
+ try {
126
+ cache = await openEmbeddingCache(dataDir);
127
+ const embedder = await getExtractor({ allowRemote, dataDir });
128
+ if (String(task || "").trim()) await getCachedEmbedding({ cache, embedder, text: task, sources });
129
+
130
+ const indexed = [];
131
+ for (const item of items) {
132
+ const text = String(item.text || "");
133
+ if (!item.id || !text.trim()) continue;
134
+ const vector = await getCachedEmbedding({ cache, embedder, text, sources, flush: false });
135
+ indexed.push({ id: item.id, text, vector });
136
+ }
137
+ cache.replaceIndex(kind, indexed);
138
+ cache.close();
139
+ return { count: indexed.length, cachePath: cache.path, status: "enabled" };
140
+ } catch (error) {
141
+ try {
142
+ cache?.close();
143
+ } catch {
144
+ // Ignore close failures while reporting a best-effort warm result.
145
+ }
146
+ return {
147
+ count: 0,
148
+ cachePath: path.join(dataDir, "embeddings.db"),
149
+ status: "warm-failed",
150
+ error: error?.message || String(error)
151
+ };
119
152
  }
120
- cache.replaceIndex(kind, indexed);
121
- cache.close();
122
- return { count: indexed.length, cachePath: cache.path };
123
153
  }
124
154
 
125
155
  async function enhanceRuleScores(rules, task, { dataDir, sources, allowRemote }) {
@@ -187,7 +217,10 @@ async function getExtractor({ allowRemote, dataDir }) {
187
217
  return transformers.pipeline("feature-extraction", DEFAULT_MODEL, {
188
218
  quantized: true
189
219
  });
190
- })());
220
+ })().catch((error) => {
221
+ extractorPromises.delete(key);
222
+ throw error;
223
+ }));
191
224
  }
192
225
  return extractorPromises.get(key);
193
226
  }
@@ -206,7 +239,7 @@ export function isModelCacheReady(dataDir = defaultDataRoot()) {
206
239
  ].every((relativePath) => fs.existsSync(path.join(modelDir, relativePath)));
207
240
  }
208
241
 
209
- async function getCachedEmbedding({ cache, embedder, text, sources }) {
242
+ async function getCachedEmbedding({ cache, embedder, text, sources, flush = true }) {
210
243
  const key = cacheKey(text, sources);
211
244
  const existing = cache.get(key);
212
245
  if (existing) return existing;
@@ -216,7 +249,7 @@ async function getCachedEmbedding({ cache, embedder, text, sources }) {
216
249
  normalize: true
217
250
  });
218
251
  const embedding = Array.from(output.data || []);
219
- cache.set(key, embedding);
252
+ cache.set(key, embedding, { flush });
220
253
  return embedding;
221
254
  }
222
255
 
@@ -225,6 +258,7 @@ export async function openEmbeddingCache(dataDir) {
225
258
  const cachePath = path.join(dataDir, "embeddings.db");
226
259
  const SQL = await getSql();
227
260
  const db = initializeEmbeddingDatabase(SQL, cachePath);
261
+ let dirty = false;
228
262
 
229
263
  return {
230
264
  path: cachePath,
@@ -238,12 +272,16 @@ export async function openEmbeddingCache(dataDir) {
238
272
  stmt.free();
239
273
  }
240
274
  },
241
- set(key, vector) {
275
+ set(key, vector, { flush = true } = {}) {
242
276
  db.run(
243
277
  "INSERT OR REPLACE INTO embeddings (key, model, vector, updated_at) VALUES (?, ?, ?, ?)",
244
278
  [key, DEFAULT_MODEL, JSON.stringify(vector), new Date().toISOString()]
245
279
  );
246
- writeDatabaseAtomically(cachePath, db);
280
+ dirty = true;
281
+ if (flush) {
282
+ writeDatabaseAtomically(cachePath, db);
283
+ dirty = false;
284
+ }
247
285
  },
248
286
  listIndexed(kind) {
249
287
  const stmt = db.prepare("SELECT id, text, vector FROM embedding_index WHERE kind = ? AND model = ?");
@@ -268,9 +306,10 @@ export async function openEmbeddingCache(dataDir) {
268
306
  );
269
307
  }
270
308
  writeDatabaseAtomically(cachePath, db);
309
+ dirty = false;
271
310
  },
272
311
  close() {
273
- writeDatabaseAtomically(cachePath, db);
312
+ if (dirty) writeDatabaseAtomically(cachePath, db);
274
313
  db.close();
275
314
  }
276
315
  };
@@ -91,6 +91,7 @@ export function formatCodeReviewGraphEmbedding(result) {
91
91
  }
92
92
  if (result.reason === "missing-graph-index") return "skipped (no .code-review-graph/graph.db)";
93
93
  if (result.reason === "missing-code-review-graph-python") return "skipped (code-review-graph Python unavailable)";
94
+ if (result.reason === "remote-embedding-disabled") return "skipped (remote embedding disabled)";
94
95
  return `skipped (${result.error || result.reason || "unavailable"})`;
95
96
  }
96
97
 
@@ -0,0 +1,207 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const PROFILE_CACHE_FILE = "project-profile.json";
6
+ const MAX_DEPENDENCIES = 80;
7
+ const MAX_SCRIPTS = 30;
8
+ const MAX_RECENT_FILES = 20;
9
+
10
+ export function projectProfile({ cwd = process.cwd(), dataDir } = {}) {
11
+ const fingerprint = projectFingerprint(cwd);
12
+ const cachePath = dataDir ? path.join(dataDir, PROFILE_CACHE_FILE) : null;
13
+ const cached = cachePath ? readCachedProfile(cachePath, fingerprint) : null;
14
+ if (cached) return cached;
15
+
16
+ const packagePaths = workspacePackagePaths(cwd);
17
+ const packageTexts = packagePaths.map((packagePath) => packageSignal(cwd, packagePath)).filter(Boolean);
18
+ const recentFiles = recentGitFiles(cwd, MAX_RECENT_FILES);
19
+ const languages = languageSignal({ cwd, packagePaths, recentFiles });
20
+ const embeddableString = [
21
+ packageTexts.length ? `[project packages: ${packageTexts.join(" | ")}]` : "",
22
+ languages.length ? `[project languages: ${languages.join(" ")}]` : "",
23
+ recentFiles.length ? `[recent files: ${recentFiles.join(", ")}]` : ""
24
+ ].filter(Boolean).join(" ");
25
+
26
+ const profile = { fingerprint, embeddableString };
27
+ if (cachePath) writeCachedProfile(cachePath, profile);
28
+ return profile;
29
+ }
30
+
31
+ export function fusedProjectQuery({ prompt = "", cwd = process.cwd(), dataDir } = {}) {
32
+ const profile = projectProfile({ cwd, dataDir });
33
+ return [String(prompt || "").trim(), profile.embeddableString].filter(Boolean).join("\n");
34
+ }
35
+
36
+ function readCachedProfile(cachePath, fingerprint) {
37
+ try {
38
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
39
+ if (cached?.fingerprint === fingerprint && typeof cached.embeddableString === "string") return cached;
40
+ } catch {
41
+ // Cache miss.
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function writeCachedProfile(cachePath, profile) {
47
+ try {
48
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
49
+ fs.writeFileSync(cachePath, JSON.stringify(profile, null, 2));
50
+ } catch {
51
+ // The profile is an optimization; prompt scoring can continue without a cache write.
52
+ }
53
+ }
54
+
55
+ function projectFingerprint(cwd) {
56
+ const parts = [];
57
+ for (const packagePath of workspacePackagePaths(cwd)) {
58
+ try {
59
+ const stat = fs.statSync(packagePath);
60
+ parts.push(`${path.relative(cwd, packagePath)}:${stat.mtimeMs}:${stat.size}`);
61
+ } catch {
62
+ parts.push(`${path.relative(cwd, packagePath)}:missing`);
63
+ }
64
+ }
65
+ parts.push(`git:${gitHead(cwd)}`);
66
+ return parts.join("|");
67
+ }
68
+
69
+ function packageSignal(cwd, packagePath) {
70
+ const packageJson = readJson(packagePath);
71
+ if (!packageJson) return "";
72
+ const packageDir = path.dirname(packagePath);
73
+ const dependencies = [
74
+ ...Object.keys(packageJson.dependencies || {}),
75
+ ...Object.keys(packageJson.devDependencies || {}),
76
+ ...Object.keys(packageJson.peerDependencies || {})
77
+ ].slice(0, MAX_DEPENDENCIES);
78
+ const scripts = Object.keys(packageJson.scripts || {}).slice(0, MAX_SCRIPTS);
79
+ const configFiles = ["app.json", "app.config.js", "app.config.ts", "eas.json", "tsconfig.json", "Dockerfile"]
80
+ .filter((fileName) => fs.existsSync(path.join(packageDir, fileName)));
81
+ return [
82
+ path.relative(cwd, packagePath) || "package.json",
83
+ packageJson.name,
84
+ packageJson.description,
85
+ Array.isArray(packageJson.keywords) ? packageJson.keywords.join(" ") : "",
86
+ dependencies.join(" "),
87
+ scripts.length ? `scripts ${scripts.join(" ")}` : "",
88
+ configFiles.join(" ")
89
+ ].filter(Boolean).join(" ");
90
+ }
91
+
92
+ function languageSignal({ cwd, packagePaths, recentFiles }) {
93
+ const values = new Set();
94
+ for (const packagePath of packagePaths) {
95
+ const packageDir = path.dirname(packagePath);
96
+ const packageJson = readJson(packagePath);
97
+ const deps = normalize(Object.keys({
98
+ ...(packageJson?.dependencies || {}),
99
+ ...(packageJson?.devDependencies || {})
100
+ }).join(" "));
101
+ if (deps.includes("typescript") || fs.existsSync(path.join(packageDir, "tsconfig.json"))) values.add("TypeScript");
102
+ if (deps.includes("python")) values.add("Python");
103
+ if (deps.includes("go")) values.add("Go");
104
+ if (deps.includes("react")) values.add("React");
105
+ if (deps.includes("next")) values.add("Next.js");
106
+ if (deps.includes("expo") || deps.includes("react native")) values.add("React Native");
107
+ }
108
+ for (const file of recentFiles) {
109
+ const ext = path.extname(file);
110
+ if ([".ts", ".tsx"].includes(ext)) values.add("TypeScript");
111
+ if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) values.add("JavaScript");
112
+ if (ext === ".py") values.add("Python");
113
+ if (ext === ".go") values.add("Go");
114
+ if (ext === ".rs") values.add("Rust");
115
+ if (ext === ".java") values.add("Java");
116
+ }
117
+ if (!values.size && fs.existsSync(path.join(cwd, "package.json"))) values.add("JavaScript");
118
+ return [...values];
119
+ }
120
+
121
+ function recentGitFiles(cwd, limit) {
122
+ try {
123
+ const output = execFileSync("git", ["log", "--name-only", "--pretty=format:", "-n", "30"], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ stdio: ["ignore", "pipe", "ignore"],
127
+ timeout: 300
128
+ });
129
+ const seen = new Set();
130
+ for (const line of output.split(/\r?\n/)) {
131
+ const file = line.trim();
132
+ if (!file || seen.has(file)) continue;
133
+ seen.add(file);
134
+ if (seen.size >= limit) break;
135
+ }
136
+ return [...seen];
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ function gitHead(cwd) {
143
+ try {
144
+ return execFileSync("git", ["rev-parse", "HEAD"], {
145
+ cwd,
146
+ encoding: "utf8",
147
+ stdio: ["ignore", "pipe", "ignore"],
148
+ timeout: 300
149
+ }).trim();
150
+ } catch {
151
+ return "nogit";
152
+ }
153
+ }
154
+
155
+ export function workspacePackagePaths(cwd) {
156
+ const rootPackagePath = path.join(cwd, "package.json");
157
+ const rootPackage = readJson(rootPackagePath);
158
+ const paths = new Set(fs.existsSync(rootPackagePath) ? [rootPackagePath] : []);
159
+ for (const workspace of workspacePatterns(rootPackage?.workspaces)) {
160
+ for (const packagePath of expandWorkspacePattern({ cwd, pattern: workspace })) {
161
+ paths.add(packagePath);
162
+ }
163
+ }
164
+ return [...paths];
165
+ }
166
+
167
+ function workspacePatterns(workspaces) {
168
+ if (Array.isArray(workspaces)) return workspaces.filter((item) => typeof item === "string");
169
+ if (Array.isArray(workspaces?.packages)) return workspaces.packages.filter((item) => typeof item === "string");
170
+ return [];
171
+ }
172
+
173
+ function expandWorkspacePattern({ cwd, pattern }) {
174
+ const normalizedPattern = String(pattern || "").replace(/\\/g, "/").replace(/\/+$/g, "");
175
+ if (!normalizedPattern || normalizedPattern.startsWith("..") || path.isAbsolute(normalizedPattern)) return [];
176
+ if (!normalizedPattern.includes("*")) {
177
+ const packagePath = path.join(cwd, normalizedPattern, "package.json");
178
+ return fs.existsSync(packagePath) ? [packagePath] : [];
179
+ }
180
+ const parts = normalizedPattern.split("/");
181
+ const starIndex = parts.indexOf("*");
182
+ if (starIndex < 0 || parts.includes("**")) return [];
183
+ const baseDir = path.join(cwd, ...parts.slice(0, starIndex));
184
+ const suffix = parts.slice(starIndex + 1);
185
+ let entries = [];
186
+ try {
187
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
188
+ } catch {
189
+ return [];
190
+ }
191
+ return entries
192
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
193
+ .map((entry) => path.join(baseDir, entry.name, ...suffix, "package.json"))
194
+ .filter((packagePath) => fs.existsSync(packagePath));
195
+ }
196
+
197
+ function readJson(filePath) {
198
+ try {
199
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function normalize(value) {
206
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
207
+ }
@@ -56,7 +56,8 @@ export async function handlePromptPayload(
56
56
  maxWorkflows: promptLimits.workflows,
57
57
  dataDir: mcpDataDir || dataDir,
58
58
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS || 500),
59
- fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 1000)
59
+ fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 1000),
60
+ skillEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_SKILL_EMBEDDING_TIMEOUT_MS || 2000)
60
61
  }), directFallbackTimeoutMs, "direct fallback scoring");
61
62
  scored.telemetry = {
62
63
  ...(scored.telemetry || {}),
@@ -97,7 +97,8 @@ function formatFile(file, basenameCounts) {
97
97
  }
98
98
 
99
99
  function formatSkill(skill) {
100
- return skill.name;
100
+ const name = String(skill.name || "").trim();
101
+ return name.startsWith("$") ? name : `$${name}`;
101
102
  }
102
103
 
103
104
  function formatWorkflow(workflow) {
@@ -17,7 +17,9 @@ export async function scoreContext({
17
17
  skills = null,
18
18
  workflows = null,
19
19
  embeddingTimeoutMs = 5000,
20
- fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 1000)
20
+ fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 1000),
21
+ skillEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || embeddingTimeoutMs),
22
+ skillSearchOptions = {}
21
23
  } = {}) {
22
24
  const started = Date.now();
23
25
  const ruleInputsPromise = Promise.resolve().then(() => {
@@ -59,7 +61,15 @@ export async function scoreContext({
59
61
  const catalog = Array.isArray(skills) ? skills : scanSkills({ cwd });
60
62
  return {
61
63
  catalog,
62
- suggestions: await suggestSkills({ cwd, prompt, skills: catalog, dataDir, limit: maxSkills })
64
+ suggestions: await suggestSkills({
65
+ cwd,
66
+ prompt,
67
+ skills: catalog,
68
+ dataDir,
69
+ limit: maxSkills,
70
+ timeoutMs: skillEmbeddingTimeoutMs,
71
+ ...skillSearchOptions
72
+ })
63
73
  };
64
74
  });
65
75
 
@@ -2,40 +2,33 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { enhanceRuleScoresWithEmbeddings, warmRuleEmbeddings } from "./embedding-scorer.js";
5
+ import {
6
+ enhanceRuleScoresWithEmbeddings,
7
+ searchIndexedEmbeddings,
8
+ warmIndexedEmbeddings
9
+ } from "./embedding-scorer.js";
10
+ import { fusedProjectQuery, workspacePackagePaths } from "./project-profiler.js";
6
11
 
7
12
  const DEFAULT_LIMIT = 3;
8
13
  const DEFAULT_MAX_SKILLS = 2000;
9
14
  const DEFAULT_EMBEDDING_CANDIDATES = 120;
10
- const DEFAULT_SEMANTIC_CATALOG_LIMIT = 300;
11
15
  const SCAN_CACHE_TTL_MS = 5 * 60 * 1000;
12
16
  const MAX_DESCRIPTION_CHARS = 500;
13
- const GENERIC_SKILL_TOKENS = new Set([
14
- "active", "agent", "agents", "code", "config", "configuration", "create", "development",
15
- "environment", "file", "files", "graph", "install", "integration", "local", "node", "package",
16
- "project", "refresh", "rebuild", "setup", "skill", "skills", "sync", "tool", "tools", "using",
17
- "build", "can", "not", "production", "show", "something", "https", "http", "com", "www",
18
- "a", "an", "and", "are", "as", "at", "be", "before", "after", "both", "by", "from", "for",
19
- "if", "in", "into", "is", "must", "of", "on", "or", "the", "then", "this", "to", "user",
20
- "users", "when", "where", "whether", "with"
21
- ]);
22
- const SPECIALIZED_SKILL_TOKENS = new Set([
23
- "android", "architecture", "authorization", "cicd", "documentation", "docs", "document",
24
- "eas", "expo", "frontend", "ios", "next", "nextjs", "mcp", "modelcontextprotocol",
25
- "postgres", "postgresql", "react", "react-native", "readme", "tailwind", "typescript",
26
- "ui", "wiki", "writer"
27
- ]);
17
+ const SKILL_EMBEDDING_THRESHOLD = 0.45;
18
+ const DEFAULT_SKILL_TIMEOUT_MS = 2000;
28
19
 
29
20
  const scanCache = new Map();
30
21
 
31
22
  export function skillSearchRoots({ cwd = process.cwd(), home = os.homedir() } = {}) {
32
23
  return [
33
24
  path.join(cwd, ".codex", "skills"),
25
+ path.join(cwd, ".agents", "skills"),
34
26
  path.join(cwd, ".claude", "skills"),
35
27
  path.join(cwd, ".gemini", "skills"),
36
28
  path.join(cwd, ".gemini", "antigravity", "skills"),
37
29
  path.join(cwd, ".gemini", "antigravity-cli", "skills"),
38
30
  path.join(home, ".codex", "skills"),
31
+ path.join(home, ".agents", "skills"),
39
32
  path.join(home, ".claude", "skills"),
40
33
  path.join(home, ".config", "skillshare", "skills"),
41
34
  path.join(home, ".gemini", "skills"),
@@ -124,8 +117,9 @@ function monotonicNow() {
124
117
  }
125
118
 
126
119
  function cacheAndReturnSkills(cacheKey, skills) {
127
- scanCache.set(cacheKey, { createdAt: monotonicNow(), skills });
128
- return skills;
120
+ const deduped = dedupeSkills(skills);
121
+ scanCache.set(cacheKey, { createdAt: monotonicNow(), skills: deduped });
122
+ return deduped;
129
123
  }
130
124
 
131
125
  function findSkillFiles(root) {
@@ -171,47 +165,90 @@ export async function suggestSkills({
171
165
  dataDir,
172
166
  cwd = process.cwd(),
173
167
  limit = DEFAULT_LIMIT,
174
- timeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || 800)
168
+ timeoutMs = Number(process.env.CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS || process.env.CONTEXTOS_EMBEDDING_TIMEOUT_MS || DEFAULT_SKILL_TIMEOUT_MS),
169
+ indexedSearcher = searchIndexedEmbeddings,
170
+ embeddingEnhancer = enhanceRuleScoresWithEmbeddings
175
171
  } = {}) {
176
172
  if (!String(prompt || "").trim() || !skills.length) return [];
177
- const base = scoreSkillsByKeyword({ prompt, skills, projectHints: projectSkillHints({ cwd }) });
178
- if (skills.length > DEFAULT_SEMANTIC_CATALOG_LIMIT) {
179
- return finalizeSkillScores(base, limit, { minimumKeywordScore: 0.5 });
173
+ const catalog = dedupeSkills(skills);
174
+ const query = fusedProjectQuery({ prompt, cwd, dataDir });
175
+ const byId = new Map(catalog.map((skill) => [skillIndexId(skill), skill]));
176
+ const explicitSkills = explicitSkillSuggestions({ prompt, byId });
177
+
178
+ if (dataDir) {
179
+ const indexed = await indexedSearcher({
180
+ kind: skillIndexKind(cwd),
181
+ task: query,
182
+ dataDir,
183
+ timeoutMs,
184
+ allowRemote: false
185
+ });
186
+ if (indexed.status === "enabled" && indexed.items.length) {
187
+ return finalizeSkillScores([
188
+ ...explicitSkills,
189
+ ...indexed.items
190
+ .map((item) => {
191
+ const skill = byId.get(item.id);
192
+ if (!skill) return null;
193
+ return skillScoreFromEmbedding(skill, item.embeddingScore, [`embedding:${Number(item.embeddingScore || 0).toFixed(2)}`]);
194
+ })
195
+ .filter(Boolean)
196
+ ], limit);
197
+ }
180
198
  }
181
199
 
182
- const embeddingCandidates = selectEmbeddingCandidates(base);
183
- if (!embeddingCandidates.length) return [];
200
+ if (catalog.length > DEFAULT_EMBEDDING_CANDIDATES) return finalizeSkillScores(explicitSkills, limit);
201
+
202
+ const embeddingCandidates = catalog.map((skill, index) => skillRule({ skill, index }));
203
+ if (!embeddingCandidates.length) return finalizeSkillScores(explicitSkills, limit);
184
204
 
185
- const embedding = await enhanceRuleScoresWithEmbeddings(embeddingCandidates, prompt, {
205
+ const embedding = await embeddingEnhancer(embeddingCandidates, query, {
186
206
  dataDir,
187
207
  sources: embeddingCandidates.map((skill) => skill.path).filter(Boolean),
188
208
  timeoutMs,
189
209
  allowRemote: false
190
210
  });
191
211
 
192
- return finalizeSkillScores(embedding.rules, limit);
212
+ return finalizeSkillScores([...explicitSkills, ...embedding.rules], limit);
213
+ }
214
+
215
+ function explicitSkillSuggestions({ prompt = "", byId = new Map() } = {}) {
216
+ const names = extractExplicitSkillNames(prompt);
217
+ return names
218
+ .map((name, index) => ({ skill: byId.get(normalize(name)), index }))
219
+ .filter(({ skill }) => Boolean(skill))
220
+ .map(({ skill, index }) => skillScoreFromEmbedding(skill, 1 - index * 0.0001, ["explicit-skill"]));
193
221
  }
194
222
 
195
- function finalizeSkillScores(skills, limit, { minimumKeywordScore = 0.35 } = {}) {
223
+ function extractExplicitSkillNames(prompt = "") {
224
+ const names = [];
225
+ const seen = new Set();
226
+ const pattern = /(?:^|[\s([{,])\$([A-Za-z0-9][A-Za-z0-9_.:-]*)/g;
227
+ let match;
228
+ while ((match = pattern.exec(String(prompt || "")))) {
229
+ const name = match[1];
230
+ const key = normalize(name);
231
+ if (!key || seen.has(key)) continue;
232
+ seen.add(key);
233
+ names.push(name);
234
+ }
235
+ return names;
236
+ }
237
+
238
+ function finalizeSkillScores(skills, limit) {
196
239
  const ranked = skills
197
- .filter((rule) => rule.domainEligible !== false)
198
240
  .map((rule) => ({
199
241
  name: rule.name,
200
242
  description: rule.description,
201
243
  path: rule.path,
202
244
  scope: rule.scope,
203
- keywordScore: rule.keywordScore,
204
245
  score: Math.min(1, Number(rule.score || 0)),
205
246
  embeddingScore: rule.embeddingScore,
206
- relevancePriority: Number(rule.relevancePriority || 0),
207
- rankScore: Math.min(1, Number(rule.score || 0)) + Number(rule.relevancePriority || 0) / 100,
247
+ rankScore: Math.min(1, Number(rule.score || 0)),
208
248
  reasons: rule.reasons || []
209
249
  }))
210
- .filter((skill) => Number(skill.keywordScore || 0) >= minimumKeywordScore
211
- || Number(skill.embeddingScore || 0) >= 0.62
212
- || Number(skill.relevancePriority || 0) >= 50)
250
+ .filter((skill) => Number(skill.embeddingScore || skill.score || 0) >= SKILL_EMBEDDING_THRESHOLD)
213
251
  .sort((a, b) => b.rankScore - a.rankScore
214
- || b.relevancePriority - a.relevancePriority
215
252
  || b.score - a.score
216
253
  || scopePriority(b.scope) - scopePriority(a.scope)
217
254
  || a.name.localeCompare(b.name));
@@ -230,14 +267,6 @@ function scopePriority(scope) {
230
267
  return scope === "project" ? 1 : 0;
231
268
  }
232
269
 
233
- function selectEmbeddingCandidates(skills) {
234
- if (skills.length <= DEFAULT_EMBEDDING_CANDIDATES) return skills;
235
- return [...skills]
236
- .filter((skill) => Number(skill.keywordScore || 0) > 0)
237
- .sort((a, b) => Number(b.keywordScore || 0) - Number(a.keywordScore || 0) || a.name.localeCompare(b.name))
238
- .slice(0, DEFAULT_EMBEDDING_CANDIDATES);
239
- }
240
-
241
270
  export async function warmSkillEmbeddings({
242
271
  cwd = process.cwd(),
243
272
  dataDir,
@@ -245,304 +274,81 @@ export async function warmSkillEmbeddings({
245
274
  skills = scanSkills({ cwd })
246
275
  } = {}) {
247
276
  if (!dataDir || !skills.length) return { count: 0, cachePath: null };
248
- return warmRuleEmbeddings({
249
- rules: skills.map((skill) => ({ content: `${skill.name} ${skill.description}` })),
250
- task: "skill discovery semantic retrieval",
277
+ const catalog = dedupeSkills(skills);
278
+ return warmIndexedEmbeddings({
279
+ kind: skillIndexKind(cwd),
280
+ items: catalog.map((skill) => ({
281
+ id: skillIndexId(skill),
282
+ text: skillEmbeddingText(skill)
283
+ })),
284
+ task: fusedProjectQuery({ prompt: "skill discovery semantic retrieval", cwd, dataDir }),
251
285
  dataDir,
252
- sources: skills.map((skill) => skill.path).filter(Boolean),
286
+ sources: catalog.map((skill) => skill.path).filter(Boolean),
253
287
  allowRemote
254
288
  });
255
289
  }
256
290
 
257
- function scoreSkillsByKeyword({ prompt, skills, projectHints = [] }) {
258
- const normalizedPrompt = normalizePrompt(prompt);
259
- const promptTokens = new Set(normalizedPrompt.split(/\s+/).filter(Boolean));
260
- const projectTokens = new Set(projectHints);
261
- return skills.map((skill, index) => {
262
- const enriched = skill.searchTokens ? skill : enrichSkill(skill);
263
- const name = String(enriched.name || "");
264
- const description = truncateDescription(enriched.description || "");
265
- const content = `${name} ${description}`;
266
- const matches = filterSkillMatches(
267
- enriched.searchTokens.filter((token) => promptTokens.has(token) && token.length > 2 && !GENERIC_SKILL_TOKENS.has(token)),
268
- { normalizedPrompt, enriched }
269
- );
270
- const projectMatches = enriched.searchTokens.filter((token) => projectTokens.has(token) && SPECIALIZED_SKILL_TOKENS.has(token));
271
- const normalizedName = enriched.normalizedName;
272
- const nameTokens = enriched.nameTokens;
273
- const nameHit = normalizedPrompt.includes(normalizedName);
274
- const nameTokenHit = nameTokens.length > 1 && nameTokens.every((token) => promptTokens.has(token));
275
- const scopeBonus = enriched.scope === "project" ? 0.08 : 0;
276
- const intentBonus = skillIntentBonus(normalizedPrompt, enriched, projectTokens);
277
- const relevancePriority = skillRelevancePriority(normalizedPrompt, enriched, projectTokens);
278
- const domainEligible = isSkillDomainEligible(normalizedPrompt, enriched, projectTokens);
279
- const matchScore = matches.reduce((sum, token) => sum + (SPECIALIZED_SKILL_TOKENS.has(token) ? 0.2 : 0.08), 0);
280
- const projectBonus = intentBonus ? Math.min(0.16, projectMatches.length * 0.04) : 0;
281
- const score = Math.min(1, (matches.length ? 0.25 + matchScore : 0) + projectBonus + intentBonus + (nameHit ? 0.2 : 0) + (nameTokenHit ? 0.18 : 0) + scopeBonus);
282
- return {
283
- id: `skill-${index + 1}`,
284
- name,
285
- description,
286
- path: enriched.path,
287
- scope: enriched.scope,
288
- content,
289
- score,
290
- keywordScore: score,
291
- relevancePriority,
292
- domainEligible,
293
- reasons: [
294
- ...(matches.length ? [`keyword:${matches.slice(0, 4).join(",")}`] : []),
295
- ...(projectBonus ? [`project:${projectMatches.slice(0, 4).join(",")}`] : []),
296
- ...(intentBonus ? ["intent-match"] : []),
297
- ...(nameHit || nameTokenHit ? ["name-match"] : [])
298
- ],
299
- originalOrder: index
300
- };
301
- });
302
- }
303
-
304
- function filterSkillMatches(matches, { normalizedPrompt, enriched }) {
305
- if (!/\beas\b/.test(normalizedPrompt)) return matches;
306
- const skillText = normalize(`${enriched.name} ${enriched.description}`);
307
- if (/\b(eas|expo|cicd)\b/.test(skillText)) return matches;
308
- return matches.filter((token) => token !== "android" && token !== "ios");
291
+ function skillRule({ skill, index }) {
292
+ const enriched = skill.searchTokens ? skill : enrichSkill(skill);
293
+ return {
294
+ id: skillIndexId(enriched),
295
+ name: enriched.name,
296
+ description: enriched.description,
297
+ path: enriched.path,
298
+ scope: enriched.scope,
299
+ content: skillEmbeddingText(enriched),
300
+ score: 0,
301
+ originalOrder: index
302
+ };
309
303
  }
310
304
 
311
- function isSkillDomainEligible(normalizedPrompt, enriched, projectTokens = new Set()) {
312
- const skillText = normalize(`${enriched.name} ${enriched.description}`);
313
- if (isMcpSkill(skillText) && !isMcpRelevantTask(normalizedPrompt, projectTokens)) return false;
314
- if (isOffensiveSecuritySkill(skillText) && !isSecurityTask(normalizedPrompt)) return false;
315
- if (isPlatformCommerceSkill(skillText) && !isPlatformCommerceTask(normalizedPrompt, skillText)) return false;
316
- if (isDocumentProcessingSkill(skillText) && !isDocumentProcessingTask(normalizedPrompt, skillText)) return false;
317
- if (isWorkspaceAutomationSkill(skillText) && !isWorkspaceAutomationTask(normalizedPrompt, skillText)) return false;
318
- if (!/\beas\b/.test(normalizedPrompt)) return true;
319
- if (!/\b(android|ios)\b/.test(skillText)) return true;
320
- return /\b(eas|expo|cicd)\b/.test(skillText);
305
+ function skillScoreFromEmbedding(skill, embeddingScore, reasons = []) {
306
+ const score = Math.min(1, Number(embeddingScore || 0));
307
+ return {
308
+ name: skill.name,
309
+ description: skill.description,
310
+ path: skill.path,
311
+ scope: skill.scope,
312
+ score,
313
+ embeddingScore: score,
314
+ reasons
315
+ };
321
316
  }
322
317
 
323
- function skillIntentBonus(normalizedPrompt, enriched, projectTokens = new Set()) {
324
- const skillText = normalize(`${enriched.name} ${enriched.description}`);
325
- if (isDocumentAuthoringTask(normalizedPrompt)
326
- && /\b(documentation|document|docs|doc|readme|wiki|writer|writing|coauthor|technical documentation|architecture documentation|onboarding|office productivity)\b/.test(skillText)) {
327
- return 0.48;
328
- }
329
- if (isMcpRelevantTask(normalizedPrompt, projectTokens)
330
- && /\b(mcp|model context protocol|modelcontextprotocol|agent memory|tool developer|tool builder)\b/.test(skillText)) {
331
- return 0.48;
332
- }
333
- if (isCommerceTask(normalizedPrompt)
334
- && /\b(payment|payments|checkout|billing|bill|invoice|wallet|balance|stripe|paypal|commerce|monetization)\b/.test(skillText)) {
335
- return 0.46;
336
- }
337
- if (isContentAccessTask(normalizedPrompt)
338
- && /\b(api|endpoint|backend|service|services|auth|authorization|permission|permissions|access|rbac|frontend api)\b/.test(skillText)) {
339
- return 0.34;
340
- }
341
- if (isNotificationTask(normalizedPrompt)
342
- && /\b(notification|notifications|notify|message|sms|email|event|webhook)\b/.test(skillText)) {
343
- return 0.3;
344
- }
345
- if (isFrontendCheckoutTask(normalizedPrompt)
346
- && /\b(frontend|react|next|nextjs|ui|component|modal|api integration)\b/.test(skillText)) {
347
- return 0.32;
348
- }
349
- if (isExpoRuntimeTask(normalizedPrompt, projectTokens)
350
- && /\b(expo|eas|nativewind|react native|tailwind)\b/.test(skillText)) {
351
- return 0.46;
352
- }
353
- if (isNextAppRouterTask(normalizedPrompt)
354
- && /\b(next|nextjs)\b/.test(skillText)
355
- && /\b(app router|router|routing|server components)\b/.test(skillText)) {
356
- return 0.5;
357
- }
358
- if (/\beas\b/.test(normalizedPrompt)
359
- && /\b(eas|expo)\b/.test(skillText)
360
- && /\b(cicd|workflow|workflows|build|deploy|deployment|pipeline|pipelines)\b/.test(skillText)) {
361
- return 0.28;
362
- }
363
- if (/\b(webapp|frontend|ui|dashboard|button|page|component|app|router)\b/.test(normalizedPrompt)
364
- && /\b(frontend|react|next|nextjs|ui|component|tailwind|app router)\b/.test(skillText)) {
365
- return 0.36;
366
- }
367
- if (/\b(role|admin|creator|permission|permissions|authorization|access)\b/.test(normalizedPrompt)
368
- && /\b(auth|authentication|authorization|permission|permissions|access|rbac)\b/.test(skillText)) {
369
- return 0.32;
318
+ function dedupeSkills(skills) {
319
+ const byName = new Map();
320
+ for (const skill of skills || []) {
321
+ const enriched = skill.searchTokens ? skill : enrichSkill(skill);
322
+ const key = normalize(enriched.name);
323
+ if (!key) continue;
324
+ const existing = byName.get(key);
325
+ if (!existing || skillSourcePriority(enriched) > skillSourcePriority(existing)) {
326
+ byName.set(key, enriched);
327
+ }
370
328
  }
371
- return 0;
329
+ return [...byName.values()];
372
330
  }
373
331
 
374
- function skillRelevancePriority(normalizedPrompt, enriched, projectTokens = new Set()) {
375
- const skillText = normalize(`${enriched.name} ${enriched.description}`);
376
- const skillName = normalize(enriched.name);
332
+ function skillSourcePriority(skill) {
377
333
  let priority = 0;
378
- if (isDocumentAuthoringTask(normalizedPrompt)) {
379
- if (skillName === "doc coauthoring") priority += 1300;
380
- if (skillName === "documentation") priority += 720;
381
- if (skillName === "docs architect") priority += 700;
382
- if (skillName === "readme") priority += 660;
383
- if (skillName === "wiki page writer") priority += 640;
384
- if (skillName === "wiki architect") priority += 620;
385
- if (skillName === "wiki onboarding") priority += 600;
386
- if (skillName === "writer" || skillName === "docx" || skillName === "office productivity") priority += 560;
387
- if (skillName === "agents md") priority += 420;
388
- if (/\b(code documentation doc generate|documentation generation doc generate|api documentation|api documenter|reference builder|architecture)\b/.test(skillText)) priority += 320;
389
- if (/\b(documentation|document|docs|doc|readme|wiki|writer|writing|coauthor|technical documentation)\b/.test(skillText)) priority += 130;
390
- if (/\b(mcp|model context protocol|metasploit|penetration|exploit)\b/.test(skillText)) priority -= 220;
391
- }
392
- if (isMcpRelevantTask(normalizedPrompt, projectTokens)) {
393
- if (skillName === "mcp builder") priority += 760;
394
- if (skillName === "mcp management") priority += 740;
395
- if (skillName === "mcp tool developer") priority += 720;
396
- if (skillName === "agent memory mcp") priority += 700;
397
- if (skillName === "agent tool builder" || skillName === "context agent") priority += 260;
398
- if (/\b(mcp|model context protocol|modelcontextprotocol)\b/.test(skillText)) priority += 160;
399
- }
400
- if (isCommerceTask(normalizedPrompt)) {
401
- if (/\b(payment integration|stripe integration|paypal integration)\b/.test(skillText)) priority += 520;
402
- if (/\bbilling automation\b/.test(skillText)) priority += 430;
403
- if (/\b(payment|payments|checkout|billing|wallet|balance|stripe|paypal|commerce|monetization)\b/.test(skillText)) priority += 160;
404
- if (!/\bstripe\b/.test(normalizedPrompt) && /\bstripe\b/.test(skillText)) priority -= 520;
405
- if (!/\bpaypal\b/.test(normalizedPrompt) && /\bpaypal\b/.test(skillText)) priority -= 520;
406
- if (!/\bsquare\b/.test(normalizedPrompt) && /\bsquare\b/.test(skillText)) priority -= 440;
407
- if (/\b(mcp|metasploit|penetration|exploit|bug bounty)\b/.test(skillText)) priority -= 500;
408
- }
409
- if (isContentAccessTask(normalizedPrompt)) {
410
- if (/\b(api endpoint builder|backend development|backend architect|frontend api integration patterns)\b/.test(skillText)) priority += 260;
411
- if (/\b(auth implementation patterns|authorization|permission|permissions|access|rbac)\b/.test(skillText)) priority += 120;
412
- }
413
- if (isNotificationTask(normalizedPrompt)) {
414
- if (/\bsendblue notify\b/.test(skillText)) priority += 140;
415
- if (/\b(notification|notifications|notify|message|sms|email|event|webhook)\b/.test(skillText)) priority += 90;
416
- }
417
- if (isFrontendCheckoutTask(normalizedPrompt)) {
418
- if (/\bfrontend api integration patterns\b/.test(skillText)) priority += 220;
419
- if (/\breact nextjs development|nextjs best practices|nextjs app router patterns|frontend developer\b/.test(skillText)) priority += 90;
420
- }
421
- if (isExpoRuntimeTask(normalizedPrompt, projectTokens)) {
422
- if (/\bexpo deployment\b/.test(skillText)) priority += 900;
423
- if (/\bbuilding native ui\b/.test(skillText)) priority += 760;
424
- if (/\bexpo tailwind setup\b/.test(skillText)) priority += 620;
425
- if (/\bexpo\b/.test(skillText) && /\b(qr|expo go|run|running|start|connect|eas|deployment|build)\b/.test(skillText)) priority += 220;
426
- if (/\bnativewind|tailwind\b/.test(skillText) && projectTokens.has("nativewind")) priority += 120;
427
- if (/\b(next|nextjs|frontend designer|dark themed|glassmorphism|framer motion)\b/.test(skillText)) priority -= 160;
428
- }
429
- if (isNextAppRouterTask(normalizedPrompt)) {
430
- if (/\bnextjs app router patterns\b/.test(skillText)) priority += 600;
431
- if (/\bnextjs best practices\b/.test(skillText)) priority += 560;
432
- if (/\breact nextjs development\b/.test(skillText)) priority += 420;
433
- if (/\b(next|nextjs)\b/.test(skillText) && /\b(app router|router|routing|server components)\b/.test(skillText)) priority += 100;
434
- if (/\b(next|nextjs)\b/.test(skillText) && /\breact\b/.test(skillText)) priority += 70;
435
- if (/\b(glassmorphism|dark themed|dark theme|framer motion)\b/.test(skillText)) priority -= 40;
436
- }
437
- if (/\b(role|admin|creator|permission|permissions|authorization|access)\b/.test(normalizedPrompt)
438
- && /\b(auth|authentication|authorization|permission|permissions|access|rbac)\b/.test(skillText)) {
439
- priority += 55;
440
- }
334
+ if (skill.scope === "project") priority += 100;
335
+ const skillPath = String(skill.path || "");
336
+ if (skillPath.includes(`${path.sep}.codex${path.sep}skills${path.sep}`)) priority += 30;
337
+ if (skillPath.includes(`${path.sep}.config${path.sep}skillshare${path.sep}skills${path.sep}`)) priority += 20;
338
+ if (skillPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)) priority += 10;
441
339
  return priority;
442
340
  }
443
341
 
444
- function isNextAppRouterTask(normalizedPrompt) {
445
- return /\bwebapp\b.*\bsrc\b.*\bapp\b/.test(normalizedPrompt)
446
- || /\b(next|nextjs)\b.*\b(app router|router|routing)\b/.test(normalizedPrompt)
447
- || /\bapp router\b/.test(normalizedPrompt);
448
- }
449
-
450
- function isExpoRuntimeTask(normalizedPrompt, projectTokens = new Set()) {
451
- const expoProject = projectTokens.has("expo") || projectTokens.has("nativewind") || projectTokens.has("eas");
452
- if (!expoProject) return false;
453
- return /\b(qr|connect|run|start|expo go|device|metro|tunnel|lan)\b/.test(normalizedPrompt);
454
- }
455
-
456
- function isCommerceTask(normalizedPrompt) {
457
- return /\b(purchase|purchased|buy|buyer|seller|payment|pay|checkout|wallet|balance|top up|topup|funded|billing|invoice)\b/.test(normalizedPrompt);
458
- }
459
-
460
- function isContentAccessTask(normalizedPrompt) {
461
- return /\b(content access service|content access|access permissions|grant access|permissions|library|resources|tutorials|collections)\b/.test(normalizedPrompt);
462
- }
463
-
464
- function isNotificationTask(normalizedPrompt) {
465
- return /\b(notification|notifications|notify|buyer|seller)\b/.test(normalizedPrompt);
466
- }
467
-
468
- function isFrontendCheckoutTask(normalizedPrompt) {
469
- return /\b(modal|display|show|checkout|library|frontend|webapp|page|button)\b/.test(normalizedPrompt);
470
- }
471
-
472
- function isDocumentAuthoringTask(normalizedPrompt) {
473
- return /\b(create|write|edit|update|draft|generate|author|maintain|work on|produce)\b.*\b(document|documents|documentation|docs|doc|readme|wiki|workspace|workspaces|manual|guide|onboarding|spec|adr)\b/.test(normalizedPrompt)
474
- || /\b(document|documents|documentation|docs|doc|readme|wiki|workspace|workspaces|manual|guide|onboarding|spec|adr)\b.*\b(create|write|edit|update|draft|generate|author|maintain|work on|produce)\b/.test(normalizedPrompt);
475
- }
476
-
477
- function isMcpTask(normalizedPrompt) {
478
- return /\b(mcp|model context protocol|tool server|tools server|server tool|bridge|proxy)\b/.test(normalizedPrompt);
479
- }
480
-
481
- function isMcpRelevantTask(normalizedPrompt, projectTokens = new Set()) {
482
- return isMcpTask(normalizedPrompt)
483
- || (isMcpProject(projectTokens) && isContextRetrievalTask(normalizedPrompt));
484
- }
485
-
486
- function isMcpProject(projectTokens = new Set()) {
487
- return projectTokens.has("mcp") || projectTokens.has("modelcontextprotocol");
488
- }
489
-
490
- function isContextRetrievalTask(normalizedPrompt) {
491
- return /\b(suggest|suggested|suggestion|skills|files|context|retrieval|retrieve|scorer|scoring|match|matching|prompt|hook|inject|injection)\b/.test(normalizedPrompt);
492
- }
493
-
494
- function isSecurityTask(normalizedPrompt) {
495
- return /\b(security|pentest|penetration|exploit|vulnerability|metasploit|bug bounty|owasp|xss|csrf|attack|audit)\b/.test(normalizedPrompt);
496
- }
497
-
498
- function isMcpSkill(skillText) {
499
- return /\bmcp\b|\bmodel context protocol\b/.test(skillText);
500
- }
501
-
502
- function isOffensiveSecuritySkill(skillText) {
503
- return /\b(metasploit|penetration testing|bug bounty|exploit|exploitation|privilege escalation|ethical hacking|web fuzzing|security assessment)\b/.test(skillText);
504
- }
505
-
506
- function isPlatformCommerceSkill(skillText) {
507
- return /\b(wordpress|woocommerce|shopify|odoo)\b/.test(skillText);
508
- }
509
-
510
- function isPlatformCommerceTask(normalizedPrompt, skillText) {
511
- if (/\bwordpress\b/.test(skillText)) return /\bwordpress\b/.test(normalizedPrompt);
512
- if (/\bwoocommerce\b/.test(skillText)) return /\bwoocommerce\b/.test(normalizedPrompt);
513
- if (/\bshopify\b/.test(skillText)) return /\bshopify\b/.test(normalizedPrompt);
514
- if (/\bodoo\b/.test(skillText)) return /\bodoo\b/.test(normalizedPrompt);
515
- return true;
342
+ function skillIndexKind(cwd) {
343
+ return `skill:${path.resolve(cwd)}`;
516
344
  }
517
345
 
518
- function isDocumentProcessingSkill(skillText) {
519
- return /\b(azure ai document|document intelligence|formrecognizer|document translation|cosmos db|azure cosmos|search documents|docusign)\b/.test(skillText);
346
+ function skillIndexId(skill) {
347
+ return normalize(skill.name);
520
348
  }
521
349
 
522
- function isDocumentProcessingTask(normalizedPrompt, skillText) {
523
- if (/\bdocusign\b/.test(skillText)) return /\bdocusign|signature|envelope|sign\b/.test(normalizedPrompt);
524
- if (/\bcosmos db|azure cosmos\b/.test(skillText)) return /\bcosmos|database|nosql|query|container\b/.test(normalizedPrompt);
525
- if (/\bsearch documents\b/.test(skillText)) return /\bazure search|vector search|semantic search|index\b/.test(normalizedPrompt);
526
- return /\bextract|ocr|analyze|translate|translation|form recognizer|document intelligence|azure\b/.test(normalizedPrompt);
527
- }
528
-
529
- function isWorkspaceAutomationSkill(skillText) {
530
- return /\b(asana|bitbucket|slack|coda|google docs|google drive|google sheets|google slides|notion|telegram)\b/.test(skillText)
531
- && /\b(automation|automate|workspace|workspaces|manage docs|documents)\b/.test(skillText);
532
- }
533
-
534
- function isWorkspaceAutomationTask(normalizedPrompt, skillText) {
535
- if (/\basana\b/.test(skillText)) return /\basana\b/.test(normalizedPrompt);
536
- if (/\bbitbucket\b/.test(skillText)) return /\bbitbucket\b/.test(normalizedPrompt);
537
- if (/\bslack\b/.test(skillText)) return /\bslack\b/.test(normalizedPrompt);
538
- if (/\bcoda\b/.test(skillText)) return /\bcoda\b/.test(normalizedPrompt);
539
- if (/\bgoogle docs\b/.test(skillText)) return /\bgoogle docs\b/.test(normalizedPrompt);
540
- if (/\bgoogle drive\b/.test(skillText)) return /\bgoogle drive\b/.test(normalizedPrompt);
541
- if (/\bgoogle sheets\b/.test(skillText)) return /\bgoogle sheets\b/.test(normalizedPrompt);
542
- if (/\bgoogle slides\b/.test(skillText)) return /\bgoogle slides\b/.test(normalizedPrompt);
543
- if (/\bnotion\b/.test(skillText)) return /\bnotion\b/.test(normalizedPrompt);
544
- if (/\btelegram\b/.test(skillText)) return /\btelegram\b/.test(normalizedPrompt);
545
- return true;
350
+ function skillEmbeddingText(skill) {
351
+ return [skill.name, skill.description].filter(Boolean).join("\n");
546
352
  }
547
353
 
548
354
  export function projectSkillHints({ cwd = process.cwd() } = {}) {
@@ -567,48 +373,6 @@ export function projectSkillHints({ cwd = process.cwd() } = {}) {
567
373
  return [...hints];
568
374
  }
569
375
 
570
- function workspacePackagePaths(cwd) {
571
- const rootPackagePath = path.join(cwd, "package.json");
572
- const rootPackage = readJson(rootPackagePath);
573
- const paths = new Set([rootPackagePath]);
574
- for (const workspace of workspacePatterns(rootPackage?.workspaces)) {
575
- for (const packagePath of expandWorkspacePattern({ cwd, pattern: workspace })) {
576
- paths.add(packagePath);
577
- }
578
- }
579
- return [...paths];
580
- }
581
-
582
- function workspacePatterns(workspaces) {
583
- if (Array.isArray(workspaces)) return workspaces.filter((item) => typeof item === "string");
584
- if (Array.isArray(workspaces?.packages)) return workspaces.packages.filter((item) => typeof item === "string");
585
- return [];
586
- }
587
-
588
- function expandWorkspacePattern({ cwd, pattern }) {
589
- const normalized = String(pattern || "").replace(/\\/g, "/").replace(/\/+$/g, "");
590
- if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) return [];
591
- if (!normalized.includes("*")) {
592
- const packagePath = path.join(cwd, normalized, "package.json");
593
- return fs.existsSync(packagePath) ? [packagePath] : [];
594
- }
595
- const parts = normalized.split("/");
596
- const starIndex = parts.indexOf("*");
597
- if (starIndex < 0 || parts.includes("**")) return [];
598
- const baseDir = path.join(cwd, ...parts.slice(0, starIndex));
599
- const suffix = parts.slice(starIndex + 1);
600
- let entries = [];
601
- try {
602
- entries = fs.readdirSync(baseDir, { withFileTypes: true });
603
- } catch {
604
- return [];
605
- }
606
- return entries
607
- .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
608
- .map((entry) => path.join(baseDir, entry.name, ...suffix, "package.json"))
609
- .filter((packagePath) => fs.existsSync(packagePath));
610
- }
611
-
612
376
  function readJson(filePath) {
613
377
  try {
614
378
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -639,10 +403,3 @@ function enrichSkill(skill) {
639
403
  function normalize(value) {
640
404
  return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
641
405
  }
642
-
643
- function normalizePrompt(value) {
644
- return normalize(String(value || "")
645
- .replace(/https?:\/\/\S+/gi, " ")
646
- .replace(/giao\s+di[eệ]n/gi, "frontend ui")
647
- .replace(/phan\s+quyen/gi, "authorization role"));
648
- }