dinorex 1.0.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/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # 🦕 Dinorex
2
+
3
+ > AI-powered API documentation generator. One command, full docs.
4
+
5
+ Dinorex scans your Node.js project — routes, controllers, services, and DB models — and uses AI to automatically generate an interactive docs UI, a Postman collection, and a Swagger/OpenAPI file.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g dinorex
13
+ ```
14
+
15
+ Or use without installing (requires Node 18+):
16
+
17
+ ```bash
18
+ npx dinorex scan
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Setup: Your Anthropic API Key
24
+
25
+ Dinorex uses Claude AI to analyze your code. You need a free Anthropic API key.
26
+
27
+ 1. Go to [https://console.anthropic.com](https://console.anthropic.com)
28
+ 2. Create an account and generate an API key
29
+ 3. Set it in your environment:
30
+
31
+ ```bash
32
+ # Mac/Linux — add to ~/.zshrc or ~/.bashrc
33
+ export ANTHROPIC_API_KEY=sk-ant-your-key-here
34
+
35
+ # Windows (PowerShell)
36
+ $env:ANTHROPIC_API_KEY="sk-ant-your-key-here"
37
+
38
+ # Or pass it directly every time
39
+ dinorex scan --api-key sk-ant-your-key-here
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Usage
45
+
46
+ ### Scan your project and open docs
47
+ ```bash
48
+ cd your-project/
49
+ dinorex scan
50
+ # Opens http://localhost:4321 automatically
51
+ ```
52
+
53
+ ### Scan a specific folder
54
+ ```bash
55
+ dinorex scan ./backend --port 5000
56
+ ```
57
+
58
+ ### After adding new endpoints — smart rescan
59
+ ```bash
60
+ dinorex scan
61
+ # Dinorex detects only new/changed files and updates the spec incrementally
62
+ # No need to re-analyze everything from scratch
63
+ ```
64
+
65
+ ### Force a full re-analysis
66
+ ```bash
67
+ dinorex scan --full
68
+ ```
69
+
70
+ ### Generate files only (no UI server)
71
+ ```bash
72
+ dinorex generate ./backend --out ./docs
73
+ # Outputs: *-postman.json, *-openapi.yaml, *-spec.json
74
+ ```
75
+
76
+ ---
77
+
78
+ ## What gets scanned
79
+
80
+ | Pattern | Examples |
81
+ |---|---|
82
+ | Routes | `routes/`, `*route*.js`, `*router*.js` |
83
+ | Controllers | `controllers/`, `*controller*.js` |
84
+ | Services | `services/`, `*service*.js` |
85
+ | Models/Schemas | `models/`, `schemas/`, `*model*.js`, `*schema*.js` |
86
+
87
+ Ignores: `node_modules`, `dist`, `build`, `.git`, test files.
88
+
89
+ ---
90
+
91
+ ## Output
92
+
93
+ - **Interactive UI** at `http://localhost:4321` — browse, search, and test endpoints live
94
+ - **Postman Collection** — download and import directly into Postman
95
+ - **Swagger/OpenAPI YAML** — import into any OpenAPI-compatible tool
96
+
97
+ ---
98
+
99
+ ## Works with
100
+
101
+ - Express.js
102
+ - Fastify
103
+ - NestJS
104
+ - Koa
105
+ - Any Node.js framework that uses route files
106
+
107
+ ---
108
+
109
+ ## Cache & incremental updates
110
+
111
+ Dinorex stores a `.dinorex/spec.json` file in your project directory (auto-gitignored). On subsequent runs it only re-analyzes new or changed files — making rescans much faster.
112
+
113
+ ---
114
+
115
+ ## Options
116
+
117
+ ```
118
+ dinorex scan [directory]
119
+ -p, --port <port> Port to run docs server (default: 4321)
120
+ --no-open Don't auto-open the browser
121
+ --api-key <key> Anthropic API key
122
+
123
+ dinorex generate [directory]
124
+ --out <dir> Output directory (default: ./dinorex-output)
125
+ --api-key <key> Anthropic API key
126
+ ```
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "dinorex",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered API documentation generator — one command, full docs.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "dinorex": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/server.js",
12
+ "dev": "nodemon src/server.js"
13
+ },
14
+ "keywords": [
15
+ "api",
16
+ "swagger",
17
+ "postman",
18
+ "openapi",
19
+ "documentation",
20
+ "cli",
21
+ "ai"
22
+ ],
23
+ "author": "Dinorex",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@anthropic-ai/sdk": "^0.20.0",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^11.1.0",
29
+ "cors": "^2.8.5",
30
+ "express": "^4.18.2",
31
+ "glob": "^10.3.10",
32
+ "js-yaml": "^4.1.0",
33
+ "multer": "^1.4.5-lts.1",
34
+ "ora": "^7.0.1",
35
+ "chokidar": "^3.5.3"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ }
40
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * agent.groq.js — Free Groq/Llama3 version of the Dinorex AI agent.
3
+ *
4
+ * Get a free API key at: https://console.groq.com
5
+ * Set it: export GROQ_API_KEY=gsk_your_key_here
6
+ *
7
+ * To use this instead of the Anthropic agent, change the import in cli.js and server.js:
8
+ * import { analyzeWithAI, analyzeIncremental } from "./agent.groq.js";
9
+ */
10
+
11
+ const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
12
+ const MODEL = "llama-3.3-70b-versatile";
13
+ const MAX_CHARS = 12000; // safe limit per request (~3000 tokens of context)
14
+
15
+ function buildContext(files) {
16
+ return files.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n");
17
+ }
18
+
19
+ const SYSTEM_FULL = `You are an expert API analyst. Analyze source code (JavaScript OR TypeScript) and extract a complete API specification.
20
+
21
+ Supported frameworks — recognize ALL of these:
22
+ - Express / Fastify / Koa: router.get('/path', handler), app.post('/path', handler)
23
+ - NestJS decorators: @Controller('base'), @Get(':id'), @Post(), @Put(), @Patch(), @Delete(), @Body(), @Param(), @Query(), @UseGuards()
24
+ - TypeScript DTOs, interfaces, type aliases, class properties
25
+ - Mongoose / Sequelize / TypeORM / Prisma schemas
26
+ - Zod / Joi / Yup schemas: extract field names and types
27
+ - class-validator decorators: @IsString(), @IsEmail(), etc.
28
+
29
+ Rules:
30
+ - Return ONLY valid JSON. No markdown, no explanation, no code fences.
31
+ - Infer realistic example values from model/schema field names and types.
32
+ - Group endpoints into logical collections (e.g. "Users", "Auth", "Products").
33
+ - Detect auth guards: @UseGuards(), authMiddleware, isAuthenticated, verifyToken, requireAuth, JwtAuthGuard → requiresAuth: true.
34
+ - For NestJS: combine @Controller('users') prefix with method paths (@Get(':id') → /users/:id).
35
+ - TypeScript optional fields (field?: type) → required: false.
36
+
37
+ Return ONLY this JSON structure, nothing else:
38
+ {
39
+ "projectName": "string",
40
+ "baseUrl": "http://localhost:3000",
41
+ "version": "1.0.0",
42
+ "description": "string",
43
+ "collections": [
44
+ {
45
+ "name": "string",
46
+ "description": "string",
47
+ "endpoints": [
48
+ {
49
+ "id": "unique-kebab-slug",
50
+ "method": "GET|POST|PUT|PATCH|DELETE",
51
+ "path": "/api/resource/:id",
52
+ "summary": "Short title",
53
+ "description": "Longer description",
54
+ "requiresAuth": false,
55
+ "pathParams": [{ "name": "id", "type": "string", "description": "...", "example": "abc123" }],
56
+ "queryParams": [{ "name": "page", "type": "integer", "description": "...", "example": 1 }],
57
+ "requestBody": {
58
+ "contentType": "application/json",
59
+ "schema": {
60
+ "fieldName": { "type": "string", "example": "value", "required": true, "description": "..." }
61
+ }
62
+ },
63
+ "responses": {
64
+ "200": { "description": "Success", "example": {} },
65
+ "400": { "description": "Bad Request" },
66
+ "401": { "description": "Unauthorized" },
67
+ "404": { "description": "Not Found" },
68
+ "500": { "description": "Server Error" }
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ ]
74
+ }`;
75
+
76
+ const SYSTEM_INCREMENTAL = `You are an expert API analyst doing an INCREMENTAL update to an existing API spec.
77
+
78
+ You understand JavaScript AND TypeScript including Express, NestJS decorators, DTOs, Zod schemas, Mongoose/TypeORM/Prisma models.
79
+
80
+ You will receive:
81
+ 1. The EXISTING spec (full JSON)
82
+ 2. NEW or CHANGED source files to analyze
83
+
84
+ Your job:
85
+ - Extract endpoints from new/changed files
86
+ - If endpoint already exists (same method + path): update it if code changed, keep it if unchanged
87
+ - If it is NEW: add it to the correct collection (create collection if needed)
88
+ - Remove endpoints whose source files are listed under REMOVED FILES
89
+ - Keep all existing endpoints from unchanged files
90
+
91
+ Return the COMPLETE updated spec JSON. No markdown, no explanation, ONLY JSON.`;
92
+
93
+ async function callGroq(systemPrompt, userMessage) {
94
+ const apiKey = process.env.GROQ_API_KEY;
95
+ if (!apiKey) {
96
+ throw new Error(
97
+ "GROQ_API_KEY is not set.\nGet a free key at https://console.groq.com\nThen run: export GROQ_API_KEY=gsk_your_key_here"
98
+ );
99
+ }
100
+
101
+ const response = await fetch(GROQ_API_URL, {
102
+ method: "POST",
103
+ headers: {
104
+ "Content-Type": "application/json",
105
+ "Authorization": `Bearer ${apiKey}`,
106
+ },
107
+ body: JSON.stringify({
108
+ model: MODEL,
109
+ temperature: 0.1, // low temp = more deterministic JSON output
110
+ max_tokens: 8000,
111
+ messages: [
112
+ { role: "system", content: systemPrompt },
113
+ { role: "user", content: userMessage },
114
+ ],
115
+ }),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const err = await response.text();
120
+ throw new Error(`Groq API error ${response.status}: ${err}`);
121
+ }
122
+
123
+ const data = await response.json();
124
+ const raw = data.choices?.[0]?.message?.content || "";
125
+ return raw;
126
+ }
127
+
128
+ function parseJSON(raw) {
129
+ // Strip any accidental markdown fences Llama might add
130
+ const cleaned = raw
131
+ .trim()
132
+ .replace(/^```json\s*/i, "")
133
+ .replace(/^```\s*/i, "")
134
+ .replace(/```\s*$/i, "")
135
+ .trim();
136
+
137
+ // Find the first { and last } to extract just the JSON object
138
+ const start = cleaned.indexOf("{");
139
+ const end = cleaned.lastIndexOf("}");
140
+ if (start === -1 || end === -1) {
141
+ throw new Error(`No JSON object found in response.\n\nSnippet: ${raw.slice(0, 300)}`);
142
+ }
143
+
144
+ const jsonStr = cleaned.slice(start, end + 1);
145
+
146
+ try {
147
+ return JSON.parse(jsonStr);
148
+ } catch (err) {
149
+ throw new Error(`Invalid JSON from Groq: ${err.message}\n\nSnippet: ${jsonStr.slice(0, 300)}`);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Split files into batches that fit within MAX_CHARS each.
155
+ */
156
+ function batchFiles(files) {
157
+ const batches = [];
158
+ let current = [];
159
+ let size = 0;
160
+
161
+ for (const f of files) {
162
+ const len = f.content.length + f.path.length + 20;
163
+ if (size + len > MAX_CHARS && current.length > 0) {
164
+ batches.push(current);
165
+ current = [];
166
+ size = 0;
167
+ }
168
+ // If a single file is too large, truncate it
169
+ const truncated = { ...f, content: f.content.slice(0, MAX_CHARS) };
170
+ current.push(truncated);
171
+ size += len;
172
+ }
173
+ if (current.length > 0) batches.push(current);
174
+ return batches;
175
+ }
176
+
177
+ /**
178
+ * Merge multiple partial specs into one, deduplicating endpoints by method+path.
179
+ */
180
+ function mergeSpecs(specs) {
181
+ const base = specs[0];
182
+ const collectionsMap = {};
183
+
184
+ for (const spec of specs) {
185
+ for (const col of spec.collections) {
186
+ if (!collectionsMap[col.name]) {
187
+ collectionsMap[col.name] = { ...col, endpoints: [] };
188
+ }
189
+ for (const ep of col.endpoints) {
190
+ const key = `${ep.method}:${ep.path}`;
191
+ const existing = collectionsMap[col.name].endpoints.find(
192
+ e => `${e.method}:${e.path}` === key
193
+ );
194
+ if (!existing) collectionsMap[col.name].endpoints.push(ep);
195
+ }
196
+ }
197
+ }
198
+
199
+ return {
200
+ ...base,
201
+ collections: Object.values(collectionsMap),
202
+ };
203
+ }
204
+
205
+ export async function analyzeWithAI(collected, projectName = "API") {
206
+ // Priority order: routes+controllers first (most important), then services, then models
207
+ const allFiles = [
208
+ ...collected.routes.map(f => ({ ...f, kind: "ROUTE" })),
209
+ ...collected.controllers.map(f => ({ ...f, kind: "CONTROLLER" })),
210
+ ...collected.services.map(f => ({ ...f, kind: "SERVICE" })),
211
+ ...collected.models.map(f => ({ ...f, kind: "MODEL" })),
212
+ ];
213
+
214
+ const batches = batchFiles(allFiles);
215
+ console.log(`\n 📦 Sending ${batches.length} batch(es) to Groq (${allFiles.length} files total)...`);
216
+
217
+ const partialSpecs = [];
218
+
219
+ for (let i = 0; i < batches.length; i++) {
220
+ const batch = batches[i];
221
+ const context = batch
222
+ .map(f => `### [${f.kind}] ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
223
+ .join("\n\n");
224
+
225
+ const userMessage = `Project name: "${projectName}" (batch ${i + 1} of ${batches.length})
226
+
227
+ ${context}
228
+
229
+ Extract all API endpoints found in these files and return ONLY the JSON spec.`;
230
+
231
+ const raw = await callGroq(SYSTEM_FULL, userMessage);
232
+ const partial = parseJSON(raw);
233
+ partialSpecs.push(partial);
234
+
235
+ // Small delay between batches to avoid rate limiting
236
+ if (i < batches.length - 1) await sleep(1000);
237
+ }
238
+
239
+ return batches.length === 1 ? partialSpecs[0] : mergeSpecs(partialSpecs);
240
+ }
241
+
242
+ function sleep(ms) {
243
+ return new Promise(resolve => setTimeout(resolve, ms));
244
+ }
245
+
246
+ export async function analyzeIncremental(existingSpec, diff) {
247
+ const { newFiles, changedFiles, removedFiles } = diff;
248
+
249
+ if (!newFiles.length && !changedFiles.length && !removedFiles.length) {
250
+ return { spec: existingSpec, changed: false };
251
+ }
252
+
253
+ const filesToAnalyze = [...newFiles, ...changedFiles];
254
+ const removedContext = removedFiles.length > 0
255
+ ? `\n\nREMOVED FILES (delete their endpoints):\n${removedFiles.join("\n")}`
256
+ : "";
257
+
258
+ // Spec JSON itself can be large — truncate for incremental context
259
+ const specStr = JSON.stringify(existingSpec, null, 2);
260
+ const specTruncated = specStr.length > 6000
261
+ ? specStr.slice(0, 6000) + "\n... [truncated]"
262
+ : specStr;
263
+
264
+ const changedContext = filesToAnalyze
265
+ .map(f => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
266
+ .join("\n\n");
267
+
268
+ const userMessage = `EXISTING SPEC:
269
+ ${specTruncated}
270
+
271
+ NEW/CHANGED FILES:
272
+ ${changedContext}${removedContext}
273
+
274
+ Return the complete updated spec JSON only.`;
275
+
276
+ const raw = await callGroq(SYSTEM_INCREMENTAL, userMessage);
277
+ const updated = parseJSON(raw);
278
+ return { spec: updated, changed: true };
279
+ }
package/src/agent.js ADDED
@@ -0,0 +1,155 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+
3
+ const client = new Anthropic();
4
+
5
+ function buildContext(files) {
6
+ return files.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n");
7
+ }
8
+
9
+ const SYSTEM_FULL = `You are an expert API analyst. Analyze source code (JavaScript OR TypeScript) and extract a complete API specification.
10
+
11
+ Supported frameworks and patterns — recognize ALL of these:
12
+ - Express / Fastify / Koa: router.get('/path', handler), app.post('/path', handler)
13
+ - NestJS decorators: @Controller('base'), @Get(':id'), @Post(), @Put(), @Patch(), @Delete(), @Body(), @Param(), @Query(), @UseGuards()
14
+ - TypeScript types & interfaces: extract field names/types from DTOs, interfaces, type aliases, class properties
15
+ - Mongoose/Sequelize/TypeORM/Prisma schemas: extract model fields and types
16
+ - tRPC routers: t.router({ ... }), publicProcedure, protectedProcedure — map to equivalent REST-style endpoints
17
+ - Zod/Joi/Yup schemas: extract field names and types as request/response body schemas
18
+ - Class-validator decorators: @IsString(), @IsEmail(), @IsNumber(), etc. → use as field type hints
19
+
20
+ Rules:
21
+ - Return ONLY valid JSON. No markdown, no explanation.
22
+ - Infer realistic example values from model/schema field names and types (TypeScript types count).
23
+ - Group endpoints into logical tags/collections (e.g. "Users", "Auth", "Products").
24
+ - Detect auth guards/middleware: @UseGuards(), @Roles(), authMiddleware, isAuthenticated, verifyToken, requireAuth, JwtAuthGuard, etc. → requiresAuth: true.
25
+ - For NestJS: combine @Controller('users') prefix with method decorator paths (e.g. @Get(':id') → /users/:id).
26
+ - Use DTO classes, interfaces, Zod schemas, or Mongoose models to build realistic request/response body examples.
27
+ - TypeScript optional fields (field?: type) → required: false. Non-optional → required: true.
28
+
29
+ Return this exact structure:
30
+ {
31
+ "projectName": "string",
32
+ "baseUrl": "http://localhost:3000",
33
+ "version": "1.0.0",
34
+ "description": "string",
35
+ "collections": [
36
+ {
37
+ "name": "string",
38
+ "description": "string",
39
+ "endpoints": [
40
+ {
41
+ "id": "unique-kebab-slug",
42
+ "method": "GET|POST|PUT|PATCH|DELETE",
43
+ "path": "/api/resource/:id",
44
+ "summary": "Short title",
45
+ "description": "Longer description",
46
+ "requiresAuth": false,
47
+ "pathParams": [{ "name": "id", "type": "string", "description": "...", "example": "abc123" }],
48
+ "queryParams": [{ "name": "page", "type": "integer", "description": "...", "example": 1 }],
49
+ "requestBody": {
50
+ "contentType": "application/json",
51
+ "schema": {
52
+ "fieldName": { "type": "string", "example": "value", "required": true, "description": "..." }
53
+ }
54
+ },
55
+ "responses": {
56
+ "200": { "description": "Success", "example": {} },
57
+ "400": { "description": "Bad Request" },
58
+ "401": { "description": "Unauthorized" },
59
+ "404": { "description": "Not Found" },
60
+ "500": { "description": "Server Error" }
61
+ }
62
+ }
63
+ ]
64
+ }
65
+ ]
66
+ }`;
67
+
68
+ const SYSTEM_INCREMENTAL = `You are an expert API analyst doing an INCREMENTAL update to an existing API spec.
69
+
70
+ You understand JavaScript AND TypeScript, including: Express, Fastify, NestJS decorators (@Controller, @Get, @Post, etc.), DTOs, Zod/Joi schemas, Mongoose/TypeORM/Prisma models, and class-validator decorators.
71
+
72
+ You will receive:
73
+ 1. The EXISTING spec (full JSON)
74
+ 2. NEW or CHANGED source files to analyze
75
+
76
+ Your job:
77
+ - Extract endpoints from the new/changed files
78
+ - For each endpoint, check if it already exists in the spec (match by method + path)
79
+ - If it EXISTS and is unchanged: keep it as-is (don't re-add)
80
+ - If it EXISTS but the code changed: update it with improved info
81
+ - If it's NEW: add it to the correct collection (create the collection if needed)
82
+ - Remove endpoints whose source files were deleted (listed under REMOVED FILES)
83
+ - Keep all existing endpoints from files that weren't changed
84
+
85
+ Return the COMPLETE updated spec JSON (same structure as input). No markdown, no explanation, only JSON.`;
86
+
87
+ export async function analyzeWithAI(collected, projectName = "API") {
88
+ const allFiles = [
89
+ ...collected.routes,
90
+ ...collected.controllers,
91
+ ...collected.services,
92
+ ...collected.models,
93
+ ];
94
+
95
+ const context = [
96
+ collected.routes.length > 0 ? "## ROUTES\n" + buildContext(collected.routes) : null,
97
+ collected.controllers.length > 0 ? "## CONTROLLERS\n" + buildContext(collected.controllers) : null,
98
+ collected.services.length > 0 ? "## SERVICES\n" + buildContext(collected.services) : null,
99
+ collected.models.length > 0 ? "## MODELS\n" + buildContext(collected.models) : null,
100
+ ].filter(Boolean).join("\n\n---\n\n");
101
+
102
+ const response = await client.messages.create({
103
+ model: "claude-opus-4-5",
104
+ max_tokens: 8000,
105
+ system: SYSTEM_FULL,
106
+ messages: [{
107
+ role: "user",
108
+ content: `Project: "${projectName}"\n\n${context}\n\nExtract all API endpoints and return the JSON spec.`
109
+ }],
110
+ });
111
+
112
+ return parseJSON(response.content[0].text);
113
+ }
114
+
115
+ export async function analyzeIncremental(existingSpec, diff) {
116
+ const { newFiles, changedFiles, removedFiles } = diff;
117
+
118
+ if (newFiles.length === 0 && changedFiles.length === 0 && removedFiles.length === 0) {
119
+ return { spec: existingSpec, changed: false };
120
+ }
121
+
122
+ const changedContext = [...newFiles, ...changedFiles]
123
+ .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
124
+ .join("\n\n");
125
+
126
+ const removedContext = removedFiles.length > 0
127
+ ? `\n\nREMOVED FILES (delete their endpoints):\n${removedFiles.join("\n")}`
128
+ : "";
129
+
130
+ const response = await client.messages.create({
131
+ model: "claude-opus-4-5",
132
+ max_tokens: 8000,
133
+ system: SYSTEM_INCREMENTAL,
134
+ messages: [{
135
+ role: "user",
136
+ content: `EXISTING SPEC:\n${JSON.stringify(existingSpec, null, 2)}\n\nNEW/CHANGED FILES:\n${changedContext}${removedContext}\n\nReturn the complete updated spec.`
137
+ }],
138
+ });
139
+
140
+ const updated = parseJSON(response.content[0].text);
141
+ return { spec: updated, changed: true };
142
+ }
143
+
144
+ function parseJSON(raw) {
145
+ const cleaned = raw.trim()
146
+ .replace(/^```json\s*/i, "")
147
+ .replace(/^```\s*/i, "")
148
+ .replace(/```\s*$/i, "")
149
+ .trim();
150
+ try {
151
+ return JSON.parse(cleaned);
152
+ } catch (err) {
153
+ throw new Error(`AI returned invalid JSON: ${err.message}\n\nSnippet: ${raw.slice(0, 300)}`);
154
+ }
155
+ }