Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

558 lines
21 KiB
Go

package vector
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sync"
kitvec "go.kenn.io/kit/vector"
"go.kenn.io/kit/vector/sqlitevec"
)
// registerVecOnce guards the process-wide sqlite-vec extension registration
// that sqlitevec.Register performs; calling it more than once would attempt
// to register the same SQL functions twice.
var registerVecOnce sync.Once
// messageMirrorDDL creates agentsview's mirror of embeddable message content
// plus a small key/value table for metadata kit's store does not track (the
// display model name for a generation). ordinal is the unit's first
// (start) ordinal; ordinal_end is its last member's ordinal, equal to
// ordinal for single-message (user) documents. subordinate and offsets
// default to the "no run grouping yet" shape so a row inserted without
// them (see mirror.go) is still valid.
const messageMirrorDDL = `
CREATE TABLE IF NOT EXISTS vector_messages (
doc_key TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
source_uuid TEXT NOT NULL DEFAULT '',
ordinal INTEGER NOT NULL,
ordinal_end INTEGER NOT NULL,
subordinate INTEGER NOT NULL DEFAULT 0,
offsets TEXT NOT NULL DEFAULT '[]',
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
embed_gen TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vector_messages_session_ordinal
ON vector_messages(session_id, ordinal);
CREATE TABLE IF NOT EXISTS vector_meta (
key TEXT PRIMARY KEY, value TEXT NOT NULL
);
`
// mirrorSchemaVersionKey is the metadata-table key holding a spec's
// MirrorSchemaVersion.
const mirrorSchemaVersionKey = "mirror_schema_version"
// IndexSpec binds an Index to one embedding store inside vectors.db. Every
// table the Index touches derives from the spec — DocsTable and MetaTable
// are the store's own tables, and every kit-managed table starts with
// VectorsPrefix — so two stores can share one database file without reading
// or resetting each other's state. Table names and prefixes of distinct
// specs must not overlap: no spec's VectorsPrefix may be a prefix of
// another spec's table names, since mirrorStateTableNames matches by these
// names when a schema-version mismatch resets the store.
type IndexSpec struct {
// Name identifies the store for CLI selection and display ("messages").
Name string
// DocsTable is the store's mirror documents table.
DocsTable string
// MetaTable is the store's own key/value metadata table (schema
// version, refresh watermark, scope, generation model names). Each
// store has its own so a reset of one store never clears another's
// metadata.
MetaTable string
// VectorsPrefix names the kit-managed bookkeeping tables
// (<prefix>_generations, _stamps, _chunks, _v<N>).
VectorsPrefix string
// MirrorDDL creates DocsTable, its indexes, and MetaTable
// (CREATE ... IF NOT EXISTS).
MirrorDDL string
// MirrorSchemaVersion versions the mirror's DDL shape and its
// document-identity scheme; see prepareMirrorSchema.
MirrorSchemaVersion string
}
func (s IndexSpec) schema() sqlitevec.Schema {
return sqlitevec.Schema{
DocsTable: s.DocsTable,
IDColumn: "doc_key",
ContentColumn: "content",
EmbedGenColumn: "embed_gen",
RevisionColumn: "content_hash",
VectorsPrefix: s.VectorsPrefix,
}
}
func (s IndexSpec) generationsTable() string { return s.VectorsPrefix + "_generations" }
func (s IndexSpec) stampsTable() string { return s.VectorsPrefix + "_stamps" }
func (s IndexSpec) chunksTable() string { return s.VectorsPrefix + "_chunks" }
// MessageIndexSpec is the conversation-message embedding store. Its table
// names, metadata keys, and schema version predate IndexSpec and must stay
// byte-identical so existing vectors.db files open without a reset.
//
// History: "2" added the ordinal_end/subordinate/offsets columns but still
// held one row per message; "3" switched document identity to run-grouped
// units (one row per user message or per run of contiguous assistant
// messages) with no DDL change.
func MessageIndexSpec() IndexSpec {
return IndexSpec{
Name: "messages",
DocsTable: "vector_messages",
MetaTable: "vector_meta",
VectorsPrefix: "message_vectors",
MirrorDDL: messageMirrorDDL,
MirrorSchemaVersion: "3",
}
}
// ErrMirrorVersionMismatch reports a vectors.db written by a different
// mirror schema version than this binary expects. Write-path Open calls
// reset the mirror instead of returning this error (see prepareMirrorSchema);
// read-path Open calls (CLI reads, direct-install search) succeed regardless,
// but every subsequent Search call on that Index fails with this sentinel
// until a build recreates the mirror.
var ErrMirrorVersionMismatch = errors.New(
"vector index was built by an incompatible version: run `agentsview embeddings build`")
// Index wraps vectors.db: agentsview's mirror of embeddable message content
// plus kit's sqlitevec store, which owns the generation and vec0 tables
// derived from spec's VectorsPrefix.
type Index struct {
db *sql.DB
store *sqlitevec.Store[string, string]
spec IndexSpec
split kitvec.SplitOptions
readOnly bool
// versionMismatch records a read-path Open's version-gate finding: the
// mirror was written by a different spec.MirrorSchemaVersion. Write-path
// Opens always resolve the mismatch themselves (reset and restamp), so
// this is only ever true on a read-only Index. Search checks it before
// touching any table.
versionMismatch bool
}
// GenerationInfo describes one embedding generation and its coverage of the
// current mirror, for CLI and status display.
type GenerationInfo struct {
ID int64 `json:"id"` // generations-table ordinal, CLI-facing
State string `json:"state"`
Model string `json:"model"`
Dimension int `json:"dimension"`
Fingerprint string `json:"fingerprint"`
Embedded int64 `json:"embedded"` // stamped docs
Missing int64 `json:"missing"` // mirror docs not stamped
// Store names the embedding store this generation belongs to (e.g.
// "messages"). Populated by the CLI after fetching, not by the daemon
// API, since generation IDs are only unique within a store.
Store string `json:"store,omitempty"`
}
// ChunkOverlap derives the SplitOptions.Overlap rune count from
// maxInputChars: 15% of the chunk size, so consecutive chunks share enough
// context for the anchor/window logic to bridge a run split mid-message.
// Open and vectorGeneration (cmd/agentsview/embeddings.go) both call this so
// the split behavior and its fingerprint can never drift apart.
func ChunkOverlap(maxInputChars int) int {
return maxInputChars * 15 / 100
}
// Open opens the message-store index; see OpenSpec.
func Open(ctx context.Context, path string, readOnly bool, maxInputChars int) (*Index, error) {
return OpenSpec(ctx, path, MessageIndexSpec(), readOnly, maxInputChars)
}
// OpenSpec opens (creating when rw) vectors.db and the kit sqlitevec store
// atop it, bound to spec's store. maxInputChars bounds the rune length of a
// single embedding request via SplitOptions{MaxRunes: maxInputChars,
// Overlap: ChunkOverlap(maxInputChars)}.
//
// When readOnly is true the file must already exist; OpenSpec never creates
// or migrates schema in that mode, matching internal/db.OpenReadOnly's
// cold-CLI-read contract.
func OpenSpec(
ctx context.Context, path string, spec IndexSpec, readOnly bool, maxInputChars int,
) (*Index, error) {
registerVecOnce.Do(sqlitevec.Register)
if readOnly {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("opening read-only vectors.db: %w", err)
}
} else if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("creating vectors.db directory: %w", err)
}
db, err := sql.Open(vectorDriverName, vectorDSN(path, readOnly))
if err != nil {
return nil, fmt.Errorf("opening vectors.db: %w", err)
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("opening vectors.db: %w", err)
}
versionMismatch, err := prepareMirrorSchema(ctx, db, spec, readOnly)
if err != nil {
db.Close()
return nil, err
}
store, err := sqlitevec.New[string, string](ctx, db, spec.schema())
if err != nil {
db.Close()
return nil, fmt.Errorf("opening vector store: %w", err)
}
overlap := ChunkOverlap(maxInputChars)
return &Index{
db: db,
store: store,
spec: spec,
split: kitvec.SplitOptions{MaxRunes: maxInputChars, Overlap: overlap},
readOnly: readOnly,
versionMismatch: versionMismatch,
}, nil
}
// prepareMirrorSchema checks vectors.db's stamped mirror_schema_version
// against spec.MirrorSchemaVersion and, on the write path, brings the schema
// current. On a mismatch — including the version key being absent while any
// mirror-state table already exists — the write path drops every such table
// and recreates the current schema from scratch: vectors.db is disposable by
// design (unlike sessions.db, which is never reset this way), so a clean
// rebuild is simpler and safer than an in-place column migration. On the
// read path a mismatch is reported back as mismatch=true without touching
// any table, leaving stale rows exactly as they are; the caller (Search)
// then fails closed with ErrMirrorVersionMismatch instead of risking a
// misread of old-shaped rows.
func prepareMirrorSchema(ctx context.Context, db *sql.DB, spec IndexSpec, readOnly bool) (mismatch bool, err error) {
mismatch, tables, err := mirrorVersionMismatch(ctx, db, spec)
if err != nil {
return false, err
}
if readOnly {
return mismatch, nil
}
if mismatch {
if err := dropMirrorTables(ctx, db, tables); err != nil {
return false, err
}
}
if _, err := db.ExecContext(ctx, spec.MirrorDDL); err != nil {
return false, fmt.Errorf("creating vectors.db schema: %w", err)
}
if err := stampMirrorSchemaVersion(ctx, db, spec); err != nil {
return false, err
}
return false, nil
}
// mirrorStateTableNames lists the sqlite_master table names considered part
// of the versioned mirror: the mirror's own tables, plus any kit-owned
// spec.VectorsPrefix* table (the generations and stamps bookkeeping tables
// and one vec0 table per embedding generation, including retired or
// abandoned ones left behind by a prior build). The prefix is matched
// literally via substr, not LIKE: LIKE would treat "_" in a prefix such as
// "message_vectors" as a single-character wildcard, letting one store's
// schema-version reset match — and drop — another store's tables whose
// names differ only at wildcard positions.
func mirrorStateTableNames(ctx context.Context, db *sql.DB, spec IndexSpec) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT name FROM sqlite_master
WHERE type = 'table'
AND (name IN (?, ?) OR substr(name, 1, ?) = ?)`,
spec.DocsTable, spec.MetaTable, len(spec.VectorsPrefix), spec.VectorsPrefix)
if err != nil {
return nil, fmt.Errorf("listing mirror tables: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scanning mirror table name: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("listing mirror tables: %w", err)
}
return names, nil
}
// mirrorVersionMismatch reports whether vectors.db's stamped
// mirror_schema_version differs from spec.MirrorSchemaVersion. An
// empty/fresh database (no mirror-state table at all) is never a mismatch —
// there is nothing yet to be incompatible with. Otherwise, a version key
// that is absent, or holds a different value, while any mirror-state table
// already exists is a mismatch: that table predates versioning entirely, or
// was stamped by a different scheme. tables is always returned so a
// write-path caller can drop exactly the set that was inspected.
func mirrorVersionMismatch(ctx context.Context, db *sql.DB, spec IndexSpec) (mismatch bool, tables []string, err error) {
tables, err = mirrorStateTableNames(ctx, db, spec)
if err != nil {
return false, nil, err
}
if len(tables) == 0 {
return false, tables, nil
}
if !slices.Contains(tables, spec.MetaTable) {
return true, tables, nil
}
var stamped string
err = db.QueryRowContext(ctx,
`SELECT value FROM `+spec.MetaTable+` WHERE key = ?`, mirrorSchemaVersionKey,
).Scan(&stamped)
if err == sql.ErrNoRows {
return true, tables, nil
}
if err != nil {
return false, nil, fmt.Errorf("reading mirror schema version: %w", err)
}
return stamped != spec.MirrorSchemaVersion, tables, nil
}
// dropMirrorTables drops every named table, resetting vectors.db's mirror
// state ahead of a fresh MirrorDDL + stampMirrorSchemaVersion. Table names
// come from sqlite_master (mirrorStateTableNames), not caller input, so
// building the DROP statement by concatenation is safe; SQL does not allow
// binding identifiers as query parameters.
func dropMirrorTables(ctx context.Context, db *sql.DB, tables []string) error {
for _, name := range tables {
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS "`+name+`"`); err != nil {
return fmt.Errorf("dropping stale mirror table %s: %w", name, err)
}
}
return nil
}
// stampMirrorSchemaVersion records spec.MirrorSchemaVersion into
// spec.MetaTable, overwriting any prior value.
func stampMirrorSchemaVersion(ctx context.Context, db *sql.DB, spec IndexSpec) error {
if _, err := db.ExecContext(ctx, `
INSERT INTO `+spec.MetaTable+` (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
mirrorSchemaVersionKey, spec.MirrorSchemaVersion,
); err != nil {
return fmt.Errorf("stamping mirror schema version: %w", err)
}
return nil
}
// Close closes the underlying vectors.db connection.
func (ix *Index) Close() error {
return ix.db.Close()
}
// requireWritable rejects generation-mutating calls on an Index opened
// read-only, matching internal/db's read-only guard pattern.
func (ix *Index) requireWritable() error {
if ix.readOnly {
return fmt.Errorf("vectors.db is opened read-only")
}
return nil
}
// metaGet reads one key from the spec's metadata table; ok is false when
// the key is absent.
func (ix *Index) metaGet(ctx context.Context, key string) (value string, ok bool, err error) {
err = ix.db.QueryRowContext(ctx,
`SELECT value FROM `+ix.spec.MetaTable+` WHERE key = ?`, key,
).Scan(&value)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("reading %s key %s: %w", ix.spec.MetaTable, key, err)
}
return value, true, nil
}
// metaSet upserts one key into the spec's metadata table.
func (ix *Index) metaSet(ctx context.Context, key, value string) error {
if _, err := ix.db.ExecContext(ctx, `
INSERT INTO `+ix.spec.MetaTable+` (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
); err != nil {
return fmt.Errorf("writing %s key %s: %w", ix.spec.MetaTable, key, err)
}
return nil
}
// EnsureGeneration registers gen (a model + dimension configuration) with
// kit's store under its own fingerprint as the gen_key, creating its vec0
// table on first use, and records the model's display name in the spec's
// metadata table so Generations can show it (kit's store persists only the
// fingerprint). Calling it again for the same fingerprint updates only the
// state.
func (ix *Index) EnsureGeneration(
ctx context.Context, gen kitvec.Generation, state sqlitevec.State,
) (string, error) {
if err := ix.requireWritable(); err != nil {
return "", err
}
fingerprint := gen.Fingerprint()
if err := ix.store.EnsureGeneration(ctx, fingerprint, gen, state); err != nil {
return "", fmt.Errorf("ensure generation: %w", err)
}
if err := ix.metaSet(ctx, "gen_model:"+fingerprint, gen.Model); err != nil {
return "", fmt.Errorf("record generation model: %w", err)
}
return fingerprint, nil
}
// SetStateByID transitions the generation identified by its generations-table
// ordinal (the CLI-facing ID) to state.
func (ix *Index) SetStateByID(ctx context.Context, id int64, state sqlitevec.State) error {
if err := ix.requireWritable(); err != nil {
return err
}
res, err := ix.db.ExecContext(ctx,
`UPDATE `+ix.spec.generationsTable()+` SET state = ? WHERE ordinal = ?`, string(state), id)
if err != nil {
return fmt.Errorf("set generation state: %w", err)
}
if n, err := res.RowsAffected(); err != nil {
return fmt.Errorf("set generation state rows: %w", err)
} else if n == 0 {
return fmt.Errorf("generation %d: %w", id, ErrGenerationNotFound)
}
return nil
}
// ActiveFingerprint returns the fingerprint of the generation currently in
// the active state, if any.
func (ix *Index) ActiveFingerprint(ctx context.Context) (string, bool, error) {
return ix.fingerprintByState(ctx, string(sqlitevec.StateActive))
}
// BuildingFingerprint returns the fingerprint of the generation currently in
// the building state, if any.
func (ix *Index) BuildingFingerprint(ctx context.Context) (string, bool, error) {
return ix.fingerprintByState(ctx, string(sqlitevec.StateBuilding))
}
func (ix *Index) fingerprintByState(ctx context.Context, state string) (string, bool, error) {
var fingerprint string
err := ix.db.QueryRowContext(ctx,
`SELECT gen_key FROM `+ix.spec.generationsTable()+` WHERE state = ? ORDER BY ordinal LIMIT 1`,
state).Scan(&fingerprint)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("lookup %s generation: %w", state, err)
}
return fingerprint, true, nil
}
// generationCoverageQuery is the exact join agentsview uses to report each
// generation's coverage of the current mirror: Embedded counts stamps for
// that generation ordinal whose revision still matches the mirror row's
// current content_hash, Missing counts mirror documents with no such
// matching-revision stamp. A stamp whose revision no longer matches (the
// mirror row's content changed since it was embedded) counts as Missing
// rather than Embedded, since kit's store treats it as pending re-embed.
func (ix *Index) generationCoverageQuery() string {
return `
SELECT g.ordinal, g.gen_key, g.fingerprint, g.dimension, g.state,
(SELECT COUNT(*) FROM ` + ix.spec.stampsTable() + ` s WHERE s.ordinal = g.ordinal
AND EXISTS (SELECT 1 FROM ` + ix.spec.DocsTable + ` d
WHERE s.doc_key = d.doc_key AND s.revision = d.content_hash)),
(SELECT COUNT(*) FROM ` + ix.spec.DocsTable + ` d WHERE NOT EXISTS
(SELECT 1 FROM ` + ix.spec.stampsTable() + ` s
WHERE s.ordinal = g.ordinal AND s.doc_key = d.doc_key AND s.revision = d.content_hash))
FROM ` + ix.spec.generationsTable() + ` g`
}
// Generations returns every generation with its coverage counts against the
// current mirror, ordered by ordinal. Like Search and StaleActive it fails
// closed with ErrMirrorVersionMismatch on a read-only Index over a
// mismatched mirror, rather than reporting coverage counts computed over
// stale-shape rows.
func (ix *Index) Generations(ctx context.Context) ([]GenerationInfo, error) {
if ix.versionMismatch {
return nil, ErrMirrorVersionMismatch
}
rows, err := ix.db.QueryContext(ctx, ix.generationCoverageQuery()+` ORDER BY g.ordinal`)
if err != nil {
return nil, fmt.Errorf("list generations: %w", err)
}
defer rows.Close()
var infos []GenerationInfo
for rows.Next() {
info, err := ix.scanGenerationInfo(ctx, rows)
if err != nil {
return nil, err
}
infos = append(infos, info)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list generations: %w", err)
}
return infos, nil
}
// ErrGenerationNotFound is returned by GenerationByID (and propagated by
// Manager.Activate/Retire) when id does not match any row in the
// generations table. Match it with errors.Is; the wrapping error's message
// still carries the specific id for logs and direct display.
var ErrGenerationNotFound = errors.New("generation not found")
// GenerationByID returns the single generation identified by its
// generations-table ordinal.
func (ix *Index) GenerationByID(ctx context.Context, id int64) (GenerationInfo, error) {
row := ix.db.QueryRowContext(ctx, ix.generationCoverageQuery()+` WHERE g.ordinal = ?`, id)
info, err := ix.scanGenerationInfo(ctx, row)
if err == sql.ErrNoRows {
return GenerationInfo{}, fmt.Errorf("generation %d: %w", id, ErrGenerationNotFound)
}
if err != nil {
return GenerationInfo{}, err
}
return info, nil
}
// genInfoScanner is the subset of *sql.Row / *sql.Rows Scan needs, letting
// scanGenerationInfo serve both Generations (rows) and GenerationByID (row).
type genInfoScanner interface {
Scan(dest ...any) error
}
func (ix *Index) scanGenerationInfo(ctx context.Context, src genInfoScanner) (GenerationInfo, error) {
var (
info GenerationInfo
genKey string
)
if err := src.Scan(
&info.ID, &genKey, &info.Fingerprint, &info.Dimension, &info.State,
&info.Embedded, &info.Missing,
); err != nil {
if err == sql.ErrNoRows {
return GenerationInfo{}, err
}
return GenerationInfo{}, fmt.Errorf("scan generation: %w", err)
}
model, _, err := ix.metaGet(ctx, "gen_model:"+info.Fingerprint)
if err != nil {
return GenerationInfo{}, fmt.Errorf("lookup generation model: %w", err)
}
info.Model = model
return info, nil
}