abstract-toolserver 0.0.1__tar.gz

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.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract_toolserver
3
+ Version: 0.0.1
4
+ Summary: The abstract_* ecosystem exposed as an API-callable AI toolset: functions become self-describing Flask endpoints with /endpoints discovery and ?help.
5
+ Home-page: https://github.com/AbstractEndeavors/abstract_toolserver
6
+ Author: putkoff
7
+ Author-email: partners@abstractendeavors.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: abstract_flask
16
+ Requires-Dist: abstract_utilities
17
+ Provides-Extra: files
18
+ Requires-Dist: abstract_search; extra == "files"
19
+ Requires-Dist: abstract_paths; extra == "files"
20
+ Provides-Extra: web
21
+ Requires-Dist: abstract_webtools; extra == "web"
22
+ Provides-Extra: media
23
+ Requires-Dist: abstract_ocr; extra == "media"
24
+ Requires-Dist: abstract_pandas; extra == "media"
25
+ Requires-Dist: media_intelligence; extra == "media"
26
+ Provides-Extra: ai
27
+ Requires-Dist: abstract_ai; extra == "ai"
28
+ Provides-Extra: db
29
+ Requires-Dist: abstract_database; extra == "db"
30
+ Provides-Extra: ui
31
+ Requires-Dist: abstract_clicks; extra == "ui"
32
+ Requires-Dist: abstract_windows; extra == "ui"
33
+ Dynamic: author
34
+ Dynamic: author-email
35
+ Dynamic: classifier
36
+ Dynamic: description
37
+ Dynamic: description-content-type
38
+ Dynamic: home-page
39
+ Dynamic: provides-extra
40
+ Dynamic: requires-dist
41
+ Dynamic: requires-python
42
+ Dynamic: summary
43
+
44
+ # abstract_toolserver
45
+
46
+ The `abstract_*` ecosystem exposed as an **API-callable AI toolset** — every tool
47
+ is a plain Python function turned into a self-describing HTTP endpoint by
48
+ [`abstract_flask`](https://github.com/AbstractEndeavors/abstract_flask). A portable
49
+ tool layer any model (Claude, hugpy, …) can drive over HTTP instead of being
50
+ bound to one runtime's tool harness.
51
+
52
+ ## Run it
53
+
54
+ ```bash
55
+ pip install abstract_toolserver # + the extras you want to expose
56
+ python -m abstract_toolserver # HOST/PORT/DEBUG from TOOLSERVER_* env
57
+ ```
58
+
59
+ ```python
60
+ from abstract_toolserver import get_toolserver_app
61
+ app = get_toolserver_app() # a normal Flask/WSGI app
62
+ ```
63
+
64
+ ## Self-describing surface
65
+
66
+ The app auto-mounts introspection endpoints (from `abstract_flask`):
67
+
68
+ | Endpoint | What it gives an LLM |
69
+ |---|---|
70
+ | `GET /prefixes` | the tool categories (`/fs`, `/db`, `/ui`, …) |
71
+ | `GET /endpoints` | every tool as `{endpoint, url, methods}` |
72
+ | `GET /<cat>/<tool>?help=true` | that tool's signature/help |
73
+
74
+ Call a tool with JSON; unknown keys are pruned to the function signature, and the
75
+ reply is `{"result": ...}` (or `{"error": ...}`). Discover-and-dispatch from a
76
+ client is already provided by `abstract_apis.make_endpoint_call`.
77
+
78
+ ```bash
79
+ curl -s localhost:5000/fs/count_tokens -d '{"text":"hello world"}'
80
+ # {"result": 2}
81
+ curl -s localhost:5000/db/schema # {"result": {table: [cols...]}}
82
+ curl -s 'localhost:5000/db/query?help=true'
83
+ ```
84
+
85
+ ## Tool categories
86
+
87
+ | Prefix | Tools | Backend |
88
+ |---|---|---|
89
+ | `/fs` | search, read_span, extract, read_file, write_file, read_json, find_keys, find_paths, glob, imports, find_content | abstract_search, abstract_utilities, abstract_paths |
90
+ | `/text` | count_tokens, chunk, detect_language | abstract_utilities |
91
+ | `/web` | text, links, attributes | abstract_webtools |
92
+ | `/media` | ocr_image, pdf_text, summarize, keywords, transcribe | abstract_ocr, abstract_pandas, media_intelligence |
93
+ | `/ai` | query | abstract_ai |
94
+ | `/db` | tables, schema, columns, fetch, **query** | abstract_database |
95
+ | `/sys` | run_cmd | stdlib (gated) |
96
+ | `/ui` | capture, monitors, ocr, windows, **click_verify** | abstract_clicks, abstract_windows |
97
+
98
+ ## Safety gates
99
+
100
+ Backends load lazily, so the server boots on a headless box and a missing backend
101
+ errors only when its tool is called. Beyond that:
102
+
103
+ - **`/db/query`** — read-only gate: rejects anything that isn't a single
104
+ `SELECT`/`WITH`, blocks stacked statements and data-modifying keywords. Use
105
+ `/db/fetch` (identifier-composed, params-not-SQL) as the default read path.
106
+ - **`/sys/run_cmd`** — disabled unless `TOOLSERVER_CMD_ALLOWLIST=ls,grep,…` is set;
107
+ only allowlisted binaries run.
108
+ - **`/fs/read_file` · `/fs/write_file`** — local-only; the underlying SSH/remote
109
+ kwargs are never exposed at the boundary.
110
+ - **`/ui/click_verify`** — the click→observe→**verify** loop: locate (text or image
111
+ template) → click → re-capture → report whether the screen (or a `region`)
112
+ changed. The half most tool APIs lack.
113
+
114
+ ## Configuration
115
+
116
+ | Env var | Purpose |
117
+ |---|---|
118
+ | `TOOLSERVER_HOST` / `TOOLSERVER_PORT` / `TOOLSERVER_DEBUG` | bind + debug |
119
+ | `TOOLSERVER_CMD_ALLOWLIST` | comma-separated binaries `/sys/run_cmd` may run |
120
+ | `SOLCATCHER_POSTGRESQL_*` | DB connection (via abstract_database) |
@@ -0,0 +1,77 @@
1
+ # abstract_toolserver
2
+
3
+ The `abstract_*` ecosystem exposed as an **API-callable AI toolset** — every tool
4
+ is a plain Python function turned into a self-describing HTTP endpoint by
5
+ [`abstract_flask`](https://github.com/AbstractEndeavors/abstract_flask). A portable
6
+ tool layer any model (Claude, hugpy, …) can drive over HTTP instead of being
7
+ bound to one runtime's tool harness.
8
+
9
+ ## Run it
10
+
11
+ ```bash
12
+ pip install abstract_toolserver # + the extras you want to expose
13
+ python -m abstract_toolserver # HOST/PORT/DEBUG from TOOLSERVER_* env
14
+ ```
15
+
16
+ ```python
17
+ from abstract_toolserver import get_toolserver_app
18
+ app = get_toolserver_app() # a normal Flask/WSGI app
19
+ ```
20
+
21
+ ## Self-describing surface
22
+
23
+ The app auto-mounts introspection endpoints (from `abstract_flask`):
24
+
25
+ | Endpoint | What it gives an LLM |
26
+ |---|---|
27
+ | `GET /prefixes` | the tool categories (`/fs`, `/db`, `/ui`, …) |
28
+ | `GET /endpoints` | every tool as `{endpoint, url, methods}` |
29
+ | `GET /<cat>/<tool>?help=true` | that tool's signature/help |
30
+
31
+ Call a tool with JSON; unknown keys are pruned to the function signature, and the
32
+ reply is `{"result": ...}` (or `{"error": ...}`). Discover-and-dispatch from a
33
+ client is already provided by `abstract_apis.make_endpoint_call`.
34
+
35
+ ```bash
36
+ curl -s localhost:5000/fs/count_tokens -d '{"text":"hello world"}'
37
+ # {"result": 2}
38
+ curl -s localhost:5000/db/schema # {"result": {table: [cols...]}}
39
+ curl -s 'localhost:5000/db/query?help=true'
40
+ ```
41
+
42
+ ## Tool categories
43
+
44
+ | Prefix | Tools | Backend |
45
+ |---|---|---|
46
+ | `/fs` | search, read_span, extract, read_file, write_file, read_json, find_keys, find_paths, glob, imports, find_content | abstract_search, abstract_utilities, abstract_paths |
47
+ | `/text` | count_tokens, chunk, detect_language | abstract_utilities |
48
+ | `/web` | text, links, attributes | abstract_webtools |
49
+ | `/media` | ocr_image, pdf_text, summarize, keywords, transcribe | abstract_ocr, abstract_pandas, media_intelligence |
50
+ | `/ai` | query | abstract_ai |
51
+ | `/db` | tables, schema, columns, fetch, **query** | abstract_database |
52
+ | `/sys` | run_cmd | stdlib (gated) |
53
+ | `/ui` | capture, monitors, ocr, windows, **click_verify** | abstract_clicks, abstract_windows |
54
+
55
+ ## Safety gates
56
+
57
+ Backends load lazily, so the server boots on a headless box and a missing backend
58
+ errors only when its tool is called. Beyond that:
59
+
60
+ - **`/db/query`** — read-only gate: rejects anything that isn't a single
61
+ `SELECT`/`WITH`, blocks stacked statements and data-modifying keywords. Use
62
+ `/db/fetch` (identifier-composed, params-not-SQL) as the default read path.
63
+ - **`/sys/run_cmd`** — disabled unless `TOOLSERVER_CMD_ALLOWLIST=ls,grep,…` is set;
64
+ only allowlisted binaries run.
65
+ - **`/fs/read_file` · `/fs/write_file`** — local-only; the underlying SSH/remote
66
+ kwargs are never exposed at the boundary.
67
+ - **`/ui/click_verify`** — the click→observe→**verify** loop: locate (text or image
68
+ template) → click → re-capture → report whether the screen (or a `region`)
69
+ changed. The half most tool APIs lack.
70
+
71
+ ## Configuration
72
+
73
+ | Env var | Purpose |
74
+ |---|---|
75
+ | `TOOLSERVER_HOST` / `TOOLSERVER_PORT` / `TOOLSERVER_DEBUG` | bind + debug |
76
+ | `TOOLSERVER_CMD_ALLOWLIST` | comma-separated binaries `/sys/run_cmd` may run |
77
+ | `SOLCATCHER_POSTGRESQL_*` | DB connection (via abstract_database) |
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,44 @@
1
+ import setuptools
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setuptools.setup(
7
+ name='abstract_toolserver',
8
+ version='0.0.1',
9
+ author='putkoff',
10
+ author_email='partners@abstractendeavors.com',
11
+ description="The abstract_* ecosystem exposed as an API-callable AI toolset: functions become self-describing Flask endpoints with /endpoints discovery and ?help.",
12
+ long_description=long_description,
13
+ long_description_content_type='text/markdown',
14
+ url="https://github.com/AbstractEndeavors/abstract_toolserver",
15
+ classifiers=[
16
+ 'Development Status :: 3 - Alpha',
17
+ 'Intended Audience :: Developers',
18
+ 'License :: OSI Approved :: MIT License',
19
+ 'Programming Language :: Python :: 3',
20
+ 'Programming Language :: Python :: 3.11',
21
+ ],
22
+ install_requires=[
23
+ 'abstract_flask',
24
+ 'abstract_utilities',
25
+ ],
26
+ extras_require={
27
+ # Tool backends are imported lazily; install only what you expose.
28
+ 'files': ['abstract_search', 'abstract_paths'],
29
+ 'web': ['abstract_webtools'],
30
+ 'media': ['abstract_ocr', 'abstract_pandas', 'media_intelligence'],
31
+ 'ai': ['abstract_ai'],
32
+ 'db': ['abstract_database'],
33
+ 'ui': ['abstract_clicks', 'abstract_windows'],
34
+ },
35
+ package_dir={"": "src"},
36
+ packages=setuptools.find_packages(where="src"),
37
+ python_requires=">=3.8",
38
+ entry_points={
39
+ "console_scripts": [
40
+ "abstract-toolserver = abstract_toolserver.app:main",
41
+ ],
42
+ },
43
+ setup_requires=['wheel'],
44
+ )
@@ -0,0 +1,5 @@
1
+ """abstract_toolserver — the abstract_* ecosystem exposed as an API-callable AI toolset."""
2
+ from .app import get_toolserver_app, main
3
+ from .tools import TOOLSETS
4
+
5
+ __all__ = ["get_toolserver_app", "main", "TOOLSETS"]
@@ -0,0 +1,5 @@
1
+ """python -m abstract_toolserver → run the server."""
2
+ from .app import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,33 @@
1
+ """
2
+ App factory + runner for the AI toolserver.
3
+
4
+ from abstract_toolserver.app import get_toolserver_app
5
+ app = get_toolserver_app() # WSGI app, /endpoints + /prefixes + ?help
6
+ app.run()
7
+
8
+ or just: python -m abstract_toolserver
9
+ """
10
+ from abstract_flask import get_Flask_app, main_flask_start
11
+
12
+ from . import routes
13
+
14
+
15
+ def get_toolserver_app(allowed_origins=None, debug=None, **flask_kwargs):
16
+ """Return the fully-wired Flask app (all tool blueprints + introspection)."""
17
+ return get_Flask_app(
18
+ name="abstract_toolserver",
19
+ routes=routes,
20
+ allowed_origins=allowed_origins,
21
+ debug=debug,
22
+ **flask_kwargs,
23
+ )
24
+
25
+
26
+ def main():
27
+ """Run the server; HOST/PORT/DEBUG come from TOOLSERVER_* env vars."""
28
+ app = get_toolserver_app()
29
+ main_flask_start(app, key_head="TOOLSERVER")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,109 @@
1
+ """
2
+ Safety gates and lazy-import plumbing for the AI toolserver.
3
+
4
+ Everything a tool needs to be *safe to expose over HTTP* lives here:
5
+ * lazy import so the Flask app boots even when a heavy backend is absent,
6
+ * a read-only SQL guard for the raw-query tool,
7
+ * a command allowlist for shell exec (off by default),
8
+ * a scrubber that strips the SSH/remote-exec kwargs off the file tools.
9
+ """
10
+ import importlib
11
+ import os
12
+ import re
13
+ import shlex
14
+
15
+
16
+ class ToolError(Exception):
17
+ """Raised by a tool wrapper; the Flask layer turns it into {"error": ...}."""
18
+
19
+
20
+ # ──────────────────────────────────────────────────────────
21
+ # Lazy import ("pkg.module:attr" or "pkg.module")
22
+ # ──────────────────────────────────────────────────────────
23
+ def lazy(path):
24
+ """Import on first call so a missing backend can't stop the server booting."""
25
+ module, _, attr = path.partition(":")
26
+ try:
27
+ mod = importlib.import_module(module)
28
+ except Exception as e: # ImportError, or a backend that explodes at import
29
+ raise ToolError(f"backend '{module}' unavailable: {e}")
30
+ if not attr:
31
+ return mod
32
+ try:
33
+ return getattr(mod, attr)
34
+ except AttributeError:
35
+ raise ToolError(f"'{module}' has no attribute '{attr}'")
36
+
37
+
38
+ def first_lazy(*paths):
39
+ """Return the first import path that resolves; used when a symbol moved."""
40
+ last = None
41
+ for p in paths:
42
+ try:
43
+ return lazy(p)
44
+ except ToolError as e:
45
+ last = e
46
+ raise last or ToolError("no import path resolved")
47
+
48
+
49
+ # ──────────────────────────────────────────────────────────
50
+ # SQL read-only guard
51
+ # ──────────────────────────────────────────────────────────
52
+ _WRITE_TOKENS = re.compile(
53
+ r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|"
54
+ r"COPY|MERGE|CALL|DO|VACUUM|REINDEX|COMMENT|SET)\b",
55
+ re.IGNORECASE,
56
+ )
57
+
58
+
59
+ def assert_readonly_sql(query):
60
+ """Reject anything that isn't a single SELECT/WITH read. Raises ToolError."""
61
+ if not isinstance(query, str) or not query.strip():
62
+ raise ToolError("query must be a non-empty string")
63
+ stripped = query.strip().rstrip(";").strip()
64
+ # block stacked statements (a trailing ';' was already removed above)
65
+ if ";" in stripped:
66
+ raise ToolError("multiple statements are not allowed")
67
+ if not re.match(r"^(SELECT|WITH)\b", stripped, re.IGNORECASE):
68
+ raise ToolError("only SELECT / WITH queries are allowed")
69
+ if _WRITE_TOKENS.search(stripped):
70
+ raise ToolError("query contains a data-modifying keyword")
71
+ return stripped
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────
75
+ # Shell command allowlist (run_cmd is OFF unless explicitly enabled)
76
+ # ──────────────────────────────────────────────────────────
77
+ def _allowlist():
78
+ raw = os.environ.get("TOOLSERVER_CMD_ALLOWLIST", "")
79
+ return {c.strip() for c in raw.split(",") if c.strip()}
80
+
81
+
82
+ def assert_cmd_allowed(cmd):
83
+ """Only permit a binary named in TOOLSERVER_CMD_ALLOWLIST. Raises ToolError."""
84
+ allow = _allowlist()
85
+ if not allow:
86
+ raise ToolError(
87
+ "run_cmd disabled — set TOOLSERVER_CMD_ALLOWLIST=ls,grep,... to enable"
88
+ )
89
+ try:
90
+ parts = shlex.split(cmd)
91
+ except ValueError as e:
92
+ raise ToolError(f"unparseable command: {e}")
93
+ if not parts:
94
+ raise ToolError("empty command")
95
+ binary = os.path.basename(parts[0])
96
+ if binary not in allow:
97
+ raise ToolError(f"'{binary}' not in allowlist {sorted(allow)}")
98
+ return parts
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────
102
+ # File-tool kwarg scrubber
103
+ # ──────────────────────────────────────────────────────────
104
+ _SSH_KEYS = {"user_at_host", "password", "key", "host", "ssh", "remote"}
105
+
106
+
107
+ def strip_remote_kwargs(kwargs):
108
+ """Drop the SSH/remote-exec kwargs so file tools stay local-only."""
109
+ return {k: v for k, v in kwargs.items() if k not in _SSH_KEYS}
@@ -0,0 +1,16 @@
1
+ """
2
+ Routes module consumed by abstract_flask.get_Flask_app(routes=...).
3
+
4
+ get_Flask_app scans this module for any top-level object named ``*_bp`` that is
5
+ a Blueprint and registers it, so all we do here is build one blueprint and hang
6
+ every tool category off it via register_categories.
7
+ """
8
+ from abstract_flask import get_bp, register_categories
9
+
10
+ from ..tools import TOOLSETS
11
+
12
+ # name → "tools_bp" so the factory's *_bp auto-discovery finds it
13
+ tools_bp, logger = get_bp("tools", __name__)
14
+
15
+ # {category: {route: func}} → /category/route (+ /category/route/ , GET+POST)
16
+ register_categories(tools_bp, TOOLSETS)
@@ -0,0 +1,367 @@
1
+ """
2
+ The compiled AI toolset.
3
+
4
+ Each function here is a thin, JSON-in / JSON-out wrapper over a symbol that
5
+ already exists in the abstract_* ecosystem. Backends are imported lazily (via
6
+ gates.lazy) so this module imports cleanly on a headless box; a tool whose
7
+ backend is missing raises ToolError only when actually called.
8
+
9
+ The TOOLSETS dict at the bottom is the whole API surface — it maps
10
+ {category: {route_name: callable}} and is fed straight to
11
+ abstract_flask.register_categories.
12
+ """
13
+ import hashlib
14
+ import os
15
+ import time
16
+
17
+ from .gates import (
18
+ ToolError,
19
+ assert_cmd_allowed,
20
+ assert_readonly_sql,
21
+ first_lazy,
22
+ lazy,
23
+ strip_remote_kwargs,
24
+ )
25
+
26
+
27
+ # ══════════════════════════════════════════════════════════
28
+ # FILES / CODE (abstract_search, abstract_utilities, abstract_paths)
29
+ # ══════════════════════════════════════════════════════════
30
+ def search_files(needle, paths, case_sensitive=False, regex=False):
31
+ """Grep a string/regex across files or dirs (code, office, pdf)."""
32
+ fn = lazy("abstract_search:search_files")
33
+ return fn(needle, paths, case_sensitive=case_sensitive, regex=regex)
34
+
35
+
36
+ def read_span(path, ranges=None):
37
+ """Read 1-based inclusive line ranges from one file (whole file if omitted)."""
38
+ return lazy("abstract_search:read_span")(path, ranges=ranges)
39
+
40
+
41
+ def extract_text(path):
42
+ """Any single file → plain text (pdf/docx/xlsx/pptx/code); errors-as-data."""
43
+ return lazy("abstract_search:extract_text")(path)
44
+
45
+
46
+ def read_file(file_path):
47
+ """Read a local text file (SSH/remote kwargs are intentionally not exposed)."""
48
+ return lazy("abstract_utilities:read_from_file")(file_path=file_path)
49
+
50
+
51
+ def write_file(file_path, contents):
52
+ """Write text to a local file (local-only; remote-exec kwargs stripped)."""
53
+ fn = lazy("abstract_utilities:write_to_file")
54
+ return fn(contents=contents, file_path=file_path, **strip_remote_kwargs({}))
55
+
56
+
57
+ def read_json(file_path):
58
+ """Robustly load a JSON file."""
59
+ return lazy("abstract_utilities:safe_read_from_json")(file_path)
60
+
61
+
62
+ def find_keys(data, target_keys):
63
+ """Recursively collect all values for the given keys in nested JSON."""
64
+ return lazy("abstract_utilities:find_keys")(data, target_keys)
65
+
66
+
67
+ def find_paths_to_key(data, key):
68
+ """Return the paths where a key occurs in nested JSON."""
69
+ return lazy("abstract_utilities:find_paths_to_key")(data, key)
70
+
71
+
72
+ def count_tokens(text, model_name="gpt-4"):
73
+ """Count LLM tokens in a string."""
74
+ return lazy("abstract_utilities:num_tokens_from_string")(text, model_name=model_name)
75
+
76
+
77
+ def chunk_text(data, max_tokens, model_name="gpt-4"):
78
+ """Split text/code into token-bounded chunks."""
79
+ return lazy("abstract_utilities:chunk_any_to_tokens")(data, max_tokens, model_name=model_name)
80
+
81
+
82
+ def detect_language(text):
83
+ """Heuristically identify the programming language of a snippet."""
84
+ return lazy("abstract_utilities:detect_language_from_text")(text)
85
+
86
+
87
+ def glob_files(path, pattern, ext=None):
88
+ """Glob files under a path by pattern."""
89
+ return lazy("abstract_paths:glob_search")(path, pattern, ext=ext)
90
+
91
+
92
+ def python_imports(file_path):
93
+ """List the imports of a Python file."""
94
+ return lazy("abstract_paths:extract_python_imports")(file_path)
95
+
96
+
97
+ def find_content(dirs, strings, get_lines=True):
98
+ """grep-like recursive search: files containing the given strings (+line nums)."""
99
+ fn = first_lazy(
100
+ "abstract_paths.content_utils.backs:findContent",
101
+ "abstract_paths:findContent",
102
+ )
103
+ dirs = dirs if isinstance(dirs, (list, tuple)) else [dirs]
104
+ return fn(*dirs, strings=strings, get_lines=get_lines)
105
+
106
+
107
+ # ══════════════════════════════════════════════════════════
108
+ # WEB (abstract_webtools)
109
+ # ══════════════════════════════════════════════════════════
110
+ def web_text(url):
111
+ """Fetch a URL and return its visible page text."""
112
+ return lazy("abstract_webtools:get_soup_text")(url)
113
+
114
+
115
+ def web_links(url):
116
+ """Crawl a page and return every link on it."""
117
+ return lazy("abstract_webtools:get_all_crawl_links")(url)
118
+
119
+
120
+ def web_attributes(url, tags_list=None):
121
+ """Extract attribute values for given tags on a page."""
122
+ return lazy("abstract_webtools:get_all_attribute_values")(url=url, tags_list=tags_list)
123
+
124
+
125
+ # ══════════════════════════════════════════════════════════
126
+ # MEDIA → TEXT (abstract_ocr, abstract_pandas, media_intelligence)
127
+ # ══════════════════════════════════════════════════════════
128
+ def ocr_image(image_path):
129
+ """OCR a single image to text."""
130
+ return first_lazy(
131
+ "abstract_ocr:extract_text_from_image",
132
+ "media_intelligence.ocr:image_to_text",
133
+ )(image_path)
134
+
135
+
136
+ def pdf_text(path, ocr_if_empty=True):
137
+ """Full text of a PDF (pdfplumber + OCR fallback)."""
138
+ return lazy("abstract_pandas:pdf_to_text")(path, ocr_if_empty=ocr_if_empty)
139
+
140
+
141
+ def summarize(text, max_output_words=None):
142
+ """Abstractive summary of text (delegates to the media_intelligence facade)."""
143
+ fn = first_lazy("media_intelligence.enrich:summarize", "abstract_hugpy:summarize")
144
+ return fn(text=text, max_output_words=max_output_words) if max_output_words else fn(text=text)
145
+
146
+
147
+ def keywords(text, top_n=None):
148
+ """Keyword/keyphrase extraction from text."""
149
+ fn = first_lazy("media_intelligence.enrich:keywords", "abstract_hugpy:extract_keywords")
150
+ return fn(text, top_n=top_n) if top_n else fn(text)
151
+
152
+
153
+ def transcribe(path):
154
+ """Transcribe a local audio/video file to text."""
155
+ return first_lazy(
156
+ "media_intelligence.transcribe:transcribe",
157
+ "abstract_hugpy:whisper_transcribe",
158
+ )(path)
159
+
160
+
161
+ # ══════════════════════════════════════════════════════════
162
+ # AI / LLM (abstract_ai)
163
+ # ══════════════════════════════════════════════════════════
164
+ def ai_query(prompt, model=None, completion_percentage=50):
165
+ """Run one prompt through the abstract_ai model pipeline."""
166
+ fn = lazy("abstract_ai:make_general_query")
167
+ return fn(prompt=prompt, model=model, completion_percentage=completion_percentage)
168
+
169
+
170
+ # ══════════════════════════════════════════════════════════
171
+ # DATABASE (abstract_database) — server-side, co-located
172
+ # ══════════════════════════════════════════════════════════
173
+ def db_tables(schema="public"):
174
+ """List table names in a schema."""
175
+ return lazy("abstract_database:get_all_table_names")(schema=schema)
176
+
177
+
178
+ def db_schema(schema="public"):
179
+ """Map every table to its columns — let the model discover the DB."""
180
+ return lazy("abstract_database:get_all_table_info")(schema=schema)
181
+
182
+
183
+ def db_columns(table_name, schema="public"):
184
+ """List the columns of one table."""
185
+ return lazy("abstract_database:get_column_names")(table_name, schema=schema)
186
+
187
+
188
+ def db_fetch(table_name, column_names="*", search_map=None, any_value=False, schema="public"):
189
+ """Safe, identifier-composed SELECT (the model supplies params, not SQL)."""
190
+ fn = lazy("abstract_database:fetch_any_combo_")
191
+ return fn(
192
+ table_name=table_name,
193
+ column_names=column_names,
194
+ search_map=search_map,
195
+ any_value=any_value,
196
+ schema=schema,
197
+ )
198
+
199
+
200
+ def db_query(query, values=None):
201
+ """Run a raw SELECT/WITH query (read-only gate: writes are rejected)."""
202
+ safe = assert_readonly_sql(query)
203
+ return lazy("abstract_database:execute_query")(safe, values=values, fetch=True)
204
+
205
+
206
+ # ══════════════════════════════════════════════════════════
207
+ # SYSTEM (gated)
208
+ # ══════════════════════════════════════════════════════════
209
+ def run_cmd(cmd):
210
+ """Run a shell command — only binaries in TOOLSERVER_CMD_ALLOWLIST (off by default)."""
211
+ import subprocess
212
+
213
+ parts = assert_cmd_allowed(cmd)
214
+ proc = subprocess.run(parts, capture_output=True, text=True, timeout=60)
215
+ return {"returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
216
+
217
+
218
+ # ══════════════════════════════════════════════════════════
219
+ # COMPUTER-USE (abstract_clicks / abstract_windows) + the verify glue
220
+ # ══════════════════════════════════════════════════════════
221
+ def screen_capture(monitor_index=1, output_path=None):
222
+ """Screenshot one monitor to a PNG; returns the file path."""
223
+ return lazy("abstract_clicks.utils.monitor_utils:capture_monitor")(monitor_index, output_path=output_path)
224
+
225
+
226
+ def screen_monitors():
227
+ """Enumerate monitors with geometry."""
228
+ return lazy("abstract_clicks.utils.monitor_utils:get_monitors")()
229
+
230
+
231
+ def screen_ocr(screenshot_file="new_screen.png", confidence_threshold=60):
232
+ """OCR the screen → list of {text,x,y,width,height,confidence}."""
233
+ return first_lazy(
234
+ "abstract_clicks:perform_ocr",
235
+ "abstract_clicks.managers.clipboardManager.utils:perform_ocr",
236
+ )(screenshot_file, confidence_threshold=confidence_threshold)
237
+
238
+
239
+ def list_windows(with_geometry=True):
240
+ """Enumerate all open windows with geometry + process info (X11)."""
241
+ fn = lazy("abstract_windows.window_utils.window_utils:get_all_parsed_windows")
242
+ return fn(with_signature=True, with_geometry=with_geometry)
243
+
244
+
245
+ def _locate(target=None, template_path=None, monitor_index=1):
246
+ """Return (x, y) screen coords for a text target or an image template, or None."""
247
+ shot = screen_capture(monitor_index=monitor_index)
248
+ if template_path:
249
+ matches = first_lazy(
250
+ "abstract_clicks:get_ocr_locate_image",
251
+ "abstract_clicks.managers.clipboardManager.clipboardManager:ocr_locate_image",
252
+ )(shot, template_path)
253
+ if matches:
254
+ m = matches[0]
255
+ c = m.get("center") or (m.get("x"), m.get("y"))
256
+ return (int(c[0]), int(c[1]))
257
+ return None
258
+ # text target via OCR box centres
259
+ boxes = screen_ocr(shot)
260
+ tl = (target or "").strip().lower()
261
+ for b in boxes or []:
262
+ if tl and tl in str(b.get("text", "")).strip().lower():
263
+ return (int(b["x"] + b.get("width", 0) / 2), int(b["y"] + b.get("height", 0) / 2))
264
+ return None
265
+
266
+
267
+ def _region_hash(png_path, region=None):
268
+ if region:
269
+ try:
270
+ Image = lazy("PIL.Image:") # module
271
+ img = Image.open(png_path)
272
+ x, y, w, h = region
273
+ img = img.crop((x, y, x + w, y + h))
274
+ return hashlib.sha256(img.tobytes()).hexdigest()
275
+ except ToolError:
276
+ pass # PIL missing → fall through to full-file hash
277
+ with open(png_path, "rb") as fh:
278
+ return hashlib.sha256(fh.read()).hexdigest()
279
+
280
+
281
+ def click_and_verify(target=None, template_path=None, x=None, y=None,
282
+ monitor_index=1, region=None, settle=0.6):
283
+ """
284
+ The missing computer-use loop: locate → click → re-capture → report whether
285
+ the screen changed. Supply either explicit (x,y), a text `target`, or an
286
+ image `template_path`. `region`=[x,y,w,h] narrows the change check.
287
+ """
288
+ try:
289
+ gui = lazy("abstract_clicks.managers.importClasses.importClasses:get_auto_gui")()
290
+ except ToolError:
291
+ gui = lazy("pyautogui:") # empty attr → the module itself
292
+
293
+ if x is None or y is None:
294
+ coords = _locate(target=target, template_path=template_path, monitor_index=monitor_index)
295
+ if not coords:
296
+ return {"clicked": None, "changed": False,
297
+ "error": "target not found on screen"}
298
+ x, y = coords
299
+
300
+ before = screen_capture(monitor_index=monitor_index)
301
+ b_hash = _region_hash(before, region)
302
+
303
+ gui.click(x, y)
304
+ time.sleep(settle)
305
+
306
+ after = screen_capture(monitor_index=monitor_index)
307
+ a_hash = _region_hash(after, region)
308
+
309
+ return {"clicked": [int(x), int(y)], "changed": b_hash != a_hash,
310
+ "before": before, "after": after}
311
+
312
+
313
+ # ══════════════════════════════════════════════════════════
314
+ # THE API SURFACE → {category: {route: func}}
315
+ # ══════════════════════════════════════════════════════════
316
+ TOOLSETS = {
317
+ "fs": {
318
+ "search": search_files,
319
+ "read_span": read_span,
320
+ "extract": extract_text,
321
+ "read_file": read_file,
322
+ "write_file": write_file,
323
+ "read_json": read_json,
324
+ "find_keys": find_keys,
325
+ "find_paths": find_paths_to_key,
326
+ "glob": glob_files,
327
+ "imports": python_imports,
328
+ "find_content": find_content,
329
+ },
330
+ "text": {
331
+ "count_tokens": count_tokens,
332
+ "chunk": chunk_text,
333
+ "detect_language": detect_language,
334
+ },
335
+ "web": {
336
+ "text": web_text,
337
+ "links": web_links,
338
+ "attributes": web_attributes,
339
+ },
340
+ "media": {
341
+ "ocr_image": ocr_image,
342
+ "pdf_text": pdf_text,
343
+ "summarize": summarize,
344
+ "keywords": keywords,
345
+ "transcribe": transcribe,
346
+ },
347
+ "ai": {
348
+ "query": ai_query,
349
+ },
350
+ "db": {
351
+ "tables": db_tables,
352
+ "schema": db_schema,
353
+ "columns": db_columns,
354
+ "fetch": db_fetch,
355
+ "query": db_query, # SELECT-only gated
356
+ },
357
+ "sys": {
358
+ "run_cmd": run_cmd, # allowlist gated, off by default
359
+ },
360
+ "ui": {
361
+ "capture": screen_capture,
362
+ "monitors": screen_monitors,
363
+ "ocr": screen_ocr,
364
+ "windows": list_windows,
365
+ "click_verify": click_and_verify,
366
+ },
367
+ }
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstract_toolserver
3
+ Version: 0.0.1
4
+ Summary: The abstract_* ecosystem exposed as an API-callable AI toolset: functions become self-describing Flask endpoints with /endpoints discovery and ?help.
5
+ Home-page: https://github.com/AbstractEndeavors/abstract_toolserver
6
+ Author: putkoff
7
+ Author-email: partners@abstractendeavors.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: abstract_flask
16
+ Requires-Dist: abstract_utilities
17
+ Provides-Extra: files
18
+ Requires-Dist: abstract_search; extra == "files"
19
+ Requires-Dist: abstract_paths; extra == "files"
20
+ Provides-Extra: web
21
+ Requires-Dist: abstract_webtools; extra == "web"
22
+ Provides-Extra: media
23
+ Requires-Dist: abstract_ocr; extra == "media"
24
+ Requires-Dist: abstract_pandas; extra == "media"
25
+ Requires-Dist: media_intelligence; extra == "media"
26
+ Provides-Extra: ai
27
+ Requires-Dist: abstract_ai; extra == "ai"
28
+ Provides-Extra: db
29
+ Requires-Dist: abstract_database; extra == "db"
30
+ Provides-Extra: ui
31
+ Requires-Dist: abstract_clicks; extra == "ui"
32
+ Requires-Dist: abstract_windows; extra == "ui"
33
+ Dynamic: author
34
+ Dynamic: author-email
35
+ Dynamic: classifier
36
+ Dynamic: description
37
+ Dynamic: description-content-type
38
+ Dynamic: home-page
39
+ Dynamic: provides-extra
40
+ Dynamic: requires-dist
41
+ Dynamic: requires-python
42
+ Dynamic: summary
43
+
44
+ # abstract_toolserver
45
+
46
+ The `abstract_*` ecosystem exposed as an **API-callable AI toolset** — every tool
47
+ is a plain Python function turned into a self-describing HTTP endpoint by
48
+ [`abstract_flask`](https://github.com/AbstractEndeavors/abstract_flask). A portable
49
+ tool layer any model (Claude, hugpy, …) can drive over HTTP instead of being
50
+ bound to one runtime's tool harness.
51
+
52
+ ## Run it
53
+
54
+ ```bash
55
+ pip install abstract_toolserver # + the extras you want to expose
56
+ python -m abstract_toolserver # HOST/PORT/DEBUG from TOOLSERVER_* env
57
+ ```
58
+
59
+ ```python
60
+ from abstract_toolserver import get_toolserver_app
61
+ app = get_toolserver_app() # a normal Flask/WSGI app
62
+ ```
63
+
64
+ ## Self-describing surface
65
+
66
+ The app auto-mounts introspection endpoints (from `abstract_flask`):
67
+
68
+ | Endpoint | What it gives an LLM |
69
+ |---|---|
70
+ | `GET /prefixes` | the tool categories (`/fs`, `/db`, `/ui`, …) |
71
+ | `GET /endpoints` | every tool as `{endpoint, url, methods}` |
72
+ | `GET /<cat>/<tool>?help=true` | that tool's signature/help |
73
+
74
+ Call a tool with JSON; unknown keys are pruned to the function signature, and the
75
+ reply is `{"result": ...}` (or `{"error": ...}`). Discover-and-dispatch from a
76
+ client is already provided by `abstract_apis.make_endpoint_call`.
77
+
78
+ ```bash
79
+ curl -s localhost:5000/fs/count_tokens -d '{"text":"hello world"}'
80
+ # {"result": 2}
81
+ curl -s localhost:5000/db/schema # {"result": {table: [cols...]}}
82
+ curl -s 'localhost:5000/db/query?help=true'
83
+ ```
84
+
85
+ ## Tool categories
86
+
87
+ | Prefix | Tools | Backend |
88
+ |---|---|---|
89
+ | `/fs` | search, read_span, extract, read_file, write_file, read_json, find_keys, find_paths, glob, imports, find_content | abstract_search, abstract_utilities, abstract_paths |
90
+ | `/text` | count_tokens, chunk, detect_language | abstract_utilities |
91
+ | `/web` | text, links, attributes | abstract_webtools |
92
+ | `/media` | ocr_image, pdf_text, summarize, keywords, transcribe | abstract_ocr, abstract_pandas, media_intelligence |
93
+ | `/ai` | query | abstract_ai |
94
+ | `/db` | tables, schema, columns, fetch, **query** | abstract_database |
95
+ | `/sys` | run_cmd | stdlib (gated) |
96
+ | `/ui` | capture, monitors, ocr, windows, **click_verify** | abstract_clicks, abstract_windows |
97
+
98
+ ## Safety gates
99
+
100
+ Backends load lazily, so the server boots on a headless box and a missing backend
101
+ errors only when its tool is called. Beyond that:
102
+
103
+ - **`/db/query`** — read-only gate: rejects anything that isn't a single
104
+ `SELECT`/`WITH`, blocks stacked statements and data-modifying keywords. Use
105
+ `/db/fetch` (identifier-composed, params-not-SQL) as the default read path.
106
+ - **`/sys/run_cmd`** — disabled unless `TOOLSERVER_CMD_ALLOWLIST=ls,grep,…` is set;
107
+ only allowlisted binaries run.
108
+ - **`/fs/read_file` · `/fs/write_file`** — local-only; the underlying SSH/remote
109
+ kwargs are never exposed at the boundary.
110
+ - **`/ui/click_verify`** — the click→observe→**verify** loop: locate (text or image
111
+ template) → click → re-capture → report whether the screen (or a `region`)
112
+ changed. The half most tool APIs lack.
113
+
114
+ ## Configuration
115
+
116
+ | Env var | Purpose |
117
+ |---|---|
118
+ | `TOOLSERVER_HOST` / `TOOLSERVER_PORT` / `TOOLSERVER_DEBUG` | bind + debug |
119
+ | `TOOLSERVER_CMD_ALLOWLIST` | comma-separated binaries `/sys/run_cmd` may run |
120
+ | `SOLCATCHER_POSTGRESQL_*` | DB connection (via abstract_database) |
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ src/abstract_toolserver/__init__.py
5
+ src/abstract_toolserver/__main__.py
6
+ src/abstract_toolserver/app.py
7
+ src/abstract_toolserver/gates.py
8
+ src/abstract_toolserver/tools.py
9
+ src/abstract_toolserver.egg-info/PKG-INFO
10
+ src/abstract_toolserver.egg-info/SOURCES.txt
11
+ src/abstract_toolserver.egg-info/dependency_links.txt
12
+ src/abstract_toolserver.egg-info/entry_points.txt
13
+ src/abstract_toolserver.egg-info/requires.txt
14
+ src/abstract_toolserver.egg-info/top_level.txt
15
+ src/abstract_toolserver/routes/__init__.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ abstract-toolserver = abstract_toolserver.app:main
@@ -0,0 +1,24 @@
1
+ abstract_flask
2
+ abstract_utilities
3
+
4
+ [ai]
5
+ abstract_ai
6
+
7
+ [db]
8
+ abstract_database
9
+
10
+ [files]
11
+ abstract_search
12
+ abstract_paths
13
+
14
+ [media]
15
+ abstract_ocr
16
+ abstract_pandas
17
+ media_intelligence
18
+
19
+ [ui]
20
+ abstract_clicks
21
+ abstract_windows
22
+
23
+ [web]
24
+ abstract_webtools
@@ -0,0 +1 @@
1
+ abstract_toolserver