Cody Agent: reuse TypeScript logic between JetBrains and VS Code (#53370)

Previously, the JetBrains plugin had to manually translate all the
prompt logic from TypeScript from Java. This commit fixes the problem
by introducing a Cody "agent" that runs as a JSON-RPC server and
encapsulates all the prompt logic. The JetBrains plugin launches the
Cody agent server during startup and communicates via the agent protocol
to accomplish tasks like listing recipes and executing recipes.

This commit only updates the JetBrains plugin to use the "chat-question"
recipe. Remaining functionality like other recipes and completions will
need to migrate to the agent in follow-up PRs.

Based on https://github.com/sourcegraph/sourcegraph/pull/52800 by
@SuperAuguste

More details in the RFC
https://docs.google.com/document/d/1KEJEd_Xx8STbJeGyOo86dkh3QRHQMs4CPw4XRAm7o7U/edit#heading=h.trqab8y0kufp

## Test plan

- Green CI.
- See new client/cody-agent/src/index.test.ts
- Manual testing with `./gradlew :runIde -PdisableAgent=false`. First, validate that chat works. Secondly, validate that `build/sourcegraph/cody-agent-trace.json` is non-empty.

<!-- All pull requests REQUIRE a test plan:
https://docs.sourcegraph.com/dev/background-information/testing_principles
-->
This commit is contained in:
Ólafur Páll Geirsson 2023-06-22 15:58:03 +02:00 committed by GitHub
parent 65e9fa5488
commit c320b41764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2100 additions and 40 deletions

View File

@ -11,6 +11,7 @@ client/cody-cli/node_modules
client/cody-icons-font/node_modules
client/cody-shared/node_modules
client/cody-slack/node_modules
client/cody-agent/node_modules
client/cody-ui/node_modules
client/cody-web/node_modules
client/common/node_modules

View File

@ -30,6 +30,8 @@ jobs:
- name: Gradle Wrapper Validation
uses: gradle/wrapper-validation-action@v1.0.4
- run: yarn global add pnpm
# Setup Java 11 environment for the next steps
- name: Setup Java
uses: actions/setup-java@v2

View File

@ -0,0 +1,2 @@
/dist/
/out/

View File

@ -0,0 +1,21 @@
// @ts-check
const baseConfig = require('../../.eslintrc')
module.exports = {
extends: '../../.eslintrc.js',
parserOptions: {
...baseConfig.parserOptions,
project: [__dirname + '/tsconfig.json'],
},
overrides: baseConfig.overrides,
rules: {
'no-console': 'off',
'id-length': 'off',
'no-restricted-imports': [
'error',
{
paths: ['!highlight.js'],
},
],
},
}

1
client/cody-agent/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/dist

49
client/cody-agent/BUILD.bazel generated Normal file
View File

@ -0,0 +1,49 @@
load("@aspect_rules_ts//ts:defs.bzl", "ts_config")
load("@npm//:defs.bzl", "npm_link_all_packages")
load("//dev:defs.bzl", "ts_project")
load("//dev:eslint.bzl", "eslint_config_and_lint_root")
npm_link_all_packages(name = "node_modules")
eslint_config_and_lint_root()
ts_config(
name = "tsconfig",
src = "tsconfig.json",
visibility = ["//client:__subpackages__"],
deps = [
"//:tsconfig",
"//client/cody-shared:tsconfig",
"//client/common:tsconfig",
],
)
ts_project(
name = "cody-agent",
srcs = [
"src/agent.ts",
"src/editor.ts",
"src/index.ts",
"src/jsonrpc.ts",
"src/offsets.ts",
"src/protocol.ts",
],
tsconfig = ":tsconfig",
deps = [
":node_modules/@sourcegraph/cody-shared",
"//:node_modules/@types/node",
],
)
ts_project(
name = "cody-agent_tests",
testonly = True,
srcs = [
"src/index.test.ts",
],
tsconfig = ":tsconfig",
deps = [
":cody-agent",
"//:node_modules/@types/node",
],
)

View File

@ -0,0 +1,3 @@
# See https://github.com/sourcegraph/codenotify for documentation.
src/protocol.ts @olafurpg

View File

@ -0,0 +1,54 @@
# Cody agent
The `@sourcegraph/cody-agent` package implements a JSON-RPC server to interact
with Cody via stdout/stdin. This package is intended to be used by
non-ECMAScript clients such as the JetBrains and NeoVim plugins.
## Protocol
The protocol is defined in the file [`src/protocol.ts`](src/protocol.ts). The
TypeScript code is the single source of truth of what JSON-RPC methods are
supported in the protocol.
## Updating the protocol
Directly edit the TypeScript source code to add new JSON-RPC methods or add
properties to existing data structures.
The agent is a new project that is being actively worked on at the time of this
writing. The protocol is subject to breaking changes without notice. Please
let us know if you are implementing an agent client.
## Client bindings
There's no tool to automatically generate bindings for the Cody agent protocol.
Currently, clients have to manually write bindings for the JSON-RPC methods.
## Useful commands
- The command `pnpm run build-agent-binaries` builds standalone binaries for
macOS, Linux, and Windows. By default, the binaries get written to the `dist/`
directory. The destination directory can be configured with the environment
variable `AGENT_EXECUTABLE_TARGET_DIRECTORY`.
- The command `pnpm run test` runs the agent against a minimized testing client.
The tests are disabled in CI because they run against uses an actual Sourcegraph
instance. Set the environment variables `SRC_ENDPOINT` and `SRC_ACCESS_TOKEN`
to run the tests against an actual Sourcegraph instance.
See the file [`src/index.test.ts`](src/index.test.ts) for a detailed but minimized example
interaction between an agent client and agent server.
## Client implementations
- The Sourcegraph JetBrains plugin is defined in the sibling directory
[`client/jetbrains`](../jetbrains/README.md). The file
[`CodyAgentClient.java`](../jetbrains/src/main/java/com/sourcegraph/agent/CodyAgentClient.java)
implements the client-side of the protocol.
## Miscellaneous notes
- By the nature of using JSON-RPC via stdin/stdout, both the agent server and
client run on the same computer and there can only be one client per server.
It's normal for both the client and server to be stateful processes. For
example, the `connectionConfiguration/didChange` notification is sent from the
client to the server to notify that subsequent requests should use the new
connection configuration.

View File

@ -0,0 +1,11 @@
// @ts-check
/** @type {import('@jest/types').Config.InitialOptions} */
const config = require('../../jest.config.base')
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
...config,
displayName: 'cody-agent',
rootDir: __dirname,
}

View File

@ -0,0 +1,29 @@
{
"private": true,
"name": "@sourcegraph/cody-agent",
"version": "0.0.1",
"description": "Cody JSON-RPC agent for consistent cross-editor support",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/sourcegraph/sourcegraph.git",
"directory": "client/cody-agent"
},
"main": "src/index.ts",
"sideEffects": false,
"scripts": {
"build": "esbuild ./src/index.ts --bundle --outfile=dist/agent.js --format=cjs --platform=node",
"build-ts": "tsc -b",
"build-agent-binaries": "pnpm run build && pkg -t node16-linux-arm64,node16-linux-x64,node16-macos-arm64,node16-macos-x64,node16-win-x64 dist/agent.js --out-path ${AGENT_EXECUTABLE_TARGET_DIRECTORY:-dist}",
"lint": "pnpm run lint:js",
"lint:js": "eslint --cache '**/*.[tj]s?(x)'",
"test": "pnpm run build && jest"
},
"dependencies": {
"@sourcegraph/cody-shared": "workspace:*",
"@sourcegraph/common": "workspace:*"
},
"devDependencies": {
"pkg": "^5.8.1"
}
}

View File

@ -0,0 +1,100 @@
import { Client, createClient } from '@sourcegraph/cody-shared/src/chat/client'
import { registeredRecipes } from '@sourcegraph/cody-shared/src/chat/recipes/agent-recipes'
import { SourcegraphNodeCompletionsClient } from '@sourcegraph/cody-shared/src/sourcegraph-api/completions/nodeClient'
import { AgentEditor } from './editor'
import { MessageHandler } from './jsonrpc'
import { ConnectionConfiguration, TextDocument } from './protocol'
export class Agent extends MessageHandler {
private client?: Promise<Client>
public workspaceRootPath: string | null = null
public activeDocumentFilePath: string | null = null
public documents: Map<string, TextDocument> = new Map()
constructor() {
super()
this.setClient({
customHeaders: {},
accessToken: process.env.SRC_ACCESS_TOKEN || '',
serverEndpoint: process.env.SRC_ENDPOINT || 'https://sourcegraph.com',
})
this.registerRequest('initialize', client => {
process.stderr.write(
`Cody Agent: handshake with client '${client.name}' (version '${client.version}') at workspace root path '${client.workspaceRootPath}'\n`
)
this.workspaceRootPath = client.workspaceRootPath
if (client.connectionConfiguration) {
this.setClient(client.connectionConfiguration)
}
return Promise.resolve({
name: 'cody-agent',
})
})
this.registerNotification('initialized', () => {})
this.registerRequest('shutdown', () => Promise.resolve(null))
this.registerNotification('exit', () => {
process.exit(0)
})
this.registerNotification('textDocument/didFocus', document => {
this.activeDocumentFilePath = document.filePath
})
this.registerNotification('textDocument/didOpen', document => {
this.documents.set(document.filePath, document)
this.activeDocumentFilePath = document.filePath
})
this.registerNotification('textDocument/didChange', document => {
if (document.content === undefined) {
document.content = this.documents.get(document.filePath)?.content
}
this.documents.set(document.filePath, document)
this.activeDocumentFilePath = document.filePath
})
this.registerNotification('textDocument/didClose', document => {
this.documents.delete(document.filePath)
})
this.registerNotification('connectionConfiguration/didChange', config => {
this.setClient(config)
})
this.registerRequest('recipes/list', () =>
Promise.resolve(
Object.values(registeredRecipes).map(({ id }) => ({
id,
title: id, // TODO: will be added in a follow PR
}))
)
)
this.registerRequest('recipes/execute', async data => {
const client = await this.client
if (!client) {
return null
}
await client.executeRecipe(data.id, {
humanChatInput: data.humanChatInput,
})
return null
})
}
private setClient(config: ConnectionConfiguration): void {
this.client = createClient({
editor: new AgentEditor(this),
config: { ...config, useContext: 'none' },
setMessageInProgress: messageInProgress => {
this.notify('chat/updateMessageInProgress', messageInProgress)
},
setTranscript: () => {
// Not supported yet by agent.
},
createCompletionsClient: config => new SourcegraphNodeCompletionsClient(config),
})
}
}

View File

@ -0,0 +1,99 @@
import {
ActiveTextEditor,
ActiveTextEditorSelection,
ActiveTextEditorViewControllers,
ActiveTextEditorVisibleContent,
Editor,
} from '@sourcegraph/cody-shared/src/editor'
import { Agent } from './agent'
import { DocumentOffsets } from './offsets'
import { TextDocument } from './protocol'
export class AgentEditor implements Editor {
public controllers?: ActiveTextEditorViewControllers | undefined
constructor(private agent: Agent) {}
public didReceiveFixupText(): Promise<void> {
throw new Error('Method not implemented.')
}
public getWorkspaceRootPath(): string | null {
return this.agent.workspaceRootPath
}
private activeDocument(): TextDocument | undefined {
if (this.agent.activeDocumentFilePath === null) {
return undefined
}
return this.agent.documents.get(this.agent.activeDocumentFilePath)
}
public getActiveTextEditor(): ActiveTextEditor | null {
const document = this.activeDocument()
if (document === undefined) {
return null
}
return {
filePath: document.filePath,
content: document.content || '',
}
}
public getActiveTextEditorSelection(): ActiveTextEditorSelection | null {
const document = this.activeDocument()
if (document === undefined || document.content === undefined || document.selection === undefined) {
return null
}
const offsets = new DocumentOffsets(document)
const from = offsets.offset(document.selection.start)
const to = offsets.offset(document.selection.end)
return {
fileName: document.filePath || '',
precedingText: document.content.slice(0, from),
selectedText: document.content.slice(from, to),
followingText: document.content.slice(to, document.content.length),
}
}
public getActiveTextEditorSelectionOrEntireFile(): ActiveTextEditorSelection | null {
const document = this.activeDocument()
if (document !== undefined && document.selection === undefined) {
return {
fileName: document.filePath || '',
precedingText: '',
selectedText: document.content || '',
followingText: '',
}
}
return this.getActiveTextEditorSelection()
}
public getActiveTextEditorVisibleContent(): ActiveTextEditorVisibleContent | null {
const document = this.activeDocument()
if (document === undefined) {
return null
}
return {
content: document.content || '',
fileName: document.filePath,
}
}
public replaceSelection(): Promise<void> {
throw new Error('Not implemented')
}
public showQuickPick(): Promise<string | undefined> {
throw new Error('Not implemented')
}
public showWarningMessage(): Promise<void> {
throw new Error('Not implemented')
}
public showInputBox(): Promise<string | undefined> {
throw new Error('Not implemented')
}
}

View File

@ -0,0 +1,80 @@
import assert from 'assert'
import { spawn } from 'child_process'
import path from 'path'
import { RecipeID } from '@sourcegraph/cody-shared/src/chat/recipes/recipe'
import { MessageHandler } from './jsonrpc'
export class TestClient extends MessageHandler {
public async handshake() {
const info = await this.request('initialize', {
name: 'test-client',
version: 'v1',
workspaceRootPath: '/path/to/foo',
})
this.notify('initialized', null)
return info
}
public listRecipes() {
return this.request('recipes/list', null)
}
public async executeRecipe(id: RecipeID, humanChatInput: string) {
return this.request('recipes/execute', {
id,
humanChatInput,
})
}
public async shutdownAndExit() {
await this.request('shutdown', null)
this.notify('exit', null)
}
}
describe('StandardAgent', () => {
if (process.env.SRC_ACCESS_TOKEN === undefined || process.env.SRC_ENDPOINT === undefined) {
it('no-op test because SRC_ACCESS_TOKEN is not set. To actually run the Cody Agent tests, set the environment variables SRC_ENDPOINT and SRC_ACCESS_TOKEN', () => {})
return
}
const client = new TestClient()
const agentProcess = spawn('node', [path.join(__dirname, '../dist/agent.js')], {
stdio: 'pipe',
})
agentProcess.stdout.pipe(client.messageDecoder)
client.messageEncoder.pipe(agentProcess.stdin)
agentProcess.stderr.on('data', msg => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
console.log(msg.toString())
})
it('initializes properly', async () => {
assert.deepStrictEqual(await client.handshake(), { name: 'cody-agent' }, 'Agent should be cody-agent')
})
it('lists recipes correctly', async () => {
const recipes = await client.listRecipes()
assert(recipes.length === 8)
})
const streamingChatMessages = new Promise<void>(resolve => {
client.registerNotification('chat/updateMessageInProgress', msg => {
if (msg === null) {
resolve()
}
})
})
it('allows us to execute recipes properly', async () => {
await client.executeRecipe('chat-question', "What's 2+2?")
})
it('sends back transcript updates and makes sense', () => streamingChatMessages, 20_000)
afterAll(async () => {
await client.shutdownAndExit()
})
})

View File

@ -0,0 +1,10 @@
import { Agent } from './agent'
process.stderr.write('Starting Cody Agent...\n')
const agent = new Agent()
console.log = console.error
process.stdin.pipe(agent.messageDecoder)
agent.messageEncoder.pipe(process.stdout)

View File

@ -0,0 +1,285 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import assert from 'assert'
import { Readable, Writable } from 'stream'
import { Notifications, Requests } from './protocol'
// This file is a standalone implementation of JSON-RPC for Node.js
// ReadStream/WriteStream, which conventionally map to stdin/stdout.
// The code assumes familiarity with the JSON-RPC specification as documented
// here https://www.jsonrpc.org/specification
// To learn more about how JSON-RPC protocols work, the LSP specification is
// also a good read
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
// String literal types for the names of the Cody Agent protocol methods.
type RequestMethodName = keyof Requests
type NotificationMethodName = keyof Notifications
type MethodName = RequestMethodName | NotificationMethodName
// Parameter type of a request or notification. Note: JSON-RPC methods can only
// accept one parameter. Multiple parameters must be encoded as an array or an
// object.
type ParamsOf<K extends MethodName> = (Requests & Notifications)[K][0]
// Request result types. Note: notifications don't return values.
type ResultOf<K extends RequestMethodName> = Requests[K][1]
type Id = string | number
// Error codes as defined by the JSON-RPC spec.
enum ErrorCode {
ParseError = -32700,
InvalidRequest = -32600,
MethodNotFound = -32601,
InvalidParams = -32602,
InternalError = -32603,
}
// Result of an erroneous request, which populates the `error` property instead
// of `result` for successful results.
interface ErrorInfo<T> {
code: ErrorCode
message: string
data: T
}
// The three different kinds of toplevel JSON objects that get written to the
// wire: requests, request responses, and notifications.
interface RequestMessage<M extends RequestMethodName> {
jsonrpc: '2.0'
id: Id
method: M
params?: ParamsOf<M>
}
interface ResponseMessage<M extends RequestMethodName> {
jsonrpc: '2.0'
id: Id
result?: ResultOf<M>
error?: ErrorInfo<any>
}
interface NotificationMessage<M extends NotificationMethodName> {
jsonrpc: '2.0'
method: M
params?: ParamsOf<M>
}
type Message = RequestMessage<any> & ResponseMessage<any> & NotificationMessage<any>
type MessageHandlerCallback = (err: Error | null, msg: Message | null) => void
class MessageDecoder extends Writable {
private buffer: Buffer = Buffer.alloc(0)
private contentLengthRemaining: number | null = null
private contentBuffer: Buffer = Buffer.alloc(0)
constructor(public callback: MessageHandlerCallback) {
super()
}
public _write(chunk: Buffer, encoding: string, callback: (error?: Error | null) => void): void {
this.buffer = Buffer.concat([this.buffer, chunk])
// We loop through as we could have a double message that requires processing twice
read: while (true) {
if (this.contentLengthRemaining === null) {
const headerString = this.buffer.toString()
let startIndex = 0
let endIndex
// We create this as we might get partial messages
// so we only want to set the content length
// once we get the whole thing
let newContentLength = 0
const LINE_TERMINATOR = '\r\n'
while ((endIndex = headerString.indexOf(LINE_TERMINATOR, startIndex)) !== -1) {
const entry = headerString.slice(startIndex, endIndex)
const [headerName, headerValue] = entry.split(':').map(_ => _.trim())
if (headerValue === undefined) {
this.buffer = this.buffer.slice(endIndex + LINE_TERMINATOR.length)
// Asserts we actually have a valid header with a Content-Length
// This state is irrecoverable because the stream is polluted
// Also what is the client doing 😭
this.contentLengthRemaining = newContentLength
assert(
isFinite(this.contentLengthRemaining),
`parsed Content-Length ${this.contentLengthRemaining} is not a finite number`
)
continue read
}
switch (headerName) {
case 'Content-Length':
newContentLength = parseInt(headerValue, 10)
break
default:
console.error(`Unknown header '${headerName}': ignoring!`)
break
}
startIndex = endIndex + LINE_TERMINATOR.length
}
break
} else {
if (this.contentLengthRemaining === 0) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = JSON.parse(this.contentBuffer.toString())
this.contentBuffer = Buffer.alloc(0)
this.contentLengthRemaining = null
this.callback(null, data)
} catch (error) {
this.callback(error, null)
}
continue
}
const data = this.buffer.slice(0, this.contentLengthRemaining)
this.contentBuffer = Buffer.concat([this.contentBuffer, data])
this.buffer = this.buffer.slice(this.contentLengthRemaining)
this.contentLengthRemaining -= data.byteLength
}
}
callback()
}
}
class MessageEncoder extends Readable {
private buffer: Buffer = Buffer.alloc(0)
public send(data: any): void {
this.pause()
const content = Buffer.from(JSON.stringify(data), 'utf-8')
const header = Buffer.from(`Content-Length: ${content.byteLength}\r\n\r\n`, 'utf-8')
this.buffer = Buffer.concat([this.buffer, header, content])
this.resume()
}
public _read(size: number): void {
this.push(this.buffer.slice(0, size))
this.buffer = this.buffer.slice(size)
}
}
type RequestCallback<M extends RequestMethodName> = (params: ParamsOf<M>) => Promise<ResultOf<M>>
type NotificationCallback<M extends NotificationMethodName> = (params: ParamsOf<M>) => void
/**
* Only exported API in this file. MessageHandler exposes a public `messageDecoder` property
* that can be piped with ReadStream/WriteStream.
*/
export class MessageHandler {
private id = 0
private requestHandlers: Map<RequestMethodName, RequestCallback<any>> = new Map()
private notificationHandlers: Map<NotificationMethodName, NotificationCallback<any>> = new Map()
private responseHandlers: Map<Id, (params: any) => void> = new Map()
// TODO: RPC error handling
public messageDecoder: MessageDecoder = new MessageDecoder((err: Error | null, msg: Message | null) => {
if (err) {
console.error(`Error: ${err}`)
}
if (!msg) {
return
}
if (msg.id !== undefined && msg.method) {
if (typeof msg.id === 'number' && msg.id > this.id) {
this.id = msg.id + 1
}
// Requests have ids and methods
const handler = this.requestHandlers.get(msg.method)
if (handler) {
handler(msg.params).then(
result => {
const data: ResponseMessage<any> = {
jsonrpc: '2.0',
id: msg.id,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
result,
}
this.messageEncoder.send(data)
},
error => {
const message = error instanceof Error ? error.message : `${error}`
const data: ResponseMessage<any> = {
jsonrpc: '2.0',
id: msg.id,
error: {
code: ErrorCode.InternalError,
message,
data: JSON.stringify(error),
},
}
this.messageEncoder.send(data)
}
)
} else {
console.error(`No handler for request with method ${msg.method}`)
}
} else if (msg.id !== undefined) {
// Responses have ids
const handler = this.responseHandlers.get(msg.id)
if (handler) {
handler(msg.result)
this.responseHandlers.delete(msg.id)
} else {
console.error(`No handler for response with id ${msg.id}`)
}
} else if (msg.method) {
// Notifications have methods
const notificationHandler = this.notificationHandlers.get(msg.method)
if (notificationHandler) {
notificationHandler(msg.params)
} else {
console.error(`No handler for notification with method ${msg.method}`)
}
}
})
public messageEncoder: MessageEncoder = new MessageEncoder()
public registerRequest<M extends RequestMethodName>(method: M, callback: RequestCallback<M>): void {
this.requestHandlers.set(method, callback)
}
public registerNotification<M extends NotificationMethodName>(method: M, callback: NotificationCallback<M>): void {
this.notificationHandlers.set(method, callback)
}
public request<M extends RequestMethodName>(method: M, params: ParamsOf<M>): Promise<ResultOf<M>> {
const id = this.id++
const data: RequestMessage<M> = {
jsonrpc: '2.0',
id,
method,
params,
}
this.messageEncoder.send(data)
return new Promise(resolve => {
this.responseHandlers.set(id, resolve)
})
}
public notify<M extends NotificationMethodName>(method: M, params: ParamsOf<M>): void {
const data: NotificationMessage<M> = {
jsonrpc: '2.0',
method,
params,
}
this.messageEncoder.send(data)
}
}

View File

@ -0,0 +1,27 @@
import { Position, TextDocument } from './protocol'
/**
* Utility class to convert line/character positions into offsets.
*/
export class DocumentOffsets {
private lines: number[] = []
constructor(public readonly document: TextDocument) {
if (document.content) {
this.lines.push(0)
let index = 1
while (index < document.content.length) {
if (document.content[index] === '\n') {
this.lines.push(index + 1)
}
index++
}
if (document.content.length !== this.lines[this.lines.length - 1]) {
this.lines.push(document.content.length) // sentinel value
}
}
}
public offset(position: Position): number {
const lineStartOffset = this.lines[position.line]
return lineStartOffset + position.character
}
}

View File

@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import { RecipeID } from '@sourcegraph/cody-shared/src/chat/recipes/recipe'
import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages'
// This file documents the Cody Agent JSON-RPC protocol. Consult the JSON-RPC
// specification to learn about how JSON-RPC works https://www.jsonrpc.org/specification
// The Cody Agent server only supports transport via stdout/stdin.
// The JSON-RPC requests of the Cody Agent protocol. Requests are async
// functions that return some (possibly null) value.
export type Requests = {
// ================
// Client -> Server
// ================
// The 'initialize' request must be sent at the start of the connection
// before any other request/notification is sent.
initialize: [ClientInfo, ServerInfo]
// The 'shutdown' request must be sent before terminating the agent process.
shutdown: [null, null]
// Client requests the agent server to lists all recipes that are supported
// by the agent.
'recipes/list': [null, RecipeInfo[]]
// Client requests the agent server to execute an individual recipe.
// The response is null because the AI/Assistant messages are streamed through
// the chat/updateMessageInProgress notification. The flow to trigger a recipe
// is like this:
// client --- recipes/execute --> server
// client <-- chat/updateMessageInProgress --- server
// ....
// client <-- chat/updateMessageInProgress --- server
'recipes/execute': [ExecuteRecipeParams, null]
// ================
// Server -> Client
// ================
}
// The JSON-RPC notifications of the Cody Agent protocol. Notifications are
// synchronous fire-and-forget messages that have no return value. Notifications are
// conventionally used to represent streams of values.
export type Notifications = {
// ================
// Client -> Server
// ================
// The 'initalized' notification must be sent after receiving the 'initialize' response.
initialized: [null]
// The 'exit' notification must be sent after the client receives the 'shutdown' response.
exit: [null]
// The server should use the provided connection configuration for all
// subsequent requests/notications. The previous connection configuration
// should no longer be used.
'connectionConfiguration/didChange': [ConnectionConfiguration]
// Lifecycle notifications for the client to notify the server about text
// contents of documents and to notify which document is currently focused.
'textDocument/didOpen': [TextDocument]
// The 'textDocument/didChange' notification should be sent on almost every
// keystroke, whether the text contents changed or the cursor/selection
// changed. Leave the `content` property undefined when the document's
// content is unchanged.
'textDocument/didChange': [TextDocument]
// The user focused on a document without changing the document's content.
// Only the 'uri' property is required, other properties are ignored.
'textDocument/didFocus': [TextDocument]
// The user closed the editor tab for the given document.
// Only the 'uri' property is required, other properties are ignored.
'textDocument/didClose': [TextDocument]
// ================
// Server -> Client
// ================
// The server received new messages for the ongoing 'chat/executeRecipe'
// request. The server should never send this notification outside of a
// 'chat/executeRecipe' request.
'chat/updateMessageInProgress': [ChatMessage | null]
}
export interface ClientInfo {
name: string
version: string
workspaceRootPath: string
connectionConfiguration?: ConnectionConfiguration
capabilities?: ClientCapabilities
}
export interface ClientCapabilities {
completions?: 'none'
// When 'streaming', handles 'chat/updateMessageInProgress' streaming notifications.
chat?: 'none' | 'streaming'
}
export interface ServerInfo {
name: string
capabilities?: ServerCapabilities
}
export interface ServerCapabilities {}
export interface ConnectionConfiguration {
serverEndpoint: string
accessToken: string
customHeaders: Record<string, string>
}
export interface Position {
// 0-indexed
line: number
// 0-indexed
character: number
}
export interface Range {
start: Position
end: Position
}
export interface TextDocument {
filePath: string
content?: string
selection?: Range
}
export interface RecipeInfo {
id: RecipeID
title: string
}
export interface ExecuteRecipeParams {
id: RecipeID
humanChatInput: string
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"rootDir": ".",
"sourceRoot": "src",
"outDir": "out",
},
"include": ["**/*", ".*", "package.json"],
"exclude": ["out"],
"references": [{ "path": "../common" }, { "path": "../cody-shared" }],
}

View File

@ -29,6 +29,7 @@ ts_project(
"src/chat/context.ts",
"src/chat/markdown.ts",
"src/chat/preamble.ts",
"src/chat/recipes/agent-recipes.ts",
"src/chat/recipes/browser-recipes.ts",
"src/chat/recipes/chat-question.ts",
"src/chat/recipes/context-search.ts",

View File

@ -5,6 +5,7 @@ import { PrefilledOptions, withPreselectedOptions } from '../editor/withPreselec
import { SourcegraphEmbeddingsSearchClient } from '../embeddings/client'
import { SourcegraphIntentDetectorClient } from '../intent-detector/client'
import { SourcegraphBrowserCompletionsClient } from '../sourcegraph-api/completions/browserClient'
import { CompletionsClientConfig, SourcegraphCompletionsClient } from '../sourcegraph-api/completions/client'
import { SourcegraphGraphQLAPIClient } from '../sourcegraph-api/graphql'
import { isError } from '../utils'
@ -20,15 +21,18 @@ import { reformatBotMessage } from './viewHelpers'
export type { TranscriptJSON }
export { Transcript }
export type ClientInitConfig = Pick<
ConfigurationWithAccessToken,
'serverEndpoint' | 'codebase' | 'useContext' | 'accessToken' | 'customHeaders'
>
export interface ClientInit {
config: Pick<
ConfigurationWithAccessToken,
'serverEndpoint' | 'codebase' | 'useContext' | 'accessToken' | 'customHeaders'
>
config: ClientInitConfig
setMessageInProgress: (messageInProgress: ChatMessage | null) => void
setTranscript: (transcript: Transcript) => void
editor: Editor
initialTranscript?: Transcript
createCompletionsClient?: (config: CompletionsClientConfig) => SourcegraphCompletionsClient
}
export interface Client {
@ -52,10 +56,11 @@ export async function createClient({
setTranscript,
editor,
initialTranscript,
createCompletionsClient = config => new SourcegraphBrowserCompletionsClient(config),
}: ClientInit): Promise<Client> {
const fullConfig = { debugEnable: false, ...config }
const completionsClient = new SourcegraphBrowserCompletionsClient(fullConfig)
const completionsClient = createCompletionsClient(fullConfig)
const chatClient = new ChatClient(completionsClient)
const graphqlClient = new SourcegraphGraphQLAPIClient(fullConfig)

View File

@ -0,0 +1,50 @@
import { ChatQuestion } from './chat-question'
import { ExplainCodeDetailed } from './explain-code-detailed'
import { ExplainCodeHighLevel } from './explain-code-high-level'
import { FindCodeSmells } from './find-code-smells'
import { GenerateDocstring } from './generate-docstring'
import { GenerateTest } from './generate-test'
import { ImproveVariableNames } from './improve-variable-names'
import { Recipe, RecipeID } from './recipe'
import { TranslateToLanguage } from './translate'
function nullLog(filterLabel: string, text: string, ...args: unknown[]): void {
// Do nothing
}
export const registeredRecipes: { [id in RecipeID]?: Recipe } = {}
export function getRecipe(id: RecipeID): Recipe | undefined {
return registeredRecipes[id]
}
function registerRecipe(id: RecipeID, recipe: Recipe): void {
registeredRecipes[id] = recipe
}
function init(): void {
if (Object.keys(registeredRecipes).length > 0) {
return
}
const recipes: Recipe[] = [
new ChatQuestion(nullLog),
new ExplainCodeDetailed(),
new ExplainCodeHighLevel(),
new GenerateDocstring(),
new GenerateTest(),
new ImproveVariableNames(),
new TranslateToLanguage(),
new FindCodeSmells(),
]
for (const recipe of recipes) {
const existingRecipe = getRecipe(recipe.id)
if (existingRecipe) {
throw new Error(`Duplicate recipe with ID ${recipe.id}`)
}
registerRecipe(recipe.id, recipe)
}
}
init()

View File

@ -12,7 +12,7 @@ export interface CompletionLogger {
}
}
export type Config = Pick<
export type CompletionsClientConfig = Pick<
ConfigurationWithAccessToken,
'serverEndpoint' | 'accessToken' | 'debugEnable' | 'customHeaders'
>
@ -20,9 +20,9 @@ export type Config = Pick<
export abstract class SourcegraphCompletionsClient {
private errorEncountered = false
constructor(protected config: Config, protected logger?: CompletionLogger) {}
constructor(protected config: CompletionsClientConfig, protected logger?: CompletionLogger) {}
public onConfigurationChange(newConfig: Config): void {
public onConfigurationChange(newConfig: CompletionsClientConfig): void {
this.config = newConfig
}

View File

@ -37,6 +37,15 @@ New issues and feature requests can be filed through our [issue tracker](https:/
- If you are using an M1 MacBook and get a JCEF-related error using the "Find with Sourcegraph" command, try
running `./gradlew -PplatformVersion=221.5080.210 :runIde` instead.
See https://youtrack.jetbrains.com/issue/IDEA-291946 for more details.
- To debug communication between the IntelliJ plugin and Cody agent, it's useful to keep an open terminal tab that's
running the command `fail -f build/sourcegraph/cody-agent-trace.json`.
- The Cody agent is a JSON-RPC server that implements the promt logic for Cody. The JetBrains plugin needs access to the
agent binary to function propertly. This agent binary is automatically built from source if it does not exist. To
speed up edit/test/debug feedback loops, the agent binary does not get rebuilt unless you provdide the
`-PforceAgentBuild=true` flag when running Gradle. For example, `./gradlew :runIde -PforceAgentBuild=true`.
- Use the `-PenableAgent=true` property to enable the Cody agent. For example, `./gradlew :runIde -PenableAgent=true`.
When the agent is disabled, the plugin falls back to the non-agent based implementation. Once we have more
confidence with the agent, we will turn this on by default.
## Publishing a new version

View File

@ -1,5 +1,7 @@
import org.jetbrains.changelog.markdownToHTML
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.nio.file.Files
import java.nio.file.Paths
fun properties(key: String) = project.findProperty(key).toString()
@ -33,6 +35,7 @@ intellij {
dependencies {
implementation("org.commonmark:commonmark:0.21.0")
implementation("org.commonmark:commonmark-ext-gfm-tables:0.21.0")
implementation("org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:0.21.0")
testImplementation(platform("org.junit:junit-bom:5.7.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
@ -76,15 +79,15 @@ tasks {
// Extract the <!-- Plugin description --> section from README.md and provide for the plugin's manifest
pluginDescription.set(
projectDir.resolve("README.md").readText().lines().run {
val start = "<!-- Plugin description -->"
val end = "<!-- Plugin description end -->"
projectDir.resolve("README.md").readText().lines().run {
val start = "<!-- Plugin description -->"
val end = "<!-- Plugin description end -->"
if (!containsAll(listOf(start, end))) {
throw GradleException("Plugin description section not found in README.md:\n$start ... $end")
}
subList(indexOf(start) + 1, indexOf(end))
}.joinToString("\n").run { markdownToHTML(this) }
if (!containsAll(listOf(start, end))) {
throw GradleException("Plugin description section not found in README.md:\n$start ... $end")
}
subList(indexOf(start) + 1, indexOf(end))
}.joinToString("\n").run { markdownToHTML(this) }
)
// Get the latest available change notes from the changelog file
@ -95,8 +98,56 @@ tasks {
})
}
val agentSourceDirectory = Paths.get("..", "cody-agent").normalize()
val agentTargetDirectory =
buildDir.resolve("sourcegraph").resolve("agent").toPath()
fun cleanAgentTargetDirectory() {
if (Files.isDirectory(agentTargetDirectory)) {
agentTargetDirectory.toFile().deleteRecursively()
}
}
fun copyAgentBinariesToPluginPath(targetPath: String) {
val shouldBuildBinaries =
findProperty("forceAgentBuild") == "true" ||
!Files.isDirectory(agentTargetDirectory) ||
agentTargetDirectory.toFile().list().isEmpty()
if (shouldBuildBinaries) {
exec {
commandLine("pnpm", "install")
workingDir(agentSourceDirectory.toString())
}
exec {
commandLine("pnpm", "run", "build-agent-binaries")
workingDir(agentSourceDirectory.toString())
environment("AGENT_EXECUTABLE_TARGET_DIRECTORY", targetPath)
}
}
}
register("copyAgentBinariesToPluginPath") {
doLast {
copyAgentBinariesToPluginPath(agentTargetDirectory.toString())
}
}
buildPlugin {
dependsOn("copyAgentBinariesToPluginPath")
// Copy agent binaries into the zip file that `buildPlugin` produces.
from(fileTree(agentTargetDirectory.parent.toString()) {
include("agent/*")
}) {
into("agent")
}
}
runIde {
dependsOn("copyAgentBinariesToPluginPath")
jvmArgs("-Djdk.module.illegalAccess.silent=true")
systemProperty("cody-agent.trace-path", "$buildDir/sourcegraph/cody-agent-trace.json")
systemProperty("cody-agent.directory", agentTargetDirectory.parent.toString())
val isAgentEnabled = findProperty("disableAgent") == "true"
systemProperty("cody-agent.enabled", (!isAgentEnabled).toString())
}
// Configure UI tests plugin
@ -114,6 +165,7 @@ tasks {
password.set(System.getenv("PRIVATE_KEY_PASSWORD"))
}
publishPlugin {
dependsOn("patchChangelog")
token.set(System.getenv("PUBLISH_TOKEN"))

View File

@ -15,7 +15,7 @@ popd > /dev/null || exit
pnpm build
# Build the release candidate and publish it onto the registry
./gradlew publishPlugin
./gradlew -PforceAgentBuild=true publishPlugin
# The release script automatically changes the README and moves the unreleased
# section into a version numbered one. We don't care about this while we are

View File

@ -0,0 +1,28 @@
package com.sourcegraph.cody;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.ex.FocusChangeListener;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.vfs.VirtualFile;
import com.sourcegraph.cody.agent.CodyAgent;
import com.sourcegraph.cody.agent.CodyAgentServer;
import com.sourcegraph.cody.agent.protocol.TextDocument;
import org.jetbrains.annotations.NotNull;
public class CodyAgentFocusListener implements FocusChangeListener {
@Override
public void focusGained(@NotNull Editor editor) {
if (editor.getProject() == null) {
return;
}
VirtualFile file = FileDocumentManager.getInstance().getFile(editor.getDocument());
if (file == null) {
return;
}
CodyAgentServer server = CodyAgent.getServer(editor.getProject());
if (server == null) {
return;
}
server.textDocumentDidFocus(new TextDocument().setFilePath(file.getPath()));
}
}

View File

@ -0,0 +1,26 @@
package com.sourcegraph.cody;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManagerListener;
import com.sourcegraph.cody.agent.CodyAgent;
import org.jetbrains.annotations.NotNull;
public class CodyAgentProjectListener implements ProjectManagerListener {
@Override
public void projectOpened(@NotNull Project project) {
CodyAgent service = project.getService(CodyAgent.class);
if (service == null) {
return;
}
service.initialize();
}
@Override
public void projectClosed(@NotNull Project project) {
CodyAgent service = project.getService(CodyAgent.class);
if (service == null) {
return;
}
service.shutdown();
}
}

View File

@ -0,0 +1,5 @@
package com.sourcegraph.cody;
public class CodyComponent {
public void initComponent() {}
}

View File

@ -0,0 +1,36 @@
package com.sourcegraph.cody;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.FileEditorManagerListener;
import com.intellij.openapi.vfs.VirtualFile;
import com.sourcegraph.cody.agent.CodyAgent;
import com.sourcegraph.cody.agent.CodyAgentServer;
import com.sourcegraph.cody.agent.protocol.TextDocument;
import org.jetbrains.annotations.NotNull;
public class CodyFileEditorListener implements FileEditorManagerListener {
@Override
public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) {
Document document = FileDocumentManager.getInstance().getDocument(file);
if (document == null) {
return;
}
CodyAgentServer server = CodyAgent.getServer(source.getProject());
if (server == null) {
return;
}
server.textDocumentDidOpen(
new TextDocument().setFilePath(file.getPath()).setContent(document.getText()));
}
@Override
public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) {
CodyAgentServer server = CodyAgent.getServer(source.getProject());
if (server == null) {
return;
}
server.textDocumentDidClose(new TextDocument().setFilePath(file.getPath()));
}
}

View File

@ -10,6 +10,7 @@ import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI;
import com.intellij.ide.ui.laf.darcula.ui.DarculaTextAreaUI;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.DumbAwareAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.VerticalFlowLayout;
@ -39,6 +40,7 @@ import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
class CodyToolWindowContent implements UpdatableChat {
public static Logger logger = Logger.getInstance(CodyToolWindowContent.class);
private static final int CHAT_TAB_INDEX = 0;
private static final int RECIPES_TAB_INDEX = 1;
private final @NotNull JBTabbedPane tabbedPane = new JBTabbedPane();
@ -382,7 +384,6 @@ class CodyToolWindowContent implements UpdatableChat {
isEnterprise
? ConfigUtil.getEnterpriseAccessToken(project)
: ConfigUtil.getDotComAccessToken(project);
System.out.println("isEnterprise: " + isEnterprise);
var chat = new Chat("", instanceUrl, accessToken != null ? accessToken : "");
ArrayList<String> contextFiles =
@ -397,7 +398,11 @@ class CodyToolWindowContent implements UpdatableChat {
// in the main thread and then waited, we wouldn't see the messages streamed back to us.
new Thread(
() -> {
chat.sendMessage(humanMessage, responsePrefix, this); // TODO: Use prefix
try {
chat.sendMessage(project, humanMessage, responsePrefix, this);
} catch (Exception e) {
logger.error("Error sending message '" + humanMessage + "' to chat", e);
}
})
.start();
}

View File

@ -0,0 +1,233 @@
package com.sourcegraph.cody.agent;
import com.intellij.ide.plugins.IdeaPluginDescriptor;
import com.intellij.ide.plugins.PluginManagerCore;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.editor.event.EditorEventMulticaster;
import com.intellij.openapi.editor.ex.EditorEventMulticasterEx;
import com.intellij.openapi.extensions.PluginId;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.SystemInfoRt;
import com.intellij.util.system.CpuArch;
import com.sourcegraph.cody.CodyAgentFocusListener;
import com.sourcegraph.cody.agent.protocol.ClientInfo;
import com.sourcegraph.cody.agent.protocol.ServerInfo;
import com.sourcegraph.config.ConfigUtil;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.*;
import java.util.Objects;
import java.util.concurrent.*;
import org.eclipse.lsp4j.jsonrpc.Launcher;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Orchestrator for the Cody agent, which is a Node.js program that implements the prompt logic for
* Cody. The agent communicates via a JSON-RPC protocol that is documented in the file
* "client/cody-agent/src/protocol.ts".
*
* <p>The class {{{@link com.sourcegraph.cody.CodyAgentProjectListener}}} is responsible for
* initializing and shutting down the agent.
*/
public class CodyAgent implements Disposable {
public static Logger logger = Logger.getInstance(CodyAgent.class);
private static final @NotNull PluginId PLUGIN_ID = PluginId.getId("com.sourcegraph.jetbrains");
public static final ExecutorService executorService = Executors.newCachedThreadPool();
Disposable disposable = Disposer.newDisposable("CodyAgent");
private final @NotNull Project project;
private final CodyAgentClient client = new CodyAgentClient();
private String initializationErrorMessage = "";
private final CompletableFuture<CodyAgentServer> initialized = new CompletableFuture<>();
private Future<Void> listeningToJsonRpc;
public CodyAgent(@NotNull Project project) {
this.project = project;
}
@NotNull
public static CodyAgentClient getClient(@NotNull Project project) {
return project.getService(CodyAgent.class).client;
}
@NotNull
public static CompletableFuture<CodyAgentServer> getInitializedServer(@NotNull Project project) {
return project.getService(CodyAgent.class).initialized;
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean isConnected(@NotNull Project project) {
CodyAgent agent = project.getService(CodyAgent.class);
return agent != null
&& agent.initializationErrorMessage.isEmpty()
&& agent.listeningToJsonRpc != null
&& !agent.listeningToJsonRpc.isDone()
&& !agent.listeningToJsonRpc.isCancelled()
&& agent.client.server != null;
}
@Nullable
public static CodyAgentServer getServer(@NotNull Project project) {
if (!isConnected(project)) {
return null;
}
return getClient(project).server;
}
public void initialize() {
System.out.println("AGENT ENABLED " + System.getProperty("cody-agent.enabled", "false"));
if (!"true".equals(System.getProperty("cody-agent.enabled", "false"))) {
logger.info("Cody agent is disabled due to system property '-Dcody-agent.enabled=false'");
return;
}
try {
startListeningToAgent();
executorService.submit(
() -> {
try {
final CodyAgentServer server = Objects.requireNonNull(client.server);
ServerInfo info =
server
.initialize(
new ClientInfo()
.setName("JetBrains")
.setVersion(ConfigUtil.getPluginVersion())
.setWorkspaceRootPath(ConfigUtil.getWorkspaceRoot(project))
.setConnectionConfiguration(
ConfigUtil.getAgentConfiguration(this.project)))
.get();
logger.info("connected to Cody agent " + info.name);
server.initialized();
this.subscribeToFocusEvents();
this.initialized.complete(server);
} catch (Exception e) {
initializationErrorMessage =
"failed to send 'initialize' JSON-RPC request Cody agent";
logger.error(initializationErrorMessage, e);
}
});
} catch (Exception e) {
initializationErrorMessage = "unable to start Cody agent";
logger.error(initializationErrorMessage, e);
}
}
public void subscribeToFocusEvents() {
// Code example taken from
// https://intellij-support.jetbrains.com/hc/en-us/community/posts/4578776718354/comments/4594838404882
// This listener is registered programmatically because it was not working via plugin.xml
// listeners.
EditorEventMulticaster multicaster = EditorFactory.getInstance().getEventMulticaster();
if (multicaster instanceof EditorEventMulticasterEx) {
EditorEventMulticasterEx ex = (EditorEventMulticasterEx) multicaster;
ex.addFocusChangeListener(new CodyAgentFocusListener(), this.disposable);
}
}
public void shutdown() {
final CodyAgentServer server = CodyAgent.getServer(project);
if (server == null) {
return;
}
executorService.submit(() -> server.shutdown().thenAccept((Void) -> server.exit()));
}
private static String binarySuffix() {
return SystemInfoRt.isWindows ? ".exe" : "";
}
private static String agentBinaryName() {
String os = SystemInfoRt.isMac ? "macos" : SystemInfoRt.isWindows ? "windows" : "linux";
@SuppressWarnings("MissingRecentApi")
String arch = CpuArch.isArm64() ? "arm64" : "x64";
return "agent-" + os + "-" + arch + binarySuffix();
}
@Nullable
private static Path agentDirectory() {
String fromProperty = System.getProperty("cody-agent.directory", "");
if (!fromProperty.isEmpty()) {
return Paths.get(fromProperty);
}
IdeaPluginDescriptor plugin = PluginManagerCore.getPlugin(PLUGIN_ID);
if (plugin == null) {
return null;
}
return plugin.getPluginPath();
}
@NotNull
private static File agentBinary() throws CodyAgentException {
Path pluginPath = agentDirectory();
if (pluginPath == null) {
throw new CodyAgentException("Sourcegraph plugin path not found");
}
Path binarySource = pluginPath.resolve("agent").resolve(agentBinaryName());
if (!Files.isRegularFile(binarySource)) {
throw new CodyAgentException(
"Cody agent binary not found at path " + binarySource.toAbsolutePath());
}
try {
Path binaryTarget = Files.createTempFile("cody-agent", binarySuffix());
logger.info("extracting Cody agent binary to " + binaryTarget.toAbsolutePath());
Files.copy(binarySource, binaryTarget, StandardCopyOption.REPLACE_EXISTING);
File binary = binaryTarget.toFile();
if (binary.setExecutable(true)) {
binary.deleteOnExit();
return binary;
} else {
throw new CodyAgentException("failed to make executable " + binary.getAbsolutePath());
}
} catch (IOException e) {
throw new CodyAgentException("failed to create agent binary", e);
}
}
@Nullable
private static PrintWriter traceWriter() {
String tracePath = System.getProperty("cody-agent.trace-path", "");
if (!tracePath.isEmpty()) {
Path trace = Paths.get(tracePath);
try {
Files.createDirectories(trace.getParent());
return new PrintWriter(
Files.newOutputStream(
trace, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING));
} catch (IOException e) {
logger.error("unable to trace JSON-RPC debugging information to path " + tracePath, e);
}
}
return null;
}
private void startListeningToAgent() throws IOException, CodyAgentException {
File binary = agentBinary();
logger.info("starting Cody agent " + binary.getAbsolutePath());
Process process =
new ProcessBuilder(binary.getAbsolutePath())
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
Launcher<CodyAgentServer> launcher =
new Launcher.Builder<CodyAgentServer>()
.setRemoteInterface(CodyAgentServer.class)
.traceMessages(traceWriter())
.setExecutorService(executorService)
.setInput(process.getInputStream())
.setOutput(process.getOutputStream())
.setLocalService(client)
.create();
client.server = launcher.getRemoteProxy();
client.documents = new CodyAgentDocuments(client.server);
this.listeningToJsonRpc = launcher.startListening();
}
@Override
public void dispose() {
this.disposable.dispose();
}
}

View File

@ -0,0 +1,52 @@
package com.sourcegraph.cody.agent;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Editor;
import com.sourcegraph.cody.agent.protocol.ChatMessage;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;
import org.jetbrains.annotations.Nullable;
/** Implementation of the client part of the Cody agent protocol. */
@SuppressWarnings("unused")
public class CodyAgentClient {
@Nullable public CodyAgentServer server;
@Nullable public CodyAgentDocuments documents;
// Callback that is invoked when the agent sends a "chat/updateMessageInProgress" notification.
@Nullable public Consumer<ChatMessage> onChatUpdateMessageInProgress;
@Nullable public Editor editor;
/**
* Helper to run client request/notification handlers on the IntelliJ event thread. Use this
* helper for handlers that require access to the IntelliJ editor, for example to read the text
* contents of the open editor.
*/
private <T> CompletableFuture<T> onEventThread(Supplier<T> handler) {
CompletableFuture<T> result = new CompletableFuture<>();
ApplicationManager.getApplication()
.invokeLater(
() -> {
try {
result.complete(handler.get());
} catch (Exception e) {
result.completeExceptionally(e);
}
});
return result;
}
// =============
// Notifications
// =============
@JsonNotification("chat/updateMessageInProgress")
public void chatUpdateMessageInProgress(ChatMessage params) {
if (onChatUpdateMessageInProgress != null && params != null) {
ApplicationManager.getApplication()
.invokeLater(() -> onChatUpdateMessageInProgress.accept(params));
}
}
}

View File

@ -0,0 +1,48 @@
package com.sourcegraph.cody.agent;
import com.sourcegraph.cody.agent.protocol.TextDocument;
import java.util.HashMap;
import java.util.Map;
// Work-in-progress implementation of a helper class to optimize the notification traffic for
// textDocument/* methods. For example, we don't need to include the content of the document
// when we move the cursor around, or we don't need to send repeated didFocus events for the
// same file path. Currently, we send duplicate didFocus events when the user focuses on
// another application than IntelliJ, and then focuses back on the original document.
public class CodyAgentDocuments {
private final CodyAgentServer underlying;
private String focusedPath = null;
private Map<String, TextDocument> documents = new HashMap<>();
public CodyAgentDocuments(CodyAgentServer underlying) {
this.underlying = underlying;
}
private void handleDocument(TextDocument document) {
TextDocument old = this.documents.get(document.filePath);
if (old == null) {
this.documents.put(document.filePath, document);
return;
}
if (document.content == null) {
document.content = old.content;
}
if (document.selection == null) {
document.selection = old.selection;
}
this.documents.put(document.filePath, document);
}
public void didOpen(TextDocument document) {
this.documents.put(document.filePath, document);
underlying.textDocumentDidOpen(document);
}
public void didFocus(TextDocument document) {
this.documents.put(document.filePath, document);
}
public void didChange(TextDocument document) {}
public void didClose(TextDocument document) {}
}

View File

@ -0,0 +1,19 @@
package com.sourcegraph.cody.agent;
import java.io.IOException;
public class CodyAgentException extends Exception {
public CodyAgentException(String message) {
super(message);
}
public CodyAgentException(String message, IOException e) {
super(message, e);
}
@Override
public Throwable fillInStackTrace() {
// don't fill in stack trace
return this;
}
}

View File

@ -0,0 +1,51 @@
package com.sourcegraph.cody.agent;
import com.sourcegraph.cody.agent.protocol.*;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;
import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;
/**
* Interface for the server-part of the Cody agent protocol. The implementation of this interface is
* written in TypeScript in the file "client/cody-agent/src/agent.ts". The Eclipse LSP4J bindings
* create a Java implementation of this interface by using a JVM-reflection feature called "Proxy",
* which works similar to JavaScript Proxy.
*/
public interface CodyAgentServer {
// Requests
@JsonRequest("initialize")
CompletableFuture<ServerInfo> initialize(ClientInfo clientInfo);
@JsonRequest("shutdown")
CompletableFuture<Void> shutdown();
@JsonRequest("recipes/list")
CompletableFuture<List<RecipeInfo>> recipesList();
@JsonRequest("recipes/execute")
CompletableFuture<Void> recipesExecute(ExecuteRecipeParams params);
// Notifications
@JsonNotification("initialized")
void initialized();
@JsonNotification("exit")
void exit();
@JsonNotification("connectionConfiguration/didChange")
void configurationDidChange(ConnectionConfiguration document);
@JsonNotification("textDocument/didFocus")
void textDocumentDidFocus(TextDocument document);
@JsonNotification("textDocument/didOpen")
void textDocumentDidOpen(TextDocument document);
@JsonNotification("textDocument/didChange")
void textDocumentDidChange(TextDocument document);
@JsonNotification("textDocument/didClose")
void textDocumentDidClose(TextDocument document);
}

View File

@ -0,0 +1,25 @@
package com.sourcegraph.cody.agent;
import java.util.Map;
public class ConnectionConfiguration {
public String serverEndpoint;
public String accessToken;
public Map<String, String> customHeaders;
public ConnectionConfiguration setServerEndpoint(String serverEndpoint) {
this.serverEndpoint = serverEndpoint;
return this;
}
public ConnectionConfiguration setAccessToken(String accessToken) {
this.accessToken = accessToken;
return this;
}
public ConnectionConfiguration setCustomHeaders(Map<String, String> customHeaders) {
this.customHeaders = customHeaders;
return this;
}
}

View File

@ -0,0 +1,27 @@
package com.sourcegraph.cody.agent.protocol;
import java.util.List;
// TODO: consolidate with the other ChatMessage. This duplication exists because the other
// ChatMessage uses an enum for Message.speaker that doesn't decode nicely with Gson.
public class ChatMessage extends Message {
public String displayText;
public List<ContextFile> contextFiles;
@Override
public String toString() {
return "ChatMessage{"
+ "displayText='"
+ displayText
+ '\''
+ ", contextFiles="
+ contextFiles
+ ", speaker='"
+ speaker
+ '\''
+ ", text='"
+ text
+ '\''
+ '}';
}
}

View File

@ -0,0 +1,32 @@
package com.sourcegraph.cody.agent.protocol;
import com.sourcegraph.cody.agent.ConnectionConfiguration;
import org.jetbrains.annotations.Nullable;
public class ClientInfo {
public String name;
public String version;
public String workspaceRootPath;
@Nullable public ConnectionConfiguration connectionConfiguration;
public ClientInfo setName(String name) {
this.name = name;
return this;
}
public ClientInfo setVersion(String version) {
this.version = version;
return this;
}
public ClientInfo setWorkspaceRootPath(String workspaceRootPath) {
this.workspaceRootPath = workspaceRootPath;
return this;
}
public ClientInfo setConnectionConfiguration(ConnectionConfiguration connectionConfiguration) {
this.connectionConfiguration = connectionConfiguration;
return this;
}
}

View File

@ -0,0 +1,7 @@
package com.sourcegraph.cody.agent.protocol;
public class ContextFile {
public String fileName;
public String repoName;
public String revision;
}

View File

@ -0,0 +1,5 @@
package com.sourcegraph.cody.agent.protocol;
public class ContextMessage {
public ContextFile file;
}

View File

@ -0,0 +1,17 @@
package com.sourcegraph.cody.agent.protocol;
public class ExecuteRecipeParams {
public String id;
public String humanChatInput;
public ExecuteRecipeParams setId(String id) {
this.id = id;
return this;
}
public ExecuteRecipeParams setHumanChatInput(String humanChatInput) {
this.humanChatInput = humanChatInput;
return this;
}
}

View File

@ -0,0 +1,11 @@
package com.sourcegraph.cody.agent.protocol;
public class Message {
public String speaker;
public String text;
@Override
public String toString() {
return "Message{" + "speaker='" + speaker + '\'' + ", text='" + text + '\'' + '}';
}
}

View File

@ -0,0 +1,16 @@
package com.sourcegraph.cody.agent.protocol;
public class Position {
public int line;
public int character;
public Position setLine(int line) {
this.line = line;
return this;
}
public Position setCharacter(int character) {
this.character = character;
return this;
}
}

View File

@ -0,0 +1,16 @@
package com.sourcegraph.cody.agent.protocol;
public class Range {
public Position start;
public Position end;
public Range setStart(Position start) {
this.start = start;
return this;
}
public Range setEnd(Position end) {
this.end = end;
return this;
}
}

View File

@ -0,0 +1,11 @@
package com.sourcegraph.cody.agent.protocol;
public class RecipeInfo {
public String id;
public String title;
@Override
public String toString() {
return "RecipeInfo{" + "id='" + id + '\'' + ", title='" + title + '\'' + '}';
}
}

View File

@ -0,0 +1,9 @@
package com.sourcegraph.cody.agent.protocol;
public class ServerInfo {
public final String name;
public ServerInfo(String name) {
this.name = name;
}
}

View File

@ -0,0 +1,39 @@
package com.sourcegraph.cody.agent.protocol;
import org.jetbrains.annotations.Nullable;
public class TextDocument {
public String filePath;
@Nullable public String content;
@Nullable public Range selection;
public TextDocument setFilePath(String filePath) {
this.filePath = filePath;
return this;
}
public TextDocument setContent(String content) {
this.content = content;
return this;
}
public TextDocument setSelection(Range selection) {
this.selection = selection;
return this;
}
@Override
public String toString() {
return "TextDocument{"
+ "filePath='"
+ filePath
+ '\''
+ ", content='"
+ content
+ '\''
+ ", selection="
+ selection
+ '}';
}
}

View File

@ -0,0 +1,6 @@
/**
* Handwritten classes for the agent JSON-RPC protocol.
*
* <p>In the future, these classes should ideally be replaced with automatically generated classes.
*/
package com.sourcegraph.cody.agent.protocol;

View File

@ -1,11 +1,17 @@
package com.sourcegraph.cody.chat;
import com.intellij.openapi.project.Project;
import com.sourcegraph.cody.UpdatableChat;
import com.sourcegraph.cody.agent.CodyAgent;
import com.sourcegraph.cody.agent.protocol.ExecuteRecipeParams;
import com.sourcegraph.cody.api.*;
import com.sourcegraph.cody.prompts.Preamble;
import com.sourcegraph.cody.vscode.CancellationToken;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -19,10 +25,22 @@ public class Chat {
}
public void sendMessage(
@NotNull ChatMessage humanMessage, @Nullable String prefix, @NotNull UpdatableChat chat) {
List<Message> preamble = Preamble.getPreamble(codebase);
@NotNull Project project,
@NotNull ChatMessage humanMessage,
@Nullable String prefix,
@NotNull UpdatableChat chat)
throws ExecutionException, InterruptedException {
if (CodyAgent.isConnected(project)) {
sendMessageViaAgent(project, humanMessage, chat);
} else {
sendMessageWithoutAgent(humanMessage, prefix, chat);
}
}
// TODO: Use the context getting logic from VS Code
private void sendMessageWithoutAgent(
@NotNull ChatMessage humanMessage, @Nullable String prefix, @NotNull UpdatableChat chat) {
// TODO: Usethe context getting logic from VS Code
List<Message> preamble = Preamble.getPreamble(codebase);
var codeContext = "";
if (humanMessage.getContextFiles().size() == 0) {
codeContext = "I have no file open in the editor right now.";
@ -41,11 +59,46 @@ public class Chat {
// ConfigUtil.getAccessToken(project) TODO: Get the access token from the plugin config
// TODO: Don't create this each time
completionsService.streamCompletion(
input,
new ChatUpdaterCallbacks(chat, prefix),
CompletionsService.Endpoint.Stream,
new CancellationToken());
}
private void sendMessageViaAgent(
@NotNull Project project, @NotNull ChatMessage humanMessage, @NotNull UpdatableChat chat)
throws ExecutionException, InterruptedException {
final AtomicBoolean isFirstMessage = new AtomicBoolean(false);
CodyAgent.getClient(project).onChatUpdateMessageInProgress =
(agentChatMessage) -> {
if (agentChatMessage.text == null) {
return;
}
ChatMessage chatMessage =
new ChatMessage(
Speaker.ASSISTANT,
agentChatMessage.text,
agentChatMessage.displayText,
agentChatMessage.contextFiles.stream()
.map(file -> file.fileName)
.collect(Collectors.toList()));
if (isFirstMessage.compareAndSet(false, true)) {
chat.addMessageToChat(chatMessage);
} else {
chat.updateLastMessage(chatMessage);
}
};
CodyAgent.getInitializedServer(project)
.thenAcceptAsync(
server ->
server.recipesExecute(
new ExecuteRecipeParams()
.setId("chat-question")
.setHumanChatInput(humanMessage.getText())),
CodyAgent.executorService)
.get();
chat.finishMessageProcessing();
}
}

View File

@ -10,7 +10,7 @@ public class ChatMessage extends Message {
private final @NotNull String displayText;
private final @NotNull List<String> contextFiles;
private ChatMessage(
ChatMessage(
@NotNull Speaker speaker,
@NotNull String text,
@NotNull String displayText,

View File

@ -3,17 +3,29 @@ package com.sourcegraph.cody.completions;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.editor.Caret;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.SelectionModel;
import com.intellij.openapi.editor.VisualPosition;
import com.intellij.openapi.editor.event.*;
import com.intellij.openapi.editor.ex.util.EditorUtil;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.TextEditor;
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.vfs.VirtualFile;
import com.sourcegraph.cody.agent.CodyAgent;
import com.sourcegraph.cody.agent.CodyAgentServer;
import com.sourcegraph.cody.agent.protocol.Position;
import com.sourcegraph.cody.agent.protocol.Range;
import com.sourcegraph.cody.agent.protocol.TextDocument;
import com.sourcegraph.cody.vscode.InlineCompletionTriggerKind;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Determines when to trigger completions and when to clear completions.
@ -29,6 +41,7 @@ public class CodyEditorFactoryListener implements EditorFactoryListener {
@Override
public void editorCreated(@NotNull EditorFactoryEvent event) {
Editor editor = event.getEditor();
informAgentAboutEditorChange(editor);
Project project = editor.getProject();
if (project == null || project.isDisposed()) {
return;
@ -44,6 +57,7 @@ public class CodyEditorFactoryListener implements EditorFactoryListener {
@Override
public void caretPositionChanged(@NotNull CaretEvent e) {
informAgentAboutEditorChange(e.getEditor());
CodyCompletionsManager suggestions = CodyCompletionsManager.getInstance();
if (suggestions.isEnabledForEditor(e.getEditor())
&& CodyEditorFactoryListener.isSelectedEditor(e.getEditor())) {
@ -58,6 +72,7 @@ public class CodyEditorFactoryListener implements EditorFactoryListener {
public void selectionChanged(@NotNull SelectionEvent e) {
if (CodyCompletionsManager.getInstance().isEnabledForEditor(e.getEditor())
&& CodyEditorFactoryListener.isSelectedEditor(e.getEditor())) {
informAgentAboutEditorChange(e.getEditor());
ApplicationManager.getApplication()
.getService(CodyCompletionsManager.class)
.clearCompletions(e.getEditor());
@ -80,6 +95,7 @@ public class CodyEditorFactoryListener implements EditorFactoryListener {
completions.clearCompletions(this.editor);
if (completions.isEnabledForEditor(this.editor)
&& !CommandProcessor.getInstance().isUndoTransparentActionInProgress()) {
informAgentAboutEditorChange(this.editor);
int changeOffset = event.getOffset() + event.getNewLength();
if (this.editor.getCaretModel().getOffset() == changeOffset) {
InlineCompletionTriggerKind requestType =
@ -115,4 +131,57 @@ public class CodyEditorFactoryListener implements EditorFactoryListener {
FileEditor current = editorManager.getSelectedEditor();
return current instanceof TextEditor && editor.equals(((TextEditor) current).getEditor());
}
@Nullable
private static Range getSelection(Editor editor) {
SelectionModel selectionModel = editor.getSelectionModel();
VisualPosition selectionStartPosition = selectionModel.getSelectionStartPosition();
VisualPosition selectionEndPosition = selectionModel.getSelectionEndPosition();
if (selectionStartPosition != null && selectionEndPosition != null) {
return new Range()
.setStart(
new Position()
.setLine(selectionStartPosition.line)
.setCharacter(selectionStartPosition.column))
.setEnd(
new Position()
.setLine(selectionEndPosition.line)
.setCharacter(selectionEndPosition.column));
}
List<Caret> carets = editor.getCaretModel().getAllCarets();
if (!carets.isEmpty()) {
Caret caret = carets.get(0);
Position position =
new Position()
.setLine(caret.getLogicalPosition().line)
.setCharacter(caret.getLogicalPosition().column);
// A single-offset caret is a selection where end == start.
return new Range().setStart(position).setEnd(position);
}
return null;
}
// Sends a textDocument/didChange notification to the agent server.
public static void informAgentAboutEditorChange(@Nullable Editor editor) {
if (editor == null) {
return;
}
if (editor.getProject() == null) {
return;
}
CodyAgentServer server = CodyAgent.getServer(editor.getProject());
if (server == null) {
return;
}
VirtualFile file = FileDocumentManager.getInstance().getFile(editor.getDocument());
if (file == null) {
return;
}
TextDocument document =
new TextDocument()
.setFilePath(file.getPath())
.setContent(editor.getDocument().getText())
.setSelection(getSelection(editor));
server.textDocumentDidChange(document);
}
}

View File

@ -5,7 +5,10 @@ import com.intellij.ide.plugins.IdeaPluginDescriptor;
import com.intellij.ide.plugins.PluginManagerCore;
import com.intellij.openapi.extensions.PluginId;
import com.intellij.openapi.project.Project;
import com.sourcegraph.cody.agent.ConnectionConfiguration;
import com.sourcegraph.find.Search;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
@ -14,15 +17,19 @@ import org.jetbrains.annotations.Nullable;
public class ConfigUtil {
public static final String DOTCOM_URL = "https://sourcegraph.com/";
@NotNull
public static ConnectionConfiguration getAgentConfiguration(@NotNull Project project) {
return new ConnectionConfiguration()
.setServerEndpoint(getSourcegraphUrl(project))
.setAccessToken(getProjectAccessToken(project))
.setCustomHeaders(getCustomRequestHeadersAsMap(project));
}
@NotNull
public static JsonObject getConfigAsJson(@NotNull Project project) {
JsonObject configAsJson = new JsonObject();
configAsJson.addProperty("instanceURL", ConfigUtil.getSourcegraphUrl(project));
configAsJson.addProperty(
"accessToken",
ConfigUtil.getInstanceType(project) == SettingsComponent.InstanceType.ENTERPRISE
? ConfigUtil.getDotComAccessToken(project)
: ConfigUtil.getEnterpriseAccessToken(project));
configAsJson.addProperty("accessToken", ConfigUtil.getProjectAccessToken(project));
configAsJson.addProperty(
"customRequestHeadersAsString", ConfigUtil.getCustomRequestHeaders(project));
configAsJson.addProperty("pluginVersion", ConfigUtil.getPluginVersion());
@ -84,6 +91,15 @@ public class ConfigUtil {
return !userLevelUrl.equals("") ? addSlashIfNeeded(userLevelUrl) : "";
}
public static Map<String, String> getCustomRequestHeadersAsMap(@NotNull Project project) {
Map<String, String> result = new HashMap<>();
String[] pairs = getCustomRequestHeaders(project).split(",");
for (int i = 0; i + 1 < pairs.length; i = i + 2) {
result.put(pairs[i], pairs[i + 1]);
}
return result;
}
@NotNull
public static String getCustomRequestHeaders(@NotNull Project project) {
// Project level
@ -242,6 +258,23 @@ public class ConfigUtil {
return Objects.requireNonNull(SourcegraphService.getInstance(project));
}
@Nullable
public static String getWorkspaceRoot(@NotNull Project project) {
if (project.getBasePath() != null) {
return project.getBasePath();
}
// The base path should only be null for the default project. The agent server assumes that the
// workspace root is not null, so we have to provide some default value. Feel free to change to
// something else than the home directory if this is causing problems.
return System.getProperty("user.home");
}
public static String getProjectAccessToken(Project project) {
return ConfigUtil.getInstanceType(project) == SettingsComponent.InstanceType.ENTERPRISE
? ConfigUtil.getEnterpriseAccessToken(project)
: ConfigUtil.getProjectAccessToken(project);
}
public static String getEnterpriseAccessToken(Project project) {
// Project level application level
String projectLevelAccessToken = getProjectLevelConfig(project).getEnterpriseAccessToken();

View File

@ -15,6 +15,8 @@ import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import com.intellij.util.messages.MessageBus;
import com.intellij.util.messages.MessageBusConnection;
import com.sourcegraph.cody.agent.CodyAgent;
import com.sourcegraph.cody.agent.CodyAgentServer;
import com.sourcegraph.cody.completions.CodyCompletionsManager;
import com.sourcegraph.find.browser.JavaToJSBridge;
import com.sourcegraph.telemetry.GraphQlLogger;
@ -55,6 +57,12 @@ public class SettingsChangeListener implements Disposable {
javaToJSBridge.callJS("pluginSettingsChanged", ConfigUtil.getConfigAsJson(project));
}
// Notify Cody Agent about config changes.
CodyAgentServer agentServer = CodyAgent.getServer(project);
if (agentServer != null) {
agentServer.configurationDidChange(ConfigUtil.getAgentConfiguration(project));
}
// Log install events
if (!Objects.equals(context.oldUrl, context.newUrl)) {
GraphQlLogger.logInstallEvent(project, ConfigUtil::setInstallEventLogged);

View File

@ -44,6 +44,7 @@
secondary="false"
factoryClass="com.sourcegraph.cody.CodyToolWindowFactory"/>
<notificationGroup id="Cody Sourcegraph access" displayType="BALLOON"/>
<projectService serviceImplementation="com.sourcegraph.cody.agent.CodyAgent"/>
<!-- Code completions -->
@ -56,6 +57,14 @@
</extensions>
<applicationListeners>
<listener class="com.sourcegraph.cody.CodyAgentProjectListener" topic="com.intellij.openapi.project.ProjectManagerListener"/>
<listener class="com.sourcegraph.cody.CodyFileEditorListener" topic="com.intellij.openapi.fileEditor.FileEditorManagerListener"/>
<!-- CodyAgentFocusListener is commented out since it doesn't seem possible to register a listener via plugin.xml.
We programmatically register a listener from CodyAgent instead. -->
<!-- <listener class="com.sourcegraph.cody.CodyAgentFocusListener" topic="com.intellij.openapi.editor.ex.FocusChangeListener"/> -->
</applicationListeners>
<actions>
<action
id="sourcegraph.openFile"

View File

@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
browserify-zlib: 0.2.0
'@types/webpack': '5'
@ -1435,6 +1439,19 @@ importers:
specifier: ^0.16.0
version: 0.16.0(vite@4.1.4)
client/cody-agent:
dependencies:
'@sourcegraph/cody-shared':
specifier: workspace:*
version: link:../cody-shared
'@sourcegraph/common':
specifier: workspace:*
version: link:../common
devDependencies:
pkg:
specifier: ^5.8.1
version: 5.8.1
client/cody-cli:
dependencies:
'@sourcegraph/cody-shared':
@ -2123,6 +2140,15 @@ packages:
transitivePeerDependencies:
- supports-color
/@babel/generator@7.18.2:
resolution: {integrity: sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.22.5
'@jridgewell/gen-mapping': 0.3.3
jsesc: 2.5.2
dev: true
/@babel/generator@7.22.5:
resolution: {integrity: sha512-+lcUbnTRhd0jOewtFSedLyiPsD5tswKkbgcezOqqWFUVNEwoUTlpPOBmvhG7OXWLR4jMdv0czPGH5XbflnD1EA==}
engines: {node: '>=6.9.0'}
@ -2482,6 +2508,14 @@ packages:
'@babel/types': 7.22.5
dev: true
/@babel/parser@7.18.4:
resolution: {integrity: sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.22.5
dev: true
/@babel/parser@7.22.5:
resolution: {integrity: sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q==}
engines: {node: '>=6.0.0'}
@ -4987,6 +5021,15 @@ packages:
to-fast-properties: 2.0.0
dev: true
/@babel/types@7.19.0:
resolution: {integrity: sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-string-parser': 7.22.5
'@babel/helper-validator-identifier': 7.22.5
to-fast-properties: 2.0.0
dev: true
/@babel/types@7.22.5:
resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==}
engines: {node: '>=6.9.0'}
@ -10187,7 +10230,7 @@ packages:
file-system-cache: 1.0.5
find-up: 5.0.0
fork-ts-checker-webpack-plugin: 6.5.2(eslint@8.34.0)(typescript@5.0.2)(webpack@5.75.0)
fs-extra: 9.0.1
fs-extra: 9.1.0
glob: 7.2.3
handlebars: 4.7.7
interpret: 2.2.0
@ -10263,7 +10306,7 @@ packages:
cpy: 8.1.2
detect-port: 1.3.0
express: 4.18.2
fs-extra: 9.0.1
fs-extra: 9.1.0
global: 4.4.0
globby: 11.1.0
ip: 2.0.0
@ -10358,7 +10401,7 @@ packages:
'@storybook/csf': 0.0.2--canary.4566f4d.1
'@storybook/mdx1-csf': 0.0.1(@babel/core@7.22.1)
core-js: 3.22.8
fs-extra: 9.0.1
fs-extra: 9.1.0
global: 4.4.0
regenerator-runtime: 0.13.11
ts-dedent: 2.0.0
@ -10416,7 +10459,7 @@ packages:
express: 4.18.2
file-loader: 6.2.0(webpack@5.75.0)
find-up: 5.0.0
fs-extra: 9.0.1
fs-extra: 9.1.0
html-webpack-plugin: 4.5.2(esbuild@0.17.14)(webpack-cli@5.0.1)(webpack@5.75.0)
node-fetch: 2.6.11
pnp-webpack-plugin: 1.6.4(typescript@5.0.2)
@ -10474,7 +10517,7 @@ packages:
css-loader: 5.2.6(webpack@5.75.0)
express: 4.18.2
find-up: 5.0.0
fs-extra: 9.0.1
fs-extra: 9.1.0
html-webpack-plugin: 5.5.0(webpack@5.75.0)
node-fetch: 2.6.11
process: 0.11.10
@ -10655,7 +10698,7 @@ packages:
babel-plugin-react-docgen: 4.2.1
core-js: 3.22.8
escodegen: 2.0.0
fs-extra: 9.0.1
fs-extra: 9.1.0
global: 4.4.0
html-tags: 3.1.0
lodash: 4.17.21
@ -10767,7 +10810,7 @@ packages:
core-js: 3.22.8
detect-package-manager: 2.0.1
fetch-retry: 5.0.2
fs-extra: 9.0.1
fs-extra: 9.1.0
global: 4.4.0
isomorphic-unfetch: 3.1.0
nanoid: 3.3.6
@ -19094,7 +19137,7 @@ packages:
cosmiconfig: 6.0.0
deepmerge: 4.2.2
eslint: 8.34.0
fs-extra: 9.0.1
fs-extra: 9.1.0
glob: 7.2.3
memfs: 3.4.12
minimatch: 3.1.2
@ -19172,6 +19215,13 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
/from2@2.3.0:
resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==}
dependencies:
inherits: 2.0.4
readable-stream: 2.3.8
dev: true
/fromentries@1.3.2:
resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==}
dev: true
@ -19212,14 +19262,14 @@ packages:
jsonfile: 4.0.0
universalify: 0.1.2
/fs-extra@9.0.1:
resolution: {integrity: sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==}
/fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
dependencies:
at-least-node: 1.0.0
graceful-fs: 4.2.11
jsonfile: 6.0.1
universalify: 1.0.0
universalify: 2.0.0
/fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
@ -20934,6 +20984,14 @@ packages:
intl-messageformat-parser: 1.8.1
dev: true
/into-stream@6.0.0:
resolution: {integrity: sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==}
engines: {node: '>=10'}
dependencies:
from2: 2.3.0
p-is-promise: 3.0.0
dev: true
/invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies:
@ -21102,6 +21160,12 @@ packages:
dependencies:
has: 1.0.3
/is-core-module@2.9.0:
resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
dependencies:
has: 1.0.3
dev: true
/is-data-descriptor@0.1.4:
resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==}
engines: {node: '>=0.10.0'}
@ -25406,6 +25470,13 @@ packages:
dns-packet: 5.4.0
thunky: 1.1.0
/multistream@4.1.0:
resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==}
dependencies:
once: 1.4.0
readable-stream: 3.6.2
dev: true
/mute-stdout@1.0.1:
resolution: {integrity: sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg==}
engines: {node: '>= 0.10'}
@ -26244,6 +26315,11 @@ packages:
engines: {node: '>=6'}
dev: true
/p-is-promise@3.0.0:
resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==}
engines: {node: '>=8'}
dev: true
/p-limit@1.3.0:
resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==}
engines: {node: '>=4'}
@ -26721,6 +26797,23 @@ packages:
dependencies:
find-up: 5.0.0
/pkg-fetch@3.4.2:
resolution: {integrity: sha512-0+uijmzYcnhC0hStDjm/cl2VYdrmVVBpe7Q8k9YBojxmR5tG8mvR9/nooQq3QSXiQqORDVOTY3XqMEqJVIzkHA==}
hasBin: true
dependencies:
chalk: 4.1.2
fs-extra: 9.1.0
https-proxy-agent: 5.0.1
node-fetch: 2.6.11
progress: 2.0.3
semver: 7.5.1
tar-fs: 2.1.1
yargs: 16.2.0
transitivePeerDependencies:
- encoding
- supports-color
dev: true
/pkg-types@1.0.3:
resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==}
dependencies:
@ -26728,6 +26821,34 @@ packages:
mlly: 1.3.0
pathe: 1.1.1
/pkg@5.8.1:
resolution: {integrity: sha512-CjBWtFStCfIiT4Bde9QpJy0KeH19jCfwZRJqHFDFXfhUklCx8JoFmMj3wgnEYIwGmZVNkhsStPHEOnrtrQhEXA==}
hasBin: true
peerDependencies:
node-notifier: '>=9.0.1'
peerDependenciesMeta:
node-notifier:
optional: true
dependencies:
'@babel/generator': 7.18.2
'@babel/parser': 7.18.4
'@babel/types': 7.19.0
chalk: 4.1.2
fs-extra: 9.1.0
globby: 11.1.0
into-stream: 6.0.0
is-core-module: 2.9.0
minimist: 1.2.8
multistream: 4.1.0
pkg-fetch: 3.4.2
prebuild-install: 7.1.1
resolve: 1.22.1
stream-meter: 1.0.4
transitivePeerDependencies:
- encoding
- supports-color
dev: true
/playwright-core@1.25.0:
resolution: {integrity: sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q==}
engines: {node: '>=14'}
@ -30493,6 +30614,12 @@ packages:
stream-chain: 2.2.5
dev: false
/stream-meter@1.0.4:
resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==}
dependencies:
readable-stream: 2.3.8
dev: true
/stream-shift@1.0.0:
resolution: {integrity: sha512-Afuc4BKirbx0fwm9bKOehZPG01DJkm/4qbklw4lo9nMPqd2x0kZTLcgwQUXdGiPPY489l3w8cQ5xEEAGbg8ACQ==}