@tasksai/install 0.1.9 → 0.1.10

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
@@ -30,6 +30,27 @@ The installer:
30
30
  from writing local config files
31
31
  - supports Claude Desktop, Cursor, Windsurf, and Codex
32
32
  - runs a local health check
33
+ - exposes a local save-document tool for finished skill outputs
33
34
 
34
35
  TasksAI servers handle authentication, credits, catalog/search metadata, and
35
36
  licensed skill delivery. TasksAI does not process user task content.
37
+
38
+ ## Local document output
39
+
40
+ After a workflow is executed, the customer's AI assistant applies the expert
41
+ framework locally. When the final deliverable is ready, the assistant can call
42
+ the vertical-specific save tool, such as `lawtasksai_save_document` or
43
+ `farmertasksai_save_document`, to create a downloadable file on the customer's
44
+ machine.
45
+
46
+ By default, files are saved under:
47
+
48
+ ```text
49
+ ~/Documents/TasksAI/<ProductName>/
50
+ ```
51
+
52
+ Set `TASKSAI_OUTPUT_DIR` in the local runtime environment to use a different
53
+ folder. The save tool supports `.docx` and Markdown output, sanitizes filenames,
54
+ and avoids overwriting existing files unless explicitly told to overwrite.
55
+ Generated document content is handled by the customer's AI framework and local
56
+ MCP runtime; it is not sent to TasksAI websites or APIs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,4 @@
1
1
  mcp>=1.0.0
2
2
  httpx>=0.27.0
3
3
  python-dotenv>=1.0.0
4
+ python-docx>=1.1.2
package/runtime/server.py CHANGED
@@ -8,12 +8,14 @@ then self-configures tool names, system prompt, and abbreviation maps.
8
8
  Tools (names are vertical-prefixed at runtime, e.g. farmertasksai_search):
9
9
  {prefix}_search — Find the right skill for your task
10
10
  {prefix}_execute — Get the full expert framework for a skill (costs 1 credit)
11
+ {prefix}_save_document — Save the finished local output as a downloadable file
11
12
  {prefix}_balance — Check your remaining credit balance
12
13
  {prefix}_categories — Browse skills by category
13
14
 
14
15
  Privacy: Your queries, documents, and client data never leave your machine.
15
16
  Skills run entirely locally. The API only delivers skill metadata and
16
- counts credits — it never sees what you're working on.
17
+ counts credits — it never sees what you're working on. Saved documents are
18
+ created by this local MCP server, not by TasksAI websites or APIs.
17
19
  """
18
20
 
19
21
  import os
@@ -23,6 +25,7 @@ import time
23
25
  import asyncio
24
26
  import platform
25
27
  import httpx
28
+ from datetime import datetime
26
29
  from pathlib import Path
27
30
 
28
31
  # Force UTF-8 stdout/stderr on Windows (default is CP1252 which breaks emoji)
@@ -89,7 +92,7 @@ if not LICENSE_KEY:
89
92
  print("Find your key in your purchase confirmation email.", file=sys.stderr, flush=True)
90
93
  sys.exit(1)
91
94
 
92
- SERVER_VERSION = "2.1.0"
95
+ SERVER_VERSION = "2.2.0"
93
96
 
94
97
  AUTH_HEADERS = {
95
98
  "Authorization": f"Bearer {LICENSE_KEY}",
@@ -587,8 +590,170 @@ def score_skill(skill, query_lower, query_words, triggers):
587
590
  )
588
591
 
589
592
 
593
+ def safe_filename_component(value, fallback="tasksai-output", max_length=90):
594
+ """Return a conservative filename stem with no path separators."""
595
+ text = str(value or "").strip()
596
+ text = re.sub(r"[\\/]+", "-", text)
597
+ text = re.sub(r"[^A-Za-z0-9._ -]+", "", text)
598
+ text = re.sub(r"\s+", "-", text)
599
+ text = re.sub(r"-{2,}", "-", text)
600
+ text = text.strip(" ._-")
601
+ if not text:
602
+ text = fallback
603
+ return text[:max_length].strip(" ._-") or fallback
604
+
605
+
606
+ def output_root(product_name):
607
+ """Return the local folder used for saved customer deliverables."""
608
+ configured = os.getenv("TASKSAI_OUTPUT_DIR", "").strip()
609
+ if configured:
610
+ root = Path(configured).expanduser()
611
+ else:
612
+ root = Path.home() / "Documents" / "TasksAI" / safe_filename_component(product_name, "TasksAI")
613
+ root.mkdir(parents=True, exist_ok=True)
614
+ return root
615
+
616
+
617
+ def unique_output_path(root, filename, overwrite=False):
618
+ """Build a local output path without allowing arbitrary directories."""
619
+ candidate_name = Path(str(filename or "")).name
620
+ path = root / candidate_name
621
+ if overwrite or not path.exists():
622
+ return path
623
+
624
+ stem = path.stem
625
+ suffix = path.suffix
626
+ for index in range(2, 1000):
627
+ candidate = root / f"{stem}-{index}{suffix}"
628
+ if not candidate.exists():
629
+ return candidate
630
+ raise RuntimeError("Could not choose a unique output filename.")
631
+
632
+
633
+ def add_markdown_text(paragraph, text):
634
+ """Add basic inline markdown formatting to a python-docx paragraph."""
635
+ pattern = r"\*\*(.+?)\*\*"
636
+ last_end = 0
637
+ for match in re.finditer(pattern, text):
638
+ if match.start() > last_end:
639
+ paragraph.add_run(text[last_end:match.start()])
640
+ bold_run = paragraph.add_run(match.group(1))
641
+ bold_run.bold = True
642
+ last_end = match.end()
643
+ if last_end < len(text):
644
+ paragraph.add_run(text[last_end:])
645
+
646
+
647
+ def write_docx(path, title, content_markdown, product_name):
648
+ """Write a simple, polished DOCX file locally."""
649
+ try:
650
+ from docx import Document
651
+ from docx.shared import Pt
652
+ except Exception as exc:
653
+ raise RuntimeError(
654
+ "python-docx is not installed. Re-run the official installer or install python-docx in the MCP runtime environment."
655
+ ) from exc
656
+
657
+ doc = Document()
658
+ if title:
659
+ doc.add_heading(title, level=0)
660
+
661
+ lines = content_markdown.splitlines()
662
+ paragraph_buffer = []
663
+
664
+ def flush_paragraph():
665
+ if not paragraph_buffer:
666
+ return
667
+ text = " ".join(part.strip() for part in paragraph_buffer if part.strip())
668
+ paragraph_buffer.clear()
669
+ if text:
670
+ add_markdown_text(doc.add_paragraph(), text)
671
+
672
+ for raw_line in lines:
673
+ line = raw_line.rstrip()
674
+ stripped = line.strip()
675
+ if not stripped:
676
+ flush_paragraph()
677
+ continue
678
+
679
+ heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
680
+ bullet = re.match(r"^[-*]\s+(.+)$", stripped)
681
+ numbered = re.match(r"^\d+\.\s+(.+)$", stripped)
682
+
683
+ if heading:
684
+ flush_paragraph()
685
+ doc.add_heading(heading.group(2).strip(), level=min(len(heading.group(1)), 4))
686
+ elif bullet:
687
+ flush_paragraph()
688
+ para = doc.add_paragraph(style="List Bullet")
689
+ add_markdown_text(para, bullet.group(1).strip())
690
+ elif numbered:
691
+ flush_paragraph()
692
+ para = doc.add_paragraph(style="List Number")
693
+ add_markdown_text(para, numbered.group(1).strip())
694
+ elif stripped in {"---", "***", "___"}:
695
+ flush_paragraph()
696
+ doc.add_paragraph()
697
+ else:
698
+ paragraph_buffer.append(stripped)
699
+
700
+ flush_paragraph()
701
+
702
+ doc.add_paragraph()
703
+ footer = doc.add_paragraph()
704
+ run = footer.add_run(f"Generated locally by {product_name}. Content was not sent to TasksAI servers.")
705
+ run.italic = True
706
+ run.font.size = Pt(8)
707
+
708
+ doc.save(path)
709
+
710
+
711
+ def write_markdown(path, title, content_markdown, product_name):
712
+ """Write a Markdown file locally."""
713
+ parts = []
714
+ if title:
715
+ parts.append(f"# {title.strip()}\n")
716
+ parts.append(content_markdown.strip())
717
+ parts.append(f"\n\n---\nGenerated locally by {product_name}. Content was not sent to TasksAI servers.\n")
718
+ path.write_text("\n\n".join(part for part in parts if part), encoding="utf-8")
719
+
720
+
721
+ def save_document(arguments, product_name):
722
+ """Save AI-produced final content to a local downloadable file."""
723
+ content_markdown = (arguments.get("content_markdown") or arguments.get("content") or "").strip()
724
+ if not content_markdown:
725
+ raise ValueError("content_markdown is required.")
726
+
727
+ title = (arguments.get("title") or "").strip()
728
+ skill_id = (arguments.get("skill_id") or "").strip()
729
+ fmt = (arguments.get("format") or "docx").strip().lower()
730
+ if fmt == "md":
731
+ fmt = "markdown"
732
+ if fmt not in {"docx", "markdown"}:
733
+ raise ValueError("format must be 'docx' or 'markdown'.")
734
+
735
+ extension = ".docx" if fmt == "docx" else ".md"
736
+ supplied_filename = (arguments.get("filename") or "").strip()
737
+ if supplied_filename:
738
+ stem = safe_filename_component(Path(supplied_filename).stem)
739
+ else:
740
+ base = title or skill_id or f"{product_name} output"
741
+ stamp = datetime.now().strftime("%Y-%m-%d")
742
+ stem = f"{stamp}-{safe_filename_component(base)}"
743
+
744
+ root = output_root(product_name)
745
+ output_path = unique_output_path(root, f"{stem}{extension}", arguments.get("overwrite") is True)
746
+
747
+ if fmt == "docx":
748
+ write_docx(output_path, title, content_markdown, product_name)
749
+ else:
750
+ write_markdown(output_path, title, content_markdown, product_name)
751
+
752
+ return output_path, fmt
753
+
754
+
590
755
  def build_tools(prefix, product_name, occupation):
591
- """Build the four MCP tools with vertical-specific names and descriptions."""
756
+ """Build MCP tools with vertical-specific names and descriptions."""
592
757
  # Build example queries from the top trigger phrases for this vertical
593
758
  _examples = {
594
759
  "attorney": "e.g. 'statute of limitations', 'motion to compel', 'demand letter', 'deposition prep', 'discovery requests'",
@@ -646,6 +811,46 @@ def build_tools(prefix, product_name, occupation):
646
811
  "required": ["skill_id"]
647
812
  }
648
813
  ),
814
+ Tool(
815
+ name=f"{prefix}_save_document",
816
+ description=(
817
+ f"Save the finished {product_name} deliverable as a local downloadable file after you have "
818
+ "completed the user's final answer. The file is created by the local MCP server in the user's "
819
+ "TasksAI documents folder; customer content is not sent to TasksAI websites or APIs. "
820
+ "Use this after applying a workflow, not before drafting the final output."
821
+ ),
822
+ inputSchema={
823
+ "type": "object",
824
+ "properties": {
825
+ "content_markdown": {
826
+ "type": "string",
827
+ "description": "The final customer-facing deliverable content in Markdown."
828
+ },
829
+ "title": {
830
+ "type": "string",
831
+ "description": "Short document title."
832
+ },
833
+ "skill_id": {
834
+ "type": "string",
835
+ "description": "Workflow ID used to create the deliverable."
836
+ },
837
+ "format": {
838
+ "type": "string",
839
+ "enum": ["docx", "markdown"],
840
+ "description": "Output file format. Use docx unless the user asks for Markdown."
841
+ },
842
+ "filename": {
843
+ "type": "string",
844
+ "description": "Optional filename stem. Paths are ignored; files are saved in the local TasksAI output folder."
845
+ },
846
+ "overwrite": {
847
+ "type": "boolean",
848
+ "description": "Whether to overwrite an existing file with the same sanitized name."
849
+ }
850
+ },
851
+ "required": ["content_markdown"]
852
+ }
853
+ ),
649
854
  Tool(
650
855
  name=f"{prefix}_balance",
651
856
  description=f"Check your remaining {product_name} credit balance.",
@@ -824,9 +1029,24 @@ async def call_tool(name, arguments):
824
1029
  return [TextContent(type="text", text=(
825
1030
  f"# {skill_name}\n\n"
826
1031
  f"{content}\n\n"
1032
+ f"---\n"
1033
+ f"**Local document output:** After you produce the user's final deliverable, call "
1034
+ f"`{prefix}_save_document` with the final Markdown content, title, skill_id, and "
1035
+ "`format: \"docx\"` unless the user asks for Markdown. The file is written locally; "
1036
+ f"{product_name} websites and APIs do not receive the user's content or generated output.\n\n"
827
1037
  f"---\n*Credits remaining: {credits_remaining}*"
828
1038
  ))]
829
1039
 
1040
+ # ── Save Document ───────────────────────────────────────────────────
1041
+ elif name == f"{prefix}_save_document":
1042
+ output_path, fmt = save_document(arguments or {}, product_name)
1043
+ return [TextContent(type="text", text=(
1044
+ "**Document Saved**\n\n"
1045
+ f"- File: `{output_path}`\n"
1046
+ f"- Format: {fmt}\n"
1047
+ f"- Privacy: saved locally by the {product_name} MCP server; content was not sent to TasksAI servers."
1048
+ ))]
1049
+
830
1050
  # ── Balance ───────────────────────────────────────────────────────────
831
1051
  elif name == f"{prefix}_balance":
832
1052
  result = await api_get("/v1/credits/balance")
@@ -904,7 +1124,7 @@ async def main():
904
1124
  print(f"[OK] {v.get('product_name', 'TasksAI')} MCP Server ready (v{SERVER_VERSION})", file=_sys.stderr, flush=True)
905
1125
  print(f" Abbreviations: {abbrev_count} loaded from {abbrev_src}", file=_sys.stderr, flush=True)
906
1126
  print(f" Vertical: {v.get('product_id', 'unknown')} | "
907
- f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / balance / categories",
1127
+ f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / save_document / balance / categories",
908
1128
  file=_sys.stderr, flush=True)
909
1129
 
910
1130
  async with stdio_server() as (read_stream, write_stream):