memex-graph 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
memex/app.py ADDED
@@ -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()
memex/binaries.py ADDED
@@ -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)
memex/chat.py ADDED
@@ -0,0 +1,150 @@
1
+ """Chat panel widget for Memex."""
2
+
3
+ import json
4
+ from typing import Callable, Awaitable
5
+
6
+ from textual.widgets import RichLog
7
+ from rich.markdown import Markdown
8
+ from rich.text import Text
9
+
10
+ from .provider import ChatProvider, Chunk, ToolCall
11
+ from .tools import get_all_tools, execute_tool
12
+
13
+
14
+ SYSTEM_PROMPT = """You are Memex, an intelligent assistant with access to a knowledge graph (memex) and a decentralized social network (dagit).
15
+
16
+ Available capabilities:
17
+ - Search and query the knowledge graph (memex_search, memex_get_node, memex_traverse, etc.)
18
+ - Create notes and save information (memex_create_node)
19
+ - Post to the dagit social network (dagit_post)
20
+ - Read posts from dagit (dagit_read)
21
+ - Check your identity (dagit_whoami)
22
+
23
+ When users ask questions:
24
+ 1. Use the appropriate tools to find information
25
+ 2. Synthesize the results into a helpful response
26
+ 3. If saving information, confirm what was saved
27
+
28
+ Be concise but helpful. Use the tools proactively when they would help answer the user's question."""
29
+
30
+
31
+ class ChatEngine:
32
+ """Manages chat state and model interactions."""
33
+
34
+ def __init__(self, first_run: bool = False):
35
+ self.provider = ChatProvider()
36
+ self.messages: list[dict] = []
37
+ self.tools = get_all_tools()
38
+ self.first_run = first_run
39
+
40
+ async def send(
41
+ self,
42
+ user_input: str,
43
+ on_text: Callable[[str], Awaitable[None]],
44
+ on_tool: Callable[[str], Awaitable[None]],
45
+ ) -> None:
46
+ """Send a message and stream the response.
47
+
48
+ Args:
49
+ user_input: User's message
50
+ on_text: Callback for text chunks
51
+ on_tool: Callback for tool call notifications
52
+ """
53
+ self.messages.append({"role": "user", "content": user_input})
54
+
55
+ max_turns = 10
56
+ for _ in range(max_turns):
57
+ text_buffer = ""
58
+ tool_calls: list[ToolCall] = []
59
+
60
+ from .onboarding import get_system_prompt
61
+ system_prompt = get_system_prompt(self.first_run)
62
+
63
+ for chunk in self.provider.stream(
64
+ system_prompt, self.messages, self.tools
65
+ ):
66
+ if chunk.type == "text":
67
+ await on_text(chunk.text)
68
+ text_buffer += chunk.text
69
+
70
+ elif chunk.type == "tool_call":
71
+ tool_calls.append(chunk.tool_call)
72
+ await on_tool(f"[{chunk.tool_call.name}]")
73
+
74
+ elif chunk.type == "error":
75
+ await on_text(f"\nError: {chunk.error}")
76
+ return
77
+
78
+ # If there were tool calls, execute them and continue
79
+ if tool_calls:
80
+ # Add assistant message with tool calls
81
+ self.messages.append(
82
+ {
83
+ "role": "assistant",
84
+ "content": text_buffer or None,
85
+ "tool_calls": [
86
+ {
87
+ "id": tc.id,
88
+ "type": "function",
89
+ "function": {
90
+ "name": tc.name,
91
+ "arguments": json.dumps(tc.arguments),
92
+ },
93
+ }
94
+ for tc in tool_calls
95
+ ],
96
+ }
97
+ )
98
+
99
+ # Execute tools and add results
100
+ for tc in tool_calls:
101
+ result = execute_tool(tc.name, tc.arguments)
102
+ self.messages.append(
103
+ {
104
+ "role": "tool",
105
+ "tool_call_id": tc.id,
106
+ "content": result,
107
+ }
108
+ )
109
+ continue
110
+
111
+ # No tool calls - conversation turn complete
112
+ if text_buffer:
113
+ self.messages.append({"role": "assistant", "content": text_buffer})
114
+ return
115
+
116
+ def clear(self) -> None:
117
+ """Clear conversation history."""
118
+ self.messages.clear()
119
+
120
+
121
+ class ChatPanel(RichLog):
122
+ """Chat display panel with rich formatting."""
123
+
124
+ def __init__(self, **kwargs):
125
+ super().__init__(markup=True, wrap=True, **kwargs)
126
+
127
+ def add_user_message(self, text: str) -> None:
128
+ """Add a user message to the display."""
129
+ self.write(Text.from_markup(f"[bold cyan]You:[/bold cyan] {text}"))
130
+
131
+ def add_assistant_text(self, text: str) -> None:
132
+ """Add assistant text (streaming)."""
133
+ # For streaming, we append to the current line
134
+ self.write(text, scroll_end=True)
135
+
136
+ def start_assistant_response(self) -> None:
137
+ """Start a new assistant response line."""
138
+ self.write(Text.from_markup("[bold green]Memex:[/bold green] "), scroll_end=True)
139
+
140
+ def add_tool_indicator(self, tool_name: str) -> None:
141
+ """Show a tool being called."""
142
+ self.write(Text.from_markup(f"[dim]{tool_name}[/dim]"))
143
+
144
+ def add_error(self, error: str) -> None:
145
+ """Display an error message."""
146
+ self.write(Text.from_markup(f"[bold red]Error:[/bold red] {error}"))
147
+
148
+ def add_system_message(self, text: str) -> None:
149
+ """Display a system message."""
150
+ self.write(Text.from_markup(f"[dim italic]{text}[/dim italic]"))
memex/onboarding.py ADDED
@@ -0,0 +1,23 @@
1
+ """First-run onboarding prompt for new users."""
2
+
3
+ from .chat import SYSTEM_PROMPT
4
+
5
+ ONBOARDING_ADDENDUM = """
6
+
7
+ IMPORTANT: This is the user's first time using memex. Follow these steps:
8
+
9
+ 1. Start by calling `dagit_whoami` to look up the user's decentralized identity (DID).
10
+ 2. Welcome the user and show them their DID.
11
+ 3. Explain memex in 2 sentences: it's a personal knowledge graph where you can save thoughts, notes, and connections — paired with a decentralized social network (dagit) where you can publish and share using your cryptographic identity.
12
+ 4. Ask the user for their first thought, idea, or note they'd like to save.
13
+ 5. When they provide one, save it using `memex_create_node` with type "Note".
14
+ 6. After saving, suggest they post an introduction to dagit using `dagit_post` — something like "Hello from memex! My first note: ..."
15
+
16
+ Be warm, concise, and encouraging. This is their first experience — make it count."""
17
+
18
+
19
+ def get_system_prompt(first_run: bool) -> str:
20
+ """Return system prompt, with onboarding addendum if first run."""
21
+ if first_run:
22
+ return SYSTEM_PROMPT + ONBOARDING_ADDENDUM
23
+ return SYSTEM_PROMPT