forgecommandline 0.8.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 (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/attach.py ADDED
@@ -0,0 +1,246 @@
1
+ """Files the user hands Forge by typing or pasting their path: any kind, not just images.
2
+
3
+ A path in a message (or in an answer to one of Forge's questions) is found even with spaces, without
4
+ quotes, or with words glued to its end ("C:\\clip.mp4continue"). Each file becomes an attachment note
5
+ the model can't miss: what it is, how big, and what to do with it. Word documents, PDFs, slides and
6
+ text files have their text pulled out right here, so the model never has to guess at a parser (the
7
+ old way: `python3 -c "import docx"` failing four times in a row). Images still go to vision models
8
+ through images.py."""
9
+ from __future__ import annotations
10
+
11
+ import pathlib
12
+ import re
13
+ import zipfile
14
+
15
+ from . import images
16
+
17
+ TEXT_EXTS = {".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".toml", ".html", ".htm", ".css", ".js",
18
+ ".ts", ".tsx", ".jsx", ".py", ".rtf", ".xml", ".ini", ".log", ".sql", ".php", ".java", ".c",
19
+ ".cpp", ".h", ".go", ".rs", ".rb", ".sh", ".ps1", ".bat", ".svg", ".tex", ".bib"}
20
+ VIDEO = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"}
21
+ AUDIO = {".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"}
22
+ FONTS = {".woff", ".woff2", ".ttf", ".otf"}
23
+ ARCHIVES = {".zip", ".tar", ".gz", ".7z", ".rar"}
24
+ MAX_TEXT = 8000 # characters of a document put into the conversation
25
+ MAX_FILES = 8
26
+
27
+ # Where a path can start: a drive, a UNC share, home, or ./ ../
28
+ _START = re.compile(r"""(?:(?<=^)|(?<=[\s"'(\[<,;]))(?:[A-Za-z]:[\\/]|\\\\|~[\\/]|\.{1,2}[\\/]|/(?=[^\s/]))""")
29
+ _QUOTED = re.compile(r""""([^"\n]{2,400})"|'([^'\n]{2,400})'""")
30
+
31
+
32
+ def kind(path: pathlib.Path) -> str:
33
+ if path.is_dir():
34
+ return "folder"
35
+ ext = path.suffix.lower()
36
+ if ext in images.EXTS:
37
+ return "image"
38
+ if ext in VIDEO:
39
+ return "video"
40
+ if ext in AUDIO:
41
+ return "audio"
42
+ if ext in {".docx", ".pdf", ".pptx", ".odt", ".doc"}:
43
+ return "document"
44
+ if ext in {".xlsx", ".xls", ".ods"}:
45
+ return "spreadsheet"
46
+ if ext in FONTS:
47
+ return "font"
48
+ if ext in ARCHIVES:
49
+ return "archive"
50
+ if ext in TEXT_EXTS:
51
+ return "text"
52
+ return "file"
53
+
54
+
55
+ def _exists(raw: str, root: pathlib.Path) -> pathlib.Path | None:
56
+ raw = raw.strip().rstrip(".,;:)]}>!?")
57
+ if not raw:
58
+ return None
59
+ p = pathlib.Path(raw).expanduser()
60
+ if not p.is_absolute():
61
+ p = root / p
62
+ try:
63
+ if p.exists():
64
+ return p.resolve()
65
+ except (OSError, ValueError):
66
+ return None
67
+ return None
68
+
69
+
70
+ def _longest(text: str, start: int, root: pathlib.Path) -> tuple[pathlib.Path, int] | None:
71
+ """The longest existing path that starts at `start`, so spaces and glued words both work:
72
+ "C:\\a b\\clip.mp4continue" → C:\\a b\\clip.mp4. Files beat folders that are only a prefix."""
73
+ end = text.find("\n", start)
74
+ end = len(text) if end < 0 else end
75
+ chunk = text[start:min(end, start + 400)]
76
+ best = None
77
+ # Candidate cut points: every position after an extension-looking end, or a separator.
78
+ for i in range(len(chunk), 1, -1):
79
+ cand = chunk[:i]
80
+ if cand[-1] in " \t":
81
+ continue
82
+ nxt = chunk[i:i + 1]
83
+ is_file_end = bool(re.search(r"\.[A-Za-z0-9]{1,5}$", cand))
84
+ if not (is_file_end or nxt in ("", " ", "\t", '"', "'", ",", ";", ")") or cand[-1] in "\\/"):
85
+ continue
86
+ p = _exists(cand, root)
87
+ if p is not None:
88
+ if p.is_file():
89
+ return p, start + len(cand)
90
+ if best is None and nxt in ("", " ", "\t", '"', "'", ",", ";", ")"):
91
+ best = (p, start + len(cand))
92
+ return best
93
+
94
+
95
+ def find(text: str, root: pathlib.Path | None = None) -> list[pathlib.Path]:
96
+ """Existing files and folders named in the text, in order, without repeats."""
97
+ root = pathlib.Path(root or pathlib.Path.cwd())
98
+ text = text or ""
99
+ found: list[pathlib.Path] = []
100
+
101
+ def add(p):
102
+ if p is not None and p not in found and str(p) != str(root.resolve()):
103
+ found.append(p)
104
+
105
+ for m in _QUOTED.finditer(text):
106
+ add(_exists(m.group(1) or m.group(2), root))
107
+ for m in _START.finditer(text):
108
+ hit = _longest(text, m.start(), root)
109
+ if hit:
110
+ add(hit[0])
111
+ # Bare relative names with an extension ("photo.jpg", "docs/cv.pdf").
112
+ for m in re.finditer(r"(?<![\w\\/:.-])([\w.-]+(?:[\\/][\w .-]+)*\.[A-Za-z0-9]{1,5})(?![\w\\/])", text):
113
+ add(_exists(m.group(1), root))
114
+ # Drop a folder when a file inside it was also named (the folder was just a prefix).
115
+ files = [p for p in found if p.is_file()]
116
+ found = [p for p in found if p.is_file() or not any(f.is_relative_to(p) for f in files)]
117
+ return found[:MAX_FILES]
118
+
119
+
120
+ def strip_paths(text: str, root: pathlib.Path | None = None) -> str:
121
+ """The text with every existing path taken out: "C:\\clip.mp4continue" → "continue"."""
122
+ root = pathlib.Path(root or pathlib.Path.cwd())
123
+ text = text or ""
124
+ cuts = []
125
+ for m in _QUOTED.finditer(text):
126
+ if _exists(m.group(1) or m.group(2), root):
127
+ cuts.append((m.start(), m.end()))
128
+ for m in _START.finditer(text):
129
+ hit = _longest(text, m.start(), root)
130
+ if hit:
131
+ cuts.append((m.start(), hit[1]))
132
+ out, pos = [], 0
133
+ for a, b in sorted(cuts):
134
+ if a >= pos:
135
+ out.append(text[pos:a])
136
+ pos = b
137
+ out.append(text[pos:])
138
+ return " ".join("".join(out).split())
139
+
140
+
141
+ def _size(p: pathlib.Path) -> str:
142
+ try:
143
+ n = p.stat().st_size
144
+ except OSError:
145
+ return "?"
146
+ for unit in ("bytes", "KB", "MB", "GB"):
147
+ if n < 1024 or unit == "GB":
148
+ return f"{n:.0f} {unit}" if unit == "bytes" else f"{n:.1f} {unit}"
149
+ n /= 1024
150
+ return "?"
151
+
152
+
153
+ def _xml_text(xml: str, para: str, run: str) -> str:
154
+ out = []
155
+ for p in re.findall(rf"<{para}[ >].*?</{para}>", xml, re.S):
156
+ t = "".join(re.findall(rf"<{run}(?: [^>]*)?>([^<]*)</{run}>", p))
157
+ if t.strip():
158
+ out.append(t)
159
+ return "\n".join(out)
160
+
161
+
162
+ def _unescape(s: str) -> str:
163
+ return (s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"')
164
+ .replace("&apos;", "'").replace("&amp;", "&"))
165
+
166
+
167
+ def extract(p: pathlib.Path) -> str | None:
168
+ """Readable text of a document, or None when it can't be read here."""
169
+ ext = p.suffix.lower()
170
+ try:
171
+ if ext == ".docx":
172
+ with zipfile.ZipFile(p) as z:
173
+ xml = z.read("word/document.xml").decode("utf8", "replace")
174
+ return _unescape(_xml_text(xml, "w:p", "w:t"))
175
+ if ext == ".pptx":
176
+ with zipfile.ZipFile(p) as z:
177
+ names = sorted((n for n in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)),
178
+ key=lambda n: int(re.search(r"(\d+)", n.rsplit("/", 1)[1]).group(1)))
179
+ slides = [_unescape(_xml_text(z.read(n).decode("utf8", "replace"), "a:p", "a:t"))
180
+ for n in names]
181
+ return "\n\n".join(f"[slide {i}]\n{s}" for i, s in enumerate(slides, 1) if s.strip())
182
+ if ext == ".odt":
183
+ with zipfile.ZipFile(p) as z:
184
+ xml = z.read("content.xml").decode("utf8", "replace")
185
+ return _unescape(re.sub(r"<[^>]+>", " ", re.sub(r"</text:p>", "\n", xml)))
186
+ if ext == ".pdf":
187
+ try:
188
+ from pypdf import PdfReader # optional
189
+ except ImportError:
190
+ return None
191
+ return "\n".join((pg.extract_text() or "") for pg in PdfReader(str(p)).pages[:30])
192
+ if kind(p) == "text" and p.stat().st_size <= 2_000_000:
193
+ return p.read_text(encoding="utf8", errors="replace")
194
+ except (OSError, KeyError, ValueError, zipfile.BadZipFile, AttributeError):
195
+ return None
196
+ except Exception: # a broken document must never break the turn
197
+ return None
198
+ return None
199
+
200
+
201
+ _USE = {
202
+ "image": "an image the user wants used; to put it on a page copy it into the project (e.g. "
203
+ "assets/) and reference that copy, replacing any placeholder in the slot it fits",
204
+ "video": "a video the user wants used (e.g. a hero/background); copy it into the project and use a "
205
+ "<video autoplay muted loop playsinline> element pointing at the copy",
206
+ "audio": "an audio file; copy it into the project before referencing it",
207
+ "font": "a font; copy it into the project and load it with @font-face",
208
+ "folder": "a folder the user pointed at; list it before using what's inside",
209
+ }
210
+
211
+
212
+ def note(paths: list[pathlib.Path], root: pathlib.Path | None = None) -> str:
213
+ """The attachment block appended to the user's message."""
214
+ if not paths:
215
+ return ""
216
+ lines = ["[Forge: the user attached these — they are inputs to use, not questions to ask about. "
217
+ "Paths are exact; on Windows use `python`, not `python3`.]"]
218
+ bodies = []
219
+ for p in paths:
220
+ k = kind(p)
221
+ lines.append(f"- {p} ({k}, {_size(p)})" + (f" — {_USE[k]}" if k in _USE else ""))
222
+ if k in ("document", "text", "spreadsheet"):
223
+ text = extract(p)
224
+ if text and text.strip():
225
+ text = re.sub(r"\n{3,}", "\n\n", text.strip())
226
+ cut = len(text) > MAX_TEXT
227
+ bodies.append(f"--- text of {p.name}{' (first part)' if cut else ''} ---\n"
228
+ f"{text[:MAX_TEXT]}\n--- end of {p.name} ---")
229
+ lines[-1] += " — its text is below, use it directly"
230
+ elif k != "text":
231
+ lines[-1] += " — couldn't read it here; read it with a tool if you need its contents"
232
+ return "\n".join(lines + bodies)
233
+
234
+
235
+ def user_message(text: str, root: pathlib.Path | None = None) -> dict:
236
+ """A user message with every attached file noted and images listed for vision models."""
237
+ msg = images.user_message(text, root)
238
+ paths = find(text, root)
239
+ if paths:
240
+ msg["content"] = (text or "") + "\n\n" + note(paths, root)
241
+ msg["attachments"] = [str(p) for p in paths]
242
+ imgs = [str(p) for p in paths if kind(p) == "image" and 0 < p.stat().st_size <= images.MAX_BYTES]
243
+ merged = list(dict.fromkeys((msg.get("images") or []) + imgs))[:4]
244
+ if merged:
245
+ msg["images"] = merged
246
+ return msg
forgecli/brief.py ADDED
@@ -0,0 +1,215 @@
1
+ """The pinned brief: what the user asked for and already decided, kept in front of the model.
2
+
3
+ Long builds overflow the context, and trimming used to drop the very things the model needed most:
4
+ the real request (when the newest message was only "continue") and the answers to its own
5
+ questions. With those gone it interviewed the user again, invented details ("product designer"),
6
+ and re-read the same reference files. The brief is rebuilt from the full saved conversation before
7
+ every request and joined to the system message, so trimming can never remove it.
8
+
9
+ It also powers two guards on ask_user: a question on a topic the user already answered gets that
10
+ answer back instead of reaching the user, and an interview is capped at a few questions."""
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import json
15
+ import re
16
+
17
+ ATTACH_MARK = "\n\n[Forge: the user attached"
18
+ ANSWER_PREFIX = "The user answered: "
19
+ MAX_QUESTIONS = 4 # ask_user calls per request before Forge says "build with what you have"
20
+
21
+ _CONTINUE = re.compile(
22
+ r"^\s*(?:ok(?:ay)?|yes|yep|yeah|sure|go|go on|go ahead|continue|keep going|carry on|proceed|resume|"
23
+ r"next|do it|please continue|continue please|keep building|finish(?: it)?|carry on please)"
24
+ r"[\s.!,]*$", re.I)
25
+
26
+ # Questions are compared by topic, not wording: "Which Hallmark theme resonates most?" and
27
+ # "Which visual style appeals to you?" are the same question.
28
+ TOPICS = {
29
+ "style": ("theme", "style", "visual", "look", "aesthetic", "vibe", "palette", "colour", "color",
30
+ "mood", "design direction", "feel"),
31
+ "goal": ("goal", "purpose", "aim", "objective", "visitors", "what do you want", "primary"),
32
+ "identity": ("your name", "who are you", "what do you do", "your role", "job title", "profession",
33
+ "describe yourself", "occupation"),
34
+ "content": ("case stud", "projects", "work should", "show", "screenshots", "materials", "gallery",
35
+ "portfolio include", "content"),
36
+ "pages": ("one page", "multi-page", "multi page", "single page", "how many pages", "separate pages",
37
+ "sections"),
38
+ "audience": ("audience", "who is it for", "target", "who will"),
39
+ "stack": ("framework", "react", "plain html", "tech stack", "which language", "library"),
40
+ }
41
+
42
+
43
+ def topic(question: str) -> str | None:
44
+ q = (question or "").lower()
45
+ scores = {name: sum(1 for w in words if w in q) for name, words in TOPICS.items()}
46
+ best = max(scores, key=scores.get)
47
+ return best if scores[best] else None
48
+
49
+
50
+ def _words(s: str) -> set[str]:
51
+ return {w for w in re.findall(r"[a-z]{4,}", (s or "").lower())}
52
+
53
+
54
+ def similar(a: str, b: str) -> bool:
55
+ ta, tb = topic(a), topic(b)
56
+ if ta and ta == tb:
57
+ return True
58
+ wa, wb = _words(a), _words(b)
59
+ return bool(wa and wb) and len(wa & wb) / len(wa | wb) >= 0.5
60
+
61
+
62
+ @functools.lru_cache(maxsize=512)
63
+ def _continuation(text: str) -> bool:
64
+ from . import attach
65
+ if _CONTINUE.match(text):
66
+ return True
67
+ return len(text) < 600 and bool(_CONTINUE.match(attach.strip_paths(text)))
68
+
69
+
70
+ def is_continuation(text) -> bool:
71
+ """'continue', 'yes', 'go on' … — also after attached paths are taken out of the text."""
72
+ return _continuation(own_text(text).strip())
73
+
74
+
75
+ def own_text(content) -> str:
76
+ """The words the user typed, without Forge's attachment note."""
77
+ if isinstance(content, list):
78
+ content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
79
+ text = str(content or "")
80
+ return text.split(ATTACH_MARK, 1)[0]
81
+
82
+
83
+ def _is_forge(text: str) -> bool:
84
+ return text.lstrip().startswith("[Forge")
85
+
86
+
87
+ def requests(messages: list[dict]) -> list[str]:
88
+ """The user's own messages, oldest first, without Forge's injected notes."""
89
+ out = []
90
+ for m in messages:
91
+ if m.get("role") != "user":
92
+ continue
93
+ text = own_text(m.get("content")).strip()
94
+ if text and not _is_forge(text):
95
+ out.append(text)
96
+ return out
97
+
98
+
99
+ def current_request(messages: list[dict]) -> str:
100
+ """The newest real request: 'continue' and friends point back to the one before."""
101
+ reqs = requests(messages)
102
+ for text in reversed(reqs):
103
+ if not is_continuation(text):
104
+ return text
105
+ return reqs[-1] if reqs else ""
106
+
107
+
108
+ def answers(messages: list[dict]) -> list[tuple[str, str]]:
109
+ """Every (question, answer) pair from ask_user, oldest first."""
110
+ asked = {}
111
+ out = []
112
+ for m in messages:
113
+ if m.get("role") == "assistant":
114
+ for c in m.get("tool_calls") or []:
115
+ fn = c.get("function") or {}
116
+ if fn.get("name") == "ask_user":
117
+ try:
118
+ q = json.loads(fn.get("arguments") or "{}").get("question", "")
119
+ except (ValueError, AttributeError):
120
+ q = ""
121
+ asked[c.get("id")] = str(q)
122
+ elif m.get("role") == "tool" and m.get("tool_call_id") in asked:
123
+ content = str(m.get("content") or "")
124
+ if content.startswith(ANSWER_PREFIX):
125
+ a = own_text(content[len(ANSWER_PREFIX):]).strip()
126
+ out.append((asked[m["tool_call_id"]], a))
127
+ elif content.startswith("Already answered"):
128
+ pass
129
+ return out
130
+
131
+
132
+ def asked_since_request(messages: list[dict]) -> int:
133
+ """ask_user calls since the user's newest real (non-continuation) request."""
134
+ start = 0
135
+ for i, m in enumerate(messages):
136
+ if m.get("role") == "user":
137
+ text = own_text(m.get("content")).strip()
138
+ if text and not _is_forge(text) and not is_continuation(text):
139
+ start = i
140
+ n = 0
141
+ for m in messages[start:]:
142
+ for c in m.get("tool_calls") or [] if m.get("role") == "assistant" else []:
143
+ if (c.get("function") or {}).get("name") == "ask_user":
144
+ n += 1
145
+ return n
146
+
147
+
148
+ def attachments(messages: list[dict]) -> list[str]:
149
+ """Attached paths, from user messages and from answers to ask_user."""
150
+ seen = []
151
+ for m in messages:
152
+ found = list(m.get("attachments") or [])
153
+ content = m.get("content")
154
+ if m.get("role") == "tool" and isinstance(content, str) and ATTACH_MARK in content:
155
+ note = content.split(ATTACH_MARK, 1)[1]
156
+ found += re.findall(r"^- (.+?) \(", note, re.M)
157
+ for p in found:
158
+ if p not in seen:
159
+ seen.append(p)
160
+ return seen
161
+
162
+
163
+ def _clip(s: str, n: int) -> str:
164
+ s = re.sub(r"\s+", " ", s).strip()
165
+ return s if len(s) <= n else s[:n - 1] + "…"
166
+
167
+
168
+ def build(messages: list[dict], todo: list[dict] | None = None) -> str:
169
+ """The brief text, or '' when there is nothing worth pinning yet."""
170
+ reqs = requests(messages)
171
+ qa = answers(messages)
172
+ files = attachments(messages)
173
+ if len(reqs) < 2 and not qa and not files and not todo:
174
+ return "" # a one-message conversation is fully visible anyway
175
+ lines = ["[Pinned brief: the user's own words and decisions, always current even when older "
176
+ "messages were trimmed. Never ask again about anything answered here and never invent "
177
+ "details that contradict it; build from it.]"]
178
+ current = current_request(messages)
179
+ earlier = [r for r in reqs if r != current and not is_continuation(r)][-5:]
180
+ if earlier:
181
+ lines.append("Earlier requests (oldest first):")
182
+ lines += [f"- {_clip(r, 300)}" for r in earlier]
183
+ if current:
184
+ lines.append(f"Current request: {_clip(current, 900)}")
185
+ if reqs and is_continuation(reqs[-1]) and current != reqs[-1]:
186
+ lines.append(f"The user's newest message was \"{_clip(own_text(reqs[-1]), 60)}\": keep working "
187
+ "on the current request from where it stopped. Don't start over, don't re-read "
188
+ "references you already used, and don't interview again.")
189
+ if qa:
190
+ lines.append("Answers the user already gave (final; do not ask these again):")
191
+ lines += [f"- Q: {_clip(q, 160)} → A: {_clip(a, 300)}" for q, a in qa[-12:]]
192
+ if files:
193
+ lines.append("Files the user attached (use them; exact paths):")
194
+ lines += [f"- {p}" for p in files[-10:]]
195
+ if todo:
196
+ open_items = [i.get("task", "") for i in todo if i.get("status") != "done"]
197
+ done_items = [i.get("task", "") for i in todo if i.get("status") == "done"]
198
+ if open_items:
199
+ lines.append("Plan, still open: " + "; ".join(_clip(t, 80) for t in open_items[:10]))
200
+ if done_items:
201
+ lines.append("Plan, done: " + "; ".join(_clip(t, 60) for t in done_items[:10]))
202
+ return "\n".join(lines)
203
+
204
+
205
+ def reuse(messages: list[dict], question: str) -> str | None:
206
+ """A tool result for an ask_user that shouldn't reach the user, or None to ask it."""
207
+ for q, a in reversed(answers(messages)):
208
+ if similar(question, q):
209
+ return (f"Already answered — the user said: {a!r} (to: {_clip(q, 120)!r}). Don't ask again; "
210
+ "use that answer.")
211
+ if asked_since_request(messages) > MAX_QUESTIONS:
212
+ return (f"You've already asked {MAX_QUESTIONS} questions for this request. Stop interviewing: "
213
+ "build now with the answers in the pinned brief, pick sensible defaults for anything "
214
+ "still open, and say which defaults you chose.")
215
+ return None
@@ -0,0 +1,99 @@
1
+ """Looking at a local web page the way a person would: a headless browser loads it, collects errors
2
+ and takes a screenshot. Playwright is used when installed (it also catches console and page errors);
3
+ otherwise Microsoft Edge or Chrome, which most computers already have, take the screenshot from the
4
+ command line. Only localhost pages are checked (tools.t_browser_check enforces that)."""
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import pathlib
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+
14
+ WIDTH, HEIGHT = 1280, 860
15
+
16
+
17
+ def shot_path(root: pathlib.Path) -> pathlib.Path:
18
+ """Screenshots go in Forge's own folder, never the user's project."""
19
+ d = pathlib.Path(os.environ.get("FORGE_HOME", pathlib.Path.home() / ".forge")) / "screenshots"
20
+ d.mkdir(parents=True, exist_ok=True)
21
+ olds = sorted(d.glob("page-*.png"))
22
+ for old in olds[:-20]: # keep the last 20
23
+ try:
24
+ old.unlink()
25
+ except OSError:
26
+ pass
27
+ return d / f"page-{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid() % 1000}.png"
28
+
29
+
30
+ def playwright_check(url: str, shot: pathlib.Path) -> str | None:
31
+ """Status, title, errors and a full-page screenshot; None when Playwright isn't installed."""
32
+ try:
33
+ from playwright.sync_api import sync_playwright
34
+ except ImportError:
35
+ return None
36
+ console_errors, page_errors = [], []
37
+ status = title = None
38
+ try:
39
+ with sync_playwright() as pw:
40
+ browser = pw.chromium.launch(headless=True)
41
+ try:
42
+ page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
43
+ page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
44
+ page.on("pageerror", lambda e: page_errors.append(str(e)))
45
+ resp = page.goto(url, wait_until="networkidle", timeout=20_000)
46
+ status = resp.status if resp else "no response"
47
+ title = page.title()
48
+ try:
49
+ page.screenshot(path=str(shot), full_page=True, timeout=10_000)
50
+ except Exception:
51
+ pass
52
+ finally:
53
+ browser.close()
54
+ except Exception as exc:
55
+ return f"Browser check failed: {type(exc).__name__}: {exc}"
56
+ lines = [f"HTTP {status}", f"title: {title or '(empty)'}",
57
+ f"console errors: {len(console_errors)}", f"page errors: {len(page_errors)}"]
58
+ lines += [f"console: {e[:300]}" for e in console_errors[:5]]
59
+ lines += [f"page error: {e[:300]}" for e in page_errors[:5]]
60
+ return "\n".join(lines)
61
+
62
+
63
+ def find_browser() -> str | None:
64
+ env = os.environ.get("FORGE_BROWSER")
65
+ if env and pathlib.Path(env).exists():
66
+ return env
67
+ for name in ("msedge", "chrome", "google-chrome", "chromium", "chromium-browser"):
68
+ p = shutil.which(name)
69
+ if p:
70
+ return p
71
+ pf = [os.environ.get(k) for k in ("ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA")]
72
+ rel = [r"Microsoft\Edge\Application\msedge.exe", r"Google\Chrome\Application\chrome.exe"]
73
+ for base in filter(None, pf):
74
+ for r in rel:
75
+ p = pathlib.Path(base) / r
76
+ if p.exists():
77
+ return str(p)
78
+ mac = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
79
+ return mac if pathlib.Path(mac).exists() else None
80
+
81
+
82
+ def cli_screenshot(url: str, shot: pathlib.Path) -> str:
83
+ """Screenshot with Edge/Chrome's own headless mode (no console errors this way)."""
84
+ exe = find_browser()
85
+ if not exe:
86
+ return ("Skipped: no Playwright and no Edge/Chrome found to take a screenshot. "
87
+ "Install Playwright (pip install playwright; playwright install chromium) for full checks.")
88
+ with tempfile.TemporaryDirectory(prefix="forge-browser-") as prof:
89
+ cmd = [exe, "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
90
+ "--hide-scrollbars", f"--user-data-dir={prof}", f"--window-size={WIDTH},{HEIGHT}",
91
+ "--virtual-time-budget=4000", f"--screenshot={shot}", url]
92
+ try:
93
+ subprocess.run(cmd, capture_output=True, timeout=45)
94
+ except Exception as exc:
95
+ return f"Browser check failed: {type(exc).__name__}: {exc}"
96
+ if shot.exists() and shot.stat().st_size > 0:
97
+ return (f"Loaded {url} in headless {pathlib.Path(exe).stem} (Playwright not installed, so console "
98
+ "errors were not collected).")
99
+ return f"Browser check failed: {pathlib.Path(exe).stem} did not produce a screenshot of {url}."
@@ -0,0 +1,81 @@
1
+ """In-memory file snapshots used by /undo."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import pathlib
6
+ import stat
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class FileState:
13
+ path: pathlib.Path
14
+ existed: bool
15
+ content: bytes = b""
16
+ mode: int | None = None
17
+
18
+
19
+ @dataclass
20
+ class Checkpoint:
21
+ files: list[FileState]
22
+ message_index: int | None
23
+
24
+
25
+ class Checkpoints:
26
+ def __init__(self, limit: int = 50):
27
+ self.limit = limit
28
+ self.entries: list[Checkpoint] = []
29
+ self.message_index: int | None = None
30
+
31
+ def capture(self, paths: list[pathlib.Path]) -> Checkpoint:
32
+ files = []
33
+ for path in dict.fromkeys(paths):
34
+ exists = path.exists()
35
+ if exists and not path.is_file():
36
+ raise OSError(f"cannot checkpoint non-file path: {path}")
37
+ content = path.read_bytes() if exists else b""
38
+ mode = stat.S_IMODE(path.stat().st_mode) if exists else None
39
+ files.append(FileState(path, exists, content, mode))
40
+ return Checkpoint(files, self.message_index)
41
+
42
+ def add(self, checkpoint: Checkpoint) -> None:
43
+ if (checkpoint.message_index is not None and self.entries and
44
+ self.entries[-1].message_index == checkpoint.message_index):
45
+ previous = self.entries[-1]
46
+ known = {state.path for state in previous.files}
47
+ previous.files.extend(state for state in checkpoint.files if state.path not in known)
48
+ else:
49
+ self.entries.append(checkpoint)
50
+ del self.entries[:-self.limit]
51
+ self.message_index = None
52
+
53
+ def clear(self) -> None:
54
+ self.entries.clear()
55
+ self.message_index = None
56
+
57
+ def restore(self, checkpoint: Checkpoint) -> None:
58
+ for state in reversed(checkpoint.files):
59
+ if not state.existed:
60
+ state.path.unlink(missing_ok=True)
61
+ continue
62
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{state.path.name}.forge-undo-",
63
+ dir=state.path.parent)
64
+ tmp = pathlib.Path(tmp_name)
65
+ try:
66
+ with os.fdopen(fd, "wb") as stream:
67
+ stream.write(state.content)
68
+ if state.mode is not None:
69
+ os.chmod(tmp, state.mode)
70
+ os.replace(tmp, state.path)
71
+ finally:
72
+ if tmp.exists():
73
+ tmp.unlink()
74
+
75
+ def undo(self) -> Checkpoint:
76
+ if not self.entries:
77
+ raise LookupError("there are no file changes to undo")
78
+ checkpoint = self.entries[-1]
79
+ self.restore(checkpoint)
80
+ self.entries.pop()
81
+ return checkpoint