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.
- {memorybot-0.1.0 → memorybot-0.2.0}/PKG-INFO +1 -1
- {memorybot-0.1.0 → memorybot-0.2.0}/pyproject.toml +1 -1
- {memorybot-0.1.0 → memorybot-0.2.0}/src/memorybot/__init__.py +1 -1
- {memorybot-0.1.0 → memorybot-0.2.0}/src/memorybot/cli.py +100 -28
- memorybot-0.2.0/src/memorybot/client.py +61 -0
- memorybot-0.1.0/src/memorybot/client.py +0 -37
- {memorybot-0.1.0 → memorybot-0.2.0}/.github/workflows/publish.yml +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/.gitignore +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/LICENSE +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/README.md +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/src/memorybot/__main__.py +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/src/memorybot/auth.py +0 -0
- {memorybot-0.1.0 → memorybot-0.2.0}/src/memorybot/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memorybot
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
+
op["tag_sids"] = [s.strip() for s in tag_sid.split(",") if s.strip()]
|
|
132
149
|
|
|
133
150
|
try:
|
|
134
|
-
|
|
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(
|
|
162
|
+
typer.echo(json_module.dumps(inner, indent=2))
|
|
141
163
|
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|