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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- 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("<", "<").replace(">", ">").replace(""", '"')
|
|
164
|
+
.replace("'", "'").replace("&", "&"))
|
|
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
|
forgecli/browsercheck.py
ADDED
|
@@ -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}."
|
forgecli/checkpoints.py
ADDED
|
@@ -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
|