contextforge-cli-ai-prompt-pirates 0.4.0

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 (72) hide show
  1. package/.contextforge/config.ai.example.json +28 -0
  2. package/.contextforge/config.example.json +29 -0
  3. package/.contextforge/prompts/README.md +50 -0
  4. package/.env.example +73 -0
  5. package/LICENSE +21 -0
  6. package/README.md +223 -0
  7. package/bin/contextforge.js +14 -0
  8. package/bin/postinstall-worker.js +14 -0
  9. package/bin/postinstall.js +26 -0
  10. package/package.json +65 -0
  11. package/prompts/README.md +50 -0
  12. package/prompts/response-schema.md +22 -0
  13. package/prompts/retry-addon.md +1 -0
  14. package/prompts/system.md +10 -0
  15. package/prompts/user-template.md +22 -0
  16. package/src/analyzers/ast-parser.js +139 -0
  17. package/src/analyzers/bugs-lite.js +118 -0
  18. package/src/analyzers/changelog.js +190 -0
  19. package/src/analyzers/code-insights.js +225 -0
  20. package/src/analyzers/dependencies.js +110 -0
  21. package/src/analyzers/detection-quality.js +106 -0
  22. package/src/analyzers/eslint-runner.js +56 -0
  23. package/src/analyzers/folder-tree.js +60 -0
  24. package/src/analyzers/git-insights.js +94 -0
  25. package/src/analyzers/project-meta.js +98 -0
  26. package/src/cli/commands/changes.js +36 -0
  27. package/src/cli/commands/doctor.js +152 -0
  28. package/src/cli/commands/generate.js +7 -0
  29. package/src/cli/commands/init.js +98 -0
  30. package/src/cli/commands/prompt.js +53 -0
  31. package/src/cli/commands/stop.js +10 -0
  32. package/src/cli/commands/summary.js +22 -0
  33. package/src/cli/commands/watch.js +9 -0
  34. package/src/cli/index.js +120 -0
  35. package/src/core/confidence.js +51 -0
  36. package/src/core/config.js +183 -0
  37. package/src/core/cursor-rules.js +293 -0
  38. package/src/core/ensure-setup.js +45 -0
  39. package/src/core/env.js +113 -0
  40. package/src/core/package-meta.js +35 -0
  41. package/src/core/paths.js +39 -0
  42. package/src/core/pipeline.js +256 -0
  43. package/src/core/postinstall.js +168 -0
  44. package/src/core/setup-env.js +86 -0
  45. package/src/core/setup-prompts.js +31 -0
  46. package/src/core/snapshot-hash.js +70 -0
  47. package/src/detectors/architecture.js +117 -0
  48. package/src/detectors/auth-deploy.js +51 -0
  49. package/src/detectors/database.js +127 -0
  50. package/src/detectors/tech-stack.js +180 -0
  51. package/src/generators/artifacts.js +44 -0
  52. package/src/generators/changes-markdown.js +72 -0
  53. package/src/generators/context-json.js +91 -0
  54. package/src/generators/context-markdown.js +571 -0
  55. package/src/generators/mermaid.js +59 -0
  56. package/src/index.js +13 -0
  57. package/src/scanner/change-detector.js +24 -0
  58. package/src/scanner/config-files.js +52 -0
  59. package/src/scanner/file-index.js +55 -0
  60. package/src/scanner/package-deps.js +32 -0
  61. package/src/scanner/repo-scanner.js +143 -0
  62. package/src/services/ai/background.js +87 -0
  63. package/src/services/ai/config.js +48 -0
  64. package/src/services/ai/enrich.js +87 -0
  65. package/src/services/ai/index.js +3 -0
  66. package/src/services/ai/prompt-loader.js +104 -0
  67. package/src/services/ai/prompt.js +42 -0
  68. package/src/services/ai/providers/groq.js +36 -0
  69. package/src/services/ai/providers/openai.js +32 -0
  70. package/src/services/ai/redact.js +33 -0
  71. package/src/services/ai/validate.js +70 -0
  72. package/src/watcher/watcher.js +128 -0
@@ -0,0 +1,52 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+
4
+ const CONFIG_PATTERNS = [
5
+ { key: 'packageJson', match: (p) => p === 'package.json' },
6
+ { key: 'tsconfig', match: (p) => /^tsconfig(\..*)?\.json$/i.test(path.basename(p)) },
7
+ { key: 'vite', match: (p) => /vite\.config\.(js|ts|mjs|cjs)$/i.test(p) },
8
+ { key: 'next', match: (p) => /next\.config\.(js|ts|mjs|cjs)$/i.test(p) },
9
+ { key: 'dockerfile', match: (p) => /dockerfile$/i.test(p) || p.toLowerCase() === 'dockerfile' },
10
+ { key: 'dockerCompose', match: (p) => /docker-compose\.(yml|yaml)$/i.test(p) },
11
+ { key: 'tailwind', match: (p) => /tailwind\.config\.(js|ts|cjs|mjs)$/i.test(p) },
12
+ { key: 'prisma', match: (p) => p.replace(/\\/g, '/').endsWith('schema.prisma') },
13
+ { key: 'webpack', match: (p) => /webpack\.config\.(js|ts)$/i.test(p) },
14
+ { key: 'nginx', match: (p) => /nginx\.conf$/i.test(p) },
15
+ { key: 'envExample', match: (p) => /^\.env\.example$/i.test(path.basename(p)) },
16
+ { key: 'eslint', match: (p) => /^\.eslintrc/i.test(path.basename(p)) || p.endsWith('eslint.config.js') },
17
+ { key: 'prettier', match: (p) => /^\.prettierrc/i.test(path.basename(p)) || p === 'prettier.config.js' },
18
+ ];
19
+
20
+ /**
21
+ * @param {string} projectRoot
22
+ * @param {string[]} relativePaths
23
+ */
24
+ export async function findConfigFiles(projectRoot, relativePaths) {
25
+ const found = {
26
+ paths: [],
27
+ packageJson: null,
28
+ parsed: {},
29
+ };
30
+
31
+ for (const rel of relativePaths) {
32
+ for (const { key, match } of CONFIG_PATTERNS) {
33
+ if (match(rel)) {
34
+ found.paths.push(rel);
35
+ if (!found.parsed[key]) {
36
+ found.parsed[key] = rel;
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ const pkgPath = path.join(projectRoot, 'package.json');
43
+ if (await fs.pathExists(pkgPath)) {
44
+ try {
45
+ found.packageJson = await fs.readJson(pkgPath);
46
+ } catch {
47
+ found.packageJson = null;
48
+ }
49
+ }
50
+
51
+ return found;
52
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'fs-extra';
2
+ import crypto from 'node:crypto';
3
+ import { getFileIndexPath } from '../core/paths.js';
4
+
5
+ export async function loadFileIndex(projectRoot) {
6
+ const indexPath = getFileIndexPath(projectRoot);
7
+ if (await fs.pathExists(indexPath)) {
8
+ return fs.readJson(indexPath);
9
+ }
10
+ return { version: 1, files: {} };
11
+ }
12
+
13
+ export async function saveFileIndex(projectRoot, index) {
14
+ await fs.writeJson(getFileIndexPath(projectRoot), index, { spaces: 2 });
15
+ }
16
+
17
+ export function hashContent(content) {
18
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
19
+ }
20
+
21
+ /**
22
+ * @param {{ files: Record<string, { mtime: number, size: number, hash: string }> }} previous
23
+ * @param {Array<{ relativePath: string, mtime: number, size: number, hash: string }>} scannedFiles
24
+ */
25
+ export function updateFileIndex(previous, scannedFiles) {
26
+ const files = { ...previous.files };
27
+
28
+ for (const file of scannedFiles) {
29
+ files[file.relativePath] = {
30
+ mtime: file.mtime,
31
+ size: file.size,
32
+ hash: file.hash,
33
+ };
34
+ }
35
+
36
+ const scannedSet = new Set(scannedFiles.map((f) => f.relativePath));
37
+ for (const key of Object.keys(files)) {
38
+ if (!scannedSet.has(key)) {
39
+ delete files[key];
40
+ }
41
+ }
42
+
43
+ return { version: 1, updatedAt: new Date().toISOString(), files };
44
+ }
45
+
46
+ export function getChangedFiles(previous, current) {
47
+ const changed = [];
48
+ for (const file of current) {
49
+ const prev = previous.files?.[file.relativePath];
50
+ if (!prev || prev.hash !== file.hash) {
51
+ changed.push(file.relativePath);
52
+ }
53
+ }
54
+ return changed;
55
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Merge dependencies from root + nested package.json (monorepos / client+server).
6
+ * @param {string} projectRoot
7
+ * @param {string[]} relativePaths
8
+ * @param {object | null} rootPackageJson
9
+ */
10
+ export async function mergePackageDependencies(projectRoot, relativePaths, rootPackageJson) {
11
+ const merged = {
12
+ ...(rootPackageJson?.dependencies || {}),
13
+ ...(rootPackageJson?.devDependencies || {}),
14
+ ...(rootPackageJson?.peerDependencies || {}),
15
+ };
16
+
17
+ const pkgPaths = relativePaths.filter((p) => {
18
+ const n = p.replace(/\\/g, '/');
19
+ return n === 'package.json' || /\/package\.json$/.test(n);
20
+ });
21
+
22
+ for (const rel of pkgPaths) {
23
+ try {
24
+ const pkg = await fs.readJson(path.join(projectRoot, rel));
25
+ Object.assign(merged, pkg.dependencies || {}, pkg.devDependencies || {}, pkg.peerDependencies || {});
26
+ } catch {
27
+ /* skip invalid package.json */
28
+ }
29
+ }
30
+
31
+ return merged;
32
+ }
@@ -0,0 +1,143 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import ignore from 'ignore';
4
+ import { isSensitivePath } from '../core/config.js';
5
+ import { hashContent } from './file-index.js';
6
+
7
+ const SCAN_EXTENSIONS = new Set([
8
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
9
+ '.json', '.md', '.yml', '.yaml', '.sql',
10
+ '.prisma', '.graphql', '.vue', '.svelte',
11
+ '.py', '.go', '.rs', '.java', '.php',
12
+ '.css', '.scss', '.html', '.env.example',
13
+ ]);
14
+
15
+ const ALWAYS_INCLUDE_FILES = new Set([
16
+ 'package.json', 'tsconfig.json', 'dockerfile',
17
+ 'docker-compose.yml', 'readme.md', 'schema.prisma',
18
+ ]);
19
+
20
+ const PROJECT_DIRS = [
21
+ 'src', 'app', 'pages', 'components', 'controllers',
22
+ 'routes', 'services', 'models', 'middleware', 'hooks',
23
+ 'utils', 'config', 'api', 'lib', 'server', 'client',
24
+ 'features', 'modules', 'prisma',
25
+ ];
26
+
27
+ async function walkDir(dir, projectRoot, ig, config, results, depth = 0) {
28
+ if (depth > 12) return;
29
+
30
+ let entries;
31
+ try {
32
+ entries = await fs.readdir(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ const fullPath = path.join(dir, entry.name);
39
+ const relativePath = path.relative(projectRoot, fullPath).replace(/\\/g, '/');
40
+
41
+ if (ig.ignores(relativePath) || ig.ignores(`${relativePath}/`)) {
42
+ continue;
43
+ }
44
+
45
+ if (isSensitivePath(relativePath)) {
46
+ continue;
47
+ }
48
+
49
+ if (entry.isDirectory()) {
50
+ await walkDir(fullPath, projectRoot, ig, config, results, depth + 1);
51
+ continue;
52
+ }
53
+
54
+ if (!entry.isFile()) continue;
55
+
56
+ const ext = path.extname(entry.name).toLowerCase();
57
+ const lowerName = entry.name.toLowerCase();
58
+
59
+ const shouldInclude =
60
+ SCAN_EXTENSIONS.has(ext) ||
61
+ ALWAYS_INCLUDE_FILES.has(lowerName) ||
62
+ lowerName.startsWith('vite.config') ||
63
+ lowerName.startsWith('next.config') ||
64
+ lowerName.startsWith('tailwind.config') ||
65
+ lowerName.startsWith('webpack.config') ||
66
+ lowerName === 'nginx.conf' ||
67
+ lowerName === 'dockerfile';
68
+
69
+ if (!shouldInclude) continue;
70
+
71
+ let stat;
72
+ try {
73
+ stat = await fs.stat(fullPath);
74
+ } catch {
75
+ continue;
76
+ }
77
+
78
+ if (stat.size > config.maxFileSizeBytes) continue;
79
+
80
+ let hash = 'skipped-large';
81
+ if (stat.size < config.maxFileSizeBytes) {
82
+ try {
83
+ const content = await fs.readFile(fullPath, 'utf8');
84
+ hash = hashContent(content);
85
+ } catch {
86
+ hash = 'unreadable';
87
+ }
88
+ }
89
+
90
+ results.files.push({
91
+ relativePath,
92
+ mtime: stat.mtimeMs,
93
+ size: stat.size,
94
+ hash,
95
+ });
96
+ results.relativePaths.push(relativePath);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * @param {string} projectRoot
102
+ * @param {import('../core/config.js').DEFAULT_CONFIG} config
103
+ */
104
+ export async function scanRepository(projectRoot, config) {
105
+ const ig = ignore().add(config.ignore);
106
+
107
+ const results = {
108
+ files: [],
109
+ relativePaths: [],
110
+ projectDirs: [],
111
+ };
112
+
113
+ for (const dir of PROJECT_DIRS) {
114
+ const full = path.join(projectRoot, dir);
115
+ if (await fs.pathExists(full)) {
116
+ const stat = await fs.stat(full);
117
+ if (stat.isDirectory()) {
118
+ results.projectDirs.push(dir);
119
+ }
120
+ }
121
+ }
122
+
123
+ await walkDir(projectRoot, projectRoot, ig, config, results);
124
+
125
+ const rootPackage = path.join(projectRoot, 'package.json');
126
+ if (await fs.pathExists(rootPackage)) {
127
+ const rel = 'package.json';
128
+ if (!results.relativePaths.includes(rel)) {
129
+ const stat = await fs.stat(rootPackage);
130
+ const content = await fs.readFile(rootPackage, 'utf8');
131
+ results.files.push({
132
+ relativePath: rel,
133
+ mtime: stat.mtimeMs,
134
+ size: stat.size,
135
+ hash: hashContent(content),
136
+ });
137
+ results.relativePaths.push(rel);
138
+ }
139
+ }
140
+
141
+ results.relativePaths.sort();
142
+ return results;
143
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { getContextForgeRoot, getCacheDir } from '../../core/paths.js';
4
+ import { enrichWithAI } from './enrich.js';
5
+ import { writeArtifacts } from '../../generators/artifacts.js';
6
+ import { renderContextMarkdown } from '../../generators/context-markdown.js';
7
+ import { saveScanHash } from '../../core/snapshot-hash.js';
8
+ import { installCursorRules } from '../../core/cursor-rules.js';
9
+ import { loadConfig } from '../../core/config.js';
10
+
11
+ function getAiCachePath(projectRoot) {
12
+ return path.join(getCacheDir(projectRoot), 'ai-enrichment.json');
13
+ }
14
+
15
+ function getAiStatusPath(projectRoot) {
16
+ return path.join(getCacheDir(projectRoot), 'ai-status.json');
17
+ }
18
+
19
+ /** Prevent duplicate background jobs */
20
+ let activeJobs = new Set();
21
+
22
+ /**
23
+ * Run AI enrichment in background; updates context.md when complete.
24
+ * @param {string} projectRoot
25
+ * @param {object} snapshot
26
+ * @param {object} config
27
+ */
28
+ export function scheduleBackgroundEnrichment(projectRoot, snapshot, config, scanHash = null) {
29
+ const key = path.resolve(projectRoot);
30
+ if (activeJobs.has(key)) {
31
+ return;
32
+ }
33
+ activeJobs.add(key);
34
+
35
+ setImmediate(async () => {
36
+ try {
37
+ await fs.writeJson(getAiStatusPath(projectRoot), {
38
+ status: 'running',
39
+ startedAt: new Date().toISOString(),
40
+ });
41
+
42
+ console.log('[contextforge] AI enrichment running in background...');
43
+ const enrichment = await enrichWithAI(snapshot, config);
44
+
45
+ snapshot.aiEnrichment = enrichment;
46
+ await fs.writeJson(getAiCachePath(projectRoot), enrichment, { spaces: 2 });
47
+
48
+ await writeArtifacts(projectRoot, snapshot);
49
+ await renderContextMarkdown(projectRoot, snapshot);
50
+
51
+ const cfg = await loadConfig(projectRoot);
52
+ if (cfg.installCursorRules !== false) {
53
+ await installCursorRules(projectRoot, snapshot);
54
+ }
55
+
56
+ if (scanHash) await saveScanHash(projectRoot, scanHash);
57
+
58
+ await fs.writeJson(getAiStatusPath(projectRoot), {
59
+ status: 'completed',
60
+ provider: enrichment.provider,
61
+ model: enrichment.model,
62
+ completedAt: new Date().toISOString(),
63
+ });
64
+
65
+ console.log(
66
+ `[contextforge] AI enrichment done (${enrichment.provider}/${enrichment.model}) — context.md updated`
67
+ );
68
+ } catch (err) {
69
+ await fs.writeJson(getAiStatusPath(projectRoot), {
70
+ status: 'failed',
71
+ error: err.message,
72
+ failedAt: new Date().toISOString(),
73
+ });
74
+ console.error('[contextforge] AI enrichment failed:', err.message);
75
+ } finally {
76
+ activeJobs.delete(key);
77
+ }
78
+ });
79
+ }
80
+
81
+ export async function loadCachedEnrichment(projectRoot) {
82
+ const cachePath = getAiCachePath(projectRoot);
83
+ if (await fs.pathExists(cachePath)) {
84
+ return fs.readJson(cachePath);
85
+ }
86
+ return null;
87
+ }
@@ -0,0 +1,48 @@
1
+ export const DEFAULT_AI_CONFIG = {
2
+ enabled: false,
3
+ /** Primary provider: "openai" | "groq" */
4
+ provider: 'openai',
5
+ /** Try this if primary fails */
6
+ fallbackProvider: 'groq',
7
+ enrichContext: true,
8
+ /** Run AI after scan without blocking CLI (updates context.md when done) */
9
+ background: true,
10
+ openai: {
11
+ model: 'gpt-4o-mini',
12
+ },
13
+ groq: {
14
+ model: 'llama-3.3-70b-versatile',
15
+ },
16
+ maxTokens: 6144,
17
+ timeoutMs: 120000,
18
+ /** Folder for editable prompts (relative to project root) */
19
+ promptsDir: '.contextforge/prompts',
20
+ };
21
+
22
+ import { isAiEnabledViaEnv, getEnvModel } from '../../core/env.js';
23
+
24
+ export function resolveAiConfig(config) {
25
+ const ai = { ...DEFAULT_AI_CONFIG, ...(config.ai || {}) };
26
+ ai.openai = { ...DEFAULT_AI_CONFIG.openai, ...(config.ai?.openai || {}) };
27
+ ai.groq = { ...DEFAULT_AI_CONFIG.groq, ...(config.ai?.groq || {}) };
28
+
29
+ const envProvider = process.env.CONTEXTFORGE_AI_PROVIDER;
30
+ if (envProvider === 'openai' || envProvider === 'groq') {
31
+ ai.provider = envProvider;
32
+ }
33
+
34
+ const envEnabled = isAiEnabledViaEnv();
35
+ if (envEnabled !== null) {
36
+ ai.enabled = envEnabled;
37
+ }
38
+
39
+ ai.openai.model = getEnvModel('openai', ai.openai.model);
40
+ ai.groq.model = getEnvModel('groq', ai.groq.model);
41
+
42
+ return ai;
43
+ }
44
+
45
+ export function getModelForProvider(aiConfig, provider) {
46
+ if (provider === 'groq') return aiConfig.groq.model;
47
+ return aiConfig.openai.model;
48
+ }
@@ -0,0 +1,87 @@
1
+ import { getApiKey } from '../../core/env.js';
2
+ import { resolveAiConfig, getModelForProvider } from './config.js';
3
+ import { buildEnrichmentMessages, parseAiResponse } from './prompt.js';
4
+ import { callOpenAI } from './providers/openai.js';
5
+ import { callGroq } from './providers/groq.js';
6
+ import { validateAiSections } from './validate.js';
7
+
8
+ const PROVIDERS = {
9
+ openai: callOpenAI,
10
+ groq: callGroq,
11
+ };
12
+
13
+ const MAX_RETRIES = 2;
14
+
15
+ /**
16
+ * @param {object} snapshot
17
+ * @param {object} config
18
+ */
19
+ export async function enrichWithAI(snapshot, config) {
20
+ const aiConfig = resolveAiConfig(config);
21
+
22
+ if (!aiConfig.enabled || !aiConfig.enrichContext) {
23
+ return null;
24
+ }
25
+
26
+ const providersToTry = [aiConfig.provider];
27
+ if (aiConfig.fallbackProvider && aiConfig.fallbackProvider !== aiConfig.provider) {
28
+ providersToTry.push(aiConfig.fallbackProvider);
29
+ }
30
+
31
+ let lastError = null;
32
+
33
+ for (const providerName of providersToTry) {
34
+ if (!PROVIDERS[providerName]) continue;
35
+ if (!getApiKey(providerName)) {
36
+ lastError = new Error(`${providerName.toUpperCase()} API key missing`);
37
+ continue;
38
+ }
39
+
40
+ const model = getModelForProvider(aiConfig, providerName);
41
+
42
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
43
+ try {
44
+ const messages = await buildEnrichmentMessages(snapshot, config, attempt > 0);
45
+
46
+ const result = await PROVIDERS[providerName](messages, {
47
+ model,
48
+ maxTokens: aiConfig.maxTokens,
49
+ timeoutMs: aiConfig.timeoutMs,
50
+ });
51
+
52
+ const parsed = parseAiResponse(result.content);
53
+ const validation = validateAiSections(parsed, snapshot);
54
+
55
+ if (!validation.valid && attempt < MAX_RETRIES) {
56
+ lastError = new Error(validation.errors.join('; '));
57
+ continue;
58
+ }
59
+
60
+ if (!validation.valid) {
61
+ throw new Error(`AI validation failed: ${validation.errors.join('; ')}`);
62
+ }
63
+
64
+ return {
65
+ provider: result.provider,
66
+ model: result.model,
67
+ generatedAt: new Date().toISOString(),
68
+ usage: result.usage,
69
+ sections: validation.parsed,
70
+ validated: true,
71
+ attempt: attempt + 1,
72
+ };
73
+ } catch (err) {
74
+ lastError = err;
75
+ if (attempt >= MAX_RETRIES) break;
76
+ }
77
+ }
78
+ }
79
+
80
+ throw lastError || new Error('No AI provider available. Set OPENAI_API_KEY or GROQ_API_KEY in .env');
81
+ }
82
+
83
+ export function isAiAvailable(config) {
84
+ const aiConfig = resolveAiConfig(config);
85
+ if (!aiConfig.enabled) return false;
86
+ return !!(getApiKey('openai') || getApiKey('groq'));
87
+ }
@@ -0,0 +1,3 @@
1
+ export { enrichWithAI, isAiAvailable } from './enrich.js';
2
+ export { scheduleBackgroundEnrichment, loadCachedEnrichment } from './background.js';
3
+ export { resolveAiConfig, DEFAULT_AI_CONFIG } from './config.js';
@@ -0,0 +1,104 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const PACKAGE_PROMPTS = path.join(__dirname, '../../..', 'prompts');
7
+
8
+ const PROMPT_FILES = {
9
+ system: 'system.md',
10
+ userTemplate: 'user-template.md',
11
+ responseSchema: 'response-schema.md',
12
+ retryAddon: 'retry-addon.md',
13
+ };
14
+
15
+ /**
16
+ * Resolve prompts directory: project .contextforge/prompts or config override.
17
+ * @param {string} projectRoot
18
+ * @param {object} aiConfig
19
+ */
20
+ export function resolvePromptsDir(projectRoot, aiConfig) {
21
+ const rel = aiConfig?.promptsDir || '.contextforge/prompts';
22
+ const projectPrompts = path.isAbsolute(rel)
23
+ ? rel
24
+ : path.join(projectRoot, rel);
25
+ return projectPrompts;
26
+ }
27
+
28
+ async function readPromptFile(dir, filename, fallbackDir) {
29
+ const primary = path.join(dir, filename);
30
+ if (await fs.pathExists(primary)) {
31
+ return fs.readFile(primary, 'utf8');
32
+ }
33
+ const fallback = path.join(fallbackDir, filename);
34
+ if (await fs.pathExists(fallback)) {
35
+ return fs.readFile(fallback, 'utf8');
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Load all prompt templates from disk.
42
+ * @param {string} projectRoot
43
+ * @param {object} aiConfig
44
+ */
45
+ export async function loadPrompts(projectRoot, aiConfig) {
46
+ const promptsDir = resolvePromptsDir(projectRoot, aiConfig);
47
+ const fallback = PACKAGE_PROMPTS;
48
+
49
+ const [system, userTemplate, responseSchema, retryAddon] = await Promise.all([
50
+ readPromptFile(promptsDir, PROMPT_FILES.system, fallback),
51
+ readPromptFile(promptsDir, PROMPT_FILES.userTemplate, fallback),
52
+ readPromptFile(promptsDir, PROMPT_FILES.responseSchema, fallback),
53
+ readPromptFile(promptsDir, PROMPT_FILES.retryAddon, fallback),
54
+ ]);
55
+
56
+ if (!system || !userTemplate) {
57
+ throw new Error(
58
+ `Prompt files missing in ${promptsDir}. Run: npx contextforge init`
59
+ );
60
+ }
61
+
62
+ return {
63
+ promptsDir,
64
+ system: system.trim(),
65
+ userTemplate: userTemplate.trim(),
66
+ responseSchema: (responseSchema || '').trim(),
67
+ retryAddon: (retryAddon || '').trim(),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Build user prompt from template + placeholders.
73
+ */
74
+ export function renderUserPrompt(template, { scanData, projectName, retryNote }) {
75
+ return template
76
+ .replace(/\{\{SCAN_DATA\}\}/g, scanData)
77
+ .replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'project')
78
+ .replace(/\{\{RETRY_NOTE\}\}/g, retryNote || '')
79
+ .replace(/\n{3,}/g, '\n\n')
80
+ .trim();
81
+ }
82
+
83
+ /**
84
+ * Full messages array for chat completion.
85
+ */
86
+ export function buildMessages(prompts, { scanData, projectName, isRetry }) {
87
+ const retryNote = isRetry ? prompts.retryAddon : '';
88
+ let userContent = renderUserPrompt(prompts.userTemplate, {
89
+ scanData,
90
+ projectName,
91
+ retryNote,
92
+ });
93
+
94
+ if (prompts.responseSchema) {
95
+ userContent += `\n\n## Required response schema\n\n${prompts.responseSchema}`;
96
+ }
97
+
98
+ return [
99
+ { role: 'system', content: prompts.system },
100
+ { role: 'user', content: userContent },
101
+ ];
102
+ }
103
+
104
+ export { PACKAGE_PROMPTS, PROMPT_FILES };
@@ -0,0 +1,42 @@
1
+ import { sanitizeSnapshotForAi } from './redact.js';
2
+ import { loadPrompts, buildMessages } from './prompt-loader.js';
3
+
4
+ /**
5
+ * Build chat messages from external prompt files.
6
+ * @param {object} snapshot
7
+ * @param {object} config - full project config
8
+ * @param {boolean} isRetry
9
+ */
10
+ export async function buildEnrichmentMessages(snapshot, config, isRetry = false) {
11
+ const aiConfig = config.ai || {};
12
+ const prompts = await loadPrompts(snapshot.projectRoot, aiConfig);
13
+ const scanData = sanitizeSnapshotForAi(snapshot);
14
+
15
+ return buildMessages(prompts, {
16
+ scanData,
17
+ projectName: snapshot.projectName,
18
+ isRetry,
19
+ });
20
+ }
21
+
22
+ /** @deprecated Use buildEnrichmentMessages — kept for tests */
23
+ export function buildEnrichmentPrompt(snapshot, isRetry = false) {
24
+ const scanData = sanitizeSnapshotForAi(snapshot);
25
+ const payload = JSON.parse(scanData);
26
+ Object.assign(payload, {
27
+ confidence: snapshot.confidence,
28
+ changelog: snapshot.changelog?.summary,
29
+ astRoutes: snapshot.ast?.routes?.slice(0, 20),
30
+ });
31
+ const retryNote = isRetry ? '\nRETRY: Output complete valid JSON.\n' : '';
32
+ return `Analyze ${snapshot.projectName}.\n${retryNote}\n${JSON.stringify(payload, null, 2)}`;
33
+ }
34
+
35
+ export function parseAiResponse(text) {
36
+ const trimmed = text.trim();
37
+ const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
38
+ if (!jsonMatch) {
39
+ throw new Error('AI response did not contain JSON');
40
+ }
41
+ return JSON.parse(jsonMatch[0]);
42
+ }
@@ -0,0 +1,36 @@
1
+ import OpenAI from 'openai';
2
+ import { getApiKey } from '../../../core/env.js';
3
+
4
+ const GROQ_BASE_URL = 'https://api.groq.com/openai/v1';
5
+
6
+ /**
7
+ * Groq uses OpenAI-compatible API.
8
+ * @param {{ model: string, maxTokens: number, timeoutMs: number }} options
9
+ */
10
+ export async function callGroq(messages, options) {
11
+ const apiKey = getApiKey('groq');
12
+ if (!apiKey) {
13
+ throw new Error('GROQ_API_KEY is not set in .env');
14
+ }
15
+
16
+ const client = new OpenAI({
17
+ apiKey,
18
+ baseURL: GROQ_BASE_URL,
19
+ timeout: options.timeoutMs,
20
+ });
21
+
22
+ const response = await client.chat.completions.create({
23
+ model: options.model,
24
+ messages,
25
+ max_tokens: options.maxTokens,
26
+ temperature: 0.3,
27
+ response_format: { type: 'json_object' },
28
+ });
29
+
30
+ return {
31
+ content: response.choices[0]?.message?.content || '',
32
+ model: response.model,
33
+ provider: 'groq',
34
+ usage: response.usage,
35
+ };
36
+ }