homelab: add memory verb-group (v0.3.0) — direct claude-memory HTTP client
Lets agents search/navigate memory via the CLI, as the first step toward deprecating the memory MCP. claude-memory is a FastAPI service (the MCP is just one frontend); homelab memory is a thin Bearer-auth HTTP client over the same API, using the env the hooks already set (CLAUDE_MEMORY_API_URL/KEY). It works even when the MCP frontend is down — the recurring disconnect that took the MCP offline for this whole session. Verbs: recall (server-side semantic search), list, categories, tags, stats, secret (read); store, update, delete (write). Validated against the live API including a store→recall→delete round-trip — full data-plane parity with the MCP. The deprecation itself (rewiring the per-prompt auto-recall + auto-learn hooks to the CLI, then uninstalling the MCP) is a deliberate follow-up, sequenced after the CLI is proven in the hooks — see docs/adr/0008. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3594485f77
commit
48b63ffa6f
8 changed files with 514 additions and 3 deletions
|
|
@ -70,6 +70,30 @@ Tiers are recorded per verb so a future PreToolUse classifier can auto-allow
|
|||
reads / prompt writes; v0.1 allows everything and relies on existing gates
|
||||
(permission mode, presence claims, plan approval).
|
||||
|
||||
### v0.3 verbs — memory
|
||||
|
||||
A thin HTTP client over the **claude-memory** service (the same backend the
|
||||
memory MCP wraps), authed with `CLAUDE_MEMORY_API_KEY` against
|
||||
`CLAUDE_MEMORY_API_URL` (the env the hooks already set; defaults to the
|
||||
ingress). Because it hits the HTTP API directly, it **works even when the MCP
|
||||
frontend is down**.
|
||||
|
||||
| Command | Tier | What it does |
|
||||
|---|---|---|
|
||||
| `memory recall "<context>" [--query --category --sort --limit]` | read | semantic search (server-side ranking) — the navigate workhorse |
|
||||
| `memory list [--category --tag --limit]` | read | recent memories |
|
||||
| `memory categories` / `memory tags` / `memory stats` | read | enumerate the store |
|
||||
| `memory secret <id>` | read | reveal a sensitive memory's content |
|
||||
| `memory store "<content>" [--category --tags --keywords --importance --sensitive]` | write | store a memory |
|
||||
| `memory update <id> [--content --tags --importance]` | write | edit a memory |
|
||||
| `memory delete <id>` | write | delete a memory |
|
||||
|
||||
All read/write paths are validated against the live API (incl. a
|
||||
store→recall→delete round-trip). This gives full data-plane parity with the MCP;
|
||||
the eventual deprecation (rewiring the per-prompt auto-recall + auto-learn hooks
|
||||
to the CLI, then uninstalling the MCP) is a **separate, deliberate follow-up** —
|
||||
see `docs/adr/0008`.
|
||||
|
||||
## Build / install
|
||||
|
||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||
|
|
@ -89,4 +113,4 @@ original flag-based path unchanged, so the webhook handler is unaffected.
|
|||
|
||||
## Design
|
||||
|
||||
See `infra/docs/adr/0004`–`0007` for the architecture decisions.
|
||||
See `infra/docs/adr/0004`–`0008` for the architecture decisions.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
v0.2.0
|
||||
v0.3.0
|
||||
|
|
|
|||
302
cli/cmd_memory.go
Normal file
302
cli/cmd_memory.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func memoryCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"memory", "recall"}, Tier: TierRead,
|
||||
Summary: `semantic search of memory: memory recall "<context>" [--query …] [--category] [--sort] [--limit]`, Run: memoryRecall},
|
||||
{Path: []string{"memory", "list"}, Tier: TierRead,
|
||||
Summary: "list recent memories [--category C] [--tag T] [--limit N]", Run: memoryList},
|
||||
{Path: []string{"memory", "categories"}, Tier: TierRead,
|
||||
Summary: "list memory categories", Run: memorySimpleGet("/api/categories")},
|
||||
{Path: []string{"memory", "tags"}, Tier: TierRead,
|
||||
Summary: "list memory tags", Run: memorySimpleGet("/api/tags")},
|
||||
{Path: []string{"memory", "stats"}, Tier: TierRead,
|
||||
Summary: "memory store stats", Run: memorySimpleGet("/api/stats")},
|
||||
{Path: []string{"memory", "secret"}, Tier: TierRead,
|
||||
Summary: "reveal a sensitive memory's content: memory secret <id>", Run: memorySecret},
|
||||
{Path: []string{"memory", "store"}, Tier: TierWrite,
|
||||
Summary: `store a memory: memory store "<content>" [--category --tags --keywords --importance --sensitive]`, Run: memoryStore},
|
||||
{Path: []string{"memory", "update"}, Tier: TierWrite,
|
||||
Summary: "update a memory: memory update <id> [--content --tags --importance --keywords]", Run: memoryUpdate},
|
||||
{Path: []string{"memory", "delete"}, Tier: TierWrite,
|
||||
Summary: "delete a memory: memory delete <id>", Run: memoryDelete},
|
||||
}
|
||||
}
|
||||
|
||||
// printMemories renders a {memories:[…]} response as compact lines, or raw JSON.
|
||||
func printMemories(raw []byte, jsonOut bool) error {
|
||||
if jsonOut {
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
var r struct {
|
||||
Memories []struct {
|
||||
ID int `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
Importance float64 `json:"importance"`
|
||||
} `json:"memories"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
if len(r.Memories) == 0 {
|
||||
fmt.Println("(no memories)")
|
||||
return nil
|
||||
}
|
||||
for _, m := range r.Memories {
|
||||
c := strings.ReplaceAll(m.Content, "\n", " ")
|
||||
if len(c) > 240 {
|
||||
c = c[:240] + "…"
|
||||
}
|
||||
fmt.Printf("#%d [%s] (%.2f) %s\n", m.ID, m.Category, m.Importance, c)
|
||||
if m.Tags != "" {
|
||||
fmt.Printf(" tags: %s\n", m.Tags)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func memoryRecall(args []string) error {
|
||||
req := memRecallReq{}
|
||||
jsonOut := false
|
||||
var pos []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--query":
|
||||
if i+1 < len(args) {
|
||||
req.ExpandedQuery = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--category":
|
||||
if i+1 < len(args) {
|
||||
req.Category = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--sort":
|
||||
if i+1 < len(args) {
|
||||
req.SortBy = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--limit":
|
||||
if i+1 < len(args) {
|
||||
fmt.Sscanf(args[i+1], "%d", &req.Limit)
|
||||
i++
|
||||
}
|
||||
case a == "--json":
|
||||
jsonOut = true
|
||||
case !strings.HasPrefix(a, "-"):
|
||||
pos = append(pos, a)
|
||||
}
|
||||
}
|
||||
req.Context = strings.Join(pos, " ")
|
||||
if req.Context == "" {
|
||||
return fmt.Errorf(`usage: homelab memory recall "<context>" [--query …] [--category C] [--sort importance|relevance|recency] [--limit N]`)
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("POST", "/api/memories/recall", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printMemories(raw, jsonOut)
|
||||
}
|
||||
|
||||
func memoryList(args []string) error {
|
||||
q := url.Values{}
|
||||
jsonOut := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--category":
|
||||
if i+1 < len(args) {
|
||||
q.Set("category", args[i+1])
|
||||
i++
|
||||
}
|
||||
case a == "--tag":
|
||||
if i+1 < len(args) {
|
||||
q.Set("tag", args[i+1])
|
||||
i++
|
||||
}
|
||||
case a == "--limit":
|
||||
if i+1 < len(args) {
|
||||
q.Set("limit", args[i+1])
|
||||
i++
|
||||
}
|
||||
case a == "--json":
|
||||
jsonOut = true
|
||||
}
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/api/memories"
|
||||
if len(q) > 0 {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
raw, err := c.do("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printMemories(raw, jsonOut)
|
||||
}
|
||||
|
||||
func memorySimpleGet(path string) func([]string) error {
|
||||
return func(args []string) error {
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("GET", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func memorySecret(args []string) error {
|
||||
id, _ := firstPositional(args)
|
||||
if id == "" {
|
||||
return fmt.Errorf("usage: homelab memory secret <id>")
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("POST", "/api/memories/"+id+"/secret", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
func memoryStore(args []string) error {
|
||||
req := memStoreReq{Category: "facts", Importance: 0.5}
|
||||
var pos []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--category":
|
||||
if i+1 < len(args) {
|
||||
req.Category = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--tags":
|
||||
if i+1 < len(args) {
|
||||
req.Tags = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--keywords":
|
||||
if i+1 < len(args) {
|
||||
req.ExpandedKeywords = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--importance":
|
||||
if i+1 < len(args) {
|
||||
fmt.Sscanf(args[i+1], "%f", &req.Importance)
|
||||
i++
|
||||
}
|
||||
case a == "--sensitive":
|
||||
req.ForceSensitive = true
|
||||
case !strings.HasPrefix(a, "-"):
|
||||
pos = append(pos, a)
|
||||
}
|
||||
}
|
||||
req.Content = strings.Join(pos, " ")
|
||||
if req.Content == "" {
|
||||
return fmt.Errorf(`usage: homelab memory store "<content>" [--category C] [--tags ...] [--keywords ...] [--importance 0.5] [--sensitive]`)
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("POST", "/api/memories", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
func memoryUpdate(args []string) error {
|
||||
var id string
|
||||
req := memUpdateReq{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--content":
|
||||
if i+1 < len(args) {
|
||||
v := args[i+1]
|
||||
req.Content = &v
|
||||
i++
|
||||
}
|
||||
case a == "--tags":
|
||||
if i+1 < len(args) {
|
||||
v := args[i+1]
|
||||
req.Tags = &v
|
||||
i++
|
||||
}
|
||||
case a == "--keywords":
|
||||
if i+1 < len(args) {
|
||||
v := args[i+1]
|
||||
req.ExpandedKeywords = &v
|
||||
i++
|
||||
}
|
||||
case a == "--importance":
|
||||
if i+1 < len(args) {
|
||||
var f float64
|
||||
fmt.Sscanf(args[i+1], "%f", &f)
|
||||
req.Importance = &f
|
||||
i++
|
||||
}
|
||||
case !strings.HasPrefix(a, "-") && id == "":
|
||||
id = a
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("usage: homelab memory update <id> [--content ...] [--tags ...] [--importance N] [--keywords ...]")
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("PUT", "/api/memories/"+id, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
func memoryDelete(args []string) error {
|
||||
id, _ := firstPositional(args)
|
||||
if id == "" {
|
||||
return fmt.Errorf("usage: homelab memory delete <id>")
|
||||
}
|
||||
c, err := newMemoryClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.do("DELETE", "/api/memories/"+id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ func buildRegistry() []Command {
|
|||
reg = append(reg, tfCommands()...)
|
||||
reg = append(reg, workCommands()...)
|
||||
reg = append(reg, k8sCommands()...)
|
||||
reg = append(reg, memoryCommands()...)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
|
|
|||
103
cli/memory.go
Normal file
103
cli/memory.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultMemoryURL is used when no env override is present (agents normally have
|
||||
// CLAUDE_MEMORY_API_URL set by the memory hooks).
|
||||
const defaultMemoryURL = "https://claude-memory.viktorbarzin.me"
|
||||
|
||||
type memoryClient struct {
|
||||
base string
|
||||
key string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func firstEnv(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveMemoryBase() string {
|
||||
if b := firstEnv("CLAUDE_MEMORY_API_URL", "MEMORY_API_URL"); b != "" {
|
||||
return strings.TrimRight(b, "/")
|
||||
}
|
||||
return defaultMemoryURL
|
||||
}
|
||||
|
||||
// newMemoryClient talks straight to the claude-memory HTTP API (the same backend
|
||||
// the MCP wraps), so it works even when the MCP frontend is down.
|
||||
func newMemoryClient() (*memoryClient, error) {
|
||||
key := firstEnv("CLAUDE_MEMORY_API_KEY", "MEMORY_API_KEY")
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("no memory API key — set CLAUDE_MEMORY_API_KEY (or MEMORY_API_KEY)")
|
||||
}
|
||||
return &memoryClient{base: resolveMemoryBase(), key: key, http: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c *memoryClient) do(method, path string, body interface{}) ([]byte, error) {
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("memory API %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Request bodies mirror src/claude_memory/api/models.py.
|
||||
|
||||
type memRecallReq struct {
|
||||
Context string `json:"context"`
|
||||
ExpandedQuery string `json:"expanded_query,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
SortBy string `json:"sort_by,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type memStoreReq struct {
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
ExpandedKeywords string `json:"expanded_keywords,omitempty"`
|
||||
Importance float64 `json:"importance"`
|
||||
ForceSensitive bool `json:"force_sensitive,omitempty"`
|
||||
}
|
||||
|
||||
type memUpdateReq struct {
|
||||
Content *string `json:"content,omitempty"`
|
||||
Tags *string `json:"tags,omitempty"`
|
||||
Importance *float64 `json:"importance,omitempty"`
|
||||
ExpandedKeywords *string `json:"expanded_keywords,omitempty"`
|
||||
}
|
||||
51
cli/memory_test.go
Normal file
51
cli/memory_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveMemoryBase(t *testing.T) {
|
||||
old1, old2 := os.Getenv("CLAUDE_MEMORY_API_URL"), os.Getenv("MEMORY_API_URL")
|
||||
defer func() { os.Setenv("CLAUDE_MEMORY_API_URL", old1); os.Setenv("MEMORY_API_URL", old2) }()
|
||||
|
||||
os.Unsetenv("CLAUDE_MEMORY_API_URL")
|
||||
os.Unsetenv("MEMORY_API_URL")
|
||||
if got := resolveMemoryBase(); got != defaultMemoryURL {
|
||||
t.Errorf("resolveMemoryBase() = %q, want default %q", got, defaultMemoryURL)
|
||||
}
|
||||
os.Setenv("CLAUDE_MEMORY_API_URL", "https://m.example/") // trailing slash trimmed
|
||||
if got := resolveMemoryBase(); got != "https://m.example" {
|
||||
t.Errorf("resolveMemoryBase() = %q, want https://m.example", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemStoreReqAlwaysSendsImportance(t *testing.T) {
|
||||
b, _ := json.Marshal(memStoreReq{Content: "x", Category: "facts", Importance: 0.5})
|
||||
s := string(b)
|
||||
if !strings.Contains(s, `"content":"x"`) || !strings.Contains(s, `"importance":0.5`) {
|
||||
t.Fatalf("memStoreReq JSON missing fields: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemUpdateReqOmitsUnsetFields(t *testing.T) {
|
||||
tags := "a,b"
|
||||
b, _ := json.Marshal(memUpdateReq{Tags: &tags})
|
||||
s := string(b)
|
||||
if strings.Contains(s, "content") || strings.Contains(s, "importance") {
|
||||
t.Fatalf("unset update fields must be omitted: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"tags":"a,b"`) {
|
||||
t.Fatalf("set field missing: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemRecallReqOmitsEmptyOptionals(t *testing.T) {
|
||||
b, _ := json.Marshal(memRecallReq{Context: "hi"})
|
||||
s := string(b)
|
||||
if strings.Contains(s, "expanded_query") || strings.Contains(s, "category") || strings.Contains(s, "limit") {
|
||||
t.Fatalf("empty optionals must be omitted: %s", s)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue