@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { MainaConfig } from "../../config/index";
|
|
3
|
+
import { getTaskTier, resolveModel } from "../tiers";
|
|
4
|
+
|
|
5
|
+
const TEST_CONFIG: MainaConfig = {
|
|
6
|
+
models: {
|
|
7
|
+
mechanical: "google/gemini-2.5-flash",
|
|
8
|
+
standard: "anthropic/claude-sonnet-4",
|
|
9
|
+
architectural: "anthropic/claude-sonnet-4-5",
|
|
10
|
+
local: "ollama/qwen3-coder-8b",
|
|
11
|
+
},
|
|
12
|
+
provider: "openrouter",
|
|
13
|
+
budget: {
|
|
14
|
+
daily: 5.0,
|
|
15
|
+
perTask: 0.5,
|
|
16
|
+
alertAt: 0.8,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("getTaskTier", () => {
|
|
21
|
+
test("commit maps to mechanical", () => {
|
|
22
|
+
expect(getTaskTier("commit")).toBe("mechanical");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("tests maps to mechanical", () => {
|
|
26
|
+
expect(getTaskTier("tests")).toBe("mechanical");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("slop maps to mechanical", () => {
|
|
30
|
+
expect(getTaskTier("slop")).toBe("mechanical");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("compress maps to mechanical", () => {
|
|
34
|
+
expect(getTaskTier("compress")).toBe("mechanical");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("review maps to standard", () => {
|
|
38
|
+
expect(getTaskTier("review")).toBe("standard");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("plan maps to standard", () => {
|
|
42
|
+
expect(getTaskTier("plan")).toBe("standard");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("design maps to standard", () => {
|
|
46
|
+
expect(getTaskTier("design")).toBe("standard");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("fix maps to standard", () => {
|
|
50
|
+
expect(getTaskTier("fix")).toBe("standard");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("design-review maps to architectural", () => {
|
|
54
|
+
expect(getTaskTier("design-review")).toBe("architectural");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("architecture maps to architectural", () => {
|
|
58
|
+
expect(getTaskTier("architecture")).toBe("architectural");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("learn maps to architectural", () => {
|
|
62
|
+
expect(getTaskTier("learn")).toBe("architectural");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("unknown task defaults to standard", () => {
|
|
66
|
+
expect(getTaskTier("unknown")).toBe("standard");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("empty string defaults to standard", () => {
|
|
70
|
+
expect(getTaskTier("")).toBe("standard");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveModel", () => {
|
|
75
|
+
test("returns correct modelId for mechanical tier task", () => {
|
|
76
|
+
const result = resolveModel("commit", TEST_CONFIG);
|
|
77
|
+
expect(result.tier).toBe("mechanical");
|
|
78
|
+
expect(result.modelId).toBe("google/gemini-2.5-flash");
|
|
79
|
+
expect(result.provider).toBe("openrouter");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns correct modelId for standard tier task", () => {
|
|
83
|
+
const result = resolveModel("review", TEST_CONFIG);
|
|
84
|
+
expect(result.tier).toBe("standard");
|
|
85
|
+
expect(result.modelId).toBe("anthropic/claude-sonnet-4");
|
|
86
|
+
expect(result.provider).toBe("openrouter");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns correct modelId for architectural tier task", () => {
|
|
90
|
+
const result = resolveModel("architecture", TEST_CONFIG);
|
|
91
|
+
expect(result.tier).toBe("architectural");
|
|
92
|
+
expect(result.modelId).toBe("anthropic/claude-sonnet-4-5");
|
|
93
|
+
expect(result.provider).toBe("openrouter");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("provider comes from config", () => {
|
|
97
|
+
const customConfig: MainaConfig = {
|
|
98
|
+
...TEST_CONFIG,
|
|
99
|
+
provider: "custom-provider",
|
|
100
|
+
};
|
|
101
|
+
const result = resolveModel("commit", customConfig);
|
|
102
|
+
expect(result.provider).toBe("custom-provider");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("unknown task resolves to standard tier model", () => {
|
|
106
|
+
const result = resolveModel("bogus-task", TEST_CONFIG);
|
|
107
|
+
expect(result.tier).toBe("standard");
|
|
108
|
+
expect(result.modelId).toBe("anthropic/claude-sonnet-4");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { tryAIGenerate } from "./try-generate";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a conventional commit message from a diff using AI.
|
|
5
|
+
*
|
|
6
|
+
* Returns null when no API key is available or on any failure,
|
|
7
|
+
* allowing callers to fall back to manual message entry.
|
|
8
|
+
*/
|
|
9
|
+
export async function generateCommitMessage(
|
|
10
|
+
diff: string,
|
|
11
|
+
stagedFiles: string[],
|
|
12
|
+
mainaDir: string,
|
|
13
|
+
): Promise<string | null> {
|
|
14
|
+
const aiResult = await tryAIGenerate(
|
|
15
|
+
"commit",
|
|
16
|
+
mainaDir,
|
|
17
|
+
{ diff, files: stagedFiles.join(", ") },
|
|
18
|
+
`Generate a conventional commit message for this diff:\n\n${diff}\n\nFiles: ${stagedFiles.join(", ")}`,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (aiResult.text && aiResult.fromAI) {
|
|
22
|
+
// Clean up: extract first line as commit message
|
|
23
|
+
const firstLine = aiResult.text.trim().split("\n")[0] ?? "";
|
|
24
|
+
return firstLine.replace(/^["'`]|["'`]$/g, "").trim() || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Result } from "../db/index";
|
|
2
|
+
import { tryAIGenerate } from "./try-generate";
|
|
3
|
+
|
|
4
|
+
export interface DesignApproach {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
pros: string[];
|
|
8
|
+
cons: string[];
|
|
9
|
+
recommended: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_APPROACHES = 3;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generates 2-3 design approaches with pros/cons/recommendation by asking
|
|
16
|
+
* the AI to analyze the design context and propose alternatives.
|
|
17
|
+
*
|
|
18
|
+
* Returns an empty array when:
|
|
19
|
+
* - Context is empty
|
|
20
|
+
* - AI is not available (no API key)
|
|
21
|
+
* - AI returns malformed JSON
|
|
22
|
+
*/
|
|
23
|
+
export async function generateDesignApproaches(
|
|
24
|
+
designContext: string,
|
|
25
|
+
mainaDir: string,
|
|
26
|
+
): Promise<Result<DesignApproach[], string>> {
|
|
27
|
+
if (!designContext.trim()) {
|
|
28
|
+
return { ok: true, value: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await tryAIGenerate(
|
|
32
|
+
"design-approaches",
|
|
33
|
+
mainaDir,
|
|
34
|
+
{ context: designContext },
|
|
35
|
+
`Propose 2-3 architectural approaches for this design decision as a JSON array.\n\n${designContext}`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (!result.text) {
|
|
39
|
+
return { ok: true, value: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = parseApproachesJSON(result.text);
|
|
44
|
+
const validated = parsed.filter(isValidApproach);
|
|
45
|
+
return { ok: true, value: validated.slice(0, MAX_APPROACHES) };
|
|
46
|
+
} catch {
|
|
47
|
+
return { ok: true, value: [] };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseApproachesJSON(text: string): unknown[] {
|
|
52
|
+
let cleaned = text.trim();
|
|
53
|
+
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
54
|
+
if (fenceMatch?.[1]) {
|
|
55
|
+
cleaned = fenceMatch[1].trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed: unknown = JSON.parse(cleaned);
|
|
59
|
+
if (!Array.isArray(parsed)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidApproach(item: unknown): item is DesignApproach {
|
|
66
|
+
if (typeof item !== "object" || item === null) return false;
|
|
67
|
+
const obj = item as Record<string, unknown>;
|
|
68
|
+
return (
|
|
69
|
+
typeof obj.name === "string" &&
|
|
70
|
+
obj.name.length > 0 &&
|
|
71
|
+
typeof obj.description === "string" &&
|
|
72
|
+
Array.isArray(obj.pros) &&
|
|
73
|
+
Array.isArray(obj.cons) &&
|
|
74
|
+
typeof obj.recommended === "boolean"
|
|
75
|
+
);
|
|
76
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { buildCacheKey, hashContent } from "../cache/keys";
|
|
2
|
+
import { createCacheManager } from "../cache/manager";
|
|
3
|
+
import { getTtl } from "../cache/ttl";
|
|
4
|
+
import {
|
|
5
|
+
getApiKey,
|
|
6
|
+
loadConfig,
|
|
7
|
+
resolveProvider,
|
|
8
|
+
shouldDelegateToHost,
|
|
9
|
+
} from "../config/index";
|
|
10
|
+
import { resolveModel } from "./tiers";
|
|
11
|
+
import { validateAIOutput } from "./validate";
|
|
12
|
+
|
|
13
|
+
export interface GenerateOptions {
|
|
14
|
+
task: string;
|
|
15
|
+
systemPrompt: string;
|
|
16
|
+
userPrompt: string;
|
|
17
|
+
files?: string[]; // for cache key
|
|
18
|
+
mainaDir?: string; // for cache storage
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GenerateResult {
|
|
22
|
+
text: string;
|
|
23
|
+
cached: boolean;
|
|
24
|
+
model: string;
|
|
25
|
+
tokens?: { input: number; output: number };
|
|
26
|
+
slopWarnings?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface StoredResult {
|
|
30
|
+
text: string;
|
|
31
|
+
model: string;
|
|
32
|
+
tokens?: { input: number; output: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Performs the actual AI SDK call. Isolated here so tests never need to invoke it.
|
|
37
|
+
* Returns null on any error so callers can handle gracefully.
|
|
38
|
+
*/
|
|
39
|
+
export async function callModel(
|
|
40
|
+
modelId: string,
|
|
41
|
+
provider: string,
|
|
42
|
+
apiKey: string,
|
|
43
|
+
system: string,
|
|
44
|
+
user: string,
|
|
45
|
+
): Promise<{
|
|
46
|
+
text: string;
|
|
47
|
+
tokens?: { input: number; output: number };
|
|
48
|
+
} | null> {
|
|
49
|
+
try {
|
|
50
|
+
const { generateText } = await import("ai");
|
|
51
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
52
|
+
|
|
53
|
+
// Provider-specific base URLs
|
|
54
|
+
let baseURL: string | undefined;
|
|
55
|
+
if (provider === "openrouter") {
|
|
56
|
+
baseURL = "https://openrouter.ai/api/v1";
|
|
57
|
+
} else if (provider === "anthropic") {
|
|
58
|
+
baseURL = "https://api.anthropic.com/v1";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const openai = createOpenAI({
|
|
62
|
+
apiKey,
|
|
63
|
+
baseURL,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const result = await generateText({
|
|
67
|
+
model: openai(modelId),
|
|
68
|
+
system,
|
|
69
|
+
prompt: user,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
text: result.text,
|
|
74
|
+
tokens:
|
|
75
|
+
result.usage != null
|
|
76
|
+
? {
|
|
77
|
+
input: result.usage.inputTokens ?? 0,
|
|
78
|
+
output: result.usage.outputTokens ?? 0,
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Main AI generation function with cache-first strategy.
|
|
89
|
+
*
|
|
90
|
+
* 1. Hash the prompts to build a stable cache key.
|
|
91
|
+
* 2. Return cached result if available.
|
|
92
|
+
* 3. If no API key, return a helpful error result (never throw).
|
|
93
|
+
* 4. Call the model, cache the result, and return it.
|
|
94
|
+
*/
|
|
95
|
+
export async function generate(
|
|
96
|
+
options: GenerateOptions,
|
|
97
|
+
): Promise<GenerateResult> {
|
|
98
|
+
const { task, systemPrompt, userPrompt, files, mainaDir } = options;
|
|
99
|
+
|
|
100
|
+
const config = await loadConfig();
|
|
101
|
+
const resolved = resolveModel(task, config);
|
|
102
|
+
const provider = resolveProvider(config);
|
|
103
|
+
// In host mode with Anthropic, use a sensible model instead of OpenRouter model IDs
|
|
104
|
+
const modelId =
|
|
105
|
+
provider === "anthropic" && resolved.modelId.startsWith("google/")
|
|
106
|
+
? "claude-sonnet-4-20250514"
|
|
107
|
+
: provider === "anthropic" && resolved.modelId.includes("/")
|
|
108
|
+
? (resolved.modelId.split("/")[1] ?? resolved.modelId)
|
|
109
|
+
: resolved.modelId;
|
|
110
|
+
|
|
111
|
+
// Build cache key
|
|
112
|
+
const promptHash = hashContent(systemPrompt + userPrompt);
|
|
113
|
+
const cacheKey = await buildCacheKey({
|
|
114
|
+
task,
|
|
115
|
+
files,
|
|
116
|
+
promptHash,
|
|
117
|
+
model: modelId,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Set up cache (no-op manager if mainaDir not provided)
|
|
121
|
+
const effectiveMainaDir = mainaDir ?? ".maina";
|
|
122
|
+
const cache = createCacheManager(effectiveMainaDir);
|
|
123
|
+
|
|
124
|
+
// Cache hit
|
|
125
|
+
const cached = cache.get(cacheKey);
|
|
126
|
+
if (cached !== null) {
|
|
127
|
+
try {
|
|
128
|
+
const stored = JSON.parse(cached.value) as StoredResult;
|
|
129
|
+
return {
|
|
130
|
+
text: stored.text,
|
|
131
|
+
cached: true,
|
|
132
|
+
model: stored.model,
|
|
133
|
+
tokens: stored.tokens,
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
// Corrupted cache entry — fall through to re-generate
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Host delegation: when running inside Claude Code/Cursor without own API key,
|
|
141
|
+
// return the prompt so the host agent can process it via MCP or skills
|
|
142
|
+
if (shouldDelegateToHost()) {
|
|
143
|
+
const delegationText = `[HOST_DELEGATION] Task: ${task}\n\nSystem: ${systemPrompt}\n\nUser: ${userPrompt}`;
|
|
144
|
+
// Cache the delegation prompt to avoid rebuilding on repeat calls
|
|
145
|
+
const ttl = getTtl(task as Parameters<typeof getTtl>[0]);
|
|
146
|
+
const storedDelegation = { text: delegationText, model: "host" };
|
|
147
|
+
cache.set(cacheKey, JSON.stringify(storedDelegation), {
|
|
148
|
+
ttl,
|
|
149
|
+
model: "host",
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
text: delegationText,
|
|
153
|
+
cached: false,
|
|
154
|
+
model: "host",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check for API key
|
|
159
|
+
const apiKey = getApiKey();
|
|
160
|
+
if (apiKey === null) {
|
|
161
|
+
return {
|
|
162
|
+
text: "No API key found. Set MAINA_API_KEY or OPENROUTER_API_KEY environment variable to use AI features.",
|
|
163
|
+
cached: false,
|
|
164
|
+
model: "",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Call the model
|
|
169
|
+
const aiResult = await callModel(
|
|
170
|
+
modelId,
|
|
171
|
+
provider,
|
|
172
|
+
apiKey,
|
|
173
|
+
systemPrompt,
|
|
174
|
+
userPrompt,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (aiResult === null) {
|
|
178
|
+
return {
|
|
179
|
+
text: "AI call failed. Check your API key and network connection.",
|
|
180
|
+
cached: false,
|
|
181
|
+
model: modelId,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Store in cache
|
|
186
|
+
const ttl = getTtl(task as Parameters<typeof getTtl>[0]);
|
|
187
|
+
const storedResult: StoredResult = {
|
|
188
|
+
text: aiResult.text,
|
|
189
|
+
model: modelId,
|
|
190
|
+
tokens: aiResult.tokens,
|
|
191
|
+
};
|
|
192
|
+
cache.set(cacheKey, JSON.stringify(storedResult), { ttl, model: modelId });
|
|
193
|
+
|
|
194
|
+
// Validate AI output for slop patterns
|
|
195
|
+
const validation = validateAIOutput(aiResult.text);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
text: validation.sanitized,
|
|
199
|
+
cached: false,
|
|
200
|
+
model: modelId,
|
|
201
|
+
tokens: aiResult.tokens,
|
|
202
|
+
slopWarnings:
|
|
203
|
+
validation.warnings.length > 0 ? validation.warnings : undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { tryAIGenerate } from "./try-generate";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a structured PR summary from a diff and commit list.
|
|
5
|
+
*
|
|
6
|
+
* Returns a markdown body with ## Summary, ## What Changed, and ## Review sections.
|
|
7
|
+
* Falls back to commit list if AI is unavailable.
|
|
8
|
+
*/
|
|
9
|
+
export async function generatePrSummary(
|
|
10
|
+
diff: string,
|
|
11
|
+
commits: Array<{ hash: string; message: string }>,
|
|
12
|
+
reviewSummary: string,
|
|
13
|
+
mainaDir: string,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
const commitList = commits
|
|
16
|
+
.map((c) => `- ${c.message} (${c.hash.slice(0, 7)})`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
|
|
19
|
+
const aiResult = await tryAIGenerate(
|
|
20
|
+
"pr",
|
|
21
|
+
mainaDir,
|
|
22
|
+
{ diff, commits: commitList },
|
|
23
|
+
`Write a concise PR description for the following changes.
|
|
24
|
+
|
|
25
|
+
## Commits
|
|
26
|
+
${commitList}
|
|
27
|
+
|
|
28
|
+
## Diff (truncated)
|
|
29
|
+
${diff.slice(0, 8000)}
|
|
30
|
+
|
|
31
|
+
Instructions:
|
|
32
|
+
- Start with a 1-2 sentence summary of WHAT this PR does and WHY
|
|
33
|
+
- Then a "## What Changed" section with 3-6 bullet points grouped by theme (not one per commit)
|
|
34
|
+
- Focus on user-visible impact, not implementation details
|
|
35
|
+
- Do not repeat commit hashes or the full commit log
|
|
36
|
+
- Do not include a review section (that's added separately)
|
|
37
|
+
- Use markdown formatting`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (aiResult.text && aiResult.fromAI) {
|
|
41
|
+
return `${aiResult.text.trim()}
|
|
42
|
+
|
|
43
|
+
## Review
|
|
44
|
+
|
|
45
|
+
${reviewSummary}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback: structured commit list
|
|
49
|
+
return `## Summary
|
|
50
|
+
|
|
51
|
+
${commits.length} commit(s) in this PR.
|
|
52
|
+
|
|
53
|
+
## What Changed
|
|
54
|
+
|
|
55
|
+
${commitList}
|
|
56
|
+
|
|
57
|
+
## Review
|
|
58
|
+
|
|
59
|
+
${reviewSummary}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Result } from "../db/index";
|
|
2
|
+
import { tryAIGenerate } from "./try-generate";
|
|
3
|
+
|
|
4
|
+
export interface SpecQuestion {
|
|
5
|
+
question: string;
|
|
6
|
+
type: "text" | "select";
|
|
7
|
+
options?: string[];
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_QUESTIONS = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates clarifying questions from plan.md content by asking the AI
|
|
15
|
+
* to identify ambiguities, missing edge cases, and unstated assumptions.
|
|
16
|
+
*
|
|
17
|
+
* Returns an empty array when:
|
|
18
|
+
* - Plan content is empty
|
|
19
|
+
* - AI is not available (no API key)
|
|
20
|
+
* - AI returns malformed JSON
|
|
21
|
+
*/
|
|
22
|
+
export async function generateSpecQuestions(
|
|
23
|
+
planContent: string,
|
|
24
|
+
mainaDir: string,
|
|
25
|
+
): Promise<Result<SpecQuestion[], string>> {
|
|
26
|
+
if (!planContent.trim()) {
|
|
27
|
+
return { ok: true, value: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = await tryAIGenerate(
|
|
31
|
+
"spec-questions",
|
|
32
|
+
mainaDir,
|
|
33
|
+
{ plan: planContent },
|
|
34
|
+
`Analyze this implementation plan and return 3-5 clarifying questions as a JSON array.\n\n${planContent}`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!result.text) {
|
|
38
|
+
return { ok: true, value: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const parsed = parseQuestionsJSON(result.text);
|
|
43
|
+
const validated = parsed.filter(isValidQuestion);
|
|
44
|
+
return { ok: true, value: validated.slice(0, MAX_QUESTIONS) };
|
|
45
|
+
} catch {
|
|
46
|
+
return { ok: true, value: [] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseQuestionsJSON(text: string): unknown[] {
|
|
51
|
+
// Strip markdown code fences if present
|
|
52
|
+
let cleaned = text.trim();
|
|
53
|
+
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
54
|
+
if (fenceMatch?.[1]) {
|
|
55
|
+
cleaned = fenceMatch[1].trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parsed: unknown = JSON.parse(cleaned);
|
|
59
|
+
if (!Array.isArray(parsed)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidQuestion(item: unknown): item is SpecQuestion {
|
|
66
|
+
if (typeof item !== "object" || item === null) return false;
|
|
67
|
+
const obj = item as Record<string, unknown>;
|
|
68
|
+
return (
|
|
69
|
+
typeof obj.question === "string" &&
|
|
70
|
+
obj.question.length > 0 &&
|
|
71
|
+
(obj.type === "text" || obj.type === "select") &&
|
|
72
|
+
typeof obj.reason === "string"
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/ai/tiers.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MainaConfig } from "../config/index";
|
|
2
|
+
|
|
3
|
+
export type ModelTier = "mechanical" | "standard" | "architectural" | "local";
|
|
4
|
+
|
|
5
|
+
export interface ModelResolution {
|
|
6
|
+
tier: ModelTier;
|
|
7
|
+
modelId: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MECHANICAL_TASKS = new Set([
|
|
12
|
+
"commit",
|
|
13
|
+
"tests",
|
|
14
|
+
"slop",
|
|
15
|
+
"compress",
|
|
16
|
+
"code-review",
|
|
17
|
+
]);
|
|
18
|
+
const ARCHITECTURAL_TASKS = new Set(["design-review", "architecture", "learn"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maps a task name to its model tier.
|
|
22
|
+
* - mechanical: commit, tests, slop, compress
|
|
23
|
+
* - standard: review, plan, design, fix (and any unknown task)
|
|
24
|
+
* - architectural: design-review, architecture, learn
|
|
25
|
+
* - local: not auto-assigned; user must explicitly set
|
|
26
|
+
*/
|
|
27
|
+
export function getTaskTier(task: string): ModelTier {
|
|
28
|
+
if (MECHANICAL_TASKS.has(task)) {
|
|
29
|
+
return "mechanical";
|
|
30
|
+
}
|
|
31
|
+
if (ARCHITECTURAL_TASKS.has(task)) {
|
|
32
|
+
return "architectural";
|
|
33
|
+
}
|
|
34
|
+
// standard is the default for known standard tasks and all unknowns
|
|
35
|
+
return "standard";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the model ID and provider for a given task using the provided config.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveModel(
|
|
42
|
+
task: string,
|
|
43
|
+
config: MainaConfig,
|
|
44
|
+
): ModelResolution {
|
|
45
|
+
const tier = getTaskTier(task);
|
|
46
|
+
const modelId = config.models[tier];
|
|
47
|
+
return {
|
|
48
|
+
tier,
|
|
49
|
+
modelId,
|
|
50
|
+
provider: config.provider,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getApiKey, isHostMode } from "../config/index";
|
|
2
|
+
|
|
3
|
+
export interface DelegationPrompt {
|
|
4
|
+
task: string;
|
|
5
|
+
systemPrompt: string;
|
|
6
|
+
userPrompt: string;
|
|
7
|
+
promptHash: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TryAIResult {
|
|
11
|
+
text: string | null;
|
|
12
|
+
fromAI: boolean;
|
|
13
|
+
hostDelegation: boolean;
|
|
14
|
+
promptHash?: string;
|
|
15
|
+
/** Structured prompt for host to process when hostDelegation is true */
|
|
16
|
+
delegation?: DelegationPrompt;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single entry point for all AI calls.
|
|
21
|
+
*
|
|
22
|
+
* Returns:
|
|
23
|
+
* - { text, fromAI: true } when AI generates a response (has API key)
|
|
24
|
+
* - { text: null, hostDelegation: true, delegation } when in host mode (no key)
|
|
25
|
+
* The delegation contains structured prompts for the host agent to process.
|
|
26
|
+
* - { text: null } when AI is not available and not in host mode
|
|
27
|
+
*/
|
|
28
|
+
export async function tryAIGenerate(
|
|
29
|
+
task: string,
|
|
30
|
+
mainaDir: string,
|
|
31
|
+
variables: Record<string, string>,
|
|
32
|
+
userPrompt: string,
|
|
33
|
+
): Promise<TryAIResult> {
|
|
34
|
+
const apiKey = getApiKey();
|
|
35
|
+
|
|
36
|
+
// Not in host mode and no key → unavailable
|
|
37
|
+
if (!apiKey && !isHostMode()) {
|
|
38
|
+
return { text: null, fromAI: false, hostDelegation: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const { buildSystemPrompt } = await import("../prompts/engine");
|
|
43
|
+
|
|
44
|
+
// Build the prompt (needed for both direct call and delegation)
|
|
45
|
+
const builtPrompt = await buildSystemPrompt(task, mainaDir, variables);
|
|
46
|
+
|
|
47
|
+
// If we have an API key, make the direct call
|
|
48
|
+
if (apiKey) {
|
|
49
|
+
const { generate } = await import("./index");
|
|
50
|
+
const result = await generate({
|
|
51
|
+
task,
|
|
52
|
+
systemPrompt: builtPrompt.prompt,
|
|
53
|
+
userPrompt,
|
|
54
|
+
mainaDir,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Skip delegation responses from generate() — we handle it below
|
|
58
|
+
if (
|
|
59
|
+
result.text &&
|
|
60
|
+
!result.text.startsWith("[HOST_DELEGATION]") &&
|
|
61
|
+
!result.text.includes("API key")
|
|
62
|
+
) {
|
|
63
|
+
return {
|
|
64
|
+
text: result.text,
|
|
65
|
+
fromAI: true,
|
|
66
|
+
hostDelegation: false,
|
|
67
|
+
promptHash: builtPrompt.hash,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Host mode — return structured delegation for host agent to process
|
|
73
|
+
return {
|
|
74
|
+
text: null,
|
|
75
|
+
fromAI: false,
|
|
76
|
+
hostDelegation: true,
|
|
77
|
+
promptHash: builtPrompt.hash,
|
|
78
|
+
delegation: {
|
|
79
|
+
task,
|
|
80
|
+
systemPrompt: builtPrompt.prompt,
|
|
81
|
+
userPrompt,
|
|
82
|
+
promptHash: builtPrompt.hash,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
// AI failure — return null for fallback
|
|
87
|
+
}
|
|
88
|
+
return { text: null, fromAI: false, hostDelegation: false };
|
|
89
|
+
}
|