agent-security-scanner-mcp 3.20.0 → 4.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 +144 -43
- package/code-review-agent/.env.example +8 -0
- package/code-review-agent/README.md +142 -0
- package/code-review-agent/TODO.md +149 -0
- package/code-review-agent/bin/cr-agent.ts +313 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts +3 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts.map +1 -0
- package/code-review-agent/dist/bin/cr-agent.js +299 -0
- package/code-review-agent/dist/bin/cr-agent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts +16 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.js +298 -0
- package/code-review-agent/dist/src/analyzer/engine.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts +10 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.js +40 -0
- package/code-review-agent/dist/src/analyzer/intent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts +19 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.js +150 -0
- package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -0
- package/code-review-agent/dist/src/context/assembler.d.ts +16 -0
- package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/assembler.js +135 -0
- package/code-review-agent/dist/src/context/assembler.js.map +1 -0
- package/code-review-agent/dist/src/context/file.d.ts +6 -0
- package/code-review-agent/dist/src/context/file.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/file.js +139 -0
- package/code-review-agent/dist/src/context/file.js.map +1 -0
- package/code-review-agent/dist/src/context/project.d.ts +4 -0
- package/code-review-agent/dist/src/context/project.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/project.js +252 -0
- package/code-review-agent/dist/src/context/project.js.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts +11 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.js +102 -0
- package/code-review-agent/dist/src/graph/dependency.js.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts +9 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.js +124 -0
- package/code-review-agent/dist/src/graph/resolver.js.map +1 -0
- package/code-review-agent/dist/src/index.d.ts +21 -0
- package/code-review-agent/dist/src/index.d.ts.map +1 -0
- package/code-review-agent/dist/src/index.js +21 -0
- package/code-review-agent/dist/src/index.js.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts +13 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.js +83 -0
- package/code-review-agent/dist/src/llm/anthropic.js.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts +13 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.js +142 -0
- package/code-review-agent/dist/src/llm/claude-cli.js.map +1 -0
- package/code-review-agent/dist/src/llm/openai.d.ts +13 -0
- package/code-review-agent/dist/src/llm/openai.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/openai.js +78 -0
- package/code-review-agent/dist/src/llm/openai.js.map +1 -0
- package/code-review-agent/dist/src/llm/provider.d.ts +18 -0
- package/code-review-agent/dist/src/llm/provider.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/provider.js +11 -0
- package/code-review-agent/dist/src/llm/provider.js.map +1 -0
- package/code-review-agent/dist/src/llm/router.d.ts +14 -0
- package/code-review-agent/dist/src/llm/router.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/router.js +67 -0
- package/code-review-agent/dist/src/llm/router.js.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts +18 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.js +91 -0
- package/code-review-agent/dist/src/llm/schemas.js.map +1 -0
- package/code-review-agent/dist/src/types/analysis.d.ts +56 -0
- package/code-review-agent/dist/src/types/analysis.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/analysis.js +2 -0
- package/code-review-agent/dist/src/types/analysis.js.map +1 -0
- package/code-review-agent/dist/src/types/config.d.ts +24 -0
- package/code-review-agent/dist/src/types/config.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/config.js +42 -0
- package/code-review-agent/dist/src/types/config.js.map +1 -0
- package/code-review-agent/dist/src/types/findings.d.ts +236 -0
- package/code-review-agent/dist/src/types/findings.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/findings.js +64 -0
- package/code-review-agent/dist/src/types/findings.js.map +1 -0
- package/code-review-agent/package.json +36 -0
- package/code-review-agent/src/analyzer/engine.ts +374 -0
- package/code-review-agent/src/analyzer/intent.ts +49 -0
- package/code-review-agent/src/analyzer/semantic.ts +222 -0
- package/code-review-agent/src/context/assembler.ts +165 -0
- package/code-review-agent/src/context/file.ts +145 -0
- package/code-review-agent/src/context/project.ts +253 -0
- package/code-review-agent/src/graph/dependency.ts +116 -0
- package/code-review-agent/src/graph/resolver.ts +138 -0
- package/code-review-agent/src/index.ts +58 -0
- package/code-review-agent/src/llm/anthropic.ts +106 -0
- package/code-review-agent/src/llm/claude-cli.ts +188 -0
- package/code-review-agent/src/llm/openai.ts +95 -0
- package/code-review-agent/src/llm/provider.ts +33 -0
- package/code-review-agent/src/llm/router.ts +86 -0
- package/code-review-agent/src/llm/schemas.ts +125 -0
- package/code-review-agent/src/types/analysis.ts +62 -0
- package/code-review-agent/src/types/config.ts +72 -0
- package/code-review-agent/src/types/findings.ts +81 -0
- package/code-review-agent/tests/analyzer/engine.test.ts +194 -0
- package/code-review-agent/tests/analyzer/intent.test.ts +76 -0
- package/code-review-agent/tests/analyzer/semantic.test.ts +131 -0
- package/code-review-agent/tests/context/file.test.ts +21 -0
- package/code-review-agent/tests/context/project.test.ts +20 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/README.md +19 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/builder.js +52 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/README.md +16 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/organizer.py +70 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/README.md +17 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/server.js +52 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/README.md +18 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/checkout.js +63 -0
- package/code-review-agent/tests/graph/dependency.test.ts +136 -0
- package/code-review-agent/tests/helpers/mock-provider.ts +48 -0
- package/code-review-agent/tests/llm/claude-cli.test.ts +251 -0
- package/code-review-agent/tests/llm/router.test.ts +77 -0
- package/code-review-agent/tests/llm/schemas.test.ts +142 -0
- package/code-review-agent/tsconfig.json +20 -0
- package/code-review-agent/vitest.config.ts +11 -0
- package/index.js +18 -18
- package/openclaw.plugin.json +2 -2
- package/package.json +13 -3
- package/server.json +3 -3
- package/src/cli/init-hooks.js +3 -3
- package/src/cli/init.js +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface ImportInfo {
|
|
5
|
+
specifier: string;
|
|
6
|
+
isLocal: boolean;
|
|
7
|
+
resolved: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const JS_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
|
|
11
|
+
const PY_EXTENSIONS = ['.py'];
|
|
12
|
+
|
|
13
|
+
export function extractImports(content: string, language: string): ImportInfo[] {
|
|
14
|
+
const imports: ImportInfo[] = [];
|
|
15
|
+
|
|
16
|
+
if (['javascript', 'typescript'].includes(language)) {
|
|
17
|
+
// ES imports
|
|
18
|
+
for (const m of content.matchAll(/import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g)) {
|
|
19
|
+
const spec = m[1];
|
|
20
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
21
|
+
}
|
|
22
|
+
// Re-exports: export { ... } from '...', export * from '...', export { default } from '...'
|
|
23
|
+
for (const m of content.matchAll(/export\s+(?:\*|{[^}]*})\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
24
|
+
const spec = m[1];
|
|
25
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
26
|
+
}
|
|
27
|
+
// require
|
|
28
|
+
for (const m of content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
29
|
+
const spec = m[1];
|
|
30
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
31
|
+
}
|
|
32
|
+
// dynamic import
|
|
33
|
+
for (const m of content.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
34
|
+
const spec = m[1];
|
|
35
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
36
|
+
}
|
|
37
|
+
} else if (language === 'python') {
|
|
38
|
+
// from .module import ... (relative) and from package import ... (absolute)
|
|
39
|
+
for (const m of content.matchAll(/from\s+(\S+)\s+import/g)) {
|
|
40
|
+
const spec = m[1];
|
|
41
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
42
|
+
}
|
|
43
|
+
// import module
|
|
44
|
+
for (const m of content.matchAll(/^import\s+(\S+)/gm)) {
|
|
45
|
+
const spec = m[1];
|
|
46
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
47
|
+
}
|
|
48
|
+
} else if (language === 'go') {
|
|
49
|
+
for (const m of content.matchAll(/["']([^"']+)["']/g)) {
|
|
50
|
+
const spec = m[1];
|
|
51
|
+
if (spec.includes('/')) {
|
|
52
|
+
imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return imports;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveImportPath(
|
|
61
|
+
specifier: string,
|
|
62
|
+
fromFile: string,
|
|
63
|
+
language: string,
|
|
64
|
+
): string | null {
|
|
65
|
+
if (!isLocalImport(specifier, language)) return null;
|
|
66
|
+
|
|
67
|
+
const fromDir = path.dirname(fromFile);
|
|
68
|
+
|
|
69
|
+
if (['javascript', 'typescript'].includes(language)) {
|
|
70
|
+
// Try exact path first, then with extensions, then index files
|
|
71
|
+
const candidates: string[] = [];
|
|
72
|
+
const base = path.resolve(fromDir, specifier);
|
|
73
|
+
|
|
74
|
+
candidates.push(base);
|
|
75
|
+
for (const ext of JS_EXTENSIONS) {
|
|
76
|
+
candidates.push(base + ext);
|
|
77
|
+
}
|
|
78
|
+
for (const ext of JS_EXTENSIONS) {
|
|
79
|
+
candidates.push(path.join(base, `index${ext}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
try {
|
|
84
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
85
|
+
} catch { /* not found */ }
|
|
86
|
+
}
|
|
87
|
+
} else if (language === 'python') {
|
|
88
|
+
// Handle relative imports: .utils, ..models, .sub.module
|
|
89
|
+
let resolveDir = fromDir;
|
|
90
|
+
let moduleSpec = specifier;
|
|
91
|
+
|
|
92
|
+
if (specifier.startsWith('.')) {
|
|
93
|
+
// Count leading dots for parent traversal
|
|
94
|
+
const dotMatch = specifier.match(/^(\.+)(.*)/);
|
|
95
|
+
if (dotMatch) {
|
|
96
|
+
const dots = dotMatch[1].length;
|
|
97
|
+
moduleSpec = dotMatch[2]; // remainder after dots (may be empty for bare ".")
|
|
98
|
+
// Each dot beyond the first goes up one directory
|
|
99
|
+
resolveDir = fromDir;
|
|
100
|
+
for (let i = 1; i < dots; i++) {
|
|
101
|
+
resolveDir = path.dirname(resolveDir);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Convert module.path to filesystem path
|
|
107
|
+
const modulePath = moduleSpec ? moduleSpec.replace(/\./g, path.sep) : '';
|
|
108
|
+
const base = modulePath ? path.resolve(resolveDir, modulePath) : resolveDir;
|
|
109
|
+
|
|
110
|
+
for (const ext of PY_EXTENSIONS) {
|
|
111
|
+
try {
|
|
112
|
+
const candidate = base + ext;
|
|
113
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
114
|
+
} catch { /* not found */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Try as package (directory with __init__.py)
|
|
118
|
+
try {
|
|
119
|
+
const initFile = path.join(base, '__init__.py');
|
|
120
|
+
if (fs.statSync(initFile).isFile()) return initFile;
|
|
121
|
+
} catch { /* not found */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isLocalImport(specifier: string, language: string): boolean {
|
|
128
|
+
if (['javascript', 'typescript'].includes(language)) {
|
|
129
|
+
return specifier.startsWith('./') || specifier.startsWith('../');
|
|
130
|
+
}
|
|
131
|
+
if (language === 'python') {
|
|
132
|
+
return specifier.startsWith('.');
|
|
133
|
+
}
|
|
134
|
+
if (language === 'go') {
|
|
135
|
+
return !specifier.includes('.');
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Core engine
|
|
2
|
+
export { AnalysisEngine, type ProgressCallback } from './analyzer/engine.js';
|
|
3
|
+
export { IntentProfiler } from './analyzer/intent.js';
|
|
4
|
+
export { SemanticAnalyzer } from './analyzer/semantic.js';
|
|
5
|
+
|
|
6
|
+
// LLM providers
|
|
7
|
+
export { AnthropicProvider } from './llm/anthropic.js';
|
|
8
|
+
export { ClaudeCliProvider } from './llm/claude-cli.js';
|
|
9
|
+
export { OpenAIProvider } from './llm/openai.js';
|
|
10
|
+
export { ModelRouter } from './llm/router.js';
|
|
11
|
+
export type { LLMProvider, ChatMessage } from './llm/provider.js';
|
|
12
|
+
export { SchemaValidationError } from './llm/provider.js';
|
|
13
|
+
export { zodToJsonSchema, zodToAnthropicTool, zodToOpenAIResponseFormat } from './llm/schemas.js';
|
|
14
|
+
|
|
15
|
+
// Context
|
|
16
|
+
export { buildProjectContext, formatProjectContextForLLM } from './context/project.js';
|
|
17
|
+
export { buildFileContext, isTestFile, isConfigFile, isGeneratedFile } from './context/file.js';
|
|
18
|
+
export { ContextAssembler } from './context/assembler.js';
|
|
19
|
+
|
|
20
|
+
// Graph
|
|
21
|
+
export { DependencyGraphBuilder } from './graph/dependency.js';
|
|
22
|
+
export { resolveImportPath, extractImports, isLocalImport } from './graph/resolver.js';
|
|
23
|
+
|
|
24
|
+
// Types
|
|
25
|
+
export type {
|
|
26
|
+
AnalysisResult,
|
|
27
|
+
AnalysisStats,
|
|
28
|
+
FileAnalysisResult,
|
|
29
|
+
ProjectContext,
|
|
30
|
+
FileContext,
|
|
31
|
+
DependencyNode,
|
|
32
|
+
DependencyGraph,
|
|
33
|
+
} from './types/analysis.js';
|
|
34
|
+
export type {
|
|
35
|
+
AnalysisOptions,
|
|
36
|
+
CRAgentConfig,
|
|
37
|
+
} from './types/config.js';
|
|
38
|
+
export { loadConfig, resolveOptions } from './types/config.js';
|
|
39
|
+
export {
|
|
40
|
+
FindingSchema,
|
|
41
|
+
FileAnalysisResponseSchema,
|
|
42
|
+
IntentProfileSchema,
|
|
43
|
+
TriageDecisionSchema,
|
|
44
|
+
SeveritySchema,
|
|
45
|
+
CategorySchema,
|
|
46
|
+
IntentAlignmentSchema,
|
|
47
|
+
RiskDomainSchema,
|
|
48
|
+
} from './types/findings.js';
|
|
49
|
+
export type {
|
|
50
|
+
Finding,
|
|
51
|
+
FileAnalysisResponse,
|
|
52
|
+
IntentProfile,
|
|
53
|
+
TriageDecision,
|
|
54
|
+
Severity,
|
|
55
|
+
Category,
|
|
56
|
+
IntentAlignment,
|
|
57
|
+
RiskDomain,
|
|
58
|
+
} from './types/findings.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { countTokens } from '@anthropic-ai/tokenizer';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
|
|
5
|
+
import { zodToAnthropicTool } from './schemas.js';
|
|
6
|
+
|
|
7
|
+
const MAX_RETRIES = 3;
|
|
8
|
+
|
|
9
|
+
export class AnthropicProvider implements LLMProvider {
|
|
10
|
+
private client: Anthropic;
|
|
11
|
+
readonly modelId: string;
|
|
12
|
+
readonly providerName = 'anthropic';
|
|
13
|
+
|
|
14
|
+
constructor(apiKey: string, model?: string) {
|
|
15
|
+
this.client = new Anthropic({ apiKey });
|
|
16
|
+
this.modelId = model ?? 'claude-sonnet-4-20250514';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async chat(messages: ChatMessage[]): Promise<string> {
|
|
20
|
+
const { system, userMessages } = this.splitMessages(messages);
|
|
21
|
+
|
|
22
|
+
const response = await this.client.messages.create({
|
|
23
|
+
model: this.modelId,
|
|
24
|
+
max_tokens: 4096,
|
|
25
|
+
system: system ?? undefined,
|
|
26
|
+
messages: userMessages,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const textBlock = response.content.find((b) => b.type === 'text');
|
|
30
|
+
return textBlock?.text ?? '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async chatStructured<T>(
|
|
34
|
+
messages: ChatMessage[],
|
|
35
|
+
schema: z.ZodType<T>,
|
|
36
|
+
schemaName: string,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const tool = zodToAnthropicTool(schema, schemaName, `Respond with ${schemaName}`);
|
|
39
|
+
const { system, userMessages } = this.splitMessages(messages);
|
|
40
|
+
|
|
41
|
+
let lastError: Error | null = null;
|
|
42
|
+
const conversationMessages = [...userMessages];
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
45
|
+
const response = await this.client.messages.create({
|
|
46
|
+
model: this.modelId,
|
|
47
|
+
max_tokens: 8192,
|
|
48
|
+
system: system ?? undefined,
|
|
49
|
+
messages: conversationMessages,
|
|
50
|
+
tools: [tool as Anthropic.Tool],
|
|
51
|
+
tool_choice: { type: 'tool' as const, name: schemaName },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const toolBlock = response.content.find((b) => b.type === 'tool_use');
|
|
55
|
+
if (!toolBlock || toolBlock.type !== 'tool_use') {
|
|
56
|
+
lastError = new Error('No tool_use block in response');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = schema.safeParse(toolBlock.input);
|
|
61
|
+
if (result.success) {
|
|
62
|
+
return result.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lastError = new Error(result.error.message);
|
|
66
|
+
|
|
67
|
+
// Append error feedback for retry
|
|
68
|
+
conversationMessages.push({
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
content: JSON.stringify(toolBlock.input),
|
|
71
|
+
});
|
|
72
|
+
conversationMessages.push({
|
|
73
|
+
role: 'user',
|
|
74
|
+
content: `Schema validation error: ${result.error.message}. Please fix and respond again.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new SchemaValidationError(MAX_RETRIES, lastError!);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
countTokens(text: string): number {
|
|
82
|
+
try {
|
|
83
|
+
return countTokens(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return Math.ceil(text.length / 4);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private splitMessages(messages: ChatMessage[]): {
|
|
90
|
+
system: string | null;
|
|
91
|
+
userMessages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
|
92
|
+
} {
|
|
93
|
+
let system: string | null = null;
|
|
94
|
+
const userMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
|
95
|
+
|
|
96
|
+
for (const msg of messages) {
|
|
97
|
+
if (msg.role === 'system') {
|
|
98
|
+
system = msg.content;
|
|
99
|
+
} else {
|
|
100
|
+
userMessages.push({ role: msg.role, content: msg.content });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { system, userMessages };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
|
|
4
|
+
import { zodToJsonSchema } from './schemas.js';
|
|
5
|
+
|
|
6
|
+
const MAX_RETRIES = 3;
|
|
7
|
+
|
|
8
|
+
interface ClaudeCliResult {
|
|
9
|
+
type: string;
|
|
10
|
+
result: string;
|
|
11
|
+
is_error: boolean;
|
|
12
|
+
usage?: {
|
|
13
|
+
input_tokens?: number;
|
|
14
|
+
output_tokens?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ClaudeCliProvider implements LLMProvider {
|
|
19
|
+
readonly modelId: string;
|
|
20
|
+
readonly providerName = 'claude-cli';
|
|
21
|
+
|
|
22
|
+
constructor(model?: string) {
|
|
23
|
+
this.modelId = model ?? 'sonnet';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async chat(messages: ChatMessage[]): Promise<string> {
|
|
27
|
+
const prompt = this.formatMessages(messages);
|
|
28
|
+
return this.runClaude(prompt);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async chatStructured<T>(
|
|
32
|
+
messages: ChatMessage[],
|
|
33
|
+
schema: z.ZodType<T>,
|
|
34
|
+
schemaName: string,
|
|
35
|
+
): Promise<T> {
|
|
36
|
+
const jsonSchema = zodToJsonSchema(schema);
|
|
37
|
+
const schemaInstruction = [
|
|
38
|
+
`You MUST respond with ONLY a valid JSON object matching this schema (no markdown, no explanation, no wrapping):`,
|
|
39
|
+
`Schema name: ${schemaName}`,
|
|
40
|
+
'```json',
|
|
41
|
+
JSON.stringify(jsonSchema, null, 2),
|
|
42
|
+
'```',
|
|
43
|
+
'Respond with ONLY the JSON object. No other text.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
|
|
46
|
+
let lastError: Error | null = null;
|
|
47
|
+
const conversationParts = [...messages];
|
|
48
|
+
|
|
49
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
50
|
+
const prompt = this.formatMessages([
|
|
51
|
+
...conversationParts,
|
|
52
|
+
{ role: 'user', content: schemaInstruction },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const raw = await this.runClaude(prompt);
|
|
56
|
+
|
|
57
|
+
// Extract JSON from response (handle markdown code blocks)
|
|
58
|
+
const jsonStr = extractJson(raw);
|
|
59
|
+
|
|
60
|
+
let parsed: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(jsonStr);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
65
|
+
conversationParts.push(
|
|
66
|
+
{ role: 'assistant', content: raw },
|
|
67
|
+
{ role: 'user', content: `That was not valid JSON. Parse error: ${lastError.message}. Respond with ONLY a valid JSON object.` },
|
|
68
|
+
);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = schema.safeParse(parsed);
|
|
73
|
+
if (result.success) {
|
|
74
|
+
return result.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
lastError = new Error(result.error.message);
|
|
78
|
+
conversationParts.push(
|
|
79
|
+
{ role: 'assistant', content: raw },
|
|
80
|
+
{ role: 'user', content: `Schema validation error: ${result.error.message}. Fix the JSON and respond with ONLY the corrected JSON object.` },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new SchemaValidationError(MAX_RETRIES, lastError!);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
countTokens(text: string): number {
|
|
88
|
+
// Approximate — Claude CLI doesn't expose a token counter
|
|
89
|
+
return Math.ceil(text.length / 4);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private formatMessages(messages: ChatMessage[]): string {
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
|
|
95
|
+
for (const msg of messages) {
|
|
96
|
+
if (msg.role === 'system') {
|
|
97
|
+
parts.push(`[System Instructions]\n${msg.content}\n`);
|
|
98
|
+
} else if (msg.role === 'user') {
|
|
99
|
+
parts.push(`${msg.content}`);
|
|
100
|
+
} else if (msg.role === 'assistant') {
|
|
101
|
+
parts.push(`[Previous response]\n${msg.content}\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parts.join('\n\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private runClaude(prompt: string): Promise<string> {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const args = [
|
|
111
|
+
'-p', '-',
|
|
112
|
+
'--output-format', 'json',
|
|
113
|
+
'--model', this.modelId,
|
|
114
|
+
'--no-session-persistence',
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const child = spawn('claude', args, {
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
|
+
timeout: 180_000,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
let stdout = '';
|
|
123
|
+
let stderr = '';
|
|
124
|
+
|
|
125
|
+
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
126
|
+
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
127
|
+
|
|
128
|
+
child.on('close', (code) => {
|
|
129
|
+
if (code !== 0 && !stdout) {
|
|
130
|
+
// Sanitize stderr — only show first 200 chars, never leak prompt content
|
|
131
|
+
const safeStderr = stderr ? sanitizeError(stderr) : '';
|
|
132
|
+
reject(new Error(`claude CLI exited with code ${code}${safeStderr ? `: ${safeStderr}` : ''}`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const result = JSON.parse(stdout) as ClaudeCliResult;
|
|
138
|
+
if (result.is_error) {
|
|
139
|
+
reject(new Error(`claude CLI error: ${sanitizeError(result.result)}`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
resolve(result.result);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(stdout.trim());
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
child.on('error', (err) => {
|
|
149
|
+
reject(new Error(`claude CLI failed to start: ${sanitizeError(err.message)}`));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Handle stdin write errors (e.g., broken pipe if child exits early)
|
|
153
|
+
child.stdin.on('error', (err) => {
|
|
154
|
+
// Ignore EPIPE — child may have already exited, close handler will deal with it
|
|
155
|
+
if ((err as NodeJS.ErrnoException).code !== 'EPIPE') {
|
|
156
|
+
reject(new Error(`Failed to write prompt to claude CLI: ${sanitizeError(err.message)}`));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Write prompt to stdin and close it
|
|
161
|
+
child.stdin.write(prompt);
|
|
162
|
+
child.stdin.end();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sanitizeError(msg: string): string {
|
|
168
|
+
// Never leak prompt content in errors — truncate and strip anything
|
|
169
|
+
// that looks like it could be prompt/code/schema content
|
|
170
|
+
const firstLine = msg.split('\n')[0].trim();
|
|
171
|
+
return firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function extractJson(text: string): string {
|
|
175
|
+
// Try to extract JSON from markdown code blocks
|
|
176
|
+
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
177
|
+
if (codeBlockMatch) {
|
|
178
|
+
return codeBlockMatch[1].trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Try to find a JSON object directly
|
|
182
|
+
const objectMatch = text.match(/\{[\s\S]*\}/);
|
|
183
|
+
if (objectMatch) {
|
|
184
|
+
return objectMatch[0];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return text.trim();
|
|
188
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { type Tiktoken, encoding_for_model } from 'tiktoken';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
|
|
5
|
+
import { zodToOpenAIResponseFormat } from './schemas.js';
|
|
6
|
+
|
|
7
|
+
const MAX_RETRIES = 3;
|
|
8
|
+
|
|
9
|
+
export class OpenAIProvider implements LLMProvider {
|
|
10
|
+
private client: OpenAI;
|
|
11
|
+
private encoder: Tiktoken | null = null;
|
|
12
|
+
readonly modelId: string;
|
|
13
|
+
readonly providerName = 'openai';
|
|
14
|
+
|
|
15
|
+
constructor(apiKey: string, model?: string) {
|
|
16
|
+
this.client = new OpenAI({ apiKey });
|
|
17
|
+
this.modelId = model ?? 'gpt-4o';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async chat(messages: ChatMessage[]): Promise<string> {
|
|
21
|
+
const response = await this.client.chat.completions.create({
|
|
22
|
+
model: this.modelId,
|
|
23
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
24
|
+
max_tokens: 4096,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return response.choices[0]?.message?.content ?? '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async chatStructured<T>(
|
|
31
|
+
messages: ChatMessage[],
|
|
32
|
+
schema: z.ZodType<T>,
|
|
33
|
+
schemaName: string,
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
const responseFormat = zodToOpenAIResponseFormat(schema, schemaName);
|
|
36
|
+
|
|
37
|
+
let lastError: Error | null = null;
|
|
38
|
+
const conversationMessages = messages.map((m) => ({
|
|
39
|
+
role: m.role,
|
|
40
|
+
content: m.content,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
44
|
+
const response = await this.client.chat.completions.create({
|
|
45
|
+
model: this.modelId,
|
|
46
|
+
messages: conversationMessages,
|
|
47
|
+
max_tokens: 8192,
|
|
48
|
+
response_format: responseFormat,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const content = response.choices[0]?.message?.content;
|
|
52
|
+
if (!content) {
|
|
53
|
+
lastError = new Error('Empty response from OpenAI');
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(content);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = schema.safeParse(parsed);
|
|
66
|
+
if (result.success) {
|
|
67
|
+
return result.data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lastError = new Error(result.error.message);
|
|
71
|
+
|
|
72
|
+
conversationMessages.push({
|
|
73
|
+
role: 'assistant' as const,
|
|
74
|
+
content,
|
|
75
|
+
});
|
|
76
|
+
conversationMessages.push({
|
|
77
|
+
role: 'user' as const,
|
|
78
|
+
content: `Schema validation error: ${result.error.message}. Please fix and respond again.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new SchemaValidationError(MAX_RETRIES, lastError!);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
countTokens(text: string): number {
|
|
86
|
+
try {
|
|
87
|
+
if (!this.encoder) {
|
|
88
|
+
this.encoder = encoding_for_model(this.modelId as Parameters<typeof encoding_for_model>[0]);
|
|
89
|
+
}
|
|
90
|
+
return this.encoder.encode(text).length;
|
|
91
|
+
} catch {
|
|
92
|
+
return Math.ceil(text.length / 4);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export interface ChatMessage {
|
|
4
|
+
role: 'system' | 'user' | 'assistant';
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LLMProvider {
|
|
9
|
+
readonly modelId: string;
|
|
10
|
+
readonly providerName: string;
|
|
11
|
+
|
|
12
|
+
chat(messages: ChatMessage[]): Promise<string>;
|
|
13
|
+
|
|
14
|
+
chatStructured<T>(
|
|
15
|
+
messages: ChatMessage[],
|
|
16
|
+
schema: z.ZodType<T>,
|
|
17
|
+
schemaName: string,
|
|
18
|
+
): Promise<T>;
|
|
19
|
+
|
|
20
|
+
countTokens(text: string): number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SchemaValidationError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
public readonly attempts: number,
|
|
26
|
+
public readonly lastError: Error,
|
|
27
|
+
) {
|
|
28
|
+
super(
|
|
29
|
+
`Schema validation failed after ${attempts} attempts: ${lastError.message}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = 'SchemaValidationError';
|
|
32
|
+
}
|
|
33
|
+
}
|