memorybot 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorybot
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: MemoryBot CLI — your personal knowledge graph from the command line
5
5
  Project-URL: Homepage, https://www.memorybot.com
6
6
  Project-URL: Repository, https://github.com/nolanlove/memorybot-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memorybot"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "MemoryBot CLI — your personal knowledge graph from the command line"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """MemoryBot CLI."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -1,10 +1,10 @@
1
- """MemoryBot CLI — Typer app."""
1
+ """MemoryBot CLI — Typer app. All commands route through tool-exec."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json as json_module
6
6
  import time
7
- from typing import Optional
7
+ from typing import Any, Optional
8
8
 
9
9
  import typer
10
10
  from rich.console import Console
@@ -12,7 +12,7 @@ from rich.table import Table
12
12
 
13
13
  from . import __version__
14
14
  from .auth import fetch_user_email, login_flow
15
- from .client import APIError, Client
15
+ from .client import APIError, Client, ToolError
16
16
  from .config import Config, config_path, resolve_server_url
17
17
 
18
18
  app = typer.Typer(
@@ -43,6 +43,24 @@ def _root(
43
43
  pass
44
44
 
45
45
 
46
+ def _client(base_url: Optional[str]) -> Client:
47
+ cfg = Config.load()
48
+ server_url = resolve_server_url(base_url, cfg)
49
+ return Client(cfg, server_url)
50
+
51
+
52
+ def _unwrap_single_op(result: dict) -> dict:
53
+ """manage_* responses come wrapped as {results: [op_result]}.
54
+
55
+ Single-op CLI commands want the inner result directly.
56
+ """
57
+ if isinstance(result, dict) and "results" in result and isinstance(result["results"], list):
58
+ items = result["results"]
59
+ if len(items) == 1:
60
+ return items[0]
61
+ return result
62
+
63
+
46
64
  @app.command()
47
65
  def login(
48
66
  base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
@@ -97,7 +115,7 @@ def whoami() -> None:
97
115
  typer.echo(who)
98
116
 
99
117
 
100
- def _print_memo_table(memos: list[dict]) -> None:
118
+ def _print_memos_table(memos: list[dict]) -> None:
101
119
  if not memos:
102
120
  err_console.print("[dim](no results)[/dim]")
103
121
  return
@@ -107,7 +125,11 @@ def _print_memo_table(memos: list[dict]) -> None:
107
125
  table.add_column("Tags", style="magenta")
108
126
  for m in memos:
109
127
  title = m.get("title") or m.get("structured_data", {}).get("memo", {}).get("title") or "(untitled)"
110
- tags = ", ".join(t.get("name", "") for t in m.get("tags", []) if t.get("name"))
128
+ tag_field = m.get("tags") or []
129
+ if tag_field and isinstance(tag_field[0], dict):
130
+ tags = ", ".join(t.get("name", "") for t in tag_field if t.get("name"))
131
+ else:
132
+ tags = ", ".join(str(t) for t in tag_field)
111
133
  table.add_row(m.get("sid", ""), title, tags)
112
134
  console.print(table)
113
135
 
@@ -115,35 +137,39 @@ def _print_memo_table(memos: list[dict]) -> None:
115
137
  @memo_app.command("search")
116
138
  def memo_search(
117
139
  query: str = typer.Argument(..., help="Search query."),
118
- limit: int = typer.Option(20, "--limit", "-n", help="Max results (1-100)."),
119
- mode: str = typer.Option("combined", "--mode", help="combined | fts | trigram | semantic."),
140
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results."),
120
141
  tag_sid: Optional[str] = typer.Option(None, "--tag-sid", help="Filter under tag sid(s), comma-separated."),
121
142
  json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
122
143
  base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
123
144
  ) -> None:
124
- """Search memos."""
125
- cfg = Config.load()
126
- server_url = resolve_server_url(base_url, cfg)
127
- client = Client(cfg, server_url)
128
-
129
- params: dict[str, object] = {"q": query, "limit": limit, "mode": mode}
145
+ """Search memos via manage_memos action=search."""
146
+ op: dict[str, Any] = {"action": "search", "query": query, "limit": limit}
130
147
  if tag_sid:
131
- params["tag_sids"] = tag_sid
148
+ op["tag_sids"] = [s.strip() for s in tag_sid.split(",") if s.strip()]
132
149
 
133
150
  try:
134
- data = client.get("/memory/api/memos/search", params=params)
151
+ result = _client(base_url).tool_exec("manage_memos", {"operations": [op]})
135
152
  except APIError as e:
136
153
  err_console.print(f"[red]API error:[/red] {e}")
137
154
  raise typer.Exit(code=1)
155
+ except ToolError as e:
156
+ err_console.print(f"[red]Tool error:[/red] {e.message}")
157
+ raise typer.Exit(code=1)
158
+
159
+ inner = _unwrap_single_op(result)
138
160
 
139
161
  if json:
140
- typer.echo(json_module.dumps(data, indent=2))
162
+ typer.echo(json_module.dumps(inner, indent=2))
141
163
  return
142
- _print_memo_table(data.get("memos", []))
143
- err_console.print(
144
- f"[dim]{data.get('count', 0)} of {data.get('total_count', 0)} results "
145
- f"(mode: {data.get('mode', mode)})[/dim]"
146
- )
164
+
165
+ memos = inner.get("memos") if isinstance(inner, dict) else None
166
+ if memos is None and isinstance(inner, list):
167
+ memos = inner
168
+ _print_memos_table(memos or [])
169
+ if isinstance(inner, dict):
170
+ count = inner.get("count", len(memos or []))
171
+ total = inner.get("total_count", count)
172
+ err_console.print(f"[dim]{count} of {total} results[/dim]")
147
173
 
148
174
 
149
175
  @memo_app.command("get")
@@ -152,18 +178,20 @@ def memo_get(
152
178
  json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
153
179
  base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
154
180
  ) -> None:
155
- """Fetch a single memo by sid."""
156
- cfg = Config.load()
157
- server_url = resolve_server_url(base_url, cfg)
158
- client = Client(cfg, server_url)
181
+ """Fetch a single memo via manage_memos action=get."""
182
+ op = {"action": "get", "memo_sids": [sid], "full": True}
159
183
 
160
184
  try:
161
- data = client.get("/memory/api/memos/list", params={"sids": sid})
185
+ result = _client(base_url).tool_exec("manage_memos", {"operations": [op]})
162
186
  except APIError as e:
163
187
  err_console.print(f"[red]API error:[/red] {e}")
164
188
  raise typer.Exit(code=1)
189
+ except ToolError as e:
190
+ err_console.print(f"[red]Tool error:[/red] {e.message}")
191
+ raise typer.Exit(code=1)
165
192
 
166
- memos = data.get("memos", [])
193
+ inner = _unwrap_single_op(result)
194
+ memos = inner.get("memos") if isinstance(inner, dict) else (inner if isinstance(inner, list) else [])
167
195
  if not memos:
168
196
  err_console.print(f"[red]No memo found with sid {sid}.[/red]")
169
197
  raise typer.Exit(code=1)
@@ -176,7 +204,11 @@ def memo_get(
176
204
  sd = memo.get("structured_data", {}) or {}
177
205
  title = memo.get("title") or sd.get("memo", {}).get("title") or "(untitled)"
178
206
  body = sd.get("memo", {}).get("content", "")
179
- tags = ", ".join(t.get("name", "") for t in memo.get("tags", []) if t.get("name"))
207
+ tag_field = memo.get("tags") or []
208
+ if tag_field and isinstance(tag_field[0], dict):
209
+ tags = ", ".join(t.get("name", "") for t in tag_field if t.get("name"))
210
+ else:
211
+ tags = ", ".join(str(t) for t in tag_field)
180
212
 
181
213
  console.print(f"[bold cyan]{memo.get('sid', '')}[/bold cyan] [bold]{title}[/bold]")
182
214
  if tags:
@@ -186,6 +218,46 @@ def memo_get(
186
218
  console.print(body)
187
219
 
188
220
 
221
+ @app.command("query")
222
+ def query_cmd(
223
+ sql: str = typer.Argument(..., help="A read-only SELECT against the v_* views."),
224
+ json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
225
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
226
+ ) -> None:
227
+ """Run a read-only SQL query against the user's data (run_query tool)."""
228
+ try:
229
+ result = _client(base_url).tool_exec("run_query", {"sql": sql})
230
+ except APIError as e:
231
+ err_console.print(f"[red]API error:[/red] {e}")
232
+ raise typer.Exit(code=1)
233
+ except ToolError as e:
234
+ err_console.print(f"[red]Query error:[/red] {e.message}")
235
+ raise typer.Exit(code=1)
236
+
237
+ if json:
238
+ typer.echo(json_module.dumps(result, indent=2))
239
+ return
240
+
241
+ columns = result.get("columns", [])
242
+ rows = result.get("rows", [])
243
+ if not rows:
244
+ err_console.print("[dim](no rows)[/dim]")
245
+ return
246
+
247
+ table = Table(show_lines=False)
248
+ for col in columns:
249
+ table.add_column(col, overflow="fold")
250
+ for row in rows:
251
+ if isinstance(row, dict):
252
+ table.add_row(*[str(row.get(c, "")) for c in columns])
253
+ else:
254
+ table.add_row(*[str(v) for v in row])
255
+ console.print(table)
256
+
257
+ suffix = " (truncated at 200)" if result.get("truncated") else ""
258
+ err_console.print(f"[dim]{result.get('row_count', len(rows))} rows{suffix}[/dim]")
259
+
260
+
189
261
  def main() -> int:
190
262
  app()
191
263
  return 0
@@ -0,0 +1,61 @@
1
+ """Authenticated HTTP client for the MemoryBot tool-exec endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .auth import refresh_access_token
10
+ from .config import Config
11
+
12
+ TOOL_EXEC_PATH = "/memory/api/tool-exec/"
13
+
14
+
15
+ class APIError(RuntimeError):
16
+ def __init__(self, status: int, body: str) -> None:
17
+ super().__init__(f"HTTP {status}: {body}")
18
+ self.status = status
19
+ self.body = body
20
+
21
+
22
+ class ToolError(RuntimeError):
23
+ """Raised when the server returns 200 with a {'error': ...} body."""
24
+
25
+ def __init__(self, message: str) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+
29
+
30
+ class Client:
31
+ def __init__(self, cfg: Config, server_url: str) -> None:
32
+ self.cfg = cfg
33
+ self.server_url = server_url
34
+
35
+ def _headers(self) -> dict[str, str]:
36
+ if not self.cfg.access_token:
37
+ raise RuntimeError("Not logged in. Run `mb login`.")
38
+ return {
39
+ "Authorization": f"Bearer {self.cfg.access_token}",
40
+ "Content-Type": "application/json",
41
+ }
42
+
43
+ def tool_exec(self, tool: str, arguments: dict[str, Any]) -> dict:
44
+ """Call POST /api/tool-exec/ with {tool, arguments}. Returns parsed JSON.
45
+
46
+ Raises APIError on HTTP error, ToolError if the response body has
47
+ {'error': ...} (the executor's own validation/errors).
48
+ """
49
+ url = f"{self.server_url}{TOOL_EXEC_PATH}"
50
+ body = {"tool": tool, "arguments": arguments}
51
+
52
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=60.0)
53
+ if resp.status_code == 401 and refresh_access_token(self.cfg, self.server_url):
54
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=60.0)
55
+ if resp.status_code >= 400:
56
+ raise APIError(resp.status_code, resp.text)
57
+
58
+ data = resp.json()
59
+ if isinstance(data, dict) and "error" in data and len(data) == 1:
60
+ raise ToolError(data["error"])
61
+ return data
@@ -1,37 +0,0 @@
1
- """Authenticated HTTP client with auto-refresh on 401."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any, Optional
6
-
7
- import httpx
8
-
9
- from .auth import refresh_access_token
10
- from .config import Config
11
-
12
-
13
- class APIError(RuntimeError):
14
- def __init__(self, status: int, body: str) -> None:
15
- super().__init__(f"HTTP {status}: {body}")
16
- self.status = status
17
- self.body = body
18
-
19
-
20
- class Client:
21
- def __init__(self, cfg: Config, server_url: str) -> None:
22
- self.cfg = cfg
23
- self.server_url = server_url
24
-
25
- def _headers(self) -> dict[str, str]:
26
- if not self.cfg.access_token:
27
- raise RuntimeError("Not logged in. Run `mb login`.")
28
- return {"Authorization": f"Bearer {self.cfg.access_token}"}
29
-
30
- def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict:
31
- url = f"{self.server_url}{path}"
32
- resp = httpx.get(url, headers=self._headers(), params=params, timeout=30.0)
33
- if resp.status_code == 401 and refresh_access_token(self.cfg, self.server_url):
34
- resp = httpx.get(url, headers=self._headers(), params=params, timeout=30.0)
35
- if resp.status_code >= 400:
36
- raise APIError(resp.status_code, resp.text)
37
- return resp.json()
File without changes
File without changes
File without changes