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.
- memstack_skill_loader/__init__.py +1 -0
- memstack_skill_loader/__main__.py +18 -0
- memstack_skill_loader/compression.py +345 -0
- memstack_skill_loader/config.py +114 -0
- memstack_skill_loader/dashboard.html +829 -0
- memstack_skill_loader/dashboard.py +360 -0
- memstack_skill_loader/indexer.py +240 -0
- memstack_skill_loader/license.py +409 -0
- memstack_skill_loader/search.py +164 -0
- memstack_skill_loader/server.py +883 -0
- memstack_skill_loader/stats.py +428 -0
- memstack_skill_loader/tfidf_search.py +142 -0
- memstack_skill_loader/version_check.py +93 -0
- memstack_skill_loader-3.5.0.dist-info/METADATA +10 -0
- memstack_skill_loader-3.5.0.dist-info/RECORD +18 -0
- memstack_skill_loader-3.5.0.dist-info/WHEEL +5 -0
- memstack_skill_loader-3.5.0.dist-info/entry_points.txt +2 -0
- memstack_skill_loader-3.5.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|