diff --git a/backend/cli.py b/backend/cli.py new file mode 100755 index 0000000..79ff515 --- /dev/null +++ b/backend/cli.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +MIND CLI (v0) — supports Ops 1–8 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 <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() diff --git a/backend/cmd/main.go b/backend/cmd/main.go index a913128..6fd6dac 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -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{ diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6647377..747a161 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), + } } diff --git a/backend/internal/db/repo.go b/backend/internal/db/repo.go index 3a5a712..7842cb8 100644 --- a/backend/internal/db/repo.go +++ b/backend/internal/db/repo.go @@ -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 +} diff --git a/backend/internal/glue/branches.go b/backend/internal/glue/branches.go index 759385e..08ebc6b 100644 --- a/backend/internal/glue/branches.go +++ b/backend/internal/glue/branches.go @@ -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) +} diff --git a/backend/internal/glue/completion.go b/backend/internal/glue/completion.go index 95dc19c..04c263c 100644 --- a/backend/internal/glue/completion.go +++ b/backend/internal/glue/completion.go @@ -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 } diff --git a/backend/internal/glue/conversations.go b/backend/internal/glue/conversations.go deleted file mode 100644 index 74f9c21..0000000 --- a/backend/internal/glue/conversations.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 4e5441f..6bc02d0 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -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} diff --git a/backend/mind.db b/backend/mind.db deleted file mode 100644 index ee85de9..0000000 Binary files a/backend/mind.db and /dev/null differ