@tasksai/install 0.1.28 → 0.1.30

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/README.md CHANGED
@@ -75,3 +75,15 @@ folder. The save tool supports `.docx` and Markdown output, sanitizes filenames,
75
75
  and avoids overwriting existing files unless explicitly told to overwrite.
76
76
  Generated document content is handled by the customer's AI framework and local
77
77
  MCP runtime; it is not sent to TasksAI websites or APIs.
78
+
79
+ ## Realtor jurisdiction sources
80
+
81
+ For jurisdiction-sensitive RealtorTasksAI workflows, the local runtime sends
82
+ only the selected skill ID and general state, county, city, public-authority,
83
+ and MLS labels to the TasksAI API. It never sends the user's prompt, property
84
+ address, client facts, uploaded records, or generated document.
85
+
86
+ The API returns a reviewed source pack when one exists. If it does not, the
87
+ licensed runtime can request a controlled official-web lookup. Live results are
88
+ cached, source-linked, and visibly labeled `provisional`; they never substitute
89
+ for private MLS rules, brokerage policy, approved forms, or professional review.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/server.py CHANGED
@@ -125,7 +125,7 @@ if not LICENSE_KEY:
125
125
  print("Find your key in your purchase confirmation email.", file=sys.stderr, flush=True)
126
126
  sys.exit(1)
127
127
 
128
- SERVER_VERSION = "2.4.4"
128
+ SERVER_VERSION = "2.5.0"
129
129
 
130
130
  AUTH_HEADERS = {
131
131
  "Authorization": f"Bearer {LICENSE_KEY}",
@@ -509,8 +509,8 @@ async def api_get(path):
509
509
  return resp.json()
510
510
 
511
511
 
512
- async def api_post(path, payload):
513
- async with httpx.AsyncClient(timeout=10.0) as client:
512
+ async def api_post(path, payload, *, timeout=10.0):
513
+ async with httpx.AsyncClient(timeout=timeout) as client:
514
514
  resp = await client.post(
515
515
  f"{API_BASE}{path}",
516
516
  json=payload,
@@ -523,20 +523,58 @@ async def api_post(path, payload):
523
523
  def jurisdiction_sources_path(arguments):
524
524
  """Build a metadata-only source lookup path without customer or property data."""
525
525
  skill_id = (arguments.get("skill_id") or "").strip()
526
- state = (arguments.get("state") or "").strip()
526
+ state = general_authority_label(arguments.get("state"), "state")
527
527
  if not skill_id:
528
528
  raise ValueError("skill_id is required.")
529
529
 
530
530
  params = {"skill_id": skill_id}
531
531
  if state:
532
532
  params["state"] = state
533
- for field in ("county", "locality", "district"):
534
- value = (arguments.get(field) or "").strip()
533
+ for field in ("county", "locality", "district", "mls"):
534
+ value = general_authority_label(arguments.get(field), field)
535
535
  if value:
536
536
  params[field] = value
537
537
  return f"/v1/skills/jurisdiction-sources?{urlencode(params)}"
538
538
 
539
539
 
540
+ _GENERAL_AUTHORITY_LABEL = re.compile(r"^[A-Za-z0-9 .,'&()/-]{1,240}$")
541
+ _LIKELY_STREET_ADDRESS = re.compile(
542
+ r"^\d+\s+.+\b(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|court|ct|way)\b",
543
+ re.IGNORECASE,
544
+ )
545
+
546
+
547
+ def general_authority_label(value, field):
548
+ """Normalize a public authority label and reject likely customer data."""
549
+ normalized = " ".join(str(value or "").strip().split())
550
+ if not normalized:
551
+ return None
552
+ if (
553
+ not _GENERAL_AUTHORITY_LABEL.fullmatch(normalized)
554
+ or _LIKELY_STREET_ADDRESS.search(normalized)
555
+ ):
556
+ raise ValueError(
557
+ f"{field} must be a general authority label, not an address or customer record."
558
+ )
559
+ return normalized
560
+
561
+
562
+ def jurisdiction_research_payload(arguments):
563
+ """Build the privacy-safe research request sent only after reviewed lookup misses."""
564
+ payload = {}
565
+ for field in ("skill_id", "state", "county", "locality", "district", "mls"):
566
+ value = (
567
+ (arguments.get(field) or "").strip()
568
+ if field == "skill_id"
569
+ else general_authority_label(arguments.get(field), field)
570
+ )
571
+ if value:
572
+ payload[field] = value
573
+ if not payload.get("skill_id") or not payload.get("state"):
574
+ raise ValueError("skill_id and state are required for live jurisdiction research.")
575
+ return payload
576
+
577
+
540
578
  def format_jurisdiction_source_pack(pack):
541
579
  """Render the public source pack as compact instructions for the local AI."""
542
580
  status = pack.get("status", "unsupported")
@@ -548,6 +586,7 @@ def format_jurisdiction_source_pack(pack):
548
586
  f"- Skill: `{pack.get('skill_id', '')}`",
549
587
  f"- Registry reviewed: {pack.get('registry_reviewed_on', 'not supplied')}",
550
588
  f"- Registry refresh due: {pack.get('registry_refresh_due_on', 'not supplied')}",
589
+ f"- Resolution: **{pack.get('resolution_source', 'packaged')}**",
551
590
  ]
552
591
  if pack.get("state_name"):
553
592
  lines.append(f"- State: {pack['state_name']} ({pack.get('state_code', '')})")
@@ -566,6 +605,8 @@ def format_jurisdiction_source_pack(pack):
566
605
  lines.extend(["", "## Sources"])
567
606
  for source in sources:
568
607
  effective = source.get("effective_date") or "not specified"
608
+ verification = source.get("verification_status", "reviewed")
609
+ date_label = "Machine researched" if verification == "machine_researched" else "Reviewed"
569
610
  lines.extend(
570
611
  [
571
612
  "",
@@ -573,7 +614,8 @@ def format_jurisdiction_source_pack(pack):
573
614
  f"- Authority: {source.get('authority', '')}",
574
615
  f"- Jurisdiction: {source.get('jurisdiction', '')} ({source.get('scope_type', 'scope not supplied')})",
575
616
  f"- Effective date: {effective}",
576
- f"- Reviewed: {source.get('reviewed_on', '')}",
617
+ f"- Verification: {verification}",
618
+ f"- {date_label}: {source.get('reviewed_on', '')}",
577
619
  f"- Refresh due: {source.get('refresh_due_on', '')}",
578
620
  f"- URL: {source.get('url', '')}",
579
621
  f"- Use: {source.get('purpose', '')}",
@@ -1436,11 +1478,85 @@ def write_markdown(path, title, content_markdown, product_name):
1436
1478
  path.write_text("\n\n".join(part for part in parts if part), encoding="utf-8")
1437
1479
 
1438
1480
 
1481
+ def reconcile_public_copy_counts(content_markdown):
1482
+ """Replace stale public-copy counts with deterministic local counts.
1483
+
1484
+ AI hosts sometimes leave ``NOT VERIFIED`` or an estimated count beside a
1485
+ marked public-copy body. The local runtime can resolve that mechanical
1486
+ value without transmitting the document. It changes only count lines tied
1487
+ to balanced PUBLIC COPY markers and a detectable numeric maximum.
1488
+ """
1489
+ start_marker = "<!-- PUBLIC COPY START -->"
1490
+ end_marker = "<!-- PUBLIC COPY END -->"
1491
+ if content_markdown.count(start_marker) != content_markdown.count(end_marker):
1492
+ return content_markdown
1493
+
1494
+ blocks = list(
1495
+ re.finditer(
1496
+ re.escape(start_marker) + r"\n?(.*?)\n?" + re.escape(end_marker),
1497
+ content_markdown,
1498
+ re.DOTALL,
1499
+ )
1500
+ )
1501
+ if not blocks:
1502
+ return content_markdown
1503
+
1504
+ count_pattern = re.compile(
1505
+ r"(?im)^(?P<prefix>\s*(?:\*\*)?Deterministic count:(?:\*\*)?\s*)"
1506
+ r"(?P<actual>NOT VERIFIED|[\d,]+)"
1507
+ r"(?:\s*/\s*(?P<target>[\d,]+)\s*(?P<unit>words|characters))?\s*$"
1508
+ )
1509
+ limit_patterns = (
1510
+ re.compile(r"(?i)\bmaximum(?:\s+public[- ]copy\s+length)?\s+(?:is\s+)?([\d,]+)\s*(words|characters)\b"),
1511
+ re.compile(r"(?i)\bmax(?:imum)?\s*[:=]?\s*([\d,]+)\s*(words|characters)\b"),
1512
+ re.compile(r"(?i)\b([\d,]+)[ -](word|character)\s+maximum\b"),
1513
+ )
1514
+
1515
+ replacements = []
1516
+ count_matches = list(count_pattern.finditer(content_markdown))
1517
+ for block in blocks:
1518
+ following = [match for match in count_matches if match.start() >= block.end()]
1519
+ if not following:
1520
+ continue
1521
+ count_match = min(following, key=lambda match: match.start() - block.end())
1522
+ if count_match.start() - block.end() > 2000:
1523
+ continue
1524
+
1525
+ target_text = count_match.group("target")
1526
+ unit = (count_match.group("unit") or "").lower()
1527
+ if not target_text or not unit:
1528
+ limit_match = None
1529
+ for pattern in limit_patterns:
1530
+ limit_match = pattern.search(content_markdown)
1531
+ if limit_match:
1532
+ break
1533
+ if not limit_match:
1534
+ continue
1535
+ target_text = limit_match.group(1)
1536
+ unit = limit_match.group(2).lower()
1537
+ if unit == "word":
1538
+ unit = "words"
1539
+ elif unit == "character":
1540
+ unit = "characters"
1541
+
1542
+ target = int(target_text.replace(",", ""))
1543
+ body = block.group(1)
1544
+ actual = len(body) if unit == "characters" else len(re.findall(r"\S+", body))
1545
+ replacement = f"{count_match.group('prefix')}{actual} / {target} {unit}"
1546
+ replacements.append((count_match.start(), count_match.end(), replacement))
1547
+
1548
+ corrected = content_markdown
1549
+ for start, end, replacement in reversed(replacements):
1550
+ corrected = corrected[:start] + replacement + corrected[end:]
1551
+ return corrected
1552
+
1553
+
1439
1554
  def save_document(arguments, product_name):
1440
1555
  """Save AI-produced final content to a local downloadable file."""
1441
1556
  content_markdown = (arguments.get("content_markdown") or arguments.get("content") or "").strip()
1442
1557
  if not content_markdown:
1443
1558
  raise ValueError("content_markdown is required.")
1559
+ content_markdown = reconcile_public_copy_counts(content_markdown)
1444
1560
 
1445
1561
  title = (arguments.get("title") or "").strip()
1446
1562
  skill_id = (arguments.get("skill_id") or "").strip()
@@ -1598,8 +1714,9 @@ def build_tools(prefix, product_name, occupation):
1598
1714
  Tool(
1599
1715
  name=f"{prefix}_jurisdiction_sources",
1600
1716
  description=(
1601
- f"Retrieve the curated current jurisdiction-and-authority source pack for a jurisdiction-sensitive {product_name} workflow. "
1717
+ f"Retrieve the current jurisdiction-and-authority sources for a jurisdiction-sensitive {product_name} workflow. "
1602
1718
  "Use after the applicable state is known and before applying state, local, district, regulator, form, or policy requirements. "
1719
+ "For RealtorTasksAI, a missing reviewed pack can trigger a licensed, privacy-safe official-web lookup whose results are explicitly provisional. "
1603
1720
  "Send only the skill ID and general authority labels; never send an address, person name, student or client facts, documents, or generated content."
1604
1721
  ),
1605
1722
  inputSchema={
@@ -1625,6 +1742,10 @@ def build_tools(prefix, product_name, occupation):
1625
1742
  "type": "string",
1626
1743
  "description": "Optional public authority or school-district name only; do not include a person, student, or record identifier.",
1627
1744
  },
1745
+ "mls": {
1746
+ "type": "string",
1747
+ "description": "Optional MLS or Realtor-association name only; never send credentials, listing data, or member-only content.",
1748
+ },
1628
1749
  },
1629
1750
  "required": ["skill_id"],
1630
1751
  },
@@ -1831,6 +1952,24 @@ async def call_tool(name, arguments):
1831
1952
  }:
1832
1953
  path = jurisdiction_sources_path(arguments or {})
1833
1954
  result = await api_get(path)
1955
+ if (
1956
+ prefix == "realtortasksai"
1957
+ and (arguments or {}).get("state")
1958
+ and result.get("status") not in {"ready", "provisional"}
1959
+ ):
1960
+ try:
1961
+ result = await api_post(
1962
+ "/v1/skills/jurisdiction-research",
1963
+ jurisdiction_research_payload(arguments or {}),
1964
+ timeout=90.0,
1965
+ )
1966
+ except Exception:
1967
+ result = dict(result)
1968
+ instructions = list(result.get("instructions") or [])
1969
+ instructions.append(
1970
+ "Controlled live research was unavailable. Continue only with intake or professional-supplied current official sources; do not substitute another jurisdiction."
1971
+ )
1972
+ result["instructions"] = instructions
1834
1973
  return [TextContent(type="text", text=format_jurisdiction_source_pack(result))]
1835
1974
 
1836
1975
  # ── Save Document ───────────────────────────────────────────────────