@minhpnq1807/contextos 0.5.44 → 0.5.45

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.45
4
+
5
+ - **Project-aware MCP skill suggestions:** Skill ranking now reads `package.json` keywords and dependencies such as `@modelcontextprotocol/sdk`. MCP projects can recommend `mcp-builder`, `mcp-management`, `mcp-tool-developer`, and `agent-memory-mcp` for context retrieval, scorer, hook, and prompt-injection debugging tasks even when the prompt does not explicitly say `mcp`.
6
+ - **Domain-safe skill ranking:** Added common-language stopwords and bounded domain gates so generic prompt overlap no longer revives unrelated MCP, offensive-security, or platform-commerce skills. Purchase prompts prioritize payment, billing, frontend API integration, and backend skills without assuming Stripe, PayPal, WooCommerce, or another provider unless named.
7
+ - **Purchase-flow file retrieval:** File embedding and graph retrieval queries now expand purchase, wallet, checkout, content-access, library, and notification prompts with focused retrieval hints while keeping repository walking out of the prompt hot path.
8
+ - **Seven-item prompt summaries:** Increased suggested file and skill limits from three to seven in prompt hooks and `ctx debug`. Suggested files now render as a compact comma-separated inline summary while duplicate basenames remain disambiguated with relative paths.
9
+ - **Target-workspace prompt scoring:** Prompt hooks can score an explicitly named sibling workspace such as `../philo-mind`, allowing repo-specific manifest and skill recommendations when debugging another project from the current shell.
10
+ - **Monorepo manifest awareness:** Project skill hints and run/connect file suggestions now read bounded root and workspace `package.json` metadata, including workspace arrays, `{ packages: [...] }`, and one-level globs.
11
+ - **Fallback file retrieval budget:** Raised direct hook fallback file-vector timeout to 1000ms so indexed file suggestions remain available when the private MCP bridge is unavailable.
12
+
3
13
  ## 0.5.44
4
14
 
5
15
  - **Robust MCP TOML handling:** Added `smol-toml` parsing for Codex MCP config while preserving comments, ordering, multiline arrays, and nested tool approval sections during telemetry proxy rewrites.
package/bin/ctx.js CHANGED
@@ -590,12 +590,13 @@ async function debug(task) {
590
590
  cwd,
591
591
  prompt: task,
592
592
  dataDir: contextOSDataDir(),
593
- maxFiles: 3,
593
+ maxFiles: 7,
594
+ maxSkills: 7,
594
595
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_EMBEDDING_DEBUG_TIMEOUT_MS || 5000)
595
596
  });
596
597
  const rules = scored.scoredRules;
597
- const relevantFiles = scored.suggestedFiles.slice(0, 3);
598
- const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
598
+ const relevantFiles = scored.suggestedFiles.slice(0, 7);
599
+ const suggestedSkills = (scored.suggestedSkills || []).slice(0, 7);
599
600
  const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, 2);
600
601
  const scheduled = scheduleContext({ rules, relevantFiles, suggestedSkills, suggestedWorkflows });
601
602
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.44",
3
+ "version": "0.5.45",
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.44",
3
+ "version": "0.5.45",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { findGraphRelevantFiles, mergeRelevantFiles } from "./graph-retriever.js";
3
4
  import { expandImportGraph } from "./import-graph.js";
@@ -282,9 +283,12 @@ export async function findRelevantFiles({
282
283
  } = {}) {
283
284
  if (!String(task || "").trim()) return [];
284
285
 
286
+ const retrievalTask = expandFileRetrievalTask(task);
287
+ const explicitFiles = findExplicitPromptFiles({ cwd, task, limit: Math.max(limit * 2, 6) });
288
+ const manifestFiles = findProjectManifestFiles({ cwd, task, limit: Math.max(limit * 2, 6) });
285
289
  const embeddingFiles = await embeddingFileFinder({
286
290
  cwd,
287
- task,
291
+ task: retrievalTask,
288
292
  dataDir,
289
293
  timeoutMs: fileEmbeddingTimeoutMs,
290
294
  embeddingOptions: fileEmbeddingOptions,
@@ -292,16 +296,16 @@ export async function findRelevantFiles({
292
296
  });
293
297
  const importGraphFiles = expandImportGraph({
294
298
  cwd,
295
- seedFiles: embeddingFiles.slice(0, limit),
299
+ seedFiles: [...explicitFiles, ...manifestFiles, ...embeddingFiles].slice(0, limit),
296
300
  dataDir,
297
301
  limit: Math.max(limit * 2, 6)
298
302
  });
299
- const seedFiles = mergeLocalFileCandidates([...embeddingFiles, ...importGraphFiles])
303
+ const seedFiles = mergeLocalFileCandidates([...explicitFiles, ...manifestFiles, ...embeddingFiles, ...importGraphFiles])
300
304
  .slice(0, Math.max(limit * 3, 9));
301
305
 
302
306
  const graphFiles = findGraphRelevantFiles({
303
307
  cwd,
304
- task,
308
+ task: retrievalTask,
305
309
  rules,
306
310
  seedFiles,
307
311
  limit: Math.max(limit * 2, 6)
@@ -310,6 +314,186 @@ export async function findRelevantFiles({
310
314
  return mergeRelevantFiles({ graphFiles, heuristicFiles: seedFiles, limit });
311
315
  }
312
316
 
317
+ export function findProjectManifestFiles({ cwd = process.cwd(), task = "", limit = 6 } = {}) {
318
+ const tokens = new Set(tokenize(task));
319
+ if (!isManifestRelevantTask(tokens)) return [];
320
+ const manifests = workspacePackageManifests(cwd, tokens);
321
+ return manifests.slice(0, limit).map((filePath, index) => ({
322
+ path: filePath,
323
+ score: manifestScore(filePath, tokens, index),
324
+ source: "manifest",
325
+ reasons: ["project-manifest"]
326
+ }));
327
+ }
328
+
329
+ function manifestScore(manifest, taskTokens, index) {
330
+ if (manifest === "package.json") return 50;
331
+ const parts = manifest.split(/[\\/]+/).filter(Boolean);
332
+ const workspaceName = parts.at(-2);
333
+ return (taskTokens.has(workspaceName) ? 35 : 20) - index * 0.01;
334
+ }
335
+
336
+ function isManifestRelevantTask(tokens) {
337
+ const runIntent = ["run", "start", "connect", "qr", "install", "build", "script", "scripts"].some((token) => tokens.has(token));
338
+ const projectIntent = ["webapp", "frontend", "expo", "native", "app", "package", "workspace"].some((token) => tokens.has(token));
339
+ return runIntent && projectIntent;
340
+ }
341
+
342
+ function workspacePackageManifests(cwd, taskTokens = new Set()) {
343
+ const rootManifest = path.join(cwd, "package.json");
344
+ const manifests = [];
345
+ if (fs.existsSync(rootManifest)) manifests.push("package.json");
346
+ const rootPackage = readJson(rootManifest);
347
+ for (const pattern of workspacePatterns(rootPackage?.workspaces)) {
348
+ for (const manifest of expandWorkspacePattern({ cwd, pattern })) {
349
+ manifests.push(path.relative(cwd, manifest));
350
+ }
351
+ }
352
+ return [...new Set(manifests)].sort((a, b) => manifestPriority(b, taskTokens) - manifestPriority(a, taskTokens) || a.localeCompare(b));
353
+ }
354
+
355
+ function manifestPriority(manifest, taskTokens) {
356
+ if (manifest === "package.json") return 100;
357
+ const parts = manifest.split(/[\\/]+/).filter(Boolean);
358
+ const workspaceName = parts.at(-2);
359
+ return taskTokens.has(workspaceName) ? 80 : 0;
360
+ }
361
+
362
+ function workspacePatterns(workspaces) {
363
+ if (Array.isArray(workspaces)) return workspaces.filter((item) => typeof item === "string");
364
+ if (Array.isArray(workspaces?.packages)) return workspaces.packages.filter((item) => typeof item === "string");
365
+ return [];
366
+ }
367
+
368
+ function expandWorkspacePattern({ cwd, pattern }) {
369
+ const normalized = String(pattern || "").replace(/\\/g, "/").replace(/\/+$/g, "");
370
+ if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) return [];
371
+ if (!normalized.includes("*")) {
372
+ const manifest = path.join(cwd, normalized, "package.json");
373
+ return fs.existsSync(manifest) ? [manifest] : [];
374
+ }
375
+ const parts = normalized.split("/");
376
+ const starIndex = parts.indexOf("*");
377
+ if (starIndex < 0 || parts.includes("**")) return [];
378
+ const baseDir = path.join(cwd, ...parts.slice(0, starIndex));
379
+ const suffix = parts.slice(starIndex + 1);
380
+ let entries = [];
381
+ try {
382
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
383
+ } catch {
384
+ return [];
385
+ }
386
+ return entries
387
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
388
+ .map((entry) => path.join(baseDir, entry.name, ...suffix, "package.json"))
389
+ .filter((manifest) => fs.existsSync(manifest));
390
+ }
391
+
392
+ function readJson(filePath) {
393
+ try {
394
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ function expandFileRetrievalTask(task) {
401
+ const tokens = new Set(tokenize(task));
402
+ const additions = new Set();
403
+ if (hasAny(tokens, ["purchase", "purchased", "buy", "buyer", "seller", "payment", "pay", "checkout"])) {
404
+ addAll(additions, [
405
+ "purchase", "payment", "checkout", "billing", "wallet", "balance", "top up",
406
+ "transaction", "order", "invoice"
407
+ ]);
408
+ }
409
+ if (hasAny(tokens, ["wallet", "balance", "topup", "top", "funded"])) {
410
+ addAll(additions, ["wallet", "balance", "top up", "billing"]);
411
+ }
412
+ if (hasAny(tokens, ["library", "access", "permissions", "permission", "resources", "tutorials", "collections"])) {
413
+ addAll(additions, [
414
+ "content access", "content-access-service", "access permissions", "library",
415
+ "resource", "resources", "tutorial", "tutorials", "collections"
416
+ ]);
417
+ }
418
+ if (hasAny(tokens, ["notification", "notifications", "notify", "buyer", "seller"])) {
419
+ addAll(additions, ["notification", "notifications", "notify", "buyer", "seller"]);
420
+ }
421
+ if (!additions.size) return task;
422
+ return `${task}\n\nContextOS retrieval hints: ${[...additions].join(", ")}`;
423
+ }
424
+
425
+ function hasAny(tokens, values) {
426
+ return values.some((value) => tokens.has(value));
427
+ }
428
+
429
+ function addAll(target, values) {
430
+ for (const value of values) target.add(value);
431
+ }
432
+
433
+ export function findExplicitPromptFiles({ cwd = process.cwd(), task = "", limit = 6 } = {}) {
434
+ const candidates = new Set();
435
+ const normalizedTask = String(task || "").replace(/\/\s+/g, "/");
436
+ const matches = normalizedTask.match(/[A-Za-z0-9_.()[\]@~:-]+(?:\/[A-Za-z0-9_.()[\]@~:-]+)+/g) || [];
437
+ for (const match of matches) {
438
+ const cleaned = match.replace(/[),.;:]+$/g, "");
439
+ for (const filePath of resolvePromptPathCandidates({ cwd, promptPath: cleaned })) {
440
+ candidates.add(filePath);
441
+ if (candidates.size >= limit) break;
442
+ }
443
+ if (candidates.size >= limit) break;
444
+ }
445
+ return [...candidates].map((filePath, index) => ({
446
+ path: filePath,
447
+ score: 12 - index * 0.01,
448
+ source: "prompt-path",
449
+ reasons: ["prompt-path"]
450
+ }));
451
+ }
452
+
453
+ function resolvePromptPathCandidates({ cwd, promptPath }) {
454
+ if (!promptPath || promptPath.includes("://")) return [];
455
+ const relative = promptPath.replace(/^\.?\//, "");
456
+ if (relative.startsWith("..")) return [];
457
+ const absolute = path.resolve(cwd, relative);
458
+ if (!isInsidePath(absolute, cwd)) return [];
459
+ const resolved = [];
460
+ if (isSourceFile(absolute)) resolved.push(path.relative(cwd, absolute));
461
+ if (isDirectory(absolute)) {
462
+ for (const fileName of ["page.tsx", "page.ts", "page.jsx", "page.js", "layout.tsx", "index.tsx", "index.ts"]) {
463
+ const candidate = path.join(absolute, fileName);
464
+ if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
465
+ }
466
+ }
467
+ if (!path.extname(relative)) {
468
+ for (const extension of [".tsx", ".ts", ".jsx", ".js", ".md", ".json"]) {
469
+ const candidate = `${absolute}${extension}`;
470
+ if (isSourceFile(candidate)) resolved.push(path.relative(cwd, candidate));
471
+ }
472
+ }
473
+ return resolved;
474
+ }
475
+
476
+ function isInsidePath(filePath, parentPath) {
477
+ const relative = path.relative(path.resolve(parentPath), path.resolve(filePath));
478
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
479
+ }
480
+
481
+ function isDirectory(filePath) {
482
+ try {
483
+ return fs.statSync(filePath).isDirectory();
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+
489
+ function isSourceFile(filePath) {
490
+ try {
491
+ return fs.statSync(filePath).isFile();
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
313
497
  function mergeLocalFileCandidates(files) {
314
498
  const byPath = new Map();
315
499
  for (const file of files) {
@@ -5,8 +5,13 @@ import { callCtxScoreContext } from "./ctx-mcp-client.js";
5
5
  import { resolveHookCwd } from "./hook-io.js";
6
6
  import { loadOutputConfig } from "./output-config.js";
7
7
  import { scoreContext as scoreContextDirect } from "./score-context.js";
8
+ import fs from "node:fs";
8
9
  import path from "node:path";
9
10
 
11
+ const PROMPT_FILE_LIMIT = 7;
12
+ const PROMPT_SKILL_LIMIT = 7;
13
+ const PROMPT_WORKFLOW_LIMIT = 2;
14
+
10
15
  export async function handlePromptPayload(
11
16
  payload,
12
17
  {
@@ -24,7 +29,8 @@ export async function handlePromptPayload(
24
29
  } = {}
25
30
  ) {
26
31
  const prompt = payload.prompt || payload.message || payload.user_prompt || "";
27
- const cwd = resolveHookCwd(payload);
32
+ const hookCwd = resolveHookCwd(payload);
33
+ const cwd = resolvePromptTargetCwd({ cwd: hookCwd, prompt });
28
34
  const openFiles = payload.openFiles || payload.open_files || payload.files || [];
29
35
  const dataDir = dataPath ? path.dirname(dataPath) : undefined;
30
36
 
@@ -34,7 +40,9 @@ export async function handlePromptPayload(
34
40
  cwd,
35
41
  prompt,
36
42
  openFiles,
37
- maxFiles: 3
43
+ maxFiles: PROMPT_FILE_LIMIT,
44
+ maxSkills: PROMPT_SKILL_LIMIT,
45
+ maxWorkflows: PROMPT_WORKFLOW_LIMIT
38
46
  }, {
39
47
  dataDir: mcpDataDir || dataDir,
40
48
  timeoutMs: Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || 2000)
@@ -45,10 +53,12 @@ export async function handlePromptPayload(
45
53
  cwd,
46
54
  prompt,
47
55
  openFiles,
48
- maxFiles: 3,
56
+ maxFiles: PROMPT_FILE_LIMIT,
57
+ maxSkills: PROMPT_SKILL_LIMIT,
58
+ maxWorkflows: PROMPT_WORKFLOW_LIMIT,
49
59
  dataDir: mcpDataDir || dataDir,
50
60
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS || 500),
51
- fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 500)
61
+ fileEmbeddingTimeoutMs: Number(process.env.CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS || 1000)
52
62
  }), directFallbackTimeoutMs, "direct fallback scoring");
53
63
  scored.telemetry = {
54
64
  ...(scored.telemetry || {}),
@@ -66,9 +76,9 @@ export async function handlePromptPayload(
66
76
 
67
77
  if (scored.error) throw new Error(scored.error);
68
78
  const scoredRules = scored.scoredRules || [];
69
- const relevantFiles = (scored.suggestedFiles || []).slice(0, 3);
70
- const suggestedSkills = (scored.suggestedSkills || []).slice(0, 3);
71
- const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, 2);
79
+ const relevantFiles = (scored.suggestedFiles || []).slice(0, PROMPT_FILE_LIMIT);
80
+ const suggestedSkills = (scored.suggestedSkills || []).slice(0, PROMPT_SKILL_LIMIT);
81
+ const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, PROMPT_WORKFLOW_LIMIT);
72
82
  const effectiveOutputConfig = outputConfig || loadOutputConfig();
73
83
  const scheduled = scheduleContext({ rules: scoredRules, relevantFiles, suggestedSkills, suggestedWorkflows, outputConfig: effectiveOutputConfig });
74
84
  const contextEmptyReason = emptyContextReason({ scheduled, outputConfig: effectiveOutputConfig, injectContext });
@@ -127,6 +137,58 @@ export async function handlePromptPayload(
127
137
  return output;
128
138
  }
129
139
 
140
+ export function resolvePromptTargetCwd({ cwd = process.cwd(), prompt = "" } = {}) {
141
+ const current = path.resolve(cwd);
142
+ const candidates = targetPathCandidates(prompt);
143
+ for (const candidate of candidates) {
144
+ const resolved = path.resolve(current, candidate);
145
+ if (!isAllowedTargetCwd({ current, resolved })) continue;
146
+ if (isWorkspaceRoot(resolved)) return resolved;
147
+ }
148
+ return current;
149
+ }
150
+
151
+ function targetPathCandidates(prompt) {
152
+ const text = String(prompt || "");
153
+ const patterns = [
154
+ /\b(?:tr[eê]n|in|inside|under|repo|workspace|cwd)\s+([.~A-Za-z0-9_/@.-]+(?:\/[A-Za-z0-9_@().-]+)*)/gi,
155
+ /\b(?:debug|test|check|run)\s+(?:on|tr[eê]n)\s+([.~A-Za-z0-9_/@.-]+(?:\/[A-Za-z0-9_@().-]+)*)/gi
156
+ ];
157
+ const results = [];
158
+ for (const pattern of patterns) {
159
+ let match;
160
+ while ((match = pattern.exec(text))) {
161
+ const value = cleanPromptPath(match[1]);
162
+ if (value) results.push(value);
163
+ }
164
+ }
165
+ return results;
166
+ }
167
+
168
+ function cleanPromptPath(value) {
169
+ const cleaned = String(value || "").trim().replace(/[),.;:]+$/g, "");
170
+ if (!cleaned || cleaned.includes("://")) return null;
171
+ return cleaned;
172
+ }
173
+
174
+ function isAllowedTargetCwd({ current, resolved }) {
175
+ const parent = path.dirname(current);
176
+ const relative = path.relative(parent, resolved);
177
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
178
+ }
179
+
180
+ function isWorkspaceRoot(directory) {
181
+ try {
182
+ const stat = fs.statSync(directory);
183
+ if (!stat.isDirectory()) return false;
184
+ } catch {
185
+ return false;
186
+ }
187
+ return fs.existsSync(path.join(directory, "package.json"))
188
+ || fs.existsSync(path.join(directory, "AGENTS.md"))
189
+ || fs.existsSync(path.join(directory, ".git"));
190
+ }
191
+
130
192
  function emptyContextReason({ scheduled, outputConfig, injectContext }) {
131
193
  if (!injectContext) return "injection-disabled";
132
194
  if (scheduled.additionalContext) return null;
@@ -22,7 +22,7 @@ export function scheduleContext({
22
22
  sections.push(section("Critical ContextOS rules", high.slice(0, 5).map(formatRule)));
23
23
  }
24
24
  if (outputConfig.sections.files && relevantFiles.length) {
25
- sections.push(section("Suggested files to check", relevantFiles.map(formatFile)));
25
+ sections.push(commaSection("Suggested files to check", formatFiles(relevantFiles)));
26
26
  }
27
27
  if (outputConfig.sections.skills && suggestedSkills.length) {
28
28
  sections.push(inlineSection("Skills to activate for this task", suggestedSkills.map(formatSkill)));
@@ -72,13 +72,28 @@ function inlineSection(title, values) {
72
72
  return `## ${title}: ${uniqueValues.join(", ")}`;
73
73
  }
74
74
 
75
+ function commaSection(title, values) {
76
+ const uniqueValues = [...new Set(values)];
77
+ if (!uniqueValues.length) return "";
78
+ return `## ${title}, ${uniqueValues.join(", ")}`;
79
+ }
75
80
 
76
81
  function formatRule(rule) {
77
82
  return `- ${rule.content}`;
78
83
  }
79
84
 
80
- function formatFile(file) {
81
- return `- ${path.basename(file.path)}`;
85
+ function formatFiles(files) {
86
+ const counts = new Map();
87
+ for (const file of files) {
88
+ const name = path.basename(file.path);
89
+ counts.set(name, (counts.get(name) || 0) + 1);
90
+ }
91
+ return files.map((file) => formatFile(file, counts));
92
+ }
93
+
94
+ function formatFile(file, basenameCounts) {
95
+ const name = path.basename(file.path);
96
+ return basenameCounts.get(name) > 1 ? file.path : name;
82
97
  }
83
98
 
84
99
  function formatSkill(skill) {
@@ -14,10 +14,15 @@ const GENERIC_SKILL_TOKENS = new Set([
14
14
  "active", "agent", "agents", "code", "config", "configuration", "create", "development",
15
15
  "environment", "file", "files", "graph", "install", "integration", "local", "node", "package",
16
16
  "project", "refresh", "rebuild", "setup", "skill", "skills", "sync", "tool", "tools", "using",
17
- "build", "production", "https", "http", "com", "www"
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"
18
21
  ]);
19
22
  const SPECIALIZED_SKILL_TOKENS = new Set([
20
- "android", "cicd", "eas", "expo", "ios", "postgres", "postgresql", "react-native"
23
+ "android", "authorization", "cicd", "eas", "expo", "frontend", "ios", "next", "nextjs",
24
+ "mcp", "modelcontextprotocol", "postgres", "postgresql", "react", "react-native", "tailwind",
25
+ "typescript", "ui"
21
26
  ]);
22
27
 
23
28
  const scanCache = new Map();
@@ -29,9 +34,9 @@ export function skillSearchRoots({ cwd = process.cwd(), home = os.homedir() } =
29
34
  path.join(cwd, ".gemini", "skills"),
30
35
  path.join(cwd, ".gemini", "antigravity", "skills"),
31
36
  path.join(cwd, ".gemini", "antigravity-cli", "skills"),
32
- path.join(home, ".config", "skillshare", "skills"),
33
37
  path.join(home, ".codex", "skills"),
34
38
  path.join(home, ".claude", "skills"),
39
+ path.join(home, ".config", "skillshare", "skills"),
35
40
  path.join(home, ".gemini", "skills"),
36
41
  path.join(home, ".gemini", "antigravity", "skills"),
37
42
  path.join(home, ".gemini", "antigravity-cli", "skills")
@@ -197,10 +202,18 @@ function finalizeSkillScores(skills, limit, { minimumKeywordScore = 0.35 } = {})
197
202
  keywordScore: rule.keywordScore,
198
203
  score: Math.min(1, Number(rule.score || 0)),
199
204
  embeddingScore: rule.embeddingScore,
205
+ relevancePriority: Number(rule.relevancePriority || 0),
206
+ rankScore: Math.min(1, Number(rule.score || 0)) + Number(rule.relevancePriority || 0) / 100,
200
207
  reasons: rule.reasons || []
201
208
  }))
202
- .filter((skill) => Number(skill.keywordScore || 0) >= minimumKeywordScore || Number(skill.embeddingScore || 0) >= 0.62)
203
- .sort((a, b) => b.score - a.score || scopePriority(b.scope) - scopePriority(a.scope) || a.name.localeCompare(b.name));
209
+ .filter((skill) => Number(skill.keywordScore || 0) >= minimumKeywordScore
210
+ || Number(skill.embeddingScore || 0) >= 0.62
211
+ || Number(skill.relevancePriority || 0) >= 50)
212
+ .sort((a, b) => b.rankScore - a.rankScore
213
+ || b.relevancePriority - a.relevancePriority
214
+ || b.score - a.score
215
+ || scopePriority(b.scope) - scopePriority(a.scope)
216
+ || a.name.localeCompare(b.name));
204
217
  const seen = new Set();
205
218
  return ranked
206
219
  .filter((skill) => {
@@ -259,10 +272,11 @@ function scoreSkillsByKeyword({ prompt, skills, projectHints = [] }) {
259
272
  const nameHit = normalizedPrompt.includes(normalizedName);
260
273
  const nameTokenHit = nameTokens.length > 1 && nameTokens.every((token) => promptTokens.has(token));
261
274
  const scopeBonus = enriched.scope === "project" ? 0.08 : 0;
262
- const intentBonus = skillIntentBonus(normalizedPrompt, enriched);
263
- const domainEligible = isSkillDomainEligible(normalizedPrompt, enriched);
275
+ const intentBonus = skillIntentBonus(normalizedPrompt, enriched, projectTokens);
276
+ const relevancePriority = skillRelevancePriority(normalizedPrompt, enriched, projectTokens);
277
+ const domainEligible = isSkillDomainEligible(normalizedPrompt, enriched, projectTokens);
264
278
  const matchScore = matches.reduce((sum, token) => sum + (SPECIALIZED_SKILL_TOKENS.has(token) ? 0.2 : 0.08), 0);
265
- const projectBonus = matches.length && intentBonus ? Math.min(0.16, projectMatches.length * 0.04) : 0;
279
+ const projectBonus = intentBonus ? Math.min(0.16, projectMatches.length * 0.04) : 0;
266
280
  const score = Math.min(1, (matches.length ? 0.25 + matchScore : 0) + projectBonus + intentBonus + (nameHit ? 0.2 : 0) + (nameTokenHit ? 0.18 : 0) + scopeBonus);
267
281
  return {
268
282
  id: `skill-${index + 1}`,
@@ -273,6 +287,7 @@ function scoreSkillsByKeyword({ prompt, skills, projectHints = [] }) {
273
287
  content,
274
288
  score,
275
289
  keywordScore: score,
290
+ relevancePriority,
276
291
  domainEligible,
277
292
  reasons: [
278
293
  ...(matches.length ? [`keyword:${matches.slice(0, 4).join(",")}`] : []),
@@ -292,31 +307,191 @@ function filterSkillMatches(matches, { normalizedPrompt, enriched }) {
292
307
  return matches.filter((token) => token !== "android" && token !== "ios");
293
308
  }
294
309
 
295
- function isSkillDomainEligible(normalizedPrompt, enriched) {
296
- if (!/\beas\b/.test(normalizedPrompt)) return true;
310
+ function isSkillDomainEligible(normalizedPrompt, enriched, projectTokens = new Set()) {
297
311
  const skillText = normalize(`${enriched.name} ${enriched.description}`);
312
+ if (isMcpSkill(skillText) && !isMcpRelevantTask(normalizedPrompt, projectTokens)) return false;
313
+ if (isOffensiveSecuritySkill(skillText) && !isSecurityTask(normalizedPrompt)) return false;
314
+ if (isPlatformCommerceSkill(skillText) && !isPlatformCommerceTask(normalizedPrompt, skillText)) return false;
315
+ if (!/\beas\b/.test(normalizedPrompt)) return true;
298
316
  if (!/\b(android|ios)\b/.test(skillText)) return true;
299
317
  return /\b(eas|expo|cicd)\b/.test(skillText);
300
318
  }
301
319
 
302
- function skillIntentBonus(normalizedPrompt, enriched) {
320
+ function skillIntentBonus(normalizedPrompt, enriched, projectTokens = new Set()) {
303
321
  const skillText = normalize(`${enriched.name} ${enriched.description}`);
322
+ if (isMcpRelevantTask(normalizedPrompt, projectTokens)
323
+ && /\b(mcp|model context protocol|modelcontextprotocol|agent memory|tool developer|tool builder)\b/.test(skillText)) {
324
+ return 0.48;
325
+ }
326
+ if (isCommerceTask(normalizedPrompt)
327
+ && /\b(payment|payments|checkout|billing|bill|invoice|wallet|balance|stripe|paypal|commerce|monetization)\b/.test(skillText)) {
328
+ return 0.46;
329
+ }
330
+ if (isContentAccessTask(normalizedPrompt)
331
+ && /\b(api|endpoint|backend|service|services|auth|authorization|permission|permissions|access|rbac|frontend api)\b/.test(skillText)) {
332
+ return 0.34;
333
+ }
334
+ if (isNotificationTask(normalizedPrompt)
335
+ && /\b(notification|notifications|notify|message|sms|email|event|webhook)\b/.test(skillText)) {
336
+ return 0.3;
337
+ }
338
+ if (isFrontendCheckoutTask(normalizedPrompt)
339
+ && /\b(frontend|react|next|nextjs|ui|component|modal|api integration)\b/.test(skillText)) {
340
+ return 0.32;
341
+ }
342
+ if (isExpoRuntimeTask(normalizedPrompt, projectTokens)
343
+ && /\b(expo|eas|nativewind|react native|tailwind)\b/.test(skillText)) {
344
+ return 0.46;
345
+ }
346
+ if (isNextAppRouterTask(normalizedPrompt)
347
+ && /\b(next|nextjs)\b/.test(skillText)
348
+ && /\b(app router|router|routing|server components)\b/.test(skillText)) {
349
+ return 0.5;
350
+ }
304
351
  if (/\beas\b/.test(normalizedPrompt)
305
352
  && /\b(eas|expo)\b/.test(skillText)
306
353
  && /\b(cicd|workflow|workflows|build|deploy|deployment|pipeline|pipelines)\b/.test(skillText)) {
307
354
  return 0.28;
308
355
  }
356
+ if (/\b(webapp|frontend|ui|dashboard|button|page|component|app|router)\b/.test(normalizedPrompt)
357
+ && /\b(frontend|react|next|nextjs|ui|component|tailwind|app router)\b/.test(skillText)) {
358
+ return 0.36;
359
+ }
360
+ if (/\b(role|admin|creator|permission|permissions|authorization|access)\b/.test(normalizedPrompt)
361
+ && /\b(auth|authentication|authorization|permission|permissions|access|rbac)\b/.test(skillText)) {
362
+ return 0.32;
363
+ }
309
364
  return 0;
310
365
  }
311
366
 
367
+ function skillRelevancePriority(normalizedPrompt, enriched, projectTokens = new Set()) {
368
+ const skillText = normalize(`${enriched.name} ${enriched.description}`);
369
+ const skillName = normalize(enriched.name);
370
+ let priority = 0;
371
+ if (isMcpRelevantTask(normalizedPrompt, projectTokens)) {
372
+ if (skillName === "mcp builder") priority += 760;
373
+ if (skillName === "mcp management") priority += 740;
374
+ if (skillName === "mcp tool developer") priority += 720;
375
+ if (skillName === "agent memory mcp") priority += 700;
376
+ if (skillName === "agent tool builder" || skillName === "context agent") priority += 260;
377
+ if (/\b(mcp|model context protocol|modelcontextprotocol)\b/.test(skillText)) priority += 160;
378
+ }
379
+ if (isCommerceTask(normalizedPrompt)) {
380
+ if (/\b(payment integration|stripe integration|paypal integration)\b/.test(skillText)) priority += 520;
381
+ if (/\bbilling automation\b/.test(skillText)) priority += 430;
382
+ if (/\b(payment|payments|checkout|billing|wallet|balance|stripe|paypal|commerce|monetization)\b/.test(skillText)) priority += 160;
383
+ if (!/\bstripe\b/.test(normalizedPrompt) && /\bstripe\b/.test(skillText)) priority -= 520;
384
+ if (!/\bpaypal\b/.test(normalizedPrompt) && /\bpaypal\b/.test(skillText)) priority -= 520;
385
+ if (!/\bsquare\b/.test(normalizedPrompt) && /\bsquare\b/.test(skillText)) priority -= 440;
386
+ if (/\b(mcp|metasploit|penetration|exploit|bug bounty)\b/.test(skillText)) priority -= 500;
387
+ }
388
+ if (isContentAccessTask(normalizedPrompt)) {
389
+ if (/\b(api endpoint builder|backend development|backend architect|frontend api integration patterns)\b/.test(skillText)) priority += 260;
390
+ if (/\b(auth implementation patterns|authorization|permission|permissions|access|rbac)\b/.test(skillText)) priority += 120;
391
+ }
392
+ if (isNotificationTask(normalizedPrompt)) {
393
+ if (/\bsendblue notify\b/.test(skillText)) priority += 140;
394
+ if (/\b(notification|notifications|notify|message|sms|email|event|webhook)\b/.test(skillText)) priority += 90;
395
+ }
396
+ if (isFrontendCheckoutTask(normalizedPrompt)) {
397
+ if (/\bfrontend api integration patterns\b/.test(skillText)) priority += 220;
398
+ if (/\breact nextjs development|nextjs best practices|nextjs app router patterns|frontend developer\b/.test(skillText)) priority += 90;
399
+ }
400
+ if (isExpoRuntimeTask(normalizedPrompt, projectTokens)) {
401
+ if (/\bexpo deployment\b/.test(skillText)) priority += 900;
402
+ if (/\bbuilding native ui\b/.test(skillText)) priority += 760;
403
+ if (/\bexpo tailwind setup\b/.test(skillText)) priority += 620;
404
+ if (/\bexpo\b/.test(skillText) && /\b(qr|expo go|run|running|start|connect|eas|deployment|build)\b/.test(skillText)) priority += 220;
405
+ if (/\bnativewind|tailwind\b/.test(skillText) && projectTokens.has("nativewind")) priority += 120;
406
+ if (/\b(next|nextjs|frontend designer|dark themed|glassmorphism|framer motion)\b/.test(skillText)) priority -= 160;
407
+ }
408
+ if (isNextAppRouterTask(normalizedPrompt)) {
409
+ if (/\bnextjs app router patterns\b/.test(skillText)) priority += 600;
410
+ if (/\bnextjs best practices\b/.test(skillText)) priority += 560;
411
+ if (/\breact nextjs development\b/.test(skillText)) priority += 420;
412
+ if (/\b(next|nextjs)\b/.test(skillText) && /\b(app router|router|routing|server components)\b/.test(skillText)) priority += 100;
413
+ if (/\b(next|nextjs)\b/.test(skillText) && /\breact\b/.test(skillText)) priority += 70;
414
+ if (/\b(glassmorphism|dark themed|dark theme|framer motion)\b/.test(skillText)) priority -= 40;
415
+ }
416
+ if (/\b(role|admin|creator|permission|permissions|authorization|access)\b/.test(normalizedPrompt)
417
+ && /\b(auth|authentication|authorization|permission|permissions|access|rbac)\b/.test(skillText)) {
418
+ priority += 55;
419
+ }
420
+ return priority;
421
+ }
422
+
423
+ function isNextAppRouterTask(normalizedPrompt) {
424
+ return /\bwebapp\b.*\bsrc\b.*\bapp\b/.test(normalizedPrompt)
425
+ || /\b(next|nextjs)\b.*\b(app router|router|routing)\b/.test(normalizedPrompt)
426
+ || /\bapp router\b/.test(normalizedPrompt);
427
+ }
428
+
429
+ function isExpoRuntimeTask(normalizedPrompt, projectTokens = new Set()) {
430
+ const expoProject = projectTokens.has("expo") || projectTokens.has("nativewind") || projectTokens.has("eas");
431
+ if (!expoProject) return false;
432
+ return /\b(qr|connect|run|start|expo go|device|metro|tunnel|lan)\b/.test(normalizedPrompt);
433
+ }
434
+
435
+ function isCommerceTask(normalizedPrompt) {
436
+ return /\b(purchase|purchased|buy|buyer|seller|payment|pay|checkout|wallet|balance|top up|topup|funded|billing|invoice)\b/.test(normalizedPrompt);
437
+ }
438
+
439
+ function isContentAccessTask(normalizedPrompt) {
440
+ return /\b(content access service|content access|access permissions|grant access|permissions|library|resources|tutorials|collections)\b/.test(normalizedPrompt);
441
+ }
442
+
443
+ function isNotificationTask(normalizedPrompt) {
444
+ return /\b(notification|notifications|notify|buyer|seller)\b/.test(normalizedPrompt);
445
+ }
446
+
447
+ function isFrontendCheckoutTask(normalizedPrompt) {
448
+ return /\b(modal|display|show|checkout|library|frontend|webapp|page|button)\b/.test(normalizedPrompt);
449
+ }
450
+
451
+ function isMcpTask(normalizedPrompt) {
452
+ return /\b(mcp|model context protocol|tool server|tools server|server tool|bridge|proxy)\b/.test(normalizedPrompt);
453
+ }
454
+
455
+ function isMcpRelevantTask(normalizedPrompt, projectTokens = new Set()) {
456
+ return isMcpTask(normalizedPrompt)
457
+ || (isMcpProject(projectTokens) && isContextRetrievalTask(normalizedPrompt));
458
+ }
459
+
460
+ function isMcpProject(projectTokens = new Set()) {
461
+ return projectTokens.has("mcp") || projectTokens.has("modelcontextprotocol");
462
+ }
463
+
464
+ function isContextRetrievalTask(normalizedPrompt) {
465
+ return /\b(suggest|suggested|suggestion|skills|files|context|retrieval|retrieve|scorer|scoring|match|matching|prompt|hook|inject|injection)\b/.test(normalizedPrompt);
466
+ }
467
+
468
+ function isSecurityTask(normalizedPrompt) {
469
+ return /\b(security|pentest|penetration|exploit|vulnerability|metasploit|bug bounty|owasp|xss|csrf|attack|audit)\b/.test(normalizedPrompt);
470
+ }
471
+
472
+ function isMcpSkill(skillText) {
473
+ return /\bmcp\b|\bmodel context protocol\b/.test(skillText);
474
+ }
475
+
476
+ function isOffensiveSecuritySkill(skillText) {
477
+ return /\b(metasploit|penetration testing|bug bounty|exploit|exploitation|privilege escalation|ethical hacking|web fuzzing|security assessment)\b/.test(skillText);
478
+ }
479
+
480
+ function isPlatformCommerceSkill(skillText) {
481
+ return /\b(wordpress|woocommerce|shopify|odoo)\b/.test(skillText);
482
+ }
483
+
484
+ function isPlatformCommerceTask(normalizedPrompt, skillText) {
485
+ if (/\bwordpress\b/.test(skillText)) return /\bwordpress\b/.test(normalizedPrompt);
486
+ if (/\bwoocommerce\b/.test(skillText)) return /\bwoocommerce\b/.test(normalizedPrompt);
487
+ if (/\bshopify\b/.test(skillText)) return /\bshopify\b/.test(normalizedPrompt);
488
+ if (/\bodoo\b/.test(skillText)) return /\bodoo\b/.test(normalizedPrompt);
489
+ return true;
490
+ }
491
+
312
492
  export function projectSkillHints({ cwd = process.cwd() } = {}) {
313
493
  const hints = new Set();
314
- const packagePaths = [path.join(cwd, "package.json")];
315
- const rootPackage = readJson(path.join(cwd, "package.json"));
316
- for (const workspace of rootPackage?.workspaces || []) {
317
- if (typeof workspace !== "string" || workspace.includes("*")) continue;
318
- packagePaths.push(path.join(cwd, workspace, "package.json"));
319
- }
494
+ const packagePaths = workspacePackagePaths(cwd);
320
495
 
321
496
  for (const packagePath of packagePaths) {
322
497
  const packageDir = path.dirname(packagePath);
@@ -324,6 +499,8 @@ export function projectSkillHints({ cwd = process.cwd() } = {}) {
324
499
  addHintText(hints, JSON.stringify({
325
500
  name: packageJson?.name,
326
501
  description: packageJson?.description,
502
+ keywords: packageJson?.keywords || [],
503
+ scripts: packageJson?.scripts || {},
327
504
  dependencies: Object.keys(packageJson?.dependencies || {}),
328
505
  devDependencies: Object.keys(packageJson?.devDependencies || {})
329
506
  }));
@@ -334,6 +511,48 @@ export function projectSkillHints({ cwd = process.cwd() } = {}) {
334
511
  return [...hints];
335
512
  }
336
513
 
514
+ function workspacePackagePaths(cwd) {
515
+ const rootPackagePath = path.join(cwd, "package.json");
516
+ const rootPackage = readJson(rootPackagePath);
517
+ const paths = new Set([rootPackagePath]);
518
+ for (const workspace of workspacePatterns(rootPackage?.workspaces)) {
519
+ for (const packagePath of expandWorkspacePattern({ cwd, pattern: workspace })) {
520
+ paths.add(packagePath);
521
+ }
522
+ }
523
+ return [...paths];
524
+ }
525
+
526
+ function workspacePatterns(workspaces) {
527
+ if (Array.isArray(workspaces)) return workspaces.filter((item) => typeof item === "string");
528
+ if (Array.isArray(workspaces?.packages)) return workspaces.packages.filter((item) => typeof item === "string");
529
+ return [];
530
+ }
531
+
532
+ function expandWorkspacePattern({ cwd, pattern }) {
533
+ const normalized = String(pattern || "").replace(/\\/g, "/").replace(/\/+$/g, "");
534
+ if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) return [];
535
+ if (!normalized.includes("*")) {
536
+ const packagePath = path.join(cwd, normalized, "package.json");
537
+ return fs.existsSync(packagePath) ? [packagePath] : [];
538
+ }
539
+ const parts = normalized.split("/");
540
+ const starIndex = parts.indexOf("*");
541
+ if (starIndex < 0 || parts.includes("**")) return [];
542
+ const baseDir = path.join(cwd, ...parts.slice(0, starIndex));
543
+ const suffix = parts.slice(starIndex + 1);
544
+ let entries = [];
545
+ try {
546
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
547
+ } catch {
548
+ return [];
549
+ }
550
+ return entries
551
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
552
+ .map((entry) => path.join(baseDir, entry.name, ...suffix, "package.json"))
553
+ .filter((packagePath) => fs.existsSync(packagePath));
554
+ }
555
+
337
556
  function readJson(filePath) {
338
557
  try {
339
558
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -366,5 +585,8 @@ function normalize(value) {
366
585
  }
367
586
 
368
587
  function normalizePrompt(value) {
369
- return normalize(String(value || "").replace(/https?:\/\/\S+/gi, " "));
588
+ return normalize(String(value || "")
589
+ .replace(/https?:\/\/\S+/gi, " ")
590
+ .replace(/giao\s+di[eệ]n/gi, "frontend ui")
591
+ .replace(/phan\s+quyen/gi, "authorization role"));
370
592
  }