You've already forked agentic-coding-workflow
130 lines
4.3 KiB
Go
130 lines
4.3 KiB
Go
package projectmemory
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
|
|
)
|
|
|
|
// EmbedderSelector resolves the configured platform embedder, or a typed error
|
|
// when embeddings are disabled. Implemented in app.go (shared with codeindex).
|
|
type EmbedderSelector interface {
|
|
Embedder(ctx context.Context) (emb llm.Embedder, endpointID uuid.UUID, model string, dims int, err error)
|
|
}
|
|
|
|
// Service is the projectmemory application service. Scope enforcement (project
|
|
// access) is performed by callers (MCP tools / supervisor) before invoking it.
|
|
type Service interface {
|
|
Write(ctx context.Context, in WriteInput) (*Entry, error)
|
|
Search(ctx context.Context, q Query) ([]Entry, error)
|
|
List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error)
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error)
|
|
}
|
|
|
|
type service struct {
|
|
repo Repository
|
|
embedSel EmbedderSelector
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewService constructs the projectmemory Service. embedSel may be nil (no
|
|
// embeddings → keyword fallback only); log may be nil.
|
|
func NewService(repo Repository, embedSel EmbedderSelector, log *slog.Logger) Service {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &service{repo: repo, embedSel: embedSel, log: log}
|
|
}
|
|
|
|
func (s *service) Write(ctx context.Context, in WriteInput) (*Entry, error) {
|
|
if !IsValidKind(in.Kind) {
|
|
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
|
|
}
|
|
if in.Title == "" || in.Body == "" {
|
|
return nil, errs.New(errs.CodeInvalidInput, "title and body required")
|
|
}
|
|
// Best-effort embed of title+body; null embedding when disabled/unavailable.
|
|
var model *string
|
|
var vec []float32
|
|
if emb, _, m, _, err := s.embedder(ctx); err == nil {
|
|
vecs, eerr := emb.Embed(ctx, m, []string{in.Title + "\n\n" + in.Body})
|
|
if eerr != nil {
|
|
s.log.Warn("projectmemory.embed_failed", "err", eerr.Error())
|
|
} else if len(vecs) == 1 {
|
|
vec = vecs[0]
|
|
model = &m
|
|
}
|
|
}
|
|
entry, err := s.repo.Insert(ctx, in, model)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if vec != nil && model != nil {
|
|
if err := s.repo.SetEmbedding(ctx, entry.ID, vec, *model); err != nil {
|
|
// Non-fatal: the entry exists and is keyword-searchable.
|
|
s.log.Warn("projectmemory.set_embedding_failed", "id", entry.ID, "err", err.Error())
|
|
} else {
|
|
entry.EmbeddingModel = model
|
|
}
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *service) Search(ctx context.Context, q Query) ([]Entry, error) {
|
|
if q.Text == "" {
|
|
return nil, errs.New(errs.CodeInvalidInput, "query text required")
|
|
}
|
|
if q.Kind != "" && !IsValidKind(q.Kind) {
|
|
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
|
|
}
|
|
// Vector path when an embedder is configured; ILIKE/tags fallback otherwise.
|
|
if emb, _, model, _, err := s.embedder(ctx); err == nil {
|
|
vecs, eerr := emb.Embed(ctx, model, []string{q.Text})
|
|
if eerr == nil && len(vecs) == 1 {
|
|
res, kerr := s.repo.SearchKNN(ctx, q.ProjectID, vecs[0], q.Kind, q.TopK)
|
|
if kerr != nil {
|
|
return nil, kerr
|
|
}
|
|
// If the project has no embedded entries yet, fall back to keyword.
|
|
if len(res) > 0 {
|
|
return res, nil
|
|
}
|
|
} else if eerr != nil {
|
|
s.log.Warn("projectmemory.search_embed_failed", "err", eerr.Error())
|
|
}
|
|
}
|
|
return s.repo.SearchKeyword(ctx, q.ProjectID, q.Kind, q.Text, q.TopK)
|
|
}
|
|
|
|
func (s *service) List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) {
|
|
if kind != "" && !IsValidKind(kind) {
|
|
return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind")
|
|
}
|
|
return s.repo.ListByProject(ctx, projectID, kind)
|
|
}
|
|
|
|
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.Delete(ctx, id)
|
|
}
|
|
|
|
func (s *service) RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) {
|
|
entries, err := s.repo.ListByProject(ctx, projectID, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return renderAgentsMD(entries, wsID), nil
|
|
}
|
|
|
|
// embedder returns the selector result, or an error when no selector is set.
|
|
func (s *service) embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) {
|
|
if s.embedSel == nil {
|
|
return nil, uuid.Nil, "", 0, llm.ErrEmbeddingsUnsupported
|
|
}
|
|
return s.embedSel.Embedder(ctx)
|
|
}
|