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.
- papers/__init__.py +2 -0
- papers/__main__.py +3 -0
- papers/agent/__init__.py +2 -0
- papers/agent/loop.py +295 -0
- papers/agent/mcp_server.py +65 -0
- papers/agent/tools.py +329 -0
- papers/ai/__init__.py +67 -0
- papers/ai/anthropic_api.py +53 -0
- papers/ai/claude_code.py +126 -0
- papers/ai/codex.py +66 -0
- papers/ai/errors.py +13 -0
- papers/ai/openai_compat.py +124 -0
- papers/ai/providers.py +112 -0
- papers/ai/streaming.py +111 -0
- papers/bibtex.py +28 -0
- papers/chat/__init__.py +1 -0
- papers/chat/answer.py +92 -0
- papers/chat/store.py +178 -0
- papers/cli.py +50 -0
- papers/config.py +52 -0
- papers/db.py +120 -0
- papers/figures.py +234 -0
- papers/folders.py +56 -0
- papers/index.py +96 -0
- papers/ingest.py +128 -0
- papers/keys.py +103 -0
- papers/library.py +208 -0
- papers/llm.py +428 -0
- papers/main.py +73 -0
- papers/notes.py +73 -0
- papers/pdf.py +116 -0
- papers/routes/__init__.py +4 -0
- papers/routes/chat.py +166 -0
- papers/routes/goals.py +65 -0
- papers/routes/learning.py +195 -0
- papers/routes/notes.py +92 -0
- papers/routes/papers.py +213 -0
- papers/routes/reading.py +114 -0
- papers/routes/recall.py +34 -0
- papers/routes/settings.py +133 -0
- papers/scholar.py +128 -0
- papers/static/css/base.css +109 -0
- papers/static/css/chat.css +241 -0
- papers/static/css/components.css +285 -0
- papers/static/css/goals.css +85 -0
- papers/static/css/library.css +142 -0
- papers/static/css/map.css +182 -0
- papers/static/css/paper.css +138 -0
- papers/static/css/reader.css +256 -0
- papers/static/css/recall.css +69 -0
- papers/static/css/settings.css +36 -0
- papers/static/css/tokens.css +129 -0
- papers/static/index.html +44 -0
- papers/static/js/chat/answer.js +130 -0
- papers/static/js/chat/canvas.js +24 -0
- papers/static/js/chat/conversation.js +345 -0
- papers/static/js/chat/render.js +230 -0
- papers/static/js/chat/stream.js +39 -0
- papers/static/js/chat/widgets.js +161 -0
- papers/static/js/components/combobox.js +91 -0
- papers/static/js/components/folderpicker.js +95 -0
- papers/static/js/core.js +358 -0
- papers/static/js/covers.js +74 -0
- papers/static/js/highlights.js +181 -0
- papers/static/js/main.js +115 -0
- papers/static/js/notes.js +160 -0
- papers/static/js/pages/goals.js +149 -0
- papers/static/js/pages/library.js +144 -0
- papers/static/js/pages/map.js +84 -0
- papers/static/js/pages/recall.js +70 -0
- papers/static/js/pages/settings.js +225 -0
- papers/static/js/pages/shared.js +46 -0
- papers/static/js/paper/figures.js +62 -0
- papers/static/js/paper/overview.js +291 -0
- papers/static/js/paper/reader/bar.js +50 -0
- papers/static/js/paper/reader/cite.js +36 -0
- papers/static/js/paper/reader/finish.js +54 -0
- papers/static/js/paper/reader/highlighter.js +132 -0
- papers/static/js/paper/reader/keys.js +30 -0
- papers/static/js/paper/reader/layout.js +69 -0
- papers/static/js/paper/reader/panel.js +105 -0
- papers/static/js/paper/reader/progress.js +71 -0
- papers/static/js/paper/reader/route.js +17 -0
- papers/static/js/paper/reader/selection.js +113 -0
- papers/static/js/paper/reader/state.js +22 -0
- papers/static/js/paper/reader/terms.js +85 -0
- papers/static/js/paper/reader/views.js +126 -0
- papers/static/js/paper/reader/vocab.js +122 -0
- papers/static/js/paper/reader/zoom.js +29 -0
- papers/static/js/paper/reader.js +92 -0
- papers/static/js/paper/review.js +109 -0
- papers/static/js/paper/shared.js +23 -0
- papers/static/js/pdfview.js +128 -0
- papers/static/js/readprefs.js +123 -0
- papers/static/js/speech.js +39 -0
- papers/static/js/textview.js +52 -0
- papers/static/js/theme.js +52 -0
- papers/static/js/tracker.js +97 -0
- papers/store.py +100 -0
- papers_app-0.2.0.dist-info/METADATA +138 -0
- papers_app-0.2.0.dist-info/RECORD +104 -0
- papers_app-0.2.0.dist-info/WHEEL +4 -0
- papers_app-0.2.0.dist-info/entry_points.txt +3 -0
- papers_app-0.2.0.dist-info/licenses/LICENSE +21 -0
papers/__init__.py
ADDED
papers/__main__.py
ADDED
papers/agent/__init__.py
ADDED
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()
|