legacyver 2.1.0 → 2.1.2

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.
Files changed (126) hide show
  1. package/.agent/skills/openspec-apply-change/SKILL.md +156 -0
  2. package/.agent/skills/openspec-archive-change/SKILL.md +114 -0
  3. package/.agent/skills/openspec-bulk-archive-change/SKILL.md +246 -0
  4. package/.agent/skills/openspec-continue-change/SKILL.md +118 -0
  5. package/.agent/skills/openspec-explore/SKILL.md +290 -0
  6. package/.agent/skills/openspec-ff-change/SKILL.md +101 -0
  7. package/.agent/skills/openspec-new-change/SKILL.md +74 -0
  8. package/.agent/skills/openspec-onboard/SKILL.md +529 -0
  9. package/.agent/skills/openspec-sync-specs/SKILL.md +138 -0
  10. package/.agent/skills/openspec-verify-change/SKILL.md +168 -0
  11. package/.agent/workflows/opsx-apply.md +149 -0
  12. package/.agent/workflows/opsx-archive.md +154 -0
  13. package/.agent/workflows/opsx-bulk-archive.md +239 -0
  14. package/.agent/workflows/opsx-continue.md +111 -0
  15. package/.agent/workflows/opsx-explore.md +171 -0
  16. package/.agent/workflows/opsx-ff.md +91 -0
  17. package/.agent/workflows/opsx-new.md +66 -0
  18. package/.agent/workflows/opsx-onboard.md +522 -0
  19. package/.agent/workflows/opsx-sync.md +131 -0
  20. package/.agent/workflows/opsx-verify.md +161 -0
  21. package/.github/prompts/opsx-apply.prompt.md +149 -0
  22. package/.github/prompts/opsx-archive.prompt.md +154 -0
  23. package/.github/prompts/opsx-bulk-archive.prompt.md +239 -0
  24. package/.github/prompts/opsx-continue.prompt.md +111 -0
  25. package/.github/prompts/opsx-explore.prompt.md +171 -0
  26. package/.github/prompts/opsx-ff.prompt.md +91 -0
  27. package/.github/prompts/opsx-new.prompt.md +66 -0
  28. package/.github/prompts/opsx-onboard.prompt.md +522 -0
  29. package/.github/prompts/opsx-sync.prompt.md +131 -0
  30. package/.github/prompts/opsx-verify.prompt.md +161 -0
  31. package/.github/skills/openspec-apply-change/SKILL.md +156 -0
  32. package/.github/skills/openspec-archive-change/SKILL.md +114 -0
  33. package/.github/skills/openspec-bulk-archive-change/SKILL.md +246 -0
  34. package/.github/skills/openspec-continue-change/SKILL.md +118 -0
  35. package/.github/skills/openspec-explore/SKILL.md +290 -0
  36. package/.github/skills/openspec-ff-change/SKILL.md +101 -0
  37. package/.github/skills/openspec-new-change/SKILL.md +74 -0
  38. package/.github/skills/openspec-onboard/SKILL.md +529 -0
  39. package/.github/skills/openspec-sync-specs/SKILL.md +138 -0
  40. package/.github/skills/openspec-verify-change/SKILL.md +168 -0
  41. package/.legacyverignore.example +43 -0
  42. package/.legacyverrc +7 -0
  43. package/.opencode/command/opsx-apply.md +149 -0
  44. package/.opencode/command/opsx-archive.md +154 -0
  45. package/.opencode/command/opsx-bulk-archive.md +239 -0
  46. package/.opencode/command/opsx-continue.md +111 -0
  47. package/.opencode/command/opsx-explore.md +171 -0
  48. package/.opencode/command/opsx-ff.md +91 -0
  49. package/.opencode/command/opsx-new.md +66 -0
  50. package/.opencode/command/opsx-onboard.md +522 -0
  51. package/.opencode/command/opsx-sync.md +131 -0
  52. package/.opencode/command/opsx-verify.md +161 -0
  53. package/.opencode/skills/openspec-apply-change/SKILL.md +156 -0
  54. package/.opencode/skills/openspec-archive-change/SKILL.md +114 -0
  55. package/.opencode/skills/openspec-bulk-archive-change/SKILL.md +246 -0
  56. package/.opencode/skills/openspec-continue-change/SKILL.md +118 -0
  57. package/.opencode/skills/openspec-explore/SKILL.md +290 -0
  58. package/.opencode/skills/openspec-ff-change/SKILL.md +101 -0
  59. package/.opencode/skills/openspec-new-change/SKILL.md +74 -0
  60. package/.opencode/skills/openspec-onboard/SKILL.md +529 -0
  61. package/.opencode/skills/openspec-sync-specs/SKILL.md +138 -0
  62. package/.opencode/skills/openspec-verify-change/SKILL.md +168 -0
  63. package/LICENSE +1 -1
  64. package/README.md +128 -83
  65. package/bin/legacyver.js +48 -25
  66. package/legacyver-docs/SUMMARY.md +3 -0
  67. package/legacyver-docs/components.md +57 -0
  68. package/legacyver-docs/index.md +15 -0
  69. package/nul +2 -0
  70. package/package.json +23 -25
  71. package/src/cache/hash.js +9 -10
  72. package/src/cache/index.js +43 -65
  73. package/src/cli/commands/analyze.js +212 -190
  74. package/src/cli/commands/cache.js +15 -35
  75. package/src/cli/commands/init.js +63 -107
  76. package/src/cli/commands/providers.js +56 -81
  77. package/src/cli/commands/version.js +7 -10
  78. package/src/cli/ui.js +58 -77
  79. package/src/crawler/filters.js +41 -40
  80. package/src/crawler/index.js +52 -36
  81. package/src/crawler/manifest.js +31 -43
  82. package/src/crawler/walk.js +32 -38
  83. package/src/llm/chunker.js +34 -56
  84. package/src/llm/cost-estimator.js +68 -51
  85. package/src/llm/free-model.js +67 -0
  86. package/src/llm/index.js +22 -43
  87. package/src/llm/prompts.js +45 -33
  88. package/src/llm/providers/gemini.js +94 -0
  89. package/src/llm/providers/groq.js +55 -40
  90. package/src/llm/providers/ollama.js +38 -65
  91. package/src/llm/providers/openrouter.js +67 -0
  92. package/src/llm/queue.js +59 -88
  93. package/src/llm/re-prompter.js +41 -0
  94. package/src/llm/validator.js +72 -0
  95. package/src/parser/ast/generic.js +45 -222
  96. package/src/parser/ast/go.js +86 -205
  97. package/src/parser/ast/java.js +76 -146
  98. package/src/parser/ast/javascript.js +173 -241
  99. package/src/parser/ast/laravel/blade.js +56 -0
  100. package/src/parser/ast/laravel/classifier.js +30 -0
  101. package/src/parser/ast/laravel/controller.js +35 -0
  102. package/src/parser/ast/laravel/index.js +54 -0
  103. package/src/parser/ast/laravel/model.js +41 -0
  104. package/src/parser/ast/laravel/provider.js +28 -0
  105. package/src/parser/ast/laravel/routes.js +45 -0
  106. package/src/parser/ast/php.js +129 -0
  107. package/src/parser/ast/python.js +76 -199
  108. package/src/parser/ast/typescript.js +10 -244
  109. package/src/parser/body-extractor.js +40 -0
  110. package/src/parser/call-graph.js +50 -67
  111. package/src/parser/complexity-scorer.js +59 -0
  112. package/src/parser/index.js +61 -86
  113. package/src/parser/pattern-detector.js +71 -0
  114. package/src/parser/pkg-builder.js +36 -83
  115. package/src/renderer/html.js +63 -135
  116. package/src/renderer/index.js +23 -35
  117. package/src/renderer/json.js +17 -35
  118. package/src/renderer/markdown.js +83 -117
  119. package/src/utils/config.js +52 -53
  120. package/src/utils/errors.js +26 -41
  121. package/src/utils/logger.js +32 -53
  122. package/src/cli/flags.js +0 -87
  123. package/src/llm/providers/anthropic.js +0 -57
  124. package/src/llm/providers/google.js +0 -65
  125. package/src/llm/providers/openai.js +0 -52
  126. package/src/parser/ast/tree-sitter-init.js +0 -80
@@ -1,49 +1,65 @@
1
- /**
2
- * Crawler main module.
3
- * Composes walk.js and manifest.js.
4
- * Applies size filter, logs warnings for skipped files.
5
- */
1
+ 'use strict';
6
2
 
7
- import { walkFiles } from './walk.js';
8
- import { buildManifest } from './manifest.js';
9
- import { logger } from '../utils/logger.js';
3
+ const { existsSync } = require('fs');
4
+ const path = require('path');
5
+ const { walk } = require('./walk');
6
+ const { buildManifest } = require('./manifest');
7
+ const { detectPrimaryLanguage } = require('./filters');
8
+ const logger = require('../utils/logger');
10
9
 
11
10
  /**
12
- * Crawl a target directory and return file manifests.
13
- * @param {string} targetDir - Absolute path to scan
14
- * @param {object} options
15
- * @param {number} [options.maxFileSizeKb=500] - Skip files larger than this (KB)
16
- * @param {string[]} [options.ignore] - Additional ignore patterns
17
- * @returns {Promise<import('./manifest.js').FileManifest[]>}
11
+ * @typedef {Object} FileManifest
12
+ * @property {string} relativePath
13
+ * @property {string} absolutePath
14
+ * @property {string} language
15
+ * @property {number} sizeBytes
16
+ * @property {string} hash
18
17
  */
19
- export async function crawl(targetDir, options = {}) {
20
- const { maxFileSizeKb = 500, ignore: customIgnore = [] } = options;
21
- const maxBytes = maxFileSizeKb * 1024;
22
18
 
23
- // Discover files
24
- const relativePaths = await walkFiles(targetDir, { customIgnore });
19
+ /**
20
+ * Main crawler entrypoint.
21
+ * @param {string} targetDir
22
+ * @param {Object} config
23
+ * @returns {{ files: FileManifest[], skipped: Object[], meta: Object }}
24
+ */
25
+ async function crawl(targetDir, config) {
26
+ const maxSizeBytes = (config.maxFileSizeKb || 500) * 1024;
25
27
 
26
- // Build manifest
27
- const manifest = buildManifest(relativePaths, targetDir);
28
+ const absPaths = await walk(targetDir, config);
29
+ let allFiles = buildManifest(absPaths, targetDir);
28
30
 
29
- // Filter by size
30
- const kept = [];
31
+ // Size filter
31
32
  const skipped = [];
32
-
33
- for (const entry of manifest) {
34
- if (entry.sizeBytes > maxBytes) {
35
- skipped.push(entry);
36
- } else {
37
- kept.push(entry);
33
+ const files = allFiles.filter((f) => {
34
+ if (f.sizeBytes > maxSizeBytes) {
35
+ logger.warn(`Skipping large file (${Math.round(f.sizeBytes / 1024)}KB): ${f.relativePath}`);
36
+ skipped.push({ ...f, reason: 'too large' });
37
+ return false;
38
38
  }
39
- }
39
+ return true;
40
+ });
40
41
 
41
- if (skipped.length > 0) {
42
- logger.warn(`Skipped ${skipped.length} file(s) exceeding ${maxFileSizeKb}KB:`);
43
- for (const s of skipped) {
44
- logger.debug(` ${s.relativePath} (${(s.sizeBytes / 1024).toFixed(1)}KB)`);
45
- }
42
+ const primaryLanguage = detectPrimaryLanguage(files);
43
+
44
+ // Laravel detection
45
+ let framework = null;
46
+ const artisanPath = path.join(targetDir, 'artisan');
47
+ const appDir = path.join(targetDir, 'app');
48
+ if (existsSync(artisanPath) && existsSync(appDir)) {
49
+ framework = 'laravel';
50
+ logger.info('Detected Laravel project.');
46
51
  }
47
52
 
48
- return kept;
53
+ const meta = {
54
+ name: path.basename(targetDir),
55
+ primaryLanguage,
56
+ framework,
57
+ targetDir,
58
+ totalFiles: files.length,
59
+ analyzedAt: new Date().toISOString(),
60
+ };
61
+
62
+ return { files, skipped, meta };
49
63
  }
64
+
65
+ module.exports = { crawl };
@@ -1,51 +1,39 @@
1
- /**
2
- * File manifest builder.
3
- * For each discovered file: compute sizeBytes, detect language, compute SHA-256 hash.
4
- */
1
+ 'use strict';
5
2
 
6
- import { readFileSync, statSync } from 'node:fs';
7
- import { join, extname } from 'node:path';
8
- import { createHash } from 'node:crypto';
9
- import { EXTENSION_TO_LANGUAGE } from './filters.js';
3
+ const { createHash } = require('crypto');
4
+ const { statSync, readFileSync } = require('fs');
5
+ const path = require('path');
6
+ const { detectLanguage } = require('./filters');
10
7
 
11
8
  /**
12
- * @typedef {object} FileManifest
13
- * @property {string} relativePath - Relative path from target dir
14
- * @property {string} absolutePath - Absolute path on disk
15
- * @property {string} language - Detected language
16
- * @property {number} sizeBytes - File size in bytes
17
- * @property {string} hash - SHA-256 hash of file content
9
+ * Build a FileManifest[] from a list of absolute paths.
10
+ * @param {string[]} absPaths
11
+ * @param {string} targetDir
12
+ * @returns {import('./index').FileManifest[]}
18
13
  */
14
+ function buildManifest(absPaths, targetDir) {
15
+ return absPaths.map((absPath) => {
16
+ const ext = path.extname(absPath).toLowerCase();
17
+ const language = detectLanguage(ext);
18
+ const stat = statSync(absPath);
19
+ const sizeBytes = stat.size;
19
20
 
20
- /**
21
- * Build manifest entry for a single file.
22
- * @param {string} relativePath - Relative path
23
- * @param {string} targetDir - Base directory
24
- * @returns {FileManifest}
25
- */
26
- export function buildManifestEntry(relativePath, targetDir) {
27
- const absolutePath = join(targetDir, relativePath);
28
- const ext = extname(relativePath).toLowerCase();
29
- const language = EXTENSION_TO_LANGUAGE[ext] || 'unknown';
30
- const stat = statSync(absolutePath);
31
- const content = readFileSync(absolutePath);
32
- const hash = 'sha256:' + createHash('sha256').update(content).digest('hex');
21
+ let hash = null;
22
+ try {
23
+ const content = readFileSync(absPath);
24
+ hash = 'sha256:' + createHash('sha256').update(content).digest('hex');
25
+ } catch (e) {
26
+ // unreadable file
27
+ }
33
28
 
34
- return {
35
- relativePath,
36
- absolutePath,
37
- language,
38
- sizeBytes: stat.size,
39
- hash,
40
- };
29
+ return {
30
+ relativePath: path.relative(targetDir, absPath).replace(/\\/g, '/'),
31
+ absolutePath: absPath,
32
+ language,
33
+ sizeBytes,
34
+ hash,
35
+ };
36
+ });
41
37
  }
42
38
 
43
- /**
44
- * Build manifest entries for all discovered files.
45
- * @param {string[]} relativePaths - List of relative file paths
46
- * @param {string} targetDir - Base directory
47
- * @returns {FileManifest[]}
48
- */
49
- export function buildManifest(relativePaths, targetDir) {
50
- return relativePaths.map((rp) => buildManifestEntry(rp, targetDir));
51
- }
39
+ module.exports = { buildManifest };
@@ -1,57 +1,51 @@
1
- /**
2
- * File walker using fast-glob with ignore package.
3
- * Loads .legacyverignore if present.
4
- * Applies default ignores + custom ignores.
5
- */
1
+ 'use strict';
6
2
 
7
- import fg from 'fast-glob';
8
- import ignore from 'ignore';
9
- import { readFileSync, existsSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { SUPPORTED_EXTENSIONS, DEFAULT_IGNORE_PATTERNS } from './filters.js';
3
+ const fg = require('fast-glob');
4
+ const ignore = require('ignore');
5
+ const { readFileSync, existsSync } = require('fs');
6
+ const path = require('path');
7
+ const { DEFAULT_IGNORE_PATTERNS, ALL_EXTENSIONS } = require('./filters');
12
8
 
13
9
  /**
14
- * Discover all source files in the target directory.
15
- * @param {string} targetDir - Absolute path to scan
16
- * @param {object} options
17
- * @param {string[]} [options.customIgnore] - Additional ignore patterns
18
- * @returns {Promise<string[]>} List of relative file paths
10
+ * Walk a directory and return all matching source file paths.
11
+ * @param {string} targetDir
12
+ * @param {Object} config
13
+ * @returns {string[]} absolute paths
19
14
  */
20
- export async function walkFiles(targetDir, options = {}) {
21
- const { customIgnore = [] } = options;
22
-
23
- // Build glob patterns for supported extensions
24
- const patterns = SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
15
+ async function walk(targetDir, config) {
16
+ // Build ignore manager
17
+ const ig = ignore();
25
18
 
26
19
  // Load .legacyverignore
27
- const ig = ignore();
28
- const legacyverignorePath = join(targetDir, '.legacyverignore');
29
- if (existsSync(legacyverignorePath)) {
30
- const content = readFileSync(legacyverignorePath, 'utf-8');
20
+ const legacyverIgnorePath = path.join(targetDir, '.legacyverignore');
21
+ if (existsSync(legacyverIgnorePath)) {
22
+ const content = readFileSync(legacyverIgnorePath, 'utf8');
31
23
  ig.add(content);
32
24
  }
33
25
 
34
- // Also load .gitignore patterns
35
- const gitignorePath = join(targetDir, '.gitignore');
26
+ // Load .gitignore
27
+ const gitignorePath = path.join(targetDir, '.gitignore');
36
28
  if (existsSync(gitignorePath)) {
37
- const content = readFileSync(gitignorePath, 'utf-8');
29
+ const content = readFileSync(gitignorePath, 'utf8');
38
30
  ig.add(content);
39
31
  }
40
32
 
41
- // Combine all ignore patterns
42
- const allIgnore = [...DEFAULT_IGNORE_PATTERNS, ...customIgnore];
33
+ // Build extension glob patterns
34
+ const extGlobs = ALL_EXTENSIONS.map(ext => `**/*${ext}`);
43
35
 
44
- // Use fast-glob to discover files
45
- const files = await fg(patterns, {
36
+ const rawPaths = await fg(extGlobs, {
46
37
  cwd: targetDir,
47
- ignore: allIgnore,
38
+ absolute: true,
39
+ ignore: DEFAULT_IGNORE_PATTERNS,
48
40
  dot: false,
49
- absolute: false,
50
- onlyFiles: true,
41
+ followSymbolicLinks: false,
51
42
  });
52
43
 
53
- // Apply .legacyverignore / .gitignore filtering
54
- const filtered = ig.filter(files);
55
-
56
- return filtered.sort();
44
+ // Apply .legacyverignore and .gitignore on relative paths
45
+ return rawPaths.filter((absPath) => {
46
+ const rel = path.relative(targetDir, absPath);
47
+ return !ig.ignores(rel);
48
+ });
57
49
  }
50
+
51
+ module.exports = { walk };
@@ -1,77 +1,55 @@
1
- /**
2
- * LLM request chunker.
3
- * Builds user messages from FileFacts + raw source.
4
- * Checks token count vs provider limit.
5
- * Truncates raw source if over limit (never truncates FileFacts).
6
- */
1
+ 'use strict';
7
2
 
8
- import { readFileSync } from 'node:fs';
9
- import { join } from 'node:path';
10
- import { buildUserMessage, SYSTEM_PROMPT } from './prompts.js';
11
- import { countTokens } from './cost-estimator.js';
3
+ const { readFileSync } = require('fs');
4
+ const { SYSTEM_PROMPT, buildUserMessage } = require('./prompts');
5
+ const { countTokens } = require('./cost-estimator');
6
+ const logger = require('../utils/logger');
12
7
 
13
- /**
14
- * Provider token limits (input context window).
15
- */
16
- const TOKEN_LIMITS = {
17
- anthropic: 200000,
18
- openai: 128000,
19
- google: 1000000,
20
- groq: 128000,
21
- ollama: 8000,
22
- };
8
+ const DEFAULT_MAX_TOKENS = 120000;
23
9
 
24
10
  /**
25
- * Build LLM request chunks for all files in the PKG.
26
- * @param {import('../parser/index.js').FileFacts[]} allFacts
27
- * @param {object} config - App configuration
28
- * @param {string} targetDir - Base directory for reading source files
29
- * @returns {Array<{relativePath: string, userMessage: string, systemPrompt: string}>}
11
+ * Build LLM request chunks from the PKG.
12
+ * @param {Object} pkg
13
+ * @param {Object} config
14
+ * @returns {Array<{relativePath, systemPrompt, userMessage, tokenCount}>}
30
15
  */
31
- export function buildChunks(allFacts, config, targetDir) {
32
- const maxTokens = TOKEN_LIMITS[config.provider] || TOKEN_LIMITS.anthropic;
33
- // Reserve tokens for system prompt and output
34
- const systemTokens = countTokens(SYSTEM_PROMPT);
35
- const reservedForOutput = Math.min(4000, maxTokens * 0.1);
36
- const availableTokens = maxTokens - systemTokens - reservedForOutput;
37
-
16
+ function buildChunks(pkg, config) {
38
17
  const chunks = [];
18
+ const maxTokens = DEFAULT_MAX_TOKENS;
39
19
 
40
- for (const facts of allFacts) {
41
- let rawSource;
20
+ for (const [relativePath, fileFacts] of Object.entries(pkg.files || {})) {
21
+ let rawSource = '';
42
22
  try {
43
- rawSource = readFileSync(join(targetDir, facts.relativePath), 'utf-8');
44
- } catch {
45
- rawSource = '// Source file not readable';
23
+ rawSource = readFileSync(fileFacts.relativePath && fileFacts.absolutePath ? fileFacts.absolutePath : (pkg.meta.targetDir + '/' + relativePath), 'utf8');
24
+ } catch (e) {
25
+ // file might not be available; use empty source
26
+ rawSource = '';
46
27
  }
47
28
 
48
- // Build the full user message
49
- let userMessage = buildUserMessage(facts, rawSource);
50
- let messageTokens = countTokens(userMessage);
29
+ let userMessage = buildUserMessage(fileFacts, rawSource);
30
+ const combined = SYSTEM_PROMPT + '\n' + userMessage;
31
+ let tokenCount = countTokens(combined);
51
32
 
52
33
  // If over limit, truncate raw source (never truncate FileFacts)
53
- if (messageTokens > availableTokens) {
54
- const factsJson = JSON.stringify(facts, null, 2);
55
- const factsTokens = countTokens(factsJson);
56
- const remainingTokens = availableTokens - factsTokens - 200; // 200 token buffer for template text
57
-
58
- if (remainingTokens > 0) {
59
- // Truncate source to fit
60
- const maxChars = remainingTokens * 4; // inverse of our token estimation
61
- const truncatedSource = rawSource.substring(0, maxChars) + '\n\n[... source truncated due to token limit ...]';
62
- userMessage = buildUserMessage(facts, truncatedSource);
63
- } else {
64
- // FileFacts alone exceeds limit — skip source entirely
65
- userMessage = buildUserMessage(facts, '[Source omitted — file too large for context window]');
66
- }
34
+ if (tokenCount > maxTokens) {
35
+ const factsPart = buildUserMessage(fileFacts, '');
36
+ const factsTokens = countTokens(SYSTEM_PROMPT + '\n' + factsPart);
37
+ const budgetChars = Math.max(0, (maxTokens - factsTokens) * 4 - 200);
38
+ const truncatedSource = rawSource.slice(0, budgetChars) + '\n...[truncated]';
39
+ userMessage = buildUserMessage(fileFacts, truncatedSource);
40
+ tokenCount = countTokens(SYSTEM_PROMPT + '\n' + userMessage);
41
+ logger.warn(`Truncated source for ${relativePath} to fit context window.`);
67
42
  }
68
43
 
69
44
  chunks.push({
70
- relativePath: facts.relativePath,
71
- userMessage,
45
+ relativePath,
72
46
  systemPrompt: SYSTEM_PROMPT,
47
+ userMessage,
48
+ tokenCount,
73
49
  });
74
50
  }
75
51
 
76
52
  return chunks;
77
53
  }
54
+
55
+ module.exports = { buildChunks };
@@ -1,67 +1,84 @@
1
- /**
2
- * Cost estimator using tiktoken for token counting.
3
- * Calculates total cost per provider based on per-token pricing.
4
- */
1
+ 'use strict';
5
2
 
6
- import { PROVIDERS } from '../cli/commands/providers.js';
3
+ const { readFileSync } = require('fs');
4
+ const path = require('path');
5
+ const logger = require('../utils/logger');
7
6
 
8
- /**
9
- * Pricing table per provider (per token, not per 1k).
10
- */
11
- const PRICING = {};
12
- for (const p of PROVIDERS) {
13
- PRICING[p.name] = {
14
- inputPerToken: p.costPer1kInput / 1000,
15
- outputPerToken: p.costPer1kOutput / 1000,
16
- };
7
+ // Simple token estimator (tiktoken may have WASM issues in some envs)
8
+ let encoder = null;
9
+ function countTokens(text) {
10
+ try {
11
+ if (!encoder) {
12
+ const { get_encoding } = require('tiktoken');
13
+ encoder = get_encoding('cl100k_base');
14
+ }
15
+ return encoder.encode(text).length;
16
+ } catch (e) {
17
+ // Fallback: rough approximation (1 token ≈ 4 chars)
18
+ return Math.ceil(text.length / 4);
19
+ }
17
20
  }
18
21
 
19
- /**
20
- * Estimate token count for a string.
21
- * Uses a simple heuristic (~4 chars per token for English code).
22
- * For more accuracy, tiktoken can be used but is heavy to initialize.
23
- * @param {string} text
24
- * @returns {number} Estimated token count
25
- */
26
- export function countTokens(text) {
27
- // Rough estimate: ~4 chars per token for code
28
- return Math.ceil(text.length / 4);
22
+ // Cached model pricing (populated lazily)
23
+ let modelPricingCache = null;
24
+ let modelPricingCachedAt = 0;
25
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
26
+
27
+ const FALLBACK_PRICING = {
28
+ 'meta-llama/llama-3.3-70b-instruct:free': { input: 0, output: 0 },
29
+ 'anthropic/claude-haiku-3-5': { input: 0.8 / 1e6, output: 4.0 / 1e6 },
30
+ 'anthropic/claude-sonnet-4-5': { input: 3.0 / 1e6, output: 15.0 / 1e6 },
31
+ 'openai/gpt-4o-mini': { input: 0.15 / 1e6, output: 0.6 / 1e6 },
32
+ 'openai/gpt-4o': { input: 5.0 / 1e6, output: 15.0 / 1e6 },
33
+ 'google/gemini-flash-1.5': { input: 0.075 / 1e6, output: 0.3 / 1e6 },
34
+ };
35
+
36
+ async function fetchModelPricing() {
37
+ if (modelPricingCache && Date.now() - modelPricingCachedAt < CACHE_TTL_MS) {
38
+ return modelPricingCache;
39
+ }
40
+ try {
41
+ const resp = await fetch('https://openrouter.ai/api/v1/models');
42
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
43
+ const data = await resp.json();
44
+ const pricing = {};
45
+ for (const m of (data.data || [])) {
46
+ pricing[m.id] = {
47
+ input: parseFloat(m.pricing && m.pricing.prompt) || 0,
48
+ output: parseFloat(m.pricing && m.pricing.completion) || 0,
49
+ };
50
+ }
51
+ modelPricingCache = pricing;
52
+ modelPricingCachedAt = Date.now();
53
+ return pricing;
54
+ } catch (e) {
55
+ logger.warn(`Could not fetch model pricing from OpenRouter: ${e.message}. Using fallback.`);
56
+ return FALLBACK_PRICING;
57
+ }
29
58
  }
30
59
 
31
60
  /**
32
- * Estimate cost for a set of LLM requests.
33
- * @param {Array<{userMessage: string, relativePath: string}>} chunks - LLM request chunks
34
- * @param {string} providerName - Provider name
35
- * @returns {object} Cost estimate
61
+ * Estimate cost for a list of LLM request chunks.
62
+ * @param {Array} chunks
63
+ * @param {Object} config
64
+ * @returns {Promise<{totalInputTokens, totalOutputTokens, estimatedCostUSD, modelId}>}
36
65
  */
37
- export function estimateCost(chunks, providerName) {
38
- const pricing = PRICING[providerName] || PRICING.anthropic;
66
+ async function estimateCost(chunks, config) {
67
+ const modelId = config.model || 'meta-llama/llama-3.3-70b-instruct:free';
68
+ const pricing = await fetchModelPricing();
69
+ const modelPricing = pricing[modelId] || { input: 0, output: 0 };
39
70
 
40
71
  let totalInputTokens = 0;
41
- const breakdown = [];
42
-
43
72
  for (const chunk of chunks) {
44
- const inputTokens = countTokens(chunk.userMessage);
45
- totalInputTokens += inputTokens;
46
-
47
- breakdown.push({
48
- file: chunk.relativePath,
49
- inputTokens,
50
- });
73
+ totalInputTokens += countTokens(chunk.systemPrompt + '\n' + chunk.userMessage);
51
74
  }
52
-
53
- // Estimate output tokens as ~40% of input (documentation is shorter than code + facts)
54
- const totalOutputTokens = Math.ceil(totalInputTokens * 0.4);
75
+ const avgOutputTokens = 400;
76
+ const totalOutputTokens = chunks.length * avgOutputTokens;
55
77
 
56
78
  const estimatedCostUSD =
57
- totalInputTokens * pricing.inputPerToken +
58
- totalOutputTokens * pricing.outputPerToken;
79
+ totalInputTokens * modelPricing.input + totalOutputTokens * modelPricing.output;
59
80
 
60
- return {
61
- provider: providerName,
62
- totalInputTokens,
63
- totalOutputTokens,
64
- estimatedCostUSD,
65
- breakdown,
66
- };
81
+ return { totalInputTokens, totalOutputTokens, estimatedCostUSD, modelId };
67
82
  }
83
+
84
+ module.exports = { estimateCost, countTokens };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const logger = require('../utils/logger');
4
+ const pc = require('picocolors');
5
+
6
+ /**
7
+ * Apply free model policies when model ID ends with `:free`.
8
+ * @param {Object} config
9
+ * @returns {Object} mutated config
10
+ */
11
+ function applyFreeModelPolicy(config) {
12
+ const provider = (config.provider || 'openrouter').toLowerCase();
13
+
14
+ // Ollama, Groq, and Gemini are always free — skip openrouter-specific logic
15
+ if (provider === 'ollama' || provider === 'groq' || provider === 'gemini') {
16
+ config.isFreeModel = true;
17
+ config.skipCostEstimation = true;
18
+ if (provider === 'groq') {
19
+ console.log(pc.cyan('[info] Using Groq — free tier. Rate limit: 30 req/min, 14,400 req/day'));
20
+ // Cap concurrency to 1 for Groq to avoid rate limits (30 req/min)
21
+ const requested = parseInt(config.concurrency) || 1;
22
+ if (requested > 1) {
23
+ logger.warn(`Groq free tier: capping concurrency from ${requested} to 1.`);
24
+ config.concurrency = 1;
25
+ }
26
+ }
27
+ if (provider === 'gemini') {
28
+ console.log(pc.cyan('[info] Using Gemini — free tier. Rate limit: 15 req/min, 1,500 req/day'));
29
+ // Cap concurrency to 2 for Gemini (15 req/min is more generous)
30
+ const requested = parseInt(config.concurrency) || 1;
31
+ if (requested > 2) {
32
+ logger.warn(`Gemini free tier: capping concurrency from ${requested} to 2.`);
33
+ config.concurrency = 2;
34
+ }
35
+ }
36
+ return config;
37
+ }
38
+
39
+ const model = config.model || 'meta-llama/llama-3.3-70b-instruct:free';
40
+ if (!model.endsWith(':free')) {
41
+ config.isFreeModel = false;
42
+ return config;
43
+ }
44
+
45
+ config.isFreeModel = true;
46
+
47
+ // Display notice
48
+ console.log(pc.cyan('[info] Using free model — no cost estimate available. Rate limit: 200 req/day'));
49
+
50
+ // Cap concurrency
51
+ const requestedConcurrency = parseInt(config.concurrency) || 1;
52
+ if (requestedConcurrency > 2) {
53
+ logger.warn(`Free model: capping concurrency from ${requestedConcurrency} to 2.`);
54
+ config.concurrency = 2;
55
+ } else if (requestedConcurrency > 1) {
56
+ config.concurrency = requestedConcurrency;
57
+ } else {
58
+ config.concurrency = 1;
59
+ }
60
+
61
+ // Skip cost estimation
62
+ config.skipCostEstimation = true;
63
+
64
+ return config;
65
+ }
66
+
67
+ module.exports = { applyFreeModelPolicy };