contextforge-cli-ai-prompt-pirates 0.4.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextforge-cli-ai-prompt-pirates",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Background AI context engine — auto-generates context.md on npm install for Cursor, Claude, and ChatGPT",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -52,10 +52,11 @@
52
52
  "@babel/parser": "^7.26.3",
53
53
  "chokidar": "^4.0.3",
54
54
  "commander": "^13.1.0",
55
+ "contextforge-cli-ai-prompt": "^0.3.0",
56
+ "dotenv": "^16.4.7",
55
57
  "fs-extra": "^11.3.0",
56
58
  "glob": "^11.0.1",
57
59
  "ignore": "^7.0.3",
58
- "dotenv": "^16.4.7",
59
60
  "openai": "^4.77.0",
60
61
  "simple-git": "^3.27.0"
61
62
  },
@@ -3,6 +3,20 @@ import path from 'node:path';
3
3
  import { getOutputPath, OUTPUT_FILES } from './paths.js';
4
4
 
5
5
  const RULE_FILENAME = 'contextforge.mdc';
6
+ /** Max chars of context.md embedded in .mdc (Cursor rule size limits). */
7
+ const MAX_EMBEDDED_CONTEXT_CHARS = 14000;
8
+
9
+ /**
10
+ * Create `.cursor/` and `.cursor/rules/` if they do not exist.
11
+ * @param {string} projectRoot
12
+ * @returns {Promise<{ cursorDir: string, created: boolean }>}
13
+ */
14
+ export async function ensureCursorRulesDir(projectRoot) {
15
+ const cursorDir = path.join(projectRoot, '.cursor', 'rules');
16
+ const existed = await fs.pathExists(cursorDir);
17
+ await fs.ensureDir(cursorDir);
18
+ return { cursorDir, created: !existed };
19
+ }
6
20
 
7
21
  /**
8
22
  * @param {{ description?: string, alwaysApply?: boolean }} meta
@@ -78,7 +92,7 @@ function renderRoutesBrief(endpoints = []) {
78
92
  ...endpoints.slice(0, 20).map((e) => `| ${e.method} | ${e.path} | \`${e.file}\` |`),
79
93
  ];
80
94
  if (endpoints.length > 20) {
81
- lines.push('', `_+ ${endpoints.length - 20} more in \`.contextforge/context.md\`_`);
95
+ lines.push('', `_+ ${endpoints.length - 20} more in full context below._`);
82
96
  }
83
97
  return lines.join('\n');
84
98
  }
@@ -92,11 +106,30 @@ function renderServicesBrief(businessLogic = []) {
92
106
  }
93
107
 
94
108
  /**
95
- * Build a single Cursor rule file from the pipeline snapshot (same source as context.md).
109
+ * @param {string} projectRoot
110
+ * @param {{ maxChars?: number }} [options]
111
+ */
112
+ export async function readContextMarkdownForRule(projectRoot, options = {}) {
113
+ const maxChars = options.maxChars ?? MAX_EMBEDDED_CONTEXT_CHARS;
114
+ const mdPath = getOutputPath(projectRoot, OUTPUT_FILES.contextMd);
115
+ if (!(await fs.pathExists(mdPath))) return null;
116
+
117
+ let md = await fs.readFile(mdPath, 'utf8');
118
+ const truncated = md.length > maxChars;
119
+ if (truncated) {
120
+ md =
121
+ md.slice(0, maxChars) +
122
+ '\n\n---\n\n_…truncated. Full file: `.contextforge/context.md`._';
123
+ }
124
+ return { content: md, truncated, sourcePath: '.contextforge/context.md' };
125
+ }
126
+
127
+ /**
128
+ * Build rule body (markdown inside .mdc).
96
129
  * @param {object} snapshot
97
- * @returns {{ filename: string, content: string }[]}
130
+ * @param {string|null} embeddedContextMd
98
131
  */
99
- export function buildCursorRuleFiles(snapshot) {
132
+ export function buildCursorRuleBody(snapshot, embeddedContextMd = null) {
100
133
  const projectName = snapshot.projectName || 'project';
101
134
  const generatedAt = snapshot.generatedAt || new Date().toISOString();
102
135
  const ts = snapshot.techStack || {};
@@ -107,18 +140,15 @@ export function buildCursorRuleFiles(snapshot) {
107
140
  '',
108
141
  `_Synced from \`.contextforge/context.md\` (${generatedAt})_`,
109
142
  '',
110
- 'Before answering questions about this codebase, **read [`.contextforge/context.md`](.contextforge/context.md)** for full detail.',
111
- '',
112
143
  '## At a Glance',
113
144
  '',
114
145
  renderAtAGlanceTable(snapshot),
115
146
  '',
116
147
  '## Commands',
117
148
  '',
118
- '- Regenerate: `npx contextforge generate`',
119
- '- Watch: `npx contextforge watch`',
120
- '- Changes: `npx contextforge changes` or `.contextforge/CHANGES.md`',
121
- '- Timer: `CONTEXTFORGE_REFRESH_INTERVAL_MINUTES` in `.env` (e.g. 15)'
149
+ '- Regenerate: `npx cfpirates generate` or `npx contextforge-cli-ai-prompt-pirates generate`',
150
+ '- Watch: `npx cfpirates watch`',
151
+ '- Changes: `npx cfpirates changes`'
122
152
  );
123
153
 
124
154
  const importantRules = collectImportantRules(snapshot);
@@ -177,10 +207,33 @@ export function buildCursorRuleFiles(snapshot) {
177
207
  );
178
208
  }
179
209
 
180
- sections.push(
181
- '_More detail in `.contextforge/context.md` (architecture, git, database, full issue list)._'
182
- );
210
+ if (embeddedContextMd) {
211
+ sections.push(
212
+ '',
213
+ '---',
214
+ '',
215
+ '## Full project context (from context.md)',
216
+ '',
217
+ embeddedContextMd
218
+ );
219
+ } else {
220
+ sections.push(
221
+ '',
222
+ '_Full detail: [`.contextforge/context.md`](.contextforge/context.md)._'
223
+ );
224
+ }
225
+
226
+ return sections.join('\n\n');
227
+ }
183
228
 
229
+ /**
230
+ * Build a single Cursor rule file from the pipeline snapshot.
231
+ * @param {object} snapshot
232
+ * @param {string|null} [embeddedContextMd]
233
+ * @returns {{ filename: string, content: string }[]}
234
+ */
235
+ export function buildCursorRuleFiles(snapshot, embeddedContextMd = null) {
236
+ const body = buildCursorRuleBody(snapshot, embeddedContextMd);
184
237
  return [
185
238
  {
186
239
  filename: RULE_FILENAME,
@@ -189,7 +242,7 @@ export function buildCursorRuleFiles(snapshot) {
189
242
  description: 'ContextForge project memory — read before coding',
190
243
  alwaysApply: true,
191
244
  },
192
- sections.join('\n\n')
245
+ body
193
246
  ),
194
247
  },
195
248
  ];
@@ -198,9 +251,10 @@ export function buildCursorRuleFiles(snapshot) {
198
251
  /**
199
252
  * @param {string} projectRoot
200
253
  * @param {object} [snapshot]
254
+ * @param {{ embedContextMd?: boolean }} [options]
201
255
  */
202
- export async function installCursorRules(projectRoot, snapshot = null) {
203
- const cursorDir = path.join(projectRoot, '.cursor', 'rules');
256
+ export async function installCursorRules(projectRoot, snapshot = null, options = {}) {
257
+ const embedContextMd = options.embedContextMd !== false;
204
258
  const agentsPath = path.join(projectRoot, 'AGENTS.md');
205
259
 
206
260
  if (!snapshot) {
@@ -210,9 +264,17 @@ export async function installCursorRules(projectRoot, snapshot = null) {
210
264
  }
211
265
  }
212
266
 
213
- await fs.ensureDir(cursorDir);
267
+ const { cursorDir, created } = await ensureCursorRulesDir(projectRoot);
268
+
269
+ let embeddedMd = null;
270
+ if (embedContextMd && snapshot) {
271
+ const loaded = await readContextMarkdownForRule(projectRoot);
272
+ if (loaded) embeddedMd = loaded.content;
273
+ }
214
274
 
215
- const ruleFiles = snapshot ? buildCursorRuleFiles(snapshot) : [buildFallbackRule()];
275
+ const ruleFiles = snapshot
276
+ ? buildCursorRuleFiles(snapshot, embeddedMd)
277
+ : [buildFallbackRule()];
216
278
 
217
279
  const written = [];
218
280
  for (const { filename, content } of ruleFiles) {
@@ -225,7 +287,7 @@ export async function installCursorRules(projectRoot, snapshot = null) {
225
287
 
226
288
  await syncAgentsMd(agentsPath);
227
289
 
228
- return { rulePaths: written, agentsPath };
290
+ return { rulePaths: written, agentsPath, createdCursorDir: created, cursorDir };
229
291
  }
230
292
 
231
293
  function buildFallbackRule() {
@@ -238,11 +300,10 @@ function buildFallbackRule() {
238
300
  },
239
301
  `# ContextForge — Project Memory
240
302
 
241
- Before answering questions about this codebase, **read \`.contextforge/context.md\`** in full.
303
+ Run \`npx cfpirates generate\` to create \`.contextforge/context.md\`, then re-run to sync this rule.
242
304
 
243
- Regenerate context: \`npx contextforge generate\`
244
- Watch mode: \`npx contextforge watch\`
245
- Change history: \`npx contextforge changes\` or \`.contextforge/CHANGES.md\``
305
+ Regenerate: \`npx cfpirates generate\`
306
+ Watch: \`npx cfpirates watch\``
246
307
  ),
247
308
  };
248
309
  }
@@ -203,10 +203,16 @@ export async function runPipeline(projectRoot, options = {}) {
203
203
  await renderContextMarkdown(projectRootResolved, snapshot);
204
204
 
205
205
  if (config.installCursorRules !== false) {
206
- const { rulePaths } = await installCursorRules(projectRootResolved, snapshot);
206
+ const { rulePaths, createdCursorDir, cursorDir } = await installCursorRules(
207
+ projectRootResolved,
208
+ snapshot
209
+ );
210
+ if (createdCursorDir) {
211
+ log(projectRootResolved, `Created ${cursorDir}/`, verbose);
212
+ }
207
213
  log(
208
214
  projectRootResolved,
209
- `Synced ${rulePaths.length} Cursor rule(s) in .cursor/rules/`,
215
+ `Synced ${rulePaths.length} Cursor rule(s) with embedded context.md`,
210
216
  verbose
211
217
  );
212
218
  }
@@ -12,6 +12,12 @@ const PROVIDERS = {
12
12
 
13
13
  const MAX_RETRIES = 2;
14
14
 
15
+ function isRateLimitError(err) {
16
+ const status = err?.status ?? err?.response?.status;
17
+ const msg = err?.message || String(err);
18
+ return status === 429 || /429|rate limit|tokens per day/i.test(msg);
19
+ }
20
+
15
21
  /**
16
22
  * @param {object} snapshot
17
23
  * @param {object} config
@@ -72,11 +78,20 @@ export async function enrichWithAI(snapshot, config) {
72
78
  };
73
79
  } catch (err) {
74
80
  lastError = err;
81
+ if (isRateLimitError(err)) {
82
+ break;
83
+ }
75
84
  if (attempt >= MAX_RETRIES) break;
76
85
  }
77
86
  }
78
87
  }
79
88
 
89
+ if (lastError && isRateLimitError(lastError)) {
90
+ throw new Error(
91
+ `AI rate limit (${lastError.message}). Wait and retry, or set CONTEXTFORGE_AI_PROVIDER=openai in .env.`
92
+ );
93
+ }
94
+
80
95
  throw lastError || new Error('No AI provider available. Set OPENAI_API_KEY or GROQ_API_KEY in .env');
81
96
  }
82
97
 
@@ -33,7 +33,10 @@ export function buildEnrichmentPrompt(snapshot, isRetry = false) {
33
33
  }
34
34
 
35
35
  export function parseAiResponse(text) {
36
- const trimmed = text.trim();
36
+ const trimmed = (text ?? '').trim();
37
+ if (!trimmed) {
38
+ throw new Error('AI response was empty');
39
+ }
37
40
  const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
38
41
  if (!jsonMatch) {
39
42
  throw new Error('AI response did not contain JSON');
@@ -1,6 +1,18 @@
1
1
  import OpenAI from 'openai';
2
2
  import { getApiKey } from '../../../core/env.js';
3
3
 
4
+ /** GPT-5 / o-series models use max_completion_tokens instead of max_tokens. */
5
+ function usesCompletionTokensParam(model) {
6
+ return /^(gpt-5|o[0-9](-|$))/i.test(model || '');
7
+ }
8
+
9
+ function buildCompletionParams(model, maxTokens) {
10
+ if (usesCompletionTokensParam(model)) {
11
+ return { max_completion_tokens: maxTokens };
12
+ }
13
+ return { max_tokens: maxTokens };
14
+ }
15
+
4
16
  /**
5
17
  * @param {{ model: string, maxTokens: number, timeoutMs: number }} options
6
18
  */
@@ -15,16 +27,31 @@ export async function callOpenAI(messages, options) {
15
27
  timeout: options.timeoutMs,
16
28
  });
17
29
 
18
- const response = await client.chat.completions.create({
30
+ const request = {
19
31
  model: options.model,
20
32
  messages,
21
- max_tokens: options.maxTokens,
22
- temperature: 0.3,
33
+ ...buildCompletionParams(options.model, options.maxTokens),
23
34
  response_format: { type: 'json_object' },
24
- });
35
+ };
36
+
37
+ if (!usesCompletionTokensParam(options.model)) {
38
+ request.temperature = 0.3;
39
+ }
40
+
41
+ const response = await client.chat.completions.create(request);
42
+ const choice = response.choices[0];
43
+ const content = choice?.message?.content ?? '';
44
+
45
+ if (!content.trim()) {
46
+ const finish = choice?.finish_reason ?? 'unknown';
47
+ throw new Error(
48
+ `OpenAI returned empty content (finish_reason=${finish}). ` +
49
+ 'For gpt-5/o-series models, raise maxTokens — reasoning uses completion budget.'
50
+ );
51
+ }
25
52
 
26
53
  return {
27
- content: response.choices[0]?.message?.content || '',
54
+ content,
28
55
  model: response.model,
29
56
  provider: 'openai',
30
57
  usage: response.usage,
@@ -1,28 +0,0 @@
1
- {
2
- "ignore": ["node_modules", "dist", ".next", "coverage", ".git"],
3
- "watch": true,
4
- "autoGenerate": true,
5
- "includeGitHistory": true,
6
- "detectBugs": true,
7
- "debounceMs": 2000,
8
- "refreshIntervalMinutes": 15,
9
- "trackChanges": true,
10
- "maxTreeDepth": 4,
11
- "gitCommitLimit": 20,
12
- "ai": {
13
- "enabled": true,
14
- "provider": "openai",
15
- "fallbackProvider": "groq",
16
- "enrichContext": true,
17
- "background": true,
18
- "openai": {
19
- "model": "gpt-4o-mini"
20
- },
21
- "groq": {
22
- "model": "llama-3.3-70b-versatile"
23
- },
24
- "maxTokens": 4096,
25
- "timeoutMs": 120000,
26
- "promptsDir": ".contextforge/prompts"
27
- }
28
- }
@@ -1,29 +0,0 @@
1
- {
2
- "ignore": ["node_modules", "dist", "build", ".next", "coverage", ".git"],
3
- "watch": true,
4
- "autoGenerate": true,
5
- "includeGitHistory": true,
6
- "detectBugs": true,
7
- "debounceMs": 2000,
8
- "refreshIntervalMinutes": 0,
9
- "trackChanges": true,
10
- "maxTreeDepth": 4,
11
- "gitCommitLimit": 20,
12
- "maxFileSizeBytes": 524288,
13
- "installCursorRules": true,
14
- "postinstallGenerate": true,
15
- "postinstallWatch": true,
16
- "postinstallOnNpmInstall": true,
17
- "ai": {
18
- "enabled": false,
19
- "provider": "openai",
20
- "fallbackProvider": "groq",
21
- "enrichContext": true,
22
- "background": true,
23
- "openai": { "model": "gpt-4o-mini" },
24
- "groq": { "model": "llama-3.3-70b-versatile" },
25
- "maxTokens": 4096,
26
- "timeoutMs": 120000,
27
- "promptsDir": ".contextforge/prompts"
28
- }
29
- }
@@ -1,50 +0,0 @@
1
- # ContextForge Prompts
2
-
3
- Edit these files to change how AI enriches `context.md` — **no code changes needed**.
4
-
5
- ## Files
6
-
7
- | File | Purpose |
8
- |------|---------|
9
- | `system.md` | System message sent to OpenAI / Groq |
10
- | `user-template.md` | User message template (repository analysis request) |
11
- | `response-schema.md` | Required JSON keys the AI must return |
12
- | `retry-addon.md` | Extra instructions when validation fails (retry) |
13
-
14
- ## Placeholders (user-template.md)
15
-
16
- | Placeholder | Replaced with |
17
- |-------------|----------------|
18
- | `{{SCAN_DATA}}` | Sanitized JSON scan snapshot |
19
- | `{{RETRY_NOTE}}` | Retry instructions (empty on first attempt) |
20
- | `{{PROJECT_NAME}}` | Project name from scan |
21
-
22
- ## Per-project customization
23
-
24
- On `contextforge init`, prompts are copied to:
25
-
26
- ```
27
- .contextforge/prompts/
28
- ```
29
-
30
- Edit files there for **this project only**. Config key:
31
-
32
- ```json
33
- "ai": {
34
- "promptsDir": ".contextforge/prompts"
35
- }
36
- ```
37
-
38
- Point to another folder:
39
-
40
- ```json
41
- "ai": {
42
- "promptsDir": "my-custom-prompts"
43
- }
44
- ```
45
-
46
- ## Tips
47
-
48
- - Keep `response-schema.md` keys in sync with `src/services/ai/validate.js` required keys.
49
- - Do not ask AI to read source files — only scan JSON is sent.
50
- - After edits, run: `npx contextforge generate --ai`