contextos-agents 1.5.0 → 1.6.1

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.
@@ -2240,8 +2240,8 @@ var require_resolve = __commonJS({
2240
2240
  }
2241
2241
  return count;
2242
2242
  }
2243
- function getFullPath(resolver, id = "", normalize2) {
2244
- if (normalize2 !== false)
2243
+ function getFullPath(resolver, id = "", normalize3) {
2244
+ if (normalize3 !== false)
2245
2245
  id = normalizeId(id);
2246
2246
  const p5 = resolver.parse(id);
2247
2247
  return _getFullPath(resolver, p5);
@@ -3581,7 +3581,7 @@ var require_fast_uri = __commonJS({
3581
3581
  "use strict";
3582
3582
  var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3583
3583
  var { SCHEMES, getSchemeHandler } = require_schemes();
3584
- function normalize2(uri, options) {
3584
+ function normalize3(uri, options) {
3585
3585
  if (typeof uri === "string") {
3586
3586
  uri = /** @type {T} */
3587
3587
  serialize(parse3(uri, options), options);
@@ -3817,7 +3817,7 @@ var require_fast_uri = __commonJS({
3817
3817
  }
3818
3818
  var fastUri = {
3819
3819
  SCHEMES,
3820
- normalize: normalize2,
3820
+ normalize: normalize3,
3821
3821
  resolve: resolve10,
3822
3822
  resolveComponent,
3823
3823
  equal,
@@ -6833,9 +6833,10 @@ var init_provider = __esm({
6833
6833
  // src/security/secret-filter.ts
6834
6834
  import * as path3 from "node:path";
6835
6835
  function isBlockedPath(filePath) {
6836
- const basename5 = path3.basename(filePath).toLowerCase();
6837
- const ext = path3.extname(filePath).toLowerCase();
6838
- const parts = filePath.split(path3.sep).map((p5) => p5.toLowerCase());
6836
+ const normalized = path3.normalize(filePath);
6837
+ const basename5 = path3.basename(normalized).toLowerCase();
6838
+ const ext = path3.extname(normalized).toLowerCase();
6839
+ const parts = normalized.split(/[/\\]/).map((p5) => p5.toLowerCase());
6839
6840
  if (BLOCKED_FILENAMES.some((f7) => basename5 === f7.toLowerCase())) {
6840
6841
  return true;
6841
6842
  }
@@ -6851,7 +6852,8 @@ function isBlockedPath(filePath) {
6851
6852
  return false;
6852
6853
  }
6853
6854
  function containsSecrets(content) {
6854
- const sample = content.slice(0, 5e3);
6855
+ if (!content) return false;
6856
+ const sample = content.length > 1e6 ? content.slice(0, 1e6) : content;
6855
6857
  return SECRET_CONTENT_PATTERNS.some((pattern) => pattern.test(sample));
6856
6858
  }
6857
6859
  function redactSecrets(text) {
@@ -6875,7 +6877,10 @@ var init_secret_filter = __esm({
6875
6877
  "credentials",
6876
6878
  "credentials.json",
6877
6879
  "service-account.json",
6878
- "serviceAccountKey.json"
6880
+ "serviceAccountKey.json",
6881
+ ".netrc",
6882
+ ".git-credentials",
6883
+ ".npmrc"
6879
6884
  ];
6880
6885
  BLOCKED_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".jks", ".keystore"];
6881
6886
  BLOCKED_DIRS = [".ssh", ".gnupg", ".aws", ".azure", ".gcp", ".kube"];
@@ -6887,12 +6892,18 @@ var init_secret_filter = __esm({
6887
6892
  // GitHub personal access token
6888
6893
  /gho_[A-Za-z0-9]{36}/,
6889
6894
  // GitHub OAuth token
6895
+ /github_pat_[A-Za-z0-9_]{82}/,
6896
+ // GitHub fine-grained PAT
6890
6897
  /sk-[A-Za-z0-9]{32,}/,
6891
6898
  // OpenAI API key
6892
6899
  /sk-ant-[A-Za-z0-9-]{90,}/,
6893
6900
  // Anthropic API key
6894
- /AIza[A-Za-z0-9_-]{35}/
6901
+ /AIza[A-Za-z0-9_-]{35}/,
6895
6902
  // Google API key
6903
+ /npm_[A-Za-z0-9]{32,36}/,
6904
+ // npm access token
6905
+ /xox[baprs]-[A-Za-z0-9_-]{10,48}/
6906
+ // Slack token
6896
6907
  ];
6897
6908
  }
6898
6909
  });
@@ -41559,121 +41570,6 @@ var require_browser = __commonJS({
41559
41570
  }
41560
41571
  });
41561
41572
 
41562
- // ../node_modules/has-flag/index.js
41563
- var require_has_flag = __commonJS({
41564
- "../node_modules/has-flag/index.js"(exports, module) {
41565
- "use strict";
41566
- module.exports = (flag, argv = process.argv) => {
41567
- const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
41568
- const position = argv.indexOf(prefix + flag);
41569
- const terminatorPosition = argv.indexOf("--");
41570
- return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
41571
- };
41572
- }
41573
- });
41574
-
41575
- // ../node_modules/supports-color/index.js
41576
- var require_supports_color = __commonJS({
41577
- "../node_modules/supports-color/index.js"(exports, module) {
41578
- "use strict";
41579
- var os7 = __require("os");
41580
- var tty = __require("tty");
41581
- var hasFlag = require_has_flag();
41582
- var { env } = process;
41583
- var forceColor;
41584
- if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
41585
- forceColor = 0;
41586
- } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
41587
- forceColor = 1;
41588
- }
41589
- if ("FORCE_COLOR" in env) {
41590
- if (env.FORCE_COLOR === "true") {
41591
- forceColor = 1;
41592
- } else if (env.FORCE_COLOR === "false") {
41593
- forceColor = 0;
41594
- } else {
41595
- forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3);
41596
- }
41597
- }
41598
- function translateLevel(level) {
41599
- if (level === 0) {
41600
- return false;
41601
- }
41602
- return {
41603
- level,
41604
- hasBasic: true,
41605
- has256: level >= 2,
41606
- has16m: level >= 3
41607
- };
41608
- }
41609
- function supportsColor(haveStream, streamIsTTY) {
41610
- if (forceColor === 0) {
41611
- return 0;
41612
- }
41613
- if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
41614
- return 3;
41615
- }
41616
- if (hasFlag("color=256")) {
41617
- return 2;
41618
- }
41619
- if (haveStream && !streamIsTTY && forceColor === void 0) {
41620
- return 0;
41621
- }
41622
- const min = forceColor || 0;
41623
- if (env.TERM === "dumb") {
41624
- return min;
41625
- }
41626
- if (process.platform === "win32") {
41627
- const osRelease = os7.release().split(".");
41628
- if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
41629
- return Number(osRelease[2]) >= 14931 ? 3 : 2;
41630
- }
41631
- return 1;
41632
- }
41633
- if ("CI" in env) {
41634
- if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"].some((sign) => sign in env) || env.CI_NAME === "codeship") {
41635
- return 1;
41636
- }
41637
- return min;
41638
- }
41639
- if ("TEAMCITY_VERSION" in env) {
41640
- return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
41641
- }
41642
- if (env.COLORTERM === "truecolor") {
41643
- return 3;
41644
- }
41645
- if ("TERM_PROGRAM" in env) {
41646
- const version2 = parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
41647
- switch (env.TERM_PROGRAM) {
41648
- case "iTerm.app":
41649
- return version2 >= 3 ? 3 : 2;
41650
- case "Apple_Terminal":
41651
- return 2;
41652
- }
41653
- }
41654
- if (/-256(color)?$/i.test(env.TERM)) {
41655
- return 2;
41656
- }
41657
- if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
41658
- return 1;
41659
- }
41660
- if ("COLORTERM" in env) {
41661
- return 1;
41662
- }
41663
- return min;
41664
- }
41665
- function getSupportLevel(stream2) {
41666
- const level = supportsColor(stream2, stream2 && stream2.isTTY);
41667
- return translateLevel(level);
41668
- }
41669
- module.exports = {
41670
- supportsColor: getSupportLevel,
41671
- stdout: translateLevel(supportsColor(true, tty.isatty(1))),
41672
- stderr: translateLevel(supportsColor(true, tty.isatty(2)))
41673
- };
41674
- }
41675
- });
41676
-
41677
41573
  // node_modules/debug/src/node.js
41678
41574
  var require_node = __commonJS({
41679
41575
  "node_modules/debug/src/node.js"(exports, module) {
@@ -41692,7 +41588,7 @@ var require_node = __commonJS({
41692
41588
  );
41693
41589
  exports.colors = [6, 2, 3, 4, 5, 1];
41694
41590
  try {
41695
- const supportsColor = require_supports_color();
41591
+ const supportsColor = __require("supports-color");
41696
41592
  if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
41697
41593
  exports.colors = [
41698
41594
  20,
@@ -118880,7 +118776,7 @@ var require_util5 = __commonJS({
118880
118776
  return url2;
118881
118777
  }
118882
118778
  exports.urlGenerate = urlGenerate;
118883
- function normalize2(aPath) {
118779
+ function normalize3(aPath) {
118884
118780
  var path16 = aPath;
118885
118781
  var url2 = urlParse(aPath);
118886
118782
  if (url2) {
@@ -118917,7 +118813,7 @@ var require_util5 = __commonJS({
118917
118813
  }
118918
118814
  return path16;
118919
118815
  }
118920
- exports.normalize = normalize2;
118816
+ exports.normalize = normalize3;
118921
118817
  function join10(aRoot, aPath) {
118922
118818
  if (aRoot === "") {
118923
118819
  aRoot = ".";
@@ -118943,7 +118839,7 @@ var require_util5 = __commonJS({
118943
118839
  aRootUrl.host = aPath;
118944
118840
  return urlGenerate(aRootUrl);
118945
118841
  }
118946
- var joined = aPath.charAt(0) === "/" ? aPath : normalize2(aRoot.replace(/\/+$/, "") + "/" + aPath);
118842
+ var joined = aPath.charAt(0) === "/" ? aPath : normalize3(aRoot.replace(/\/+$/, "") + "/" + aPath);
118947
118843
  if (aRootUrl) {
118948
118844
  aRootUrl.path = joined;
118949
118845
  return urlGenerate(aRootUrl);
@@ -119125,7 +119021,7 @@ var require_util5 = __commonJS({
119125
119021
  }
119126
119022
  sourceURL = join10(urlGenerate(parsed), sourceURL);
119127
119023
  }
119128
- return normalize2(sourceURL);
119024
+ return normalize3(sourceURL);
119129
119025
  }
119130
119026
  exports.computeSourceURL = computeSourceURL;
119131
119027
  }
@@ -162826,7 +162722,12 @@ function writeCodeBlocks(workDir, blocks) {
162826
162722
  for (const block of blocks) {
162827
162723
  const normalized = path13.normalize(block.filePath);
162828
162724
  if (normalized.startsWith("..") || path13.isAbsolute(normalized)) {
162829
- process.stderr.write(`[contextos-agent] Skipping unsafe path: ${block.filePath}
162725
+ process.stderr.write(`[contextos-agent] Skipping unsafe path (traversal): ${block.filePath}
162726
+ `);
162727
+ continue;
162728
+ }
162729
+ if (isBlockedPath(normalized) || !isPathAllowed(normalized, workDir)) {
162730
+ process.stderr.write(`[contextos-agent] Skipping forbidden/policy-blocked path: ${block.filePath}
162830
162731
  `);
162831
162732
  continue;
162832
162733
  }
@@ -188780,20 +188681,8 @@ function buildContextPrompt(projectRoot, task, options = {}) {
188780
188681
  // src/mcp/tools/contextos.ts
188781
188682
  init_secret_filter();
188782
188683
  init_command_exists();
188783
- var ALLOWED_VERIFY_TOOLS = /* @__PURE__ */ new Set([
188784
- "npm",
188785
- "npx",
188786
- "pnpm",
188787
- "yarn",
188788
- "pytest",
188789
- "python",
188790
- "python3",
188791
- "cargo",
188792
- "go",
188793
- "vitest",
188794
- "jest",
188795
- "node"
188796
- ]);
188684
+ var ALLOWED_VERIFY_TOOLS = /* @__PURE__ */ new Set(["npm", "npx", "pnpm", "yarn", "pytest", "cargo", "go", "vitest", "jest"]);
188685
+ var FORBIDDEN_VERIFY_ARGS = /* @__PURE__ */ new Set(["-e", "-c", "--eval", "--print", "-p", "--input-type"]);
188797
188686
  var STRICT_ENV_ALLOWLIST = /* @__PURE__ */ new Set([
188798
188687
  "PATH",
188799
188688
  "HOME",
@@ -188846,6 +188735,15 @@ async function runWorktreeVerification(worktreePath, command) {
188846
188735
  output: `Security error: Command "${rawTool}" is not in the allowed verification tools whitelist (${Array.from(ALLOWED_VERIFY_TOOLS).join(", ")})`
188847
188736
  };
188848
188737
  }
188738
+ const args = parts.slice(1);
188739
+ for (const arg of args) {
188740
+ if (FORBIDDEN_VERIFY_ARGS.has(arg.toLowerCase())) {
188741
+ return {
188742
+ verified: false,
188743
+ output: `Security error: Argument "${arg}" is not permitted in verify_command`
188744
+ };
188745
+ }
188746
+ }
188849
188747
  const exePath = await resolveExecutablePath(rawTool);
188850
188748
  const targetExecutable = exePath || rawTool;
188851
188749
  return new Promise((resolveResult) => {
@@ -43,14 +43,20 @@ const PROMPT_INJECTION_PATTERNS = [
43
43
  /you\s+are\s+now/i,
44
44
  /system\s*:\s*/i,
45
45
  /\[SYSTEM\]/i,
46
+ /base64\s*,\s*[A-Za-z0-9+/=]{40,}/i,
47
+ /https?:\/\/[^\s)]+\/(?:exfil|steal|leak|webhook|collect)/i,
48
+ /\b(?:eval|exec|Function)\s*\(/i,
46
49
  ];
47
50
 
48
- function scanForPromptInjection(content) {
51
+ function scanForPromptInjection(content, options = {}) {
49
52
  if (typeof content !== 'string') return false;
50
53
  const found = PROMPT_INJECTION_PATTERNS.filter(p => p.test(content));
51
54
  if (found.length > 0) {
52
- console.warn(c.yellow(' ⚠️ Potential prompt injection detected in downloaded skill'));
53
- console.warn(c.yellow(` Patterns: ${found.map(p => p.source).join(', ')}`));
55
+ console.warn(c.red(' 🚨 Potential prompt injection or unsafe pattern detected in skill:'));
56
+ console.warn(c.yellow(` Pattern matches: ${found.map(p => p.source).join(', ')}`));
57
+ if (options.throwOnMatch) {
58
+ throw new Error(`Security validation failed: prompt injection detected (${found.length} pattern matches). Installation aborted. Pass --force-unsafe-prompts to override.`);
59
+ }
54
60
  return true;
55
61
  }
56
62
  return false;
@@ -166,7 +172,7 @@ function parseRef(ref) {
166
172
  }
167
173
 
168
174
  // ── GitHub installer ──────────────────────────────────────────────────────────
169
- async function installFromGitHub(descriptor, skillName, dryRun, checksum) {
175
+ async function installFromGitHub(descriptor, skillName, dryRun, checksum, forceUnsafe = false) {
170
176
  const { owner, repo, gitRef, isPinned, subPath } = descriptor;
171
177
  const skillPath = subPath || '';
172
178
  const base = `https://raw.githubusercontent.com/${owner}/${repo}/${gitRef}`;
@@ -198,7 +204,7 @@ async function installFromGitHub(descriptor, skillName, dryRun, checksum) {
198
204
  if (checksum && sha256.toLowerCase() !== checksum.toLowerCase()) {
199
205
  throw new Error(`Checksum mismatch for ${skillName}: expected ${checksum}, got ${sha256}`);
200
206
  }
201
- scanForPromptInjection(skillMdContent);
207
+ scanForPromptInjection(skillMdContent, { throwOnMatch: !forceUnsafe });
202
208
 
203
209
  // Optionally fetch skill.yaml if it exists
204
210
  const yamlUrl = skillPath
@@ -244,7 +250,7 @@ async function installFromGitHub(descriptor, skillName, dryRun, checksum) {
244
250
  }
245
251
 
246
252
  // ── npm installer ─────────────────────────────────────────────────────────────
247
- function installFromNpm(descriptor, skillName, dryRun, checksum) {
253
+ function installFromNpm(descriptor, skillName, dryRun, checksum, forceUnsafe = false) {
248
254
  const pkgName = descriptor.package;
249
255
 
250
256
  if (dryRun) {
@@ -280,7 +286,7 @@ function installFromNpm(descriptor, skillName, dryRun, checksum) {
280
286
  if (checksum && sha256.toLowerCase() !== checksum.toLowerCase()) {
281
287
  throw new Error(`Checksum mismatch for ${skillName}: expected ${checksum}, got ${sha256}`);
282
288
  }
283
- scanForPromptInjection(skillMdContent);
289
+ scanForPromptInjection(skillMdContent, { throwOnMatch: !forceUnsafe });
284
290
 
285
291
  const targetDir = path.join(PLUGINS_DIR, skillName);
286
292
  fs.rmSync(targetDir, { recursive: true, force: true });
@@ -321,9 +327,13 @@ function isSafeSkillName(skillName) {
321
327
  // ═════════════════════════════════════════════════════════════════════════════
322
328
 
323
329
  /**
324
- * skill add <ref> [--dry-run] [--checksum <sha256>]
330
+ * skill add <ref> [--dry-run] [--checksum <sha256>] [--force-unsafe-prompts]
325
331
  */
326
- async function add(ref, { dryRun = false, checksum } = {}) {
332
+ async function add(ref, options = {}) {
333
+ const dryRun = Boolean(options.dryRun || options['dry-run']);
334
+ const checksum = options.checksum || null;
335
+ const forceUnsafe = Boolean(options.forceUnsafe || options['force-unsafe-prompts']);
336
+
327
337
  if (!ref) {
328
338
  console.error(c.red('Usage: ctx.js skill add <ref> [--checksum <sha256>]'));
329
339
  console.error(' ref can be: username/repo, username/repo/path/to/skill, or npm-package-name');
@@ -361,9 +371,9 @@ async function add(ref, { dryRun = false, checksum } = {}) {
361
371
  let installedSha256 = null;
362
372
  try {
363
373
  if (descriptor.type === 'github') {
364
- installedSha256 = await installFromGitHub(descriptor, skillName, dryRun, checksum);
374
+ installedSha256 = await installFromGitHub(descriptor, skillName, dryRun, checksum, forceUnsafe);
365
375
  } else {
366
- installedSha256 = installFromNpm(descriptor, skillName, dryRun, checksum);
376
+ installedSha256 = installFromNpm(descriptor, skillName, dryRun, checksum, forceUnsafe);
367
377
  }
368
378
  } catch (err) {
369
379
  console.error(c.red(`\n[ERROR] Failed to install '${skillName}': ${err.message}`));
@@ -225,13 +225,115 @@ function buildSkillIndex(projectDir = process.cwd()) {
225
225
  return index;
226
226
  }
227
227
 
228
+ /**
229
+ * Phase 2: AST & Project Dependency Graph Analysis.
230
+ * Scans project manifests and configs to detect active technologies
231
+ * that keyword or regex matching in prompts might omit.
232
+ *
233
+ * @param {string} [projectDir=process.cwd()] - Project root directory
234
+ * @returns {Map<string, number>} Map of skill name to score weight
235
+ */
236
+ function analyzeImportGraph(projectDir = process.cwd()) {
237
+ const techSignals = new Map();
238
+ if (!projectDir || !fs.existsSync(projectDir)) return techSignals;
239
+
240
+ const addSignal = (skill, weight) => {
241
+ techSignals.set(skill, (techSignals.get(skill) || 0) + weight);
242
+ };
243
+
244
+ // 1. Scan package.json dependencies and devDependencies
245
+ const pkgPath = path.join(projectDir, 'package.json');
246
+ if (fs.existsSync(pkgPath)) {
247
+ try {
248
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
249
+ const allDeps = {
250
+ ...(pkg.dependencies || {}),
251
+ ...(pkg.devDependencies || {}),
252
+ ...(pkg.peerDependencies || {}),
253
+ };
254
+
255
+ const DEP_SKILL_MAP = {
256
+ 'react': 'react',
257
+ 'react-dom': 'react',
258
+ 'next': 'nextjs',
259
+ 'prisma': 'database',
260
+ '@prisma/client': 'database',
261
+ 'drizzle-orm': 'database',
262
+ 'drizzle-kit': 'database',
263
+ 'typeorm': 'database',
264
+ 'mongoose': 'database',
265
+ 'pg': 'database',
266
+ 'mysql2': 'database',
267
+ 'vitest': 'testing',
268
+ 'jest': 'testing',
269
+ 'playwright': 'testing',
270
+ '@playwright/test': 'testing',
271
+ 'cypress': 'testing',
272
+ 'express': 'node',
273
+ 'fastify': 'node',
274
+ 'hono': 'node',
275
+ '@nestjs/core': 'nestjs',
276
+ 'typescript': 'typescript',
277
+ 'zod': 'typescript',
278
+ 'tailwindcss': 'ui-ux-pro',
279
+ 'lucide-react': 'ui-ux-pro',
280
+ '@radix-ui/react-dialog': 'web-accessibility',
281
+ 'framer-motion': 'impeccable-design',
282
+ 'dockerode': 'docker',
283
+ 'ioredis': 'system-design',
284
+ 'kafkajs': 'microservices',
285
+ 'amqplib': 'microservices',
286
+ };
287
+
288
+ for (const [dep, skill] of Object.entries(DEP_SKILL_MAP)) {
289
+ if (allDeps[dep]) {
290
+ addSignal(skill, 15);
291
+ }
292
+ }
293
+ } catch {
294
+ // ignore malformed package.json
295
+ }
296
+ }
297
+
298
+ // 2. Scan framework & tooling configuration files
299
+ const CONFIG_FILE_MAP = [
300
+ { file: 'tsconfig.json', skill: 'typescript', weight: 10 },
301
+ { file: 'next.config.js', skill: 'nextjs', weight: 15 },
302
+ { file: 'next.config.mjs', skill: 'nextjs', weight: 15 },
303
+ { file: 'next.config.ts', skill: 'nextjs', weight: 15 },
304
+ { file: 'tailwind.config.js', skill: 'ui-ux-pro', weight: 10 },
305
+ { file: 'tailwind.config.ts', skill: 'ui-ux-pro', weight: 10 },
306
+ { file: 'nest-cli.json', skill: 'nestjs', weight: 15 },
307
+ { file: 'prisma/schema.prisma', skill: 'database', weight: 15 },
308
+ { file: 'drizzle.config.ts', skill: 'database', weight: 15 },
309
+ { file: 'drizzle.config.js', skill: 'database', weight: 15 },
310
+ { file: 'Dockerfile', skill: 'docker', weight: 15 },
311
+ { file: 'docker-compose.yml', skill: 'docker', weight: 15 },
312
+ { file: 'docker-compose.yaml', skill: 'docker', weight: 15 },
313
+ { file: 'requirements.txt', skill: 'fastapi', weight: 15 },
314
+ { file: 'pyproject.toml', skill: 'fastapi', weight: 15 },
315
+ { file: 'vitest.config.ts', skill: 'testing', weight: 15 },
316
+ { file: 'vitest.config.js', skill: 'testing', weight: 15 },
317
+ { file: 'playwright.config.ts', skill: 'testing', weight: 15 },
318
+ { file: 'playwright.config.js', skill: 'testing', weight: 15 },
319
+ ];
320
+
321
+ for (const { file, skill, weight } of CONFIG_FILE_MAP) {
322
+ if (fs.existsSync(path.join(projectDir, file))) {
323
+ addSignal(skill, weight);
324
+ }
325
+ }
326
+
327
+ return techSignals;
328
+ }
329
+
228
330
  /**
229
331
  * Resolves the minimal set of skills for a given prompt, file list, and phase.
230
332
  *
231
333
  * @param {ResolveSkillsOptions} [options={}] - Task context options
232
334
  * @returns {ResolvedSkillsResult} Resolved domain, role, and activated skill names
233
335
  */
234
- function resolveSkills({ prompt = '', files = [], phase = 'Build', domain = '' } = {}) {
336
+ function resolveSkills({ prompt = '', files = [], phase = 'Build', domain = '', projectDir = process.cwd() } = {}) {
235
337
  const promptText = (prompt || '').toLowerCase();
236
338
  const scores = new Map();
237
339
 
@@ -267,6 +369,14 @@ function resolveSkills({ prompt = '', files = [], phase = 'Build', domain = '' }
267
369
  }
268
370
  }
269
371
 
372
+ // Phase 2: Merge AST & Project Dependency Graph Signals
373
+ if (projectDir) {
374
+ const astSignals = analyzeImportGraph(projectDir);
375
+ for (const [skill, astScore] of astSignals) {
376
+ scores.set(skill, (scores.get(skill) || 0) + astScore);
377
+ }
378
+ }
379
+
270
380
  // Sort matched domain skills by score descending
271
381
  const sortedDomainSkills = Array.from(scores.entries())
272
382
  .sort((a, b) => b[1] - a[1])
@@ -332,5 +442,6 @@ function formatDeclaration(resolution) {
332
442
  module.exports = {
333
443
  buildSkillIndex,
334
444
  resolveSkills,
445
+ analyzeImportGraph,
335
446
  formatDeclaration,
336
447
  };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * .agents/stats.js
3
+ * ContextOS — Context Savings & Token Optimization Report
4
+ *
5
+ * Measures:
6
+ * 1. Full context payload (all skills + AGENTS.md)
7
+ * 2. Profiled context payload (after applying active profile exclusions)
8
+ * 3. Dynamically resolved context (lean on-demand skill set for typical tasks)
9
+ * 4. Concrete token savings percentage and cost reductions
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const profiles = require('./profiles.js');
17
+
18
+ function estimateTokens(text) {
19
+ // Common heuristic across GPT-4, Claude, Gemini: ~4 characters per token
20
+ return Math.max(1, Math.round(text.length / 4));
21
+ }
22
+
23
+ function calculateContextStats(projectDir = process.cwd()) {
24
+ const agentsDir = path.join(projectDir, '.agents');
25
+ const skillsDir = path.join(agentsDir, 'core', 'skills');
26
+ const agentsMdPath = path.join(agentsDir, 'AGENTS.md');
27
+
28
+ let agentsMdChars = 0;
29
+ if (fs.existsSync(agentsMdPath)) {
30
+ agentsMdChars = fs.readFileSync(agentsMdPath, 'utf8').length;
31
+ }
32
+
33
+ const skillCharMap = new Map();
34
+ if (fs.existsSync(skillsDir)) {
35
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory()) continue;
38
+ const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
39
+ if (fs.existsSync(skillMd)) {
40
+ skillCharMap.set(entry.name, fs.readFileSync(skillMd, 'utf8').length);
41
+ }
42
+ }
43
+ }
44
+
45
+ const totalSkillCount = skillCharMap.size;
46
+ let fullChars = agentsMdChars;
47
+ for (const chars of skillCharMap.values()) {
48
+ fullChars += chars;
49
+ }
50
+
51
+ // Active profile
52
+ const activeProfile = profiles.getActiveProfile(projectDir);
53
+ const profileName = activeProfile ? (activeProfile.name || activeProfile.profile) : 'startup (recommended)';
54
+ const excludedSkills = new Set(
55
+ activeProfile ? activeProfile.exclude_skills || [] : (profiles.getProfile('startup')?.exclude_skills || [])
56
+ );
57
+
58
+ let profileChars = agentsMdChars;
59
+ let profiledSkillCount = 0;
60
+ for (const [skill, chars] of skillCharMap) {
61
+ if (!excludedSkills.has(skill)) {
62
+ profileChars += chars;
63
+ profiledSkillCount++;
64
+ }
65
+ }
66
+
67
+ // Resolved context: 2 foundational skills + average 2 domain skills (e.g. react + ui-ux-pro)
68
+ const typicalResolvedSkills = ['engineering-workflow', 'ponytail-mindset', 'react', 'ui-ux-pro'];
69
+ let resolvedChars = agentsMdChars;
70
+ let resolvedSkillCount = 0;
71
+ for (const s of typicalResolvedSkills) {
72
+ if (skillCharMap.has(s)) {
73
+ resolvedChars += skillCharMap.get(s);
74
+ resolvedSkillCount++;
75
+ }
76
+ }
77
+
78
+ const fullTokens = estimateTokens({ length: fullChars });
79
+ const profileTokens = estimateTokens({ length: profileChars });
80
+ const resolvedTokens = estimateTokens({ length: resolvedChars });
81
+
82
+ const profileSavings = fullTokens > 0 ? (((fullTokens - profileTokens) / fullTokens) * 100).toFixed(1) : '0.0';
83
+ const resolvedSavings = fullTokens > 0 ? (((fullTokens - resolvedTokens) / fullTokens) * 100).toFixed(1) : '0.0';
84
+
85
+ return {
86
+ totalSkillCount,
87
+ profiledSkillCount,
88
+ resolvedSkillCount,
89
+ profileName,
90
+ full: { chars: fullChars, tokens: fullTokens },
91
+ profile: { chars: profileChars, tokens: profileTokens, savings: profileSavings },
92
+ resolved: { chars: resolvedChars, tokens: resolvedTokens, savings: resolvedSavings },
93
+ };
94
+ }
95
+
96
+ function runStats(projectDir = process.cwd()) {
97
+ const stats = calculateContextStats(projectDir);
98
+
99
+ console.log('\nContextOS — Context Savings Report\n');
100
+ console.log('┌──────────────────────────────────────┬─────────────┬──────────┐');
101
+ console.log('│ Mode │ Tokens │ Savings │');
102
+ console.log('├──────────────────────────────────────┼─────────────┼──────────┤');
103
+
104
+ const fullLabel = `Full (${stats.totalSkillCount} skills)`;
105
+ const fullTok = stats.full.tokens.toLocaleString().padStart(9);
106
+ console.log(`│ ${fullLabel.padEnd(36)} │ ${fullTok} │ — │`);
107
+
108
+ const profLabel = `Profile: ${stats.profileName} (${stats.profiledSkillCount} skills)`;
109
+ const profTok = stats.profile.tokens.toLocaleString().padStart(9);
110
+ const profSav = `${stats.profile.savings}%`.padStart(7);
111
+ console.log(`│ ${profLabel.slice(0, 36).padEnd(36)} │ ${profTok} │ ${profSav} │`);
112
+
113
+ const resLabel = `Resolved: typical task (${stats.resolvedSkillCount} skills)`;
114
+ const resTok = stats.resolved.tokens.toLocaleString().padStart(9);
115
+ const resSav = `${stats.resolved.savings}%`.padStart(7);
116
+ console.log(`│ ${resLabel.slice(0, 36).padEnd(36)} │ ${resTok} │ ${resSav} │`);
117
+
118
+ console.log('└──────────────────────────────────────┴─────────────┴──────────┘');
119
+ console.log('\nToken estimation: ~4 chars/token (GPT-4 / Claude / Gemini approximation)');
120
+ console.log('Dynamic skill resolution prevents prompt bloat and cuts LLM API costs.\n');
121
+
122
+ return stats;
123
+ }
124
+
125
+ if (require.main === module) {
126
+ runStats();
127
+ }
128
+
129
+ module.exports = {
130
+ calculateContextStats,
131
+ runStats,
132
+ estimateTokens,
133
+ };