recall-cli 0.1.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.
- recall_cli-0.1.0/.env.example +12 -0
- recall_cli-0.1.0/.gitignore +9 -0
- recall_cli-0.1.0/LICENSE +21 -0
- recall_cli-0.1.0/Makefile +40 -0
- recall_cli-0.1.0/PKG-INFO +144 -0
- recall_cli-0.1.0/README.md +113 -0
- recall_cli-0.1.0/cli/recall_cli.py +308 -0
- recall_cli-0.1.0/docker-compose.yml +12 -0
- recall_cli-0.1.0/install.sh +75 -0
- recall_cli-0.1.0/memory_api/__init__.py +0 -0
- recall_cli-0.1.0/memory_api/auth.py +16 -0
- recall_cli-0.1.0/memory_api/config.py +36 -0
- recall_cli-0.1.0/memory_api/embeddings.py +94 -0
- recall_cli-0.1.0/memory_api/main.py +310 -0
- recall_cli-0.1.0/memory_api/models.py +134 -0
- recall_cli-0.1.0/memory_api/store.py +513 -0
- recall_cli-0.1.0/pyproject.toml +60 -0
- recall_cli-0.1.0/tests/__init__.py +0 -0
- recall_cli-0.1.0/tests/test_degraded.py +99 -0
- recall_cli-0.1.0/tests/test_integration.py +339 -0
- recall_cli-0.1.0/tests/test_unit.py +195 -0
- recall_cli-0.1.0/uv.lock +1237 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
QDRANT_HOST=localhost
|
|
2
|
+
QDRANT_PORT=6333
|
|
3
|
+
COLLECTION_NAME=memories
|
|
4
|
+
OLLAMA_BASE_URL=http://localhost:11434
|
|
5
|
+
EMBED_MODEL=nomic-embed-text
|
|
6
|
+
OLLAMA_EMBED_PATH=/api/embed
|
|
7
|
+
API_HOST=127.0.0.1
|
|
8
|
+
API_PORT=8100
|
|
9
|
+
API_AUTH_TOKEN=
|
|
10
|
+
MAX_TEXT_LENGTH=8000
|
|
11
|
+
MAX_BATCH_SIZE=100
|
|
12
|
+
HEALTH_CHECK_TIMEOUT_S=5
|
recall_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anel Canto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
.PHONY: up down serve dev test test-integration test-degraded test-all status install
|
|
2
|
+
|
|
3
|
+
up:
|
|
4
|
+
docker run -d --name qdrant \
|
|
5
|
+
-p 127.0.0.1:6333:6333 \
|
|
6
|
+
-p 127.0.0.1:6334:6334 \
|
|
7
|
+
-v qdrant_data:/qdrant/storage \
|
|
8
|
+
qdrant/qdrant:v1.13.2 || docker start qdrant
|
|
9
|
+
|
|
10
|
+
down:
|
|
11
|
+
docker stop qdrant && docker rm qdrant
|
|
12
|
+
|
|
13
|
+
serve:
|
|
14
|
+
uv run uvicorn memory_api.main:app --host 127.0.0.1 --port 8100
|
|
15
|
+
|
|
16
|
+
dev:
|
|
17
|
+
$(MAKE) up && $(MAKE) serve
|
|
18
|
+
|
|
19
|
+
test:
|
|
20
|
+
uv run pytest tests/test_unit.py -v
|
|
21
|
+
|
|
22
|
+
test-integration:
|
|
23
|
+
uv run pytest tests/test_integration.py -v
|
|
24
|
+
|
|
25
|
+
test-degraded:
|
|
26
|
+
uv run pytest tests/test_degraded.py -v
|
|
27
|
+
|
|
28
|
+
test-all:
|
|
29
|
+
uv run pytest tests/ -v
|
|
30
|
+
|
|
31
|
+
status:
|
|
32
|
+
@curl -sf http://127.0.0.1:8100/health | python3 -m json.tool || echo "API not running"
|
|
33
|
+
|
|
34
|
+
install:
|
|
35
|
+
@mkdir -p ~/.memories
|
|
36
|
+
@test -f ~/.memories/.env || cp .env.example ~/.memories/.env
|
|
37
|
+
uv sync
|
|
38
|
+
@echo ""
|
|
39
|
+
@echo "Installed. Run 'make dev' to start Qdrant + API server."
|
|
40
|
+
@echo "CLI available via: uv run recall --help"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: recall-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A personal semantic memory system — store, search, and manage memories locally with vector embeddings
|
|
5
|
+
Project-URL: Homepage, https://github.com/anelcanto/recall
|
|
6
|
+
Project-URL: Repository, https://github.com/anelcanto/recall
|
|
7
|
+
Project-URL: Issues, https://github.com/anelcanto/recall/issues
|
|
8
|
+
Author-email: Anel Canto <anelcanto@users.noreply.github.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: fastapi
|
|
23
|
+
Requires-Dist: httpx
|
|
24
|
+
Requires-Dist: pydantic-settings
|
|
25
|
+
Requires-Dist: python-dotenv
|
|
26
|
+
Requires-Dist: qdrant-client
|
|
27
|
+
Requires-Dist: rich
|
|
28
|
+
Requires-Dist: typer
|
|
29
|
+
Requires-Dist: uvicorn[standard]
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# recall
|
|
33
|
+
|
|
34
|
+
A personal semantic memory system. Store, search, and manage memories locally using vector embeddings.
|
|
35
|
+
|
|
36
|
+
Everything runs on your machine: FastAPI server, Qdrant vector database (Docker), and Ollama for local embeddings. Zero cost, full privacy.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
### From PyPI
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install recall-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### From Homebrew
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
brew tap anelcanto/recall-cli
|
|
50
|
+
brew install recall-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### From source
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/anelcanto/recall.git
|
|
57
|
+
cd recall
|
|
58
|
+
./install.sh
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or the quick version:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
make install
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Prerequisites
|
|
68
|
+
|
|
69
|
+
- **Docker** — runs Qdrant vector database
|
|
70
|
+
- **Ollama** — local embeddings (`brew install ollama && ollama pull nomic-embed-text`)
|
|
71
|
+
- **uv** — Python package manager (`brew install uv`)
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Start services
|
|
77
|
+
make up # Start Qdrant in Docker
|
|
78
|
+
make serve # Start API server (keep this running)
|
|
79
|
+
|
|
80
|
+
# In another terminal
|
|
81
|
+
recall add "The quick brown fox" --tag test
|
|
82
|
+
recall search "fox"
|
|
83
|
+
recall list
|
|
84
|
+
recall status
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## CLI
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
recall add "text" --tag work --source cli [--dedupe-key "..."]
|
|
91
|
+
recall search "query" --top-k 10 [--no-text] [--output table|json]
|
|
92
|
+
recall ingest <file> [--format lines|jsonl] [--source name] [--auto-dedupe]
|
|
93
|
+
recall list [--limit 20] [--cursor ...] [--output table|json]
|
|
94
|
+
recall delete <id>
|
|
95
|
+
recall status
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Environment variables
|
|
99
|
+
|
|
100
|
+
| Variable | Default | Description |
|
|
101
|
+
|----------|---------|-------------|
|
|
102
|
+
| `RECALL_API_URL` | `http://127.0.0.1:8100` | API server URL |
|
|
103
|
+
| `RECALL_API_TOKEN` | (none) | Bearer token for auth |
|
|
104
|
+
|
|
105
|
+
## Architecture
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
recall CLI --> FastAPI server (:8100) --> Qdrant (Docker :6333)
|
|
109
|
+
|
|
|
110
|
+
v
|
|
111
|
+
Ollama (:11434)
|
|
112
|
+
nomic-embed-text
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **FastAPI** serves the HTTP API
|
|
116
|
+
- **Qdrant** stores vectors and payloads
|
|
117
|
+
- **Ollama** generates embeddings locally using `nomic-embed-text`
|
|
118
|
+
- **CLI** talks to the API over HTTP
|
|
119
|
+
|
|
120
|
+
User config lives in `~/.memories/.env`. Qdrant data persists in a Docker volume.
|
|
121
|
+
|
|
122
|
+
## API endpoints
|
|
123
|
+
|
|
124
|
+
| Method | Path | Description |
|
|
125
|
+
|--------|------|-------------|
|
|
126
|
+
| `POST` | `/memory` | Store a memory |
|
|
127
|
+
| `POST` | `/search` | Semantic search |
|
|
128
|
+
| `POST` | `/ingest` | Batch import |
|
|
129
|
+
| `GET` | `/memories` | List with pagination |
|
|
130
|
+
| `DELETE` | `/memory/{id}` | Delete a memory |
|
|
131
|
+
| `GET` | `/health` | Service health check |
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
make test # Unit tests (no services needed)
|
|
137
|
+
make test-integration # Integration tests (Qdrant + Ollama required)
|
|
138
|
+
make test-degraded # Degraded mode tests (Qdrant only)
|
|
139
|
+
make test-all # All tests
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# recall
|
|
2
|
+
|
|
3
|
+
A personal semantic memory system. Store, search, and manage memories locally using vector embeddings.
|
|
4
|
+
|
|
5
|
+
Everything runs on your machine: FastAPI server, Qdrant vector database (Docker), and Ollama for local embeddings. Zero cost, full privacy.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### From PyPI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install recall-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### From Homebrew
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
brew tap anelcanto/recall-cli
|
|
19
|
+
brew install recall-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### From source
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/anelcanto/recall.git
|
|
26
|
+
cd recall
|
|
27
|
+
./install.sh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or the quick version:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
make install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Prerequisites
|
|
37
|
+
|
|
38
|
+
- **Docker** — runs Qdrant vector database
|
|
39
|
+
- **Ollama** — local embeddings (`brew install ollama && ollama pull nomic-embed-text`)
|
|
40
|
+
- **uv** — Python package manager (`brew install uv`)
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Start services
|
|
46
|
+
make up # Start Qdrant in Docker
|
|
47
|
+
make serve # Start API server (keep this running)
|
|
48
|
+
|
|
49
|
+
# In another terminal
|
|
50
|
+
recall add "The quick brown fox" --tag test
|
|
51
|
+
recall search "fox"
|
|
52
|
+
recall list
|
|
53
|
+
recall status
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
recall add "text" --tag work --source cli [--dedupe-key "..."]
|
|
60
|
+
recall search "query" --top-k 10 [--no-text] [--output table|json]
|
|
61
|
+
recall ingest <file> [--format lines|jsonl] [--source name] [--auto-dedupe]
|
|
62
|
+
recall list [--limit 20] [--cursor ...] [--output table|json]
|
|
63
|
+
recall delete <id>
|
|
64
|
+
recall status
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Environment variables
|
|
68
|
+
|
|
69
|
+
| Variable | Default | Description |
|
|
70
|
+
|----------|---------|-------------|
|
|
71
|
+
| `RECALL_API_URL` | `http://127.0.0.1:8100` | API server URL |
|
|
72
|
+
| `RECALL_API_TOKEN` | (none) | Bearer token for auth |
|
|
73
|
+
|
|
74
|
+
## Architecture
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
recall CLI --> FastAPI server (:8100) --> Qdrant (Docker :6333)
|
|
78
|
+
|
|
|
79
|
+
v
|
|
80
|
+
Ollama (:11434)
|
|
81
|
+
nomic-embed-text
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **FastAPI** serves the HTTP API
|
|
85
|
+
- **Qdrant** stores vectors and payloads
|
|
86
|
+
- **Ollama** generates embeddings locally using `nomic-embed-text`
|
|
87
|
+
- **CLI** talks to the API over HTTP
|
|
88
|
+
|
|
89
|
+
User config lives in `~/.memories/.env`. Qdrant data persists in a Docker volume.
|
|
90
|
+
|
|
91
|
+
## API endpoints
|
|
92
|
+
|
|
93
|
+
| Method | Path | Description |
|
|
94
|
+
|--------|------|-------------|
|
|
95
|
+
| `POST` | `/memory` | Store a memory |
|
|
96
|
+
| `POST` | `/search` | Semantic search |
|
|
97
|
+
| `POST` | `/ingest` | Batch import |
|
|
98
|
+
| `GET` | `/memories` | List with pagination |
|
|
99
|
+
| `DELETE` | `/memory/{id}` | Delete a memory |
|
|
100
|
+
| `GET` | `/health` | Service health check |
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
make test # Unit tests (no services needed)
|
|
106
|
+
make test-integration # Integration tests (Qdrant + Ollama required)
|
|
107
|
+
make test-degraded # Degraded mode tests (Qdrant only)
|
|
108
|
+
make test-all # All tests
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="recall", help="Personal semantic memory CLI")
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
DEFAULT_API_URL = "http://127.0.0.1:8100"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_api_url(api_url: Optional[str]) -> str:
|
|
23
|
+
return api_url or os.environ.get("RECALL_API_URL", DEFAULT_API_URL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_token(token: Optional[str]) -> Optional[str]:
|
|
27
|
+
return token or os.environ.get("RECALL_API_TOKEN") or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_headers(token: Optional[str]) -> dict:
|
|
31
|
+
h = {"Content-Type": "application/json"}
|
|
32
|
+
if token:
|
|
33
|
+
h["Authorization"] = f"Bearer {token}"
|
|
34
|
+
return h
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _handle_error(resp: httpx.Response, api_url: str) -> None:
|
|
38
|
+
if resp.status_code in (200, 201):
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
data = resp.json()
|
|
42
|
+
detail = data.get("detail", resp.text)
|
|
43
|
+
except Exception:
|
|
44
|
+
detail = resp.text
|
|
45
|
+
console.print(f"[red]Error {resp.status_code}:[/red] {detail}")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _connection_error(url: str) -> None:
|
|
50
|
+
console.print(
|
|
51
|
+
f"[red]Cannot reach recall service at {url}.[/red]\n"
|
|
52
|
+
f"Try: [bold]make serve[/bold] (from your recall project directory)"
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_tty() -> bool:
|
|
58
|
+
return sys.stdout.isatty()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def add(
|
|
63
|
+
text: str = typer.Argument(..., help="The memory text to store"),
|
|
64
|
+
tag: list[str] = typer.Option([], "--tag", "-t", help="Tag(s) to attach"),
|
|
65
|
+
source: str = typer.Option("cli", "--source", "-s", help="Source identifier"),
|
|
66
|
+
dedupe_key: Optional[str] = typer.Option(None, "--dedupe-key", "-d", help="Deduplication key"),
|
|
67
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
68
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
69
|
+
):
|
|
70
|
+
"""Store a memory."""
|
|
71
|
+
url = _get_api_url(api_url)
|
|
72
|
+
tok = _get_token(token)
|
|
73
|
+
payload = {"text": text, "tags": list(tag), "source": source, "dedupe_key": dedupe_key}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
resp = httpx.post(f"{url}/memory", json=payload, headers=_build_headers(tok), timeout=30)
|
|
77
|
+
except httpx.RequestError:
|
|
78
|
+
_connection_error(url)
|
|
79
|
+
|
|
80
|
+
_handle_error(resp, url)
|
|
81
|
+
data = resp.json()
|
|
82
|
+
console.print(f"[green]Stored[/green] id=[bold]{data['id']}[/bold] strategy={data['id_strategy']}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def search(
|
|
87
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
88
|
+
top_k: int = typer.Option(5, "--top-k", "-k"),
|
|
89
|
+
no_text: bool = typer.Option(False, "--no-text"),
|
|
90
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="table|json"),
|
|
91
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
92
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
93
|
+
):
|
|
94
|
+
"""Search memories by semantic similarity."""
|
|
95
|
+
url = _get_api_url(api_url)
|
|
96
|
+
tok = _get_token(token)
|
|
97
|
+
fmt = output or ("table" if _is_tty() else "json")
|
|
98
|
+
payload = {"query": query, "top_k": top_k, "include_text": not no_text}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
resp = httpx.post(f"{url}/search", json=payload, headers=_build_headers(tok), timeout=30)
|
|
102
|
+
except httpx.RequestError:
|
|
103
|
+
_connection_error(url)
|
|
104
|
+
|
|
105
|
+
_handle_error(resp, url)
|
|
106
|
+
results = resp.json().get("results", [])
|
|
107
|
+
|
|
108
|
+
if fmt == "json":
|
|
109
|
+
print(json.dumps(results, indent=2))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if not results:
|
|
113
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
table = Table(title=f"Search: {query!r}")
|
|
117
|
+
table.add_column("Score", style="cyan", width=6)
|
|
118
|
+
table.add_column("ID", style="dim", width=36)
|
|
119
|
+
table.add_column("Tags")
|
|
120
|
+
table.add_column("Written At")
|
|
121
|
+
if not no_text:
|
|
122
|
+
table.add_column("Text")
|
|
123
|
+
|
|
124
|
+
for r in results:
|
|
125
|
+
row = [
|
|
126
|
+
f"{r['score']:.3f}",
|
|
127
|
+
r["id"],
|
|
128
|
+
", ".join(r.get("tags", [])),
|
|
129
|
+
r.get("written_at", "")[:19],
|
|
130
|
+
]
|
|
131
|
+
if not no_text:
|
|
132
|
+
row.append((r.get("text") or "")[:80])
|
|
133
|
+
table.add_row(*row)
|
|
134
|
+
|
|
135
|
+
console.print(table)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def ingest(
|
|
140
|
+
file: Path = typer.Argument(..., help="File to ingest"),
|
|
141
|
+
format: str = typer.Option("lines", "--format", "-f", help="lines|jsonl"),
|
|
142
|
+
source: str = typer.Option("ingest", "--source", "-s"),
|
|
143
|
+
auto_dedupe: bool = typer.Option(False, "--auto-dedupe"),
|
|
144
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
145
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
146
|
+
):
|
|
147
|
+
"""Ingest memories from a file."""
|
|
148
|
+
url = _get_api_url(api_url)
|
|
149
|
+
tok = _get_token(token)
|
|
150
|
+
|
|
151
|
+
if not file.exists():
|
|
152
|
+
console.print(f"[red]File not found:[/red] {file}")
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
|
|
155
|
+
raw = file.read_text(encoding="utf-8")
|
|
156
|
+
items: list[dict] = []
|
|
157
|
+
|
|
158
|
+
if format == "jsonl":
|
|
159
|
+
for line in raw.splitlines():
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
obj = json.loads(line)
|
|
164
|
+
items.append(obj)
|
|
165
|
+
else:
|
|
166
|
+
for line in raw.splitlines():
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
items.append({"text": line, "tags": [], "source": source})
|
|
171
|
+
|
|
172
|
+
if auto_dedupe:
|
|
173
|
+
for item in items:
|
|
174
|
+
src = item.get("source", source)
|
|
175
|
+
item["dedupe_key"] = hashlib.sha256(
|
|
176
|
+
(item["text"] + src).encode()
|
|
177
|
+
).hexdigest()
|
|
178
|
+
|
|
179
|
+
batch_size = 100
|
|
180
|
+
total_succeeded = 0
|
|
181
|
+
total_failed = 0
|
|
182
|
+
all_errors: list[dict] = []
|
|
183
|
+
|
|
184
|
+
for i in range(0, len(items), batch_size):
|
|
185
|
+
batch = items[i : i + batch_size]
|
|
186
|
+
try:
|
|
187
|
+
resp = httpx.post(
|
|
188
|
+
f"{url}/ingest",
|
|
189
|
+
json={"items": batch},
|
|
190
|
+
headers=_build_headers(tok),
|
|
191
|
+
timeout=60,
|
|
192
|
+
)
|
|
193
|
+
except httpx.RequestError:
|
|
194
|
+
_connection_error(url)
|
|
195
|
+
|
|
196
|
+
_handle_error(resp, url)
|
|
197
|
+
data = resp.json()
|
|
198
|
+
total_succeeded += data.get("succeeded", 0)
|
|
199
|
+
total_failed += data.get("failed", 0)
|
|
200
|
+
for err in data.get("errors", []):
|
|
201
|
+
err["index"] += i
|
|
202
|
+
all_errors.append(err)
|
|
203
|
+
|
|
204
|
+
console.print(
|
|
205
|
+
f"[green]Ingested[/green] {total_succeeded} succeeded, "
|
|
206
|
+
f"[{'red' if total_failed else 'green'}]{total_failed}[/{'red' if total_failed else 'green'}] failed"
|
|
207
|
+
)
|
|
208
|
+
for err in all_errors:
|
|
209
|
+
console.print(f" [red]Error[/red] item {err['index']}: {err['error']}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command(name="list")
|
|
213
|
+
def list_memories(
|
|
214
|
+
limit: int = typer.Option(20, "--limit", "-l"),
|
|
215
|
+
cursor: Optional[str] = typer.Option(None, "--cursor"),
|
|
216
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="table|json"),
|
|
217
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
218
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
219
|
+
):
|
|
220
|
+
"""List stored memories."""
|
|
221
|
+
url = _get_api_url(api_url)
|
|
222
|
+
tok = _get_token(token)
|
|
223
|
+
fmt = output or ("table" if _is_tty() else "json")
|
|
224
|
+
|
|
225
|
+
params: dict = {"limit": limit}
|
|
226
|
+
if cursor:
|
|
227
|
+
params["cursor"] = cursor
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
resp = httpx.get(f"{url}/memories", params=params, headers=_build_headers(tok), timeout=30)
|
|
231
|
+
except httpx.RequestError:
|
|
232
|
+
_connection_error(url)
|
|
233
|
+
|
|
234
|
+
_handle_error(resp, url)
|
|
235
|
+
data = resp.json()
|
|
236
|
+
memories = data.get("memories", [])
|
|
237
|
+
next_cursor = data.get("next_cursor")
|
|
238
|
+
|
|
239
|
+
if fmt == "json":
|
|
240
|
+
print(json.dumps(data, indent=2))
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if not memories:
|
|
244
|
+
console.print("[yellow]No memories found.[/yellow]")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
table = Table(title="Memories")
|
|
248
|
+
table.add_column("ID", style="dim", width=36)
|
|
249
|
+
table.add_column("Tags")
|
|
250
|
+
table.add_column("Source")
|
|
251
|
+
table.add_column("Written At")
|
|
252
|
+
table.add_column("Text")
|
|
253
|
+
|
|
254
|
+
for m in memories:
|
|
255
|
+
table.add_row(
|
|
256
|
+
m["id"],
|
|
257
|
+
", ".join(m.get("tags", [])),
|
|
258
|
+
m.get("source", ""),
|
|
259
|
+
m.get("written_at", "")[:19],
|
|
260
|
+
(m.get("text") or "")[:60],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
console.print(table)
|
|
264
|
+
if next_cursor:
|
|
265
|
+
console.print(f"\n[dim]Next cursor:[/dim] {next_cursor}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@app.command()
|
|
269
|
+
def delete(
|
|
270
|
+
id: str = typer.Argument(..., help="Memory ID to delete"),
|
|
271
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
272
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
273
|
+
):
|
|
274
|
+
"""Delete a memory by ID."""
|
|
275
|
+
url = _get_api_url(api_url)
|
|
276
|
+
tok = _get_token(token)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
resp = httpx.delete(f"{url}/memory/{id}", headers=_build_headers(tok), timeout=30)
|
|
280
|
+
except httpx.RequestError:
|
|
281
|
+
_connection_error(url)
|
|
282
|
+
|
|
283
|
+
_handle_error(resp, url)
|
|
284
|
+
console.print(f"[green]Deleted[/green] {id}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command()
|
|
288
|
+
def status(
|
|
289
|
+
api_url: Optional[str] = typer.Option(None, "--api-url"),
|
|
290
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
291
|
+
):
|
|
292
|
+
"""Show API health status."""
|
|
293
|
+
url = _get_api_url(api_url)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
resp = httpx.get(f"{url}/health", timeout=10)
|
|
297
|
+
except httpx.RequestError:
|
|
298
|
+
_connection_error(url)
|
|
299
|
+
|
|
300
|
+
data = resp.json()
|
|
301
|
+
color = "green" if data.get("status") == "ok" else "yellow" if data.get("status") == "degraded" else "red"
|
|
302
|
+
console.print(f"[{color}]Status:[/{color}] {data.get('status')}")
|
|
303
|
+
console.print(f" Qdrant: {data.get('qdrant')}")
|
|
304
|
+
console.print(f" Ollama: {data.get('ollama')}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
app()
|