memex-graph 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.
- memex_graph-0.1.0/LICENSE +29 -0
- memex_graph-0.1.0/PKG-INFO +16 -0
- memex_graph-0.1.0/README.md +72 -0
- memex_graph-0.1.0/memex/__init__.py +14 -0
- memex_graph-0.1.0/memex/app.py +181 -0
- memex_graph-0.1.0/memex/binaries.py +236 -0
- memex_graph-0.1.0/memex/chat.py +150 -0
- memex_graph-0.1.0/memex/onboarding.py +23 -0
- memex_graph-0.1.0/memex/provider.py +130 -0
- memex_graph-0.1.0/memex/services.py +79 -0
- memex_graph-0.1.0/memex/stack.py +236 -0
- memex_graph-0.1.0/memex/tools.py +383 -0
- memex_graph-0.1.0/memex_graph.egg-info/PKG-INFO +16 -0
- memex_graph-0.1.0/memex_graph.egg-info/SOURCES.txt +18 -0
- memex_graph-0.1.0/memex_graph.egg-info/dependency_links.txt +1 -0
- memex_graph-0.1.0/memex_graph.egg-info/entry_points.txt +3 -0
- memex_graph-0.1.0/memex_graph.egg-info/requires.txt +6 -0
- memex_graph-0.1.0/memex_graph.egg-info/top_level.txt +1 -0
- memex_graph-0.1.0/pyproject.toml +26 -0
- memex_graph-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Memex Contributors
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memex-graph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-native knowledge graph with decentralized social networking
|
|
5
|
+
Project-URL: Homepage, https://github.com/systemshift/memex
|
|
6
|
+
Project-URL: Repository, https://github.com/systemshift/memex
|
|
7
|
+
Project-URL: Issues, https://github.com/systemshift/memex/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: textual>=0.50
|
|
11
|
+
Requires-Dist: openai>=1.0
|
|
12
|
+
Requires-Dist: httpx>=0.25
|
|
13
|
+
Requires-Dist: rich>=13.0
|
|
14
|
+
Requires-Dist: python-dotenv>=1.0
|
|
15
|
+
Requires-Dist: pydagit>=0.1.0
|
|
16
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Memex
|
|
2
|
+
|
|
3
|
+
AI-native knowledge graph + decentralized social network.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install memex
|
|
9
|
+
memex-stack
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What happens
|
|
13
|
+
|
|
14
|
+
- Downloads knowledge graph server + IPFS automatically
|
|
15
|
+
- Creates your cryptographic identity (Ed25519 DID)
|
|
16
|
+
- Guides you through your first interaction
|
|
17
|
+
|
|
18
|
+
## What is this
|
|
19
|
+
|
|
20
|
+
- **Knowledge graph** ([memex-server](https://github.com/systemshift/memex-server)): entities, relationships, raw sources — stored locally in SQLite
|
|
21
|
+
- **Social network** ([dagit](https://github.com/systemshift/dagit)): IPFS-based, Ed25519-signed, no central servers
|
|
22
|
+
- **LLM** (OpenAI): searches your graph, creates nodes, posts to dagit — all through natural language chat
|
|
23
|
+
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
- Python 3.10+
|
|
27
|
+
- `OPENAI_API_KEY` environment variable set
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export OPENAI_API_KEY=sk-...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Architecture
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
┌─────────────────────────────────────┐
|
|
37
|
+
│ memex TUI │
|
|
38
|
+
│ (textual chat UI) │
|
|
39
|
+
├─────────────┬───────────────────────┤
|
|
40
|
+
│ OpenAI API │ function calls │
|
|
41
|
+
├─────────────┼───────────┬───────────┤
|
|
42
|
+
│ memex-server│ dagit │ IPFS │
|
|
43
|
+
│ (SQLite) │ (Ed25519)│ (kubo) │
|
|
44
|
+
└─────────────┴───────────┴───────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
| Variable | Default | Description |
|
|
50
|
+
|----------|---------|-------------|
|
|
51
|
+
| `OPENAI_API_KEY` | (required) | OpenAI API key |
|
|
52
|
+
| `PORT` | `8080` | memex-server port |
|
|
53
|
+
| `MEMEX_BACKEND` | `sqlite` | Storage backend (`sqlite` or `neo4j`) |
|
|
54
|
+
| `SQLITE_PATH` | `~/.memex/memex.db` | Database path |
|
|
55
|
+
| `MEMEX_SERVER` | (auto-detect) | Path to memex-server binary |
|
|
56
|
+
|
|
57
|
+
## CLI Flags
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
memex-stack [options]
|
|
61
|
+
|
|
62
|
+
--server-only Start server without TUI
|
|
63
|
+
--port PORT Server port (default: 8080)
|
|
64
|
+
--backend TYPE sqlite or neo4j (default: sqlite)
|
|
65
|
+
--db-path PATH SQLite database path
|
|
66
|
+
--skip-ipfs Skip IPFS daemon setup
|
|
67
|
+
--skip-download Don't auto-download binaries
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
BSD 3-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Memex - AI-native knowledge graph with decentralized social networking."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .app import MemexApp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
"""Entry point for memex command."""
|
|
10
|
+
app = MemexApp()
|
|
11
|
+
app.run()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["main", "MemexApp"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Memex Application."""
|
|
2
|
+
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.widgets import Input, Footer, Header
|
|
5
|
+
from textual.containers import Container
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
|
|
8
|
+
from .chat import ChatPanel, ChatEngine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemexApp(App):
|
|
12
|
+
"""Interactive interface for memex-server and dagit."""
|
|
13
|
+
|
|
14
|
+
CSS = """
|
|
15
|
+
Screen {
|
|
16
|
+
layout: grid;
|
|
17
|
+
grid-size: 1;
|
|
18
|
+
grid-rows: 1fr auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#chat-container {
|
|
22
|
+
height: 100%;
|
|
23
|
+
border: solid $primary;
|
|
24
|
+
padding: 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#chat-log {
|
|
28
|
+
height: 100%;
|
|
29
|
+
scrollbar-gutter: stable;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#input-container {
|
|
33
|
+
height: auto;
|
|
34
|
+
padding: 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#input {
|
|
38
|
+
dock: bottom;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Footer {
|
|
42
|
+
height: auto;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
BINDINGS = [
|
|
47
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
48
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
49
|
+
Binding("escape", "focus_input", "Focus Input", show=False),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
TITLE = "Memex"
|
|
53
|
+
|
|
54
|
+
def __init__(self, first_run: bool = False):
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.first_run = first_run
|
|
57
|
+
self.chat_engine = ChatEngine(first_run=first_run)
|
|
58
|
+
self._current_response = ""
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
"""Create the UI layout."""
|
|
62
|
+
yield Header()
|
|
63
|
+
with Container(id="chat-container"):
|
|
64
|
+
yield ChatPanel(id="chat-log")
|
|
65
|
+
with Container(id="input-container"):
|
|
66
|
+
yield Input(placeholder="Ask anything... (Ctrl+C to quit)", id="input")
|
|
67
|
+
yield Footer()
|
|
68
|
+
|
|
69
|
+
def on_mount(self) -> None:
|
|
70
|
+
"""Focus input on start."""
|
|
71
|
+
self.query_one("#input", Input).focus()
|
|
72
|
+
chat = self.query_one("#chat-log", ChatPanel)
|
|
73
|
+
|
|
74
|
+
if self.first_run:
|
|
75
|
+
chat.add_system_message("Welcome to Memex — setting things up...")
|
|
76
|
+
self.run_worker(self._auto_greet())
|
|
77
|
+
else:
|
|
78
|
+
chat.add_system_message(
|
|
79
|
+
"Welcome to Memex. Ask questions about your knowledge graph or dagit network."
|
|
80
|
+
)
|
|
81
|
+
chat.add_system_message('Type "help" for commands, Ctrl+C to quit.')
|
|
82
|
+
|
|
83
|
+
async def _auto_greet(self) -> None:
|
|
84
|
+
"""Send initial greeting on first run so the LLM speaks first."""
|
|
85
|
+
chat = self.query_one("#chat-log", ChatPanel)
|
|
86
|
+
chat.start_assistant_response()
|
|
87
|
+
self._current_response = ""
|
|
88
|
+
|
|
89
|
+
async def on_text(text: str) -> None:
|
|
90
|
+
self._current_response += text
|
|
91
|
+
chat.add_assistant_text(text)
|
|
92
|
+
|
|
93
|
+
async def on_tool(tool_name: str) -> None:
|
|
94
|
+
chat.add_tool_indicator(tool_name)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
await self.chat_engine.send(
|
|
98
|
+
"I just installed memex. Help me get started.", on_text, on_tool
|
|
99
|
+
)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
chat.add_error(str(e))
|
|
102
|
+
|
|
103
|
+
if self._current_response and not self._current_response.endswith("\n"):
|
|
104
|
+
chat.write("")
|
|
105
|
+
|
|
106
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
107
|
+
"""Handle user input."""
|
|
108
|
+
user_input = event.value.strip()
|
|
109
|
+
if not user_input:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Clear input
|
|
113
|
+
input_widget = self.query_one("#input", Input)
|
|
114
|
+
input_widget.value = ""
|
|
115
|
+
|
|
116
|
+
chat = self.query_one("#chat-log", ChatPanel)
|
|
117
|
+
|
|
118
|
+
# Handle special commands
|
|
119
|
+
if user_input.lower() in ("exit", "quit"):
|
|
120
|
+
self.exit()
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if user_input.lower() == "clear":
|
|
124
|
+
self.action_clear()
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if user_input.lower() == "help":
|
|
128
|
+
self._show_help(chat)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Show user message
|
|
132
|
+
chat.add_user_message(user_input)
|
|
133
|
+
|
|
134
|
+
# Start assistant response
|
|
135
|
+
chat.start_assistant_response()
|
|
136
|
+
self._current_response = ""
|
|
137
|
+
|
|
138
|
+
# Stream response
|
|
139
|
+
async def on_text(text: str) -> None:
|
|
140
|
+
self._current_response += text
|
|
141
|
+
chat.add_assistant_text(text)
|
|
142
|
+
|
|
143
|
+
async def on_tool(tool_name: str) -> None:
|
|
144
|
+
chat.add_tool_indicator(tool_name)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
await self.chat_engine.send(user_input, on_text, on_tool)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
chat.add_error(str(e))
|
|
150
|
+
|
|
151
|
+
# Ensure we end on a new line
|
|
152
|
+
if self._current_response and not self._current_response.endswith("\n"):
|
|
153
|
+
chat.write("")
|
|
154
|
+
|
|
155
|
+
def _show_help(self, chat: ChatPanel) -> None:
|
|
156
|
+
"""Show help message."""
|
|
157
|
+
chat.add_system_message("Commands:")
|
|
158
|
+
chat.add_system_message(" help - Show this help")
|
|
159
|
+
chat.add_system_message(" clear - Clear chat history")
|
|
160
|
+
chat.add_system_message(" exit - Quit the application")
|
|
161
|
+
chat.add_system_message("")
|
|
162
|
+
chat.add_system_message("Examples:")
|
|
163
|
+
chat.add_system_message(' "search for notes about topic"')
|
|
164
|
+
chat.add_system_message(' "what\'s my dagit identity"')
|
|
165
|
+
chat.add_system_message(' "save this as a note: <your content>"')
|
|
166
|
+
chat.add_system_message(' "post to dagit: <your message>"')
|
|
167
|
+
|
|
168
|
+
def action_clear(self) -> None:
|
|
169
|
+
"""Clear chat history."""
|
|
170
|
+
self.chat_engine.clear()
|
|
171
|
+
chat = self.query_one("#chat-log", ChatPanel)
|
|
172
|
+
chat.clear()
|
|
173
|
+
chat.add_system_message("Chat cleared.")
|
|
174
|
+
|
|
175
|
+
def action_focus_input(self) -> None:
|
|
176
|
+
"""Focus the input field."""
|
|
177
|
+
self.query_one("#input", Input).focus()
|
|
178
|
+
|
|
179
|
+
def action_quit(self) -> None:
|
|
180
|
+
"""Quit the application."""
|
|
181
|
+
self.exit()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Auto-download and cache external binaries in ~/.memex/bin/."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tarfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
BIN_DIR = Path.home() / ".memex" / "bin"
|
|
14
|
+
|
|
15
|
+
MEMEX_SERVER_VERSION = "v0.1.0"
|
|
16
|
+
KUBO_VERSION = "v0.32.1"
|
|
17
|
+
|
|
18
|
+
MEMEX_SERVER_REPO = "systemshift/memex-server"
|
|
19
|
+
KUBO_REPO = "ipfs/kubo"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_platform() -> tuple[str, str]:
|
|
23
|
+
"""Detect OS and architecture.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
(os_name, arch) normalized for download URLs.
|
|
27
|
+
"""
|
|
28
|
+
system = platform.system()
|
|
29
|
+
machine = platform.machine().lower()
|
|
30
|
+
|
|
31
|
+
if system == "Linux":
|
|
32
|
+
os_name = "linux"
|
|
33
|
+
elif system == "Darwin":
|
|
34
|
+
os_name = "darwin"
|
|
35
|
+
else:
|
|
36
|
+
raise RuntimeError(f"Unsupported OS: {system}. Only Linux and macOS are supported.")
|
|
37
|
+
|
|
38
|
+
if machine in ("x86_64", "amd64"):
|
|
39
|
+
arch = "amd64"
|
|
40
|
+
elif machine in ("aarch64", "arm64"):
|
|
41
|
+
arch = "arm64"
|
|
42
|
+
else:
|
|
43
|
+
raise RuntimeError(f"Unsupported architecture: {machine}. Only x86_64 and arm64 are supported.")
|
|
44
|
+
|
|
45
|
+
return os_name, arch
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _download_file(url: str, dest: Path, label: str) -> None:
|
|
49
|
+
"""Download a file with progress output.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
url: URL to download from.
|
|
53
|
+
dest: Destination path.
|
|
54
|
+
label: Human-readable label for progress output.
|
|
55
|
+
"""
|
|
56
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
print(f" Downloading {label}...")
|
|
58
|
+
print(f" {url}")
|
|
59
|
+
|
|
60
|
+
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as resp:
|
|
61
|
+
if resp.status_code == 404:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"Download not found (404): {url}\n"
|
|
64
|
+
f" The release may not be published yet."
|
|
65
|
+
)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
|
|
68
|
+
total = int(resp.headers.get("content-length", 0))
|
|
69
|
+
downloaded = 0
|
|
70
|
+
|
|
71
|
+
with open(dest, "wb") as f:
|
|
72
|
+
for chunk in resp.iter_bytes(chunk_size=65536):
|
|
73
|
+
f.write(chunk)
|
|
74
|
+
downloaded += len(chunk)
|
|
75
|
+
if total:
|
|
76
|
+
pct = downloaded * 100 // total
|
|
77
|
+
mb = downloaded / (1024 * 1024)
|
|
78
|
+
total_mb = total / (1024 * 1024)
|
|
79
|
+
print(f"\r {mb:.1f}/{total_mb:.1f} MB ({pct}%)", end="", flush=True)
|
|
80
|
+
|
|
81
|
+
if total:
|
|
82
|
+
print() # newline after progress
|
|
83
|
+
print(f" Downloaded {label}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_binary(archive: Path, binary_name: str, dest: Path) -> None:
|
|
87
|
+
"""Extract a single binary from an archive.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
archive: Path to tar.gz or zip archive.
|
|
91
|
+
binary_name: Path of the binary within the archive (e.g. "kubo/ipfs").
|
|
92
|
+
dest: Destination path for the extracted binary.
|
|
93
|
+
"""
|
|
94
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
if archive.name.endswith(".tar.gz") or archive.name.endswith(".tgz"):
|
|
97
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
98
|
+
# Look for the binary in the archive
|
|
99
|
+
for member in tf.getmembers():
|
|
100
|
+
if member.name == binary_name or member.name.endswith(f"/{binary_name}"):
|
|
101
|
+
# Extract to temp location then move
|
|
102
|
+
member.name = dest.name
|
|
103
|
+
tf.extract(member, dest.parent)
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
# Try just the basename
|
|
107
|
+
basename = Path(binary_name).name
|
|
108
|
+
for member in tf.getmembers():
|
|
109
|
+
if Path(member.name).name == basename:
|
|
110
|
+
member.name = dest.name
|
|
111
|
+
tf.extract(member, dest.parent)
|
|
112
|
+
break
|
|
113
|
+
else:
|
|
114
|
+
names = [m.name for m in tf.getmembers()]
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"Binary '{binary_name}' not found in archive. Contents: {names}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
elif archive.name.endswith(".zip"):
|
|
120
|
+
with zipfile.ZipFile(archive) as zf:
|
|
121
|
+
for name in zf.namelist():
|
|
122
|
+
if name == binary_name or name.endswith(f"/{binary_name}"):
|
|
123
|
+
data = zf.read(name)
|
|
124
|
+
dest.write_bytes(data)
|
|
125
|
+
break
|
|
126
|
+
else:
|
|
127
|
+
basename = Path(binary_name).name
|
|
128
|
+
for name in zf.namelist():
|
|
129
|
+
if Path(name).name == basename:
|
|
130
|
+
data = zf.read(name)
|
|
131
|
+
dest.write_bytes(data)
|
|
132
|
+
break
|
|
133
|
+
else:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
f"Binary '{binary_name}' not found in archive. Contents: {zf.namelist()}"
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
raise RuntimeError(f"Unknown archive format: {archive.name}")
|
|
139
|
+
|
|
140
|
+
# Make executable
|
|
141
|
+
dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
142
|
+
|
|
143
|
+
# Clean up archive
|
|
144
|
+
archive.unlink()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def ensure_memex_server() -> str:
|
|
148
|
+
"""Ensure memex-server binary is available.
|
|
149
|
+
|
|
150
|
+
Checks in order:
|
|
151
|
+
1. MEMEX_SERVER environment variable
|
|
152
|
+
2. System PATH
|
|
153
|
+
3. ~/.memex/bin/ cache
|
|
154
|
+
4. Downloads from GitHub releases
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Path to the memex-server binary.
|
|
158
|
+
"""
|
|
159
|
+
# Check env var
|
|
160
|
+
if server := os.environ.get("MEMEX_SERVER"):
|
|
161
|
+
if Path(server).is_file():
|
|
162
|
+
return server
|
|
163
|
+
|
|
164
|
+
# Check PATH
|
|
165
|
+
if path_bin := shutil.which("memex-server"):
|
|
166
|
+
return path_bin
|
|
167
|
+
|
|
168
|
+
# Check cache
|
|
169
|
+
cached = BIN_DIR / "memex-server"
|
|
170
|
+
if cached.is_file():
|
|
171
|
+
return str(cached)
|
|
172
|
+
|
|
173
|
+
# Download
|
|
174
|
+
print("\033[0;32m[memex]\033[0m memex-server not found, downloading...")
|
|
175
|
+
try:
|
|
176
|
+
os_name, arch = _detect_platform()
|
|
177
|
+
|
|
178
|
+
# memex-server uses Linux/Darwin and x86_64/arm64
|
|
179
|
+
os_label = "Linux" if os_name == "linux" else "Darwin"
|
|
180
|
+
arch_label = "x86_64" if arch == "amd64" else "arm64"
|
|
181
|
+
|
|
182
|
+
filename = f"memex_{os_label}_{arch_label}.tar.gz"
|
|
183
|
+
url = f"https://github.com/{MEMEX_SERVER_REPO}/releases/download/{MEMEX_SERVER_VERSION}/{filename}"
|
|
184
|
+
|
|
185
|
+
archive = BIN_DIR / filename
|
|
186
|
+
_download_file(url, archive, "memex-server")
|
|
187
|
+
_extract_binary(archive, "memex-server", cached)
|
|
188
|
+
|
|
189
|
+
print(f"\033[0;32m[memex]\033[0m memex-server installed to {cached}")
|
|
190
|
+
return str(cached)
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"\033[0;31m[memex]\033[0m Failed to download memex-server: {e}")
|
|
194
|
+
print(f"\033[0;31m[memex]\033[0m Install manually from: https://github.com/{MEMEX_SERVER_REPO}/releases")
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def ensure_ipfs() -> str:
|
|
199
|
+
"""Ensure IPFS (kubo) binary is available.
|
|
200
|
+
|
|
201
|
+
Checks in order:
|
|
202
|
+
1. System PATH
|
|
203
|
+
2. ~/.memex/bin/ cache
|
|
204
|
+
3. Downloads from GitHub releases
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Path to the ipfs binary.
|
|
208
|
+
"""
|
|
209
|
+
# Check PATH
|
|
210
|
+
if path_bin := shutil.which("ipfs"):
|
|
211
|
+
return path_bin
|
|
212
|
+
|
|
213
|
+
# Check cache
|
|
214
|
+
cached = BIN_DIR / "ipfs"
|
|
215
|
+
if cached.is_file():
|
|
216
|
+
return str(cached)
|
|
217
|
+
|
|
218
|
+
# Download
|
|
219
|
+
print("\033[0;32m[memex]\033[0m IPFS not found, downloading kubo...")
|
|
220
|
+
try:
|
|
221
|
+
os_name, arch = _detect_platform()
|
|
222
|
+
|
|
223
|
+
filename = f"kubo_{KUBO_VERSION}_{os_name}-{arch}.tar.gz"
|
|
224
|
+
url = f"https://github.com/{KUBO_REPO}/releases/download/{KUBO_VERSION}/{filename}"
|
|
225
|
+
|
|
226
|
+
archive = BIN_DIR / filename
|
|
227
|
+
_download_file(url, archive, "IPFS (kubo)")
|
|
228
|
+
_extract_binary(archive, "kubo/ipfs", cached)
|
|
229
|
+
|
|
230
|
+
print(f"\033[0;32m[memex]\033[0m IPFS installed to {cached}")
|
|
231
|
+
return str(cached)
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"\033[0;31m[memex]\033[0m Failed to download IPFS: {e}")
|
|
235
|
+
print(f"\033[0;31m[memex]\033[0m Install manually from: https://docs.ipfs.tech/install/")
|
|
236
|
+
raise SystemExit(1)
|