Copilot embeddings → semantic search¶
The Copilot → Claude Code proxy (copilot-api on
localhost:4141) is OpenAI-compatible and also serves POST /v1/embeddings,
backed by the same GitHub Copilot subscription. Copilot exposes three 1536-dim
embedding models — text-embedding-3-small (default), text-embedding-ada-002,
text-embedding-3-small-inference. This turns the proxy into a zero-extra-cost
local semantic-search backend.
- Shell helpers:
~/.config/shell/44_copilot_embed.sh(copilot-embed,semsearch) - Search engine:
scripts/semsearch.py(run viauv run --script, resolved bychezmoi source-path— stays in the chezmoi source tree, not applied to$HOME) - Model pin (SSOT):
AICAP_EMBED_MODELin~/.config/shell/04_ai_agents.sh - Requires the proxy to be running (
copilot-proxy start); both helpers auto-start it.
Same Copilot ToS caveat as the proxy
Embeddings go through the same reverse-engineered proxy and Copilot subscription — see the proxy doc's ToS warning. Indexing a large corpus fires many requests and the fork has no rate limiter; index big trees deliberately.
Quick start¶
copilot-embed "hello world" | jq 'length' # → 1536 (a raw embedding vector)
semsearch index # index the default corpus (chezmoi docs/tools)
semsearch "how do I switch the copilot model" # natural-language search → ranked hits
semsearch "trim a video clip" -k 5 # top-5
semsearch index ~/notes # index another directory
semsearch "quarterly goals" --corpus ~/notes # query that corpus
How it works¶
text ──▶ copilot-embed / semsearch.py ──POST /v1/embeddings──▶ copilot-api (localhost:4141)
│ Authorization: Bearer <copilot token>
▼
api.githubcopilot.com (your Copilot sub)
semsearch chunks files by blank-line paragraphs (tracking each chunk's start
line), embeds them in batches, caches the vectors, and ranks by cosine similarity
against your query embedding. Ranking is by meaning, not keywords — e.g.
"trim a video clip" surfaces ffmpeg.md's "Trim / cut" section even though the
words differ.
The embeddings input MUST be an array (issue #100)
The proxy rejects a scalar input string with a generic 400 Bad Request
— this is the fork's
issue #100
("Can not call /v1/embeddings successful"). The fix is simply to send
"input": ["your text"] (an array) — {"input":"your text"} 400s,
{"input":["your text"]} returns a vector. Both helpers always send an array
(copilot-embed wraps one text; semsearch.py batches up to 64 per request).
Shell helpers¶
copilot-embed [--model M] [--json] [TEXT | -]¶
Embeds TEXT (a positional arg) or stdin (- or piped) and prints the vector
as a JSON array on stdout (status/metadata go to stderr, so pipes stay clean).
copilot-embed "some text" # vector as a JSON array
printf 'doc body' | copilot-embed # embed from stdin
copilot-embed --json "hi" | jq '.usage' # full API response (usage, index, …)
copilot-embed --model text-embedding-ada-002 "text" # override the model
copilot-embed -l # list the embedding-model ids the proxy serves
| Flag / env | Default | Meaning |
|---|---|---|
--model M / AICAP_EMBED_MODEL |
text-embedding-3-small |
embedding model (empty → endpoint default) |
--json |
off | print the full response instead of just the vector |
-l / --list |
— | list embedding models from /v1/models |
Auto-starts the proxy when it isn't answering (mirrors copilot-run). Requires
curl + jq.
semsearch index [PATH...] / semsearch <QUERY> [-k N] [--corpus PATH]¶
Thin wrapper over scripts/semsearch.py (resolved via chezmoi source-path,
cached per-shell — same pattern as aiblock). Auto-starts the proxy, then runs the
engine with uv.
index [PATH...]— walk eachPATHfor*.md,*.txt,*.sh,*.py(override with--glob '*.md,*.py'), chunk, batch-embed, cache. DefaultPATH(none given) =<chezmoi source>/docs/tools. Incremental: only chunks whose content hash is new get embedded; unchanged chunks are reused and deleted/changed ones pruned.--rebuildforces a full re-embed.<QUERY>— embed the query, print the top-k(default 8) chunks asscore path:start_line+ a one-line snippet, best first.--corpus PATHselects which indexed corpus to search (default: the docs corpus).
Each corpus is keyed by a hash of its root path(s), so different trees don't collide. Cache lives at:
One JSON line per chunk ({path, start_line, hash, text, embedding}) — inspectable,
and cheap to prune. (Vectors are stored as JSON floats, so the cache is large-ish:
~20 KB/chunk. Fine for docs-sized corpora; delete a <hash>.jsonl to reset one.)
Config¶
| Env var | Default | Meaning |
|---|---|---|
AICAP_EMBED_MODEL |
text-embedding-3-small |
embedding model (SSOT in 04_ai_agents.sh; empty → endpoint default) |
COPILOT_EMBED_BASE |
http://localhost:$COPILOT_PROXY_PORT |
proxy base URL (set by the semsearch wrapper) |
COPILOT_PROXY_PORT |
4141 |
proxy port (shared with 43_copilot_proxy.sh) |
XDG_STATE_HOME |
~/.local/state |
where the embedding cache lives |
Verify¶
echo "hello world" | copilot-embed | jq 'length' # → 1536
copilot-embed -l # → the three embedding models
semsearch index && semsearch "how do I switch the copilot model"
# → copilot-claude-proxy.md ranks at the top
semsearch index # re-run → "0 new" (incremental cache hit)
See also¶
- Copilot → Claude Code proxy — the proxy that serves
/v1/embeddings(auth, models, ToS, gotchas) - aicapture — the sibling AI shell tooling (
aifix,air, thehttpagent) whose curl+jq patterncopilot-embedreuses - caozhiyuan/copilot-api#100
— the array-
inputgotcha this doc resolves