@tasksai/install 0.1.9 → 0.1.11
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 +21 -0
- package/package.json +1 -1
- package/runtime/requirements.txt +1 -0
- package/runtime/server.py +224 -4
- package/src/index.js +9 -5
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
package/runtime/requirements.txt
CHANGED
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.
|
|
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
|
|
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):
|
package/src/index.js
CHANGED
|
@@ -180,7 +180,7 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
180
180
|
|
|
181
181
|
await writeJson(path.join(installDir, "agent-install.json"), manifest);
|
|
182
182
|
await writeJson(path.join(installDir, "vertical.json"), vertical);
|
|
183
|
-
await downloadRuntime(source, runtimeDir);
|
|
183
|
+
await downloadRuntime(source, runtimeDir, manifest);
|
|
184
184
|
|
|
185
185
|
const licenseKey = await resolveLicenseKey(options, vertical, installDir);
|
|
186
186
|
|
|
@@ -568,14 +568,18 @@ async function checkWritable({ label, targetPath, kind }) {
|
|
|
568
568
|
}
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
-
async function downloadRuntime(source, runtimeDir) {
|
|
572
|
-
const
|
|
573
|
-
const
|
|
571
|
+
async function downloadRuntime(source, runtimeDir, manifest = {}) {
|
|
572
|
+
const useBundledRuntime = manifest.runtime?.source === "shared_installer";
|
|
573
|
+
const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
|
|
574
|
+
const requirementsText = await loadRuntimeText(source, "requirements.txt", { useBundledRuntime });
|
|
574
575
|
await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
|
|
575
576
|
await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
|
|
576
577
|
}
|
|
577
578
|
|
|
578
|
-
async function loadRuntimeText(source, filePath) {
|
|
579
|
+
async function loadRuntimeText(source, filePath, { useBundledRuntime = false } = {}) {
|
|
580
|
+
if (useBundledRuntime) {
|
|
581
|
+
return fsp.readFile(path.join(BUNDLED_RUNTIME_DIR, filePath), "utf8");
|
|
582
|
+
}
|
|
579
583
|
try {
|
|
580
584
|
return await loadText(source, filePath);
|
|
581
585
|
} catch (error) {
|