diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 16:40:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 16:40:28 +0200 |
| commit | efe6bb69bb22d17501f513a7fc070fbf580ff024 (patch) | |
| tree | 1412437619d632a7eed6ae8f43126d06cd87f0c6 | |
| parent | b136b0b80bce3f9db7770ebd7be69b6181d92349 (diff) | |
| download | desktop-assistant-efe6bb69bb22d17501f513a7fc070fbf580ff024.tar.gz desktop-assistant-efe6bb69bb22d17501f513a7fc070fbf580ff024.zip | |
Write a reply to a file: the write command
For the canvas's To .md: a one-off request with attachments, no history,
no thinking and no tools, whose reply is written to the first free name
in a folder instead of the conversation. It streams progress as a
character count, unwraps a reply the model fenced whole, and shares the
turn counter, so it stops a reply in flight and stop ends it with no file
left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | assistant.py | 125 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-16-write-design.md | 2 |
3 files changed, 129 insertions, 5 deletions
@@ -43,7 +43,7 @@ first answers. JSON lines both ways on `$XDG_RUNTIME_DIR/desktop-assistant.sock` (mode 0600). Every connected client sees every event. -In: `ask {text, files}`, `stop`, `new`, `list`, `open {id}`, `search {q}`, +In: `ask {text, files}`, `write {files, prompt, dir, name}`, `stop`, `new`, `list`, `open {id}`, `search {q}`, `delete {id}`. Out: `hello` on connect (state, model, context use, the current @@ -55,6 +55,11 @@ conversation and any reply in flight), then `user`, `think`, `token`, Ids are integers; a malformed command or argument answers `error` and the connection stays open. +`write` is a one-off request whose reply goes to a file instead of the +conversation (the canvas's To .md): no history, no thinking, no tools; it +streams `progress {chars}` and ends with `written {path}`, the first free +name of `name` in `dir`. A stop leaves no file. + `files` is a list of absolute paths, at most 8. Text and code files and PDFs are read and inlined, sharing half the free context (a PDF page with no text goes as an image, at most 4, rendered at 150 dpi, at most 1600 px); diff --git a/assistant.py b/assistant.py index 8cdf232..4107092 100644 --- a/assistant.py +++ b/assistant.py @@ -406,6 +406,7 @@ ctx = {"model": None, "max": 0, "used": 0} lock = threading.RLock() cur = {"id": None, "history": [], "turns": [], "pending": None} inflight = {"conn": None} # the HTTP connection the current reply streams over +writing = {"on": False} # a write in flight, which interrupt() also ends turn = 0 # bumped by every ask, stop, new and open: older replies are stale DB = None @@ -519,6 +520,9 @@ def interrupt(): # prompt pass would otherwise keep the GPU busy until that chunk. global turn turn += 1 + if writing["on"]: # a write has no pending turn to close + writing["on"] = False + emit({"type": "state", "state": "idle"}) conn = inflight["conn"] if conn is not None and conn.sock is not None: try: @@ -553,6 +557,13 @@ def handle(raw): isinstance(cmd.get("files", []), list) and all(isinstance(f, str) for f in cmd.get("files", []))) else "bad query" if kind == "search" and not isinstance(cmd.get("q"), str) + else "bad write" if kind == "write" and not ( + isinstance(cmd.get("files"), list) and cmd["files"] + and all(isinstance(f, str) for f in cmd["files"]) + and isinstance(cmd.get("prompt"), str) + and isinstance(cmd.get("dir"), str) and os.path.isabs(cmd["dir"]) + and isinstance(cmd.get("name"), str) and cmd["name"].strip(". ") + and "/" not in cmd["name"]) else None) if bad: emit({"type": "error", "message": bad}) @@ -569,7 +580,11 @@ def handle(raw): def command(kind, cmd): # Caller holds lock; arguments are already checked. - if kind == "ask": + if kind == "write": + interrupt() + threading.Thread(target=write, daemon=True, + args=(turn, cmd["files"], cmd["prompt"], cmd["dir"], cmd["name"])).start() + elif kind == "ask": text, files = cmd["text"].strip(), cmd.get("files", []) if text or files: interrupt() @@ -678,7 +693,7 @@ def llm_model(): return model_inputs()[0] -def ask(messages, calls, tools=None, mine=None): +def ask(messages, calls, tools=None, mine=None, think=None): # Yields ("think", text) and ("token", text) as they stream; tool-call # fragments go into calls. mine is the reply's turn: a stop shuts the # connection from the other thread (interrupt), which ends the reading. @@ -686,7 +701,7 @@ def ask(messages, calls, tools=None, mine=None): context_size(model) body = {"model": model, "messages": messages, "stream": True, "stream_options": {"include_usage": True}, - "chat_template_kwargs": {"enable_thinking": CFG["thinking"]}} + "chat_template_kwargs": {"enable_thinking": CFG["thinking"] if think is None else think}} if tools: body["tools"] = tools if CFG["temperature"] is not None: @@ -773,6 +788,80 @@ def say(mine, kind, value, done=False): return True +def free_name(d, name): + # The first of name, "stem (1).ext", "stem (2).ext" not taken in d. + stem, ext = os.path.splitext(name) + path, n = os.path.join(d, name), 1 + while os.path.lexists(path): + path, n = os.path.join(d, f"{stem} ({n}){ext}"), n + 1 + return path + + +def unfence(text): + # Models often wrap a whole document in one code fence; the file wants + # what is inside it. + lines = text.strip().split("\n") + if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]) + "\n" + return text + + +def write(mine, files, prompt, d, name): + # A one-off request whose reply goes to a file, for the canvas's To .md: + # no history in, nothing kept in the conversation, no thinking, no tools. + tmp = None + try: + free = (ctx["max"] - ctx["used"]) // 2 * 3 if ctx["max"] else 24000 + blocks, refs, _ = attach(files, max(free, 2000)) + model, inputs = model_inputs() + images = inputs is None or "image" in inputs + if refs and not images: + raise Refused(f"{model} cannot see images") + with lock: + if turn != mine: + return + writing["on"] = True + emit({"type": "state", "state": "thinking"}) + body = "\n\n".join(blocks + [prompt]) + msgs = [{"role": "user", "content": [{"type": "text", "text": body}] + refs if refs else body}] + text, shown = "", 0.0 + for kind, piece in ask(wire(msgs, images), {}, None, mine, think=False): + if turn != mine: + return + if kind == "token": + text += piece + if time.time() - shown >= 1: + shown = time.time() + emit({"type": "progress", "chars": len(text)}) + if turn != mine: # stopped: the hang-up ended the stream + return + os.makedirs(d, exist_ok=True) + tmp = os.path.join(d, f".write-{os.getpid()}-{mine}.tmp") + with open(tmp, "w") as f: + f.write(unfence(text)) + with lock: + if turn != mine: + return + path = free_name(d, name) + os.rename(tmp, path) + tmp = None + writing["on"] = False + emit({"type": "written", "path": path}) + emit({"type": "state", "state": "idle"}) + log(f"wrote {path}") + except Exception as e: + log(f"refused: {e}" if isinstance(e, Refused) + else "write failed:\n" + traceback.format_exc().rstrip()) + with lock: + if turn == mine: + writing["on"] = False + emit({"type": "error", "message": str(e) or type(e).__name__, "turn": True}) + emit({"type": "state", "state": "idle"}) + finally: + if tmp is not None and os.path.exists(tmp): + os.remove(tmp) + + def reply(mine, text, files=()): try: answer(mine, text, files) @@ -1329,6 +1418,36 @@ def test(): assert "chat supports" in until("error")[-1]["message"] send(type="ask", text="x", files="not a list") assert until("error")[-1]["message"] == "bad files" + # write: the reply goes to a file, not the conversation. + before = len(list_convs(DB)) + with open(os.path.join(fd, "doc.md"), "w") as fh: # f is the socket's + fh.write("taken") + script["replies"] = [['data: {"choices": [{"delta": {"content": "```markdown\\n# Title\\n\\nbody\\n"}}]}\n\n', + {"content": "```"}]] + send(type="write", files=[pdf], prompt="As Markdown.", dir=fd, name="doc.md") + w = until("written")[-1] + assert w["path"] == os.path.join(fd, "doc (1).md"), w + with open(w["path"]) as fh: + assert fh.read() == "# Title\n\nbody\n" # the one fence unwrapped + assert script["body"]["chat_template_kwargs"]["enable_thinking"] is False + assert "tools" not in script["body"] and script["body"]["messages"][-1]["role"] == "user" + assert len(list_convs(DB)) == before # nothing in History + assert until("state")[-1]["state"] == "idle" + # A stopped write leaves nothing, and the state goes back to idle. + script.update(hold=True, hung_up=None) + script["replies"] = [[]] + send(type="write", files=[pdf], prompt="As Markdown.", dir=fd, name="held.md") + until("state") + send(type="stop") + assert until("state")[-1]["state"] == "idle" + time.sleep(0.3) + assert not os.path.exists(os.path.join(fd, "held.md")) + assert not [n for n in os.listdir(fd) if n.startswith(".write-")] + script["hold"] = False + for bad in ({"dir": "relative", "name": "x.md"}, {"dir": fd, "name": "a/b.md"}, + {"dir": fd, "name": ""}): + send(type="write", files=[pdf], prompt="p", **bad) + assert until("error")[-1]["message"] == "bad write", bad # llama-server down: an error, not a dead thread. CFG["llm"] = "http://127.0.0.1:9/v1" send(type="ask", text="anyone?") diff --git a/docs/superpowers/specs/2026-10-16-write-design.md b/docs/superpowers/specs/2026-10-16-write-design.md index 5ccf600..c995fe4 100644 --- a/docs/superpowers/specs/2026-10-16-write-design.md +++ b/docs/superpowers/specs/2026-10-16-write-design.md @@ -1,6 +1,6 @@ # Write to a file: design -Date: 2026-10-16. Status: approved in brainstorming, not implemented. +Date: 2026-10-16. Status: implemented (2026-10-16). ## Goal |
