chill-agent 1.4.0 → 1.6.0
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.
- package/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.clinerules/ponytail.md +30 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.grok-plugin/marketplace.json +16 -0
- package/.kimi-plugin/plugin.json +11 -29
- package/.kiro/steering/ponytail.md +35 -0
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +47 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.qoder-plugin/plugin.json +12 -26
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +32 -0
- package/__init__.py +228 -0
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +17 -6
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks-windows.test.js +205 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/map.test.js +30 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/qoder-plugin.test.js +84 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Email Validation
|
|
2
|
+
|
|
3
|
+
**Task:** "Write me a Python function that validates email addresses."
|
|
4
|
+
|
|
5
|
+
Verbatim model output from a benchmark run, Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
|
|
6
|
+
|
|
7
|
+
## Without Ponytail, 75 lines of code
|
|
8
|
+
|
|
9
|
+
# Email Validation Function
|
|
10
|
+
|
|
11
|
+
Here's a practical Python function that validates email addresses:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
def validate_email(email: str) -> bool:
|
|
17
|
+
"""
|
|
18
|
+
Validates an email address using a regular expression.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
email (str): The email address to validate
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
bool: True if valid, False otherwise
|
|
25
|
+
"""
|
|
26
|
+
# Regular expression pattern for email validation
|
|
27
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
28
|
+
|
|
29
|
+
return re.match(pattern, email) is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Test cases
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
test_emails = [
|
|
35
|
+
"user@example.com", # Valid
|
|
36
|
+
"john.doe@company.co.uk", # Valid
|
|
37
|
+
"test+tag@domain.org", # Valid
|
|
38
|
+
"invalid.email@", # Invalid
|
|
39
|
+
"missing@domain", # Invalid
|
|
40
|
+
"@nodomain.com", # Invalid
|
|
41
|
+
"spaces in@email.com", # Invalid
|
|
42
|
+
"user@domain..com", # Invalid (double dot)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for email in test_emails:
|
|
46
|
+
result = validate_email(email)
|
|
47
|
+
print(f"{email:<30} -> {result}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## More Robust Version (with additional checks)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import re
|
|
54
|
+
|
|
55
|
+
def validate_email_advanced(email: str) -> tuple[bool, str]:
|
|
56
|
+
"""
|
|
57
|
+
Advanced email validation with detailed feedback.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
email (str): The email address to validate
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
tuple: (is_valid, message)
|
|
64
|
+
"""
|
|
65
|
+
# Basic checks
|
|
66
|
+
if not email or not isinstance(email, str):
|
|
67
|
+
return False, "Email must be a non-empty string"
|
|
68
|
+
|
|
69
|
+
email = email.strip()
|
|
70
|
+
|
|
71
|
+
if len(email) > 254:
|
|
72
|
+
return False, "Email is too long (max 254 characters)"
|
|
73
|
+
|
|
74
|
+
# Regular expression pattern
|
|
75
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
76
|
+
|
|
77
|
+
if not re.match(pattern, email):
|
|
78
|
+
return False, "Email format is invalid"
|
|
79
|
+
|
|
80
|
+
# Additional checks
|
|
81
|
+
local_part, domain = email.rsplit('@', 1)
|
|
82
|
+
|
|
83
|
+
if len(local_part) > 64:
|
|
84
|
+
return False, "Local part is too long (max 64 characters)"
|
|
85
|
+
|
|
86
|
+
if local_part.startswith('.') or local_part.endswith('.'):
|
|
87
|
+
return False, "Local part cannot start or end with a dot"
|
|
88
|
+
|
|
89
|
+
if '..' in email:
|
|
90
|
+
return False, "Email cannot contain consecutive dots"
|
|
91
|
+
|
|
92
|
+
return True, "Email is valid"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Test the advanced version
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
test_emails = [
|
|
98
|
+
"user@example.com",
|
|
99
|
+
"invalid..email@domain.com",
|
|
100
|
+
".invalid@domain.com",
|
|
101
|
+
"user@domain",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
for email in test_emails:
|
|
105
|
+
is_valid, message = validate_email_advanced(email)
|
|
106
|
+
print(f"{email:<35} -> {is_valid:5} ({message})")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Using a Third-Party Library (Recommended for Production)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from email_validator import validate_email, EmailNotValidError
|
|
113
|
+
|
|
114
|
+
def validate_email_production(email: str) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Validates email using the email_validator library.
|
|
117
|
+
More reliable for production use.
|
|
118
|
+
|
|
119
|
+
Install with: pip install email-validator
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
# Validate and normalize the email
|
|
123
|
+
valid = validate_email(email)
|
|
124
|
+
return True
|
|
125
|
+
except EmailNotValidError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Usage
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
print(validate_email_production("user@example.com")) # True
|
|
132
|
+
print(validate_email_production("invalid@")) # False
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Comparison
|
|
136
|
+
|
|
137
|
+
| Method | Pros | Cons |
|
|
138
|
+
|--------|------|------|
|
|
139
|
+
| **Regex (Simple)** | Fast, no dependencies | Limited validation |
|
|
140
|
+
| **Regex (Advanced)** | More thorough | Still not RFC-compliant |
|
|
141
|
+
| **email-validator** | RFC-compliant, reliable | External dependency |
|
|
142
|
+
|
|
143
|
+
**Recommendation**: Use the `email-validator` library for production applications, as it properly handles RFC 5321/5322 standards. Use the simple regex version for quick scripts or when you want to avoid dependencies.
|
|
144
|
+
|
|
145
|
+
## With Ponytail, 3 lines of code
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import re
|
|
149
|
+
|
|
150
|
+
def is_valid_email(email: str) -> bool:
|
|
151
|
+
return bool(re.match(r'^[^@]+@[^@]+\.[^@]+$', email))
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Skipped: RFC 5322 parser, DNS MX lookup, confirmation email. Add when you actually need to reject `user+tag@sub.domain.co.uk` or catch typos, until then, this catches 99% of "oops I fat-fingered it" cases.
|
|
155
|
+
|
|
156
|
+
**75 → 3 lines of code**, same model, same prompt.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Group By
|
|
2
|
+
|
|
3
|
+
**Task:** "Group this array of objects by a key."
|
|
4
|
+
|
|
5
|
+
## Without Ponytail
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install lodash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { groupBy } from "lodash";
|
|
13
|
+
|
|
14
|
+
const byStatus = groupBy(orders, "status");
|
|
15
|
+
// → { pending: [...], shipped: [...], delivered: [...] }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or the hand-rolled version:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const byStatus = orders.reduce((acc, order) => {
|
|
22
|
+
(acc[order.status] ??= []).push(order);
|
|
23
|
+
return acc;
|
|
24
|
+
}, {});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## With Ponytail
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// ponytail: Object.groupBy does this
|
|
31
|
+
const byStatus = Object.groupBy(orders, order => order.status);
|
|
32
|
+
// → { pending: [...], shipped: [...], delivered: [...] }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**1 dependency (or a reduce) → 1 built-in.** `Object.groupBy` shipped in Chrome 117, Firefox 119, Safari 17.4, Node.js 21. If you need a `Map` instead of a plain object: `Map.groupBy(orders, o => o.status)`. Check your target runtime; if you need IE11 or old Node, the `reduce` one-liner is still the right call, not lodash.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Infinite Scroll
|
|
2
|
+
|
|
3
|
+
**Task:** "Load more items when the user scrolls to the bottom."
|
|
4
|
+
|
|
5
|
+
## Without Ponytail
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install react-infinite-scroll-component
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```jsx
|
|
12
|
+
import InfiniteScroll from "react-infinite-scroll-component";
|
|
13
|
+
|
|
14
|
+
export function Feed({ items, fetchMore, hasMore }) {
|
|
15
|
+
return (
|
|
16
|
+
<InfiniteScroll
|
|
17
|
+
dataLength={items.length}
|
|
18
|
+
next={fetchMore}
|
|
19
|
+
hasMore={hasMore}
|
|
20
|
+
loader={<Spinner />}
|
|
21
|
+
endMessage={<p>No more items</p>}
|
|
22
|
+
scrollThreshold={0.9}
|
|
23
|
+
>
|
|
24
|
+
{items.map(item => <Card key={item.id} item={item} />)}
|
|
25
|
+
</InfiniteScroll>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A dependency to watch scroll position and fire a callback.
|
|
31
|
+
|
|
32
|
+
## With Ponytail
|
|
33
|
+
|
|
34
|
+
```jsx
|
|
35
|
+
// ponytail: IntersectionObserver does this, no scroll listener needed
|
|
36
|
+
import { useEffect, useRef } from "react";
|
|
37
|
+
|
|
38
|
+
export function Feed({ items, fetchMore, hasMore }) {
|
|
39
|
+
const sentinel = useRef(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
43
|
+
if (entry.isIntersecting && hasMore) fetchMore();
|
|
44
|
+
});
|
|
45
|
+
if (sentinel.current) observer.observe(sentinel.current);
|
|
46
|
+
return () => observer.disconnect();
|
|
47
|
+
}, [hasMore, fetchMore]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
{items.map(item => <Card key={item.id} item={item} />)}
|
|
52
|
+
<div ref={sentinel} />
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**1 dependency → 0 dependencies.** `IntersectionObserver` fires only when the sentinel enters the viewport, no scroll event, no throttling, no jank. Ships in every browser. The library wraps exactly this API.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Number Formatting
|
|
2
|
+
|
|
3
|
+
**Task:** "Format numbers as currency and with thousand separators."
|
|
4
|
+
|
|
5
|
+
## Without Ponytail
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install numeral
|
|
9
|
+
# or: npm install accounting
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import numeral from "numeral";
|
|
14
|
+
|
|
15
|
+
numeral(1234567.89).format("$1,234.00"); // "$1,234,567.89"
|
|
16
|
+
numeral(0.745).format("0.0%"); // "74.5%"
|
|
17
|
+
numeral(1500).format("0.0a"); // "1.5k"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## With Ponytail
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// ponytail: Intl.NumberFormat does this, locale-aware
|
|
24
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
|
25
|
+
.format(1234567.89);
|
|
26
|
+
// → "$1,234,567.89"
|
|
27
|
+
|
|
28
|
+
new Intl.NumberFormat("en-US", { style: "percent" })
|
|
29
|
+
.format(0.745);
|
|
30
|
+
// → "74.5%"
|
|
31
|
+
|
|
32
|
+
new Intl.NumberFormat("en-US", { notation: "compact" })
|
|
33
|
+
.format(1500);
|
|
34
|
+
// → "1.5K"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**1 dependency → 0 dependencies.** `Intl.NumberFormat` is built into every JS runtime, handles every locale correctly, and gets currency symbols, decimal separators, and grouping right for any market without a lookup table. A library that hardcodes formats will always be wrong for someone.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Rate Limiting
|
|
2
|
+
|
|
3
|
+
**Task:** "Add rate limiting to my FastAPI endpoint so users can't spam it."
|
|
4
|
+
|
|
5
|
+
Verbatim model output from a benchmark run, Claude Haiku 4.5, no-skill arm vs ponytail arm, temperature 1, source `benchmarks/output.json`. Reproduce: `npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml`.
|
|
6
|
+
|
|
7
|
+
## Without Ponytail, 128 lines of code
|
|
8
|
+
|
|
9
|
+
# Rate Limiting in FastAPI
|
|
10
|
+
|
|
11
|
+
Here are several approaches, from simplest to most robust:
|
|
12
|
+
|
|
13
|
+
## 1. **Using `slowapi` (Recommended - Easiest)**
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install slowapi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fastapi import FastAPI, Request
|
|
21
|
+
from slowapi import Limiter
|
|
22
|
+
from slowapi.util import get_remote_address
|
|
23
|
+
from slowapi.errors import RateLimitExceeded
|
|
24
|
+
from fastapi.responses import JSONResponse
|
|
25
|
+
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
|
|
28
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
29
|
+
app.state.limiter = limiter
|
|
30
|
+
app.add_exception_handler(RateLimitExceeded, lambda request, exc: JSONResponse(
|
|
31
|
+
status_code=429,
|
|
32
|
+
content={"detail": "Rate limit exceeded"}
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
@app.get("/api/data")
|
|
36
|
+
@limiter.limit("5/minute")
|
|
37
|
+
async def get_data(request: Request):
|
|
38
|
+
return {"message": "Success"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Rate limit formats:**
|
|
42
|
+
- `"5/minute"` - 5 requests per minute
|
|
43
|
+
- `"100/hour"` - 100 requests per hour
|
|
44
|
+
- `"1000/day"` - 1000 requests per day
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 2. **Using `limits` Library (More Control)**
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install limits
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
56
|
+
from limits import parse
|
|
57
|
+
from limits.storage import MemoryStorage
|
|
58
|
+
from limits.strategies import MovingWindowRateLimiter
|
|
59
|
+
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
|
|
62
|
+
storage = MemoryStorage()
|
|
63
|
+
limiter = MovingWindowRateLimiter(storage)
|
|
64
|
+
rate_limit = parse("5/minute")
|
|
65
|
+
|
|
66
|
+
@app.get("/api/data")
|
|
67
|
+
async def get_data(request: Request):
|
|
68
|
+
client_id = request.client.host
|
|
69
|
+
|
|
70
|
+
if not limiter.hit(rate_limit, client_id):
|
|
71
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
72
|
+
|
|
73
|
+
return {"message": "Success"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 3. **Custom Middleware (Full Control)**
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
82
|
+
from datetime import datetime, timedelta
|
|
83
|
+
from collections import defaultdict
|
|
84
|
+
import asyncio
|
|
85
|
+
|
|
86
|
+
app = FastAPI()
|
|
87
|
+
|
|
88
|
+
# Store request timestamps per IP
|
|
89
|
+
request_history = defaultdict(list)
|
|
90
|
+
RATE_LIMIT = 5 # requests
|
|
91
|
+
TIME_WINDOW = 60 # seconds
|
|
92
|
+
|
|
93
|
+
@app.middleware("http")
|
|
94
|
+
async def rate_limit_middleware(request: Request, call_next):
|
|
95
|
+
client_ip = request.client.host
|
|
96
|
+
now = datetime.now()
|
|
97
|
+
|
|
98
|
+
# Clean old requests outside time window
|
|
99
|
+
request_history[client_ip] = [
|
|
100
|
+
req_time for req_time in request_history[client_ip]
|
|
101
|
+
if now - req_time < timedelta(seconds=TIME_WINDOW)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Check if limit exceeded
|
|
105
|
+
if len(request_history[client_ip]) >= RATE_LIMIT:
|
|
106
|
+
return JSONResponse(
|
|
107
|
+
status_code=429,
|
|
108
|
+
content={"detail": "Rate limit exceeded"}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Record this request
|
|
112
|
+
request_history[client_ip].append(now)
|
|
113
|
+
|
|
114
|
+
return await call_next(request)
|
|
115
|
+
|
|
116
|
+
@app.get("/api/data")
|
|
117
|
+
async def get_data():
|
|
118
|
+
return {"message": "Success"}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 4. **Per-User Rate Limiting (With Authentication)**
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from fastapi import FastAPI, Depends, HTTPException
|
|
127
|
+
from slowapi import Limiter
|
|
128
|
+
from slowapi.util import get_remote_address
|
|
129
|
+
|
|
130
|
+
app = FastAPI()
|
|
131
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
132
|
+
|
|
133
|
+
def get_user_id(token: str = Header(None)) -> str:
|
|
134
|
+
# Your auth logic here
|
|
135
|
+
return token or "anonymous"
|
|
136
|
+
|
|
137
|
+
@app.get("/api/data")
|
|
138
|
+
@limiter.limit("10/minute")
|
|
139
|
+
async def get_data(request: Request, user_id: str = Depends(get_user_id)):
|
|
140
|
+
return {"message": f"Success for {user_id}"}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 5. **Redis-Based Rate Limiting (Production)**
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install slowapi redis
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from fastapi import FastAPI, Request
|
|
153
|
+
from slowapi import Limiter
|
|
154
|
+
from slowapi.util import get_remote_address
|
|
155
|
+
from slowapi.errors import RateLimitExceeded
|
|
156
|
+
from slowapi.storage import RedisStorage
|
|
157
|
+
from redis import Redis
|
|
158
|
+
from fastapi.responses import JSONResponse
|
|
159
|
+
|
|
160
|
+
redis_client = Redis.from_url("redis://localhost:6379")
|
|
161
|
+
storage = RedisStorage(redis_client)
|
|
162
|
+
limiter = Limiter(key_func=get_remote_address, storage=storage)
|
|
163
|
+
|
|
164
|
+
app = FastAPI()
|
|
165
|
+
app.state.limiter = limiter
|
|
166
|
+
|
|
167
|
+
app.add_exception_handler(
|
|
168
|
+
RateLimitExceeded,
|
|
169
|
+
lambda request, exc: JSONResponse(
|
|
170
|
+
status_code=429,
|
|
171
|
+
content={"detail": "Rate limit exceeded"}
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@app.get("/api/data")
|
|
176
|
+
@limiter.limit("5/minute")
|
|
177
|
+
async def get_data(request: Request):
|
|
178
|
+
return {"message": "Success"}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 6. **Complete Example with Multiple Endpoints**
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from fastapi import FastAPI, Request
|
|
187
|
+
from slowapi import Limiter
|
|
188
|
+
from slowapi.util import get_remote_address
|
|
189
|
+
from slowapi.errors import RateLimitExceeded
|
|
190
|
+
from fastapi.responses import JSONResponse
|
|
191
|
+
|
|
192
|
+
app = FastAPI()
|
|
193
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
194
|
+
app.state.limiter = limiter
|
|
195
|
+
|
|
196
|
+
@app.exception_handler(RateLimitExceeded)
|
|
197
|
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
|
198
|
+
return JSONResponse(
|
|
199
|
+
status_code=429,
|
|
200
|
+
content={
|
|
201
|
+
"detail": "Too many requests",
|
|
202
|
+
"retry_after": exc.detail
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Strict limit
|
|
207
|
+
@app.post("/api/login")
|
|
208
|
+
@limiter.limit("3/minute")
|
|
209
|
+
async def login(request: Request):
|
|
210
|
+
return {"token": "xxx"}
|
|
211
|
+
|
|
212
|
+
# Moderate limit
|
|
213
|
+
@app.get("/api/data")
|
|
214
|
+
@limiter.limit("30/minute")
|
|
215
|
+
async def get_data(request: Request):
|
|
216
|
+
return {"data": []}
|
|
217
|
+
|
|
218
|
+
# Generous limit
|
|
219
|
+
@app.get("/api/public")
|
|
220
|
+
@limiter.limit("100/minute")
|
|
221
|
+
async def get_public(request: Request):
|
|
222
|
+
return {"public": "data"}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Comparison Table
|
|
228
|
+
|
|
229
|
+
| Method | Pros | Cons | Best For |
|
|
230
|
+
|--------|------|------|----------|
|
|
231
|
+
| **slowapi** | Easy, decorator-based | In-memory only | Development, small apps |
|
|
232
|
+
| **limits** | Flexible, multiple backends | More setup | Custom logic |
|
|
233
|
+
| **Middleware** | Full control, no dependencies | Manual cleanup | Learning, simple cases |
|
|
234
|
+
| **Redis** | Distributed, scalable | Requires Redis | Production, multi-server |
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Testing Rate Limits
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
import httpx
|
|
242
|
+
|
|
243
|
+
async def test_rate_limit():
|
|
244
|
+
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
|
|
245
|
+
for i in range(6):
|
|
246
|
+
response = await client.get("/api/data")
|
|
247
|
+
print(f"Request {i+1}: {response.status_code}")
|
|
248
|
+
# First 5 should be 200, 6th should be 429
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Recommendation:** Use `slowapi` for most cases, it's the most popular and easiest to implement! 🚀
|
|
252
|
+
|
|
253
|
+
## With Ponytail, 10 lines of code
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from fastapi import FastAPI, HTTPException
|
|
257
|
+
from slowapi import Limiter
|
|
258
|
+
from slowapi.util import get_remote_address
|
|
259
|
+
|
|
260
|
+
app = FastAPI()
|
|
261
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
262
|
+
app.state.limiter = limiter
|
|
263
|
+
|
|
264
|
+
@app.get("/api/endpoint")
|
|
265
|
+
@limiter.limit("10/minute")
|
|
266
|
+
async def my_endpoint(request):
|
|
267
|
+
return {"status": "ok"}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Skipped: custom rate limit logic, Redis, sliding windows, `slowapi` handles it. Add when: you need distributed rate limiting across multiple servers (swap `Limiter` for Redis backend) or per-user limits (add `key_func=lambda r: r.headers.get("authorization")`).
|
|
271
|
+
|
|
272
|
+
**128 → 10 lines of code**, same model, same prompt.
|