@tasksai/install 0.1.20 → 0.1.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "src/",
11
11
  "runtime/server.py",
12
+ "runtime/skill_matcher.py",
12
13
  "runtime/requirements.txt",
13
14
  "README.md"
14
15
  ],
package/runtime/server.py CHANGED
@@ -29,6 +29,15 @@ import uuid
29
29
  from datetime import datetime
30
30
  from pathlib import Path
31
31
 
32
+ # The runtime is installed as a pair of adjacent Python files and is also
33
+ # loaded directly by path in focused tests, so make the sibling matcher
34
+ # importable without requiring a package install.
35
+ _RUNTIME_DIR = Path(__file__).resolve().parent
36
+ if str(_RUNTIME_DIR) not in sys.path:
37
+ sys.path.insert(0, str(_RUNTIME_DIR))
38
+
39
+ from skill_matcher import POLICY_VERSION, match_skills
40
+
32
41
  # Force UTF-8 stdout/stderr on Windows (default is CP1252 which breaks emoji)
33
42
  if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
34
43
  sys.stdout.reconfigure(encoding='utf-8', errors='replace')
@@ -484,6 +493,9 @@ _skills_cache_err_until = 0.0
484
493
  _triggers_cache = None
485
494
  _triggers_cache_ts = 0.0
486
495
  _triggers_cache_err_until = 0.0
496
+ _matcher_config_cache = None
497
+ _matcher_config_cache_ts = 0.0
498
+ _matcher_config_err_until = 0.0
487
499
 
488
500
 
489
501
  async def api_get(path):
@@ -541,6 +553,25 @@ async def report_activation_event(event_name, *, skill_id=None, file_format=None
541
553
  pass
542
554
 
543
555
 
556
+ def fallback_vertical(product_id):
557
+ """Build local vertical metadata without an API request."""
558
+ product_id = str(product_id or "").strip().lower() or "tasksai"
559
+ known = _VERTICAL_FALLBACKS.get(product_id)
560
+ if known:
561
+ return dict(known)
562
+ safe_id = re.sub(r"[^a-z0-9]+", "", product_id) or "tasksai"
563
+ display_name = f"{safe_id.title()}TasksAI"
564
+ return {
565
+ "product_id": product_id,
566
+ "product_name": display_name,
567
+ "display_name": display_name,
568
+ "tool_prefix": f"{safe_id}tasksai",
569
+ "occupation": "professional",
570
+ "support_email": "hello@tasksai.com",
571
+ "domain": "tasksai.com",
572
+ }
573
+
574
+
544
575
  async def load_vertical():
545
576
  """Fetch vertical metadata from /v1/me without crossing product boundaries."""
546
577
  global _vertical
@@ -548,22 +579,7 @@ async def load_vertical():
548
579
  path = f"/v1/me?product_id={PRODUCT_ID}" if PRODUCT_ID else "/v1/me"
549
580
  _vertical = await api_get(path)
550
581
  except Exception:
551
- product_id = PRODUCT_ID.strip().lower() or "tasksai"
552
- known = _VERTICAL_FALLBACKS.get(product_id)
553
- if known:
554
- _vertical = dict(known)
555
- else:
556
- safe_id = re.sub(r"[^a-z0-9]+", "", product_id) or "tasksai"
557
- display_name = f"{safe_id.title()}TasksAI"
558
- _vertical = {
559
- "product_id": product_id,
560
- "product_name": display_name,
561
- "display_name": display_name,
562
- "tool_prefix": f"{safe_id}tasksai",
563
- "occupation": "professional",
564
- "support_email": "hello@tasksai.com",
565
- "domain": "tasksai.com",
566
- }
582
+ _vertical = fallback_vertical(PRODUCT_ID)
567
583
  return _vertical
568
584
 
569
585
 
@@ -603,9 +619,21 @@ async def get_skills():
603
619
  return _skills_cache
604
620
 
605
621
 
622
+ def product_ids_match(left, right):
623
+ """Compare canonical and legacy TasksAI product IDs."""
624
+ aliases = {
625
+ "farmertasksai": "farmer",
626
+ "lawtasksai": "law",
627
+ "realtortasksai": "realtor",
628
+ }
629
+ left_id = aliases.get(str(left or "").strip().lower(), str(left or "").strip().lower())
630
+ right_id = aliases.get(str(right or "").strip().lower(), str(right or "").strip().lower())
631
+ return left_id == right_id
632
+
633
+
606
634
  async def get_triggers():
607
635
  """Return trigger phrases {skill_id: [phrase, ...]}. Fails silently."""
608
- global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until
636
+ global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until, _matcher_config_cache
609
637
  now = time.monotonic()
610
638
  if _triggers_cache is not None and (now - _triggers_cache_ts) < CACHE_TTL:
611
639
  return _triggers_cache
@@ -613,10 +641,37 @@ async def get_triggers():
613
641
  return _triggers_cache if _triggers_cache is not None else {}
614
642
  try:
615
643
  raw = await api_get("/v1/skills/triggers")
616
- _triggers_cache = {
617
- sid: [p.lower() for p in v.get("triggers", [])]
618
- for sid, v in raw.items()
619
- }
644
+ if isinstance(raw, dict) and raw.get("policy_version"):
645
+ _matcher_config_cache = {
646
+ key: raw[key]
647
+ for key in (
648
+ "policy_version", "product_id", "default_result_count",
649
+ "maximum_result_count", "thresholds", "stop_words",
650
+ "context_words", "abbreviations",
651
+ )
652
+ if key in raw
653
+ }
654
+ if isinstance(raw, dict) and isinstance(raw.get("skills"), dict):
655
+ trigger_records = raw["skills"]
656
+ elif (
657
+ isinstance(raw, dict)
658
+ and raw.get("policy_version")
659
+ and isinstance(raw.get("triggers"), dict)
660
+ ):
661
+ trigger_records = raw["triggers"]
662
+ else:
663
+ trigger_records = raw if isinstance(raw, dict) else {}
664
+ active_product = (_vertical or {}).get("product_id", PRODUCT_ID)
665
+ _triggers_cache = {}
666
+ for sid, value in trigger_records.items():
667
+ if isinstance(value, dict):
668
+ if value.get("product_id") and not product_ids_match(value["product_id"], active_product):
669
+ continue
670
+ value = value.get("triggers", [])
671
+ if isinstance(value, str):
672
+ value = [value]
673
+ if isinstance(value, (list, tuple)):
674
+ _triggers_cache[str(sid)] = [str(phrase).lower() for phrase in value if str(phrase).strip()]
620
675
  _triggers_cache_ts = now
621
676
  _triggers_cache_err_until = 0.0
622
677
  except Exception:
@@ -626,13 +681,54 @@ async def get_triggers():
626
681
  return _triggers_cache
627
682
 
628
683
 
684
+ async def get_matching_config():
685
+ """Return cached safe matching policy metadata for the active product."""
686
+ global _matcher_config_cache, _matcher_config_cache_ts, _matcher_config_err_until
687
+ now = time.monotonic()
688
+ if _matcher_config_cache is not None and (now - _matcher_config_cache_ts) < CACHE_TTL:
689
+ return _matcher_config_cache
690
+ if now < _matcher_config_err_until:
691
+ return _matcher_config_cache
692
+ try:
693
+ raw = await api_get("/v1/skills/matching-config")
694
+ active_product = (_vertical or {}).get("product_id", PRODUCT_ID)
695
+ if (
696
+ isinstance(raw, dict)
697
+ and raw.get("policy_version") == POLICY_VERSION
698
+ and product_ids_match(raw.get("product_id"), active_product)
699
+ ):
700
+ _matcher_config_cache = {
701
+ key: raw[key]
702
+ for key in (
703
+ "policy_version", "product_id", "default_result_count",
704
+ "maximum_result_count", "thresholds", "stop_words",
705
+ "context_words", "abbreviations",
706
+ )
707
+ if key in raw
708
+ }
709
+ _matcher_config_cache_ts = now
710
+ _matcher_config_err_until = 0.0
711
+ except Exception:
712
+ _matcher_config_err_until = now + ERROR_COOLDOWN
713
+ return _matcher_config_cache
714
+
715
+
716
+ async def load_discovery_snapshot():
717
+ """Refresh public discovery metadata before accepting user prompts."""
718
+ await asyncio.gather(get_skills(), get_triggers(), get_matching_config())
719
+
720
+
721
+ def active_abbreviations(product_id):
722
+ """Return the already-loaded product abbreviation map without network I/O."""
723
+ if _abbrevs_db is not None:
724
+ return _abbrevs_db
725
+ return _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
726
+
727
+
629
728
  def expand_query(query, product_id):
630
729
  """Expand vertical-specific abbreviations before matching."""
631
730
  # Prefer DB-loaded abbreviations; fall back to hardcoded map
632
- if _abbrevs_db is not None:
633
- abbrevs = _abbrevs_db
634
- else:
635
- abbrevs = _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
731
+ abbrevs = active_abbreviations(product_id)
636
732
  if not abbrevs:
637
733
  return query
638
734
  words = query.lower().split()
@@ -640,29 +736,6 @@ def expand_query(query, product_id):
640
736
  return (query + " " + " ".join(expansions)).strip() if expansions else query
641
737
 
642
738
 
643
- def _word_in_text(word, text):
644
- """True if `word` appears as a whole word in `text`."""
645
- return bool(re.search(r'(?<!\w)' + re.escape(word) + r'(?!\w)', text))
646
-
647
-
648
- def score_skill(skill, query_lower, query_words, triggers):
649
- """Three-tier scoring: trigger match (10) > name match (3) > description match (1)."""
650
- skill_id = skill.get("id", "")
651
- name_text = skill.get("name", "").lower()
652
- desc_text = skill.get("description", "").lower()
653
- full_text = name_text + " " + desc_text
654
- # Tier 1 — trigger phrase (whole-word, bidirectional)
655
- for phrase in triggers.get(skill_id, []):
656
- if _word_in_text(phrase, query_lower) or _word_in_text(query_lower, phrase):
657
- return 10
658
- # Tier 2 — keyword
659
- return sum(
660
- 3 if _word_in_text(w, name_text) else 1
661
- for w in query_words
662
- if _word_in_text(w, full_text)
663
- )
664
-
665
-
666
739
  def safe_filename_component(value, fallback="tasksai-output", max_length=90):
667
740
  """Return a conservative filename stem with no path separators."""
668
741
  text = str(value or "").strip()
@@ -1091,6 +1164,13 @@ def build_tools(prefix, product_name, occupation):
1091
1164
  "query": {
1092
1165
  "type": "string",
1093
1166
  "description": f"{domain_adjective.title()} topic or task ({examples})"
1167
+ },
1168
+ "max_results": {
1169
+ "type": "integer",
1170
+ "minimum": 1,
1171
+ "maximum": 10,
1172
+ "default": 3,
1173
+ "description": "Maximum recommendations to show; defaults to 3. Increase to show more."
1094
1174
  }
1095
1175
  },
1096
1176
  "required": ["query"]
@@ -1241,6 +1321,7 @@ async def list_tools():
1241
1321
  if _vertical is None:
1242
1322
  await load_vertical()
1243
1323
  await load_abbreviations()
1324
+ await load_discovery_snapshot()
1244
1325
  _rebuild_tools()
1245
1326
  return _tools
1246
1327
 
@@ -1261,10 +1342,16 @@ def _rebuild_tools():
1261
1342
 
1262
1343
  @server.call_tool()
1263
1344
  async def call_tool(name, arguments):
1264
- # Ensure vertical loaded on first tool call
1345
+ global _vertical
1346
+ # Standard startup loads all public discovery metadata before accepting
1347
+ # requests. Preserve privacy if a host bypasses startup and calls search or
1348
+ # categories directly: use only bundled identity and empty/local caches.
1265
1349
  if _vertical is None:
1266
- await load_vertical()
1267
- await load_abbreviations()
1350
+ if name.endswith("_search") or name.endswith("_categories"):
1351
+ _vertical = fallback_vertical(PRODUCT_ID)
1352
+ else:
1353
+ await load_vertical()
1354
+ await load_abbreviations()
1268
1355
  _rebuild_tools()
1269
1356
 
1270
1357
  v = _vertical or {}
@@ -1276,35 +1363,49 @@ async def call_tool(name, arguments):
1276
1363
  try:
1277
1364
  # ── Search ────────────────────────────────────────────────────────────
1278
1365
  if name == f"{prefix}_search":
1279
- skills, triggers = await get_skills(), await get_triggers()
1280
- query = expand_query(arguments.get("query", ""), product_id)
1281
- query_lower = query.lower()
1282
- STOP_WORDS = {"a","an","the","and","or","of","in","to","for","is","are",
1283
- "with","at","by","on","from","as","it","its","be","was","can"}
1284
- raw_words = query.split()
1285
- query_words = [
1286
- w_lower for w_orig, w_lower in zip(raw_words, query_lower.split())
1287
- if w_lower not in STOP_WORDS and (len(w_lower) > 2 or w_orig.isupper())
1288
- ]
1289
- scored = [(score_skill(s, query_lower, query_words, triggers), s) for s in skills]
1290
- scored = [(sc, s) for sc, s in scored if sc > 0]
1291
- scored.sort(key=lambda x: -x[0])
1292
- matches = [s for _, s in scored[:5]]
1366
+ # Discovery is snapshot-only. A user's raw prompt never causes a
1367
+ # metadata refresh, API request, log entry, or telemetry event.
1368
+ skills = _skills_cache if _skills_cache is not None else []
1369
+ triggers = _triggers_cache if _triggers_cache is not None else {}
1370
+ match = match_skills(
1371
+ arguments.get("query", ""),
1372
+ skills,
1373
+ triggers,
1374
+ product_id,
1375
+ abbreviations=active_abbreviations(product_id),
1376
+ config=_matcher_config_cache,
1377
+ result_count=arguments.get("max_results"),
1378
+ )
1293
1379
 
1294
- if not matches:
1380
+ if match["status"] == "clarification":
1295
1381
  return [TextContent(type="text", text=(
1296
- f"No skills found matching **'{arguments.get('query', '')}'**.\n\n"
1297
- "**Suggestions:**\n"
1298
- "- Try different keywords or a more specific phrase\n"
1382
+ "**I need a little more detail to find the right workflow.**\n\n"
1383
+ "Add the specific task, intended outcome, or document type, then search again.\n\n"
1384
+ "**Options:**\n"
1385
+ "- Rephrase with more specific details\n"
1299
1386
  f"- Use `{prefix}_categories` to browse all skill categories\n"
1300
- "- Ask the user to rephrase their request\n\n"
1387
+ "- Ask the user a short clarifying question\n\n"
1301
1388
  f"**DO NOT call `{prefix}_execute`** — no skill has been selected."
1302
1389
  ))]
1303
1390
 
1304
- lines = [f"**{len(matches)} skills found for '{arguments.get('query', '')}':**\n"]
1305
- for i, s in enumerate(matches, 1):
1306
- desc = s.get("description", "")[:100]
1307
- lines.append(f"{i}. **{s['name']}** (`{s['id']}`)\n {desc}\n")
1391
+ matches = match["results"]
1392
+ top_confidence = matches[0]["confidence"]
1393
+ lines = [
1394
+ f"**{len(matches)} recommended workflow{'s' if len(matches) != 1 else ''} "
1395
+ f"({top_confidence} confidence):**\n"
1396
+ ]
1397
+ for i, result in enumerate(matches, 1):
1398
+ desc = result.get("description", "")[:100]
1399
+ lines.append(
1400
+ f"{i}. **{result['name']}** (`{result['skill_id']}`)\n"
1401
+ f" {desc}\n"
1402
+ )
1403
+
1404
+ if match["has_more"]:
1405
+ lines.append(
1406
+ f"More qualifying workflows are available. Search again with `max_results` greater than "
1407
+ f"{len(matches)} (maximum 10) to show more.\n"
1408
+ )
1308
1409
 
1309
1410
  lines.append("---")
1310
1411
  lines.append(
@@ -1371,7 +1472,7 @@ async def call_tool(name, arguments):
1371
1472
 
1372
1473
  # ── Categories ────────────────────────────────────────────────────────
1373
1474
  elif name == f"{prefix}_categories":
1374
- skills = await get_skills()
1475
+ skills = _skills_cache if _skills_cache is not None else []
1375
1476
  cats: dict[str, int] = {}
1376
1477
  for s in skills:
1377
1478
  cat = s.get("category_id") or s.get("category", "General")
@@ -1419,6 +1520,7 @@ async def main():
1419
1520
  # Load vertical metadata + abbreviations before accepting connections
1420
1521
  await load_vertical()
1421
1522
  await load_abbreviations()
1523
+ await load_discovery_snapshot()
1422
1524
  _rebuild_tools()
1423
1525
 
1424
1526
  # Ping first-connection tracker (idempotent — API only records it once)
@@ -1431,6 +1533,7 @@ async def main():
1431
1533
  import sys as _sys
1432
1534
  print(f"[OK] {v.get('product_name', 'TasksAI')} MCP Server ready (v{SERVER_VERSION})", file=_sys.stderr, flush=True)
1433
1535
  print(f" Abbreviations: {abbrev_count} loaded from {abbrev_src}", file=_sys.stderr, flush=True)
1536
+ print(f" Matcher: {POLICY_VERSION} | Discovery metadata loaded before prompts", file=_sys.stderr, flush=True)
1434
1537
  print(f" Vertical: {v.get('product_id', 'unknown')} | "
1435
1538
  f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / save_document / balance / categories",
1436
1539
  file=_sys.stderr, flush=True)
@@ -0,0 +1,492 @@
1
+ """Local, deterministic skill discovery for the TasksAI terminal runtime.
2
+
3
+ This module is deliberately pure: it performs no network, filesystem, logging,
4
+ or telemetry operations. Public skill metadata and product-scoped triggers are
5
+ provided by the caller from a snapshot loaded before a discovery request.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import unicodedata
11
+ from collections.abc import Iterable, Mapping, Sequence
12
+
13
+
14
+ POLICY_VERSION = "tasksai-trigger-matcher/2.0"
15
+
16
+ DEFAULT_STOP_WORDS = {
17
+ "a", "an", "and", "are", "as", "at", "be", "by", "can", "create",
18
+ "document", "for", "from", "in", "is", "it", "its", "of", "on",
19
+ "or", "the", "to", "was", "with",
20
+ }
21
+
22
+ DEFAULT_CONTEXT_WORDS = {
23
+ "accounting": {"accountant", "accounting", "bookkeeping", "finance"},
24
+ "chiropractor": {"chiropractic", "chiropractor", "patient", "practice"},
25
+ "church": {"church", "congregation", "ministry", "parish"},
26
+ "contractor": {"build", "construction", "contractor", "project"},
27
+ "dentist": {"dental", "dentist", "patient", "practice"},
28
+ "designer": {"creative", "design", "designer", "project"},
29
+ "electrician": {"electrical", "electrician", "installation", "project"},
30
+ "eventplanner": {"event", "guest", "planning", "venue"},
31
+ "farmer": {"agriculture", "crop", "farm", "farmer", "farming", "livestock"},
32
+ "funeral": {"family", "funeral", "memorial", "service"},
33
+ "hr": {"employee", "employment", "human resources", "workplace"},
34
+ "insurance": {"claim", "coverage", "insurance", "policy"},
35
+ "landlord": {"landlord", "property", "rental", "tenant"},
36
+ "law": {"attorney", "client", "court", "law", "legal", "litigation"},
37
+ "marketing": {"audience", "brand", "campaign", "marketing"},
38
+ "militaryspouse": {"deployment", "military", "military spouse", "relocation"},
39
+ "mortgage": {"borrower", "loan", "mortgage", "property"},
40
+ "mortuary": {"funeral", "memorial", "mortuary", "service"},
41
+ "nutritionist": {"client", "diet", "nutrition", "nutritionist"},
42
+ "pastor": {"church", "congregation", "ministry", "pastor"},
43
+ "personaltrainer": {"client", "exercise", "fitness", "training"},
44
+ "plumber": {"installation", "plumber", "plumbing", "project"},
45
+ "principal": {"education", "principal", "school", "student"},
46
+ "realtor": {"buyer", "listing", "property", "real estate", "seller"},
47
+ "restaurant": {"food service", "guest", "restaurant", "staff"},
48
+ "salon": {"beauty", "client", "salon", "stylist"},
49
+ "teacher": {"classroom", "education", "student", "teacher"},
50
+ "therapist": {"client", "mental health", "patient", "therapy"},
51
+ "travelagent": {"client", "destination", "travel", "traveler"},
52
+ "vet": {"animal", "patient", "practice", "veterinary"},
53
+ }
54
+
55
+ DEFAULT_THRESHOLDS = {"high": 90, "medium": 45}
56
+ DEFAULT_RESULT_COUNT = 3
57
+ MAXIMUM_RESULT_COUNT = 10
58
+
59
+
60
+ def normalize_text(value: object) -> str:
61
+ """NFKC/casefold text and replace punctuation and separators with spaces."""
62
+ text = unicodedata.normalize("NFKC", str(value or "")).casefold()
63
+ normalized = "".join(char if char.isalnum() else " " for char in text)
64
+ return " ".join(normalized.split())
65
+
66
+
67
+ def _tokens(value: object) -> tuple[str, ...]:
68
+ normalized = normalize_text(value)
69
+ return tuple(normalized.split()) if normalized else ()
70
+
71
+
72
+ def _canonical_product_id(value: object) -> str:
73
+ product_id = normalize_text(value).replace(" ", "")
74
+ aliases = {
75
+ "farmertasksai": "farmer",
76
+ "lawtasksai": "law",
77
+ "realtortasksai": "realtor",
78
+ }
79
+ return aliases.get(product_id, product_id)
80
+
81
+
82
+ def _declared_product_ids(record: Mapping[str, object]) -> set[str]:
83
+ values: list[object] = []
84
+ for key in ("product_id", "vertical_id"):
85
+ if record.get(key):
86
+ values.append(record[key])
87
+
88
+ product = record.get("product")
89
+ if isinstance(product, Mapping):
90
+ for key in ("product_id", "id", "slug"):
91
+ if product.get(key):
92
+ values.append(product[key])
93
+ elif product:
94
+ values.append(product)
95
+
96
+ product_ids = record.get("product_ids")
97
+ if isinstance(product_ids, Sequence) and not isinstance(product_ids, (str, bytes)):
98
+ values.extend(product_ids)
99
+
100
+ return {_canonical_product_id(value) for value in values if _canonical_product_id(value)}
101
+
102
+
103
+ def _belongs_to_product(record: Mapping[str, object], product_id: str) -> bool:
104
+ declared = _declared_product_ids(record)
105
+ return not declared or _canonical_product_id(product_id) in declared
106
+
107
+
108
+ def _expand_abbreviations(
109
+ tokens: tuple[str, ...], abbreviations: Mapping[str, object]
110
+ ) -> tuple[str, ...]:
111
+ expansions: list[tuple[tuple[str, ...], tuple[str, ...]]] = []
112
+ for abbreviation, expansion in abbreviations.items():
113
+ abbreviation_tokens = _tokens(abbreviation)
114
+ expansion_tokens = _tokens(expansion)
115
+ if abbreviation_tokens and expansion_tokens:
116
+ expansions.append((abbreviation_tokens, expansion_tokens))
117
+ expansions.sort(key=lambda item: (-len(item[0]), item[0]))
118
+
119
+ expanded: list[str] = []
120
+ index = 0
121
+ while index < len(tokens):
122
+ match = next(
123
+ (
124
+ (abbreviation_tokens, expansion_tokens)
125
+ for abbreviation_tokens, expansion_tokens in expansions
126
+ if tokens[index:index + len(abbreviation_tokens)] == abbreviation_tokens
127
+ ),
128
+ None,
129
+ )
130
+ if match is None:
131
+ expanded.append(tokens[index])
132
+ index += 1
133
+ continue
134
+ abbreviation_tokens, expansion_tokens = match
135
+ expanded.extend(expansion_tokens)
136
+ index += len(abbreviation_tokens)
137
+ return tuple(expanded)
138
+
139
+
140
+ def _normalize_scoring_tokens(
141
+ value: object,
142
+ stop_words: set[str],
143
+ abbreviations: Mapping[str, object],
144
+ ) -> tuple[str, ...]:
145
+ expanded = _expand_abbreviations(_tokens(value), abbreviations)
146
+ return tuple(token for token in expanded if token not in stop_words)
147
+
148
+
149
+ def _contains_tokens(container: tuple[str, ...], candidate: tuple[str, ...]) -> bool:
150
+ if not candidate or len(candidate) > len(container):
151
+ return False
152
+ width = len(candidate)
153
+ return any(container[index:index + width] == candidate for index in range(len(container) - width + 1))
154
+
155
+
156
+ def _category_text(skill: Mapping[str, object]) -> str:
157
+ category = skill.get("category")
158
+ if isinstance(category, Mapping):
159
+ category = category.get("name") or category.get("id") or ""
160
+ return " ".join(
161
+ str(value)
162
+ for value in (category, skill.get("category_name"), skill.get("category_id"))
163
+ if value
164
+ )
165
+
166
+
167
+ def _family_id(skill: Mapping[str, object]) -> str:
168
+ family = skill.get("family")
169
+ if isinstance(family, Mapping):
170
+ family = family.get("id") or family.get("name") or ""
171
+ value = skill.get("family_id") or skill.get("skill_family_id") or family
172
+ return normalize_text(value)
173
+
174
+
175
+ def _trigger_values(
176
+ triggers: Mapping[str, object], skill_id: str, product_id: str
177
+ ) -> tuple[str, ...]:
178
+ value = triggers.get(skill_id, ())
179
+ if isinstance(value, Mapping):
180
+ if not _belongs_to_product(value, product_id):
181
+ return ()
182
+ value = value.get("triggers", ())
183
+ if isinstance(value, str):
184
+ value = (value,)
185
+ if not isinstance(value, Iterable):
186
+ return ()
187
+ return tuple(str(trigger) for trigger in value if str(trigger).strip())
188
+
189
+
190
+ def _resolve_config(product_id: str, config: Mapping[str, object] | None) -> tuple[dict, list[str]]:
191
+ diagnostics: list[str] = []
192
+ config_product = _canonical_product_id(config.get("product_id")) if config else ""
193
+ valid_config = (
194
+ config
195
+ if config
196
+ and config.get("policy_version") == POLICY_VERSION
197
+ and (not config_product or config_product == _canonical_product_id(product_id))
198
+ else None
199
+ )
200
+ if valid_config is None:
201
+ if config is None:
202
+ diagnostics.append("bundled_policy_defaults")
203
+ elif config.get("policy_version") != POLICY_VERSION:
204
+ diagnostics.append("bundled_policy_defaults:policy_version_mismatch")
205
+ else:
206
+ diagnostics.append("bundled_policy_defaults:product_mismatch")
207
+
208
+ source = valid_config or {}
209
+ supplied_stop_words = source.get("stop_words", DEFAULT_STOP_WORDS)
210
+ if not isinstance(supplied_stop_words, Sequence) or isinstance(supplied_stop_words, (str, bytes)):
211
+ supplied_stop_words = DEFAULT_STOP_WORDS
212
+ stop_words = {
213
+ token
214
+ for value in supplied_stop_words
215
+ for token in _tokens(value)
216
+ }
217
+ context_default = DEFAULT_CONTEXT_WORDS.get(_canonical_product_id(product_id), set())
218
+ supplied_context_words = source.get("context_words", context_default)
219
+ if not isinstance(supplied_context_words, Sequence) or isinstance(supplied_context_words, (str, bytes)):
220
+ supplied_context_words = context_default
221
+ context_words = {
222
+ token
223
+ for value in supplied_context_words
224
+ for token in _tokens(value)
225
+ }
226
+ thresholds = dict(DEFAULT_THRESHOLDS)
227
+ supplied_thresholds = source.get("thresholds")
228
+ if isinstance(supplied_thresholds, Mapping):
229
+ for key in ("high", "medium"):
230
+ value = supplied_thresholds.get(key)
231
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
232
+ thresholds[key] = value
233
+ if thresholds["high"] < 1 or thresholds["medium"] > thresholds["high"]:
234
+ diagnostics.append("bundled_thresholds:invalid_order")
235
+ thresholds = dict(DEFAULT_THRESHOLDS)
236
+
237
+ default_count = source.get("default_result_count", DEFAULT_RESULT_COUNT)
238
+ maximum_count = source.get("maximum_result_count", MAXIMUM_RESULT_COUNT)
239
+ if not isinstance(default_count, int) or isinstance(default_count, bool) or default_count < 1:
240
+ default_count = DEFAULT_RESULT_COUNT
241
+ if not isinstance(maximum_count, int) or isinstance(maximum_count, bool) or maximum_count < 1:
242
+ maximum_count = MAXIMUM_RESULT_COUNT
243
+ maximum_count = min(maximum_count, MAXIMUM_RESULT_COUNT)
244
+ default_count = min(default_count, maximum_count)
245
+
246
+ configured_abbreviations = source.get("abbreviations", {})
247
+ if not isinstance(configured_abbreviations, Mapping):
248
+ configured_abbreviations = {}
249
+
250
+ return {
251
+ "stop_words": stop_words,
252
+ "context_words": context_words,
253
+ "thresholds": thresholds,
254
+ "default_result_count": default_count,
255
+ "maximum_result_count": maximum_count,
256
+ "abbreviations": configured_abbreviations,
257
+ }, diagnostics
258
+
259
+
260
+ def _score_trigger(
261
+ query_tokens: tuple[str, ...],
262
+ trigger: str,
263
+ stop_words: set[str],
264
+ context_words: set[str],
265
+ abbreviations: Mapping[str, object],
266
+ ) -> tuple[int, int, set[str], str]:
267
+ trigger_tokens = _normalize_scoring_tokens(trigger, stop_words, abbreviations)
268
+ if not trigger_tokens:
269
+ return 0, 0, set(), ""
270
+
271
+ query_set = set(query_tokens)
272
+ trigger_set = set(trigger_tokens)
273
+ matched = query_set & trigger_set
274
+ specific_tokens = trigger_set - context_words
275
+ specificity = len(specific_tokens)
276
+
277
+ if query_tokens == trigger_tokens:
278
+ return 120, specificity, matched, "trigger_exact"
279
+ if _contains_tokens(query_tokens, trigger_tokens):
280
+ bonus = min(15, max(0, specificity - 1) * 3)
281
+ return 90 + bonus, specificity, matched, "trigger_complete"
282
+ query_specific = query_set - context_words
283
+ if len(query_specific) >= 2 and _contains_tokens(trigger_tokens, query_tokens):
284
+ return 70, specificity, matched, "trigger_query_complete"
285
+
286
+ coverage_tokens = specific_tokens or trigger_set
287
+ covered = query_set & coverage_tokens
288
+ coverage = len(covered) / len(coverage_tokens)
289
+ if len(covered) >= 2 and coverage >= 0.67:
290
+ return round(40 * coverage), specificity, matched, "trigger_partial"
291
+ return 0, specificity, set(), ""
292
+
293
+
294
+ def _score_skill(
295
+ skill: Mapping[str, object],
296
+ query_tokens: tuple[str, ...],
297
+ triggers: Mapping[str, object],
298
+ product_id: str,
299
+ policy: Mapping[str, object],
300
+ ) -> dict:
301
+ stop_words = policy["stop_words"]
302
+ context_words = policy["context_words"]
303
+ abbreviations = policy["abbreviations"]
304
+ query_set = set(query_tokens)
305
+ specific_query = query_set - context_words
306
+ context_query = query_set & context_words
307
+ skill_id = str(skill.get("id", ""))
308
+
309
+ strongest_trigger = (0, 0, set(), "", "")
310
+ for trigger in _trigger_values(triggers, skill_id, product_id):
311
+ score, specificity, matched, evidence = _score_trigger(
312
+ query_tokens, trigger, stop_words, context_words, abbreviations
313
+ )
314
+ candidate = (score, specificity, matched, evidence, normalize_text(trigger))
315
+ candidate_key = (score, specificity, len(matched), normalize_text(trigger))
316
+ strongest_key = (
317
+ strongest_trigger[0],
318
+ strongest_trigger[1],
319
+ len(strongest_trigger[2]),
320
+ strongest_trigger[4],
321
+ )
322
+ if candidate_key > strongest_key:
323
+ strongest_trigger = candidate
324
+
325
+ trigger_score, trigger_specificity, trigger_matched, trigger_evidence, _ = strongest_trigger
326
+ evidence = [trigger_evidence] if trigger_evidence else []
327
+ matched_tokens = set(trigger_matched)
328
+
329
+ name_tokens = _normalize_scoring_tokens(
330
+ skill.get("name", ""), stop_words, abbreviations
331
+ )
332
+ name_set = set(name_tokens)
333
+ identity_score = 0
334
+ if name_tokens and _contains_tokens(query_tokens, name_tokens):
335
+ identity_score += 35
336
+ evidence.append("name_complete")
337
+ for token in sorted(specific_query & name_set):
338
+ identity_score += 8
339
+ matched_tokens.add(token)
340
+ evidence.append(f"name:{token}")
341
+ for token in sorted(context_query & name_set):
342
+ identity_score += 3
343
+ matched_tokens.add(token)
344
+ evidence.append(f"name_context:{token}")
345
+
346
+ category_set = set(
347
+ _normalize_scoring_tokens(_category_text(skill), stop_words, abbreviations)
348
+ )
349
+ category_score = 0
350
+ for token in sorted(specific_query & category_set):
351
+ category_score += 3
352
+ matched_tokens.add(token)
353
+ evidence.append(f"category:{token}")
354
+ for token in sorted(context_query & category_set):
355
+ category_score += 1
356
+ matched_tokens.add(token)
357
+ evidence.append(f"category_context:{token}")
358
+
359
+ description_set = set(
360
+ _normalize_scoring_tokens(skill.get("description", ""), stop_words, abbreviations)
361
+ )
362
+ description_matches = sorted(specific_query & description_set)
363
+ description_score = len(description_matches)
364
+ for token in description_matches:
365
+ matched_tokens.add(token)
366
+ evidence.append(f"description:{token}")
367
+
368
+ score = trigger_score + identity_score + category_score + description_score
369
+ if trigger_score == identity_score == category_score == 0:
370
+ score = min(score, policy["thresholds"]["high"] - 1)
371
+
372
+ if score >= policy["thresholds"]["high"]:
373
+ confidence = "high"
374
+ elif score >= policy["thresholds"]["medium"]:
375
+ confidence = "medium"
376
+ else:
377
+ confidence = "low"
378
+
379
+ return {
380
+ "skill_id": skill_id,
381
+ "name": str(skill.get("name", "")),
382
+ "description": str(skill.get("description", "")),
383
+ "category": _category_text(skill),
384
+ "score": score,
385
+ "confidence": confidence,
386
+ "evidence": evidence,
387
+ "_trigger_specificity": trigger_specificity,
388
+ "_matched_tokens": matched_tokens,
389
+ "_description_normalized": normalize_text(skill.get("description", "")),
390
+ "_family_id": _family_id(skill),
391
+ }
392
+
393
+
394
+ def _sort_key(result: Mapping[str, object]) -> tuple:
395
+ return (
396
+ -result["score"],
397
+ -result["_trigger_specificity"],
398
+ -len(result["_matched_tokens"]),
399
+ normalize_text(result["name"]),
400
+ str(result["skill_id"]),
401
+ )
402
+
403
+
404
+ def _diversify(results: list[dict]) -> list[dict]:
405
+ diversified: list[dict] = []
406
+ seen_ids: set[str] = set()
407
+ seen_descriptions: set[str] = set()
408
+ family_counts: dict[str, int] = {}
409
+ for result in results:
410
+ skill_id = result["skill_id"]
411
+ description = result["_description_normalized"]
412
+ family_id = result["_family_id"]
413
+ if skill_id in seen_ids:
414
+ continue
415
+ if description and description in seen_descriptions:
416
+ continue
417
+ if family_id and family_counts.get(family_id, 0) >= 2:
418
+ continue
419
+ diversified.append(result)
420
+ seen_ids.add(skill_id)
421
+ if description:
422
+ seen_descriptions.add(description)
423
+ if family_id:
424
+ family_counts[family_id] = family_counts.get(family_id, 0) + 1
425
+ return diversified
426
+
427
+
428
+ def match_skills(
429
+ query: str,
430
+ skills: Sequence[Mapping[str, object]],
431
+ triggers: Mapping[str, object],
432
+ product_id: str,
433
+ *,
434
+ abbreviations: Mapping[str, object] | None = None,
435
+ config: Mapping[str, object] | None = None,
436
+ result_count: int | None = None,
437
+ ) -> dict:
438
+ """Rank product-scoped public skill metadata under matcher policy 2.0."""
439
+ if not product_id or not _canonical_product_id(product_id):
440
+ raise ValueError("product_id is required for product-isolated matching")
441
+
442
+ policy, diagnostics = _resolve_config(product_id, config)
443
+ configured_abbreviations = dict(policy["abbreviations"])
444
+ if abbreviations:
445
+ configured_abbreviations.update(abbreviations)
446
+ policy["abbreviations"] = configured_abbreviations
447
+
448
+ raw_tokens = _tokens(query)
449
+ expanded_tokens = _expand_abbreviations(raw_tokens, configured_abbreviations)
450
+ query_tokens = tuple(token for token in expanded_tokens if token not in policy["stop_words"])
451
+ query_normalized = " ".join(query_tokens)
452
+
453
+ requested_count = policy["default_result_count"] if result_count is None else result_count
454
+ if not isinstance(requested_count, int) or isinstance(requested_count, bool):
455
+ requested_count = policy["default_result_count"]
456
+ requested_count = max(1, min(requested_count, policy["maximum_result_count"]))
457
+
458
+ scoped_skills = [skill for skill in skills if _belongs_to_product(skill, product_id)]
459
+ if len(scoped_skills) != len(skills):
460
+ diagnostics.append("product_mismatches_filtered")
461
+
462
+ ranked = [
463
+ _score_skill(skill, query_tokens, triggers, product_id, policy)
464
+ for skill in scoped_skills
465
+ if skill.get("id")
466
+ ]
467
+ ranked.sort(key=_sort_key)
468
+ qualified = [
469
+ result
470
+ for result in _diversify(ranked)
471
+ if result["confidence"] in {"high", "medium"}
472
+ ]
473
+ selected = qualified[:requested_count]
474
+
475
+ public_results = []
476
+ for result in selected:
477
+ public_results.append({
478
+ key: value
479
+ for key, value in result.items()
480
+ if not key.startswith("_")
481
+ })
482
+
483
+ return {
484
+ "policy_version": POLICY_VERSION,
485
+ "status": "matched" if public_results else "clarification",
486
+ "query_normalized": query_normalized,
487
+ "results": public_results,
488
+ "total_qualified": len(qualified),
489
+ "has_more": len(qualified) > len(public_results),
490
+ "result_count": requested_count,
491
+ "diagnostics": diagnostics,
492
+ }
package/src/index.js CHANGED
@@ -732,8 +732,10 @@ async function checkWritable({ label, targetPath, kind }) {
732
732
  async function downloadRuntime(source, runtimeDir, manifest = {}) {
733
733
  const useBundledRuntime = manifest.runtime?.source === "shared_installer";
734
734
  const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
735
+ const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
735
736
  const requirementsText = await loadRuntimeText(source, "requirements.txt", { useBundledRuntime });
736
737
  await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
738
+ await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
737
739
  await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
738
740
  }
739
741