.await(): T =
+ suspendCancellableCoroutine { cont ->
+ whenComplete { value, error ->
+ if (error == null) cont.resume(value) else cont.resumeWithException(error)
+ }
+ cont.invokeOnCancellation { cancel(true) }
+ }
diff --git a/code-suggestions-plugin/src/main/res/values/colors.xml b/code-suggestions-plugin/src/main/res/values/colors.xml
new file mode 100644
index 00000000..43e21263
--- /dev/null
+++ b/code-suggestions-plugin/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #485D92
+ #FFFFFF
+ #DAE2FF
+ #001847
+
diff --git a/code-suggestions-plugin/src/main/res/values/styles.xml b/code-suggestions-plugin/src/main/res/values/styles.xml
new file mode 100644
index 00000000..17bb8c55
--- /dev/null
+++ b/code-suggestions-plugin/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/speech-to-text-plugin/README.md b/speech-to-text-plugin/README.md
new file mode 100644
index 00000000..a377b24b
--- /dev/null
+++ b/speech-to-text-plugin/README.md
@@ -0,0 +1,85 @@
+# Speech to Text plugin for CodeOnTheGo
+
+Voice-to-code. Adds a **Voice to Code** button to the editor toolbar: tap it,
+speak, and the recognized text — or code generated from it — is inserted at the
+cursor. Speech recognition uses Android's `SpeechRecognizer` (on-device when
+available); code generation is delegated to the companion `ai-core` plugin.
+
+> Code generation has **no compile-time dependency** on `ai-core`; the LLM
+> service is resolved at runtime. Install **`ai-core` first** for voice→code.
+> Without it, the raw transcript is inserted instead.
+
+## Architecture
+
+```
+┌──────────────────────────┐
+│ speech-to-text (this) │ ← toolbar button, SpeechRecognizer, insert at cursor
+└────────────┬─────────────┘
+ │ SharedServices (runtime) → LlmInferenceService (optional)
+ ▼
+┌──────────────────────────┐
+│ ai-core │ ← turns the transcript into code
+└──────────────────────────┘
+```
+
+## Features
+
+- "Voice to Code" toolbar action (`UIExtension`), enabled only while a file is open
+- Dynamic icon: mic (idle) → waves (recording) → spinner (processing)
+- On-device speech recognition preferred, with network fallback
+- Optional LLM code generation from the transcript via `ai-core`
+- Inserts the result at the editor cursor
+
+## Permissions
+
+Android (`uses-permission`): `RECORD_AUDIO`.
+
+Plugin (`plugin.permissions`): `filesystem.read`, `filesystem.write`.
+
+| Permission | Why |
+|---|---|
+| `RECORD_AUDIO` | capture microphone audio for recognition |
+| `filesystem.write` | insert transcribed/generated text into the open file |
+| `filesystem.read` | read editor/file context |
+
+Speech recognition runs through Android's `SpeechRecognizer` and code
+generation through `ai-core`'s local backend, so the plugin itself makes no
+network calls and bundles no native code.
+
+`RECORD_AUDIO` is a runtime (dangerous) permission: it is requested on first tap
+of the button, not at load. If denied, the plugin degrades gracefully (no voice
+capture) and can still insert LLM output. Microphone audio is captured only
+while recording and is not stored by the plugin. When `ai-core`'s **Gemini**
+backend is selected, the transcript is sent to Google over HTTPS; the **Local**
+backend keeps everything on-device.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`.
+
+```bash
+cd speech-to-text-plugin
+../gradlew assemblePlugin # release -> build/plugin/speech-to-text-plugin.cgp
+../gradlew assemblePluginDebug # debug variant
+```
+
+The build resolves `plugin-api.jar` from the repo-root `../libs/`.
+
+## Installation
+
+1. (Optional but recommended) Build and install **`ai-core` first** for
+ voice→code (see [`../ai-core/README.md`](../ai-core/README.md)).
+2. Build this plugin, install `build/plugin/speech-to-text-plugin.cgp` via
+ CodeOnTheGo's Plugin Manager, and restart the IDE.
+3. Open a file, tap the microphone in the editor toolbar, grant the microphone
+ permission on first use, and speak.
+
+## Key classes
+
+- `SpeechToTextPlugin.kt` — toolbar action, `SpeechRecognizer` lifecycle,
+ transcript handling, editor insertion
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/speech-to-text-plugin/build.gradle.kts b/speech-to-text-plugin/build.gradle.kts
new file mode 100644
index 00000000..6f56f5f0
--- /dev/null
+++ b/speech-to-text-plugin/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "speech-to-text-plugin"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.stt"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.stt"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // Material Design & AndroidX
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.core:core:1.12.0")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
+}
+
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") || it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/speech-to-text-plugin/gradle.properties b/speech-to-text-plugin/gradle.properties
new file mode 100644
index 00000000..ddc879cb
--- /dev/null
+++ b/speech-to-text-plugin/gradle.properties
@@ -0,0 +1,6 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+# The ONNX Runtime dependency makes dex/resource merging memory-hungry; the Gradle
+# defaults (512m heap / 384m metaspace) OOM during assemblePlugin. Raise them.
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g
diff --git a/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.jar b/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 00000000..8bdaf60c
Binary files /dev/null and b/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.properties b/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..d4081da4
--- /dev/null
+++ b/speech-to-text-plugin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/speech-to-text-plugin/gradlew b/speech-to-text-plugin/gradlew
new file mode 100755
index 00000000..ef07e016
--- /dev/null
+++ b/speech-to-text-plugin/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/speech-to-text-plugin/gradlew.bat b/speech-to-text-plugin/gradlew.bat
new file mode 100755
index 00000000..db3a6ac2
--- /dev/null
+++ b/speech-to-text-plugin/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/speech-to-text-plugin/settings.gradle.kts b/speech-to-text-plugin/settings.gradle.kts
new file mode 100644
index 00000000..4ebe4103
--- /dev/null
+++ b/speech-to-text-plugin/settings.gradle.kts
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+
+buildscript {
+ repositories { google(); mavenCentral() }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.11.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+
+rootProject.name = "speech-to-text-plugin"
diff --git a/speech-to-text-plugin/speech-to-text-plugin.html b/speech-to-text-plugin/speech-to-text-plugin.html
new file mode 100644
index 00000000..954f7b8a
--- /dev/null
+++ b/speech-to-text-plugin/speech-to-text-plugin.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+Speech to Text Plugin
+
+
+
+ Speech to Text Plugin
+
+ Executive overview
+ Speech to Text adds voice-to-code to the CodeOnTheGo editor. It
+ contributes a Voice to Code button to the editor toolbar: tap it, speak,
+ and the recognized text — or code generated from it — is inserted at the
+ cursor.
+ Recognition uses Android's built-in SpeechRecognizer
+ (on-device when the device supports it). Turning the transcript into code is
+ optional and is delegated to the companion AI Core plugin; if AI Core
+ isn't installed, the raw transcript is inserted instead.
+
+ Core functionality
+
+ - Voice to Code toolbar button — enabled only while a file is open
+ in the editor.
+ - Dynamic icon — microphone (idle) → waves (recording) → spinner
+ (processing).
+ - Speech recognition via the Android platform recognizer, preferring
+ on-device recognition.
+ - Optional code generation — with AI Core installed, the transcript
+ is turned into code by the local model.
+ - Insert at cursor — the result is written into the active editor at
+ the caret.
+
+
+ Technical architecture
+
+ | Piece | Role |
+ Toolbar action (UIExtension) | Registers the
+ "Voice to Code" button and drives its dynamic icon via the recording
+ state. |
+ SpeechRecognizer | Android platform speech
+ recognition, created and torn down per capture on the UI thread. |
+ IdeEditorService | Reports the open file and
+ inserts the transcribed/generated text at the cursor. |
+ LlmInferenceService (AI Core) | Optional. Resolved
+ at runtime; converts the transcript into code using the local backend. |
+
+ The plugin contains no bundled model and no native code of its own —
+ recognition is provided by the platform and code generation by AI Core.
+
+ Usage
+
+ - (Optional) Install AI Core first and select a local
+
.gguf model in AI Assistant → AI Settings to enable
+ voice→code.
+ - Install Speech to Text via the Plugin Manager and restart the
+ IDE.
+ - Open a file, tap the microphone button in the editor toolbar, and
+ grant microphone permission on first use.
+ - Speak; the recognized text (or generated code) is inserted at the
+ cursor.
+
+
+ Privacy: microphone audio is handled by Android's recognizer, and any
+ code generation runs on AI Core's on-device model. The plugin itself makes no
+ network calls.
+
+
+ Key benefits
+
+ - Hands-free editing — dictate code or commands without typing.
+ - Lightweight — no bundled model or native libraries; reuses the
+ platform recognizer and (optionally) AI Core.
+ - Graceful degradation — works as plain transcription when AI Core
+ isn't present, and greys out when no file is open.
+ - Clear feedback — the toolbar icon reflects idle / recording /
+ processing state.
+
+
+
diff --git a/speech-to-text-plugin/src/main/AndroidManifest.xml b/speech-to-text-plugin/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..fdcacaba
--- /dev/null
+++ b/speech-to-text-plugin/src/main/AndroidManifest.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/speech-to-text-plugin/src/main/assets/docs/index.html b/speech-to-text-plugin/src/main/assets/docs/index.html
new file mode 100644
index 00000000..1496810a
--- /dev/null
+++ b/speech-to-text-plugin/src/main/assets/docs/index.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+Speech to Text — Guide
+
+
+
+ Speech to Text — Voice to Code
+
+ Speech to Text adds a Voice to Code button to the editor
+ toolbar (the microphone icon). Tap it, speak, and the recognized text — or
+ code generated from it — is inserted at the cursor.
+
+ Using it
+
+ - Open a file in the editor (the button is disabled when nothing is open).
+ - Tap the microphone. On first use, grant the microphone permission
+ and tap again.
+ - Speak your request. The icon shows the state: mic (idle) → waves
+ (recording) → spinner (processing).
+ - If AI Core is installed, the transcript is turned into code by the
+ model; otherwise the raw transcript is inserted.
+
+
+ On-device vs. cloud recognition
+ The plugin prefers Android's on-device speech recognition. If the
+ device lacks an offline recognizer, Android may fall back to its network
+ recognition service.
+
+ Privacy
+
+ Speech recognition is handled by Android's recognizer (on-device when
+ available). When AI Core is used to generate code, the transcript is sent to
+ the configured backend — on-device for Local, or to Google over HTTPS
+ for Gemini. Audio is captured only while recording and is not stored
+ by the plugin.
+
+
+ Troubleshooting
+
+ - Button greyed out — open a file first; voice input inserts at the
+ editor cursor.
+ - "Speech recognition is not available" — the device has no speech
+ recognizer; install/enable Google's speech services.
+ - Permission prompt didn't appear — grant Microphone under
+ Settings → Apps → CodeOnTheGo → Permissions, then tap again.
+
+
+
diff --git a/speech-to-text-plugin/src/main/assets/icon_day.png b/speech-to-text-plugin/src/main/assets/icon_day.png
new file mode 100644
index 00000000..c7478961
Binary files /dev/null and b/speech-to-text-plugin/src/main/assets/icon_day.png differ
diff --git a/speech-to-text-plugin/src/main/assets/icon_night.png b/speech-to-text-plugin/src/main/assets/icon_night.png
new file mode 100644
index 00000000..7a55d930
Binary files /dev/null and b/speech-to-text-plugin/src/main/assets/icon_night.png differ
diff --git a/speech-to-text-plugin/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt b/speech-to-text-plugin/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt
new file mode 100644
index 00000000..a586c2cc
--- /dev/null
+++ b/speech-to-text-plugin/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt
@@ -0,0 +1,458 @@
+package com.itsaky.androidide.plugins.stt
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import android.util.Log
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.extensions.ShowAsAction
+import com.itsaky.androidide.plugins.extensions.ToolbarAction
+import com.itsaky.androidide.plugins.extensions.UIExtension
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import com.itsaky.androidide.plugins.services.IdeUIService
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private const val TAG = "SpeechToTextPlugin"
+
+/**
+ * Speech-to-Text Plugin provides voice-to-code capabilities.
+ *
+ * Features:
+ * - Voice capture via Android [SpeechRecognizer] (on-device when available)
+ * - Optional LLM-based code generation from the transcript
+ * - Editor integration for inserting the result at the cursor
+ *
+ * The plugin surfaces a "Voice to Code" action in the editor toolbar by
+ * implementing [UIExtension.getToolbarActions]. The action is only visible
+ * while a file is open in the editor.
+ */
+class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension {
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private lateinit var context: PluginContext
+ private var llmService: LlmInferenceService? = null
+ private var editorService: IdeEditorService? = null
+ private var uiService: IdeUIService? = null
+
+ /** Held only between startListening() and the terminal result/error callback. */
+ private var speechRecognizer: SpeechRecognizer? = null
+
+ /** Drives the toolbar icon via [ToolbarAction.iconProvider]. */
+ private enum class RecordingState { IDLE, RECORDING, PROCESSING }
+
+ @Volatile
+ private var recordingState = RecordingState.IDLE
+
+ /**
+ * Updates the recording state and asks the IDE to rebuild the toolbar so the
+ * icon provider is re-evaluated and the new (possibly animated) icon is shown.
+ */
+ private fun setState(state: RecordingState) {
+ recordingState = state
+ uiService?.refreshToolbarActions()
+ }
+
+ private fun currentIconRes(): Int = when (recordingState) {
+ RecordingState.IDLE -> R.drawable.ic_mic
+ RecordingState.RECORDING -> R.drawable.ic_waves
+ RecordingState.PROCESSING -> R.drawable.ic_processing
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ this.context = context
+ Log.i(TAG, "SpeechToTextPlugin initialized")
+ return true
+ }
+
+ override fun activate(): Boolean {
+ Log.i(TAG, "SpeechToTextPlugin activating...")
+
+ // Get services from plugin context
+ llmService = context.services.get(LlmInferenceService::class.java)
+ editorService = context.services.get(IdeEditorService::class.java)
+ uiService = context.services.get(IdeUIService::class.java)
+
+ if (llmService == null) {
+ Log.w(TAG, "LlmInferenceService not available - voice generation disabled")
+ }
+ if (editorService == null) {
+ Log.w(TAG, "IdeEditorService not available - editor integration disabled")
+ }
+ if (uiService == null) {
+ Log.w(TAG, "IdeUIService not available - toolbar icon will not animate between states")
+ }
+
+ Log.i(TAG, "SpeechToTextPlugin activated")
+ return true
+ }
+
+ override fun deactivate(): Boolean {
+ Log.i(TAG, "SpeechToTextPlugin deactivating")
+ destroyRecognizer()
+ return true
+ }
+
+ override fun dispose() {
+ Log.i(TAG, "SpeechToTextPlugin disposed")
+ destroyRecognizer()
+ // Tear down the transcript-processing scope so no LLM/generation coroutine
+ // outlives the plugin after unload.
+ scope.cancel()
+ }
+
+ /**
+ * Contributes the "Voice to Code" button to the editor toolbar. The IDE
+ * registers this via [UIExtension] and shows it while an editor is open.
+ */
+ override fun getToolbarActions(): List = listOf(
+ ToolbarAction(
+ id = TOOLBAR_ACTION_ID,
+ title = str(R.string.stt_action_title),
+ // Static fallback for hosts that don't support iconProvider.
+ icon = R.drawable.ic_mic,
+ showAsAction = ShowAsAction.IF_ROOM,
+ order = 100,
+ action = { startVoiceCapture() }
+ ).apply {
+ // Dynamic icon: mic (idle) -> animated waves (recording) -> spinner (processing).
+ iconProvider = { currentIconRes() }
+ // Voice-to-code inserts into the active editor, so the button is only usable
+ // while a file is open. The host greys it out and blocks taps when this is false,
+ // and re-evaluates it whenever the toolbar is rebuilt (including editor changes).
+ isEnabledProvider = { hasOpenFile() }
+ }
+ )
+
+ /** True when there is a file open in the editor to insert transcribed text into. */
+ private fun hasOpenFile(): Boolean = try {
+ editorService?.getCurrentFile() != null
+ } catch (e: Exception) {
+ false
+ }
+ override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLBAR_ACTION_ID,
+ summary = "Voice to Code: tap, speak, and insert the transcript — or code generated from it — at the cursor.",
+ detail = """
+ The microphone button in the editor toolbar records a
+ short voice command and inserts the result at the cursor.
+ Recognition uses Android's on-device recognizer when available.
+ If the AI Core plugin is installed, the transcript is turned
+ into code by the model; otherwise the raw transcript is inserted.
+ The button is enabled only while a file is open, and microphone
+ permission is requested on first use.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "Speech to Text guide",
+ uri = "index.html",
+ order = 0
+ )
+ )
+ )
+ )
+
+ /** Subdirectory under src/main/assets/ holding the Tier 3 offline docs. */
+ override fun getTier3DocsAssetPath(): String = "docs"
+
+ /**
+ * Entry point for the toolbar action. Runs on the UI thread (the toolbar
+ * action item requires it), which is also required to construct and drive
+ * [SpeechRecognizer].
+ */
+ private fun startVoiceCapture() {
+ val ctx = hostContext()
+
+ // Belt-and-suspenders: the toolbar already disables the button when no file is open,
+ // but guard here too so a stale enabled state can't start a pointless recording.
+ if (!hasOpenFile()) {
+ toast(str(R.string.stt_need_open_file))
+ return
+ }
+
+ if (!hasMicrophonePermission()) {
+ requestMicrophonePermission()
+ toast(str(R.string.stt_need_mic_permission))
+ return
+ }
+
+ if (!SpeechRecognizer.isRecognitionAvailable(ctx)) {
+ toast(str(R.string.stt_recognition_unavailable))
+ return
+ }
+
+ try {
+ destroyRecognizer()
+ val recognizer = SpeechRecognizer.createSpeechRecognizer(ctx)
+ recognizer.setRecognitionListener(recognitionListener)
+ speechRecognizer = recognizer
+
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false)
+ // Prefer on-device recognition; falls back to network if unsupported.
+ putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
+ }
+ toast(str(R.string.stt_listening))
+ recognizer.startListening(intent)
+ setState(RecordingState.RECORDING)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start speech recognition", e)
+ toast(str(R.string.stt_start_failed))
+ destroyRecognizer()
+ setState(RecordingState.IDLE)
+ }
+ }
+
+ private val recognitionListener = object : RecognitionListener {
+ override fun onResults(results: Bundle?) {
+ val transcript = results
+ ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ ?.firstOrNull()
+ ?.trim()
+ destroyRecognizer()
+ if (transcript.isNullOrBlank()) {
+ toast(str(R.string.stt_no_words))
+ setState(RecordingState.IDLE)
+ return
+ }
+ setState(RecordingState.PROCESSING)
+ handleTranscript(transcript)
+ }
+
+ override fun onError(error: Int) {
+ destroyRecognizer()
+ toast(describeError(error))
+ setState(RecordingState.IDLE)
+ }
+
+ override fun onReadyForSpeech(params: Bundle?) {}
+ override fun onBeginningOfSpeech() {}
+ override fun onRmsChanged(rmsdB: Float) {}
+ override fun onBufferReceived(buffer: ByteArray?) {}
+ override fun onEndOfSpeech() {}
+ override fun onPartialResults(partialResults: Bundle?) {}
+ override fun onEvent(eventType: Int, params: Bundle?) {}
+ }
+
+ /**
+ * Takes the recognized text, optionally runs it through the LLM to produce
+ * code, and inserts the result at the cursor.
+ */
+ private fun handleTranscript(transcript: String) {
+ Log.i(TAG, "Recognized: $transcript")
+ scope.launch {
+ // Generate code when AI Core is present; fall back to the raw transcript so speech is never dropped.
+ val generated = if (llmService != null) generateCodeFromVoice(transcript) else null
+ val generationFailed = llmService != null && generated == null
+ val output = generated ?: transcript
+ withContext(Dispatchers.Main) {
+ try {
+ val inserted = insertCodeAtCursor(output)
+ toast(
+ when {
+ !inserted -> str(R.string.stt_recognized_no_file, transcript)
+ generationFailed -> str(R.string.stt_generation_failed_inserted_raw)
+ else -> str(R.string.stt_inserted)
+ }
+ )
+ } finally {
+ // Back to idle (mic) regardless of how processing ended.
+ setState(RecordingState.IDLE)
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates code from a voice command using the LLM.
+ *
+ * @param voiceText The transcribed text from speech-to-text
+ * @param language Programming language context (e.g., "kotlin", "java")
+ * @return Generated code snippet, or null if generation failed (details are logged).
+ */
+ suspend fun generateCodeFromVoice(voiceText: String, language: String = "kotlin"): String? {
+ return try {
+ val service = llmService ?: run {
+ Log.w(TAG, "LlmInferenceService not available - cannot generate code")
+ return null
+ }
+
+ // Build a completion prompt for code generation
+ val prompt = """
+ User request: $voiceText
+
+ Generate $language code to fulfill this request. Return only the code, no explanation.
+ Code:
+ """.trimIndent()
+
+ // AI Core routes to the user-selected backend; we don't pick one here.
+ val config = LlmInferenceService.LlmConfig(AUTO_BACKEND_ID)
+ val response = service.generateCompletion(prompt, config).get()
+ if (response.success) {
+ response.text.trim()
+ } else {
+ Log.w(TAG, "Code generation failed: ${response.error}")
+ null
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error generating code from voice", e)
+ null
+ }
+ }
+
+ /**
+ * Inserts generated code at the cursor position.
+ */
+ fun insertCodeAtCursor(code: String): Boolean {
+ // insertTextAtCursor throws SecurityException if the plugin lacks FILESYSTEM_WRITE
+ // (declared in the manifest). Never let a service error crash the host process.
+ return try {
+ editorService?.insertTextAtCursor(code) ?: false
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to insert text at cursor", e)
+ false
+ }
+ }
+
+ /**
+ * Checks if microphone permission is granted.
+ * @return true if RECORD_AUDIO permission is granted, false otherwise
+ */
+ fun hasMicrophonePermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ hostContext(),
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ /**
+ * Requests microphone permission from the user.
+ * Returns immediately if already granted.
+ */
+ fun requestMicrophonePermission() {
+ if (hasMicrophonePermission()) {
+ Log.d(TAG, "Microphone permission already granted")
+ return
+ }
+
+ Log.d(TAG, "Requesting microphone permission...")
+ try {
+ // Must be the host Activity: the plugin's androidContext is a ContextThemeWrapper,
+ // never an Activity, and RECORD_AUDIO is owned by the host app's UID.
+ val activity = hostActivity()
+ if (activity != null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
+ activity.requestPermissions(
+ arrayOf(Manifest.permission.RECORD_AUDIO),
+ PERMISSION_REQUEST_CODE
+ )
+ } else {
+ Log.w(TAG, "No host Activity available to request microphone permission")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error requesting microphone permission", e)
+ }
+ }
+
+ private fun destroyRecognizer() {
+ val recognizer = speechRecognizer ?: return
+ speechRecognizer = null
+ runOnMain {
+ try {
+ recognizer.destroy()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to destroy SpeechRecognizer", e)
+ }
+ }
+ }
+
+ /**
+ * Maps a [SpeechRecognizer] error code to a full, user-facing message that says
+ * what went wrong and what the user can do about it.
+ */
+ private fun describeError(error: Int): String = when (error) {
+ SpeechRecognizer.ERROR_AUDIO -> str(R.string.stt_error_audio)
+ SpeechRecognizer.ERROR_CLIENT -> str(R.string.stt_error_client)
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> str(R.string.stt_error_permissions)
+ SpeechRecognizer.ERROR_NETWORK -> str(R.string.stt_error_network)
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> str(R.string.stt_error_network_timeout)
+ SpeechRecognizer.ERROR_NO_MATCH -> str(R.string.stt_error_no_match)
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> str(R.string.stt_error_busy)
+ SpeechRecognizer.ERROR_SERVER -> str(R.string.stt_error_server)
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> str(R.string.stt_error_speech_timeout)
+ else -> str(R.string.stt_error_unknown, error)
+ }
+
+ /**
+ * The foreground host Activity, if any. Required for anything that talks to the
+ * window manager or requests runtime permissions — the plugin's own
+ * [PluginContext.androidContext] reports the plugin package while running under the
+ * host UID, which the framework rejects (SecurityException: package not in UID).
+ */
+ private fun hostActivity(): Activity? = uiService?.getCurrentActivity()
+
+ /**
+ * A host-owned Context (package + UID match the running process). Prefer the
+ * foreground Activity; fall back to the host application context, which the
+ * plugin resource context delegates to.
+ */
+ private fun hostContext(): Context =
+ hostActivity() ?: context.androidContext.applicationContext
+
+ /**
+ * Resolves a plugin string resource. Uses [PluginContext.androidContext] (which carries the
+ * plugin's own resources) rather than the host Context, whose resources don't include ours.
+ */
+ private fun str(resId: Int, vararg formatArgs: Any): String =
+ context.androidContext.getString(resId, *formatArgs)
+
+ private fun toast(message: String) = runOnMain {
+ try {
+ Toast.makeText(hostContext(), message, Toast.LENGTH_LONG).show()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to show toast", e)
+ }
+ }
+
+ private fun runOnMain(block: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ block()
+ } else {
+ Handler(Looper.getMainLooper()).post(block)
+ }
+ }
+
+ companion object {
+ // Must match plugin.id in AndroidManifest.xml; used for the tooltip category.
+ private const val PLUGIN_ID = "com.itsaky.androidide.plugins.stt"
+ private const val PERMISSION_REQUEST_CODE = 100
+ private const val TOOLBAR_ACTION_ID = "stt_voice_to_code"
+
+ /** Sentinel backend id: AI Core resolves the user-selected backend for us. */
+ private const val AUTO_BACKEND_ID = "auto"
+ }
+}
diff --git a/speech-to-text-plugin/src/main/res/drawable/ic_mic.xml b/speech-to-text-plugin/src/main/res/drawable/ic_mic.xml
new file mode 100644
index 00000000..cb0245cc
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/drawable/ic_mic.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/speech-to-text-plugin/src/main/res/drawable/ic_processing.xml b/speech-to-text-plugin/src/main/res/drawable/ic_processing.xml
new file mode 100644
index 00000000..f609dfe1
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/drawable/ic_processing.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/speech-to-text-plugin/src/main/res/drawable/ic_waves.xml b/speech-to-text-plugin/src/main/res/drawable/ic_waves.xml
new file mode 100644
index 00000000..f9e3b665
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/drawable/ic_waves.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/speech-to-text-plugin/src/main/res/values/colors.xml b/speech-to-text-plugin/src/main/res/values/colors.xml
new file mode 100644
index 00000000..43e21263
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #485D92
+ #FFFFFF
+ #DAE2FF
+ #001847
+
diff --git a/speech-to-text-plugin/src/main/res/values/strings.xml b/speech-to-text-plugin/src/main/res/values/strings.xml
new file mode 100644
index 00000000..61175e5f
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/values/strings.xml
@@ -0,0 +1,38 @@
+
+
+
+
+ Voice to Code
+
+
+ Open a file first. Voice to Code inserts text where your cursor is.
+ Microphone access is needed to record your voice. Please allow it, then tap the microphone again.
+ Speech recognition isn\'t available on this device. It may need the Google app or a language pack installed.
+
+
+ Listening… speak now.
+ Couldn\'t start recording. Please close any app using the microphone and try again.
+
+
+ No words were recognized. Speak clearly and a little louder, then tap the microphone to try again.
+ Voice input inserted at the cursor.
+
+ Heard \"%1$s\", but there\'s no open file to insert it into. Open a file and try again.
+
+
+ Couldn\'t turn your voice into code, so the raw transcript was inserted instead.
+
+
+ Couldn\'t capture audio from the microphone. Check that no other app is using it, then try again.
+ Speech recognition ran into a problem on this device. Please try again.
+ Microphone permission is missing. Enable it for Code on the Go in Android Settings, then try again.
+ Speech recognition needs a network connection, but none is available. Check your connection and try again.
+ The speech recognition service took too long to respond. Check your connection and try again.
+ Couldn\'t make out any words. Reduce background noise, speak clearly, and try again.
+ Speech recognition is still finishing the previous request. Wait a moment, then try again.
+ The speech recognition service returned an error. Please try again in a moment.
+ No speech was detected. Tap the microphone and start speaking right away.
+
+ Speech recognition failed (error %1$d). Please try again.
+
+
diff --git a/speech-to-text-plugin/src/main/res/values/styles.xml b/speech-to-text-plugin/src/main/res/values/styles.xml
new file mode 100644
index 00000000..17bb8c55
--- /dev/null
+++ b/speech-to-text-plugin/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/vector-search-plugin/README.md b/vector-search-plugin/README.md
new file mode 100644
index 00000000..fbb8242a
--- /dev/null
+++ b/vector-search-plugin/README.md
@@ -0,0 +1,78 @@
+# Vector Search plugin for CodeOnTheGo
+
+Semantic (meaning-based) code search. Files are chunked and embedded into
+vectors; a query is embedded the same way and ranked by cosine similarity. The
+plugin contributes a **"Semantic Results"** section to the project search screen
+via `ProjectSearchExtension`.
+
+> Embeddings come from the `ai-core` plugin at runtime (no compile-time
+> dependency). Install **`ai-core` first** for real model embeddings. Without
+> it, a lightweight **lexical** embedding fallback keeps search working at lower
+> quality.
+
+## Architecture
+
+```
+┌──────────────────────────┐
+│ vector-search (this) │ ← chunk, embed, cosine-similarity ranking, project search
+└────────────┬─────────────┘
+ │ SharedServices (runtime) → LlmInferenceService (embeddings)
+ ▼
+┌──────────────────────────┐
+│ ai-core │ ← embedding generation (falls back to lexical if absent)
+└──────────────────────────┘
+```
+
+## Features
+
+- Semantic search over the current project via `ProjectSearchExtension`
+- On-device embeddings through `ai-core`, with a lexical fallback
+- Chunk-level results with file, line range, and a preview snippet
+- Local SQLite embedding store; on-demand indexing per searched root
+
+## Permissions
+
+Declared in `plugin.permissions`:
+
+| Permission | Why |
+|---|---|
+| `filesystem.read` | read project files to chunk and embed |
+| `project.structure` | enumerate the project's source roots |
+
+Indexing reads files in the current project only. Embeddings are stored in a
+local database on the device. Semantic embeddings always use `ai-core`'s
+**Local** on-device backend, and fall back to a lexical embedding when it is
+unavailable, so chunk text never leaves the device.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. No NDK or native toolchain.
+
+```bash
+cd vector-search-plugin
+../gradlew assemblePlugin # release -> build/plugin/vector-search-plugin.cgp
+../gradlew assemblePluginDebug # debug variant
+```
+
+The build resolves `plugin-api.jar` from the repo-root `../libs/`.
+
+## Installation
+
+1. Build and install **`ai-core` first** for quality embeddings (see
+ [`../ai-core/README.md`](../ai-core/README.md)).
+2. Build this plugin, install `build/plugin/vector-search-plugin.cgp` via
+ CodeOnTheGo's Plugin Manager, and restart the IDE.
+3. Run a query from the project search screen; look for the **Semantic
+ Results** section.
+
+## Key classes
+
+- `VectorSearchPlugin.kt` — lifecycle, `ProjectSearchExtension`, search flow
+- `EmbeddingIndexingService.kt` — file collection, embedding storage (SQLite)
+- `CodeChunker.kt` — splits files into embeddable chunks
+- `VectorSearchService.kt` / `VectorMath.kt` — similarity ranking
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/vector-search-plugin/build.gradle.kts b/vector-search-plugin/build.gradle.kts
new file mode 100644
index 00000000..492c58b4
--- /dev/null
+++ b/vector-search-plugin/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "vector-search-plugin"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.vectorsearch"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.vectorsearch"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ viewBinding = false
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ // Coroutines for async operations
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // SQLite for embeddings storage (Android built-in, but explicit for clarity)
+ implementation("androidx.sqlite:sqlite:2.4.0")
+
+ // Material Design
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.10.0")
+}
+
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") || it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/vector-search-plugin/gradle.properties b/vector-search-plugin/gradle.properties
new file mode 100644
index 00000000..2c9f5454
--- /dev/null
+++ b/vector-search-plugin/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/vector-search-plugin/gradle/wrapper/gradle-wrapper.jar b/vector-search-plugin/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 00000000..8bdaf60c
Binary files /dev/null and b/vector-search-plugin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/vector-search-plugin/gradle/wrapper/gradle-wrapper.properties b/vector-search-plugin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..d4081da4
--- /dev/null
+++ b/vector-search-plugin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/vector-search-plugin/gradlew b/vector-search-plugin/gradlew
new file mode 100755
index 00000000..ef07e016
--- /dev/null
+++ b/vector-search-plugin/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/vector-search-plugin/gradlew.bat b/vector-search-plugin/gradlew.bat
new file mode 100755
index 00000000..db3a6ac2
--- /dev/null
+++ b/vector-search-plugin/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/vector-search-plugin/settings.gradle.kts b/vector-search-plugin/settings.gradle.kts
new file mode 100644
index 00000000..79f610a6
--- /dev/null
+++ b/vector-search-plugin/settings.gradle.kts
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+
+buildscript {
+ repositories { google(); mavenCentral() }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.11.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+
+rootProject.name = "vector-search-plugin"
diff --git a/vector-search-plugin/src/main/AndroidManifest.xml b/vector-search-plugin/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..f370395f
--- /dev/null
+++ b/vector-search-plugin/src/main/AndroidManifest.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vector-search-plugin/src/main/assets/docs/index.html b/vector-search-plugin/src/main/assets/docs/index.html
new file mode 100644
index 00000000..ca44a98f
--- /dev/null
+++ b/vector-search-plugin/src/main/assets/docs/index.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+Vector Search — Guide
+
+
+
+ Vector Search — Semantic Code Search
+
+ Vector Search adds meaning-based search to the project. Instead of
+ matching exact text, it ranks code by semantic similarity to your
+ query and contributes a "Semantic Results" section to the project
+ search screen.
+
+ How it works
+
+ - Project files are split into chunks and turned into embedding vectors.
+ - Your query is embedded the same way and compared by cosine similarity;
+ the closest chunks are returned.
+ - Embeddings come from AI Core when it's installed. Without it, the
+ plugin falls back to a lightweight lexical embedding so search still works
+ (with lower quality).
+
+
+ Using it
+ Open the project search screen and run a query as usual. When Vector Search
+ is active, results include a Semantic Results section alongside the
+ normal text matches. Indexing happens on demand for the searched roots.
+
+ Privacy
+
+ Indexing reads files in the current project only. Embeddings are stored in a
+ local database on the device. Semantic embeddings always use AI Core's
+ Local on-device backend, and fall back to a lexical embedding when it
+ is unavailable, so chunk text never leaves the device.
+
+
+ Troubleshooting
+
+ - Results look weak — install/activate AI Core so real model
+ embeddings are used instead of the lexical fallback.
+ - No semantic results — the roots may not be indexed yet; run the
+ search again after indexing completes.
+
+
+
diff --git a/vector-search-plugin/src/main/assets/icon_day.png b/vector-search-plugin/src/main/assets/icon_day.png
new file mode 100644
index 00000000..8d1ad7f7
Binary files /dev/null and b/vector-search-plugin/src/main/assets/icon_day.png differ
diff --git a/vector-search-plugin/src/main/assets/icon_night.png b/vector-search-plugin/src/main/assets/icon_night.png
new file mode 100644
index 00000000..99ffa838
Binary files /dev/null and b/vector-search-plugin/src/main/assets/icon_night.png differ
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeChunker.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeChunker.kt
new file mode 100644
index 00000000..90466b5e
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeChunker.kt
@@ -0,0 +1,533 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.plugins.vectorsearch
+
+import java.io.File
+
+/**
+ * Data class representing a chunked piece of code with metadata.
+ *
+ * @param content The actual text content of the chunk
+ * @param startLine The starting line number (0-indexed) in the original file
+ * @param endLine The ending line number (0-indexed, inclusive) in the original file
+ * @param isCodeChunk Whether this chunk contains code boundaries (functions, classes, etc.)
+ */
+data class CodeChunk(
+ val content: String,
+ val startLine: Int,
+ val endLine: Int,
+ val isCodeChunk: Boolean,
+)
+
+/**
+ * Semantic file chunker that splits files into manageable pieces based on language-specific
+ * boundaries (functions, classes, etc.) with configurable overlap for context preservation.
+ *
+ * Supports:
+ * - Language detection from file extensions
+ * - Smart boundary detection for Kotlin/Java code
+ * - Configurable overlap between chunks to maintain context
+ * - Merging of very small chunks (< minChunkSize) with next chunk
+ * - Simple text files bypass boundary detection
+ */
+object CodeChunker {
+ // Default configuration.
+ // Chunks are kept small so a match localizes to a single method/block rather than a
+ // whole file, while staying within the embedding model's token budget.
+ private const val DEFAULT_MAX_CHUNK_SIZE = 600
+ private const val DEFAULT_OVERLAP_SIZE = 80
+ private const val DEFAULT_MIN_CHUNK_SIZE = 5
+
+ // Code file extensions that should use boundary detection
+ private val CODE_EXTENSIONS = setOf(
+ "kt", "java", "scala", "groovy",
+ "py", "js", "ts", "tsx", "jsx",
+ "go", "rs", "c", "cpp", "h", "hpp",
+ "cs", "swift", "rb", "php"
+ )
+
+ // Text file extensions that should NOT use boundary detection
+ private val TEXT_EXTENSIONS = setOf(
+ "txt", "md", "rst", "csv", "json", "xml", "yaml", "yml",
+ "html", "css", "sql"
+ )
+
+ /**
+ * Chunks a file into semantic pieces.
+ *
+ * @param file The file to chunk
+ * @param maxChunkSize Maximum size of each chunk in characters (default: 2000)
+ * @param overlapSize Number of characters to overlap between chunks (default: 100)
+ * @param minChunkSize Minimum chunk size before merging with next chunk (default: 5 lines)
+ * @return List of CodeChunk objects representing the file
+ * @throws IllegalArgumentException if file doesn't exist or cannot be read
+ */
+ fun chunkFile(
+ file: File,
+ maxChunkSize: Int = DEFAULT_MAX_CHUNK_SIZE,
+ overlapSize: Int = DEFAULT_OVERLAP_SIZE,
+ minChunkSize: Int = DEFAULT_MIN_CHUNK_SIZE,
+ ): List {
+ require(file.exists()) { "File does not exist: ${file.absolutePath}" }
+ require(file.isFile) { "Path is not a file: ${file.absolutePath}" }
+ require(maxChunkSize > 0) { "maxChunkSize must be positive" }
+ require(overlapSize >= 0) { "overlapSize must be non-negative" }
+ require(minChunkSize > 0) { "minChunkSize must be positive" }
+
+ val content = file.readText()
+ return chunkText(
+ content,
+ file.extension,
+ maxChunkSize,
+ overlapSize,
+ minChunkSize,
+ )
+ }
+
+ /**
+ * Chunks text content into semantic pieces.
+ *
+ * @param content The text content to chunk
+ * @param fileExtension The file extension to determine language (e.g., "kt", "java", "py")
+ * @param maxChunkSize Maximum size of each chunk in characters (default: 2000)
+ * @param overlapSize Number of characters to overlap between chunks (default: 100)
+ * @param minChunkSize Minimum chunk size before merging with next chunk (default: 5 lines)
+ * @return List of CodeChunk objects
+ */
+ fun chunkText(
+ content: String,
+ fileExtension: String = "txt",
+ maxChunkSize: Int = DEFAULT_MAX_CHUNK_SIZE,
+ overlapSize: Int = DEFAULT_OVERLAP_SIZE,
+ minChunkSize: Int = DEFAULT_MIN_CHUNK_SIZE,
+ ): List {
+ if (content.isBlank()) {
+ return emptyList()
+ }
+
+ // Strip leading license/comment headers (and XML prolog) so the shared boilerplate
+ // doesn't pollute embeddings or snippets. The removed line count is added back to
+ // each chunk's range so navigation still lands on the right line.
+ val (cleaned, lineOffset) = stripLeadingBoilerplate(content, fileExtension)
+ if (cleaned.isBlank()) {
+ return emptyList()
+ }
+
+ val lines = cleaned.split("\n")
+ val isCodeFile = isCodeLanguage(fileExtension)
+
+ // For text files or very small files, use simple line-based chunking
+ val chunks = if (!isCodeFile || lines.size <= 10) {
+ simpleChunk(lines, maxChunkSize, overlapSize)
+ } else {
+ // For code files, use boundary-aware chunking
+ semanticChunk(lines, maxChunkSize, overlapSize, minChunkSize)
+ }
+
+ return if (lineOffset == 0) {
+ chunks
+ } else {
+ chunks.map {
+ it.copy(
+ startLine = it.startLine + lineOffset,
+ endLine = it.endLine + lineOffset,
+ )
+ }
+ }
+ }
+
+ /**
+ * Strips a leading license/comment header (for code) or XML prolog/comment (for XML) from
+ * [content]. Returns the cleaned content and the number of leading lines removed, so the
+ * caller can offset chunk line numbers back onto the original file.
+ *
+ * Only contiguous *leading* boilerplate is removed, so the offset is uniform for all
+ * remaining lines. If the file turns out to be nothing but boilerplate, the original
+ * content is kept (offset 0) to avoid dropping it entirely.
+ */
+ private fun stripLeadingBoilerplate(content: String, fileExtension: String): Pair {
+ val lines = content.split("\n")
+ var idx = 0
+
+ fun skipBlankLines() {
+ while (idx < lines.size && lines[idx].isBlank()) idx++
+ }
+
+ skipBlankLines()
+ val firstNonBlank = idx
+
+ when {
+ isCodeLanguage(fileExtension) -> {
+ // Leading block comment (e.g. license header): /* ... */
+ if (idx < lines.size && lines[idx].trimStart().startsWith("/*")) {
+ while (idx < lines.size && !lines[idx].contains("*/")) idx++
+ if (idx < lines.size) idx++ // consume the line with the closing */
+ }
+ skipBlankLines()
+ // Leading single-line comments: //
+ while (idx < lines.size && lines[idx].trimStart().startsWith("//")) idx++
+ skipBlankLines()
+ }
+
+ fileExtension.lowercase() == "xml" -> {
+ // XML declaration:
+ if (idx < lines.size && lines[idx].trimStart().startsWith("
+ if (idx < lines.size && lines[idx].trimStart().startsWith("")) idx++
+ if (idx < lines.size) idx++
+ }
+ skipBlankLines()
+ }
+ }
+
+ // Nothing stripped beyond initial blanks, or the whole file was boilerplate:
+ // keep the original content so we never lose everything.
+ if (idx <= firstNonBlank || idx >= lines.size) {
+ return content to 0
+ }
+
+ val cleaned = lines.subList(idx, lines.size).joinToString("\n")
+ return cleaned to idx
+ }
+
+ /**
+ * Simple line-based chunking without boundary detection.
+ * Used for text files or very small files.
+ */
+ private fun simpleChunk(
+ lines: List,
+ maxChunkSize: Int,
+ overlapSize: Int,
+ ): List {
+ val chunks = mutableListOf()
+ var currentStart = 0
+
+ while (currentStart < lines.size) {
+ val currentChunk = mutableListOf()
+ var currentSize = 0
+ var currentEnd = currentStart
+
+ // Add lines until we reach maxChunkSize or run out of lines
+ while (currentEnd < lines.size) {
+ val line = lines[currentEnd]
+ val lineSize = line.length + 1 // +1 for newline
+ if (currentSize + lineSize > maxChunkSize && currentChunk.isNotEmpty()) {
+ break
+ }
+ currentChunk.add(line)
+ currentSize += lineSize
+ currentEnd++
+ }
+
+ // Defensive: the inner loop always adds ≥1 line, but break rather than risk a stall.
+ if (currentChunk.isEmpty()) {
+ break
+ }
+
+ chunks.add(
+ CodeChunk(
+ content = currentChunk.joinToString("\n"),
+ startLine = currentStart,
+ endLine = currentEnd - 1,
+ isCodeChunk = false,
+ )
+ )
+
+ // Move to next chunk with overlap
+ val overlapLines = calculateOverlapLines(currentChunk, overlapSize)
+ currentStart = maxOf(currentEnd - overlapLines, currentStart + 1)
+ }
+
+ return chunks
+ }
+
+ /**
+ * Semantic chunking for code files.
+ * Breaks on function/class boundaries and respects minChunkSize.
+ */
+ private fun semanticChunk(
+ lines: List,
+ maxChunkSize: Int,
+ overlapSize: Int,
+ minChunkSize: Int,
+ ): List {
+ val chunks = mutableListOf()
+ var currentStart = 0
+
+ while (currentStart < lines.size) {
+ val (chunkLines, nextStart, brokeAtDeclaration) = buildSemanticChunk(
+ lines,
+ currentStart,
+ maxChunkSize,
+ )
+
+ if (chunkLines.isEmpty()) {
+ break
+ }
+
+ // Expand a too-small chunk so we don't emit slivers — but never across a declaration
+ // boundary (that would re-absorb the next method into this chunk and undo the
+ // per-method localization). Only meaningful for mid-flow (size-limit) breaks.
+ if (chunkLines.size < minChunkSize && nextStart < lines.size && !brokeAtDeclaration) {
+ val expandedLines = chunkLines.toMutableList()
+ var expandedStart = nextStart
+
+ while (expandedLines.size < minChunkSize &&
+ expandedStart < lines.size &&
+ !isDeclarationStart(lines[expandedStart])
+ ) {
+ expandedLines.add(lines[expandedStart])
+ expandedStart++
+ }
+
+ chunks.add(
+ CodeChunk(
+ content = expandedLines.joinToString("\n"),
+ startLine = currentStart,
+ endLine = currentStart + expandedLines.size - 1,
+ isCodeChunk = true,
+ )
+ )
+
+ currentStart = expandedStart
+ } else {
+ chunks.add(
+ CodeChunk(
+ content = chunkLines.joinToString("\n"),
+ startLine = currentStart,
+ endLine = currentStart + chunkLines.size - 1,
+ isCodeChunk = true,
+ )
+ )
+
+ // The chunk reached the end of the file: stop, otherwise the overlap step below
+ // would keep re-emitting shrinking fragments of the already-covered tail.
+ if (nextStart >= lines.size) {
+ break
+ }
+
+ // Overlap only makes sense for a mid-flow (size-limit) break, to carry context
+ // into the next chunk. A clean declaration boundary starts the next chunk exactly
+ // at the declaration; adding overlap there just duplicates this chunk's tail.
+ currentStart = if (brokeAtDeclaration) {
+ nextStart
+ } else {
+ val overlapLines = calculateOverlapLines(chunkLines, overlapSize)
+ maxOf(nextStart - overlapLines, currentStart + 1)
+ }
+ }
+ }
+
+ // Post-process to handle overlap: ensure last N lines of chunk N match first N lines of N+1
+ return reconcileOverlaps(chunks)
+ }
+
+ /**
+ * Builds a single semantic chunk starting from a given line.
+ *
+ * Breaks proactively BEFORE the next top-level declaration (a new method/function/class, or
+ * its leading annotation) so that a semantic match localizes to a single method instead of an
+ * entire class body. Without this, a small class whose body fits inside [maxChunkSize] never
+ * splits and the whole body becomes one coarse chunk. The break only fires once the chunk holds
+ * substantive content that is not merely the declaration's own leading annotation(s) (see
+ * [hasBreakableContentBefore]), so annotations stay attached to what they annotate. Falls back
+ * to breaking AFTER a closing brace when the size limit is hit before any declaration boundary.
+ *
+ * @return Triple of (chunk lines, next start line index, whether the break was at a declaration
+ * boundary — a clean boundary the caller should not apply overlap across)
+ */
+ private fun buildSemanticChunk(
+ lines: List,
+ startLine: Int,
+ maxChunkSize: Int,
+ ): Triple, Int, Boolean> {
+ val chunk = mutableListOf()
+ var currentSize = 0
+ var currentLine = startLine
+ var lastBoundaryLine = startLine // Last line with a closing boundary
+
+ while (currentLine < lines.size) {
+ val line = lines[currentLine]
+ val lineSize = line.length + 1 // +1 for newline
+
+ // Start a fresh chunk at the next declaration so each method/function is its own chunk.
+ if (currentLine > startLine &&
+ isDeclarationStart(line) &&
+ hasBreakableContentBefore(lines, startLine, currentLine)
+ ) {
+ return Triple(lines.subList(startLine, currentLine), currentLine, true)
+ }
+
+ if (currentSize + lineSize > maxChunkSize && chunk.isNotEmpty()) {
+ // We've exceeded the size limit
+ // Break at the last boundary if we have one
+ if (lastBoundaryLine >= startLine && lastBoundaryLine < currentLine) {
+ return Triple(
+ lines.subList(startLine, lastBoundaryLine + 1),
+ lastBoundaryLine + 1,
+ false,
+ )
+ } else {
+ // No boundary found, just break here
+ return Triple(
+ lines.subList(startLine, currentLine),
+ currentLine,
+ false,
+ )
+ }
+ }
+
+ chunk.add(line)
+ currentSize += lineSize
+
+ // Track closing braces as potential break points (prefer breaking AFTER })
+ if (line.trim().startsWith("}")) {
+ lastBoundaryLine = currentLine
+ }
+
+ // Also track function/class/object starts as potential context boundaries
+ if (isBoundaryLine(line)) {
+ lastBoundaryLine = currentLine
+ }
+
+ currentLine++
+ }
+
+ return Triple(
+ lines.subList(startLine, minOf(currentLine, lines.size)),
+ minOf(currentLine, lines.size),
+ false,
+ )
+ }
+
+ /**
+ * True if lines `[startLine, currentLine)` contain substantive content beyond the trailing run
+ * of blank and annotation lines that belong to the declaration at [currentLine]. Used so that
+ * we break BEFORE a declaration's leading annotations (keeping them attached) but do not break
+ * when the chunk so far is only those annotations.
+ */
+ private fun hasBreakableContentBefore(
+ lines: List,
+ startLine: Int,
+ currentLine: Int,
+ ): Boolean {
+ var i = currentLine - 1
+ while (i >= startLine && lines[i].isBlank()) i--
+ while (i >= startLine && lines[i].trim().startsWith("@")) i--
+ while (i >= startLine && lines[i].isBlank()) i--
+ return i >= startLine
+ }
+
+ /**
+ * Checks if a line contains a code boundary marker (fun, class, object, interface).
+ * Only matches meaningful declarations, not comments.
+ */
+ private fun isBoundaryLine(line: String): Boolean {
+ val trimmed = line.trim()
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
+ return false
+ }
+
+ // Match Kotlin/Java keywords for declarations
+ return trimmed.matches(KEYWORD_DECLARATION)
+ }
+
+ // Kotlin/Java keyword-led declarations (class/fun/object/...).
+ private val KEYWORD_DECLARATION =
+ Regex("""^(public|private|protected|internal)?\s*(fun|class|object|interface|enum|sealed|data|companion|open|abstract)\b.*""")
+
+ // Java-style method signature: optional modifiers, then ` (`. Two
+ // whitespace-separated tokens before the paren is what distinguishes a declaration
+ // (`void onCreate(`) from a bare call (`setContentView(`), which has only one.
+ private val JAVA_METHOD_SIGNATURE =
+ Regex(
+ """^(?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*""" +
+ """[A-Za-z_][\w.$]*(?:<[^>]*>)?(?:\[\])*\s+""" + // return type (optional generics / array)
+ """[A-Za-z_]\w*\s*\(.*""", // method name followed by (
+ )
+
+ // Statements that also look like `name(...)` but are NOT declarations.
+ private val STATEMENT_KEYWORDS =
+ setOf("if", "for", "while", "switch", "catch", "synchronized", "else", "do", "try", "when", "return", "throw", "new", "assert", "yield", "case")
+
+ /**
+ * True if [line] begins a new top-level declaration that should start a fresh chunk: a leading
+ * annotation (so it stays attached to the declaration it precedes), a Kotlin/Java keyword
+ * declaration (class/fun/...), or a Java-style method signature. Used to localize a semantic
+ * match to a single method/function rather than an entire class body.
+ */
+ private fun isDeclarationStart(line: String): Boolean {
+ val trimmed = line.trim()
+ if (trimmed.isEmpty() ||
+ trimmed.startsWith("//") ||
+ trimmed.startsWith("*") ||
+ trimmed.startsWith("/*")
+ ) {
+ return false
+ }
+
+ // Annotations (@Override, @Composable, ...) precede a declaration.
+ if (trimmed.startsWith("@") && trimmed.getOrNull(1)?.isLetter() == true) {
+ return true
+ }
+
+ if (trimmed.matches(KEYWORD_DECLARATION)) {
+ return true
+ }
+
+ // Java method signature, excluding control-flow/statements that share the `name(...)` shape.
+ val firstToken = trimmed.takeWhile { it.isLetterOrDigit() || it == '_' }
+ if (firstToken in STATEMENT_KEYWORDS) {
+ return false
+ }
+ return trimmed.matches(JAVA_METHOD_SIGNATURE)
+ }
+
+ /**
+ * Calculates how many lines to use for overlap based on character count.
+ */
+ private fun calculateOverlapLines(lines: List, overlapSize: Int): Int {
+ var charCount = 0
+ for ((index, line) in lines.withIndex()) {
+ charCount += line.length + 1 // +1 for newline
+ if (charCount >= overlapSize) {
+ return index + 1
+ }
+ }
+ return minOf(lines.size / 4, 10) // Default: ~25% of chunk or max 10 lines
+ }
+
+ /**
+ * Post-processes chunks to ensure overlaps are properly set up.
+ * The last N lines of chunk N should match the first N lines of chunk N+1.
+ */
+ private fun reconcileOverlaps(chunks: List): List {
+ // Overlap is already encoded in each chunk's line ranges; nothing to reconcile.
+ return chunks
+ }
+
+ /**
+ * Determines if the file extension represents a code language.
+ */
+ private fun isCodeLanguage(extension: String): Boolean {
+ val normalized = extension.lowercase()
+ return CODE_EXTENSIONS.contains(normalized)
+ }
+}
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeEmbedding.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeEmbedding.kt
new file mode 100644
index 00000000..2f3dcb92
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/CodeEmbedding.kt
@@ -0,0 +1,66 @@
+package com.itsaky.androidide.plugins.vectorsearch
+
+/**
+ * Represents a code embedding (semantic vector) stored in the database.
+ *
+ * Each CodeEmbedding corresponds to a chunk of source code from a file.
+ * The embedding is a 384-dimensional vector computed from the code text,
+ * enabling semantic search and similarity comparisons.
+ *
+ * @property key Unique identifier in format "{filePath}:{chunkIndex}"
+ * @property filePath Full path to the source file
+ * @property chunkText The actual code text for this chunk
+ * @property language Programming language (kotlin, java, or xml)
+ * @property chunkIndex Which chunk of the file this is (0-indexed)
+ * @property startLine Line number where chunk starts (1-indexed)
+ * @property endLine Line number where chunk ends (inclusive, 1-indexed)
+ * @property embedding The 384-dimensional embedding vector
+ */
+data class CodeEmbedding(
+ val key: String,
+ val filePath: String,
+ val chunkText: String,
+ val language: String,
+ val chunkIndex: Int,
+ val startLine: Int,
+ val endLine: Int,
+ val embedding: FloatArray,
+) {
+
+ /**
+ * Custom equality check that includes floating-point array comparison.
+ * Uses contentEquals for FloatArray instead of identity comparison.
+ */
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as CodeEmbedding
+
+ if (key != other.key) return false
+ if (filePath != other.filePath) return false
+ if (chunkText != other.chunkText) return false
+ if (language != other.language) return false
+ if (chunkIndex != other.chunkIndex) return false
+ if (startLine != other.startLine) return false
+ if (endLine != other.endLine) return false
+ if (!embedding.contentEquals(other.embedding)) return false
+
+ return true
+ }
+
+ /**
+ * Custom hash code that includes the embedding array.
+ */
+ override fun hashCode(): Int {
+ var result = key.hashCode()
+ result = 31 * result + filePath.hashCode()
+ result = 31 * result + chunkText.hashCode()
+ result = 31 * result + language.hashCode()
+ result = 31 * result + chunkIndex
+ result = 31 * result + startLine
+ result = 31 * result + endLine
+ result = 31 * result + embedding.contentHashCode()
+ return result
+ }
+}
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/EmbeddingIndexingService.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/EmbeddingIndexingService.kt
new file mode 100644
index 00000000..0c8987e6
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/EmbeddingIndexingService.kt
@@ -0,0 +1,197 @@
+package com.itsaky.androidide.plugins.vectorsearch
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+import android.content.Context
+import android.util.Log
+import java.io.File
+import java.nio.ByteBuffer
+
+private const val TAG = "EmbeddingIndexing"
+
+/**
+ * SQLite helper for embeddings storage.
+ */
+class EmbeddingsDbHelper(context: Context) : SQLiteOpenHelper(context, "embeddings.db", null, 1) {
+ override fun onCreate(db: SQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS embeddings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT UNIQUE,
+ file_path TEXT,
+ chunk_text TEXT,
+ language TEXT,
+ chunk_index INTEGER,
+ start_line INTEGER,
+ end_line INTEGER,
+ embedding BLOB
+ )
+ """.trimIndent())
+ db.execSQL("CREATE INDEX IF NOT EXISTS idx_file_path ON embeddings(file_path)")
+ }
+
+ override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
+ // No-op for now
+ }
+}
+
+/**
+ * Service for indexing code files into embeddings stored in a local SQLite database.
+ *
+ * Walks the project tree, chunks files, generates embeddings via LlmInferenceService,
+ * and stores them for later semantic search.
+ */
+class EmbeddingIndexingService(private val context: Context) {
+
+ private val dbHelper = EmbeddingsDbHelper(context)
+
+ /**
+ * Stores a pre-computed embedding in the database.
+ * Call this method after generating embeddings via LlmInferenceService.
+ *
+ * @param embedding The CodeEmbedding to store
+ */
+ fun storeEmbeddingDirect(embedding: CodeEmbedding) {
+ storeEmbedding(embedding)
+ Log.d(TAG, "Stored embedding for ${embedding.filePath}:${embedding.chunkIndex}")
+ }
+
+ /**
+ * Collects and returns all code files in a directory that can be chunked and indexed.
+ * Caller is responsible for generating embeddings and storing them via storeEmbeddingDirect().
+ *
+ * @param projectRoot Root directory of the project
+ * @param maxFiles Maximum number of files to collect (default 500)
+ * @return List of code files ready for chunking
+ */
+ fun collectFiles(projectRoot: File, maxFiles: Int = 500): List {
+ return collectCodeFiles(projectRoot, maxFiles)
+ }
+
+ fun languageFor(file: File): String {
+ return getLanguageFromExtension(file.extension)
+ }
+
+ /**
+ * Retrieves all embeddings from the database.
+ */
+ fun getAllEmbeddings(): List {
+ val db = dbHelper.readableDatabase
+ val embeddings = mutableListOf()
+
+ db.query(
+ "embeddings",
+ arrayOf("key", "file_path", "chunk_text", "language", "chunk_index", "start_line", "end_line", "embedding"),
+ null, null, null, null, null
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val buffer = cursor.getBlob(7)
+ val embedding = FloatArray(buffer.size / 4)
+ val byteBuffer = ByteBuffer.wrap(buffer)
+ for (i in embedding.indices) {
+ embedding[i] = byteBuffer.float
+ }
+
+ embeddings.add(
+ CodeEmbedding(
+ key = cursor.getString(0),
+ filePath = cursor.getString(1),
+ chunkText = cursor.getString(2),
+ language = cursor.getString(3),
+ chunkIndex = cursor.getInt(4),
+ startLine = cursor.getInt(5),
+ endLine = cursor.getInt(6),
+ embedding = embedding,
+ )
+ )
+ }
+ }
+
+ return embeddings
+ }
+
+ /**
+ * Releases the SQLite connection. Call from the plugin's dispose() so the open
+ * database handle doesn't outlive the plugin when it is unloaded/reloaded.
+ */
+ fun close() {
+ dbHelper.close()
+ Log.d(TAG, "Embeddings database closed")
+ }
+
+ /**
+ * Clears all embeddings from the database (useful for reindexing).
+ */
+ fun clearIndex() {
+ val db = dbHelper.writableDatabase
+ db.delete("embeddings", null, null)
+ Log.i(TAG, "Index cleared")
+ }
+
+ private fun storeEmbedding(embedding: CodeEmbedding) {
+ val db = dbHelper.writableDatabase
+ val buffer = ByteBuffer.allocate(embedding.embedding.size * 4)
+ for (f in embedding.embedding) {
+ buffer.putFloat(f)
+ }
+
+ val values = ContentValues().apply {
+ put("key", embedding.key)
+ put("file_path", embedding.filePath)
+ put("chunk_text", embedding.chunkText)
+ put("language", embedding.language)
+ put("chunk_index", embedding.chunkIndex)
+ put("start_line", embedding.startLine)
+ put("end_line", embedding.endLine)
+ put("embedding", buffer.array())
+ }
+
+ db.insertWithOnConflict("embeddings", null, values, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+
+ private fun collectCodeFiles(root: File, maxCount: Int): List {
+ val files = mutableListOf()
+ val skipDirs = setOf("build", ".gradle", "node_modules", ".git", "dist", "out")
+
+ fun walk(dir: File) {
+ if (files.size >= maxCount) return
+ if (dir.name.startsWith(".") && dir.name != ".") return
+ if (dir.name in skipDirs) return
+
+ try {
+ dir.listFiles()?.forEach { file ->
+ // Cap additions here (return@forEach is continue, not break), not just descent.
+ if (files.size >= maxCount) return@forEach
+ when {
+ file.isDirectory -> walk(file)
+ file.isFile && isCodeFile(file) -> files.add(file)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error walking directory ${dir.absolutePath}", e)
+ }
+ }
+
+ walk(root)
+ return files
+ }
+
+ private fun isCodeFile(file: File): Boolean {
+ val ext = file.extension.lowercase()
+ return ext in setOf("kt", "java", "xml", "gradle", "kts", "py", "js", "ts")
+ }
+
+ private fun getLanguageFromExtension(ext: String): String {
+ return when (ext.lowercase()) {
+ "kt", "kts" -> "kotlin"
+ "java" -> "java"
+ "xml" -> "xml"
+ "gradle" -> "gradle"
+ "py" -> "python"
+ "js", "jsx" -> "javascript"
+ "ts", "tsx" -> "typescript"
+ else -> "text"
+ }
+ }
+}
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorMath.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorMath.kt
new file mode 100644
index 00000000..bcf3fbfa
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorMath.kt
@@ -0,0 +1,62 @@
+package com.itsaky.androidide.plugins.vectorsearch
+
+import kotlin.math.sqrt
+
+/**
+ * Utility object for vector mathematics operations.
+ * Provides functions for comparing and analyzing embeddings.
+ */
+object VectorMath {
+
+ /**
+ * Calculates the cosine similarity between two vectors.
+ *
+ * Cosine similarity is the standard metric for comparing embeddings.
+ * It measures the cosine of the angle between two vectors in a multi-dimensional space.
+ *
+ * Algorithm:
+ * 1. Calculate dot product: sum of a[i] * b[i]
+ * 2. Calculate norm for vector a: sqrt(sum of a[i]²)
+ * 3. Calculate norm for vector b: sqrt(sum of b[i]²)
+ * 4. Return dotProduct / (normA * normB)
+ * 5. Handle zero-division: return 0.0f if denominator is 0
+ *
+ * @param a First embedding vector
+ * @param b Second embedding vector
+ * @return Float between -1.0 and 1.0 (or 0.0 if either vector has zero magnitude)
+ * - 1.0 indicates identical direction (maximum similarity)
+ * - 0.0 indicates orthogonal vectors (no similarity)
+ * - -1.0 indicates opposite direction (inverse similarity)
+ */
+ fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
+ // Different-dimension vectors are incomparable; guard so a shorter b can't throw AIOOBE.
+ if (a.size != b.size) {
+ return 0.0f
+ }
+
+ var dotProduct = 0.0f
+ var normASquared = 0.0f
+ var normBSquared = 0.0f
+
+ // Single-pass calculation for efficiency
+ for (i in a.indices) {
+ val aVal = a[i]
+ val bVal = b[i]
+
+ dotProduct += aVal * bVal
+ normASquared += aVal * aVal
+ normBSquared += bVal * bVal
+ }
+
+ val normA = sqrt(normASquared)
+ val normB = sqrt(normBSquared)
+ val denominator = normA * normB
+
+ // Handle zero-division case
+ return if (denominator == 0.0f) {
+ 0.0f
+ } else {
+ dotProduct / denominator
+ }
+ }
+}
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchPlugin.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchPlugin.kt
new file mode 100644
index 00000000..3615fc23
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchPlugin.kt
@@ -0,0 +1,377 @@
+package com.itsaky.androidide.plugins.vectorsearch
+
+import android.util.Log
+import com.itsaky.androidide.plugins.IPlugin
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.extensions.DocumentationExtension
+import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
+import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
+import com.itsaky.androidide.plugins.extensions.ProjectSearchExtension
+import com.itsaky.androidide.plugins.extensions.ProjectSearchRequest
+import com.itsaky.androidide.plugins.extensions.ProjectSearchResult
+import com.itsaky.androidide.plugins.extensions.ProjectSearchSection
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import java.io.File
+import java.util.concurrent.CompletableFuture
+import kotlin.coroutines.coroutineContext
+import kotlin.math.sqrt
+
+private const val TAG = "VectorSearchPlugin"
+private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
+private const val SEMANTIC_RESULTS_TITLE = "Semantic Results"
+private const val FALLBACK_EMBEDDING_DIMENSIONS = 384
+
+private const val INDEXING_WAIT_MS = 20_000L
+
+/**
+ * Vector Search Plugin provides semantic code search capabilities.
+ *
+ * When activated, it indexes the project using on-device LLM embeddings
+ * and stores them in a local SQLite database. Other plugins can use
+ * this service to perform semantic searches.
+ */
+class VectorSearchPlugin : IPlugin, ProjectSearchExtension, DocumentationExtension {
+
+ private lateinit var context: PluginContext
+ private lateinit var indexingService: EmbeddingIndexingService
+ @Volatile private var indexedRootsKey: String? = null
+ @Volatile private var indexingJob: Job? = null
+ // Roots the in-flight [indexingJob] is building; guards against reusing another project's build.
+ @Volatile private var indexingRootsKey: String? = null
+
+ // Background scope for indexing so a large project never stalls a search request.
+ private val indexingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun initialize(context: PluginContext): Boolean {
+ this.context = context
+ Log.i(TAG, "VectorSearchPlugin initialized")
+ return true
+ }
+
+ override fun activate(): Boolean {
+ Log.i(TAG, "VectorSearchPlugin activating...")
+
+ // Initialize indexing service
+ indexingService = EmbeddingIndexingService(context.androidContext)
+
+ Log.i(TAG, "VectorSearchPlugin activated. Call indexProject() to start indexing.")
+ return true
+ }
+
+ override fun deactivate(): Boolean {
+ Log.i(TAG, "VectorSearchPlugin deactivating")
+ return true
+ }
+
+ override fun dispose() {
+ indexingScope.cancel()
+ // Release the SQLite connection so it doesn't leak when the plugin unloads.
+ if (::indexingService.isInitialized) {
+ indexingService.close()
+ }
+ Log.i(TAG, "VectorSearchPlugin disposed")
+ }
+
+ /**
+ * Initiates project indexing (files will need to be chunked and embeddings generated separately).
+ * Should be called after activate().
+ */
+ fun prepareIndexing() {
+ Log.i(TAG, "Project indexing prepared. Use getFiles() and storeEmbedding() to index.")
+ }
+
+ /**
+ * Gets the list of code files to index in the current project.
+ */
+ fun getFiles(): List {
+ val projectDir = java.io.File(
+ System.getProperty("project.dir") ?: System.getProperty("user.dir") ?: return emptyList()
+ )
+ return indexingService.collectFiles(projectDir)
+ }
+
+ override fun searchProject(request: ProjectSearchRequest): CompletableFuture> {
+ Log.i(TAG, "Project search requested for '${request.query}'")
+ return CompletableFuture.supplyAsync {
+ val results = runBlocking {
+ search(
+ query = request.query,
+ backendId = "local",
+ roots = request.roots,
+ topK = 10,
+ )
+ }
+ if (results.isEmpty()) {
+ Log.i(TAG, "No semantic results available for '${request.query}'")
+ emptyList()
+ } else {
+ listOf(
+ ProjectSearchSection(
+ title = SEMANTIC_RESULTS_TITLE,
+ results = results.map { it.toProjectSearchResult(request.query) },
+ )
+ )
+ }
+ }
+ }
+
+ /**
+ * Searches embeddings semantically.
+ * Requires the project to have been indexed first.
+ *
+ * @param query Search query string
+ * @param backendId LLM backend to use (default "local")
+ * @param topK Maximum number of results to return (default 10)
+ * @return List of CodeEmbedding results ranked by relevance
+ */
+ suspend fun search(
+ query: String,
+ backendId: String = "local",
+ roots: List = emptyList(),
+ topK: Int = 10,
+ ): List {
+ return try {
+ val llmService = SharedServices.get(LlmInferenceService::class.java)
+ ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java)
+ ?: context.services.get(LlmInferenceService::class.java)
+
+ if (roots.isNotEmpty()) {
+ // Wait for an in-flight build so the first query returns real results, not empty.
+ val job = startIndexingIfNeeded(roots, llmService, backendId)
+ if (job != null && withTimeoutOrNull(INDEXING_WAIT_MS) { job.join() } == null) {
+ Log.w(TAG, "Indexing still running after ${INDEXING_WAIT_MS}ms; using partial index")
+ }
+ }
+
+ val queryEmbedding = generateEmbedding(query, llmService, backendId)
+ val allEmbeddings = indexingService.getAllEmbeddings()
+ if (allEmbeddings.isEmpty()) {
+ Log.w(TAG, "Index not ready yet; returning no semantic results for now")
+ return emptyList()
+ }
+
+ val results = VectorSearchService.searchWithScores(queryEmbedding, allEmbeddings, topK = topK)
+ Log.d(TAG, "Search for '$query' returned ${results.size} results")
+
+ results.map { it.first }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error during search", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Clears the index (e.g., for reindexing after project changes).
+ */
+ fun clearIndex() {
+ indexingService.clearIndex()
+ indexedRootsKey = null
+ Log.i(TAG, "Index cleared")
+ }
+
+ /**
+ * Starts (or reuses) a background index build for [roots], returning its [Job] so the caller
+ * can await it, or null when the roots are already fully indexed and no build is needed.
+ *
+ * @param roots project root directories to index
+ * @param llmService inference service used to embed chunks, or null to use the lexical fallback
+ * @param backendId LLM backend id to request embeddings from
+ * @return the running index-build job, or null if already indexed
+ */
+ @Synchronized
+ private fun startIndexingIfNeeded(
+ roots: List,
+ llmService: LlmInferenceService?,
+ backendId: String,
+ ): Job? {
+ val rootsKey = roots
+ .map { it.absolutePath }
+ .sorted()
+ .joinToString("|")
+ val running = indexingJob
+ if (running != null && running.isActive) {
+ // Reuse the in-flight build only for the same roots; another project's build gives wrong results.
+ if (indexingRootsKey == rootsKey) {
+ return running
+ }
+ }
+ if (running?.isActive != true &&
+ indexedRootsKey == rootsKey &&
+ indexingService.getAllEmbeddings().isNotEmpty()
+ ) {
+ return null
+ }
+
+ // Cancel any build for other roots and wait for it to unwind so builds never interleave writes.
+ val previous = running
+ previous?.cancel()
+ indexingRootsKey = rootsKey
+ val job = indexingScope.launch {
+ try {
+ previous?.join()
+ buildIndex(rootsKey, roots, llmService, backendId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Background indexing failed", e)
+ }
+ }
+ indexingJob = job
+ return job
+ }
+
+ private suspend fun buildIndex(
+ rootsKey: String,
+ roots: List,
+ llmService: LlmInferenceService?,
+ backendId: String,
+ ) {
+ // Reset the marker up front so a mid-build failure doesn't leave a stale "indexed" flag.
+ indexedRootsKey = null
+ indexingService.clearIndex()
+ var fileCount = 0
+ var chunkCount = 0
+
+ roots.forEach { root ->
+ indexingService.collectFiles(root).forEach { file ->
+ // Bail promptly if this build was superseded by one for different roots.
+ coroutineContext.ensureActive()
+ fileCount++
+ val language = indexingService.languageFor(file)
+ val chunks = try {
+ CodeChunker.chunkFile(file)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to chunk ${file.absolutePath}", e)
+ emptyList()
+ }
+
+ chunks.forEachIndexed { index, chunk ->
+ val embedding = CodeEmbedding(
+ key = "${file.absolutePath}:$index",
+ filePath = file.absolutePath,
+ chunkText = chunk.content,
+ language = language,
+ chunkIndex = index,
+ startLine = chunk.startLine + 1,
+ endLine = chunk.endLine + 1,
+ embedding = generateEmbedding(chunk.content, llmService, backendId),
+ )
+ indexingService.storeEmbeddingDirect(embedding)
+ chunkCount++
+ }
+ }
+ }
+
+ indexedRootsKey = rootsKey
+ Log.i(TAG, "Indexed $chunkCount chunks from $fileCount files")
+ }
+
+ private fun generateEmbedding(
+ text: String,
+ llmService: LlmInferenceService?,
+ backendId: String,
+ ): FloatArray {
+ val llmEmbedding = try {
+ llmService?.getEmbeddings(text, backendId)?.get()
+ } catch (e: Exception) {
+ Log.w(TAG, "LLM embeddings unavailable, using lexical fallback", e)
+ null
+ }
+ return if (llmEmbedding != null && llmEmbedding.isNotEmpty()) {
+ llmEmbedding
+ } else {
+ lexicalEmbedding(text)
+ }
+ }
+
+ private fun lexicalEmbedding(text: String): FloatArray {
+ val vector = FloatArray(FALLBACK_EMBEDDING_DIMENSIONS)
+ lexicalTokens(text).forEach { token ->
+ val slot = (token.hashCode() and Int.MAX_VALUE) % vector.size
+ vector[slot] += 1f
+ }
+
+ var normSquared = 0f
+ for (value in vector) {
+ normSquared += value * value
+ }
+ if (normSquared == 0f) {
+ return vector
+ }
+
+ val norm = sqrt(normSquared)
+ for (i in vector.indices) {
+ vector[i] = vector[i] / norm
+ }
+ return vector
+ }
+
+ private fun lexicalTokens(text: String): Sequence {
+ val words = Regex("[A-Za-z_][A-Za-z0-9_]*|\\d+")
+ .findAll(text)
+ .map { it.value.lowercase() }
+ return words.flatMap { word ->
+ sequence {
+ yield(word)
+ val maxPrefix = word.length.coerceAtMost(8)
+ for (length in 3..maxPrefix) {
+ yield(word.substring(0, length))
+ }
+ }
+ }
+ }
+
+ private fun CodeEmbedding.toProjectSearchResult(query: String): ProjectSearchResult {
+ val line = startLine.coerceAtLeast(1) - 1
+ return ProjectSearchResult(
+ file = java.io.File(filePath),
+ linePreview = chunkText.replace(Regex("\\s+"), " ").take(160),
+ matchText = query,
+ startLine = line,
+ startColumn = 0,
+ endLine = endLine.coerceAtLeast(startLine).coerceAtLeast(1) - 1,
+ endColumn = 0,
+ )
+ }
+
+ override fun getTooltipCategory(): String = "plugin_com.itsaky.androidide.plugins.vectorsearch"
+
+ override fun getTooltipEntries(): List = listOf(
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_PLUGIN,
+ summary = "Vector Search adds semantic, meaning-based matches to project search.",
+ detail = """
+ Vector Search chunks project files, generates
+ embeddings, and ranks matches by semantic similarity instead of
+ only exact text.
+ AI Core provides model embeddings when available. If it is not
+ loaded, Vector Search falls back to local lexical embeddings with
+ lower result quality.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "Vector Search guide",
+ uri = "index.html",
+ order = 0
+ )
+ )
+ )
+ )
+
+ override fun getTier3DocsAssetPath(): String = "docs"
+
+ private companion object {
+ const val TOOLTIP_TAG_PLUGIN = "plugin_vector_search"
+ }
+}
diff --git a/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchService.kt b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchService.kt
new file mode 100644
index 00000000..a9789b37
--- /dev/null
+++ b/vector-search-plugin/src/main/kotlin/com/itsaky/androidide/plugins/vectorsearch/VectorSearchService.kt
@@ -0,0 +1,49 @@
+package com.itsaky.androidide.plugins.vectorsearch
+
+/**
+ * Service for searching code embeddings by semantic similarity.
+ * Given a query string's embedding, finds the most relevant stored embeddings.
+ */
+object VectorSearchService {
+
+ /**
+ * Searches embeddings by cosine similarity to a query embedding.
+ *
+ * @param queryEmbedding The embedding vector for the search query
+ * @param allEmbeddings List of all stored embeddings to search
+ * @param similarityThreshold Minimum similarity score (0.0-1.0) to include in results (default 0.05)
+ * @param topK Maximum number of results to return (default 10)
+ * @return List of (embedding, score) pairs sorted by similarity (highest first)
+ */
+ fun searchWithScores(
+ queryEmbedding: FloatArray,
+ allEmbeddings: List,
+ similarityThreshold: Float = 0.05f,
+ topK: Int = 10,
+ ): List> {
+ if (allEmbeddings.isEmpty() || queryEmbedding.isEmpty()) {
+ return emptyList()
+ }
+
+ // Compute cosine similarity for each embedding
+ val similarities = allEmbeddings.map { embedding ->
+ Pair(embedding, VectorMath.cosineSimilarity(queryEmbedding, embedding.embedding))
+ }
+
+ // Filter by threshold
+ val filtered = similarities.filter { it.second >= similarityThreshold }
+
+ // Apply relevance cutoff: keep results within 50% of top score and above 0.1 floor
+ if (filtered.isEmpty()) {
+ return emptyList()
+ }
+
+ val topScore = filtered.maxByOrNull { it.second }?.second ?: return emptyList()
+ val relevanceThreshold = (topScore * 0.5f).coerceAtLeast(0.1f)
+
+ return filtered
+ .filter { it.second >= relevanceThreshold }
+ .sortedByDescending { it.second }
+ .take(topK)
+ }
+}
diff --git a/vector-search-plugin/src/main/res/values/colors.xml b/vector-search-plugin/src/main/res/values/colors.xml
new file mode 100644
index 00000000..03363c00
--- /dev/null
+++ b/vector-search-plugin/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+
+ #485D92
+ #FFFFFF
+ #DAE2FF
+ #001847
+
diff --git a/vector-search-plugin/src/main/res/values/styles.xml b/vector-search-plugin/src/main/res/values/styles.xml
new file mode 100644
index 00000000..17bb8c55
--- /dev/null
+++ b/vector-search-plugin/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/vector-search-plugin/vector-search-plugin.html b/vector-search-plugin/vector-search-plugin.html
new file mode 100644
index 00000000..ece90971
--- /dev/null
+++ b/vector-search-plugin/vector-search-plugin.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+Vector Search Plugin
+
+
+
+ Vector Search Plugin
+
+ Executive overview
+ Vector Search adds semantic, meaning-based results to
+ CodeOnTheGo's project search. Instead of matching only exact text, it ranks
+ code by similarity of meaning, so a search for "read a file into a string" can
+ surface the relevant method even when those exact words don't appear.
+ It is a headless plugin — it has no screens of its own. It
+ contributes an extra "Semantic Results" section to the existing project
+ search. Everything runs on-device.
+
+ Core functionality
+
+ - Semantic project search — contributes ranked results via the IDE's
+ project-search extension point.
+ - On-device indexing — walks the project (capped, skipping
+
build/.git and similar), chunks files by
+ language-aware boundaries, and stores embeddings in a local SQLite
+ database.
+ - Model embeddings when available — uses AI Core's embedding service
+ when present.
+ - Lexical fallback — when AI Core embeddings aren't available, it
+ falls back to a local lexical embedding (lower quality, but always works
+ offline).
+ - Cosine-similarity ranking with a relevance cutoff so only strong
+ matches are returned.
+
+
+ Technical architecture
+
+ | Component | Role |
+ VectorSearchPlugin | Entry point. Implements
+ ProjectSearchExtension; runs indexing on a background scope and
+ answers search requests. |
+ CodeChunker | Splits files into small, language-aware
+ chunks with overlap for context. |
+ EmbeddingIndexingService | Collects files and stores
+ chunk embeddings as BLOBs in a local SQLite database
+ (embeddings.db). |
+ VectorMath / VectorSearchService | Cosine
+ similarity and top-K ranking of stored embeddings against the query. |
+
+ Embeddings are requested from AI Core over the shared service registry
+ when available; the plugin contains no model and no native code of its own.
+
+ Usage
+
+ - (Optional) Install AI Core and select a local model for
+ higher-quality embeddings.
+ - Install Vector Search via the Plugin Manager and restart the
+ IDE.
+ - Use the IDE's project search. Semantic matches appear under a
+ "Semantic Results" section. The first search on a project triggers a
+ background index build.
+
+
+ Privacy: indexing and search run entirely on-device. File contents are
+ chunked and stored in a local SQLite database; nothing leaves the device.
+
+
+ Key benefits
+
+ - Find code by meaning — surfaces relevant code even without an exact
+ keyword match.
+ - Fully offline — no network use; embeddings and the index stay on the
+ device.
+ - Always usable — degrades gracefully to lexical embeddings when AI
+ Core isn't installed.
+ - Lightweight — no bundled model or native libraries; a compact SQLite
+ index.
+
+
+