bluesilk 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.
- bluesilk-0.1.0/.gitignore +3 -0
- bluesilk-0.1.0/LICENSE +21 -0
- bluesilk-0.1.0/PKG-INFO +31 -0
- bluesilk-0.1.0/README.md +21 -0
- bluesilk-0.1.0/bluesilk.py +485 -0
- bluesilk-0.1.0/pyproject.toml +24 -0
- bluesilk-0.1.0/test_bluesilk.py +149 -0
bluesilk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wunsiangcheng
|
|
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.
|
bluesilk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bluesilk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, minimal personal AI agent: DeepSeek + Telegram, zero dependencies.
|
|
5
|
+
Project-URL: Repository, https://github.com/wunsiang-cheng/bluesilk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# bluesilk
|
|
12
|
+
|
|
13
|
+
A fast, minimal personal AI agent. DeepSeek + Telegram, one file, zero dependencies.
|
|
14
|
+
|
|
15
|
+
1 key + 1 bot token + 1 user ID and you're good to go:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv tool install bluesilk # or: pipx install bluesilk
|
|
19
|
+
bluesilk # first run asks for the three values, then starts
|
|
20
|
+
bluesilk setup # redo setup
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Tools:** `shell` (unrestricted) and `send` (text/files to Telegram).
|
|
24
|
+
- **Commands:** `/new` starts a new conversation; `/reset` returns to a fresh install (keys kept, old data moved to `~/.bluesilk.bak-<time>`).
|
|
25
|
+
- **Images:** photos you send go straight to deepseek-flash vision.
|
|
26
|
+
- **Feedback:** 👀 on receipt, a live status draft with a stop button, then the reply.
|
|
27
|
+
- **Memory:** plain files in `~/.bluesilk/` (`MEMORY.md`, `memory/`, `skills/`, `tools/`), managed by the agent itself.
|
|
28
|
+
- **Context:** compacted into a summary past 50% of the 1M window.
|
|
29
|
+
- **Reflection:** after 24h, once idle for 30 min, it reviews history, tidies memory and writes new skills/tools.
|
|
30
|
+
|
|
31
|
+
Keep it running with `tmux` or `nohup bluesilk &`.
|
bluesilk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# bluesilk
|
|
2
|
+
|
|
3
|
+
A fast, minimal personal AI agent. DeepSeek + Telegram, one file, zero dependencies.
|
|
4
|
+
|
|
5
|
+
1 key + 1 bot token + 1 user ID and you're good to go:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv tool install bluesilk # or: pipx install bluesilk
|
|
9
|
+
bluesilk # first run asks for the three values, then starts
|
|
10
|
+
bluesilk setup # redo setup
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- **Tools:** `shell` (unrestricted) and `send` (text/files to Telegram).
|
|
14
|
+
- **Commands:** `/new` starts a new conversation; `/reset` returns to a fresh install (keys kept, old data moved to `~/.bluesilk.bak-<time>`).
|
|
15
|
+
- **Images:** photos you send go straight to deepseek-flash vision.
|
|
16
|
+
- **Feedback:** 👀 on receipt, a live status draft with a stop button, then the reply.
|
|
17
|
+
- **Memory:** plain files in `~/.bluesilk/` (`MEMORY.md`, `memory/`, `skills/`, `tools/`), managed by the agent itself.
|
|
18
|
+
- **Context:** compacted into a summary past 50% of the 1M window.
|
|
19
|
+
- **Reflection:** after 24h, once idle for 30 min, it reviews history, tidies memory and writes new skills/tools.
|
|
20
|
+
|
|
21
|
+
Keep it running with `tmux` or `nohup bluesilk &`.
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""bluesilk: a fast, minimal personal AI agent. DeepSeek + Telegram, stdlib only."""
|
|
2
|
+
import base64, datetime, getpass, json, os, platform, queue, shutil, signal, subprocess, sys, threading, time, uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.error import HTTPError
|
|
5
|
+
from urllib.request import Request, urlopen
|
|
6
|
+
|
|
7
|
+
MODEL = "deepseek-flash"
|
|
8
|
+
API = "https://api.deepseek.com"
|
|
9
|
+
COMPACT_AT = 500_000 # 50% of deepseek-flash's 1M context
|
|
10
|
+
SHELL_TIMEOUT = 600
|
|
11
|
+
CLIP = 20_000 # long tool output keeps this many chars of head and tail
|
|
12
|
+
DREAM_EVERY = 24 * 3600
|
|
13
|
+
DREAM_IDLE = 30 * 60
|
|
14
|
+
IMAGE_BUDGET = 20 * 2**20 # base64 chars of images kept in context; the API caps a request at 48 MiB
|
|
15
|
+
IMAGE_SIGS = ((b"\xff\xd8\xff", "image/jpeg"), (b"\x89PNG", "image/png"), (b"GIF8", "image/gif")) # WebP: see as_image
|
|
16
|
+
|
|
17
|
+
HOME = Path(os.environ.get("BLUESILK_HOME", Path.home() / ".bluesilk"))
|
|
18
|
+
CONFIG, STATE_FILE, HISTORY = HOME / "config.json", HOME / "state.json", HOME / "history.jsonl"
|
|
19
|
+
|
|
20
|
+
CFG, STATE = {}, {}
|
|
21
|
+
Q = queue.Queue() # (text, message_id) for chat turns, None for a dream
|
|
22
|
+
STOP, BUSY = threading.Event(), threading.Event()
|
|
23
|
+
DRAFT = {"id": 0, "text": ""}
|
|
24
|
+
LAST = {"active": time.time(), "prompt_tokens": 0}
|
|
25
|
+
|
|
26
|
+
TOOLS = [
|
|
27
|
+
{"type": "function", "function": {
|
|
28
|
+
"name": "shell",
|
|
29
|
+
"description": "Run a bash command on the host. Unrestricted, no confirmation needed. stdin is closed; stdout+stderr "
|
|
30
|
+
"are merged and clipped to head/tail. Background processes must redirect output: `cmd > log 2>&1 &`.",
|
|
31
|
+
"parameters": {"type": "object", "properties": {
|
|
32
|
+
"command": {"type": "string"},
|
|
33
|
+
"timeout": {"type": "integer", "description": f"seconds, default {SHELL_TIMEOUT}"}},
|
|
34
|
+
"required": ["command"]}}},
|
|
35
|
+
{"type": "function", "function": {
|
|
36
|
+
"name": "send",
|
|
37
|
+
"description": "Send the user a Telegram message and/or file right now (progress updates, files, proactive notices). "
|
|
38
|
+
"Your final reply is delivered automatically; don't duplicate it here.",
|
|
39
|
+
"parameters": {"type": "object", "properties": {
|
|
40
|
+
"text": {"type": "string"},
|
|
41
|
+
"file": {"type": "string", "description": "path of a file to send"}}}}},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
SYSTEM = """You are bluesilk, a personal AI agent chatting with its owner through Telegram. Be fast, direct and concise. Reply in the user's language.
|
|
45
|
+
Environment: {os} as user {user}; shell cwd is {cwd}. Session started {now}.
|
|
46
|
+
|
|
47
|
+
Tools: `shell` (unrestricted, never ask for permission, just do it) and `send`. Your final reply is delivered to Telegram automatically.
|
|
48
|
+
Telegram Markdown only: *bold*, _italic_, `code`, ```block```. No headings, no tables.
|
|
49
|
+
|
|
50
|
+
Your home is {home}:
|
|
51
|
+
- MEMORY.md: core memory, included below. Keep it short.
|
|
52
|
+
- memory/*.md: detailed memory by topic. grep/cat when relevant.
|
|
53
|
+
- skills/*.md: how-tos. Read the matching skill before doing that kind of task.
|
|
54
|
+
- tools/: scripts you wrote. Run them with shell.
|
|
55
|
+
- inbox/: files the user sent you. Images (JPEG/PNG/GIF/WebP) are also attached to the message, so you see them directly.
|
|
56
|
+
- history.jsonl: the full conversation log.
|
|
57
|
+
|
|
58
|
+
Memory is your job and fully automatic: whenever you learn something durable (preferences, facts about the user, environment, projects, decisions, lessons from mistakes), write it to MEMORY.md or memory/<topic>.md right away, without asking or announcing it. Fix or delete entries that turn out wrong.
|
|
59
|
+
|
|
60
|
+
## Skills
|
|
61
|
+
{skills}
|
|
62
|
+
|
|
63
|
+
## Tools
|
|
64
|
+
{tools}
|
|
65
|
+
|
|
66
|
+
## MEMORY.md
|
|
67
|
+
{memory}"""
|
|
68
|
+
|
|
69
|
+
REFLECT = """# reflect: review recent history, consolidate memory, turn repeated work into skills and tools
|
|
70
|
+
Use when asked to reflect or dream, and on the periodic trigger.
|
|
71
|
+
|
|
72
|
+
1. Read the new part of history.jsonl (the trigger gives the byte range; if it is big, read it in chunks).
|
|
73
|
+
2. Consolidate memory:
|
|
74
|
+
- Add durable facts that were missed: preferences, facts about the user, environment, projects, decisions, lessons.
|
|
75
|
+
- Merge duplicates, resolve contradictions (newer wins), delete stale entries.
|
|
76
|
+
- Keep MEMORY.md under 100 lines: only what every conversation needs. Move details to memory/<topic>.md and leave a one-line pointer.
|
|
77
|
+
3. Find repeated work: the same kind of task done twice or more, or a procedure that went wrong before.
|
|
78
|
+
- Judgment or procedure -> skills/<name>.md, first line `# <name>: <when to use it>`.
|
|
79
|
+
- Deterministic steps -> executable tools/<name>, second line `# <what it does, usage>`. chmod +x it and run it once to verify.
|
|
80
|
+
- Fix or delete skills and tools that are wrong or unused.
|
|
81
|
+
4. If anything substantive changed, send the user a short summary. Otherwise stay silent.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
COMMANDS = [{"command": "new", "description": "Start a new conversation"},
|
|
85
|
+
{"command": "reset", "description": "Wipe memory, skills, tools and history (keeps keys)"}]
|
|
86
|
+
|
|
87
|
+
COMPACT = ("The context is getting long. Write a summary that will replace the conversation so far: the user's goals, "
|
|
88
|
+
"key facts and decisions, the state of ongoing tasks, open questions and important file paths. "
|
|
89
|
+
"Dense but complete. Reply with the summary only.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log(*a):
|
|
93
|
+
print(time.strftime("%H:%M:%S"), *a, flush=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def quiet(f, *a, **k):
|
|
97
|
+
try:
|
|
98
|
+
return f(*a, **k)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
log("ignored:", e)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def http(url, payload=None, data=None, headers=None, timeout=60):
|
|
104
|
+
if payload is not None:
|
|
105
|
+
data = json.dumps(payload).encode()
|
|
106
|
+
try:
|
|
107
|
+
with urlopen(Request(url, data, {"Content-Type": "application/json", **(headers or {})}), timeout=timeout) as r:
|
|
108
|
+
return json.load(r)
|
|
109
|
+
except HTTPError as e:
|
|
110
|
+
e.msg = e.read().decode(errors="replace")[:500] # surface the API's reason in str(e)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- DeepSeek
|
|
115
|
+
|
|
116
|
+
def chat(messages, **extra):
|
|
117
|
+
for attempt in range(4):
|
|
118
|
+
try:
|
|
119
|
+
r = http(f"{API}/chat/completions", {"model": MODEL, "messages": messages, "tools": TOOLS, **extra},
|
|
120
|
+
headers={"Authorization": f"Bearer {CFG['api_key']}"}, timeout=900)
|
|
121
|
+
LAST["prompt_tokens"] = r["usage"]["prompt_tokens"]
|
|
122
|
+
return r["choices"][0]["message"]
|
|
123
|
+
except OSError as e: # network errors, timeouts, HTTP errors
|
|
124
|
+
code = getattr(e, "code", 500)
|
|
125
|
+
if attempt == 3 or 400 <= code < 500 and code != 429:
|
|
126
|
+
raise
|
|
127
|
+
log("retrying:", e)
|
|
128
|
+
time.sleep(5 * 2 ** attempt)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Telegram
|
|
132
|
+
|
|
133
|
+
def tg_url(method):
|
|
134
|
+
return f"https://api.telegram.org/bot{CFG['bot_token']}/{method}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def tg(method, **params):
|
|
138
|
+
return http(tg_url(method), params, timeout=70)["result"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def send_text(text):
|
|
142
|
+
for i in range(0, len(text), 4096):
|
|
143
|
+
chunk = text[i:i + 4096]
|
|
144
|
+
try:
|
|
145
|
+
tg("sendMessage", chat_id=CFG["user_id"], text=chunk, parse_mode="Markdown")
|
|
146
|
+
except HTTPError as e:
|
|
147
|
+
if e.code != 400:
|
|
148
|
+
raise
|
|
149
|
+
tg("sendMessage", chat_id=CFG["user_id"], text=chunk) # the model's Markdown didn't parse
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def send_file(path):
|
|
153
|
+
path = Path(path).expanduser()
|
|
154
|
+
quiet(tg, "sendChatAction", chat_id=CFG["user_id"], action="upload_document")
|
|
155
|
+
b = uuid.uuid4().hex
|
|
156
|
+
head = (f'--{b}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n{CFG["user_id"]}\r\n'
|
|
157
|
+
f'--{b}\r\nContent-Disposition: form-data; name="document"; filename="{path.name.replace(chr(34), "")}"\r\n\r\n')
|
|
158
|
+
body = head.encode() + path.read_bytes() + f"\r\n--{b}--\r\n".encode()
|
|
159
|
+
http(tg_url("sendDocument"), data=body, headers={"Content-Type": f"multipart/form-data; boundary={b}"}, timeout=600)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def download(file):
|
|
163
|
+
fp = tg("getFile", file_id=file["file_id"])["file_path"]
|
|
164
|
+
dest = HOME / "inbox" / f"{int(time.time())}_{Path(file.get('file_name') or fp).name}"
|
|
165
|
+
with urlopen(f"https://api.telegram.org/file/bot{CFG['bot_token']}/{fp}", timeout=300) as r:
|
|
166
|
+
dest.write_bytes(r.read())
|
|
167
|
+
return dest
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def draft(text=None):
|
|
171
|
+
"""Live status under the user's message: empty text shows Telegram's "Thinking..." placeholder."""
|
|
172
|
+
if text is not None:
|
|
173
|
+
DRAFT["text"] = text[:300]
|
|
174
|
+
if DRAFT["id"]:
|
|
175
|
+
quiet(tg, "sendMessageDraft", chat_id=CFG["user_id"], draft_id=DRAFT["id"], text=DRAFT["text"], can_stop=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def heartbeat():
|
|
179
|
+
while True: # drafts vanish after 30s
|
|
180
|
+
time.sleep(20)
|
|
181
|
+
draft()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- tools
|
|
185
|
+
|
|
186
|
+
def clip(s):
|
|
187
|
+
return s if len(s) <= 2 * CLIP else f"{s[:CLIP]}\n\n[... {len(s) - 2 * CLIP} chars omitted ...]\n\n{s[-CLIP:]}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def shell(command, timeout=SHELL_TIMEOUT):
|
|
191
|
+
# ponytail: POSIX only (process groups), Windows would need taskkill /T
|
|
192
|
+
p = subprocess.Popen(command, shell=True, executable=shutil.which("bash"), cwd=Path.home(), text=True, errors="replace",
|
|
193
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
|
194
|
+
deadline = time.time() + int(timeout)
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
out = p.communicate(timeout=1)[0]
|
|
198
|
+
break
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
if STOP.is_set() or time.time() > deadline:
|
|
201
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
202
|
+
out = p.communicate()[0] + ("\n[stopped by user]" if STOP.is_set() else f"\n[killed after {timeout}s]")
|
|
203
|
+
break
|
|
204
|
+
return clip(f"exit {p.returncode}\n{out}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def send(text="", file=""):
|
|
208
|
+
if text:
|
|
209
|
+
send_text(text)
|
|
210
|
+
if file:
|
|
211
|
+
send_file(file)
|
|
212
|
+
return "sent"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def call_tool(c):
|
|
216
|
+
try:
|
|
217
|
+
args = json.loads(c["function"]["arguments"] or "{}")
|
|
218
|
+
draft(f"🔧 {args.get('command') or c['function']['name']}")
|
|
219
|
+
return {"shell": shell, "send": send}[c["function"]["name"]](**args)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return f"error: {e!r}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --- agent
|
|
225
|
+
|
|
226
|
+
def system_prompt():
|
|
227
|
+
first = lambda p, i: (p.read_text(errors="replace").splitlines()[i:i + 1] or [""])[0].lstrip("# ")
|
|
228
|
+
skills = "\n".join(f"- skills/{p.name}: {first(p, 0)}" for p in sorted((HOME / "skills").glob("*.md")))
|
|
229
|
+
tools = "\n".join(f"- tools/{p.name}: {first(p, 1)}" for p in sorted((HOME / "tools").iterdir()) if p.is_file())
|
|
230
|
+
text = SYSTEM.format(os=platform.platform(), user=getpass.getuser(), cwd=Path.home(), home=HOME,
|
|
231
|
+
now=datetime.datetime.now().astimezone().isoformat(timespec="minutes"),
|
|
232
|
+
skills=skills or "(none)", tools=tools or "(none)", memory=(HOME / "MEMORY.md").read_text())
|
|
233
|
+
if STATE.get("summary"):
|
|
234
|
+
text += f"\n\n## Summary of the earlier conversation\n{STATE['summary']}"
|
|
235
|
+
return text
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def add(messages, msg, record):
|
|
239
|
+
messages.append(msg) # as returned: reasoning_content must be sent back when tools are in play
|
|
240
|
+
if record:
|
|
241
|
+
with HISTORY.open("a") as f:
|
|
242
|
+
entry = {"t": int(time.time()), **{k: v for k, v in msg.items() if k != "reasoning_content"}}
|
|
243
|
+
if isinstance(entry.get("content"), list): # no base64 in the log; the text part has the file path
|
|
244
|
+
entry["content"] = "\n".join(p.get("text", "[image]") for p in entry["content"])
|
|
245
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run(messages, record):
|
|
249
|
+
# ponytail: compaction only between turns, a single turn past 1M tokens will fail
|
|
250
|
+
while True:
|
|
251
|
+
draft("")
|
|
252
|
+
msg = chat(messages)
|
|
253
|
+
add(messages, msg, record)
|
|
254
|
+
calls = msg.get("tool_calls")
|
|
255
|
+
if not calls:
|
|
256
|
+
return msg.get("content") or ""
|
|
257
|
+
for c in calls: # every tool_call needs a reply, even when stopped
|
|
258
|
+
log("tool:", c["function"]["arguments"][:200])
|
|
259
|
+
result = "[stopped by user]" if STOP.is_set() else call_tool(c)
|
|
260
|
+
add(messages, {"role": "tool", "tool_call_id": c["id"], "content": result}, record)
|
|
261
|
+
if STOP.is_set():
|
|
262
|
+
return "⏹ stopped"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def trim_images(messages):
|
|
266
|
+
budget = IMAGE_BUDGET
|
|
267
|
+
for m in reversed(messages):
|
|
268
|
+
for p in m["content"] if isinstance(m.get("content"), list) else []:
|
|
269
|
+
if p["type"] == "image_url" and (budget := budget - len(p["image_url"]["url"])) < 0:
|
|
270
|
+
p.clear()
|
|
271
|
+
p.update(type="text", text="[image dropped from context; the file is still in inbox/]")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def chat_turn(content, message_id):
|
|
275
|
+
STOP.clear()
|
|
276
|
+
DRAFT["id"] = message_id
|
|
277
|
+
n = len(STATE["messages"])
|
|
278
|
+
add(STATE["messages"], {"role": "user", "content": content}, True)
|
|
279
|
+
trim_images(STATE["messages"])
|
|
280
|
+
try:
|
|
281
|
+
reply = run(STATE["messages"], True)
|
|
282
|
+
except HTTPError as e:
|
|
283
|
+
if e.code == 400: # a rejected request (e.g. an unreadable image) would fail every later turn too
|
|
284
|
+
del STATE["messages"][n:]
|
|
285
|
+
raise
|
|
286
|
+
finally:
|
|
287
|
+
DRAFT["id"] = 0
|
|
288
|
+
send_text(reply or "✅")
|
|
289
|
+
if LAST["prompt_tokens"] > COMPACT_AT:
|
|
290
|
+
compact()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def compact():
|
|
294
|
+
log("compacting at", LAST["prompt_tokens"], "tokens")
|
|
295
|
+
STATE["summary"] = chat(STATE["messages"] + [{"role": "user", "content": COMPACT}], tool_choice="none")["content"]
|
|
296
|
+
STATE["messages"] = [{"role": "system", "content": system_prompt()}]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def dream():
|
|
300
|
+
size = HISTORY.stat().st_size if HISTORY.exists() else 0
|
|
301
|
+
start = STATE["history_offset"]
|
|
302
|
+
if size > start:
|
|
303
|
+
log("dreaming over", size - start, "bytes")
|
|
304
|
+
STOP.clear()
|
|
305
|
+
run([{"role": "system", "content": system_prompt()},
|
|
306
|
+
{"role": "user", "content": f"Periodic reflection: follow skills/reflect.md. New history is bytes {start}-{size}: "
|
|
307
|
+
f"`tail -c +{start + 1} {HISTORY} | head -c {size - start}`. "
|
|
308
|
+
"Your final reply here is NOT delivered; use send only if something substantive changed."}],
|
|
309
|
+
False)
|
|
310
|
+
STATE["messages"][0] = {"role": "system", "content": system_prompt()} # pick up new memory, skills, tools
|
|
311
|
+
STATE.update(last_dream=time.time(), history_offset=size)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def new_chat():
|
|
315
|
+
STATE.update(summary="", messages=[{"role": "system", "content": system_prompt()}])
|
|
316
|
+
send_text("🆕 New conversation")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def reset():
|
|
320
|
+
backup = HOME.with_name(f"{HOME.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
|
|
321
|
+
backup.mkdir(mode=0o700)
|
|
322
|
+
for p in HOME.iterdir():
|
|
323
|
+
if p != CONFIG:
|
|
324
|
+
p.rename(backup / p.name) # moved, not deleted: a mis-tap is recoverable
|
|
325
|
+
init_home()
|
|
326
|
+
STATE.clear()
|
|
327
|
+
STATE.update(last_dream=time.time(), history_offset=0, messages=[{"role": "system", "content": system_prompt()}])
|
|
328
|
+
send_text(f"♻️ Reset to a fresh install. Old data: {backup}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def save():
|
|
332
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
333
|
+
tmp.write_text(json.dumps(STATE, ensure_ascii=False))
|
|
334
|
+
tmp.replace(STATE_FILE)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def worker():
|
|
338
|
+
while True:
|
|
339
|
+
item = Q.get()
|
|
340
|
+
BUSY.set()
|
|
341
|
+
try:
|
|
342
|
+
if item is None:
|
|
343
|
+
dream()
|
|
344
|
+
elif item == "/new":
|
|
345
|
+
new_chat()
|
|
346
|
+
elif item == "/reset":
|
|
347
|
+
reset()
|
|
348
|
+
else:
|
|
349
|
+
chat_turn(*item)
|
|
350
|
+
except Exception as e:
|
|
351
|
+
log("error:", repr(e))
|
|
352
|
+
quiet(send_text, f"⚠️ {e}")
|
|
353
|
+
finally:
|
|
354
|
+
BUSY.clear()
|
|
355
|
+
LAST["active"] = time.time()
|
|
356
|
+
save()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def as_image(path):
|
|
360
|
+
"""data: URI if DeepSeek can read the file as an image. It sniffs content, not names, so do we."""
|
|
361
|
+
data = path.read_bytes()
|
|
362
|
+
mime = next((t for sig, t in IMAGE_SIGS if data.startswith(sig)), "image/webp" if data[8:12] == b"WEBP" else None)
|
|
363
|
+
return mime and f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def incoming(m):
|
|
367
|
+
parts, images = [], []
|
|
368
|
+
for kind in ("document", "photo", "audio", "video", "voice", "video_note", "animation"):
|
|
369
|
+
if f := m.get(kind):
|
|
370
|
+
try:
|
|
371
|
+
path = download(f[-1] if kind == "photo" else f)
|
|
372
|
+
parts.append(f"[file saved: {path}]")
|
|
373
|
+
images += filter(None, [as_image(path)])
|
|
374
|
+
except Exception as e:
|
|
375
|
+
parts.append(f"[{kind} download failed: {e}]")
|
|
376
|
+
text = "\n".join(parts + [m.get("text") or m.get("caption") or ""]).strip()
|
|
377
|
+
return [{"type": "text", "text": text}, *({"type": "image_url", "image_url": {"url": u}} for u in images)] if images else text
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def handle_update(u):
|
|
381
|
+
if "stopped_message_generation" in u:
|
|
382
|
+
STOP.set()
|
|
383
|
+
return
|
|
384
|
+
m = u.get("message")
|
|
385
|
+
if not m or m.get("from", {}).get("id") != CFG["user_id"]: # the only trust boundary: owner only
|
|
386
|
+
return
|
|
387
|
+
quiet(tg, "setMessageReaction", chat_id=CFG["user_id"], message_id=m["message_id"],
|
|
388
|
+
reaction=[{"type": "emoji", "emoji": "👀"}])
|
|
389
|
+
LAST["active"] = time.time()
|
|
390
|
+
if m.get("text") in ("/new", "/reset"):
|
|
391
|
+
STOP.set() # interrupt whatever is running, then the worker handles the command in order
|
|
392
|
+
Q.put(m["text"])
|
|
393
|
+
else:
|
|
394
|
+
Q.put((incoming(m), m["message_id"]))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def dream_due(now):
|
|
398
|
+
return (not BUSY.is_set() and Q.empty() and now - LAST["active"] > DREAM_IDLE
|
|
399
|
+
and now - STATE["last_dream"] > DREAM_EVERY)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def init_home():
|
|
403
|
+
for d in ("memory", "skills", "tools", "inbox"):
|
|
404
|
+
(HOME / d).mkdir(parents=True, exist_ok=True)
|
|
405
|
+
(HOME / "MEMORY.md").touch()
|
|
406
|
+
if not (HOME / "skills" / "reflect.md").exists():
|
|
407
|
+
(HOME / "skills" / "reflect.md").write_text(REFLECT)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def serve():
|
|
411
|
+
CFG.update(json.loads(CONFIG.read_text()))
|
|
412
|
+
init_home()
|
|
413
|
+
if STATE_FILE.exists():
|
|
414
|
+
STATE.update(json.loads(STATE_FILE.read_text()))
|
|
415
|
+
STATE.setdefault("last_dream", time.time())
|
|
416
|
+
STATE.setdefault("history_offset", 0)
|
|
417
|
+
STATE.setdefault("messages", [None])[0] = {"role": "system", "content": system_prompt()} # fresh memory/skills per start
|
|
418
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
419
|
+
threading.Thread(target=heartbeat, daemon=True).start()
|
|
420
|
+
log(f"bluesilk running as @{tg('getMe')['username']}, Ctrl+C to stop")
|
|
421
|
+
quiet(tg, "setMyCommands", commands=COMMANDS, scope={"type": "chat", "chat_id": CFG["user_id"]})
|
|
422
|
+
quiet(tg, "sendMessage", chat_id=CFG["user_id"], text="🟢 bluesilk online")
|
|
423
|
+
offset = 0
|
|
424
|
+
while True:
|
|
425
|
+
try:
|
|
426
|
+
for u in tg("getUpdates", offset=offset, timeout=50, allowed_updates=["message", "stopped_message_generation"]):
|
|
427
|
+
offset = u["update_id"] + 1
|
|
428
|
+
handle_update(u)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
log("poll error:", e)
|
|
431
|
+
time.sleep(5)
|
|
432
|
+
if dream_due(time.time()):
|
|
433
|
+
LAST["active"] = time.time()
|
|
434
|
+
Q.put(None)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# --- setup
|
|
438
|
+
|
|
439
|
+
def ask(prompt, check, secret=False):
|
|
440
|
+
while True:
|
|
441
|
+
value = (getpass.getpass if secret else input)(f"{prompt}: ").strip().strip("\"'")
|
|
442
|
+
try:
|
|
443
|
+
check(value)
|
|
444
|
+
return value
|
|
445
|
+
except Exception as e:
|
|
446
|
+
print(f" ✗ {e}")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def setup():
|
|
450
|
+
print("bluesilk setup: 1 key + 1 bot token + 1 user ID\n")
|
|
451
|
+
|
|
452
|
+
def check_key(v):
|
|
453
|
+
http(f"{API}/models", headers={"Authorization": f"Bearer {v}"})
|
|
454
|
+
print(" ✓ DeepSeek key works")
|
|
455
|
+
|
|
456
|
+
def check_token(v):
|
|
457
|
+
CFG["bot_token"] = v
|
|
458
|
+
print(f" ✓ bot @{tg('getMe')['username']}")
|
|
459
|
+
|
|
460
|
+
def check_user(v):
|
|
461
|
+
CFG["user_id"] = int(v)
|
|
462
|
+
tg("sendChatAction", chat_id=CFG["user_id"], action="typing") # fails until the user has pressed Start
|
|
463
|
+
print(" ✓ bot can reach you")
|
|
464
|
+
|
|
465
|
+
CFG["api_key"] = ask("DeepSeek API key (platform.deepseek.com/api_keys)", check_key, secret=True)
|
|
466
|
+
ask("Telegram bot token (from @BotFather)", check_token, secret=True)
|
|
467
|
+
ask("Your Telegram user ID (ask @userinfobot; press Start on your bot first)", check_user)
|
|
468
|
+
HOME.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
469
|
+
CONFIG.touch(mode=0o600)
|
|
470
|
+
CONFIG.chmod(0o600)
|
|
471
|
+
CONFIG.write_text(json.dumps(CFG))
|
|
472
|
+
print(f"\nSaved to {CONFIG}. Starting...\n")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main():
|
|
476
|
+
try:
|
|
477
|
+
if sys.argv[1:2] == ["setup"] or not CONFIG.exists():
|
|
478
|
+
setup()
|
|
479
|
+
serve()
|
|
480
|
+
except (KeyboardInterrupt, EOFError):
|
|
481
|
+
print()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bluesilk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A fast, minimal personal AI agent: DeepSeek + Telegram, zero dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Repository = "https://github.com/wunsiang-cheng/bluesilk"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
bluesilk = "bluesilk:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
only-include = ["bluesilk.py"]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.sdist]
|
|
24
|
+
only-include = ["bluesilk.py", "test_bluesilk.py", "README.md", "LICENSE"] # never ship .env
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Offline self-check: python test_bluesilk.py"""
|
|
2
|
+
import json, os, tempfile, time
|
|
3
|
+
|
|
4
|
+
os.environ["BLUESILK_HOME"] = tempfile.mkdtemp()
|
|
5
|
+
import bluesilk as b
|
|
6
|
+
|
|
7
|
+
sent = []
|
|
8
|
+
b.tg = lambda method, **p: sent.append((method, p)) or {"file_path": "photos/x.jpg", "username": "bot"}
|
|
9
|
+
b.CFG.update(api_key="k", bot_token="t", user_id=42)
|
|
10
|
+
b.init_home()
|
|
11
|
+
b.STATE.update(last_dream=time.time(), history_offset=0, messages=[{"role": "system", "content": b.system_prompt()}])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def script(*replies, tokens=100, on_call=None):
|
|
15
|
+
replies = list(replies)
|
|
16
|
+
def chat(messages, **extra):
|
|
17
|
+
b.LAST["prompt_tokens"] = tokens
|
|
18
|
+
on_call and on_call()
|
|
19
|
+
return replies.pop(0)
|
|
20
|
+
b.chat = chat
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def call(i, cmd):
|
|
24
|
+
return {"id": f"c{i}", "type": "function", "function": {"name": "shell", "arguments": json.dumps({"command": cmd})}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# clip keeps head and tail
|
|
28
|
+
assert b.clip("x" * 10) == "x" * 10
|
|
29
|
+
s = b.clip("a" * b.CLIP + "m" * 50 + "z" * b.CLIP)
|
|
30
|
+
assert s.startswith("a") and s.endswith("z") and "50 chars omitted" in s
|
|
31
|
+
|
|
32
|
+
# shell: exit code, merged output, closed stdin, timeout kills the whole process group
|
|
33
|
+
assert b.shell("echo hi; echo err >&2; exit 3") == "exit 3\nhi\nerr\n"
|
|
34
|
+
assert b.shell("cat").startswith("exit 0")
|
|
35
|
+
t = time.time()
|
|
36
|
+
assert "killed after 1s" in b.shell("sleep 30 | cat", timeout=1) and time.time() - t < 5
|
|
37
|
+
|
|
38
|
+
# agent loop: tool call -> result -> final reply; reasoning kept in context, not in history
|
|
39
|
+
script({"role": "assistant", "content": "", "reasoning_content": "r1", "tool_calls": [call(1, "echo hello")]},
|
|
40
|
+
{"role": "assistant", "content": "done", "reasoning_content": "r2"})
|
|
41
|
+
b.chat_turn("hi", 7)
|
|
42
|
+
msgs = b.STATE["messages"]
|
|
43
|
+
assert [m["role"] for m in msgs] == ["system", "user", "assistant", "tool", "assistant"]
|
|
44
|
+
assert msgs[2]["reasoning_content"] == "r1" and "hello" in msgs[3]["content"]
|
|
45
|
+
assert sent[-1] == ("sendMessage", {"chat_id": 42, "text": "done", "parse_mode": "Markdown"})
|
|
46
|
+
assert any(m == "sendMessageDraft" and p["draft_id"] == 7 and p["text"] == "🔧 echo hello" for m, p in sent)
|
|
47
|
+
assert b.DRAFT["id"] == 0
|
|
48
|
+
assert "reasoning_content" not in b.HISTORY.read_text() and b.HISTORY.read_text().count("\n") == 4
|
|
49
|
+
|
|
50
|
+
# stop: every pending tool_call still gets a reply
|
|
51
|
+
script({"role": "assistant", "content": "", "tool_calls": [call(2, "touch /tmp/should-not"), call(3, "true")]},
|
|
52
|
+
on_call=b.STOP.set)
|
|
53
|
+
b.chat_turn("go", 8)
|
|
54
|
+
assert [m["content"] for m in b.STATE["messages"][-2:]] == ["[stopped by user]"] * 2
|
|
55
|
+
assert sent[-1][1]["text"] == "⏹ stopped"
|
|
56
|
+
|
|
57
|
+
# compaction past 50%: context becomes system prompt + summary
|
|
58
|
+
script({"role": "assistant", "content": "big"}, {"role": "assistant", "content": "SUMMARY"}, tokens=b.COMPACT_AT + 1)
|
|
59
|
+
b.chat_turn("more", 9)
|
|
60
|
+
assert len(b.STATE["messages"]) == 1 and "SUMMARY" in b.STATE["messages"][0]["content"]
|
|
61
|
+
|
|
62
|
+
# updates: strangers ignored, owner gets 👀 and is queued, stop button sets STOP
|
|
63
|
+
b.STOP.clear()
|
|
64
|
+
b.handle_update({"message": {"message_id": 1, "from": {"id": 666}, "text": "hack"}})
|
|
65
|
+
assert b.Q.empty()
|
|
66
|
+
b.handle_update({"message": {"message_id": 2, "from": {"id": 42}, "caption": "look", "photo": [{"file_id": "s"}, {"file_id": "L"}]}})
|
|
67
|
+
assert [m for m, _ in sent[-2:]] == ["setMessageReaction", "getFile"] # 👀 before the download
|
|
68
|
+
text, mid = b.Q.get_nowait()
|
|
69
|
+
assert mid == 2 and text.endswith("look") and "[" in text # download attempted (fails offline, noted inline)
|
|
70
|
+
b.handle_update({"update_id": 3, "stopped_message_generation": {}})
|
|
71
|
+
assert b.STOP.is_set()
|
|
72
|
+
|
|
73
|
+
# images: detected by content, attached for vision, kept out of history
|
|
74
|
+
shot = b.HOME / "inbox" / "shot"
|
|
75
|
+
shot.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 100)
|
|
76
|
+
b.download = lambda f: shot
|
|
77
|
+
b.handle_update({"message": {"message_id": 4, "from": {"id": 42}, "caption": "what is this", "document": {"file_id": "d"}}})
|
|
78
|
+
content, _ = b.Q.get_nowait()
|
|
79
|
+
assert content[0] == {"type": "text", "text": f"[file saved: {shot}]\nwhat is this"}
|
|
80
|
+
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
|
|
81
|
+
fake = b.HOME / "inbox" / "notes.png"
|
|
82
|
+
fake.write_text("not an image")
|
|
83
|
+
b.download = lambda f: fake
|
|
84
|
+
b.handle_update({"message": {"message_id": 5, "from": {"id": 42}, "document": {"file_id": "d"}}})
|
|
85
|
+
assert isinstance(b.Q.get_nowait()[0], str)
|
|
86
|
+
script({"role": "assistant", "content": "a png"})
|
|
87
|
+
b.chat_turn(content, 10)
|
|
88
|
+
assert "base64" not in b.HISTORY.read_text() and "what is this" in b.HISTORY.read_text()
|
|
89
|
+
|
|
90
|
+
# oldest images leave the context first once over budget
|
|
91
|
+
img = lambda n: {"role": "user", "content": [{"type": "text", "text": "t"}, {"type": "image_url", "image_url": {"url": "x" * n}}]}
|
|
92
|
+
b.IMAGE_BUDGET, msgs = 100, [img(60), img(50)]
|
|
93
|
+
b.trim_images(msgs)
|
|
94
|
+
assert msgs[1]["content"][1]["type"] == "image_url" and msgs[0]["content"][1]["type"] == "text"
|
|
95
|
+
|
|
96
|
+
# a request the API rejects (bad image, ...) is rolled back so it can't poison later turns
|
|
97
|
+
def reject(messages, **extra):
|
|
98
|
+
raise b.HTTPError("u", 400, "bad image", {}, None)
|
|
99
|
+
b.chat, n = reject, len(b.STATE["messages"])
|
|
100
|
+
try:
|
|
101
|
+
b.chat_turn("poison", 11)
|
|
102
|
+
raise AssertionError("expected HTTPError")
|
|
103
|
+
except b.HTTPError:
|
|
104
|
+
pass
|
|
105
|
+
assert len(b.STATE["messages"]) == n
|
|
106
|
+
|
|
107
|
+
# dream: due only when idle and old enough; consumes history silently
|
|
108
|
+
b.BUSY.clear()
|
|
109
|
+
b.LAST["active"] = time.time() - b.DREAM_IDLE - 1
|
|
110
|
+
assert not b.dream_due(time.time())
|
|
111
|
+
b.STATE["last_dream"] = 0
|
|
112
|
+
assert b.dream_due(time.time())
|
|
113
|
+
n = len(sent)
|
|
114
|
+
script({"role": "assistant", "content": "nothing new"})
|
|
115
|
+
b.dream()
|
|
116
|
+
assert b.STATE["history_offset"] == b.HISTORY.stat().st_size and b.STATE["last_dream"] > 0
|
|
117
|
+
assert not any(m == "sendMessage" for m, _ in sent[n:])
|
|
118
|
+
|
|
119
|
+
# multipart upload carries the file bytes
|
|
120
|
+
f = b.HOME / "inbox" / "report.txt"
|
|
121
|
+
f.write_bytes(b"\x00payload")
|
|
122
|
+
captured = {}
|
|
123
|
+
b.http = lambda url, **k: captured.update(url=url, **k)
|
|
124
|
+
b.send_file(f)
|
|
125
|
+
assert captured["url"].endswith("/sendDocument") and b"\x00payload" in captured["data"]
|
|
126
|
+
assert b'filename="report.txt"' in captured["data"] and "boundary=" in captured["headers"]["Content-Type"]
|
|
127
|
+
|
|
128
|
+
# commands: interrupt the running turn and go through the worker queue
|
|
129
|
+
b.STOP.clear()
|
|
130
|
+
b.handle_update({"message": {"message_id": 30, "from": {"id": 42}, "text": "/reset"}})
|
|
131
|
+
assert b.STOP.is_set() and b.Q.get_nowait() == "/reset"
|
|
132
|
+
|
|
133
|
+
# /new: fresh context, fresh system prompt
|
|
134
|
+
b.STATE.update(summary="old", messages=[{"role": "system", "content": "stale"}, {"role": "user", "content": "x"}])
|
|
135
|
+
b.new_chat()
|
|
136
|
+
assert len(b.STATE["messages"]) == 1 and b.STATE["messages"][0]["content"] != "stale" and not b.STATE["summary"]
|
|
137
|
+
|
|
138
|
+
# /reset: everything but config moves to a backup, home is back to a fresh install
|
|
139
|
+
b.CONFIG.write_text("{}")
|
|
140
|
+
(b.HOME / "skills" / "mine.md").write_text("# mine: custom")
|
|
141
|
+
(b.HOME / "MEMORY.md").write_text("secret fact")
|
|
142
|
+
b.reset()
|
|
143
|
+
backup = next(b.HOME.parent.glob(b.HOME.name + ".bak-*"))
|
|
144
|
+
assert b.CONFIG.exists() and (backup / "skills" / "mine.md").exists() and (backup / "history.jsonl").exists()
|
|
145
|
+
assert [p.name for p in (b.HOME / "skills").iterdir()] == ["reflect.md"] and (b.HOME / "MEMORY.md").read_text() == ""
|
|
146
|
+
assert not b.HISTORY.exists() and b.STATE["history_offset"] == 0 and "secret fact" not in b.STATE["messages"][0]["content"]
|
|
147
|
+
assert sent[-1][1]["text"].startswith("♻️")
|
|
148
|
+
|
|
149
|
+
print("ok")
|