
Specstory Link Trail
- 238 installs
- 31 repo stars
- Updated January 31, 2026
- specstoryai/agent-skills
Use specstory-link-trail for development tasks
About
specstory-link-trail: A skill for development. This provides functionality for development workflows.
- specstory-link-trail
Specstory Link Trail by the numbers
- 238 all-time installs (skills.sh)
- +4 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,585 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/specstoryai/agent-skills --skill specstory-link-trailAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 238 |
|---|---|
| repo stars | ★ 31 |
| Last updated | January 31, 2026 |
| Repository | specstoryai/agent-skills ↗ |
What it does
Use specstory-link-trail for development tasks
Files
SpecStory Link Trail
Reviews your .specstory/history sessions and creates a summary of all URLs that were fetched via WebFetch tool calls. Useful for auditing external resources accessed during development.
How It Works
1. Parses SpecStory history files for WebFetch tool calls 2. Extracts URLs, status codes, and context 3. Groups by session with timestamps 4. Separates successful fetches from failures 5. Deduplicates repeated URLs with fetch counts
Why Track Links?
During AI-assisted coding, your assistant fetches documentation, APIs, and resources on your behalf. Link Trail helps you:
- Audit what external resources were accessed
- Find that documentation page you saw earlier
- Review failed fetches that might need retry
- Understand your research patterns
Usage
Slash Command
| User says | Script behavior |
|---|---|
/specstory-link-trail | All sessions in history |
/specstory-link-trail today | Today's sessions only |
/specstory-link-trail last session | Most recent session |
/specstory-link-trail 2026-01-22 | Sessions from specific date |
/specstory-link-trail *.md | Custom glob pattern |
Direct Script Usage
# All sessions
python skills/specstory-link-trail/parse_webfetch.py .specstory/history/*.md | \
python skills/specstory-link-trail/generate_report.py -
# Specific session
python skills/specstory-link-trail/parse_webfetch.py .specstory/history/2026-01-22*.md | \
python skills/specstory-link-trail/generate_report.py -
# Sessions from a date range
python skills/specstory-link-trail/parse_webfetch.py .specstory/history/2026-01-2*.md | \
python skills/specstory-link-trail/generate_report.py -Output
Link Trail Report
=================
Sessions analyzed: 5
Total URLs fetched: 23 (18 successful, 5 failed)
Session: fix-authentication-bug (2026-01-22)
--------------------------------------------
Successful fetches:
- https://docs.github.com/en/rest/authentication (×2)
- https://jwt.io/introduction
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401
Failed fetches:
- https://internal.company.com/api/docs (403 Forbidden)
Session: add-caching-layer (2026-01-21)
---------------------------------------
Successful fetches:
- https://redis.io/docs/latest/commands
- https://docs.python.org/3/library/functools.html#functools.lru_cache
- https://stackoverflow.com/questions/... (×3)
Summary by Domain
-----------------
github.com: 5 fetches
stackoverflow.com: 4 fetches
docs.python.org: 3 fetches
redis.io: 2 fetches
(9 other domains): 9 fetchesPresent Results to User
The script output IS the report. Present it directly without additional commentary, but you may:
1. Highlight key findings - Most frequently accessed domains, any failed fetches 2. Offer follow-ups - "Want me to retry the failed fetches?" or "Need details on any of these?"
Example Response
Here's your link trail from recent sessions:
[script output here]
I noticed 5 failed fetches - mostly internal URLs that require authentication.
The most accessed domain was github.com (5 fetches), mostly for their REST API docs.
Would you like me to:
- Retry any of the failed fetches?
- Open any of these links?
- Filter to a specific session?Notes
- Uses streaming parsing for large history files
- URLs are extracted from WebFetch tool calls in the history
- Fetch counts show when the same URL was accessed multiple times
- Failed fetches include the HTTP status code when available
#!/usr/bin/env python3
"""
Extract URLs from context surrounding a WebFetch block.
This helper scans backwards from a WebFetch position to find the likely URL
that was fetched, using multiple discovery strategies.
"""
import re
from typing import Optional, Tuple
# URL pattern - matches http:// or https:// URLs
URL_PATTERN = re.compile(
r'https?://[^\s<>"\'\]\)]+',
re.IGNORECASE
)
# Patterns that indicate a URL fetch request
FETCH_PATTERNS = [
re.compile(r'(?:read|fetch|go to|visit|check|analyze|look at)\s+(https?://[^\s<>"\']+)', re.IGNORECASE),
re.compile(r'(https?://[^\s<>"\']+)\s*(?:and|,|\s)', re.IGNORECASE),
]
# Domain pattern for domain analysis sessions - capture full domain
DOMAIN_PATTERN = re.compile(r'\b([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)*\.[a-zA-Z]{2,})\b')
# Common TLDs for validation
COMMON_TLDS = ['com', 'org', 'net', 'io', 'ai', 'dev', 'co', 'it', 'de', 'fr', 'uk', 'kr', 'ua', 'tech', 'app', 'edu', 'gov', 'ac']
def extract_url_from_context(
lines: list[str],
webfetch_line_idx: int,
lookback: int = 50,
result_content: Optional[str] = None
) -> Tuple[Optional[str], str]:
"""
Extract the URL that was likely fetched from surrounding context.
Args:
lines: All lines from the file
webfetch_line_idx: Line index where WebFetch block starts
lookback: Number of lines to look back
result_content: Optional content of the WebFetch result (for inferring from headers)
Returns:
Tuple of (url, source) where source is one of:
- "user_message": Found in a user message
- "thinking": Found in a <think> block
- "task_prompt": Found in a Task tool prompt
- "result_content": Inferred from the result header
- "inferred": Inferred from domain mention
- "unknown": Could not determine
"""
start_idx = max(0, webfetch_line_idx - lookback)
context_lines = lines[start_idx:webfetch_line_idx]
context = '\n'.join(context_lines)
# Strategy 1: Look for explicit fetch patterns in user messages
user_msg_match = find_url_in_user_message(context_lines)
if user_msg_match:
return user_msg_match, "user_message"
# Strategy 2: Look in thinking blocks
think_match = find_url_in_thinking(context)
if think_match:
return think_match, "thinking"
# Strategy 3: Look in Task tool prompts (subagent spawning)
task_match = find_url_in_task_prompt(context)
if task_match:
return task_match, "task_prompt"
# Strategy 4: Find any URL in recent context (last 20 lines)
recent_context = '\n'.join(context_lines[-20:])
urls = URL_PATTERN.findall(recent_context)
if urls:
# Filter out common non-target URLs
urls = [u for u in urls if not is_noise_url(u)]
if urls:
return urls[-1], "inferred"
# Strategy 5: Infer from result content header (e.g., "# SpecStory: Company Overview")
if result_content:
result_url = infer_url_from_result(result_content)
if result_url:
return result_url, "result_content"
# Strategy 6: Infer from domain mention (for domain analysis sessions)
domain_match = find_domain_for_inference(context_lines[-30:])
if domain_match:
return f"https://{domain_match}", "inferred"
return None, "unknown"
def infer_url_from_result(content: str) -> Optional[str]:
"""
Infer the URL from the WebFetch result content.
Strategies (in order):
1. Look for explicit URLs in the result
2. For error messages, extract domain from error text
3. Look for domain patterns anywhere in the result
"""
if not content:
return None
content_lower = content.lower()
# Strategy 1: Look for explicit URLs in the result
urls = URL_PATTERN.findall(content[:1000])
if urls:
urls = [u for u in urls if not is_noise_url(u)]
if urls:
return urls[0]
# Strategy 2: For error messages, extract domain from error text
# e.g., "getaddrinfo ENOTFOUND billyemail.com"
# e.g., "Hostname/IP does not match certificate's altnames: Host: mailbox.in.ua"
error_domain_patterns = [
re.compile(r'ENOTFOUND\s+([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)', re.IGNORECASE),
re.compile(r'Host:\s*([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)', re.IGNORECASE),
re.compile(r'certificate.*?([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,})', re.IGNORECASE),
]
for pattern in error_domain_patterns:
match = pattern.search(content)
if match:
domain = match.group(1).strip().rstrip('.')
if not is_noise_domain(domain):
return f"https://{domain}"
# Strategy 3: Look for domain patterns in the first part of content
# This helps for results that mention the domain being analyzed
domains = DOMAIN_PATTERN.findall(content[:500])
if domains:
# Filter out noise and pick the first valid domain
for domain in domains:
if not is_noise_domain(domain) and not is_noise_url(f"https://{domain}"):
return f"https://{domain}"
return None
def find_url_in_user_message(lines: list[str]) -> Optional[str]:
"""Find URL in user message blocks."""
in_user_msg = False
user_msg_content = []
for line in reversed(lines):
if '_**User' in line:
in_user_msg = True
continue
if in_user_msg:
if line.startswith('_**') or line.startswith('---'):
# End of user message block, check what we found
content = '\n'.join(reversed(user_msg_content))
for pattern in FETCH_PATTERNS:
match = pattern.search(content)
if match:
return match.group(1) if match.lastindex else match.group(0)
# Also try simple URL extraction
urls = URL_PATTERN.findall(content)
if urls:
return urls[0]
break
user_msg_content.append(line)
return None
def find_url_in_thinking(context: str) -> Optional[str]:
"""Find URL mentioned in <think> blocks."""
think_pattern = re.compile(r'<think>.*?</think>', re.DOTALL)
think_blocks = think_pattern.findall(context)
for block in reversed(think_blocks):
# Look for fetch-related URL mentions
for pattern in FETCH_PATTERNS:
match = pattern.search(block)
if match:
return match.group(1) if match.lastindex else match.group(0)
# Try simple URL extraction
urls = URL_PATTERN.findall(block)
if urls:
urls = [u for u in urls if not is_noise_url(u)]
if urls:
return urls[-1]
return None
def find_url_in_task_prompt(context: str) -> Optional[str]:
"""Find URL in Task tool prompts that spawned subagents."""
task_pattern = re.compile(
r'<tool-use[^>]*data-tool-name="Task"[^>]*>.*?</tool-use>',
re.DOTALL
)
task_blocks = task_pattern.findall(context)
for block in reversed(task_blocks):
urls = URL_PATTERN.findall(block)
if urls:
urls = [u for u in urls if not is_noise_url(u)]
if urls:
return urls[-1]
# Also look for domain patterns in task prompts
domains = DOMAIN_PATTERN.findall(block)
if domains:
# Reconstruct domain from tuple (DOMAIN_PATTERN captures groups)
for domain_parts in domains:
if isinstance(domain_parts, tuple):
continue
domain = domain_parts
if not is_noise_domain(domain):
return f"https://{domain}"
return None
def find_domain_for_inference(lines: list[str]) -> Optional[str]:
"""Find a domain that was likely being analyzed."""
# Look for domain patterns in recent lines
for line in reversed(lines):
# Skip tool output lines
if '<tool-use' in line or '</tool-use>' in line:
continue
domains = DOMAIN_PATTERN.findall(line)
for domain in domains:
if isinstance(domain, str) and not is_noise_domain(domain):
return domain
return None
def is_noise_url(url: str) -> bool:
"""Check if URL is likely noise (not the target of a fetch)."""
noise_patterns = [
'github.com/anthropics',
'claude.com/docs',
'json-schema.org',
'localhost',
'127.0.0.1',
'example.com', # Unless explicitly being analyzed
]
url_lower = url.lower()
# Check for noise patterns
if any(pattern in url_lower for pattern in noise_patterns):
return True
# Check for file extensions that aren't real domains
fake_tld_patterns = [
r'\.md$', r'\.js$', r'\.ts$', r'\.py$', r'\.json$', r'\.yaml$', r'\.yml$',
r'\.sh$', r'\.css$', r'\.html$', r'\.txt$', r'\.xml$', r'\.toml$',
]
for pattern in fake_tld_patterns:
if re.search(pattern, url_lower):
return True
return False
def is_noise_domain(domain: str) -> bool:
"""Check if domain is likely noise."""
noise_domains = [
'github.com',
'claude.com',
'anthropic.com',
'example.com',
'localhost',
'schema.org',
]
domain_lower = domain.lower()
# Check exact matches
if domain_lower in noise_domains:
return True
# Check subdomain matches
if any(domain_lower.endswith('.' + nd) for nd in noise_domains):
return True
# Check for file extensions masquerading as TLDs
file_extensions = [
'.md', '.js', '.ts', '.jsx', '.tsx', '.py', '.json', '.yaml', '.yml',
'.sh', '.css', '.html', '.txt', '.xml', '.toml', '.rb', '.go', '.rs',
'.vue', '.svelte', '.astro', '.php', '.java', '.c', '.cpp', '.h',
]
if any(domain_lower.endswith(ext) for ext in file_extensions):
return True
# Check for common non-domain patterns
tech_names = [
'next.js', 'react.js', 'vue.js', 'node.js', 'express.js',
'skill.md', 'readme.md', 'settings.json', 'package.json',
# JavaScript/API patterns that look like domains
'location.href', 'window.location', 'document.location',
'tweet.fields', 'user.fields', 'media.fields',
# Other false positives
'e.g.', 'i.e.', 'etc.',
]
if domain_lower in tech_names:
return True
# Check if it looks like a JavaScript property access pattern
if '.href' in domain_lower or '.fields' in domain_lower:
return True
return False
if __name__ == '__main__':
# Simple test
test_lines = [
"_**User (2026-01-22T19:27:48.418Z)**_",
"",
"read https://specstory.com and analyze it",
"",
"---",
"",
"_**Agent**_",
"",
'<tool-use data-tool-type="unknown" data-tool-name="WebFetch">',
]
url, source = extract_url_from_context(test_lines, len(test_lines) - 1)
print(f"URL: {url}, Source: {source}")
#!/usr/bin/env python3
"""
Generate a markdown report from parsed WebFetch data.
Reads JSON from parse_webfetch.py and produces a formatted report with:
- Sessions grouped by file/date
- Successful vs failed fetches separated
- Deduplication with fetch counts
- Summary statistics
Usage:
python generate_report.py <json_file>
python generate_report.py - # Read from stdin
cat data.json | python generate_report.py -
Output: Markdown report to stdout
"""
import sys
import json
import re
from collections import defaultdict
from datetime import datetime
from typing import Optional
def extract_date_from_filename(filename: str) -> Optional[str]:
"""Extract date from SpecStory filename pattern: YYYY-MM-DD_HH-MM-SSZ"""
# Match pattern like 2026-01-22_19-20-56Z
match = re.search(r'(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}Z', filename)
if match:
return match.group(1)
return None
def extract_session_title(filename: str) -> str:
"""Extract human-readable title from filename."""
# Remove path and extension
name = filename.split('/')[-1]
if name.endswith('.md'):
name = name[:-3]
# Remove timestamp prefix if present
match = re.match(r'\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}Z-?(.*)$', name)
if match:
title = match.group(1)
if title:
# Convert dashes to spaces and capitalize
return title.replace('-', ' ').strip()
return name
def truncate_url(url: str, max_length: int = 60) -> str:
"""Truncate URL for display while keeping it recognizable."""
if not url or len(url) <= max_length:
return url or 'Unknown'
# Try to keep the domain and some of the path
parts = url.split('/')
if len(parts) > 3:
domain = '/'.join(parts[:3])
if len(domain) < max_length - 5:
remaining = max_length - len(domain) - 4
return f"{domain}/...{url[-remaining:]}"
return url[:max_length - 3] + '...'
def format_error(error: str, error_raw: Optional[str] = None) -> str:
"""Format error for display."""
error_descriptions = {
'ENOTFOUND': 'DNS lookup failed',
'ETIMEDOUT': 'Connection timed out',
'ECONNREFUSED': 'Connection refused',
'ECONNRESET': 'Connection reset',
'SSL_EXPIRED': 'SSL certificate expired',
'SSL_MISMATCH': 'SSL hostname mismatch',
'SSL_SELF_SIGNED': 'Self-signed certificate',
'SSL_CHAIN': 'SSL certificate chain error',
'CONTENT_TOO_LONG': 'Content exceeded size limit',
'SOCKET_HANGUP': 'Connection dropped',
'TIMEOUT': 'Request timed out',
'EMPTY_RESPONSE': 'Empty response',
'GENERIC_ERROR': 'Request failed',
}
# Handle HTTP errors
if error and error.startswith('HTTP_'):
code = error.replace('HTTP_', '')
http_messages = {
'400': 'Bad Request',
'401': 'Unauthorized',
'403': 'Forbidden',
'404': 'Not Found',
'429': 'Rate Limited',
'500': 'Server Error',
'502': 'Bad Gateway',
'503': 'Service Unavailable',
}
return f"HTTP {code} ({http_messages.get(code, 'Error')})"
return error_descriptions.get(error, error or 'Unknown error')
def generate_report(data: list[dict]) -> str:
"""Generate markdown report from parsed WebFetch data."""
if not data:
return "# Link Trail Report\n\nNo WebFetch instances found.\n"
# Group by file
by_file = defaultdict(list)
for item in data:
by_file[item['file']].append(item)
# Sort files by date (extracted from filename)
sorted_files = sorted(
by_file.keys(),
key=lambda f: extract_date_from_filename(f) or '0000-00-00',
reverse=True
)
# Calculate global stats
total_fetches = len(data)
successful = sum(1 for d in data if d['success'])
failed = total_fetches - successful
# Track unique URLs across all files
all_urls = defaultdict(int)
for item in data:
if item['url']:
all_urls[item['url']] += 1
unique_urls = len(all_urls)
duplicate_fetches = sum(1 for count in all_urls.values() if count > 1)
# Build report
lines = []
lines.append("# Link Trail Report")
lines.append("")
lines.append("## Summary")
lines.append("")
lines.append(f"- **Total WebFetch calls:** {total_fetches}")
lines.append(f"- **Unique URLs:** {unique_urls}")
lines.append(f"- **Successful:** {successful} ({successful/total_fetches*100:.0f}%)")
lines.append(f"- **Failed:** {failed} ({failed/total_fetches*100:.0f}%)")
if duplicate_fetches:
lines.append(f"- **URLs fetched multiple times:** {duplicate_fetches}")
lines.append(f"- **Sessions analyzed:** {len(by_file)}")
lines.append("")
# Report by session
lines.append("---")
lines.append("")
for filepath in sorted_files:
items = by_file[filepath]
date = extract_date_from_filename(filepath)
title = extract_session_title(filepath)
lines.append(f"## {date or 'Unknown Date'}: {title or 'Session'}")
lines.append("")
lines.append(f"**File:** `{filepath.split('/')[-1]}`")
lines.append(f"**Total fetches:** {len(items)}")
lines.append("")
# Separate successful and failed
successes = [i for i in items if i['success']]
failures = [i for i in items if not i['success']]
# Deduplicate within session
success_by_url = defaultdict(list)
for item in successes:
url = item['url'] or 'Unknown'
success_by_url[url].append(item)
failure_by_url = defaultdict(list)
for item in failures:
url = item['url'] or 'Unknown'
failure_by_url[url].append(item)
if successes:
lines.append(f"### Successful ({len(successes)})")
lines.append("")
lines.append("| URL | Summary |")
lines.append("|-----|---------|")
for url, items_list in sorted(success_by_url.items()):
display_url = truncate_url(url)
summary = items_list[0].get('summary', 'No summary')
count_note = f" (x{len(items_list)})" if len(items_list) > 1 else ""
# Escape pipe characters in summary
summary = (summary or 'No summary').replace('|', '\\|')
lines.append(f"| {display_url}{count_note} | {summary} |")
lines.append("")
if failures:
lines.append(f"### Failed ({len(failures)})")
lines.append("")
lines.append("| URL | Error |")
lines.append("|-----|-------|")
for url, items_list in sorted(failure_by_url.items()):
display_url = truncate_url(url)
error = format_error(
items_list[0].get('error'),
items_list[0].get('error_raw')
)
count_note = f" (x{len(items_list)})" if len(items_list) > 1 else ""
lines.append(f"| {display_url}{count_note} | {error} |")
lines.append("")
lines.append("---")
lines.append("")
# Add URL index if there are URLs fetched multiple times
multi_fetch_urls = {url: count for url, count in all_urls.items() if count > 1}
if multi_fetch_urls:
lines.append("## URLs Fetched Multiple Times")
lines.append("")
lines.append("| URL | Times Fetched |")
lines.append("|-----|---------------|")
for url, count in sorted(multi_fetch_urls.items(), key=lambda x: -x[1]):
lines.append(f"| {truncate_url(url)} | {count} |")
lines.append("")
# Footer
lines.append("---")
lines.append(f"*Generated by link-trail skill*")
return '\n'.join(lines)
def main():
if len(sys.argv) < 2:
print("Usage: python generate_report.py <json_file>", file=sys.stderr)
print(" python generate_report.py - # read from stdin", file=sys.stderr)
sys.exit(1)
input_source = sys.argv[1]
try:
if input_source == '-':
data = json.load(sys.stdin)
else:
with open(input_source, 'r', encoding='utf-8') as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(f"Error: File not found: {input_source}", file=sys.stderr)
sys.exit(1)
report = generate_report(data)
print(report)
if __name__ == '__main__':
main()
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.#!/usr/bin/env python3
"""
Parse WebFetch tool uses from SpecStory history files.
Extracts all WebFetch instances with their URLs, results, and status.
Handles large files via streaming and outputs structured JSON.
Usage:
python parse_webfetch.py <file_or_glob_pattern> [...]
python parse_webfetch.py .specstory/history/*.md
python parse_webfetch.py .specstory/history/2026-01-22*.md
Output: JSON array to stdout
"""
import sys
import re
import json
import glob
from pathlib import Path
from typing import Iterator, Optional
from dataclasses import dataclass, asdict
# Import the URL extraction helper
from extract_urls_context import extract_url_from_context
@dataclass
class WebFetchResult:
"""Represents a single WebFetch tool use."""
url: Optional[str]
url_source: str # user_message, thinking, task_prompt, inferred, unknown
success: bool
summary: Optional[str]
error: Optional[str]
error_raw: Optional[str]
line_number: int
file: str
# Error patterns to detect failed fetches
ERROR_PATTERNS = [
(re.compile(r'getaddrinfo ENOTFOUND', re.IGNORECASE), 'ENOTFOUND'),
(re.compile(r'ETIMEDOUT', re.IGNORECASE), 'ETIMEDOUT'),
(re.compile(r'ECONNREFUSED', re.IGNORECASE), 'ECONNREFUSED'),
(re.compile(r'ECONNRESET', re.IGNORECASE), 'ECONNRESET'),
(re.compile(r'Request failed with status code (\d+)'), 'HTTP_ERROR'),
(re.compile(r'certificate has expired', re.IGNORECASE), 'SSL_EXPIRED'),
(re.compile(r'Hostname/IP does not match certificate', re.IGNORECASE), 'SSL_MISMATCH'),
(re.compile(r'self[- ]signed certificate', re.IGNORECASE), 'SSL_SELF_SIGNED'),
(re.compile(r'unable to verify the first certificate', re.IGNORECASE), 'SSL_CHAIN'),
(re.compile(r'Prompt is too long', re.IGNORECASE), 'CONTENT_TOO_LONG'),
(re.compile(r'socket hang up', re.IGNORECASE), 'SOCKET_HANGUP'),
(re.compile(r'timeout', re.IGNORECASE), 'TIMEOUT'),
]
# WebFetch block start pattern
WEBFETCH_START = re.compile(
r'<tool-use[^>]*data-tool-name="WebFetch"[^>]*>',
re.IGNORECASE
)
# End of tool-use block
TOOLUSE_END = '</tool-use>'
def parse_file(filepath: Path) -> Iterator[WebFetchResult]:
"""
Parse a single SpecStory history file for WebFetch uses.
Uses line-by-line streaming to handle large files.
"""
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
except Exception as e:
print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr)
return
i = 0
while i < len(lines):
line = lines[i]
# Look for WebFetch block start
if WEBFETCH_START.search(line):
webfetch_start_line = i + 1 # 1-indexed for reporting
# Extract the content of this WebFetch block
content_lines = []
in_code_block = False
j = i + 1
while j < len(lines):
current_line = lines[j]
# Check for end of tool-use
if TOOLUSE_END in current_line:
break
# Track code blocks (where the result content is)
if current_line.strip().startswith('```'):
if in_code_block:
in_code_block = False
else:
in_code_block = True
j += 1
continue
if in_code_block:
content_lines.append(current_line)
j += 1
# Process the extracted content
content = ''.join(content_lines).strip()
# Find the URL from context (pass content for result-based inference)
url, url_source = extract_url_from_context(lines, i, result_content=content)
# Determine success/failure and extract error info
success, error, error_raw = analyze_result(content)
# Generate summary for successful fetches
summary = None
if success and content:
summary = generate_summary(content)
yield WebFetchResult(
url=url,
url_source=url_source,
success=success,
summary=summary,
error=error,
error_raw=error_raw if error else None,
line_number=webfetch_start_line,
file=str(filepath)
)
# Move past this block
i = j
i += 1
def analyze_result(content: str) -> tuple[bool, Optional[str], Optional[str]]:
"""
Analyze WebFetch result content to determine success/failure.
Returns: (success, error_type, error_raw)
"""
if not content:
return False, 'EMPTY_RESPONSE', None
# Check for known error patterns
for pattern, error_type in ERROR_PATTERNS:
match = pattern.search(content)
if match:
error_raw = content[:200].strip()
# For HTTP errors, include the status code
if error_type == 'HTTP_ERROR' and match.groups():
error_type = f'HTTP_{match.group(1)}'
return False, error_type, error_raw
# Check content length - very short responses are often errors
if len(content) < 50:
# But some short responses are valid (e.g., redirects)
if any(word in content.lower() for word in ['redirect', 'moved']):
return True, None, None
# Check if it looks like an error message
if any(word in content.lower() for word in ['error', 'failed', 'denied', 'forbidden']):
return False, 'GENERIC_ERROR', content[:200].strip()
# Assume success if no error patterns matched
return True, None, None
def generate_summary(content: str, max_length: int = 200) -> str:
"""Generate a brief summary of successful fetch content."""
# Try to extract a title or first meaningful line
lines = content.strip().split('\n')
# Look for a header
for line in lines[:5]:
line = line.strip()
if line.startswith('#'):
# Remove markdown header markers
title = line.lstrip('#').strip()
if title:
return title[:max_length]
# Look for "What They Do" or similar sections
overview_patterns = [
r'##\s*What They Do\s*\n+(.+)',
r'##\s*Overview\s*\n+(.+)',
r'##\s*Company Overview\s*\n+(.+)',
r'##\s*Business Description\s*\n+(.+)',
]
for pattern in overview_patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
summary = match.group(1).strip()
return summary[:max_length]
# Fall back to first non-empty, non-header line
for line in lines:
line = line.strip()
if line and not line.startswith('#') and not line.startswith('-'):
return line[:max_length]
return content[:max_length]
def expand_paths(patterns: list[str]) -> list[Path]:
"""Expand glob patterns and return list of files."""
files = []
for pattern in patterns:
# Check if it's a glob pattern
if '*' in pattern or '?' in pattern:
matches = glob.glob(pattern, recursive=True)
files.extend(Path(m) for m in matches)
else:
path = Path(pattern)
if path.exists():
files.append(path)
else:
print(f"Warning: File not found: {pattern}", file=sys.stderr)
# Remove duplicates while preserving order
seen = set()
unique_files = []
for f in files:
resolved = f.resolve()
if resolved not in seen:
seen.add(resolved)
unique_files.append(f)
return sorted(unique_files, key=lambda p: p.name)
def main():
if len(sys.argv) < 2:
print("Usage: python parse_webfetch.py <file_or_pattern> [...]", file=sys.stderr)
print("Example: python parse_webfetch.py .specstory/history/*.md", file=sys.stderr)
sys.exit(1)
patterns = sys.argv[1:]
files = expand_paths(patterns)
if not files:
print("No files found matching the provided patterns.", file=sys.stderr)
sys.exit(1)
print(f"Processing {len(files)} file(s)...", file=sys.stderr)
results = []
for filepath in files:
for result in parse_file(filepath):
results.append(asdict(result))
# Output JSON to stdout
print(json.dumps(results, indent=2))
print(f"Found {len(results)} WebFetch instance(s).", file=sys.stderr)
if __name__ == '__main__':
main()