claude-memory-agent 3.0.3 → 3.2.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.
@@ -58,7 +58,9 @@ function runSync(cmd, cwd) {
58
58
  */
59
59
  function buildEnvContent(config, agentDir) {
60
60
  const timestamp = new Date().toISOString();
61
- const dbPath = config.dbPath || path.join(agentDir, 'memories.db').replace(/\\/g, '/');
61
+ const homedir = require('os').homedir();
62
+ const defaultDbPath = path.join(homedir, '.claude-memory', 'memories.db').replace(/\\/g, '/');
63
+ const dbPath = config.dbPath || defaultDbPath;
62
64
  const memoryUrl = 'http://' + (config.host === '0.0.0.0' ? 'localhost' : config.host) + ':' + config.port;
63
65
 
64
66
  const lines = [
@@ -44,7 +44,7 @@ async function promptAdvanced() {
44
44
  });
45
45
 
46
46
  const dbPath = await input({
47
- message: 'Database path (leave empty for agent directory):',
47
+ message: 'Database path (leave empty for ~/.claude-memory/):',
48
48
  default: '',
49
49
  });
50
50
 
package/config.py CHANGED
@@ -21,6 +21,9 @@ logger = logging.getLogger(__name__)
21
21
  AGENT_DIR = Path(__file__).parent.resolve()
22
22
  load_dotenv(AGENT_DIR / ".env")
23
23
 
24
+ # User data directory — safe from code updates (zip, git pull, npm update)
25
+ USER_DATA_DIR = Path(os.getenv("USER_DATA_DIR", str(Path.home() / ".claude-memory")))
26
+
24
27
 
25
28
  class Config:
26
29
  """Configuration singleton with environment variable loading."""
@@ -30,11 +33,15 @@ class Config:
30
33
  self.AGENT_DIR = AGENT_DIR
31
34
  self.DATABASE_PATH = Path(os.getenv(
32
35
  "DATABASE_PATH",
33
- str(AGENT_DIR / "memories.db")
36
+ str(USER_DATA_DIR / "memories.db")
34
37
  ))
35
38
  self.INDEX_DIR = Path(os.getenv(
36
39
  "INDEX_DIR",
37
- str(AGENT_DIR / "indexes")
40
+ str(USER_DATA_DIR / "indexes")
41
+ ))
42
+ self.QUEUE_DB_PATH = Path(os.getenv(
43
+ "QUEUE_DB_PATH",
44
+ str(USER_DATA_DIR / "queue.db")
38
45
  ))
39
46
  self.LOG_FILE = AGENT_DIR / "memory-agent.log"
40
47
  self.LOCK_FILE = AGENT_DIR / "memory-agent.lock"
@@ -207,3 +214,4 @@ OLLAMA_HOST = config.OLLAMA_HOST
207
214
  EMBEDDING_PROVIDER = config.EMBEDDING_PROVIDER
208
215
  EMBEDDING_MODEL = config.EMBEDDING_MODEL
209
216
  DATABASE_PATH = config.DATABASE_PATH
217
+ QUEUE_DB_PATH = config.QUEUE_DB_PATH
package/dashboard.html CHANGED
@@ -466,6 +466,31 @@
466
466
  top: 0; left: 0; right: 0;
467
467
  height: 3px;
468
468
  }
469
+ .agent-badges {
470
+ display: flex;
471
+ flex-wrap: wrap;
472
+ gap: 6px;
473
+ margin-bottom: 12px;
474
+ }
475
+ .agent-badge {
476
+ padding: 3px 10px;
477
+ border-radius: 12px;
478
+ font-size: 11px;
479
+ font-weight: 600;
480
+ letter-spacing: 0.3px;
481
+ }
482
+ .agent-badge.scope-global {
483
+ background: rgba(88, 166, 255, 0.15);
484
+ color: #58a6ff;
485
+ }
486
+ .agent-badge.scope-project {
487
+ background: rgba(63, 185, 80, 0.15);
488
+ color: #3fb950;
489
+ }
490
+ .agent-badge.category-badge {
491
+ background: var(--bg-tertiary);
492
+ color: var(--text-secondary);
493
+ }
469
494
  /* Config Cards */
470
495
  .config-card {
471
496
  background: var(--bg-card);
@@ -2205,6 +2230,10 @@
2205
2230
 
2206
2231
  <!-- Agents Tab -->
2207
2232
  <div class="tab-content active" id="agents-tab">
2233
+ <div style="background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.2); border-radius: 12px; padding: 12px 20px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--text-secondary);">
2234
+ <i class="fas fa-info-circle" style="color: #58a6ff; font-size: 16px;"></i>
2235
+ <span>Agents discovered from <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">~/.claude/agents/</code>. Toggle moves files between <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">agents/</code> and <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">agents/_disabled/</code>. Global agents are available across all projects.</span>
2236
+ </div>
2208
2237
  <div class="section-header">
2209
2238
  <div class="search-bar">
2210
2239
  <i class="fas fa-search"></i>
@@ -3056,8 +3085,9 @@
3056
3085
 
3057
3086
  async function loadAgentData() {
3058
3087
  try {
3088
+ const agentParams = currentProject ? `?project_path=${encodeURIComponent(currentProject)}` : '';
3059
3089
  const [agentsRes, mcpsRes, hooksRes] = await Promise.all([
3060
- fetch(`${API_URL}/api/agents`),
3090
+ fetch(`${API_URL}/api/agents${agentParams}`),
3061
3091
  fetch(`${API_URL}/api/mcps`),
3062
3092
  fetch(`${API_URL}/api/hooks`)
3063
3093
  ]);
@@ -3071,12 +3101,14 @@
3071
3101
  document.getElementById('agentsTabCount').textContent = allAgents.length;
3072
3102
  document.getElementById('mcpsTabCount').textContent = allMcps.length;
3073
3103
  document.getElementById('hooksTabCount').textContent = allHooks.length;
3104
+ // Update agent stats from discovery
3105
+ const enabledCount = allAgents.filter(a => a.enabled).length;
3106
+ document.getElementById('enabledAgentsCount').textContent = enabledCount;
3107
+ document.getElementById('agentsTotalCount').textContent = `/ ${allAgents.length} total`;
3074
3108
  renderCategoryFilter();
3075
- if (projectConfig) {
3076
- renderAgents();
3077
- renderMcps();
3078
- renderHooks();
3079
- }
3109
+ renderAgents();
3110
+ renderMcps();
3111
+ renderHooks();
3080
3112
  } catch (e) {
3081
3113
  console.error('Error loading agent data:', e);
3082
3114
  }
@@ -3107,21 +3139,26 @@
3107
3139
  // Rendering
3108
3140
  function renderCategoryFilter() {
3109
3141
  const container = document.getElementById('categoryFilter');
3110
- container.innerHTML = `<div class="category-chip active" data-category="all" onclick="filterByCategory('all')"><i class="fas fa-th"></i> All</div>`;
3142
+ const allCount = allAgents.length;
3143
+ container.innerHTML = `<div class="category-chip active" data-category="all" onclick="filterByCategory('all')"><i class="fas fa-th"></i> All (${allCount})</div>`;
3144
+ // Count agents per category
3145
+ const catCounts = {};
3146
+ allAgents.forEach(a => { catCounts[a.category] = (catCounts[a.category] || 0) + 1; });
3111
3147
  Object.entries(categories).forEach(([key, cat]) => {
3148
+ const count = catCounts[key] || 0;
3112
3149
  const chip = document.createElement('div');
3113
3150
  chip.className = 'category-chip';
3114
3151
  chip.dataset.category = key;
3115
- chip.style.setProperty('--chip-color', cat.color);
3152
+ chip.style.setProperty('--chip-color', cat.color || '#8b949e');
3116
3153
  chip.onclick = () => filterByCategory(key);
3117
- chip.innerHTML = `<i class="fas fa-${getIconForCategory(cat.icon)}"></i> ${cat.name}`;
3154
+ chip.innerHTML = `<i class="fas fa-${getIconForCategory(cat.icon)}"></i> ${cat.name} (${count})`;
3118
3155
  container.appendChild(chip);
3119
3156
  });
3120
3157
  }
3121
3158
 
3122
3159
  function getIconForCategory(icon) {
3123
- const iconMap = { 'code': 'code', 'shield-check': 'shield-alt', 'palette': 'palette', 'lightbulb': 'lightbulb', 'megaphone': 'bullhorn', 'settings': 'cog', 'chart-bar': 'chart-bar', 'cube': 'cube', 'search': 'search' };
3124
- return iconMap[icon] || 'circle';
3160
+ const iconMap = { 'code': 'code', 'shield-check': 'shield-alt', 'palette': 'palette', 'lightbulb': 'lightbulb', 'megaphone': 'bullhorn', 'settings': 'cog', 'chart-bar': 'chart-bar', 'cube': 'cube', 'search': 'search', 'tasks': 'tasks', 'star': 'star', 'headset': 'headset', 'vial': 'vial', 'bullhorn': 'bullhorn', 'circle': 'circle' };
3161
+ return iconMap[icon] || icon || 'circle';
3125
3162
  }
3126
3163
 
3127
3164
  function renderAgents() {
@@ -3129,25 +3166,31 @@
3129
3166
  const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
3130
3167
  let filteredAgents = allAgents;
3131
3168
  if (activeCategory !== 'all') filteredAgents = filteredAgents.filter(a => a.category === activeCategory);
3132
- if (searchTerm) filteredAgents = filteredAgents.filter(a => a.name.toLowerCase().includes(searchTerm) || a.description.toLowerCase().includes(searchTerm) || a.tags.some(t => t.includes(searchTerm)));
3169
+ if (searchTerm) filteredAgents = filteredAgents.filter(a => a.name.toLowerCase().includes(searchTerm) || (a.description || '').toLowerCase().includes(searchTerm) || a.category.includes(searchTerm));
3133
3170
  if (!filteredAgents.length) {
3134
- grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 60px; color: var(--text-secondary);">No agents found</div>';
3171
+ grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 60px; color: var(--text-secondary);"><i class="fas fa-robot" style="font-size: 48px; margin-bottom: 16px; display: block; opacity: 0.3;"></i>No agents found</div>';
3135
3172
  return;
3136
3173
  }
3137
3174
  grid.innerHTML = filteredAgents.map(agent => {
3138
- const config = projectConfig?.agents?.[agent.id] || { enabled: agent.default_enabled };
3139
- const category = categories[agent.category] || {};
3140
- const isEnabled = config.enabled;
3175
+ const isEnabled = agent.enabled;
3176
+ const catMeta = categories[agent.category] || {};
3177
+ const catColor = catMeta.color || agent.color || '#8b949e';
3178
+ const scopeClass = agent.scope === 'global' ? 'scope-global' : 'scope-project';
3179
+ const scopeLabel = agent.scope === 'global' ? 'Global' : 'Project';
3180
+ const catLabel = (catMeta.name || agent.category || '').replace(/-/g, ' ');
3141
3181
  return `
3142
3182
  <div class="agent-card ${isEnabled ? '' : 'disabled'}">
3143
- <div class="agent-category-label" style="background: ${category.color || '#58a6ff'}"></div>
3183
+ <div class="agent-category-label" style="background: ${catColor}"></div>
3144
3184
  <div class="agent-card-header">
3145
- <div class="agent-icon" style="background: ${category.color}20; color: ${category.color}"><i class="fas fa-${getIconForCategory(category.icon)}"></i></div>
3185
+ <div class="agent-icon" style="background: ${catColor}20; color: ${catColor}"><i class="fas fa-${getIconForCategory(catMeta.icon)}"></i></div>
3146
3186
  <div class="agent-toggle ${isEnabled ? 'active' : ''}" onclick="toggleAgent('${agent.id}', ${!isEnabled})"></div>
3147
3187
  </div>
3148
3188
  <div class="agent-name">${agent.name}</div>
3149
- <div class="agent-description">${agent.description}</div>
3150
- <div class="agent-tags">${agent.tags.slice(0, 4).map(tag => `<span class="agent-tag">${tag}</span>`).join('')}</div>
3189
+ <div class="agent-description">${agent.description || 'No description'}</div>
3190
+ <div class="agent-badges">
3191
+ <span class="agent-badge ${scopeClass}">${scopeLabel}</span>
3192
+ <span class="agent-badge category-badge">${catLabel}</span>
3193
+ </div>
3151
3194
  </div>
3152
3195
  `;
3153
3196
  }).join('');
@@ -3197,18 +3240,18 @@
3197
3240
 
3198
3241
  // Toggle actions
3199
3242
  async function toggleAgent(agentId, enabled) {
3200
- if (!currentProject) return;
3201
3243
  try {
3202
- const response = await fetch(`${API_URL}/api/project/${encodeURIComponent(currentProject)}/agent/${agentId}`, {
3244
+ const response = await fetch(`${API_URL}/api/agents/${agentId}/toggle`, {
3203
3245
  method: 'POST',
3204
3246
  headers: { 'Content-Type': 'application/json' },
3205
- body: JSON.stringify({ enabled })
3247
+ body: JSON.stringify({ enabled, project_path: currentProject || undefined })
3206
3248
  });
3207
3249
  const result = await response.json();
3208
3250
  if (result.success) {
3209
- await loadProjectConfig();
3210
- renderAgents();
3251
+ await loadAgentData();
3211
3252
  showToast(`${agentId} ${enabled ? 'enabled' : 'disabled'}`, 'success');
3253
+ } else {
3254
+ showToast(result.error || 'Failed to toggle agent', 'error');
3212
3255
  }
3213
3256
  } catch (e) {
3214
3257
  showToast('Failed to update agent', 'error');
@@ -3254,21 +3297,28 @@
3254
3297
  }
3255
3298
 
3256
3299
  async function enableAllAgents(enabled) {
3257
- if (!currentProject) return;
3258
- const updates = {};
3259
- allAgents.forEach(agent => { updates[agent.id] = enabled; });
3300
+ // Filter to only agents that need toggling
3301
+ const toToggle = allAgents.filter(a => a.enabled !== enabled);
3302
+ if (!toToggle.length) {
3303
+ showToast(`All agents already ${enabled ? 'enabled' : 'disabled'}`, 'info');
3304
+ return;
3305
+ }
3260
3306
  try {
3261
- const response = await fetch(`${API_URL}/api/project/${encodeURIComponent(currentProject)}/agents/bulk`, {
3262
- method: 'POST',
3263
- headers: { 'Content-Type': 'application/json' },
3264
- body: JSON.stringify({ updates })
3265
- });
3266
- const result = await response.json();
3267
- if (result.success) {
3268
- await loadProjectConfig();
3269
- renderAgents();
3270
- showToast(`All agents ${enabled ? 'enabled' : 'disabled'}`, 'success');
3307
+ let succeeded = 0;
3308
+ // Toggle in batches of 5 for reasonable concurrency
3309
+ for (let i = 0; i < toToggle.length; i += 5) {
3310
+ const batch = toToggle.slice(i, i + 5);
3311
+ const results = await Promise.all(batch.map(agent =>
3312
+ fetch(`${API_URL}/api/agents/${agent.id}/toggle`, {
3313
+ method: 'POST',
3314
+ headers: { 'Content-Type': 'application/json' },
3315
+ body: JSON.stringify({ enabled, project_path: currentProject || undefined })
3316
+ }).then(r => r.json())
3317
+ ));
3318
+ succeeded += results.filter(r => r.success).length;
3271
3319
  }
3320
+ await loadAgentData();
3321
+ showToast(`${succeeded} agents ${enabled ? 'enabled' : 'disabled'}`, 'success');
3272
3322
  } catch (e) {
3273
3323
  showToast('Failed to update agents', 'error');
3274
3324
  }
@@ -261,12 +261,9 @@ def main():
261
261
  if not response_text or len(response_text) < 50:
262
262
  sys.exit(0)
263
263
 
264
- # Load session data (includes current_request_id for causal chain)
265
- session_data = load_session_data()
266
- if not session_data:
267
- sys.exit(0)
268
-
269
- session_id = session_data.get("session_id")
264
+ # Load session data, prefer session_id from stdin
265
+ session_data = load_session_data() or {}
266
+ session_id = hook_input.get("session_id") or session_data.get("session_id")
270
267
  if not session_id:
271
268
  sys.exit(0)
272
269
 
@@ -110,8 +110,8 @@ def main():
110
110
  except (json.JSONDecodeError, ValueError, EOFError):
111
111
  sys.exit(0)
112
112
 
113
- # Get user message
114
- user_message = hook_input.get("user_prompt", "")
113
+ # Get user message (Claude Code sends "prompt", legacy uses "user_prompt")
114
+ user_message = hook_input.get("prompt", "") or hook_input.get("user_prompt", "")
115
115
  if not user_message:
116
116
  session_messages = hook_input.get("session_messages", [])
117
117
  if session_messages:
@@ -128,14 +128,15 @@ def main():
128
128
  if not is_correction:
129
129
  sys.exit(0)
130
130
 
131
- # Load session data (includes current_request_id for causal chain)
132
- session_data = load_session_data()
133
- if not session_data:
131
+ # Load session data, prefer session_id from stdin
132
+ session_data = load_session_data() or {}
133
+ stdin_session_id = hook_input.get("session_id")
134
+ if stdin_session_id and session_data.get("session_id") != stdin_session_id:
135
+ session_data["session_id"] = stdin_session_id
136
+ if not session_data.get("session_id"):
134
137
  sys.exit(0)
135
138
 
136
139
  session_id = session_data.get("session_id")
137
- if not session_id:
138
- sys.exit(0)
139
140
 
140
141
  # Get the current request ID for causal chain linking
141
142
  root_event_id = session_data.get("current_request_id")
@@ -66,10 +66,23 @@ PATTERN_PATTERNS = [
66
66
  re.compile(r"(?:^|\n)\s*(?:Always|Never|Should always|Should never|Must always|Must never) (.*?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
67
67
  ]
68
68
 
69
+ # Workflow/procedure patterns
70
+ WORKFLOW_PATTERNS = [
71
+ # "To build X, run Y" / "To deploy X, do Y"
72
+ re.compile(r"(?:^|\n)\s*(?:To|to) (\w[\w\s]{3,30}),\s*(?:run|do|use|execute|type) (.{10,}?)(?:\n|$)", re.IGNORECASE | re.MULTILINE),
73
+ # "learned how to..."
74
+ re.compile(r"(?:^|\n)\s*(?:I learned|We learned|learned how to|figured out how to) (.{20,}?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
75
+ # Step-by-step: "1. ...\n2. ...\n3. ..."
76
+ re.compile(r"(?:^|\n)\s*1[.)]\s+(.+)\n\s*2[.)]\s+(.+)\n\s*3[.)]\s+(.+)", re.MULTILINE),
77
+ # "The workflow is..." / "The process is..."
78
+ re.compile(r"(?:^|\n)\s*(?:The workflow|The process|The procedure|Steps to) (?:is|are|for)[:\s]+(.*?)(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
79
+ ]
80
+
69
81
  # Broader keyword triggers (used for line-level scanning)
70
82
  DECISION_KEYWORDS = {"decided", "let's use", "going with", "chose", "choosing", "will use", "the plan is", "approach is", "strategy is", "i'll implement", "we'll implement"}
71
83
  ERROR_KEYWORDS = {"error", "bug", "fix", "issue", "traceback", "exception", "failed", "failure", "broken", "crash", "root cause"}
72
84
  PATTERN_KEYWORDS = {"pattern", "approach", "architecture", "convention", "best practice", "always", "never", "rule"}
85
+ WORKFLOW_KEYWORDS = {"workflow", "procedure", "steps to", "how to", "process for", "pipeline", "build steps", "deploy steps"}
73
86
 
74
87
 
75
88
  # ---------------------------------------------------------------------------
@@ -224,6 +237,13 @@ def extract_from_text(text: str, existing_hashes: set) -> List[Dict[str, Any]]:
224
237
  if len(context) > 30:
225
238
  add_extraction(context, "code", 6, ["pattern"])
226
239
 
240
+ # Workflows / Procedures
241
+ for pattern in WORKFLOW_PATTERNS:
242
+ for match in pattern.finditer(text):
243
+ context = extract_context_around(text, match.start(), match.end(), context_chars=300)
244
+ if len(context) > 40:
245
+ add_extraction(context, "code", 7, ["workflow", "procedure"])
246
+
227
247
  # --- Line-level keyword scanning (fallback for cases regex misses) ---
228
248
  # Only do this if we have not yet hit our cap
229
249
  if len(extractions) < MAX_MEMORIES_PER_RUN:
@@ -256,6 +276,12 @@ def extract_from_text(text: str, existing_hashes: set) -> List[Dict[str, Any]]:
256
276
  if len(block) > 30:
257
277
  add_extraction(block, "code", 5, ["pattern", "keyword-match"])
258
278
 
279
+ # Check for workflow keywords
280
+ elif any(kw in line_lower for kw in WORKFLOW_KEYWORDS):
281
+ block = '\n'.join(lines[i:i+5]).strip() # Wider context for workflows
282
+ if len(block) > 40:
283
+ add_extraction(block, "code", 6, ["workflow", "keyword-match"])
284
+
259
285
  i += 1
260
286
 
261
287
  return extractions
@@ -313,6 +339,84 @@ def store_memory_sync(extraction: Dict[str, Any], project_path: Optional[str] =
313
339
  return False
314
340
 
315
341
 
342
+ # ---------------------------------------------------------------------------
343
+ # Workflow / Bash command extraction from JSONL transcript
344
+ # ---------------------------------------------------------------------------
345
+
346
+ def extract_bash_commands(transcript_path: str, byte_offset: int = 0) -> List[str]:
347
+ """Extract successful bash commands from JSONL transcript.
348
+
349
+ Looks for tool_use blocks with tool=Bash that were followed by success results.
350
+ Returns deduplicated command list.
351
+ """
352
+ path = Path(transcript_path)
353
+ if not path.exists():
354
+ return []
355
+
356
+ commands = []
357
+ seen = set()
358
+ try:
359
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
360
+ if byte_offset > 0:
361
+ f.seek(byte_offset)
362
+ f.readline() # skip partial line
363
+ for line in f:
364
+ line = line.strip()
365
+ if not line:
366
+ continue
367
+ try:
368
+ msg = json.loads(line)
369
+ content = msg.get("content", [])
370
+ if not isinstance(content, list):
371
+ continue
372
+ for part in content:
373
+ if not isinstance(part, dict):
374
+ continue
375
+ if part.get("type") == "tool_use" and part.get("name") == "Bash":
376
+ cmd = ""
377
+ inp = part.get("input", {})
378
+ if isinstance(inp, dict):
379
+ cmd = inp.get("command", "")
380
+ if cmd and len(cmd) > 5 and cmd not in seen:
381
+ # Skip trivial commands
382
+ if not cmd.strip().startswith(("ls", "pwd", "echo", "cat ")):
383
+ seen.add(cmd)
384
+ commands.append(cmd)
385
+ except (json.JSONDecodeError, TypeError):
386
+ continue
387
+ except OSError:
388
+ pass
389
+ return commands[-20:] # Keep last 20 commands
390
+
391
+
392
+ def capture_workflow_sync(name: str, steps: List[str], commands: List[str],
393
+ project_path: Optional[str] = None) -> bool:
394
+ """POST a captured workflow to /api/workflow/capture."""
395
+ import urllib.request
396
+ import urllib.error
397
+
398
+ payload = json.dumps({
399
+ "name": name,
400
+ "steps": steps,
401
+ "commands": commands,
402
+ "project_path": project_path or "",
403
+ }).encode("utf-8")
404
+
405
+ headers = {"Content-Type": "application/json"}
406
+ if API_KEY:
407
+ headers["X-Memory-Key"] = API_KEY
408
+
409
+ try:
410
+ req = urllib.request.Request(
411
+ f"{MEMORY_AGENT_URL}/api/workflow/capture",
412
+ data=payload, headers=headers, method="POST",
413
+ )
414
+ with urllib.request.urlopen(req, timeout=2) as resp:
415
+ return resp.status == 200
416
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
417
+ return False
418
+
419
+
316
420
  # ---------------------------------------------------------------------------
317
421
  # Main entry point
318
422
  # ---------------------------------------------------------------------------