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.
@@ -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)