minimal working version of backend and cli

This commit is contained in:
2025-10-14 01:58:24 -04:00
parent 839f0c9107
commit 2fdb1ece43
9 changed files with 615 additions and 48 deletions

434
backend/cli.py Executable file
View File

@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""
MIND CLI (v0) — supports Ops 18 via REST calls to the Go backend.
Assumptions:
- API base: http://localhost:8080 (change with \\set api ...)
- llama.cpp server: http://localhost:8081 (change with \\set llama ...)
- Auth: dev token ("Bearer dev"). Adjust as needed.
Core usage model:
- Free text (no leading '\\') calls /completion (Op 2 helper) on the active conversation/branch.
- Backslash commands perform explicit ops.
Operations → Commands mapping
1) Start conversation → \\new <title> <owner_id>
2) Commit (prompt/answer or node)→ free text → /completion; or \\node <user|assistant> <content> [parent_id]
3) Fork branch → \\branch <name> [head_node_id]
4) Delete branch or node → \\delbranch <name> | \\delnode <node_id>
5) Extract subset as new tree → \\extract <title> <node_id ...>
6) Merge two branches → \\merge <left> <right> <left-first|right-first>
7) Detach branch to new convo → \\detach <branch>
8) Append/copy tree to another → \\append-tree <src_conv> <src_branch> [dst_conv [dst_branch]]
Helpful extras:
- \\listconvs <owner_id>
- \\listbranches <conversation_id>
- \\linearize (active conv/branch)
- \\llama <prompt> (debug llama-server directly)
- \\useconv <id> ; \\usebranch <name> ; \\who ; \\quit
"""
import json
import os
import shlex
import sys
from typing import Dict, Any, List
import requests
DEFAULT_API = os.environ.get("MIND_API", "http://localhost:8080")
DEFAULT_LLAMA = os.environ.get("LLAMA_API", "http://localhost:8081")
auth_headers = lambda: {"Authorization": "Bearer dev", "Content-Type": "application/json"}
class State:
def __init__(self):
self.api = DEFAULT_API.rstrip('/')
self.llama = DEFAULT_LLAMA.rstrip('/')
self.conversation_id = None # int
self.branch = None # str
def as_dict(self):
return {
"api": self.api,
"llama": self.llama,
"conversation_id": self.conversation_id,
"branch": self.branch,
}
def pretty(obj: Any) -> str:
try:
return json.dumps(obj, indent=2, ensure_ascii=False)
except Exception:
return str(obj)
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
r = requests.post(url, headers=auth_headers(), data=json.dumps(payload), timeout=60)
r.raise_for_status()
if r.text.strip() == "":
return {}
return r.json()
def delete_json(url: str, payload: Dict[str, Any] | None = None) -> Dict[str, Any]:
r = requests.delete(url, headers=auth_headers(), data=json.dumps(payload) if payload else None, timeout=60)
r.raise_for_status()
return r.json() if r.text.strip() else {}
def get_json(url: str, params: Dict[str, Any]) -> Dict[str, Any] | List[Any]:
r = requests.get(url, headers=auth_headers(), params=params, timeout=60)
r.raise_for_status()
ct = r.headers.get('Content-Type','')
if 'application/json' in ct:
return r.json()
return {"raw": r.text}
# ---------- Commands ----------
def cmd_help():
print(__doc__)
def cmd_set(st: State, args):
if len(args) < 2:
print("usage: \\set <api|llama> <URL>")
return
key, val = args[0], args[1]
if key == 'api':
st.api = val.rstrip('/')
elif key == 'llama':
st.llama = val.rstrip('/')
else:
print("unknown key; use 'api' or 'llama'")
return
print("ok:", pretty(st.as_dict()))
# (1) Start conversation
def cmd_new(st: State, args):
if len(args) < 2:
print("usage: \\new <title> <owner_id>")
return
title = args[0]
try:
owner_id = int(args[1])
except ValueError:
print("owner_id must be an integer")
return
out = post_json(f"{st.api}/conversations", {"title": title, "owner_id": owner_id})
cid = out.get("id")
st.conversation_id = cid
print("created conversation:", cid)
def cmd_listconvs(st: State, args):
if not args:
print("usage: \\listconvs <owner_id>")
return
owner_id = int(args[0])
out = get_json(f"{st.api}/conversations", {"owner_id": owner_id})
print(pretty(out))
# (3) Fork branch
def cmd_branch(st: State, args):
if st.conversation_id is None:
print("set a conversation first: \\useconv <id> or \\new <title> <owner_id>")
return
if len(args) < 1:
print("usage: \\branch <name> [head_node_id]")
return
name = args[0]
head = int(args[1]) if len(args) > 1 else 0
payload = {
"conversation_id": st.conversation_id,
"name": name,
"head_node_id": head
}
out = post_json(f"{st.api}/branches", payload)
st.branch = out.get("name", name)
print("active branch:", st.branch, pretty(out))
def cmd_usebranch(st: State, args):
if not args:
print("usage: \\usebranch <name>")
return
st.branch = args[0]
print("active branch:", st.branch)
def cmd_listbranches(st: State, args):
if not args:
if st.conversation_id is None:
print("usage: \\listbranches <conversation_id> (or set active with \\useconv)")
return
conv = st.conversation_id
else:
conv = int(args[0])
out = get_json(f"{st.api}/branches", {"conversation_id": conv})
print(pretty(out))
def cmd_useconv(st: State, args):
if not args:
print("usage: \\useconv <conversation_id>")
return
st.conversation_id = int(args[0])
print("active conversation:", st.conversation_id)
# (2) Commit helpers
def cmd_node(st: State, args):
"""Create a single node (explicit Op 2). usage: \\node <user|assistant> <content> [parent_id]"""
if st.conversation_id is None:
print("set active conversation first")
return
if len(args) < 2:
print("usage: \\node <user|assistant> <content> [parent_id]")
return
author = args[0]
content = args[1]
parent_id = int(args[2]) if len(args) > 2 else None
payload = {
"conversation_id": st.conversation_id,
"author_kind": author,
"content": content
}
if parent_id is not None:
payload["parent_id"] = parent_id
out = post_json(f"{st.api}/nodes", payload)
print(pretty(out))
def send_prompt(st: State, line: str):
# Free text → /completion (with auto-branch-create retry)
if st.conversation_id is None or not st.branch:
print("need active conversation and branch (\\new/\\useconv and \\branch/\\usebranch)")
return
payload = {
"conversation_id": st.conversation_id,
"branch": st.branch,
"prompt": line.strip(),
}
try:
out = post_json(f"{st.api}/completion", payload)
except requests.HTTPError as e:
body = e.response.text if e.response is not None else ""
# If branch doesn't exist yet, create it and retry once
if e.response is not None and e.response.status_code in (400, 404, 500) and "branch" in body.lower():
_ = post_json(f"{st.api}/branches", {
"conversation_id": st.conversation_id,
"name": st.branch,
"head_node_id": 0
})
out = post_json(f"{st.api}/completion", payload)
else:
raise
print("assistant:\n", out.get("answer"))
# (4) Delete branch / node
def cmd_delbranch(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if not args:
print("usage: \\delbranch <name>")
return
name = args[0]
out = delete_json(f"{st.api}/branches", {"conversation_id": st.conversation_id, "name": name})
if st.branch == name:
st.branch = None
print("deleted branch:", name, pretty(out))
def cmd_delnode(st: State, args):
if not args:
print("usage: \\delnode <node_id>")
return
node_id = int(args[0])
out = delete_json(f"{st.api}/nodes/{node_id}")
print(pretty(out))
# (5) Extract selected nodes to new conversation
def cmd_extract(st: State, args):
if len(args) < 2:
print("usage: \\extract <title> <node_id ...>")
return
title = args[0]
node_ids = [int(x) for x in args[1:]]
payload = {"conversation_id": st.conversation_id, "title": title, "node_ids": node_ids}
out = post_json(f"{st.api}/extract", payload)
new_cid = out.get("new_conversation_id")
print("extracted to conversation:", new_cid, pretty(out))
# (6) Merge two branches (concat order)
def cmd_merge(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if len(args) < 3:
print("usage: \\merge <left_branch> <right_branch> <left-first|right-first>")
return
left, right, order = args[0], args[1], args[2]
payload = {"conversation_id": st.conversation_id, "left_branch": left, "right_branch": right, "order": order}
out = post_json(f"{st.api}/merge", payload)
print("merge result:", pretty(out))
# (7) Detach branch → new conversation
def cmd_detach(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if not args:
print("usage: \\detach <branch>")
return
payload = {"conversation_id": st.conversation_id, "branch": args[0]}
out = post_json(f"{st.api}/detach", payload)
print("detached to:", pretty(out))
# (8) Append/copy tree
def cmd_append_tree(st: State, args):
if len(args) < 2:
print("usage: \\append-tree <src_conv> <src_branch> [dst_conv [dst_branch]]")
return
src_conv = int(args[0])
src_branch = args[1]
dst_conv = int(args[2]) if len(args) > 2 else st.conversation_id
dst_branch = args[3] if len(args) > 3 else st.branch
payload = {"src_conversation_id": src_conv, "src_branch": src_branch, "dst_conversation_id": dst_conv, "dst_branch": dst_branch}
out = post_json(f"{st.api}/append-tree", payload)
print("append-tree result:", pretty(out))
# Linearize & llama & misc
def cmd_linearize(st: State):
if st.conversation_id is None or not st.branch:
print("need active conversation and branch (\\useconv / \\usebranch)")
return
out = get_json(f"{st.api}/linearize", {"conversation_id": st.conversation_id, "branch": st.branch})
if isinstance(out, dict):
print("==== Transcript ====")
print(out.get("text", ""))
else:
print(pretty(out))
def cmd_llama(st: State, args):
if not args:
print("usage: \\llama <prompt>")
return
prompt = " ".join(args)
payload = {"prompt": prompt}
try:
r = requests.post(f"{st.llama}/completion", headers={"Content-Type": "application/json"}, data=json.dumps(payload), timeout=60)
r.raise_for_status()
data = r.json()
text = None
if isinstance(data, dict):
if 'completion' in data:
text = data['completion']
elif 'content' in data and isinstance(data['content'], list) and data['content']:
seg = data['content'][0]
if isinstance(seg, dict) and 'text' in seg:
text = seg['text']
print("llama says:", text or pretty(data))
except Exception as e:
print("llama error:", e)
def cmd_who(st: State):
print(pretty(st.as_dict()))
# ---------- REPL ----------
def repl():
st = State()
print("MIND CLI — type \\help for commands. Free text sends /completion.")
while True:
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line.startswith("\\"):
parts = shlex.split(line[1:])
if not parts:
continue
cmd, *args = parts
cmd = cmd.lower()
try:
if cmd == 'help':
cmd_help()
elif cmd == 'set':
cmd_set(st, args)
elif cmd == 'new':
cmd_new(st, args)
elif cmd == 'listconvs':
cmd_listconvs(st, args)
elif cmd == 'listbranches':
cmd_listbranches(st, args)
elif cmd == 'useconv':
cmd_useconv(st, args)
elif cmd == 'branch':
cmd_branch(st, args)
elif cmd == 'usebranch':
cmd_usebranch(st, args)
elif cmd == 'node':
cmd_node(st, args)
elif cmd == 'delbranch':
cmd_delbranch(st, args)
elif cmd == 'delnode':
cmd_delnode(st, args)
elif cmd == 'extract':
cmd_extract(st, args)
elif cmd == 'merge':
cmd_merge(st, args)
elif cmd == 'detach':
cmd_detach(st, args)
elif cmd in ('append-tree', 'appendtree', 'append'):
cmd_append_tree(st, args)
elif cmd == 'linearize':
cmd_linearize(st)
elif cmd == 'llama':
cmd_llama(st, args)
elif cmd == 'who':
cmd_who(st)
elif cmd in ('quit', 'exit', 'q'):
break
else:
print("unknown command, try \\help")
except requests.HTTPError as e:
try:
print("HTTP", e.response.status_code, e.response.text)
except Exception:
print("HTTP error:", e)
except Exception as e:
print("error:", e)
else:
send_prompt(st, line)
if __name__ == '__main__':
repl()

View File

@@ -26,7 +26,7 @@ func main() {
}
repo := db.NewRepo(database)
g := glue.NewGlue(repo)
g := glue.NewGlue(repo, cfg)
r := httpapi.NewRouter(logger, cfg, g)
srv := &http.Server{

View File

@@ -5,19 +5,21 @@ import (
)
type Config struct {
DSN string // e.g. mysql: "user:pass@tcp(127.0.0.1:3306)/mind?parseTime=true"; sqlite: "file:mind.db?_pragma=busy_timeout(5000)"
DSN string
Driver string // "mysql" or "sqlite"
JWTSecret string
Port string // default 8080
Port string
LlamaURL string
}
func getenv(k, def string) string { v := os.Getenv(k); if v == "" { return def }; return v }
func Load() Config {
return Config{
DSN: getenv("DB_DSN", "file:mind.db?_pragma=busy_timeout(5000)"),
Driver: getenv("DB_DRIVER", "sqlite"),
JWTSecret: getenv("JWT_SECRET", "devsecret"),
Port: getenv("PORT", "8080"),
}
return Config{
DSN: getenv("DB_DSN", "file:mind.db?_pragma=busy_timeout(5000)"),
Driver: getenv("DB_DRIVER", "sqlite"),
JWTSecret: getenv("JWT_SECRET", "devsecret"),
Port: getenv("PORT", "8080"),
LlamaURL: getenv("LLAMA_URL", "http://localhost:8081"),
}
}

View File

@@ -86,3 +86,29 @@ func (r *Repo) LatestParent(ctx context.Context, childID int64) (models.Node, bo
}
return n, true, nil
}
func (r *Repo) DeleteBranch(ctx context.Context, convID int64, name string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM branches WHERE conversation_id=? AND name=?`, convID, name)
return err
}
// ListBranches returns all branches for a conversation, newest first.
func (r *Repo) ListBranches(ctx context.Context, convID int64) ([]models.Branch, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, conversation_id, name, head_node_id
FROM branches
WHERE conversation_id=?
ORDER BY id DESC`, convID)
if err != nil { return nil, err }
defer rows.Close()
var out []models.Branch
for rows.Next() {
var b models.Branch
if err := rows.Scan(&b.ID, &b.ConversationID, &b.Name, &b.HeadNodeID); err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}

View File

@@ -5,8 +5,20 @@ import (
"mind/internal/models"
)
type ForkReq struct { ConversationID int64; Name string; HeadNodeID int64 }
type ForkReq struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
HeadNodeID int64 `json:"head_node_id"`
}
func (g *Glue) ForkBranch(ctx context.Context, fr ForkReq) (models.Branch, error) {
return g.repo.CreateOrGetBranch(ctx, fr.ConversationID, fr.Name, fr.HeadNodeID)
}
func (g *Glue) DeleteBranch(ctx context.Context, conversationID int64, name string) error {
return g.repo.DeleteBranch(ctx, conversationID, name)
}
func (g *Glue) ListBranches(ctx context.Context, conversationID int64) ([]models.Branch, error) {
return g.repo.ListBranches(ctx, conversationID)
}

View File

@@ -1,9 +1,34 @@
package glue
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"time"
"mind/internal/config"
"mind/internal/db"
"mind/internal/models"
)
type Glue struct {
repo *db.Repo
cfg config.Config
}
// NewGlue now receives cfg
func NewGlue(r *db.Repo, cfg config.Config) *Glue { return &Glue{repo: r, cfg: cfg} }
func (g *Glue) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
return g.repo.CreateConversation(ctx, ownerID, title)
}
func (g *Glue) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
return g.repo.ListConversations(ctx, ownerID)
}
type CompletionReq struct {
ConversationID int64 `json:"conversation_id"`
BranchName string `json:"branch"`
@@ -11,29 +36,78 @@ type CompletionReq struct {
}
type CompletionResp struct {
PromptNodeID int64 `json:"prompt_node_id"`
AnswerNodeID int64 `json:"answer_node_id"`
Answer string `json:"answer"`
PromptNodeID int64 `json:"prompt_node_id"`
AnswerNodeID int64 `json:"answer_node_id"`
Answer string `json:"answer"`
}
// llama request/response (support two common shapes)
type llamaReq struct {
Prompt string `json:"prompt"`
}
type llamaResp struct {
Content string `json:"content"`
}
func (g *Glue) callLlama(ctx context.Context, prompt string) (string, error) {
if g.cfg.LlamaURL == "" {
return "", errors.New("LLAMA_URL not set")
}
body, _ := json.Marshal(llamaReq{Prompt: prompt})
url := g.cfg.LlamaURL + "/completion"
httpc := &http.Client{Timeout: 45 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
var a llamaResp
if err := dec.Decode(&a); err == nil && a.Content != "" {
return a.Content, nil
}
return "", errors.New("unexpected llama response")
}
// For v0 we stub the answer as a simple echo with a prefix.
func (g *Glue) AppendCompletion(ctx context.Context, req CompletionReq) (CompletionResp, error) {
b, err := g.repo.GetBranch(ctx, req.ConversationID, req.BranchName)
if err != nil { return CompletionResp{}, err }
if err != nil {
return CompletionResp{}, err
}
// 1) create user prompt node
// 1) user prompt node
promptID, err := g.repo.CreateNode(ctx, req.ConversationID, "user", req.Prompt)
if err != nil { return CompletionResp{}, err }
if err := g.repo.Link(ctx, b.HeadNodeID, promptID); err != nil { return CompletionResp{}, err }
if err != nil {
return CompletionResp{}, err
}
if err := g.repo.Link(ctx, b.HeadNodeID, promptID); err != nil {
return CompletionResp{}, err
}
// 2) real llama call (fallback to stub if it fails)
answerText, err := g.callLlama(ctx, req.Prompt)
if err != nil {
answerText = "(stub) You said: " + req.Prompt
}
// 2) create assistant answer node (stub)
answerText := "(stub) You said: " + req.Prompt
answerID, err := g.repo.CreateNode(ctx, req.ConversationID, "assistant", answerText)
if err != nil { return CompletionResp{}, err }
if err := g.repo.Link(ctx, promptID, answerID); err != nil { return CompletionResp{}, err }
if err != nil {
return CompletionResp{}, err
}
if err := g.repo.Link(ctx, promptID, answerID); err != nil {
return CompletionResp{}, err
}
// 3) move branch head
if err := g.repo.MoveBranchHead(ctx, b.ID, answerID); err != nil { return CompletionResp{}, err }
if err := g.repo.MoveBranchHead(ctx, b.ID, answerID); err != nil {
return CompletionResp{}, err
}
return CompletionResp{PromptNodeID: promptID, AnswerNodeID: answerID, Answer: answerText}, nil
}

View File

@@ -1,19 +0,0 @@
package glue
import (
"context"
"mind/internal/db"
"mind/internal/models"
)
type Glue struct { repo *db.Repo }
func NewGlue(r *db.Repo) *Glue { return &Glue{repo: r} }
func (g *Glue) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
return g.repo.CreateConversation(ctx, ownerID, title)
}
func (g *Glue) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
return g.repo.ListConversations(ctx, ownerID)
}

View File

@@ -30,13 +30,51 @@ func (s *server) conversations(w http.ResponseWriter, r *http.Request) {
}
// POST /branches {conversation_id, name, head_node_id}
// GET /branches?conversation_id=...
// DELETE /branches {conversation_id, name}
func (s *server) branches(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { w.WriteHeader(405); return }
var in glue.ForkReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
b, err := s.glue.ForkBranch(r.Context(), in)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, b)
switch r.Method {
case http.MethodPost:
var in struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
HeadNodeID int64 `json:"head_node_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, 400, map[string]string{"error": "bad json"}); return
}
b, err := s.glue.ForkBranch(r.Context(), glue.ForkReq{
ConversationID: in.ConversationID,
Name: in.Name,
HeadNodeID: in.HeadNodeID,
})
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, b)
case http.MethodGet:
qs := r.URL.Query().Get("conversation_id")
if qs == "" { writeJSON(w, 400, map[string]string{"error":"conversation_id required"}); return }
convID, _ := strconv.ParseInt(qs, 10, 64)
out, err := s.glue.ListBranches(r.Context(), convID)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, out)
case http.MethodDelete:
var in struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, 400, map[string]string{"error":"bad json"}); return
}
if err := s.glue.DeleteBranch(r.Context(), in.ConversationID, in.Name); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()}); return
}
writeJSON(w, 200, map[string]string{"ok":"true"})
default:
w.WriteHeader(405)
}
}
// POST /completion {conversation_id, branch, prompt}

Binary file not shown.