package projectmemory import ( "context" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/pgvector/pgvector-go" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" projectmemorysqlc "github.com/yan1h/agent-coding-workflow/internal/projectmemory/sqlc" ) // Repository is the projectmemory persistence contract. type Repository interface { Insert(ctx context.Context, in WriteInput, embeddingModel *string) (*Entry, error) SetEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string) error Get(ctx context.Context, id uuid.UUID) (*Entry, error) ListByProject(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) SearchKeyword(ctx context.Context, projectID uuid.UUID, kind, text string, topK int) ([]Entry, error) SearchKNN(ctx context.Context, projectID uuid.UUID, vec []float32, kind string, topK int) ([]Entry, error) Delete(ctx context.Context, id uuid.UUID) error } // PgRepository is the production Repository on a pgxpool.Pool. type PgRepository struct { pool *pgxpool.Pool q *projectmemorysqlc.Queries } // NewPostgresRepository builds a PgRepository. func NewPostgresRepository(pool *pgxpool.Pool) *PgRepository { return &PgRepository{pool: pool, q: projectmemorysqlc.New(pool)} } var _ Repository = (*PgRepository)(nil) func pgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } func pgUUIDPtr(u *uuid.UUID) pgtype.UUID { if u == nil { return pgtype.UUID{} } return pgtype.UUID{Bytes: *u, Valid: true} } func uuidPtr(p pgtype.UUID) *uuid.UUID { if !p.Valid { return nil } id := uuid.UUID(p.Bytes) return &id } // entryFields is the common projection shared by every sqlc row type in this // package; a tiny adapter keeps a single conversion path. type entryFields struct { ID pgtype.UUID ProjectID pgtype.UUID WorkspaceID pgtype.UUID Kind string Title string Body string Tags []string Source string EmbeddingModel *string CreatedBy pgtype.UUID CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } func toEntry(f entryFields) Entry { e := Entry{ ID: uuid.UUID(f.ID.Bytes), ProjectID: uuid.UUID(f.ProjectID.Bytes), WorkspaceID: uuidPtr(f.WorkspaceID), Kind: f.Kind, Title: f.Title, Body: f.Body, Tags: f.Tags, Source: f.Source, EmbeddingModel: f.EmbeddingModel, CreatedBy: uuidPtr(f.CreatedBy), } if e.Tags == nil { e.Tags = []string{} } if f.CreatedAt.Valid { e.CreatedAt = f.CreatedAt.Time } if f.UpdatedAt.Valid { e.UpdatedAt = f.UpdatedAt.Time } return e } func (r *PgRepository) Insert(ctx context.Context, in WriteInput, embeddingModel *string) (*Entry, error) { tags := in.Tags if tags == nil { // TEXT[] NOT NULL: never write a nil slice (would violate the constraint). tags = []string{} } source := in.Source if source == "" { source = SourceAgent } row, err := r.q.InsertEntry(ctx, projectmemorysqlc.InsertEntryParams{ ID: pgUUID(uuid.New()), ProjectID: pgUUID(in.ProjectID), WorkspaceID: pgUUIDPtr(in.WorkspaceID), Kind: in.Kind, Title: in.Title, Body: in.Body, Tags: tags, Source: source, EmbeddingModel: embeddingModel, CreatedBy: pgUUIDPtr(in.CreatedBy), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "insert project memory") } e := toEntry(entryFields(row)) return &e, nil } // SetEmbedding writes the vector + model with a hand-written pgx UPDATE (the // embedding column is excluded from sqlc). updated_at is bumped. func (r *PgRepository) SetEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string) error { _, err := r.pool.Exec(ctx, `UPDATE project_memory SET embedding = $2::vector, embedding_model = $3, updated_at = now() WHERE id = $1`, pgUUID(id), pgvector.NewVector(vec), model) if err != nil { return errs.Wrap(err, errs.CodeInternal, "set memory embedding") } return nil } func (r *PgRepository) Get(ctx context.Context, id uuid.UUID) (*Entry, error) { row, err := r.q.GetEntry(ctx, pgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "memory entry not found") } return nil, errs.Wrap(err, errs.CodeInternal, "get memory entry") } e := toEntry(entryFields(row)) return &e, nil } func (r *PgRepository) ListByProject(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) { rows, err := r.q.ListByProject(ctx, projectmemorysqlc.ListByProjectParams{ ProjectID: pgUUID(projectID), Column2: kind, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list project memory") } out := make([]Entry, 0, len(rows)) for _, row := range rows { out = append(out, toEntry(entryFields(row))) } return out, nil } func (r *PgRepository) SearchKeyword(ctx context.Context, projectID uuid.UUID, kind, text string, topK int) ([]Entry, error) { if topK <= 0 { topK = 10 } rows, err := r.q.SearchByKeyword(ctx, projectmemorysqlc.SearchByKeywordParams{ ProjectID: pgUUID(projectID), Column2: kind, Column3: text, Limit: int32(topK), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "search memory keyword") } out := make([]Entry, 0, len(rows)) for _, row := range rows { out = append(out, toEntry(entryFields(row))) } return out, nil } // SearchKNN runs cosine-KNN over entries that have an embedding, scoped to the // project (and optional kind). Hand-written because the vector column is excluded // from sqlc. func (r *PgRepository) SearchKNN(ctx context.Context, projectID uuid.UUID, vec []float32, kind string, topK int) ([]Entry, error) { if topK <= 0 { topK = 10 } q := ` SELECT id, project_id, workspace_id, kind, title, body, tags, source, embedding_model, created_by, created_at, updated_at FROM project_memory WHERE project_id = $1 AND embedding IS NOT NULL AND ($3::text = '' OR kind = $3) ORDER BY embedding <=> $2::vector LIMIT $4` rows, err := r.pool.Query(ctx, q, pgUUID(projectID), pgvector.NewVector(vec), kind, int32(topK)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "search memory knn") } defer rows.Close() var out []Entry for rows.Next() { var f entryFields if err := rows.Scan(&f.ID, &f.ProjectID, &f.WorkspaceID, &f.Kind, &f.Title, &f.Body, &f.Tags, &f.Source, &f.EmbeddingModel, &f.CreatedBy, &f.CreatedAt, &f.UpdatedAt); err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "scan memory knn row") } out = append(out, toEntry(f)) } if err := rows.Err(); err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "memory knn rows") } return out, nil } func (r *PgRepository) Delete(ctx context.Context, id uuid.UUID) error { if err := r.q.DeleteEntry(ctx, pgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "delete memory entry") } return nil }