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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue