memstack-skill-loader 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,883 @@
1
+ """MCP server entry point — exposes skill search tools via stdio transport."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from mcp.server import Server
13
+ from mcp.server.stdio import stdio_server
14
+ from mcp.types import TextContent, Tool
15
+
16
+ from .config import load_config
17
+ from .license import (
18
+ MAX_KEY_LEN,
19
+ clear_cache,
20
+ get_license_key,
21
+ is_pro_exclusive,
22
+ save_license_key,
23
+ validate_license,
24
+ )
25
+ from .compression import clear_cache as clear_compression_cache, get_or_compress
26
+ from .stats import get_compression_stats, get_dashboard_data, log_compression, log_skill_fire
27
+ from .tfidf_search import get_skill_by_name, list_all_skills, reset_cache, search_skills
28
+
29
+ app = Server("memstack-skill-loader")
30
+ _config = None
31
+ _ignored_skills: frozenset[str] = frozenset()
32
+ USAGE_FILE = Path.home() / ".memstack" / "skill-usage.json"
33
+
34
+ PRO_BUNDLE_URL = "https://admin.cwaffiliateinvestments.com/api/skills/pro-bundle"
35
+ PRO_SKILLS_HOME = Path.home() / ".memstack" / "pro-skills"
36
+ _MAX_BUNDLE_SIZE = 10 * 1024 * 1024 # 10 MB
37
+
38
+
39
+ async def _download_pro_skills(license_key: str, force: bool = False) -> str:
40
+ """Download and extract Pro skills bundle to ~/.memstack/pro-skills/."""
41
+ sentinel = PRO_SKILLS_HOME / ".complete"
42
+ if sentinel.exists() and not force:
43
+ return f"Pro skills already present at {PRO_SKILLS_HOME}"
44
+
45
+ import httpx
46
+
47
+ print("[memstack] Downloading Pro skills...", file=sys.stderr)
48
+ async with httpx.AsyncClient(timeout=60) as client:
49
+ resp = await client.get(
50
+ PRO_BUNDLE_URL,
51
+ headers={"Authorization": f"Bearer {license_key}"},
52
+ )
53
+ resp.raise_for_status()
54
+
55
+ content = resp.content
56
+ if len(content) > _MAX_BUNDLE_SIZE:
57
+ raise ValueError(f"Bundle too large ({len(content)} bytes, limit {_MAX_BUNDLE_SIZE})")
58
+
59
+ tmp_path = None
60
+ tmp_dir = None
61
+ try:
62
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
63
+ tmp.write(content)
64
+ tmp_path = tmp.name
65
+
66
+ with zipfile.ZipFile(tmp_path) as zf:
67
+ # Zip-slip protection
68
+ for member in zf.namelist():
69
+ member_path = Path(member)
70
+ if member_path.is_absolute() or ".." in member_path.parts:
71
+ raise ValueError(f"Unsafe zip member: {member}")
72
+
73
+ tmp_dir = tempfile.mkdtemp(
74
+ prefix="pro-skills-tmp-",
75
+ dir=PRO_SKILLS_HOME.parent,
76
+ )
77
+ zf.extractall(tmp_dir)
78
+
79
+ # Atomic swap
80
+ if PRO_SKILLS_HOME.exists():
81
+ import shutil
82
+ shutil.rmtree(PRO_SKILLS_HOME)
83
+ os.rename(tmp_dir, str(PRO_SKILLS_HOME))
84
+ tmp_dir = None # rename succeeded, don't clean up
85
+
86
+ # Count installed skills
87
+ skill_count = sum(1 for _ in PRO_SKILLS_HOME.rglob("SKILL.md"))
88
+
89
+ # Write sentinel
90
+ (PRO_SKILLS_HOME / ".complete").write_text("ok", encoding="utf-8")
91
+
92
+ msg = f"{skill_count} Pro skills installed to {PRO_SKILLS_HOME}"
93
+ print(f"[memstack] {msg}", file=sys.stderr)
94
+ return msg
95
+ finally:
96
+ if tmp_path and os.path.exists(tmp_path):
97
+ os.unlink(tmp_path)
98
+ if tmp_dir and os.path.exists(tmp_dir):
99
+ import shutil
100
+ shutil.rmtree(tmp_dir, ignore_errors=True)
101
+
102
+
103
+ def _load_memstack_ignore() -> frozenset[str]:
104
+ """Load .memstack-ignore from the current working directory."""
105
+ ignore_path = Path.cwd() / ".memstack-ignore"
106
+ if not ignore_path.exists():
107
+ return frozenset()
108
+ try:
109
+ lines = ignore_path.read_text(encoding="utf-8").splitlines()
110
+ ignored = frozenset(
111
+ line.strip().lower()
112
+ for line in lines
113
+ if line.strip() and not line.strip().startswith("#")
114
+ )
115
+ if ignored:
116
+ print(
117
+ f'MemStack™: [{len(ignored)}] skills filtered by .memstack-ignore',
118
+ file=sys.stderr,
119
+ )
120
+ return ignored
121
+ except OSError as exc:
122
+ print(f"[memstack] failed to read .memstack-ignore: {exc}", file=sys.stderr)
123
+ return frozenset()
124
+
125
+
126
+ def _is_ignored(skill_name: str) -> bool:
127
+ """Check if a skill name matches the ignore list."""
128
+ return skill_name.lower() in _ignored_skills
129
+
130
+
131
+ # Category derivation — mirrors dashboard._CATEGORY_MAP for stats consistency
132
+ _CATEGORY_MAP = {
133
+ # Internal
134
+ "__list__": "Core",
135
+ # Automation
136
+ "cron-scheduler": "Automation", "n8n-workflow-builder": "Automation",
137
+ "api-integration": "Automation", "webhook-designer": "Automation",
138
+ "content-pipeline": "Automation", "hosted-mcp-catalog": "Automation",
139
+ # Business
140
+ "quill": "Business", "scan": "Business", "governor": "Business",
141
+ "client-onboarding": "Business", "contract-template": "Business", "financial-model": "Business",
142
+ "freelancer-toolkit": "Business", "invoice-generator": "Business", "scope-of-work": "Business",
143
+ "sop-builder": "Business", "proposal-writer": "Business",
144
+ # Content
145
+ "humanize": "Content", "blog-post": "Content", "email-sequence": "Content",
146
+ "landing-page-copy": "Content", "newsletter": "Content", "product-description": "Content",
147
+ "tiktok-script": "Content", "twitter-thread": "Content", "youtube-script": "Content",
148
+ "kdp-format": "Content",
149
+ # Deployment
150
+ "railway-deploy": "Deployment", "docker-setup": "Deployment", "netlify-deploy": "Deployment",
151
+ "domain-ssl": "Deployment", "hetzner-setup": "Deployment", "ci-cd-pipeline": "Deployment",
152
+ "marketplace-submit": "Deployment",
153
+ # Development
154
+ "forge": "Development", "shard": "Development",
155
+ "state": "Development", "work": "Development", "verify": "Development",
156
+ "project": "Development", "familiar": "Development", "api-designer": "Development",
157
+ "code-reviewer": "Development", "database-architect": "Development", "migration-planner": "Development",
158
+ "performance-audit": "Development", "refactor-planner": "Development", "test-writer": "Development",
159
+ "changelog-generator": "Development", "mentor": "Development", "webapp-testing": "Development",
160
+ # Core
161
+ "compress": "Core", "diary": "Core", "echo": "Core", "grimoire": "Core",
162
+ "sight": "Core", "token-optimization": "Core",
163
+ # Marketing
164
+ "facebook-ad": "Marketing", "google-ad": "Marketing",
165
+ "launch-plan": "Marketing", "competitor-analysis": "Marketing", "pricing-strategy": "Marketing",
166
+ "lead-magnet": "Marketing", "webinar-script": "Marketing", "sales-funnel": "Marketing",
167
+ # SEO & GEO
168
+ "site-audit": "SEO & GEO", "keyword-research": "SEO & GEO",
169
+ "meta-tag-optimizer": "SEO & GEO", "schema-markup": "SEO & GEO",
170
+ "ai-search-visibility": "SEO & GEO", "local-seo": "SEO & GEO",
171
+ # Product
172
+ "prd-writer": "Product", "feature-spec": "Product",
173
+ "user-story-generator": "Product", "mvp-scoper": "Product", "roadmap-builder": "Product",
174
+ "feedback-analyzer": "Product",
175
+ # Security
176
+ "advanced-security": "Security", "env-manager-pro": "Security",
177
+ "api-audit": "Security", "csp-headers": "Security", "dependency-audit": "Security",
178
+ "owasp-top10": "Security", "owasp-top-10": "Security",
179
+ "rls-checker": "Security", "rls-guardian": "Security",
180
+ "secrets-scanner": "Security",
181
+ # Pro skills (for backfill matching)
182
+ "api-docs": "Core", "branching": "Core", "consolidate": "Core", "context-db": "Core",
183
+ "multi-agent": "Development", "codebase-index": "Development", "doc-index": "Development",
184
+ "diagram-generator": "Development", "browser-use": "Development", "session-restore": "Core",
185
+ "drift-detection": "Security", "mcp-builder": "Development", "claude-api-helper": "Development",
186
+ "test-generator": "Development", "log-analyzer": "Development",
187
+ "performance-profiler": "Development", "dependency-auditor": "Security",
188
+ "git-worktrees": "Development", "error-handler": "Development", "web-scraper": "Automation",
189
+ "hooks-integration": "Development",
190
+ }
191
+
192
+
193
+ def _derive_category(slug: str) -> str:
194
+ """Derive a display category from a skill slug or its parent directory."""
195
+ if slug in _CATEGORY_MAP:
196
+ return _CATEGORY_MAP[slug]
197
+ # Fallback: try to match the first path segment of the skill's source path
198
+ return "Other"
199
+
200
+
201
+ def _record_skill_usage(skill_name: str) -> None:
202
+ """Increment the usage counter for a skill."""
203
+ try:
204
+ USAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
205
+ data: dict[str, int] = {}
206
+ if USAGE_FILE.exists():
207
+ data = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
208
+ data[skill_name] = data.get(skill_name, 0) + 1
209
+ USAGE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
210
+ except (OSError, json.JSONDecodeError) as exc:
211
+ print(f"[memstack] failed to record skill usage: {exc}", file=sys.stderr)
212
+
213
+ _BLOCKED_MSG = (
214
+ "MemStack\u2122 grace period expired.\n\n"
215
+ "Run `activate_license` with your **email** to unlock 85 free skills.\n\n"
216
+ "Example: `activate_license(key=\"free\", email=\"you@example.com\")`\n\n"
217
+ "Already have a Pro key? Run `activate_license(key=\"your-key\", email=\"you@example.com\")`."
218
+ )
219
+ def _pro_skill_notice(skill_name: str) -> str:
220
+ return f"{skill_name} is a Pro skill. Details at memstack.pro"
221
+ _GRACE_EXPIRED_MSG = (
222
+ "\U0001f512 MemStack\u2122 grace period expired. "
223
+ "Run `activate_license` with your email to unlock 85 free skills."
224
+ )
225
+ _TAMPERED_MSG = (
226
+ "\U0001f512 MemStack\u2122 Pro: License file integrity check failed. "
227
+ "Get your key at https://memstack.pro"
228
+ )
229
+
230
+
231
+ def _grace_banner(status) -> str:
232
+ """Return a warning banner if the user is on a grace period, else empty."""
233
+ if status.grace_period and status.grace_days_remaining > 0:
234
+ return (
235
+ f"> \u26a0\ufe0f **MemStack\u2122 free trial:** "
236
+ f"**{status.grace_days_remaining} days** remaining. "
237
+ f"Run `activate_license` with your email to keep access permanently.\n\n"
238
+ )
239
+ return ""
240
+
241
+
242
+ def _get_config():
243
+ global _config
244
+ if _config is None:
245
+ _config = load_config().with_pro_skills()
246
+ return _config
247
+
248
+
249
+ def _check_index() -> bool:
250
+ """Return True if TF-IDF index exists on disk."""
251
+ config = _get_config()
252
+ pkl_path = config.resolved_vector_db_path / "tfidf_index.pkl"
253
+ return pkl_path.exists()
254
+
255
+
256
+ @app.list_tools()
257
+ async def list_tools() -> list[Tool]:
258
+ return [
259
+ Tool(
260
+ name="find_skill",
261
+ description=(
262
+ "Search MemStack\u2122 skills by describing what you need. Returns the most "
263
+ "relevant skill(s) with full instructions. Call this BEFORE starting any "
264
+ "task to check if a skill exists for it.\n"
265
+ "Workflow: 1) find_skill (preview) to identify the right skill, "
266
+ "2) get_skill to load full content for the one you need."
267
+ ),
268
+ inputSchema={
269
+ "type": "object",
270
+ "properties": {
271
+ "query": {
272
+ "type": "string",
273
+ "description": (
274
+ "Natural language description of what you need "
275
+ "(e.g., 'deploy to Railway', 'set up Supabase RLS')"
276
+ ),
277
+ },
278
+ "top_k": {
279
+ "type": "integer",
280
+ "description": "Number of results to return (1-10, default 3)",
281
+ "minimum": 1,
282
+ "maximum": 10,
283
+ "default": 3,
284
+ },
285
+ "full": {
286
+ "type": "boolean",
287
+ "description": (
288
+ "Return full skill content (default false). "
289
+ "When false, returns name + short description + score only. "
290
+ "Use get_skill to fetch full content for a specific skill."
291
+ ),
292
+ "default": False,
293
+ },
294
+ },
295
+ "required": ["query"],
296
+ },
297
+ ),
298
+ Tool(
299
+ name="list_skills",
300
+ description=(
301
+ "List all available MemStack\u2122 skills with names and descriptions. "
302
+ "Use this to browse the full skill catalog."
303
+ ),
304
+ inputSchema={
305
+ "type": "object",
306
+ "properties": {
307
+ "compact": {
308
+ "type": "boolean",
309
+ "description": (
310
+ "Return skill names only, grouped by category (default false). "
311
+ "When false, includes descriptions."
312
+ ),
313
+ "default": False,
314
+ },
315
+ },
316
+ },
317
+ ),
318
+ Tool(
319
+ name="get_skill",
320
+ description=(
321
+ "Get the full content of a specific skill by exact name. "
322
+ "Use after find_skill to load a specific skill."
323
+ ),
324
+ inputSchema={
325
+ "type": "object",
326
+ "properties": {
327
+ "name": {
328
+ "type": "string",
329
+ "description": "Skill name (case-insensitive, fuzzy match supported)",
330
+ },
331
+ "full": {
332
+ "type": "boolean",
333
+ "description": "Return uncompressed skill content. Default false (compressed).",
334
+ "default": False,
335
+ },
336
+ },
337
+ "required": ["name"],
338
+ },
339
+ ),
340
+ Tool(
341
+ name="reindex_skills",
342
+ description="Rebuild the skill vector index. Run after adding or modifying skills.",
343
+ inputSchema={
344
+ "type": "object",
345
+ "properties": {},
346
+ },
347
+ ),
348
+ Tool(
349
+ name="skill_stats",
350
+ description=(
351
+ "View MemStack\u2122 skill usage statistics. Shows most used skills, "
352
+ "least used skills, and total activations."
353
+ ),
354
+ inputSchema={
355
+ "type": "object",
356
+ "properties": {},
357
+ },
358
+ ),
359
+ Tool(
360
+ name="manage_skills",
361
+ description=(
362
+ "Enable, disable, or list disabled skills for this project. "
363
+ "Disabled skills are hidden from find_skill, list_skills, and get_skill."
364
+ ),
365
+ inputSchema={
366
+ "type": "object",
367
+ "properties": {
368
+ "action": {
369
+ "type": "string",
370
+ "description": "Action to perform: disable, enable, or list_disabled",
371
+ "enum": ["disable", "enable", "list_disabled"],
372
+ },
373
+ "skill": {
374
+ "type": "string",
375
+ "description": "Skill name to enable or disable (not required for list_disabled)",
376
+ },
377
+ },
378
+ "required": ["action"],
379
+ },
380
+ ),
381
+ Tool(
382
+ name="activate_license",
383
+ description=(
384
+ "Activate a MemStack license key to unlock skills. "
385
+ "Requires your email. Use key=\"free\" for a free license. "
386
+ "Get a Pro key at memstack.pro"
387
+ ),
388
+ inputSchema={
389
+ "type": "object",
390
+ "properties": {
391
+ "key": {
392
+ "type": "string",
393
+ "description": "Your MemStack license key (use \"free\" for free tier)",
394
+ },
395
+ "email": {
396
+ "type": "string",
397
+ "description": "Your email (required for activation)",
398
+ },
399
+ },
400
+ "required": ["key", "email"],
401
+ },
402
+ ),
403
+ Tool(
404
+ name="dashboard_stats",
405
+ description=(
406
+ "View MemStack\u2122 usage dashboard data \u2014 skill fires, trends, "
407
+ "category breakdown, and context savings estimates."
408
+ ),
409
+ inputSchema={
410
+ "type": "object",
411
+ "properties": {},
412
+ },
413
+ ),
414
+ ]
415
+
416
+
417
+ @app.call_tool()
418
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
419
+ try:
420
+ return await _handle_tool(name, arguments)
421
+ except Exception as e:
422
+ print(f"Error in tool {name}: {e}", file=sys.stderr)
423
+ return [TextContent(type="text", text=f"Error executing {name}: {e}")]
424
+
425
+
426
+ async def _handle_tool(name: str, arguments: dict) -> list[TextContent]:
427
+ config = _get_config()
428
+
429
+ if name == "find_skill":
430
+ t0 = time.perf_counter()
431
+ print(f"[find_skill] start query={arguments.get('query', '')!r}", file=sys.stderr)
432
+ if not _check_index():
433
+ return [TextContent(
434
+ type="text",
435
+ text="No TF-IDF index found. Call reindex_skills first to build the index.",
436
+ )]
437
+
438
+ license_status = await validate_license()
439
+
440
+ # No valid license and no grace period
441
+ if not license_status.valid:
442
+ if license_status.grace_tampered:
443
+ return [TextContent(type="text", text=_TAMPERED_MSG)]
444
+ if license_status.grace_expired:
445
+ return [TextContent(type="text", text=_GRACE_EXPIRED_MSG)]
446
+ return [TextContent(type="text", text=_BLOCKED_MSG)]
447
+
448
+ query = arguments["query"]
449
+ top_k = arguments.get("top_k", config.default_top_k)
450
+ results = await asyncio.to_thread(search_skills, query, config, top_k)
451
+ # Filter ignored skills
452
+ results = [r for r in results if not _is_ignored(r["name"])]
453
+ t1 = time.perf_counter()
454
+ print(f"[find_skill] search done in {t1 - t0:.3f}s", file=sys.stderr)
455
+
456
+ if not results:
457
+ return [TextContent(type="text", text="No matching skills found.")]
458
+
459
+ # Track usage
460
+ project_name = os.path.basename(os.getcwd())
461
+ for r in results:
462
+ _record_skill_usage(r["name"])
463
+ try:
464
+ log_skill_fire(r["name"], category=_derive_category(r.get("slug", "")), project=project_name, tool="find_skill")
465
+ except Exception:
466
+ pass
467
+
468
+ full_mode = arguments.get("full", False)
469
+ banner = _grace_banner(license_status)
470
+ parts = []
471
+ for i, r in enumerate(results, 1):
472
+ if full_mode:
473
+ if license_status.tier == "pro" or not is_pro_exclusive(r["slug"]):
474
+ tier = license_status.tier or "free"
475
+ content, t_before, t_after = get_or_compress(r, tier=tier)
476
+ try:
477
+ log_compression(r["name"], t_before, t_after, tier)
478
+ except Exception:
479
+ pass
480
+ parts.append(
481
+ f"## {i}. {r['name']} (score: {r['score']})\n"
482
+ f"**Source:** {r['source_label']}\n\n"
483
+ f"{content}"
484
+ )
485
+ else:
486
+ parts.append(
487
+ f"## {i}. {r['name']} (score: {r['score']})\n"
488
+ f"**Source:** {r['source_label']}\n\n"
489
+ + _pro_skill_notice(r["name"])
490
+ )
491
+ else:
492
+ # Preview mode: name + short description + score only
493
+ desc = r.get("description", "")
494
+ desc_short = desc[:120] + "..." if len(desc) > 120 else desc
495
+ lock = ""
496
+ if license_status.tier != "pro" and is_pro_exclusive(r["slug"]):
497
+ lock = " \U0001f512 Pro"
498
+ parts.append(
499
+ f"{i}. **{r['name']}** (score: {r['score']}){lock}\n"
500
+ f" {desc_short}"
501
+ )
502
+ t2 = time.perf_counter()
503
+ print(f"[find_skill] response ready in {t2 - t0:.3f}s", file=sys.stderr)
504
+ separator = "\n\n---\n\n" if full_mode else "\n"
505
+ hint = "" if full_mode else "\n\n_Use `get_skill` with the skill name to load full content._"
506
+ return [TextContent(type="text", text=banner + separator.join(parts) + hint)]
507
+
508
+ elif name == "list_skills":
509
+ license_status = await validate_license()
510
+
511
+ if not license_status.valid:
512
+ if license_status.grace_tampered:
513
+ return [TextContent(type="text", text=_TAMPERED_MSG)]
514
+ if license_status.grace_expired:
515
+ return [TextContent(type="text", text=_GRACE_EXPIRED_MSG)]
516
+ return [TextContent(type="text", text=_BLOCKED_MSG)]
517
+
518
+ skills = await asyncio.to_thread(list_all_skills, config)
519
+ # Filter ignored skills
520
+ skills = [s for s in skills if not _is_ignored(s["name"])]
521
+ if not skills:
522
+ return [TextContent(
523
+ type="text",
524
+ text="No skills indexed yet. Call reindex_skills to build the index.",
525
+ )]
526
+
527
+ try:
528
+ log_skill_fire("__list__", tool="list_skills", project=os.path.basename(os.getcwd()))
529
+ except Exception:
530
+ pass
531
+
532
+ compact = arguments.get("compact", False)
533
+ banner = _grace_banner(license_status)
534
+
535
+ if compact:
536
+ # Group by source_label, names only
537
+ groups: dict[str, list[str]] = {}
538
+ for s in skills:
539
+ label = s["source_label"]
540
+ lock = ""
541
+ if license_status.tier != "pro" and is_pro_exclusive(s["slug"]):
542
+ lock = " \U0001f512"
543
+ groups.setdefault(label, []).append(f"{s['name']}{lock}")
544
+ lines = [f"# MemStack\u2122 Skills ({len(skills)})\n"]
545
+ for label, names in groups.items():
546
+ lines.append(f"**{label}** ({len(names)}):")
547
+ lines.append(", ".join(names))
548
+ lines.append("")
549
+ lines.append("_Use `get_skill` with a name to load full content._")
550
+ return [TextContent(type="text", text=banner + "\n".join(lines))]
551
+
552
+ lines = [f"# MemStack\u2122 Skill Catalog ({len(skills)} skills)\n"]
553
+ for i, s in enumerate(skills, 1):
554
+ desc = s["description"][:120] + "..." if len(s["description"]) > 120 else s["description"]
555
+ if license_status.tier != "pro" and is_pro_exclusive(s["slug"]):
556
+ lock = " \U0001f512 Pro"
557
+ else:
558
+ lock = ""
559
+ lines.append(f"{i}. **{s['name']}** [{s['source_label']}]{lock}\n {desc}")
560
+ return [TextContent(type="text", text=banner + "\n".join(lines))]
561
+
562
+ elif name == "get_skill":
563
+ if not _check_index():
564
+ return [TextContent(
565
+ type="text",
566
+ text="No TF-IDF index found. Call reindex_skills first to build the index.",
567
+ )]
568
+
569
+ license_status = await validate_license()
570
+
571
+ # Check license before reading from disk
572
+ if not license_status.valid:
573
+ if license_status.grace_tampered:
574
+ return [TextContent(type="text", text=_TAMPERED_MSG)]
575
+ if license_status.grace_expired:
576
+ return [TextContent(type="text", text=_GRACE_EXPIRED_MSG)]
577
+ return [TextContent(type="text", text=_BLOCKED_MSG)]
578
+
579
+ skill = await asyncio.to_thread(get_skill_by_name, arguments["name"], config)
580
+ if skill is None or _is_ignored(skill["name"]):
581
+ return [TextContent(
582
+ type="text",
583
+ text=f"Skill '{arguments['name']}' not found. Use list_skills to see available skills.",
584
+ )]
585
+
586
+ # Track usage
587
+ _record_skill_usage(skill["name"])
588
+ try:
589
+ log_skill_fire(skill["name"], category=_derive_category(skill.get("slug", "")), project=os.path.basename(os.getcwd()), tool="get_skill")
590
+ except Exception:
591
+ pass
592
+
593
+ banner = _grace_banner(license_status)
594
+ full_mode = arguments.get("full", False)
595
+ if license_status.tier == "pro" or not is_pro_exclusive(skill["slug"]):
596
+ if full_mode:
597
+ content = skill["content"]
598
+ else:
599
+ tier = license_status.tier or "free"
600
+ content, tokens_before, tokens_after = get_or_compress(skill, tier=tier)
601
+ try:
602
+ log_compression(skill["name"], tokens_before, tokens_after, tier)
603
+ except Exception:
604
+ pass
605
+ return [TextContent(
606
+ type="text",
607
+ text=banner + f"# {skill['name']}\n**Source:** {skill['source_label']}\n\n{content}",
608
+ )]
609
+ # Free tier + Pro-exclusive skill
610
+ return [TextContent(
611
+ type="text",
612
+ text=(
613
+ f"# {skill['name']}\n**Source:** {skill['source_label']}\n\n"
614
+ + _pro_skill_notice(skill["name"])
615
+ ),
616
+ )]
617
+
618
+ elif name == "activate_license":
619
+ key = arguments.get("key", "").strip()
620
+ email = arguments.get("email", "").strip() or None
621
+ if not email:
622
+ return [TextContent(
623
+ type="text",
624
+ text=(
625
+ "\u274c Email is required to activate MemStack\u2122.\n\n"
626
+ "Run: `activate_license(key=\"your-key\", email=\"you@example.com\")`\n\n"
627
+ "Don't have a key? Use `key=\"free\"` to get a free license."
628
+ ),
629
+ )]
630
+ if not key or len(key) > MAX_KEY_LEN:
631
+ return [TextContent(
632
+ type="text",
633
+ text="\u274c No valid license key provided. Get one at https://memstack.pro",
634
+ )]
635
+
636
+ # Clear any stale cache for this key so the fresh API call runs.
637
+ clear_cache(key)
638
+
639
+ status = await validate_license(license_key=key, email=email)
640
+ if status.valid:
641
+ save_license_key(key)
642
+ if status.tier == "pro":
643
+ try:
644
+ dl_msg = await _download_pro_skills(key)
645
+ print(f"[memstack] {dl_msg}", file=sys.stderr)
646
+ except Exception as exc:
647
+ print(
648
+ f"[memstack] Pro skills download failed: {exc}. "
649
+ "Please try again or contact support@memstack.pro",
650
+ file=sys.stderr,
651
+ )
652
+ from .indexer import build_index
653
+ await asyncio.to_thread(build_index, config)
654
+ reset_cache()
655
+ clear_compression_cache()
656
+ skills = await asyncio.to_thread(list_all_skills, config)
657
+ total = len(skills) if skills else 0
658
+ msg = (
659
+ f"\u2705 License activated! Tier: **Pro**. "
660
+ f"All {total} skills unlocked."
661
+ )
662
+ else:
663
+ skills = await asyncio.to_thread(list_all_skills, config)
664
+ total = len(skills) if skills else 0
665
+ free_count = sum(
666
+ 1 for s in (skills or []) if not is_pro_exclusive(s.get("slug", s["name"]))
667
+ )
668
+ msg = (
669
+ f"\u2705 License activated! Tier: **Free**. "
670
+ f"{free_count} skills unlocked. "
671
+ f"Upgrade to Pro at https://memstack.pro to unlock all {total}."
672
+ )
673
+ return [TextContent(type="text", text=msg)]
674
+ return [TextContent(
675
+ type="text",
676
+ text="\u274c Invalid license key. Get one at https://memstack.pro",
677
+ )]
678
+
679
+ elif name == "skill_stats":
680
+ try:
681
+ if USAGE_FILE.exists():
682
+ data: dict[str, int] = json.loads(
683
+ USAGE_FILE.read_text(encoding="utf-8")
684
+ )
685
+ else:
686
+ data = {}
687
+ except (OSError, json.JSONDecodeError):
688
+ data = {}
689
+
690
+ if not data:
691
+ return [TextContent(
692
+ type="text",
693
+ text="No skill usage data yet. Use find_skill or get_skill to start tracking.",
694
+ )]
695
+
696
+ total = sum(data.values())
697
+ sorted_skills = sorted(data.items(), key=lambda x: x[1], reverse=True)
698
+ most_used = sorted_skills[:10]
699
+ least_used = sorted_skills[-5:] if len(sorted_skills) > 5 else sorted_skills
700
+
701
+ lines = [f"# MemStack\u2122 Skill Usage Stats\n"]
702
+ lines.append(f"**Total activations:** {total}")
703
+ lines.append(f"**Unique skills used:** {len(data)}\n")
704
+ lines.append("## Most Used")
705
+ for name_, count in most_used:
706
+ lines.append(f"- **{name_}**: {count} activations")
707
+ lines.append("\n## Least Used")
708
+ for name_, count in reversed(least_used):
709
+ lines.append(f"- **{name_}**: {count} activations")
710
+
711
+ return [TextContent(type="text", text="\n".join(lines))]
712
+
713
+ elif name == "dashboard_stats":
714
+ data = get_dashboard_data()
715
+ try:
716
+ data["compression"] = get_compression_stats()
717
+ except Exception:
718
+ pass
719
+ return [TextContent(type="text", text=json.dumps(data, indent=2))]
720
+
721
+ elif name == "manage_skills":
722
+ global _ignored_skills
723
+ action = arguments.get("action", "")
724
+ skill_name = arguments.get("skill", "").strip()
725
+ ignore_path = Path.cwd() / ".memstack-ignore"
726
+
727
+ if action == "list_disabled":
728
+ if not _ignored_skills:
729
+ return [TextContent(
730
+ type="text",
731
+ text="No skills are currently disabled for this project.",
732
+ )]
733
+ lines_ = ["# Disabled Skills\n"]
734
+ for s in sorted(_ignored_skills):
735
+ lines_.append(f"- {s}")
736
+ lines_.append(f"\n**{len(_ignored_skills)}** skills filtered by .memstack-ignore")
737
+ return [TextContent(type="text", text="\n".join(lines_))]
738
+
739
+ if not skill_name:
740
+ return [TextContent(
741
+ type="text",
742
+ text="Skill name is required for enable/disable actions.",
743
+ )]
744
+
745
+ if action == "disable":
746
+ # Read existing file content (preserve comments)
747
+ existing_lines: list[str] = []
748
+ if ignore_path.exists():
749
+ existing_lines = ignore_path.read_text(encoding="utf-8").splitlines()
750
+
751
+ # Check if already disabled
752
+ active_entries = {
753
+ line.strip().lower()
754
+ for line in existing_lines
755
+ if line.strip() and not line.strip().startswith("#")
756
+ }
757
+ if skill_name.lower() in active_entries:
758
+ return [TextContent(
759
+ type="text",
760
+ text=f"'{skill_name}' is already disabled.",
761
+ )]
762
+
763
+ # Append the skill
764
+ existing_lines.append(skill_name.lower())
765
+ ignore_path.write_text(
766
+ "\n".join(existing_lines) + "\n", encoding="utf-8"
767
+ )
768
+
769
+ # Refresh in-memory ignore list
770
+ _ignored_skills = _load_memstack_ignore()
771
+ return [TextContent(
772
+ type="text",
773
+ text=f"Disabled {skill_name}. {len(_ignored_skills)} skills now filtered.",
774
+ )]
775
+
776
+ if action == "enable":
777
+ if not ignore_path.exists():
778
+ return [TextContent(
779
+ type="text",
780
+ text=f"'{skill_name}' is not disabled (no .memstack-ignore file).",
781
+ )]
782
+
783
+ existing_lines = ignore_path.read_text(encoding="utf-8").splitlines()
784
+ # Remove matching entries, preserve comments and other entries
785
+ new_lines = [
786
+ line for line in existing_lines
787
+ if line.strip().startswith("#") or line.strip().lower() != skill_name.lower()
788
+ ]
789
+
790
+ # Check if anything was actually removed
791
+ if len(new_lines) == len(existing_lines):
792
+ return [TextContent(
793
+ type="text",
794
+ text=f"'{skill_name}' is not currently disabled.",
795
+ )]
796
+
797
+ # Check if only comments/blanks remain
798
+ active_entries = [
799
+ line for line in new_lines
800
+ if line.strip() and not line.strip().startswith("#")
801
+ ]
802
+ if not active_entries:
803
+ # No active entries left — delete the file
804
+ ignore_path.unlink()
805
+ _ignored_skills = frozenset()
806
+ return [TextContent(
807
+ type="text",
808
+ text=f"Enabled {skill_name}. No skills filtered (removed .memstack-ignore).",
809
+ )]
810
+
811
+ ignore_path.write_text(
812
+ "\n".join(new_lines) + "\n", encoding="utf-8"
813
+ )
814
+
815
+ # Refresh in-memory ignore list
816
+ _ignored_skills = _load_memstack_ignore()
817
+ return [TextContent(
818
+ type="text",
819
+ text=f"Enabled {skill_name}. {len(_ignored_skills)} skills now filtered.",
820
+ )]
821
+
822
+ return [TextContent(
823
+ type="text",
824
+ text=f"Unknown action: {action}. Use disable, enable, or list_disabled.",
825
+ )]
826
+
827
+ elif name == "reindex_skills":
828
+ from .indexer import build_index
829
+ count = await asyncio.to_thread(build_index, config)
830
+ reset_cache()
831
+ clear_compression_cache()
832
+ if count == 0:
833
+ return [TextContent(
834
+ type="text",
835
+ text="Warning: No skills found to index. Check config.json skill_sources paths.",
836
+ )]
837
+ return [TextContent(
838
+ type="text",
839
+ text=f"Reindexed {count} skills successfully.",
840
+ )]
841
+
842
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
843
+
844
+
845
+ async def run():
846
+ global _ignored_skills
847
+ config = _get_config()
848
+
849
+ # Load .memstack-ignore from project directory
850
+ _ignored_skills = _load_memstack_ignore()
851
+
852
+ # Print grace period status on startup if no license key is set
853
+ if not get_license_key():
854
+ from .license import _get_grace_period_status
855
+ active, remaining, _tampered = _get_grace_period_status()
856
+ if active:
857
+ print(
858
+ f"[memstack] No license key. Grace period: {remaining} days remaining. "
859
+ f"Get your free key at memstack.pro",
860
+ file=sys.stderr,
861
+ )
862
+ else:
863
+ print(
864
+ "[memstack] No license key. Grace period expired. "
865
+ "Get your free key at memstack.pro",
866
+ file=sys.stderr,
867
+ )
868
+
869
+ # Check for updates (cached, non-blocking)
870
+ from .version_check import check_for_updates
871
+ check_for_updates()
872
+
873
+ # Backfill NULL categories in stats.db from category map
874
+ from .stats import backfill_categories
875
+ backfill_categories(_CATEGORY_MAP)
876
+
877
+ # Pre-load TF-IDF index before stdio_server starts its stdin reader thread.
878
+ # On Windows, a blocking readline() in the stdin thread causes GIL contention
879
+ # that slows down CPU-intensive operations (like sklearn import) by ~20x.
880
+ from .tfidf_search import _get_index
881
+ _get_index(config)
882
+ async with stdio_server() as (read_stream, write_stream):
883
+ await app.run(read_stream, write_stream, app.create_initialization_options())