langstage-codraft 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 (150) hide show
  1. langstage_codraft/__init__.py +42 -0
  2. langstage_codraft/agent.py +301 -0
  3. langstage_codraft/cli.py +237 -0
  4. langstage_codraft/config.py +58 -0
  5. langstage_codraft/middleware.py +133 -0
  6. langstage_codraft/pages.py +46 -0
  7. langstage_codraft/render.py +243 -0
  8. langstage_codraft/server.py +465 -0
  9. langstage_codraft/static/assets/embed-DBsb0C1n.js +201 -0
  10. langstage_codraft/static/assets/index-aAs5o0Qg.js +280 -0
  11. langstage_codraft/static/assets/index-yhQ7mwPb.css +1 -0
  12. langstage_codraft/static/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  13. langstage_codraft/static/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  14. langstage_codraft/static/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  15. langstage_codraft/static/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  16. langstage_codraft/static/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  17. langstage_codraft/static/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  18. langstage_codraft/static/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  19. langstage_codraft/static/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  20. langstage_codraft/static/assets/inter-cyrillic-800-normal-C7MGvYyJ.woff2 +0 -0
  21. langstage_codraft/static/assets/inter-cyrillic-800-normal-CCHyn08d.woff +0 -0
  22. langstage_codraft/static/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  23. langstage_codraft/static/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  24. langstage_codraft/static/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  25. langstage_codraft/static/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  26. langstage_codraft/static/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  27. langstage_codraft/static/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  28. langstage_codraft/static/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  29. langstage_codraft/static/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  30. langstage_codraft/static/assets/inter-cyrillic-ext-800-normal-BZOjs1Xv.woff2 +0 -0
  31. langstage_codraft/static/assets/inter-cyrillic-ext-800-normal-Ca-gJeZY.woff +0 -0
  32. langstage_codraft/static/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  33. langstage_codraft/static/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  34. langstage_codraft/static/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  35. langstage_codraft/static/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  36. langstage_codraft/static/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  37. langstage_codraft/static/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  38. langstage_codraft/static/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  39. langstage_codraft/static/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  40. langstage_codraft/static/assets/inter-greek-800-normal-BU00tryP.woff +0 -0
  41. langstage_codraft/static/assets/inter-greek-800-normal-CLIouy3y.woff2 +0 -0
  42. langstage_codraft/static/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  43. langstage_codraft/static/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  44. langstage_codraft/static/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  45. langstage_codraft/static/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  46. langstage_codraft/static/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  47. langstage_codraft/static/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  48. langstage_codraft/static/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  49. langstage_codraft/static/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  50. langstage_codraft/static/assets/inter-greek-ext-800-normal-B--PVpEC.woff2 +0 -0
  51. langstage_codraft/static/assets/inter-greek-ext-800-normal-DUe57HfS.woff +0 -0
  52. langstage_codraft/static/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  53. langstage_codraft/static/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  54. langstage_codraft/static/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  55. langstage_codraft/static/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  56. langstage_codraft/static/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  57. langstage_codraft/static/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  58. langstage_codraft/static/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  59. langstage_codraft/static/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  60. langstage_codraft/static/assets/inter-latin-800-normal-BYj_oED-.woff2 +0 -0
  61. langstage_codraft/static/assets/inter-latin-800-normal-D1mf63XC.woff +0 -0
  62. langstage_codraft/static/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  63. langstage_codraft/static/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  64. langstage_codraft/static/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  65. langstage_codraft/static/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  66. langstage_codraft/static/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  67. langstage_codraft/static/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  68. langstage_codraft/static/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  69. langstage_codraft/static/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  70. langstage_codraft/static/assets/inter-latin-ext-800-normal-BOMpwxm3.woff +0 -0
  71. langstage_codraft/static/assets/inter-latin-ext-800-normal-DZJjya6U.woff2 +0 -0
  72. langstage_codraft/static/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  73. langstage_codraft/static/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  74. langstage_codraft/static/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  75. langstage_codraft/static/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  76. langstage_codraft/static/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  77. langstage_codraft/static/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  78. langstage_codraft/static/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  79. langstage_codraft/static/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  80. langstage_codraft/static/assets/inter-vietnamese-800-normal-Cm7tD1pz.woff2 +0 -0
  81. langstage_codraft/static/assets/inter-vietnamese-800-normal-DDlpr_Ee.woff +0 -0
  82. langstage_codraft/static/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  83. langstage_codraft/static/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  84. langstage_codraft/static/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  85. langstage_codraft/static/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  86. langstage_codraft/static/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  87. langstage_codraft/static/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  88. langstage_codraft/static/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  89. langstage_codraft/static/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  90. langstage_codraft/static/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  91. langstage_codraft/static/assets/source-serif-4-cyrillic-400-italic-BLN19cOa.woff2 +0 -0
  92. langstage_codraft/static/assets/source-serif-4-cyrillic-400-italic-C-sRFhvU.woff +0 -0
  93. langstage_codraft/static/assets/source-serif-4-cyrillic-400-normal-C44pakCn.woff2 +0 -0
  94. langstage_codraft/static/assets/source-serif-4-cyrillic-400-normal-DEwCKrdL.woff +0 -0
  95. langstage_codraft/static/assets/source-serif-4-cyrillic-600-normal-BDP5YDHD.woff2 +0 -0
  96. langstage_codraft/static/assets/source-serif-4-cyrillic-600-normal-CHh5eQWW.woff +0 -0
  97. langstage_codraft/static/assets/source-serif-4-cyrillic-700-normal-BK249n6E.woff +0 -0
  98. langstage_codraft/static/assets/source-serif-4-cyrillic-700-normal-BmBwwUw2.woff2 +0 -0
  99. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-italic-Drp6JL_V.woff +0 -0
  100. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-italic-zoDFlEzN.woff2 +0 -0
  101. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-normal-CWV284fw.woff2 +0 -0
  102. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-normal-CYkrUebz.woff +0 -0
  103. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-600-normal-D_E8FDgP.woff2 +0 -0
  104. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-600-normal-h2VnbV3V.woff +0 -0
  105. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-700-normal-B9R79AaY.woff2 +0 -0
  106. langstage_codraft/static/assets/source-serif-4-cyrillic-ext-700-normal-K0LivNVE.woff +0 -0
  107. langstage_codraft/static/assets/source-serif-4-greek-400-italic-C7D4Dq0H.woff2 +0 -0
  108. langstage_codraft/static/assets/source-serif-4-greek-400-italic-Ce3U_qGW.woff +0 -0
  109. langstage_codraft/static/assets/source-serif-4-greek-400-normal-Cw5-AHNN.woff2 +0 -0
  110. langstage_codraft/static/assets/source-serif-4-greek-400-normal-D_DO3bho.woff +0 -0
  111. langstage_codraft/static/assets/source-serif-4-greek-600-normal-DdRkKr-1.woff2 +0 -0
  112. langstage_codraft/static/assets/source-serif-4-greek-600-normal-OkzgCkGB.woff +0 -0
  113. langstage_codraft/static/assets/source-serif-4-greek-700-normal-BrLlvq6D.woff +0 -0
  114. langstage_codraft/static/assets/source-serif-4-greek-700-normal-BzuEQSqJ.woff2 +0 -0
  115. langstage_codraft/static/assets/source-serif-4-latin-400-italic-0j97Jm6B.woff2 +0 -0
  116. langstage_codraft/static/assets/source-serif-4-latin-400-italic-DZulBB6l.woff +0 -0
  117. langstage_codraft/static/assets/source-serif-4-latin-400-normal-C1O10bxN.woff +0 -0
  118. langstage_codraft/static/assets/source-serif-4-latin-400-normal-DJ5YJwmz.woff2 +0 -0
  119. langstage_codraft/static/assets/source-serif-4-latin-600-normal-DeJomR3S.woff +0 -0
  120. langstage_codraft/static/assets/source-serif-4-latin-600-normal-DouSKlru.woff2 +0 -0
  121. langstage_codraft/static/assets/source-serif-4-latin-700-normal-C30JPKlG.woff +0 -0
  122. langstage_codraft/static/assets/source-serif-4-latin-700-normal-CGGdTIBe.woff2 +0 -0
  123. langstage_codraft/static/assets/source-serif-4-latin-ext-400-italic-Cj8BerOQ.woff2 +0 -0
  124. langstage_codraft/static/assets/source-serif-4-latin-ext-400-italic-Cttr6ITu.woff +0 -0
  125. langstage_codraft/static/assets/source-serif-4-latin-ext-400-normal-B5LksYz5.woff2 +0 -0
  126. langstage_codraft/static/assets/source-serif-4-latin-ext-400-normal-DKmgyNre.woff +0 -0
  127. langstage_codraft/static/assets/source-serif-4-latin-ext-600-normal-B2fExS53.woff +0 -0
  128. langstage_codraft/static/assets/source-serif-4-latin-ext-600-normal-pa-C5Dkf.woff2 +0 -0
  129. langstage_codraft/static/assets/source-serif-4-latin-ext-700-normal-D0Q30MFM.woff2 +0 -0
  130. langstage_codraft/static/assets/source-serif-4-latin-ext-700-normal-PGcHY9mz.woff +0 -0
  131. langstage_codraft/static/assets/source-serif-4-vietnamese-400-italic-7zG0SGDK.woff +0 -0
  132. langstage_codraft/static/assets/source-serif-4-vietnamese-400-italic-B7_QYl3c.woff2 +0 -0
  133. langstage_codraft/static/assets/source-serif-4-vietnamese-400-normal-9XEw6Pmr.woff +0 -0
  134. langstage_codraft/static/assets/source-serif-4-vietnamese-400-normal-DFDfvBrN.woff2 +0 -0
  135. langstage_codraft/static/assets/source-serif-4-vietnamese-600-normal-B33FcZBi.woff2 +0 -0
  136. langstage_codraft/static/assets/source-serif-4-vietnamese-600-normal-CKUOBVO_.woff +0 -0
  137. langstage_codraft/static/assets/source-serif-4-vietnamese-700-normal-CAL-i-xy.woff2 +0 -0
  138. langstage_codraft/static/assets/source-serif-4-vietnamese-700-normal-DTkW1D86.woff +0 -0
  139. langstage_codraft/static/favicon.svg +1 -0
  140. langstage_codraft/static/index.html +14 -0
  141. langstage_codraft/store.py +801 -0
  142. langstage_codraft/templates.py +143 -0
  143. langstage_codraft/textutil.py +95 -0
  144. langstage_codraft/themes.py +95 -0
  145. langstage_codraft/tools.py +574 -0
  146. langstage_codraft-0.1.0.dist-info/METADATA +222 -0
  147. langstage_codraft-0.1.0.dist-info/RECORD +150 -0
  148. langstage_codraft-0.1.0.dist-info/WHEEL +4 -0
  149. langstage_codraft-0.1.0.dist-info/entry_points.txt +2 -0
  150. langstage_codraft-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,42 @@
1
+ """langstage-codraft: the document stage for your LangGraph agent.
2
+
3
+ Bring your own agent::
4
+
5
+ from langchain.agents import create_agent
6
+ from langstage_codraft import CodraftMiddleware
7
+
8
+ graph = create_agent("openai:gpt-4.1-mini", middleware=[CodraftMiddleware()])
9
+
10
+ or a factory Codraft calls with its tools and middleware::
11
+
12
+ def make(tools, middleware):
13
+ return create_agent(my_model, tools=[*tools, *my_tools], middleware=middleware)
14
+
15
+ then ``langstage-codraft run --agent my_agent.py:graph`` (or ``:make``).
16
+ """
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ try:
20
+ __version__ = version("langstage-codraft")
21
+ except PackageNotFoundError: # pragma: no cover - source checkout
22
+ __version__ = "0.0.0+local"
23
+
24
+
25
+ def __getattr__(name: str):
26
+ # Lazy so `import langstage_codraft` (and the CLI's --version) stays fast.
27
+ if name == "CODRAFT_TOOLS":
28
+ from .tools import CODRAFT_TOOLS
29
+
30
+ return CODRAFT_TOOLS
31
+ if name == "CodraftMiddleware":
32
+ from .middleware import CodraftMiddleware
33
+
34
+ return CodraftMiddleware
35
+ if name == "CODRAFT_PROMPT":
36
+ from .middleware import CODRAFT_PROMPT
37
+
38
+ return CODRAFT_PROMPT
39
+ raise AttributeError(name)
40
+
41
+
42
+ __all__ = ["CODRAFT_PROMPT", "CODRAFT_TOOLS", "CodraftMiddleware", "__version__"]
@@ -0,0 +1,301 @@
1
+ """Resolve which agent a Codraft server runs.
2
+
3
+ Three ways to bring an agent (``--agent path.py:obj``):
4
+
5
+ * **a compiled graph** that already carries the tools, e.g.
6
+ ``create_agent(model, middleware=[CodraftMiddleware()])``. It is used as-is.
7
+ * **a factory** ``def make(tools, middleware): ...``. Codraft calls it with
8
+ ``CODRAFT_TOOLS`` and ``[CodraftMiddleware()]``: the deployer builds the graph and
9
+ Codraft adds what this app needs (notes p.4).
10
+ * **a compiled graph without the tools.** It still chats, but it cannot touch the
11
+ document. ``langstage-codraft check`` reports this.
12
+
13
+ Without ``--agent``, a default agent is built from ``--model`` or ``[codraft] model``
14
+ (or the first API key found). ``--demo`` runs a keyless scripted co-author that
15
+ exercises the whole suggestion flow.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import inspect
20
+ import json
21
+ import os
22
+ import re
23
+ import uuid
24
+ from typing import Any
25
+
26
+ from .middleware import CodraftMiddleware
27
+ from .tools import CODRAFT_TOOLS, TOOL_NAMES
28
+
29
+ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
30
+ DEFAULT_OPENROUTER_MODEL = "anthropic/claude-haiku-5.5"
31
+ DEFAULT_OPENAI_MODEL = "gpt-5-mini"
32
+
33
+
34
+ # ------------------------------------------------------------------------- models
35
+ def default_model_spec() -> str | None:
36
+ if os.environ.get("OPENROUTER_API_KEY"):
37
+ return f"openrouter:{DEFAULT_OPENROUTER_MODEL}"
38
+ if os.environ.get("OPENAI_API_KEY"):
39
+ return f"openai:{DEFAULT_OPENAI_MODEL}"
40
+ if os.environ.get("ANTHROPIC_API_KEY"):
41
+ return "anthropic:claude-haiku-5-5"
42
+ return None
43
+
44
+
45
+ def make_model(spec: str) -> Any:
46
+ """``openrouter:<slug>`` or any ``init_chat_model`` spec (``openai:gpt-4.1``…)."""
47
+ if spec.startswith("openrouter:"):
48
+ from langchain_openai import ChatOpenAI
49
+
50
+ key = os.environ.get("OPENROUTER_API_KEY")
51
+ if not key:
52
+ raise RuntimeError("OPENROUTER_API_KEY is not set (needed for an openrouter: model)")
53
+ return ChatOpenAI(model=spec.split(":", 1)[1], base_url=OPENROUTER_BASE, api_key=key,
54
+ temperature=0.3, default_headers={"X-Title": "langstage-codraft"})
55
+ from langchain.chat_models import init_chat_model
56
+
57
+ return init_chat_model(spec)
58
+
59
+
60
+ def research_tools() -> list:
61
+ """Keyless research helpers for the default agent: fetch a URL, and web search
62
+ when the optional ``ddgs`` package (``[search]`` extra) is installed."""
63
+ from langchain_core.tools import tool
64
+
65
+ @tool
66
+ def fetch_url(url: str, max_chars: int = 6000) -> str:
67
+ """Fetch a web page and return its readable text (truncated)."""
68
+ import httpx
69
+
70
+ try:
71
+ r = httpx.get(url, timeout=20, follow_redirects=True,
72
+ headers={"User-Agent": "Mozilla/5.0 (langstage-codraft)"})
73
+ r.raise_for_status()
74
+ except Exception as exc: # noqa: BLE001
75
+ return f"ERROR fetching {url}: {exc}"
76
+ from .textutil import extract_text
77
+
78
+ text = extract_text("page.html", r.content) if "html" in r.headers.get("content-type", "") else r.text
79
+ return text[: max(500, min(max_chars, 20000))]
80
+
81
+ tools = [fetch_url]
82
+ try:
83
+ from ddgs import DDGS
84
+
85
+ @tool
86
+ def web_search(query: str, max_results: int = 5) -> str:
87
+ """Search the web. Returns titles, urls and snippets to cite as references."""
88
+
89
+ try:
90
+ hits = list(DDGS().text(query, max_results=max(1, min(max_results, 10))))
91
+ except Exception as exc: # noqa: BLE001
92
+ return f"ERROR searching: {exc}"
93
+ return "\n".join(f"- {h.get('title')} — {h.get('href')}\n {h.get('body')}" for h in hits) or "No results."
94
+
95
+ tools.append(web_search)
96
+ except ImportError:
97
+ pass
98
+ return tools
99
+
100
+
101
+ def build_default_agent(model_spec: str, *, extra_tools: list | None = None) -> Any:
102
+ from langchain.agents import create_agent
103
+
104
+ return create_agent(
105
+ make_model(model_spec),
106
+ tools=research_tools() + list(extra_tools or []),
107
+ middleware=[CodraftMiddleware()],
108
+ name="codraft",
109
+ )
110
+
111
+
112
+ # --------------------------------------------------------------------- inspection
113
+ def graph_tool_names(graph: Any) -> set[str]:
114
+ """Best-effort: the tool names a compiled graph's tool nodes can call."""
115
+ names: set[str] = set()
116
+ for node in (getattr(graph, "nodes", None) or {}).values():
117
+ for obj in (node, getattr(node, "bound", None), getattr(node, "runnable", None)):
118
+ tbn = getattr(obj, "tools_by_name", None)
119
+ if isinstance(tbn, dict):
120
+ names |= set(tbn)
121
+ return names
122
+
123
+
124
+ def is_graph(obj: Any) -> bool:
125
+ return hasattr(obj, "aget_state") and hasattr(obj, "nodes")
126
+
127
+
128
+ def resolve_agent_object(obj: Any) -> tuple[Any, str]:
129
+ """Turn a loaded spec object into (graph, kind)."""
130
+ if is_graph(obj):
131
+ return obj, "graph"
132
+ if callable(obj):
133
+ params = inspect.signature(obj).parameters
134
+ kwargs: dict[str, Any] = {}
135
+ if "tools" in params:
136
+ kwargs["tools"] = list(CODRAFT_TOOLS)
137
+ if "middleware" in params:
138
+ kwargs["middleware"] = [CodraftMiddleware(tools=[])] if "tools" in params else [CodraftMiddleware()]
139
+ g = obj(**kwargs)
140
+ if not is_graph(g):
141
+ raise TypeError(f"agent factory returned {type(g).__name__}, not a compiled LangGraph graph")
142
+ return g, "factory"
143
+ raise TypeError(f"agent spec resolved to {type(obj).__name__}; expected a compiled graph or a factory")
144
+
145
+
146
+ def missing_tools(graph: Any) -> list[str]:
147
+ have = graph_tool_names(graph)
148
+ return sorted(TOOL_NAMES - have)
149
+
150
+
151
+ # --------------------------------------------------------------------- demo agent
152
+ def _last_human_idx(messages: list) -> int:
153
+ for i in range(len(messages) - 1, -1, -1):
154
+ if getattr(messages[i], "type", "") == "human":
155
+ return i
156
+ return -1
157
+
158
+
159
+ def _call(name: str, args: dict) -> dict:
160
+ return {"name": name, "args": args, "id": f"call_{uuid.uuid4().hex[:12]}", "type": "tool_call"}
161
+
162
+
163
+ def _demo_revise(block_text: str, comment: str) -> str:
164
+ c = comment.lower()
165
+ text = block_text.strip()
166
+ if any(w in c for w in ("short", "concise", "tighten", "trim")):
167
+ sentences = re.split(r"(?<=[.!?])\s+", text)
168
+ return " ".join(sentences[: max(1, len(sentences) // 2)])
169
+ if any(w in c for w in ("expand", "more detail", "elaborate", "longer")):
170
+ return text + "\n\nThis point deserves more detail: it shapes the reader's next decision, so it is spelled out here."
171
+ if any(w in c for w in ("formal", "professional")):
172
+ return text.replace("can't", "cannot").replace("don't", "do not").replace("it's", "it is")
173
+ return text + f"\n\n*Revised to address: “{comment.strip()}”.*"
174
+
175
+
176
+ def demo_turn(messages: list) -> dict:
177
+ """The scripted co-author: returns {"content": str, "tool_calls": [...]}.
178
+
179
+ Deterministic and keyless. It follows the same tool path a real model would, so
180
+ the demo exercises the suggestion/approval flow end to end."""
181
+ i = _last_human_idx(messages)
182
+ human = messages[i] if i >= 0 else None
183
+ text = human.content if human is not None and isinstance(human.content, str) else ""
184
+ low = text.lower()
185
+ tool_msgs = [m for m in messages[i + 1:] if getattr(m, "type", "") == "tool"]
186
+ done = {m.name for m in tool_msgs}
187
+ last = tool_msgs[-1] if tool_msgs else None
188
+
189
+ if last is None:
190
+ if "comment" in low:
191
+ return {"content": "Let me look at the open comments, top to bottom.",
192
+ "tool_calls": [_call("list_comments", {})]}
193
+ if any(w in low for w in ("layout", "look at", "view", "pages")):
194
+ return {"content": "", "tool_calls": [_call("view_pages", {})]}
195
+ if "reference" in low or "sources" in low:
196
+ return {"content": "", "tool_calls": [_call("compile_references", {})]}
197
+ if any(low.startswith(w) for w in ("write", "draft", "add")):
198
+ topic = re.sub(r"^(write|draft|add)\b(.*?\b(about|on)\b)?\s*(a|an|the)?\s*", "", text,
199
+ flags=re.IGNORECASE).strip().rstrip(".") or "this topic"
200
+ body = (f"{topic[:1].upper()}{topic[1:]} matters because it changes what the reader can do next. "
201
+ "This draft states the claim, gives one piece of evidence, and closes with the implication.")
202
+ return {"content": "", "tool_calls": [_call("propose_insert", {
203
+ "block_type": "text", "content": body, "note": f"Draft paragraph: {topic[:60]}"})]}
204
+ return {"content": "", "tool_calls": [_call("get_document", {})]}
205
+
206
+ if last.name == "list_comments":
207
+ found = re.findall(r"\[(cmt_\w+)\] \(open\) block \[(blk_\w+)\].*? — \w+: (.*)", last.content)
208
+ if not found:
209
+ return {"content": "There are no open comments on blocks, so there is nothing to address.", "tool_calls": []}
210
+ return {"content": "", "tool_calls": [_call("read_blocks", {"block_ids": [b for _, b, _ in found]})]}
211
+
212
+ if last.name == "read_blocks" and "propose_edit" not in done:
213
+ lc = next((m for m in tool_msgs if m.name == "list_comments"), None)
214
+ found = re.findall(r"\[(cmt_\w+)\] \(open\) block \[(blk_\w+)\].*? — \w+: (.*)", lc.content if lc else "")
215
+ bodies: dict[str, str] = {}
216
+ for chunk in re.split(r"\n\n(?=\[blk_)", last.content):
217
+ m = re.match(r"\[(blk_\w+)\] (\w[\w-]*) [^\n]*\n(.*)", chunk, re.DOTALL)
218
+ if m:
219
+ bodies[m.group(1)] = (m.group(2), re.sub(r"\n ref: .*", "", m.group(3)).strip())
220
+ calls = []
221
+ for cid, bid, body in found:
222
+ btype, current = bodies.get(bid, ("text", ""))
223
+ if btype not in ("text", "callout", "heading"):
224
+ calls.append(_call("reply_to_comment", {
225
+ "comment_id": cid,
226
+ "message": f"The demo agent can only revise text; this is a {btype} block, so there's no tool for it."}))
227
+ continue
228
+ new = current.lstrip("# ").title() if btype == "heading" else _demo_revise(current, body)
229
+ calls.append(_call("propose_edit", {"block_id": bid, "content": new, "comment_id": cid,
230
+ "note": f"Addresses: {body.strip()[:80]}"}))
231
+ calls.append(_call("reply_to_comment", {"comment_id": cid,
232
+ "message": "Proposed a revision; accept or reject it on the block."}))
233
+ return {"content": "", "tool_calls": calls}
234
+
235
+ if last.name in ("propose_edit", "reply_to_comment"):
236
+ n = sum(1 for m in tool_msgs if m.name == "propose_edit")
237
+ return {"content": f"Done. I proposed {n} edit(s), handling the comments from top to bottom. "
238
+ "Each one is waiting on its block for you to accept or reject.", "tool_calls": []}
239
+ if last.name == "propose_insert":
240
+ return {"content": "I drafted a paragraph as a suggestion at the end of the document. Accept it to keep it.",
241
+ "tool_calls": []}
242
+ if last.name == "view_pages":
243
+ return {"content": "I looked at the rendered pages. (The demo agent can't judge images; a vision model "
244
+ "would comment on spacing and overflow here.)", "tool_calls": []}
245
+ if last.name == "compile_references":
246
+ return {"content": "Here are the sources cited so far:\n\n" + last.content, "tool_calls": []}
247
+ if last.name == "get_document":
248
+ n = len(re.findall(r"^\d+\. \[blk_", last.content, re.MULTILINE))
249
+ return {"content": (f"(demo agent) I can see {n} block(s). Try: **address all comments**, "
250
+ "**write a paragraph about <topic>**, **look at the layout**, or **list the references**."),
251
+ "tool_calls": []}
252
+ return {"content": "(demo agent) Done.", "tool_calls": []}
253
+
254
+
255
+ def build_demo_agent() -> Any:
256
+ from langchain.agents import create_agent
257
+ from langchain_core.language_models import BaseChatModel
258
+ from langchain_core.messages import AIMessage
259
+ from langchain_core.outputs import ChatGeneration, ChatResult
260
+
261
+ class DemoCoauthorModel(BaseChatModel):
262
+ """Keyless scripted model (see :func:`demo_turn`)."""
263
+
264
+ @property
265
+ def _llm_type(self) -> str:
266
+ return "codraft-demo"
267
+
268
+ def bind_tools(self, tools, **kwargs):
269
+ return self
270
+
271
+ def _generate(self, messages, stop=None, run_manager=None, **kwargs):
272
+ turn = demo_turn(list(messages))
273
+ msg = AIMessage(content=turn["content"], tool_calls=turn["tool_calls"])
274
+ return ChatResult(generations=[ChatGeneration(message=msg)])
275
+
276
+ return create_agent(DemoCoauthorModel(), middleware=[CodraftMiddleware(attach_images=False)],
277
+ name="codraft-demo")
278
+
279
+
280
+ # ---------------------------------------------------------------------- resolution
281
+ def resolve(cfg: Any, *, model: str | None = None) -> tuple[Any, dict]:
282
+ """(graph, info) for a resolved CodraftConfig. ``info`` feeds ``check`` and the UI."""
283
+ from langstage_core import load_agent_spec
284
+
285
+ if cfg.demo:
286
+ return build_demo_agent(), {"kind": "demo", "label": "Demo co-author (keyless)", "missing_tools": []}
287
+ if cfg.agent_spec:
288
+ obj = load_agent_spec(cfg.agent_spec, base_dir=cfg.toml_dir_for("agent_spec"))
289
+ graph, kind = resolve_agent_object(obj)
290
+ return graph, {"kind": kind, "label": cfg.agent_spec, "missing_tools": missing_tools(graph)}
291
+ spec = model or cfg.model or default_model_spec()
292
+ if not spec:
293
+ return None, {"kind": "none", "label": "No agent configured",
294
+ "missing_tools": sorted(TOOL_NAMES),
295
+ "hint": "Pass --agent path.py:graph, --model provider:model, set OPENROUTER_API_KEY "
296
+ "or OPENAI_API_KEY, or run with --demo."}
297
+ return build_default_agent(spec), {"kind": "default", "label": spec, "missing_tools": []}
298
+
299
+
300
+ def describe_info(info: dict) -> str:
301
+ return json.dumps(info, indent=2)
@@ -0,0 +1,237 @@
1
+ """``langstage-codraft`` command line.
2
+
3
+ langstage-codraft run [--agent path.py:graph | --model provider:model | --demo]
4
+ langstage-codraft check [--live]
5
+ langstage-codraft new "Title" [--template report]
6
+ langstage-codraft list
7
+ langstage-codraft export DOC_ID --format pdf -o out.pdf (needs a running server)
8
+ langstage-codraft install-browser
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import webbrowser
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ from . import __version__
20
+ from .config import CodraftConfig
21
+
22
+
23
+ def _cfg(**overrides) -> CodraftConfig:
24
+ return CodraftConfig.resolve(overrides=overrides)
25
+
26
+
27
+ def _ok(msg: str) -> None:
28
+ click.echo(f"[ ok ] {msg}")
29
+
30
+
31
+ def _warn(msg: str) -> None:
32
+ click.echo(f"[warn] {msg}")
33
+
34
+
35
+ def _fail(msg: str) -> None:
36
+ click.echo(f"[fail] {msg}")
37
+
38
+
39
+ @click.group(invoke_without_command=True)
40
+ @click.version_option(__version__, prog_name="langstage-codraft")
41
+ @click.pass_context
42
+ def main(ctx: click.Context) -> None:
43
+ """Codraft: the document stage for your LangGraph agent."""
44
+ if ctx.invoked_subcommand is None:
45
+ click.echo(ctx.get_help())
46
+
47
+
48
+ _agent_opts = [
49
+ click.option("-a", "--agent", "agent_spec", help="Agent spec, e.g. my_agent.py:graph (a graph or a factory)."),
50
+ click.option("-m", "--model", help="Model for the default agent, e.g. openrouter:anthropic/claude-haiku-5.5."),
51
+ click.option("--demo", is_flag=True, default=None, help="Run the keyless scripted demo co-author."),
52
+ click.option("--data-dir", type=click.Path(path_type=Path), help="Where documents, uploads and checkpoints live."),
53
+ ]
54
+
55
+
56
+ def _with_agent_opts(f):
57
+ for opt in reversed(_agent_opts):
58
+ f = opt(f)
59
+ return f
60
+
61
+
62
+ @main.command()
63
+ @_with_agent_opts
64
+ @click.option("--host", help="Bind address (default localhost).")
65
+ @click.option("-p", "--port", type=int, help="Port (default 8060).")
66
+ @click.option("--approval", type=click.Choice(["required", "auto"]), help="Default approval mode for new documents.")
67
+ @click.option("--no-browser", is_flag=True, help="Don't open a browser tab.")
68
+ @click.option("--show-config", is_flag=True, help="Print the resolved config and exit.")
69
+ def run(agent_spec, model, demo, data_dir, host, port, approval, no_browser, show_config) -> None:
70
+ """Start the Codraft web app."""
71
+ cfg = _cfg(agent_spec=agent_spec, model=model, demo=demo, data_dir=data_dir, host=host, port=port,
72
+ approval=approval)
73
+ if show_config:
74
+ click.echo(cfg.describe())
75
+ return
76
+ from .agent import resolve
77
+ from .server import create_app
78
+
79
+ try:
80
+ graph, info = resolve(cfg)
81
+ except Exception as exc: # noqa: BLE001
82
+ _fail(f"could not load the agent: {type(exc).__name__}: {exc}")
83
+ sys.exit(2)
84
+ if graph is None:
85
+ _warn(info.get("hint", "no agent configured; the editor works, chat is disabled"))
86
+ elif info.get("missing_tools"):
87
+ _warn(f"agent {info['label']} lacks codraft tools ({len(info['missing_tools'])} missing); it can chat "
88
+ "but cannot edit the document. Add CodraftMiddleware() or export a factory(tools, middleware).")
89
+ app = create_app(cfg, graph, info)
90
+ url = f"http://{cfg.host}:{cfg.port}"
91
+ click.echo(f"Codraft {__version__} agent: {info.get('label')} data: {Path(cfg.data_dir).resolve()}")
92
+ click.echo(f"Open {url}")
93
+ if not no_browser:
94
+ import threading
95
+
96
+ threading.Timer(1.2, lambda: webbrowser.open(url)).start()
97
+ import uvicorn
98
+
99
+ uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="warning")
100
+
101
+
102
+ @main.command()
103
+ @_with_agent_opts
104
+ @click.option("--live", is_flag=True, help="Also run one real agent turn (costs a model call).")
105
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
106
+ def check(agent_spec, model, demo, data_dir, live, as_json) -> None:
107
+ """Preflight: agent, tools, browser for export/vision, and (with --live) one real turn."""
108
+ cfg = _cfg(agent_spec=agent_spec, model=model, demo=demo, data_dir=data_dir)
109
+ from .agent import resolve
110
+
111
+ report: dict = {"version": __version__, "checks": []}
112
+
113
+ def add(name: str, ok: bool | None, detail: str) -> None:
114
+ report["checks"].append({"name": name, "ok": ok, "detail": detail})
115
+
116
+ graph = None
117
+ try:
118
+ graph, info = resolve(cfg)
119
+ if graph is None:
120
+ add("agent", False, info.get("hint", "no agent"))
121
+ else:
122
+ add("agent", True, f"{info['kind']}: {info['label']}")
123
+ missing = info.get("missing_tools") or []
124
+ add("tools", not missing, "all codraft tools present" if not missing
125
+ else f"missing {', '.join(missing)} (add CodraftMiddleware() or use a factory)")
126
+ add("checkpointer", True, "compiled-in" if getattr(graph, "checkpointer", None)
127
+ else "none compiled in; the server attaches a durable SQLite one")
128
+ except Exception as exc: # noqa: BLE001
129
+ add("agent", False, f"{type(exc).__name__}: {exc}")
130
+
131
+ try:
132
+ from playwright.sync_api import sync_playwright
133
+
134
+ with sync_playwright() as p:
135
+ b = p.chromium.launch()
136
+ b.close()
137
+ add("browser", True, "Chromium available for export and view_pages")
138
+ except Exception as exc: # noqa: BLE001
139
+ msg = str(exc).splitlines()[0][:160]
140
+ add("browser", None, f"not available ({msg}); run: langstage-codraft install-browser")
141
+
142
+ static = Path(__file__).parent / "static" / "index.html"
143
+ add("web-ui", static.is_file(), "bundled" if static.is_file() else "not built (cd frontend && npm run build)")
144
+
145
+ if live and graph is not None:
146
+ from langstage_core.agui import verify
147
+
148
+ from .store import Store, set_store
149
+
150
+ tmp = Path(cfg.data_dir) / "check"
151
+ store = Store(tmp / "check.db")
152
+ set_store(store)
153
+ doc = store.create_document("check", {})
154
+ res = verify(graph, message="Call get_document, then say OK.", thread_id=doc["id"])
155
+ add("live-turn", bool(res.ok), res.reason)
156
+
157
+ ok = all(c["ok"] is not False for c in report["checks"])
158
+ report["ok"] = ok
159
+ if as_json:
160
+ click.echo(json.dumps(report, indent=2))
161
+ else:
162
+ for c in report["checks"]:
163
+ (_ok if c["ok"] else _warn if c["ok"] is None else _fail)(f"{c['name']}: {c['detail']}")
164
+ sys.exit(0 if ok else 1)
165
+
166
+
167
+ @main.command("list")
168
+ @click.option("--data-dir", type=click.Path(path_type=Path))
169
+ @click.option("--json", "as_json", is_flag=True)
170
+ def list_docs(data_dir, as_json) -> None:
171
+ """List documents in the data directory."""
172
+ from .store import Store
173
+
174
+ cfg = _cfg(data_dir=data_dir)
175
+ docs = Store(Path(cfg.data_dir) / "codraft.db").list_documents()
176
+ if as_json:
177
+ click.echo(json.dumps(docs, indent=2))
178
+ return
179
+ if not docs:
180
+ click.echo("No documents yet.")
181
+ for d in docs:
182
+ click.echo(f"{d['id']} {d['title']} ({d['n_blocks']} blocks)")
183
+
184
+
185
+ @main.command()
186
+ @click.argument("title")
187
+ @click.option("--template", default="blank", help="Template id (see the new-document dialog).")
188
+ @click.option("--data-dir", type=click.Path(path_type=Path))
189
+ def new(title, template, data_dir) -> None:
190
+ """Create a document from a template."""
191
+ from . import templates as tpl
192
+ from .store import Store
193
+
194
+ cfg = _cfg(data_dir=data_dir)
195
+ tpl.configure(cfg.templates_dir)
196
+ t = tpl.all_templates().get(template)
197
+ if t is None:
198
+ _fail(f"unknown template {template!r}; available: {', '.join(tpl.all_templates())}")
199
+ sys.exit(2)
200
+ store = Store(Path(cfg.data_dir) / "codraft.db")
201
+ doc = store.create_document(title, {"approval": cfg.approval, "theme": cfg.default_theme,
202
+ **t.get("settings", {})}, t.get("blocks", []))
203
+ click.echo(doc["id"])
204
+
205
+
206
+ @main.command()
207
+ @click.argument("doc_id")
208
+ @click.option("-f", "--format", "fmt", type=click.Choice(["pdf", "png", "jpg", "html"]), default="pdf")
209
+ @click.option("-o", "--output", type=click.Path(path_type=Path))
210
+ @click.option("--url", default=None, help="Server URL (default http://localhost:<port>).")
211
+ def export(doc_id, fmt, output, url) -> None:
212
+ """Export a document through a running Codraft server."""
213
+ import httpx
214
+
215
+ cfg = _cfg()
216
+ base = url or f"http://{cfg.host}:{cfg.port}"
217
+ r = httpx.get(f"{base}/api/docs/{doc_id}/export", params={"format": fmt}, timeout=180)
218
+ if r.status_code != 200:
219
+ _fail(f"{r.status_code}: {r.text[:300]}")
220
+ sys.exit(1)
221
+ disp = r.headers.get("content-disposition", "")
222
+ name = disp.split("filename=")[-1].strip('"') if "filename=" in disp else f"{doc_id}.{fmt}"
223
+ out = output or Path(name)
224
+ out.write_bytes(r.content)
225
+ _ok(f"wrote {out} ({len(r.content)} bytes)")
226
+
227
+
228
+ @main.command("install-browser")
229
+ def install_browser_cmd() -> None:
230
+ """Download Chromium for export and the agent's view_pages tool."""
231
+ from .render import install_browser
232
+
233
+ sys.exit(install_browser())
234
+
235
+
236
+ if __name__ == "__main__": # pragma: no cover
237
+ main()
@@ -0,0 +1,58 @@
1
+ """Codraft configuration: the family's ``HostConfig`` chain plus a ``[codraft]`` table.
2
+
3
+ Precedence (lowest to highest): defaults < ``langstage.toml`` < ``LANGSTAGE_*`` env <
4
+ CLI flags, exactly as on every other LangStage surface.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, ClassVar
11
+
12
+ from langstage_core import HostConfig
13
+
14
+
15
+ def _env_bool(v: str) -> bool:
16
+ return v.strip().lower() in ("1", "true", "yes", "on")
17
+
18
+
19
+ @dataclass
20
+ class CodraftConfig(HostConfig):
21
+ port: int = 8060
22
+ title: str = "Codraft"
23
+ model: str | None = None # e.g. "openrouter:anthropic/claude-haiku-5.5", "openai:gpt-4.1-mini"
24
+ approval: str = "required" # default approval mode for new documents
25
+ data_dir: Path = Path(".codraft") # SQLite store, uploads, checkpoints, renders
26
+ templates_dir: Path | None = None
27
+ default_theme: str = "classic"
28
+ demo: bool = False
29
+
30
+ _ENV: ClassVar[dict] = {
31
+ "model": ("LANGSTAGE_CODRAFT_MODEL", str),
32
+ "approval": ("LANGSTAGE_CODRAFT_APPROVAL", str),
33
+ "data_dir": ("LANGSTAGE_CODRAFT_DATA_DIR", Path),
34
+ "templates_dir": ("LANGSTAGE_CODRAFT_TEMPLATES_DIR", Path),
35
+ "default_theme": ("LANGSTAGE_CODRAFT_THEME", str),
36
+ "demo": ("LANGSTAGE_CODRAFT_DEMO", _env_bool),
37
+ }
38
+ _TOML: ClassVar[dict] = {
39
+ "model": "codraft.model",
40
+ "approval": "codraft.approval",
41
+ "data_dir": "codraft.data_dir",
42
+ "templates_dir": "codraft.templates_dir",
43
+ "default_theme": "codraft.default_theme",
44
+ "demo": "codraft.demo",
45
+ }
46
+ _TOML_PATHS: ClassVar[dict] = {"data_dir": "path", "templates_dir": "path"}
47
+
48
+
49
+ def codraft_toml_table() -> dict[str, Any]:
50
+ """The raw ``[codraft]`` table (themes and the allowed list live there)."""
51
+ try:
52
+ from langstage_core.host.config import load_toml_config
53
+
54
+ data, _ = load_toml_config()
55
+ except Exception: # noqa: BLE001 - a malformed TOML is reported by describe()
56
+ return {}
57
+ t = data.get("codraft", {})
58
+ return t if isinstance(t, dict) else {}