ai-spec-dev 0.42.0 → 0.55.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 +86 -40
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +246 -11
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +344 -106
- package/cli/index.ts +3 -7
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +291 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +95 -4
- package/core/code-generator.ts +63 -14
- package/core/config-defaults.ts +44 -0
- package/core/constitution-generator.ts +2 -1
- package/core/cross-stack-verifier.ts +395 -0
- package/core/dsl-extractor.ts +2 -1
- package/core/error-feedback.ts +3 -2
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/openapi-exporter.ts +3 -2
- package/core/repo-store.ts +95 -0
- package/core/reviewer.ts +14 -13
- package/core/run-logger.ts +3 -4
- package/core/run-snapshot.ts +2 -3
- package/core/run-trend.ts +3 -4
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +30 -45
- package/core/token-budget.ts +3 -8
- package/core/types-generator.ts +2 -2
- package/core/vcr.ts +3 -1
- package/dist/cli/index.js +3889 -1937
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3888 -1936
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +17 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +292 -181
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +292 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +301 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/cli/commands/model.ts +0 -156
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
- package/demo-backend/.ai-spec-constitution.md +0 -65
- package/demo-backend/package.json +0 -21
- package/demo-backend/prisma/schema.prisma +0 -22
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
- package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
- package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
- package/demo-backend/src/index.ts +0 -17
- package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
- package/demo-backend/src/routes/bookmark.routes.ts +0 -11
- package/demo-backend/src/routes/index.ts +0 -8
- package/demo-backend/src/services/bookmark.service.test.ts +0 -433
- package/demo-backend/src/services/bookmark.service.ts +0 -261
- package/demo-backend/tsconfig.json +0 -12
- package/demo-frontend/.ai-spec-constitution.md +0 -95
- package/demo-frontend/package.json +0 -23
- package/demo-frontend/src/App.tsx +0 -12
- package/demo-frontend/src/main.tsx +0 -9
- package/demo-frontend/tsconfig.json +0 -13
package/core/openapi-exporter.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
2
|
import * as fs from "fs-extra";
|
|
3
3
|
import { SpecDSL, ApiEndpoint, DataModel, ModelField, FieldMap } from "./dsl-types";
|
|
4
|
+
import { DEFAULT_OPENAPI_SERVER_URL } from "./config-defaults";
|
|
4
5
|
|
|
5
6
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -272,7 +273,7 @@ function buildYamlDoc(obj: Record<string, unknown>): string {
|
|
|
272
273
|
* Convert a SpecDSL to an OpenAPI 3.1.0 document.
|
|
273
274
|
* Returns the document as a plain JS object (can be serialised to YAML or JSON).
|
|
274
275
|
*/
|
|
275
|
-
export function dslToOpenApi(dsl: SpecDSL, serverUrl =
|
|
276
|
+
export function dslToOpenApi(dsl: SpecDSL, serverUrl = DEFAULT_OPENAPI_SERVER_URL): Record<string, unknown> {
|
|
276
277
|
// ── Info ──────────────────────────────────────────────────────────────────
|
|
277
278
|
const info = {
|
|
278
279
|
title: dsl.feature.title,
|
|
@@ -338,7 +339,7 @@ export async function exportOpenApi(
|
|
|
338
339
|
opts: OpenApiExportOptions = {}
|
|
339
340
|
): Promise<string> {
|
|
340
341
|
const format = opts.format ?? "yaml";
|
|
341
|
-
const serverUrl = opts.serverUrl ??
|
|
342
|
+
const serverUrl = opts.serverUrl ?? DEFAULT_OPENAPI_SERVER_URL;
|
|
342
343
|
const defaultName = `openapi.${format}`;
|
|
343
344
|
const outputPath = opts.outputPath
|
|
344
345
|
? path.isAbsolute(opts.outputPath)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import { RepoType, RepoRole } from "./workspace-loader";
|
|
5
|
+
|
|
6
|
+
const REPO_STORE_FILE = path.join(os.homedir(), ".ai-spec-repos.json");
|
|
7
|
+
|
|
8
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface RegisteredRepo {
|
|
11
|
+
/** Display name (defaults to directory basename) */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Absolute path to the repo root */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Auto-detected repo type */
|
|
16
|
+
type: RepoType;
|
|
17
|
+
/** Auto-detected repo role */
|
|
18
|
+
role: RepoRole;
|
|
19
|
+
/** Whether a project constitution has been generated */
|
|
20
|
+
hasConstitution: boolean;
|
|
21
|
+
/** ISO timestamp of registration */
|
|
22
|
+
registeredAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RepoStoreData {
|
|
26
|
+
repos: RegisteredRepo[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Read / Write ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
async function readStore(): Promise<RepoStoreData> {
|
|
32
|
+
try {
|
|
33
|
+
if (await fs.pathExists(REPO_STORE_FILE)) {
|
|
34
|
+
return await fs.readJson(REPO_STORE_FILE);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.warn(`Warning: Could not read repo store at ${REPO_STORE_FILE}: ${(err as Error).message}.`);
|
|
38
|
+
}
|
|
39
|
+
return { repos: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function writeStore(store: RepoStoreData): Promise<void> {
|
|
43
|
+
await fs.ensureFile(REPO_STORE_FILE);
|
|
44
|
+
await fs.writeJson(REPO_STORE_FILE, store, { spaces: 2 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Get all registered repos. */
|
|
50
|
+
export async function getRegisteredRepos(): Promise<RegisteredRepo[]> {
|
|
51
|
+
const store = await readStore();
|
|
52
|
+
return store.repos;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Find a registered repo by its absolute path. */
|
|
56
|
+
export async function getRepoByPath(absPath: string): Promise<RegisteredRepo | undefined> {
|
|
57
|
+
const store = await readStore();
|
|
58
|
+
return store.repos.find((r) => r.path === absPath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Register a new repo. If already registered (by path), update it. */
|
|
62
|
+
export async function registerRepo(repo: RegisteredRepo): Promise<void> {
|
|
63
|
+
const store = await readStore();
|
|
64
|
+
const idx = store.repos.findIndex((r) => r.path === repo.path);
|
|
65
|
+
if (idx >= 0) {
|
|
66
|
+
store.repos[idx] = repo;
|
|
67
|
+
} else {
|
|
68
|
+
store.repos.push(repo);
|
|
69
|
+
}
|
|
70
|
+
await writeStore(store);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Remove a registered repo by path. */
|
|
74
|
+
export async function unregisterRepo(absPath: string): Promise<boolean> {
|
|
75
|
+
const store = await readStore();
|
|
76
|
+
const before = store.repos.length;
|
|
77
|
+
store.repos = store.repos.filter((r) => r.path !== absPath);
|
|
78
|
+
if (store.repos.length < before) {
|
|
79
|
+
await writeStore(store);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Update the hasConstitution flag for a repo. */
|
|
86
|
+
export async function markRepoConstitution(absPath: string, has: boolean): Promise<void> {
|
|
87
|
+
const store = await readStore();
|
|
88
|
+
const repo = store.repos.find((r) => r.path === absPath);
|
|
89
|
+
if (repo) {
|
|
90
|
+
repo.hasConstitution = has;
|
|
91
|
+
await writeStore(store);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { REPO_STORE_FILE };
|
package/core/reviewer.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
reviewImpactComplexitySystemPrompt,
|
|
11
11
|
} from "../prompts/codegen.prompt";
|
|
12
12
|
import { CONSTITUTION_FILE } from "./constitution-generator";
|
|
13
|
+
import { DEFAULT_REVIEW_HISTORY_FILE, DEFAULT_MAX_REVIEW_FILE_CHARS } from "./config-defaults";
|
|
13
14
|
|
|
14
15
|
// ─── Constitution Lessons Helper ──────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -48,7 +49,7 @@ interface ReviewHistoryEntry {
|
|
|
48
49
|
complexityLevel?: "低" | "中" | "高";
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
const REVIEW_HISTORY_FILE =
|
|
52
|
+
const REVIEW_HISTORY_FILE = DEFAULT_REVIEW_HISTORY_FILE;
|
|
52
53
|
|
|
53
54
|
async function loadReviewHistory(projectRoot: string): Promise<ReviewHistoryEntry[]> {
|
|
54
55
|
const historyPath = path.join(projectRoot, REVIEW_HISTORY_FILE);
|
|
@@ -225,16 +226,19 @@ ${codeContext}`;
|
|
|
225
226
|
console.log(chalk.gray(" Pass 2/3: Implementation review..."));
|
|
226
227
|
|
|
227
228
|
// ── Pass 2: Implementation + History ─────────────────────────────────────
|
|
229
|
+
// Token savings: Pass 2/3 receive a spec digest instead of the full spec,
|
|
230
|
+
// and omit the raw code context (Pass 1 already analyzed it).
|
|
231
|
+
const specDigest = specContent && specContent.length > 600
|
|
232
|
+
? specContent.slice(0, 600) + "\n... [spec truncated — see Pass 0/1 for full text]"
|
|
233
|
+
: specContent || "(No spec)";
|
|
234
|
+
|
|
228
235
|
const history = await loadReviewHistory(this.projectRoot);
|
|
229
236
|
const historyContext = buildHistoryContext(history);
|
|
230
237
|
|
|
231
238
|
const implPrompt = `Review the implementation details of this change.
|
|
232
239
|
|
|
233
|
-
=== Feature Spec ===
|
|
234
|
-
${
|
|
235
|
-
|
|
236
|
-
=== Code ===
|
|
237
|
-
${codeContext}
|
|
240
|
+
=== Feature Spec (digest — full spec was provided in Pass 0/1) ===
|
|
241
|
+
${specDigest}
|
|
238
242
|
|
|
239
243
|
=== Architecture Review (Pass 1 — do NOT repeat these findings) ===
|
|
240
244
|
${archReview}
|
|
@@ -246,11 +250,8 @@ ${historyContext}`;
|
|
|
246
250
|
// ── Pass 3: Impact & Complexity ───────────────────────────────────────────
|
|
247
251
|
const impactPrompt = `Assess the impact and complexity of this change.
|
|
248
252
|
|
|
249
|
-
=== Feature Spec ===
|
|
250
|
-
${
|
|
251
|
-
|
|
252
|
-
=== Code ===
|
|
253
|
-
${codeContext}
|
|
253
|
+
=== Feature Spec (digest) ===
|
|
254
|
+
${specDigest}
|
|
254
255
|
|
|
255
256
|
=== Architecture Review (Pass 1 — do NOT repeat) ===
|
|
256
257
|
${archReview}
|
|
@@ -338,8 +339,8 @@ ${implReview}`;
|
|
|
338
339
|
const fullPath = path.join(workingDir, filePath);
|
|
339
340
|
try {
|
|
340
341
|
const content = await fs.readFile(fullPath, "utf-8");
|
|
341
|
-
filesSection += `\n\n=== ${filePath} ===\n${content.slice(0,
|
|
342
|
-
if (content.length >
|
|
342
|
+
filesSection += `\n\n=== ${filePath} ===\n${content.slice(0, DEFAULT_MAX_REVIEW_FILE_CHARS)}`;
|
|
343
|
+
if (content.length > DEFAULT_MAX_REVIEW_FILE_CHARS) filesSection += `\n... (truncated, ${content.length} chars total)`;
|
|
343
344
|
} catch {
|
|
344
345
|
filesSection += `\n\n=== ${filePath} ===\n(file not found)`;
|
|
345
346
|
}
|
package/core/run-logger.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs-extra";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
-
|
|
5
|
-
const LOG_DIR = ".ai-spec-logs";
|
|
4
|
+
import { DEFAULT_LOG_DIR } from "./config-defaults";
|
|
6
5
|
|
|
7
6
|
// ─── JSONL helpers ────────────────────────────────────────────────────────────
|
|
8
7
|
// Each event is synchronously appended as one JSON line to a `.jsonl` shadow
|
|
@@ -131,8 +130,8 @@ export class RunLogger {
|
|
|
131
130
|
meta?: { provider?: string; model?: string; specPath?: string }
|
|
132
131
|
) {
|
|
133
132
|
this.startMs = Date.now();
|
|
134
|
-
this.logPath = path.join(workingDir,
|
|
135
|
-
this.jsonlPath = path.join(workingDir,
|
|
133
|
+
this.logPath = path.join(workingDir, DEFAULT_LOG_DIR, `${runId}.json`);
|
|
134
|
+
this.jsonlPath = path.join(workingDir, DEFAULT_LOG_DIR, `${runId}.jsonl`);
|
|
136
135
|
this.log = {
|
|
137
136
|
runId,
|
|
138
137
|
startedAt: new Date().toISOString(),
|
package/core/run-snapshot.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs-extra";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
|
|
4
|
-
const BACKUP_DIR = ".ai-spec-backup";
|
|
3
|
+
import { DEFAULT_BACKUP_DIR } from "./config-defaults";
|
|
5
4
|
|
|
6
5
|
// ─── RunSnapshot ──────────────────────────────────────────────────────────────
|
|
7
6
|
/**
|
|
@@ -18,7 +17,7 @@ export class RunSnapshot {
|
|
|
18
17
|
private readonly workingDir: string,
|
|
19
18
|
readonly runId: string
|
|
20
19
|
) {
|
|
21
|
-
this.backupRoot = path.join(workingDir,
|
|
20
|
+
this.backupRoot = path.join(workingDir, DEFAULT_BACKUP_DIR, runId);
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
package/core/run-trend.ts
CHANGED
|
@@ -2,8 +2,7 @@ import * as fs from "fs-extra";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { RunLog, reconstructRunLogFromJsonl } from "./run-logger";
|
|
5
|
-
|
|
6
|
-
const LOG_DIR = ".ai-spec-logs";
|
|
5
|
+
import { DEFAULT_LOG_DIR } from "./config-defaults";
|
|
7
6
|
|
|
8
7
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
9
8
|
|
|
@@ -45,7 +44,7 @@ export interface TrendReport {
|
|
|
45
44
|
* Silently skips unreadable / corrupt files.
|
|
46
45
|
*/
|
|
47
46
|
export async function loadRunLogs(workingDir: string): Promise<RunLog[]> {
|
|
48
|
-
const logDir = path.join(workingDir,
|
|
47
|
+
const logDir = path.join(workingDir, DEFAULT_LOG_DIR);
|
|
49
48
|
if (!(await fs.pathExists(logDir))) return [];
|
|
50
49
|
|
|
51
50
|
const files = await fs.readdir(logDir);
|
|
@@ -255,7 +254,7 @@ export function printTrendReport(report: TrendReport, workingDir: string): void
|
|
|
255
254
|
}
|
|
256
255
|
|
|
257
256
|
// ── Footer ────────────────────────────────────────────────────────
|
|
258
|
-
const logRelDir = path.relative(workingDir, path.join(workingDir,
|
|
257
|
+
const logRelDir = path.relative(workingDir, path.join(workingDir, DEFAULT_LOG_DIR));
|
|
259
258
|
console.log(chalk.gray(`\n ${entries.length} run(s) shown · logs: ${logRelDir}/`));
|
|
260
259
|
console.log(chalk.cyan("─".repeat(63)));
|
|
261
260
|
}
|
package/core/self-evaluator.ts
CHANGED
|
@@ -35,8 +35,8 @@ export interface SelfEvalResult {
|
|
|
35
35
|
|
|
36
36
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
/** File-path patterns that indicate an API / controller / route layer file. */
|
|
39
|
-
const
|
|
38
|
+
/** File-path patterns that indicate an API / controller / route layer file (backend). */
|
|
39
|
+
const BACKEND_ENDPOINT_LAYER_PATTERNS = [
|
|
40
40
|
/src\/api/,
|
|
41
41
|
/src\/routes?/,
|
|
42
42
|
/src\/controller/,
|
|
@@ -44,8 +44,8 @@ const ENDPOINT_LAYER_PATTERNS = [
|
|
|
44
44
|
/src\/endpoints?/,
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
-
/** File-path patterns that indicate a data / model / schema layer file. */
|
|
48
|
-
const
|
|
47
|
+
/** File-path patterns that indicate a data / model / schema layer file (backend). */
|
|
48
|
+
const BACKEND_MODEL_LAYER_PATTERNS = [
|
|
49
49
|
/src\/model/,
|
|
50
50
|
/src\/schema/,
|
|
51
51
|
/src\/entit/,
|
|
@@ -55,6 +55,33 @@ const MODEL_LAYER_PATTERNS = [
|
|
|
55
55
|
/src\/domain/,
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* File-path patterns that indicate a view / page / screen layer file (frontend/mobile).
|
|
60
|
+
* Covers React (pages/), Next.js (app/ or pages/), Vue (views/), React Native (screens/).
|
|
61
|
+
*/
|
|
62
|
+
const FRONTEND_ENDPOINT_LAYER_PATTERNS = [
|
|
63
|
+
/src\/pages/,
|
|
64
|
+
/src\/views/,
|
|
65
|
+
/src\/screens/, // React Native / mobile
|
|
66
|
+
/(?:^|\/)pages\//, // Next.js pages router (root-level pages/)
|
|
67
|
+
/(?:^|\/)app\//, // Next.js App Router
|
|
68
|
+
/src\/routes?/, // client-side routing files
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* File-path patterns that indicate a type / store / hook / service layer (frontend/mobile).
|
|
73
|
+
* These are the "model layer" equivalent on the client side.
|
|
74
|
+
*/
|
|
75
|
+
const FRONTEND_MODEL_LAYER_PATTERNS = [
|
|
76
|
+
/src\/types/,
|
|
77
|
+
/src\/store/,
|
|
78
|
+
/src\/stores/,
|
|
79
|
+
/src\/hooks/,
|
|
80
|
+
/src\/composables/, // Vue Composition API
|
|
81
|
+
/src\/services/,
|
|
82
|
+
/src\/api/, // frontend API client layer
|
|
83
|
+
];
|
|
84
|
+
|
|
58
85
|
/**
|
|
59
86
|
* Extract a numeric score from review text.
|
|
60
87
|
* Matches the same "Score: X/10" pattern as `reviewer.ts → extractScore()`.
|
|
@@ -128,21 +155,31 @@ export function runSelfEval(opts: {
|
|
|
128
155
|
reviewText: string;
|
|
129
156
|
promptHash: string;
|
|
130
157
|
logger: RunLogger;
|
|
158
|
+
/**
|
|
159
|
+
* Repo role — selects the appropriate layer-pattern set.
|
|
160
|
+
* 'frontend' and 'mobile' use page/view/hook/store patterns;
|
|
161
|
+
* 'backend' and 'shared' (default) use controller/model/schema patterns.
|
|
162
|
+
*/
|
|
163
|
+
repoType?: 'frontend' | 'backend' | 'mobile' | 'shared' | string;
|
|
131
164
|
}): SelfEvalResult {
|
|
132
165
|
const { dsl, generatedFiles, compilePassed, reviewText, promptHash, logger } = opts;
|
|
133
166
|
|
|
167
|
+
const isFrontend = opts.repoType === 'frontend' || opts.repoType === 'mobile';
|
|
168
|
+
const endpointLayerPatterns = isFrontend ? FRONTEND_ENDPOINT_LAYER_PATTERNS : BACKEND_ENDPOINT_LAYER_PATTERNS;
|
|
169
|
+
const modelLayerPatterns = isFrontend ? FRONTEND_MODEL_LAYER_PATTERNS : BACKEND_MODEL_LAYER_PATTERNS;
|
|
170
|
+
|
|
134
171
|
// ── DSL Coverage Score ────────────────────────────────────────────────────
|
|
135
172
|
const endpointsTotal = dsl?.endpoints?.length ?? 0;
|
|
136
173
|
const modelsTotal = dsl?.models?.length ?? 0;
|
|
137
174
|
|
|
138
175
|
const endpointLayerCovered = generatedFiles.some((f) =>
|
|
139
|
-
|
|
176
|
+
endpointLayerPatterns.some((p) => p.test(f))
|
|
140
177
|
);
|
|
141
178
|
const endpointLayerFiles = generatedFiles.filter((f) =>
|
|
142
|
-
|
|
179
|
+
endpointLayerPatterns.some((p) => p.test(f))
|
|
143
180
|
).length;
|
|
144
181
|
const modelLayerCovered = generatedFiles.some((f) =>
|
|
145
|
-
|
|
182
|
+
modelLayerPatterns.some((p) => p.test(f))
|
|
146
183
|
);
|
|
147
184
|
|
|
148
185
|
// ── Tier 2: Model name coverage ───────────────────────────────────────────
|
package/core/spec-generator.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
2
2
|
import Anthropic from "@anthropic-ai/sdk";
|
|
3
3
|
import OpenAI from "openai";
|
|
4
|
-
import axios from "axios";
|
|
5
4
|
import { ProxyAgent } from "undici";
|
|
6
5
|
import { specPrompt } from "../prompts/spec.prompt";
|
|
7
6
|
import { ProjectContext } from "./context-loader";
|
|
8
7
|
import { withReliability } from "./provider-utils";
|
|
9
8
|
|
|
10
9
|
// ─── Proxy Helper ─────────────────────────────────────────────────────────────
|
|
11
|
-
// 仅用于 Gemini:其他 SDK(Anthropic / OpenAI)会自动读取 HTTPS_PROXY。
|
|
12
10
|
// Gemini SDK 使用 Node.js 原生 fetch(undici),不会自动读代理环境变量,
|
|
13
11
|
// 需要手动创建 ProxyAgent 并通过 fetchOptions 注入。
|
|
12
|
+
// Anthropic SDK (node-fetch) 也不会自动读代理环境变量。
|
|
14
13
|
// 这是 in-process 级别的配置,完全不影响 execSync 启动的子进程(如 claude CLI)。
|
|
15
14
|
|
|
16
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -47,6 +46,8 @@ export interface ProviderMeta {
|
|
|
47
46
|
models: string[];
|
|
48
47
|
/** Environment variable name for the API key */
|
|
49
48
|
envKey: string;
|
|
49
|
+
/** Fallback env var names checked if envKey is not set */
|
|
50
|
+
fallbackEnvKeys?: string[];
|
|
50
51
|
/**
|
|
51
52
|
* Base URL for OpenAI-compatible providers.
|
|
52
53
|
* Undefined means the provider has its own SDK (Gemini / Claude).
|
|
@@ -72,6 +73,8 @@ export const PROVIDER_CATALOG: Record<string, ProviderMeta> = {
|
|
|
72
73
|
description: "小米 MiMo — mimo-v2-pro (Anthropic-compatible API)",
|
|
73
74
|
models: ["mimo-v2-pro"],
|
|
74
75
|
envKey: "MIMO_API_KEY",
|
|
76
|
+
// Fallback env var — MiMo's token plan uses ANTHROPIC_AUTH_TOKEN
|
|
77
|
+
fallbackEnvKeys: ["ANTHROPIC_AUTH_TOKEN"],
|
|
75
78
|
// baseURL not used — MiMo has a dedicated provider class
|
|
76
79
|
},
|
|
77
80
|
gemini: {
|
|
@@ -114,10 +117,10 @@ export const PROVIDER_CATALOG: Record<string, ProviderMeta> = {
|
|
|
114
117
|
},
|
|
115
118
|
deepseek: {
|
|
116
119
|
displayName: "DeepSeek",
|
|
117
|
-
description: "DeepSeek
|
|
120
|
+
description: "DeepSeek V3.2 (chat) / R1 (reasoner) — alias auto-tracks latest stable",
|
|
118
121
|
models: [
|
|
119
|
-
"deepseek-chat", // DeepSeek
|
|
120
|
-
"deepseek-reasoner", //
|
|
122
|
+
"deepseek-chat", // V3.2 (alias auto-updates as DeepSeek releases new versions)
|
|
123
|
+
"deepseek-reasoner", // R1 (reasoning model)
|
|
121
124
|
],
|
|
122
125
|
envKey: "DEEPSEEK_API_KEY",
|
|
123
126
|
baseURL: "https://api.deepseek.com/v1",
|
|
@@ -244,8 +247,8 @@ export class ClaudeProvider implements AIProvider {
|
|
|
244
247
|
...(systemInstruction ? { system: systemInstruction } : {}),
|
|
245
248
|
messages: [{ role: "user", content: prompt }],
|
|
246
249
|
});
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
250
|
+
const textBlock = message.content.find((b) => b.type === "text");
|
|
251
|
+
if (textBlock) return textBlock.text;
|
|
249
252
|
throw new Error("Unexpected response type from Claude API");
|
|
250
253
|
},
|
|
251
254
|
{ label: `${this.providerName}/${this.modelName}` }
|
|
@@ -307,58 +310,40 @@ export class OpenAICompatibleProvider implements AIProvider {
|
|
|
307
310
|
// ─── MiMo Provider ─────────────────────────────────────────────────────────────
|
|
308
311
|
// MiMo uses the Anthropic messages format but with a different base URL
|
|
309
312
|
// and a custom "api-key" auth header (not "x-api-key" / "Authorization: Bearer").
|
|
310
|
-
//
|
|
311
|
-
// directly
|
|
313
|
+
// MiMo's token-plan API is Anthropic-compatible — we reuse the Anthropic SDK
|
|
314
|
+
// directly, reading base URL from env (MIMO_BASE_URL / ANTHROPIC_BASE_URL).
|
|
312
315
|
|
|
313
316
|
export class MiMoProvider implements AIProvider {
|
|
317
|
+
private client: Anthropic;
|
|
314
318
|
readonly providerName = "mimo";
|
|
315
319
|
readonly modelName: string;
|
|
316
|
-
private apiKey: string;
|
|
317
|
-
private readonly baseUrl = "https://api.xiaomimimo.com/anthropic/v1/messages";
|
|
318
320
|
|
|
319
321
|
constructor(apiKey: string, modelName = PROVIDER_CATALOG.mimo.models[0]) {
|
|
320
|
-
|
|
322
|
+
const baseURL = process.env["MIMO_BASE_URL"]
|
|
323
|
+
|| process.env["ANTHROPIC_BASE_URL"]
|
|
324
|
+
|| "https://token-plan-cn.xiaomimimo.com/anthropic";
|
|
325
|
+
this.client = new Anthropic({ apiKey, baseURL });
|
|
321
326
|
this.modelName = modelName;
|
|
322
327
|
}
|
|
323
328
|
|
|
324
329
|
async generate(prompt: string, systemInstruction?: string): Promise<string> {
|
|
325
330
|
return withReliability(
|
|
326
331
|
async () => {
|
|
327
|
-
|
|
332
|
+
// Use streaming to avoid timeout errors with large max_tokens
|
|
333
|
+
const stream = this.client.messages.stream({
|
|
328
334
|
model: this.modelName,
|
|
329
|
-
max_tokens:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
stream: false,
|
|
333
|
-
temperature: 1.0,
|
|
334
|
-
stop_sequences: null,
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
if (systemInstruction) {
|
|
338
|
-
body.system = systemInstruction;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const response = await axios.post(this.baseUrl, body, {
|
|
342
|
-
headers: {
|
|
343
|
-
"api-key": this.apiKey,
|
|
344
|
-
"Content-Type": "application/json",
|
|
345
|
-
},
|
|
335
|
+
max_tokens: 65536,
|
|
336
|
+
...(systemInstruction ? { system: systemInstruction } : {}),
|
|
337
|
+
messages: [{ role: "user", content: prompt }],
|
|
346
338
|
});
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// If stop_reason is max_tokens, the model was cut off mid-generation (thinking block only)
|
|
357
|
-
if (data?.stop_reason === "max_tokens") {
|
|
358
|
-
throw new Error(`MiMo response truncated (max_tokens reached). The prompt may be too long. Try a shorter spec or switch to a model with larger context.`);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
|
|
339
|
+
const message = await stream.finalMessage();
|
|
340
|
+
// MiMo may return "thinking" blocks before or instead of "text" blocks.
|
|
341
|
+
// Extract the first text block; fall back to thinking content; last resort: concatenate all.
|
|
342
|
+
const textBlock = message.content.find((b) => b.type === "text");
|
|
343
|
+
if (textBlock) return textBlock.text;
|
|
344
|
+
const thinkBlock = message.content.find((b) => b.type === "thinking");
|
|
345
|
+
if (thinkBlock) return (thinkBlock as unknown as { thinking: string }).thinking;
|
|
346
|
+
return message.content.map((b: { type: string; text?: string }) => b.text ?? "").join("");
|
|
362
347
|
},
|
|
363
348
|
{ label: `${this.providerName}/${this.modelName}` }
|
|
364
349
|
);
|
package/core/token-budget.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import chalk from "chalk";
|
|
9
|
+
import { DEFAULT_TOKEN_BUDGETS as _CONFIG_BUDGETS } from "./config-defaults";
|
|
9
10
|
|
|
10
11
|
// ─── Token Estimation ────────────────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -110,14 +111,8 @@ export function assembleSections(
|
|
|
110
111
|
|
|
111
112
|
// ─── Default Budgets ─────────────────────────────────────────────────────────
|
|
112
113
|
|
|
113
|
-
/** Default context token budgets per provider. */
|
|
114
|
-
export const DEFAULT_TOKEN_BUDGETS
|
|
115
|
-
gemini: 900_000,
|
|
116
|
-
claude: 180_000,
|
|
117
|
-
openai: 120_000,
|
|
118
|
-
deepseek: 60_000,
|
|
119
|
-
default: 100_000,
|
|
120
|
-
};
|
|
114
|
+
/** Default context token budgets per provider (sourced from config-defaults). */
|
|
115
|
+
export const DEFAULT_TOKEN_BUDGETS = _CONFIG_BUDGETS;
|
|
121
116
|
|
|
122
117
|
export function getDefaultBudget(providerName: string): number {
|
|
123
118
|
return DEFAULT_TOKEN_BUDGETS[providerName] ?? DEFAULT_TOKEN_BUDGETS.default;
|
package/core/types-generator.ts
CHANGED
|
@@ -23,7 +23,7 @@ const PRIMITIVE_MAP: Record<string, string> = {
|
|
|
23
23
|
any: "unknown",
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
function mapFieldType(raw: string): string {
|
|
26
|
+
export function mapFieldType(raw: string): string {
|
|
27
27
|
const trimmed = raw.trim();
|
|
28
28
|
// Array types: "String[]" or "User[]"
|
|
29
29
|
if (trimmed.endsWith("[]")) {
|
|
@@ -39,7 +39,7 @@ function mapFieldType(raw: string): string {
|
|
|
39
39
|
|
|
40
40
|
// ─── Model → Interface ────────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
|
-
function renderModelInterface(
|
|
42
|
+
export function renderModelInterface(
|
|
43
43
|
name: string,
|
|
44
44
|
fields: ModelField[],
|
|
45
45
|
description?: string
|
package/core/vcr.ts
CHANGED
|
@@ -27,7 +27,9 @@ import * as fs from "fs-extra";
|
|
|
27
27
|
import * as path from "path";
|
|
28
28
|
import { AIProvider } from "./spec-generator";
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
import { DEFAULT_VCR_DIR } from "./config-defaults";
|
|
31
|
+
|
|
32
|
+
export const VCR_DIR = DEFAULT_VCR_DIR;
|
|
31
33
|
|
|
32
34
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
33
35
|
|