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.
@@ -0,0 +1,3 @@
1
+ .env
2
+ __pycache__/
3
+ dist/
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.
@@ -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 &`.
@@ -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")