mdpad 0.1.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 (84) hide show
  1. mdpad/__init__.py +5 -0
  2. mdpad/app.py +619 -0
  3. mdpad/applog.py +30 -0
  4. mdpad/configure.py +73 -0
  5. mdpad/context.py +187 -0
  6. mdpad/example/book.md +31 -0
  7. mdpad/provider.py +195 -0
  8. mdpad/py.typed +0 -0
  9. mdpad/sections.py +252 -0
  10. mdpad/static/app.js +1120 -0
  11. mdpad/static/favicon.svg +10 -0
  12. mdpad/static/index.html +166 -0
  13. mdpad/static/styles.css +453 -0
  14. mdpad/static/vendor/katex/contrib/auto-render.min.js +1 -0
  15. mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  16. mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  17. mdpad/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  18. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  19. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  20. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  21. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  22. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  23. mdpad/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  24. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  25. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  26. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  27. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  28. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  29. mdpad/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  30. mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  31. mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  32. mdpad/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  33. mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  34. mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  35. mdpad/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  36. mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  37. mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  38. mdpad/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  39. mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  40. mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  41. mdpad/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  42. mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  43. mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  44. mdpad/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  45. mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  46. mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  47. mdpad/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  48. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  49. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  50. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  51. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  52. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  53. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  55. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  56. mdpad/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  57. mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  58. mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  59. mdpad/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  60. mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  61. mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  62. mdpad/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  63. mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  64. mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  65. mdpad/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  66. mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  67. mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  68. mdpad/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  69. mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  70. mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  71. mdpad/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  72. mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  73. mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  74. mdpad/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  75. mdpad/static/vendor/katex/katex.min.css +1 -0
  76. mdpad/static/vendor/katex/katex.min.js +1 -0
  77. mdpad/static/vendor/markdown-it.min.js +2 -0
  78. mdpad/static/vendor/mermaid/mermaid.min.js +2029 -0
  79. mdpad/summaries.py +393 -0
  80. mdpad-0.1.0.dist-info/METADATA +97 -0
  81. mdpad-0.1.0.dist-info/RECORD +84 -0
  82. mdpad-0.1.0.dist-info/WHEEL +4 -0
  83. mdpad-0.1.0.dist-info/entry_points.txt +4 -0
  84. mdpad-0.1.0.dist-info/licenses/LICENSE +201 -0
mdpad/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mdpad — context-aware scientific writing environment (PoC)."""
2
+
3
+ from .app import main
4
+
5
+ __all__ = ["main"]
mdpad/app.py ADDED
@@ -0,0 +1,619 @@
1
+ """FastAPI application: serves the single-page workspace and its JSON endpoints.
2
+
3
+ Provider config lives in the browser (localStorage) and travels with each revise
4
+ request. The base URL and API key can also be supplied server-side via a
5
+ ``.env`` file (``MDPAD_BASE_URL`` / ``MDPAD_API_KEY`` — see ``mdpad-configure``)
6
+ so the key is never exposed to client code; the browser then only picks a
7
+ model. A blank field in a request falls back to the server default.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import time
15
+ import webbrowser
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ import pystray
22
+ import uvicorn
23
+
24
+ from dotenv import load_dotenv
25
+ from fastapi import FastAPI, HTTPException
26
+ from fastapi.responses import FileResponse
27
+ from fastapi.staticfiles import StaticFiles
28
+ from pydantic import BaseModel, Field
29
+
30
+ from . import applog
31
+ from . import context as ctx
32
+ from . import provider as prov
33
+ from . import summaries as summ
34
+ from .sections import block_at, parse_blocks, parse_sections
35
+
36
+ load_dotenv()
37
+ # Server-side provider defaults, set via `.env` (see `mdpad-configure`). Never
38
+ # sent to the browser; blank means no server default (offline mock unless a
39
+ # request supplies its own base URL + key). GPUSTACK_TEST_API_KEY is a legacy
40
+ # alias kept for existing `.env` files.
41
+ _server_api_key = os.getenv("MDPAD_API_KEY") or os.getenv("GPUSTACK_TEST_API_KEY")
42
+ SERVER_API_KEY: str = _server_api_key if _server_api_key else ""
43
+ SERVER_BASE_URL = (os.getenv("MDPAD_BASE_URL") or "").rstrip("/")
44
+
45
+ _HERE = Path(__file__).parent
46
+ _STATIC = _HERE / "static"
47
+ _DEFAULT_DOC = (_HERE / "example" / "book.md").read_text(encoding="utf-8")
48
+ _PROJECT_SUMMARY = (
49
+ "A vision/review manuscript on representation dynamics and the physics of "
50
+ "information processing. Audience: researchers in representation learning. "
51
+ "Tone: precise, hypothesis-aware, mathematically careful."
52
+ )
53
+
54
+ # The manuscript served to the browser. Defaults to the bundled example; a file
55
+ # path passed to `mdpad` (e.g. `uv run mdpad test.md`) replaces it at startup.
56
+ _current_doc = _DEFAULT_DOC
57
+ _current_name: str | None = None
58
+
59
+ # Projects: each is a folder of flat .md files (+ .summ.json sidecars) plus a
60
+ # shared history/ of save snapshots. PROJECTS_ROOT is where "New Project"
61
+ # creates one by default; PROJECT_DIR is whichever project is currently open
62
+ # (None until one is opened/created -- open/save/history are gated on it).
63
+ PROJECTS_ROOT = Path.cwd() / "projects"
64
+ PROJECT_DIR: Path | None = None
65
+ _NAME_RE = re.compile(r"^[\w.\-]+\.md$")
66
+ _PROJECT_NAME_RE = re.compile(r"^[\w.\- ]+$")
67
+ HISTORY_RETENTION_DAYS = 30
68
+ DEFAULT_PORT = 8670
69
+
70
+ app = FastAPI(title="mdpad")
71
+
72
+
73
+ def _require_project() -> Path:
74
+ if PROJECT_DIR is None:
75
+ raise HTTPException(400, "No project is open. Create or open a project first.")
76
+ return PROJECT_DIR
77
+
78
+
79
+ def _safe_doc_path(name: str) -> Path:
80
+ """Resolve ``name`` to a .md file directly inside PROJECT_DIR (no traversal)."""
81
+ project_dir = _require_project()
82
+ if not _NAME_RE.match(name):
83
+ raise HTTPException(400, "Invalid filename (use letters, digits, . - _ and a .md extension).")
84
+ path = (project_dir / name).resolve()
85
+ if path.parent != project_dir.resolve():
86
+ raise HTTPException(400, "Filename must not point outside the project folder.")
87
+ return path
88
+
89
+
90
+ def _summ_path(name: str) -> Path:
91
+ """Sidecar file caching a document's summaries: ``<name>.summ.json``."""
92
+ return _require_project() / f"{name}.summ.json"
93
+
94
+
95
+ def _history_dir() -> Path:
96
+ return _require_project() / "history"
97
+
98
+
99
+ def _snapshot_timestamp() -> str:
100
+ now = datetime.now()
101
+ return now.strftime("%y%m%d_%H%M%S") + f".{now.microsecond:06d}"
102
+
103
+
104
+ def _safe_project_path(name: str) -> Path:
105
+ """Resolve ``name`` to a project directory directly inside PROJECTS_ROOT."""
106
+ if not _PROJECT_NAME_RE.match(name):
107
+ raise HTTPException(400, "Invalid project name (use letters, digits, spaces, . - _).")
108
+ path = (PROJECTS_ROOT / name).resolve()
109
+ if path.parent != PROJECTS_ROOT.resolve():
110
+ raise HTTPException(400, "Project name must not point outside the projects folder.")
111
+ return path
112
+
113
+
114
+ def _open_project(project_dir: Path) -> None:
115
+ global PROJECT_DIR, _current_doc, _current_name
116
+ PROJECT_DIR = project_dir
117
+ files = sorted(p.name for p in project_dir.glob("*.md") if p.is_file())
118
+ first = files[0] if files else None
119
+ if first:
120
+ _current_doc = (project_dir / first).read_text(encoding="utf-8")
121
+ _current_name = first
122
+ summ.load(_summ_path(first))
123
+ else:
124
+ _current_doc = ""
125
+ _current_name = None
126
+ summ.reset()
127
+
128
+
129
+ class ProviderSettings(BaseModel):
130
+ base_url: str = ""
131
+ api_key: str = ""
132
+ model: str = ""
133
+ temperature: float = 0.3
134
+ max_tokens: int = 1024
135
+
136
+
137
+ class ReviseRequest(BaseModel):
138
+ text: str
139
+ sel_start: int
140
+ sel_end: int
141
+ instruction: str
142
+ provider: ProviderSettings = Field(default_factory=ProviderSettings)
143
+
144
+
145
+ class SummariesRefreshRequest(BaseModel):
146
+ text: str
147
+ mode: str = "invalidated"
148
+ provider: ProviderSettings = Field(default_factory=ProviderSettings)
149
+
150
+
151
+ class SummariesEstimateRequest(BaseModel):
152
+ text: str
153
+ mode: str = "all"
154
+
155
+
156
+ class SummariesStatusRequest(BaseModel):
157
+ text: str = ""
158
+
159
+
160
+ class ContextMapRequest(BaseModel):
161
+ text: str
162
+ offset: int = 0
163
+
164
+
165
+ def _resolve_endpoint(base_url: str, api_key: str) -> tuple[str, str]:
166
+ """Resolve (base_url, api_key) without ever leaking the server key.
167
+
168
+ The server-side .env key is only ever paired with the server's own base URL.
169
+ A caller-supplied custom base URL must bring its own key -- otherwise a
170
+ forged request (e.g. ?base_url=http://attacker/) could exfiltrate the key.
171
+ Exception: a local endpoint (e.g. Ollama/LM Studio on localhost) carries
172
+ no server secret either way, so it's fine with no key at all.
173
+ """
174
+ bu = (base_url or SERVER_BASE_URL).rstrip("/")
175
+ if bu == SERVER_BASE_URL:
176
+ return bu, (api_key or SERVER_API_KEY)
177
+ if not api_key and not prov.is_local(bu):
178
+ raise ValueError("A custom (non-local) base URL requires its own API key.")
179
+ return bu, api_key
180
+
181
+
182
+ # Model ids whose names indicate a non-chat model (won't work with /chat/completions).
183
+ _NON_CHAT_HINTS = ("whisper", "embedding", "rerank", "-tts", "-stt")
184
+
185
+
186
+ def _chat_models(ids: list[str]) -> list[str]:
187
+ return [m for m in ids if not any(h in m.lower() for h in _NON_CHAT_HINTS)]
188
+
189
+
190
+ @app.get("/")
191
+ def index() -> FileResponse:
192
+ return FileResponse(_STATIC / "index.html")
193
+
194
+
195
+ @app.get("/api/document")
196
+ def document() -> dict:
197
+ return {"text": _current_doc, "project_summary": _PROJECT_SUMMARY, "name": _current_name}
198
+
199
+
200
+ @app.get("/api/config")
201
+ def provider_config() -> dict:
202
+ """Non-secret provider defaults for the UI (never returns the key itself)."""
203
+ return {"base_url": SERVER_BASE_URL, "has_key": bool(SERVER_API_KEY)}
204
+
205
+
206
+ @app.get("/api/usage")
207
+ def usage() -> dict:
208
+ """Cumulative token usage for this server session."""
209
+ return prov.usage_totals()
210
+
211
+
212
+ @app.get("/api/logs")
213
+ def get_logs(since: int = 0) -> dict:
214
+ """Backend events (provider errors, mock-fallback notices, summary-refresh
215
+ results) newer than ``since`` -- polled by the dock's Logs tab."""
216
+ return {"logs": applog.get_logs(since)}
217
+
218
+
219
+ @app.post("/api/logs/clear")
220
+ def clear_logs() -> dict:
221
+ applog.clear()
222
+ return {"ok": True}
223
+
224
+
225
+ @app.get("/api/models")
226
+ def list_models_endpoint(base_url: str = "", api_key: str = "") -> dict:
227
+ """Fetch the OpenAI-compatible model list. Blank args use the server defaults."""
228
+ bu = (base_url or SERVER_BASE_URL).rstrip("/")
229
+ try:
230
+ _, ak = _resolve_endpoint(base_url, api_key)
231
+ models = _chat_models(prov.list_models(base_url=bu, api_key=ak))
232
+ return {"models": models, "base_url": bu, "has_key": bool(ak), "error": None}
233
+ except (ValueError, prov.ProviderError) as e:
234
+ applog.log("error", "models", str(e))
235
+ return {"models": [], "base_url": bu, "has_key": bool(api_key), "error": str(e)}
236
+
237
+
238
+ @app.get("/api/files")
239
+ def list_files() -> dict:
240
+ if PROJECT_DIR is None:
241
+ return {"dir": None, "files": [], "current": _current_name}
242
+ files = sorted(p.name for p in PROJECT_DIR.glob("*.md") if p.is_file())
243
+ return {"dir": str(PROJECT_DIR), "files": files, "current": _current_name}
244
+
245
+
246
+ class SaveRequest(BaseModel):
247
+ name: str
248
+ text: str
249
+
250
+
251
+ @app.get("/api/file/{name}")
252
+ def read_file(name: str) -> dict:
253
+ global _current_doc, _current_name
254
+ path = _safe_doc_path(name)
255
+ if not path.is_file():
256
+ raise HTTPException(404, f"No such file in {PROJECT_DIR}: {name}")
257
+ _current_doc = path.read_text(encoding="utf-8")
258
+ _current_name = name
259
+ summ.load(_summ_path(name)) # reuse this doc's cached summaries if present
260
+ return {"name": name, "text": _current_doc}
261
+
262
+
263
+ @app.post("/api/file")
264
+ def save_file(req: SaveRequest) -> dict:
265
+ global _current_doc, _current_name
266
+ path = _safe_doc_path(req.name)
267
+ path.write_text(req.text, encoding="utf-8")
268
+ _current_doc = req.text
269
+ _current_name = req.name
270
+ if summ._STORE: # persist current summaries alongside the document
271
+ summ.save(_summ_path(req.name))
272
+ history_dir = _history_dir()
273
+ history_dir.mkdir(exist_ok=True) # defensive: projects predating this feature
274
+ (history_dir / f"{req.name}_{_snapshot_timestamp()}.md").write_text(req.text, encoding="utf-8")
275
+ return {"ok": True, "name": req.name}
276
+
277
+
278
+ class ProjectCreateRequest(BaseModel):
279
+ name: str = ""
280
+ path: str = ""
281
+
282
+
283
+ class ProjectOpenRequest(BaseModel):
284
+ name: str = ""
285
+ path: str = ""
286
+
287
+
288
+ _README_TEMPLATE = "# {name}\n"
289
+
290
+
291
+ @app.get("/api/projects")
292
+ def list_projects() -> dict:
293
+ PROJECTS_ROOT.mkdir(exist_ok=True)
294
+ projects = sorted(p.name for p in PROJECTS_ROOT.iterdir() if p.is_dir())
295
+ return {"projects": projects, "current": PROJECT_DIR.name if PROJECT_DIR else None}
296
+
297
+
298
+ @app.post("/api/projects")
299
+ def create_project(req: ProjectCreateRequest) -> dict:
300
+ if req.path:
301
+ project_dir = Path(req.path).expanduser().resolve()
302
+ name = project_dir.name
303
+ elif req.name:
304
+ project_dir = _safe_project_path(req.name)
305
+ name = req.name
306
+ else:
307
+ raise HTTPException(400, "A project name or path is required.")
308
+ if project_dir.exists():
309
+ raise HTTPException(400, f"Project already exists: {project_dir}")
310
+ project_dir.mkdir(parents=True)
311
+ (project_dir / "history").mkdir()
312
+ (project_dir / "README.md").write_text(_README_TEMPLATE.format(name=name), encoding="utf-8")
313
+ _open_project(project_dir)
314
+ return list_files()
315
+
316
+
317
+ @app.post("/api/projects/open")
318
+ def open_project(req: ProjectOpenRequest) -> dict:
319
+ if req.path:
320
+ project_dir = Path(req.path).expanduser().resolve()
321
+ elif req.name:
322
+ project_dir = _safe_project_path(req.name)
323
+ else:
324
+ raise HTTPException(400, "A project name or path is required.")
325
+ if not project_dir.is_dir():
326
+ raise HTTPException(404, f"No such project folder: {project_dir}")
327
+ _open_project(project_dir)
328
+ return list_files()
329
+
330
+
331
+ @app.get("/api/history/status")
332
+ def history_status() -> dict:
333
+ if PROJECT_DIR is None:
334
+ return {"total_bytes": 0}
335
+ history_dir = PROJECT_DIR / "history"
336
+ if not history_dir.is_dir() or (history_dir / "keepforever.md").exists():
337
+ return {"total_bytes": 0}
338
+ cutoff = time.time() - HISTORY_RETENTION_DAYS * 86400
339
+ total = sum(
340
+ f.stat().st_size for f in history_dir.glob("*.md") if f.name != "keepforever.md" and f.stat().st_mtime < cutoff
341
+ )
342
+ return {"total_bytes": total}
343
+
344
+
345
+ class HistoryCleanupRequest(BaseModel):
346
+ action: str # "delete" | "keep_forever" | "skip"
347
+
348
+
349
+ @app.post("/api/history/cleanup")
350
+ def history_cleanup(req: HistoryCleanupRequest) -> dict:
351
+ if PROJECT_DIR is None:
352
+ return {"ok": True}
353
+ history_dir = PROJECT_DIR / "history"
354
+ if req.action == "keep_forever":
355
+ history_dir.mkdir(exist_ok=True)
356
+ (history_dir / "keepforever.md").write_text("", encoding="utf-8")
357
+ elif req.action == "delete":
358
+ if history_dir.is_dir():
359
+ cutoff = time.time() - HISTORY_RETENTION_DAYS * 86400
360
+ for f in history_dir.glob("*.md"):
361
+ if f.name != "keepforever.md" and f.stat().st_mtime < cutoff:
362
+ f.unlink()
363
+ elif req.action != "skip":
364
+ raise HTTPException(400, f"Unknown action: {req.action!r}")
365
+ return {"ok": True}
366
+
367
+
368
+ @app.post("/api/revise")
369
+ def revise(req: ReviseRequest) -> dict:
370
+ selection = req.text[req.sel_start : req.sel_end]
371
+ sections = parse_sections(req.text)
372
+ blocks = parse_blocks(req.text)
373
+ target_block = block_at(blocks, req.sel_start, req.text)
374
+ owner_section = None
375
+ if target_block is not None:
376
+ owner_section = next((s for s in sections if s.id == target_block.section_id), None)
377
+ package = ctx.build_context(
378
+ project_summary=_PROJECT_SUMMARY,
379
+ sections=sections,
380
+ blocks=blocks,
381
+ target_block=target_block,
382
+ selection=selection,
383
+ instruction=req.instruction,
384
+ store=summ._STORE,
385
+ )
386
+ proposal = None
387
+ error = None
388
+ try:
389
+ d = req.provider.model_dump()
390
+ d["base_url"], d["api_key"] = _resolve_endpoint(d.get("base_url", ""), d.get("api_key", ""))
391
+ proposal = prov.revise(
392
+ package_text=package.as_prompt(),
393
+ selection=selection,
394
+ instruction=req.instruction,
395
+ provider=d,
396
+ )
397
+ except (ValueError, prov.ProviderError) as e:
398
+ error = str(e)
399
+ applog.log("error", "revise", error)
400
+ if proposal is not None and proposal.mock:
401
+ applog.log("info", "revise", "No provider configured -- returned the offline mock.")
402
+ return {
403
+ "section": owner_section.title if owner_section else None,
404
+ "context": package.as_dict(),
405
+ "prompt": {"system": prov.SYSTEM_PROMPT, "user": package.as_prompt()},
406
+ "proposal": proposal.as_dict() if proposal else None,
407
+ "error": error,
408
+ "usage": prov.usage_totals(),
409
+ }
410
+
411
+
412
+ @app.post("/api/summaries/refresh")
413
+ def summaries_refresh(req: SummariesRefreshRequest) -> dict:
414
+ sections = parse_sections(req.text)
415
+ blocks = parse_blocks(req.text)
416
+ d = req.provider.model_dump()
417
+ try:
418
+ d["base_url"], d["api_key"] = _resolve_endpoint(d.get("base_url", ""), d.get("api_key", ""))
419
+ except ValueError as e:
420
+ return {"generated": 0, "checked": 0, "skipped": 0, "tokens": 0, "error": str(e)}
421
+ result = summ.refresh(sections, blocks, req.text, d, req.mode)
422
+ if _current_name and summ._STORE: # only persist when there's something to save
423
+ summ.save(_summ_path(_current_name)) # so a reopen reuses them
424
+ result["usage"] = prov.usage_totals()
425
+ return result
426
+
427
+
428
+ @app.post("/api/summaries/estimate")
429
+ def summaries_estimate(req: SummariesEstimateRequest) -> dict:
430
+ sections = parse_sections(req.text)
431
+ blocks = parse_blocks(req.text)
432
+ return summ.estimate(sections, blocks, req.mode)
433
+
434
+
435
+ @app.get("/api/summaries/progress")
436
+ def summaries_progress() -> dict:
437
+ """Live done/total for whichever summaries/refresh call is in flight (if
438
+ any) -- polled by the frontend so a multi-item batch isn't silent."""
439
+ return summ.get_progress()
440
+
441
+
442
+ @app.post("/api/summaries")
443
+ def summaries_status(req: SummariesStatusRequest) -> dict:
444
+ text = req.text or _current_doc
445
+ sections = parse_sections(text)
446
+ blocks = parse_blocks(text)
447
+ return summ.status(sections, blocks, text)
448
+
449
+
450
+ @app.post("/api/context/map")
451
+ def context_map(req: ContextMapRequest) -> dict:
452
+ """The summary hierarchy for the paragraph at ``offset`` (no LLM): the same
453
+ items build_context assembles, plus per-unit validity and counts."""
454
+ sections = parse_sections(req.text)
455
+ blocks = parse_blocks(req.text)
456
+ target = block_at(blocks, req.offset, req.text)
457
+ package = ctx.build_context(
458
+ project_summary=_PROJECT_SUMMARY,
459
+ sections=sections,
460
+ blocks=blocks,
461
+ target_block=target,
462
+ selection="",
463
+ instruction="",
464
+ store=summ._STORE,
465
+ )
466
+ return {
467
+ "items": package.as_dict()["items"],
468
+ "counts": summ.status(sections, blocks, req.text),
469
+ "current": target.id if target else None,
470
+ "tree": ctx.build_tree(
471
+ sections=sections,
472
+ blocks=blocks,
473
+ target_block=target,
474
+ store=summ._STORE,
475
+ states=summ.validity(sections, blocks, req.text),
476
+ ),
477
+ }
478
+
479
+
480
+ @app.middleware("http")
481
+ async def _revalidate_assets(request, call_next):
482
+ """Always revalidate the app's HTML/CSS/JS so edits show up on a normal reload
483
+ (no hard-refresh needed). Vendored libs (fonts/KaTeX) stay cacheable."""
484
+ resp = await call_next(request)
485
+ p = request.url.path
486
+ if p == "/" or (p.startswith("/static/") and "/vendor/" not in p):
487
+ resp.headers["Cache-Control"] = "no-cache"
488
+ return resp
489
+
490
+
491
+ app.mount("/static", StaticFiles(directory=_STATIC), name="static")
492
+
493
+
494
+ def _free_port(preferred: int) -> int:
495
+ """Return ``preferred`` if bindable, else an OS-assigned free port."""
496
+ import socket
497
+
498
+ for candidate in (preferred, 0):
499
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
500
+ try:
501
+ s.bind(("127.0.0.1", candidate))
502
+ return s.getsockname()[1]
503
+ except OSError:
504
+ continue
505
+ return preferred
506
+
507
+
508
+ def _open_when_ready(url: str) -> None:
509
+ """Open the browser only once the server answers (avoids ERR_EMPTY_RESPONSE)."""
510
+ import time
511
+ import urllib.request
512
+
513
+ for _ in range(60):
514
+ try:
515
+ urllib.request.urlopen(url, timeout=0.5)
516
+ break
517
+ except Exception:
518
+ time.sleep(0.2)
519
+ try:
520
+ webbrowser.open(url)
521
+ except Exception:
522
+ pass
523
+
524
+
525
+ def _tray_image():
526
+ """A small rounded-square icon mirroring static/favicon.svg's spectrum-bar
527
+ motif (teal bars on graphite), drawn with Pillow -- no asset file needed."""
528
+ from PIL import Image, ImageDraw
529
+
530
+ size = 64
531
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
532
+ draw = ImageDraw.Draw(img)
533
+ draw.rounded_rectangle([0, 0, size - 1, size - 1], radius=14, fill=(26, 29, 33, 255))
534
+ bars = [(12, 36, 6, 16), (22, 14, 6, 38), (32, 28, 6, 24), (42, 10, 6, 42), (52, 38, 6, 14)]
535
+ for x, y, w, h in bars:
536
+ draw.rounded_rectangle([x, y, x + w, y + h], radius=2, fill=(47, 111, 107, 255))
537
+ return img
538
+
539
+
540
+ def _build_tray_icon(url: str, server: uvicorn.Server) -> pystray.Icon | None:
541
+ """A pystray.Icon with Open/Quit, or None if no tray backend is available
542
+ (headless CI, SSH session, missing OS integration, etc.) -- callers must
543
+ fall back to the plain blocking server in that case."""
544
+ try:
545
+ import pystray
546
+ except ImportError:
547
+ return None
548
+
549
+ def _open(icon=None, item=None) -> None:
550
+ webbrowser.open(url)
551
+
552
+ def _quit(icon, item) -> None:
553
+ server.should_exit = True
554
+ icon.stop()
555
+
556
+ return pystray.Icon(
557
+ "mdpad",
558
+ _tray_image(),
559
+ "mdpad",
560
+ menu=pystray.Menu(
561
+ pystray.MenuItem("Open mdpad", _open, default=True),
562
+ pystray.MenuItem("Quit", _quit),
563
+ ),
564
+ )
565
+
566
+
567
+ def main() -> None:
568
+ """Launch the local server and open the workspace in a browser.
569
+
570
+ Usage: ``mdpad [FILE.md]`` -- an optional Markdown file to open instead of
571
+ the bundled example. Its parent directory becomes the open project.
572
+
573
+ Where a tray backend is available (Windows/macOS/most Linux desktops),
574
+ mdpad runs as a tray icon with "Open mdpad" / "Quit", and the server runs
575
+ in a background thread. Otherwise (headless/CI/SSH) it falls back to the
576
+ previous behavior: block in the foreground, open one browser tab.
577
+ """
578
+ import sys
579
+ import threading
580
+
581
+ import uvicorn
582
+
583
+ global PROJECT_DIR, _current_doc, _current_name
584
+ paths = [a for a in sys.argv[1:] if not a.startswith("-")]
585
+ if paths:
586
+ target = Path(paths[0]).resolve()
587
+ if target.is_file():
588
+ PROJECT_DIR = target.parent
589
+ (PROJECT_DIR / "history").mkdir(exist_ok=True)
590
+ _current_doc = target.read_text(encoding="utf-8")
591
+ _current_name = target.name
592
+ n = summ.load(_summ_path(target.name))
593
+ print(f"mdpad: loaded {target}" + (f" (+{n} cached summaries)" if n else ""))
594
+ else:
595
+ print(f"mdpad: file not found, using the bundled example: {target}")
596
+
597
+ host = "127.0.0.1"
598
+ port = _free_port(DEFAULT_PORT)
599
+ url = f"http://{host}:{port}"
600
+ if port != DEFAULT_PORT:
601
+ print(f"mdpad: port {DEFAULT_PORT} busy, using {port}")
602
+ print(f"mdpad: serving on {url}")
603
+
604
+ config = uvicorn.Config(app, host=host, port=port, log_level="info")
605
+ server = uvicorn.Server(config)
606
+ icon = _build_tray_icon(url, server)
607
+ threading.Thread(target=_open_when_ready, args=(url,), daemon=True).start()
608
+
609
+ if icon is None:
610
+ server.run()
611
+ return
612
+
613
+ server_thread = threading.Thread(target=server.run, daemon=True)
614
+ server_thread.start()
615
+ try:
616
+ icon.run() # blocks on the main thread (required on macOS) until Quit
617
+ except Exception as e:
618
+ print(f"mdpad: tray icon unavailable ({e}); running without it. Ctrl+C to stop.")
619
+ server_thread.join()
mdpad/applog.py ADDED
@@ -0,0 +1,30 @@
1
+ """In-memory ring buffer of backend events (provider errors, mock-fallback
2
+ notices, summary-refresh results) -- surfaced in the frontend's Logs tab.
3
+ Nothing here is persisted to disk; it resets on restart.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from collections import deque
10
+
11
+ _MAX_LOGS = 200
12
+ _LOGS: deque[dict] = deque(maxlen=_MAX_LOGS)
13
+ _seq = 0
14
+
15
+
16
+ def log(level: str, source: str, message: str) -> None:
17
+ """Record one event. ``level`` is "info" | "warn" | "error"."""
18
+ global _seq
19
+ _seq += 1
20
+ _LOGS.append({"id": _seq, "ts": time.time(), "level": level, "source": source, "message": message})
21
+
22
+
23
+ def get_logs(since_id: int = 0) -> list[dict]:
24
+ """Entries with id > since_id, oldest first. ``since_id=0`` returns all
25
+ currently retained entries (older ones fall off the ring buffer)."""
26
+ return [e for e in _LOGS if e["id"] > since_id]
27
+
28
+
29
+ def clear() -> None:
30
+ _LOGS.clear()