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.
- package/.agent/skills/openspec-apply-change/SKILL.md +156 -0
- package/.agent/skills/openspec-archive-change/SKILL.md +114 -0
- package/.agent/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/.agent/skills/openspec-continue-change/SKILL.md +118 -0
- package/.agent/skills/openspec-explore/SKILL.md +290 -0
- package/.agent/skills/openspec-ff-change/SKILL.md +101 -0
- package/.agent/skills/openspec-new-change/SKILL.md +74 -0
- package/.agent/skills/openspec-onboard/SKILL.md +529 -0
- package/.agent/skills/openspec-sync-specs/SKILL.md +138 -0
- package/.agent/skills/openspec-verify-change/SKILL.md +168 -0
- package/.agent/workflows/opsx-apply.md +149 -0
- package/.agent/workflows/opsx-archive.md +154 -0
- package/.agent/workflows/opsx-bulk-archive.md +239 -0
- package/.agent/workflows/opsx-continue.md +111 -0
- package/.agent/workflows/opsx-explore.md +171 -0
- package/.agent/workflows/opsx-ff.md +91 -0
- package/.agent/workflows/opsx-new.md +66 -0
- package/.agent/workflows/opsx-onboard.md +522 -0
- package/.agent/workflows/opsx-sync.md +131 -0
- package/.agent/workflows/opsx-verify.md +161 -0
- package/.github/prompts/opsx-apply.prompt.md +149 -0
- package/.github/prompts/opsx-archive.prompt.md +154 -0
- package/.github/prompts/opsx-bulk-archive.prompt.md +239 -0
- package/.github/prompts/opsx-continue.prompt.md +111 -0
- package/.github/prompts/opsx-explore.prompt.md +171 -0
- package/.github/prompts/opsx-ff.prompt.md +91 -0
- package/.github/prompts/opsx-new.prompt.md +66 -0
- package/.github/prompts/opsx-onboard.prompt.md +522 -0
- package/.github/prompts/opsx-sync.prompt.md +131 -0
- package/.github/prompts/opsx-verify.prompt.md +161 -0
- package/.github/skills/openspec-apply-change/SKILL.md +156 -0
- package/.github/skills/openspec-archive-change/SKILL.md +114 -0
- package/.github/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/.github/skills/openspec-continue-change/SKILL.md +118 -0
- package/.github/skills/openspec-explore/SKILL.md +290 -0
- package/.github/skills/openspec-ff-change/SKILL.md +101 -0
- package/.github/skills/openspec-new-change/SKILL.md +74 -0
- package/.github/skills/openspec-onboard/SKILL.md +529 -0
- package/.github/skills/openspec-sync-specs/SKILL.md +138 -0
- package/.github/skills/openspec-verify-change/SKILL.md +168 -0
- package/.legacyverignore.example +43 -0
- package/.legacyverrc +7 -0
- package/.opencode/command/opsx-apply.md +149 -0
- package/.opencode/command/opsx-archive.md +154 -0
- package/.opencode/command/opsx-bulk-archive.md +239 -0
- package/.opencode/command/opsx-continue.md +111 -0
- package/.opencode/command/opsx-explore.md +171 -0
- package/.opencode/command/opsx-ff.md +91 -0
- package/.opencode/command/opsx-new.md +66 -0
- package/.opencode/command/opsx-onboard.md +522 -0
- package/.opencode/command/opsx-sync.md +131 -0
- package/.opencode/command/opsx-verify.md +161 -0
- package/.opencode/skills/openspec-apply-change/SKILL.md +156 -0
- package/.opencode/skills/openspec-archive-change/SKILL.md +114 -0
- package/.opencode/skills/openspec-bulk-archive-change/SKILL.md +246 -0
- package/.opencode/skills/openspec-continue-change/SKILL.md +118 -0
- package/.opencode/skills/openspec-explore/SKILL.md +290 -0
- package/.opencode/skills/openspec-ff-change/SKILL.md +101 -0
- package/.opencode/skills/openspec-new-change/SKILL.md +74 -0
- package/.opencode/skills/openspec-onboard/SKILL.md +529 -0
- package/.opencode/skills/openspec-sync-specs/SKILL.md +138 -0
- package/.opencode/skills/openspec-verify-change/SKILL.md +168 -0
- package/LICENSE +1 -1
- package/README.md +128 -83
- package/bin/legacyver.js +48 -25
- package/legacyver-docs/SUMMARY.md +3 -0
- package/legacyver-docs/components.md +57 -0
- package/legacyver-docs/index.md +15 -0
- package/nul +2 -0
- package/package.json +23 -25
- package/src/cache/hash.js +9 -10
- package/src/cache/index.js +43 -65
- package/src/cli/commands/analyze.js +212 -190
- package/src/cli/commands/cache.js +15 -35
- package/src/cli/commands/init.js +63 -107
- package/src/cli/commands/providers.js +56 -81
- package/src/cli/commands/version.js +7 -10
- package/src/cli/ui.js +58 -77
- package/src/crawler/filters.js +41 -40
- package/src/crawler/index.js +52 -36
- package/src/crawler/manifest.js +31 -43
- package/src/crawler/walk.js +32 -38
- package/src/llm/chunker.js +34 -56
- package/src/llm/cost-estimator.js +68 -51
- package/src/llm/free-model.js +67 -0
- package/src/llm/index.js +22 -43
- package/src/llm/prompts.js +45 -33
- package/src/llm/providers/gemini.js +94 -0
- package/src/llm/providers/groq.js +55 -40
- package/src/llm/providers/ollama.js +38 -65
- package/src/llm/providers/openrouter.js +67 -0
- package/src/llm/queue.js +59 -88
- package/src/llm/re-prompter.js +41 -0
- package/src/llm/validator.js +72 -0
- package/src/parser/ast/generic.js +45 -222
- package/src/parser/ast/go.js +86 -205
- package/src/parser/ast/java.js +76 -146
- package/src/parser/ast/javascript.js +173 -241
- package/src/parser/ast/laravel/blade.js +56 -0
- package/src/parser/ast/laravel/classifier.js +30 -0
- package/src/parser/ast/laravel/controller.js +35 -0
- package/src/parser/ast/laravel/index.js +54 -0
- package/src/parser/ast/laravel/model.js +41 -0
- package/src/parser/ast/laravel/provider.js +28 -0
- package/src/parser/ast/laravel/routes.js +45 -0
- package/src/parser/ast/php.js +129 -0
- package/src/parser/ast/python.js +76 -199
- package/src/parser/ast/typescript.js +10 -244
- package/src/parser/body-extractor.js +40 -0
- package/src/parser/call-graph.js +50 -67
- package/src/parser/complexity-scorer.js +59 -0
- package/src/parser/index.js +61 -86
- package/src/parser/pattern-detector.js +71 -0
- package/src/parser/pkg-builder.js +36 -83
- package/src/renderer/html.js +63 -135
- package/src/renderer/index.js +23 -35
- package/src/renderer/json.js +17 -35
- package/src/renderer/markdown.js +83 -117
- package/src/utils/config.js +52 -53
- package/src/utils/errors.js +26 -41
- package/src/utils/logger.js +32 -53
- package/src/cli/flags.js +0 -87
- package/src/llm/providers/anthropic.js +0 -57
- package/src/llm/providers/google.js +0 -65
- package/src/llm/providers/openai.js +0 -52
- package/src/parser/ast/tree-sitter-init.js +0 -80
package/src/crawler/index.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
13
|
-
* @
|
|
14
|
-
* @
|
|
15
|
-
* @
|
|
16
|
-
* @
|
|
17
|
-
* @
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
+
const absPaths = await walk(targetDir, config);
|
|
29
|
+
let allFiles = buildManifest(absPaths, targetDir);
|
|
28
30
|
|
|
29
|
-
//
|
|
30
|
-
const kept = [];
|
|
31
|
+
// Size filter
|
|
31
32
|
const skipped = [];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
skipped.push(
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 };
|
package/src/crawler/manifest.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
13
|
-
* @
|
|
14
|
-
* @
|
|
15
|
-
* @
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 };
|
package/src/crawler/walk.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
15
|
-
* @param {string} targetDir
|
|
16
|
-
* @param {
|
|
17
|
-
* @
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
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, '
|
|
29
|
+
const content = readFileSync(gitignorePath, 'utf8');
|
|
38
30
|
ig.add(content);
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
//
|
|
42
|
-
const
|
|
33
|
+
// Build extension glob patterns
|
|
34
|
+
const extGlobs = ALL_EXTENSIONS.map(ext => `**/*${ext}`);
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
const files = await fg(patterns, {
|
|
36
|
+
const rawPaths = await fg(extGlobs, {
|
|
46
37
|
cwd: targetDir,
|
|
47
|
-
|
|
38
|
+
absolute: true,
|
|
39
|
+
ignore: DEFAULT_IGNORE_PATTERNS,
|
|
48
40
|
dot: false,
|
|
49
|
-
|
|
50
|
-
onlyFiles: true,
|
|
41
|
+
followSymbolicLinks: false,
|
|
51
42
|
});
|
|
52
43
|
|
|
53
|
-
// Apply .legacyverignore
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 };
|
package/src/llm/chunker.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
26
|
-
* @param {
|
|
27
|
-
* @param {
|
|
28
|
-
* @
|
|
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
|
-
|
|
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
|
|
41
|
-
let rawSource;
|
|
20
|
+
for (const [relativePath, fileFacts] of Object.entries(pkg.files || {})) {
|
|
21
|
+
let rawSource = '';
|
|
42
22
|
try {
|
|
43
|
-
rawSource = readFileSync(
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
let
|
|
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 (
|
|
54
|
-
const
|
|
55
|
-
const factsTokens = countTokens(
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
3
|
+
const { readFileSync } = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
* @param {Array
|
|
34
|
-
* @param {
|
|
35
|
-
* @returns {
|
|
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
|
-
|
|
38
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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 };
|