papers-app 0.2.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.
Files changed (104) hide show
  1. papers/__init__.py +2 -0
  2. papers/__main__.py +3 -0
  3. papers/agent/__init__.py +2 -0
  4. papers/agent/loop.py +295 -0
  5. papers/agent/mcp_server.py +65 -0
  6. papers/agent/tools.py +329 -0
  7. papers/ai/__init__.py +67 -0
  8. papers/ai/anthropic_api.py +53 -0
  9. papers/ai/claude_code.py +126 -0
  10. papers/ai/codex.py +66 -0
  11. papers/ai/errors.py +13 -0
  12. papers/ai/openai_compat.py +124 -0
  13. papers/ai/providers.py +112 -0
  14. papers/ai/streaming.py +111 -0
  15. papers/bibtex.py +28 -0
  16. papers/chat/__init__.py +1 -0
  17. papers/chat/answer.py +92 -0
  18. papers/chat/store.py +178 -0
  19. papers/cli.py +50 -0
  20. papers/config.py +52 -0
  21. papers/db.py +120 -0
  22. papers/figures.py +234 -0
  23. papers/folders.py +56 -0
  24. papers/index.py +96 -0
  25. papers/ingest.py +128 -0
  26. papers/keys.py +103 -0
  27. papers/library.py +208 -0
  28. papers/llm.py +428 -0
  29. papers/main.py +73 -0
  30. papers/notes.py +73 -0
  31. papers/pdf.py +116 -0
  32. papers/routes/__init__.py +4 -0
  33. papers/routes/chat.py +166 -0
  34. papers/routes/goals.py +65 -0
  35. papers/routes/learning.py +195 -0
  36. papers/routes/notes.py +92 -0
  37. papers/routes/papers.py +213 -0
  38. papers/routes/reading.py +114 -0
  39. papers/routes/recall.py +34 -0
  40. papers/routes/settings.py +133 -0
  41. papers/scholar.py +128 -0
  42. papers/static/css/base.css +109 -0
  43. papers/static/css/chat.css +241 -0
  44. papers/static/css/components.css +285 -0
  45. papers/static/css/goals.css +85 -0
  46. papers/static/css/library.css +142 -0
  47. papers/static/css/map.css +182 -0
  48. papers/static/css/paper.css +138 -0
  49. papers/static/css/reader.css +256 -0
  50. papers/static/css/recall.css +69 -0
  51. papers/static/css/settings.css +36 -0
  52. papers/static/css/tokens.css +129 -0
  53. papers/static/index.html +44 -0
  54. papers/static/js/chat/answer.js +130 -0
  55. papers/static/js/chat/canvas.js +24 -0
  56. papers/static/js/chat/conversation.js +345 -0
  57. papers/static/js/chat/render.js +230 -0
  58. papers/static/js/chat/stream.js +39 -0
  59. papers/static/js/chat/widgets.js +161 -0
  60. papers/static/js/components/combobox.js +91 -0
  61. papers/static/js/components/folderpicker.js +95 -0
  62. papers/static/js/core.js +358 -0
  63. papers/static/js/covers.js +74 -0
  64. papers/static/js/highlights.js +181 -0
  65. papers/static/js/main.js +115 -0
  66. papers/static/js/notes.js +160 -0
  67. papers/static/js/pages/goals.js +149 -0
  68. papers/static/js/pages/library.js +144 -0
  69. papers/static/js/pages/map.js +84 -0
  70. papers/static/js/pages/recall.js +70 -0
  71. papers/static/js/pages/settings.js +225 -0
  72. papers/static/js/pages/shared.js +46 -0
  73. papers/static/js/paper/figures.js +62 -0
  74. papers/static/js/paper/overview.js +291 -0
  75. papers/static/js/paper/reader/bar.js +50 -0
  76. papers/static/js/paper/reader/cite.js +36 -0
  77. papers/static/js/paper/reader/finish.js +54 -0
  78. papers/static/js/paper/reader/highlighter.js +132 -0
  79. papers/static/js/paper/reader/keys.js +30 -0
  80. papers/static/js/paper/reader/layout.js +69 -0
  81. papers/static/js/paper/reader/panel.js +105 -0
  82. papers/static/js/paper/reader/progress.js +71 -0
  83. papers/static/js/paper/reader/route.js +17 -0
  84. papers/static/js/paper/reader/selection.js +113 -0
  85. papers/static/js/paper/reader/state.js +22 -0
  86. papers/static/js/paper/reader/terms.js +85 -0
  87. papers/static/js/paper/reader/views.js +126 -0
  88. papers/static/js/paper/reader/vocab.js +122 -0
  89. papers/static/js/paper/reader/zoom.js +29 -0
  90. papers/static/js/paper/reader.js +92 -0
  91. papers/static/js/paper/review.js +109 -0
  92. papers/static/js/paper/shared.js +23 -0
  93. papers/static/js/pdfview.js +128 -0
  94. papers/static/js/readprefs.js +123 -0
  95. papers/static/js/speech.js +39 -0
  96. papers/static/js/textview.js +52 -0
  97. papers/static/js/theme.js +52 -0
  98. papers/static/js/tracker.js +97 -0
  99. papers/store.py +100 -0
  100. papers_app-0.2.0.dist-info/METADATA +138 -0
  101. papers_app-0.2.0.dist-info/RECORD +104 -0
  102. papers_app-0.2.0.dist-info/WHEEL +4 -0
  103. papers_app-0.2.0.dist-info/entry_points.txt +3 -0
  104. papers_app-0.2.0.dist-info/licenses/LICENSE +21 -0
papers/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Papers: an AI reading companion for research papers."""
2
+ __version__ = "0.2.0"
papers/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from papers.cli import main
2
+
3
+ main()
@@ -0,0 +1,2 @@
1
+ """Deep mode: the AI uses tools (tools.py) in a loop (loop.py) while it answers; mcp_server.py gives the same tools
2
+ to Claude Code."""
papers/agent/loop.py ADDED
@@ -0,0 +1,295 @@
1
+ """Deep mode: the model answers in a loop, using tools (tools.py) to read the paper precisely, look at its figures,
2
+ search the reader's library and notes, before (and while) it writes its answer.
3
+
4
+ `run()` yields what happens, as it happens: str pieces of the answer, Thinking pieces, and Step events (a tool
5
+ starting and finishing). Each provider runs the loop its own way:
6
+ OpenAI-compatible (OpenAI, OpenRouter, Ollama, ...) the chat API's tool calls, streamed
7
+ Anthropic API tool_use blocks, streamed
8
+ Claude subscription (Claude Code) Claude Code runs the loop; tools come from mcp_server.py
9
+ ChatGPT subscription (Codex) not yet: answered without tools, and it says so
10
+ """
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from dataclasses import asdict, dataclass
18
+
19
+ import anthropic
20
+ import httpx
21
+
22
+ from papers import config, llm
23
+ from papers.agent import tools
24
+ from papers.ai import anthropic_api, claude_code, openai_compat, providers
25
+ from papers.ai.errors import AIError
26
+ from papers.ai.streaming import Thinking, ThinkTags, as_transcript
27
+
28
+ MAX_STEPS = 8 # tool calls per answer; then it must answer with what it has
29
+ MAX_ACTIONS = 5 # notes, highlights, words and papers added per answer
30
+
31
+ AGENT = """You can use tools to work precisely, like a careful researcher, before you answer:
32
+ - search_paper and read_section: read the exact text instead of relying on memory of it;
33
+ - look_at_figure: see a figure or table (with its caption) before you describe it; once you've seen it you can draw
34
+ accurate "look here" boxes on it in a ```figure visual;
35
+ - search_library: connect this paper to the reader's other papers when that helps;
36
+ - get_notes and get_vocabulary: what the reader noted and is learning, to fit the answer to them.
37
+ - save_note, highlight, save_word, add_paper: these change the reader's library. Use them only when the reader asks
38
+ ("note that", "highlight the definition", "add this word", "I want to read that paper") or clearly wants it, and
39
+ say in your answer what you did (they can undo it).
40
+ Use a tool when the answer depends on details you should check; don't use tools you don't need. At most
41
+ """ + str(MAX_STEPS) + """ tool calls; then answer. Say briefly what you're checking before a tool call only if it helps the reader
42
+ follow. Your final answer follows all the rules above."""
43
+
44
+
45
+ @dataclass
46
+ class Action:
47
+ """Something the agent did to the library (a note, a highlight, a word, a paper), with what Undo needs."""
48
+ data: dict
49
+
50
+
51
+ @dataclass
52
+ class Step:
53
+ id: str
54
+ name: str
55
+ label: str
56
+ state: str # "start" or "done"
57
+ summary: str = ""
58
+ args: dict | None = None
59
+
60
+ def event(self) -> dict:
61
+ return {k: v for k, v in asdict(self).items() if v is not None}
62
+
63
+
64
+ def _system(paper: dict, figures: list[dict]) -> list[dict]:
65
+ return [
66
+ {"type": "text", "text": llm.tutor() + "\n\n" + AGENT},
67
+ {"type": "text", "text": llm._paper_context(paper) + "\n\n" + llm._figures_list(figures), "cache_control": {"type": "ephemeral"}},
68
+ ]
69
+
70
+
71
+ def _messages(turns: list[dict], language: str) -> list[dict]:
72
+ """The conversation as chat messages (the same as quick mode builds them)."""
73
+ turns = turns[-llm.KEEP_TURNS:]
74
+ while turns and turns[0]["role"] != "user":
75
+ turns = turns[1:]
76
+ out = []
77
+ for i, m in enumerate(turns):
78
+ if m["role"] == "user":
79
+ out.append({"role": "user", "content": llm._turn(m, language, i == len(turns) - 1)})
80
+ else:
81
+ text = m.get("content") or "(no answer)"
82
+ out.append({"role": "assistant", "content": text[:llm.LONG_ANSWER]})
83
+ return out
84
+
85
+
86
+ def run(paper: dict, turns: list[dict], language: str = "", figures: list[dict] | None = None):
87
+ kind = providers.backend()
88
+ system, messages = _system(paper, figures or []), _messages(turns, language)
89
+ if kind == "openai":
90
+ events = _openai(paper, system, messages)
91
+ elif kind == "api":
92
+ events = _anthropic(paper, system, messages)
93
+ elif kind == "claude-code":
94
+ events = _claude_code(paper, system, messages)
95
+ else: # Codex: no tools yet
96
+ yield Step("note", "note", "Deep mode isn't available with ChatGPT yet: answering without tools", "done")
97
+ events = llm.ai.stream(system, messages)
98
+ # Text written before a tool call ("Let me look at Figure 1.") and the text after it are separate paragraphs.
99
+ wrote, after_tool = False, False
100
+ for e in events:
101
+ if isinstance(e, Step):
102
+ after_tool = after_tool or (wrote and e.state == "done")
103
+ elif isinstance(e, str) and not isinstance(e, Thinking) and e:
104
+ if after_tool:
105
+ yield "\n\n"
106
+ after_tool = False
107
+ wrote = True
108
+ yield e
109
+
110
+
111
+ # ---------- OpenAI-compatible ----------
112
+ def _openai(paper: dict, system: list[dict], messages: list[dict]):
113
+ cfg = openai_compat._ready()
114
+ chat = [{"role": "system", "content": "\n\n".join(b["text"] for b in system)}, *messages]
115
+ specs = [{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}}
116
+ for t in tools.specs()]
117
+ vision = tools.setting_vision()
118
+ for step in range(MAX_STEPS + 1):
119
+ body = {"model": cfg["model"], "stream": True, "messages": chat}
120
+ if step < MAX_STEPS:
121
+ body["tools"] = specs
122
+ text, calls, think = [], {}, ThinkTags()
123
+ try:
124
+ with openai_compat._http() as http, http.stream("POST", f"{cfg['base_url']}/chat/completions", json=body,
125
+ headers=openai_compat._headers(cfg)) as r:
126
+ if r.status_code >= 400:
127
+ r.read()
128
+ if step == 0 and r.status_code in (400, 404, 422) and "tool" in r.text.lower():
129
+ raise AIError(f"{cfg['name']} or this model can't use tools, so Deep mode can't work with it. "
130
+ "Use Quick, or choose a model that supports tool calling in Settings.")
131
+ raise openai_compat._error(cfg, r)
132
+ for line in r.iter_lines():
133
+ if not line.startswith("data:") or line[5:].strip() == "[DONE]":
134
+ continue
135
+ delta = ((json.loads(line[5:]).get("choices") or [{}])[0].get("delta")) or {}
136
+ if thought := delta.get("reasoning") or delta.get("reasoning_content"):
137
+ yield Thinking(thought)
138
+ if piece := delta.get("content"):
139
+ for part in think.feed(piece):
140
+ if not isinstance(part, Thinking):
141
+ text.append(part)
142
+ yield part
143
+ for tc in delta.get("tool_calls") or []:
144
+ c = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "arguments": ""})
145
+ c["id"] = tc.get("id") or c["id"]
146
+ fn = tc.get("function") or {}
147
+ c["name"] += fn.get("name") or ""
148
+ c["arguments"] += fn.get("arguments") or ""
149
+ yield from think.flush()
150
+ except httpx.HTTPError as e:
151
+ raise openai_compat._unreachable(cfg, e) from e
152
+ if not calls:
153
+ return
154
+ ordered = [calls[k] for k in sorted(calls)]
155
+ for c in ordered:
156
+ c["id"] = c["id"] or f"call_{step}_{ordered.index(c)}"
157
+ chat.append({"role": "assistant", "content": "".join(text) or None, "tool_calls": [
158
+ {"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["arguments"] or "{}"}} for c in ordered]})
159
+ images = []
160
+ for c in ordered:
161
+ result = yield from _call(paper, c["id"], c["name"], c["arguments"])
162
+ chat.append({"role": "tool", "tool_call_id": c["id"], "content": result.text})
163
+ if result.image and vision:
164
+ images.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{result.image_b64()}"}})
165
+ if images:
166
+ chat.append({"role": "user", "content": [{"type": "text", "text": "The figures you asked to look at:"}, *images]})
167
+
168
+
169
+ def _call(paper: dict, call_id: str, name: str, arguments: str | dict):
170
+ """Run one tool, announcing it; returns its Result (use with `yield from`)."""
171
+ try:
172
+ args = json.loads(arguments) if isinstance(arguments, str) and arguments.strip() else (arguments or {})
173
+ except ValueError:
174
+ args = {}
175
+ tool = tools.TOOLS.get(name, {})
176
+ label = tool.get("label", name)
177
+ yield Step(call_id, name, label, "start", args=args)
178
+ acted = paper.setdefault("_actions", [0]) # changes made in this answer
179
+ if tool.get("act") and acted[0] >= MAX_ACTIONS:
180
+ result = tools.Result(f"That's {MAX_ACTIONS} changes in one answer already; ask the reader before doing more.",
181
+ "Stopped: too many changes")
182
+ else:
183
+ result = tools.run(name, paper["id"], args)
184
+ if result.data:
185
+ acted[0] += 1
186
+ yield Step(call_id, name, label, "done", result.summary or label, args=args)
187
+ if result.data:
188
+ yield Action({**result.data, "paper_id": paper["id"]})
189
+ return result
190
+
191
+
192
+ # ---------- Anthropic API ----------
193
+ def _anthropic(paper: dict, system: list[dict], messages: list[dict]):
194
+ specs = [{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools.specs()]
195
+ chat = list(messages)
196
+ for step in range(MAX_STEPS + 1):
197
+ extra = {"tools": specs} if step < MAX_STEPS else {}
198
+ try:
199
+ with anthropic_api.client().messages.stream(model=providers.claude_model(), max_tokens=16000, system=system,
200
+ messages=chat, **extra) as stream:
201
+ for event in stream:
202
+ if event.type == "content_block_delta" and event.delta.type == "text_delta":
203
+ yield event.delta.text
204
+ elif event.type == "content_block_delta" and event.delta.type == "thinking_delta":
205
+ yield Thinking(event.delta.thinking)
206
+ final = stream.get_final_message()
207
+ except TypeError as e:
208
+ raise AIError(anthropic_api.NOT_SET_UP) from e
209
+ except anthropic.APIError as e:
210
+ raise AIError(f"Claude request failed: {e}") from e
211
+ uses = [b for b in final.content if b.type == "tool_use"]
212
+ if final.stop_reason != "tool_use" or not uses:
213
+ return
214
+ chat.append({"role": "assistant", "content": [b.model_dump(exclude_none=True) for b in final.content]})
215
+ results = []
216
+ for b in uses:
217
+ result = yield from _call(paper, b.id, b.name, b.input)
218
+ content = [{"type": "text", "text": result.text}]
219
+ if result.image:
220
+ content.append({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": result.image_b64()}})
221
+ results.append({"type": "tool_result", "tool_use_id": b.id, "content": content})
222
+ chat.append({"role": "user", "content": results})
223
+
224
+
225
+ # ---------- Claude subscription: Claude Code with the tools from mcp_server.py ----------
226
+ PREFIX = "mcp__papers__"
227
+ ACTION_MARK = re.compile(r"<!--action (\{.*?\})-->")
228
+
229
+
230
+ def _claude_code(paper: dict, system: list[dict], messages: list[dict]):
231
+ binary = providers.claude_binary()
232
+ if not binary:
233
+ raise AIError(claude_code.NOT_INSTALLED)
234
+ work = tempfile.mkdtemp(prefix="papers-agent-")
235
+ mcp_file = os.path.join(work, "mcp.json")
236
+ with open(mcp_file, "w", encoding="utf-8") as f:
237
+ json.dump({"mcpServers": {"papers": {
238
+ "command": sys.executable, "args": ["-m", "papers.agent.mcp_server", "--paper", str(paper["id"])],
239
+ "env": {"PAPERS_DB": str(config.db_path().resolve()), "PYTHONIOENCODING": "utf-8"}}}}, f)
240
+ system_file = claude_code._system_file("\n\n".join(b["text"] for b in system))
241
+ args = [binary, "-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
242
+ "--tools", "", "--mcp-config", mcp_file, "--strict-mcp-config", "--allowedTools", "mcp__papers",
243
+ "--max-turns", str(MAX_STEPS + 2), "--no-session-persistence", "--model", providers.claude_model(),
244
+ "--system-prompt-file", system_file]
245
+ try:
246
+ proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
247
+ text=True, encoding="utf-8", env=claude_code._env(), cwd=work)
248
+ except OSError as e:
249
+ raise AIError(f"Couldn't start Claude Code: {e}") from e
250
+ running = {} # tool_use id → Step that started
251
+ wrote = False
252
+ try:
253
+ proc.stdin.write(as_transcript(messages))
254
+ proc.stdin.close()
255
+ for line in proc.stdout:
256
+ try:
257
+ event = json.loads(line)
258
+ except json.JSONDecodeError:
259
+ continue
260
+ kind = event.get("type")
261
+ if kind == "stream_event":
262
+ e = event.get("event") or {}
263
+ delta = e.get("delta") or {}
264
+ block = e.get("content_block") or {}
265
+ if e.get("type") == "content_block_delta" and delta.get("type") == "text_delta":
266
+ wrote = True
267
+ yield delta["text"]
268
+ elif e.get("type") == "content_block_delta" and delta.get("type") == "thinking_delta":
269
+ yield Thinking(delta.get("thinking", ""))
270
+ elif e.get("type") == "content_block_start" and block.get("type") == "tool_use":
271
+ name = block.get("name", "").removeprefix(PREFIX)
272
+ step = Step(block.get("id", name), name, tools.TOOLS.get(name, {}).get("label", name), "start")
273
+ running[step.id] = step
274
+ yield step
275
+ elif kind == "user": # tool results come back as the "user" turn
276
+ for b in (event.get("message") or {}).get("content") or []:
277
+ if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in running:
278
+ step = running.pop(b["tool_use_id"])
279
+ text = b.get("content")
280
+ text = text if isinstance(text, str) else " ".join(x.get("text", "") for x in text or [] if isinstance(x, dict))
281
+ summary = text.split("\n", 1)[0].strip("[] ") if text.startswith("[") else step.label
282
+ yield Step(step.id, step.name, step.label, "done", summary)
283
+ if marker := ACTION_MARK.search(text): # the MCP server reports what it changed
284
+ yield Action({**json.loads(marker.group(1)), "paper_id": paper["id"]})
285
+ wrote = False # text after a tool call starts a new paragraph
286
+ elif kind == "result" and event.get("is_error"):
287
+ raise claude_code._error(event.get("result"))
288
+ if proc.wait(timeout=60) and not wrote:
289
+ err = proc.stderr.read().strip()[:300]
290
+ if err:
291
+ raise AIError(f"Claude Code failed: {err}")
292
+ finally:
293
+ if proc.poll() is None:
294
+ proc.kill()
295
+ os.unlink(system_file)
@@ -0,0 +1,65 @@
1
+ """The agent's tools for Claude Code (a Claude subscription), as a small MCP server over stdin/stdout.
2
+
3
+ Claude Code starts it for one answer (`python -m papers.agent.mcp_server --paper 12`), lists the tools, and calls
4
+ them; each call runs the same function as the other providers use (tools.py), on the same library (PAPERS_DB).
5
+ It speaks just the part of MCP that's needed: initialize, tools/list, tools/call, ping. Nothing listens on the network.
6
+ """
7
+ import argparse
8
+ import json
9
+ import sys
10
+
11
+ PROTOCOL = "2024-11-05"
12
+ MAX_ACTIONS = 5 # changes per answer, as in loop.py
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> None:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--paper", type=int, required=True)
18
+ paper_id = parser.parse_args(argv).paper
19
+ from papers.agent import tools # after argparse: PAPERS_DB is already in the environment
20
+
21
+ def reply(rid, result=None, error=None):
22
+ msg = {"jsonrpc": "2.0", "id": rid, **({"error": error} if error else {"result": result})}
23
+ sys.stdout.write(json.dumps(msg) + "\n")
24
+ sys.stdout.flush()
25
+
26
+ acted = 0 # changes made in this answer (one server runs for one answer)
27
+ for line in sys.stdin:
28
+ if not line.strip():
29
+ continue
30
+ try:
31
+ msg = json.loads(line)
32
+ except ValueError:
33
+ continue
34
+ method, rid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
35
+ if rid is None: # a notification (e.g. notifications/initialized): no answer
36
+ continue
37
+ if method == "initialize":
38
+ reply(rid, {"protocolVersion": params.get("protocolVersion") or PROTOCOL, "capabilities": {"tools": {}},
39
+ "serverInfo": {"name": "papers", "version": "1"}})
40
+ elif method == "tools/list":
41
+ reply(rid, {"tools": [{"name": t["name"], "description": t["description"], "inputSchema": t["schema"]}
42
+ for t in tools.specs()]})
43
+ elif method == "tools/call":
44
+ name = params.get("name", "")
45
+ if tools.TOOLS.get(name, {}).get("act") and acted >= MAX_ACTIONS:
46
+ result = tools.Result(f"That's {MAX_ACTIONS} changes in one answer already; ask the reader before doing more.",
47
+ "Stopped: too many changes")
48
+ else:
49
+ result = tools.run(name, paper_id, params.get("arguments") or {})
50
+ text = f"[{result.summary}]\n{result.text}" if result.summary else result.text
51
+ if result.data: # what was changed, for the app's Undo (read back from Claude Code's output)
52
+ acted += 1
53
+ text += f"\n<!--action {json.dumps(result.data)}-->"
54
+ content = [{"type": "text", "text": text}]
55
+ if result.image:
56
+ content.append({"type": "image", "data": result.image_b64(), "mimeType": "image/png"})
57
+ reply(rid, {"content": content, "isError": False})
58
+ elif method == "ping":
59
+ reply(rid, {})
60
+ else:
61
+ reply(rid, error={"code": -32601, "message": f"Unknown method {method}"})
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()