client.go

  1package hyper
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"net/http"
  9	"net/url"
 10	"strings"
 11	"time"
 12
 13	"github.com/google/uuid"
 14	fantasy "charm.land/fantasy"
 15)
 16
 17// Client is a minimal client for the Hyper API.
 18type Client struct {
 19	BaseURL    *url.URL
 20	APIKey     string
 21	HTTPClient *http.Client
 22}
 23
 24// New creates a new Hyper client.
 25func New(base string, apiKey string) (*Client, error) {
 26	u, err := url.Parse(strings.TrimRight(base, "/"))
 27	if err != nil {
 28		return nil, fmt.Errorf("parse base url: %w", err)
 29	}
 30	return &Client{
 31		BaseURL:    u,
 32		APIKey:     apiKey,
 33		HTTPClient: &http.Client{Timeout: 30 * time.Second},
 34	}, nil
 35}
 36
 37// Project mirrors the JSON returned by Hyper.
 38type Project struct {
 39	ID             uuid.UUID  `json:"id"`
 40	Name           string     `json:"name"`
 41	Description    string     `json:"description"`
 42	OrganizationID uuid.UUID  `json:"organization_id"`
 43	UserID         uuid.UUID  `json:"user_id"`
 44	Archived       bool       `json:"archived"`
 45	Identifiers    []string   `json:"identifiers"`
 46	CreatedAt      time.Time  `json:"created_at"`
 47	UpdatedAt      time.Time  `json:"updated_at"`
 48}
 49
 50// CreateProject creates a project.
 51func (c *Client) CreateProject(ctx context.Context, name, description string, organizationID uuid.UUID, identifiers []string) (Project, error) {
 52	var p Project
 53	body := map[string]any{
 54		"name":            name,
 55		"description":     description,
 56		"organization_id": organizationID,
 57	}
 58	if len(identifiers) > 0 {
 59		body["identifiers"] = identifiers
 60	}
 61	bts, _ := json.Marshal(body)
 62	endpoint := c.BaseURL.JoinPath("api/v1", "projects").String()
 63	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bts))
 64	if err != nil {
 65		return p, err
 66	}
 67	c.addAuth(req)
 68	req.Header.Set("Content-Type", "application/json")
 69	resp, err := c.http().Do(req)
 70	if err != nil {
 71		return p, err
 72	}
 73	defer resp.Body.Close()
 74	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 75		return p, fmt.Errorf("create project: http %d", resp.StatusCode)
 76	}
 77	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
 78		return p, err
 79	}
 80	return p, nil
 81}
 82
 83// ListProjects lists projects for the authenticated user.
 84// If identifiers is not empty, projects that match ANY identifier are returned.
 85func (c *Client) ListProjects(ctx context.Context, identifiers []string) ([]Project, error) {
 86	endpoint := c.BaseURL.JoinPath("api/v1", "projects")
 87	q := endpoint.Query()
 88	if len(identifiers) > 0 {
 89		q.Set("identifiers", strings.Join(identifiers, ","))
 90		endpoint.RawQuery = q.Encode()
 91	}
 92	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
 93	if err != nil {
 94		return nil, err
 95	}
 96	c.addAuth(req)
 97	resp, err := c.http().Do(req)
 98	if err != nil {
 99		return nil, err
100	}
101	defer resp.Body.Close()
102	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
103		return nil, fmt.Errorf("list projects: http %d", resp.StatusCode)
104	}
105	var ps []Project
106	if err := json.NewDecoder(resp.Body).Decode(&ps); err != nil {
107		return nil, err
108	}
109	return ps, nil
110}
111
112// Memorize sends messages to be memorized for a given project and echoes them back.
113func (c *Client) Memorize(ctx context.Context, projectID uuid.UUID, msgs []fantasy.Message) ([]fantasy.Message, error) {
114	bts, _ := json.Marshal(msgs)
115	endpoint := c.BaseURL.JoinPath("api/v1", "projects", projectID.String(), "memorize").String()
116	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bts))
117	if err != nil {
118		return nil, err
119	}
120	c.addAuth(req)
121	req.Header.Set("Content-Type", "application/json")
122	resp, err := c.http().Do(req)
123	if err != nil {
124		return nil, err
125	}
126	defer resp.Body.Close()
127	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
128		return nil, fmt.Errorf("memorize: http %d", resp.StatusCode)
129	}
130	var echoed []fantasy.Message
131	if err := json.NewDecoder(resp.Body).Decode(&echoed); err != nil {
132		return nil, err
133	}
134	return echoed, nil
135}
136
137// ProjectMemories fetches memory bullets for a project using optional query, type filters and limit.
138func (c *Client) ProjectMemories(ctx context.Context, projectID uuid.UUID, query string, types []string, limit int) ([]string, error) {
139	endpoint := c.BaseURL.JoinPath("api/v1", "projects", projectID.String(), "memories")
140	q := endpoint.Query()
141	if strings.TrimSpace(query) != "" {
142		q.Set("q", query)
143	}
144	for _, t := range types {
145		if strings.TrimSpace(t) != "" {
146			q.Add("type", t)
147		}
148	}
149	if limit > 0 {
150		q.Set("limit", fmt.Sprintf("%d", limit))
151	}
152	endpoint.RawQuery = q.Encode()
153
154	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
155	if err != nil {
156		return nil, err
157	}
158	c.addAuth(req)
159	resp, err := c.http().Do(req)
160	if err != nil {
161		return nil, err
162	}
163	defer resp.Body.Close()
164	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
165		return nil, fmt.Errorf("project memories: http %d", resp.StatusCode)
166	}
167	var bullets []string
168	if err := json.NewDecoder(resp.Body).Decode(&bullets); err != nil {
169		return nil, err
170	}
171	return bullets, nil
172}
173
174func (c *Client) http() *http.Client {
175	if c.HTTPClient != nil {
176		return c.HTTPClient
177	}
178	return http.DefaultClient
179}
180
181func (c *Client) addAuth(req *http.Request) {
182	if c.APIKey != "" {
183		req.Header.Set("Authorization", "Bearer "+c.APIKey)
184	}
185}