diff --git a/src/common/constants.ts b/src/common/constants.ts index e8211f3c..5321593e 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -336,7 +336,7 @@ export const WASM_LANGUAGES: { [key: string]: string } = { } // TODO: We could have an extendable regex for this -export const EMBEDDING_IGNORE_LIST = [ +export const FILE_IGNORE_LIST = [ '__mocks__', '__tests__', '.babelrc.js', diff --git a/src/common/types.ts b/src/common/types.ts index f5f089e6..cf6aa3da 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,4 +1,4 @@ -import { InlineCompletionItem, InlineCompletionList } from 'vscode' +import { InlineCompletionItem, InlineCompletionList, Uri } from 'vscode' import { CodeLanguageDetails } from './languages' import { ALL_BRACKETS } from './constants' import { serverMessageKeys } from 'symmetry-core' @@ -28,6 +28,15 @@ export interface PrefixSuffix { suffix: string } + +export interface RepositoryLevelData { + uri: Uri; + text: string; + name: string; + isOpen: boolean; + relevanceScore: number; +} + export interface StreamResponse { model: string created_at: string @@ -209,6 +218,8 @@ export interface InteractionItem { name: string | null | undefined sessionLength: number visits: number | null | undefined + isOpen?: boolean + relevanceScore: number | null | undefined activeLines: { line: number character: number diff --git a/src/extension.global.d.ts b/src/extension.global.d.ts index 552f5b79..1c3a6daf 100644 --- a/src/extension.global.d.ts +++ b/src/extension.global.d.ts @@ -10,7 +10,7 @@ declare module 'hypercore-crypto' { verify: (challenge: Buffer, signature: Buffer, publicKey: Buffer) => boolean } - export = hyperCoreCrypto + export default hyperCoreCrypto } declare module '*.css' diff --git a/src/extension/file-interaction.ts b/src/extension/file-interaction.ts index 33b45071..0968a0f2 100644 --- a/src/extension/file-interaction.ts +++ b/src/extension/file-interaction.ts @@ -2,12 +2,17 @@ import { InteractionItem } from '../common/types' import { LRUCache } from './cache' export class FileInteractionCache { - private _interactions = new LRUCache(20) private _currentFile: string | null = null - private _sessionStartTime: Date | null = null - private _sessionPauseTime: Date | null = null private _inactivityTimeout: ReturnType | null = null + private _interactions = new LRUCache(20) + private _sessionPauseTime: Date | null = null + private _sessionStartTime: Date | null = null private readonly _inactivityThreshold = 5 * 60 * 1000 // 5 minutes + private static readonly KEY_STROKE_WEIGHT = 2 + private static readonly OPEN_FILE_WEIGHT = 10 + private static readonly RECENCY_WEIGHT = 2.1 + private static readonly SESSION_LENGTH_WEIGHT = 1 + private static readonly VISIT_WEIGHT = 0.5 constructor() { this.resetInactivityTimeout() @@ -41,36 +46,34 @@ export class FileInteractionCache { this.resetInactivityTimeout() } - getAll() { - const recencyWeight = 2.1 - const keyStrokeWeight = 2 - const sessionLengthWeight = 1 - const visitWeight = 0.5 + private calculateRelevanceScore(interaction: InteractionItem | null): number { + if (!interaction) return 0 + + const recency = Date.now() - (interaction.lastVisited || 0) + const score = + (interaction.keyStrokes || 0) * FileInteractionCache.KEY_STROKE_WEIGHT + + (interaction.visits || 0) * FileInteractionCache.VISIT_WEIGHT + + (interaction.sessionLength || 0) * + FileInteractionCache.SESSION_LENGTH_WEIGHT - + recency * FileInteractionCache.RECENCY_WEIGHT + + return ( + score + (interaction.isOpen ? FileInteractionCache.OPEN_FILE_WEIGHT : 0) + ) + } + getAll(): InteractionItem[] { return Array.from(this._interactions.getAll()) - .map(([a, b]) => ({ - name: a, - keyStrokes: b?.keyStrokes || 0, - visits: b?.visits || 0, - sessionLength: b?.sessionLength || 0, - lastVisited: b?.lastVisited || 0, - activeLines: b?.activeLines || [], + .map(([name, interaction]) => ({ + name, + keyStrokes: interaction?.keyStrokes || 0, + visits: interaction?.visits || 0, + sessionLength: interaction?.sessionLength || 0, + lastVisited: interaction?.lastVisited || 0, + activeLines: interaction?.activeLines || [], + relevanceScore: this.calculateRelevanceScore(interaction) })) - .sort((a, b) => { - const recencyA = Date.now() - (a.lastVisited || 0) - const recencyB = Date.now() - (b.lastVisited || 0) - const scoreA = - a.keyStrokes * keyStrokeWeight + - a.visits * visitWeight + - a.sessionLength * sessionLengthWeight - - recencyA * recencyWeight - const scoreB = - b.keyStrokes * keyStrokeWeight + - b.visits * visitWeight + - b.sessionLength * sessionLengthWeight - - recencyB * recencyWeight - return scoreB - scoreA - }) + .sort((a, b) => b.relevanceScore - a.relevanceScore) } getCurrentFile(): string | null { @@ -83,7 +86,8 @@ export class FileInteractionCache { if (!item) return this._interactions.set(this._currentFile, { ...item, - visits: (item.visits || 0) + 1 + visits: (item.visits || 0) + 1, + lastVisited: Date.now() }) } @@ -98,15 +102,19 @@ export class FileInteractionCache { activeLines: [ ...item.activeLines, { line: currentLine, character: currentCharacter } - ] + ], + lastVisited: Date.now() }) this.resumeSession() + this.resetInactivityTimeout() } startSession(filePath: string): void { this._sessionStartTime = new Date() this.put(filePath) + this.incrementVisits() + this.resetInactivityTimeout() } endSession(): void { @@ -127,12 +135,14 @@ export class FileInteractionCache { if (item) { this._interactions.set(this._currentFile, { ...item, - sessionLength: (item.sessionLength || 0) + sessionLength + sessionLength: (item.sessionLength || 0) + sessionLength, + lastVisited: Date.now() }) } this._sessionStartTime = null this._sessionPauseTime = null + this._currentFile = null } delete(filePath: string): void { @@ -143,15 +153,19 @@ export class FileInteractionCache { put(filePath: string): void { this._currentFile = filePath.replace('.git', '').replace('.hg', '') const fileExtension = this._currentFile.split('.').pop() - if (this._interactions.get(this._currentFile)) return + if (this._interactions.get(this._currentFile)) { + this.incrementVisits() + return + } if (this._currentFile.includes('.') && fileExtension) { this._interactions.set(this._currentFile, { name: this._currentFile, keyStrokes: 0, - visits: 0, + visits: 1, sessionLength: 0, activeLines: [], - lastVisited: Date.now() + lastVisited: Date.now(), + relevanceScore: 0 }) } } diff --git a/src/extension/fim-templates.ts b/src/extension/fim-templates.ts index d8429c46..f02c13dc 100644 --- a/src/extension/fim-templates.ts +++ b/src/extension/fim-templates.ts @@ -5,10 +5,14 @@ import { STOP_DEEPSEEK, STOP_LLAMA, STOP_QWEN, - STOP_STARCODER, + STOP_STARCODER } from '../common/constants' import { supportedLanguages } from '../common/languages' -import { FimPromptTemplate } from '../common/types' +import { + RepositoryLevelData, + FimPromptTemplate, + PrefixSuffix +} from '../common/types' const getFileContext = ( fileContextEnabled: boolean, @@ -208,6 +212,37 @@ export const getStopWordsAuto = (fimModel: string) => { return STOP_LLAMA } +export const getFimTemplateRepositoryLevel = ( + repo: string, + code: RepositoryLevelData[], + prefixSuffix: PrefixSuffix, + currentFileName: string | undefined +) => { + return getFimPromptTemplateQwenMulti( + repo, + code, + prefixSuffix, + currentFileName + ) +} + +export const getFimPromptTemplateQwenMulti = ( + repo: string, + files: RepositoryLevelData[], + prefixSuffix: PrefixSuffix, + currentFileName: string | undefined +): string => { + let prompt = `<|repo_name|>${repo}\n` + + for (const file of files) { + prompt += `<|file_sep|>${file.name}\n${file.text}\n` + } + + prompt += `<|file_sep|>${currentFileName}\n${prefixSuffix.prefix}` + + return prompt.trim() +} + export const getStopWordsChosen = (format: string) => { if (format === FIM_TEMPLATE_FORMAT.codellama) return STOP_LLAMA if (format === FIM_TEMPLATE_FORMAT.deepseek) return STOP_DEEPSEEK diff --git a/src/extension/provider-manager.ts b/src/extension/provider-manager.ts index df389b59..6e9463f0 100644 --- a/src/extension/provider-manager.ts +++ b/src/extension/provider-manager.ts @@ -23,6 +23,7 @@ export interface TwinnyProvider { type: string apiKey?: string fimTemplate?: string + repositoryLevel?: boolean } type Providers = Record | undefined diff --git a/src/extension/providers/completion.ts b/src/extension/providers/completion.ts index 0ee1431c..26cd7025 100644 --- a/src/extension/providers/completion.ts +++ b/src/extension/providers/completion.ts @@ -27,15 +27,21 @@ import { import { cache } from '../cache' import { supportedLanguages } from '../../common/languages' import { + RepositoryLevelData as RepositoryDocment, FimTemplateData, PrefixSuffix, ResolvedInlineCompletion, StreamRequestOptions, StreamResponse } from '../../common/types' -import { getFimPrompt, getStopWords } from '../fim-templates' +import { + getFimPrompt, + getFimTemplateRepositoryLevel, + getStopWords +} from '../fim-templates' import { ACTIVE_FIM_PROVIDER_STORAGE_KEY, + FILE_IGNORE_LIST, FIM_TEMPLATE_FORMAT, LINE_BREAK_REGEX, MAX_CONTEXT_LINE_COUNT, @@ -96,7 +102,9 @@ export class CompletionProvider implements InlineCompletionItemProvider { private _statusBar: StatusBarItem private _temperature = this._config.get('temperature') as number private _templateProvider: TemplateProvider - private _fileContextEnabled = this._config.get('fileContextEnabled') as boolean + private _fileContextEnabled = this._config.get( + 'fileContextEnabled' + ) as boolean private _usingFimTemplate = false private _provider: TwinnyProvider | undefined @@ -207,7 +215,7 @@ export class CompletionProvider implements InlineCompletionItemProvider { }) } - private async tryParseDocument (document: TextDocument) { + private async tryParseDocument(document: TextDocument) { try { if (!this._position || !this._document) return const parser = await getParser(document.uri.fsPath) @@ -273,7 +281,7 @@ export class CompletionProvider implements InlineCompletionItemProvider { ) } - if (stopWords.some(stopWord => this._completion.includes(stopWord))) { + if (stopWords.some((stopWord) => this._completion.includes(stopWord))) { return this._completion } @@ -338,7 +346,8 @@ export class CompletionProvider implements InlineCompletionItemProvider { } } } - } catch (e) { // Currently doesnt catch when parser fucks up + } catch (e) { + // Currently doesnt catch when parser fucks up console.error(e) this.abortCompletion() } @@ -388,6 +397,81 @@ export class CompletionProvider implements InlineCompletionItemProvider { return `\n${language}\n${path}\n` } + public getIgnoreDirectory(fileName: string): boolean { + return FILE_IGNORE_LIST.some((ignoreItem: string) => + fileName.includes(ignoreItem) + ) + } + + private async getRelevantDocuments(): Promise { + const interactions = this._fileInteractionCache.getAll() + const currentFileName = this._document?.fileName || '' + const openTextDocuments = workspace.textDocuments + + const openDocumentsData: RepositoryDocment[] = openTextDocuments + .filter((doc) => { + const isCurrentFile = doc.fileName === currentFileName + const isGitFile = + doc.fileName.includes('.git') || doc.fileName.includes('git/') + const isIgnored = this.getIgnoreDirectory(doc.fileName) + return !isCurrentFile && !isGitFile && !isIgnored + }) + .map((doc) => { + const interaction = interactions.find((i) => i.name === doc.fileName) + return { + uri: doc.uri, + text: doc.getText(), + name: doc.fileName, + isOpen: true, + relevanceScore: interaction?.relevanceScore || 0 + } + }) + + const otherDocumentsData: RepositoryDocment[] = ( + await Promise.all( + interactions + .filter( + (interaction) => + !openTextDocuments.some( + (doc) => doc.fileName === interaction.name + ) + ) + .filter( + (interaction) => !this.getIgnoreDirectory(interaction.name || '') + ) + .map(async (interaction) => { + const filePath = interaction.name + if (!filePath) return null + if ( + filePath.toString().match('.git') || + currentFileName === filePath + ) + return null + const uri = Uri.file(filePath) + try { + const document = await workspace.openTextDocument(uri) + return { + uri, + text: document.getText(), + name: filePath, + isOpen: false, + relevanceScore: interaction.relevanceScore + } + } catch (error) { + console.error(`Error opening document ${filePath}:`, error) + return null + } + }) + ) + ).filter((doc): doc is RepositoryDocment => doc !== null) + + const allDocuments = [...openDocumentsData, ...otherDocumentsData].sort( + (a, b) => b.relevanceScore - a.relevanceScore + ) + + return allDocuments.slice(0, 3) + } + private async getFileInteractionContext() { const interactions = this._fileInteractionCache.getAll() const currentFileName = this._document?.fileName || '' @@ -396,6 +480,8 @@ export class CompletionProvider implements InlineCompletionItemProvider { for (const interaction of interactions) { const filePath = interaction.name + if (!filePath) return + if (filePath.toString().match('.git')) continue const uri = Uri.file(filePath) @@ -467,9 +553,9 @@ export class CompletionProvider implements InlineCompletionItemProvider { prefix: prefixSuffix.prefix, suffix: prefixSuffix.suffix, systemMessage, - context: fileInteractionContext, + context: fileInteractionContext || '', fileName: this._document.uri.fsPath, - language: documentLanguage, + language: documentLanguage }) if (fimTemplate) { @@ -478,6 +564,18 @@ export class CompletionProvider implements InlineCompletionItemProvider { } } + if (this._provider.repositoryLevel) { + const repositoryLevelData = await this.getRelevantDocuments() + const repoName = workspace.name + const currentFile = await this._document.uri.fsPath + return getFimTemplateRepositoryLevel( + repoName || 'untitled', + repositoryLevelData, + prefixSuffix, + currentFile + ) + } + return getFimPrompt( this._provider.modelName, this._provider.fimTemplate || FIM_TEMPLATE_FORMAT.automatic, diff --git a/src/extension/utils.ts b/src/extension/utils.ts index ce61be83..779c6564 100644 --- a/src/extension/utils.ts +++ b/src/extension/utils.ts @@ -36,7 +36,7 @@ import { ALL_BRACKETS, CLOSING_BRACKETS, defaultChunkOptions, - EMBEDDING_IGNORE_LIST, + FILE_IGNORE_LIST, EVENT_NAME, EXTENSION_CONTEXT_NAME, LINE_BREAK_REGEX, @@ -694,7 +694,7 @@ export async function getAllFilePaths(dirPath: string): Promise { } export function getIgnoreDirectory(fileName: string): boolean { - return EMBEDDING_IGNORE_LIST.some((ignoreItem: string) => + return FILE_IGNORE_LIST.some((ignoreItem: string) => fileName.includes(ignoreItem) ) } diff --git a/src/webview/providers.tsx b/src/webview/providers.tsx index 313dac5d..b4102c80 100644 --- a/src/webview/providers.tsx +++ b/src/webview/providers.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useOllamaModels, useProviders } from './hooks' import { VSCodeButton, + VSCodeCheckbox, VSCodeDivider, VSCodeDropdown, VSCodeOption, @@ -17,14 +18,20 @@ import { } from '../common/constants' import { ModelSelect } from './model-select' import styles from './styles/providers.module.css' +import indexStyles from './styles/index.module.css' export const Providers = () => { const [showForm, setShowForm] = React.useState(false) const [provider, setProvider] = React.useState() const { models } = useOllamaModels() const hasOllamaModels = !!models?.length - const { updateProvider, providers, removeProvider, copyProvider, resetProviders } = - useProviders() + const { + updateProvider, + providers, + removeProvider, + copyProvider, + resetProviders + } = useProviders() const handleClose = () => { setShowForm(false) @@ -212,6 +219,14 @@ function ProviderForm({ onClose, provider }: ProviderFormProps) { setFormState({ ...formState, [name]: value.trim() }) } + const handleRepositoryLevelCheck = ( + e: React.MouseEvent + ) => { + const target = e.target as HTMLInputElement + console.log(target.checked, target.value) + setFormState({ ...formState, repositoryLevel: target.checked }) + } + const handleCancel = () => { onClose() } @@ -392,6 +407,20 @@ function ProviderForm({ onClose, provider }: ProviderFormProps) { > + {formState.type === 'fim' && ( +
+ +
+ )} +
Save