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 +14 -0
- memex/app.py +181 -0
- memex/binaries.py +236 -0
- memex/chat.py +150 -0
- memex/onboarding.py +23 -0
- memex/provider.py +130 -0
- memex/services.py +79 -0
- memex/stack.py +236 -0
- memex/tools.py +383 -0
- memex_graph-0.1.0.dist-info/METADATA +16 -0
- memex_graph-0.1.0.dist-info/RECORD +15 -0
- memex_graph-0.1.0.dist-info/WHEEL +5 -0
- memex_graph-0.1.0.dist-info/entry_points.txt +3 -0
- memex_graph-0.1.0.dist-info/licenses/LICENSE +29 -0
- memex_graph-0.1.0.dist-info/top_level.txt +1 -0
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
|