memstack-skill-loader 4.0.2__tar.gz → 4.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {memstack_skill_loader-4.0.2/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.0.4}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/agent_runner.py +155 -51
  5. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/dashboard.html +40 -3
  6. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/dashboard.py +2 -0
  7. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  8. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/MANIFEST.in +0 -0
  9. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/README.md +0 -0
  10. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/setup.cfg +0 -0
  11. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/__main__.py +0 -0
  12. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/categories.py +0 -0
  13. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/compression.py +0 -0
  14. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/config.py +0 -0
  15. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/indexer.py +0 -0
  16. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/license.py +0 -0
  17. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/memory_db.py +0 -0
  18. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/search.py +0 -0
  19. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/server.py +0 -0
  20. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/skill_config.py +0 -0
  21. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/stats.py +0 -0
  22. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/tfidf_search.py +0 -0
  23. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader/version_check.py +0 -0
  24. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  25. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  26. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  27. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  28. {memstack_skill_loader-4.0.2 → memstack_skill_loader-4.0.4}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.0.2"
7
+ version = "4.0.4"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.0.2"
3
+ __version__ = "4.0.4"
@@ -12,6 +12,8 @@ import uuid
12
12
  from pathlib import Path
13
13
  from typing import Optional
14
14
 
15
+ import httpx
16
+
15
17
  from .stats import log_agent_invocation
16
18
 
17
19
 
@@ -26,6 +28,8 @@ AGENT_TIMEOUT = 3600 # seconds per --print invocation (default 60 minutes)
26
28
  MAX_ITERATIONS = 2
27
29
 
28
30
  ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "")
31
+ API_DEFAULT_MODEL = "claude-sonnet-4-20250514"
32
+ API_MAX_TOKENS = 16000
29
33
 
30
34
  SYSTEM_PROMPTS = {
31
35
  "manager": (
@@ -188,6 +192,7 @@ def _extract_commit_from_reviewer(reviewer_output: str) -> str:
188
192
  def _build_env() -> dict:
189
193
  """Build environment for subprocess, ensuring Anthropic vars are passed."""
190
194
  env = os.environ.copy()
195
+ env.pop("MEMSTACK_ENABLE_TTS", None)
191
196
  if ANTHROPIC_BASE_URL:
192
197
  env["ANTHROPIC_BASE_URL"] = ANTHROPIC_BASE_URL
193
198
  return env
@@ -254,8 +259,105 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
254
259
  return text, input_tokens, output_tokens, cost_usd, context_tokens
255
260
 
256
261
 
262
+ # ---------------------------------------------------------------------------
263
+ # Direct API invocation (Manager / Reviewer — no file tools needed)
264
+ # ---------------------------------------------------------------------------
265
+
266
+ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
267
+ log_path: Optional[Path] = None, timeout: int = 600,
268
+ model: str = "", session_id: Optional[str] = None,
269
+ ) -> tuple[str, int, int]:
270
+ """Call the Anthropic Messages API directly via httpx.
271
+
272
+ Returns (text, input_tokens, output_tokens).
273
+ """
274
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
275
+ base_url = ANTHROPIC_BASE_URL or "https://api.anthropic.com"
276
+
277
+ if not api_key and not ANTHROPIC_BASE_URL:
278
+ raise RuntimeError(
279
+ f"{name}: ANTHROPIC_API_KEY not set and no ANTHROPIC_BASE_URL proxy configured"
280
+ )
281
+
282
+ url = f"{base_url.rstrip('/')}/v1/messages"
283
+ headers = {
284
+ "content-type": "application/json",
285
+ "anthropic-version": "2023-06-01",
286
+ }
287
+ if api_key:
288
+ headers["x-api-key"] = api_key
289
+
290
+ body = {
291
+ "model": model or API_DEFAULT_MODEL,
292
+ "max_tokens": API_MAX_TOKENS,
293
+ "system": system_prompt,
294
+ "messages": [{"role": "user", "content": prompt}],
295
+ }
296
+
297
+ if log_path:
298
+ log_path.parent.mkdir(parents=True, exist_ok=True)
299
+ ts = time.strftime("%H:%M:%S")
300
+ with open(log_path, "a", encoding="utf-8") as f:
301
+ f.write(f"[{ts}] === API call: {name} ===\n")
302
+ f.write(f"[{ts}] Model: {body['model']}\n")
303
+ f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
304
+
305
+ try:
306
+ resp = httpx.post(url, json=body, headers=headers, timeout=timeout)
307
+ resp.raise_for_status()
308
+ except httpx.TimeoutException:
309
+ if log_path:
310
+ ts = time.strftime("%H:%M:%S")
311
+ with open(log_path, "a", encoding="utf-8") as f:
312
+ f.write(f"[{ts}] API timeout after {timeout}s\n")
313
+ raise subprocess.TimeoutExpired(f"api:{name}", timeout)
314
+ except httpx.HTTPStatusError as exc:
315
+ if log_path:
316
+ ts = time.strftime("%H:%M:%S")
317
+ with open(log_path, "a", encoding="utf-8") as f:
318
+ f.write(f"[{ts}] API error {exc.response.status_code}: {exc.response.text[:500]}\n")
319
+ raise RuntimeError(f"{name} API error {exc.response.status_code}: {exc.response.text[:200]}") from exc
320
+ except httpx.HTTPError as exc:
321
+ if log_path:
322
+ ts = time.strftime("%H:%M:%S")
323
+ with open(log_path, "a", encoding="utf-8") as f:
324
+ f.write(f"[{ts}] HTTP error: {exc}\n")
325
+ raise RuntimeError(f"{name} HTTP error: {exc}") from exc
326
+
327
+ data = resp.json()
328
+ text_parts = []
329
+ for block in data.get("content", []):
330
+ if block.get("type") == "text":
331
+ text_parts.append(block["text"])
332
+ output = "\n".join(text_parts).strip()
333
+
334
+ usage = data.get("usage", {})
335
+ input_tokens = usage.get("input_tokens", 0)
336
+ output_tokens = usage.get("output_tokens", 0)
337
+
338
+ if log_path:
339
+ ts = time.strftime("%H:%M:%S")
340
+ with open(log_path, "a", encoding="utf-8") as f:
341
+ f.write(f"[{ts}] Response: {len(output)} chars, "
342
+ f"in={input_tokens} out={output_tokens}\n")
343
+
344
+ try:
345
+ log_agent_invocation(
346
+ name, len(prompt), len(output), session_id, "",
347
+ input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=0.0,
348
+ )
349
+ except Exception:
350
+ pass
351
+
352
+ return output, input_tokens, output_tokens
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # CC subprocess invocation (Builder — needs file tools)
357
+ # ---------------------------------------------------------------------------
358
+
257
359
  def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[Path] = None,
258
- skip_permissions: bool = False, bare: bool = False,
360
+ skip_permissions: bool = False,
259
361
  session_id: Optional[str] = None, timeout: int = AGENT_TIMEOUT,
260
362
  model: str = "") -> tuple[str, int, int, int]:
261
363
  """Run a single claude --print invocation and return the output."""
@@ -264,8 +366,6 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
264
366
  raise FileNotFoundError("'claude' CLI not found on PATH")
265
367
 
266
368
  cmd = [claude_bin, "--print", "--verbose", "--output-format", "stream-json"]
267
- if bare and os.environ.get("ANTHROPIC_API_KEY"):
268
- cmd.append("--bare")
269
369
  if skip_permissions:
270
370
  cmd.append("--dangerously-skip-permissions")
271
371
  if model:
@@ -280,10 +380,16 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
280
380
  f.write(f"[{ts}] === Invoking {name} ===\n")
281
381
  f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
282
382
 
383
+ prompt_dir = log_path.parent if log_path else Path.home() / ".memstack" / "agent-runner"
384
+ prompt_dir.mkdir(parents=True, exist_ok=True)
385
+ prompt_file = prompt_dir / f"{name}_prompt.txt"
386
+ prompt_file.write_text(prompt, encoding="utf-8")
387
+
388
+ stdin_fh = open(prompt_file, "r", encoding="utf-8") # noqa: SIM115
283
389
  global _current_process
284
390
  proc = subprocess.Popen(
285
391
  cmd,
286
- stdin=subprocess.PIPE,
392
+ stdin=stdin_fh,
287
393
  stdout=subprocess.PIPE,
288
394
  stderr=subprocess.PIPE,
289
395
  cwd=working_dir,
@@ -291,54 +397,50 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
291
397
  encoding="utf-8",
292
398
  errors="replace",
293
399
  env=_build_env(),
294
- creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
400
+ creationflags=0,
295
401
  )
402
+ stdin_fh.close()
296
403
  with _lock:
297
404
  _current_process = proc
298
405
 
299
- stdout_chunks: list[str] = []
300
- stderr_chunks: list[str] = []
406
+ if log_path:
407
+ with open(log_path, "a", encoding="utf-8") as f:
408
+ f.write(f"[{ts}] PID: {proc.pid}\n")
301
409
 
302
- def _reader(stream: object, dest: list[str]) -> None:
303
- try:
304
- for line in stream: # type: ignore[union-attr]
305
- dest.append(line)
306
- except Exception:
307
- pass
410
+ killed_by_watchdog = threading.Event()
411
+
412
+ def _watchdog_kill() -> None:
413
+ killed_by_watchdog.set()
414
+ proc.kill()
308
415
 
309
- t_out = threading.Thread(target=_reader, args=(proc.stdout, stdout_chunks), daemon=True)
310
- t_err = threading.Thread(target=_reader, args=(proc.stderr, stderr_chunks), daemon=True)
416
+ watchdog = threading.Timer(timeout, _watchdog_kill)
417
+ watchdog.daemon = True
418
+ watchdog.start()
311
419
 
312
420
  try:
313
- if proc.stdin:
314
- try:
315
- proc.stdin.write(prompt)
316
- proc.stdin.close()
317
- except OSError:
318
- pass
319
- t_out.start()
320
- t_err.start()
321
-
322
- deadline = time.monotonic() + timeout
323
- while True:
324
- t_out.join(timeout=30)
325
- rc = proc.poll()
326
- if rc is not None:
327
- break
328
- if time.monotonic() > deadline:
329
- proc.kill()
330
- t_out.join(5)
331
- t_err.join(5)
332
- raise subprocess.TimeoutExpired(cmd, timeout)
333
- t_out.join(5)
334
- t_err.join(5)
335
- stdout_data = "".join(stdout_chunks)
336
- stderr_data = "".join(stderr_chunks)
421
+ stdout_data, stderr_data = proc.communicate()
422
+ except Exception as e:
423
+ watchdog.cancel()
424
+ with _lock:
425
+ if _current_process is proc:
426
+ _current_process = None
427
+ if log_path:
428
+ with open(log_path, "a", encoding="utf-8") as f:
429
+ f.write(f"[{ts}] communicate() error: {e}\n")
430
+ raise RuntimeError(f"{name} communicate() failed: {e}") from e
337
431
  finally:
432
+ watchdog.cancel()
338
433
  with _lock:
339
434
  if _current_process is proc:
340
435
  _current_process = None
341
436
 
437
+ if killed_by_watchdog.is_set():
438
+ raise subprocess.TimeoutExpired(cmd, timeout)
439
+
440
+ if log_path:
441
+ with open(log_path, "a", encoding="utf-8") as f:
442
+ f.write(f"[{ts}] communicate() returned: stdout={len(stdout_data)} stderr={len(stderr_data)}\n")
443
+
342
444
  raw_stdout = stdout_data or ""
343
445
  stderr = stderr_data or ""
344
446
  output, input_tokens, output_tokens, cost_usd, context_tokens = _parse_stream_json(raw_stdout)
@@ -515,17 +617,18 @@ def _orchestrate(session: Session) -> None:
515
617
  if session.user_name:
516
618
  manager_prompt = f"The user's name is {session.user_name}.\n\n" + manager_prompt
517
619
  try:
518
- manager_output, m_in, m_out, m_ctx = _invoke_agent(
519
- "manager", manager_prompt, session.working_dir,
620
+ manager_output, m_in, m_out = _invoke_api_agent(
621
+ "manager", manager_prompt,
622
+ system_prompt=SYSTEM_PROMPTS["manager"],
520
623
  log_path=session_log_dir / "manager.log",
521
- skip_permissions=True, session_id=session.session_id,
522
- timeout=min(180, session.timeout),
624
+ timeout=min(600, session.timeout),
523
625
  model=session.models.get("manager", ""),
626
+ session_id=session.session_id,
524
627
  )
525
628
  except subprocess.TimeoutExpired:
526
629
  session.agents["manager"]["status"] = "timeout"
527
630
  session.status = "error"
528
- session.result = "Manager timed out after 3 minutes. Try a simpler task description or break the task into smaller pieces."
631
+ session.result = "Manager timed out after 10 minutes. Try a simpler task description or break the task into smaller pieces."
529
632
  session._save_state()
530
633
  return
531
634
  except RuntimeError:
@@ -536,7 +639,7 @@ def _orchestrate(session: Session) -> None:
536
639
  return
537
640
  session.agents["manager"]["input_tokens"] += m_in
538
641
  session.agents["manager"]["output_tokens"] += m_out
539
- session.agents["manager"]["context_tokens"] = m_ctx
642
+ session.agents["manager"]["context_tokens"] = m_in
540
643
  session.agents["manager"]["last_output"] = (manager_output or "")[:500]
541
644
 
542
645
  session.agents["manager"]["status"] = "done"
@@ -578,7 +681,7 @@ def _orchestrate(session: Session) -> None:
578
681
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
579
682
  "builder", builder_prompt, session.working_dir,
580
683
  log_path=session_log_dir / "builder.log",
581
- skip_permissions=True, bare=True, session_id=session.session_id,
684
+ skip_permissions=True, session_id=session.session_id,
582
685
  timeout=session.timeout,
583
686
  model=session.models.get("builder", ""),
584
687
  )
@@ -638,12 +741,13 @@ def _orchestrate(session: Session) -> None:
638
741
  + f"\n\nBuilder output (iteration {iteration}):\n{builder_output}"
639
742
  )
640
743
  try:
641
- reviewer_output, r_in, r_out, r_ctx = _invoke_agent(
642
- "reviewer", reviewer_prompt, session.working_dir,
744
+ reviewer_output, r_in, r_out = _invoke_api_agent(
745
+ "reviewer", reviewer_prompt,
746
+ system_prompt=SYSTEM_PROMPTS["reviewer"],
643
747
  log_path=session_log_dir / "reviewer.log",
644
- bare=True, session_id=session.session_id,
645
748
  timeout=session.timeout,
646
749
  model=session.models.get("reviewer", ""),
750
+ session_id=session.session_id,
647
751
  )
648
752
  except subprocess.TimeoutExpired:
649
753
  session.agents["reviewer"]["status"] = "timeout"
@@ -659,7 +763,7 @@ def _orchestrate(session: Session) -> None:
659
763
  return
660
764
  session.agents["reviewer"]["input_tokens"] += r_in
661
765
  session.agents["reviewer"]["output_tokens"] += r_out
662
- session.agents["reviewer"]["context_tokens"] = r_ctx
766
+ session.agents["reviewer"]["context_tokens"] = r_in
663
767
  session.agents["reviewer"]["last_output"] = (reviewer_output or "")[:500]
664
768
 
665
769
  session.agents["reviewer"]["status"] = "done"
@@ -802,7 +906,7 @@ def start_run(task: str, working_dir: Optional[str] = None, context: Optional[st
802
906
  session._save_state()
803
907
 
804
908
  _orchestration_thread = threading.Thread(
805
- target=_orchestrate, args=(session,), daemon=True, name="orchestrator"
909
+ target=_orchestrate, args=(session,), daemon=False, name="orchestrator"
806
910
  )
807
911
  _orchestration_thread.start()
808
912
 
@@ -1449,7 +1449,7 @@
1449
1449
  <summary class="model-selection-summary">Model Selection</summary>
1450
1450
  <div class="model-selection-grid">
1451
1451
  <div class="model-selection-col">
1452
- <label class="model-selection-label">Manager</label>
1452
+ <label id="agent-model-label-manager" class="model-selection-label">Manager</label>
1453
1453
  <select id="agent-model-manager" class="model-selection-select">
1454
1454
  <option value="claude-opus-4-6">claude-opus-4-6</option>
1455
1455
  <option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
@@ -1457,7 +1457,7 @@
1457
1457
  </select>
1458
1458
  </div>
1459
1459
  <div class="model-selection-col">
1460
- <label class="model-selection-label">Builder</label>
1460
+ <label id="agent-model-label-builder" class="model-selection-label">Builder</label>
1461
1461
  <select id="agent-model-builder" class="model-selection-select">
1462
1462
  <option value="claude-opus-4-6">claude-opus-4-6</option>
1463
1463
  <option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
@@ -1465,7 +1465,7 @@
1465
1465
  </select>
1466
1466
  </div>
1467
1467
  <div class="model-selection-col">
1468
- <label class="model-selection-label">Reviewer</label>
1468
+ <label id="agent-model-label-reviewer" class="model-selection-label">Reviewer</label>
1469
1469
  <select id="agent-model-reviewer" class="model-selection-select">
1470
1470
  <option value="claude-opus-4-6">claude-opus-4-6</option>
1471
1471
  <option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
@@ -1644,6 +1644,20 @@
1644
1644
  </div>
1645
1645
  </div>
1646
1646
 
1647
+ <div class="panel">
1648
+ <h3>Agent Runner Setup</h3>
1649
+ <p style="color:#8b949e;font-size:0.82rem;margin:0 0 0.8rem;">
1650
+ The Agent Runner requires an Anthropic API key for the Manager and Reviewer agents.
1651
+ The Builder uses Claude Code directly. Headroom is optional but recommended for token compression.
1652
+ </p>
1653
+ <div style="display:grid;grid-template-columns:auto 1fr;gap:0.5rem 1rem;font-size:0.82rem;align-items:start;">
1654
+ <span style="color:#8b949e;">API Key</span>
1655
+ <span id="settings-api-key-status" style="color:#c9d1d9;">—</span>
1656
+ <span style="color:#8b949e;">Headroom Proxy</span>
1657
+ <span id="settings-proxy-status" style="color:#c9d1d9;">—</span>
1658
+ </div>
1659
+ </div>
1660
+
1647
1661
  <div class="panel">
1648
1662
  <h3>Dashboard Info</h3>
1649
1663
  <div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1.2rem;font-size:0.82rem;">
@@ -2780,6 +2794,8 @@ async function resetAgentUI() {
2780
2794
  lastGitSuccess = true;
2781
2795
  const dsCard = document.getElementById('diary-summary-card');
2782
2796
  if (dsCard) { dsCard.style.display = 'none'; dsCard.innerHTML = ''; }
2797
+ document.getElementById('agent-workdir-input').value = '';
2798
+ loadLastWorkdir();
2783
2799
  }
2784
2800
 
2785
2801
  async function startAgentTask() {
@@ -2835,6 +2851,12 @@ async function loadAgentMonitor() {
2835
2851
  if (name) opt.textContent = name;
2836
2852
  }
2837
2853
  }
2854
+ for (const role of ['manager', 'builder', 'reviewer']) {
2855
+ const lbl = document.getElementById('agent-model-label-' + role);
2856
+ if (lbl && userProfile.agent_names && userProfile.agent_names[role]) {
2857
+ lbl.textContent = userProfile.agent_names[role];
2858
+ }
2859
+ }
2838
2860
  fetchAgentStatus();
2839
2861
  loadRecentProjects();
2840
2862
  loadLastWorkdir();
@@ -3473,6 +3495,21 @@ async function loadSettings() {
3473
3495
  document.getElementById('settings-pro-dir').textContent = d.pro_skills_dir || '—';
3474
3496
  document.getElementById('settings-stats-db').textContent = d.stats_db || '—';
3475
3497
  document.getElementById('settings-sessions-dir').textContent = d.sessions_dir || '—';
3498
+
3499
+ const apiKeyEl = document.getElementById('settings-api-key-status');
3500
+ if (d.api_key_set) {
3501
+ apiKeyEl.innerHTML = '<span style="color:#3fb950;">&#10003;</span> API key detected';
3502
+ } else {
3503
+ apiKeyEl.innerHTML = '<span style="color:#f85149;">&#10007;</span> Not set &mdash; run <code style="background:#161b22;padding:0.1rem 0.4rem;border-radius:3px;color:#c9d1d9;">set ANTHROPIC_API_KEY=sk-ant-...</code> before starting the dashboard';
3504
+ }
3505
+
3506
+ const proxyEl = document.getElementById('settings-proxy-status');
3507
+ if (d.base_url) {
3508
+ proxyEl.innerHTML = '<span style="color:#3fb950;">&#10003;</span> Headroom proxy active at <code style="background:#161b22;padding:0.1rem 0.4rem;border-radius:3px;color:#c9d1d9;">' + d.base_url.replace(/</g,'&lt;') + '</code>';
3509
+ } else {
3510
+ proxyEl.innerHTML = 'Optional: Install <a href="https://github.com/chopratejas/headroom" target="_blank" style="color:#58a6ff;">Headroom</a> for ~34% token savings';
3511
+ }
3512
+
3476
3513
  loadModelPrefs();
3477
3514
  await loadUserProfile();
3478
3515
  settingsLoaded = true;
@@ -511,6 +511,8 @@ class _Handler(BaseHTTPRequestHandler):
511
511
  "pro_skills_dir": str(home / ".memstack" / "pro-skills"),
512
512
  "stats_db": str(DB_PATH),
513
513
  "sessions_dir": str(home / ".memstack" / "agent-runner" / "sessions"),
514
+ "api_key_set": bool(os.environ.get("ANTHROPIC_API_KEY")),
515
+ "base_url": os.environ.get("ANTHROPIC_BASE_URL", ""),
514
516
  }
515
517
  body = json.dumps(data).encode()
516
518
  self._respond(200, "application/json", body)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0