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) }